@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
@@ -6,13 +6,16 @@
6
6
  import { describePlugin, surfaceBadge } from "../../../plugins/surfaces.js";
7
7
  import { matchesKey, truncateToWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
8
8
  import { BACK, applyPluginAction, menuRows, rowActions } from "../../pluginMenu.js";
9
- import { safeText } from "../chrome.js";
9
+ import { inspectorListWidth, safeText, splitColumns } from "../chrome.js";
10
10
  import { FilterList } from "../list.js";
11
11
  const SECTIONS = [
12
12
  { id: 'plugins', title: 'Plugins' },
13
- { id: 'trust', title: 'Trust' }
13
+ { id: 'trust', title: 'Trust' },
14
+ { id: 'packages', title: 'Packages' }
14
15
  ];
15
16
  const SETTINGS_ROW = 'settings:';
17
+ const PACKAGE_ROW = 'package:';
18
+ const INSTALL_PACKAGE = 'action:install-package';
16
19
  const isSwitch = (setting) => setting.min === 0 && setting.max === 1;
17
20
  const settingValueText = (setting) => isSwitch(setting)
18
21
  ? setting.value === 1 ? 'on' : 'off'
@@ -20,14 +23,17 @@ const settingValueText = (setting) => isSwitch(setting)
20
23
  const settingsOf = (plugin) => plugin.settings ?? [];
21
24
  export class PluginsPage {
22
25
  options;
26
+ presentation = 'fullscreen';
23
27
  plugins;
24
28
  section = 'plugins';
25
29
  projectId = null;
30
+ hubManagedIds = new Set();
26
31
  error;
27
32
  restart = false;
28
33
  busy = false;
29
34
  disposed = false;
30
35
  list;
36
+ pointerLayout;
31
37
  /** The plugin whose settings are open, or null on the list. */
32
38
  settingsFor = null;
33
39
  saved = false;
@@ -70,6 +76,12 @@ export class PluginsPage {
70
76
  this.projectId = (await call('plugins.session').catch(() => ({ projectId: null }))).projectId;
71
77
  this.plugins = await call('plugins.list');
72
78
  this.error = undefined;
79
+ this.hubManagedIds = new Set();
80
+ try {
81
+ const overview = await call('hub.list');
82
+ this.hubManagedIds = new Set(overview.installed.filter(receipt => receipt.entry.delivery.kind === 'pi-package').flatMap(receipt => receipt.resourceIds));
83
+ }
84
+ catch { /* Package actions still go through the receipt-aware Harness service. */ }
73
85
  }
74
86
  catch (error) {
75
87
  this.error = error instanceof Error ? error.message : String(error);
@@ -91,8 +103,17 @@ export class PluginsPage {
91
103
  searchText: `${setting.label} ${setting.description}`
92
104
  })));
93
105
  }
94
- /** The menu rows, with a Settings row after the last row of each plugin that has settings. */
106
+ /** Switch, trust, settings and package actions stay grouped under their owning plugin. */
95
107
  rows() {
108
+ if (this.section === 'packages')
109
+ return [
110
+ ...(this.plugins ?? []).filter(plugin => plugin.install).map(plugin => {
111
+ const source = plugin.install.kind === 'folder' ? 'local folder' : 'package';
112
+ const hub = this.hubManagedIds.has(plugin.id);
113
+ return { id: `${PACKAGE_ROW}${plugin.id}`, group: safeText(plugin.title), label: 'Manage package', detail: `${source} · ${hub ? 'Hub' : 'local'} · ${plugin.version ?? plugin.install.source}`, tone: 'muted', searchText: `${plugin.title} ${plugin.install.source} ${plugin.version ?? ''} package ${hub ? 'Hub' : 'local'}` };
114
+ }),
115
+ { id: INSTALL_PACKAGE, group: 'Packages', label: 'Install package…', detail: 'Folder, npm or Git', tone: 'accent', searchText: 'install add package plugin npm Git folder' }
116
+ ];
96
117
  const menu = this.menu();
97
118
  const out = [];
98
119
  const placed = new Set();
@@ -107,18 +128,19 @@ export class PluginsPage {
107
128
  if (this.section === 'plugins' && last && settingsOf(row.plugin).length)
108
129
  out.push(settingsRow(row.plugin));
109
130
  });
110
- if (this.section === 'plugins') {
131
+ if (this.section === 'plugins')
111
132
  for (const plugin of this.plugins ?? [])
112
133
  if (settingsOf(plugin).length && !placed.has(plugin.id))
113
134
  out.push(settingsRow(plugin));
114
- }
115
135
  return out;
116
136
  }
117
137
  menu() {
118
138
  const rows = menuRows(this.plugins ?? [], this.projectId);
119
- return this.section === 'trust'
120
- ? rows.filter(row => row.whole && Boolean(row.plugin.code))
121
- : rows.filter(row => rowActions(row, this.projectId).some(action => action.startsWith('Switch ')));
139
+ if (this.section === 'trust')
140
+ return rows.filter(row => row.whole && Boolean(row.plugin.code));
141
+ if (this.section === 'packages')
142
+ return [];
143
+ return rows.filter(row => rowActions(row, this.projectId).some(action => action.startsWith('Switch ')));
122
144
  }
123
145
  key(row) { return `${row.whole ? 'plugin' : 'item'}:${row.id}`; }
124
146
  toRow(row) {
@@ -137,6 +159,16 @@ export class PluginsPage {
137
159
  this.options.changed();
138
160
  return;
139
161
  }
162
+ if (id === INSTALL_PACKAGE) {
163
+ await this.installPackage();
164
+ return;
165
+ }
166
+ if (id.startsWith(PACKAGE_ROW)) {
167
+ const plugin = this.plugins?.find(candidate => candidate.id === id.slice(PACKAGE_ROW.length));
168
+ if (plugin?.install)
169
+ await this.managePackage(plugin);
170
+ return;
171
+ }
140
172
  const row = this.menu().find(candidate => this.key(candidate) === id);
141
173
  if (!row || this.busy)
142
174
  return;
@@ -151,6 +183,74 @@ export class PluginsPage {
151
183
  }
152
184
  await this.reload();
153
185
  }
186
+ async installPackage() {
187
+ if (this.busy)
188
+ return;
189
+ this.busy = true;
190
+ try {
191
+ const source = await this.options.ui.input('Install local plugin package', 'npm:package@version, Git address, or absolute folder path');
192
+ if (!source?.trim())
193
+ return;
194
+ const confirmed = await this.options.ui.confirm('Install package?', `${source.trim()} will be installed without running package code. It arrives switched off and code remains untrusted until reviewed.`);
195
+ if (!confirmed)
196
+ return;
197
+ await this.options.call('plugins.install', { source: source.trim() });
198
+ this.restart = true;
199
+ this.options.ui.notify('Installed switched off. Changes apply when this session restarts.', 'info');
200
+ await this.reload();
201
+ }
202
+ catch (error) {
203
+ this.options.ui.notify(error instanceof Error ? error.message : String(error), 'error');
204
+ }
205
+ finally {
206
+ this.busy = false;
207
+ if (!this.disposed)
208
+ this.options.changed();
209
+ }
210
+ }
211
+ async managePackage(plugin) {
212
+ if (this.busy || !plugin.install)
213
+ return;
214
+ const hubManaged = this.hubManagedIds.has(plugin.id);
215
+ const actions = [
216
+ ...(hubManaged || plugin.install.kind === 'folder' ? [] : ['Update package']),
217
+ hubManaged ? 'Uninstall Hub version' : 'Remove package',
218
+ BACK
219
+ ];
220
+ this.busy = true;
221
+ try {
222
+ const action = await this.options.ui.select(`${plugin.title}: ${plugin.install.source}`, actions);
223
+ if (!action || action === BACK)
224
+ return;
225
+ if (action === 'Update package') {
226
+ const confirmed = await this.options.ui.confirm(`Update ${plugin.title}?`, 'Fetches a newer package version without running its scripts. Changed code remains subject to trust review.');
227
+ if (!confirmed)
228
+ return;
229
+ await this.options.call('plugins.update', { pluginId: plugin.id });
230
+ this.options.ui.notify('Updated. Changes apply when this session restarts.', 'info');
231
+ }
232
+ else {
233
+ const title = hubManaged ? `Uninstall ${plugin.title} from Hub?` : `Remove ${plugin.title}?`;
234
+ const message = hubManaged
235
+ ? 'This removes the exact Hub receipt and only its recorded package resources.'
236
+ : 'This removes the installed package. A local folder is moved to the Harness trash.';
237
+ if (!await this.options.ui.confirm(title, message))
238
+ return;
239
+ await this.options.call('plugins.remove', { pluginId: plugin.id });
240
+ this.options.ui.notify(hubManaged ? 'Hub package uninstalled.' : 'Package removed.', 'info');
241
+ }
242
+ this.restart = true;
243
+ await this.reload();
244
+ }
245
+ catch (error) {
246
+ this.options.ui.notify(error instanceof Error ? error.message : String(error), 'error');
247
+ }
248
+ finally {
249
+ this.busy = false;
250
+ if (!this.disposed)
251
+ this.options.changed();
252
+ }
253
+ }
154
254
  back() {
155
255
  this.settingsFor = null;
156
256
  this.saved = false;
@@ -196,7 +296,12 @@ export class PluginsPage {
196
296
  }
197
297
  }
198
298
  handleMouse(event) {
199
- return this.settingsPlugin() ? this.settingsList.handleMouse(event) : this.list.handleMouse(event);
299
+ const layout = this.pointerLayout;
300
+ if (!layout)
301
+ return undefined;
302
+ if (event.x < 0 || event.x >= layout.width || event.y < layout.top || event.y >= layout.top + layout.rows)
303
+ return { handled: true, render: false };
304
+ return layout.list.handleMouse({ ...event, y: event.y - layout.top });
200
305
  }
201
306
  handleInput(data) {
202
307
  if (this.settingsPlugin()) {
@@ -206,44 +311,111 @@ export class PluginsPage {
206
311
  }
207
312
  if (matchesKey(data, 'tab') || matchesKey(data, 'shift+tab')) {
208
313
  if (!this.list.query) {
209
- this.section = this.section === 'plugins' ? 'trust' : 'plugins';
314
+ const direction = matchesKey(data, 'shift+tab') ? -1 : 1;
315
+ const current = SECTIONS.findIndex(section => section.id === this.section);
316
+ this.section = SECTIONS[(current + direction + SECTIONS.length) % SECTIONS.length].id;
210
317
  this.fill();
211
318
  }
212
319
  return true;
213
320
  }
214
321
  return this.list.handleInput(data);
215
322
  }
323
+ selectedPlugin() {
324
+ const selectedId = this.list.selected?.id;
325
+ return selectedId?.startsWith(SETTINGS_ROW)
326
+ ? this.plugins?.find(candidate => `${SETTINGS_ROW}${candidate.id}` === selectedId)
327
+ : selectedId?.startsWith(PACKAGE_ROW)
328
+ ? this.plugins?.find(candidate => `${PACKAGE_ROW}${candidate.id}` === selectedId)
329
+ : this.menu().find(candidate => this.key(candidate) === selectedId)?.plugin;
330
+ }
331
+ pluginInspector(plugin, width, rows) {
332
+ const theme = this.options.theme();
333
+ if (width < 1 || rows < 1)
334
+ return [];
335
+ if (!plugin) {
336
+ const action = this.list.selected?.id === INSTALL_PACKAGE;
337
+ return [theme.fg('muted', action ? 'Add a package from npm, Git, or a local folder.' : 'Select a plugin to inspect it.')].slice(0, rows).map(line => truncateToWidth(line, width, ''));
338
+ }
339
+ const selectedId = this.list.selected?.id;
340
+ const row = this.menu().find(candidate => this.key(candidate) === selectedId);
341
+ const title = row ? row.whole ? row.plugin.title : row.name : plugin.title;
342
+ const lines = [theme.bold(safeText(title))];
343
+ if (title !== plugin.title)
344
+ lines.push(theme.fg('dim', `Part of ${safeText(plugin.title)}`));
345
+ lines.push('', ...wrapTextWithAnsi(safeText(describePlugin(plugin)), width));
346
+ if (row) {
347
+ const status = this.section === 'trust' && row.whole
348
+ ? plugin.trust === 'trusted' ? 'Code trusted' : 'Code not trusted'
349
+ : row.on ? 'Enabled' : 'Disabled';
350
+ lines.push('', theme.fg(row.on ? 'success' : 'dim', status));
351
+ }
352
+ if (plugin.code) {
353
+ lines.push(theme.fg('muted', `Code trust: ${plugin.trust === 'trusted' ? 'trusted' : 'not trusted'}`));
354
+ lines.push(theme.fg('muted', `Fingerprint: ${plugin.code.hash.slice(0, 12)}`));
355
+ }
356
+ if (plugin.install) {
357
+ lines.push('', theme.fg('muted', `Package: ${this.hubManagedIds.has(plugin.id) ? 'Hub receipt' : 'local'}`));
358
+ lines.push(...wrapTextWithAnsi(theme.fg('dim', safeText(plugin.install.source)), width));
359
+ }
360
+ if (settingsOf(plugin).length)
361
+ lines.push('', theme.fg('muted', `${settingsOf(plugin).length} settings`));
362
+ return lines.slice(0, rows).map(line => truncateToWidth(line, width, ''));
363
+ }
216
364
  render(width, rows) {
217
365
  const theme = this.options.theme();
218
366
  if (width < 1 || rows < 1)
219
367
  return [];
220
- if (!this.plugins)
368
+ if (!this.plugins) {
369
+ this.pointerLayout = undefined;
221
370
  return [theme.fg(this.error ? 'error' : 'dim', this.error ? `Could not read plugins: ${this.error}` : 'Reading plugins…')];
371
+ }
222
372
  const plugin = this.settingsPlugin();
223
373
  if (plugin)
224
374
  return this.renderSettings(plugin, width, rows);
225
- const selectedId = this.list.selected?.id;
226
- const selected = selectedId?.startsWith(SETTINGS_ROW)
227
- ? this.plugins.find(candidate => `${SETTINGS_ROW}${candidate.id}` === selectedId)
228
- : this.menu().find(candidate => this.key(candidate) === selectedId)?.plugin;
375
+ const note = this.restart ? [theme.fg('warning', 'Saved. Changes apply when this session restarts.')] : [];
376
+ const paneWidth = inspectorListWidth(width);
377
+ if (paneWidth !== undefined) {
378
+ const bodyRows = Math.max(0, rows - note.length);
379
+ const rightWidth = Math.max(0, width - paneWidth - 3);
380
+ const listLines = this.list.render(paneWidth, bodyRows);
381
+ this.pointerLayout = { list: this.list, width: paneWidth, top: note.length, rows: listLines.length };
382
+ return [...note, ...splitColumns(listLines, this.pluginInspector(this.selectedPlugin(), rightWidth, bodyRows), paneWidth, width, bodyRows, theme)]
383
+ .slice(0, rows).map(line => truncateToWidth(line, width, ''));
384
+ }
385
+ const selected = this.selectedPlugin();
229
386
  const detail = selected && rows >= 8 ? wrapTextWithAnsi(safeText(describePlugin(selected)), width).slice(0, 3) : [];
230
- const note = this.restart ? ['Saved. Changes apply when this session restarts.'] : [];
231
- const extra = detail.length + note.length;
232
- const lines = this.list.render(width, Math.max(2, rows - (extra ? extra + 1 : 0)));
233
- if (extra)
234
- lines.push('', ...detail.map(line => theme.fg('muted', line)), ...note.map(line => theme.fg('warning', line)));
387
+ const listRows = Math.max(2, rows - note.length - (detail.length ? detail.length + 1 : 0));
388
+ const listLines = this.list.render(width, listRows);
389
+ this.pointerLayout = { list: this.list, width, top: note.length, rows: Math.min(listLines.length, Math.max(0, rows - note.length)) };
390
+ const lines = [...note, ...listLines];
391
+ if (detail.length)
392
+ lines.push('', ...detail.map(line => theme.fg('muted', line)));
235
393
  return lines.slice(0, rows).map(line => truncateToWidth(line, width, ''));
236
394
  }
237
395
  renderSettings(plugin, width, rows) {
238
396
  const theme = this.options.theme();
239
397
  const setting = settingsOf(plugin).find(candidate => candidate.key === this.settingsList.selected?.id);
240
398
  const range = setting && !isSwitch(setting) ? [`From ${setting.min.toLocaleString('en-US')} to ${setting.max.toLocaleString('en-US')} · default ${setting.default.toLocaleString('en-US')}`] : [];
399
+ const note = this.saved ? [theme.fg('success', 'Saved.')] : [];
400
+ const paneWidth = inspectorListWidth(width);
401
+ if (paneWidth !== undefined) {
402
+ const bodyRows = Math.max(0, rows - note.length);
403
+ const rightWidth = Math.max(0, width - paneWidth - 3);
404
+ const listLines = this.settingsList.render(paneWidth, bodyRows);
405
+ const details = setting
406
+ ? [theme.bold(safeText(setting.label)), '', ...wrapTextWithAnsi(safeText(setting.description), rightWidth), '', ...range.map(line => theme.fg('muted', line)), theme.fg('dim', `Current value: ${settingValueText(setting)}`)]
407
+ : [theme.fg('muted', 'Select a setting to review its value and description.')];
408
+ this.pointerLayout = { list: this.settingsList, width: paneWidth, top: note.length, rows: listLines.length };
409
+ return [...note, ...splitColumns(listLines, details, paneWidth, width, bodyRows, theme)]
410
+ .slice(0, rows).map(line => truncateToWidth(line, width, ''));
411
+ }
241
412
  const detail = setting && rows >= 8 ? [...wrapTextWithAnsi(safeText(setting.description), width).slice(0, 3), ...range] : [];
242
- const note = this.saved ? ['Saved.'] : [];
243
- const extra = detail.length + note.length;
244
- const lines = this.settingsList.render(width, Math.max(2, rows - (extra ? extra + 1 : 0)));
245
- if (extra)
246
- lines.push('', ...detail.map(line => theme.fg('muted', line)), ...note.map(line => theme.fg('success', line)));
413
+ const listRows = Math.max(2, rows - note.length - (detail.length ? detail.length + 1 : 0));
414
+ const listLines = this.settingsList.render(width, listRows);
415
+ this.pointerLayout = { list: this.settingsList, width, top: note.length, rows: Math.min(listLines.length, Math.max(0, rows - note.length)) };
416
+ const lines = [...note, ...listLines];
417
+ if (detail.length)
418
+ lines.push('', ...detail.map(line => theme.fg('muted', line)));
247
419
  return lines.slice(0, rows).map(line => truncateToWidth(line, width, ''));
248
420
  }
249
421
  }
@@ -88,9 +88,11 @@ export class SubagentsPage {
88
88
  activeTab() { return 'subagents'; }
89
89
  hint() { return '↑↓ select · type to search'; }
90
90
  rows(width = 80, now = this.options.now?.() ?? Date.now()) {
91
+ // The main agent is pinned first so its children read against it.
91
92
  const ordered = [...this.options.snapshot()].sort((left, right) => {
93
+ const main = Number(right.relation === 'main') - Number(left.relation === 'main');
92
94
  const active = Number(ACTIVE_SUBAGENT_STATUSES.has(right.status)) - Number(ACTIVE_SUBAGENT_STATUSES.has(left.status));
93
- return active || left.startedAt.localeCompare(right.startedAt) || left.id.localeCompare(right.id);
95
+ return main || active || left.startedAt.localeCompare(right.startedAt) || left.id.localeCompare(right.id);
94
96
  });
95
97
  return ordered.map(item => {
96
98
  const profile = clean(item.profile?.name ?? '') || 'subagent';
@@ -115,9 +117,10 @@ export class SubagentsPage {
115
117
  render(width, rows) {
116
118
  const items = this.rows(width, this.options.now?.() ?? Date.now());
117
119
  this.list.setRows(items);
118
- const active = items.filter(item => item.data && ACTIVE_SUBAGENT_STATUSES.has(item.data.status)).length;
120
+ const children = items.filter(item => item.data?.relation !== 'main');
121
+ const active = children.filter(item => item.data && ACTIVE_SUBAGENT_STATUSES.has(item.data.status)).length;
119
122
  const selected = this.list.selected?.data;
120
- const header = headerRow('Subagents', `${active} active · ${items.length} total`, width, this.options.theme());
123
+ const header = headerRow('Subagents', `${active} active · ${children.length} total`, width, this.options.theme());
121
124
  const bodyRows = Math.max(0, rows - 7);
122
125
  const listRows = Math.max(1, Math.min(this.list.height, Math.floor(bodyRows * 0.6)));
123
126
  const output = [header, '', ...this.list.render(width, listRows), ''];
@@ -129,8 +132,8 @@ export class SubagentsPage {
129
132
  output.push(neutralRule(width, theme));
130
133
  output.push(padded(theme.fg('muted', `Profile: ${clean(selected.profile?.name ?? '') || 'copy of the parent'}`), width));
131
134
  output.push(padded(theme.fg('accent', 'Task'), width));
132
- output.push(...detailLines(selected.goal || 'Subagent'));
133
- output.push(padded(theme.fg('muted', `Status: ${[selected.status.replaceAll('_', ' '), subagentTasks(selected.checklist), terminalSubagentDuration(selected)].filter(Boolean).join(' · ')}`), width));
135
+ output.push(...detailLines(selected.goal || (selected.relation === 'main' ? 'Main agent' : 'Subagent')));
136
+ output.push(padded(theme.fg('muted', `Status: ${[selected.status.replaceAll('_', ' '), subagentTasks(selected.todo), terminalSubagentDuration(selected)].filter(Boolean).join(' · ')}`), width));
134
137
  output.push(padded(theme.fg('accent', 'Current activity'), width));
135
138
  output.push(...detailLines(selected.activity));
136
139
  }
@@ -1,5 +1,7 @@
1
1
  import type { ToolSpec } from "./types.js";
2
2
  export declare const SESSION_NAME = "session_name";
3
+ /** The trigger the prompt and the tool description share, so naming guidance cannot drift. */
4
+ export declare const SESSION_GUIDANCE: string;
3
5
  export interface NamedSession {
4
6
  readonly sessionName?: string;
5
7
  setSessionName(name: string): void;
@@ -2,13 +2,15 @@
2
2
  // the current native session; it never receives a target session id from the model.
3
3
  import { invalid } from "../core/errors.js";
4
4
  export const SESSION_NAME = 'session_name';
5
+ /** The trigger the prompt and the tool description share, so naming guidance cannot drift. */
6
+ export const SESSION_GUIDANCE = 'If the request needs tools or changes state — not a greeting, thanks, or a short conversational reply — call session_name once before your first other tool call, naming the work from the request. ' +
7
+ 'Use one short imperative sentence of at most six words. Revise the name only when the overall objective changes; keep the current name when it does not.';
5
8
  export function sessionNameTool(current) {
6
9
  return {
7
10
  name: SESSION_NAME,
8
11
  label: 'Name the session',
9
- description: 'Set or revise this session’s name. Name meaningful work early, then revise the name when the overall objective changes; greetings alone need no name. ' +
10
- 'Use one short imperative sentence describing the objective, at most six words, such as "Add web browser integrations". ' +
11
- 'You may revise a manually assigned name. Keep a useful current name when the objective is unchanged. This only names your own session.',
12
+ description: `Set or revise this session’s name. ${SESSION_GUIDANCE} ` +
13
+ 'For example, "Add web browser integrations". You may revise a manually assigned name. This only names your own session.',
12
14
  parameters: Type => Type.Object({ name: Type.String({ description: 'One short sentence describing the objective, at most six words' }) }),
13
15
  planModeSafe: () => true,
14
16
  execute: async (input) => {
@@ -27,20 +27,22 @@ export function subagentTools(service, parentRunId) {
27
27
  return [{
28
28
  name: SPAWN_SUBAGENT,
29
29
  label: 'Start a subagent',
30
- description: 'Start a fresh child run for independent work. Delegate substantial, bounded tasks that benefit from a separate context, not simple lookups, tiny edits or tightly coupled steps. Prefer a named specialist profile; omit agent only when no profile fits. Plan mode permits only eligible read-only research profiles. Give a short title and a self-contained brief: the child has no conversation history and no more capabilities than you. Returns its run ID immediately by default so you can continue other useful work. Results arrive automatically before you finish. Use wait_subagents when you need results before proceeding, or wait: true for an inline result. Avoid overlapping write assignments. Stopping you stops your children.',
30
+ description: 'Start a fresh child run for independent work. Delegate substantial, bounded tasks that benefit from a separate context, not simple lookups, tiny edits or tightly coupled steps. Prefer a named specialist profile; omit agent only when no profile fits. Plan mode permits only eligible read-only research profiles. Give a short title and a self-contained brief: the child has no conversation history and no more capabilities than you. The optional tools field only narrows the profile and parent intersection; in plan mode, omit it unless you need a stricter subset. Returns its run ID immediately by default so you can continue other useful work. Results arrive automatically before you finish. Use wait_subagents when you need results before proceeding, or wait: true for an inline result. Avoid overlapping write assignments. Stopping you stops your children. To resume a child that stopped before finishing (interrupted or failed), pass continue with its run ID and no title, agent or tools: it reopens that child\'s own conversation with the same profile and tools, and brief becomes an optional extra instruction.',
31
31
  planModeSafe: input => service.planSpawnRefusal(parentRunId, input) === null,
32
32
  parameters: (Type) => Type.Object({
33
- title: Type.String({ description: `A short imperative title for the roster, at most ${MAX_SUBAGENT_TITLE_WORDS} words, such as "Map the auth flow"` }),
34
- brief: Type.String({ description: 'A self-contained task: goal, relevant context or paths, non-goals, expected result, and acceptance or verification. The child cannot see this conversation.' }),
33
+ title: Type.Optional(Type.String({ description: `Required for a new child: a short imperative title for the roster, at most ${MAX_SUBAGENT_TITLE_WORDS} words, such as "Map the auth flow"` })),
34
+ brief: Type.Optional(Type.String({ description: 'Required for a new child: a self-contained task: goal, relevant context or paths, non-goals, expected result, and acceptance or verification. The child cannot see this conversation. With continue, an optional extra instruction.' })),
35
+ continue: Type.Optional(Type.String({ description: 'The run ID of one of your subagents that stopped before finishing, to continue it from where it stopped instead of starting a fresh child' })),
35
36
  agent: Type.Optional(Type.String({ description: 'The name of a specialist profile from your Subagents list; omit only when no listed profile fits (omitting starts an unprofiled copy)' })),
36
- tools: Type.Optional(Type.Array(Type.String())),
37
+ tools: Type.Optional(Type.Array(Type.String({ description: 'Only request a stricter subset of the profile and parent tools. In plan mode, omit this field to inherit the safe available research intersection.' }))),
37
38
  wait: Type.Optional(Type.Boolean({ description: 'Wait for the child and return its result; default false' }))
38
39
  }),
39
40
  execute: execute(async (input) => {
40
- const title = subagentTitle(input.title);
41
- if (!title)
41
+ const resume = input.continue === undefined ? undefined : input.continue;
42
+ const title = resume === undefined ? subagentTitle(input.title) : undefined;
43
+ if (resume === undefined && !title)
42
44
  throw invalid(`Give the subagent a title of at most ${MAX_SUBAGENT_TITLE_WORDS} words, with no control characters.`);
43
- const child = await service.spawn({ parentRunId, brief: input.brief, title, agent: input.agent, tools: input.tools, wait: input.wait });
45
+ const child = await service.spawn({ parentRunId, brief: input.brief, title, agent: input.agent, tools: input.tools, wait: input.wait, ...(resume === undefined ? {} : { continue: resume }) });
44
46
  if (input.wait === true)
45
47
  return reported(parentRunId, service.results({ parentRunId, runIds: [child.runId] }), [child.runId]);
46
48
  return { text: JSON.stringify(child), details: { [SUBAGENT_RECEIPT]: { version: 1, ownerRunId: parentRunId, spawned: [child.runId] } } };
@@ -0,0 +1,15 @@
1
+ import { type TodoItem, type RunEvent } from "../sessions/contracts.js";
2
+ import type { ActiveRun } from "../sessions/registry.js";
3
+ import type { ToolSpec } from "./types.js";
4
+ export declare const TODO_WRITE = "todo_write";
5
+ /** The trigger the prompt and the tool description share, so progress guidance cannot drift. */
6
+ export declare const TODO_GUIDANCE: string;
7
+ /** The todo list as the model reads it back. */
8
+ export declare function describeTodo(items: readonly TodoItem[]): string;
9
+ /** The latest todo list a run wrote, or an empty list. Reads the pre-rename event too. */
10
+ export declare function todoOf(events: readonly RunEvent[]): TodoItem[];
11
+ export declare function todoTool(run: Pick<ActiveRun, 'emit'>): ToolSpec;
12
+ /** @deprecated Use `TODO_WRITE`; kept for one release so embedders can migrate. */
13
+ export declare const CHECKLIST_WRITE = "todo_write";
14
+ /** @deprecated Use `todoOf`; kept for one release so embedders can migrate. */
15
+ export declare const checklistOf: typeof todoOf;
@@ -0,0 +1,64 @@
1
+ // The run's own todo list: the model writes the whole list each time, and the run records it as
2
+ // a `todo.updated` event. It only changes run state, so it stays callable in plan mode.
3
+ import { todoItemSchema, MAX_TODO_ITEMS } from "../sessions/contracts.js";
4
+ export const TODO_WRITE = 'todo_write';
5
+ /** The trigger the prompt and the tool description share, so progress guidance cannot drift. */
6
+ export const TODO_GUIDANCE = 'If the work has several distinct steps or will take more than one tool call, call todo_write before your first other tool call with the steps you know so far, and keep the list current as steps start and finish. ' +
7
+ 'Before your final reply, write one final update: mark every fully completed step completed and leave unfinished or blocked steps accurate. Never mark unfinished work completed. ' +
8
+ 'The person follows your work from it, and a subagent’s todo list shows as its progress in the person’s list of subagents.';
9
+ /** The event a run writes; `checklist.updated` is the pre-rename name, still read for old runs. */
10
+ const TODO_EVENTS = new Set(['todo.updated', 'checklist.updated']);
11
+ const MARK = { pending: '[ ]', in_progress: '[~]', completed: '[x]' };
12
+ /** The todo list as the model reads it back. */
13
+ export function describeTodo(items) {
14
+ if (!items.length)
15
+ return 'The todo list is empty.';
16
+ return items.map((item) => `${MARK[item.status]} ${item.text}`).join('\n');
17
+ }
18
+ /** The latest todo list a run wrote, or an empty list. Reads the pre-rename event too. */
19
+ export function todoOf(events) {
20
+ const last = events.filter((event) => TODO_EVENTS.has(event.type)).at(-1);
21
+ const items = last?.payload?.items;
22
+ return Array.isArray(items) ? items.flatMap((item) => {
23
+ const parsed = todoItemSchema.safeParse(item);
24
+ return parsed.success ? [parsed.data] : [];
25
+ }) : [];
26
+ }
27
+ export function todoTool(run) {
28
+ return {
29
+ name: TODO_WRITE,
30
+ label: 'Write the todo list',
31
+ description: `Keep a todo list of the steps of the current work. The user watches this list to follow your progress. ${TODO_GUIDANCE} ` +
32
+ 'Send the whole list every time; it replaces the previous one. Do not use it for a single, simple step or for conversation. ' +
33
+ 'Statuses: pending, in_progress, completed. Keep exactly one step in_progress while you work, mark a step completed as soon as it is done (not in batches), and add steps you discover. ' +
34
+ 'Only mark a step completed when it is fully done; if something blocks it, keep it in_progress and add a step for the blocker.',
35
+ parameters: (Type) => Type.Object({
36
+ items: Type.Array(Type.Object({
37
+ text: Type.String({ description: 'One step, as a short imperative line' }),
38
+ status: Type.Union([Type.Literal('pending'), Type.Literal('in_progress'), Type.Literal('completed')])
39
+ }), { maxItems: MAX_TODO_ITEMS })
40
+ }),
41
+ readOnly: true,
42
+ planModeSafe: () => true,
43
+ execute: async (input) => {
44
+ if (!Array.isArray(input.items))
45
+ return 'Send items: the whole todo list as an array.';
46
+ if (input.items.length > MAX_TODO_ITEMS)
47
+ return `Keep the todo list to ${MAX_TODO_ITEMS} steps or fewer.`;
48
+ const items = [];
49
+ for (const raw of input.items) {
50
+ const entry = raw;
51
+ const parsed = todoItemSchema.safeParse({ text: typeof entry?.text === 'string' ? entry.text.trim() : entry?.text, status: entry?.status });
52
+ if (!parsed.success)
53
+ return 'Every step needs text of 1 to 500 characters and a status of pending, in_progress or completed.';
54
+ items.push(parsed.data);
55
+ }
56
+ run.emit('todo.updated', { items });
57
+ return describeTodo(items);
58
+ }
59
+ };
60
+ }
61
+ /** @deprecated Use `TODO_WRITE`; kept for one release so embedders can migrate. */
62
+ export const CHECKLIST_WRITE = TODO_WRITE;
63
+ /** @deprecated Use `todoOf`; kept for one release so embedders can migrate. */
64
+ export const checklistOf = todoOf;