@koalaauto/agentchannel 0.0.0-stage → 3.0.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.
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,1148 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { gitCommonDir } from '../adapters/index.js';
6
+ import { toolchainReadRoots } from '../adapters/paths.js';
7
+ import { Client as HubClient, HubError, isTerminal } from '../client.js';
8
+ import { taskFiles } from '../attachments.js';
9
+ import { buildAgentEnv } from '../env.js';
10
+ import { effectivePerm } from '../perm.js';
11
+ import { ANSWER_SCHEMA, buildAnswerPrompt, buildMentionPrompt, buildResumePrompt, buildTaskPrompt, postUrl, refOf, taskUrl } from '../prompts.js';
12
+ import { RunnerState } from './state.js';
13
+ export function jobKey(j) {
14
+ if (j.kind === 'mention')
15
+ return `mention:${j.item.mention.id}`;
16
+ return j.kind === 'answer' ? `answer:${j.question.id}` : `task:${j.taskId}`;
17
+ }
18
+ const TOKEN_REFRESH_MS = 20 * 3600_000;
19
+ const PRUNE_EVERY_MS = 10 * 60_000;
20
+ /** A gate decision that lets the run go ahead (S1). Anything else rejects. */
21
+ export function approves(decision) {
22
+ return /^\s*(approve[ds]?|yes|ok|okay|allow(ed)?|go ahead)\b/i.test(decision);
23
+ }
24
+ export function newRunId() {
25
+ return `run_${randomBytes(9).toString('base64url')}`;
26
+ }
27
+ function is409(e) {
28
+ return e instanceof HubError && e.status === 409;
29
+ }
30
+ function is401(e) {
31
+ return e instanceof HubError && e.status === 401;
32
+ }
33
+ /**
34
+ * What the worker is still waiting on: its own open questions, blocking decision
35
+ * requests, and (non-blocking) decision requests that replace one of its questions.
36
+ */
37
+ export function openRequests(messages, myId) {
38
+ const myQuestions = new Set(messages.filter((m) => m.kind === 'question' && m.author_agent === myId).map((m) => m.id));
39
+ return messages.filter((m) => {
40
+ if (m.resolved)
41
+ return false;
42
+ if (m.kind === 'question')
43
+ return m.author_agent === myId;
44
+ if (m.kind !== 'decision_request')
45
+ return false;
46
+ if (m.blocking === false)
47
+ return !!m.reply_to && myQuestions.has(m.reply_to);
48
+ return true;
49
+ });
50
+ }
51
+ /** A human note addressed to me (panel composer / `ach admin note`): wakes a parked run. */
52
+ export function isHumanNoteTo(m, myId) {
53
+ return m.kind === 'note' && m.author_type === 'human' && m.to_agent === myId;
54
+ }
55
+ /** Answers / decisions replying to something I asked, and human notes to me, that the worker has not been shown yet. */
56
+ export function unseenReplies(messages, myId, seen) {
57
+ const mine = new Set(messages.filter((m) => m.author_agent === myId).map((m) => m.id));
58
+ // A decision request that replaced one of my questions: its decision answers me.
59
+ for (const m of messages)
60
+ if (m.kind === 'decision_request' && m.reply_to && mine.has(m.reply_to))
61
+ mine.add(m.id);
62
+ const seenSet = new Set(seen);
63
+ return messages.filter((m) => !seenSet.has(m.id) &&
64
+ (((m.kind === 'answer' || m.kind === 'decision') && ((m.reply_to && mine.has(m.reply_to)) || m.to_agent === myId)) || isHumanNoteTo(m, myId)));
65
+ }
66
+ /**
67
+ * Runner files a spawned agent must never read: the agent key (config.json), other
68
+ * runs' tokens (runs.json), session ids and logs. Not the whole home: the per-task
69
+ * worktrees live in <home>/work and must stay readable.
70
+ */
71
+ export function secretPaths(home) {
72
+ return ['config.json', 'agent-key', 'admin-session', 'cf-secret', 'runs.json', 'sessions.json', 'logs'].map((f) => join(home, f));
73
+ }
74
+ /**
75
+ * Runtime config a spawned agent's CLI needs from the real home: linked into the
76
+ * scratch home. Claude only: its user settings, hooks and MCP servers are not
77
+ * loaded anyway (settingSources [], strictMcpConfig). Codex is NOT linked: it
78
+ * gets its own CODEX_HOME (see prepareCodexHome).
79
+ */
80
+ export const RUNTIME_HOME_LINKS = ['.claude', '.claude.json'];
81
+ /**
82
+ * macOS (live test): Claude Code keeps its login in the login keychain, which
83
+ * `security` finds through $HOME/Library/Keychains. Without this link a worker
84
+ * with a scratch HOME reports "Not logged in". Only the folder is linked; the
85
+ * sandbox still denies the worker's tools every path under the real home.
86
+ */
87
+ export function runtimeHomeLinks(platform = process.platform) {
88
+ return platform === 'darwin' ? [...RUNTIME_HOME_LINKS, 'Library/Keychains'] : RUNTIME_HOME_LINKS;
89
+ }
90
+ /** The config.toml of a worker's own CODEX_HOME: nothing from the user's global config (no mcp_servers, profiles, notify hooks). */
91
+ export const CODEX_WORKER_CONFIG = [
92
+ '# Written by agentchannel for background worker runs. The user\'s global Codex',
93
+ '# config (MCP servers, profiles, hooks, AGENTS.md) is deliberately not used here.',
94
+ '# Model and sandbox are set per run by the runner.',
95
+ '',
96
+ ].join('\n');
97
+ /**
98
+ * Codex isolation (live test): a worker run must not load the user's global
99
+ * Codex config (~/.codex/config.toml: MCP servers with their own credentials,
100
+ * profiles, notify hooks, AGENTS.md). The run gets its own CODEX_HOME holding
101
+ * only a generated config.toml and a symlink to the login file (auth.json) of
102
+ * the user's Codex home. The login file is placed by path only: it is never
103
+ * opened or copied here. Codex rewrites it in place through the link, so a
104
+ * refreshed login stays the user's. Sessions of the run stay in this home, so
105
+ * a resumed run of the same task finds its thread.
106
+ */
107
+ export function prepareCodexHome(home, realCodexHome) {
108
+ const dir = join(home, '.codex');
109
+ try {
110
+ // an older scratch home linked the whole ~/.codex: replace the link
111
+ if (lstatSync(dir).isSymbolicLink())
112
+ rmSync(dir);
113
+ }
114
+ catch {
115
+ /* not there */
116
+ }
117
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
118
+ writeFileSync(join(dir, 'config.toml'), CODEX_WORKER_CONFIG, { mode: 0o600 });
119
+ if (realCodexHome) {
120
+ const src = join(realCodexHome, 'auth.json');
121
+ const dst = join(dir, 'auth.json');
122
+ let have = false;
123
+ try {
124
+ lstatSync(dst);
125
+ have = true;
126
+ }
127
+ catch {
128
+ /* not there */
129
+ }
130
+ if (!have && existsSync(src)) {
131
+ try {
132
+ symlinkSync(src, dst);
133
+ }
134
+ catch {
135
+ /* best effort: codex then reports "not logged in" */
136
+ }
137
+ }
138
+ }
139
+ return dir;
140
+ }
141
+ /**
142
+ * B1: a per-task scratch HOME for spawned agents, so `~/.config/agentchannel`
143
+ * (agent key file, admin session, runner state) is not reachable through
144
+ * $HOME. Runtime config (.claude, .codex) is linked, the git identity
145
+ * (.gitconfig) copied; npm keeps the real cache via npm_config_cache.
146
+ */
147
+ export function prepareAgentHome(root, agentName, key, realHome, realCodexHome) {
148
+ const safe = (x) => x.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 80);
149
+ const home = join(root, safe(agentName), safe(key));
150
+ mkdirSync(home, { recursive: true, mode: 0o700 });
151
+ const env = { HOME: home };
152
+ if (!realHome) {
153
+ env.CODEX_HOME = prepareCodexHome(home, realCodexHome);
154
+ return { home, env };
155
+ }
156
+ for (const name of runtimeHomeLinks()) {
157
+ const src = join(realHome, name);
158
+ const dst = join(home, name);
159
+ if (!existsSync(src))
160
+ continue;
161
+ try {
162
+ lstatSync(dst);
163
+ }
164
+ catch {
165
+ try {
166
+ mkdirSync(dirname(dst), { recursive: true, mode: 0o700 });
167
+ symlinkSync(src, dst);
168
+ }
169
+ catch {
170
+ /* best effort */
171
+ }
172
+ }
173
+ }
174
+ const git = join(realHome, '.gitconfig');
175
+ if (existsSync(git)) {
176
+ try {
177
+ copyFileSync(git, join(home, '.gitconfig'));
178
+ }
179
+ catch {
180
+ /* best effort */
181
+ }
182
+ }
183
+ const npmCache = join(realHome, '.npm');
184
+ if (existsSync(npmCache))
185
+ env.npm_config_cache = npmCache;
186
+ env.CODEX_HOME = prepareCodexHome(home, realCodexHome || join(realHome, '.codex'));
187
+ return { home, env };
188
+ }
189
+ export function budgetBlocked(me) {
190
+ const budget = Number(me.budget_usd ?? 0);
191
+ return budget > 0 && Number(me.spent_usd ?? 0) >= budget;
192
+ }
193
+ export class Runner {
194
+ state;
195
+ queue = [];
196
+ active = new Map();
197
+ me;
198
+ o;
199
+ stopped = false;
200
+ waiters = [];
201
+ lastPrune = 0;
202
+ /** Last heartbeat sent for each parked run. */
203
+ parkedHb = new Map();
204
+ /** B7: set when the hub answered 401 (key rotated, agent or owner deactivated): the runner stops. */
205
+ fatal;
206
+ wake;
207
+ constructor(opts) {
208
+ this.o = { max: 2, perm: 'workdir', intervalMs: 3000, heartbeatMs: 30_000, idleMs: 60 * 60_000, ...opts };
209
+ this.state = new RunnerState(opts.home);
210
+ }
211
+ log(s) {
212
+ (this.o.log ?? ((x) => console.error(x)))(`[ach run] ${s}`);
213
+ }
214
+ taskLog(taskId, line) {
215
+ try {
216
+ const dir = join(this.o.home, 'logs');
217
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
218
+ appendFileSync(join(dir, `${taskId}.log`), `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
219
+ }
220
+ catch {
221
+ /* logging must never break a run */
222
+ }
223
+ }
224
+ isQueuedOrActive(key) {
225
+ return this.active.has(key) || this.queue.some((j) => jobKey(j) === key);
226
+ }
227
+ enqueue(job, front = false) {
228
+ const key = jobKey(job);
229
+ if (this.isQueuedOrActive(key)) {
230
+ // Merge new replies into an already queued resume.
231
+ if (job.kind === 'resume') {
232
+ const q = this.queue.find((j) => jobKey(j) === key && j.kind === 'resume');
233
+ if (q)
234
+ for (const r of job.replies)
235
+ if (!q.replies.some((x) => x.id === r.id))
236
+ q.replies.push(r);
237
+ }
238
+ return false;
239
+ }
240
+ if (front)
241
+ this.queue.unshift(job);
242
+ else
243
+ this.queue.push(job);
244
+ return true;
245
+ }
246
+ /** Re-claim runs that were active when the runner died (§14.10). */
247
+ recover() {
248
+ for (const rec of Object.values(this.state.runs.runs)) {
249
+ if (rec.phase === 'running')
250
+ this.enqueue({ kind: 'work', taskId: rec.taskId });
251
+ }
252
+ }
253
+ /**
254
+ * B7: a 401 from inbox, heartbeat or claim means the key no longer works.
255
+ * Abort every job without posting anything (no fail, no usage) and stop.
256
+ */
257
+ authFailed(where) {
258
+ if (this.fatal)
259
+ return;
260
+ this.fatal = 'unauthorized';
261
+ this.stopped = true;
262
+ this.log(`${where}: 401 from the hub; stopping`);
263
+ this.queue.length = 0;
264
+ for (const a of this.active.values()) {
265
+ a.reason = 'auth';
266
+ a.controller.abort();
267
+ }
268
+ this.wake?.();
269
+ }
270
+ /** One poll cycle. */
271
+ async tick() {
272
+ let inbox;
273
+ try {
274
+ inbox = await this.o.client.inbox();
275
+ }
276
+ catch (e) {
277
+ if (is401(e))
278
+ return this.authFailed('inbox');
279
+ throw e;
280
+ }
281
+ await this.checkActive();
282
+ await this.prune();
283
+ if (!this.me || inbox.tasks_to_me.length)
284
+ this.me = await this.o.client.me();
285
+ const me = this.me;
286
+ // New tasks.
287
+ const blocked = budgetBlocked(me);
288
+ const now = Date.now();
289
+ for (const t of inbox.tasks_to_me) {
290
+ if (this.isQueuedOrActive(`task:${t.id}`))
291
+ continue;
292
+ const sentAt = Date.parse(String(t.created ?? '').replace(' ', 'T'));
293
+ if (this.o.claimDelayMs && sentAt && now - sentAt < this.o.claimDelayMs)
294
+ continue;
295
+ if (blocked) {
296
+ await this.notifyBudget(t, me);
297
+ continue;
298
+ }
299
+ this.enqueue({ kind: 'work', taskId: t.id });
300
+ }
301
+ // Replies for parked runs.
302
+ const byTask = new Map();
303
+ for (const m of [...inbox.answers_to_me, ...inbox.decisions_for_me, ...(inbox.notes_for_me ?? [])]) {
304
+ const rec = this.state.get(m.task);
305
+ if (!rec || rec.phase !== 'parked' || rec.seen.includes(m.id))
306
+ continue;
307
+ if (!byTask.has(m.task))
308
+ byTask.set(m.task, []);
309
+ byTask.get(m.task).push(m);
310
+ }
311
+ for (const [taskId, replies] of byTask)
312
+ this.enqueue({ kind: 'resume', taskId, replies });
313
+ // Questions addressed to me.
314
+ for (const q of inbox.questions_to_me) {
315
+ if (this.state.runs.handledQuestions[q.id])
316
+ continue;
317
+ // P1: a task I am working interactively: my own session answers its questions.
318
+ const ti = q.task_info;
319
+ if (ti && ti.claim_mode === 'interactive' && ti.to_agent === me.id)
320
+ continue;
321
+ this.enqueue({ kind: 'answer', taskId: q.task, question: q });
322
+ }
323
+ // §16: @mentions in channel posts wake a read-only mention run.
324
+ if (this.o.client.mentionRun) {
325
+ for (const m of inbox.mentions_for_me ?? [])
326
+ this.enqueue({ kind: 'mention', item: m });
327
+ }
328
+ this.pump();
329
+ }
330
+ async notifyBudget(t, me) {
331
+ if (this.state.runs.budgetNotified[t.id])
332
+ return;
333
+ try {
334
+ await this.o.client.postMessage(t.id, {
335
+ kind: 'decision_request',
336
+ topic: 'budget',
337
+ body: `Budget reached for agent ${me.name}: spent $${Number(me.spent_usd ?? 0).toFixed(2)} of $${Number(me.budget_usd).toFixed(2)}. The task was not started. An admin must raise the budget (this decision is informational).`,
338
+ });
339
+ }
340
+ catch (e) {
341
+ this.log(`budget notice for ${t.id} failed: ${e?.message ?? e}`);
342
+ }
343
+ this.state.runs.budgetNotified[t.id] = new Date().toISOString();
344
+ this.state.saveRuns();
345
+ }
346
+ /**
347
+ * Cancel running jobs whose task went terminal; forget parked runs of
348
+ * terminal tasks; keep parked leases alive; and (C1) resume a parked run
349
+ * whose task is back to `working` with nothing left to wait for.
350
+ */
351
+ async checkActive() {
352
+ for (const a of this.active.values()) {
353
+ if (a.job.kind === 'answer' || a.job.kind === 'mention')
354
+ continue;
355
+ try {
356
+ const { task } = await this.o.client.getTask(a.job.taskId);
357
+ // Only a cancel (sender / human) stops a run. completed / failed come from
358
+ // the worker itself (usually the agent's own `ach complete`); aborting then
359
+ // would drop the turn's usage, which Codex reports only at turn end.
360
+ // The run token is already revoked and the idle watchdog still applies.
361
+ if (task.status === 'canceled' && !a.reason) {
362
+ a.reason = 'cancel';
363
+ this.taskLog(a.job.taskId, `task is ${task.status}; aborting run`);
364
+ a.controller.abort();
365
+ }
366
+ // A handoff update does not interrupt a running worker (that would drop
367
+ // the turn's usage): its `ach complete` carries ACH_HANDOFF_VERSION and
368
+ // the hub refuses an outdated one, and afterWork resumes on the new version.
369
+ }
370
+ catch (e) {
371
+ this.log(`status check ${a.job.taskId}: ${e?.message ?? e}`);
372
+ }
373
+ }
374
+ const now = Date.now();
375
+ for (const rec of Object.values(this.state.runs.runs)) {
376
+ if (rec.phase !== 'parked' || this.isQueuedOrActive(`task:${rec.taskId}`))
377
+ continue;
378
+ try {
379
+ const bundle = await this.o.client.getTask(rec.taskId);
380
+ const task = bundle.task;
381
+ if (isTerminal(task.status)) {
382
+ this.state.drop(rec.taskId);
383
+ continue;
384
+ }
385
+ // A parked run still holds the claim lease (§14.10): keep it alive so the
386
+ // hub's lease cron cannot hand the task to another run between an answer
387
+ // arriving and the resume starting. If the runner dies, heartbeats stop.
388
+ if (task.run_id === rec.runId && now - (this.parkedHb.get(rec.taskId) ?? 0) >= this.o.heartbeatMs) {
389
+ this.parkedHb.set(rec.taskId, now);
390
+ await this.heartbeat(rec).catch((e) => this.log(`heartbeat ${rec.taskId}: ${e?.message ?? e}`));
391
+ }
392
+ const me0 = await this.ensureMe();
393
+ // A new handoff version reaches a parked worker at once, even while it
394
+ // waits for an answer: the change may settle or replace its question.
395
+ // Not while the S1 gate waits for the owner: the full prompt shown when
396
+ // it opens carries the current handoff anyway.
397
+ if (!rec.gate && (task.handoff_version ?? 0) > (rec.handoffVersion ?? 0)) {
398
+ this.enqueue({ kind: 'resume', taskId: rec.taskId, replies: unseenReplies(bundle.messages, me0.id, rec.seen) });
399
+ continue;
400
+ }
401
+ if (task.status === 'working') {
402
+ const me = me0;
403
+ if (!openRequests(bundle.messages, me.id).length) {
404
+ this.enqueue({ kind: 'resume', taskId: rec.taskId, replies: unseenReplies(bundle.messages, me.id, rec.seen) });
405
+ }
406
+ }
407
+ }
408
+ catch (e) {
409
+ if (e instanceof HubError && (e.status === 404 || e.status === 403))
410
+ this.state.drop(rec.taskId);
411
+ }
412
+ }
413
+ }
414
+ /** Heartbeat a run; adopt a refreshed run token (C5). Throws HubError 409 when the run lost the task. */
415
+ async heartbeat(rec) {
416
+ let r;
417
+ try {
418
+ r = await this.o.client.heartbeat(rec.taskId, rec.runId);
419
+ }
420
+ catch (e) {
421
+ if (is401(e))
422
+ this.authFailed(`heartbeat ${rec.taskId}`);
423
+ throw e;
424
+ }
425
+ if (r?.run_token) {
426
+ rec.runToken = r.run_token;
427
+ rec.tokenAt = Date.now();
428
+ if (this.state.get(rec.taskId))
429
+ this.state.put(rec);
430
+ this.taskLog(rec.taskId, 'run token refreshed by heartbeat');
431
+ }
432
+ }
433
+ /** C8: drop runs.json bookkeeping for terminal (or vanished) tasks, every 10 minutes. */
434
+ async prune(force = false) {
435
+ const now = Date.now();
436
+ if (!force && now - this.lastPrune < PRUNE_EVERY_MS)
437
+ return;
438
+ this.lastPrune = now;
439
+ const dead = [];
440
+ for (const id of this.state.bookkeepingTasks()) {
441
+ if (this.state.get(id))
442
+ continue;
443
+ try {
444
+ const { task } = await this.o.client.getTask(id);
445
+ if (isTerminal(task.status))
446
+ dead.push(id);
447
+ }
448
+ catch (e) {
449
+ if (e instanceof HubError && (e.status === 404 || e.status === 403))
450
+ dead.push(id);
451
+ }
452
+ }
453
+ // Entries from older versions carry no task id: expire them after a week.
454
+ for (const [q, v] of Object.entries(this.state.runs.handledQuestions)) {
455
+ if (typeof v === 'string' && now - Date.parse(v) > 7 * 86_400_000)
456
+ delete this.state.runs.handledQuestions[q];
457
+ }
458
+ this.state.pruneTasks(dead);
459
+ for (const id of dead)
460
+ rmSync(this.filesDir(id), { recursive: true, force: true });
461
+ }
462
+ pump() {
463
+ while (!this.stopped && this.active.size < this.o.max && this.queue.length) {
464
+ const job = this.queue.shift();
465
+ const controller = new AbortController();
466
+ const entry = { job, controller, done: Promise.resolve() };
467
+ this.active.set(jobKey(job), entry);
468
+ entry.done = this.runJob(job, entry)
469
+ .catch((e) => this.log(`job ${jobKey(job)} crashed: ${e?.stack ?? e}`))
470
+ .finally(() => {
471
+ this.active.delete(jobKey(job));
472
+ if (entry.followUp && !this.stopped)
473
+ this.enqueue(entry.followUp, true);
474
+ this.pump();
475
+ if (!this.active.size && !this.queue.length)
476
+ this.waiters.splice(0).forEach((w) => w());
477
+ });
478
+ }
479
+ }
480
+ /** Resolves when nothing is queued or running (tests). */
481
+ async drain() {
482
+ if (!this.active.size && !this.queue.length)
483
+ return;
484
+ await new Promise((r) => this.waiters.push(r));
485
+ }
486
+ async runJob(job, entry) {
487
+ if (job.kind === 'work')
488
+ return this.runWork(job.taskId, entry);
489
+ if (job.kind === 'resume')
490
+ return this.runResume(job.taskId, job.replies, entry, job.nudge);
491
+ if (job.kind === 'mention')
492
+ return this.runMention(job.item, entry);
493
+ return this.runAnswer(job.question, entry);
494
+ }
495
+ async ensureMe() {
496
+ if (!this.me)
497
+ this.me = await this.o.client.me();
498
+ return this.me;
499
+ }
500
+ async runWork(taskId, entry) {
501
+ const me = await this.ensureMe();
502
+ const prev = this.state.get(taskId);
503
+ const runId = prev?.runId ?? (this.o.newRunId ?? newRunId)();
504
+ let claim;
505
+ try {
506
+ claim = await this.o.client.claim(taskId, runId);
507
+ }
508
+ catch (e) {
509
+ if (is401(e))
510
+ return this.authFailed(`claim ${taskId}`);
511
+ if (e instanceof HubError && e.code === 'budget_exceeded') {
512
+ this.me = await this.o.client.me();
513
+ const t = (await this.o.client.getTask(taskId)).task;
514
+ await this.notifyBudget(t, this.me);
515
+ return;
516
+ }
517
+ if (is409(e)) {
518
+ this.log(`claim ${taskId}: taken by another run, skipping`);
519
+ this.state.drop(taskId);
520
+ return;
521
+ }
522
+ throw e;
523
+ }
524
+ // C2: a workspace that cannot be prepared fails the task instead of
525
+ // leaving it claimed (and re-claimed after every lease expiry).
526
+ let workdir;
527
+ let sensitive = [];
528
+ let readRepos = [];
529
+ try {
530
+ // Other repos first: an unknown repo name fails the task before any worktree exists.
531
+ readRepos = (await this.o.workspace.readRepos?.(claim.task)) ?? [];
532
+ workdir = await this.o.workspace.prepare(claim.task, me.name);
533
+ sensitive = (await this.o.workspace.sensitiveChanges?.(claim.task)) ?? [];
534
+ }
535
+ catch (e) {
536
+ const reason = `workspace: ${e?.message ?? e}`;
537
+ this.taskLog(taskId, `prepare failed: ${reason}`);
538
+ await this.o.client.fail(taskId, reason, runId).catch((x) => this.log(`fail ${taskId}: ${x?.message ?? x}`));
539
+ this.state.drop(taskId);
540
+ return;
541
+ }
542
+ const rec = {
543
+ taskId,
544
+ runId,
545
+ phase: 'running',
546
+ workdir,
547
+ runToken: claim.run_token,
548
+ tokenAt: Date.now(),
549
+ handoffVersion: claim.task.handoff_version,
550
+ seen: Array.from(new Set([...(prev?.seen ?? []), ...claim.messages.map((m) => m.id)])),
551
+ ...(prev?.gate ? { gate: prev.gate } : {}),
552
+ ...(readRepos.length ? { readRepos } : {}),
553
+ };
554
+ this.state.put(rec);
555
+ this.taskLog(taskId, `claimed run_id=${runId} workdir=${workdir}`);
556
+ // S1: a sender-chosen base that adds or changes agent config needs the
557
+ // worker owner's approval before any agent runs in that directory.
558
+ if (sensitive.length && !rec.gate) {
559
+ const m = await this.o.client.postMessage(taskId, {
560
+ kind: 'decision_request',
561
+ topic: 'sensitive_base',
562
+ body: `links.base adds or changes agent configuration that the agent would load: ${sensitive.join(', ')}. ` +
563
+ 'Reply "approve" to run the agent in this worktree anyway, or anything else to fail the task.',
564
+ });
565
+ rec.gate = { requestId: m.id, files: sensitive };
566
+ rec.phase = 'parked';
567
+ rec.seen = Array.from(new Set([...rec.seen, m.id]));
568
+ this.state.put(rec);
569
+ this.taskLog(taskId, `gated on owner approval for ${sensitive.join(', ')}`);
570
+ return;
571
+ }
572
+ if (rec.gate) {
573
+ rec.phase = 'parked';
574
+ this.state.put(rec);
575
+ return;
576
+ }
577
+ const files = await this.fetchFiles(taskId, claim);
578
+ rec.files = files.map((f) => f.key);
579
+ this.state.put(rec);
580
+ const prompt = buildTaskPrompt({
581
+ agentName: me.name,
582
+ ownerName: me.owner_name,
583
+ senderName: claim.task.from_agent_name,
584
+ task: claim.task,
585
+ handoff: claim.handoff,
586
+ messages: claim.messages,
587
+ taskUrl: this.linkOf(claim.task),
588
+ readRepos: rec.readRepos?.map((r) => ({ name: r.name, path: r.path })),
589
+ files: files.map(promptFile),
590
+ channel: await this.channelContext(claim.task),
591
+ wiki: claim.wiki,
592
+ });
593
+ this.reportPromptSize(taskId, rec.runId, prompt);
594
+ const sess = this.state.session(taskId, 'work');
595
+ const resume = sess && sess.cwd === workdir ? sess.id : undefined;
596
+ const result = await this.execute(rec, entry, prompt, resume, 'work');
597
+ await this.afterWork(rec, entry, result, claim.task);
598
+ }
599
+ async runResume(taskId, replies, entry, nudge) {
600
+ const me = await this.ensureMe();
601
+ const rec = this.state.get(taskId);
602
+ if (!rec)
603
+ return;
604
+ const bundle = await this.o.client.getTask(taskId);
605
+ if (isTerminal(bundle.task.status)) {
606
+ this.state.drop(taskId);
607
+ return;
608
+ }
609
+ // Lease lost while parked (runner was down): re-claim with the same run_id.
610
+ const leaseLost = bundle.task.status === 'submitted' || (bundle.task.run_id ?? '') !== rec.runId;
611
+ if (leaseLost || Date.now() - rec.tokenAt > TOKEN_REFRESH_MS) {
612
+ try {
613
+ const c = await this.o.client.claim(taskId, rec.runId);
614
+ rec.runToken = c.run_token;
615
+ rec.tokenAt = Date.now();
616
+ }
617
+ catch (e) {
618
+ this.log(`${leaseLost ? 're-claim' : 'token refresh'} ${taskId}: ${e?.message ?? e}`);
619
+ if (leaseLost) {
620
+ if (is409(e))
621
+ this.state.drop(taskId);
622
+ return;
623
+ }
624
+ }
625
+ }
626
+ let fresh = replies.filter((r) => !rec.seen.includes(r.id));
627
+ for (const r of unseenReplies(bundle.messages, me.id, rec.seen))
628
+ if (!fresh.some((x) => x.id === r.id))
629
+ fresh.push(r);
630
+ let fullPrompt = false;
631
+ if (rec.gate) {
632
+ const gate = rec.gate;
633
+ const decision = bundle.messages.find((m) => m.kind === 'decision' && m.author_type === 'human' && m.reply_to === gate.requestId);
634
+ if (!decision) {
635
+ // Notes that arrive meanwhile are shown with the full task prompt once the gate opens.
636
+ rec.seen = Array.from(new Set([...rec.seen, ...fresh.map((r) => r.id)]));
637
+ this.state.put(rec);
638
+ return; // still waiting for the owner
639
+ }
640
+ if (!approves(decision.body)) {
641
+ this.taskLog(taskId, 'owner rejected the gated base');
642
+ await this.o.client
643
+ .fail(taskId, `owner did not approve agent-config changes in links.base (${gate.files.join(', ')}): ${decision.body}`, rec.runId)
644
+ .catch((x) => this.log(`fail ${taskId}: ${x?.message ?? x}`));
645
+ this.state.drop(taskId);
646
+ return;
647
+ }
648
+ rec.gate = undefined;
649
+ fullPrompt = true;
650
+ fresh = fresh.filter((r) => r.id !== decision.id);
651
+ rec.seen.push(decision.id);
652
+ }
653
+ const handoffChanged = (bundle.task.handoff_version ?? 0) > (rec.handoffVersion ?? 0) ? bundle.handoff : undefined;
654
+ rec.seen = Array.from(new Set([...rec.seen, ...fresh.map((r) => r.id)]));
655
+ rec.handoffVersion = bundle.task.handoff_version ?? rec.handoffVersion;
656
+ // New work (a new handoff or new replies) earns a fresh nudge if it stops silently again.
657
+ if (!nudge && (handoffChanged !== undefined || fresh.length))
658
+ rec.nudged = false;
659
+ rec.phase = 'running';
660
+ const files = await this.fetchFiles(taskId, bundle);
661
+ const shown = new Set(rec.files ?? []);
662
+ rec.files = files.map((f) => f.key);
663
+ this.state.put(rec);
664
+ const sess = this.state.session(taskId, 'work');
665
+ const resume = !fullPrompt && sess && sess.cwd === rec.workdir ? sess.id : undefined;
666
+ const prompt = resume
667
+ ? buildResumePrompt({
668
+ task: bundle.task,
669
+ replies: fresh,
670
+ handoffVersion: bundle.task.handoff_version,
671
+ handoffChanged,
672
+ files: files.filter((f) => !shown.has(f.key)).map(promptFile),
673
+ nudge,
674
+ })
675
+ : buildTaskPrompt({
676
+ agentName: me.name,
677
+ ownerName: me.owner_name,
678
+ senderName: bundle.task.from_agent_name,
679
+ task: bundle.task,
680
+ handoff: bundle.handoff,
681
+ messages: bundle.messages,
682
+ taskUrl: this.linkOf(bundle.task),
683
+ readRepos: rec.readRepos?.map((r) => ({ name: r.name, path: r.path })),
684
+ files: files.map(promptFile),
685
+ channel: await this.channelContext(bundle.task),
686
+ wiki: bundle.wiki,
687
+ });
688
+ if (!resume)
689
+ this.reportPromptSize(taskId, rec.runId, prompt);
690
+ this.taskLog(taskId, `resume with ${fresh.length} replies (session ${resume ?? 'new'})`);
691
+ const result = await this.execute(rec, entry, prompt, resume, 'work');
692
+ await this.afterWork(rec, entry, result, bundle.task);
693
+ }
694
+ /** S10: the agent cannot write .git, so the runner commits its changes after every work run. */
695
+ async commitWork(rec, task) {
696
+ if (!this.o.workspace.commit)
697
+ return;
698
+ try {
699
+ const sha = await this.o.workspace.commit(rec.taskId, `ach: ${task.title ?? rec.taskId} (task ${rec.taskId})`.slice(0, 200));
700
+ if (sha)
701
+ this.taskLog(rec.taskId, `runner committed ${sha}`);
702
+ }
703
+ catch (e) {
704
+ this.taskLog(rec.taskId, `commit failed: ${e?.message ?? e}`);
705
+ }
706
+ }
707
+ async afterWork(rec, entry, result, task0) {
708
+ const { client } = this.o;
709
+ const taskId = rec.taskId;
710
+ const me = await this.ensureMe();
711
+ if (entry.reason === 'auth' || this.fatal)
712
+ return; // B7: post nothing
713
+ await this.commitWork(rec, task0);
714
+ if (entry.reason === 'cancel' || entry.reason === 'stop' || entry.reason === 'lost') {
715
+ if (entry.reason !== 'stop')
716
+ this.state.drop(taskId);
717
+ return;
718
+ }
719
+ if (entry.reason === 'idle') {
720
+ await client.fail(taskId, 'idle timeout', rec.runId).catch((e) => this.log(`fail ${taskId}: ${e?.message ?? e}`));
721
+ this.state.drop(taskId);
722
+ return;
723
+ }
724
+ const bundle = await client.getTask(taskId);
725
+ const task = bundle.task;
726
+ if (isTerminal(task.status)) {
727
+ this.taskLog(taskId, `run ended; task already ${task.status}`);
728
+ this.state.drop(taskId);
729
+ return;
730
+ }
731
+ // C3: never finish a task another run now owns.
732
+ if ((task.run_id ?? '') !== rec.runId) {
733
+ this.taskLog(taskId, `run ended but the task now belongs to run ${task.run_id || '(none)'}; leaving it`);
734
+ this.state.drop(taskId);
735
+ return;
736
+ }
737
+ const open = openRequests(bundle.messages, me.id);
738
+ if (task.status === 'input-required' || open.length) {
739
+ rec.phase = 'parked';
740
+ this.state.put(rec);
741
+ this.taskLog(taskId, `parked: ${open.length} open request(s), status ${task.status}`);
742
+ return;
743
+ }
744
+ // Replies that arrived while the run was still going: resume instead of completing.
745
+ const unseen = unseenReplies(bundle.messages, me.id, rec.seen);
746
+ if (unseen.length && task.status === 'working') {
747
+ rec.phase = 'parked';
748
+ this.state.put(rec);
749
+ entry.followUp = { kind: 'resume', taskId, replies: unseen };
750
+ return;
751
+ }
752
+ // The sender replaced the handoff (the run was interrupted for it, or it
753
+ // arrived just as the run ended): resume with the new version, never finish on the old one.
754
+ if (task.status === 'working' && (task.handoff_version ?? 0) > (rec.handoffVersion ?? 0)) {
755
+ rec.phase = 'parked';
756
+ this.state.put(rec);
757
+ entry.followUp = { kind: 'resume', taskId, replies: [] };
758
+ return;
759
+ }
760
+ if (!result.ok) {
761
+ await client.fail(taskId, `runtime error: ${result.error ?? 'unknown'}`, rec.runId).catch((e) => this.log(`fail ${taskId}: ${e?.message ?? e}`));
762
+ this.state.drop(taskId);
763
+ return;
764
+ }
765
+ if (task.status === 'working') {
766
+ // The agent stopped without `ach complete`, `ach fail` or a question. That
767
+ // is not a finished task: ask it once to finish properly; if it stops
768
+ // silently again, fail the task with its last output so the sender sees
769
+ // what happened (it was recorded as completed before).
770
+ if (!rec.nudged) {
771
+ rec.nudged = true;
772
+ rec.phase = 'parked';
773
+ this.state.put(rec);
774
+ this.taskLog(taskId, 'the agent stopped without completing; asking it once to finish');
775
+ entry.followUp = { kind: 'resume', taskId, replies: [], nudge: true };
776
+ return;
777
+ }
778
+ const last = result.finalText?.trim().slice(0, 4000) || '(no output)';
779
+ await client
780
+ .fail(taskId, `the agent stopped twice without calling ach complete or ach fail. Its last output:\n${last}`, rec.runId)
781
+ .catch((e) => this.log(`fail ${taskId}: ${e?.message ?? e}`));
782
+ this.taskLog(taskId, 'failed: the agent stopped twice without completing');
783
+ }
784
+ this.state.drop(taskId);
785
+ }
786
+ /** Base URL for links shown to agents (§19). */
787
+ base() {
788
+ return this.o.publicUrl || this.o.hub;
789
+ }
790
+ /** The task's absolute permalink: the hub's (ACH_PUBLIC_URL on the hub), else built here. */
791
+ linkOf(t) {
792
+ if (!this.o.publicUrl && typeof t.url === 'string' && /^https?:\/\//.test(t.url))
793
+ return t.url;
794
+ return taskUrl(this.base(), refOf(t));
795
+ }
796
+ /** Memory trial metrics: the total size of a full work prompt (best effort, never blocks the run). */
797
+ reportPromptSize(taskId, runId, prompt) {
798
+ if (!this.o.client.promptSize)
799
+ return;
800
+ this.o.client.promptSize(taskId, runId, prompt.length).catch((e) => this.taskLog(taskId, `prompt size: ${e?.message ?? e}`));
801
+ }
802
+ /** §16: the latest posts of the task's channel (best effort). */
803
+ async channelContext(task) {
804
+ const n = this.o.channelContext ?? 15;
805
+ const name = typeof task.channel_name === 'string' ? task.channel_name : '';
806
+ if (!n || !name || !this.o.client.channelPosts)
807
+ return undefined;
808
+ try {
809
+ const r = await this.o.client.channelPosts(name, { limit: n });
810
+ return { name, posts: r.posts ?? [] };
811
+ }
812
+ catch (e) {
813
+ this.taskLog(task.id, `channel context: ${e?.message ?? e}`);
814
+ return undefined;
815
+ }
816
+ }
817
+ /**
818
+ * §16 mention run: read-only, in --dir, with a run token that may only read
819
+ * the channel and reply in this thread. The hub hands each mention out once.
820
+ */
821
+ async runMention(item, entry) {
822
+ const me = await this.ensureMe();
823
+ const client = this.o.client;
824
+ const mid = item.mention.id;
825
+ const runId = (this.o.newRunId ?? newRunId)();
826
+ let run;
827
+ try {
828
+ run = await client.mentionRun(mid, runId);
829
+ }
830
+ catch (e) {
831
+ this.log(`mention-run ${mid}: ${e?.message ?? e}`);
832
+ return;
833
+ }
834
+ const key = `mention-${mid}`;
835
+ const threadId = run.thread.id;
836
+ const rec = { taskId: key, runId, phase: 'running', workdir: this.o.dir, runToken: run.run_token, tokenAt: Date.now(), seen: [] };
837
+ const structured = !!this.o.adapter.structuredAnswers;
838
+ try {
839
+ const prompt = buildMentionPrompt({
840
+ agentName: me.name,
841
+ ownerName: me.owner_name,
842
+ channel: run.channel_name,
843
+ mention: run.mention,
844
+ post: run.post,
845
+ thread: run.thread,
846
+ replies: run.replies ?? [],
847
+ recent: run.recent ?? [],
848
+ threadUrl: !this.o.publicUrl && typeof run.thread.url === 'string' && /^https?:\/\//.test(run.thread.url) ? run.thread.url : postUrl(this.base(), threadId),
849
+ structured,
850
+ });
851
+ const result = await this.execute(rec, entry, prompt, undefined, 'mention', structured ? ANSWER_SCHEMA : undefined, {
852
+ threadId,
853
+ usage: (cost, est) => client.mentionUsage(mid, runId, cost, est),
854
+ });
855
+ if (structured && result.ok && !entry.reason)
856
+ await this.postStructuredReply(threadId, run.run_token, result.finalText, key);
857
+ }
858
+ finally {
859
+ if (!this.fatal)
860
+ await client.mentionEnd?.(mid, runId).catch((e) => this.log(`mention-end ${mid}: ${e?.message ?? e}`));
861
+ }
862
+ }
863
+ /** Codex mention runs: the runner posts the reply the agent returned. */
864
+ async postStructuredReply(threadId, token, text, logKey) {
865
+ let out;
866
+ try {
867
+ out = JSON.parse(text.trim());
868
+ }
869
+ catch {
870
+ this.taskLog(logKey, 'mention run returned no JSON; nothing posted');
871
+ return;
872
+ }
873
+ const c = (this.o.tokenClient ?? ((t) => new HubClient({ hub: this.o.hub, token: t, cfId: this.o.cfId, cfSecret: this.o.cfSecret })))(token);
874
+ try {
875
+ if (out.action === 'answer' && String(out.text ?? '').trim() && String(out.evidence ?? '').trim()) {
876
+ await c.reply(threadId, String(out.text), { evidence: String(out.evidence) });
877
+ }
878
+ else if (String(out.text ?? '').trim()) {
879
+ await c.reply(threadId, String(out.text), { escalate: true });
880
+ }
881
+ }
882
+ catch (e) {
883
+ this.taskLog(logKey, `posting the reply failed: ${e?.message ?? e}`);
884
+ }
885
+ }
886
+ markHandled(q) {
887
+ this.state.runs.handledQuestions[q.id] = { task: q.task, at: new Date().toISOString() };
888
+ this.state.saveRuns();
889
+ }
890
+ async runAnswer(q, entry) {
891
+ const me = await this.ensureMe();
892
+ const taskId = q.task;
893
+ const runId = (this.o.newRunId ?? newRunId)();
894
+ let token;
895
+ try {
896
+ token = (await this.o.client.answerRun(taskId, q.id, runId)).run_token;
897
+ }
898
+ catch (e) {
899
+ // C4: transient errors (network, 5xx) leave the question unhandled so the
900
+ // next poll retries; a hub refusal (4xx) is final.
901
+ this.log(`answer-run ${q.id}: ${e?.message ?? e}`);
902
+ if (e instanceof HubError && e.status >= 400 && e.status < 500)
903
+ this.markHandled(q);
904
+ return;
905
+ }
906
+ // C4: handled only once the token exists.
907
+ this.markHandled(q);
908
+ try {
909
+ const bundle = await this.o.client.getTask(taskId);
910
+ const cwd = this.state.get(taskId)?.workdir ?? this.o.dir;
911
+ const rec = { taskId, runId, phase: 'running', workdir: cwd, runToken: token, tokenAt: Date.now(), seen: [] };
912
+ const structured = !!this.o.adapter.structuredAnswers;
913
+ const prompt = buildAnswerPrompt({
914
+ agentName: me.name,
915
+ ownerName: me.owner_name,
916
+ task: bundle.task,
917
+ handoff: bundle.handoff,
918
+ messages: bundle.messages,
919
+ question: q,
920
+ structured,
921
+ files: (await this.fetchFiles(taskId, bundle)).map(promptFile),
922
+ });
923
+ const sess = this.state.session(taskId, 'answer');
924
+ const result = await this.execute(rec, entry, prompt, sess && sess.cwd === cwd ? sess.id : undefined, 'answer', structured ? ANSWER_SCHEMA : undefined);
925
+ if (structured && result.ok && !entry.reason)
926
+ await this.postStructuredAnswer(q, token, result.finalText);
927
+ }
928
+ finally {
929
+ // C4: revoke the answer token as soon as the run ends.
930
+ if (!this.fatal)
931
+ await this.o.client.endRun(taskId, runId).catch((e) => this.log(`end-run ${taskId}: ${e?.message ?? e}`));
932
+ }
933
+ }
934
+ /** Codex answer runs (S4): read-only and offline; the runner posts what the agent returned. */
935
+ async postStructuredAnswer(q, token, text) {
936
+ let out;
937
+ try {
938
+ out = JSON.parse(text.trim());
939
+ }
940
+ catch {
941
+ this.taskLog(q.task, 'answer run returned no JSON; the question will escalate');
942
+ return;
943
+ }
944
+ const c = (this.o.tokenClient ?? ((t) => new HubClient({ hub: this.o.hub, token: t, cfId: this.o.cfId, cfSecret: this.o.cfSecret })))(token);
945
+ try {
946
+ if (out.action === 'answer' && String(out.text ?? '').trim() && String(out.evidence ?? '').trim()) {
947
+ await c.postMessage(q.task, { kind: 'answer', body: String(out.text), evidence: String(out.evidence), reply_to: q.id });
948
+ }
949
+ else if (String(out.text ?? '').trim()) {
950
+ await c.postMessage(q.task, { kind: 'decision_request', body: String(out.text), reply_to: q.id });
951
+ }
952
+ }
953
+ catch (e) {
954
+ this.taskLog(q.task, `posting the answer failed: ${e?.message ?? e}`);
955
+ }
956
+ }
957
+ /** Readable roots for the spawned agent besides its cwd (S4). */
958
+ readRoots(cwd, readRepos = [], taskId) {
959
+ const home = (this.o.baseEnv ?? process.env).HOME;
960
+ return [
961
+ ...toolchainReadRoots(home || undefined),
962
+ ...gitCommonDir(cwd),
963
+ join(this.o.home, 'bin'),
964
+ ...readRepos.flatMap((r) => r.roots),
965
+ ...(taskId ? [this.filesDir(taskId)] : []),
966
+ ];
967
+ }
968
+ /** Where a task's attachments are downloaded for its agents (readable, outside the worktree). */
969
+ filesDir(taskId) {
970
+ return join(this.o.home, 'files', taskId.replace(/[^A-Za-z0-9_-]/g, '_'));
971
+ }
972
+ /** The task's attachments, downloaded into filesDir (best effort: a failed download is listed without a path). */
973
+ async fetchFiles(taskId, bundle) {
974
+ const out = [];
975
+ for (const f of taskFiles(bundle)) {
976
+ let path;
977
+ if (this.o.client.download) {
978
+ try {
979
+ path = await this.o.client.download(f, this.filesDir(taskId));
980
+ }
981
+ catch (e) {
982
+ this.taskLog(taskId, `download ${f.name} failed: ${e?.message ?? e}`);
983
+ }
984
+ }
985
+ out.push({ key: f.url, name: f.original, type: f.type, size: f.size, from: f.from, ...(path ? { path } : {}) });
986
+ }
987
+ return out;
988
+ }
989
+ /** Run the adapter with heartbeat, idle watchdog, session persistence and usage posting. */
990
+ async execute(rec, entry, prompt, resume, mode, outputSchema, extra = {}) {
991
+ const { client } = this.o;
992
+ const me = await this.ensureMe();
993
+ const taskId = rec.taskId;
994
+ const perm = mode === 'work' ? effectivePerm(me.perm_level, this.o.perm) : 'read';
995
+ const env = buildAgentEnv(this.o.baseEnv ?? process.env, {
996
+ hub: this.o.hub,
997
+ runToken: rec.runToken,
998
+ taskId: mode === 'mention' ? '' : taskId,
999
+ agentName: me.name,
1000
+ threadId: extra.threadId,
1001
+ handoffVersion: mode === 'work' ? rec.handoffVersion : undefined,
1002
+ publicUrl: this.o.publicUrl,
1003
+ cfId: this.o.cfId,
1004
+ cfSecret: this.o.cfSecret,
1005
+ passEnv: this.o.passEnv,
1006
+ });
1007
+ // B1: HOME is a per-task scratch dir (never the runner's own home).
1008
+ const baseEnv = this.o.baseEnv ?? process.env;
1009
+ const realHome = baseEnv.HOME || undefined;
1010
+ Object.assign(env, prepareAgentHome(this.o.agentHomesDir ?? join(tmpdir(), 'ach-homes'), me.name, taskId, realHome, baseEnv.CODEX_HOME || undefined).env);
1011
+ // C6: heartbeats are not activity. The watchdog fires after idleMs without
1012
+ // adapter events, unless a tool call is still in flight.
1013
+ let lastEvent = Date.now();
1014
+ const inflight = new Set();
1015
+ const hb = mode === 'work'
1016
+ ? setInterval(() => {
1017
+ this.heartbeat(rec).catch((e) => {
1018
+ this.log(`heartbeat ${taskId}: ${e?.message ?? e}`);
1019
+ // C3: 409 = this run no longer owns the task; stop working on it.
1020
+ // Except when the task is completed / failed: that is the agent's own
1021
+ // `ach complete` / `ach fail`; let the turn end so its usage is recorded.
1022
+ const st = e instanceof HubError ? String(e.body?.status ?? '') : '';
1023
+ if (is409(e) && !entry.reason && st !== 'completed' && st !== 'failed') {
1024
+ entry.reason = 'lost';
1025
+ this.taskLog(taskId, 'heartbeat 409: run lost the task; aborting');
1026
+ entry.controller.abort();
1027
+ }
1028
+ });
1029
+ }, this.o.heartbeatMs)
1030
+ : undefined;
1031
+ const idle = setInterval(() => {
1032
+ if (!inflight.size && Date.now() - lastEvent > this.o.idleMs && !entry.reason) {
1033
+ entry.reason = 'idle';
1034
+ this.taskLog(taskId, 'idle timeout; aborting');
1035
+ entry.controller.abort();
1036
+ }
1037
+ }, Math.max(10, Math.min(60_000, Math.floor(this.o.idleMs / 4))));
1038
+ this.taskLog(taskId, `${mode} run start perm=${perm} resume=${resume ?? '-'} cwd=${rec.workdir}`);
1039
+ let result;
1040
+ try {
1041
+ result = await this.o.adapter.run({
1042
+ prompt,
1043
+ cwd: rec.workdir,
1044
+ resume,
1045
+ perm,
1046
+ mode,
1047
+ env,
1048
+ signal: entry.controller.signal,
1049
+ hub: this.o.hub,
1050
+ denyReadPaths: secretPaths(this.o.home),
1051
+ ...(realHome ? { realHome } : {}),
1052
+ achHome: this.o.home,
1053
+ readRoots: this.readRoots(rec.workdir, rec.readRepos, rec.taskId),
1054
+ ...(Array.isArray(me.net_allow) ? { netAllow: me.net_allow } : {}),
1055
+ ...(outputSchema ? { outputSchema } : {}),
1056
+ onEvent: (e) => {
1057
+ lastEvent = Date.now();
1058
+ const id = e.data && typeof e.data === 'object' ? String(e.data.id ?? '') : '';
1059
+ if (e.type === 'tool_start' && id)
1060
+ inflight.add(id);
1061
+ if (e.type === 'tool_end' && id)
1062
+ inflight.delete(id);
1063
+ if (e.type === 'session' && e.data && mode !== 'mention') {
1064
+ this.state.setSession(taskId, mode, { id: String(e.data), cwd: rec.workdir });
1065
+ }
1066
+ this.taskLog(taskId, `${e.type} ${typeof e.data === 'string' ? e.data.slice(0, 500) : JSON.stringify(e.data)?.slice(0, 500)}`);
1067
+ },
1068
+ });
1069
+ }
1070
+ catch (e) {
1071
+ result = { finalText: '', ok: false, error: String(e?.message ?? e) };
1072
+ }
1073
+ finally {
1074
+ if (hb)
1075
+ clearInterval(hb);
1076
+ clearInterval(idle);
1077
+ }
1078
+ if (result.sessionId && mode !== 'mention')
1079
+ this.state.setSession(taskId, mode, { id: result.sessionId, cwd: rec.workdir });
1080
+ // §14.7: usage after every adapter run, success or failure.
1081
+ const usage = extra.usage ?? ((cost, est) => client.usage(taskId, rec.runId, cost, est));
1082
+ if (!this.fatal)
1083
+ await usage(Number(result.costUsd ?? 0), !!result.estimated).catch((e) => this.log(`usage ${taskId}: ${e?.message ?? e}`));
1084
+ this.taskLog(taskId, `${mode} run end ok=${result.ok} cost=${result.costUsd ?? 0} err=${result.error ?? ''}`);
1085
+ return result;
1086
+ }
1087
+ /** Polls until stop() or a 401 (B7: resolves 'unauthorized' after aborting every job). */
1088
+ async start() {
1089
+ this.recover();
1090
+ this.pump();
1091
+ while (!this.stopped) {
1092
+ try {
1093
+ await this.tick();
1094
+ }
1095
+ catch (e) {
1096
+ this.log(`poll: ${e?.message ?? e}`);
1097
+ }
1098
+ if (this.stopped)
1099
+ break;
1100
+ await new Promise((r) => {
1101
+ const t = setTimeout(r, this.o.intervalMs);
1102
+ this.wake = () => {
1103
+ clearTimeout(t);
1104
+ r();
1105
+ };
1106
+ });
1107
+ }
1108
+ if (this.fatal) {
1109
+ await Promise.all([...this.active.values()].map((a) => a.done));
1110
+ return 'unauthorized';
1111
+ }
1112
+ return 'stopped';
1113
+ }
1114
+ /** Stop polling; abort running jobs without failing their tasks (they resume on restart). */
1115
+ async stop() {
1116
+ this.stopped = true;
1117
+ for (const a of this.active.values()) {
1118
+ a.reason = a.reason ?? 'stop';
1119
+ a.controller.abort();
1120
+ }
1121
+ await Promise.all([...this.active.values()].map((a) => a.done));
1122
+ }
1123
+ }
1124
+ /** `ach run --gc`: remove work dirs of tasks terminal for more than `days` (branches are kept). */
1125
+ export async function gcWorktrees(client, ws, o = {}) {
1126
+ const cutoff = (o.now ?? Date.now()) - (o.days ?? 7) * 86_400_000;
1127
+ const removed = [];
1128
+ for (const id of ws.list()) {
1129
+ try {
1130
+ const { task } = await client.getTask(id);
1131
+ if (!isTerminal(task.status))
1132
+ continue;
1133
+ const when = Date.parse(String(task.updated ?? '').replace(' ', 'T')) || ws.mtime(id);
1134
+ if (when > cutoff)
1135
+ continue;
1136
+ await ws.remove(id);
1137
+ removed.push(id);
1138
+ }
1139
+ catch (e) {
1140
+ o.log?.(`gc ${id}: ${e?.message ?? e}`);
1141
+ }
1142
+ }
1143
+ return removed;
1144
+ }
1145
+ function promptFile(f) {
1146
+ const { key: _key, ...rest } = f;
1147
+ return rest;
1148
+ }