@xingular/harness 1.5.0-unstable → 1.6.0-unstable

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/browser-extension/README.md +32 -0
  3. package/browser-extension/background.js +612 -0
  4. package/browser-extension/manifest.json +16 -0
  5. package/browser-extension/overlay.js +97 -0
  6. package/browser-extension/popup.css +19 -0
  7. package/browser-extension/popup.html +30 -0
  8. package/browser-extension/popup.js +101 -0
  9. package/browser-extension/site-access.js +63 -0
  10. package/dist/cli.js +1489 -680
  11. package/dist/sdk/browser/config.d.ts +3 -0
  12. package/dist/sdk/browser/config.js +2 -1
  13. package/dist/sdk/browser/extension/bridge.d.ts +55 -0
  14. package/dist/sdk/browser/extension/bridge.js +336 -0
  15. package/dist/sdk/browser/extension/session.d.ts +50 -0
  16. package/dist/sdk/browser/extension/session.js +321 -0
  17. package/dist/sdk/browser/index.d.ts +7 -2
  18. package/dist/sdk/browser/index.js +58 -25
  19. package/dist/sdk/browser/serverMethods.js +4 -1
  20. package/dist/sdk/browser/tools.d.ts +99 -2
  21. package/dist/sdk/browser/tools.js +6 -1
  22. package/dist/sdk/commands/registry.d.ts +5 -0
  23. package/dist/sdk/commands/registry.js +1 -0
  24. package/dist/sdk/harness.d.ts +4 -0
  25. package/dist/sdk/harness.js +1 -1
  26. package/dist/sdk/pi/retryPolicy.d.ts +19 -0
  27. package/dist/sdk/pi/retryPolicy.js +11 -0
  28. package/dist/sdk/prompt/compile.d.ts +0 -2
  29. package/dist/sdk/prompt/compile.js +2 -6
  30. package/dist/sdk/prompt/reporting.d.ts +1 -1
  31. package/dist/sdk/prompt/reporting.js +30 -13
  32. package/dist/sdk/prompt/standard.d.ts +1 -1
  33. package/dist/sdk/prompt/standard.js +1 -1
  34. package/dist/sdk/remote/webClient.js +6 -2
  35. package/dist/sdk/sessions/contracts.d.ts +2 -0
  36. package/dist/sdk/sessions/contracts.js +5 -1
  37. package/dist/sdk/sessions/planMode.d.ts +16 -6
  38. package/dist/sdk/sessions/planMode.js +59 -23
  39. package/dist/sdk/sessions/runner.js +14 -5
  40. package/dist/sdk/sessions/workSlice.d.ts +15 -1
  41. package/dist/sdk/sessions/workSlice.js +17 -9
  42. package/dist/sdk/terminal/controller.d.ts +6 -0
  43. package/dist/sdk/terminal/controller.js +44 -5
  44. package/dist/sdk/terminal/opentui/model.js +3 -1
  45. package/dist/sdk/terminal/piExtension.js +7 -2
  46. package/dist/sdk/terminal/stallWatch.d.ts +18 -0
  47. package/dist/sdk/terminal/stallWatch.js +20 -0
  48. package/dist/sdk/terminal/subagentMonitor.d.ts +2 -0
  49. package/dist/sdk/terminal/subagentMonitor.js +4 -1
  50. package/dist/sdk/terminal/terminals.js +35 -4
  51. package/dist/sdk/terminal/toolName.d.ts +6 -0
  52. package/dist/sdk/terminal/toolName.js +12 -0
  53. package/dist/sdk/terminal/tui/commandPages.d.ts +1 -1
  54. package/dist/sdk/terminal/tui/commandPages.js +3 -0
  55. package/dist/sdk/terminal/tui/commands.d.ts +14 -0
  56. package/dist/sdk/terminal/tui/commands.js +4 -1
  57. package/dist/sdk/terminal/tui/pages/followUpQueuePage.d.ts +34 -0
  58. package/dist/sdk/terminal/tui/pages/followUpQueuePage.js +130 -0
  59. package/dist/sdk/terminal/tui/pages/trajectoryPage.js +37 -7
  60. package/dist/sdk/terminal/tui/runtime.js +2 -1
  61. package/dist/sdk/terminal/tui/settingsModel.js +88 -7
  62. package/dist/sdk/tools/askUser.js +2 -2
  63. package/dist/sdk/world/world.d.ts +12 -2
  64. package/dist/sdk/world/world.js +42 -4
  65. package/dist/terminal-opentui.js +1456 -756
  66. package/dist/terminal-pi.js +62 -31
  67. package/docs/README.md +1 -0
  68. package/docs/architecture/02-sessions-and-runs.md +43 -25
  69. package/docs/architecture/04-prompt.md +23 -22
  70. package/docs/architecture/10-server-protocol.md +23 -3
  71. package/docs/architecture/11-terminal.md +41 -29
  72. package/docs/architecture/12-storage.md +2 -1
  73. package/docs/architecture/13-world-tools.md +35 -22
  74. package/docs/architecture/14-surfaces-and-parity.md +24 -20
  75. package/docs/architecture/README.md +4 -1
  76. package/docs/architecture/proposals/PROP-0026-connected-sketch-display.md +3 -1
  77. package/docs/decisions/ADR-0027-connected-sketch-display.md +4 -2
  78. package/docs/decisions/ADR-0076-codex-final-answer-verbatim.md +6 -1
  79. package/docs/decisions/ADR-0077-plan-runs-may-conclude-no-plan-needed.md +120 -0
  80. package/docs/decisions/ADR-0078-child-aware-stall-watch.md +46 -0
  81. package/docs/decisions/ADR-0079-phase-led-reporting-guidance.md +72 -0
  82. package/docs/decisions/ADR-0080-fixed-width-question-sketch-canvas.md +50 -0
  83. package/docs/decisions/ADR-0081-no-work-slice-budget-by-default.md +36 -0
  84. package/docs/decisions/ADR-0082-connected-brave-browser.md +72 -0
  85. package/docs/parity.md +7 -5
  86. package/docs/releases/1.5.0-unstable.md +12 -3
  87. package/docs/releases/1.5.1-unstable.md +24 -0
  88. package/docs/releases/1.6.0-unstable.md +59 -0
  89. package/docs/study/00-index.md +17 -1
  90. package/docs/study/STUDY-0007-claude-code-prompt.md +162 -0
  91. package/docs/study/STUDY-0008-browser-control.md +281 -0
  92. package/docs/study/STUDY-0009-claude-code-reporting.md +205 -0
  93. package/docs/study/sources/claude-code.md +51 -39
  94. package/docs/terminal-tui.md +46 -18
  95. package/package.json +2 -1
@@ -0,0 +1,130 @@
1
+ import { matchesKey } from '@earendil-works/pi-tui';
2
+ import { isPrimaryClick } from "../mouse.js";
3
+ const TABS = [{ id: 'queue', title: 'Follow-up queue' }];
4
+ export class FollowUpQueuePage {
5
+ options;
6
+ presentation = 'centered';
7
+ selected = 0;
8
+ disposed = false;
9
+ closed = false;
10
+ constructor(options) {
11
+ this.options = options;
12
+ }
13
+ get focused() { return true; }
14
+ set focused(_value) { }
15
+ tabs() { return TABS; }
16
+ activeTab() { return 'queue'; }
17
+ hint() { return '↑↓ select · Enter actions · e edit · s send next · d remove · r return · Esc close'; }
18
+ activate() { this.options.changed(); }
19
+ dispose() {
20
+ if (this.disposed)
21
+ return;
22
+ this.disposed = true;
23
+ this.closeEditor();
24
+ }
25
+ closeEditor() {
26
+ if (this.closed)
27
+ return;
28
+ this.closed = true;
29
+ this.options.editor.close();
30
+ }
31
+ entries() { return this.options.editor.entries(); }
32
+ render(_width, _rows) {
33
+ const entries = this.entries();
34
+ const steering = entries.filter(entry => entry.kind === 'steering');
35
+ const followUp = entries.filter(entry => entry.kind === 'followUp');
36
+ const result = ['Follow-up and steering are shown in FIFO order.', ''];
37
+ for (const [title, group] of [['Steer', steering], ['Follow-up', followUp]]) {
38
+ result.push(title);
39
+ if (!group.length)
40
+ result.push(' (empty)');
41
+ for (const entry of group) {
42
+ const index = entries.indexOf(entry);
43
+ const marker = index === this.selected ? '›' : ' ';
44
+ const kind = entry.kind === 'steering' ? 'Steer' : 'Follow-up';
45
+ result.push(`${marker} ${kind}${entry.hasImages ? ' · images' : ''} ${entry.text.replace(/\s+/g, ' ')}`);
46
+ }
47
+ result.push('');
48
+ }
49
+ if (!entries.length)
50
+ result.push('Queue is empty. Plan, Bash, and deferred inputs are managed elsewhere.');
51
+ else
52
+ result.push('e edit in place · s send next · d remove · r return to composer');
53
+ return result;
54
+ }
55
+ async act(action) {
56
+ if (action === 'return') {
57
+ this.options.close();
58
+ return;
59
+ }
60
+ const entries = this.entries();
61
+ if (!entries.length)
62
+ return;
63
+ const entry = entries[Math.min(this.selected, entries.length - 1)];
64
+ if (action === 'edit')
65
+ await this.options.editor.edit(entry.id);
66
+ if (action === 'send')
67
+ await this.options.editor.sendNext();
68
+ if (action === 'remove')
69
+ this.options.editor.remove(entry.id);
70
+ this.selected = Math.min(this.selected, Math.max(0, this.entries().length - 1));
71
+ this.options.changed();
72
+ }
73
+ handleInput(data) {
74
+ const count = this.entries().length;
75
+ if (matchesKey(data, 'escape'))
76
+ return false;
77
+ if (matchesKey(data, 'up')) {
78
+ this.selected = Math.max(0, this.selected - 1);
79
+ this.options.changed();
80
+ return true;
81
+ }
82
+ if (matchesKey(data, 'down')) {
83
+ this.selected = Math.min(Math.max(0, count - 1), this.selected + 1);
84
+ this.options.changed();
85
+ return true;
86
+ }
87
+ const key = data.toLowerCase();
88
+ if (key === 'e') {
89
+ void this.act('edit');
90
+ return true;
91
+ }
92
+ if (key === 's') {
93
+ void this.act('send');
94
+ return true;
95
+ }
96
+ if (key === 'd' || key === 'delete' || key === '\\x7f') {
97
+ void this.act('remove');
98
+ return true;
99
+ }
100
+ if (key === 'r') {
101
+ void this.act('return');
102
+ return true;
103
+ }
104
+ if (matchesKey(data, 'enter')) {
105
+ void this.act('edit');
106
+ return true;
107
+ }
108
+ return false;
109
+ }
110
+ handleMouse(event) {
111
+ if (!isPrimaryClick(event))
112
+ return;
113
+ const entries = this.entries();
114
+ const rendered = this.render(120, 100);
115
+ const line = Math.floor(event.y);
116
+ const displayed = [...entries.filter(entry => entry.kind === 'steering'), ...entries.filter(entry => entry.kind === 'followUp')];
117
+ let index = 0;
118
+ for (let row = 0; row < rendered.length; row++) {
119
+ if (rendered[row].startsWith('› ') || rendered[row].startsWith(' Steer') || rendered[row].startsWith(' Follow-up')) {
120
+ if (row === line) {
121
+ this.selected = entries.indexOf(displayed[index]);
122
+ this.options.changed();
123
+ return { handled: true, render: true };
124
+ }
125
+ index++;
126
+ }
127
+ }
128
+ return undefined;
129
+ }
130
+ }
@@ -1,6 +1,7 @@
1
1
  import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
2
  import { FilterList } from "../list.js";
3
3
  import { stripMouseReports } from "../mouseReport.js";
4
+ import { formatWorkingDuration } from "../workingStatus.js";
4
5
  const ACTIVE = new Set(['queued', 'running', 'waiting_for_approval', 'waiting_for_input']);
5
6
  const payloadOf = (event) => event.payload && typeof event.payload === 'object' ? event.payload : {};
6
7
  const stringOf = (value) => typeof value === 'string' ? value : undefined;
@@ -44,10 +45,18 @@ function eventDescription(event) {
44
45
  return 'Recorded prompt and tool definitions';
45
46
  case 'failed':
46
47
  return stringOf(payload.message) ?? stringOf(payload.code) ?? 'run failed';
48
+ case 'heartbeat':
49
+ return heartbeatDescription(payload);
47
50
  default:
48
51
  return stringOf(payload.text) ?? stringOf(payload.name) ?? '';
49
52
  }
50
53
  }
54
+ /** A beat proves the process lives; what it was inside tells a wait from a wedge. */
55
+ function heartbeatDescription(payload) {
56
+ const tools = Array.isArray(payload.activeTools) ? payload.activeTools.filter((name) => typeof name === 'string') : [];
57
+ const oldest = numberOf(payload.oldestToolMs);
58
+ return tools.length ? `${tools.join(', ')}${oldest === undefined ? '' : ` ${formatWorkingDuration(oldest)}`}` : 'no tool running';
59
+ }
51
60
  function usageLine(events) {
52
61
  const event = [...events].reverse().find(item => item.type === 'usage.updated');
53
62
  if (!event)
@@ -223,13 +232,34 @@ export class TrajectoryPage {
223
232
  }));
224
233
  }
225
234
  eventRows() {
226
- return this.events.map(event => ({
227
- id: String(event.sequence),
228
- label: `${timeOf(event.timestamp)} ${event.type}`,
229
- detail: safe(eventDescription(event)),
230
- searchText: `${event.timestamp} ${event.type} ${safe(eventDescription(event))} ${safe(JSON.stringify(payloadOf(event)))}`,
231
- data: event
232
- }));
235
+ // A long wait is dozens of identical beats; they fold into one row that shows how long it went on.
236
+ const rows = [];
237
+ let beats = [];
238
+ const flush = () => {
239
+ const last = beats.at(-1);
240
+ if (!last)
241
+ return;
242
+ const count = beats.length > 1 ? `×${beats.length} · ` : '';
243
+ const detail = count + safe(eventDescription(last));
244
+ rows.push({ id: String(last.sequence), label: `${timeOf(last.timestamp)} ${last.type}`, detail, searchText: `${last.timestamp} ${last.type} ${detail}`, data: last });
245
+ beats = [];
246
+ };
247
+ for (const event of this.events) {
248
+ if (event.type === 'heartbeat') {
249
+ beats.push(event);
250
+ continue;
251
+ }
252
+ flush();
253
+ rows.push({
254
+ id: String(event.sequence),
255
+ label: `${timeOf(event.timestamp)} ${event.type}`,
256
+ detail: safe(eventDescription(event)),
257
+ searchText: `${event.timestamp} ${event.type} ${safe(eventDescription(event))} ${safe(JSON.stringify(payloadOf(event)))}`,
258
+ data: event
259
+ });
260
+ }
261
+ flush();
262
+ return rows;
233
263
  }
234
264
  eventJson(event) {
235
265
  return JSON.stringify({ payload: event.payload, type: event.type, sequence: event.sequence, timestamp: event.timestamp }, null, 2);
@@ -2,6 +2,7 @@ import { createAgentSession, createAgentSessionRuntime, DefaultResourceLoader, M
2
2
  import { closeSync, existsSync, openSync, readSync, realpathSync } from 'node:fs';
3
3
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import terminalExtension from "../piExtension.js";
5
+ import { withTransientRetry } from "../../pi/retryPolicy.js";
5
6
  import { GoalController } from "../../sessions/goal.js";
6
7
  import { terminalCredentialStore } from "../credentialStore.js";
7
8
  import { normalModeTools, normalToolsForBranch, planModeTools, PLAN_TOOLS_ENTRY, QUESTION_TOOL_NAMES } from "./planMode.js";
@@ -214,7 +215,7 @@ export async function createTerminalRuntime(args, host) {
214
215
  const factory = async ({ cwd: targetCwd, sessionManager, sessionStartEvent }) => {
215
216
  if (realpathSync(targetCwd) !== cwd)
216
217
  throw new Error('A native session cannot change this terminal working folder.');
217
- const settingsManager = SettingsManager.create(cwd, agentDir);
218
+ const settingsManager = withTransientRetry(SettingsManager.create(cwd, agentDir));
218
219
  const resourceLoader = new DefaultResourceLoader({
219
220
  cwd, agentDir,
220
221
  // The real settings still control models and tools; empty discovery settings prevent a
@@ -1,7 +1,11 @@
1
+ const BROWSER_BACKEND_ID = 'browser.backend';
2
+ const BROWSER_CONNECTION_ID = 'browser.connection';
1
3
  const PRIVATE_ACCESS_ID = 'browser.allowPrivate';
2
- const GROUP_ORDER = ['Theme', 'Display', 'Images', 'Thinking', 'Recovery', 'Editor', 'Queues', 'Commands', 'Terminal', 'Keyboard', 'Compaction', 'Network'];
4
+ const GROUP_ORDER = ['Theme', 'Display', 'Images', 'Thinking', 'Recovery', 'Editor', 'Queues', 'Commands', 'Terminal', 'Keyboard', 'Compaction', 'Connection', 'Network'];
3
5
  export function createSettingsModel(host) {
4
6
  let browserSettings = null;
7
+ let extensionSettings = null;
8
+ let pairingCode = null;
5
9
  const booleans = (session) => {
6
10
  const settings = session.settingsManager;
7
11
  return [
@@ -42,24 +46,101 @@ export function createSettingsModel(host) {
42
46
  const split = option.indexOf(':');
43
47
  return { id: String(index), tab: locations[index][0], group: locations[index][1], label: split < 0 ? option : option.slice(0, split), value: split < 0 ? '' : option.slice(split + 1).trim(), description: descriptions[index] };
44
48
  });
45
- if (browserSettings)
49
+ if (browserSettings) {
50
+ result.push({
51
+ id: BROWSER_BACKEND_ID,
52
+ tab: 'Browser',
53
+ group: 'Connection',
54
+ label: 'Browser backend',
55
+ value: browserSettings.backend === 'brave' ? 'Brave profile' : 'Isolated browser',
56
+ description: 'Choose the browser the agent controls. Brave uses your existing signed-in profile and only sites you allow in the extension; Incognito is excluded. The extension does not request history or cookie permissions or export cookies. A disconnect never switches to the other backend.'
57
+ });
58
+ result.push({
59
+ id: BROWSER_CONNECTION_ID,
60
+ tab: 'Browser',
61
+ group: 'Connection',
62
+ label: 'Brave extension',
63
+ value: extensionSettings?.state ?? 'Checking',
64
+ description: pairingCode
65
+ ? `Enter pairing code ${pairingCode} in the extension popup. It expires in five minutes.`
66
+ : extensionSettings?.state === 'connected'
67
+ ? 'Connected to Brave. Use the in-page badge to pause or stop control.'
68
+ : extensionSettings?.paired
69
+ ? 'The extension is paired and waiting. Open its toolbar popup to reconnect, or select this row to reset pairing.'
70
+ : 'Select this row to start the loopback bridge and get a one-time pairing code.'
71
+ });
46
72
  result.push({
47
73
  id: PRIVATE_ACCESS_ID,
48
74
  tab: 'Browser',
49
75
  group: 'Network',
50
76
  label: 'Private address access',
51
77
  value: browserSettings.allowPrivate ? 'On' : 'Off',
52
- description: 'This device-wide setting affects agent browsing and annotation capture. When on, pages can reach localhost and private networks.'
78
+ description: 'This device-wide setting lets agent browsing and annotation capture reach localhost and private networks.'
53
79
  });
80
+ }
54
81
  return result.sort((a, b) => GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group));
55
82
  }
56
83
  async function load() {
57
- const config = await host.control('terminal.browser.config');
58
- if (!config || typeof config.allowPrivate !== 'boolean')
84
+ const [config, extension] = await Promise.all([
85
+ host.control('terminal.browser.config'),
86
+ host.control('terminal.browser.extension.status')
87
+ ]);
88
+ if (!config || typeof config.allowPrivate !== 'boolean' || !['isolated', 'brave'].includes(config.backend))
59
89
  throw new Error('The Harness returned invalid browser settings.');
60
- browserSettings = { allowPrivate: config.allowPrivate };
90
+ if (!extension || typeof extension.connected !== 'boolean' || typeof extension.paired !== 'boolean' || !['stopped', 'pairing', 'waiting', 'connected', 'paused'].includes(extension.state))
91
+ throw new Error('The Harness returned invalid Brave extension status.');
92
+ browserSettings = { backend: config.backend, allowPrivate: config.allowPrivate };
93
+ extensionSettings = extension;
94
+ if (extension.paired)
95
+ pairingCode = null;
61
96
  }
62
97
  async function apply(id) {
98
+ if (id === BROWSER_BACKEND_ID) {
99
+ if (!browserSettings)
100
+ return;
101
+ const selected = await host.ui.select('Browser backend', ['Isolated browser', 'Brave profile']);
102
+ const backend = selected === 'Brave profile' ? 'brave' : selected === 'Isolated browser' ? 'isolated' : null;
103
+ if (!backend || backend === browserSettings.backend)
104
+ return;
105
+ if (backend === 'brave' && !await host.ui.confirm('Use your Brave profile?', 'The agent can see and control normal tabs on sites you explicitly allow in the extension. Your signed-in pages may contain private information. The extension requires Brave’s debugger permission but does not request history or cookie permissions or export cookies. Incognito is never available, and a disconnect will not switch browsers automatically.'))
106
+ return;
107
+ const saved = await host.control('terminal.browser.configure', { backend });
108
+ if (!saved || saved.backend !== backend)
109
+ throw new Error('The Harness did not save the browser backend.');
110
+ browserSettings = { ...browserSettings, backend: saved.backend };
111
+ extensionSettings = await host.control('terminal.browser.extension.status');
112
+ host.refresh();
113
+ return;
114
+ }
115
+ if (id === BROWSER_CONNECTION_ID) {
116
+ if (!extensionSettings)
117
+ return;
118
+ let start = false;
119
+ if (extensionSettings.paired) {
120
+ const action = await host.ui.select('Brave extension', ['Keep waiting', 'Stop and pair again']);
121
+ if (action === 'Stop and pair again') {
122
+ await host.control('terminal.browser.extension.stop');
123
+ extensionSettings = await host.control('terminal.browser.extension.status');
124
+ start = true;
125
+ }
126
+ else if (action === 'Keep waiting') {
127
+ const result = await host.control('terminal.browser.extension.start');
128
+ extensionSettings = result.status;
129
+ pairingCode = result.pairingCode ?? null;
130
+ }
131
+ }
132
+ else
133
+ start = true;
134
+ if (start) {
135
+ const result = await host.control('terminal.browser.extension.start');
136
+ extensionSettings = result.status;
137
+ pairingCode = result.pairingCode ?? null;
138
+ if (pairingCode)
139
+ host.ui.notify(`Pair the Brave extension with code ${pairingCode}.`, 'info');
140
+ }
141
+ host.refresh();
142
+ return;
143
+ }
63
144
  if (id === PRIVATE_ACCESS_ID) {
64
145
  if (!browserSettings)
65
146
  return;
@@ -69,7 +150,7 @@ export function createSettingsModel(host) {
69
150
  const saved = await host.control('terminal.browser.configure', { allowPrivate });
70
151
  if (!saved || typeof saved.allowPrivate !== 'boolean')
71
152
  throw new Error('The Harness returned invalid browser settings.');
72
- browserSettings = { allowPrivate: saved.allowPrivate };
153
+ browserSettings = { ...browserSettings, allowPrivate: saved.allowPrivate };
73
154
  host.refresh();
74
155
  return;
75
156
  }
@@ -19,7 +19,7 @@ export function askUserTool(run) {
19
19
  name: ASK_USER,
20
20
  label: 'Ask the user',
21
21
  description: 'Ask the user one to five related questions in one request and wait until they answer the batch. Give each question a concise 1–2 word label and aim to keep each question to 25 words or fewer. ' +
22
- 'Every question must offer 1–6 short choices, each with a helpful description of 25 words or fewer, and at least one choice must be marked recommended: true. Recommendations are visual only: they never preselect an answer, and submitted choices keep their original labels. Mark at most one recommended choice for a single-choice question, or several when multiSelect is true. Every question also includes a free-text “Something else” answer. Set multiSelect when several can apply. When a UI diagram helps compare choices, set uiSketch: true and give every choice a detailed, bounded ASCII wireframe. Use ASCII tables and panel borders to show the affected element with relevant neighboring UI, readable labels and spacing. Keep each panel’s outer `+` corners in fixed columns, include side edges at those same columns on every content row, and run horizontal borders corner to corner; if a title sits in a top border, leave at least two dashes on both sides. Keep framing consistent across alternatives and concentrate detail on what changes. Do not substitute a symbolic logo or one-line illustration. The preview supplies its outer border; draw internal borders only for actual UI panels. Use it when you need a decision the request and the files do not answer, not for small talk and never to ask permission for work inside the request. If no answer arrives, continue with your best judgment instead of asking again or treating the turn as blocked. Never use it to ask for a login, a secret or a credential: give the exact command in the reply instead.',
22
+ 'Every question must offer 1–6 short choices, each with a helpful description of 25 words or fewer, and at least one choice must be marked recommended: true. Recommendations are visual only: they never preselect an answer, and submitted choices keep their original labels. Mark at most one recommended choice for a single-choice question, or several when multiSelect is true. Every question also includes a free-text “Something else” answer. Set multiSelect when several can apply. When a UI diagram helps compare choices, set uiSketch: true and give every choice a detailed ASCII wireframe on a fixed 80-column canvas. Use the full canvas to show the affected element and relevant neighboring UI with readable labels, controls and spacing; keep rows aligned but omit trailing spaces where convenient. Keep each panel’s outer `+` corners in fixed columns, include side edges at those same columns on every content row, and run horizontal borders corner to corner; if a title sits in a top border, leave at least two dashes on both sides. Keep framing consistent across alternatives and concentrate detail on what changes. Do not substitute a symbolic logo or one-line illustration. The preview supplies its outer border; draw internal borders only for actual UI panels. Use it when you need a decision the request and the files do not answer, not for small talk and never to ask permission for work inside the request. If no answer arrives, continue with your best judgment instead of asking again or treating the turn as blocked. Never use it to ask for a login, a secret or a credential: give the exact command in the reply instead.',
23
23
  parameters: (Type) => Type.Object({
24
24
  questions: Type.Array(Type.Object({
25
25
  summary: Type.String({ description: 'A concise label of one or two words' }),
@@ -29,7 +29,7 @@ export function askUserTool(run) {
29
29
  label: Type.String({ description: 'A short choice; do not add a recommendation suffix to the label' }),
30
30
  description: Type.String({ description: 'Required. Briefly explain this choice in 25 words or fewer.' }),
31
31
  recommended: Type.Optional(Type.Boolean({ description: 'Whether to show this choice as a visual recommendation; at least one choice per question must be recommended, it is not preselected, and answers use the original label' })),
32
- sketch: Type.Optional(Type.String({ minLength: 1, maxLength: QUESTION_SKETCH_MAX_LENGTH, pattern: QUESTION_SKETCH_PATTERN, description: 'Literal ASCII UI wireframe: 1–24 LF-separated lines, at most 80 printable ASCII characters per line. Include the affected element and neighboring UI using structured table/panel borders with fixed, matching outer-edge columns on every row. Required for every choice when uiSketch is true.' }))
32
+ sketch: Type.Optional(Type.String({ minLength: 1, maxLength: QUESTION_SKETCH_MAX_LENGTH, pattern: QUESTION_SKETCH_PATTERN, description: 'Literal ASCII UI wireframe on a fixed 80-column canvas: 1–24 LF-separated lines, at most 80 printable ASCII characters per line; shorter rows are padded in the terminal preview. Include the affected element and neighboring UI using structured table/panel borders with fixed, matching outer-edge columns on every row. Required for every choice when uiSketch is true.' }))
33
33
  }), {
34
34
  minItems: 1,
35
35
  maxItems: MAX_OPTIONS,
@@ -2,7 +2,8 @@ import type { SecretStore } from "../accounts/secrets.js";
2
2
  import type { CallerContext } from "../core/caller.js";
3
3
  import type { PluginDescriptor } from "../plugins/types.js";
4
4
  import type { ActiveRun } from "../sessions/registry.js";
5
- import type { ToolProvider, ToolSpec } from "../tools/types.js";
5
+ import type { ToolProvider, ToolSession, ToolSpec } from "../tools/types.js";
6
+ import type { BrowserExtensionStart, BrowserExtensionStatus } from "../browser/extension/bridge.js";
6
7
  import type { WebRequest } from "../web/safeRequest.js";
7
8
  import { type BrowserConfig } from "../browser/config.js";
8
9
  import { type BrowserPartOptions } from "../browser/index.js";
@@ -27,6 +28,10 @@ export interface WorldOptions {
27
28
  request?: WebRequest;
28
29
  /** Replaces the browser's launcher and address check in tests. */
29
30
  browser?: Omit<BrowserPartOptions, 'stateDir'> | false;
31
+ /** Enables the loopback extension bridge even when the host supplies its own isolated browser. */
32
+ browserExtension?: {
33
+ port?: number;
34
+ };
30
35
  }
31
36
  /** A family of world tools, switched as one plugin. */
32
37
  export interface WorldPart {
@@ -59,7 +64,12 @@ export declare class World {
59
64
  /** Saves or forgets a search service's key. */
60
65
  setSearchKey(providerId: string, key: string | null): Promise<void>;
61
66
  browserConfig(): BrowserConfig;
62
- configureBrowser(change: Partial<BrowserConfig>): BrowserConfig;
67
+ configureBrowser(change: Partial<BrowserConfig>): Promise<BrowserConfig>;
68
+ browserExtensionStatus(): BrowserExtensionStatus;
69
+ browserExtensionStart(): Promise<BrowserExtensionStart>;
70
+ browserExtensionStop(): Promise<BrowserExtensionStatus>;
71
+ /** Browser tools for a host provider that supplies its own tool registration surface. */
72
+ browserToolsForSession(session: ToolSession): ToolSpec[];
63
73
  release(sessionKey: string): Promise<void>;
64
74
  close(): Promise<void>;
65
75
  }
@@ -15,8 +15,13 @@ export class World {
15
15
  browserEngine;
16
16
  constructor(options) {
17
17
  this.options = options;
18
- this.browserEngine = options.browser === false ? null : createBrowserEngine({ stateDir: options.stateDir, ...options.browser });
19
- this.parts.push(...BUILTIN_PLUGIN_REGISTRY.worldParts({ options, setting: (pluginId, key) => this.setting(pluginId, key), browserEngine: this.browserEngine }));
18
+ const disabled = options.browser === false;
19
+ this.browserEngine = disabled && !options.browserExtension ? null : createBrowserEngine({
20
+ stateDir: options.stateDir,
21
+ ...(disabled ? {} : options.browser),
22
+ extensionPort: options.browserExtension?.port
23
+ });
24
+ this.parts.push(...BUILTIN_PLUGIN_REGISTRY.worldParts({ options, setting: (pluginId, key) => this.setting(pluginId, key), browserEngine: disabled ? null : this.browserEngine }));
20
25
  }
21
26
  add(part) {
22
27
  this.parts.push(part);
@@ -67,8 +72,41 @@ export class World {
67
72
  browserConfig() {
68
73
  return readBrowserConfig(this.options.stateDir);
69
74
  }
70
- configureBrowser(change) {
71
- return writeBrowserConfig(this.options.stateDir, change);
75
+ async configureBrowser(change) {
76
+ const before = this.browserConfig();
77
+ const next = writeBrowserConfig(this.options.stateDir, change);
78
+ if (before.backend === 'brave' && next.backend === 'isolated')
79
+ await this.browserEngine?.extension.stop();
80
+ return next;
81
+ }
82
+ browserExtensionStatus() {
83
+ if (!this.browserEngine)
84
+ throw invalid('The Brave browser bridge is not enabled by this host.');
85
+ return this.browserEngine.extension.status();
86
+ }
87
+ browserExtensionStart() {
88
+ if (!this.browserEngine)
89
+ return Promise.reject(invalid('The Brave browser bridge is not enabled by this host.'));
90
+ return this.browserEngine.extension.start();
91
+ }
92
+ browserExtensionStop() {
93
+ if (!this.browserEngine)
94
+ return Promise.reject(invalid('The Brave browser bridge is not enabled by this host.'));
95
+ return this.browserEngine.extension.stop();
96
+ }
97
+ /** Browser tools for a host provider that supplies its own tool registration surface. */
98
+ browserToolsForSession(session) {
99
+ if (!this.browserEngine)
100
+ return [];
101
+ const record = findRun(this.options.root(), session.run.runId);
102
+ return this.browserEngine.toolsForSession(session, {
103
+ run: session.run,
104
+ caller: session.caller,
105
+ attended: !UNATTENDED.has(record?.trigger ?? ''),
106
+ subagent: session.depth > 0 || record?.relation === 'subagent',
107
+ sessionKey: session.run.sessionId,
108
+ cwd: session.cwd
109
+ });
72
110
  }
73
111
  async release(sessionKey) {
74
112
  await Promise.all([...this.parts.map((part) => part.release?.(sessionKey)), this.browserEngine?.release(sessionKey)]);