@koalaauto/agentchannel 0.0.0-stage → 3.0.0-rc.1

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.
Files changed (71) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +154 -2
  3. package/dist/src/adapters/claude.d.ts +65 -0
  4. package/dist/src/adapters/claude.js +243 -0
  5. package/dist/src/adapters/codex.d.ts +74 -0
  6. package/dist/src/adapters/codex.js +237 -0
  7. package/dist/src/adapters/fake.d.ts +65 -0
  8. package/dist/src/adapters/fake.js +105 -0
  9. package/dist/src/adapters/index.d.ts +11 -0
  10. package/dist/src/adapters/index.js +26 -0
  11. package/dist/src/adapters/paths.d.ts +35 -0
  12. package/dist/src/adapters/paths.js +113 -0
  13. package/dist/src/adapters/procgroup.d.ts +14 -0
  14. package/dist/src/adapters/procgroup.js +49 -0
  15. package/dist/src/adapters/types.d.ts +57 -0
  16. package/dist/src/adapters/types.js +1 -0
  17. package/dist/src/args.d.ts +14 -0
  18. package/dist/src/args.js +257 -0
  19. package/dist/src/attachments.d.ts +63 -0
  20. package/dist/src/attachments.js +227 -0
  21. package/dist/src/attention.d.ts +85 -0
  22. package/dist/src/attention.js +262 -0
  23. package/dist/src/avatars.d.ts +40 -0
  24. package/dist/src/avatars.js +90 -0
  25. package/dist/src/cli.d.ts +45 -0
  26. package/dist/src/cli.js +1822 -0
  27. package/dist/src/client.d.ts +520 -0
  28. package/dist/src/client.js +449 -0
  29. package/dist/src/config.d.ts +62 -0
  30. package/dist/src/config.js +121 -0
  31. package/dist/src/desknotify.d.ts +154 -0
  32. package/dist/src/desknotify.js +426 -0
  33. package/dist/src/doctor.d.ts +75 -0
  34. package/dist/src/doctor.js +375 -0
  35. package/dist/src/env.d.ts +31 -0
  36. package/dist/src/env.js +52 -0
  37. package/dist/src/import.d.ts +245 -0
  38. package/dist/src/import.js +1008 -0
  39. package/dist/src/index.d.ts +13 -0
  40. package/dist/src/index.js +13 -0
  41. package/dist/src/invite.d.ts +103 -0
  42. package/dist/src/invite.js +229 -0
  43. package/dist/src/invite_person.d.ts +16 -0
  44. package/dist/src/invite_person.js +75 -0
  45. package/dist/src/keystore.d.ts +55 -0
  46. package/dist/src/keystore.js +108 -0
  47. package/dist/src/mcp.d.ts +62 -0
  48. package/dist/src/mcp.js +503 -0
  49. package/dist/src/names.d.ts +30 -0
  50. package/dist/src/names.js +108 -0
  51. package/dist/src/netproxy.d.ts +20 -0
  52. package/dist/src/netproxy.js +36 -0
  53. package/dist/src/onboard.d.ts +105 -0
  54. package/dist/src/onboard.js +270 -0
  55. package/dist/src/perm.d.ts +12 -0
  56. package/dist/src/perm.js +20 -0
  57. package/dist/src/prompts.d.ts +166 -0
  58. package/dist/src/prompts.js +409 -0
  59. package/dist/src/runner/runner.d.ts +212 -0
  60. package/dist/src/runner/runner.js +1148 -0
  61. package/dist/src/runner/state.d.ts +68 -0
  62. package/dist/src/runner/state.js +80 -0
  63. package/dist/src/runner/workspace.d.ts +70 -0
  64. package/dist/src/runner/workspace.js +265 -0
  65. package/dist/src/service.d.ts +29 -0
  66. package/dist/src/service.js +101 -0
  67. package/dist/src/version.d.ts +3 -0
  68. package/dist/src/version.js +3 -0
  69. package/dist/src/wiki.d.ts +74 -0
  70. package/dist/src/wiki.js +222 -0
  71. package/package.json +48 -4
@@ -0,0 +1,154 @@
1
+ export type NotifyExec = (cmd: string, args: string[]) => Promise<{
2
+ code: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ }>;
6
+ export interface NotifyOption {
7
+ label: string;
8
+ detail?: string;
9
+ /** Index in the decision's own option list (the panel's `?pick=`); default: position. */
10
+ option?: number;
11
+ /** The hub's panel link with this option preselected (`…?pick=<i>`; hub round 4). */
12
+ url?: string;
13
+ /** Single-use push action token for this option (hub "push actions"). */
14
+ token?: string;
15
+ }
16
+ export interface NotifyItem {
17
+ event_id?: string;
18
+ id?: string;
19
+ event?: string;
20
+ task_id?: string;
21
+ task_ref?: string;
22
+ message_id?: string;
23
+ question?: string;
24
+ post_id?: string;
25
+ decision?: boolean;
26
+ /** Decision items: still open and addressed to the owner. */
27
+ open?: boolean;
28
+ options?: (string | {
29
+ option?: number;
30
+ label?: string;
31
+ detail?: string;
32
+ token?: string;
33
+ action_token?: string;
34
+ url?: string;
35
+ })[];
36
+ /** ?actions=1: single-use push action tokens per option. */
37
+ actions?: {
38
+ option?: number;
39
+ label?: string;
40
+ detail?: string;
41
+ token?: string;
42
+ action_token?: string;
43
+ url?: string;
44
+ }[];
45
+ text?: string;
46
+ title?: string;
47
+ url?: string;
48
+ path?: string;
49
+ alert?: boolean;
50
+ at?: string;
51
+ }
52
+ export interface DeskNotifierOptions {
53
+ request: (method: string, path: string, body?: unknown) => Promise<any>;
54
+ home: string;
55
+ hub: string;
56
+ env?: NodeJS.ProcessEnv;
57
+ platform?: string;
58
+ exec?: NotifyExec;
59
+ intervalMs?: number;
60
+ /** Runs a notifier that waits for the click (alerter, notify-send --wait): long timeout. */
61
+ actionExec?: NotifyExec;
62
+ log?: (s: string) => void;
63
+ }
64
+ /** A decision notification waits up to an hour for the click. */
65
+ export declare const ACTION_TIMEOUT_S = 3600;
66
+ /** The command that shows one notification on this platform. */
67
+ export declare function notifyCommand(o: {
68
+ title: string;
69
+ message: string;
70
+ url: string;
71
+ group?: string;
72
+ }, platform: string, env: NodeJS.ProcessEnv): {
73
+ cmd: string;
74
+ args: string[];
75
+ } | null;
76
+ /** The options a decision item offers (labels/details from `options`, tokens from `actions` by option index). Pure. */
77
+ export declare function itemOptions(x: NotifyItem): NotifyOption[];
78
+ /** Task ref and message id of a decision item: fields, else its link `t/T-12/m/<id>`. Pure. */
79
+ export declare function itemTarget(x: NotifyItem): {
80
+ task?: string;
81
+ message?: string;
82
+ };
83
+ /**
84
+ * The panel link with option `i` preselected: `…/#/t/T-12/m/<id>?pick=<i>`
85
+ * (the query goes inside the hash route, where the panel reads it). Pure.
86
+ */
87
+ export declare function pickUrl(url: string, i: number): string;
88
+ /** The reply text of an option: "label — detail" (the panel's buttons send the same words). */
89
+ export declare function optionReply(o: NotifyOption): string;
90
+ export interface ActionCommand {
91
+ cmd: string;
92
+ args: string[];
93
+ /** The clicked option's index, 'open' (the notification body), or null (closed / timed out). */
94
+ parse: (stdout: string) => number | 'open' | null;
95
+ }
96
+ /**
97
+ * The command that shows a notification with option buttons and reports the
98
+ * click on stdout, or null when this platform/setup cannot (then the caller
99
+ * shows a plain notification). `linuxActions`: notify-send supports --action.
100
+ */
101
+ export declare function actionCommand(o: {
102
+ title: string;
103
+ message: string;
104
+ url: string;
105
+ labels: string[];
106
+ group?: string;
107
+ }, platform: string, env: NodeJS.ProcessEnv, linuxActions?: boolean): ActionCommand | null;
108
+ /** `ach notify --test`: one test notification through the same path the helper uses. */
109
+ export declare function testNotification(o: {
110
+ hub?: string;
111
+ env?: NodeJS.ProcessEnv;
112
+ platform?: string;
113
+ exec?: NotifyExec;
114
+ lang?: 'en' | 'zh';
115
+ }): Promise<{
116
+ ok: boolean;
117
+ cmd?: string;
118
+ error?: string;
119
+ }>;
120
+ export declare class DeskNotifier {
121
+ private seen;
122
+ private seenSet;
123
+ private baseline;
124
+ private timer;
125
+ private busy;
126
+ private feed;
127
+ private linuxActions?;
128
+ /** Click handlers still waiting (tests await them). */
129
+ readonly pending: Promise<void>[];
130
+ private readonly o;
131
+ constructor(o: DeskNotifierOptions);
132
+ private remember;
133
+ private persist;
134
+ /** One poll: show new alert items (oldest first). Returns how many were shown. */
135
+ tick(): Promise<number>;
136
+ /**
137
+ * Options of a decision item: from the notification row, else (older hubs)
138
+ * from the task's request message. Never asks the hub for push action
139
+ * tokens: the helper does not answer, it only opens the panel.
140
+ */
141
+ private optionsFor;
142
+ /** Whether this notifier can report clicks (Linux: notify-send --action). */
143
+ private canAct;
144
+ /**
145
+ * A decision request with option buttons. Returns false when the platform
146
+ * cannot report clicks (the caller shows a plain notification). The click is
147
+ * handled in the background: the notifier keeps polling meanwhile.
148
+ */
149
+ private showActions;
150
+ /** Follows the hub's change stream (long poll; 15 s polling fallback). */
151
+ start(): void;
152
+ private stopped;
153
+ stop(): void;
154
+ }
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Desktop notifications from `ach run --notify`: the runner polls its owner's
3
+ * notification list on the hub (agent key; `alert` items: things that need the
4
+ * person, important events) and shows an OS notification for each new event.
5
+ * Clicking opens the deep link where the platform supports it.
6
+ *
7
+ * - macOS: terminal-notifier when installed (click opens the link), else osascript.
8
+ * - Linux: notify-send.
9
+ * - Windows: a PowerShell toast (click opens the link).
10
+ * - ACH_NOTIFIER=<command>: run `<command> <title> <message> <url>` instead (tests, custom setups).
11
+ *
12
+ * Decision requests (multiple choice) get option buttons where the OS can
13
+ * return the click: macOS `alerter` (when installed; terminal-notifier and
14
+ * osascript cannot report a click), Linux `notify-send --action` (libnotify
15
+ * 0.7.9+), Windows toast buttons. A click never answers anything: it opens
16
+ * the panel at the decision with that option preselected (`…?pick=<i>`), and
17
+ * the person presses Send there. The helper runs with the agent key, so it
18
+ * must not record the person's reply (no push action tokens, no /decide).
19
+ * Without a click-capable notifier: a plain notification that opens the link.
20
+ *
21
+ * Changes arrive through the hub's change stream (long poll, see attention.ts),
22
+ * with 15 s polling as the fallback.
23
+ *
24
+ * Deduped by event id, also across restarts (<home>/notify-seen.json), so the
25
+ * same event never pops twice. The first run only records what is already
26
+ * there: installing the service never floods the screen with old items.
27
+ */
28
+ import { execFile } from 'node:child_process';
29
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
30
+ import { join, delimiter } from 'node:path';
31
+ const SEEN_FILE = 'notify-seen.json';
32
+ const SEEN_MAX = 1000;
33
+ const execWith = (timeout) => (cmd, args) => new Promise((resolve) => {
34
+ execFile(cmd, args, { timeout }, (err, stdout, stderr) => {
35
+ resolve({ code: err ? (typeof err.code === 'number' ? err.code : 127) : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
36
+ });
37
+ });
38
+ const defaultExec = execWith(10_000);
39
+ /** A decision notification waits up to an hour for the click. */
40
+ export const ACTION_TIMEOUT_S = 3600;
41
+ const defaultActionExec = execWith((ACTION_TIMEOUT_S + 60) * 1000);
42
+ function whichIn(name, env) {
43
+ for (const d of String(env.PATH ?? '').split(delimiter))
44
+ if (d && existsSync(join(d, name)))
45
+ return join(d, name);
46
+ return undefined;
47
+ }
48
+ function onPath(name, env) {
49
+ return !!whichIn(name, env);
50
+ }
51
+ function oneLine(s, max = 200) {
52
+ return String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
53
+ }
54
+ function appleQuote(s) {
55
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
56
+ }
57
+ function xmlEscape(s) {
58
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
59
+ }
60
+ /** The command that shows one notification on this platform. */
61
+ export function notifyCommand(o, platform, env) {
62
+ if (env.ACH_NOTIFIER)
63
+ return { cmd: env.ACH_NOTIFIER, args: [o.title, o.message, o.url] };
64
+ if (platform === 'darwin') {
65
+ if (onPath('terminal-notifier', env)) {
66
+ const args = ['-title', o.title, '-message', o.message, '-open', o.url];
67
+ if (o.group)
68
+ args.push('-group', o.group);
69
+ return { cmd: 'terminal-notifier', args };
70
+ }
71
+ return { cmd: 'osascript', args: ['-e', `display notification ${appleQuote(o.message)} with title ${appleQuote(o.title)}`] };
72
+ }
73
+ if (platform === 'linux')
74
+ return { cmd: 'notify-send', args: ['--app-name=AgentChannel', o.title, `${o.message}\n${o.url}`] };
75
+ if (platform === 'win32') {
76
+ const xml = `<toast activationType="protocol" launch="${xmlEscape(o.url)}"><visual><binding template="ToastGeneric">` +
77
+ `<text>${xmlEscape(o.title)}</text><text>${xmlEscape(o.message)}</text></binding></visual></toast>`;
78
+ const ps = [
79
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null',
80
+ '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null',
81
+ '$x = New-Object Windows.Data.Xml.Dom.XmlDocument',
82
+ `$x.LoadXml('${xml.replace(/'/g, "''")}')`,
83
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('AgentChannel').Show([Windows.UI.Notifications.ToastNotification]::new($x))",
84
+ ].join('; ');
85
+ return { cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', ps] };
86
+ }
87
+ return null;
88
+ }
89
+ /** "A (Recommended)" / "A(推荐)" -> "A". */
90
+ function stripRec(s) {
91
+ return s.replace(/\s*\((recommended)\)\s*$/i, '').replace(/\s*(推荐)\s*$/, '').trim();
92
+ }
93
+ /** The options a decision item offers (labels/details from `options`, tokens from `actions` by option index). Pure. */
94
+ export function itemOptions(x) {
95
+ const raw = Array.isArray(x.options) && x.options.length ? x.options : Array.isArray(x.actions) ? x.actions : [];
96
+ const tokens = new Map();
97
+ (Array.isArray(x.actions) ? x.actions : []).forEach((a, i) => {
98
+ const t = a && (a.token || a.action_token);
99
+ if (t)
100
+ tokens.set(typeof a.option === 'number' ? a.option : i, String(t));
101
+ });
102
+ const out = [];
103
+ raw.forEach((o, i) => {
104
+ let opt = null;
105
+ if (typeof o === 'string') {
106
+ const [label, ...rest] = o.split(' — ');
107
+ if (stripRec(label))
108
+ opt = { label: stripRec(label), ...(rest.length ? { detail: rest.join(' — ') } : {}) };
109
+ }
110
+ else if (o && typeof o === 'object' && o.label) {
111
+ const own = o.token || o.action_token;
112
+ opt = { label: stripRec(String(o.label)), ...(o.detail ? { detail: String(o.detail) } : {}), ...(own ? { token: String(own) } : {}), ...(typeof o.url === 'string' && /^https?:\/\//.test(o.url) ? { url: o.url } : {}) };
113
+ }
114
+ if (!opt)
115
+ return;
116
+ const idx = typeof o === 'object' && o && typeof o.option === 'number' ? o.option : i;
117
+ if (!opt.token && tokens.has(idx))
118
+ opt.token = tokens.get(idx);
119
+ opt.option = idx;
120
+ out.push(opt);
121
+ });
122
+ return out.slice(0, 4);
123
+ }
124
+ /** Task ref and message id of a decision item: fields, else its link `t/T-12/m/<id>`. Pure. */
125
+ export function itemTarget(x) {
126
+ const m = /(?:^|#\/|\/)t\/([^/?#]+)\/m\/([A-Za-z0-9_-]+)/.exec(String(x.path ?? '') || String(x.url ?? ''));
127
+ return { task: x.task_id || x.task_ref || (m ? decodeURIComponent(m[1]) : undefined), message: x.message_id || (m ? m[2] : undefined) };
128
+ }
129
+ function isDecision(x) {
130
+ if (x.open === false)
131
+ return false;
132
+ return x.decision === true || /decision_request|memory_question/.test(String(x.event ?? '')) || itemOptions(x).length > 0;
133
+ }
134
+ /**
135
+ * The panel link with option `i` preselected: `…/#/t/T-12/m/<id>?pick=<i>`
136
+ * (the query goes inside the hash route, where the panel reads it). Pure.
137
+ */
138
+ export function pickUrl(url, i) {
139
+ const u = url.replace(/([?&])pick=\d+(&|$)/, (_m, a, b) => (b ? a : ''));
140
+ const hash = u.indexOf('#');
141
+ const route = hash >= 0 ? u.slice(hash) : '';
142
+ const sep = (hash >= 0 ? route : u).includes('?') ? '&' : '?';
143
+ return `${u}${sep}pick=${i}`;
144
+ }
145
+ /** The reply text of an option: "label — detail" (the panel's buttons send the same words). */
146
+ export function optionReply(o) {
147
+ return o.detail ? `${o.label} — ${o.detail}` : o.label;
148
+ }
149
+ /** A button label: one line, short; no commas (alerter separates actions with them). */
150
+ function buttonLabel(s, i) {
151
+ const t = oneLine(s, 40).replace(/,/g, ' ').trim();
152
+ return t || `Option ${i + 1}`;
153
+ }
154
+ /**
155
+ * The command that shows a notification with option buttons and reports the
156
+ * click on stdout, or null when this platform/setup cannot (then the caller
157
+ * shows a plain notification). `linuxActions`: notify-send supports --action.
158
+ */
159
+ export function actionCommand(o, platform, env, linuxActions = false) {
160
+ const labels = o.labels.map(buttonLabel);
161
+ const byLabel = (out) => {
162
+ const t = out.trim();
163
+ if (!t)
164
+ return null;
165
+ const m = /^o(\d)$/.exec(t);
166
+ if (m && Number(m[1]) < labels.length)
167
+ return Number(m[1]);
168
+ const i = labels.indexOf(t);
169
+ if (i >= 0)
170
+ return i;
171
+ if (t === '@CONTENTCLICKED' || t === 'default' || t === 'open')
172
+ return 'open';
173
+ return null;
174
+ };
175
+ if (platform === 'darwin') {
176
+ const alerter = whichIn('alerter', env);
177
+ if (!alerter)
178
+ return null;
179
+ const args = ['-title', o.title, '-message', o.message, '-actions', labels.join(','), '-closeLabel', 'Later', '-timeout', String(ACTION_TIMEOUT_S)];
180
+ if (labels.length > 1)
181
+ args.push('-dropdownLabel', 'Reply');
182
+ if (o.group)
183
+ args.push('-group', o.group);
184
+ return { cmd: alerter, args, parse: byLabel };
185
+ }
186
+ if (platform === 'linux') {
187
+ if (!linuxActions)
188
+ return null;
189
+ const args = ['--app-name=AgentChannel', '--wait', `--expire-time=${ACTION_TIMEOUT_S * 1000}`, ...labels.map((l, i) => `--action=o${i}=${l}`), '--action=default=Open', o.title, o.message];
190
+ return { cmd: 'notify-send', args, parse: byLabel };
191
+ }
192
+ if (platform === 'win32') {
193
+ const actions = labels.map((l, i) => `<action content="${xmlEscape(l)}" arguments="o${i}" activationType="foreground"/>`).join('');
194
+ const xml = `<toast launch="open" duration="long"><visual><binding template="ToastGeneric">` +
195
+ `<text>${xmlEscape(o.title)}</text><text>${xmlEscape(o.message)}</text></binding></visual><actions>${actions}</actions></toast>`;
196
+ const ps = [
197
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null',
198
+ '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null',
199
+ '$x = New-Object Windows.Data.Xml.Dom.XmlDocument',
200
+ `$x.LoadXml('${xml.replace(/'/g, "''")}')`,
201
+ '$t = [Windows.UI.Notifications.ToastNotification]::new($x)',
202
+ '$global:r = $null',
203
+ '$t.add_Activated({ param($s, $e) $global:r = ([Windows.UI.Notifications.ToastActivatedEventArgs]$e).Arguments })',
204
+ '$t.add_Dismissed({ param($s, $e) $global:r = "" })',
205
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('AgentChannel').Show($t)",
206
+ `$end = (Get-Date).AddSeconds(${ACTION_TIMEOUT_S})`,
207
+ 'while ($global:r -eq $null -and (Get-Date) -lt $end) { Start-Sleep -Milliseconds 300 }',
208
+ 'if ($global:r) { Write-Output $global:r }',
209
+ ].join('; ');
210
+ return { cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', ps], parse: byLabel };
211
+ }
212
+ return null;
213
+ }
214
+ /** Opens a link in the default browser. */
215
+ function openCommand(url, platform) {
216
+ if (platform === 'darwin')
217
+ return { cmd: 'open', args: [url] };
218
+ if (platform === 'linux')
219
+ return { cmd: 'xdg-open', args: [url] };
220
+ if (platform === 'win32')
221
+ return { cmd: 'rundll32', args: ['url.dll,FileProtocolHandler', url] };
222
+ return null;
223
+ }
224
+ /** `ach notify --test`: one test notification through the same path the helper uses. */
225
+ export async function testNotification(o) {
226
+ const env = o.env ?? process.env;
227
+ const zh = o.lang === 'zh';
228
+ const c = notifyCommand({
229
+ title: zh ? 'AgentChannel · 测试通知' : 'AgentChannel · test notification',
230
+ message: zh ? '能看到这条,说明后台小助手可以提醒你。' : 'If you can see this, the background helper can reach you.',
231
+ url: `${String(o.hub ?? '').replace(/\/+$/, '')}/#/inbox`,
232
+ group: 'ach-test',
233
+ }, o.platform ?? process.platform, env);
234
+ if (!c)
235
+ return { ok: false, error: `no desktop notifier for platform ${o.platform ?? process.platform}` };
236
+ const r = await (o.exec ?? defaultExec)(c.cmd, c.args);
237
+ return r.code === 0 ? { ok: true, cmd: c.cmd } : { ok: false, cmd: c.cmd, error: oneLine(r.stderr, 300) || `exit ${r.code}` };
238
+ }
239
+ export class DeskNotifier {
240
+ seen = [];
241
+ seenSet = new Set();
242
+ baseline;
243
+ timer = null;
244
+ busy = false;
245
+ feed = null;
246
+ linuxActions;
247
+ /** Click handlers still waiting (tests await them). */
248
+ pending = [];
249
+ o;
250
+ constructor(o) {
251
+ this.o = { intervalMs: 15_000, ...o };
252
+ const f = join(o.home, SEEN_FILE);
253
+ this.baseline = !existsSync(f);
254
+ if (!this.baseline) {
255
+ try {
256
+ const v = JSON.parse(readFileSync(f, 'utf8'));
257
+ if (Array.isArray(v))
258
+ for (const id of v)
259
+ if (typeof id === 'string')
260
+ this.remember(id, false);
261
+ }
262
+ catch {
263
+ /* a broken file: start over (no flood: the baseline below) */
264
+ this.baseline = true;
265
+ }
266
+ }
267
+ }
268
+ remember(id, save = true) {
269
+ if (this.seenSet.has(id))
270
+ return;
271
+ this.seenSet.add(id);
272
+ this.seen.push(id);
273
+ while (this.seen.length > SEEN_MAX)
274
+ this.seenSet.delete(this.seen.shift());
275
+ if (save)
276
+ this.persist();
277
+ }
278
+ persist() {
279
+ try {
280
+ mkdirSync(this.o.home, { recursive: true, mode: 0o700 });
281
+ writeFileSync(join(this.o.home, SEEN_FILE), JSON.stringify(this.seen), { mode: 0o600 });
282
+ }
283
+ catch {
284
+ /* best effort */
285
+ }
286
+ }
287
+ /** One poll: show new alert items (oldest first). Returns how many were shown. */
288
+ async tick() {
289
+ if (this.busy)
290
+ return 0;
291
+ this.busy = true;
292
+ try {
293
+ const r = await this.o.request('GET', '/api/ach/me/notifications?all=1&limit=50');
294
+ const items = Array.isArray(r?.items) ? r.items : [];
295
+ const fresh = items.filter((x) => x && x.alert && (x.event_id || x.id)).reverse();
296
+ if (this.baseline) {
297
+ for (const x of items)
298
+ if (x && (x.event_id || x.id))
299
+ this.remember(String(x.event_id || x.id), false);
300
+ this.baseline = false;
301
+ this.persist();
302
+ return 0;
303
+ }
304
+ let shown = 0;
305
+ for (const x of fresh) {
306
+ const id = String(x.event_id || x.id);
307
+ if (this.seenSet.has(id))
308
+ continue;
309
+ this.remember(id);
310
+ const url = x.url && /^https?:\/\//.test(x.url) ? x.url : `${this.o.hub.replace(/\/+$/, '')}/#/${String(x.path ?? 'inbox').replace(/^\/+/, '')}`;
311
+ const title = `AgentChannel · ${oneLine(x.text, 60) || 'update'}`;
312
+ const message = oneLine(x.question || x.title) || 'update';
313
+ if (isDecision(x) && (await this.showActions(x, { title, message, url, id }))) {
314
+ shown++;
315
+ continue;
316
+ }
317
+ const c = notifyCommand({ title, message: oneLine(x.title) || 'update', url, group: id }, this.o.platform ?? process.platform, this.o.env ?? process.env);
318
+ if (!c)
319
+ continue;
320
+ const res = await (this.o.exec ?? defaultExec)(c.cmd, c.args);
321
+ if (res.code !== 0)
322
+ this.o.log?.(`ach run --notify: ${c.cmd} failed (${res.code}): ${oneLine(res.stderr, 200)}`);
323
+ shown++;
324
+ }
325
+ return shown;
326
+ }
327
+ catch (e) {
328
+ this.o.log?.(`ach run --notify: ${oneLine(e?.message ?? e, 200)}`);
329
+ return 0;
330
+ }
331
+ finally {
332
+ this.busy = false;
333
+ }
334
+ }
335
+ /**
336
+ * Options of a decision item: from the notification row, else (older hubs)
337
+ * from the task's request message. Never asks the hub for push action
338
+ * tokens: the helper does not answer, it only opens the panel.
339
+ */
340
+ async optionsFor(x) {
341
+ const opts = itemOptions(x).map(({ label, detail, option, url }) => ({ label, option, ...(detail ? { detail } : {}), ...(url ? { url } : {}) }));
342
+ if (opts.length)
343
+ return opts;
344
+ const t = itemTarget(x);
345
+ if (!t.task || !t.message)
346
+ return [];
347
+ try {
348
+ const b = await this.o.request('GET', `/api/ach/tasks/${encodeURIComponent(t.task)}`);
349
+ const msgs = Array.isArray(b?.messages) ? b.messages : [];
350
+ const m = msgs.find((y) => y && y.id === t.message);
351
+ if (!m || m.kind !== 'decision_request' || m.resolved)
352
+ return [];
353
+ const ch = m.choices;
354
+ return Array.isArray(ch?.options) ? itemOptions({ options: ch.options }).map(({ label, detail, option }) => ({ label, option, ...(detail ? { detail } : {}) })) : [];
355
+ }
356
+ catch {
357
+ return [];
358
+ }
359
+ }
360
+ /** Whether this notifier can report clicks (Linux: notify-send --action). */
361
+ async canAct(platform) {
362
+ if (platform !== 'linux')
363
+ return true;
364
+ if (this.linuxActions === undefined) {
365
+ const r = await (this.o.exec ?? defaultExec)('notify-send', ['--help']);
366
+ this.linuxActions = r.code === 0 && /--action/.test(r.stdout + r.stderr);
367
+ }
368
+ return this.linuxActions;
369
+ }
370
+ /**
371
+ * A decision request with option buttons. Returns false when the platform
372
+ * cannot report clicks (the caller shows a plain notification). The click is
373
+ * handled in the background: the notifier keeps polling meanwhile.
374
+ */
375
+ async showActions(x, n) {
376
+ const env = this.o.env ?? process.env;
377
+ if (env.ACH_NOTIFIER)
378
+ return false;
379
+ const platform = this.o.platform ?? process.platform;
380
+ const opts = await this.optionsFor(x);
381
+ if (opts.length < 2)
382
+ return false;
383
+ const ac = actionCommand({ title: n.title, message: n.message, url: n.url, labels: opts.map((o) => o.label), group: n.id }, platform, env, await this.canAct(platform));
384
+ if (!ac)
385
+ return false;
386
+ const p = (async () => {
387
+ const r = await (this.o.actionExec ?? defaultActionExec)(ac.cmd, ac.args);
388
+ const pick = ac.parse(r.stdout);
389
+ if (pick === null)
390
+ return;
391
+ // a button opens the panel at the decision with that option preselected; the person sends it there
392
+ const url = pick === 'open' ? n.url : opts[pick].url ?? pickUrl(n.url, opts[pick].option ?? pick);
393
+ const oc = openCommand(url, platform);
394
+ if (oc)
395
+ await (this.o.exec ?? defaultExec)(oc.cmd, oc.args);
396
+ if (pick !== 'open')
397
+ this.o.log?.(`ach run --notify: opened the panel with "${oneLine(opts[pick].label, 80)}" preselected (desktop click)`);
398
+ })().catch((e) => this.o.log?.(`ach run --notify: could not open the panel: ${oneLine(e?.message ?? e, 200)}`));
399
+ this.pending.push(p);
400
+ p.finally(() => {
401
+ const i = this.pending.indexOf(p);
402
+ if (i >= 0)
403
+ this.pending.splice(i, 1);
404
+ });
405
+ return true;
406
+ }
407
+ /** Follows the hub's change stream (long poll; 15 s polling fallback). */
408
+ start() {
409
+ void import('./attention.js').then(({ ChangeFeed }) => {
410
+ if (this.stopped)
411
+ 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 });
413
+ this.feed = feed;
414
+ void feed.run();
415
+ });
416
+ }
417
+ stopped = false;
418
+ stop() {
419
+ this.stopped = true;
420
+ this.feed?.stop();
421
+ this.feed = null;
422
+ if (this.timer)
423
+ clearInterval(this.timer);
424
+ this.timer = null;
425
+ }
426
+ }
@@ -0,0 +1,75 @@
1
+ import { type FetchLike } from './client.js';
2
+ export type ExecResult = {
3
+ code: number;
4
+ stdout: string;
5
+ stderr: string;
6
+ };
7
+ export type ExecLike = (file: string, args: string[], env: NodeJS.ProcessEnv) => Promise<ExecResult>;
8
+ export type DoctorSection = 'hub' | 'front door' | 'key' | 'machine';
9
+ export interface DoctorCheck {
10
+ name: string;
11
+ status: 'ok' | 'warn' | 'fail' | 'info';
12
+ detail: string;
13
+ /** Report section (default: machine). */
14
+ section?: DoctorSection;
15
+ /** One line: what to do about a warn/fail. */
16
+ fix?: string;
17
+ }
18
+ /** Ask-the-admin fix: a fresh join line carries a new key and whatever the hub needs to let you in. */
19
+ export declare const NEW_LINE_FIX = "ask your admin to run `ach admin invite <your agent>` again and paste the new line";
20
+ /** Allowed clock difference with the hub before warning (login links and tokens are time-bound). */
21
+ export declare const CLOCK_SKEW_WARN_MS: number;
22
+ export type Probe = {
23
+ kind: 'ok';
24
+ status: number;
25
+ date?: string;
26
+ } | {
27
+ kind: 'gate';
28
+ status: number;
29
+ where: string;
30
+ } | {
31
+ kind: 'http';
32
+ status: number;
33
+ } | {
34
+ kind: 'dns' | 'refused' | 'timeout' | 'tls' | 'network';
35
+ message: string;
36
+ };
37
+ /** GET /api/health without following redirects, and classify what came back. */
38
+ export declare function probeHub(hub: string, headers: Record<string, string>, fetchFn: FetchLike): Promise<Probe>;
39
+ /** Runs a status command with a timeout; never throws. */
40
+ export declare const defaultExec: ExecLike;
41
+ /**
42
+ * `ach doctor`: a read-only setup check. Never prints the key
43
+ * or the CF secret, and never reads Claude/Codex credential files: login state
44
+ * comes from `claude auth status` and `codex login status`.
45
+ */
46
+ export declare function runDoctor(o: {
47
+ env: NodeJS.ProcessEnv;
48
+ fetch?: FetchLike;
49
+ exec?: ExecLike;
50
+ self?: string;
51
+ }): Promise<DoctorCheck[]>;
52
+ /**
53
+ * Command, args and AGENTCHANNEL_HOME of an MCP entry from `claude mcp get`
54
+ * ("Command: npx" / "Args: -y @koalaauto/agentchannel@3 mcp" / "AGENTCHANNEL_HOME=...")
55
+ * or `codex mcp get` ("command: npx" / "args: ..." / "env: AGENTCHANNEL_HOME=..."),
56
+ * or a `name: command args` line (mcp list style).
57
+ */
58
+ export declare function parseMcpEntry(text: string, name: string): {
59
+ command?: string;
60
+ args: string[];
61
+ home?: string;
62
+ };
63
+ /**
64
+ * The commands that make this CLI the `ach` on PATH when another program
65
+ * called `ach` comes first: remove that link, or put this CLI's directory
66
+ * first (or install this CLI when it is not on PATH at all).
67
+ */
68
+ export declare function shadowFix(other: {
69
+ path: string;
70
+ real?: string;
71
+ }, ours: {
72
+ path: string;
73
+ } | undefined, self: string): string;
74
+ /** Grouped report: hub, front door (only when there is one), key, this machine; each problem with a one-line fix. */
75
+ export declare function formatDoctor(checks: DoctorCheck[]): string;