@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
@@ -21,24 +21,26 @@ function mark(status, frame) {
21
21
  case 'running': return { mark: frame, tone: 'warning' };
22
22
  }
23
23
  }
24
- export function subagentTasks(checklist) {
25
- return checklist && checklist.total > 0 ? `${checklist.done} of ${checklist.total} tasks` : null;
24
+ export function subagentTasks(todo) {
25
+ return todo && todo.total > 0 ? `${todo.done} of ${todo.total} tasks` : null;
26
26
  }
27
27
  /**
28
28
  * The compact live roster shown under the status line: active children first, at most three
29
29
  * rows. The session name is cut first when the terminal is narrow.
30
30
  */
31
31
  export function subagentRoster(items, width, frame = getWorkingSpinnerFrames()[0] ?? '') {
32
- if (!items.length || width < 1)
32
+ // The status line already describes the main agent; its row is only on the /subagents page.
33
+ const children = items.filter(item => item.relation !== 'main');
34
+ if (!children.length || width < 1)
33
35
  return null;
34
- const ordered = [...items].sort((left, right) => {
36
+ const ordered = [...children].sort((left, right) => {
35
37
  const active = Number(ACTIVE_SUBAGENT_STATUSES.has(right.status)) - Number(ACTIVE_SUBAGENT_STATUSES.has(left.status));
36
38
  return active || left.startedAt.localeCompare(right.startedAt) || left.id.localeCompare(right.id);
37
39
  });
38
40
  const rows = ordered.slice(0, MAX_VISIBLE_ROWS).map((item) => {
39
41
  const status = mark(item.status, frame);
40
42
  const profile = cut(safe(item.profile?.name ?? '') || 'subagent', Math.max(1, Math.min(24, width - 2)));
41
- const tasks = subagentTasks(item.checklist);
43
+ const tasks = subagentTasks(item.todo);
42
44
  const used = 2 + visibleWidth(profile) + GAP.length + (tasks ? GAP.length + tasks.length : 0);
43
45
  const title = cut(safe(item.title) || 'Subagent', Math.max(1, width - used));
44
46
  return { id: item.id, mark: status.mark, tone: status.tone, profile, color: item.profile?.color ?? null, title, tasks };
@@ -37,6 +37,7 @@ export function profileColor(theme, name) {
37
37
  case 'cyan': return theme.cyan;
38
38
  case 'orange': return theme.primary;
39
39
  case 'purple': return theme.purple;
40
+ case 'main': return theme.primary;
40
41
  default: return theme.accent;
41
42
  }
42
43
  }
@@ -2,15 +2,15 @@
2
2
  // session does to the harness, asks before each tool call, takes instructions the harness
3
3
  // queues for it, and gives the session the harness's own tools. It runs in the `pi` process,
4
4
  // so everything it knows about the harness comes through the session socket.
5
- import { createBashTool, createEditTool, createWriteTool } from '@earendil-works/pi-coding-agent';
5
+ import { createBashTool, createEditTool, createPowerShellTool, createWriteTool } from '@earendil-works/pi-coding-agent';
6
6
  import { Type } from 'typebox';
7
7
  import { connectionFromEnv, socketCall, terminalCall } from "./client.js";
8
8
  import { controlCall } from "./control.js";
9
9
  import { SPAWN_SUBAGENT, SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, subagentHistory } from "../sessions/subagents.js";
10
10
  import { PLAN_RESEARCH_TOOLS } from "../agents/planResearch.js";
11
11
  import { askUserTool } from "../tools/askUser.js";
12
- import { checklistTool } from "../tools/checklist.js";
13
- import { checkpointTool } from "../tools/checkpoint.js";
12
+ import { todoTool } from "../tools/todo.js";
13
+ import { CHECKPOINT_WRITE, checkpointTool } from "../tools/checkpoint.js";
14
14
  import { createSliceState, parseWorkSlice, workSliceGuard } from "../sessions/workSlice.js";
15
15
  import { DEFAULT_COMMAND_TIMEOUT_SECONDS, withCommandBudget } from "../core/commandBudget.js";
16
16
  import { sessionNameTool } from "../tools/sessionName.js";
@@ -20,7 +20,7 @@ import { compactionCheckpoint } from "../sessions/compactionCheckpoint.js";
20
20
  import { pruneToolImages } from "../world/images.js";
21
21
  import { parseInitialBashCommand } from "../sessions/initialBash.js";
22
22
  import { inspectWorkspaceWrite } from "../sessions/workspaceBoundary.js";
23
- import { withWorkspaceWriteScope } from "../core/workspaceBashTool.js";
23
+ import { createWorkspaceShellTool } from "../core/workspaceBashTool.js";
24
24
  import { createWorkspaceBashOperations, runWithWorkspaceWriteScope } from "../core/workspaceSandbox.js";
25
25
  import { withWorkspaceWriteAuthorization, workspaceEditOperations, workspaceWriteOperations } from "../core/workspaceWriteOperations.js";
26
26
  import { BUDGET_GUARD, OUTPUT_TRIM, budgetGuard, outputTrim } from "../plugins/firstParty.js";
@@ -48,6 +48,7 @@ export default async function (pi, planRun, getSession) {
48
48
  let context = null;
49
49
  let nativeSubagents = false;
50
50
  let subagentParent = false;
51
+ let stoppedChildren = null;
51
52
  let timer = null;
52
53
  let polling = false;
53
54
  let failure;
@@ -135,7 +136,7 @@ export default async function (pi, planRun, getSession) {
135
136
  },
136
137
  workspaceWriteScope: () => planRun?.active ? 'read-only' : undefined
137
138
  });
138
- pi.registerTool(withWorkspaceWriteScope(createBashTool(process.cwd(), { operations: workspaceBash })));
139
+ pi.registerTool(createWorkspaceShellTool({ createBashTool, createPowerShellTool }, process.cwd(), workspaceBash));
139
140
  pi.registerTool(withWorkspaceWriteAuthorization(createWriteTool(process.cwd(), { operations: workspaceWriteOperations }), writeAuthorizations));
140
141
  pi.registerTool(withWorkspaceWriteAuthorization(createEditTool(process.cwd(), { operations: workspaceEditOperations }), writeAuthorizations));
141
142
  compactionCheckpoint({ planInstructions: () => planRun?.active ? PLAN_INSTRUCTIONS : null })(pi);
@@ -145,7 +146,7 @@ export default async function (pi, planRun, getSession) {
145
146
  const sliceState = createSliceState(slicePolicy);
146
147
  // `XINGULAR_WORK_SLICE=off` at launch leaves terminal replies unbounded, as before PROP-0041.
147
148
  if (slicePolicy.enabled)
148
- workSliceGuard({ policy: slicePolicy, state: sliceState })(pi);
149
+ workSliceGuard({ policy: slicePolicy, state: sliceState, checkpointAvailable: () => !planRun?.active || planRun.allowedTools.has(CHECKPOINT_WRITE) })(pi);
149
150
  // Only the latest screenshots stay in what the model sees (world/images.ts).
150
151
  pi.on('context', (event) => {
151
152
  const messages = pruneToolImages(event.messages);
@@ -155,7 +156,8 @@ export default async function (pi, planRun, getSession) {
155
156
  context = ctx;
156
157
  if (timer)
157
158
  clearInterval(timer);
158
- await terminalCall('ready', { nativeSessionId: ctx.sessionManager.getSessionFile() });
159
+ // The branch's parent runs let the controller show the latest one's rows again on reopen.
160
+ await terminalCall('ready', { nativeSessionId: ctx.sessionManager.getSessionFile(), owners: subagentHistory(ctx.sessionManager.getBranch?.() ?? []).owners });
159
161
  timer = setInterval(() => {
160
162
  if (polling || !context)
161
163
  return;
@@ -188,15 +190,20 @@ export default async function (pi, planRun, getSession) {
188
190
  reasoningTokens = undefined;
189
191
  costReported = false;
190
192
  // A run a plugin starts with a custom message has no user prompt to record; it still begins a turn.
191
- const begun = await terminalCall('begin', { text: event.messageType === 'user' ? event.prompt : '' });
193
+ const begun = await terminalCall('begin', { text: event.messageType === 'user' ? event.prompt : '', model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null, effort: pi.getThinkingLevel?.() ?? null });
192
194
  workspaceRoot = typeof begun.workspaceRoot === 'string' ? begun.workspaceRoot : null;
193
195
  if (!workspaceRoot)
194
196
  throw new Error('The active project workspace could not be resolved; the turn was not started.');
197
+ // Every turn marks its run on the branch, so a reopened conversation finds its latest one.
198
+ pi.appendEntry(SUBAGENT_OWNER, { runId: begun.runId });
199
+ stoppedChildren = null;
195
200
  if (nativeSubagents) {
196
- pi.appendEntry(SUBAGENT_OWNER, { runId: begun.runId });
197
201
  const snapshot = await controlCall('terminal.subagents.register', { tools: pi.getActiveTools(), model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null, effort: pi.getThinkingLevel(), plan: planRun?.active === true, history: subagentHistory(ctx.sessionManager.getBranch()) });
198
202
  if (planRun && Array.isArray(snapshot.connectors))
199
203
  planRun.connectors = snapshot.connectors;
204
+ if (planRun && snapshot.subagentProfiles)
205
+ planRun.subagentProfiles = snapshot.subagentProfiles;
206
+ stoppedChildren = snapshot.notice ?? null;
200
207
  subagentParent = true;
201
208
  }
202
209
  if (planRun && event.messageType === 'user') {
@@ -260,6 +267,13 @@ export default async function (pi, planRun, getSession) {
260
267
  : 'The user’s initial Bash command has already run. Read its native Bash result and explain what it shows. Do not run the same command again unless the user explicitly asks.';
261
268
  return { message: { customType: 'xingular-initial-bash', content: instruction, display: false, details: {} } };
262
269
  });
270
+ // A second handler, so the notice joins any initial-Bash message rather than replacing it.
271
+ pi.on('before_agent_start', async () => {
272
+ const notice = stoppedChildren;
273
+ stoppedChildren = null;
274
+ if (notice)
275
+ return { message: { customType: SUBAGENT_RESULTS, content: notice.text, display: false, details: { [SUBAGENT_RECEIPT]: notice.receipt } } };
276
+ });
263
277
  const deliverChildren = async (event, ctx, settle) => {
264
278
  if (!nativeSubagents || (settle && event.outcome !== 'completed') || ctx.signal?.aborted)
265
279
  return;
@@ -437,15 +451,15 @@ export default async function (pi, planRun, getSession) {
437
451
  return { content: [{ type: 'text', text: typeof result === 'string' ? result : result.text }], details: undefined };
438
452
  }
439
453
  });
440
- // The checklist is recorded on the harness run; the shared tool validates and the socket carries the event.
454
+ // The todo list is recorded on the harness run; the shared tool validates and the socket carries the event.
441
455
  let recorded = Promise.resolve();
442
- const checklist = checklistTool({ emit: (_type, payload) => { recorded = terminalCall('checklist', payload); } });
456
+ const todo = todoTool({ emit: (_type, payload) => { recorded = terminalCall('todo', payload); } });
443
457
  pi.registerTool({
444
- name: checklist.name, label: checklist.label, description: checklist.description,
445
- parameters: Type.Unsafe(checklist.parameters(Type)),
458
+ name: todo.name, label: todo.label, description: todo.description,
459
+ parameters: Type.Unsafe(todo.parameters(Type)),
446
460
  async execute(_id, input) {
447
461
  recorded = Promise.resolve();
448
- const result = await checklist.execute(input);
462
+ const result = await todo.execute(input);
449
463
  await recorded;
450
464
  return { content: [{ type: 'text', text: typeof result === 'string' ? result : result.text }], details: undefined };
451
465
  }
@@ -5,10 +5,13 @@ export interface TerminalSubagentProfile {
5
5
  name: string;
6
6
  color: string;
7
7
  }
8
+ /** The main agent's row: the parent run itself, shown by name rather than by a profile. */
9
+ export declare const MAIN_PROFILE: TerminalSubagentProfile;
8
10
  export interface TerminalSubagentRun {
9
11
  id: string;
10
12
  parentRunId: string | null;
11
- relation: 'subagent';
13
+ /** `main` is the parent run itself; every other row is a child or nested child. */
14
+ relation: 'main' | 'subagent';
12
15
  /** The short name the parent gave the child (PROP-0030). */
13
16
  title: string;
14
17
  goal: string | null;
@@ -27,8 +30,8 @@ export interface TerminalSubagentActivity extends TerminalSubagentRun {
27
30
  activity: string;
28
31
  /** Cumulative run usage only; provider reasoning summaries and request details stay private. */
29
32
  usage: TerminalSubagentUsage | null;
30
- /** How far the child's own checklist is: counts only, never the items. */
31
- checklist: {
33
+ /** How far the child's own todo is: counts only, never the items. */
34
+ todo: {
32
35
  done: number;
33
36
  total: number;
34
37
  } | null;
@@ -41,7 +44,17 @@ export declare class SubagentMonitor {
41
44
  private parentRunId;
42
45
  private readonly activities;
43
46
  begin(parentRunId: string): void;
44
- observe(parentRunId: string, run: TerminalSubagentRun, event: RunEvent): void;
47
+ /** Folds one event of the tracked parent (`main`) or one of its descendants; `null` seeds the row. */
48
+ observe(parentRunId: string, run: TerminalSubagentRun, event: RunEvent | null): void;
49
+ /**
50
+ * Rebuilds the rows of a parent run that is no longer live, such as the latest one of a reopened
51
+ * conversation, by folding each run's saved events. Rows keep their final status.
52
+ */
53
+ restore(parentRunId: string, runs: readonly {
54
+ run: TerminalSubagentRun;
55
+ events: readonly RunEvent[];
56
+ }[]): void;
57
+ /** The main row first, then children in start order. */
45
58
  snapshot(parentRunId: string): TerminalSubagentActivity[];
46
59
  clear(parentRunId?: string): void;
47
60
  }
@@ -1,4 +1,6 @@
1
1
  import { usagePayloadSchema } from "../sessions/contracts.js";
2
+ /** The main agent's row: the parent run itself, shown by name rather than by a profile. */
3
+ export const MAIN_PROFILE = { name: 'main', color: 'main' };
2
4
  export const ACTIVE_SUBAGENT_STATUSES = new Set(['queued', 'running', 'waiting_for_approval', 'waiting_for_input']);
3
5
  export function terminalSubagentDuration(activity, now = Date.now()) {
4
6
  const start = Date.parse(activity.startedAt);
@@ -50,8 +52,11 @@ export class SubagentMonitor {
50
52
  this.parentRunId = parentRunId;
51
53
  this.activities.clear();
52
54
  }
55
+ /** Folds one event of the tracked parent (`main`) or one of its descendants; `null` seeds the row. */
53
56
  observe(parentRunId, run, event) {
54
- if (this.parentRunId !== parentRunId || run.relation !== 'subagent')
57
+ if (this.parentRunId !== parentRunId)
58
+ return;
59
+ if ((run.relation === 'main') !== (run.id === parentRunId))
55
60
  return;
56
61
  const row = this.activities.get(run.id) ?? {
57
62
  id: run.id,
@@ -63,7 +68,7 @@ export class SubagentMonitor {
63
68
  startedAt: run.startedAt,
64
69
  endedAt: run.endedAt,
65
70
  activity: statusActivity(run.status),
66
- checklist: null,
71
+ todo: null,
67
72
  profile: run.profile ?? null,
68
73
  accountLabel: run.accountLabel ?? null,
69
74
  model: run.model ?? null,
@@ -86,8 +91,11 @@ export class SubagentMonitor {
86
91
  row.status = run.status;
87
92
  row.startedAt = run.startedAt;
88
93
  row.endedAt = run.endedAt;
89
- const payload = payloadOf(event);
90
- if (event.type === 'tool.started') {
94
+ const payload = event ? payloadOf(event) : {};
95
+ if (!event) {
96
+ // A seeded row keeps what it already folded.
97
+ }
98
+ else if (event.type === 'tool.started') {
91
99
  const toolCallId = stringOf(payload.toolCallId);
92
100
  const name = stringOf(payload.name) ?? 'Tool';
93
101
  if (toolCallId)
@@ -125,22 +133,35 @@ export class SubagentMonitor {
125
133
  };
126
134
  }
127
135
  }
128
- else if (event.type === 'checklist.updated') {
136
+ else if (event.type === 'todo.updated' || event.type === 'checklist.updated') {
129
137
  const items = Array.isArray(payload.items) ? payload.items : [];
130
- row.checklist = items.length ? { done: items.filter(item => item?.status === 'completed').length, total: items.length } : null;
138
+ row.todo = items.length ? { done: items.filter(item => item?.status === 'completed').length, total: items.length } : null;
131
139
  }
132
140
  if (run.status !== 'running')
133
141
  row.tools.clear();
134
142
  row.activity = run.status === 'running'
135
- ? latestTool(row.tools) ?? row.lastTool?.text ?? (event.type === 'message.delta' ? 'Responding' : 'Working')
143
+ ? latestTool(row.tools) ?? row.lastTool?.text ?? (event?.type === 'message.delta' || (!event && row.activity === 'Responding') ? 'Responding' : 'Working')
136
144
  : statusActivity(run.status);
137
145
  this.activities.set(run.id, row);
138
146
  }
147
+ /**
148
+ * Rebuilds the rows of a parent run that is no longer live, such as the latest one of a reopened
149
+ * conversation, by folding each run's saved events. Rows keep their final status.
150
+ */
151
+ restore(parentRunId, runs) {
152
+ this.begin(parentRunId);
153
+ for (const { run, events } of runs) {
154
+ this.observe(parentRunId, run, null);
155
+ for (const event of events)
156
+ this.observe(parentRunId, run, event);
157
+ }
158
+ }
159
+ /** The main row first, then children in start order. */
139
160
  snapshot(parentRunId) {
140
161
  if (this.parentRunId !== parentRunId)
141
162
  return [];
142
163
  return [...this.activities.values()]
143
- .sort((left, right) => left.startedAt.localeCompare(right.startedAt) || left.id.localeCompare(right.id))
164
+ .sort((left, right) => Number(right.relation === 'main') - Number(left.relation === 'main') || left.startedAt.localeCompare(right.startedAt) || left.id.localeCompare(right.id))
144
165
  .map(({ tools: _tools, lastTool: _lastTool, ...activity }) => activity);
145
166
  }
146
167
  clear(parentRunId) {
@@ -150,6 +150,13 @@ export declare class Terminals {
150
150
  /** Each child's profile name and colour, looked up once per child run. */
151
151
  private readonly childProfiles;
152
152
  private subagentProfile;
153
+ /** Account, model and effort for a monitor row, from the run record. */
154
+ private monitorRuntime;
155
+ /**
156
+ * Shows the reopened conversation's latest parent run again: the last owner on its native branch
157
+ * that ran in this folder, with every descendant flat, as each ended.
158
+ */
159
+ private hydrateMonitor;
153
160
  constructor(options: TerminalsOptions);
154
161
  /** The commands a terminal session finds on PATH. Rewritten only when they change. */
155
162
  private ensureCommands;
@@ -22,9 +22,9 @@ import * as componentLibrary from "../variant/components.js";
22
22
  import * as variantStore from "../variant/store.js";
23
23
  import { compile, copyToRun, record, render, saveForSession } from "../prompt/compile.js";
24
24
  import { isActiveStatus } from "../sessions/contracts.js";
25
- import { findRun, loadRun, saveRun, updateRun } from "../sessions/runStore.js";
25
+ import { findRun, loadRun, readEvents, recordedSubagents, saveRun, updateRun } from "../sessions/runStore.js";
26
26
  import { combineWorkspaceScope, inspectRunWorkspaceWrite } from "../sessions/workspaceBoundary.js";
27
- import { CHECKLIST_WRITE } from "../tools/checklist.js";
27
+ import { TODO_WRITE } from "../tools/todo.js";
28
28
  import { DEFAULT_TOOL_NAMES } from "../tools/definitions.js";
29
29
  import { harnessTools } from "../tools/harnessTools.js";
30
30
  import { HARNESS_PLUGINS } from "../plugins/builtin.js";
@@ -42,7 +42,7 @@ import { TerminalController } from "./controller.js";
42
42
  import { terminalCommandFiles } from "./launch.js";
43
43
  import { SessionSocket, socketPath } from "./socket.js";
44
44
  import { harnessLogInfo, readHarnessLogChunk } from "./logs.js";
45
- import { listTerminalTrajectoryRuns, readTerminalTrajectory } from "./trajectory.js";
45
+ import { listTerminalTrajectoryRuns, readTerminalTrajectory, latestTerminalTodo } from "./trajectory.js";
46
46
  import { BrowserControl } from "./browserControl.js";
47
47
  /** Identity of the exact resource decision that this terminal was prepared with. */
48
48
  function resourceIdentity(loading) {
@@ -98,12 +98,61 @@ export class Terminals {
98
98
  }
99
99
  return this.childProfiles.get(record.id) ?? null;
100
100
  }
101
+ /** Account, model and effort for a monitor row, from the run record. */
102
+ monitorRuntime(live, record) {
103
+ let accountLabel = record.accountId === live.account.id ? live.account.label : null;
104
+ if (accountLabel === null && record.accountId) {
105
+ try {
106
+ accountLabel = this.options.accounts.find(record.accountId).label;
107
+ }
108
+ catch { }
109
+ }
110
+ return { accountLabel, model: record.model ?? null, effort: record.effort ?? null };
111
+ }
112
+ /**
113
+ * Shows the reopened conversation's latest parent run again: the last owner on its native branch
114
+ * that ran in this folder, with every descendant flat, as each ended.
115
+ */
116
+ hydrateMonitor(live, owners) {
117
+ const { root } = this.options.env;
118
+ const native = live.controller.nativeSessionId;
119
+ if (!native)
120
+ return;
121
+ for (const id of [...owners].reverse()) {
122
+ const record = findRun(root, id);
123
+ if (!record || record.relation || record.nativeSessionId !== native || record.workingDirectory !== live.input.cwd)
124
+ continue;
125
+ const children = [];
126
+ const seen = new Set([record.id]);
127
+ const visit = (parentId) => {
128
+ for (const childId of recordedSubagents(root, parentId)) {
129
+ if (seen.has(childId))
130
+ continue;
131
+ seen.add(childId);
132
+ const child = findRun(root, childId);
133
+ if (child?.relation !== 'subagent')
134
+ continue;
135
+ children.push(child);
136
+ visit(child.id);
137
+ }
138
+ };
139
+ visit(record.id);
140
+ const entry = (run, profile = null) => ({ record: run, events: readEvents(root, run.id), profile, runtime: this.monitorRuntime(live, run) });
141
+ live.controller.restoreMonitor(entry(record), children.map(child => entry(child, this.subagentProfile(child))));
142
+ return;
143
+ }
144
+ }
101
145
  constructor(options) {
102
146
  this.options = options;
103
147
  this.assets = options.assets ?? defaultAssets();
104
148
  options.runs.listen(event => {
105
- this.sessions.get(event.sessionId)?.controller.observe(event);
149
+ const session = this.sessions.get(event.sessionId);
150
+ session?.controller.observe(event);
106
151
  const record = findRun(options.env.root, event.runId);
152
+ if (record && !record.relation && session) {
153
+ session.controller.observeMain(record, event, this.monitorRuntime(session, record));
154
+ return;
155
+ }
107
156
  if (record?.relation !== 'subagent')
108
157
  return;
109
158
  let parentId = record.parentRunId;
@@ -119,8 +168,8 @@ export class Terminals {
119
168
  const settings = options.subagents?.runtimeSettings(record.id);
120
169
  owner.controller.observeSubagent(parent.id, record, event, this.subagentProfile(record), {
121
170
  accountLabel: owner.account.label,
122
- model: settings?.model ?? null,
123
- effort: settings?.effort ?? null
171
+ model: settings?.model ?? record.model ?? null,
172
+ effort: settings?.effort ?? record.effort ?? null
124
173
  });
125
174
  break;
126
175
  }
@@ -690,7 +739,7 @@ export class Terminals {
690
739
  };
691
740
  }
692
741
  /** The run a native turn reports into: the one reserved by `send`, or a new one for what the person typed. */
693
- begin(live, reservedRunId, text) {
742
+ begin(live, reservedRunId, text, launch = {}) {
694
743
  const { root } = this.options.env;
695
744
  const { sessionId } = live.input;
696
745
  if (this.options.runs.activeForSession(sessionId))
@@ -706,6 +755,14 @@ export class Terminals {
706
755
  saveRun(root, this.runRecord(live, runId, text || null));
707
756
  copyToRun(root, live.input.sessionFolder, runId);
708
757
  }
758
+ if (launch.model || launch.effort) {
759
+ updateRun(root, runId, (record) => {
760
+ if (launch.model)
761
+ record.model = launch.model;
762
+ if (launch.effort)
763
+ record.effort = launch.effort;
764
+ });
765
+ }
709
766
  return this.options.runs.start(sessionId, runId);
710
767
  }
711
768
  async decide(live, run, tool, args) {
@@ -747,7 +804,7 @@ export class Terminals {
747
804
  }
748
805
  if (inspection.outside)
749
806
  return { allowed: false, requiresApproval: true, rememberable: false, standing: 'switch', reason: inspection.reason, workspaceRoot };
750
- if (name === SESSION_NAME || name === CHECKLIST_WRITE || name === SPAWN_SUBAGENT || name === WAIT_SUBAGENTS || READ_ONLY_TOOLS.has(name) || this.worldReadOnly.has(name) || this.options.apps.toolNames.includes(name) || ['bash', 'edit', 'write'].includes(name)) {
807
+ if (name === SESSION_NAME || name === TODO_WRITE || name === SPAWN_SUBAGENT || name === WAIT_SUBAGENTS || READ_ONLY_TOOLS.has(name) || this.worldReadOnly.has(name) || this.options.apps.toolNames.includes(name) || ['bash', 'edit', 'write'].includes(name)) {
751
808
  return { allowed: true, workspaceRoot };
752
809
  }
753
810
  return { allowed: false, requiresApproval: true, rememberable: true, reason: 'This native action requires approval.', workspaceRoot };
@@ -850,13 +907,22 @@ export class Terminals {
850
907
  if (params.plan !== undefined && typeof params.plan !== 'boolean')
851
908
  throw invalid('plan must be a boolean.');
852
909
  const readOnly = live.input.readOnly === true;
853
- const readTools = new Set([...READ_ONLY_TOOLS, SESSION_NAME, CHECKLIST_WRITE, SPAWN_SUBAGENT, WAIT_SUBAGENTS, 'ask_user', 'mcp_list_tools', 'mcp_call_tool', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts', 'skill_load']);
910
+ const readTools = new Set([...READ_ONLY_TOOLS, SESSION_NAME, TODO_WRITE, SPAWN_SUBAGENT, WAIT_SUBAGENTS, 'ask_user', 'mcp_list_tools', 'mcp_call_tool', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts', 'skill_load']);
854
911
  const tools = [...new Set(params.tools)].filter(name => (!readOnly || readTools.has(name)) && (params.plan === true || (name !== 'ask_user' && name !== 'ask_user_question')));
855
912
  const caller = { ...live.caller, runId: run.runId };
856
913
  const capabilities = this.options.resolveCapabilities ? await this.options.resolveCapabilities(caller, tools, live.input.cwd) : { version: 1, tools, connectors: [], contributions: [] };
914
+ const variantDir = this.variantDirs.get(live.input.sessionId) ?? null;
915
+ const subagentProfiles = {
916
+ normal: profileSection(this.options.env.root, live.input.cwd, variantDir).body,
917
+ plan: profileSection(this.options.env.root, live.input.cwd, variantDir, true, {
918
+ frozenTools: capabilities.tools,
919
+ contributions: capabilities.contributions,
920
+ descriptors: HARNESS_PLUGINS
921
+ }).body
922
+ };
857
923
  freezeRun(this.options.env.root, run.runId, caller, capabilities);
858
924
  if (!service || live.input.subagents === null)
859
- return { runId: run.runId, connectors: capabilities.connectors };
925
+ return { runId: run.runId, connectors: capabilities.connectors, subagentProfiles };
860
926
  service.register({
861
927
  run: { id: run.runId, sessionId: live.input.sessionId, mode: live.input.run?.mode ?? 'code', projectId: live.input.projectId, worktreeId: live.input.run?.worktreeId },
862
928
  cwd: live.input.cwd, sessionDir: this.historyDir(live.input), text: '', caller: live.caller,
@@ -868,8 +934,11 @@ export class Terminals {
868
934
  variant: live.variant ? { dir: live.variant.dir, scope: live.variant.scope } : null,
869
935
  capabilities
870
936
  }, live.account.id, capabilities);
871
- service.restore(run.runId, params.history ?? { owners: [], receipts: [] });
872
- return { runId: run.runId, connectors: capabilities.connectors };
937
+ const history = params.history ?? { owners: [], receipts: [] };
938
+ service.restore(run.runId, history);
939
+ // Children an earlier reply left stopped, reported before the model answers.
940
+ const notice = await service.delivery(run.runId, history, false);
941
+ return { runId: run.runId, connectors: capabilities.connectors, subagentProfiles, notice };
873
942
  }
874
943
  if (call === 'terminal.subagents.verify') {
875
944
  await service.verify(run.runId, params.history);
@@ -1030,7 +1099,7 @@ export class Terminals {
1030
1099
  this.variantDirs.set(sessionId, variant.dir);
1031
1100
  const worldTools = await this.worldToolNames(input, preparedAccount);
1032
1101
  const ownTools = await this.ownToolNames(input, preparedAccount);
1033
- const launchTools = [...new Set([...(input.launch?.tools ?? []), CHECKLIST_WRITE, SESSION_NAME, ...ownTools, ...(broker ? apps.toolNames : []), ...worldTools])];
1102
+ const launchTools = [...new Set([...(input.launch?.tools ?? []), TODO_WRITE, SESSION_NAME, ...ownTools, ...(broker ? apps.toolNames : []), ...worldTools])];
1034
1103
  // The profiles replace the variant's own subagent list, which they include.
1035
1104
  const profiles = this.options.subagents && input.subagents !== null ? profileSection(root, input.cwd, variant?.dir ?? null) : null;
1036
1105
  const launchSections = profiles ? [...(input.launch?.sections ?? []).filter((section) => section.id !== profiles.id), profiles] : input.launch?.sections;
@@ -1050,7 +1119,8 @@ export class Terminals {
1050
1119
  controller: new TerminalController({
1051
1120
  sessionId,
1052
1121
  nativeSessionId: input.nativeSessionId ?? null,
1053
- begin: ({ reservedRunId, text }) => this.begin(live, reservedRunId, text),
1122
+ begin: ({ reservedRunId, text, model, effort }) => this.begin(live, reservedRunId, text, { model, effort }),
1123
+ hydrate: (owners) => this.hydrateMonitor(live, owners),
1054
1124
  policy: (run, tool, args) => this.decide(live, run, tool, args),
1055
1125
  workspaceRoot: run => {
1056
1126
  const record = loadRun(this.options.env.root, run.runId);
@@ -1168,6 +1238,8 @@ export class Terminals {
1168
1238
  return this.componentsCall(live, call, params);
1169
1239
  if (call.startsWith('terminal.trajectory.'))
1170
1240
  return this.trajectoryCall(live, call, params);
1241
+ if (call === 'terminal.todo')
1242
+ return latestTerminalTodo(this.options.env.root, live.input.sessionId, live.controller.nativeSessionId);
1171
1243
  if (call === 'terminal.permissions.get') {
1172
1244
  const policy = this.options.approvals.read();
1173
1245
  return { full: this.options.approvals.isAuto('pi'), globalTools: policy.globalTools.pi ?? [] };
@@ -1,4 +1,4 @@
1
- import type { RunEvent, RunRecord } from "../sessions/contracts.js";
1
+ import type { RunEvent, RunRecord, TodoItem } from "../sessions/contracts.js";
2
2
  export type TerminalTrajectoryRun = Pick<RunRecord, 'id' | 'mode' | 'goal' | 'startedAt' | 'endedAt' | 'status'>;
3
3
  export interface TerminalTrajectorySnapshot {
4
4
  run: TerminalTrajectoryRun;
@@ -6,5 +6,10 @@ export interface TerminalTrajectorySnapshot {
6
6
  }
7
7
  /** The native conversation file is the terminal's boundary for this read-only view. */
8
8
  export declare function listTerminalTrajectoryRuns(root: string, sessionId: string, nativeSessionId: string | null): TerminalTrajectoryRun[];
9
+ /** The todo of the latest reply in this native conversation, for a host's /todo view. */
10
+ export declare function latestTerminalTodo(root: string, sessionId: string, nativeSessionId: string | null): {
11
+ runId: string;
12
+ todo: TodoItem[];
13
+ };
9
14
  /** Read a run only when it belongs to both this terminal and its current native conversation. */
10
15
  export declare function readTerminalTrajectory(root: string, sessionId: string, nativeSessionId: string | null, runId: string, afterSequence?: number): TerminalTrajectorySnapshot;
@@ -1,5 +1,6 @@
1
1
  import { notFound } from "../core/errors.js";
2
2
  import { findRun, listRuns, readEvents } from "../sessions/runStore.js";
3
+ import { todoOf } from "../tools/todo.js";
3
4
  function summary(run) {
4
5
  return {
5
6
  id: run.id,
@@ -18,6 +19,13 @@ export function listTerminalTrajectoryRuns(root, sessionId, nativeSessionId) {
18
19
  .filter(run => run.nativeSessionId === nativeSessionId || run.trigger === 'side-question' && run.sessionId === sessionId)
19
20
  .map(summary);
20
21
  }
22
+ /** The todo of the latest reply in this native conversation, for a host's /todo view. */
23
+ export function latestTerminalTodo(root, sessionId, nativeSessionId) {
24
+ if (!nativeSessionId)
25
+ return { runId: '', todo: [] };
26
+ const run = listRuns(root, { sessionId }).find(candidate => candidate.nativeSessionId === nativeSessionId && candidate.trigger !== 'side-question');
27
+ return run ? { runId: run.id, todo: todoOf(readEvents(root, run.id)) } : { runId: '', todo: [] };
28
+ }
21
29
  /** Read a run only when it belongs to both this terminal and its current native conversation. */
22
30
  export function readTerminalTrajectory(root, sessionId, nativeSessionId, runId, afterSequence) {
23
31
  if (!nativeSessionId || !Number.isSafeInteger(afterSequence ?? 0) || (afterSequence ?? 0) < 0)
@@ -62,3 +62,7 @@ export declare function pageFrame({ title, meta, body, footer, width, rows, them
62
62
  rows: number;
63
63
  headerLine?: string;
64
64
  }): string[];
65
+ /** A full-width two-pane list uses a 40/60 split once there is room to read both panes. */
66
+ export declare function inspectorListWidth(width: number): number | undefined;
67
+ /** Joins a list and passive detail pane without drawing a box around either. */
68
+ export declare function splitColumns(left: readonly string[], right: readonly string[], leftWidth: number, width: number, rows: number, theme: Theme): string[];
@@ -167,3 +167,16 @@ export function pageFrame({ title, meta, body, footer, width, rows, theme, heade
167
167
  footerRow(footer, width, theme)
168
168
  ].slice(0, rows);
169
169
  }
170
+ /** A full-width two-pane list uses a 40/60 split once there is room to read both panes. */
171
+ export function inspectorListWidth(width) {
172
+ return width < 88 ? undefined : Math.max(32, Math.floor((width - 3) * 0.4));
173
+ }
174
+ /** Joins a list and passive detail pane without drawing a box around either. */
175
+ export function splitColumns(left, right, leftWidth, width, rows, theme) {
176
+ const rightWidth = Math.max(0, width - leftWidth - 3);
177
+ return Array.from({ length: Math.max(0, Math.trunc(rows)) }, (_, index) => {
178
+ const leftLine = truncateToWidth(left[index] ?? '', leftWidth, '');
179
+ const rightLine = truncateToWidth(right[index] ?? '', rightWidth, '');
180
+ return leftLine + ' '.repeat(Math.max(0, leftWidth - visibleWidth(leftLine))) + theme.fg('dim', ' │ ') + rightLine;
181
+ });
182
+ }
@@ -1,6 +1,8 @@
1
1
  import { DoctorPage } from "./pages/doctorPage.js";
2
2
  import { AccountsPage } from "./pages/accountsPage.js";
3
3
  import { BrowserPage } from "./pages/browserPage.js";
4
+ import { PluginsPage } from "./pages/pluginsPage.js";
5
+ import { HubPage } from "./pages/hubPage.js";
4
6
  import { LogPage, formatRunEventLog } from "./pages/logPage.js";
5
7
  import { PermissionsPage } from "./pages/permissionsPage.js";
6
8
  import { SettingsPage } from "./pages/settingsPage.js";
@@ -40,17 +42,25 @@ export function createCommandPage(host, id, options = {}) {
40
42
  activeTools: () => host.session().getActiveToolNames(),
41
43
  confirm: (title, message) => host.ui.confirm(title, message), notify, changed: host.changed
42
44
  });
45
+ case 'plugins':
46
+ return new PluginsPage({
47
+ theme, call: host.control, changed: host.changed,
48
+ ui: { select: host.ui.select.bind(host.ui), confirm: host.ui.confirm.bind(host.ui), notify, input: host.ui.input.bind(host.ui) }
49
+ });
50
+ case 'hub':
51
+ return new HubPage({
52
+ theme, call: host.control, changed: host.changed,
53
+ ui: { select: host.ui.select.bind(host.ui), confirm: host.ui.confirm.bind(host.ui), notify }
54
+ });
43
55
  case 'harness':
44
56
  case 'skills':
45
57
  case 'connections':
46
58
  case 'tools':
47
- case 'plugins':
48
- case 'hub':
49
59
  case 'components':
50
60
  return new BrowserPage({
51
61
  theme, call: host.control, changed: host.changed,
52
- section: id === 'harness' || id === 'hub' ? 'overview' : id === 'components' ? 'library' : id,
53
- source: id === 'hub' ? 'discover' : 'installed',
62
+ section: id === 'harness' ? 'overview' : id === 'components' ? 'library' : id,
63
+ source: 'installed',
54
64
  ui: {
55
65
  select: host.ui.select.bind(host.ui), confirm: host.ui.confirm.bind(host.ui), notify: host.ui.notify.bind(host.ui), input: host.ui.input.bind(host.ui),
56
66
  secretInput: (title, placeholder) => {
@@ -2,6 +2,7 @@ import { type AgentSession, type ExtensionUIContext } from '@earendil-works/pi-c
2
2
  import { type SkillEntry } from "./skillInvocation.js";
3
3
  import type { TerminalRuntime } from "./runtime.js";
4
4
  import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from "../trajectory.js";
5
+ import type { TodoEntry } from "../opentui/model.js";
5
6
  import type { TerminalSubagentActivity } from "../subagentMonitor.js";
6
7
  import { type TerminalCommandCompletion, type TerminalCommandHelpGroup } from "../commandHelp.js";
7
8
  export type { TerminalCommandCompletion } from "../commandHelp.js";
@@ -20,6 +21,9 @@ export interface TerminalCommandHost {
20
21
  refresh(): void;
21
22
  changed(): void;
22
23
  showHelp(groups: TerminalCommandHelpGroup[]): void;
24
+ /** The latest todo list of this reply, or an empty list. */
25
+ todo(): Promise<readonly TodoEntry[]>;
26
+ showTodo(items: readonly TodoEntry[]): void;
23
27
  plan(brief: string): void;
24
28
  /** Asks a side question in its own tool-free session; empty text dismisses the one showing. */
25
29
  btw(question: string): Promise<void>;
@@ -399,6 +399,14 @@ export function createTerminalCommands(host) {
399
399
  case 'trajectory':
400
400
  await openPage('trajectory', { runId: argument });
401
401
  break;
402
+ case 'todo': {
403
+ const items = await host.todo();
404
+ if (!items.length)
405
+ notify('No todo list in this reply yet. The agent writes one with todo_write for multi-step work.');
406
+ else
407
+ host.showTodo(items);
408
+ break;
409
+ }
402
410
  case 'subagents':
403
411
  await openPage('subagents');
404
412
  break;