@staix/agent-hub 0.12.45 → 0.13.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 (99) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +83 -116
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -7
  5. package/docs/agent-notes/budget.md +6 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +6 -1
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +10 -0
  10. package/docs/agent-notes/tests.md +4 -2
  11. package/docs/dogfood.md +92 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +250 -19
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +8 -2
  16. package/docs/sdlc.md +36 -6
  17. package/docs/security.md +20 -11
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +759 -8
  20. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  21. package/docs/verification/2026-10-22-code-facts.md +78 -0
  22. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  23. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  24. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  25. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  26. package/docs/verified.json +14 -14
  27. package/package.json +1 -1
  28. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  29. package/plugins/agent-hub/server.js +613 -329
  30. package/src/adapters/agy/entry.ts +5 -4
  31. package/src/adapters/claude-channel.ts +20 -9
  32. package/src/adapters/codex-appserver.ts +18 -2
  33. package/src/adapters/codex-review-isolation.ts +12 -5
  34. package/src/adapters/compat.ts +140 -0
  35. package/src/adapters/definitions/builtin-metadata.ts +2 -1
  36. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  38. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  39. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  40. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  41. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  42. package/src/adapters/definitions/qwen-native.ts +7 -3
  43. package/src/adapters/definitions/qwen.ts +2 -1
  44. package/src/adapters/jcode.ts +72 -31
  45. package/src/adapters/kimi-review.ts +6 -3
  46. package/src/adapters/registry-types.ts +3 -1
  47. package/src/adapters/registry.ts +1 -0
  48. package/src/cli/agents.ts +40 -0
  49. package/src/cli/console-review.ts +54 -0
  50. package/src/cli/console-state.ts +124 -29
  51. package/src/cli/console-symbols.ts +74 -0
  52. package/src/cli/console-usage.ts +89 -0
  53. package/src/cli/console.ts +26 -1
  54. package/src/cli/help.ts +16 -6
  55. package/src/cli/identity.ts +29 -6
  56. package/src/cli/init.ts +109 -11
  57. package/src/cli/main.ts +122 -11
  58. package/src/cli/output.ts +44 -1
  59. package/src/cli/preview.ts +1 -1
  60. package/src/cli/sdlc-release-guard.ts +82 -0
  61. package/src/hub/action-class.ts +362 -0
  62. package/src/hub/action-policy.ts +106 -0
  63. package/src/hub/board.ts +43 -8
  64. package/src/hub/budget.ts +71 -2
  65. package/src/hub/bus.ts +43 -3
  66. package/src/hub/checks.ts +80 -31
  67. package/src/hub/code-facts.ts +223 -0
  68. package/src/hub/config-apply.ts +10 -1
  69. package/src/hub/config-trust.ts +7 -4
  70. package/src/hub/daemon.ts +237 -22
  71. package/src/hub/dogfood-read.ts +199 -0
  72. package/src/hub/dogfood.ts +163 -0
  73. package/src/hub/envelope.ts +1 -1
  74. package/src/hub/events.ts +11 -2
  75. package/src/hub/hub-tools.ts +24 -2
  76. package/src/hub/inbox-polling.ts +25 -0
  77. package/src/hub/instances.ts +73 -1
  78. package/src/hub/profiles.ts +7 -2
  79. package/src/hub/release-worktree.ts +56 -0
  80. package/src/hub/review-brief.ts +36 -0
  81. package/src/hub/sdlc-queue.ts +31 -9
  82. package/src/hub/sdlc-release-authorization.ts +173 -0
  83. package/src/hub/sdlc.ts +49 -11
  84. package/src/hub/settings.ts +5 -2
  85. package/src/hub/snapshots.ts +93 -8
  86. package/src/hub/symbol-normalizer.ts +26 -0
  87. package/src/hub/task-code-symbols.ts +90 -0
  88. package/src/hub/task-sweep.ts +3 -2
  89. package/src/hub/tasks.ts +292 -54
  90. package/src/hub/test-integrity.ts +162 -0
  91. package/src/hub/usage-rollup.ts +361 -0
  92. package/src/local/sandbox.ts +5 -3
  93. package/src/ui/index.html +1 -0
  94. package/templates/AGENTS.block.md +1 -1
  95. package/templates/profiles/dev.json +6 -1
  96. package/templates/profiles/general.json +6 -1
  97. package/templates/profiles/research.json +6 -1
  98. package/templates/profiles/writing.json +6 -1
  99. package/templates/release-guard.block.md +6 -0
@@ -0,0 +1,106 @@
1
+ import { ACTION_CLASSES, type ActionClass } from "./action-class.ts";
2
+ import { sanitize } from "./envelope.ts";
3
+
4
+ export interface ActionPolicyConfig { enabled: boolean; always_ask: ActionClass[]; budgets: Record<ActionClass, number> }
5
+ const DEFAULT_BUDGETS: Record<ActionClass, number> = { publish: 1, destroy: 3, "external-write": 5, install: 2 };
6
+ /** Defaults are host policy; overrides belong only in untracked config.local.json.
7
+ * ponytail: startup adopts that local policy without a retained person confirmation.
8
+ * Before adapter enforcement (#312/#313), bind weakened startup policy to person
9
+ * confirmation or surface it as attention; slice 1 never enforces these grants.
10
+ */
11
+ export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
12
+ const fail = (): never => { throw new Error("action_policy: expected enabled, always_ask and class budgets (integers 0..1000)"); };
13
+ if (value === undefined) return { enabled: true, always_ask: [...ACTION_CLASSES], budgets: { ...DEFAULT_BUDGETS } };
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) return fail();
15
+ const raw = value as Record<string, unknown>;
16
+ if (Object.keys(raw).some(key => !["enabled", "always_ask", "budgets"].includes(key))) return fail();
17
+ if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") return fail();
18
+ const classes = raw.always_ask === undefined ? ACTION_CLASSES : raw.always_ask;
19
+ if (!Array.isArray(classes) || classes.length > ACTION_CLASSES.length || new Set(classes).size !== classes.length || classes.some(key => !ACTION_CLASSES.includes(key))) return fail();
20
+ const budgets = { ...DEFAULT_BUDGETS };
21
+ if (raw.budgets !== undefined) {
22
+ if (!raw.budgets || typeof raw.budgets !== "object" || Array.isArray(raw.budgets)) return fail();
23
+ for (const [key, amount] of Object.entries(raw.budgets)) {
24
+ if (!ACTION_CLASSES.includes(key as ActionClass) || typeof amount !== "number" || !Number.isSafeInteger(amount) || amount < 0 || amount > 1000) return fail();
25
+ budgets[key as ActionClass] = amount;
26
+ }
27
+ }
28
+ return { enabled: raw.enabled ?? true, always_ask: [...classes], budgets } as ActionPolicyConfig;
29
+ }
30
+
31
+ export interface ActionSessionBudget { readonly peer: string; readonly session: string }
32
+ export interface ActionChallenge { readonly classes: readonly ActionClass[] }
33
+ export type ActionDecision = { kind: "native" } | { kind: "ask"; classes: ActionClass[]; challenge?: ActionChallenge } | { kind: "budget"; classes: ActionClass[] };
34
+ interface Session { handle: ActionSessionBudget; policy: string; remaining: Record<ActionClass, number> }
35
+ const empty = (): Record<ActionClass, number> => ({ publish: 0, destroy: 0, "external-write": 0, install: 0 });
36
+ /** Host-only ledger: session ids and policy come from current runtime ports, never a tool argument. */
37
+ export class ActionBudgets {
38
+ private sessions = new Map<string, Session>();
39
+ private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[] }>();
40
+ constructor(private readonly config: () => ActionPolicyConfig | undefined, private readonly currentSession: (peer: string) => string | undefined) {}
41
+ bindSession(peer: string, session: string): ActionSessionBudget | undefined {
42
+ if (!session || this.currentSession(peer) !== session) return undefined;
43
+ const previous = this.sessions.get(peer);
44
+ if (previous?.handle.session === session) return previous.handle;
45
+ const handle = Object.freeze({ peer, session });
46
+ this.sessions.set(peer, { handle, policy: "", remaining: empty() });
47
+ return handle;
48
+ }
49
+ revoke(peer: string): void { this.sessions.delete(peer); }
50
+ policyChanged(): void {
51
+ for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); }
52
+ this.challenges = new WeakMap();
53
+ }
54
+ private current(handle: ActionSessionBudget | undefined): Session | undefined {
55
+ if (!handle) return undefined;
56
+ const state = this.sessions.get(handle.peer);
57
+ if (state?.handle !== handle || this.currentSession(handle.peer) !== handle.session) return undefined;
58
+ const policy = JSON.stringify(actionPolicyConfig(this.config()));
59
+ if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); }
60
+ return state;
61
+ }
62
+ private selected(actions: readonly ActionClass[]): ActionClass[] {
63
+ const config = actionPolicyConfig(this.config());
64
+ return config.enabled ? ACTION_CLASSES.filter(key => actions.includes(key) && config.always_ask.includes(key)) : [];
65
+ }
66
+ /** A budget decision consumes every selected class atomically; native never grants permission. */
67
+ request(handle: ActionSessionBudget | undefined, actions: readonly ActionClass[]): ActionDecision {
68
+ const state = this.current(handle), classes = this.selected(actions);
69
+ if (!classes.length) return { kind: "native" };
70
+ if (!state) return { kind: "ask", classes };
71
+ if (classes.some(key => state.remaining[key] <= 0)) {
72
+ const challenge = Object.freeze({ classes: Object.freeze([...classes]) });
73
+ this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes] });
74
+ return { kind: "ask", classes, challenge };
75
+ }
76
+ for (const key of classes) state.remaining[key]--;
77
+ return { kind: "budget", classes };
78
+ }
79
+ /** Explicit class-budget approval only, never an ordinary allow_once; this invocation consumes slot one. */
80
+ approve(challenge: ActionChallenge | undefined): boolean {
81
+ if (!challenge) return false;
82
+ const grant = this.challenges.get(challenge); this.challenges.delete(challenge);
83
+ if (!grant) return false;
84
+ const state = this.current(grant.handle), config = actionPolicyConfig(this.config());
85
+ if (!state || state.policy !== grant.policy) return false;
86
+ for (const key of grant.classes) state.remaining[key] = Math.max(0, config.budgets[key] - 1);
87
+ return true;
88
+ }
89
+ /** Read-only projection: no session is created, rebound or reset by a status poll. */
90
+ status(peer: string, enforced: boolean) {
91
+ const config = actionPolicyConfig(this.config()), state = this.sessions.get(peer);
92
+ const bound = !!state && this.currentSession(peer) === state.handle.session && state.policy === JSON.stringify(config);
93
+ return { enabled: config.enabled, alwaysAsk: [...config.always_ask], limits: { ...config.budgets }, remaining: bound ? { ...state.remaining } : empty(), sessionBound: bound,
94
+ enforceable: enforced, note: !enforced ? `always-ask not enforceable: adapter integration pending${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled" };
95
+ }
96
+ }
97
+
98
+ /** Adapter integrations must pass the host's publicView and current command/private-turn screens. */
99
+ export function actionApprovalText<T>(classes: readonly ActionClass[], command: string, task: T | undefined,
100
+ ports: { publicView(task: T): Record<string, unknown>; private: boolean; commandView(command: string): string | undefined }): string {
101
+ const view = task === undefined ? undefined : ports.publicView(task);
102
+ const title = typeof view?.title === "string" ? sanitize(view.title).replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 160) : undefined;
103
+ const shown = ports.private ? undefined : ports.commandView(command);
104
+ const line = shown === undefined ? "[private command withheld]" : sanitize(shown).replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 512);
105
+ return `Action approval (${ACTION_CLASSES.filter(key => classes.includes(key)).join(", ")})${title ? `; task ${title}` : ""}: ${line}`;
106
+ }
package/src/hub/board.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
1
2
  import { planFingerprint } from "./plan-conformance.ts";
2
3
  import type { ReviewerPoolReason } from "./reviewer-pool.ts";
3
4
  import type { GitHubSource } from "./github-import.ts";
@@ -105,6 +106,8 @@ export interface Task extends PlanningFields {
105
106
  plan?: TaskPlan;
106
107
  /** Artifact identities and bounded evidence, owned by Tasks (#257). */
107
108
  sdlc?: SdlcReceipt;
109
+ /** Host-derived observation for one head, never accepted from task tool arguments. */
110
+ codeSymbols?: TaskCodeSymbols;
108
111
  /** Tasks that must be approved before this one is offered; planning edits are limited to unstarted backlog. */
109
112
  deps?: number[];
110
113
  /** Who routing offers the task first once nothing is left to wait for (issue #207); null when nobody was named. */
@@ -155,6 +158,8 @@ const MOVES: Record<TaskState, TaskState[]> = {
155
158
  };
156
159
 
157
160
  const JSON_COLS = ["refs", "plan", "deps", "signals", "history", "sdlc"] as const;
161
+ // Separate storage keeps older SELECT * readers from exporting unscreened derived metadata.
162
+ const TASK_SELECT = "SELECT tasks.*, (SELECT CASE WHEN length(CAST(payload AS BLOB)) <= 65536 THEN payload END FROM task_code_symbols WHERE task=tasks.id) AS code_symbols FROM tasks";
158
163
 
159
164
  /** Task board in `.agenthub/state/hub.db`. It outlives the hub process: `ahub kill` leaves the file. */
160
165
  export class Board {
@@ -225,6 +230,8 @@ export class Board {
225
230
  if (!have.has("external_issue")) this.db.run("ALTER TABLE tasks ADD COLUMN external_issue INTEGER");
226
231
  this.db.run("CREATE UNIQUE INDEX IF NOT EXISTS tasks_external_issue ON tasks(external_repo,external_issue) WHERE external_repo IS NOT NULL AND external_issue IS NOT NULL");
227
232
  if (!have.has("sdlc")) this.db.run("ALTER TABLE tasks ADD COLUMN sdlc TEXT");
233
+ this.db.run("CREATE TABLE IF NOT EXISTS task_code_symbols (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
234
+ this.db.run("DELETE FROM task_code_symbols WHERE task NOT IN (SELECT id FROM tasks) OR task IN (SELECT id FROM tasks WHERE json_valid(signals) AND EXISTS (SELECT 1 FROM json_each(signals) WHERE value IN ('pii','pii_path')))");
228
235
  if (!have.has("task_role")) this.db.run("ALTER TABLE tasks ADD COLUMN task_role TEXT");
229
236
  if (!have.has("project_profile")) this.db.run("ALTER TABLE tasks ADD COLUMN project_profile TEXT");
230
237
  for (const col of ["rank", "priority", "release", "planned_start", "due"]) {
@@ -253,17 +260,17 @@ export class Board {
253
260
  }
254
261
 
255
262
  imported(repo: string, issue: number): Task | undefined {
256
- const row = this.db.query("SELECT * FROM tasks WHERE external_repo=? AND external_issue=?").get(repo.toLowerCase(), issue) as Record<string, unknown> | null;
263
+ const row = this.db.query(`${TASK_SELECT} WHERE external_repo=? AND external_issue=?`).get(repo.toLowerCase(), issue) as Record<string, unknown> | null;
257
264
  return row ? parse(row) : undefined;
258
265
  }
259
266
 
260
267
  get(id: number): Task | undefined {
261
- const row = this.db.query("SELECT * FROM tasks WHERE id = ?").get(id) as Record<string, unknown> | null;
268
+ const row = this.db.query(`${TASK_SELECT} WHERE id = ?`).get(id) as Record<string, unknown> | null;
262
269
  return row ? parse(row) : undefined;
263
270
  }
264
271
 
265
272
  list(state?: TaskState): Task[] {
266
- const rows = state ? this.db.query("SELECT * FROM tasks WHERE state = ? ORDER BY id").all(state) : this.db.query("SELECT * FROM tasks ORDER BY id").all();
273
+ const rows = state ? this.db.query(`${TASK_SELECT} WHERE state = ? ORDER BY id`).all(state) : this.db.query(`${TASK_SELECT} ORDER BY id`).all();
267
274
  return (rows as Record<string, unknown>[]).map(parse);
268
275
  }
269
276
 
@@ -318,7 +325,7 @@ export class Board {
318
325
 
319
326
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
320
327
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
321
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
328
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
322
329
  const task = this.get(id);
323
330
  if (!task) throw new Error(`no task #${id}`);
324
331
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -328,6 +335,17 @@ export class Board {
328
335
  throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
329
336
  }
330
337
  const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
338
+ const headOf = (row: Task) => row.refs.commit ?? row.sdlc?.head ?? row.sdlc?.localHead;
339
+ const privateSymbols = next.signals.includes("pii") || next.signals.includes("pii_path");
340
+ if (Object.hasOwn(patch, "codeSymbols")) {
341
+ if (codeSymbolsAuthority !== HOST_CODE_SYMBOLS) throw new Error("code symbols require host authority");
342
+ const observation = patch.codeSymbols === undefined ? undefined : parseTaskCodeSymbols(patch.codeSymbols);
343
+ if (patch.codeSymbols !== undefined && !observation) throw new Error("invalid task code symbols");
344
+ if (observation && (privateSymbols || observation.head !== headOf(next))) throw new Error("code symbol head or privacy differs from task");
345
+ next.codeSymbols = observation;
346
+ } else if (privateSymbols || headOf(task) !== headOf(next) || task.refs.repo !== next.refs.repo || JSON.stringify(task.refs.paths) !== JSON.stringify(next.refs.paths) || task.sdlc?.repo !== next.sdlc?.repo) {
347
+ next.codeSymbols = undefined;
348
+ }
331
349
  if (next.state === "backlog" && (next.owner || next.reviewer)) throw new Error(`task #${id}: backlog cannot have an owner or reviewer`);
332
350
  if (!untouchedReturn) {
333
351
  if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
@@ -350,9 +368,15 @@ export class Board {
350
368
  const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
351
369
  const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
352
370
  const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
353
- this.taskWrite(() => this.db
354
- .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
355
- .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
371
+ this.taskWrite(() => {
372
+ const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
373
+ .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id);
374
+ if (result.changes) {
375
+ if (next.codeSymbols) this.db.query("INSERT INTO task_code_symbols(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(id, JSON.stringify(next.codeSymbols));
376
+ else this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(id);
377
+ }
378
+ return result;
379
+ });
356
380
  const updated = this.get(id)!;
357
381
  this.changed(updated, history.at(-1)!);
358
382
  return updated;
@@ -431,7 +455,10 @@ export class Board {
431
455
  }
432
456
  if (this.list().some(task => task.id !== id && !ids.has(task.id) && (task.deps?.some(dep => ids.has(dep)) || task.sdlc?.phase?.tasks.some(ref => ids.has(ref.id) || ref.after.some(dep => ids.has(dep)))))) throw new Error("split children are referenced by other work");
433
457
  const restored = this.plan(id, by, { deps: entry!.planning!.previous.deps ?? [] }, revision, `undo split revision ${revision}`);
434
- for (const child of ids) this.taskWrite(() => this.db.query("DELETE FROM tasks WHERE id=?").run(child));
458
+ for (const child of ids) this.taskWrite(() => {
459
+ this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(child);
460
+ return this.db.query("DELETE FROM tasks WHERE id=?").run(child);
461
+ });
435
462
  return restored;
436
463
  });
437
464
  }
@@ -517,6 +544,14 @@ function parse(row: Record<string, unknown>): Task {
517
544
  if (col === "sdlc" && row[col] == null) delete out[col];
518
545
  else out[col] = JSON.parse(String(row[col]));
519
546
  }
547
+ try {
548
+ const rawSymbols = row.code_symbols == null ? undefined : String(row.code_symbols);
549
+ const symbols = rawSymbols === undefined || Buffer.byteLength(rawSymbols) > 64 * 1024 ? undefined : parseTaskCodeSymbols(JSON.parse(rawSymbols));
550
+ const head = (out.refs as TaskRefs).commit ?? (out.sdlc as SdlcReceipt | undefined)?.head ?? (out.sdlc as SdlcReceipt | undefined)?.localHead;
551
+ const privateSymbols = (out.signals as string[]).some(signal => signal === "pii" || signal === "pii_path");
552
+ if (symbols && symbols.head === head && !privateSymbols) out.codeSymbols = symbols;
553
+ } catch { /* Malformed optional derived evidence stays unavailable. */ }
554
+ delete out.code_symbols;
520
555
  if (out.project_profile != null) Object.assign(out, JSON.parse(String(out.project_profile)));
521
556
  delete out.project_profile;
522
557
  if (out.external_repo != null && out.external_issue != null) out.external = { provider: "github", repo: String(out.external_repo), issue: Number(out.external_issue), url: `https://github.com/${out.external_repo}/issues/${out.external_issue}` };
package/src/hub/budget.ts CHANGED
@@ -49,6 +49,13 @@ export interface BudgetConfig {
49
49
  // wait_max_min is off here, as approvals.notify is: a project config turns it on (30 unless it says otherwise).
50
50
  export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
51
51
 
52
+ export type BudgetLedgerEntry =
53
+ | { type: "budget_pause"; peer: PeerId; reason: string; window: string; resetsAt: number; by: "hub" | "person" }
54
+ | { type: "budget_resume"; peer: PeerId; reason: string; window: string; resetsAt: number; by: "hub" | "person" }
55
+ | { type: "quota_reset"; peer: PeerId; window: string; cause: "resets_at_moved" | "used_dropped"; resetsAt?: number; previousResetsAt?: number; used: number; previousUsed: number; measuredAt?: string };
56
+ /** The window a pause record names: the id before ` window at` in its reason (`5h window at 93% (...)`), else unknown. */
57
+ const reasonWindow = (reason: string): string => /^(\S+) window at /.exec(reason)?.[1] ?? "unknown";
58
+
52
59
  export interface BudgetHooks {
53
60
  /** Startup-bound project resolver; undefined keeps builtin-only compatibility. */
54
61
  definitionOf?(peer: PeerId): AdapterDefinition | undefined;
@@ -70,10 +77,23 @@ export interface BudgetHooks {
70
77
  resumed(record: PauseRecord): void;
71
78
  /** Every reading as it arrives, with the time it was measured (telemetry, issue #40). */
72
79
  reading?(peer: PeerId, windows: UsageWindow[], hard: boolean, at: number): void;
80
+ /**
81
+ * The limit ledger (#683 U1): a pause or resume of the account, and a window reset derived from its readings. Ids, windows and
82
+ * numbers only. Telemetry never costs a pause or a reading, so a throwing hook is ignored.
83
+ */
84
+ ledger?(entry: BudgetLedgerEntry): void;
73
85
  notify(line: string): void;
74
86
  }
75
87
 
76
88
  const HYSTERESIS = 0.1;
89
+ /** A window whose `used` falls by this much inside one window id has been reset (#683 U1). */
90
+ const RESET_DROP = 0.5;
91
+ /**
92
+ * A provider's `resetsAt` for one window wobbles by seconds between readings (observed on the real log: 25 of 85 first-cut resets were jumps
93
+ * of 1 to 9 s with `used` unchanged, five more of about 10 min) while a real reset moves it by hours (the smallest of the 53 real ones: 2.4 h).
94
+ * A forward move of this much or less is the same window, not a reset (#683 U1).
95
+ */
96
+ const RESET_JITTER_MS = 15 * 60_000;
77
97
  /** Mark a pause whose handoff was a wait, and one whose wait was undone, in its reason (issue #36). */
78
98
  const WAIT_NOTE = "; keeps its work:";
79
99
  const WAITED_NOTE = "; waited, then the reset moved to";
@@ -90,6 +110,10 @@ export class Budget {
90
110
  private readonly readings = new Map<PeerId, Map<string, Reading>>();
91
111
  private readonly pausing = new Map<PeerId, object>();
92
112
  private readonly handingOff = new Set<PeerId>();
113
+ /** The last reading of each window of an account, for the derived `quota_reset` (see `deriveResets`). */
114
+ private readonly lastSeen = new Map<PeerId, Map<string, { used: number; resetsAt?: number; at: number }>>();
115
+ /** `peer|window|resetsAt` of every reset already written this run, so a duplicate reading adds no reset. */
116
+ private readonly resetsRecorded = new Set<string>();
93
117
  /** `ahub budget resume`: the owner overrode a pause; readings over the gate are ignored for that peer until then. */
94
118
  private readonly ignoreUntil = new Map<PeerId, number>();
95
119
  /** Chosen fallback bounds belong to a window, never to every window of the peer. */
@@ -216,6 +240,7 @@ export class Budget {
216
240
  try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
217
241
  }
218
242
  try { this.hooks.reading?.(reporter, windows, hard, at); } catch { /* telemetry never costs a reading */ }
243
+ this.deriveResets(peer, windows, at);
219
244
  const mine = this.readings.get(peer) ?? new Map<string, Reading>();
220
245
  this.readings.set(peer, mine);
221
246
  for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
@@ -230,6 +255,45 @@ export class Budget {
230
255
  return [...(this.readings.get(peer)?.values() ?? [])].filter((r) => this.now() - r.at <= staleMs && !(r.resetsAt && r.resetsAt <= this.now()));
231
256
  }
232
257
 
258
+ /**
259
+ * `quota_reset` (#683 U1), derived from two readings of one window id of the account: the window's `resetsAt` moved forward (a new
260
+ * window began, by more than RESET_JITTER_MS), or `used` fell by RESET_DROP or more inside one window id (the provider emptied it before its stated reset). The
261
+ * baseline is the last reading seen, kept apart from `readings` (a resume clears those) so a reset that ended a pause is still seen.
262
+ * One entry per peer, window and `resetsAt`: a repeated reading, a replay after a restart of the same value, or a second member of the
263
+ * account adds none. A stale file reading (`at` older than the baseline) never counts: it describes an earlier window.
264
+ */
265
+ private deriveResets(peer: PeerId, windows: UsageWindow[], at: number): void {
266
+ const last = this.lastSeen.get(peer) ?? new Map<string, { used: number; resetsAt?: number; at: number }>();
267
+ this.lastSeen.set(peer, last);
268
+ for (const w of windows) {
269
+ const used = Math.max(0, Math.min(1, w.used)), before = last.get(w.id);
270
+ if (before && at >= before.at) {
271
+ const moved = w.resetsAt !== undefined && before.resetsAt !== undefined && w.resetsAt - before.resetsAt > RESET_JITTER_MS;
272
+ const sameWindow = w.resetsAt === undefined || before.resetsAt === undefined || Math.abs(w.resetsAt - before.resetsAt) <= RESET_JITTER_MS;
273
+ const dropped = !moved && before.used - used >= RESET_DROP && sameWindow;
274
+ if (moved || dropped) {
275
+ const key = `${peer}|${w.id}|${w.resetsAt ?? "none"}`;
276
+ if (!this.resetsRecorded.has(key)) {
277
+ this.resetsRecorded.add(key);
278
+ this.writeLedger({ type: "quota_reset", peer, window: w.id, cause: moved ? "resets_at_moved" : "used_dropped", ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}),
279
+ ...(before.resetsAt !== undefined ? { previousResetsAt: before.resetsAt } : {}), used, previousUsed: before.used, ...(Math.abs(this.now() - at) > 60_000 ? { measuredAt: new Date(at).toISOString() } : {}) });
280
+ }
281
+ }
282
+ }
283
+ if (!before || at >= before.at) last.set(w.id, { used, ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), at });
284
+ }
285
+ }
286
+
287
+ /** The resets already in the ledger from earlier hub runs, so a replayed reading adds none (the daemon reads them from events.jsonl). */
288
+ seedResets(known: Iterable<{ peer: PeerId; window: string; resetsAt?: number }>): void {
289
+ for (const r of known) this.resetsRecorded.add(`${r.peer}|${r.window}|${r.resetsAt ?? "none"}`);
290
+ }
291
+
292
+ /** One ledger entry; a failing hook never costs the pause, the resume or the reading it describes. */
293
+ private writeLedger(entry: BudgetLedgerEntry): void {
294
+ try { this.hooks.ledger?.(entry); } catch { /* telemetry */ }
295
+ }
296
+
233
297
  /** The owner's override: lift the pause now and leave the peer alone until the window that caused it has reset. */
234
298
  override(peer: PeerId): boolean {
235
299
  peer = this.account(peer);
@@ -237,7 +301,7 @@ export class Budget {
237
301
  if (!open) return false;
238
302
  this.ignoreUntil.set(peer, open.resetsAt);
239
303
  const bounds = this.resetBounds.get(peer);
240
- this.resume(peer, "overridden by the console user");
304
+ this.resume(peer, "overridden by the console user", "person");
241
305
  // A new refusal may end the override before this same window has reset.
242
306
  if (bounds) this.resetBounds.set(peer, bounds);
243
307
  return true;
@@ -298,6 +362,10 @@ export class Budget {
298
362
  if (this.recoveryHeld) { this.deferred = true; return; }
299
363
  if (this.closed) return; // the hub is shutting down: nothing is recorded, the next run sees the reading again
300
364
  this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary, moved) VALUES (?, ?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null, JSON.stringify(open?.moved ?? []));
365
+ // A new pause that replaces an expired record (its window reset while nobody was attached to be resumed) ends that pause: without this
366
+ // the ledger would hold two open pauses for one peer, and a pause-duration timeline would be wrong (#683 U1).
367
+ if (open) this.writeLedger({ type: "budget_resume", peer, reason: "replaced by a new pause after its window reset", window: reasonWindow(open.reason), resetsAt: open.resetsAt, by: "hub" });
368
+ this.writeLedger({ type: "budget_pause", peer, reason, window: worst.id, resetsAt, by: "hub" });
301
369
  this.hooks.pause(peer);
302
370
  this.hooks.notify(`budget: ${peer} paused, ${reason}; resumes ${new Date(resetsAt).toLocaleTimeString()}${summary ? "; checkpoint received" : hard ? "; hard limit, no checkpoint" : "; no checkpoint"}`);
303
371
  await this.finishHandoff(peer, summary);
@@ -371,11 +439,12 @@ export class Budget {
371
439
  }
372
440
  }
373
441
 
374
- private resume(peer: PeerId, why: string): void {
442
+ private resume(peer: PeerId, why: string, by: "hub" | "person" = "hub"): void {
375
443
  peer = this.account(peer);
376
444
  const record = this.record(peer);
377
445
  if (!record) return;
378
446
  this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(peer);
447
+ this.writeLedger({ type: "budget_resume", peer, reason: why, window: reasonWindow(record.reason), resetsAt: record.resetsAt, by });
379
448
  this.readings.get(peer)?.clear(); // the old numbers describe the window that just ended
380
449
  this.resetBounds.delete(peer);
381
450
  // The notice is queued while the peer is still paused, so it leads the first delivery: the peer learns what moved
package/src/hub/bus.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
1
+ import { appendNote, DIGEST, frame, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
2
  import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
3
  import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
4
4
  import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
@@ -103,6 +103,9 @@ const MAX_ATTEMPTS = 3;
103
103
  // ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
104
104
  // Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
105
105
  const DIGEST_MAX = 10;
106
+ // #713: a pull-only peer reads between steps, often minutes apart, so one pull drains its queue up to this many bytes of rendered text instead of ten items.
107
+ // Bounded, not unbounded: a queue larger than the budget takes more than one pull, and the reply says how many are still waiting. Push peers keep DIGEST_MAX.
108
+ const PULL_BUDGET_BYTES = 64 * 1024;
106
109
  const NOTES_KEPT = 10;
107
110
 
108
111
  /**
@@ -479,7 +482,11 @@ export class Bus {
479
482
  return;
480
483
  }
481
484
  const recipientConsent = actor === `peer:${record.peer}` && action === "retry" && record.state === "needs_review";
482
- if (["dispatching", "accepted"].includes(record.state) || (this.peers.get(record.peer)?.state === "busy" && !recipientConsent)) throw new Error("active delivery cannot be resolved");
485
+ // #713: a `queued` delivery was never handed to the peer, so nothing is in flight and a busy peer (a pull-only TUI is busy most of the time) does not block
486
+ // discarding it. `dispatching` and `accepted` stay refused, and every other resolution keeps the busy fence.
487
+ if (["dispatching", "accepted"].includes(record.state)) throw new Error("active delivery cannot be resolved");
488
+ const queuedDiscard = record.state === "queued" && action === "discard";
489
+ if (this.peers.get(record.peer)?.state === "busy" && !recipientConsent && !queuedDiscard) throw new Error("peer busy: its delivery cannot be resolved while it works");
483
490
  if (record.revision !== revision) throw new Error("stale delivery revision");
484
491
  if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
485
492
  if (action === "retry" && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
@@ -907,6 +914,18 @@ export class Bus {
907
914
  if (this.withdrawn.has(env.id)) return;
908
915
  const queue = this.queues.get(id) ?? [];
909
916
  this.queues.set(id, queue);
917
+ // #713: a newer "task T is done, check your work" notice for this recipient replaces an earlier queued one for the same T: only the newest is useful, and the older
918
+ // one is discarded through the same stale/discard path with a journal outcome. Never one that already failed once or is being prepared, as for supervision.
919
+ const completedFor = env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.completedFor) ? env.refs!.completedFor : undefined;
920
+ if (!front && completedFor !== undefined) {
921
+ for (let i = queue.length - 1; i >= 0; i--) {
922
+ const old = queue[i]!;
923
+ if (old.from !== HUB || old.refs?.completedFor !== completedFor || this.attempts.has(`${id}:${old.id}`)) continue;
924
+ queue.splice(i, 1);
925
+ this.markWithdrawn(old.id);
926
+ this.discardQueued(id, old, `superseded completed-change notice for task #${completedFor}`);
927
+ }
928
+ }
910
929
  const key = env.from === HUB && env.refs?.supervision ? env.refs.supervisionKey : undefined;
911
930
  const replace = !front && key ? queue.findIndex((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`)) : -1;
912
931
  const preparing = !front && key ? this.preparing.get(id)?.find((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`) && !this.withdrawn.has(old.id)) : undefined;
@@ -1012,7 +1031,8 @@ export class Bus {
1012
1031
  const preface = this.prefaces.get(id);
1013
1032
  if (!preface && !queue.length) return [];
1014
1033
  const queued = [...queue];
1015
- const batch = [...(preface ? [preface] : []), ...this.take(id, queue)];
1034
+ // A pull-only peer drains within a byte budget (oldest first, important ones marked as they always were); an attached channel session that pulls keeps one push's worth (#493).
1035
+ const batch = [...(preface ? [preface] : []), ...(this.peers.get(id)?.pullOnly ? this.takeBudget(id, queue) : this.take(id, queue))];
1016
1036
  let out: Envelope[] = [];
1017
1037
  const deliveryId = crypto.randomUUID();
1018
1038
  try {
@@ -1158,6 +1178,26 @@ export class Bus {
1158
1178
  return batch;
1159
1179
  }
1160
1180
 
1181
+ /**
1182
+ * #713: what one pull hands a pull-only peer. Oldest first, so the oldest ordinary item is never kept behind newer important ones (the push order is important
1183
+ * first; a pull is a catch-up). The budget counts each item's rendered bytes as the peer will read them (`frame`), and the first item always goes, so one huge
1184
+ * envelope cannot stall the queue. An envelope that already failed once still goes alone, as in `take`.
1185
+ */
1186
+ private takeBudget(id: PeerId, queue: Envelope[]): Envelope[] {
1187
+ const batch: Envelope[] = [];
1188
+ let bytes = 0;
1189
+ for (const env of queue) {
1190
+ const failedBefore = this.attempts.has(`${id}:${env.id}`);
1191
+ if (failedBefore && batch.length) break;
1192
+ const size = Buffer.byteLength(frame(env)) + 2;
1193
+ if (batch.length && bytes + size > PULL_BUDGET_BYTES) break;
1194
+ batch.push(env); bytes += size;
1195
+ if (failedBefore) break;
1196
+ }
1197
+ for (const env of batch) queue.splice(queue.indexOf(env), 1);
1198
+ return batch;
1199
+ }
1200
+
1161
1201
  private arm(id: PeerId, ms: number): void {
1162
1202
  clearTimeout(this.timers.get(id));
1163
1203
  const timer = setTimeout(() => void this.drain(id), ms);
package/src/hub/checks.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { spawn } from "node:child_process";
2
1
  import { childEnv } from "./child-process.ts";
3
2
 
4
3
  export interface CheckResult {
@@ -10,24 +9,64 @@ export interface CheckResult {
10
9
  tail: string;
11
10
  }
12
11
 
12
+ /** The part of a `Bun.spawn` child a check reads; tests substitute their own. */
13
+ export interface CheckProcess {
14
+ pid: number;
15
+ /** Resolves with the exit code once the child is gone (128 + signal for a signalled child). */
16
+ exited: Promise<number>;
17
+ signalCode: string | null;
18
+ stdout: ReadableStream<Uint8Array>;
19
+ stderr: ReadableStream<Uint8Array>;
20
+ }
21
+ export type CheckSpawn = (argv: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => CheckProcess;
22
+
23
+ /**
24
+ * Why this is `Bun.spawn` and not `node:child_process` (#558): under `bun test` on a loaded macOS runner, node's spawn can
25
+ * reap a quick child inside `spawn()` itself and emit its exit and close events (and destroy its pipes, output unread)
26
+ * before any listener exists (oven-sh/bun#45483). The check then waited for events that had come already and returned a
27
+ * timeout, empty tail included, after its limit plus the kill grace. `Bun.spawn` hands over `exited` and the stream
28
+ * readers as values, so an early exit is still there to read. Measured under CPU load, one quick check per test file:
29
+ * node:child_process failed 9 of 300 rounds of 60 files, `Bun.spawn` 0 of 300.
30
+ */
31
+ const bunSpawn: CheckSpawn = (argv, options) => Bun.spawn(argv, { cwd: options.cwd, env: options.env as Record<string, string>, stdin: "ignore", stdout: "pipe", stderr: "pipe", detached: true });
32
+
13
33
  const TAIL_LINES = 40;
14
34
  const TAIL_CHARS = 4000;
15
35
  const KEEP_CHARS = 64 * 1024;
16
36
  /** How long output may still arrive after the command exited; a descendant that left the group can hold the pipes. */
17
37
  const DRAIN_MS = 500;
18
- /** How long a killed check may wait for its exit and close events before it settles without them (#558). */
38
+ /** How long a killed check may wait for its exit and its pipes before it settles without them (#558). */
19
39
  const KILL_GRACE_MS = 1000;
20
40
 
41
+ /** The last lines of the output, capped by lines and characters: the one shape every result's tail has, a failed spawn's included. */
42
+ const tailOf = (text: string): string => text.replace(/\s+$/, "").split("\n").slice(-TAIL_LINES).join("\n").slice(-TAIL_CHARS);
43
+
21
44
  /** Runs one check command in the project root, in its own process group so a timeout stops what it started. */
22
- export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>, spawnChild: typeof spawn = spawn): Promise<CheckResult> {
45
+ export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>, spawnChild: CheckSpawn = bunSpawn): Promise<CheckResult> {
23
46
  return new Promise((resolve) => {
24
47
  let out = "";
25
48
  let timedOut = false;
26
49
  let interrupted = false;
27
50
  let settled = false;
28
51
  let exited = false;
29
- const child = spawnChild("sh", ["-c", command], { cwd, env: childEnv(process.env), stdio: ["ignore", "pipe", "pipe"], detached: true });
52
+ const readers: { cancel(): Promise<unknown> }[] = [];
30
53
  let grace: ReturnType<typeof setTimeout> | undefined;
54
+ let drain: ReturnType<typeof setTimeout> | undefined;
55
+ let timer: ReturnType<typeof setTimeout> | undefined;
56
+ let child: CheckProcess;
57
+ const stopListening = () => {
58
+ clearTimeout(timer);
59
+ clearTimeout(grace);
60
+ clearTimeout(drain);
61
+ for (const reader of readers) reader.cancel().catch(() => {});
62
+ };
63
+ const done = (code: number | null) => {
64
+ if (settled) return;
65
+ settled = true;
66
+ stopListening();
67
+ running?.delete(stop);
68
+ resolve({ code: timedOut || interrupted ? null : code, timedOut, ...(interrupted ? { interrupted } : {}), tail: tailOf(out) });
69
+ };
31
70
  const killGroup = () => {
32
71
  try {
33
72
  if (child.pid) process.kill(-child.pid, "SIGKILL");
@@ -35,51 +74,61 @@ export function runCheck(command: string, cwd: string, timeoutMs: number, runnin
35
74
  // already gone
36
75
  }
37
76
  };
38
- /** A kill that is a verdict (timeout, hub stop) settles even if the child's events never come. */
77
+ /** A kill that is a verdict (timeout, hub stop) settles even if the child's exit and pipes never report. */
39
78
  const killAndSettle = () => {
40
79
  killGroup();
41
80
  grace ??= setTimeout(() => done(null), KILL_GRACE_MS);
42
81
  };
43
- const stop = () => {
44
- if (exited || settled) return; // finished before the hub stopped, or already settled: its result stands and the group may be reused
82
+ function stop() {
83
+ if (exited || settled) return; // finished in time or already settled: its result stands and the group may be reused
45
84
  interrupted = true;
46
85
  killAndSettle();
47
- };
86
+ }
87
+ try {
88
+ child = spawnChild(["sh", "-c", command], { cwd, env: childEnv(process.env) });
89
+ } catch (e) {
90
+ resolve({ code: null, timedOut: false, tail: tailOf(e instanceof Error ? e.message : String(e)) });
91
+ return;
92
+ }
48
93
  running?.add(stop);
49
94
  const keep = (chunk: string) => {
50
95
  out += chunk;
51
96
  if (out.length > KEEP_CHARS) out = out.slice(-KEEP_CHARS);
52
97
  };
53
- for (const s of [child.stdout, child.stderr]) {
54
- s?.setEncoding("utf8"); // decodes across chunk edges, so Korean output is not cut into U+FFFD
55
- s?.on("data", keep);
56
- }
57
- const timer = setTimeout(() => {
98
+ /** Reads one pipe to its end; resolves when it closes, or when `done` cancels it. */
99
+ const pump = (stream: ReadableStream<Uint8Array>): Promise<void> => {
100
+ const reader = stream.getReader();
101
+ readers.push(reader);
102
+ const decoder = new TextDecoder(); // decodes across chunk edges, so Korean output is not cut into U+FFFD
103
+ return (async () => {
104
+ try {
105
+ for (;;) {
106
+ const { value, done: ended } = await reader.read();
107
+ if (ended) break;
108
+ keep(decoder.decode(value, { stream: true }));
109
+ }
110
+ keep(decoder.decode());
111
+ } catch {
112
+ // cancelled or broken: what was read stands
113
+ }
114
+ })();
115
+ };
116
+ const pipes = Promise.all([pump(child.stdout), pump(child.stderr)]);
117
+ timer = setTimeout(() => {
58
118
  timedOut = true;
59
119
  killAndSettle();
60
120
  }, timeoutMs);
61
- const done = (code: number | null) => {
62
- if (settled) return;
63
- settled = true;
64
- clearTimeout(timer);
65
- clearTimeout(grace);
66
- running?.delete(stop);
67
- child.stdout?.destroy();
68
- child.stderr?.destroy();
69
- const tail = out.replace(/\s+$/, "").split("\n").slice(-TAIL_LINES).join("\n").slice(-TAIL_CHARS);
70
- resolve({ code: timedOut || interrupted ? null : code, timedOut, ...(interrupted ? { interrupted } : {}), tail });
71
- };
72
- child.on("error", (e) => {
73
- out += `\n${e.message}`;
74
- done(null);
75
- });
76
- child.on("exit", (code) => {
121
+ child.exited.then((code) => {
77
122
  if (settled) return; // the kill grace settled it: the process group id is no longer ours to signal
78
123
  exited = true;
79
124
  clearTimeout(timer); // it finished in time; only its output may still be on the way
80
125
  killGroup(); // a background job it left behind (sh has no job control, so it is still in the group)
81
- setTimeout(() => done(code), DRAIN_MS);
126
+ const result = child.signalCode ? null : code;
127
+ drain = setTimeout(() => done(result), DRAIN_MS);
128
+ void pipes.then(() => done(result)); // every pipe closed: nothing more is coming
129
+ }, (e: unknown) => {
130
+ out += `\n${e instanceof Error ? e.message : String(e)}`;
131
+ done(null);
82
132
  });
83
- child.on("close", (code) => done(code));
84
133
  });
85
134
  }