@koalaauto/agentchannel 3.4.0 → 3.6.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 +15 -0
- package/dist/src/args.d.ts +1 -1
- package/dist/src/args.js +4 -2
- package/dist/src/cli.js +29 -1
- package/dist/src/client.d.ts +2 -0
- package/dist/src/client.js +5 -2
- package/dist/src/doctor.d.ts +2 -2
- package/dist/src/doctor.js +35 -38
- package/dist/src/invite.js +2 -2
- package/dist/src/mcp.d.ts +4 -0
- package/dist/src/mcp.js +11 -0
- package/dist/src/runner/runner.d.ts +10 -0
- package/dist/src/runner/runner.js +34 -2
- package/dist/src/selfupdate.d.ts +135 -0
- package/dist/src/selfupdate.js +287 -0
- package/dist/src/version.d.ts +9 -1
- package/dist/src/version.js +32 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,6 +68,21 @@ It reports three things, each problem with a one-line fix:
|
|
|
68
68
|
It also checks this machine: which `ach` runs first on PATH, Claude Code and
|
|
69
69
|
Codex login, and the `ach` MCP entry.
|
|
70
70
|
|
|
71
|
+
## Staying current
|
|
72
|
+
|
|
73
|
+
Your hub names the newest `ach` release (`ach doctor` shows it), and the background helper keeps itself on it:
|
|
74
|
+
|
|
75
|
+
- **The helper (`ach run`, started by `--install-service`)** checks every few hours. When the hub is ahead it stops taking
|
|
76
|
+
new tasks, lets the runs in flight finish, installs the release (`npm install -g` for a global install; an `npx` start
|
|
77
|
+
fetches it itself) and exits with code 75, which systemd and launchd restart. It never crosses to a new major release
|
|
78
|
+
by itself, never moves a stable install onto a prerelease, leaves a source checkout alone, and tries a failed update
|
|
79
|
+
again only after a day. `ACH_AUTO_UPDATE=0` turns the installing off; it still says so in the log.
|
|
80
|
+
- **A session (`ach mcp`, started by Claude Code or Codex)** cannot be restarted from outside: it gets one line in the
|
|
81
|
+
session saying it is behind. Restart the session (`npx` installs pick up the new release); run
|
|
82
|
+
`npm install -g @koalaauto/agentchannel@latest` first if `ach` is installed globally.
|
|
83
|
+
- **A hub that sets a floor** (`ACH_MIN_CLIENT_PROTO`) refuses a CLI below it with `426 client_too_old` for new work; the
|
|
84
|
+
helper then checks for an update at once.
|
|
85
|
+
|
|
71
86
|
## Who decides
|
|
72
87
|
|
|
73
88
|
`ach ask-human` asks your own agent's owner by default. When the call belongs
|
package/dist/src/args.d.ts
CHANGED
|
@@ -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 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";
|
|
13
|
+
export declare const HELP = "ach - AgentChannel CLI\n\nSetup: npx -y @koalaauto/agentchannel@3 join <ach2.invite> [--name <n>] [--service] [--yes] [--dry-run] (run it in the\n project directory: a line from the panel's \"Connect an agent\" creates the agent there, named\n \"<GitHub name>-<directory>\" unless --name says otherwise; a 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
|
@@ -6,7 +6,7 @@ const json = { json: b, help: { type: 'boolean', short: 'h' } };
|
|
|
6
6
|
/** Option specs per command (§8). */
|
|
7
7
|
export const COMMANDS = {
|
|
8
8
|
login: { hub: s, key: s, ...json },
|
|
9
|
-
join: { yes: b, service: b, 'dry-run': b, ...json },
|
|
9
|
+
join: { yes: b, service: b, 'dry-run': b, name: s, ...json },
|
|
10
10
|
me: { ...json },
|
|
11
11
|
doctor: { ...json },
|
|
12
12
|
notify: { test: b, ...json },
|
|
@@ -133,7 +133,9 @@ export function parseCli(argv) {
|
|
|
133
133
|
}
|
|
134
134
|
export const HELP = `ach - AgentChannel CLI
|
|
135
135
|
|
|
136
|
-
Setup: npx -y @koalaauto/agentchannel@3 join <ach2.invite> [--service] [--yes] [--dry-run] (
|
|
136
|
+
Setup: npx -y @koalaauto/agentchannel@3 join <ach2.invite> [--name <n>] [--service] [--yes] [--dry-run] (run it in the
|
|
137
|
+
project directory: a line from the panel's "Connect an agent" creates the agent there, named
|
|
138
|
+
"<GitHub name>-<directory>" unless --name says otherwise; a line from the panel or
|
|
137
139
|
\`ach admin invite <agent>\`: stores the agent's key and adds the \`ach\` MCP server to the agent's own tool
|
|
138
140
|
(Claude Code or Codex). In a terminal it shows the hub and every command first and asks once; --yes skips
|
|
139
141
|
that; run by an agent (no terminal) it just does it. --service also installs the background helper
|
package/dist/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { isPerm } from './perm.js';
|
|
|
14
14
|
import { AttachError, humanSize, isLinkRef, resolveAttachments, taskFiles } from './attachments.js';
|
|
15
15
|
export { VERSION } from './version.js';
|
|
16
16
|
import { VERSION } from './version.js';
|
|
17
|
+
import { SelfUpdater, UPDATE_EXIT_CODE } from './selfupdate.js';
|
|
17
18
|
import { installProxy } from './netproxy.js';
|
|
18
19
|
async function askTty(question) {
|
|
19
20
|
const { createInterface } = await import('node:readline/promises');
|
|
@@ -392,12 +393,22 @@ async function dispatch(p, io) {
|
|
|
392
393
|
// exchange the one-time join token for the agent key
|
|
393
394
|
let key;
|
|
394
395
|
try {
|
|
395
|
-
|
|
396
|
+
// a connect invite creates its agent now, named after this directory ("<GitHub name>-<dir>") unless --name says otherwise;
|
|
397
|
+
// an invite for an existing agent ignores both
|
|
398
|
+
// only the directory's own name goes to the hub, never the path to it
|
|
399
|
+
const dir = basename(io.cwd ?? process.cwd());
|
|
400
|
+
const ex = await new Client({ hub: inv.hub, cfId, cfSecret, fetch: io.fetch }).request('POST', '/api/ach/invite/exchange', { join: inv.join, dir, ...(typeof v.name === 'string' && v.name.trim() ? { name: v.name.trim() } : {}) });
|
|
396
401
|
if (!ex?.key)
|
|
397
402
|
throw new Error('the hub returned no key');
|
|
398
403
|
key = String(ex.key);
|
|
404
|
+
if (ex.created && ex.agent?.name)
|
|
405
|
+
io.err(zhJ ? `已创建智能体 ${ex.agent.name}(按这个目录命名;要换名字,下次加 --name)。` : `created the agent ${ex.agent.name} (named after this directory; use --name next time for another).`);
|
|
399
406
|
}
|
|
400
407
|
catch (e) {
|
|
408
|
+
if (e instanceof HubError && (e.code === 'conflict' || e.code === 'invalid_name')) {
|
|
409
|
+
io.err(isZh(env) ? `ach join:${e.message}。换一个名字再运行:加上 --name <名字>(小写字母、数字和 -)。邀请还没有用掉。` : `ach join: ${e.message}. Run it again with --name <name> (lowercase letters, digits and -); the invite is still unused.`);
|
|
410
|
+
return 1;
|
|
411
|
+
}
|
|
401
412
|
const used = e instanceof HubError && (e.status === 410 || e.status === 401);
|
|
402
413
|
// run again on the same machine (e.g. after installing Claude Code): keep the key saved by the first run
|
|
403
414
|
let again;
|
|
@@ -1664,6 +1675,8 @@ async function runCommand(p, io) {
|
|
|
1664
1675
|
const runtime = str(v.runtime) ?? (me.runtime === 'codex' ? 'codex' : 'claude');
|
|
1665
1676
|
const { makeAdapter } = await import('./adapters/index.js');
|
|
1666
1677
|
const adapter = makeAdapter(runtime, { model: str(v.model) });
|
|
1678
|
+
// keeps this runner on the hub's release: waits for the runs in flight, installs, exits UPDATE_EXIT_CODE so the service restarts
|
|
1679
|
+
let updater;
|
|
1667
1680
|
const runner = new Runner({
|
|
1668
1681
|
client: c,
|
|
1669
1682
|
adapter,
|
|
@@ -1678,6 +1691,7 @@ async function runCommand(p, io) {
|
|
|
1678
1691
|
cfId: cfg.cfId,
|
|
1679
1692
|
cfSecret: cfg.cfSecret,
|
|
1680
1693
|
publicUrl: env.ACH_PUBLIC_URL || undefined,
|
|
1694
|
+
onClientTooOld: () => void updater?.nudge(),
|
|
1681
1695
|
passEnv: (Array.isArray(v['pass-env']) ? v['pass-env'] : []).flatMap(splitList),
|
|
1682
1696
|
baseEnv: (() => {
|
|
1683
1697
|
const shim = writeAchShim(home);
|
|
@@ -1697,18 +1711,32 @@ async function runCommand(p, io) {
|
|
|
1697
1711
|
desk = dn;
|
|
1698
1712
|
io.err('ach run: desktop notifications on (--no-notify to turn off)');
|
|
1699
1713
|
}
|
|
1714
|
+
updater = new SelfUpdater({
|
|
1715
|
+
fetchVersions: () => c.request('GET', '/api/ach/auth/config'),
|
|
1716
|
+
runner,
|
|
1717
|
+
home,
|
|
1718
|
+
env,
|
|
1719
|
+
log: io.err,
|
|
1720
|
+
});
|
|
1721
|
+
updater.start();
|
|
1700
1722
|
const shutdown = () => {
|
|
1701
1723
|
io.err('stopping runner...');
|
|
1724
|
+
updater?.stop();
|
|
1702
1725
|
desk?.stop();
|
|
1703
1726
|
runner.stop().then(() => process.exit(0));
|
|
1704
1727
|
};
|
|
1705
1728
|
process.once('SIGINT', shutdown);
|
|
1706
1729
|
process.once('SIGTERM', shutdown);
|
|
1707
1730
|
const end = await runner.start();
|
|
1731
|
+
updater.stop();
|
|
1708
1732
|
desk?.stop();
|
|
1709
1733
|
// B7: the hub refused the agent key: rotated, agent disabled or owner deactivated.
|
|
1710
1734
|
if (end === 'unauthorized')
|
|
1711
1735
|
return authExit(io);
|
|
1736
|
+
if (updater.restarting) {
|
|
1737
|
+
io.err(`ach run: updated; exiting ${UPDATE_EXIT_CODE} so the service restarts on the new release (started by hand? run it again)`);
|
|
1738
|
+
return UPDATE_EXIT_CODE;
|
|
1739
|
+
}
|
|
1712
1740
|
return 0;
|
|
1713
1741
|
}
|
|
1714
1742
|
/** B7: print the rejoin hint and return the auth exit code (ACH_AUTH_EXIT_CODE overrides it). */
|
package/dist/src/client.d.ts
CHANGED
|
@@ -265,6 +265,8 @@ export interface DecisionOption {
|
|
|
265
265
|
detail?: string;
|
|
266
266
|
money?: string;
|
|
267
267
|
}
|
|
268
|
+
/** Who is calling, on every request: the hub records the release per agent and may refuse a protocol below its floor (426 client_too_old). */
|
|
269
|
+
export declare const CLIENT_HEADERS: Record<string, string>;
|
|
268
270
|
export declare class HubError extends Error {
|
|
269
271
|
status: number;
|
|
270
272
|
code?: string | undefined;
|
package/dist/src/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { openAsBlob, statSync } from 'node:fs';
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
3
|
import { WebSocket as UndiciWebSocket } from 'undici';
|
|
4
|
+
import { CLIENT_PROTO, VERSION } from './version.js';
|
|
4
5
|
import { attachmentForm, bigFiles, downloadFile, isLinkRef, linkRef, taskFiles, typeOf, uploadTarget } from './attachments.js';
|
|
5
6
|
export const TERMINAL = ['completed', 'failed', 'canceled'];
|
|
6
7
|
export function isTerminal(s) {
|
|
@@ -17,6 +18,8 @@ export function parseLinkRepo(v) {
|
|
|
17
18
|
throw new Error(`bad base in "${v}"`);
|
|
18
19
|
return base ? { repo, base } : { repo };
|
|
19
20
|
}
|
|
21
|
+
/** Who is calling, on every request: the hub records the release per agent and may refuse a protocol below its floor (426 client_too_old). */
|
|
22
|
+
export const CLIENT_HEADERS = { 'x-ach-client': `ach-cli/${VERSION}`, 'x-ach-proto': String(CLIENT_PROTO) };
|
|
20
23
|
export class HubError extends Error {
|
|
21
24
|
status;
|
|
22
25
|
code;
|
|
@@ -83,7 +86,7 @@ export class Client {
|
|
|
83
86
|
return null;
|
|
84
87
|
const url = new URL('/api/ach/events', `${this.hub}/`);
|
|
85
88
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
86
|
-
const headers = { 'user-agent': 'agentchannel-cli' };
|
|
89
|
+
const headers = { 'user-agent': 'agentchannel-cli', ...CLIENT_HEADERS };
|
|
87
90
|
if (this.cfId && this.cfSecret) {
|
|
88
91
|
headers['CF-Access-Client-Id'] = this.cfId;
|
|
89
92
|
headers['CF-Access-Client-Secret'] = this.cfSecret;
|
|
@@ -91,7 +94,7 @@ export class Client {
|
|
|
91
94
|
return new UndiciWebSocket(url.href, { protocols: [`ach.${this.token}`], headers });
|
|
92
95
|
}
|
|
93
96
|
headers(json = true) {
|
|
94
|
-
const h = json ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' };
|
|
97
|
+
const h = { ...CLIENT_HEADERS, ...(json ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' }) };
|
|
95
98
|
if (this.token) {
|
|
96
99
|
// Agent keys and run tokens use Bearer; PocketBase user tokens are sent raw.
|
|
97
100
|
h.authorization = /^ach_(sk|rt)_/.test(this.token) ? `Bearer ${this.token}` : this.token;
|
package/dist/src/doctor.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type FetchLike } from './client.js';
|
|
2
|
+
import { newerVersion } from './version.js';
|
|
2
3
|
export type ExecResult = {
|
|
3
4
|
code: number;
|
|
4
5
|
stdout: string;
|
|
@@ -71,7 +72,6 @@ export declare function shadowFix(other: {
|
|
|
71
72
|
}, ours: {
|
|
72
73
|
path: string;
|
|
73
74
|
} | undefined, self: string): string;
|
|
74
|
-
|
|
75
|
-
export declare function newerVersion(a: string, b: string): boolean;
|
|
75
|
+
export { newerVersion };
|
|
76
76
|
/** Grouped report: hub, front door (only when there is one), key, this machine; each problem with a one-line fix. */
|
|
77
77
|
export declare function formatDoctor(checks: DoctorCheck[]): string;
|
package/dist/src/doctor.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Client } from './client.js';
|
|
|
6
6
|
import { achHome, loadAgentKey, loadConfig } from './config.js';
|
|
7
7
|
import { hubTransportOk, identifyAch, isOurs, npmSpec, thisCliPath, userBin, whichAll } from './invite.js';
|
|
8
8
|
import { openKeyStore } from './keystore.js';
|
|
9
|
-
import { PKG_NAME, VERSION } from './version.js';
|
|
9
|
+
import { CLIENT_PROTO, newerVersion, PKG_NAME, VERSION } from './version.js';
|
|
10
10
|
/** Ask-the-admin fix: a fresh join line carries a new key and whatever the hub needs to let you in. */
|
|
11
11
|
export const NEW_LINE_FIX = 'ask your admin to run `ach admin invite <your agent>` again and paste the new line';
|
|
12
12
|
/** Allowed clock difference with the hub before warning (login links and tokens are time-bound). */
|
|
@@ -103,6 +103,8 @@ export async function runDoctor(o) {
|
|
|
103
103
|
const where = env.ACH_KEY ? 'ACH_KEY' : openKeyStore(home, env).kind === 'keychain' ? 'OS keychain' : 'agent-key file';
|
|
104
104
|
add('agent key', key ? 'ok' : 'fail', key ? `present (${where}; not shown)` : `missing${keyErr ? ` (${keyErr})` : ''}`, 'key', key ? undefined : 'paste the join line from your admin again');
|
|
105
105
|
// Hub: reachable over HTTPS; front door (if any); the key works; clock
|
|
106
|
+
let hubLatest = '';
|
|
107
|
+
let hubMinProto = 0;
|
|
106
108
|
let runtime;
|
|
107
109
|
let agentName;
|
|
108
110
|
if (cfg.hub) {
|
|
@@ -181,26 +183,45 @@ export async function runDoctor(o) {
|
|
|
181
183
|
const c = r.ok ? await r.json() : null;
|
|
182
184
|
if (c && typeof c.version === 'string')
|
|
183
185
|
add('hub version', 'info', `AgentChannel ${c.version}${c.commit ? ` (${c.commit})` : ''}`, 'hub');
|
|
186
|
+
if (c && typeof c.latest_client === 'string')
|
|
187
|
+
hubLatest = c.latest_client;
|
|
188
|
+
if (c && Number.isInteger(c.min_client_proto))
|
|
189
|
+
hubMinProto = c.min_client_proto;
|
|
184
190
|
}
|
|
185
191
|
catch {
|
|
186
192
|
/* informational only */
|
|
187
193
|
}
|
|
188
194
|
}
|
|
189
195
|
}
|
|
196
|
+
// the hub says what it needs: a floor this CLI is below, or a newer release than this one (hubs before 3.5 say nothing)
|
|
197
|
+
const line = VERSION.includes('-') ? 'next' : 'latest';
|
|
198
|
+
const upgrade = `run the join line from the panel again (Settings > Agents > New join command), or: npm install -g ${PKG_NAME}@${line}`;
|
|
199
|
+
let hubSaid = false;
|
|
200
|
+
if (hubMinProto > CLIENT_PROTO) {
|
|
201
|
+
hubSaid = true;
|
|
202
|
+
add('update', 'fail', `this hub no longer accepts ${VERSION} (protocol ${CLIENT_PROTO}, it needs ${hubMinProto} or newer)`, 'machine', upgrade);
|
|
203
|
+
}
|
|
204
|
+
else if (hubLatest && /^\d+\.\d+\.\d+/.test(hubLatest) && newerVersion(hubLatest, VERSION)) {
|
|
205
|
+
hubSaid = true;
|
|
206
|
+
// the exact release the hub names: always installable, whatever line (latest, next) this CLI is on
|
|
207
|
+
add('update', 'warn', `${PKG_NAME} ${hubLatest} is out (this is ${VERSION}; your hub announces it)`, 'machine', `run the join line from the panel again (Settings > Agents > New join command), or: npm install -g ${PKG_NAME}@${hubLatest}`);
|
|
208
|
+
}
|
|
190
209
|
// a newer CLI on npm (the release line this CLI is on: `latest` for a release, `next` for a prerelease)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
210
|
+
if (!hubSaid) {
|
|
211
|
+
try {
|
|
212
|
+
const fetchFn = o.fetch ?? ((u, i) => fetch(u, i));
|
|
213
|
+
const r = await fetchFn(`https://registry.npmjs.org/-/package/${PKG_NAME.replace('/', '%2f')}/dist-tags`, { method: 'GET', headers: { accept: 'application/json' } });
|
|
214
|
+
const tags = r.ok ? await r.json() : null;
|
|
215
|
+
const newest = tags && typeof tags[line] === 'string' ? tags[line] : '';
|
|
216
|
+
if (newest && newerVersion(newest, VERSION)) {
|
|
217
|
+
add('update', 'warn', `${PKG_NAME} ${newest} is out (this is ${VERSION})`, 'machine', upgrade);
|
|
218
|
+
}
|
|
219
|
+
else if (newest)
|
|
220
|
+
add('update', 'ok', `${VERSION} is the newest ${PKG_NAME}`);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
/* offline: nothing to say */
|
|
198
224
|
}
|
|
199
|
-
else if (newest)
|
|
200
|
-
add('update', 'ok', `${VERSION} is the newest ${PKG_NAME}`);
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
/* offline: nothing to say */
|
|
204
225
|
}
|
|
205
226
|
// Which `ach` runs
|
|
206
227
|
const self = o.self ?? thisCliPath();
|
|
@@ -390,31 +411,7 @@ const SECTIONS = [
|
|
|
390
411
|
{ id: 'key', title: 'Key' },
|
|
391
412
|
{ id: 'machine', title: 'This machine' },
|
|
392
413
|
];
|
|
393
|
-
|
|
394
|
-
export function newerVersion(a, b) {
|
|
395
|
-
const parse = (v) => {
|
|
396
|
-
const [core, pre = ''] = v.split('-', 2);
|
|
397
|
-
return { n: core.split('.').map((x) => Number(x) || 0), pre: pre ? pre.split('.') : [] };
|
|
398
|
-
};
|
|
399
|
-
const x = parse(a);
|
|
400
|
-
const y = parse(b);
|
|
401
|
-
for (let i = 0; i < 3; i++)
|
|
402
|
-
if (x.n[i] !== y.n[i])
|
|
403
|
-
return x.n[i] > y.n[i];
|
|
404
|
-
if (!x.pre.length || !y.pre.length)
|
|
405
|
-
return !x.pre.length && !!y.pre.length;
|
|
406
|
-
for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
|
|
407
|
-
const p = x.pre[i];
|
|
408
|
-
const q = y.pre[i];
|
|
409
|
-
if (p === undefined)
|
|
410
|
-
return false;
|
|
411
|
-
if (q === undefined)
|
|
412
|
-
return true;
|
|
413
|
-
if (p !== q)
|
|
414
|
-
return /^\d+$/.test(p) && /^\d+$/.test(q) ? Number(p) > Number(q) : p > q;
|
|
415
|
-
}
|
|
416
|
-
return false;
|
|
417
|
-
}
|
|
414
|
+
export { newerVersion };
|
|
418
415
|
/** Grouped report: hub, front door (only when there is one), key, this machine; each problem with a one-line fix. */
|
|
419
416
|
export function formatDoctor(checks) {
|
|
420
417
|
const mark = { ok: '[ok] ', warn: '[warn]', fail: '[FAIL]', info: '[info]' };
|
package/dist/src/invite.js
CHANGED
|
@@ -219,11 +219,11 @@ export function agentInvitePrompt(line, lang = 'en') {
|
|
|
219
219
|
const spec = /^npx -y (\S+) join /.exec(line)?.[1] ?? npmSpec();
|
|
220
220
|
const npx = `npx -y ${spec}`;
|
|
221
221
|
if (lang === 'zh') {
|
|
222
|
-
return (`请帮我接入团队的 AgentChannel
|
|
222
|
+
return (`请帮我接入团队的 AgentChannel:在这个项目的目录里运行 \`${line}\`。` +
|
|
223
223
|
`它会保存这个智能体的密钥,并把 AgentChannel 接入你自己,不会安装别的东西;这行只能用一次。` +
|
|
224
224
|
`完成后告诉我收件箱里有什么。如果出错,运行 \`${npx} doctor\`,按提示修好再继续。`);
|
|
225
225
|
}
|
|
226
|
-
return (`Please connect me to my team's AgentChannel: run \`${line}\` in
|
|
226
|
+
return (`Please connect me to my team's AgentChannel: run \`${line}\` in this project's directory. ` +
|
|
227
227
|
`It stores this agent's key and adds AgentChannel to you; it installs nothing else (the line works once). ` +
|
|
228
228
|
`Then tell me what is in my inbox. If anything fails, run \`${npx} doctor\`, fix what it says, and continue.`);
|
|
229
229
|
}
|
package/dist/src/mcp.d.ts
CHANGED
|
@@ -53,10 +53,14 @@ export declare function startSessionNotices(server: McpServer, client: Client, o
|
|
|
53
53
|
log?: (s: string) => void;
|
|
54
54
|
feed?: Partial<ChangeFeedOptions>;
|
|
55
55
|
gapMs?: number;
|
|
56
|
+
staleFirstMs?: number;
|
|
56
57
|
}): {
|
|
57
58
|
notifier: AttentionNotifier;
|
|
58
59
|
feed: ChangeFeed;
|
|
59
60
|
done: Promise<void>;
|
|
61
|
+
stale: {
|
|
62
|
+
check(): Promise<boolean>;
|
|
63
|
+
};
|
|
60
64
|
stop(): void;
|
|
61
65
|
} | null;
|
|
62
66
|
export declare function startMcp(client: Client): Promise<void>;
|
package/dist/src/mcp.js
CHANGED
|
@@ -8,6 +8,7 @@ import { resolveAttachments, taskFiles } from './attachments.js';
|
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { AttentionNotifier, ChangeFeed, codexQueue, defaultExec, fetchAttention } from './attention.js';
|
|
10
10
|
import { isZh } from './client.js';
|
|
11
|
+
import { startStaleNotice } from './selfupdate.js';
|
|
11
12
|
import { decisionTo, explainToUser } from './names.js';
|
|
12
13
|
export const HANDOFF_TEMPLATE = `## Goal
|
|
13
14
|
<one or two sentences: what done looks like>
|
|
@@ -471,13 +472,23 @@ export function startSessionNotices(server, client, o = {}) {
|
|
|
471
472
|
});
|
|
472
473
|
const feed = new ChangeFeed({ request, socket: () => client.eventsSocket(), onChange: () => notifier.check(), log: o.log, ...o.feed });
|
|
473
474
|
const done = feed.run();
|
|
475
|
+
// a session cannot be restarted from outside: when the hub names a newer ach, say so once (see selfupdate.ts)
|
|
476
|
+
const stale = startStaleNotice({
|
|
477
|
+
fetchVersions: () => client.request('GET', '/api/ach/auth/config'),
|
|
478
|
+
deliver: sessionDeliverer(server, env, o.exec),
|
|
479
|
+
lang: isZh(env) ? 'zh' : 'en',
|
|
480
|
+
firstMs: o.staleFirstMs,
|
|
481
|
+
log: o.log,
|
|
482
|
+
});
|
|
474
483
|
return {
|
|
475
484
|
notifier,
|
|
476
485
|
feed,
|
|
477
486
|
done,
|
|
487
|
+
stale,
|
|
478
488
|
stop() {
|
|
479
489
|
feed.stop();
|
|
480
490
|
notifier.stop();
|
|
491
|
+
stale.stop();
|
|
481
492
|
},
|
|
482
493
|
};
|
|
483
494
|
}
|
|
@@ -15,6 +15,8 @@ export interface RunnerOptions {
|
|
|
15
15
|
max?: number;
|
|
16
16
|
perm?: Perm;
|
|
17
17
|
intervalMs?: number;
|
|
18
|
+
/** The hub answered 426 client_too_old: this release is below its floor (the updater checks at once). */
|
|
19
|
+
onClientTooOld?: () => void;
|
|
18
20
|
heartbeatMs?: number;
|
|
19
21
|
idleMs?: number;
|
|
20
22
|
cfId?: string;
|
|
@@ -129,8 +131,11 @@ export declare class Runner {
|
|
|
129
131
|
me?: Agent;
|
|
130
132
|
private o;
|
|
131
133
|
private stopped;
|
|
134
|
+
/** quiesce(): the runner takes nothing new (a CLI update is waiting for the runs in flight). */
|
|
135
|
+
private paused;
|
|
132
136
|
private waiters;
|
|
133
137
|
private lastPrune;
|
|
138
|
+
private lastTooOld;
|
|
134
139
|
/** Last heartbeat sent for each parked run. */
|
|
135
140
|
private parkedHb;
|
|
136
141
|
/** B7: set when the hub answered 401 (key rotated, agent or owner deactivated): the runner stops. */
|
|
@@ -162,6 +167,11 @@ export declare class Runner {
|
|
|
162
167
|
/** C8: drop runs.json bookkeeping for terminal (or vanished) tasks, every 10 minutes. */
|
|
163
168
|
prune(force?: boolean): Promise<void>;
|
|
164
169
|
pump(): void;
|
|
170
|
+
/** Stop taking new work and resolve when nothing is queued or running: what a CLI update waits for. Parked runs stay parked (the hub keeps their replies). */
|
|
171
|
+
quiesce(): Promise<void>;
|
|
172
|
+
unquiesce(): void;
|
|
173
|
+
/** Runs in flight plus queued. */
|
|
174
|
+
busy(): number;
|
|
165
175
|
/** Resolves when nothing is queued or running (tests). */
|
|
166
176
|
drain(): Promise<void>;
|
|
167
177
|
private runJob;
|
|
@@ -197,8 +197,11 @@ export class Runner {
|
|
|
197
197
|
me;
|
|
198
198
|
o;
|
|
199
199
|
stopped = false;
|
|
200
|
+
/** quiesce(): the runner takes nothing new (a CLI update is waiting for the runs in flight). */
|
|
201
|
+
paused = false;
|
|
200
202
|
waiters = [];
|
|
201
203
|
lastPrune = 0;
|
|
204
|
+
lastTooOld = 0;
|
|
202
205
|
/** Last heartbeat sent for each parked run. */
|
|
203
206
|
parkedHb = new Map();
|
|
204
207
|
/** B7: set when the hub answered 401 (key rotated, agent or owner deactivated): the runner stops. */
|
|
@@ -280,6 +283,8 @@ export class Runner {
|
|
|
280
283
|
}
|
|
281
284
|
await this.checkActive();
|
|
282
285
|
await this.prune();
|
|
286
|
+
if (this.paused)
|
|
287
|
+
return; // heartbeats of parked runs went out in checkActive; nothing new is taken (it waits in the hub's inbox)
|
|
283
288
|
if (!this.me || inbox.tasks_to_me.length)
|
|
284
289
|
this.me = await this.o.client.me();
|
|
285
290
|
const me = this.me;
|
|
@@ -389,6 +394,8 @@ export class Runner {
|
|
|
389
394
|
this.parkedHb.set(rec.taskId, now);
|
|
390
395
|
await this.heartbeat(rec).catch((e) => this.log(`heartbeat ${rec.taskId}: ${e?.message ?? e}`));
|
|
391
396
|
}
|
|
397
|
+
if (this.paused)
|
|
398
|
+
continue; // quiesced: a parked run resumes after the update (the hub keeps its replies)
|
|
392
399
|
const me0 = await this.ensureMe();
|
|
393
400
|
// A new handoff version reaches a parked worker at once, even while it
|
|
394
401
|
// waits for an answer: the change may settle or replace its question.
|
|
@@ -477,6 +484,19 @@ export class Runner {
|
|
|
477
484
|
});
|
|
478
485
|
}
|
|
479
486
|
}
|
|
487
|
+
/** Stop taking new work and resolve when nothing is queued or running: what a CLI update waits for. Parked runs stay parked (the hub keeps their replies). */
|
|
488
|
+
async quiesce() {
|
|
489
|
+
this.paused = true;
|
|
490
|
+
await this.drain();
|
|
491
|
+
}
|
|
492
|
+
unquiesce() {
|
|
493
|
+
this.paused = false;
|
|
494
|
+
this.wake?.();
|
|
495
|
+
}
|
|
496
|
+
/** Runs in flight plus queued. */
|
|
497
|
+
busy() {
|
|
498
|
+
return this.active.size + this.queue.length;
|
|
499
|
+
}
|
|
480
500
|
/** Resolves when nothing is queued or running (tests). */
|
|
481
501
|
async drain() {
|
|
482
502
|
if (!this.active.size && !this.queue.length)
|
|
@@ -1089,16 +1109,27 @@ export class Runner {
|
|
|
1089
1109
|
this.recover();
|
|
1090
1110
|
this.pump();
|
|
1091
1111
|
while (!this.stopped) {
|
|
1112
|
+
let wait = this.o.intervalMs;
|
|
1092
1113
|
try {
|
|
1093
1114
|
await this.tick();
|
|
1094
1115
|
}
|
|
1095
1116
|
catch (e) {
|
|
1096
|
-
|
|
1117
|
+
if (e instanceof HubError && e.code === 'client_too_old') {
|
|
1118
|
+
// the hub refuses this release: poll slowly, say it now and then, and let the updater try at once
|
|
1119
|
+
wait = Math.max(wait, 30_000);
|
|
1120
|
+
if (Date.now() - this.lastTooOld > 10 * 60_000) {
|
|
1121
|
+
this.lastTooOld = Date.now();
|
|
1122
|
+
this.log(`the hub refuses this ach: ${e.message}`);
|
|
1123
|
+
}
|
|
1124
|
+
this.o.onClientTooOld?.();
|
|
1125
|
+
}
|
|
1126
|
+
else
|
|
1127
|
+
this.log(`poll: ${e?.message ?? e}`);
|
|
1097
1128
|
}
|
|
1098
1129
|
if (this.stopped)
|
|
1099
1130
|
break;
|
|
1100
1131
|
await new Promise((r) => {
|
|
1101
|
-
const t = setTimeout(r,
|
|
1132
|
+
const t = setTimeout(r, wait);
|
|
1102
1133
|
this.wake = () => {
|
|
1103
1134
|
clearTimeout(t);
|
|
1104
1135
|
r();
|
|
@@ -1114,6 +1145,7 @@ export class Runner {
|
|
|
1114
1145
|
/** Stop polling; abort running jobs without failing their tasks (they resume on restart). */
|
|
1115
1146
|
async stop() {
|
|
1116
1147
|
this.stopped = true;
|
|
1148
|
+
this.wake?.(); // start() is asleep between polls: let it end now (a slow poll after a 426 would hold the exit)
|
|
1117
1149
|
for (const a of this.active.values()) {
|
|
1118
1150
|
a.reason = a.reason ?? 'stop';
|
|
1119
1151
|
a.controller.abort();
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** A restart-worthy exit: not 0 (systemd on-failure and launchd restart only a failure) and not 78 (key refused: do not restart). */
|
|
2
|
+
export declare const UPDATE_EXIT_CODE = 75;
|
|
3
|
+
export interface HubVersions {
|
|
4
|
+
latest_client?: string;
|
|
5
|
+
min_client_proto?: number;
|
|
6
|
+
}
|
|
7
|
+
export type Verdict = {
|
|
8
|
+
action: 'none';
|
|
9
|
+
}
|
|
10
|
+
/** Safe to install by itself. */
|
|
11
|
+
| {
|
|
12
|
+
action: 'update';
|
|
13
|
+
target: string;
|
|
14
|
+
}
|
|
15
|
+
/** Needs a person: `why` says what to do. */
|
|
16
|
+
| {
|
|
17
|
+
action: 'manual';
|
|
18
|
+
target: string;
|
|
19
|
+
why: string;
|
|
20
|
+
};
|
|
21
|
+
/** What to do about the release the hub names. Pure: no I/O. */
|
|
22
|
+
export declare function judge(hub: HubVersions | null | undefined, current?: string, proto?: number): Verdict;
|
|
23
|
+
export type ExecFn = (file: string, args: string[], o?: {
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}) => Promise<{
|
|
26
|
+
code: number;
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
}>;
|
|
30
|
+
export declare const defaultInstallExec: ExecFn;
|
|
31
|
+
export type InstallKind =
|
|
32
|
+
/** Started from the npx cache with a range spec (`@koalaauto/agentchannel@3`): a restart fetches the newest release. */
|
|
33
|
+
{
|
|
34
|
+
kind: 'npx';
|
|
35
|
+
}
|
|
36
|
+
/** A global npm install: `npm install -g`. `dir` is where the package lives. */
|
|
37
|
+
| {
|
|
38
|
+
kind: 'global';
|
|
39
|
+
dir: string;
|
|
40
|
+
}
|
|
41
|
+
/** Source checkout, `npm link`, a project dependency, or anything else: not ours to replace. */
|
|
42
|
+
| {
|
|
43
|
+
kind: 'other';
|
|
44
|
+
why: string;
|
|
45
|
+
};
|
|
46
|
+
/** How this CLI was installed, so that the right update is used (or none). */
|
|
47
|
+
export declare function detectInstall(o?: {
|
|
48
|
+
self?: string;
|
|
49
|
+
exec?: ExecFn;
|
|
50
|
+
version?: string;
|
|
51
|
+
}): Promise<InstallKind>;
|
|
52
|
+
/**
|
|
53
|
+
* Before the runner stops taking work: the release exists on npm, and a global install can be written. Nothing has been paused or
|
|
54
|
+
* recorded yet, so a failure here costs nothing and is simply tried again at the next check.
|
|
55
|
+
*/
|
|
56
|
+
export declare function preflight(kind: InstallKind, target: string, exec?: ExecFn): Promise<{
|
|
57
|
+
ok: boolean;
|
|
58
|
+
detail: string;
|
|
59
|
+
}>;
|
|
60
|
+
/** Install the release. npx needs nothing now (the restart fetches it). A global install is `npm install -g`, scripts off, then read back. */
|
|
61
|
+
export declare function installRelease(kind: InstallKind, target: string, exec?: ExecFn): Promise<{
|
|
62
|
+
ok: boolean;
|
|
63
|
+
detail: string;
|
|
64
|
+
}>;
|
|
65
|
+
interface UpdateState {
|
|
66
|
+
target: string;
|
|
67
|
+
/** ms since epoch of the last attempt. */
|
|
68
|
+
at: number;
|
|
69
|
+
attempts: number;
|
|
70
|
+
}
|
|
71
|
+
export declare function loadUpdateState(home: string): UpdateState | null;
|
|
72
|
+
/** The runner as the updater needs it. */
|
|
73
|
+
export interface Quiescable {
|
|
74
|
+
/** Stop taking new work and resolve when nothing is queued or running. */
|
|
75
|
+
quiesce(): Promise<void>;
|
|
76
|
+
/** Take work again (the update did not happen). */
|
|
77
|
+
unquiesce(): void;
|
|
78
|
+
/** Runs in flight + queued, for the log. */
|
|
79
|
+
busy(): number;
|
|
80
|
+
stop(): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
export type CheckResult = 'none' | 'manual' | 'off' | 'backoff' | 'busy' | 'failed' | 'restarting';
|
|
83
|
+
export interface SelfUpdaterOptions {
|
|
84
|
+
fetchVersions: () => Promise<HubVersions | null>;
|
|
85
|
+
runner: Quiescable;
|
|
86
|
+
home: string;
|
|
87
|
+
env?: NodeJS.ProcessEnv;
|
|
88
|
+
log?: (s: string) => void;
|
|
89
|
+
exec?: ExecFn;
|
|
90
|
+
self?: string;
|
|
91
|
+
version?: string;
|
|
92
|
+
proto?: number;
|
|
93
|
+
now?: () => number;
|
|
94
|
+
/** First check this long after start (default 20 s) and then every 6 h (±10 %). */
|
|
95
|
+
firstMs?: number;
|
|
96
|
+
everyMs?: number;
|
|
97
|
+
}
|
|
98
|
+
export declare class SelfUpdater {
|
|
99
|
+
private o;
|
|
100
|
+
/** True once the install is done and the runner is stopping: the process should exit UPDATE_EXIT_CODE. */
|
|
101
|
+
restarting: boolean;
|
|
102
|
+
private timer?;
|
|
103
|
+
private stopped;
|
|
104
|
+
private lastNudge;
|
|
105
|
+
private working;
|
|
106
|
+
private told;
|
|
107
|
+
constructor(o: SelfUpdaterOptions);
|
|
108
|
+
private log;
|
|
109
|
+
/** Say a thing once per process (a check every few hours must not repeat itself). */
|
|
110
|
+
private once;
|
|
111
|
+
start(): void;
|
|
112
|
+
stop(): void;
|
|
113
|
+
/** The hub just refused this release (426): check now, but not more than once in 10 minutes (the runner polls again every 30 s). */
|
|
114
|
+
nudge(): Promise<CheckResult> | undefined;
|
|
115
|
+
check(): Promise<CheckResult>;
|
|
116
|
+
}
|
|
117
|
+
export declare function staleText(latest: string, lang?: 'en' | 'zh', current?: string): string;
|
|
118
|
+
/**
|
|
119
|
+
* One line into the open session when the hub names a newer CLI (once per release per process, checked every 6 h). A session
|
|
120
|
+
* cannot be restarted from outside, so this is all there is to do for it. Returns stop().
|
|
121
|
+
*/
|
|
122
|
+
export declare function startStaleNotice(o: {
|
|
123
|
+
fetchVersions: () => Promise<HubVersions | null>;
|
|
124
|
+
deliver: (text: string) => Promise<boolean>;
|
|
125
|
+
lang?: 'en' | 'zh';
|
|
126
|
+
version?: string;
|
|
127
|
+
proto?: number;
|
|
128
|
+
firstMs?: number;
|
|
129
|
+
everyMs?: number;
|
|
130
|
+
log?: (s: string) => void;
|
|
131
|
+
}): {
|
|
132
|
+
check(): Promise<boolean>;
|
|
133
|
+
stop(): void;
|
|
134
|
+
};
|
|
135
|
+
export {};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Keeping a running `ach` current. Two jobs:
|
|
2
|
+
//
|
|
3
|
+
// - the runner (`ach run`, a long-lived daemon under systemd / launchd): SelfUpdater asks the hub which CLI release is the
|
|
4
|
+
// newest, and when this one is behind it stops taking new work, waits for the runs in flight, installs the release (a
|
|
5
|
+
// global install) or relies on npx to fetch it (a service started from the npx cache), and exits with UPDATE_EXIT_CODE.
|
|
6
|
+
// systemd (Restart=on-failure) and launchd (KeepAlive/SuccessfulExit=false) restart a non-zero exit, so the service comes
|
|
7
|
+
// back on the new release. Parked runs and queued answers live in the hub and the runner's state file: nothing is lost.
|
|
8
|
+
// - an MCP session (`ach mcp`, started by Claude Code or Codex): it cannot be restarted from outside, so startStaleNotice
|
|
9
|
+
// pushes one line into the session saying it is behind.
|
|
10
|
+
//
|
|
11
|
+
// What it never does: move across a major release by itself (the protocol may have changed: a person decides), move a stable
|
|
12
|
+
// install onto a prerelease, touch a CLI it cannot tell how it was installed, or retry a failed update more than once a day.
|
|
13
|
+
// ACH_AUTO_UPDATE=0 turns the installing off (the log and the session notice remain).
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, sep } from 'node:path';
|
|
17
|
+
import { thisCliPath } from './invite.js';
|
|
18
|
+
import { CLIENT_PROTO, newerVersion, PKG_NAME, VERSION } from './version.js';
|
|
19
|
+
/** A restart-worthy exit: not 0 (systemd on-failure and launchd restart only a failure) and not 78 (key refused: do not restart). */
|
|
20
|
+
export const UPDATE_EXIT_CODE = 75;
|
|
21
|
+
const DAY_MS = 24 * 3600_000;
|
|
22
|
+
const CHECK_EVERY_MS = 6 * 3600_000;
|
|
23
|
+
const isVersion = (v) => typeof v === 'string' && /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(v);
|
|
24
|
+
const major = (v) => Number(v.split('.')[0]);
|
|
25
|
+
/** What to do about the release the hub names. Pure: no I/O. */
|
|
26
|
+
export function judge(hub, current = VERSION, proto = CLIENT_PROTO) {
|
|
27
|
+
const target = hub?.latest_client;
|
|
28
|
+
const needs = Number(hub?.min_client_proto) > proto;
|
|
29
|
+
if (!isVersion(target) || !newerVersion(target, current)) {
|
|
30
|
+
return needs ? { action: 'manual', target: '', why: `the hub needs protocol ${hub.min_client_proto} or newer, but names no newer release: ask the hub's admin` } : { action: 'none' };
|
|
31
|
+
}
|
|
32
|
+
if (major(target) !== major(current))
|
|
33
|
+
return { action: 'manual', target, why: `${target} is a new major release: update it yourself (npm install -g ${PKG_NAME}@${target}), then restart` };
|
|
34
|
+
if (target.includes('-') && !current.includes('-')) {
|
|
35
|
+
// the hub runs a prerelease; a stable install stays on stable, unless the hub's floor leaves it no choice (then a person decides)
|
|
36
|
+
return needs ? { action: 'manual', target, why: `the hub needs protocol ${hub.min_client_proto} or newer and runs the prerelease ${target}: ask the hub's admin for a stable release, or install ${PKG_NAME}@${target} yourself` } : { action: 'none' };
|
|
37
|
+
}
|
|
38
|
+
return { action: 'update', target };
|
|
39
|
+
}
|
|
40
|
+
/** `npm` as a service can find it: the one beside this node (a version manager's bin dir is often not in a service's PATH), else PATH's. */
|
|
41
|
+
function resolveBin(file) {
|
|
42
|
+
if (file !== 'npm')
|
|
43
|
+
return file;
|
|
44
|
+
const beside = join(dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
45
|
+
return existsSync(beside) ? beside : file;
|
|
46
|
+
}
|
|
47
|
+
export const defaultInstallExec = (file, args, o) => new Promise((res) => {
|
|
48
|
+
execFile(resolveBin(file), args, { timeout: o?.timeoutMs ?? 30_000, encoding: 'utf8', maxBuffer: 4 << 20 }, (err, stdout, stderr) => {
|
|
49
|
+
res({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
const real = (p) => {
|
|
53
|
+
try {
|
|
54
|
+
return realpathSync(p);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/** How this CLI was installed, so that the right update is used (or none). */
|
|
61
|
+
export async function detectInstall(o = {}) {
|
|
62
|
+
const self = real(o.self ?? thisCliPath());
|
|
63
|
+
if (/[\\/]_npx[\\/]/.test(self)) {
|
|
64
|
+
// `...@3` re-resolves on a restart; an exact prerelease spec (`...@3.5.0-rc.1`) never moves
|
|
65
|
+
return (o.version ?? VERSION).includes('-') ? { kind: 'other', why: 'started with npx from an exact prerelease version: run the join line (or `ach run --install-service`) again with the new release' } : { kind: 'npx' };
|
|
66
|
+
}
|
|
67
|
+
const exec = o.exec ?? defaultInstallExec;
|
|
68
|
+
const root = await exec('npm', ['root', '-g']);
|
|
69
|
+
if (root.code !== 0 || !root.stdout.trim())
|
|
70
|
+
return { kind: 'other', why: 'npm is not available to update with' };
|
|
71
|
+
const dir = real(join(root.stdout.trim(), ...PKG_NAME.split('/')));
|
|
72
|
+
return self === dir || self.startsWith(dir + sep) ? { kind: 'global', dir } : { kind: 'other', why: `${self} is not a global npm install (a checkout, a link or a project dependency)` };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Before the runner stops taking work: the release exists on npm, and a global install can be written. Nothing has been paused or
|
|
76
|
+
* recorded yet, so a failure here costs nothing and is simply tried again at the next check.
|
|
77
|
+
*/
|
|
78
|
+
export async function preflight(kind, target, exec = defaultInstallExec) {
|
|
79
|
+
if (kind.kind === 'other')
|
|
80
|
+
return { ok: false, detail: kind.why };
|
|
81
|
+
const v = await exec('npm', ['view', `${PKG_NAME}@${target}`, 'version'], { timeoutMs: 30_000 });
|
|
82
|
+
if (v.code !== 0 || !v.stdout.split(/\s+/).includes(target))
|
|
83
|
+
return { ok: false, detail: `${PKG_NAME}@${target} is not on npm (yet), or npm cannot reach it` };
|
|
84
|
+
if (kind.kind === 'global') {
|
|
85
|
+
try {
|
|
86
|
+
accessSync(dirname(kind.dir), constants.W_OK); // <npm root -g>/@koalaauto: where npm replaces the package
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { ok: false, detail: `no permission to write ${dirname(kind.dir)}: run npm install -g ${PKG_NAME}@${target} with the right permissions` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, detail: 'ready' };
|
|
93
|
+
}
|
|
94
|
+
/** Install the release. npx needs nothing now (the restart fetches it). A global install is `npm install -g`, scripts off, then read back. */
|
|
95
|
+
export async function installRelease(kind, target, exec = defaultInstallExec) {
|
|
96
|
+
if (kind.kind === 'other')
|
|
97
|
+
return { ok: false, detail: kind.why };
|
|
98
|
+
if (kind.kind === 'npx')
|
|
99
|
+
return { ok: true, detail: 'npx fetches it on restart' };
|
|
100
|
+
const r = await exec('npm', ['install', '-g', '--ignore-scripts', '--no-audit', '--no-fund', `${PKG_NAME}@${target}`], { timeoutMs: 5 * 60_000 });
|
|
101
|
+
if (r.code !== 0)
|
|
102
|
+
return { ok: false, detail: `npm install -g ${PKG_NAME}@${target} failed (${(r.stderr || r.stdout).trim().split('\n').slice(-3).join(' | ').slice(0, 300)})` };
|
|
103
|
+
let got = '';
|
|
104
|
+
try {
|
|
105
|
+
got = JSON.parse(readFileSync(join(kind.dir, 'package.json'), 'utf8')).version;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* read below */
|
|
109
|
+
}
|
|
110
|
+
return got === target ? { ok: true, detail: `installed ${target}` } : { ok: false, detail: `npm said it installed ${target}, but ${kind.dir} has ${got || 'no package.json'}` };
|
|
111
|
+
}
|
|
112
|
+
const stateFile = (home) => join(home, 'update.json');
|
|
113
|
+
export function loadUpdateState(home) {
|
|
114
|
+
try {
|
|
115
|
+
const s = JSON.parse(readFileSync(stateFile(home), 'utf8'));
|
|
116
|
+
return isVersion(s?.target) && Number.isFinite(s?.at) ? { target: s.target, at: s.at, attempts: Number(s.attempts) || 1 } : null;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function saveUpdateState(home, s) {
|
|
123
|
+
try {
|
|
124
|
+
if (!existsSync(home))
|
|
125
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
126
|
+
writeFileSync(stateFile(home), s ? JSON.stringify(s) : '{}', { mode: 0o600 });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* the back-off is a courtesy; a read-only home just means a retry sooner */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export class SelfUpdater {
|
|
133
|
+
o;
|
|
134
|
+
/** True once the install is done and the runner is stopping: the process should exit UPDATE_EXIT_CODE. */
|
|
135
|
+
restarting = false;
|
|
136
|
+
timer;
|
|
137
|
+
stopped = false;
|
|
138
|
+
lastNudge = 0;
|
|
139
|
+
working = false;
|
|
140
|
+
told = new Set();
|
|
141
|
+
constructor(o) {
|
|
142
|
+
this.o = o;
|
|
143
|
+
}
|
|
144
|
+
log(s) {
|
|
145
|
+
(this.o.log ?? ((x) => console.error(x)))(`[ach update] ${s}`);
|
|
146
|
+
}
|
|
147
|
+
/** Say a thing once per process (a check every few hours must not repeat itself). */
|
|
148
|
+
once(key, s) {
|
|
149
|
+
if (this.told.has(key))
|
|
150
|
+
return;
|
|
151
|
+
this.told.add(key);
|
|
152
|
+
this.log(s);
|
|
153
|
+
}
|
|
154
|
+
start() {
|
|
155
|
+
const every = this.o.everyMs ?? CHECK_EVERY_MS;
|
|
156
|
+
const next = (ms) => {
|
|
157
|
+
if (this.stopped)
|
|
158
|
+
return;
|
|
159
|
+
this.timer = setTimeout(() => void this.check().finally(() => next(every * (0.9 + Math.random() * 0.2))), ms);
|
|
160
|
+
this.timer.unref?.();
|
|
161
|
+
};
|
|
162
|
+
next(this.o.firstMs ?? 20_000);
|
|
163
|
+
}
|
|
164
|
+
stop() {
|
|
165
|
+
this.stopped = true;
|
|
166
|
+
if (this.timer)
|
|
167
|
+
clearTimeout(this.timer);
|
|
168
|
+
this.timer = undefined;
|
|
169
|
+
}
|
|
170
|
+
/** The hub just refused this release (426): check now, but not more than once in 10 minutes (the runner polls again every 30 s). */
|
|
171
|
+
nudge() {
|
|
172
|
+
const now = (this.o.now ?? Date.now)();
|
|
173
|
+
if (this.stopped || now - this.lastNudge < 10 * 60_000)
|
|
174
|
+
return undefined;
|
|
175
|
+
this.lastNudge = now;
|
|
176
|
+
return this.check();
|
|
177
|
+
}
|
|
178
|
+
async check() {
|
|
179
|
+
if (this.working || this.restarting)
|
|
180
|
+
return 'busy';
|
|
181
|
+
const version = this.o.version ?? VERSION;
|
|
182
|
+
const now = (this.o.now ?? Date.now)();
|
|
183
|
+
let hub = null;
|
|
184
|
+
try {
|
|
185
|
+
hub = await this.o.fetchVersions();
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return 'none'; // offline or an old hub: nothing to act on
|
|
189
|
+
}
|
|
190
|
+
const prior = loadUpdateState(this.o.home);
|
|
191
|
+
if (prior && prior.target === version)
|
|
192
|
+
saveUpdateState(this.o.home, null); // it worked: forget the back-off
|
|
193
|
+
const v = judge(hub, version, this.o.proto);
|
|
194
|
+
if (v.action === 'none')
|
|
195
|
+
return 'none';
|
|
196
|
+
if (v.action === 'manual') {
|
|
197
|
+
this.once(`manual:${v.target}`, `${v.why}`);
|
|
198
|
+
return 'manual';
|
|
199
|
+
}
|
|
200
|
+
if ((this.o.env ?? process.env).ACH_AUTO_UPDATE === '0') {
|
|
201
|
+
this.once(`off:${v.target}`, `ach ${v.target} is out (this is ${version}); ACH_AUTO_UPDATE=0, so it is not installed: npm install -g ${PKG_NAME}@${v.target}`);
|
|
202
|
+
return 'off';
|
|
203
|
+
}
|
|
204
|
+
// an attempt that did not end on this release (npx served a cached one, npm refused) is not repeated for a day
|
|
205
|
+
const last = prior && prior.target === v.target ? prior : null;
|
|
206
|
+
if (last && now - last.at < DAY_MS) {
|
|
207
|
+
this.once(`backoff:${v.target}`, `ach ${v.target} is out but the last try ${new Date(last.at).toISOString()} did not end on it; trying again after a day`);
|
|
208
|
+
return 'backoff';
|
|
209
|
+
}
|
|
210
|
+
const kind = await detectInstall({ self: this.o.self, exec: this.o.exec, version });
|
|
211
|
+
if (kind.kind === 'other') {
|
|
212
|
+
this.once(`other:${v.target}`, `ach ${v.target} is out (this is ${version}) and cannot be installed by itself: ${kind.why}`);
|
|
213
|
+
return 'manual';
|
|
214
|
+
}
|
|
215
|
+
const ready = await preflight(kind, v.target, this.o.exec);
|
|
216
|
+
if (!ready.ok) {
|
|
217
|
+
this.once(`preflight:${v.target}`, `ach ${v.target} is out (this is ${version}) but cannot be installed now: ${ready.detail}; nothing was paused, trying again later`);
|
|
218
|
+
return 'failed';
|
|
219
|
+
}
|
|
220
|
+
this.working = true;
|
|
221
|
+
try {
|
|
222
|
+
this.log(`ach ${v.target} is out (this is ${version}): taking no new work, ${this.o.runner.busy()} run(s) to finish first`);
|
|
223
|
+
await this.o.runner.quiesce();
|
|
224
|
+
saveUpdateState(this.o.home, { target: v.target, at: now, attempts: (last?.attempts ?? 0) + 1 });
|
|
225
|
+
const r = await installRelease(kind, v.target, this.o.exec);
|
|
226
|
+
if (!r.ok) {
|
|
227
|
+
this.log(`update to ${v.target} failed: ${r.detail}; staying on ${version}`);
|
|
228
|
+
this.o.runner.unquiesce();
|
|
229
|
+
return 'failed';
|
|
230
|
+
}
|
|
231
|
+
this.log(`${r.detail}; restarting into ${v.target}`);
|
|
232
|
+
this.restarting = true;
|
|
233
|
+
await this.o.runner.stop();
|
|
234
|
+
return 'restarting';
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
this.working = false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// ---- the MCP session -------------------------------------------------------------------------------------------------------
|
|
242
|
+
export function staleText(latest, lang = 'en', current = VERSION) {
|
|
243
|
+
return lang === 'zh'
|
|
244
|
+
? `AgentChannel:本机的 ach ${current} 已过期(你的 hub 是 ${latest})。请告诉你的用户:重启这个会话;若 ach 是全局安装的,先运行 npm install -g ${PKG_NAME}@${latest}`
|
|
245
|
+
: `AgentChannel: the ach in this session is ${current}, out of date (your hub is on ${latest}). Tell your user to restart this session; if ach is installed globally, first run: npm install -g ${PKG_NAME}@${latest}`;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* One line into the open session when the hub names a newer CLI (once per release per process, checked every 6 h). A session
|
|
249
|
+
* cannot be restarted from outside, so this is all there is to do for it. Returns stop().
|
|
250
|
+
*/
|
|
251
|
+
export function startStaleNotice(o) {
|
|
252
|
+
const told = new Set();
|
|
253
|
+
let timer;
|
|
254
|
+
let stopped = false;
|
|
255
|
+
const check = async () => {
|
|
256
|
+
try {
|
|
257
|
+
const hub = await o.fetchVersions();
|
|
258
|
+
const v = judge(hub, o.version, o.proto);
|
|
259
|
+
const target = v.action === 'none' ? '' : v.target || (hub?.latest_client ?? '');
|
|
260
|
+
if (!target || told.has(target))
|
|
261
|
+
return false;
|
|
262
|
+
const ok = await o.deliver(staleText(target, o.lang, o.version));
|
|
263
|
+
if (ok)
|
|
264
|
+
told.add(target);
|
|
265
|
+
return ok;
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
o.log?.(`stale check: ${e?.message ?? e}`);
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
const next = (ms) => {
|
|
273
|
+
if (stopped)
|
|
274
|
+
return;
|
|
275
|
+
timer = setTimeout(() => void check().finally(() => next((o.everyMs ?? CHECK_EVERY_MS) * (0.9 + Math.random() * 0.2))), ms);
|
|
276
|
+
timer.unref?.();
|
|
277
|
+
};
|
|
278
|
+
next(o.firstMs ?? 15_000);
|
|
279
|
+
return {
|
|
280
|
+
check,
|
|
281
|
+
stop() {
|
|
282
|
+
stopped = true;
|
|
283
|
+
if (timer)
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
export declare const VERSION = "3.
|
|
1
|
+
export declare const VERSION = "3.6.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";
|
|
4
|
+
/**
|
|
5
|
+
* What this CLI says about itself on every request (x-ach-proto). The hub's ACH_MIN_CLIENT_PROTO refuses a CLI below its
|
|
6
|
+
* floor. Raise this only when the CLI starts to need something an older hub does not have, and a hub only when it can no
|
|
7
|
+
* longer serve the older CLIs: docs/api/README.md, "Client compatibility". 1 = the CLI announces itself (3.5).
|
|
8
|
+
*/
|
|
9
|
+
export declare const CLIENT_PROTO = 1;
|
|
10
|
+
/** True when version a is newer than b (x.y.z, a release beats its own prereleases; prerelease parts compared by number when numeric). */
|
|
11
|
+
export declare function newerVersion(a: string, b: string): boolean;
|
package/dist/src/version.js
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
export const VERSION = '3.
|
|
1
|
+
export const VERSION = '3.6.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';
|
|
4
|
+
/**
|
|
5
|
+
* What this CLI says about itself on every request (x-ach-proto). The hub's ACH_MIN_CLIENT_PROTO refuses a CLI below its
|
|
6
|
+
* floor. Raise this only when the CLI starts to need something an older hub does not have, and a hub only when it can no
|
|
7
|
+
* longer serve the older CLIs: docs/api/README.md, "Client compatibility". 1 = the CLI announces itself (3.5).
|
|
8
|
+
*/
|
|
9
|
+
export const CLIENT_PROTO = 1;
|
|
10
|
+
/** True when version a is newer than b (x.y.z, a release beats its own prereleases; prerelease parts compared by number when numeric). */
|
|
11
|
+
export function newerVersion(a, b) {
|
|
12
|
+
const parse = (v) => {
|
|
13
|
+
const [core, pre = ''] = v.split('-', 2);
|
|
14
|
+
return { n: core.split('.').map((x) => Number(x) || 0), pre: pre ? pre.split('.') : [] };
|
|
15
|
+
};
|
|
16
|
+
const x = parse(a);
|
|
17
|
+
const y = parse(b);
|
|
18
|
+
for (let i = 0; i < 3; i++)
|
|
19
|
+
if (x.n[i] !== y.n[i])
|
|
20
|
+
return x.n[i] > y.n[i];
|
|
21
|
+
if (!x.pre.length || !y.pre.length)
|
|
22
|
+
return !x.pre.length && !!y.pre.length;
|
|
23
|
+
for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
|
|
24
|
+
const p = x.pre[i];
|
|
25
|
+
const q = y.pre[i];
|
|
26
|
+
if (p === undefined)
|
|
27
|
+
return false;
|
|
28
|
+
if (q === undefined)
|
|
29
|
+
return true;
|
|
30
|
+
if (p !== q)
|
|
31
|
+
return /^\d+$/.test(p) && /^\d+$/.test(q) ? Number(p) > Number(q) : p > q;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koalaauto/agentchannel",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.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",
|