@staix/agent-hub 0.12.21 → 0.12.23

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 (87) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +27 -12
  3. package/docs/agent-notes/adapters.md +15 -7
  4. package/docs/agent-notes/benchmarks.md +4 -1
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/bus.md +2 -1
  7. package/docs/agent-notes/daemon.md +12 -1
  8. package/docs/agent-notes/local-worker.md +4 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +4 -0
  11. package/docs/events.md +17 -4
  12. package/docs/operations.md +478 -41
  13. package/docs/quickstart.md +14 -6
  14. package/docs/security.md +23 -11
  15. package/docs/smoke.md +133 -7
  16. package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
  18. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  19. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  20. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  21. package/docs/verified.json +32 -17
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +14 -3
  25. package/src/adapters/acp-quota.ts +13 -0
  26. package/src/adapters/acp.ts +105 -17
  27. package/src/adapters/codex-appserver.ts +70 -35
  28. package/src/adapters/local-worker.ts +44 -10
  29. package/src/adapters/pi.ts +158 -19
  30. package/src/cli/bench.ts +2 -1
  31. package/src/cli/console-state.ts +28 -22
  32. package/src/cli/console.ts +20 -10
  33. package/src/cli/help.ts +25 -19
  34. package/src/cli/identity.ts +3 -3
  35. package/src/cli/init.ts +1 -1
  36. package/src/cli/launch.ts +182 -7
  37. package/src/cli/main.ts +409 -177
  38. package/src/cli/output.ts +363 -0
  39. package/src/cli/preview.ts +2 -1
  40. package/src/cli/recovery-package.ts +17 -0
  41. package/src/cli/reset.ts +3 -0
  42. package/src/cli/status-lines.ts +0 -28
  43. package/src/cli/terminal-recovery.ts +27 -1
  44. package/src/cli/upgrade-interactive.ts +297 -0
  45. package/src/cli/upgrade-runtime.ts +80 -9
  46. package/src/cli/upgrade.ts +50 -15
  47. package/src/hub/board.ts +2 -0
  48. package/src/hub/budget.ts +39 -7
  49. package/src/hub/bus.ts +15 -14
  50. package/src/hub/child-process.ts +11 -0
  51. package/src/hub/conductor.ts +23 -11
  52. package/src/hub/config-trust.ts +16 -5
  53. package/src/hub/conflicts.ts +2 -0
  54. package/src/hub/context-window.ts +8 -1
  55. package/src/hub/crash.ts +2 -1
  56. package/src/hub/daemon.ts +483 -83
  57. package/src/hub/db-timeout.ts +3 -0
  58. package/src/hub/delivery-journal.ts +2 -1
  59. package/src/hub/envelope.ts +1 -1
  60. package/src/hub/events.ts +4 -1
  61. package/src/hub/execution-budget.ts +2 -0
  62. package/src/hub/facts.ts +5 -4
  63. package/src/hub/git.ts +32 -0
  64. package/src/hub/hub-tools.ts +1 -1
  65. package/src/hub/lifecycle.ts +10 -4
  66. package/src/hub/manager.ts +18 -5
  67. package/src/hub/peers.ts +7 -5
  68. package/src/hub/permission-mode.ts +14 -0
  69. package/src/hub/project.ts +2 -1
  70. package/src/hub/recovery-error.ts +31 -0
  71. package/src/hub/recovery-store.ts +43 -4
  72. package/src/hub/registry.ts +2 -1
  73. package/src/hub/routing.ts +114 -13
  74. package/src/hub/settings.ts +392 -0
  75. package/src/hub/snapshots.ts +4 -2
  76. package/src/hub/start-mode.ts +48 -0
  77. package/src/hub/tasks.ts +158 -43
  78. package/src/hub/terminal-open.ts +64 -0
  79. package/src/hub/ui.ts +25 -13
  80. package/src/local/deny.ts +71 -2
  81. package/src/local/sandbox.ts +47 -6
  82. package/src/local/tools.ts +25 -6
  83. package/src/memory/recall.ts +2 -2
  84. package/src/models/mlx.ts +2 -1
  85. package/src/pi/extension.ts +43 -20
  86. package/src/pi/tool-receipts.ts +19 -2
  87. package/src/ui/index.html +172 -21
@@ -8,7 +8,7 @@ import { BasePeer } from "../hub/peers.ts";
8
8
  import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
9
9
  import { processSignature } from "../pi/process-signature.ts";
10
10
  import { realPath } from "../hub/project.ts";
11
- import { toolResultFailed } from "../local/tools.ts";
11
+ import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
12
12
  import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
13
13
  import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
14
14
 
@@ -25,10 +25,13 @@ export interface PiExit {
25
25
  toolActive: boolean;
26
26
  lastToolName?: string;
27
27
  }
28
+ export interface PiToolContext { userBash: boolean; isCurrent: () => boolean; onApproval: (provenance: ApprovalProvenance) => void; }
29
+ export interface PiToolResult { text: string; exitCode: number | null; }
30
+ class PiToolSettlementFailure extends Error {}
28
31
  export interface PiOptions {
29
32
  cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
30
33
  model?: string;
31
- relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal) => Promise<string>; tools: PiToolSchema[];
34
+ relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal, context?: PiToolContext) => Promise<string | PiToolResult>; tools: PiToolSchema[];
32
35
  preamble?: string;
33
36
  selectModel?: (envs: Envelope[]) => Promise<string | undefined>; maxSteps?: number;
34
37
  /** Atomic task/run admission immediately before provider requests or tool execution. */
@@ -40,6 +43,7 @@ export interface PiOptions {
40
43
  watchdogMs?: number; log?: (line: string) => void;
41
44
  /** Once per owner, after it is offline; a TUI owner may have unknown OS exit status. */
42
45
  onExit?: (exit: PiExit) => void;
46
+ onApprovalStop?: (reason: string) => void;
43
47
  /** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
44
48
  stopGraceMs?: number;
45
49
  }
@@ -77,8 +81,17 @@ export class PiPeer extends BasePeer {
77
81
  private persistedOnce = false;
78
82
  private activeEnvs: Envelope[] = [];
79
83
  private activeTools = 0;
84
+ private readonly pendingToolGenerations = new Map<string, { generation: number; abort: AbortController; settled: Promise<void> }>();
85
+ private readonly abortedToolIds = new Set<string>();
86
+ private abortIdsOverflow = false;
80
87
  private readonly activeDeliveryIds = new Set<string>();
81
88
  private agentRunning = false;
89
+ private approvalExpiries = 0;
90
+ private approvalAnswerEpoch: number | undefined;
91
+ private approvalTurnAbort = false;
92
+ private approvalAbortSent = false;
93
+ private approvalStopReason = "";
94
+ private requestedStopReason: string | undefined;
82
95
  private owner = randomUUID();
83
96
  private _tuiLaunch?: PiTuiLaunch;
84
97
  private requestedModel = "";
@@ -180,11 +193,18 @@ export class PiPeer extends BasePeer {
180
193
  }
181
194
  private async handleToolRequest(body: any): Promise<Response> {
182
195
  if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
196
+ if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
197
+ const text = "error: Pi tool lineage is missing, stale or unverified; restart Pi with the current hub extension before retrying";
198
+ this.opts.log?.(`[${this.id}] ${text}`);
199
+ return Response.json({ text, failed: true }, { status: 409 });
200
+ }
201
+ if (this.abortIdsOverflow || this.abortedToolIds.has(String(body.toolCallId))) return Response.json({ text: "error: tool call cancelled before execution", failed: true });
202
+ if (this.pendingToolGenerations.has(String(body.toolCallId ?? ""))) return Response.json({ text: "error: this Pi tool call is already in progress", failed: true }, { status: 409 });
183
203
  let executionSignal = this.executionAbort?.signal;
184
204
  let idleBashTimer: ReturnType<typeof setTimeout> | undefined;
185
205
  if (body.purpose === "idle_user_bash") {
186
206
  const reservation = typeof body.reservation === "string" ? this.idleBashReservations.get(body.reservation) : undefined;
187
- if (!reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
207
+ if (this.opts.mode !== "tui" || !this.verifiedOwner() || !reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
188
208
  if (reservation.deadlineAt !== undefined && reservation.deadlineAt <= Date.now()) {
189
209
  this.idleBashReservations.delete(String(body.reservation));
190
210
  return Response.json({ text: "error: Pi user shell execution budget expired" }, { status: 409 });
@@ -195,6 +215,18 @@ export class PiPeer extends BasePeer {
195
215
  if (reservation.deadlineAt !== undefined) idleBashTimer = setTimeout(() => idleBashAbort.abort(), Math.max(0, reservation.deadlineAt - Date.now()));
196
216
  }
197
217
  this.noteActivity();
218
+ const generation = this.budgetGeneration;
219
+ const turnSignal = this.executionAbort?.signal;
220
+ const parentSignal = executionSignal;
221
+ const toolAbort = new AbortController();
222
+ const abortTool = () => toolAbort.abort();
223
+ parentSignal?.addEventListener("abort", abortTool, { once: true });
224
+ if (parentSignal?.aborted) abortTool();
225
+ executionSignal = toolAbort.signal;
226
+ const callId = String(body.toolCallId ?? "");
227
+ let settled!: () => void;
228
+ const settlement = new Promise<void>((resolve) => { settled = resolve; });
229
+ this.pendingToolGenerations.set(callId, { generation, abort: toolAbort, settled: settlement });
198
230
  const name = String(body.name);
199
231
  this.lastToolName = this.opts.tools.some((tool) => tool.name === name) && /^[a-zA-Z0-9_.-]{1,128}$/.test(name) ? name : "unknown";
200
232
  this.activeTools++;
@@ -203,11 +235,40 @@ export class PiPeer extends BasePeer {
203
235
  // `failed` is the managed-tool failure verdict (toolResultFailed, the one contract): Pi 1.0.1
204
236
  // classifies a native tool result by isError === true alone, so the extension needs the flag,
205
237
  // not just the text (#181). An older extension simply ignores the extra field.
206
- try { const text = await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId, executionSignal); return Response.json({ text, failed: toolResultFailed(String(body.name), text) }); }
238
+ try {
239
+ const result = await this.opts.executeTool(name, body.args, callId, this.sessionId, executionSignal, {
240
+ userBash: body.purpose === "idle_user_bash",
241
+ isCurrent: () => generation === this.budgetGeneration && !this.stopping && this.state !== "offline" && !executionSignal?.aborted && !this.approvalStopReason && this.verifiedOwner() && (body.purpose === "idle_user_bash" ? this.opts.mode === "tui" : turnSignal === this.executionAbort?.signal),
242
+ onApproval: (provenance) => {
243
+ if (!this.agentRunning || generation !== this.budgetGeneration || turnSignal !== this.executionAbort?.signal || this.approvalStopReason) return;
244
+ if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
245
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
246
+ }
247
+ if (provenance.source === "person") this.approvalExpiries = 0;
248
+ if (provenance.source === "expired" && provenance.eligibleExpiry !== false && ++this.approvalExpiries >= 2) {
249
+ this.approvalStopReason = "Pi turn stopped after two unanswered approvals; no person answered. Do not retry the calls; inspect prior work before handoff or stopping.";
250
+ try { this.opts.onApprovalStop?.(this.approvalStopReason); } catch { /* stop remains authoritative */ }
251
+ }
252
+ },
253
+ });
254
+ const text = typeof result === "string" ? result : result.text;
255
+ return Response.json({ text, failed: typeof result !== "string" && result.exitCode !== null ? result.exitCode !== 0 : toolResultFailed(name, text), ...(typeof result === "string" ? {} : { exitCode: result.exitCode }) });
256
+ }
207
257
  catch (error) { return Response.json({ text: `error: ${(error as Error).message}`, failed: true }, { status: 200 }); }
208
258
  finally {
209
259
  clearTimeout(idleBashTimer);
210
260
  this.activeTools--;
261
+ parentSignal?.removeEventListener("abort", abortTool);
262
+ if (this.pendingToolGenerations.get(callId)?.abort === toolAbort) this.pendingToolGenerations.delete(callId);
263
+ settled();
264
+ if (this.approvalStopReason && !this.approvalAbortSent) {
265
+ this.approvalAbortSent = true;
266
+ const reason = this.approvalStopReason;
267
+ // Finish this request's expiry reply before withdrawing siblings and aborting the native turn.
268
+ setTimeout(() => {
269
+ if (this.agentRunning && generation === this.budgetGeneration && this.approvalStopReason === reason && !this.stopping) void this.endApprovalTurn(generation, reason);
270
+ }, 10);
271
+ }
211
272
  if (this.state === "busy") {
212
273
  if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
213
274
  else this.touch();
@@ -218,12 +279,14 @@ export class PiPeer extends BasePeer {
218
279
  private async handleBudgetRequest(body: any): Promise<Response> {
219
280
  if (!["model_calls", "tool_calls"].includes(String(body.unit))) return Response.json({ error: "invalid Pi budget unit" }, { status: 400 });
220
281
  const idleUserBash = body.idleUserBash === true;
221
- const current = Number.isSafeInteger(body.generation) && body.generation === this.budgetGeneration;
222
- const idleOwner = this.ownerClaimed && this.ownerPid !== undefined && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
223
- if (!current || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
282
+ const generation = body.generation, controller = this.executionAbort, session = this.sessionId, ownerPid = this.ownerPid, ownerSignature = this.ownerSignature;
283
+ const current = () => Number.isSafeInteger(generation) && generation === this.budgetGeneration && controller === this.executionAbort && session === this.sessionId && ownerPid === this.ownerPid && ownerSignature === this.ownerSignature && !this.stopping && this.verifiedOwner();
284
+ const idleOwner = this.opts.mode === "tui" && !this.stopping && this.verifiedOwner() && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
285
+ if (!current() || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
224
286
  // An interactive user shell has no task delivery. It may spend an eligible run budget only.
225
287
  const envs = idleUserBash ? [] : this.budgetEnvelopes;
226
288
  const decisions = this.opts.admitBudget ? await this.opts.admitBudget(envs, body.unit as ExecutionUnit) : [];
289
+ if (!current() || (idleUserBash ? this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "Pi owner or turn changed during admission" }, { status: 409 });
227
290
  const denied = decisions.find((decision) => !decision.allowed);
228
291
  const remaining = decisions.filter((decision) => decision.unit === "elapsed_ms" && decision.remaining !== null).reduce<number | undefined>((min, decision) => min === undefined ? decision.remaining! : Math.min(min, decision.remaining!), undefined);
229
292
  let reservation: string | undefined;
@@ -248,7 +311,10 @@ export class PiPeer extends BasePeer {
248
311
 
249
312
  private async startImpl(): Promise<void> {
250
313
  this.stopping = false;
314
+ this.requestedStopReason = undefined;
315
+ this.approvalTurnAbort = false;
251
316
  this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
317
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
252
318
  mkdirSync(this.opts.stateDir, { recursive: true });
253
319
  const sessions = join(this.opts.stateDir, "pi-sessions");
254
320
  mkdirSync(sessions, { recursive: true, mode: 0o700 });
@@ -311,17 +377,48 @@ export class PiPeer extends BasePeer {
311
377
  throw new Error("Pi get_state session identity does not match the requested recovery session");
312
378
  }
313
379
  this.sessionId = state.data.sessionId; this.sessionFile = state.data.sessionFile;
380
+ // The hub spawned this headless owner and get_state proved its session. Pin the same
381
+ // identity a native extension claim uses, including fakes that do not load the extension.
382
+ if (!this.ownerClaimed) {
383
+ const pid = this.proc?.pid, signature = pid ? processSignature(pid) : undefined;
384
+ if (!pid || !signature) throw new Error("Pi headless owner process identity is not verified");
385
+ this.ownerPid = pid; this.ownerSignature = signature;
386
+ this.ownerToken = String(this._tuiLaunch?.env.AGENTHUB_PI_OWNER_TOKEN ?? ""); this.ownerClaimed = true;
387
+ }
314
388
  this.persistedOnce = existsSync(this.sessionFile);
315
389
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) throw new Error("Pi startup session identity mismatch");
316
390
  this.started = true;
317
391
  this.setState("idle");
318
392
  }
319
393
 
320
- async stop(): Promise<void> {
394
+ private verifiedOwner(): boolean {
395
+ return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature && processSignature(this.ownerPid) === this.ownerSignature;
396
+ }
397
+ private abortTools(): Promise<void>[] {
398
+ this.executionAbort?.abort();
399
+ this.idleBashReservations.clear();
400
+ const pending = [...this.pendingToolGenerations.values()];
401
+ for (const call of pending) call.abort.abort();
402
+ return pending.map((call) => call.settled);
403
+ }
404
+ get mode(): "headless" | "tui" { return this.opts.mode; }
405
+
406
+ async stop(reason?: string): Promise<void> {
407
+ if (reason !== undefined) this.requestedStopReason = reason;
408
+ this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
409
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
410
+ this.activeDeliveryIds.clear();
411
+ const settling = this.abortTools();
412
+ let unsettled: unknown;
413
+ if (settling.length) {
414
+ let timer: ReturnType<typeof setTimeout> | undefined;
415
+ try {
416
+ await Promise.race([Promise.all(settling), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new PiToolSettlementFailure("Pi managed tools did not settle after cancellation")), this.opts.stopGraceMs ?? 5_000); })]);
417
+ } catch (error) { unsettled = error; } // a tool past its grace must not block the teardown (#253): stop reports it at the end
418
+ finally { clearTimeout(timer); }
419
+ }
321
420
  const tuiExit = this.opts.mode === "tui" && this.ownerClaimed && this.ownerPid && this.ownerSignature
322
421
  ? { ...this.exitMetadata("owner_stopped"), expected: true } : undefined;
323
- for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: "Pi session stopped before settlement" });
324
- this.activeDeliveryIds.clear();
325
422
  this.stopping = true;
326
423
  this.clearOwnerMonitor();
327
424
  if (this.opts.mode === "tui") {
@@ -361,6 +458,7 @@ export class PiPeer extends BasePeer {
361
458
  this.server?.stop(true); this.server = undefined; this.setState("offline");
362
459
  if (failed) throw failed;
363
460
  if (tuiExit) this.reportExit(tuiExit); // the verified owner is gone; never invent an exit for an unclaimed launch
461
+ if (unsettled) throw unsettled;
364
462
  }
365
463
 
366
464
  /**
@@ -443,8 +541,10 @@ export class PiPeer extends BasePeer {
443
541
  }
444
542
 
445
543
  private handleBridgeEvent(event: any): void {
544
+ if (this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) return;
446
545
  if (event.type === "session_start") {
447
546
  if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
547
+ this.approvalTurnAbort = event.approvalTurnAbort === true;
448
548
  this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
449
549
  this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
450
550
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
@@ -457,11 +557,24 @@ export class PiPeer extends BasePeer {
457
557
  this.fail(new Error("Pi session shut down before settlement"));
458
558
  if (this.opts.mode === "tui") this.reportExit(exit);
459
559
  }
560
+ if (event.type === "tool_abort" && this.agentRunning && this.ownerClaimed && this.ownerPid && this.ownerSignature
561
+ && processSignature(this.ownerPid) === this.ownerSignature && event.sessionId === this.sessionId && event.generation === this.budgetGeneration
562
+ && typeof event.toolCallId === "string" && event.toolCallId.length > 0 && event.toolCallId.length <= 512) {
563
+ const pending = this.pendingToolGenerations.get(event.toolCallId);
564
+ if (pending && pending.generation === event.generation) pending.abort.abort();
565
+ else { // a native abort may arrive before its HTTP tool request; fence that call in this generation
566
+ // ponytail: 64 early aborts per turn; fence further calls rather than forget a cancellation.
567
+ // A native per-call acknowledgement would replace this bounded early-arrival cache.
568
+ if (this.abortedToolIds.size >= 64 && !this.abortedToolIds.has(event.toolCallId)) this.abortIdsOverflow = true;
569
+ else this.abortedToolIds.add(event.toolCallId);
570
+ }
571
+ }
460
572
  if (event.type === "agent_start") {
461
573
  const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
462
574
  if (generation <= this.budgetGeneration) return;
463
575
  this.modelStep = 0;
464
- this.usageSeen.clear(); this.idleBashReservations.clear(); this.ceilingStops.clear(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
576
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false; this.abortedToolIds.clear(); this.abortIdsOverflow = false;
577
+ this.usageSeen.clear(); this.ceilingStops.clear(); this.abortTools(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
465
578
  }
466
579
  // #179: the extension's tool-step ceiling signal. Only a schema-valid event bound to THIS session,
467
580
  // THIS turn generation and a running turn is stored; the first signal of a turn stands. Anything
@@ -495,7 +608,8 @@ export class PiPeer extends BasePeer {
495
608
  this.agentRunning = false;
496
609
  if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
497
610
  const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
498
- const text = this.settledText.trim(); const error = this.budgetStops.get(generation) ?? this.settledError; const cancelled = !error && this.settledCancelled;
611
+ const approvalStopReason = this.approvalStopReason;
612
+ const text = this.settledText.trim(); const error = approvalStopReason || this.budgetStops.get(generation) || this.settledError; const cancelled = !error && this.settledCancelled;
499
613
  const ceiling = this.ceilingStops.get(generation);
500
614
  this.budgetStops.delete(generation);
501
615
  this.ceilingStops.delete(generation);
@@ -505,12 +619,16 @@ export class PiPeer extends BasePeer {
505
619
  const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
506
620
  if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
507
621
  else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
622
+ else if (approvalStopReason) this.onMessage?.(approvalStopReason, reply);
508
623
  else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error, ceiling);
509
624
  else if (text) this.onMessage?.(text, reply);
510
- for (const id of this.activeDeliveryIds) this.delivery({ id, state: error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
625
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: approvalStopReason ? "completed" : error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
511
626
  this.activeDeliveryIds.clear();
512
- this.currentReply = undefined; this.activeEnvs = []; if (this.state === "busy" && !this.activeTools) this.setState("idle");
513
- this.executionAbort = undefined;
627
+ this.currentReply = undefined; this.activeEnvs = [];
628
+ this.executionAbort?.abort(); this.executionAbort = undefined;
629
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
630
+ this.abortedToolIds.clear(); this.abortIdsOverflow = false;
631
+ if (this.state === "busy" && !this.activeTools) this.setState("idle");
514
632
  }
515
633
  }
516
634
 
@@ -560,14 +678,19 @@ export class PiPeer extends BasePeer {
560
678
  }
561
679
 
562
680
  private fail(error: Error): void {
681
+ const approvalStopReason = this.approvalStopReason;
682
+ this.abortTools();
683
+ this.agentRunning = false;
684
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
685
+ this.abortedToolIds.clear(); this.abortIdsOverflow = false;
563
686
  for (const pending of this.pending.values()) pending.reject(error);
564
687
  this.pending.clear();
565
688
  const envs = this.activeEnvs; this.activeEnvs = [];
566
689
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: error.message });
567
690
  this.activeDeliveryIds.clear();
568
- if (envs.length) {
569
- this.onMessage?.("Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
570
- void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
691
+ if (envs.length && !this.requestedStopReason) {
692
+ this.onMessage?.(approvalStopReason ? `${approvalStopReason} Pi is offline because its extension could not abort the turn; restart with ahub pi and settle the held delivery after inspecting prior work.` : "Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
693
+ if (!approvalStopReason && !(error instanceof ApprovalWaitStop)) void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
571
694
  }
572
695
  this.currentReply = undefined;
573
696
  this.setState("offline");
@@ -588,6 +711,17 @@ export class PiPeer extends BasePeer {
588
711
  this.ownerMonitor.unref?.();
589
712
  }
590
713
  private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
714
+ private async endApprovalTurn(generation: number, reason: string): Promise<void> {
715
+ if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || this.stopping || this.state === "offline") return;
716
+ this.executionAbort?.abort();
717
+ if (this.approvalTurnAbort) {
718
+ try { await this.sendTui({ type: "abort_budget", generation, cause: "approval", reason }); return; }
719
+ catch { if (generation !== this.budgetGeneration || !this.agentRunning) return; }
720
+ }
721
+ this.opts.log?.(`[${this.id}] approval turn abort unsupported; Pi goes offline. Restart with ahub pi and settle the held delivery after inspecting prior work.`);
722
+ await this.terminateFailedTurn(new ApprovalWaitStop(reason));
723
+ }
724
+
591
725
  private async terminateFailedTurn(error: Error): Promise<void> {
592
726
  const envs = this.activeEnvs.slice(), reply = this.currentReply;
593
727
  this.stopping = true;
@@ -595,7 +729,12 @@ export class PiPeer extends BasePeer {
595
729
  await this.stop();
596
730
  // The process exit handler may already have reported this failure.
597
731
  if (this.activeEnvs.length) this.fail(error);
598
- } catch {
732
+ } catch (stopError) {
733
+ if (stopError instanceof PiToolSettlementFailure && this.state === "offline") {
734
+ this.opts.log?.(`Pi teardown completed with a managed-tool settlement failure: ${(stopError as Error).message}`);
735
+ if (this.activeEnvs.length) this.fail(error);
736
+ return;
737
+ }
599
738
  this.activeEnvs = envs; this.currentReply = reply;
600
739
  this.stopping = true;
601
740
  this.setState("busy");
package/src/cli/bench.ts CHANGED
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
6
6
  import { ControlClient } from "../hub/control-client.ts";
7
7
  import { readEvents } from "../hub/events.ts";
8
+ import { hubGitArgv, hubGitEnv } from "../hub/git.ts";
8
9
  import { taskRecords } from "../hub/research.ts";
9
10
  import { appendBench, BENCH_SCHEMA, parseSuite, suiteHash, type Attempt, type AttemptError, type AttemptMetrics, type Fingerprint, type Outcome, type SuiteTask } from "../hub/bench.ts";
10
11
  import { hubHome, realPath } from "../hub/project.ts";
@@ -30,7 +31,7 @@ const fail = (message: string): never => { throw new Error(message); };
30
31
 
31
32
  /** Commands git runs without a shell; the arguments are ours, never suite text. */
32
33
  async function git(cwd: string, args: string[]): Promise<{ code: number; out: string }> {
33
- const p = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
34
+ const p = Bun.spawn(hubGitArgv(args), { cwd, stdout: "pipe", stderr: "ignore", env: hubGitEnv() });
34
35
  const [code, out] = await Promise.all([p.exited, new Response(p.stdout).text()]);
35
36
  return { code, out: out.trim() };
36
37
  }
@@ -1,4 +1,4 @@
1
- import { PERMISSION_MODES } from "../hub/permission-mode.ts";
1
+ import { PERMISSION_MODES, permissionBoundary } from "../hub/permission-mode.ts";
2
2
  import { taskProgress } from "../ui/task-progress.ts";
3
3
  import type { ProgressStage } from "../ui/task-progress.ts";
4
4
  import { sanitize } from "../hub/envelope.ts";
@@ -35,12 +35,7 @@ export function initialConsoleState(panels = false): ConsoleState {
35
35
  return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
36
36
  input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], tasksKnown: false, queue: [], permissionDefaults: [], permissionDefaultsHandled: [], detailOffset: 0, help: false, notice: "" };
37
37
  }
38
- /** Approval frequency does not create a common sandbox across native peers. */
39
- export function permissionBoundary(peer: string): string {
40
- if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
41
- if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
42
- return `${peer}: runs its own tools; NO hub sandbox`;
43
- }
38
+ export { permissionBoundary };
44
39
  function permissionModeRefusal(peer: string, mode: unknown): string | undefined {
45
40
  if (mode === "unmanaged") return `${peer} permission mode is unmanaged; use its native controls`;
46
41
  if (mode === "unverified") return `${peer} permission mode is unverified; start with ahub ${peer} and run a tool first`;
@@ -174,7 +169,7 @@ export function resolveColor(flag: string | undefined, env: { isTTY: boolean; TE
174
169
  if (flag === "always") return true;
175
170
  if (flag === "never") return false;
176
171
  if (flag === undefined || flag === "auto") return env.isTTY && env.TERM !== "dumb" && !env.NO_COLOR;
177
- return new Error("usage: ahub console [--panels] [--color=auto|always|never]");
172
+ return new Error("usage: ahub <command> [--color=auto|always|never]");
178
173
  }
179
174
  export function stateTone(state: string): Tone | undefined {
180
175
  if (state === "idle" || state === "approved") return "success";
@@ -189,7 +184,7 @@ const flat = (value: unknown) => terminalText(value).replace(/[\n\t]/g, " ");
189
184
  * Clip sanitized plain text to the width and keep the tones of the surviving prefix. Widths are measured as paint()
190
185
  * prints them: paint() sanitizes each span again, and a cut that ends a span in `[agent-hub` earns it a `> `.
191
186
  */
192
- function fitLine(line: Span[], columns: number): Span[] {
187
+ export function fitLine(line: Span[], columns: number): Span[] {
193
188
  const clean = line.map(s => ({ ...s, text: flat(s.text) }));
194
189
  if (Bun.stringWidth(clean.map(s => s.text).join("")) <= columns) return clean;
195
190
  const marker = ".".repeat(Math.max(0, Math.min(3, columns)));
@@ -216,7 +211,9 @@ export function fit(value: unknown, columns: number): string {
216
211
  export function wrap(value: unknown, columns: number, hang = 4): string[] {
217
212
  const lines: string[] = [];
218
213
  const half = Math.floor(columns / 2);
219
- const width = (text: string) => Bun.stringWidth(sanitize(text));
214
+ // A bare chunk ending in "--- from" becomes a header marker when table padding follows it.
215
+ const quoteChunk = (text: string) => sanitize(text + " ").slice(0, -1);
216
+ const width = (text: string) => Bun.stringWidth(quoteChunk(text));
220
217
  for (const part of terminalText(value).replace(/\t/g, " ").split("\n")) {
221
218
  const indent = /^\s*/.exec(part)![0];
222
219
  const lead = Math.min(Bun.stringWidth(indent), half);
@@ -224,7 +221,7 @@ export function wrap(value: unknown, columns: number, hang = 4): string[] {
224
221
  const first = lines.length;
225
222
  let start = " ".repeat(lead);
226
223
  let line = start;
227
- const push = () => { lines.push(sanitize(line.trimEnd())); line = start = pad; };
224
+ const push = () => { lines.push(quoteChunk(line.trimEnd())); line = start = pad; };
228
225
  for (const token of part.slice(indent.length).match(/\s+|\S+/g) ?? []) {
229
226
  if (width(line + token) <= columns) { line += token; continue; }
230
227
  if (line !== start) push();
@@ -234,7 +231,7 @@ export function wrap(value: unknown, columns: number, hang = 4): string[] {
234
231
  line += char;
235
232
  }
236
233
  }
237
- if (line !== start || lines.length === first) lines.push(sanitize(line.trimEnd()));
234
+ if (line !== start || lines.length === first) lines.push(quoteChunk(line.trimEnd()));
238
235
  }
239
236
  return lines;
240
237
  }
@@ -308,7 +305,7 @@ export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "opti
308
305
  const KEYS: [string, string[]][] = [
309
306
  ["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
310
307
  ["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
311
- ["Panels", ["1-5 panel", "j/k or arrows move", "Enter view", "j/k scroll a view"]],
308
+ ["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Enter view", "j/k scroll a view"]],
312
309
  ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
313
310
  ["Peers", ["p pause", "r resume", "m permission mode"]],
314
311
  ["Tasks", ["a assign (then y)", "r review"]],
@@ -444,7 +441,11 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
444
441
  if (key === "q") { effects.push({ type: "exit" }); return done(); }
445
442
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
446
443
  if (key === ":") { s.editing = true; return done(); }
447
- if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) { s.panel = Number(key); s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); }
444
+ const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); };
445
+ if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
446
+ // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
447
+ // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
448
+ if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel((s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
448
449
  if (key === "?") {
449
450
  if (s.mode === "stream") effects.push({ type: "keys" });
450
451
  else s.help = !s.help;
@@ -522,7 +523,7 @@ export function renderConsole(s: ConsoleState, columns: number, rows = 24, now =
522
523
  return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
523
524
  }
524
525
  const NOTICE_MS = 10_000;
525
- const TABLES: Record<number, string[]> = {
526
+ export const TABLES: Record<number, string[]> = {
526
527
  1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
527
528
  2: ["ID", "PEER", "LEFT", "TITLE"],
528
529
  3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
@@ -531,7 +532,7 @@ const TABLES: Record<number, string[]> = {
531
532
  const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
532
533
  const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
533
534
  /** One span per column of a panel row; zero counters and unknown values read `-`. */
534
- function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
535
+ export function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
535
536
  const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
536
537
  if (s.panel === 1) {
537
538
  const b = s.budget[row.id];
@@ -558,19 +559,22 @@ function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages
558
559
  }
559
560
  if (s.panel === 4) return [id(row.id, "info"), span(row.peer), span(row.state, stateTone(row.state)), span(row.revision ?? "-"), span(typeof row.createdAt === "number" ? duration(now - row.createdAt) : "-", "muted")];
560
561
  const [header, ...body] = String(row.text).split("\n");
561
- return [span(header, row.tone), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
562
+ // The header goes through the stream's own tokenizer with the event's tone and kind, on the same tab-flattened
563
+ // text the stream tokenizes, so an event reads the same in both places (#309); body lines stay plain, as in the
564
+ // stream. Colour off concatenates to the same bytes.
565
+ return [...streamTokens((header ?? "").replace(/\t/g, " "), row.tone, row.kind), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
562
566
  }
563
567
  /**
564
568
  * A header row, then the rows, each column starting at the same place on every row: two spaces apart, as wide as its
565
569
  * widest cell or header (Bun.stringWidth, at most a third of the width, wider cells cut with the marker); the last column
566
- * takes the rest.
570
+ * takes the rest. One-shot callers may supply widths for already-wrapped cells; console defaults stay unchanged.
567
571
  */
568
- function table(head: string[], rows: Span[][], columns: number): Span[][] {
572
+ export function table(head: string[], rows: Span[][], columns: number, explicitWidths?: readonly number[]): Span[][] {
569
573
  const cap = Math.max(24, Math.floor(columns / 3));
570
574
  const width = (cell: Span | undefined) => Bun.stringWidth(flat(cell?.text));
571
- const widths = head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
575
+ const widths = explicitWidths ? [...explicitWidths] : head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
572
576
  const flexible = head.includes("STAGE") ? head.indexOf("TITLE") : head.length - 1;
573
- if (head.includes("STAGE")) {
577
+ if (!explicitWidths && head.includes("STAGE")) {
574
578
  // Keep the original columns adjacent; stage meters occupy a fixed right-edge column.
575
579
  const room = () => columns - 2 - widths.reduce((sum, value, index) => sum + (index === flexible ? 0 : value), 0) - 2 * (head.length - 1);
576
580
  for (const key of ["CLASS", "OWNER", "REVIEWER", "AGE", "STATE"]) {
@@ -579,7 +583,9 @@ function table(head: string[], rows: Span[][], columns: number): Span[][] {
579
583
  }
580
584
  widths[flexible] = Math.max(0, room());
581
585
  }
582
- return [head.map(h => span(h, "info")), ...rows].map(row => row.map((cell, i) => {
586
+ const headerCells = explicitWidths ? head.map((h, i) => wrap(h, Math.max(1, widths[i]!), 0)) : undefined;
587
+ const headers = headerCells ? Array.from({ length: Math.max(...headerCells.map(cell => cell.length)) }, (_, n) => head.map((_, i) => span(headerCells[i]![n] ?? "", "info"))) : [head.map(h => span(h, "info"))];
588
+ return [...headers, ...rows].map(row => row.map((cell, i) => {
583
589
  if (i === head.length - 1 && flexible === i) return cell;
584
590
  const text = fit(cell.text, widths[i]!);
585
591
  return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
@@ -23,7 +23,7 @@ export interface ConsoleOptions {
23
23
  terminal?: ConsoleTerminal;
24
24
  color?: boolean;
25
25
  /** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
26
- runCommand?: (args: string[], output: (text: string) => void, done: () => void) => (() => void);
26
+ runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
27
27
  pollMs?: number;
28
28
  }
29
29
  export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
@@ -76,13 +76,18 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
76
76
  let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
77
77
  const notice = (text: string) => notify(state, text, Date.now());
78
78
  const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
79
- const streamLines = (event: ConsoleEvent) => terminalText(event.text).split("\n").flatMap((text, index) => index === 0 ? wrapStreamTokens(text, columns, event.tone, event.kind) : wrap(text, columns).map(line => [{ text: line }]));
79
+ const streamLines = (event: ConsoleEvent) => terminalText(event.text).replace(/\t/g, " ").split("\n").flatMap((text, index) => {
80
+ // Tabs are flattened before measuring, as wrap() does; commands already wrap cells at their own offsets.
81
+ if (event.kind === "command" && Bun.stringWidth(text) <= columns) return [[{ text }]];
82
+ return index === 0 ? wrapStreamTokens(text, columns, event.tone, event.kind) : wrap(text, columns).map(line => [{ text: line }]);
83
+ });
80
84
  const writeStream = (event: ConsoleEvent) => {
81
85
  terminal.write(`\x1b[${rows - 4};1H`); // the scroll region ends above the rule and the three footer lines
82
86
  for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
83
87
  };
84
88
  /** `record: false` is for the console's own prints (the key table, a request viewed again): not events, so not in Events. */
85
89
  const stream = (event: ConsoleEvent, record = true) => {
90
+ if (event.kind === "command") event = { ...event, text: terminalText(event.text).replace(/\t/g, " ") };
86
91
  if (record) state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
87
92
  if (plain) {
88
93
  const [header, ...body] = terminalText(event.text).split("\n");
@@ -145,9 +150,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
145
150
  } catch (error) { if (active) { notice(String((error as Error).message)); draw(); } }
146
151
  finally { polling = false; }
147
152
  };
148
- const run = options.runCommand ?? ((args, output, finished) => {
153
+ const run = options.runCommand ?? ((args, output, finished, commandColumns) => {
149
154
  const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...args], {
150
155
  cwd: options.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe",
156
+ env: { ...process.env, COLUMNS: String(commandColumns), NO_COLOR: "1" },
151
157
  });
152
158
  const read = async (source: ReadableStream<Uint8Array>) => {
153
159
  const decoder = new TextDecoder(); const reader = source.getReader();
@@ -188,8 +194,8 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
188
194
  stream({ text: `> ${action.args.join(" ")}`, kind: "command" });
189
195
  try {
190
196
  // Output lines sit at column 4, as message bodies do: what a command prints can carry peer text, and columns 0 and 2 are the hub's.
191
- const output = (text: string) => { if (active) { stream({ text: text.replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
192
- cancelChild = run(action.args, output, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); });
197
+ const output = (text: string) => { if (active) { stream({ text: terminalText(text).replace(/\t/g, " ").replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
198
+ cancelChild = run(action.args, output, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); }, Math.max(1, columns - 4));
193
199
  } catch (error) { childRunning = false; notice(String((error as Error).message)); draw(); }
194
200
  };
195
201
  const handle = (key: string) => {
@@ -229,12 +235,16 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
229
235
  terminal.raw(true);
230
236
  removers.push(terminal.onData(text => {
231
237
  // A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
232
- // Under the modal key table it does nothing at all.
238
+ // Under the modal key table it does nothing at all. A read holding nothing but arrow sequences is that many
239
+ // key presses (key repeat, a slow link), not a paste (#309): each arrow reaches the reducer on its own.
233
240
  if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
234
- if (state.help && state.mode === "panels") return;
235
- if (state.confirm?.type === "permission_default") { draw(); return; }
236
- state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
237
- state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
241
+ if (/^(?:\x1b\[[ABCD])+$/.test(text)) { for (const key of text.match(/\x1b\[[ABCD]/g)!) handle(key); }
242
+ else {
243
+ if (state.help && state.mode === "panels") return;
244
+ if (state.confirm?.type === "permission_default") { draw(); return; }
245
+ state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
246
+ state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
247
+ }
238
248
  } else handle(text);
239
249
  }));
240
250
  removers.push(terminal.onResize(() => {