@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,436 @@
1
+ // The terminal as a client of the harness (ADR-0083, phase 2). The terminal frontend no longer runs
2
+ // Pi: each turn it sends is a `sessions.run { live: true }` of this terminal's conversation, and the
3
+ // frontend draws from the live session's native events and its own read of the history file.
4
+ // These are protected session-socket routes (INV-TUI-4): the account, folder, variant and caller come
5
+ // from preparation, never from the request.
6
+ import { randomUUID } from 'node:crypto';
7
+ import { HarnessError, invalid } from "../core/errors.js";
8
+ import { loadRun, pendingDecisions, readEvents, recordedSubagents } from "../sessions/runStore.js";
9
+ import { rename as renameHistory } from "../sessions/control.js";
10
+ /** Pi's own tools a terminal session has active, as the in-process terminal has them. */
11
+ export const TERMINAL_NATIVE_TOOLS = ['read', 'bash', 'edit', 'write'];
12
+ /** Native events kept for a client that reads late; older ones are dropped and the client resyncs. */
13
+ const JOURNAL = 4000;
14
+ const WAIT_MS = 20_000;
15
+ /** Pi's names for the commands of loaded extensions: a name two extensions share gets `:2`, `:3`. */
16
+ export function registeredCommands(extensions) {
17
+ const all = extensions.flatMap((extension) => [...extension.commands.values()].map((command) => ({ command, source: command.sourceInfo ?? extension.sourceInfo })));
18
+ const counts = new Map();
19
+ for (const { command } of all)
20
+ counts.set(command.name, (counts.get(command.name) ?? 0) + 1);
21
+ const seen = new Map();
22
+ const taken = new Set();
23
+ return all.map(({ command, source }) => {
24
+ const occurrence = (seen.get(command.name) ?? 0) + 1;
25
+ seen.set(command.name, occurrence);
26
+ let invocationName = (counts.get(command.name) ?? 0) > 1 ? `${command.name}:${occurrence}` : command.name;
27
+ let suffix = occurrence;
28
+ while (taken.has(invocationName))
29
+ invocationName = `${command.name}:${++suffix}`;
30
+ taken.add(invocationName);
31
+ return { name: command.name, invocationName, description: command.description ?? '', sourceInfo: source ?? null };
32
+ });
33
+ }
34
+ export class LiveTerminal {
35
+ context;
36
+ sessions;
37
+ runs;
38
+ journal = [];
39
+ seq = 0;
40
+ waiters = new Set();
41
+ pendingRevision = 0;
42
+ pendingWaiters = new Set();
43
+ activeRunId = null;
44
+ lastRunId = null;
45
+ stops = [];
46
+ disposed = false;
47
+ settledListeners = new Set();
48
+ constructor(context, sessions, runs) {
49
+ this.context = context;
50
+ this.sessions = sessions;
51
+ this.runs = runs;
52
+ this.stops.push(sessions.live.watch(context.sessionId, (event) => this.append(event)));
53
+ this.stops.push(runs.listen((event) => {
54
+ // A follow-up the harness started after the last reply is this terminal's reply now.
55
+ const liveActive = sessions.live.status(context.sessionId)?.activeRunId ?? null;
56
+ if (liveActive && liveActive === event.runId && this.activeRunId !== liveActive)
57
+ this.started(liveActive);
58
+ if (event.runId !== this.activeRunId && !this.isOwnChild(event.runId))
59
+ return;
60
+ if (['approval.requested', 'approval.resolved', 'question.requested', 'question.answered'].includes(event.type))
61
+ this.bumpPending();
62
+ // A plugin's dialog is shown by the window itself; its question stays answerable from anywhere.
63
+ if (event.type === 'question.requested' && event.payload.dialog) {
64
+ this.append({ type: 'harness_dialog', runId: event.runId, request: { ...event.payload, requestId: event.payload.questionId } });
65
+ }
66
+ if (event.type === 'question.answered')
67
+ this.append({ type: 'harness_dialog_done', requestId: event.payload.questionId });
68
+ // What a plugin shows (a notice, a status line, a widget) reaches the window with the reply's own events.
69
+ if (event.type === 'extension.notice')
70
+ this.append({ type: 'harness_notice', notice: event.payload });
71
+ // A reply that fails says why in the window, as an in-process prompt's error does.
72
+ if (event.type === 'failed' && event.runId === this.activeRunId) {
73
+ const message = event.payload.message;
74
+ if (typeof message === 'string' && message)
75
+ this.append({ type: 'harness_notice', notice: { kind: 'error', text: message } });
76
+ }
77
+ const ended = event.type === 'completed' || event.type === 'failed' || (event.type === 'status.changed' && event.payload.status === 'interrupted');
78
+ if (ended && event.runId === this.activeRunId) {
79
+ // The window learns that the reply is over even when Pi's loop never started (a command that failed).
80
+ // A goal run says where its goal stands, so the window's /goal shows the harness's own state.
81
+ let goalMode = null;
82
+ try {
83
+ goalMode = loadRun(this.context.root, event.runId).goalMode ?? null;
84
+ }
85
+ catch {
86
+ // No record to read.
87
+ }
88
+ const live = this.sessions.live.status(this.context.sessionId);
89
+ this.append({ type: 'harness_settled', runId: event.runId, status: event.type === 'completed' ? 'completed' : event.type === 'failed' ? 'failed' : 'interrupted', goalMode, sessionFile: live?.sessionFile ?? null, nativeSessionId: live?.nativeSessionId ?? null });
90
+ this.activeRunId = null;
91
+ this.bumpPending();
92
+ // A follow-up the harness queued starts in the same turn of the event loop; settle after it.
93
+ setImmediate(() => {
94
+ if (this.activeRunId || this.disposed)
95
+ return;
96
+ for (const settled of this.settledListeners)
97
+ settled();
98
+ });
99
+ }
100
+ }));
101
+ }
102
+ /** The subagents of the active reply: their approvals and questions are this terminal's too. */
103
+ children() {
104
+ if (!this.activeRunId)
105
+ return [];
106
+ try {
107
+ return recordedSubagents(this.context.root, this.activeRunId);
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ isOwnChild(runId) {
114
+ return this.children().includes(runId);
115
+ }
116
+ append(event) {
117
+ const type = event?.type;
118
+ const last = this.journal.at(-1);
119
+ // A streaming update carries the whole message so far, so a newer one replaces one not yet read.
120
+ if (type === 'message_update' && last?.event?.type === 'message_update')
121
+ this.journal.pop();
122
+ this.journal.push({ seq: ++this.seq, event });
123
+ if (this.journal.length > JOURNAL)
124
+ this.journal.splice(0, this.journal.length - JOURNAL);
125
+ for (const wake of this.waiters)
126
+ wake();
127
+ }
128
+ bumpPending() {
129
+ this.pendingRevision += 1;
130
+ for (const wake of this.pendingWaiters)
131
+ wake();
132
+ }
133
+ async wait(set, ms) {
134
+ await new Promise((resolve) => {
135
+ const wake = () => { clearTimeout(timer); set.delete(wake); resolve(); };
136
+ const timer = setTimeout(wake, ms);
137
+ timer.unref?.();
138
+ set.add(wake);
139
+ });
140
+ }
141
+ known = { nativeSessionId: null, sessionFile: null };
142
+ /** Pi's id of this terminal's conversation, as its harness runs record it. */
143
+ get nativeSessionId() {
144
+ const live = this.sessions.live.status(this.context.sessionId);
145
+ if (live)
146
+ this.known = { nativeSessionId: live.nativeSessionId, sessionFile: live.sessionFile ?? this.known.sessionFile };
147
+ return this.known.nativeSessionId;
148
+ }
149
+ /** The history file of this terminal's conversation, once it has one. */
150
+ get sessionFile() {
151
+ void this.nativeSessionId;
152
+ return this.known.sessionFile;
153
+ }
154
+ /** The reply running now, if one is. */
155
+ get activeRun() {
156
+ return this.activeRunId;
157
+ }
158
+ /** Called each time the terminal's replies have all settled and nothing else is queued. */
159
+ onSettled(listener) {
160
+ this.settledListeners.add(listener);
161
+ return () => { this.settledListeners.delete(listener); };
162
+ }
163
+ /** The run this terminal is showing, or the last one it finished. */
164
+ get lastRun() {
165
+ return this.activeRunId ?? this.lastRunId;
166
+ }
167
+ async call(call, params) {
168
+ if (this.disposed)
169
+ throw new HarnessError('run_not_active', 'This terminal session has closed.');
170
+ switch (call) {
171
+ case 'terminal.live.state': return this.state();
172
+ case 'terminal.live.events': return this.events(params);
173
+ case 'terminal.live.run': return this.run(params);
174
+ case 'terminal.live.steer': {
175
+ const text = this.text(params);
176
+ if (!this.activeRunId)
177
+ throw new HarnessError('run_not_active', 'No reply is running to steer.');
178
+ await this.runs.steer(this.activeRunId, text);
179
+ return { runId: this.activeRunId };
180
+ }
181
+ case 'terminal.live.followUp': {
182
+ const result = await this.sessions.followUp(this.context.sessionId, this.text(params));
183
+ if ('runId' in result)
184
+ this.started(result.runId);
185
+ return result;
186
+ }
187
+ case 'terminal.live.followUp.clear': return { removed: this.sessions.live.clearQueue(this.context.sessionId) };
188
+ case 'terminal.live.interrupt': {
189
+ const runId = this.activeRunId;
190
+ // The run is stopped and recorded at once; Pi's own abort finishes after the reply returns, so
191
+ // the window never waits on a provider stream that is slow to close.
192
+ if (runId)
193
+ void this.runs.interrupt(runId).catch(() => undefined);
194
+ return { runId };
195
+ }
196
+ case 'terminal.live.close': {
197
+ void this.nativeSessionId;
198
+ // A new or switched conversation: what this terminal knows comes from its next run.
199
+ if (params.forget === true)
200
+ this.known = { nativeSessionId: null, sessionFile: null };
201
+ if (params.reload === true)
202
+ this.listed = null;
203
+ return this.sessions.closeLive(this.context.sessionId);
204
+ }
205
+ case 'terminal.live.rename': {
206
+ const name = this.text(params).trim().slice(0, 200);
207
+ // The live session writes its own history; a saved one is renamed in place.
208
+ const session = this.sessions.live.session(this.context.sessionId);
209
+ if (session?.setSessionName && !this.activeRunId) {
210
+ session.setSessionName(name);
211
+ return { name };
212
+ }
213
+ if (this.activeRunId)
214
+ throw new HarnessError('session_busy', 'Rename the conversation after the reply settles.');
215
+ const file = typeof params.sessionFile === 'string' ? params.sessionFile : undefined;
216
+ return renameHistory({ sessionDir: this.context.sessionDir, ...(file ? { sessionFile: file } : {}), cwd: this.context.cwd, name });
217
+ }
218
+ case 'terminal.live.pending': return { revision: this.pendingRevision, requests: this.pending(params.ownDialogs === true) };
219
+ case 'terminal.live.pending.wait': {
220
+ if (params.revision === this.pendingRevision)
221
+ await this.wait(this.pendingWaiters, WAIT_MS);
222
+ return { revision: this.pendingRevision, requests: this.pending(params.ownDialogs === true) };
223
+ }
224
+ case 'terminal.live.approve': return this.approve(params);
225
+ case 'terminal.live.answer': return this.answer(params);
226
+ case 'terminal.live.answers': return this.answers(params);
227
+ default: throw invalid(`Unknown live terminal operation ${call}.`);
228
+ }
229
+ }
230
+ text(params) {
231
+ if (typeof params.text !== 'string' || !params.text.trim())
232
+ throw invalid('Text is required.');
233
+ return params.text;
234
+ }
235
+ listed = null;
236
+ async state() {
237
+ const live = this.sessions.live.status(this.context.sessionId);
238
+ const session = this.sessions.live.session(this.context.sessionId);
239
+ const view = session ? sessionView(session) : null;
240
+ // Before the first turn there is no live session to ask; the trusted extensions are listed instead.
241
+ const commands = view ? view.commands : await (this.listed ??= (this.context.commands?.() ?? Promise.resolve([])).catch((error) => {
242
+ this.listed = null;
243
+ throw error;
244
+ }));
245
+ return {
246
+ seq: this.seq,
247
+ activeRunId: this.activeRunId,
248
+ lastRunId: this.lastRunId,
249
+ live,
250
+ session: view,
251
+ commands
252
+ };
253
+ }
254
+ async events(params) {
255
+ const after = typeof params.after === 'number' && Number.isFinite(params.after) ? params.after : 0;
256
+ const read = () => {
257
+ const first = this.journal[0]?.seq ?? this.seq + 1;
258
+ // Events the client missed were dropped: it must read the session again.
259
+ const reset = after > 0 && after < first - 1;
260
+ return { seq: this.seq, events: this.journal.filter((entry) => entry.seq > after), reset };
261
+ };
262
+ let result = read();
263
+ if (!result.events.length && params.wait !== false) {
264
+ await this.wait(this.waiters, typeof params.waitMs === 'number' ? Math.min(Math.max(params.waitMs, 0), WAIT_MS) : WAIT_MS);
265
+ result = read();
266
+ }
267
+ return result;
268
+ }
269
+ started(runId) {
270
+ this.activeRunId = runId;
271
+ this.lastRunId = runId;
272
+ this.bumpPending();
273
+ }
274
+ async run(params) {
275
+ const { context } = this;
276
+ if (this.activeRunId)
277
+ throw new HarnessError('session_busy', 'A reply is running. Steer it, or queue the message as a follow-up.');
278
+ const text = typeof params.text === 'string' ? params.text : '';
279
+ const command = params.command;
280
+ if (!text.trim() && !command)
281
+ throw invalid('Text is required.');
282
+ const images = Array.isArray(params.images) ? params.images.filter((image) => !!image && typeof image.mediaType === 'string' && typeof image.data === 'string') : undefined;
283
+ const resume = params.resume && typeof params.resume === 'object'
284
+ ? { sessionFile: typeof params.resume.sessionFile === 'string' ? params.resume.sessionFile : null,
285
+ nativeSessionId: typeof params.resume.nativeSessionId === 'string' ? params.resume.nativeSessionId : null }
286
+ : null;
287
+ const runId = randomUUID();
288
+ const native = context.readOnly ? TERMINAL_NATIVE_TOOLS.filter((name) => !['bash', 'edit', 'write'].includes(name)) : TERMINAL_NATIVE_TOOLS;
289
+ const allow = [...new Set([...native, ...(await context.tools?.() ?? [])])];
290
+ const input = {
291
+ run: { id: runId, sessionId: context.sessionId, sessionType: 'terminal', mode: 'code' },
292
+ accountId: context.accountId,
293
+ model: typeof params.model === 'string' ? params.model : null,
294
+ effort: typeof params.effort === 'string' ? params.effort : null,
295
+ cwd: context.cwd,
296
+ sessionDir: context.sessionDir,
297
+ ...(resume?.sessionFile ? { resume } : {}),
298
+ ...(params.newHistory === true && !resume?.sessionFile ? { newHistory: true } : {}),
299
+ text: command ? `/${command.name}` : text,
300
+ ...(images?.length ? { images } : {}),
301
+ ...(typeof params.initialBashCommand === 'string' ? { initialBashCommand: params.initialBashCommand } : {}),
302
+ caller: context.caller,
303
+ variant: context.variant,
304
+ launch: context.launch,
305
+ tools: { allow, gated: [] },
306
+ connections: context.connections,
307
+ ...(context.subagents !== undefined ? { subagents: context.subagents } : {}),
308
+ // The terminal sends a plan only the tools it may use, as the in-process terminal does.
309
+ ...(params.plan === true ? { plan: true, planTools: 'active' } : {}),
310
+ ...(params.goal && typeof params.goal === 'object' ? { goal: params.goal } : {}),
311
+ ...(Array.isArray(params.hiddenNotes) && params.hiddenNotes.length ? { hiddenNotes: params.hiddenNotes } : {}),
312
+ ...(command ? { command } : { live: true })
313
+ };
314
+ this.started(runId);
315
+ try {
316
+ const result = await this.sessions.run(input);
317
+ if (!result.nativeSessionId)
318
+ this.activeRunId = null;
319
+ return result;
320
+ }
321
+ catch (error) {
322
+ this.activeRunId = null;
323
+ this.bumpPending();
324
+ throw error;
325
+ }
326
+ }
327
+ /** The decisions waiting; `ownDialogs` leaves out plugin dialogs the window shows itself. */
328
+ pending(ownDialogs = false) {
329
+ const runId = this.activeRunId;
330
+ if (!runId)
331
+ return [];
332
+ return [runId, ...this.children()].flatMap((id) => {
333
+ try {
334
+ return pendingDecisions(readEvents(this.context.root, id)).filter((decision) => !ownDialogs || !decision.payload.dialog).map((decision) => ({
335
+ ...decision.payload,
336
+ type: decision.kind,
337
+ requestId: decision.id,
338
+ runId: id
339
+ }));
340
+ }
341
+ catch {
342
+ return [];
343
+ }
344
+ });
345
+ }
346
+ decision(params, kind) {
347
+ const requestId = typeof params.requestId === 'string' ? params.requestId : '';
348
+ const request = this.pending().find((entry) => entry.requestId === requestId && entry.type === kind);
349
+ if (!request)
350
+ throw new HarnessError('invalid_request', 'This terminal decision is no longer pending.');
351
+ return request;
352
+ }
353
+ approve(params) {
354
+ const request = this.decision(params, 'approval');
355
+ const decision = params.decision === 'approved' ? 'approved' : params.decision === 'denied' ? 'denied' : null;
356
+ if (!decision)
357
+ throw invalid('A decision must be approved or denied.');
358
+ if (decision === 'approved' && params.alwaysAllow === true) {
359
+ if (request.rememberable !== true || typeof request.target !== 'string')
360
+ throw invalid('This action cannot be always allowed.');
361
+ this.context.allowTool(request.target);
362
+ }
363
+ this.runs.resolveApproval(String(request.runId), String(request.requestId), decision);
364
+ return {};
365
+ }
366
+ answer(params) {
367
+ const request = this.decision(params, 'question');
368
+ this.runs.answerQuestion(String(request.runId), String(request.requestId), params.answer);
369
+ return {};
370
+ }
371
+ answers(params) {
372
+ const batch = this.pending().find((entry) => entry.batchId === params.batchId);
373
+ if (!batch || typeof params.batchId !== 'string' || !Array.isArray(params.answers))
374
+ throw invalid('This question batch is no longer pending.');
375
+ this.runs.answerQuestions(String(batch.runId), params.batchId, params.answers);
376
+ return {};
377
+ }
378
+ /** A message from another surface of this terminal (remote control): a new reply, or a follow-up. */
379
+ async send(text) {
380
+ if (this.activeRunId)
381
+ return this.sessions.followUp(this.context.sessionId, text);
382
+ const state = this.sessions.live.status(this.context.sessionId);
383
+ const result = await this.run({ text, ...(state?.sessionFile ? { resume: { sessionFile: state.sessionFile, nativeSessionId: state.nativeSessionId } } : {}) });
384
+ return { runId: result.runId };
385
+ }
386
+ async dispose() {
387
+ if (this.disposed)
388
+ return;
389
+ this.disposed = true;
390
+ if (this.activeRunId)
391
+ await this.runs.interrupt(this.activeRunId).catch(() => undefined);
392
+ for (const stop of this.stops.splice(0))
393
+ stop();
394
+ this.sessions.closeLive(this.context.sessionId);
395
+ for (const wake of [...this.waiters, ...this.pendingWaiters])
396
+ wake();
397
+ }
398
+ }
399
+ function sessionView(session) {
400
+ const commands = (() => {
401
+ try {
402
+ return (session.extensionRunner?.getRegisteredCommands() ?? []).map((command) => ({
403
+ name: command.name,
404
+ invocationName: command.invocationName ?? command.name,
405
+ description: command.description ?? '',
406
+ sourceInfo: command.sourceInfo ?? null
407
+ }));
408
+ }
409
+ catch {
410
+ return [];
411
+ }
412
+ })();
413
+ const usage = (() => {
414
+ try {
415
+ return session.getContextUsage?.() ?? null;
416
+ }
417
+ catch {
418
+ return null;
419
+ }
420
+ })();
421
+ return {
422
+ sessionId: session.sessionId,
423
+ sessionFile: session.sessionFile ?? null,
424
+ sessionName: session.sessionName ?? null,
425
+ systemPrompt: session.systemPrompt ?? '',
426
+ model: session.model ? { id: session.model.id, provider: session.model.provider, name: session.model.name ?? session.model.id, reasoning: !!session.model.reasoning, contextWindow: session.model.contextWindow ?? 0, input: session.model.input ?? ['text'] } : null,
427
+ thinkingLevel: session.thinkingLevel ?? null,
428
+ isStreaming: !!session.isStreaming,
429
+ activeTools: session.getActiveToolNames?.() ?? [],
430
+ tools: (session.getAllTools?.() ?? []).map((tool) => ({ name: tool.name, description: tool.description ?? '' })),
431
+ steering: [...(session.getSteeringMessages?.() ?? [])],
432
+ followUp: [...(session.getFollowUpMessages?.() ?? [])],
433
+ context: usage ? { percent: usage.percent ?? null, window: usage.contextWindow ?? 0 } : null,
434
+ commands
435
+ };
436
+ }
@@ -1,7 +1,8 @@
1
1
  import { spawn as nodeSpawn } from 'node:child_process';
2
2
  import { type SecretStore } from "../accounts/secrets.js";
3
- import { type SecretBackendDescriptor } from "../core/interactiveProfile.js";
3
+ import { type InteractiveProfile, type SecretBackendDescriptor } from "../core/interactiveProfile.js";
4
4
  import { type Harness, type HarnessOptions } from "../harness.js";
5
+ import type { TerminalLaunch } from "./terminals.js";
5
6
  export interface StandalonePaths {
6
7
  dataRoot: string;
7
8
  stateDir: string;
@@ -16,6 +17,11 @@ export interface StandalonePaths {
16
17
  variant?: string | null;
17
18
  /** `--remote-control`, `--rc`: turn on `/remote-control` as the terminal opens. */
18
19
  remoteControl?: boolean;
20
+ /**
21
+ * `--daemon`: run in the long-lived harness for these folders, starting it when it is not running,
22
+ * so a reply outlives this window (ADR-0083). The frontend is then a client of the harness.
23
+ */
24
+ daemon?: boolean;
19
25
  }
20
26
  export interface StandaloneDependencies {
21
27
  home?: string;
@@ -30,5 +36,17 @@ export interface StandaloneDependencies {
30
36
  spawn?: typeof nodeSpawn;
31
37
  }
32
38
  export declare function standaloneArguments(argv: string[]): string[] | null;
39
+ export interface ResolvedStandaloneOptions {
40
+ paths: StandalonePaths;
41
+ profile: InteractiveProfile;
42
+ }
43
+ export declare function resolveStandaloneOptions(argv: string[], home?: string, cwd?: string): ResolvedStandaloneOptions;
44
+ /** The harness a standalone launch, or the long-lived harness for the same folders, runs. */
45
+ export declare function createStandaloneHarness(paths: StandalonePaths, profile: InteractiveProfile, dependencies?: Pick<StandaloneDependencies, 'createHarness' | 'hostSecretStore'>): Promise<Harness>;
46
+ /** What every launch needs before its first terminal: the profile's default variant and an account. */
47
+ export declare function ensureStandaloneDefaults(harness: Harness, paths: StandalonePaths, profile: InteractiveProfile): string;
48
+ /** Where a terminal in `cwd` keeps its compiled instructions and native history. */
49
+ export declare function terminalSessionFolder(dataRoot: string, cwd: string): string;
33
50
  export declare function parseStandaloneOptions(argv: string[], home?: string, cwd?: string): StandalonePaths;
51
+ export declare function spawnFrontend(launch: TerminalLaunch, cwd: string, spawn?: typeof nodeSpawn): Promise<number>;
34
52
  export declare function runStandaloneTerminal(argv: string[], dependencies?: StandaloneDependencies): Promise<number>;
@@ -18,19 +18,24 @@ export function standaloneArguments(argv) {
18
18
  return argv;
19
19
  return command === 'terminal' ? argv.slice(1) : null;
20
20
  }
21
- function resolveStandaloneOptions(argv, home = homedir(), cwd = process.cwd()) {
21
+ export function resolveStandaloneOptions(argv, home = homedir(), cwd = process.cwd()) {
22
22
  const overrides = {};
23
23
  const given = new Set();
24
24
  let worktree;
25
25
  let variant;
26
26
  let accountState;
27
27
  let remoteControl = false;
28
+ let daemon = false;
28
29
  for (let index = 0; index < argv.length; index += 1) {
29
30
  const argument = argv[index];
30
31
  if (argument === '--remote-control' || argument === '--rc') {
31
32
  remoteControl = true;
32
33
  continue;
33
34
  }
35
+ if (argument === '--daemon') {
36
+ daemon = true;
37
+ continue;
38
+ }
34
39
  if (argument === '--worktree' || argument === '-w') {
35
40
  const name = argv[index + 1];
36
41
  if (name && !name.startsWith('-')) {
@@ -113,10 +118,41 @@ function resolveStandaloneOptions(argv, home = homedir(), cwd = process.cwd()) {
113
118
  ...(accountState !== undefined ? { accountState } : {}),
114
119
  ...(worktree !== undefined ? { worktree } : {}),
115
120
  ...(variant !== undefined ? { variant } : {}),
116
- ...(remoteControl ? { remoteControl } : {})
121
+ ...(remoteControl ? { remoteControl } : {}),
122
+ ...(daemon ? { daemon } : {})
117
123
  }
118
124
  };
119
125
  }
126
+ /** The harness a standalone launch, or the long-lived harness for the same folders, runs. */
127
+ export async function createStandaloneHarness(paths, profile, dependencies = {}) {
128
+ // `--account-state` takes both halves of another state's account identity, so its credentials
129
+ // come from that state's own file store rather than from this launch's profile backend.
130
+ const secrets = paths.accountState
131
+ ? fileSecretStore(join(paths.accountState, 'secrets.json'))
132
+ : await interactiveProfileSecretStore(profile, dependencies.hostSecretStore);
133
+ const makeHarness = dependencies.createHarness ?? createHarness;
134
+ return makeHarness({
135
+ dataRoot: paths.dataRoot,
136
+ stateDir: paths.stateDir,
137
+ ...(paths.accountState ? { accountsDir: join(paths.accountState, 'accounts') } : {}),
138
+ productName: DEFAULT_PRODUCT_NAME,
139
+ secrets
140
+ });
141
+ }
142
+ /** What every launch needs before its first terminal: the profile's default variant and an account. */
143
+ export function ensureStandaloneDefaults(harness, paths, profile) {
144
+ const variantScope = paths.variant ?? profile.defaultVariantScope;
145
+ if (variantScope === profile.defaultVariantScope && !variantStore.head(paths.dataRoot, variantScope)) {
146
+ variantStore.seed(paths.dataRoot, variantScope, seedVariant(paths.dataRoot, {}), 'interactive profile default');
147
+ }
148
+ if (harness.accounts.overview().accounts.length === 0)
149
+ harness.accounts.create('Default', 'subscription');
150
+ return variantScope;
151
+ }
152
+ /** Where a terminal in `cwd` keeps its compiled instructions and native history. */
153
+ export function terminalSessionFolder(dataRoot, cwd) {
154
+ return join(dataRoot, 'sessions', 'terminal', createHash('sha256').update(cwd).digest('hex'));
155
+ }
120
156
  export function parseStandaloneOptions(argv, home = homedir(), cwd = process.cwd()) {
121
157
  return resolveStandaloneOptions(argv, home, cwd).paths;
122
158
  }
@@ -148,7 +184,7 @@ function spawnError(error, launch) {
148
184
  const problem = code === 'ENOENT' ? 'was not found' : `could not start (${code})`;
149
185
  return new Error(`Could not start the Harness terminal. The Bun executable "${launch.program}" ${problem}; install Bun or set XINGULAR_HARNESS_BUN, then run npm run build:tui.`, { cause: error });
150
186
  }
151
- function spawnFrontend(launch, cwd, spawn = nodeSpawn) {
187
+ export function spawnFrontend(launch, cwd, spawn = nodeSpawn) {
152
188
  return new Promise((resolveExit, reject) => {
153
189
  const child = spawn(launch.program, launch.args, {
154
190
  cwd,
@@ -156,10 +192,13 @@ function spawnFrontend(launch, cwd, spawn = nodeSpawn) {
156
192
  stdio: 'inherit'
157
193
  });
158
194
  let settled = false;
195
+ let fatal = false;
159
196
  const cleanup = () => {
160
197
  process.off('SIGINT', onInterrupt);
161
198
  process.off('SIGTERM', onTerminate);
162
199
  process.off('SIGHUP', onHangup);
200
+ process.off('uncaughtException', onFatal);
201
+ process.off('unhandledRejection', onFatal);
163
202
  };
164
203
  const finish = (action) => {
165
204
  if (settled)
@@ -174,12 +213,36 @@ function spawnFrontend(launch, cwd, spawn = nodeSpawn) {
174
213
  const onTerminate = () => {
175
214
  child.kill('SIGTERM');
176
215
  };
177
- const onHangup = () => undefined;
216
+ // The window closed: its frontend gets the same hangup and should end. One that is still there
217
+ // after a moment has no terminal left to restore, so it is ended, and this host carries on
218
+ // (a detached reply keeps running in the long-lived harness).
219
+ const onHangup = () => {
220
+ setTimeout(() => {
221
+ if (child.exitCode === null && child.signalCode === null)
222
+ child.kill('SIGKILL');
223
+ }, 3000).unref();
224
+ };
225
+ // A client frontend's plugins run in this process (ADR-0083). An error nothing caught ends it, as
226
+ // it ends an in-process frontend, but the window closes first so the terminal is left as it was.
227
+ const onFatal = (error) => {
228
+ if (fatal)
229
+ return;
230
+ fatal = true;
231
+ process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
232
+ child.kill('SIGTERM');
233
+ setTimeout(() => process.exit(1), 5000).unref();
234
+ };
178
235
  process.on('SIGINT', onInterrupt);
236
+ process.on('uncaughtException', onFatal);
237
+ process.on('unhandledRejection', onFatal);
179
238
  process.on('SIGTERM', onTerminate);
180
239
  process.on('SIGHUP', onHangup);
181
240
  child.once('error', (error) => finish(() => reject(spawnError(error, launch))));
182
- child.once('exit', (code, signal) => finish(() => resolveExit(exitCode(code, signal))));
241
+ child.once('exit', (code, signal) => {
242
+ if (fatal)
243
+ process.exit(1);
244
+ finish(() => resolveExit(exitCode(code, signal)));
245
+ });
183
246
  });
184
247
  }
185
248
  /**
@@ -206,19 +269,11 @@ export async function runStandaloneTerminal(argv, dependencies = {}) {
206
269
  }
207
270
  let cwd = resolve(dependencies.cwd ?? process.cwd());
208
271
  const { paths, profile } = resolveStandaloneOptions(argv, dependencies.home, cwd);
209
- // `--account-state` takes both halves of another state's account identity, so its credentials
210
- // come from that state's own file store rather than from this launch's profile backend.
211
- const secrets = paths.accountState
212
- ? fileSecretStore(join(paths.accountState, 'secrets.json'))
213
- : await interactiveProfileSecretStore(profile, dependencies.hostSecretStore);
214
- const makeHarness = dependencies.createHarness ?? createHarness;
215
- const harness = makeHarness({
216
- dataRoot: paths.dataRoot,
217
- stateDir: paths.stateDir,
218
- ...(paths.accountState ? { accountsDir: join(paths.accountState, 'accounts') } : {}),
219
- productName: DEFAULT_PRODUCT_NAME,
220
- secrets
221
- });
272
+ if (paths.daemon) {
273
+ const { runDaemonTerminal } = await import("./daemon.js");
274
+ return runDaemonTerminal({ paths, profile, cwd, ...(dependencies.spawn ? { spawn: dependencies.spawn } : {}), ...(dependencies.createSessionId ? { createSessionId: dependencies.createSessionId } : {}) });
275
+ }
276
+ const harness = await createStandaloneHarness(paths, profile, dependencies);
222
277
  const newSessionId = dependencies.createSessionId ?? randomUUID;
223
278
  let current = { sessionId: newSessionId(), accountId: null, nativeSessionId: null };
224
279
  // The frontend draws on this terminal, so the host's diagnostics go to a file the person can read instead.
@@ -233,12 +288,7 @@ export async function runStandaloneTerminal(argv, dependencies = {}) {
233
288
  });
234
289
  try {
235
290
  await harness.ready;
236
- const variantScope = paths.variant ?? profile.defaultVariantScope;
237
- if (variantScope === profile.defaultVariantScope && !variantStore.head(paths.dataRoot, variantScope)) {
238
- variantStore.seed(paths.dataRoot, variantScope, seedVariant(paths.dataRoot, {}), 'interactive profile default');
239
- }
240
- if (harness.accounts.overview().accounts.length === 0)
241
- harness.accounts.create('Default', 'subscription');
291
+ const variantScope = ensureStandaloneDefaults(harness, paths, profile);
242
292
  let worktreeId = null;
243
293
  if (paths.worktree) {
244
294
  const worktree = await enterWorktree(harness, cwd, paths.worktree);
@@ -248,7 +298,7 @@ export async function runStandaloneTerminal(argv, dependencies = {}) {
248
298
  // The version this conversation runs: the head at start, or one the person switched to (PROP-0052).
249
299
  let variantVersion = null;
250
300
  for (;;) {
251
- const sessionFolder = join(paths.dataRoot, 'sessions', 'terminal', createHash('sha256').update(cwd).digest('hex'));
301
+ const sessionFolder = terminalSessionFolder(paths.dataRoot, cwd);
252
302
  const launch = await harness.terminals.prepare({ ...current, cwd, sessionFolder, variant: { scope: variantScope, ...(variantVersion ? { version: variantVersion } : {}) }, ...(worktreeId ? { run: { worktreeId } } : {}) });
253
303
  const code = await spawnFrontend(paths.remoteControl ? { ...launch, env: { ...launch.env, [REMOTE_CONTROL_ENV]: '1' } } : launch, cwd, dependencies.spawn);
254
304
  // `/accounts` and `/worktree` ask for a switch and end the frontend. An account switch resumes