@xingular/harness 1.2.0 → 1.3.1-unstable.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +10 -5
  3. package/dist/cli.js +5560 -5056
  4. package/dist/sdk/agents/builtin.js +3 -3
  5. package/dist/sdk/agents/planResearch.d.ts +23 -1
  6. package/dist/sdk/agents/planResearch.js +71 -8
  7. package/dist/sdk/agents/profiles.d.ts +4 -1
  8. package/dist/sdk/agents/profiles.js +17 -6
  9. package/dist/sdk/capabilities/runSnapshot.js +10 -4
  10. package/dist/sdk/commands/registry.d.ts +11 -3
  11. package/dist/sdk/commands/registry.js +3 -2
  12. package/dist/sdk/core/shell.d.ts +20 -0
  13. package/dist/sdk/core/shell.js +73 -0
  14. package/dist/sdk/core/workspaceBashTool.d.ts +17 -1
  15. package/dist/sdk/core/workspaceBashTool.js +16 -2
  16. package/dist/sdk/core/workspaceSandbox.d.ts +10 -0
  17. package/dist/sdk/core/workspaceSandbox.js +52 -12
  18. package/dist/sdk/core/workspaceWriteOperations.d.ts +11 -0
  19. package/dist/sdk/core/workspaceWriteOperations.js +26 -8
  20. package/dist/sdk/doctor/checks.js +71 -12
  21. package/dist/sdk/hub/hub.js +5 -3
  22. package/dist/sdk/index.d.ts +2 -2
  23. package/dist/sdk/index.js +1 -1
  24. package/dist/sdk/plugins/builtin.js +3 -3
  25. package/dist/sdk/plugins/install.d.ts +6 -2
  26. package/dist/sdk/plugins/install.js +29 -5
  27. package/dist/sdk/plugins/inventory.js +2 -2
  28. package/dist/sdk/prompt/compile.d.ts +2 -2
  29. package/dist/sdk/prompt/compile.js +14 -16
  30. package/dist/sdk/server/methods.js +4 -2
  31. package/dist/sdk/sessions/compactionCheckpoint.d.ts +3 -3
  32. package/dist/sdk/sessions/compactionCheckpoint.js +12 -12
  33. package/dist/sdk/sessions/continuation.d.ts +4 -2
  34. package/dist/sdk/sessions/continuation.js +7 -6
  35. package/dist/sdk/sessions/contracts.d.ts +22 -4
  36. package/dist/sdk/sessions/contracts.js +12 -7
  37. package/dist/sdk/sessions/planMode.js +2 -2
  38. package/dist/sdk/sessions/runStore.js +3 -1
  39. package/dist/sdk/sessions/runner.js +2 -2
  40. package/dist/sdk/sessions/sessions.js +29 -11
  41. package/dist/sdk/sessions/subagentService.d.ts +15 -0
  42. package/dist/sdk/sessions/subagentService.js +194 -54
  43. package/dist/sdk/sessions/subagents.d.ts +10 -1
  44. package/dist/sdk/sessions/subagents.js +4 -0
  45. package/dist/sdk/sessions/workSlice.d.ts +8 -5
  46. package/dist/sdk/sessions/workSlice.js +77 -45
  47. package/dist/sdk/terminal/controller.d.ts +20 -0
  48. package/dist/sdk/terminal/controller.js +43 -18
  49. package/dist/sdk/terminal/opentui/ansi.d.ts +25 -0
  50. package/dist/sdk/terminal/opentui/ansi.js +192 -0
  51. package/dist/sdk/terminal/opentui/model.d.ts +163 -0
  52. package/dist/sdk/terminal/opentui/model.js +482 -0
  53. package/dist/sdk/terminal/opentui/subagentsWidget.d.ts +3 -3
  54. package/dist/sdk/terminal/opentui/subagentsWidget.js +7 -5
  55. package/dist/sdk/terminal/opentui/theme.js +1 -0
  56. package/dist/sdk/terminal/piExtension.js +28 -14
  57. package/dist/sdk/terminal/subagentMonitor.d.ts +17 -4
  58. package/dist/sdk/terminal/subagentMonitor.js +29 -8
  59. package/dist/sdk/terminal/terminals.d.ts +7 -0
  60. package/dist/sdk/terminal/terminals.js +86 -14
  61. package/dist/sdk/terminal/trajectory.d.ts +6 -1
  62. package/dist/sdk/terminal/trajectory.js +8 -0
  63. package/dist/sdk/terminal/tui/chrome.d.ts +4 -0
  64. package/dist/sdk/terminal/tui/chrome.js +13 -0
  65. package/dist/sdk/terminal/tui/commandPages.js +14 -4
  66. package/dist/sdk/terminal/tui/commands.d.ts +4 -0
  67. package/dist/sdk/terminal/tui/commands.js +8 -0
  68. package/dist/sdk/terminal/tui/pageOverlay.js +13 -6
  69. package/dist/sdk/terminal/tui/pages/browserPage.js +5 -4
  70. package/dist/sdk/terminal/tui/pages/hubPage.d.ts +14 -1
  71. package/dist/sdk/terminal/tui/pages/hubPage.js +192 -28
  72. package/dist/sdk/terminal/tui/pages/pluginsPage.d.ts +8 -1
  73. package/dist/sdk/terminal/tui/pages/pluginsPage.js +197 -25
  74. package/dist/sdk/terminal/tui/pages/subagentsPage.js +8 -5
  75. package/dist/sdk/tools/sessionName.d.ts +2 -0
  76. package/dist/sdk/tools/sessionName.js +5 -3
  77. package/dist/sdk/tools/spawnSubagent.js +9 -7
  78. package/dist/sdk/tools/todo.d.ts +15 -0
  79. package/dist/sdk/tools/todo.js +64 -0
  80. package/dist/terminal-opentui.js +1416 -348
  81. package/dist/terminal-pi.js +315 -126
  82. package/docs/architecture/00-overview.md +1 -1
  83. package/docs/architecture/01-extension-points.md +1 -1
  84. package/docs/architecture/02-sessions-and-runs.md +65 -34
  85. package/docs/architecture/04-prompt.md +8 -6
  86. package/docs/architecture/06-plugins-and-capabilities.md +10 -6
  87. package/docs/architecture/07-tools-and-gate.md +31 -5
  88. package/docs/architecture/09-accounts-and-connectors.md +8 -2
  89. package/docs/architecture/10-server-protocol.md +16 -9
  90. package/docs/architecture/11-terminal.md +51 -26
  91. package/docs/architecture/14-surfaces-and-parity.md +4 -1
  92. package/docs/architecture/README.md +18 -17
  93. package/docs/architecture/proposals/PROP-0006-run-checklist.md +2 -0
  94. package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +32 -21
  95. package/docs/architecture/proposals/PROP-0044-work-slice-defaults.md +93 -0
  96. package/docs/architecture/proposals/PROP-0045-rename-checklist-to-todo.md +132 -0
  97. package/docs/architecture/proposals/PROP-0046-progress-aware-stall-guards.md +82 -0
  98. package/docs/architecture/proposals/PROP-0047-plugins-hub-separation.md +122 -0
  99. package/docs/architecture/proposals/README.md +10 -6
  100. package/docs/decisions/ADR-0039-progress-aware-stall-guards.md +39 -0
  101. package/docs/decisions/ADR-0040-architecture-change-workflow.md +31 -0
  102. package/docs/decisions/ADR-0041-plugins-hub-separation.md +41 -0
  103. package/docs/decisions/ADR-0042-windows-shell-without-sandbox.md +40 -0
  104. package/docs/decisions/ADR-0043-parents-continue-stopped-subagents.md +49 -0
  105. package/docs/doctor.md +14 -1
  106. package/docs/parity.md +9 -9
  107. package/docs/releases/1.3.0.md +21 -0
  108. package/docs/releases/README.md +2 -2
  109. package/docs/terminal-tui.md +85 -46
  110. package/package.json +1 -1
  111. package/dist/sdk/tools/checklist.d.ts +0 -9
  112. package/dist/sdk/tools/checklist.js +0 -55
@@ -8,14 +8,15 @@ export const WORK_SLICE_EXTENSION = 'work-slice';
8
8
  /** The foundation defaults: enough for one meaningful step of work, short enough to report. */
9
9
  export const DEFAULT_SLICE_POLICY = {
10
10
  enabled: true,
11
- // Roughly 40 minutes of ordinary work (about 15 s a call), so a checkpoint is a chapter of a
12
- // task, not an interruption every few edits. A task still gets eight of them before the cap.
13
- maxToolCalls: 160,
14
- maxMinutes: 40,
15
- // A generous cost ceiling, counted on new input, cache writes and output only: heavy reading at
16
- // a large context can add tens of thousands of tokens a request, so a small cap stopped useful
17
- // work (see the "Confirm plan model support" thread). 320k is a safety net, not the bound.
18
- maxNewTokens: 320_000,
11
+ // A chapter of a task, not an interruption every few edits: roughly 80 minutes of ordinary work
12
+ // (about 15 s a call), with 60 minutes of wall clock as the ceiling, whichever comes first. A task
13
+ // still gets eight slices before it returns to the person.
14
+ maxToolCalls: 320,
15
+ maxMinutes: 60,
16
+ // No token cap: the tool calls and the clock are the bound. Counting new input, cache writes and
17
+ // output made a few large reads end a reply before useful work (see the "Confirm plan model
18
+ // support" thread), so a cap is available to whoever asks for one and off unless they do.
19
+ maxNewTokens: 0,
19
20
  maxSlices: 8,
20
21
  continue: true
21
22
  };
@@ -100,7 +101,7 @@ export function sliceLimitReached(policy, state, now = Date.now()) {
100
101
  const HANDOFF_TOOLS = new Set(['checkpoint_write', 'session_name']);
101
102
  /** Tools whose repetition is how waiting works, not a loop. */
102
103
  const POLLING_TOOLS = new Set(['wait_subagents', 'subagents.list', 'subagents.status']);
103
- /** Commands that read as a check, for "no progress" and "still failing" rules. */
104
+ /** Commands that read as a check for the repeated-failure rule. */
104
105
  export function looksLikeCheck(command) {
105
106
  return /\b(test|tests|typecheck|tsc|vitest|jest|cargo|check:|prove|lint|ci|build)\b/u.test(command);
106
107
  }
@@ -122,51 +123,78 @@ function canonical(value, depth = 0) {
122
123
  /**
123
124
  * A call's identity: its name and every argument, hashed so the key stays small even when an
124
125
  * argument is a whole file. A changed argument is a different call; only an identical one repeats.
125
- * Reading a fixed handful of fields made every `checklist_write` look identical, so a reply that
126
+ * Reading a fixed handful of fields made every `todo_write` look identical, so a reply that
126
127
  * reported its progress was told it was repeating work.
127
128
  */
128
129
  const signature = (name, input) => `${name}\u0000${createHash('sha256').update(canonical(input ?? null)).digest('hex')}`;
129
130
  const clip = (value, limit) => (value.length <= limit ? value : `${value.slice(0, limit - 1)}…`);
130
- function handoffRequest(limit, state) {
131
- return clip(`This reply reached its work-slice limit (${limit}; step ${state.steps}). Call checkpoint_write now with what is done, the single next step, anything blocking, the files you touched and the checks that matter. Then tell the user in a few lines where you stopped. Do not call other tools.`, 600);
131
+ function handoffRequest(limit, state, checkpointAvailable = true) {
132
+ const handoff = checkpointAvailable
133
+ ? 'Call checkpoint_write now with what is done, the single next step, anything blocking, the files you touched and the checks that matter. Then tell the user in a few lines where you stopped.'
134
+ : 'Stop using tools and return a concise partial research report with findings, blockers and the next useful research step. The Harness records an evidence-derived checkpoint.';
135
+ return clip(`This reply reached its work-slice limit (${limit}; step ${state.steps}). ${handoff} Do not call other tools.`, 600);
132
136
  }
133
- function stalledNote(reason) {
134
- return clip(`You are repeating work: ${reason}. Do not repeat it. Choose a different approach, read what the earlier attempt returned, or call checkpoint_write with the blocker so the next slice can decide.`, 600);
137
+ function stalledNote(reason, checkpointAvailable = true) {
138
+ const handoff = checkpointAvailable
139
+ ? 'Choose a different approach, read what the earlier attempt returned, or call checkpoint_write with the blocker so the next slice can decide.'
140
+ : 'Do not use more tools. Return concise findings and blockers in your final response; the Harness records an evidence-derived checkpoint.';
141
+ return clip(`You are repeating work: ${reason}. Do not repeat it. ${handoff}`, 600);
135
142
  }
136
143
  /**
137
- * Bounds one reply and names repeated work. The first refusal asks for a checkpoint; a tool call
138
- * after that refusal ends the reply. A repeated call or failing command is named once, then ends it.
144
+ * Bounds one reply and names repeated work. Refusals request a checkpoint when available; without
145
+ * one, they ask for a partial report and prohibit further tools. A repeated call or failing command
146
+ * is named once, then ends the reply.
139
147
  */
140
148
  export function workSliceGuard(options) {
141
149
  const { policy, state } = options;
142
150
  const calls = new Map();
151
+ const checkCalls = new Set();
143
152
  const edits = new Map();
144
153
  const failures = new Map();
145
- let stepsSinceProgress = 0;
146
154
  let nudged = false;
155
+ const checkpointAvailable = () => typeof options.checkpointAvailable === 'function'
156
+ ? options.checkpointAvailable()
157
+ : options.checkpointAvailable !== false;
147
158
  /** Repetition is judged inside one reply; the next reply starts from a clean count. */
148
159
  const resetRepetition = () => {
149
160
  calls.clear();
161
+ checkCalls.clear();
150
162
  edits.clear();
151
163
  failures.clear();
152
- stepsSinceProgress = 0;
153
164
  nudged = false;
154
165
  };
155
166
  const end = (kind, limit) => {
156
167
  state.endedBy = kind;
157
168
  state.endedByLimit = limit;
158
169
  options.onEnd?.(kind, limit);
170
+ const canCheckpoint = checkpointAvailable();
159
171
  const reason = kind === 'stuck'
160
- ? stalledNote(limit)
172
+ ? stalledNote(limit, canCheckpoint)
161
173
  : kind === 'compaction-thrash'
162
- ? handoffRequest('three context compactions in one reply', state)
163
- : handoffRequest(limit, state);
174
+ ? handoffRequest('three context compactions in one reply', state, canCheckpoint)
175
+ : handoffRequest(limit, state, canCheckpoint);
164
176
  return { block: true, terminate: true, reason };
165
177
  };
178
+ const nudge = (reason) => {
179
+ if (nudged)
180
+ return end('stuck', reason);
181
+ nudged = true;
182
+ const canCheckpoint = checkpointAvailable();
183
+ if (!canCheckpoint) {
184
+ state.endedBy = 'stuck';
185
+ state.endedByLimit = reason;
186
+ }
187
+ return { block: true, reason: stalledNote(reason, canCheckpoint) };
188
+ };
166
189
  const repeated = (name, input) => {
167
190
  if (POLLING_TOOLS.has(name))
168
191
  return null;
169
192
  const key = signature(name, input);
193
+ if (name === 'bash') {
194
+ const command = input && typeof input === 'object' ? String(input.command ?? '') : '';
195
+ if (command && looksLikeCheck(command))
196
+ checkCalls.add(key);
197
+ }
170
198
  const count = (calls.get(key) ?? 0) + 1;
171
199
  calls.set(key, count);
172
200
  if (count < 3)
@@ -197,9 +225,19 @@ export function workSliceGuard(options) {
197
225
  pi.on('tool_result', (event) => {
198
226
  if (event.toolName === 'checkpoint_write' && !event.isError) {
199
227
  state.checkpointWritten = true;
200
- stepsSinceProgress = 0;
201
228
  nudged = false;
202
229
  }
230
+ if ((event.toolName === 'edit' || event.toolName === 'write') && event.isError === false) {
231
+ const path = event.input && typeof event.input === 'object' ? String(event.input.path ?? '') : '';
232
+ if (path) {
233
+ edits.set(path, (edits.get(path) ?? 0) + 1);
234
+ failures.clear();
235
+ for (const key of checkCalls)
236
+ calls.delete(key);
237
+ checkCalls.clear();
238
+ nudged = false;
239
+ }
240
+ }
203
241
  if (event.toolName !== 'bash')
204
242
  return;
205
243
  const command = event.input && typeof event.input === 'object' ? String(event.input.command ?? '') : '';
@@ -213,17 +251,21 @@ export function workSliceGuard(options) {
213
251
  failures.delete(command);
214
252
  edits.clear();
215
253
  calls.clear();
216
- stepsSinceProgress = 0;
254
+ checkCalls.clear();
217
255
  nudged = false;
218
256
  });
219
257
  pi.on('tool_call', (event) => {
220
258
  const name = event.toolName;
221
259
  state.steps += 1;
222
260
  state.lastTool = name;
223
- const handoff = HANDOFF_TOOLS.has(name);
224
- // A handoff is always callable; after the slice ended it is the only thing that is.
261
+ const canCheckpoint = checkpointAvailable();
262
+ const handoff = canCheckpoint && HANDOFF_TOOLS.has(name);
263
+ // A handoff is callable only when checkpoint_write is active; after the slice ended it is the only one.
225
264
  if (state.endedBy) {
226
- return handoff ? undefined : { block: true, terminate: true, reason: 'This reply is over. Its checkpoint is recorded; nothing else runs.' };
265
+ const reason = state.checkpointWritten
266
+ ? 'This reply is over. Its checkpoint is recorded; nothing else runs.'
267
+ : 'This reply is over. The Harness will record an evidence-derived checkpoint; nothing else runs.';
268
+ return handoff ? undefined : { block: true, terminate: true, reason };
227
269
  }
228
270
  if (state.compactions >= 3) {
229
271
  if (handoff) {
@@ -240,39 +282,29 @@ export function workSliceGuard(options) {
240
282
  state.endedBy = 'budget';
241
283
  state.endedByLimit = limit;
242
284
  state.refusedAt ??= state.steps;
243
- stepsSinceProgress = 0;
244
- return handoff ? undefined : { block: true, reason: handoffRequest(limit, state) };
285
+ return handoff ? undefined : { block: true, reason: handoffRequest(limit, state, canCheckpoint) };
245
286
  }
246
287
  if (handoff)
247
288
  return undefined;
289
+ // Todo updates are bookkeeping, not evidence of progress or a stall. They still count above
290
+ // toward the reply's overall tool-call budget.
291
+ if (name === 'todo_write')
292
+ return undefined;
248
293
  const edit = name === 'edit' || name === 'write';
249
294
  const path = edit && event.input && typeof event.input === 'object' ? String(event.input.path ?? '') : '';
250
295
  if (edit && path) {
251
- const count = (edits.get(path) ?? 0) + 1;
252
- edits.set(path, count);
253
- stepsSinceProgress = 0;
296
+ const count = edits.get(path) ?? 0;
254
297
  if (count >= 4) {
255
298
  const reason = `${path} was edited ${count} times with no passing check between`;
256
- if (nudged)
257
- return end('stuck', reason);
258
- nudged = true;
259
- return { block: true, reason: stalledNote(reason) };
299
+ return nudge(reason);
260
300
  }
261
301
  }
262
- else {
263
- stepsSinceProgress += 1;
264
- }
265
302
  const repeat = repeated(name, event.input);
266
303
  const failure = failing(name, event.input);
267
- const noProgress = stepsSinceProgress >= 25 ? `${stepsSinceProgress} steps with no file change and no passing check` : null;
268
- const reason = repeat ?? failure ?? noProgress;
304
+ const reason = repeat ?? failure;
269
305
  if (!reason)
270
306
  return undefined;
271
- if (nudged)
272
- return end('stuck', reason);
273
- nudged = true;
274
- stepsSinceProgress = 0;
275
- return { block: true, reason: stalledNote(reason) };
307
+ return nudge(reason);
276
308
  });
277
309
  };
278
310
  }
@@ -1,6 +1,14 @@
1
1
  import { type RunEvent, type RunRecord } from "../sessions/contracts.js";
2
2
  import type { ActiveRun, ApprovalDecision, QuestionAnswer } from "../sessions/registry.js";
3
3
  import { type TerminalSubagentActivity, type TerminalSubagentProfile, type TerminalSubagentRun } from "./subagentMonitor.js";
4
+ type MonitorRuntime = Pick<TerminalSubagentRun, 'accountLabel' | 'model' | 'effort'>;
5
+ /** A saved run to show in the monitor after the conversation is reopened. */
6
+ export interface MonitorEntry {
7
+ record: RunRecord;
8
+ events: readonly RunEvent[];
9
+ profile?: TerminalSubagentProfile | null;
10
+ runtime?: MonitorRuntime;
11
+ }
4
12
  export interface PolicyReply {
5
13
  allowed: boolean;
6
14
  reason?: string | null;
@@ -17,6 +25,8 @@ export interface ControllerHooks {
17
25
  reservedRunId: string | null;
18
26
  nativeTurnId: string;
19
27
  text: string;
28
+ model?: string | null;
29
+ effort?: string | null;
20
30
  }): ActiveRun;
21
31
  /** Whether a native tool call may run. */
22
32
  policy(run: ActiveRun, tool: string, args: unknown): Promise<PolicyReply>;
@@ -30,6 +40,11 @@ export interface ControllerHooks {
30
40
  allowToolGlobally(target: string): void;
31
41
  /** The native conversation identity changed. */
32
42
  native(nativeSessionId: string): void;
43
+ /**
44
+ * The conversation opened with no turn running: show its latest parent run's rows again. `owners`
45
+ * are the parent run ids recorded on the native branch, oldest first.
46
+ */
47
+ hydrate?(owners: string[]): void;
33
48
  telemetry(event: string): void;
34
49
  interrupt?(runId: string): Promise<void>;
35
50
  resolveApproval?(runId: string, requestId: string, decision: ApprovalDecision): void;
@@ -76,6 +91,10 @@ export declare class TerminalController {
76
91
  observe(event: RunEvent, descendant?: boolean): void;
77
92
  /** A child event that belongs to the active terminal parent run. */
78
93
  observeSubagent(parentRunId: string, run: RunRecord, event: RunEvent, profile?: TerminalSubagentProfile | null, runtime?: Pick<TerminalSubagentRun, 'accountLabel' | 'model' | 'effort'>): void;
94
+ /** An event of the tracked parent run itself, shown as the `main` row; it outlives settlement. */
95
+ observeMain(run: RunRecord, event: RunEvent, runtime?: MonitorRuntime): void;
96
+ /** Shows a saved parent run and its descendants, as they ended, until another run begins. */
97
+ restoreMonitor(parent: MonitorEntry, children: readonly MonitorEntry[]): void;
79
98
  /** Read-only child activity for the latest parent run, retained until another run begins. */
80
99
  subagentSnapshot(): TerminalSubagentActivity[];
81
100
  /** The socket grants this interface only to the process that owns terminal input. */
@@ -84,3 +103,4 @@ export declare class TerminalController {
84
103
  accept(event: string, data: Record<string, any>): Promise<unknown>;
85
104
  dispose(): void;
86
105
  }
106
+ export {};
@@ -6,7 +6,21 @@ import { createHash, randomUUID } from 'node:crypto';
6
6
  import { HarnessError } from "../core/errors.js";
7
7
  import { runStatusSchema } from "../sessions/contracts.js";
8
8
  import { briefTitle } from "../sessions/subagents.js";
9
- import { SubagentMonitor } from "./subagentMonitor.js";
9
+ import { MAIN_PROFILE, SubagentMonitor } from "./subagentMonitor.js";
10
+ function monitorRun(record, relation, profile, runtime) {
11
+ return {
12
+ id: record.id,
13
+ parentRunId: record.parentRunId,
14
+ relation,
15
+ title: record.title ?? (briefTitle(record.goal) || (relation === 'main' ? 'Main agent' : '')),
16
+ goal: record.goal,
17
+ status: runStatusSchema.parse(record.status),
18
+ startedAt: record.startedAt,
19
+ endedAt: record.endedAt,
20
+ profile: relation === 'main' ? MAIN_PROFILE : profile,
21
+ ...runtime
22
+ };
23
+ }
10
24
  const READY_TIMEOUT_MS = 90_000;
11
25
  const ACK_TIMEOUT_MS = 25_000;
12
26
  export class TerminalController {
@@ -41,7 +55,7 @@ export class TerminalController {
41
55
  requestId(nativeId) {
42
56
  return createHash('sha256').update(`${this.hooks.sessionId}:${this.run?.runId}:${nativeId}`).digest('hex');
43
57
  }
44
- begin(nativeTurnId, text) {
58
+ begin(nativeTurnId, text, launch = {}) {
45
59
  if (this.startupError)
46
60
  throw new Error(this.startupError);
47
61
  this.dirty = false;
@@ -51,7 +65,7 @@ export class TerminalController {
51
65
  const delivery = this.delivery;
52
66
  if (delivery && text && delivery.text !== text)
53
67
  throw new Error('The native prompt did not match the reserved delivery. Inspect the terminal before continuing.');
54
- const run = this.hooks.begin({ reservedRunId: delivery?.runId ?? null, nativeTurnId, text });
68
+ const run = this.hooks.begin({ reservedRunId: delivery?.runId ?? null, nativeTurnId, text, ...launch });
55
69
  this.run = run;
56
70
  this.subagentParentRunId = run.runId;
57
71
  this.subagents.begin(run.runId);
@@ -150,18 +164,23 @@ export class TerminalController {
150
164
  observeSubagent(parentRunId, run, event, profile = null, runtime = {}) {
151
165
  if (this.disposed || this.activeRun?.runId !== parentRunId || run.relation !== 'subagent')
152
166
  return;
153
- this.subagents.observe(parentRunId, {
154
- id: run.id,
155
- parentRunId: run.parentRunId,
156
- relation: 'subagent',
157
- title: run.title ?? briefTitle(run.goal),
158
- goal: run.goal,
159
- status: runStatusSchema.parse(run.status),
160
- startedAt: run.startedAt,
161
- endedAt: run.endedAt,
162
- profile,
163
- ...runtime
164
- }, event);
167
+ this.subagents.observe(parentRunId, monitorRun(run, 'subagent', profile, runtime), event);
168
+ }
169
+ /** An event of the tracked parent run itself, shown as the `main` row; it outlives settlement. */
170
+ observeMain(run, event, runtime = {}) {
171
+ if (this.disposed || this.subagentParentRunId !== run.id || run.relation)
172
+ return;
173
+ this.subagents.observe(run.id, monitorRun(run, 'main', null, runtime), event);
174
+ }
175
+ /** Shows a saved parent run and its descendants, as they ended, until another run begins. */
176
+ restoreMonitor(parent, children) {
177
+ if (this.disposed || this.activeRun || this.delivery)
178
+ return;
179
+ this.subagentParentRunId = parent.record.id;
180
+ this.subagents.restore(parent.record.id, [
181
+ { run: monitorRun(parent.record, 'main', null, parent.runtime ?? {}), events: parent.events },
182
+ ...children.map((child) => ({ run: monitorRun(child.record, 'subagent', child.profile ?? null, child.runtime ?? {}), events: child.events }))
183
+ ]);
165
184
  }
166
185
  /** Read-only child activity for the latest parent run, retained until another run begins. */
167
186
  subagentSnapshot() {
@@ -316,10 +335,16 @@ export class TerminalController {
316
335
  this.nativeId = data.nativeSessionId;
317
336
  this.hooks.native(data.nativeSessionId);
318
337
  }
338
+ const owners = Array.isArray(data.owners) ? data.owners.filter((id) => typeof id === 'string') : [];
339
+ if (!this.activeRun && !this.delivery && this.hooks.hydrate)
340
+ this.hooks.hydrate(owners);
319
341
  return {};
320
342
  }
321
343
  if (event === 'begin') {
322
- const run = this.begin(String(data.turnId ?? randomUUID()), String(data.text ?? ''));
344
+ const run = this.begin(String(data.turnId ?? randomUUID()), String(data.text ?? ''), {
345
+ model: typeof data.model === 'string' && data.model ? data.model : null,
346
+ effort: typeof data.effort === 'string' && data.effort ? data.effort : null
347
+ });
323
348
  return { runId: run.runId, workspaceRoot: this.hooks.workspaceRoot(run) };
324
349
  }
325
350
  if (event === 'shutdown') {
@@ -377,8 +402,8 @@ export class TerminalController {
377
402
  }
378
403
  if (event === 'usage')
379
404
  run.emit('usage.updated', data);
380
- else if (event === 'checklist')
381
- run.emit('checklist.updated', { items: data.items });
405
+ else if (event === 'todo' || event === 'checklist')
406
+ run.emit('todo.updated', { items: data.items });
382
407
  else if (event === 'checkpoint')
383
408
  run.emit('checkpoint', data);
384
409
  else if (event === 'file')
@@ -0,0 +1,25 @@
1
+ export interface AnsiStyle {
2
+ fg?: string;
3
+ bg?: string;
4
+ bold?: boolean;
5
+ dim?: boolean;
6
+ italic?: boolean;
7
+ underline?: boolean;
8
+ inverse?: boolean;
9
+ }
10
+ export interface AnsiSpan {
11
+ text: string;
12
+ style: AnsiStyle;
13
+ }
14
+ export interface AnsiLine {
15
+ spans: AnsiSpan[];
16
+ }
17
+ export declare function ansi256Color(index: number): string;
18
+ /**
19
+ * Parses SGR styled lines. Non-style escape sequences, including Pi's cursor markers, are removed;
20
+ * an OSC 8 hyperlink keeps its visible label.
21
+ */
22
+ export declare function parseAnsi(text: string): AnsiLine[];
23
+ export declare function ansiPlain(text: string): string;
24
+ /** A short single-line form for labels and tool summaries. */
25
+ export declare function ansiLabel(text: string, limit?: number): string;
@@ -0,0 +1,192 @@
1
+ // Pi components and trusted extensions render ANSI text, not OpenTUI nodes. This module turns
2
+ // that text into the small style model OpenTUI can draw, and never executes anything in it.
3
+ const BASE_COLORS = [
4
+ '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5',
5
+ '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff'
6
+ ];
7
+ const CUBE = [0, 95, 135, 175, 215, 255];
8
+ export function ansi256Color(index) {
9
+ if (!Number.isFinite(index))
10
+ return BASE_COLORS[7];
11
+ const value = Math.max(0, Math.min(255, Math.trunc(index)));
12
+ if (value < 16)
13
+ return BASE_COLORS[value];
14
+ if (value < 232) {
15
+ const n = value - 16;
16
+ const r = CUBE[Math.floor(n / 36) % 6];
17
+ const g = CUBE[Math.floor(n / 6) % 6];
18
+ const b = CUBE[n % 6];
19
+ return hex(r, g, b);
20
+ }
21
+ const grey = 8 + (value - 232) * 10;
22
+ return hex(grey, grey, grey);
23
+ }
24
+ function hex(r, g, b) {
25
+ const part = (value) => Math.max(0, Math.min(255, Math.trunc(value))).toString(16).padStart(2, '0');
26
+ return `#${part(r)}${part(g)}${part(b)}`;
27
+ }
28
+ function defaultStyle() {
29
+ return {};
30
+ }
31
+ function applySgr(style, params) {
32
+ const next = { ...style };
33
+ const parts = params === '' ? ['0'] : params.split(';');
34
+ for (let index = 0; index < parts.length; index += 1) {
35
+ const raw = parts[index];
36
+ const value = raw === '' ? 0 : Number.parseInt(raw, 10);
37
+ if (!Number.isFinite(value))
38
+ continue;
39
+ if (value === 0) {
40
+ for (const key of Object.keys(next))
41
+ delete next[key];
42
+ }
43
+ else if (value === 1)
44
+ next.bold = true;
45
+ else if (value === 2)
46
+ next.dim = true;
47
+ else if (value === 3)
48
+ next.italic = true;
49
+ else if (value === 4)
50
+ next.underline = true;
51
+ else if (value === 7)
52
+ next.inverse = true;
53
+ else if (value === 22) {
54
+ next.bold = false;
55
+ next.dim = false;
56
+ }
57
+ else if (value === 23)
58
+ next.italic = false;
59
+ else if (value === 24)
60
+ next.underline = false;
61
+ else if (value === 27)
62
+ next.inverse = false;
63
+ else if (value >= 30 && value <= 37)
64
+ next.fg = BASE_COLORS[value - 30];
65
+ else if (value >= 90 && value <= 97)
66
+ next.fg = BASE_COLORS[value - 90 + 8];
67
+ else if (value >= 40 && value <= 47)
68
+ next.bg = BASE_COLORS[value - 40];
69
+ else if (value >= 100 && value <= 107)
70
+ next.bg = BASE_COLORS[value - 100 + 8];
71
+ else if (value === 39)
72
+ delete next.fg;
73
+ else if (value === 49)
74
+ delete next.bg;
75
+ else if (value === 38 || value === 48) {
76
+ const target = value === 38 ? 'fg' : 'bg';
77
+ const mode = parts[index + 1];
78
+ if (mode === '5') {
79
+ const color = Number.parseInt(parts[index + 2] ?? '', 10);
80
+ if (Number.isFinite(color))
81
+ next[target] = ansi256Color(color);
82
+ index += 2;
83
+ }
84
+ else if (mode === '2') {
85
+ const r = Number.parseInt(parts[index + 2] ?? '', 10);
86
+ const g = Number.parseInt(parts[index + 3] ?? '', 10);
87
+ const b = Number.parseInt(parts[index + 4] ?? '', 10);
88
+ if ([r, g, b].every(Number.isFinite))
89
+ next[target] = hex(r, g, b);
90
+ index += 4;
91
+ }
92
+ }
93
+ }
94
+ return next;
95
+ }
96
+ /** Consumes a CSI sequence at `start`; returns the index after it. */
97
+ function csiEnd(text, start) {
98
+ let index = start + 2;
99
+ while (index < text.length) {
100
+ const code = text.charCodeAt(index);
101
+ if (code >= 0x40 && code <= 0x7e)
102
+ return index + 1;
103
+ index += 1;
104
+ }
105
+ return text.length;
106
+ }
107
+ /** Consumes an OSC or APC sequence at `start`; returns the index after its terminator. */
108
+ function stringSequenceEnd(text, start) {
109
+ let index = start + 2;
110
+ while (index < text.length) {
111
+ const code = text.charCodeAt(index);
112
+ if (code === 0x07)
113
+ return index + 1;
114
+ if (code === 0x1b && text.charCodeAt(index + 1) === 0x5c)
115
+ return index + 2;
116
+ index += 1;
117
+ }
118
+ return text.length;
119
+ }
120
+ function stripControl(text) {
121
+ return text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
122
+ }
123
+ /**
124
+ * Parses SGR styled lines. Non-style escape sequences, including Pi's cursor markers, are removed;
125
+ * an OSC 8 hyperlink keeps its visible label.
126
+ */
127
+ export function parseAnsi(text) {
128
+ const lines = [];
129
+ let spans = [];
130
+ let style = defaultStyle();
131
+ let buffer = '';
132
+ const flush = () => {
133
+ if (!buffer)
134
+ return;
135
+ spans.push({ text: stripControl(buffer), style: { ...style } });
136
+ buffer = '';
137
+ };
138
+ const newline = () => {
139
+ flush();
140
+ lines.push({ spans });
141
+ spans = [];
142
+ };
143
+ for (let index = 0; index < text.length;) {
144
+ const char = text[index];
145
+ if (char === '\n') {
146
+ newline();
147
+ index += 1;
148
+ continue;
149
+ }
150
+ if (char === '\r') {
151
+ index += 1;
152
+ continue;
153
+ }
154
+ if (char === '\t') {
155
+ buffer += ' ';
156
+ index += 1;
157
+ continue;
158
+ }
159
+ if (char !== '\x1b') {
160
+ buffer += char;
161
+ index += 1;
162
+ continue;
163
+ }
164
+ const next = text[index + 1];
165
+ if (next === '[') {
166
+ const end = csiEnd(text, index);
167
+ const body = text.slice(index + 2, end);
168
+ if (body.endsWith('m')) {
169
+ flush();
170
+ style = applySgr(style, body.slice(0, -1));
171
+ }
172
+ index = end;
173
+ continue;
174
+ }
175
+ if (next === ']' || next === '_' || next === 'P' || next === '^') {
176
+ index = stringSequenceEnd(text, index);
177
+ continue;
178
+ }
179
+ index += next === undefined ? 1 : 2;
180
+ }
181
+ flush();
182
+ lines.push({ spans });
183
+ return lines;
184
+ }
185
+ export function ansiPlain(text) {
186
+ return parseAnsi(text).map(line => line.spans.map(span => span.text).join('')).join('\n');
187
+ }
188
+ /** A short single-line form for labels and tool summaries. */
189
+ export function ansiLabel(text, limit = 120) {
190
+ const plain = ansiPlain(text).replace(/\s+/g, ' ').trim();
191
+ return plain.length > limit ? `${plain.slice(0, Math.max(0, limit - 1))}…` : plain;
192
+ }