self: { config, lib, pkgs, ... }: let cfg = config.services.twin; inherit (lib) mkOption mkEnableOption mkIf types concatStringsSep optionalAttrs; list = concatStringsSep ","; in { options.services.twin = { enable = mkEnableOption "twin, which answers tangled mentions with an agent in a tarp daemon"; package = mkOption { type = types.package; default = self.packages.${pkgs.stdenv.hostPlatform.system}.twin; description = "The twin package."; }; user = mkOption { type = types.str; default = "twin"; description = '' The user twin runs as. The agent works in directories twin makes, so this is usually the user that runs the tarp daemon. ''; }; group = mkOption { type = types.str; default = "twin"; description = "The group twin runs as."; }; dids = mkOption { type = types.listOf types.str; example = ["did:plc:qfpnj4og54vl56wngdriaxug"]; description = "Accounts to watch. twin ignores records from all others."; }; mentionDids = mkOption { type = types.listOf types.str; default = []; example = ["did:plc:omwbu2wmkfxrsevklxbxujfx"]; description = "DIDs of the bot."; }; mentionHandles = mkOption { type = types.listOf types.str; default = []; example = ["bot.oppi.li"]; description = "Handles of the bot. twin resolves them to DIDs at start."; }; collections = mkOption { type = types.listOf types.str; default = ["sh.tangled.*"]; description = "Collections to read: NSIDs, or prefixes ending in `.*`."; }; operations = mkOption { type = types.listOf (types.enum ["create" "update"]); default = ["create"]; description = "Commit operations that can start the agent."; }; jetstreamHosts = mkOption { type = types.listOf types.str; default = ["jetstream.us-east.bsky.network" "jetstream.us-west.bsky.network"]; description = "Jetstream hosts: the first is tailed, the rest are failovers."; }; tarpAddr = mkOption { type = types.str; default = "127.0.0.1:39423"; description = "Address of the tarp daemon."; }; agentCommand = mkOption { type = types.str; default = ''exec opencode --auto --prompt "$TWIN_PROMPT"''; description = "Command run with `sh -c` in each new tarp terminal."; }; join = mkOption { type = types.bool; default = true; description = "Send a message to the terminal of its thread, when one runs."; }; promptTemplate = mkOption { type = types.nullOr types.path; default = null; description = "A Go text/template file to replace the built in prompt."; }; stateDir = mkOption { type = types.str; default = "/var/lib/twin"; description = "Holds the jetstream cursor and the prompts."; }; workDir = mkOption { type = types.str; default = "${cfg.stateDir}/work"; defaultText = lib.literalExpression ''"''${config.services.twin.stateDir}/work"''; description = "Holds one directory for each thread, where the agent starts."; }; logLevel = mkOption { type = types.enum ["debug" "info" "warn" "error"]; default = "info"; description = "Log level."; }; dryRun = mkOption { type = types.bool; default = false; description = "Log the prompt and start nothing."; }; environment = mkOption { type = types.attrsOf types.str; default = {}; description = "More environment variables for twin."; }; environmentFile = mkOption { type = types.nullOr types.path; default = null; description = "A file of more environment variables, read at start."; }; }; config = mkIf cfg.enable { assertions = [ { assertion = cfg.mentionDids != [] || cfg.mentionHandles != []; message = "services.twin: set mentionDids or mentionHandles"; } ]; users.users = mkIf (cfg.user == "twin") { twin = { isSystemUser = true; group = cfg.group; home = cfg.stateDir; }; }; users.groups = mkIf (cfg.group == "twin") {twin = {};}; systemd.tmpfiles.settings.twin = { ${cfg.stateDir}.d = { inherit (cfg) user group; mode = "0750"; }; ${cfg.workDir}.d = { inherit (cfg) user group; mode = "0750"; }; }; systemd.services.twin = { description = "twin: answers tangled mentions with an agent in tarp"; wantedBy = ["multi-user.target"]; wants = ["network-online.target"]; after = ["network-online.target"]; environment = { TWIN_DIDS = list cfg.dids; TWIN_MENTION_DIDS = list cfg.mentionDids; TWIN_MENTION_HANDLES = list cfg.mentionHandles; TWIN_COLLECTIONS = list cfg.collections; TWIN_OPERATIONS = list cfg.operations; TWIN_JETSTREAM_HOSTS = list cfg.jetstreamHosts; TWIN_TARP_ADDR = cfg.tarpAddr; TWIN_AGENT_COMMAND = cfg.agentCommand; TWIN_JOIN = lib.boolToString cfg.join; TWIN_STATE_DIR = cfg.stateDir; TWIN_WORK_DIR = cfg.workDir; TWIN_LOG_LEVEL = cfg.logLevel; TWIN_DRY_RUN = lib.boolToString cfg.dryRun; } // optionalAttrs (cfg.promptTemplate != null) { TWIN_PROMPT_TEMPLATE = "${cfg.promptTemplate}"; } // cfg.environment; serviceConfig = { ExecStart = lib.getExe cfg.package; User = cfg.user; Group = cfg.group; EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; Restart = "always"; RestartSec = 5; NoNewPrivileges = true; PrivateTmp = true; ProtectSystem = "strict"; ReadWritePaths = [cfg.stateDir cfg.workDir]; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; RestrictSUIDSGID = true; LockPersonality = true; }; }; }; }