@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.
- package/CHANGELOG.md +29 -0
- package/README.md +27 -12
- package/docs/agent-notes/adapters.md +15 -7
- package/docs/agent-notes/benchmarks.md +4 -1
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +12 -1
- package/docs/agent-notes/local-worker.md +4 -1
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +4 -0
- package/docs/events.md +17 -4
- package/docs/operations.md +478 -41
- package/docs/quickstart.md +14 -6
- package/docs/security.md +23 -11
- package/docs/smoke.md +133 -7
- package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verified.json +32 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +14 -3
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +105 -17
- package/src/adapters/codex-appserver.ts +70 -35
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- package/src/cli/bench.ts +2 -1
- package/src/cli/console-state.ts +28 -22
- package/src/cli/console.ts +20 -10
- package/src/cli/help.ts +25 -19
- package/src/cli/identity.ts +3 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +409 -177
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +0 -28
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +80 -9
- package/src/cli/upgrade.ts +50 -15
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +15 -14
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +23 -11
- package/src/hub/config-trust.ts +16 -5
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +2 -1
- package/src/hub/daemon.ts +483 -83
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +2 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +5 -4
- package/src/hub/git.ts +32 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/manager.ts +18 -5
- package/src/hub/peers.ts +7 -5
- package/src/hub/permission-mode.ts +14 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +43 -4
- package/src/hub/registry.ts +2 -1
- package/src/hub/routing.ts +114 -13
- package/src/hub/settings.ts +392 -0
- package/src/hub/snapshots.ts +4 -2
- package/src/hub/start-mode.ts +48 -0
- package/src/hub/tasks.ts +158 -43
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +71 -2
- package/src/local/sandbox.ts +47 -6
- package/src/local/tools.ts +25 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +19 -2
- package/src/ui/index.html +172 -21
package/src/adapters/pi.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
222
|
-
const
|
|
223
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 = [];
|
|
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(
|
|
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
|
}
|
package/src/cli/console-state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)) };
|
package/src/cli/console.ts
CHANGED
|
@@ -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) =>
|
|
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 (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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(() => {
|