@xingular/harness 1.6.0-unstable → 2.0.1-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 (56) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/cli.js +23375 -19883
  3. package/dist/sdk/cli.js +16 -1
  4. package/dist/sdk/harness.js +2 -1
  5. package/dist/sdk/prompt/reporting.d.ts +1 -1
  6. package/dist/sdk/prompt/reporting.js +8 -3
  7. package/dist/sdk/server/methods.js +12 -3
  8. package/dist/sdk/server/serve.d.ts +5 -2
  9. package/dist/sdk/server/serve.js +10 -3
  10. package/dist/sdk/server/socket.d.ts +28 -0
  11. package/dist/sdk/server/socket.js +122 -0
  12. package/dist/sdk/sessions/contracts.d.ts +14 -1
  13. package/dist/sdk/sessions/contracts.js +9 -0
  14. package/dist/sdk/sessions/extensionUi.js +4 -4
  15. package/dist/sdk/sessions/live.d.ts +101 -0
  16. package/dist/sdk/sessions/live.js +265 -0
  17. package/dist/sdk/sessions/registry.d.ts +12 -0
  18. package/dist/sdk/sessions/registry.js +3 -0
  19. package/dist/sdk/sessions/runner.d.ts +25 -3
  20. package/dist/sdk/sessions/runner.js +171 -31
  21. package/dist/sdk/sessions/sessions.d.ts +49 -0
  22. package/dist/sdk/sessions/sessions.js +165 -8
  23. package/dist/sdk/{terminal → sessions}/stallWatch.d.ts +4 -0
  24. package/dist/sdk/{terminal → sessions}/stallWatch.js +10 -4
  25. package/dist/sdk/sessions/subagentService.js +1 -1
  26. package/dist/sdk/terminal/controller.js +1 -3
  27. package/dist/sdk/terminal/daemon.d.ts +44 -0
  28. package/dist/sdk/terminal/daemon.js +264 -0
  29. package/dist/sdk/terminal/liveTerminal.d.ts +96 -0
  30. package/dist/sdk/terminal/liveTerminal.js +436 -0
  31. package/dist/sdk/terminal/standalone.d.ts +19 -1
  32. package/dist/sdk/terminal/standalone.js +75 -25
  33. package/dist/sdk/terminal/terminals.d.ts +43 -0
  34. package/dist/sdk/terminal/terminals.js +171 -14
  35. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  36. package/dist/sdk/terminal/tui/commands.js +24 -2
  37. package/dist/sdk/terminal/tui/harnessRuntime.d.ts +8 -0
  38. package/dist/sdk/terminal/tui/harnessRuntime.js +771 -0
  39. package/dist/sdk/terminal/tui/thinkingLevels.d.ts +11 -0
  40. package/dist/sdk/terminal/tui/thinkingLevels.js +31 -0
  41. package/dist/terminal-opentui.js +2013 -975
  42. package/dist/terminal-pi.js +9 -0
  43. package/docs/architecture/02-sessions-and-runs.md +61 -3
  44. package/docs/architecture/04-prompt.md +8 -6
  45. package/docs/architecture/10-server-protocol.md +23 -1
  46. package/docs/architecture/11-terminal.md +67 -3
  47. package/docs/architecture/12-storage.md +3 -1
  48. package/docs/architecture/14-surfaces-and-parity.md +4 -0
  49. package/docs/architecture/15-remote-control.md +3 -1
  50. package/docs/architecture/README.md +5 -0
  51. package/docs/decisions/ADR-0001-owned-terminal.md +9 -2
  52. package/docs/decisions/ADR-0083-one-live-session-runtime.md +256 -0
  53. package/docs/parity.md +4 -3
  54. package/docs/releases/2.0.1-unstable.md +63 -0
  55. package/docs/terminal-tui.md +10 -5
  56. package/package.json +1 -1
@@ -0,0 +1,771 @@
1
+ // The terminal frontend as a client of the harness (ADR-0083, phase 2). It runs no agent loop of its
2
+ // own: every turn is a `terminal.live.run` on the parent, which runs it as a live harness session.
3
+ // This process keeps what only draws: the model catalogue, the account's settings, the skills and
4
+ // prompts it lists, and a read of the conversation's history file. `session` is a mirror of the live
5
+ // Pi session, kept current from its native events, so the screen code is the same in both modes.
6
+ import { DefaultResourceLoader, ModelRuntime, resolveCliModel, SessionManager, SettingsManager } from '@earendil-works/pi-coding-agent';
7
+ import { appendFileSync, copyFileSync, existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
8
+ import { basename, isAbsolute, join, resolve } from 'node:path';
9
+ import { claimTerminalControl, controlCall } from "../control.js";
10
+ import { SocketError } from "../client.js";
11
+ import { terminalCredentialStore } from "../credentialStore.js";
12
+ import { GoalController } from "../../sessions/goal.js";
13
+ import { GOAL_CUSTOM_TYPE } from "../../sessions/goal.js";
14
+ import { requiredSkillsText } from "../../prompt/compile.js";
15
+ import { bodyOf } from "../../core/frontmatter.js";
16
+ import { skillInvocations } from "./skillInvocation.js";
17
+ import { pluginCommandPrefix } from "../../plugins/commandPrefix.js";
18
+ import { clampLevel, supportedLevels } from "./thinkingLevels.js";
19
+ import { ModeChoices } from "./modeChoices.js";
20
+ import { assertTerminalHistoryPath, assertTerminalSessionScope, parseTerminalArguments } from "./runtime.js";
21
+ /**
22
+ * The terminal's runtime (ADR-0083): this client of the harness by default; `inprocess` runs the
23
+ * previous in-process Pi runtime, which keeps plugins' own terminal components.
24
+ */
25
+ export const HARNESS_RUNTIME_ENV = 'XINGULAR_TERMINAL_RUNTIME';
26
+ export const harnessRuntimeSelected = (env = process.env) => env[HARNESS_RUNTIME_ENV]?.trim().toLowerCase() !== 'inprocess';
27
+ const DEFAULT_TOOLS = ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls'];
28
+ /** The native session id a history file carries in its header. */
29
+ function headerId(path) {
30
+ try {
31
+ const header = JSON.parse(readFileSync(path, 'utf8').split('\n', 1)[0]);
32
+ return header.type === 'session' && typeof header.id === 'string' ? header.id : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function textOf(message) {
39
+ if (!message)
40
+ return '';
41
+ if (typeof message.content === 'string')
42
+ return message.content;
43
+ return Array.isArray(message.content) ? message.content.map((part) => part.type === 'text' ? part.text ?? '' : '').join('') : '';
44
+ }
45
+ /** `XINGULAR_CLIENT_TRACE=<file>`: timestamped client events, for diagnosing a slow window. */
46
+ function trace(text) {
47
+ const file = process.env.XINGULAR_CLIENT_TRACE;
48
+ if (!file)
49
+ return;
50
+ try {
51
+ appendFileSync(file, `${new Date().toISOString()} ${text}\n`);
52
+ }
53
+ catch {
54
+ // Diagnostics only.
55
+ }
56
+ }
57
+ export async function createHarnessTerminalRuntime(args, host) {
58
+ const prepared = parseTerminalArguments(args);
59
+ const cwd = realpathSync(process.cwd());
60
+ const agentDirValue = process.env.PI_CODING_AGENT_DIR;
61
+ if (!agentDirValue || !isAbsolute(agentDirValue))
62
+ throw new Error('The Harness terminal requires its isolated account directory.');
63
+ const agentDir = realpathSync(agentDirValue);
64
+ const sessionDirectory = realpathSync(resolve(cwd, prepared.sessionDir));
65
+ let sessionFile = prepared.session && existsSync(resolve(cwd, prepared.session)) ? resolve(cwd, prepared.session) : null;
66
+ if (sessionFile) {
67
+ assertTerminalHistoryPath(sessionFile, sessionDirectory);
68
+ assertTerminalSessionScope(sessionFile, cwd);
69
+ }
70
+ await claimTerminalControl();
71
+ const identity = await controlCall('accounts.session').catch(() => null);
72
+ const account = identity && typeof identity.accountId === 'string' && typeof identity.label === 'string'
73
+ ? Object.freeze({ accountId: identity.accountId, label: identity.label }) : null;
74
+ // The catalogue and credential checks only: this process makes no model request.
75
+ const modelRuntime = await ModelRuntime.create({ credentials: terminalCredentialStore(), modelsPath: join(agentDir, 'models.json') });
76
+ const settingsManager = SettingsManager.create(cwd, agentDir);
77
+ // Skills, prompts and themes to list and complete. Extensions run in the harness, never here.
78
+ const resourceLoader = new DefaultResourceLoader({
79
+ cwd, agentDir, settingsManager: SettingsManager.inMemory({}),
80
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true,
81
+ additionalSkillPaths: prepared.skills, additionalPromptTemplatePaths: prepared.prompts, additionalThemePaths: prepared.themes
82
+ });
83
+ await resourceLoader.reload();
84
+ const services = { cwd, agentDir, modelRuntime, settingsManager, resourceLoader, diagnostics: [] };
85
+ const choiceStore = {
86
+ read: () => controlCall('accounts.choices.get'),
87
+ write: (mode, patch) => controlCall('accounts.choices.set', { mode, ...patch })
88
+ };
89
+ const modeChoices = new ModeChoices(choiceStore);
90
+ await modeChoices.load();
91
+ let currentMode = 'normal';
92
+ let planActive = false;
93
+ const recordingMode = () => planActive ? 'plan' : currentMode;
94
+ const modelFor = (reference) => resolveCliModel({ cliModel: reference, modelRuntime }).model;
95
+ /** A model this account can use: offered by its catalogue and holding credentials. Never a request. */
96
+ const usableModel = async (reference) => {
97
+ const found = modelFor(reference);
98
+ return found && await modelRuntime.checkAuth(found.provider) ? found : undefined;
99
+ };
100
+ const goal = new GoalController();
101
+ const listeners = new Set();
102
+ const changes = new Set();
103
+ const beforeChanges = new Set();
104
+ let disposed = false;
105
+ let seq = 0;
106
+ let live = { seq: 0, activeRunId: null, live: null, session: null };
107
+ let manager = null;
108
+ let messages = [];
109
+ const state = { messages, pendingToolCalls: new Set() };
110
+ let streaming = false;
111
+ /** A mode switch made during a reply: the next reply starts on the new mode's choice. */
112
+ let pendingApply = false;
113
+ let fresh = !sessionFile;
114
+ const steering = [];
115
+ const followUps = [];
116
+ const pendingNotes = [];
117
+ let model;
118
+ let thinkingLevel = 'medium';
119
+ let sessionName;
120
+ const idleWaiters = new Set();
121
+ /** Reads the history file again: after a reply, a compaction, or a switch. */
122
+ const reloadHistory = () => {
123
+ manager = sessionFile ? SessionManager.open(sessionFile, sessionDirectory) : null;
124
+ messages = manager ? manager.buildSessionContext().messages : [];
125
+ state.messages = messages;
126
+ sessionName = manager?.getSessionName() ?? sessionName;
127
+ };
128
+ reloadHistory();
129
+ // The model: an explicit launch model, then this conversation's own, then the account's latest choice.
130
+ const startChoice = modeChoices.resolve('normal');
131
+ const recorded = () => {
132
+ const context = manager?.buildSessionContext();
133
+ return context?.model ? `${context.model.provider}/${context.model.modelId}` : undefined;
134
+ };
135
+ // The level Pi resolves when no mode has saved an effort: the model's own default, then the account's.
136
+ const defaultEffort = () => ((model ? settingsManager.getModelThinkingLevel(model.provider, model.id) : undefined) ?? settingsManager.getDefaultThinkingLevel() ?? 'medium');
137
+ const initial = prepared.model ?? recorded() ?? startChoice.model;
138
+ if (initial)
139
+ model = modelFor(initial);
140
+ thinkingLevel = (startChoice.effort ?? defaultEffort());
141
+ // A conversation written on another account (a switch resumes it) can name a model this account
142
+ // cannot use. The account's own newest choice decides then; an explicit launch model is the host's.
143
+ const stale = prepared.model ? undefined : recorded();
144
+ let recovered = null;
145
+ if (stale && !(await usableModel(stale).then(Boolean, () => true))) {
146
+ const asSession = { get model() { return model; }, setModel: async (next) => { model = next; }, setThinkingLevel: (level) => { thinkingLevel = level; } };
147
+ const applied = await modeChoices.useFallback(asSession, 'normal', usableModel, defaultEffort()).catch(() => null);
148
+ if (applied)
149
+ recovered = { recorded: stale, ...applied };
150
+ // Nothing on this account is usable: name no model, so the harness picks one rather than the old account's.
151
+ else
152
+ model = undefined;
153
+ }
154
+ const reference = () => model ? `${model.provider}/${model.id}` : null;
155
+ const lastAssistant = () => [...messages].reverse().find((message) => message.role === 'assistant');
156
+ const emit = (event) => {
157
+ for (const listener of listeners)
158
+ listener(event);
159
+ host.changed();
160
+ };
161
+ const dialogs = new Map();
162
+ const showDialog = async (request) => {
163
+ if (dialogs.has(request.requestId))
164
+ return;
165
+ const done = new AbortController();
166
+ dialogs.set(request.requestId, done);
167
+ const { dialog } = request;
168
+ try {
169
+ const options = { signal: done.signal };
170
+ let answer;
171
+ if (dialog.kind === 'select') {
172
+ const chosen = await host.ui.select(dialog.title, dialog.options ?? [], options);
173
+ answer = { selected: chosen === undefined ? [] : [chosen] };
174
+ }
175
+ else if (dialog.kind === 'confirm') {
176
+ answer = { selected: [(await host.ui.confirm(dialog.title, dialog.message ?? '', options)) ? 'Yes' : 'No'] };
177
+ }
178
+ else {
179
+ const text = dialog.kind === 'input' ? await host.ui.input(dialog.title, dialog.placeholder, options) : await host.ui.editor(dialog.title, dialog.prefill);
180
+ answer = text === undefined ? { selected: [] } : { selected: [], text };
181
+ }
182
+ // Answered elsewhere first (another window or a phone): the first answer stands.
183
+ if (done.signal.aborted)
184
+ return;
185
+ await controlCall('terminal.live.answer', { requestId: request.requestId, answer }).catch(() => undefined);
186
+ }
187
+ finally {
188
+ dialogs.delete(request.requestId);
189
+ }
190
+ };
191
+ /** Applies one native event of the live session to the mirror, as Pi applies it to its own state. */
192
+ const apply = (event) => {
193
+ // A harness run names its initial Bash `<run>:initial-bash`; the window draws `initial-bash:<run>`.
194
+ const bash = event;
195
+ if (bash.type === 'bash_execution_update' && bash.id?.endsWith(':initial-bash'))
196
+ event = { ...event, id: `initial-bash:${bash.id.slice(0, -':initial-bash'.length)}` };
197
+ const loose = event;
198
+ switch (loose.type) {
199
+ case 'agent_start':
200
+ streaming = true;
201
+ break;
202
+ case 'message_update':
203
+ if (loose.message?.role === 'assistant')
204
+ state.streamingMessage = loose.message;
205
+ break;
206
+ case 'message_end':
207
+ if (loose.message) {
208
+ if (state.streamingMessage && loose.message.role === 'assistant')
209
+ state.streamingMessage = undefined;
210
+ messages.push(loose.message);
211
+ if (loose.message.role === 'user' && steering.length)
212
+ steering.shift();
213
+ }
214
+ break;
215
+ case 'tool_execution_start':
216
+ if (loose.toolCallId)
217
+ state.pendingToolCalls.add(loose.toolCallId);
218
+ break;
219
+ case 'tool_execution_end':
220
+ if (loose.toolCallId)
221
+ state.pendingToolCalls.delete(loose.toolCallId);
222
+ break;
223
+ case 'compaction_end':
224
+ reloadHistory();
225
+ break;
226
+ case 'harness_settled': {
227
+ const settled = event;
228
+ // The harness ran the goal: its rounds, time and status are the ones to show.
229
+ if (settled.goalMode)
230
+ goal.restore(settled.goalMode);
231
+ // A new conversation's first reply made its file: this window resumes it from now on.
232
+ if (!sessionFile && settled.sessionFile) {
233
+ sessionFile = settled.sessionFile;
234
+ fresh = false;
235
+ reloadHistory();
236
+ }
237
+ // Pi's own settle came first, or never will: settle the mirror as Pi would have.
238
+ if (streaming)
239
+ apply({ type: 'agent_settled', aborted: settled.status === 'interrupted' });
240
+ host.changed();
241
+ return;
242
+ }
243
+ case 'harness_dialog': {
244
+ void showDialog(event.request);
245
+ return;
246
+ }
247
+ case 'harness_dialog_done': {
248
+ dialogs.get(event.requestId)?.abort();
249
+ return;
250
+ }
251
+ case 'harness_notice': {
252
+ // A plugin's notice, status line or widget, drawn by this window as an in-process plugin's would be.
253
+ const notice = event.notice;
254
+ if (notice.kind === 'notify' || notice.kind === 'error')
255
+ host.ui.notify(notice.text, notice.kind === 'error' ? 'error' : notice.level ?? 'info');
256
+ else if (notice.kind === 'status' && notice.key)
257
+ host.ui.setStatus(notice.key, notice.text || undefined);
258
+ else if (notice.kind === 'widget' && notice.key)
259
+ host.ui.setWidget(notice.key, notice.text ? notice.text.split('\n') : undefined);
260
+ else if (notice.kind === 'title')
261
+ host.ui.setTitle(notice.text);
262
+ host.changed();
263
+ return;
264
+ }
265
+ case 'agent_settled': {
266
+ streaming = false;
267
+ if (pendingApply) {
268
+ pendingApply = false;
269
+ void modeChoices.apply(session, currentMode, modelFor, defaultEffort()).then(() => host.changed());
270
+ }
271
+ state.streamingMessage = undefined;
272
+ state.pendingToolCalls.clear();
273
+ steering.length = 0;
274
+ // The file is the record: it carries what the live session wrote, compaction included. A
275
+ // new conversation learns its file from the next state read.
276
+ if (sessionFile)
277
+ reloadHistory();
278
+ for (const wake of idleWaiters)
279
+ wake();
280
+ idleWaiters.clear();
281
+ break;
282
+ }
283
+ }
284
+ emit(event);
285
+ };
286
+ // Bumped when the window switches conversation, so a state read that started before it is ignored.
287
+ let generation = 0;
288
+ const refreshState = async () => {
289
+ const asked = generation;
290
+ const read = await controlCall('terminal.live.state');
291
+ if (asked !== generation)
292
+ return;
293
+ live = read;
294
+ if (live.live?.sessionFile && !sessionFile && !streaming) {
295
+ sessionFile = live.live.sessionFile;
296
+ fresh = false;
297
+ reloadHistory();
298
+ host.changed();
299
+ }
300
+ if (live.session?.sessionName)
301
+ sessionName = live.session.sessionName;
302
+ followUps.splice(0, followUps.length, ...(live.live?.followUps ?? []));
303
+ };
304
+ await refreshState();
305
+ seq = live.seq;
306
+ // Reattached while its reply kept running (ADR-0083): show it as running until it settles.
307
+ if (live.activeRunId)
308
+ streaming = true;
309
+ // Native events of the live session, read as they come. A gap means events were dropped: the mirror reads the file again.
310
+ // Closing the window cancels the read that is waiting, so nothing keeps this process alive.
311
+ const stopReading = new AbortController();
312
+ let unreachable = 0;
313
+ const pump = async () => {
314
+ while (!disposed) {
315
+ try {
316
+ const read = await controlCall('terminal.live.events', { after: seq }, 30_000, stopReading.signal);
317
+ if (disposed)
318
+ return;
319
+ if (read.reset) {
320
+ reloadHistory();
321
+ host.changed();
322
+ }
323
+ if (read.events.length)
324
+ trace(`events ${read.events.length} last=${read.events.at(-1).event.type}`);
325
+ for (const entry of read.events) {
326
+ seq = entry.seq;
327
+ apply(entry.event);
328
+ }
329
+ unreachable = 0;
330
+ if (!read.events.length)
331
+ seq = Math.max(seq, read.seq);
332
+ if (read.events.some((entry) => entry.event.type === 'agent_settled' || entry.event.type === 'agent_start'))
333
+ await refreshState().catch(() => undefined);
334
+ }
335
+ catch (error) {
336
+ if (disposed)
337
+ return;
338
+ // The harness that ran this window is gone (it stopped or crashed): close cleanly.
339
+ if (error instanceof SocketError && error.code === 'unavailable' && ++unreachable >= 2) {
340
+ host.notify('The harness for this window stopped; closing.');
341
+ host.shutdown();
342
+ return;
343
+ }
344
+ if (unreachable === 0)
345
+ host.notify(`The harness session stream stopped: ${error instanceof Error ? error.message : String(error)}`);
346
+ await new Promise((wait) => setTimeout(wait, 1000));
347
+ }
348
+ }
349
+ };
350
+ const requiredSkills = (text) => {
351
+ if (/^\s*!/u.test(text) || settingsManager.getEnableSkillCommands?.() === false)
352
+ return [];
353
+ const skills = resourceLoader.getSkills().skills;
354
+ const names = skillInvocations(text, new Set(skills.map((skill) => skill.name)));
355
+ if (!names.length)
356
+ return [];
357
+ const entries = names.map((name) => {
358
+ const skill = skills.find((candidate) => candidate.name === name);
359
+ try {
360
+ return { name, path: skill.filePath, baseDir: skill.baseDir, body: bodyOf(readFileSync(skill.filePath, 'utf8')) };
361
+ }
362
+ catch {
363
+ return { name, path: skill.filePath, body: '' };
364
+ }
365
+ });
366
+ const content = requiredSkillsText(entries);
367
+ return content ? [{ customType: 'xingular-required-skills', content }] : [];
368
+ };
369
+ const startRun = async (text, options = {}) => {
370
+ if (disposed)
371
+ throw new Error('The terminal runtime has closed.');
372
+ const mode = options.mode ?? (options.plan ? 'plan' : currentMode);
373
+ // A mode with no saved model or effort keeps the conversation's current one, as the in-process runtime does.
374
+ const saved = mode === currentMode ? {} : modeChoices.resolve(mode);
375
+ const choice = { model: (saved.model && modelFor(saved.model) ? saved.model : undefined) ?? reference() ?? undefined, effort: saved.effort ?? thinkingLevel };
376
+ const notes = [...pendingNotes.splice(0), ...requiredSkills(text)];
377
+ const bash = /^!\s*([\s\S]+)$/u.exec(text.trimStart())?.[1];
378
+ trace(`run asked ${JSON.stringify(text.slice(0, 40))}`);
379
+ streaming = true;
380
+ host.changed();
381
+ try {
382
+ await controlCall('terminal.live.run', {
383
+ text,
384
+ ...(choice.model ? { model: choice.model } : {}),
385
+ ...(choice.effort ? { effort: choice.effort } : {}),
386
+ ...(sessionFile ? { resume: { sessionFile, nativeSessionId: headerId(sessionFile) } } : { newHistory: fresh }),
387
+ ...(options.images?.length ? { images: options.images.map((image) => ({ mediaType: image.mimeType, data: image.data })) } : {}),
388
+ ...(options.plan ? { plan: true } : {}),
389
+ ...(options.command ? { command: options.command } : {}),
390
+ ...(options.goal ? { goal: options.goal } : {}),
391
+ ...(bash && !options.plan ? { initialBashCommand: bash } : {}),
392
+ ...(notes.length ? { hiddenNotes: notes } : {})
393
+ }, 60_000);
394
+ }
395
+ catch (error) {
396
+ streaming = false;
397
+ host.changed();
398
+ throw error;
399
+ }
400
+ };
401
+ const setModel = async (next, options = {}) => {
402
+ model = next;
403
+ // As Pi does: the new model runs at the nearest level it supports.
404
+ thinkingLevel = clampLevel(next, thinkingLevel);
405
+ if (options.persist !== false)
406
+ await modeChoices.record(recordingMode(), { model: `${next.provider}/${next.id}` });
407
+ host.changed();
408
+ };
409
+ const setThinkingLevel = (level, options = {}) => {
410
+ thinkingLevel = clampLevel(model, level);
411
+ if (options.persist !== false)
412
+ void modeChoices.record(recordingMode(), { effort: thinkingLevel });
413
+ host.changed();
414
+ };
415
+ const availableLevels = () => supportedLevels(model);
416
+ const commandsNow = () => live.session?.commands ?? live.commands ?? [];
417
+ const extensionRunner = {
418
+ getRegisteredCommands: () => commandsNow().map((command) => ({ ...command, sourceInfo: command.sourceInfo ?? { path: '', source: 'harness' } })),
419
+ getCommand: (name) => {
420
+ const command = commandsNow().find((entry) => entry.invocationName === name || entry.name === name);
421
+ if (!command)
422
+ return undefined;
423
+ // The harness's router takes a plugin command only by its owner-qualified route (ADR-0061).
424
+ const route = `${pluginCommandPrefix((command.sourceInfo ?? { path: '' }))}:${command.invocationName}`;
425
+ return { ...command, handler: async (argument) => { await startRun(`/${route}${argument ? ` ${argument}` : ''}`); } };
426
+ },
427
+ createCommandContext: () => ({}),
428
+ createContext: () => ({}),
429
+ createToolContext: () => ({}),
430
+ getShortcuts: () => new Map(),
431
+ getExtensionPaths: () => [],
432
+ setUIContext: () => undefined,
433
+ emitToolCall: async () => undefined
434
+ };
435
+ const session = {
436
+ get sessionId() { return live.session?.sessionId ?? (sessionFile ? headerId(sessionFile) ?? '' : ''); },
437
+ get sessionFile() { return sessionFile ?? undefined; },
438
+ get sessionName() { return sessionName; },
439
+ setSessionName: (name) => {
440
+ sessionName = name;
441
+ void controlCall('terminal.live.rename', { name, ...(sessionFile ? { sessionFile } : {}) }).catch((error) => host.notify(error instanceof Error ? error.message : String(error)));
442
+ host.changed();
443
+ },
444
+ get messages() { return messages; },
445
+ get state() { return state; },
446
+ get model() { return model; },
447
+ get thinkingLevel() { return thinkingLevel; },
448
+ get isStreaming() { return streaming; },
449
+ get isIdle() { return !streaming; },
450
+ get systemPrompt() { return live.session?.systemPrompt ?? ''; },
451
+ get settingsManager() { return settingsManager; },
452
+ get resourceLoader() { return resourceLoader; },
453
+ get modelRuntime() { return modelRuntime; },
454
+ get sessionManager() { return manager ?? SessionManager.inMemory(cwd); },
455
+ get extensionRunner() { return extensionRunner; },
456
+ get scopedModels() { return []; },
457
+ get steeringMode() { return 'one-at-a-time'; },
458
+ get followUpMode() { return 'one-at-a-time'; },
459
+ get autoCompactionEnabled() { return settingsManager.getCompactionSettings().enabled; },
460
+ get autoRetryEnabled() { return true; },
461
+ setSteeringMode: () => undefined,
462
+ setFollowUpMode: () => undefined,
463
+ setAutoCompactionEnabled: (enabled) => settingsManager.setCompactionEnabled?.(enabled),
464
+ setAutoRetryEnabled: () => undefined,
465
+ supportsThinking: () => !!model?.reasoning,
466
+ getAvailableThinkingLevels: availableLevels,
467
+ cycleThinkingLevel: () => {
468
+ const levels = availableLevels();
469
+ const next = levels[(levels.indexOf(thinkingLevel) + 1) % levels.length];
470
+ setThinkingLevel(next);
471
+ return next;
472
+ },
473
+ cycleModel: async () => undefined,
474
+ setModel,
475
+ setThinkingLevel,
476
+ getActiveToolNames: () => live.session?.activeTools.length ? [...live.session.activeTools] : DEFAULT_TOOLS,
477
+ getAllTools: () => live.session?.tools.length ? live.session.tools : DEFAULT_TOOLS.map((name) => ({ name, description: '' })),
478
+ setActiveToolsByName: () => undefined,
479
+ getToolDefinition: () => undefined,
480
+ getSteeringMessages: () => [...steering],
481
+ getFollowUpMessages: () => [...followUps],
482
+ clearQueue: () => {
483
+ const removed = { steering: [...steering], followUp: [...followUps] };
484
+ steering.length = 0;
485
+ followUps.length = 0;
486
+ void controlCall('terminal.live.followUp.clear').catch(() => undefined);
487
+ host.changed();
488
+ return removed;
489
+ },
490
+ getContextUsage: () => {
491
+ const usage = lastAssistant()?.usage;
492
+ const window = model?.contextWindow ?? 0;
493
+ const tokens = usage ? (usage.totalTokens ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)) : null;
494
+ return { tokens, contextWindow: window, percent: tokens !== null && window ? tokens / window * 100 : null };
495
+ },
496
+ getSessionStats: () => {
497
+ const assistant = messages.filter((message) => message.role === 'assistant');
498
+ const sum = (key) => assistant.reduce((total, message) => total + (message.usage?.[key] ?? 0), 0);
499
+ return {
500
+ sessionFile: sessionFile ?? undefined, sessionId: session.sessionId,
501
+ userMessages: messages.filter((message) => message.role === 'user').length, assistantMessages: assistant.length,
502
+ toolCalls: 0, toolResults: messages.filter((message) => message.role === 'toolResult').length, totalMessages: messages.length,
503
+ tokens: { input: sum('input'), output: sum('output'), cacheRead: sum('cacheRead'), cacheWrite: sum('cacheWrite'), total: sum('input') + sum('output') + sum('cacheRead') + sum('cacheWrite') },
504
+ cost: assistant.reduce((total, message) => total + (message.usage?.cost?.total ?? 0), 0)
505
+ };
506
+ },
507
+ getLastAssistantText: () => textOf(lastAssistant()) || undefined,
508
+ getUserMessagesForForking: () => (manager?.getEntries() ?? []).filter((entry) => entry.type === 'message' && entry.message?.role === 'user')
509
+ .map((entry) => ({ entryId: entry.id, text: textOf(entry.message) })),
510
+ subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
511
+ prompt: async (text, options = {}) => {
512
+ await runtime.submit(text, options.streamingBehavior === 'followUp', options.images);
513
+ },
514
+ sendCustomMessage: async (message, options = {}) => {
515
+ if (options.deliverAs === 'steer' && streaming) {
516
+ await controlCall('terminal.live.steer', { text: message.content });
517
+ return;
518
+ }
519
+ if (options.triggerTurn && message.customType === GOAL_CUSTOM_TYPE) {
520
+ const current = goal.get();
521
+ if (current)
522
+ await startRun(current.objective, { goal: { objective: current.objective, rounds: current.limits.rounds, hours: current.limits.hours } });
523
+ return;
524
+ }
525
+ pendingNotes.push({ customType: message.customType.replace(/[^a-z0-9-]/g, '-').toLowerCase(), content: message.content });
526
+ },
527
+ abort: async () => { await runtime.interrupt(); },
528
+ abortBash: () => undefined,
529
+ waitForIdle: async () => {
530
+ if (!streaming)
531
+ return;
532
+ await new Promise((wake) => idleWaiters.add(wake));
533
+ },
534
+ compact: async (instructions) => {
535
+ if (!sessionFile)
536
+ throw new Error('There is no saved conversation to compact yet.');
537
+ await startRun('/compact', { command: { name: 'compact', ...(instructions ? { instructions } : {}) } });
538
+ },
539
+ navigateTree: async (targetId, options = {}) => {
540
+ if (!sessionFile)
541
+ return { cancelled: true };
542
+ const entry = manager?.getEntries().find((candidate) => candidate.id === targetId);
543
+ await startRun('/tree', { command: { name: 'navigate', entryId: targetId, summarize: !!options.summarize, ...(options.customInstructions ? { instructions: options.customInstructions } : {}) } });
544
+ await session.waitForIdle();
545
+ return { cancelled: false, ...(entry?.message?.role === 'user' ? { editorText: textOf(entry.message) } : {}) };
546
+ },
547
+ exportToHtml: async (path) => {
548
+ const target = path ? resolve(cwd, path) : join(cwd, `${basename(sessionFile ?? 'session', '.jsonl')}.html`);
549
+ await startRun('/export', { command: { name: 'export', path: target } });
550
+ return target;
551
+ },
552
+ exportToJsonl: async (path) => {
553
+ if (!sessionFile)
554
+ throw new Error('There is no saved conversation to export yet.');
555
+ copyFileSync(sessionFile, resolve(cwd, path));
556
+ return resolve(cwd, path);
557
+ },
558
+ bindExtensions: async () => undefined,
559
+ reload: async () => undefined,
560
+ dispose: () => undefined
561
+ };
562
+ /**
563
+ * Whether a reply really runs. The window learns that a reply ended from the event stream, a moment
564
+ * after the harness knows; when the harness has no running reply, wait for the window to catch up.
565
+ */
566
+ const replying = async () => {
567
+ if (!streaming)
568
+ return false;
569
+ await refreshState().catch(() => undefined);
570
+ if (live.activeRunId)
571
+ return true;
572
+ await Promise.race([
573
+ new Promise((wake) => idleWaiters.add(wake)),
574
+ new Promise((wait) => setTimeout(wait, 2000))
575
+ ]);
576
+ // The harness has nothing running: whatever the stream still owes, the reply is over.
577
+ if (streaming)
578
+ apply({ type: 'agent_settled', aborted: false });
579
+ return false;
580
+ };
581
+ const announce = async () => {
582
+ for (const listener of changes)
583
+ await listener(session);
584
+ host.changed();
585
+ };
586
+ const replace = async (file) => {
587
+ if (await replying())
588
+ throw new Error('Stop the current response before changing or reloading this session.');
589
+ for (const listener of beforeChanges)
590
+ listener();
591
+ // The live session holds the old conversation; the next run opens the new one.
592
+ generation += 1;
593
+ await controlCall('terminal.live.close', { forget: true }).catch(() => undefined);
594
+ live = { ...live, live: null, session: null };
595
+ sessionFile = file;
596
+ fresh = !file;
597
+ sessionName = undefined;
598
+ reloadHistory();
599
+ await announce();
600
+ };
601
+ const runtime = {
602
+ async pending() {
603
+ if (disposed)
604
+ return [];
605
+ return (await controlCall('terminal.live.pending')).requests;
606
+ },
607
+ approve: async (requestId, decision, alwaysAllowTool = false) => { await controlCall('terminal.live.approve', { requestId, decision, alwaysAllow: alwaysAllowTool }); },
608
+ permissions: async () => disposed ? { full: false, globalTools: [] } : controlCall('terminal.permissions.get'),
609
+ setFullPermission: async (enabled) => { await controlCall('terminal.permissions.setFull', { enabled }); },
610
+ setGlobalTool: async (tool, allowed) => { await controlCall('terminal.permissions.setTool', { tool, allowed }); },
611
+ answer: async (requestId, answer) => { await controlCall('terminal.live.answer', { requestId, answer }); },
612
+ answerBatch: async (batchId, answers) => { await controlCall('terminal.live.answers', { batchId, answers }); },
613
+ snapshot() {
614
+ const usage = session.getContextUsage();
615
+ return Object.freeze({
616
+ sessionId: session.sessionId, sessionFile: sessionFile ?? undefined, name: sessionName, cwd, account,
617
+ model: model ? Object.freeze({ id: model.id, provider: model.provider, name: model.name, reasoning: model.reasoning, contextWindow: model.contextWindow }) : undefined,
618
+ thinkingLevel, isStreaming: streaming, idle: !streaming,
619
+ hasUserMessages: messages.some((message) => message.role === 'user'),
620
+ hideThinking: settingsManager.getHideThinkingBlock(),
621
+ context: Object.freeze({ percent: usage.percent, window: usage.contextWindow }),
622
+ costUsd: session.getSessionStats().cost,
623
+ subscription: model ? modelRuntime.isUsingSubscription(model.provider) : false,
624
+ queue: Object.freeze({ steering: Object.freeze([...steering]), followUp: Object.freeze([...followUps]) })
625
+ });
626
+ },
627
+ async submit(text, followUp = false, images) {
628
+ if (disposed)
629
+ throw new Error('The terminal runtime has closed.');
630
+ const input = text.trimStart().startsWith('/') ? text.trimStart() : text;
631
+ if (await replying()) {
632
+ if (followUp) {
633
+ const result = await controlCall('terminal.live.followUp', { text: input });
634
+ if (result.queued)
635
+ followUps.push(input);
636
+ }
637
+ else {
638
+ steering.push(input);
639
+ await controlCall('terminal.live.steer', { text: input }).catch((error) => {
640
+ steering.splice(steering.indexOf(input), 1);
641
+ throw error;
642
+ });
643
+ }
644
+ host.changed();
645
+ return;
646
+ }
647
+ await startRun(input, { images });
648
+ },
649
+ async submitPlan(text, images) {
650
+ if (!text.trim())
651
+ throw new Error('Use /plan <plan brief>.');
652
+ if (await replying())
653
+ throw new Error('A plan request must wait until the current response settles.');
654
+ // A one-shot plan from the normal composer: the window shows and runs the plan's choice, then returns.
655
+ const previous = { model, effort: thinkingLevel };
656
+ const oneShot = currentMode !== 'plan';
657
+ if (oneShot)
658
+ await modeChoices.apply(session, 'plan', modelFor, defaultEffort());
659
+ planActive = true;
660
+ try {
661
+ await startRun(text, { images, plan: true, mode: 'plan' });
662
+ await session.waitForIdle();
663
+ }
664
+ finally {
665
+ planActive = false;
666
+ if (oneShot && !disposed) {
667
+ model = previous.model;
668
+ thinkingLevel = previous.effort;
669
+ if (pendingApply) {
670
+ pendingApply = false;
671
+ await modeChoices.apply(session, currentMode, modelFor, defaultEffort());
672
+ }
673
+ host.changed();
674
+ }
675
+ }
676
+ },
677
+ async setMode(mode) {
678
+ currentMode = mode;
679
+ // A switch during a reply waits for it to settle, so the reply keeps the model it started with.
680
+ if (streaming) {
681
+ pendingApply = true;
682
+ return;
683
+ }
684
+ await modeChoices.apply(session, mode, modelFor, defaultEffort());
685
+ host.changed();
686
+ },
687
+ get session() { return session; },
688
+ get services() { return services; },
689
+ cwd, sessionDirectory,
690
+ subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
691
+ onSessionChange(listener) { changes.add(listener); return () => { changes.delete(listener); }; },
692
+ onBeforeSessionChange(listener) { beforeChanges.add(listener); return () => { beforeChanges.delete(listener); }; },
693
+ newSession: (async () => {
694
+ await modeChoices.load();
695
+ await replace(null);
696
+ return { cancelled: false };
697
+ }),
698
+ switchSession: (async (path, options) => {
699
+ // A conversation by its file, or by the session id its runs record.
700
+ const named = resolve(cwd, path);
701
+ const byId = () => readdirSync(sessionDirectory).filter((name) => name.endsWith('.jsonl')).map((name) => join(sessionDirectory, name)).find((file) => headerId(file) === path);
702
+ const target = existsSync(named) ? named : byId() ?? named;
703
+ assertTerminalHistoryPath(target, sessionDirectory);
704
+ assertTerminalSessionScope(target, cwd, options?.cwdOverride);
705
+ await replace(target);
706
+ return { cancelled: false };
707
+ }),
708
+ fork: (async (entryId) => {
709
+ if (!sessionFile)
710
+ throw new Error('There is no saved conversation to fork yet.');
711
+ // A fork writes a new file from the saved one; the live session's own file is not touched.
712
+ const source = SessionManager.open(sessionFile, sessionDirectory);
713
+ const entry = source.getEntries().find((candidate) => candidate.id === entryId);
714
+ const leaf = entry?.message?.role === 'user' ? entry.parentId ?? null : entryId;
715
+ const forked = leaf ? source.createBranchedSession(leaf) : undefined;
716
+ if (!forked)
717
+ throw new Error('This conversation could not be forked at that message.');
718
+ await replace(forked);
719
+ return { cancelled: false, ...(entry?.message?.role === 'user' ? { selectedText: textOf(entry.message) } : {}) };
720
+ }),
721
+ navigateTree: ((targetId, options) => session.navigateTree(targetId, options)),
722
+ importFromJsonl: (async (path, cwdOverride) => {
723
+ const target = resolve(cwd, path);
724
+ assertTerminalSessionScope(target, cwd, cwdOverride);
725
+ const copy = join(sessionDirectory, basename(target));
726
+ if (!existsSync(copy))
727
+ copyFileSync(target, copy);
728
+ await replace(copy);
729
+ return { cancelled: false };
730
+ }),
731
+ installationDeviceId: () => controlCall('accounts.deviceId'),
732
+ reload: async () => {
733
+ if (await replying())
734
+ throw new Error('Stop the current response before changing or reloading this session.');
735
+ await controlCall('terminal.resources', {}, 30_000);
736
+ // The next run opens a fresh live session that loads plugins again.
737
+ await controlCall('terminal.live.close', { reload: true }).catch(() => undefined);
738
+ await refreshState().catch(() => undefined);
739
+ await resourceLoader.reload();
740
+ await announce();
741
+ },
742
+ goal,
743
+ async interrupt() {
744
+ if (goal.active() && streaming)
745
+ goal.pause();
746
+ trace(`interrupt asked streaming=${streaming}`);
747
+ await controlCall('terminal.live.interrupt', {}, 15_000).then((result) => trace(`interrupt answered ${JSON.stringify(result)}`)).catch((error) => trace(`interrupt failed ${String(error)}`));
748
+ },
749
+ async dispose() {
750
+ if (disposed)
751
+ return;
752
+ disposed = true;
753
+ stopReading.abort();
754
+ // The reply belongs to the harness: the parent ends it, or keeps it running for a reattach.
755
+ for (const wake of idleWaiters)
756
+ wake();
757
+ for (const dialog of dialogs.values())
758
+ dialog.abort();
759
+ listeners.clear();
760
+ changes.clear();
761
+ beforeChanges.clear();
762
+ }
763
+ };
764
+ void pump();
765
+ await announce();
766
+ if (recovered) {
767
+ const effort = recovered.effort ? ` at ${recovered.effort} effort` : '';
768
+ host.notify(`This account cannot use ${recovered.recorded}. Started on ${recovered.reference}${effort}, your latest choice on this account.`);
769
+ }
770
+ return runtime;
771
+ }