@koalaauto/agentchannel 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,8 +40,8 @@ installed or changed.
40
40
  pasting the line was the consent, so it does the same and says what it ran.
41
41
  - `--service` also installs the background helper (`ach run --install-service
42
42
  --interactive-first` for the current directory; `--notify` only on a machine
43
- with a desktop). Through npx it first installs this version globally, since a
44
- service cannot run from the npx cache.
43
+ with a desktop), and starts it. Through npx the service runs the same pinned
44
+ npx spec as the MCP entry, so nothing is installed globally.
45
45
  - `--dry-run` prints the plan; the invite is not used and nothing is written.
46
46
  - `--yes` skips the question.
47
47
 
@@ -92,7 +92,8 @@ reminds you with one line, never the message itself:
92
92
  `codex queue --thread <session> --message "<the line>"` when Codex tells it
93
93
  the session id (`CODEX_THREAD_ID`; or set `ACH_CODEX_THREAD_ID`). Otherwise
94
94
  it does nothing and the desktop notification covers it.
95
- - **Desktop** (`ach run --notify`, on by default with `--install-service`):
95
+ - **Desktop** (`ach run --notify`, on by default with `--install-service` on a
96
+ machine with a desktop):
96
97
  an OS notification. Decision requests show their options as buttons where
97
98
  the OS can report the click: macOS with
98
99
  [`alerter`](https://github.com/vjeantet/alerter) installed (else
@@ -10,5 +10,5 @@ export interface Parsed {
10
10
  export declare class UsageError extends Error {
11
11
  }
12
12
  export declare function parseCli(argv: string[]): Parsed;
13
- export declare const HELP = "ach - AgentChannel CLI\n\nSetup: npx -y @koalaauto/agentchannel@3 join <ach2.invite> [--service] [--yes] [--dry-run] (the line from the panel or\n `ach admin invite <agent>`: stores the agent's key and adds the `ach` MCP server to the agent's own tool\n (Claude Code or Codex). In a terminal it shows the hub and every command first and asks once; --yes skips\n that; run by an agent (no terminal) it just does it. --service also installs the background helper\n (notifications only where there is a desktop). --dry-run prints the plan and uses nothing)\n ach login --hub <url> --key <ach_sk_...> | ach me | ach agents\n ach doctor (hub, front door, key; then this machine: which `ach` runs, claude/codex login and MCP entry)\n ach notify --test (shows a test desktop notification: checks the background helper can reach you)\nTasks: ach send --to <agent> --title <t> --priority P0..P3 --purpose <p> --key-question <q> --money <m> --handoff-file <f>\n [--depends <id>] [--base <branch|commit>] [--repo <name>] [--read-repo <name>[@base] ...] [--attach <file> ...]\n ach handoff <taskId> --note <change note> [--file <f>] [--priority P0..P3] [--purpose p] [--money m] [--key-question q] [--attach <file> ...]\n [--channel <name>] (default: the channel named like --repo, else #general)\n ach task <id|T-128> (lists attachments with download URLs) | ach inbox\n Every task argument also takes the T-<seq> number shown in the panel (T-128).\n ach download <taskId> [--out <dir>] (fetches every attachment; default ./ach-files-<taskId>)\nRecurring: ach schedule add --to <agent> --title <t> --money <m> --handoff-file <f> --every day|weekday|week|month --at HH:MM\n [--weekday 1..7] [--day 1..28] [--tz +08:00] [--purpose p] [--priority P0..P3] [--channel <name>]\n (a proposal: it runs only after your owner replies \"approve\"; {date} in the title/handoff becomes the run's date;\n a run is skipped while the previous one is still open)\n ach schedule add ... --on task_completed|task_failed|figure|post --match '<json>' (an event rule instead of a time:\n task: {\"to\":\"puller\",\"title\":\"pull\"} (its own tasks only); figure: {\"topic\":\"ads\",\"metric\":\"Meta daily spend\",\"op\":\">\",\"value\":5000};\n post: {\"channel\":\"ci\",\"contains\":\"failed\"}. {ref} {title} {link} {metric} {value} {unit} {text} fill the title/handoff)\n ach schedules | ach schedule pause <id> | ach schedule rm <id> (resume: your owner, in the panel or with a person's session)\nMessages: ach ask [--task <id>] [--to <agent>] \"<question>\"\n ach answer [--task <id>] --reply-to <msgId> --evidence \"<files/commands>\" \"<answer>\"\n ach decide [--task <id>] <requestId> \"<reply>\" (interactive sessions only, with your agent key: your owner's reply, marked \"via <agent>\")\n ach decide [--task <id>] <requestId> --grant <id> --option <n> (a runner, within an authorization grant)\nAuthorization: ach grant (--agent <name> | --person <who>) (--task <T-n> | --channel <name>) --cap <usd> [--until YYYY-MM-DD]\n [--note \"<guidance>\"] [--requester <agent|person>] (your agent key: you, present, grant it; never a run token)\n ach grants | ach revoke <grantId>\n ach ask-human [--task <id>] [--to both|<person name|email>] \"<one question>\" --option \"A|detail|$450\" --option \"B|detail|$0\" [--recommend 0]\n (2\u20133 options, recommended first, money/impact on each; the person replies by picking one or typing;\n `decide-request` is the older name)\n ach progress [--task <id>] \"<text>\" | ach note [--task <id>] \"<text>\"\n Each takes --attach <file> (repeatable; png, jpg, webp, gif, mp4, webm, mov; text/code up to 2 MB:\n txt md json ts js py go sh diff patch log csv yaml sql; .html reports; inside a spawned\n agent the file must be inside the working directory). Big videos/images go straight to the hub's\n bucket when it has S3/R2 storage. --attach-url <https url> (repeatable) references an existing\n object (R2/S3/OSS/TOS presigned or public URL) without uploading it.\nChannels: ach channels [--all] | ach channel [<#name>] [--since <iso>] [--before <iso>] [--limit n]\n ach post <#name|name|title> \"<text>\" [--attach <file> ...] [--announce] (@agent mentions wake that agent for a read-only reply;\n an @name that matches nobody is warned about, with the closest name;\n --announce: \"shipped\" / \"data published\": an announcement every channel reader is notified of)\n ach reply [<postId>] \"<text>\" [--evidence \"<files/commands>\"] [--escalate] [--attach <file> ...]\n (postId defaults to $ACH_THREAD_ID in a mention run)\n ach thread <postId> | ach search \"<query>\" [--channel <name>] [--author <agent|person>] [--limit n]\n Inside a spawned agent: no new top-level posts; replies only in the task's own thread.\nMemory: ach recall \"<query>\" [--limit 5] (one line + link per hit: what the team already knows)\n ach remember [--channel <name>] [--topic <slug>] --source <link> \"<one fact or lesson>\" (source: hub permalink or GitHub URL)\n ach remember --to-team --source <link> \"<one fact>\" (members of a private channel: make one statement team-visible;\n its source stays private: others see \"source: private channel\")\n ach decide-draft [--by <person>] --source <link> [--task <id>] [--topic <slug>] [--scope task|channel] \"<the decision>\"\n (asks that person; their reply records it. Task-only unless it says \"from now on\"/\"always\"/\"\u4EE5\u540E\u90FD\")\n ach memory search \"<query>\" [--limit n] | ach memory read <slug>\n ach memory pull <dir> [--channel <name>] | ach memory push <dir> --note \"<what changed>\" (maintainers; Markdown + YAML\n front matter; per-page note: in front matter; every problem line is listed with its reason)\n ach memory seed --from-imports (maintainers: one upkeep task per channel over its imported GitHub issues/PRs)\n ach memory hook [--channel <name> | --task <id>] (a bounded Memory summary for a Claude Code SessionStart hook;\n default: --channel, $ACH_TASK_ID, a .ach-channel file in this or a parent directory, else your main channel)\n ach memory lang [en|zh] (admins: the language pages are written in)\nWorking: ach claim <task> (take a task sent to your agent and work it yourself, interactively; no runner starts)\nFinishing: ach complete [--task <id>] [--run-id <r>] [--result-file <f> | \"<text>\"] [--attach <file> ...]\n ach fail [--task <id>] [--run-id <r>] \"<reason>\" | ach cancel <id> [--reason <r>]\n With your agent key and no --run-id: an unclaimed task sent to your agent is claimed interactively\n first (complete), or declined (fail); a task you claimed with `ach claim` uses that claim.\nRunner: ach run --dir <path> [--repo name=path ...] [--runtime claude|codex] [--interval 3] [--max 2]\n [--perm read|workdir|full] [--model m] [--pass-env NAME ...]\n [--claim-delay <sec> | --interactive-first] (default 0; --interactive-first = 600 s for you to claim first)\n [--notify | --no-notify] (desktop notifications for your owner's important / needs-you items;\n default on for --install-service, off otherwise)\n ach run --install-service [same flags] (writes a launchd/systemd user unit; does not load it)\n ach run --gc [--dir <path>] [--days 7]\nMCP: ach mcp (stdio server; your interactive agent can also claim and work tasks)\nPanel: ach open [--print] (opens the panel in your browser, logged in as this agent's owner; 60 s single-use link)\nAdmin: ach admin login --link <login link> (or --hub <url> --email <e> with a password: bootstrap only)\n ach admin users | ach admin create-user --name <n> [--email <e>] [--admin] (prints a 24 h one-time join link)\n ach admin login-link <email|userId> (a new one-time login link for a new device)\n ach admin user <email|userId> [--deactivate [--reassign-to <user>] | --reactivate] [--admin true|false] [--confirm-name <name>] [--name n] [--email e]\n ach admin create-agent --name <slug> [--owner <email|userId>] --runtime claude|codex|other --description <d>\n [--perm read|workdir|full] [--budget <usd>] [--net-allow a.com,b.com] [--invite] [--cf-token]\n ach admin invite <agent> [--line] [--lang zh|en] [--cf-token] (rotates the key; prints a paragraph to paste into the engineer's\n Claude/Codex: join, background helper + notifications, a test notification, the inbox. --line: only\n the join line npx -y @koalaauto/agentchannel@3 join <blob>. Language: --lang, else the hub's (Memory) language.\n --cf-token: also put the hub's shared front-door (Cloudflare Access) token in the line; off by default)\n ach admin invite <person> [--feishu] (prints a one-time login link; --feishu sends it on Feishu)\n ach admin avatars [refresh [--force] | priority upload,feishu,github,gravatar | feishu | set [<person>] <image>]\nOnboarding: ach admin import github --org <org> [--since 60d] --dry-run [--out plan.json] (reads GitHub with your gh login; writes a plan)\n ach admin import github --org <org> --link <one-time admin link> [--yes] (the line from the panel's Settings -> Import:\n logs in with the 15-minute link for this import only, shows the dry run, then imports on a yes / --yes)\n ach admin import github --org <org> [--plan plan.json] (channels, people, issues/PRs; idempotent, re-run updates)\n Two ranges: posted to channels = open items + items closed/merged in the last N days\n [--post-closed-days N (alias --closed-days), default 7 | --all]; used to seed Memory (links only) = closed\n items of the last M months [--seed-months M, default 6 | --seed-all]. The hub keeps both for sync.\n [--exclude security,secret,... | --no-exclude] (default exclusion:\n security, \u5B89\u5168, \u6F0F\u6D1E, secret, \u5BC6\u94A5 in labels or title: never sent, not even an excerpt)\n Plan: \"visibility\": \"private\" on a channel + people \"channels\" = members-only (the hub otherwise\n widens access: everyone on the hub reads team channels, even repos they cannot see on GitHub).\n ach admin sync github [--org <org>] [same range and --exclude flags] (issues/PRs updated since the last\n import/sync; items that closed since then still update their posts; releases too; cron-friendly)\n [--every 5m [--runs N]] (keep syncing in a loop; or cron: */5 * * * * ach admin sync github)\n ach admin invite --feishu [--people a,b] [--map login=ou_xxx ...] [--yes] (Feishu DMs via lark-cli; preview without --yes)\n ach admin invite --people a,b --yes (prints one-time login links to send yourself)\n ach admin rotate-key <name|id> | ach admin set <agentId> [--active true|false] [--perm p] [--budget usd]\n [--description d] [--accept-from a,b] [--net-allow a.com,b.com]\n ach admin profile [--notify-webhook <url>] [--name <n>] [--password] (your own account)\n ach admin decide --task <id> --message <msgId> \"<decision>\" [--attach <file> ...]\n ach admin note --task <id> \"<text>\" [--attach <file> ...] (a human note to the agents on a task)\n ach admin channels [list | create [--name n] [--title t] | archive <#name> | unarchive <#name>]\n ach admin channels private|team <#name> (private: members and admins only)\n ach admin channels members <#name> [--add a,b] [--remove c] (people by email or GitHub login)\n ach admin embeds [--set host1,host2] (embed allowlist; public suffixes are refused)\n ach admin wiki-lang [en|zh] (= ach memory lang: the language Memory pages are written in; default ACH_DEFAULT_LANG)\n ach admin storage (attachment and database size; per channel with direct uploads and retention)\n ach admin retention <#name> [<days>] (delete attachments of finished tasks older than <days>; 0 = keep everything)\n ach admin link-hosts [--set h1,h2] [--only yes|no] (hosts whose linked images/videos the panel previews; --only rejects other links)\n ach keep <taskId> [--file <name>] [--off] (flag a task's attachments as deliverables: retention never deletes them)\n\n--task defaults to $ACH_TASK_ID. Links use ACH_PUBLIC_URL when set (else the hub URL). Env: ACH_HUB, ACH_KEY, ACH_RUN_TOKEN, ACH_CF_ID, ACH_CF_SECRET, AGENTCHANNEL_HOME,\nACH_KEYSTORE=file (store the agent key in a 0600 file instead of the OS keychain).\nHome: ~/.config/agentchannel ($XDG_CONFIG_HOME/agentchannel; AGENTCHANNEL_HOME overrides).\nInside a spawned agent (ACH_RUN_TOKEN set) login, join, open, admin, run, mcp, send, handoff, cancel, doctor, post and search are refused.\n";
13
+ export declare const HELP = "ach - AgentChannel CLI\n\nSetup: npx -y @koalaauto/agentchannel@3 join <ach2.invite> [--service] [--yes] [--dry-run] (the line from the panel or\n `ach admin invite <agent>`: stores the agent's key and adds the `ach` MCP server to the agent's own tool\n (Claude Code or Codex). In a terminal it shows the hub and every command first and asks once; --yes skips\n that; run by an agent (no terminal) it just does it. --service also installs the background helper\n (notifications only where there is a desktop). --dry-run prints the plan and uses nothing)\n ach login --hub <url> --key <ach_sk_...> | ach me | ach agents\n ach doctor (hub, front door, key; then this machine: which `ach` runs, claude/codex login and MCP entry)\n ach notify --test (shows a test desktop notification: checks the background helper can reach you)\nTasks: ach send --to <agent> --title <t> --priority P0..P3 --purpose <p> --key-question <q> --money <m> --handoff-file <f>\n [--depends <id>] [--base <branch|commit>] [--repo <name>] [--read-repo <name>[@base] ...] [--attach <file> ...]\n ach handoff <taskId> --note <change note> [--file <f>] [--priority P0..P3] [--purpose p] [--money m] [--key-question q] [--attach <file> ...]\n [--channel <name>] (default: the channel named like --repo, else #general)\n ach task <id|T-128> (lists attachments with download URLs) | ach inbox\n Every task argument also takes the T-<seq> number shown in the panel (T-128).\n ach download <taskId> [--out <dir>] (fetches every attachment; default ./ach-files-<taskId>)\nRecurring: ach schedule add --to <agent> --title <t> --money <m> --handoff-file <f> --every day|weekday|week|month --at HH:MM\n [--weekday 1..7] [--day 1..28] [--tz +08:00] [--purpose p] [--priority P0..P3] [--channel <name>]\n (a proposal: it runs only after your owner replies \"approve\"; {date} in the title/handoff becomes the run's date;\n a run is skipped while the previous one is still open)\n ach schedule add ... --on task_completed|task_failed|figure|post --match '<json>' (an event rule instead of a time:\n task: {\"to\":\"puller\",\"title\":\"pull\"} (its own tasks only); figure: {\"topic\":\"ads\",\"metric\":\"Meta daily spend\",\"op\":\">\",\"value\":5000};\n post: {\"channel\":\"ci\",\"contains\":\"failed\"}. {ref} {title} {link} {metric} {value} {unit} {text} fill the title/handoff)\n ach schedules | ach schedule pause <id> | ach schedule rm <id> (resume: your owner, in the panel or with a person's session)\nMessages: ach ask [--task <id>] [--to <agent>] \"<question>\"\n ach answer [--task <id>] --reply-to <msgId> --evidence \"<files/commands>\" \"<answer>\"\n ach decide [--task <id>] <requestId> \"<reply>\" (interactive sessions only, with your agent key: your owner's reply, marked \"via <agent>\")\n ach decide [--task <id>] <requestId> --grant <id> --option <n> (a runner, within an authorization grant)\nAuthorization: ach grant (--agent <name> | --person <who>) (--task <T-n> | --channel <name>) --cap <usd> [--until YYYY-MM-DD]\n [--note \"<guidance>\"] [--requester <agent|person>] (your agent key: you, present, grant it; never a run token)\n ach grants | ach revoke <grantId>\n ach ask-human [--task <id>] [--to both|<person name|email>] \"<one question>\" --option \"A|detail|$450\" --option \"B|detail|$0\" [--recommend 0]\n (2\u20133 options, recommended first, money/impact on each; the person replies by picking one or typing;\n `decide-request` is the older name)\n ach progress [--task <id>] \"<text>\" | ach note [--task <id>] \"<text>\"\n Each takes --attach <file> (repeatable; png, jpg, webp, gif, mp4, webm, mov; text/code up to 2 MB:\n txt md json ts js py go sh diff patch log csv yaml sql; .html reports; inside a spawned\n agent the file must be inside the working directory). Big videos/images go straight to the hub's\n bucket when it has S3/R2 storage. --attach-url <https url> (repeatable) references an existing\n object (R2/S3/OSS/TOS presigned or public URL) without uploading it.\nChannels: ach channels [--all] | ach channel [<#name>] [--since <iso>] [--before <iso>] [--limit n]\n ach post <#name|name|title> \"<text>\" [--attach <file> ...] [--announce] (@agent mentions wake that agent for a read-only reply;\n an @name that matches nobody is warned about, with the closest name;\n --announce: \"shipped\" / \"data published\": an announcement every channel reader is notified of)\n ach reply [<postId>] \"<text>\" [--evidence \"<files/commands>\"] [--escalate] [--attach <file> ...]\n (postId defaults to $ACH_THREAD_ID in a mention run)\n ach thread <postId> | ach search \"<query>\" [--channel <name>] [--author <agent|person>] [--limit n]\n Inside a spawned agent: no new top-level posts; replies only in the task's own thread.\nMemory: ach recall \"<query>\" [--limit 5] (one line + link per hit: what the team already knows)\n ach remember [--channel <name>] [--topic <slug>] --source <link> \"<one fact or lesson>\" (source: hub permalink or GitHub URL)\n ach remember --to-team --source <link> \"<one fact>\" (members of a private channel: make one statement team-visible;\n its source stays private: others see \"source: private channel\")\n ach decide-draft [--by <person>] --source <link> [--task <id>] [--topic <slug>] [--scope task|channel] \"<the decision>\"\n (asks that person; their reply records it. Task-only unless it says \"from now on\"/\"always\"/\"\u4EE5\u540E\u90FD\")\n ach memory search \"<query>\" [--limit n] | ach memory read <slug>\n ach memory pull <dir> [--channel <name>] | ach memory push <dir> --note \"<what changed>\" (maintainers; Markdown + YAML\n front matter; per-page note: in front matter; every problem line is listed with its reason)\n ach memory seed --from-imports (maintainers: one upkeep task per channel over its imported GitHub issues/PRs)\n ach memory hook [--channel <name> | --task <id>] (a bounded Memory summary for a Claude Code SessionStart hook;\n default: --channel, $ACH_TASK_ID, a .ach-channel file in this or a parent directory, else your main channel)\n ach memory lang [en|zh] (admins: the language pages are written in)\nWorking: ach claim <task> (take a task sent to your agent and work it yourself, interactively; no runner starts)\nFinishing: ach complete [--task <id>] [--run-id <r>] [--result-file <f> | \"<text>\"] [--attach <file> ...]\n ach fail [--task <id>] [--run-id <r>] \"<reason>\" | ach cancel <id> [--reason <r>]\n With your agent key and no --run-id: an unclaimed task sent to your agent is claimed interactively\n first (complete), or declined (fail); a task you claimed with `ach claim` uses that claim.\nRunner: ach run --dir <path> [--repo name=path ...] [--runtime claude|codex] [--interval 3] [--max 2]\n [--perm read|workdir|full] [--model m] [--pass-env NAME ...]\n [--claim-delay <sec> | --interactive-first] (default 0; --interactive-first = 600 s for you to claim first)\n [--notify | --no-notify] (desktop notifications for your owner's important / needs-you items;\n default on for --install-service where there is a desktop, off otherwise)\n ach run --install-service [same flags] (installs and starts a launchd/systemd user unit; runs at login)\n ach run --gc [--dir <path>] [--days 7]\nMCP: ach mcp (stdio server; your interactive agent can also claim and work tasks)\nPanel: ach open [--print] (opens the panel in your browser, logged in as this agent's owner; 60 s single-use link)\nAdmin: ach admin login --link <login link> (or --hub <url> --email <e> with a password: bootstrap only)\n ach admin users | ach admin create-user --name <n> [--email <e>] [--admin] (prints a 24 h one-time join link)\n ach admin login-link <email|userId> (a new one-time login link for a new device)\n ach admin user <email|userId> [--deactivate [--reassign-to <user>] | --reactivate] [--admin true|false] [--confirm-name <name>] [--name n] [--email e]\n ach admin create-agent --name <slug> [--owner <email|userId>] --runtime claude|codex|other --description <d>\n [--perm read|workdir|full] [--budget <usd>] [--net-allow a.com,b.com] [--invite] [--cf-token]\n ach admin invite <agent> [--line] [--lang zh|en] [--cf-token] (rotates the key; prints a paragraph to paste into the engineer's\n Claude/Codex: join, background helper + notifications, a test notification, the inbox. --line: only\n the join line npx -y @koalaauto/agentchannel@3 join <blob>. Language: --lang, else the hub's (Memory) language.\n --cf-token: also put the hub's shared front-door (Cloudflare Access) token in the line; off by default)\n ach admin invite <person> [--feishu] (prints a one-time login link; --feishu sends it on Feishu)\n ach admin avatars [refresh [--force] | priority upload,feishu,github,gravatar | feishu | set [<person>] <image>]\nOnboarding: ach admin import github --org <org> [--since 60d] --dry-run [--out plan.json] (reads GitHub with your gh login; writes a plan)\n ach admin import github --org <org> --link <one-time admin link> [--yes] (the line from the panel's Settings -> Import:\n logs in with the 15-minute link for this import only, shows the dry run, then imports on a yes / --yes)\n ach admin import github --org <org> [--plan plan.json] (channels, people, issues/PRs; idempotent, re-run updates)\n Two ranges: posted to channels = open items + items closed/merged in the last N days\n [--post-closed-days N (alias --closed-days), default 7 | --all]; used to seed Memory (links only) = closed\n items of the last M months [--seed-months M, default 6 | --seed-all]. The hub keeps both for sync.\n [--exclude security,secret,... | --no-exclude] (default exclusion:\n security, \u5B89\u5168, \u6F0F\u6D1E, secret, \u5BC6\u94A5 in labels or title: never sent, not even an excerpt)\n Plan: \"visibility\": \"private\" on a channel + people \"channels\" = members-only (the hub otherwise\n widens access: everyone on the hub reads team channels, even repos they cannot see on GitHub).\n ach admin sync github [--org <org>] [same range and --exclude flags] (issues/PRs updated since the last\n import/sync; items that closed since then still update their posts; releases too; cron-friendly)\n [--every 5m [--runs N]] (keep syncing in a loop; or cron: */5 * * * * ach admin sync github)\n ach admin invite --feishu [--people a,b] [--map login=ou_xxx ...] [--yes] (Feishu DMs via lark-cli; preview without --yes)\n ach admin invite --people a,b --yes (prints one-time login links to send yourself)\n ach admin rotate-key <name|id> | ach admin set <agentId> [--active true|false] [--perm p] [--budget usd]\n [--description d] [--accept-from a,b] [--net-allow a.com,b.com]\n ach admin profile [--notify-webhook <url>] [--name <n>] [--password] (your own account)\n ach admin decide --task <id> --message <msgId> \"<decision>\" [--attach <file> ...]\n ach admin note --task <id> \"<text>\" [--attach <file> ...] (a human note to the agents on a task)\n ach admin channels [list | create [--name n] [--title t] | archive <#name> | unarchive <#name>]\n ach admin channels private|team <#name> (private: members and admins only)\n ach admin channels members <#name> [--add a,b] [--remove c] (people by email or GitHub login)\n ach admin embeds [--set host1,host2] (embed allowlist; public suffixes are refused)\n ach admin wiki-lang [en|zh] (= ach memory lang: the language Memory pages are written in; default ACH_DEFAULT_LANG)\n ach admin storage (attachment and database size; per channel with direct uploads and retention)\n ach admin retention <#name> [<days>] (delete attachments of finished tasks older than <days>; 0 = keep everything)\n ach admin link-hosts [--set h1,h2] [--only yes|no] (hosts whose linked images/videos the panel previews; --only rejects other links)\n ach keep <taskId> [--file <name>] [--off] (flag a task's attachments as deliverables: retention never deletes them)\n\n--task defaults to $ACH_TASK_ID. Links use ACH_PUBLIC_URL when set (else the hub URL). Env: ACH_HUB, ACH_KEY, ACH_RUN_TOKEN, ACH_CF_ID, ACH_CF_SECRET, AGENTCHANNEL_HOME,\nACH_KEYSTORE=file (store the agent key in a 0600 file instead of the OS keychain).\nHome: ~/.config/agentchannel ($XDG_CONFIG_HOME/agentchannel; AGENTCHANNEL_HOME overrides).\nInside a spawned agent (ACH_RUN_TOKEN set) login, join, open, admin, run, mcp, send, handoff, cancel, doctor, post and search are refused.\n";
14
14
  export {};
package/dist/src/args.js CHANGED
@@ -202,8 +202,8 @@ Runner: ach run --dir <path> [--repo name=path ...] [--runtime claude|codex]
202
202
  [--perm read|workdir|full] [--model m] [--pass-env NAME ...]
203
203
  [--claim-delay <sec> | --interactive-first] (default 0; --interactive-first = 600 s for you to claim first)
204
204
  [--notify | --no-notify] (desktop notifications for your owner's important / needs-you items;
205
- default on for --install-service, off otherwise)
206
- ach run --install-service [same flags] (writes a launchd/systemd user unit; does not load it)
205
+ default on for --install-service where there is a desktop, off otherwise)
206
+ ach run --install-service [same flags] (installs and starts a launchd/systemd user unit; runs at login)
207
207
  ach run --gc [--dir <path>] [--days 7]
208
208
  MCP: ach mcp (stdio server; your interactive agent can also claim and work tasks)
209
209
  Panel: ach open [--print] (opens the panel in your browser, logged in as this agent's owner; 60 s single-use link)
@@ -46,31 +46,59 @@ export declare class AttentionNotifier {
46
46
  private run;
47
47
  stop(): void;
48
48
  }
49
+ /** What ChangeFeed needs of a WebSocket (the WHATWG interface: undici's, a browser's, a test's fake). */
50
+ export interface FeedSocket {
51
+ addEventListener(type: 'open' | 'message' | 'close' | 'error', fn: (ev: any) => void): void;
52
+ close(code?: number, reason?: string): void;
53
+ }
49
54
  export interface ChangeFeedOptions {
50
55
  request: Request;
51
56
  onChange: () => unknown;
57
+ /** Opens the hub's change stream (GET /api/ach/events; Client.eventsSocket). Absent or null: long poll only. */
58
+ socket?: () => FeedSocket | null;
59
+ /** How long a socket may take to open before the long poll takes over. */
60
+ wsOpenMs?: number;
61
+ /** While on the socket, check anyway this often (a change that published no event, a socket that died silently). */
62
+ wsCheckMs?: number;
52
63
  /** Long-poll wait (s), ≤ 25. */
53
64
  waitS?: number;
54
65
  /** Fallback polling interval. */
55
66
  pollMs?: number;
67
+ /** In polling mode, try the socket again after this many polls (a hub that was down at start). */
68
+ wsRetryPolls?: number;
56
69
  sleep?: (ms: number) => Promise<void>;
57
70
  log?: (s: string) => void;
58
71
  }
59
72
  /**
60
- * Follows GET /api/ach/changes (long poll). Falls back to polling (onChange
61
- * every pollMs) when the hub refuses the route (403/404: an older hub, or
62
- * person sessions only) — permanently — or after an error (one interval, then
63
- * the long poll again).
73
+ * Follows the hub's changes, with fallbacks, like the panel (hub/pb_public/realtime.js):
74
+ * 1. the WebSocket GET /api/ach/events (the Worker hub): "something changed", ids only; a socket that worked and
75
+ * then closed (a deploy, a network change) is opened again;
76
+ * 2. when it does not open, the long poll GET /api/ach/changes (the PocketBase hub);
77
+ * 3. when the hub refuses that (403/404/405), polling (onChange every pollMs); from there the socket is tried again
78
+ * now and then. An error on the long poll: one polling interval, then the long poll again.
79
+ * onChange calls never overlap: a change during one runs it once more afterwards.
64
80
  */
65
81
  export declare class ChangeFeed {
66
82
  private o;
67
- mode: 'longpoll' | 'poll';
83
+ mode: 'ws' | 'longpoll' | 'poll';
68
84
  private stopped;
69
85
  private cursor;
86
+ private ws;
87
+ /** Ends the socket being followed now (stop() does not wait for the closing handshake). */
88
+ private endSocket;
89
+ private wsDue;
90
+ /** The socket opened at least once (a later failure retries it before giving up). */
91
+ private wsWorked;
92
+ private firing;
93
+ private again;
70
94
  constructor(o: ChangeFeedOptions);
71
95
  stop(): void;
72
96
  private fire;
73
- /** Runs until stop(). `rounds` bounds the loop (tests). */
97
+ /** One socket, until it ends. Resolves with how long it was open (ms), or -1 when it never opened. */
98
+ private oneSocket;
99
+ /** Stays on the socket while it works; returns when it cannot be used (or on stop). */
100
+ private followSocket;
101
+ /** Runs until stop(). `rounds` bounds the long-poll / polling loop (tests). */
74
102
  run(rounds?: number): Promise<void>;
75
103
  }
76
104
  export type Exec = (cmd: string, args: string[]) => Promise<{
@@ -2,9 +2,9 @@
2
2
  * Reminders, never content. Two pieces shared by `ach mcp` (an open Claude
3
3
  * Code / Codex session) and `ach run --notify` (the background helper):
4
4
  *
5
- * - ChangeFeed: follows the hub's change stream. GET /api/ach/changes long-poll
6
- * (wait ≈ 25 s); when the hub refuses it (older hub: person sessions only) or
7
- * it errors, falls back to polling every 15 s.
5
+ * - ChangeFeed: follows the hub's change stream: the GET /api/ach/events
6
+ * WebSocket (the Worker hub), else the GET /api/ach/changes long poll
7
+ * (wait ≈ 25 s; the PocketBase hub), else polling every 15 s.
8
8
  * - AttentionNotifier: on a change, counts what needs the owner (questions,
9
9
  * decisions, mentions...) and, when something new arrived, delivers ONE line
10
10
  * ("AgentChannel: 2 items need you (1 question, 1 decision) — say 'check my
@@ -171,40 +171,183 @@ export class AttentionNotifier {
171
171
  }
172
172
  const realSleep = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
173
173
  /**
174
- * Follows GET /api/ach/changes (long poll). Falls back to polling (onChange
175
- * every pollMs) when the hub refuses the route (403/404: an older hub, or
176
- * person sessions only) — permanently — or after an error (one interval, then
177
- * the long poll again).
174
+ * Follows the hub's changes, with fallbacks, like the panel (hub/pb_public/realtime.js):
175
+ * 1. the WebSocket GET /api/ach/events (the Worker hub): "something changed", ids only; a socket that worked and
176
+ * then closed (a deploy, a network change) is opened again;
177
+ * 2. when it does not open, the long poll GET /api/ach/changes (the PocketBase hub);
178
+ * 3. when the hub refuses that (403/404/405), polling (onChange every pollMs); from there the socket is tried again
179
+ * now and then. An error on the long poll: one polling interval, then the long poll again.
180
+ * onChange calls never overlap: a change during one runs it once more afterwards.
178
181
  */
179
182
  export class ChangeFeed {
180
183
  o;
181
184
  mode = 'longpoll';
182
185
  stopped = false;
183
186
  cursor = '';
187
+ ws = null;
188
+ /** Ends the socket being followed now (stop() does not wait for the closing handshake). */
189
+ endSocket = null;
190
+ wsDue;
191
+ /** The socket opened at least once (a later failure retries it before giving up). */
192
+ wsWorked = false;
193
+ firing = null;
194
+ again = false;
184
195
  constructor(o) {
185
196
  this.o = o;
197
+ this.wsDue = !!o.socket;
186
198
  }
187
199
  stop() {
188
200
  this.stopped = true;
189
- }
190
- async fire() {
201
+ const s = this.ws;
202
+ this.ws = null;
191
203
  try {
192
- await this.o.onChange();
204
+ s?.close(1000, 'bye');
193
205
  }
194
206
  catch {
195
- /* the consumer logs its own errors */
207
+ /* already closed */
196
208
  }
209
+ this.endSocket?.();
197
210
  }
198
- /** Runs until stop(). `rounds` bounds the loop (tests). */
211
+ fire() {
212
+ if (this.firing) {
213
+ this.again = true;
214
+ return this.firing;
215
+ }
216
+ this.firing = (async () => {
217
+ do {
218
+ this.again = false;
219
+ try {
220
+ await this.o.onChange();
221
+ }
222
+ catch {
223
+ /* the consumer logs its own errors */
224
+ }
225
+ } while (this.again && !this.stopped);
226
+ })().finally(() => {
227
+ this.firing = null;
228
+ });
229
+ return this.firing;
230
+ }
231
+ /** One socket, until it ends. Resolves with how long it was open (ms), or -1 when it never opened. */
232
+ oneSocket() {
233
+ return new Promise((resolve) => {
234
+ let s = null;
235
+ try {
236
+ s = this.o.socket();
237
+ }
238
+ catch (e) {
239
+ this.o.log?.(`change stream: socket: ${String(e?.message ?? e).slice(0, 200)}`);
240
+ }
241
+ if (!s)
242
+ return resolve(-1);
243
+ const sock = s;
244
+ this.ws = sock;
245
+ let openedAt = 0;
246
+ let done = false;
247
+ let check = null;
248
+ const finish = () => {
249
+ if (done)
250
+ return;
251
+ done = true;
252
+ clearTimeout(openTimer);
253
+ if (check)
254
+ clearInterval(check);
255
+ if (this.ws === sock)
256
+ this.ws = null;
257
+ this.endSocket = null;
258
+ resolve(openedAt ? Date.now() - openedAt : -1);
259
+ };
260
+ this.endSocket = finish;
261
+ const openTimer = setTimeout(() => {
262
+ if (openedAt)
263
+ return;
264
+ try {
265
+ sock.close();
266
+ }
267
+ catch {
268
+ /* ignore */
269
+ }
270
+ finish();
271
+ }, this.o.wsOpenMs ?? 5000);
272
+ openTimer.unref?.();
273
+ sock.addEventListener('open', () => {
274
+ if (done)
275
+ return;
276
+ openedAt = Date.now();
277
+ clearTimeout(openTimer);
278
+ if (this.mode !== 'ws')
279
+ this.o.log?.('change stream: connected (websocket)');
280
+ this.mode = 'ws';
281
+ this.wsWorked = true;
282
+ void this.fire();
283
+ check = setInterval(() => void this.fire(), this.o.wsCheckMs ?? 5 * 60_000);
284
+ check.unref?.();
285
+ });
286
+ sock.addEventListener('message', () => {
287
+ if (!done && !this.stopped)
288
+ void this.fire();
289
+ });
290
+ sock.addEventListener('close', finish);
291
+ sock.addEventListener('error', () => {
292
+ try {
293
+ sock.close();
294
+ }
295
+ catch {
296
+ /* ignore */
297
+ }
298
+ finish();
299
+ });
300
+ });
301
+ }
302
+ /** Stays on the socket while it works; returns when it cannot be used (or on stop). */
303
+ async followSocket(sleep) {
304
+ let fails = 0;
305
+ while (!this.stopped) {
306
+ const lived = await this.oneSocket();
307
+ if (this.stopped)
308
+ return;
309
+ // a socket that stayed up a while worked: open it again at once; one that dropped quickly counts as a failure
310
+ if (lived >= 10_000)
311
+ fails = 0;
312
+ else
313
+ fails++;
314
+ if (!this.wsWorked || fails >= 4)
315
+ return;
316
+ if (fails > 0)
317
+ this.o.log?.('change stream: socket closed; reconnecting');
318
+ await sleep(Math.min(30_000, 1000 * 2 ** Math.max(0, fails - 1)));
319
+ }
320
+ }
321
+ /** Runs until stop(). `rounds` bounds the long-poll / polling loop (tests). */
199
322
  async run(rounds = Infinity) {
200
323
  const sleep = this.o.sleep ?? realSleep;
201
324
  const poll = this.o.pollMs ?? 15_000;
202
325
  const wait = Math.min(25, this.o.waitS ?? 25);
326
+ const retryPolls = this.o.wsRetryPolls ?? 20;
203
327
  let errors = 0;
204
- for (let i = 0; i < rounds && !this.stopped; i++) {
328
+ let polls = 0;
329
+ let longpollRefused = false;
330
+ for (let i = 0; i < rounds && !this.stopped;) {
331
+ if (this.wsDue) {
332
+ this.wsDue = false;
333
+ await this.followSocket(sleep);
334
+ if (this.stopped)
335
+ return;
336
+ const was = this.mode;
337
+ this.mode = longpollRefused ? 'poll' : 'longpoll';
338
+ this.cursor = '';
339
+ if (was === 'ws')
340
+ this.o.log?.(`change stream: websocket lost; ${this.mode === 'poll' ? `polling every ${Math.round(poll / 1000)} s` : 'long poll'}`);
341
+ continue;
342
+ }
343
+ i++;
205
344
  if (this.mode === 'poll') {
206
345
  await this.fire();
207
346
  await sleep(poll);
347
+ if (this.o.socket && ++polls >= retryPolls) {
348
+ polls = 0;
349
+ this.wsDue = true;
350
+ }
208
351
  continue;
209
352
  }
210
353
  const t0 = Date.now();
@@ -226,6 +369,7 @@ export class ChangeFeed {
226
369
  catch (e) {
227
370
  if (e instanceof HubError && (e.status === 403 || e.status === 404 || e.status === 405)) {
228
371
  this.mode = 'poll';
372
+ longpollRefused = true;
229
373
  this.o.log?.(`change stream not available (${e.status}); polling every ${Math.round(poll / 1000)} s`);
230
374
  continue;
231
375
  }
package/dist/src/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { execFileSync } from 'node:child_process';
2
3
  import { randomBytes } from 'node:crypto';
3
4
  import { chmodSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
4
- import { delimiter, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { HELP, parseCli, UsageError } from './args.js';
7
8
  import { channelRef, Client, clientFromConfig, HubError, isZh, parseLinkRepo } from './client.js';
@@ -1691,7 +1692,7 @@ async function runCommand(p, io) {
1691
1692
  let desk = null;
1692
1693
  if (v.notify && !v['no-notify']) {
1693
1694
  const { DeskNotifier } = await import('./desknotify.js');
1694
- const dn = new DeskNotifier({ request: (m, path) => c.request(m, path), home, hub: env.ACH_PUBLIC_URL || cfg.hub, env, log: io.err });
1695
+ const dn = new DeskNotifier({ request: (m, path) => c.request(m, path), socket: () => c.eventsSocket(), home, hub: env.ACH_PUBLIC_URL || cfg.hub, env, log: io.err });
1695
1696
  dn.start();
1696
1697
  desk = dn;
1697
1698
  io.err('ach run: desktop notifications on (--no-notify to turn off)');
@@ -1748,12 +1749,15 @@ export const AUTH_EXIT_CODE = 78;
1748
1749
  export const AUTH_EXIT_MESSAGE = 'ach run: key rotated or agent deactivated — run ach join';
1749
1750
  /**
1750
1751
  * P4: write a launchd plist (macOS) or systemd user unit (Linux) that runs this
1751
- * runner with the same flags. Writes the file and prints how to start it; it
1752
- * never loads the unit. ACH_SERVICE_DIR / ACH_SERVICE_PLATFORM override the
1753
- * location and platform (tests).
1752
+ * runner with the same flags, and load it: it runs now and at every login.
1753
+ * From the npx cache the unit runs `npx -y <spec>` (the cache may be cleared;
1754
+ * the MCP entry does the same). ACH_SERVICE_DIR / ACH_SERVICE_PLATFORM override
1755
+ * the location and platform (tests); with ACH_SERVICE_DIR nothing is loaded.
1754
1756
  */
1755
1757
  async function installService(p, io, o) {
1756
1758
  const { serviceFile, writeServiceFile } = await import('./service.js');
1759
+ const { hasDesktop } = await import('./onboard.js');
1760
+ const { isBundledBin, npmSpec, whichAll } = await import('./invite.js');
1757
1761
  const env = io.env;
1758
1762
  const platform = (env.ACH_SERVICE_PLATFORM || process.platform);
1759
1763
  if (platform !== 'darwin' && platform !== 'linux')
@@ -1767,13 +1771,15 @@ async function installService(p, io, o) {
1767
1771
  args.push(`--${f}`, str(v[f]));
1768
1772
  if (v['interactive-first'])
1769
1773
  args.push('--interactive-first');
1770
- // a service runs in the background: desktop notifications are on unless --no-notify
1771
- if (!v['no-notify'])
1774
+ // a service runs in the background: desktop notifications where there is a desktop, unless --no-notify
1775
+ if (v.notify || (!v['no-notify'] && hasDesktop(env, platform)))
1772
1776
  args.push('--notify');
1773
1777
  const passEnv = (Array.isArray(v['pass-env']) ? v['pass-env'] : []).flatMap(splitList);
1774
1778
  for (const n of passEnv)
1775
1779
  args.push('--pass-env', n);
1776
- const svcEnv = { PATH: env.PATH ?? '/usr/bin:/bin' };
1780
+ // not the npx cache's bin dirs: they vanish when npm clears the cache, and hold bundled tools (a codex) that are not the user's
1781
+ const path = (env.PATH ?? '').split(delimiter).filter((d) => d && !isBundledBin(join(d, 'x'))).join(delimiter);
1782
+ const svcEnv = { PATH: path || '/usr/bin:/bin' };
1777
1783
  if (env.AGENTCHANNEL_HOME)
1778
1784
  svcEnv.AGENTCHANNEL_HOME = env.AGENTCHANNEL_HOME;
1779
1785
  if (env.ACH_KEYSTORE)
@@ -1783,30 +1789,55 @@ async function installService(p, io, o) {
1783
1789
  if (platform === 'darwin')
1784
1790
  svcEnv.ACH_AUTH_EXIT_CODE = '0';
1785
1791
  mkdirSync(join(o.home, 'logs'), { recursive: true, mode: 0o700 });
1792
+ const self = fileURLToPath(import.meta.url);
1793
+ const npx = /[\\/]_npx[\\/]/.test(self) ? (whichAll('npx', env).find((x) => !isBundledBin(x)) ?? join(dirname(process.execPath), 'npx')) : undefined;
1786
1794
  const f = serviceFile({
1787
1795
  platform,
1788
1796
  agentName: o.agentName,
1789
- node: process.execPath,
1790
- cli: fileURLToPath(import.meta.url),
1797
+ command: npx ? [npx, '-y', npmSpec()] : [process.execPath, self],
1791
1798
  args,
1792
1799
  env: svcEnv,
1793
1800
  logFile: join(o.home, 'logs', 'runner.log'),
1794
1801
  }, env.ACH_SERVICE_DIR || undefined);
1795
1802
  writeServiceFile(f);
1803
+ const load = !env.ACH_SERVICE_DIR;
1804
+ let failed = '';
1805
+ if (load) {
1806
+ for (const c of f.start) {
1807
+ try {
1808
+ execFileSync('/bin/sh', ['-c', c], { env, stdio: ['ignore', 'ignore', 'pipe'] });
1809
+ }
1810
+ catch (e) {
1811
+ failed = `${c}: ${String(e?.stderr ?? e?.message ?? e).trim().split('\n')[0]}`;
1812
+ break;
1813
+ }
1814
+ }
1815
+ }
1816
+ // Linux: a user unit stops at logout (an SSH session ending) unless the user lingers
1817
+ const linger = platform === 'linux' && load && !failed ? lingers(env) : true;
1818
+ const logs = platform === 'linux' ? `journalctl --user -u ${basename(f.path)} -f` : join(o.home, 'logs', 'runner.log');
1796
1819
  if (v.json) {
1797
- io.out(JSON.stringify({ path: f.path, start: f.start, stop: f.stop }, null, 2));
1798
- return 0;
1820
+ io.out(JSON.stringify({ path: f.path, started: load && !failed, ...(failed ? { error: failed } : {}), start: f.start, stop: f.stop }, null, 2));
1821
+ return failed ? 1 : 0;
1799
1822
  }
1800
1823
  io.out([
1801
- `wrote ${f.path} (not loaded)`,
1802
- 'Start it with:',
1803
- ...f.start.map((x) => ` ${x}`),
1804
- 'Stop it with:',
1805
- ...f.stop.map((x) => ` ${x}`),
1806
- `Runner output: ${join(o.home, 'logs', 'runner.log')}`,
1824
+ !load ? `wrote ${f.path} (not loaded: ACH_SERVICE_DIR is set). Start it with:` : failed ? `wrote ${f.path}, but starting it failed: ${failed}\nStart it with:` : `installed and started ${f.path} (also starts at login)`,
1825
+ ...(!load || failed ? f.start.map((x) => ` ${x}`) : []),
1826
+ `Stop it with: ${f.stop.join(' && ')}`,
1827
+ `Runner output: ${logs}`,
1828
+ ...(linger ? [] : ['Note: it stops when you log out of this machine. To keep it running: sudo loginctl enable-linger "$USER"']),
1807
1829
  ...(passEnv.length ? [`Note: the unit passes --pass-env ${passEnv.join(',')} but not their values; add them to the unit's environment yourself.`] : []),
1808
1830
  ].join('\n'));
1809
- return 0;
1831
+ return failed ? 1 : 0;
1832
+ }
1833
+ /** Linux: does this user's systemd outlive their logins (loginctl linger)? Unknown counts as yes (no false alarm). */
1834
+ function lingers(env) {
1835
+ try {
1836
+ return execFileSync('loginctl', ['show-user', env.USER || String(process.getuid?.() ?? ''), '-p', 'Linger', '--value'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() !== 'no';
1837
+ }
1838
+ catch {
1839
+ return true;
1840
+ }
1810
1841
  }
1811
1842
  function isEntry() {
1812
1843
  try {
@@ -303,6 +303,13 @@ export declare class Client {
303
303
  private cfSecret?;
304
304
  private fetchFn;
305
305
  constructor(o: ClientOptions);
306
+ private ownFetch;
307
+ /**
308
+ * The hub's change stream (GET /api/ach/events, a WebSocket that says "something changed", ids only). The token
309
+ * travels as the `ach.<token>` subprotocol; the Cloudflare Access service token as headers, like every request.
310
+ * null without a token, or when this client was given its own fetch (a socket would bypass it).
311
+ */
312
+ eventsSocket(): WebSocket | null;
306
313
  headers(json?: boolean): Record<string, string>;
307
314
  /** `files`: local paths (already checked by resolveAttachments) sent as multipart with the JSON in field "json". */
308
315
  request<T = any>(method: string, path: string, body?: unknown, files?: string[]): Promise<T>;
@@ -1,5 +1,6 @@
1
1
  import { openAsBlob, statSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
+ import { WebSocket as UndiciWebSocket } from 'undici';
3
4
  import { attachmentForm, bigFiles, downloadFile, isLinkRef, linkRef, taskFiles, typeOf, uploadTarget } from './attachments.js';
4
5
  export const TERMINAL = ['completed', 'failed', 'canceled'];
5
6
  export function isTerminal(s) {
@@ -69,6 +70,25 @@ export class Client {
69
70
  this.cfId = o.cfId;
70
71
  this.cfSecret = o.cfSecret;
71
72
  this.fetchFn = o.fetch ?? ((u, i) => fetch(u, i));
73
+ this.ownFetch = !!o.fetch;
74
+ }
75
+ ownFetch;
76
+ /**
77
+ * The hub's change stream (GET /api/ach/events, a WebSocket that says "something changed", ids only). The token
78
+ * travels as the `ach.<token>` subprotocol; the Cloudflare Access service token as headers, like every request.
79
+ * null without a token, or when this client was given its own fetch (a socket would bypass it).
80
+ */
81
+ eventsSocket() {
82
+ if (!this.token || this.ownFetch)
83
+ return null;
84
+ const url = new URL('/api/ach/events', `${this.hub}/`);
85
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
86
+ const headers = { 'user-agent': 'agentchannel-cli' };
87
+ if (this.cfId && this.cfSecret) {
88
+ headers['CF-Access-Client-Id'] = this.cfId;
89
+ headers['CF-Access-Client-Secret'] = this.cfSecret;
90
+ }
91
+ return new UndiciWebSocket(url.href, { protocols: [`ach.${this.token}`], headers });
72
92
  }
73
93
  headers(json = true) {
74
94
  const h = json ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' };
@@ -60,6 +60,8 @@ export interface DeskNotifierOptions {
60
60
  /** Runs a notifier that waits for the click (alerter, notify-send --wait): long timeout. */
61
61
  actionExec?: NotifyExec;
62
62
  log?: (s: string) => void;
63
+ /** Opens the hub's change stream (Client.eventsSocket); absent: long poll, then polling. */
64
+ socket?: () => import('./attention.js').FeedSocket | null;
63
65
  }
64
66
  /** A decision notification waits up to an hour for the click. */
65
67
  export declare const ACTION_TIMEOUT_S = 3600;
@@ -123,6 +125,8 @@ export declare class DeskNotifier {
123
125
  private baseline;
124
126
  private timer;
125
127
  private busy;
128
+ /** The hub has no notification list (404): stop asking, say so once. */
129
+ private off;
126
130
  private feed;
127
131
  private linuxActions?;
128
132
  /** Click handlers still waiting (tests await them). */
@@ -147,7 +151,7 @@ export declare class DeskNotifier {
147
151
  * handled in the background: the notifier keeps polling meanwhile.
148
152
  */
149
153
  private showActions;
150
- /** Follows the hub's change stream (long poll; 15 s polling fallback). */
154
+ /** Follows the hub's change stream (WebSocket, else long poll, else 15 s polling). */
151
155
  start(): void;
152
156
  private stopped;
153
157
  stop(): void;
@@ -242,6 +242,8 @@ export class DeskNotifier {
242
242
  baseline;
243
243
  timer = null;
244
244
  busy = false;
245
+ /** The hub has no notification list (404): stop asking, say so once. */
246
+ off = false;
245
247
  feed = null;
246
248
  linuxActions;
247
249
  /** Click handlers still waiting (tests await them). */
@@ -286,7 +288,7 @@ export class DeskNotifier {
286
288
  }
287
289
  /** One poll: show new alert items (oldest first). Returns how many were shown. */
288
290
  async tick() {
289
- if (this.busy)
291
+ if (this.busy || this.off)
290
292
  return 0;
291
293
  this.busy = true;
292
294
  try {
@@ -325,6 +327,11 @@ export class DeskNotifier {
325
327
  return shown;
326
328
  }
327
329
  catch (e) {
330
+ if (e?.status === 404) {
331
+ this.off = true;
332
+ this.o.log?.('ach run --notify: this hub has no notification list; desktop notifications are off');
333
+ return 0;
334
+ }
328
335
  this.o.log?.(`ach run --notify: ${oneLine(e?.message ?? e, 200)}`);
329
336
  return 0;
330
337
  }
@@ -404,12 +411,12 @@ export class DeskNotifier {
404
411
  });
405
412
  return true;
406
413
  }
407
- /** Follows the hub's change stream (long poll; 15 s polling fallback). */
414
+ /** Follows the hub's change stream (WebSocket, else long poll, else 15 s polling). */
408
415
  start() {
409
416
  void import('./attention.js').then(({ ChangeFeed }) => {
410
417
  if (this.stopped)
411
418
  return;
412
- const feed = new ChangeFeed({ request: (m, p) => this.o.request(m, p), onChange: () => this.tick(), pollMs: this.o.intervalMs, log: this.o.log });
419
+ const feed = new ChangeFeed({ request: (m, p) => this.o.request(m, p), socket: this.o.socket, onChange: () => this.tick(), pollMs: this.o.intervalMs, log: this.o.log });
413
420
  this.feed = feed;
414
421
  void feed.run();
415
422
  });
@@ -206,7 +206,7 @@ export async function runDoctor(o) {
206
206
  const self = o.self ?? thisCliPath();
207
207
  const achs = whichAll('ach', env).map((p) => identifyAch(p, self));
208
208
  if (!achs.length) {
209
- add('ach on PATH', 'warn', 'no `ach` command on PATH', 'machine', `npm install -g ${npmSpec()} (needed only to type \`ach\` yourself or for the background helper: the join line with --service does it)`);
209
+ add('ach on PATH', 'warn', 'no `ach` command on PATH', 'machine', `npm install -g ${npmSpec()} (needed only to type \`ach\` yourself)`);
210
210
  }
211
211
  else {
212
212
  const desc = (a) => `${a.path}${a.real && a.real !== a.path ? ` -> ${a.real}` : ''} (${a.ours ? 'this CLI' : `${a.name ?? 'unknown'} ${a.version ?? ''}`.trim()})`;
@@ -220,17 +220,26 @@ export async function runDoctor(o) {
220
220
  for (const a of achs.slice(1))
221
221
  add('ach (shadowed)', isOurs(a) ? 'info' : 'warn', desc(a));
222
222
  }
223
- if (/[\\/]_npx[\\/]/.test(self)) {
224
- add('install', 'warn', 'this `ach` runs from the npx cache, which npm may clear', 'machine', `npm install -g ${npmSpec()}`);
225
- }
226
- // Background runner (ach run --install-service): is a unit file there?
223
+ // Background runner (ach run --install-service): installed, and running?
227
224
  if (agentName) {
228
225
  try {
229
- const { serviceFile } = await import('./service.js');
226
+ const { serviceFile, serviceLabel } = await import('./service.js');
230
227
  const platform = (env.ACH_SERVICE_PLATFORM || process.platform);
231
228
  if (platform === 'darwin' || platform === 'linux') {
232
- const f = serviceFile({ platform, agentName, node: '', cli: '', args: [], env: {}, logFile: '' }, env.ACH_SERVICE_DIR || undefined);
233
- add('background', 'info', existsSync(f.path) ? `tasks are picked up in the background (${f.path})` : 'no background runner on this machine (optional: ach run --dir <repo> --install-service --interactive-first)');
229
+ const f = serviceFile({ platform, agentName, command: [], args: [], env: {}, logFile: '' }, env.ACH_SERVICE_DIR || undefined);
230
+ if (!existsSync(f.path))
231
+ add('background', 'info', 'no background runner on this machine (optional: ach run --dir <repo> --install-service --interactive-first)');
232
+ else {
233
+ const unit = f.path.split(/[\\/]/).pop();
234
+ const r = platform === 'linux'
235
+ ? await exec('systemctl', ['--user', 'is-active', unit], env)
236
+ : await exec('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${serviceLabel(agentName)}`], env);
237
+ const running = platform === 'linux' ? r.stdout.trim() === 'active' : r.code === 0 && /state = running/.test(r.stdout);
238
+ if (running)
239
+ add('background', 'ok', `tasks are picked up in the background (${f.path})`);
240
+ else
241
+ add('background', 'warn', `the background runner is installed but not running (${f.path})`, 'machine', f.start.join(' && '));
242
+ }
234
243
  }
235
244
  }
236
245
  catch {
package/dist/src/mcp.js CHANGED
@@ -469,7 +469,7 @@ export function startSessionNotices(server, client, o = {}) {
469
469
  gapMs: o.gapMs,
470
470
  log: o.log,
471
471
  });
472
- const feed = new ChangeFeed({ request, onChange: () => notifier.check(), log: o.log, ...o.feed });
472
+ const feed = new ChangeFeed({ request, socket: () => client.eventsSocket(), onChange: () => notifier.check(), log: o.log, ...o.feed });
473
473
  const done = feed.run();
474
474
  return {
475
475
  notifier,
@@ -5,8 +5,9 @@ import type { ExecLike } from './doctor.js';
5
5
  * 1. connect: add the `ach` MCP server to the agent's own tool (Claude Code
6
6
  * for a claude agent, Codex for a codex one; both found ones for "other")
7
7
  * 2. background helper, only with --service: `ach run --install-service`
8
- * (+ --notify on a machine with a desktop). Run through npx, `ach` is
9
- * installed first, so the helper never runs from the npx cache.
8
+ * (+ --notify on a machine with a desktop), which installs and starts it.
9
+ * Run through npx, the helper runs the same pinned npx spec as the MCP
10
+ * entry, never a path in the npx cache.
10
11
  *
11
12
  * Pasting the line is the consent: with no terminal (an agent ran it) the steps
12
13
  * run and say what they ran. In a terminal each step lists its commands and
@@ -67,7 +68,6 @@ export interface Detected {
67
68
  /** User-installed claude / codex (never the bins npx puts on PATH). */
68
69
  claude?: string;
69
70
  codex?: string;
70
- npm?: string;
71
71
  npx: boolean;
72
72
  /** An `ach` entry already registered (it is replaced). */
73
73
  claudeHasAch: boolean;
@@ -86,7 +86,6 @@ export declare function detect(env: NodeJS.ProcessEnv, exec: ExecLike, self: str
86
86
  export declare function planSteps(d: Detected, o: {
87
87
  home?: string;
88
88
  achCmd: Argv;
89
- installSpec: string;
90
89
  cwd: string;
91
90
  zh?: boolean;
92
91
  env?: NodeJS.ProcessEnv;
@@ -1,8 +1,6 @@
1
- import { existsSync } from 'node:fs';
2
1
  import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
4
- import { identifyAch, isOurs, MCP_SERVER_NAME, npmSpec, npxAch, thisCliPath, userBin, whichAll } from './invite.js';
5
- import { VERSION } from './version.js';
3
+ import { identifyAch, isOurs, MCP_SERVER_NAME, npxAch, thisCliPath, userBin, whichAll } from './invite.js';
6
4
  /** The closing line: which tool to open and what to say to it. */
7
5
  export function doneLine(tools, zh) {
8
6
  const names = [...new Set(tools)].map((k) => (k === 'claude' ? 'Claude Code' : 'Codex'));
@@ -65,7 +63,7 @@ export async function detect(env, exec, self, probe) {
65
63
  const codex = userBin('codex', env);
66
64
  const npx = runFromNpx(self);
67
65
  const d = {
68
- claude, codex, npm: whichAll('npm', env)[0], npx,
66
+ claude, codex, npx,
69
67
  claudeHasAch: false, codexHasAch: false,
70
68
  // run through npx: the MCP entry uses the same pinned spec as the join line
71
69
  achCmd: npx ? npxAch() : absoluteAch(env, self),
@@ -111,17 +109,15 @@ export function planSteps(d, o) {
111
109
  if (o.service) {
112
110
  const notify = o.desktop !== false;
113
111
  const helper = ['run', '--install-service', ...(notify ? ['--notify'] : []), '--interactive-first', '--dir', o.cwd];
114
- const install = d.npx && d.npm ? [[d.npm, 'install', '-g', o.installSpec]] : [];
115
112
  steps.push({
116
113
  id: 'service',
117
114
  question: [
118
115
  zh
119
116
  ? `安装后台小助手(在 ${o.cwd} 里自动领取任务${notify ? ',并弹出系统通知' : ''})?会运行:`
120
117
  : `Install the background helper (picks up tasks in ${o.cwd}${notify ? ' and shows desktop notifications' : ''})? This runs:`,
121
- ...install.map(() => ` npm install -g ${o.installSpec} # ${zh ? '`ach` 命令(后台小助手不能从 npx 缓存运行)' : 'the `ach` command (the helper cannot run from the npx cache)'}`),
122
- ` ${shellLine(['ach', ...helper])}`,
118
+ ` ${shellLine([...o.achCmd, ...helper])}`,
123
119
  ].join('\n') + '\n',
124
- commands: [...install, [...o.achCmd, ...helper]],
120
+ commands: [[...o.achCmd, ...helper]],
125
121
  });
126
122
  }
127
123
  return steps;
@@ -130,26 +126,16 @@ function yesNo(answer) {
130
126
  const a = answer.trim().toLowerCase();
131
127
  return !a || /^(y|yes|是|好|ok)$/.test(a);
132
128
  }
133
- /** The global `ach` after `npm install -g` (npm prefix -g)/bin/ach. */
134
- async function globalAch(npm, exec, env) {
135
- const r = await exec(npm, ['prefix', '-g'], env);
136
- const prefix = r.stdout.trim().split('\n').pop()?.trim();
137
- if (r.code !== 0 || !prefix)
138
- return undefined;
139
- const f = process.platform === 'win32' ? join(prefix, 'ach.cmd') : join(prefix, 'bin', 'ach');
140
- return existsSync(f) ? f : undefined;
141
- }
142
129
  /** Run (or, with --dry-run, only print) the setup after `ach join`. */
143
130
  export async function runSetup(io, o) {
144
131
  const env = io.env;
145
132
  const self = o.self ?? thisCliPath();
146
133
  const d = await detect(env, io.exec, self, !o.dryRun);
147
- const installSpec = npmSpec(VERSION);
148
134
  const home = env.AGENTCHANNEL_HOME || undefined;
149
135
  const result = { ran: [], skipped: [], failed: [], tools: [] };
150
136
  const zh = !!o.zh;
151
137
  const desktop = hasDesktop(env);
152
- const plan = planSteps(d, { home, achCmd: d.achCmd, installSpec, cwd: io.cwd, zh, env, runtime: o.runtime, service: o.service, desktop });
138
+ const plan = planSteps(d, { home, achCmd: d.achCmd, cwd: io.cwd, zh, env, runtime: o.runtime, service: o.service, desktop });
153
139
  if (o.dryRun) {
154
140
  printOnly(io, d, plan, { zh, join: o.join });
155
141
  return result;
@@ -192,18 +178,7 @@ export async function runSetup(io, o) {
192
178
  continue;
193
179
  }
194
180
  if (s.id === 'service') {
195
- // run through npx: install `ach` first; the helper then runs the installed one, never the npx cache
196
- let helper = s.commands.at(-1);
197
- if (s.commands.length > 1) {
198
- if (!(await runCmds(s.commands.slice(0, -1)))) {
199
- result.failed.push('service');
200
- continue;
201
- }
202
- const g = await globalAch(d.npm, io.exec, env);
203
- if (g)
204
- helper = [g, ...helper.slice(d.achCmd.length)];
205
- }
206
- const ok = await installAndStartService(io, helper);
181
+ const ok = await installService(io, s.commands[0]);
207
182
  (ok ? result.ran : result.failed).push('service');
208
183
  continue;
209
184
  }
@@ -223,36 +198,22 @@ export async function runSetup(io, o) {
223
198
  }
224
199
  return result;
225
200
  }
226
- /** `ach run --install-service --json`, then start the unit (unless ACH_SERVICE_DIR points elsewhere: tests). */
227
- async function installAndStartService(io, argv) {
228
- const env = io.env;
229
- const r = await io.exec(argv[0], [...argv.slice(1), '--json'], env);
230
- if (r.code !== 0) {
231
- io.err(` failed (exit ${r.code}): ${shellLine(argv)}${r.stderr.trim() ? `\n ${r.stderr.trim().split('\n')[0]}` : ''}`);
232
- return false;
233
- }
234
- let f;
201
+ /** `ach run --install-service --json`: it writes the unit and starts it (not under ACH_SERVICE_DIR: tests). */
202
+ async function installService(io, argv) {
203
+ const r = await io.exec(argv[0], [...argv.slice(1), '--json'], io.env);
204
+ let f = {};
235
205
  try {
236
206
  f = JSON.parse(r.stdout);
237
207
  }
238
208
  catch {
239
- io.err(' could not read the service file location');
240
- return false;
241
- }
242
- io.out(` wrote ${f.path}`);
243
- const starts = f.start.map((x) => x.replace(/\s+#.*$/, '')).filter((x) => !/loginctl/.test(x));
244
- if (env.ACH_SERVICE_DIR) {
245
- io.out(` start it with: ${starts.join(' && ')}`);
246
- return true;
209
+ /* reported below */
247
210
  }
248
- for (const c of starts) {
249
- const s = await io.exec('/bin/sh', ['-c', c], env);
250
- if (s.code !== 0) {
251
- io.err(` failed (exit ${s.code}): ${c}`);
252
- return false;
253
- }
211
+ if (r.code !== 0 || !f.path) {
212
+ const why = f.error || r.stderr.trim().split('\n')[0];
213
+ io.err(` failed (exit ${r.code}): ${shellLine(argv)}${why ? `\n ${why}` : ''}`);
214
+ return false;
254
215
  }
255
- io.out(' started');
216
+ io.out(f.started ? ` installed and started ${f.path}` : ` wrote ${f.path}; start it with: ${(f.start ?? []).join(' && ')}`);
256
217
  return true;
257
218
  }
258
219
  /** --dry-run: print what would be run (nothing is run, not even read-only probes). */
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * P4: `ach run --install-service` writes a macOS launchd agent or a Linux
3
- * systemd user unit that starts this runner at login and restarts it. It only
4
- * writes the file and prints how to start it; it never loads the unit.
3
+ * systemd user unit that starts this runner at login and restarts it, then
4
+ * loads it (startService).
5
5
  */
6
6
  export interface ServiceSpec {
7
7
  platform: 'darwin' | 'linux';
8
8
  agentName: string;
9
- /** node binary and cli.js of this install. */
10
- node: string;
11
- cli: string;
9
+ /** What starts the CLI: node + cli.js of an install, or `npx -y <spec>` when it runs from the npx cache. */
10
+ command: string[];
12
11
  /** `run` arguments, without --install-service. */
13
12
  args: string[];
14
13
  env: Record<string, string>;
@@ -25,5 +24,5 @@ export declare function serviceLabel(agentName: string): string;
25
24
  export declare function launchdPlist(s: ServiceSpec): string;
26
25
  export declare function systemdUnit(s: ServiceSpec): string;
27
26
  export declare function serviceFile(s: ServiceSpec, dir?: string): ServiceFile;
28
- /** Write the unit file (mode 0644; it holds no secrets). Never loads it. */
27
+ /** Write the unit file (mode 0644; it holds no secrets). */
29
28
  export declare function writeServiceFile(f: ServiceFile): void;
@@ -14,7 +14,7 @@ export function serviceLabel(agentName) {
14
14
  return `io.agentchannel.runner.${agentName.replace(/[^A-Za-z0-9-]/g, '-')}`;
15
15
  }
16
16
  export function launchdPlist(s) {
17
- const progArgs = [s.node, s.cli, 'run', ...s.args].map((a) => ` <string>${xml(a)}</string>`).join('\n');
17
+ const progArgs = [...s.command, 'run', ...s.args].map((a) => ` <string>${xml(a)}</string>`).join('\n');
18
18
  const env = Object.entries(s.env)
19
19
  .map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`)
20
20
  .join('\n');
@@ -50,7 +50,7 @@ ${env}
50
50
  `;
51
51
  }
52
52
  export function systemdUnit(s) {
53
- const exec = [s.node, s.cli, 'run', ...s.args].map(sd).join(' ');
53
+ const exec = [...s.command, 'run', ...s.args].map(sd).join(' ');
54
54
  const env = Object.entries(s.env)
55
55
  .map(([k, v]) => `Environment=${sd(`${k}=${v}`)}`)
56
56
  .join('\n');
@@ -79,7 +79,9 @@ export function serviceFile(s, dir) {
79
79
  return {
80
80
  path,
81
81
  content: launchdPlist(s),
82
- start: [`launchctl bootstrap gui/$(id -u) ${path}`],
82
+ // bootout first: bootstrap refuses a label that is already loaded (a reinstall); bootout returns before the
83
+ // old job is gone, so a bootstrap right after it can fail once (error 5): try again after a moment
84
+ start: [`launchctl bootout gui/$(id -u)/${label} 2>/dev/null; launchctl bootstrap gui/$(id -u) ${path} 2>/dev/null || { sleep 2; launchctl bootstrap gui/$(id -u) ${path}; }`],
83
85
  stop: [`launchctl bootout gui/$(id -u)/${label}`],
84
86
  };
85
87
  }
@@ -88,11 +90,12 @@ export function serviceFile(s, dir) {
88
90
  return {
89
91
  path,
90
92
  content: systemdUnit(s),
91
- start: ['systemctl --user daemon-reload', `systemctl --user enable --now ${unit}`, `loginctl enable-linger "$USER" # optional: keep it running while logged out`],
93
+ // restart, not `enable --now`: a reinstall must pick up the new unit
94
+ start: ['systemctl --user daemon-reload', `systemctl --user enable ${unit}`, `systemctl --user restart ${unit}`],
92
95
  stop: [`systemctl --user disable --now ${unit}`],
93
96
  };
94
97
  }
95
- /** Write the unit file (mode 0644; it holds no secrets). Never loads it. */
98
+ /** Write the unit file (mode 0644; it holds no secrets). */
96
99
  export function writeServiceFile(f) {
97
100
  const dir = join(f.path, '..');
98
101
  if (!existsSync(dir))
@@ -1,3 +1,3 @@
1
- export declare const VERSION = "3.2.0";
1
+ export declare const VERSION = "3.4.0";
2
2
  /** The npm package (scripts/release.mjs keeps it and VERSION in step with package.json). */
3
3
  export declare const PKG_NAME = "@koalaauto/agentchannel";
@@ -1,3 +1,3 @@
1
- export const VERSION = '3.2.0';
1
+ export const VERSION = '3.4.0';
2
2
  /** The npm package (scripts/release.mjs keeps it and VERSION in step with package.json). */
3
3
  export const PKG_NAME = '@koalaauto/agentchannel';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koalaauto/agentchannel",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "Hand work between your team's agents (Claude Code, Codex) and follow it in one panel: the ach CLI, MCP server and runner for AgentChannel v2",
5
5
  "keywords": [
6
6
  "agentchannel",