@xingular/harness 1.5.0-unstable → 1.6.0-unstable

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 (95) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/browser-extension/README.md +32 -0
  3. package/browser-extension/background.js +612 -0
  4. package/browser-extension/manifest.json +16 -0
  5. package/browser-extension/overlay.js +97 -0
  6. package/browser-extension/popup.css +19 -0
  7. package/browser-extension/popup.html +30 -0
  8. package/browser-extension/popup.js +101 -0
  9. package/browser-extension/site-access.js +63 -0
  10. package/dist/cli.js +1489 -680
  11. package/dist/sdk/browser/config.d.ts +3 -0
  12. package/dist/sdk/browser/config.js +2 -1
  13. package/dist/sdk/browser/extension/bridge.d.ts +55 -0
  14. package/dist/sdk/browser/extension/bridge.js +336 -0
  15. package/dist/sdk/browser/extension/session.d.ts +50 -0
  16. package/dist/sdk/browser/extension/session.js +321 -0
  17. package/dist/sdk/browser/index.d.ts +7 -2
  18. package/dist/sdk/browser/index.js +58 -25
  19. package/dist/sdk/browser/serverMethods.js +4 -1
  20. package/dist/sdk/browser/tools.d.ts +99 -2
  21. package/dist/sdk/browser/tools.js +6 -1
  22. package/dist/sdk/commands/registry.d.ts +5 -0
  23. package/dist/sdk/commands/registry.js +1 -0
  24. package/dist/sdk/harness.d.ts +4 -0
  25. package/dist/sdk/harness.js +1 -1
  26. package/dist/sdk/pi/retryPolicy.d.ts +19 -0
  27. package/dist/sdk/pi/retryPolicy.js +11 -0
  28. package/dist/sdk/prompt/compile.d.ts +0 -2
  29. package/dist/sdk/prompt/compile.js +2 -6
  30. package/dist/sdk/prompt/reporting.d.ts +1 -1
  31. package/dist/sdk/prompt/reporting.js +30 -13
  32. package/dist/sdk/prompt/standard.d.ts +1 -1
  33. package/dist/sdk/prompt/standard.js +1 -1
  34. package/dist/sdk/remote/webClient.js +6 -2
  35. package/dist/sdk/sessions/contracts.d.ts +2 -0
  36. package/dist/sdk/sessions/contracts.js +5 -1
  37. package/dist/sdk/sessions/planMode.d.ts +16 -6
  38. package/dist/sdk/sessions/planMode.js +59 -23
  39. package/dist/sdk/sessions/runner.js +14 -5
  40. package/dist/sdk/sessions/workSlice.d.ts +15 -1
  41. package/dist/sdk/sessions/workSlice.js +17 -9
  42. package/dist/sdk/terminal/controller.d.ts +6 -0
  43. package/dist/sdk/terminal/controller.js +44 -5
  44. package/dist/sdk/terminal/opentui/model.js +3 -1
  45. package/dist/sdk/terminal/piExtension.js +7 -2
  46. package/dist/sdk/terminal/stallWatch.d.ts +18 -0
  47. package/dist/sdk/terminal/stallWatch.js +20 -0
  48. package/dist/sdk/terminal/subagentMonitor.d.ts +2 -0
  49. package/dist/sdk/terminal/subagentMonitor.js +4 -1
  50. package/dist/sdk/terminal/terminals.js +35 -4
  51. package/dist/sdk/terminal/toolName.d.ts +6 -0
  52. package/dist/sdk/terminal/toolName.js +12 -0
  53. package/dist/sdk/terminal/tui/commandPages.d.ts +1 -1
  54. package/dist/sdk/terminal/tui/commandPages.js +3 -0
  55. package/dist/sdk/terminal/tui/commands.d.ts +14 -0
  56. package/dist/sdk/terminal/tui/commands.js +4 -1
  57. package/dist/sdk/terminal/tui/pages/followUpQueuePage.d.ts +34 -0
  58. package/dist/sdk/terminal/tui/pages/followUpQueuePage.js +130 -0
  59. package/dist/sdk/terminal/tui/pages/trajectoryPage.js +37 -7
  60. package/dist/sdk/terminal/tui/runtime.js +2 -1
  61. package/dist/sdk/terminal/tui/settingsModel.js +88 -7
  62. package/dist/sdk/tools/askUser.js +2 -2
  63. package/dist/sdk/world/world.d.ts +12 -2
  64. package/dist/sdk/world/world.js +42 -4
  65. package/dist/terminal-opentui.js +1456 -756
  66. package/dist/terminal-pi.js +62 -31
  67. package/docs/README.md +1 -0
  68. package/docs/architecture/02-sessions-and-runs.md +43 -25
  69. package/docs/architecture/04-prompt.md +23 -22
  70. package/docs/architecture/10-server-protocol.md +23 -3
  71. package/docs/architecture/11-terminal.md +41 -29
  72. package/docs/architecture/12-storage.md +2 -1
  73. package/docs/architecture/13-world-tools.md +35 -22
  74. package/docs/architecture/14-surfaces-and-parity.md +24 -20
  75. package/docs/architecture/README.md +4 -1
  76. package/docs/architecture/proposals/PROP-0026-connected-sketch-display.md +3 -1
  77. package/docs/decisions/ADR-0027-connected-sketch-display.md +4 -2
  78. package/docs/decisions/ADR-0076-codex-final-answer-verbatim.md +6 -1
  79. package/docs/decisions/ADR-0077-plan-runs-may-conclude-no-plan-needed.md +120 -0
  80. package/docs/decisions/ADR-0078-child-aware-stall-watch.md +46 -0
  81. package/docs/decisions/ADR-0079-phase-led-reporting-guidance.md +72 -0
  82. package/docs/decisions/ADR-0080-fixed-width-question-sketch-canvas.md +50 -0
  83. package/docs/decisions/ADR-0081-no-work-slice-budget-by-default.md +36 -0
  84. package/docs/decisions/ADR-0082-connected-brave-browser.md +72 -0
  85. package/docs/parity.md +7 -5
  86. package/docs/releases/1.5.0-unstable.md +12 -3
  87. package/docs/releases/1.5.1-unstable.md +24 -0
  88. package/docs/releases/1.6.0-unstable.md +59 -0
  89. package/docs/study/00-index.md +17 -1
  90. package/docs/study/STUDY-0007-claude-code-prompt.md +162 -0
  91. package/docs/study/STUDY-0008-browser-control.md +281 -0
  92. package/docs/study/STUDY-0009-claude-code-reporting.md +205 -0
  93. package/docs/study/sources/claude-code.md +51 -39
  94. package/docs/terminal-tui.md +46 -18
  95. package/package.json +2 -1
@@ -22,7 +22,13 @@ export interface SlicePolicy {
22
22
  maxSlices: number;
23
23
  continue: boolean;
24
24
  }
25
- /** The foundation defaults: enough for one meaningful step of work, short enough to report. */
25
+ /**
26
+ * The foundation defaults: no budget (ADR-0081). A reply is not cut at a number of tool calls, a
27
+ * length of time or a number of tokens, and nothing is continued as a "next slice"; it ends when the
28
+ * work is done, a person's decision is needed, or the person interrupts. The limits stay available
29
+ * to a caller that asks for one (`workSlice`, `XINGULAR_WORK_SLICE`), and the three-compaction guard
30
+ * is not a budget and stays on.
31
+ */
26
32
  export declare const DEFAULT_SLICE_POLICY: SlicePolicy;
27
33
  export declare class WorkSliceError extends Error {
28
34
  }
@@ -150,4 +156,12 @@ export declare function sliceContinuation(options: SliceContinuationOptions): (p
150
156
  export declare function sliceOf(root: string, runId: string, limit?: number): number;
151
157
  /** The reason a checkpoint the harness derives (no model handoff) records. */
152
158
  export declare function derivedReason(end: SliceEnd | null): Checkpoint['reason'];
159
+ /** What a heartbeat reports about the tools in flight: their names once each, and the oldest one's age. */
160
+ export declare function activeToolBeat(tools: ReadonlyMap<string, {
161
+ name: string;
162
+ at: number;
163
+ }>, now?: number): {
164
+ activeTools: string[];
165
+ oldestToolMs: number;
166
+ } | Record<string, never>;
153
167
  export {};
@@ -1,17 +1,18 @@
1
1
  import { findRun } from "./runStore.js";
2
2
  export const WORK_SLICE_EXTENSION = 'work-slice';
3
- /** The foundation defaults: enough for one meaningful step of work, short enough to report. */
3
+ /**
4
+ * The foundation defaults: no budget (ADR-0081). A reply is not cut at a number of tool calls, a
5
+ * length of time or a number of tokens, and nothing is continued as a "next slice"; it ends when the
6
+ * work is done, a person's decision is needed, or the person interrupts. The limits stay available
7
+ * to a caller that asks for one (`workSlice`, `XINGULAR_WORK_SLICE`), and the three-compaction guard
8
+ * is not a budget and stays on.
9
+ */
4
10
  export const DEFAULT_SLICE_POLICY = {
5
11
  enabled: true,
6
- // A chapter of a task, not an interruption every few edits: roughly 80 minutes of ordinary work
7
- // (about 15 s a call), with 60 minutes of wall clock as the ceiling, whichever comes first. A task
8
- // still gets eight slices before it returns to the person.
9
- maxToolCalls: 320,
10
- maxMinutes: 60,
11
- // No token cap: the tool calls and the clock are the bound. Counting new input, cache writes and
12
- // output made a few large reads end a reply before useful work (see the "Confirm plan model
13
- // support" thread), so a cap is available to whoever asks for one and off unless they do.
12
+ maxToolCalls: 0,
13
+ maxMinutes: 0,
14
14
  maxNewTokens: 0,
15
+ // Inert while no limit ends a slice; a caller that sets one gets the same continuation as before.
15
16
  maxSlices: 8,
16
17
  continue: true
17
18
  };
@@ -256,3 +257,10 @@ export function sliceOf(root, runId, limit = 100) {
256
257
  export function derivedReason(end) {
257
258
  return end ?? 'requested';
258
259
  }
260
+ /** What a heartbeat reports about the tools in flight: their names once each, and the oldest one's age. */
261
+ export function activeToolBeat(tools, now = Date.now()) {
262
+ if (!tools.size)
263
+ return {};
264
+ const names = [...new Set([...tools.values()].map((tool) => tool.name))].slice(0, 16);
265
+ return { activeTools: names, oldestToolMs: Math.max(0, now - Math.min(...[...tools.values()].map((tool) => tool.at))) };
266
+ }
@@ -78,6 +78,8 @@ export declare class TerminalController {
78
78
  /** When the reply last produced something other than a heartbeat; silence is what a stall is. */
79
79
  private progressAt;
80
80
  private readonly runningTools;
81
+ /** Each live subagent's own clock: its last real event and the tools it is inside. */
82
+ private readonly children;
81
83
  private stallRequested;
82
84
  private stallTimer;
83
85
  constructor(hooks: ControllerHooks);
@@ -103,6 +105,8 @@ export declare class TerminalController {
103
105
  * Ends a reply its native turn can no longer finish: the process is alive and keeps reporting, but
104
106
  * the reply produced nothing for the whole window (a model request that never came back). A person
105
107
  * may already be answering an approval, and a tool may still be working, so neither is cut short.
108
+ * A reply that is only waiting for subagents is judged by them: a quiet child is ended on its own
109
+ * clock, which returns the wait and lets the reply carry on without anyone stepping in.
106
110
  */
107
111
  private checkStall;
108
112
  /** Hands the native process an instruction and waits until it acknowledges the prompt. */
@@ -115,6 +119,8 @@ export declare class TerminalController {
115
119
  observe(event: RunEvent, descendant?: boolean): void;
116
120
  /** A child event that belongs to the active terminal parent run. */
117
121
  observeSubagent(parentRunId: string, run: RunRecord, event: RunEvent, profile?: TerminalSubagentProfile | null, runtime?: Pick<TerminalSubagentRun, 'accountLabel' | 'model' | 'effort'>): void;
122
+ /** Folds a child's event into its own stall clock; a heartbeat only proves the process lives. */
123
+ private trackChild;
118
124
  /** An event of the tracked parent run itself, shown as the `main` row; it outlives settlement. */
119
125
  observeMain(run: RunRecord, event: RunEvent, runtime?: MonitorRuntime): void;
120
126
  /** Shows a saved parent run and its descendants, as they ended, until another run begins. */
@@ -4,10 +4,10 @@
4
4
  // extension's questions about policy, approvals, and what to do next.
5
5
  import { createHash, randomUUID } from 'node:crypto';
6
6
  import { HarnessError } from "../core/errors.js";
7
- import { runStatusSchema } from "../sessions/contracts.js";
7
+ import { isActiveStatus, runStatusSchema } from "../sessions/contracts.js";
8
8
  import { briefTitle } from "../sessions/subagents.js";
9
9
  import { MAIN_PROFILE, SubagentMonitor } from "./subagentMonitor.js";
10
- import { replyStalled } from "./stallWatch.js";
10
+ import { childStalled, replyStalled, waitingOnChildren } from "./stallWatch.js";
11
11
  /** Stop codes the native extension may report for a reply it settled itself. */
12
12
  const SETTLED_CODES = new Set(['slice_budget', 'compaction_thrash', 'needs_person', 'unfinished']);
13
13
  function monitorRun(record, relation, profile, runtime) {
@@ -50,6 +50,8 @@ export class TerminalController {
50
50
  /** When the reply last produced something other than a heartbeat; silence is what a stall is. */
51
51
  progressAt = 0;
52
52
  runningTools = new Map();
53
+ /** Each live subagent's own clock: its last real event and the tools it is inside. */
54
+ children = new Map();
53
55
  stallRequested = null;
54
56
  stallTimer = null;
55
57
  constructor(hooks) {
@@ -131,6 +133,7 @@ export class TerminalController {
131
133
  watchProgress() {
132
134
  this.progressAt = Date.now();
133
135
  this.runningTools.clear();
136
+ this.children.clear();
134
137
  this.stallRequested = null;
135
138
  if (this.stallTimer)
136
139
  clearInterval(this.stallTimer);
@@ -143,17 +146,34 @@ export class TerminalController {
143
146
  this.stallTimer = null;
144
147
  this.stallRequested = null;
145
148
  this.runningTools.clear();
149
+ this.children.clear();
146
150
  }
147
151
  /**
148
152
  * Ends a reply its native turn can no longer finish: the process is alive and keeps reporting, but
149
153
  * the reply produced nothing for the whole window (a model request that never came back). A person
150
154
  * may already be answering an approval, and a tool may still be working, so neither is cut short.
155
+ * A reply that is only waiting for subagents is judged by them: a quiet child is ended on its own
156
+ * clock, which returns the wait and lets the reply carry on without anyone stepping in.
151
157
  */
152
158
  checkStall() {
153
159
  const run = this.activeRun;
154
- if (!run || this.stallRequested || this.pending.size)
160
+ if (!run || this.pending.size)
155
161
  return;
156
- if (!replyStalled({ now: Date.now(), progressAt: this.progressAt, runningTools: [...this.runningTools.values()] }))
162
+ const now = Date.now();
163
+ const children = [...this.children.values()].map((child) => ({ progressAt: child.progressAt, runningTools: [...child.tools.values()], stopRequestedAt: child.stopRequestedAt }));
164
+ for (const [id, child] of this.children) {
165
+ if (!childStalled(now, { progressAt: child.progressAt, runningTools: [...child.tools.values()], stopRequestedAt: child.stopRequestedAt }))
166
+ continue;
167
+ child.stopRequestedAt = now;
168
+ this.hooks.telemetry('stall');
169
+ void this.hooks.interrupt?.(id, { reason: 'stalled', code: 'stalled' }).catch(() => { });
170
+ }
171
+ if (this.stallRequested)
172
+ return;
173
+ const tools = [...this.runningTools.values()];
174
+ if (waitingOnChildren(now, tools.map((tool) => tool.name), children))
175
+ return;
176
+ if (!replyStalled({ now, progressAt: this.progressAt, runningTools: tools.map((tool) => tool.at) }))
157
177
  return;
158
178
  this.stallRequested = run.runId;
159
179
  this.hooks.telemetry('stall');
@@ -242,6 +262,25 @@ export class TerminalController {
242
262
  if (this.disposed || this.activeRun?.runId !== parentRunId || run.relation !== 'subagent')
243
263
  return;
244
264
  this.subagents.observe(parentRunId, monitorRun(run, 'subagent', profile, runtime), event);
265
+ this.trackChild(run, event);
266
+ }
267
+ /** Folds a child's event into its own stall clock; a heartbeat only proves the process lives. */
268
+ trackChild(run, event) {
269
+ const status = event.payload.status;
270
+ const settled = event.type === 'completed' || event.type === 'failed' || (event.type === 'status.changed' && typeof status === 'string' && !isActiveStatus(status));
271
+ if (settled || !isActiveStatus(run.status)) {
272
+ this.children.delete(run.id);
273
+ return;
274
+ }
275
+ const child = this.children.get(run.id) ?? { progressAt: Date.now(), tools: new Map(), stopRequestedAt: null };
276
+ if (event.type !== 'heartbeat')
277
+ child.progressAt = Date.now();
278
+ const toolCallId = String(event.payload.toolCallId ?? '');
279
+ if (event.type === 'tool.started' && toolCallId)
280
+ child.tools.set(toolCallId, Date.now());
281
+ else if (event.type === 'tool.completed')
282
+ child.tools.delete(toolCallId);
283
+ this.children.set(run.id, child);
245
284
  }
246
285
  /** An event of the tracked parent run itself, shown as the `main` row; it outlives settlement. */
247
286
  observeMain(run, event, runtime = {}) {
@@ -514,7 +553,7 @@ export class TerminalController {
514
553
  run.emit('message.completed', { messageId: data.id ?? randomUUID(), role: data.role ?? 'assistant', text: String(data.text) });
515
554
  else if (event === 'tool') {
516
555
  if (data.phase === 'start')
517
- this.runningTools.set(String(data.id), Date.now());
556
+ this.runningTools.set(String(data.id), { name: String(data.name ?? ''), at: Date.now() });
518
557
  else
519
558
  this.runningTools.delete(String(data.id));
520
559
  run.emit(data.phase === 'start' ? 'tool.started' : 'tool.completed', {
@@ -1,5 +1,6 @@
1
1
  import { sessionEntryToContextMessages } from '@earendil-works/pi-coding-agent';
2
2
  import { parseProposedPlan } from "../tui/planMode.js";
3
+ import { toolDisplayName } from "../toolName.js";
3
4
  import { ansiLabel, ansiPlain } from "./ansi.js";
4
5
  const MAX_TOOL_OUTPUT = 12_000;
5
6
  const OUTPUT_CLIP_MARKER = '\n… output clipped …\n';
@@ -447,7 +448,8 @@ export class TranscriptModel {
447
448
  const text = contentText(message.content);
448
449
  if (!text.trim())
449
450
  return items;
450
- items.push({ kind: 'custom', id: `m${index}`, label: stringField(message, 'customType') ?? 'Extension', text });
451
+ const customType = stringField(message, 'customType') ?? '';
452
+ items.push({ kind: 'custom', id: `m${index}`, label: toolDisplayName(customType) || 'Extension', text });
451
453
  return items;
452
454
  }
453
455
  if (role === 'compactionSummary') {
@@ -16,7 +16,7 @@ import { goalStopOutcome, handoffFields, stopCheckpointReason, stopSummary } fro
16
16
  import { launchSwitchOff, sessionCheckModel } from "../sessions/completionCheck.js";
17
17
  import { GOAL_STATE_ENTRY, GOAL_TOOLS, goalTools } from "../sessions/goal.js";
18
18
  import { GOAL_EXTENSION, goalLoop, goalNotice } from "../sessions/goalLoop.js";
19
- import { createSliceState, DEFAULT_SLICE_POLICY, parseWorkSlice, sliceContinuation, workSliceGuard, WORK_SLICE_EXTENSION } from "../sessions/workSlice.js";
19
+ import { activeToolBeat, createSliceState, DEFAULT_SLICE_POLICY, parseWorkSlice, sliceContinuation, workSliceGuard, WORK_SLICE_EXTENSION } from "../sessions/workSlice.js";
20
20
  import { DEFAULT_COMMAND_TIMEOUT_SECONDS, withCommandBudget } from "../core/commandBudget.js";
21
21
  import { SESSION_NAME, deriveSessionName, sessionNameTool } from "../tools/sessionName.js";
22
22
  import { CAPABILITY_DISCOVERY, describeCapabilities } from "../tools/getCapabilities.js";
@@ -76,6 +76,7 @@ export default async function (pi, planRun, getSession, goal) {
76
76
  let requestStartedAt = null;
77
77
  let pendingRefusals = [];
78
78
  const toolStartedAt = new Map();
79
+ const toolsInFlight = new Map();
79
80
  const fileTools = new Map();
80
81
  const writeAuthorizations = new Map();
81
82
  let workspaceRoot = null;
@@ -298,6 +299,7 @@ export default async function (pi, planRun, getSession, goal) {
298
299
  // A live reply reports progress so a person and the watchdog can tell a long turn from a wedged
299
300
  // one; a turn that stops producing anything is settled with reason `stalled` (INV-RUN-14).
300
301
  turnStartedAt = Date.now();
302
+ toolsInFlight.clear();
301
303
  if (heartbeat)
302
304
  clearInterval(heartbeat);
303
305
  heartbeat = setInterval(() => {
@@ -307,7 +309,8 @@ export default async function (pi, planRun, getSession, goal) {
307
309
  lastTool: sliceState.lastTool,
308
310
  compactions: sliceState.compactions,
309
311
  slice: sliceState.slice,
310
- maxToolCalls: slicePolicy.enabled ? slicePolicy.maxToolCalls : 0
312
+ maxToolCalls: slicePolicy.enabled ? slicePolicy.maxToolCalls : 0,
313
+ ...activeToolBeat(toolsInFlight)
311
314
  }, 10_000).catch(() => { });
312
315
  }, 30_000);
313
316
  heartbeat.unref?.();
@@ -735,11 +738,13 @@ export default async function (pi, planRun, getSession, goal) {
735
738
  if (['edit', 'write'].includes(event.toolName) && typeof event.args.path === 'string')
736
739
  fileTools.set(event.toolCallId, event.args.path);
737
740
  toolStartedAt.set(event.toolCallId, Date.now());
741
+ toolsInFlight.set(event.toolCallId, { name: event.toolName, at: Date.now() });
738
742
  await terminalCall('tool', { phase: 'start', id: event.toolCallId, name: event.toolName, summary: JSON.stringify(event.args).slice(0, 200) });
739
743
  });
740
744
  pi.on('tool_execution_end', async (event) => {
741
745
  const started = toolStartedAt.get(event.toolCallId);
742
746
  toolStartedAt.delete(event.toolCallId);
747
+ toolsInFlight.delete(event.toolCallId);
743
748
  const resultChars = resultSize(event.result);
744
749
  await terminalCall('tool', { phase: 'end', id: event.toolCallId, name: event.toolName, ok: !event.isError, ...(started === undefined ? {} : { durationMs: Date.now() - started }), ...(resultChars === null ? {} : { resultChars }) });
745
750
  const path = fileTools.get(event.toolCallId);
@@ -11,3 +11,21 @@ export interface ReplyProgress {
11
11
  }
12
12
  /** Whether a live reply has gone too long without progress and should be ended. The oldest running tool counts. */
13
13
  export declare function replyStalled({ now, progressAt, runningTools }: ReplyProgress): boolean;
14
+ /** The tools that block a reply until its children settle. */
15
+ export declare const CHILD_WAIT_TOOLS: ReadonlySet<string>;
16
+ /** How long a child may stay unsettled after it was told to stop before its parent's own watch resumes. */
17
+ export declare const CHILD_STOP_GRACE_MS: number;
18
+ /** One subagent the terminal reply is waiting on. */
19
+ export interface ChildProgress {
20
+ progressAt: number;
21
+ runningTools: readonly number[];
22
+ /** When the stall watch ended this child, if it did. */
23
+ stopRequestedAt: number | null;
24
+ }
25
+ /** Whether a child has gone quiet for the same windows as a reply, and was not yet told to stop. */
26
+ export declare function childStalled(now: number, child: ChildProgress): boolean;
27
+ /**
28
+ * Whether the parent's own watch is paused: it is only waiting for children, and at least one still
29
+ * answers to its own watch. A child that ignored the stop for the grace period hands the clock back.
30
+ */
31
+ export declare function waitingOnChildren(now: number, runningToolNames: readonly string[], children: readonly ChildProgress[]): boolean;
@@ -2,6 +2,9 @@
2
2
  // told instead of watching a run that never settles. This is the turn-level rule; the run-store
3
3
  // sweep (`reconcileStaleRuns`) settles a record whose owner stopped reporting, and both use the
4
4
  // same reason, `stalled` (INV-RUN-14).
5
+ //
6
+ // A reply that waits for its subagents is not silent: each child is watched on its own clock and ended
7
+ // with the same reason when it goes quiet, so the wait returns and the parent carries on by itself.
5
8
  /** Silence with no tool running: the model request never came back. */
6
9
  export const REPLY_STALL_MS = 15 * 60_000;
7
10
  /** One tool may run this long; past it the reply is wedged, not busy. */
@@ -14,3 +17,20 @@ export function replyStalled({ now, progressAt, runningTools }) {
14
17
  ? now - Math.min(...runningTools) >= TOOL_STALL_MS
15
18
  : now - progressAt >= REPLY_STALL_MS;
16
19
  }
20
+ /** The tools that block a reply until its children settle. */
21
+ export const CHILD_WAIT_TOOLS = new Set(['wait_subagents', 'spawn_subagent']);
22
+ /** How long a child may stay unsettled after it was told to stop before its parent's own watch resumes. */
23
+ export const CHILD_STOP_GRACE_MS = 5 * 60_000;
24
+ /** Whether a child has gone quiet for the same windows as a reply, and was not yet told to stop. */
25
+ export function childStalled(now, child) {
26
+ return child.stopRequestedAt === null && replyStalled({ now, progressAt: child.progressAt, runningTools: child.runningTools });
27
+ }
28
+ /**
29
+ * Whether the parent's own watch is paused: it is only waiting for children, and at least one still
30
+ * answers to its own watch. A child that ignored the stop for the grace period hands the clock back.
31
+ */
32
+ export function waitingOnChildren(now, runningToolNames, children) {
33
+ if (!runningToolNames.length || !runningToolNames.every((name) => CHILD_WAIT_TOOLS.has(name)))
34
+ return false;
35
+ return children.some((child) => child.stopRequestedAt === null || now - child.stopRequestedAt < CHILD_STOP_GRACE_MS);
36
+ }
@@ -28,6 +28,8 @@ export interface TerminalSubagentRun {
28
28
  export type TerminalSubagentUsage = Partial<Pick<UsagePayload, 'provider' | 'model' | 'inputTokens' | 'outputTokens' | 'cacheReadTokens' | 'cacheWriteTokens' | 'reasoningTokens' | 'costUsd' | 'costSource' | 'requests' | 'contextTokens' | 'contextWindow'>>;
29
29
  export interface TerminalSubagentActivity extends TerminalSubagentRun {
30
30
  activity: string;
31
+ /** When the run last did anything but heartbeat; how long it has been quiet is what a stall is. */
32
+ lastEventAt?: string;
31
33
  /** Cumulative run usage only; provider reasoning summaries and request details stay private. */
32
34
  usage: TerminalSubagentUsage | null;
33
35
  /** How far the child's own todo is: counts only, never the items. */
@@ -1,4 +1,5 @@
1
1
  import { usagePayloadSchema } from "../sessions/contracts.js";
2
+ import { toolDisplayName } from "./toolName.js";
2
3
  /** The main agent's row: the parent run itself, shown by name rather than by a profile. */
3
4
  export const MAIN_PROFILE = { name: 'main', color: 'main' };
4
5
  export const ACTIVE_SUBAGENT_STATUSES = new Set(['queued', 'running', 'waiting_for_approval', 'waiting_for_input']);
@@ -92,12 +93,14 @@ export class SubagentMonitor {
92
93
  row.startedAt = run.startedAt;
93
94
  row.endedAt = run.endedAt;
94
95
  const payload = event ? payloadOf(event) : {};
96
+ if (event && event.type !== 'heartbeat')
97
+ row.lastEventAt = event.timestamp;
95
98
  if (!event) {
96
99
  // A seeded row keeps what it already folded.
97
100
  }
98
101
  else if (event.type === 'tool.started') {
99
102
  const toolCallId = stringOf(payload.toolCallId);
100
- const name = stringOf(payload.name) ?? 'Tool';
103
+ const name = toolDisplayName(stringOf(payload.name) ?? '') || 'Tool';
101
104
  if (toolCallId)
102
105
  row.tools.set(toolCallId, { sequence: event.sequence, text: name });
103
106
  }
@@ -1259,15 +1259,46 @@ export class Terminals {
1259
1259
  const world = this.options.world;
1260
1260
  if (!world)
1261
1261
  throw invalid('Browser settings are unavailable in this host.');
1262
- return { allowPrivate: world.browserConfig().allowPrivate };
1262
+ const config = world.browserConfig();
1263
+ return { backend: config.backend, allowPrivate: config.allowPrivate };
1263
1264
  }
1264
1265
  if (call === 'terminal.browser.configure') {
1265
1266
  const world = this.options.world;
1266
1267
  if (!world)
1267
1268
  throw invalid('Browser settings are unavailable in this host.');
1268
- if (typeof params.allowPrivate !== 'boolean')
1269
- throw invalid('allowPrivate must be a boolean.');
1270
- return { allowPrivate: world.configureBrowser({ allowPrivate: params.allowPrivate }).allowPrivate };
1269
+ const change = {};
1270
+ if ('allowPrivate' in params) {
1271
+ if (typeof params.allowPrivate !== 'boolean')
1272
+ throw invalid('allowPrivate must be a boolean.');
1273
+ change.allowPrivate = params.allowPrivate;
1274
+ }
1275
+ if ('backend' in params) {
1276
+ if (params.backend !== 'isolated' && params.backend !== 'brave')
1277
+ throw invalid('backend must be isolated or brave.');
1278
+ change.backend = params.backend;
1279
+ }
1280
+ if (!Object.keys(change).length)
1281
+ throw invalid('Set a browser backend or allowPrivate value.');
1282
+ const config = await world.configureBrowser(change);
1283
+ return { backend: config.backend, allowPrivate: config.allowPrivate };
1284
+ }
1285
+ if (call === 'terminal.browser.extension.status') {
1286
+ const world = this.options.world;
1287
+ if (!world)
1288
+ throw invalid('Browser settings are unavailable in this host.');
1289
+ return world.browserExtensionStatus();
1290
+ }
1291
+ if (call === 'terminal.browser.extension.start') {
1292
+ const world = this.options.world;
1293
+ if (!world)
1294
+ throw invalid('Browser settings are unavailable in this host.');
1295
+ return world.browserExtensionStart();
1296
+ }
1297
+ if (call === 'terminal.browser.extension.stop') {
1298
+ const world = this.options.world;
1299
+ if (!world)
1300
+ throw invalid('Browser settings are unavailable in this host.');
1301
+ return world.browserExtensionStop();
1271
1302
  }
1272
1303
  if (call === 'terminal.browser.snapshot')
1273
1304
  return browser.snapshot(params.refresh === true);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A tool or message id as a person reads it: `todo_write` becomes `TodoWrite`,
3
+ * `computer-screenshot` becomes `ComputerScreenshot`. Only the first letter of each part is
4
+ * raised, so a name that already carries capitals keeps them.
5
+ */
6
+ export declare function toolDisplayName(name: string): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A tool or message id as a person reads it: `todo_write` becomes `TodoWrite`,
3
+ * `computer-screenshot` becomes `ComputerScreenshot`. Only the first letter of each part is
4
+ * raised, so a name that already carries capitals keeps them.
5
+ */
6
+ export function toolDisplayName(name) {
7
+ return name
8
+ .split(/[^A-Za-z0-9]+/u)
9
+ .filter(part => part !== '')
10
+ .map(part => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
11
+ .join('');
12
+ }
@@ -2,7 +2,7 @@ import type { TerminalPage } from "./page.js";
2
2
  import type { TerminalCommandHost } from "./commands.js";
3
3
  import { type AnnotateStart } from "./pages/annotatePage.js";
4
4
  import { type ContextTab } from "./pages/contextPage.js";
5
- export type CommandPageId = 'doctor' | 'accounts' | 'permissions' | 'plugins' | 'hub' | 'components' | 'settings' | 'trajectory' | 'log' | 'harness' | 'skills' | 'connections' | 'tools' | 'worktree' | 'notes' | 'annotate' | 'subagents' | 'agents' | 'usage' | 'context' | 'remote-control';
5
+ export type CommandPageId = 'doctor' | 'accounts' | 'permissions' | 'plugins' | 'hub' | 'components' | 'settings' | 'trajectory' | 'log' | 'harness' | 'skills' | 'connections' | 'tools' | 'worktree' | 'notes' | 'annotate' | 'subagents' | 'agents' | 'usage' | 'context' | 'remote-control' | 'follow-up';
6
6
  /** Construct one independent full-screen page for a slash command. */
7
7
  export declare function createCommandPage(host: TerminalCommandHost, id: CommandPageId, options?: {
8
8
  runId?: string;
@@ -14,12 +14,15 @@ import { AgentsPage } from "./pages/agentsPage.js";
14
14
  import { UsagePage } from "./pages/usagePage.js";
15
15
  import { ContextPage } from "./pages/contextPage.js";
16
16
  import { RemoteControlPage } from "./pages/remoteControlPage.js";
17
+ import { FollowUpQueuePage } from "./pages/followUpQueuePage.js";
17
18
  import { createSettingsModel } from "./settingsModel.js";
18
19
  /** Construct one independent full-screen page for a slash command. */
19
20
  export function createCommandPage(host, id, options = {}) {
20
21
  const theme = () => host.ui.theme;
21
22
  const notify = (message, type) => host.ui.notify(message, type === 'error' || type === 'warning' ? type : 'info');
22
23
  switch (id) {
24
+ case 'follow-up':
25
+ return new FollowUpQueuePage({ editor: host.followUpQueue(), theme, changed: host.changed, close: options.close ?? (() => { }) });
23
26
  case 'remote-control':
24
27
  return new RemoteControlPage({ theme, call: host.control, changed: host.changed, copy: host.copyText, notify });
25
28
  case 'context':
@@ -7,8 +7,22 @@ import type { TerminalSubagentActivity } from "../subagentMonitor.js";
7
7
  import { type TerminalCommandCompletion, type TerminalCommandHelpGroup } from "../commandHelp.js";
8
8
  export type { TerminalCommandCompletion } from "../commandHelp.js";
9
9
  export { parseAnnotate } from "./pluginCommands.js";
10
+ export type FollowUpQueueEntry = {
11
+ id: string;
12
+ kind: 'steering' | 'followUp';
13
+ text: string;
14
+ hasImages: boolean;
15
+ };
16
+ export type FollowUpQueueEditor = {
17
+ entries(): readonly FollowUpQueueEntry[];
18
+ edit(id: string): Promise<void>;
19
+ sendNext(): Promise<void>;
20
+ remove(id: string): void;
21
+ close(): void;
22
+ };
10
23
  export interface TerminalCommandHost {
11
24
  session(): AgentSession;
25
+ followUpQueue(): FollowUpQueueEditor;
12
26
  /** Navigation is scope checked by the Harness runtime adapter. */
13
27
  runtime(): Pick<TerminalRuntime, 'newSession' | 'switchSession' | 'fork' | 'importFromJsonl' | 'reload' | 'navigateTree' | 'installationDeviceId' | 'permissions' | 'setFullPermission' | 'setGlobalTool' | 'snapshot' | 'interrupt'>;
14
28
  /** The protected `accounts.*`, `plugins.*` and `hub.*` calls of this session, made with its control key. */
@@ -356,13 +356,16 @@ export function createTerminalCommands(host) {
356
356
  case 'worktree':
357
357
  await openPage('worktree');
358
358
  break;
359
+ case 'follow-up':
360
+ await openPage('follow-up');
361
+ break;
359
362
  case 'trajectory':
360
363
  await openPage('trajectory', { runId: argument });
361
364
  break;
362
365
  case 'todo': {
363
366
  const items = await host.todo();
364
367
  if (!items.length)
365
- notify('No todo list in this reply yet. The agent writes one with todo_write for multi-step work.');
368
+ notify('No todo list in this reply yet. The agent writes one with TodoWrite for multi-step work.');
366
369
  else
367
370
  host.showTodo(items);
368
371
  break;
@@ -0,0 +1,34 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent';
2
+ import type { TabInfo } from "../chrome.js";
3
+ import type { FollowUpQueueEditor } from "../commands.js";
4
+ import type { TerminalMouseInput } from "../mouse.js";
5
+ import type { TerminalPage } from "../page.js";
6
+ export declare class FollowUpQueuePage implements TerminalPage {
7
+ private readonly options;
8
+ presentation: "centered";
9
+ private selected;
10
+ private disposed;
11
+ private closed;
12
+ constructor(options: {
13
+ editor: FollowUpQueueEditor;
14
+ theme(): Theme;
15
+ changed(): void;
16
+ close(): void;
17
+ });
18
+ get focused(): boolean;
19
+ set focused(_value: boolean);
20
+ tabs(): readonly TabInfo[];
21
+ activeTab(): string;
22
+ hint(): string;
23
+ activate(): void;
24
+ dispose(): void;
25
+ private closeEditor;
26
+ private entries;
27
+ render(_width: number, _rows: number): string[];
28
+ private act;
29
+ handleInput(data: string): boolean;
30
+ handleMouse(event: TerminalMouseInput): {
31
+ handled: boolean;
32
+ render: boolean;
33
+ } | undefined;
34
+ }