@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
@@ -60,6 +60,18 @@ export interface TerminalPrepareInput {
60
60
  connections?: boolean;
61
61
  /** Fields copied onto each run this session records. */
62
62
  run?: Partial<Pick<RunRecord, 'mode' | 'worktreeId'>>;
63
+ /** Reattach to this session id's detached client terminal, whose reply kept running (ADR-0083). */
64
+ attach?: boolean;
65
+ }
66
+ /** A client terminal whose window closed while its reply was running (ADR-0083, phase 3). */
67
+ export interface DetachedTerminal {
68
+ sessionId: string;
69
+ cwd: string;
70
+ sessionFolder: string;
71
+ accountId: string;
72
+ activeRunId: string | null;
73
+ sessionFile: string | null;
74
+ detachedAt: string;
63
75
  }
64
76
  /** What a session asked to restart as: another account, or another folder (a worktree or the main checkout). */
65
77
  export interface TerminalRelaunch {
@@ -118,6 +130,8 @@ export interface TerminalsOptions {
118
130
  remoteControl?: RemoteControlOptions;
119
131
  /** Refinements, for `/refine` (PROP-0052). */
120
132
  refine?: Refinements;
133
+ /** The harness's sessions: a terminal frontend that is a client of the harness runs its turns here (ADR-0083). */
134
+ sessionService?: import("../sessions/sessions.js").Sessions;
121
135
  }
122
136
  /** What `/plugins` in a terminal can do. Each takes the session's folder, account and project. */
123
137
  export interface PluginControl {
@@ -168,6 +182,10 @@ export declare class Terminals {
168
182
  private readonly worktreeLeases;
169
183
  /** `/remote-control` for each live session that opened it. */
170
184
  private readonly remotes;
185
+ /** Terminals whose frontend is a client of the harness (ADR-0083). */
186
+ private readonly clients;
187
+ /** Client terminals whose window closed while a reply ran; they end when it settles. */
188
+ private readonly detachedClients;
171
189
  /** Each child's profile name and colour, looked up once per child run. */
172
190
  private readonly childProfiles;
173
191
  private subagentProfile;
@@ -264,6 +282,31 @@ export declare class Terminals {
264
282
  private remotePort;
265
283
  private remoteCall;
266
284
  /** The terminal process ended (or never started): its turn, tokens, and written credentials go with it. */
285
+ /**
286
+ * The native conversation a terminal's runs carry. A client terminal's runs are harness runs, which
287
+ * record Pi's session id; an in-process terminal's record its history file (ADR-0083).
288
+ */
289
+ private nativeOf;
290
+ /** The history file a relaunch of this terminal resumes. */
291
+ private historyFileOf;
292
+ /**
293
+ * Where a terminal's decision and stop calls go: its controller, or, once the frontend is a client
294
+ * of the harness, the live session's run (ADR-0083).
295
+ */
296
+ private controlFor;
297
+ /** The client half of a terminal whose frontend runs no Pi of its own (ADR-0083). */
298
+ private liveTerminal;
299
+ /**
300
+ * The terminal's window closed. A client terminal whose reply is still running keeps it running in
301
+ * the harness, without the window, until it settles; `prepare({ attach: true })` reconnects to it.
302
+ * Anything else ends now, as `dispose` does.
303
+ */
304
+ detach(sessionId: string): Promise<{
305
+ detached: boolean;
306
+ activeRunId: string | null;
307
+ }>;
308
+ /** Client terminals whose reply is running without a window. */
309
+ detached(): DetachedTerminal[];
267
310
  dispose(sessionId: string): Promise<void>;
268
311
  close(): Promise<void>;
269
312
  }
@@ -17,6 +17,18 @@ import { newId, now } from "../core/fs.js";
17
17
  import { formatBashCommandForApproval } from "../core/workspaceBashTool.js";
18
18
  import { resolveWorkspaceRoot } from "../core/workspace.js";
19
19
  import { prependSearchPath, searchPath } from "../core/path.js";
20
+ import { LiveTerminal, registeredCommands } from "./liveTerminal.js";
21
+ import { loadResources } from "../sessions/runner.js";
22
+ import { loadPi } from "../pi/runtime.js";
23
+ /** A client terminal's decision and stop calls, and the live-session routes that answer them. */
24
+ const LIVE_DECISION_CALLS = {
25
+ 'terminal.pending': 'terminal.live.pending',
26
+ 'terminal.pending.wait': 'terminal.live.pending.wait',
27
+ 'terminal.approve': 'terminal.live.approve',
28
+ 'terminal.answer': 'terminal.live.answer',
29
+ 'terminal.answers': 'terminal.live.answers',
30
+ 'terminal.interrupt': 'terminal.live.interrupt'
31
+ };
20
32
  import { dispatch } from "../tools/dispatch.js";
21
33
  import { defaultSkillPaths } from "../tools/defaultSkills.js";
22
34
  import { materialise } from "../variant/materialise.js";
@@ -94,6 +106,10 @@ export class Terminals {
94
106
  worktreeLeases = new Map();
95
107
  /** `/remote-control` for each live session that opened it. */
96
108
  remotes = new Map();
109
+ /** Terminals whose frontend is a client of the harness (ADR-0083). */
110
+ clients = new Map();
111
+ /** Client terminals whose window closed while a reply ran; they end when it settles. */
112
+ detachedClients = new Map();
97
113
  /** Each child's profile name and colour, looked up once per child run. */
98
114
  childProfiles = new Map();
99
115
  subagentProfile(record) {
@@ -128,7 +144,7 @@ export class Terminals {
128
144
  */
129
145
  hydrateMonitor(live, owners) {
130
146
  const { root } = this.options.env;
131
- const native = live.controller.nativeSessionId;
147
+ const native = this.nativeOf(live);
132
148
  if (!native)
133
149
  return;
134
150
  for (const id of [...owners].reverse()) {
@@ -504,7 +520,7 @@ export class Terminals {
504
520
  case 'accounts.relaunch': {
505
521
  // Checked here so a bad choice is refused before the session is asked to end.
506
522
  const target = accounts.resolve(text('accountId'));
507
- this.relaunches.set(sessionId, { accountId: target.id, nativeSessionId: live.controller.nativeSessionId });
523
+ this.relaunches.set(sessionId, { accountId: target.id, nativeSessionId: this.historyFileOf(live) });
508
524
  return { accountId: target.id, label: target.label };
509
525
  }
510
526
  default:
@@ -612,7 +628,7 @@ export class Terminals {
612
628
  if (!runId)
613
629
  return { attribution: null, text: null };
614
630
  if (params.runId !== undefined)
615
- readTerminalTrajectory(this.options.env.root, live.input.sessionId, live.controller.nativeSessionId, runId);
631
+ readTerminalTrajectory(this.options.env.root, live.input.sessionId, this.nativeOf(live), runId);
616
632
  const text = typeof params.section === 'string' && params.section ? rowText(this.options.env, runId, params.section) : null;
617
633
  return { attribution: attribution(this.options.env, runId), text };
618
634
  }
@@ -1090,23 +1106,23 @@ export class Terminals {
1090
1106
  root: this.options.env.root,
1091
1107
  bus: this.options.bus,
1092
1108
  sessionId,
1093
- nativeSessionId: live.controller.nativeSessionId,
1109
+ nativeSessionId: this.nativeOf(live),
1094
1110
  scope,
1095
1111
  pinned: live.variant?.fingerprint ?? null,
1096
1112
  active: () => Boolean(this.options.runs.activeForSession(sessionId)) || live.controller.state().active,
1097
- relaunchOn: (version) => this.relaunches.set(sessionId, { accountId: live.account.id, nativeSessionId: live.controller.nativeSessionId, variantVersion: version }),
1113
+ relaunchOn: (version) => this.relaunches.set(sessionId, { accountId: live.account.id, nativeSessionId: this.historyFileOf(live), variantVersion: version }),
1098
1114
  refine: this.options.refine ?? null,
1099
1115
  started
1100
1116
  };
1101
1117
  }
1102
1118
  trajectoryCall(live, call, params) {
1103
1119
  if (call === 'terminal.trajectory.list') {
1104
- return { runs: listTerminalTrajectoryRuns(this.options.env.root, live.input.sessionId, live.controller.nativeSessionId) };
1120
+ return { runs: listTerminalTrajectoryRuns(this.options.env.root, live.input.sessionId, this.nativeOf(live)) };
1105
1121
  }
1106
1122
  if (call === 'terminal.trajectory.details') {
1107
1123
  if (typeof params.runId !== 'string' || !params.runId)
1108
1124
  throw invalid('A run id is required.');
1109
- readTerminalTrajectory(this.options.env.root, live.input.sessionId, live.controller.nativeSessionId, params.runId);
1125
+ readTerminalTrajectory(this.options.env.root, live.input.sessionId, this.nativeOf(live), params.runId);
1110
1126
  return runDetails(this.options.env.root, params.runId);
1111
1127
  }
1112
1128
  if (call === 'terminal.trajectory.events') {
@@ -1115,7 +1131,7 @@ export class Terminals {
1115
1131
  const afterSequence = params.afterSequence;
1116
1132
  if (afterSequence !== undefined && typeof afterSequence !== 'number')
1117
1133
  throw invalid('The event sequence must be a number.');
1118
- return readTerminalTrajectory(this.options.env.root, live.input.sessionId, live.controller.nativeSessionId, params.runId, afterSequence);
1134
+ return readTerminalTrajectory(this.options.env.root, live.input.sessionId, this.nativeOf(live), params.runId, afterSequence);
1119
1135
  }
1120
1136
  throw new HarnessError('unknown_method', `Unknown call ${call}.`);
1121
1137
  }
@@ -1129,7 +1145,7 @@ export class Terminals {
1129
1145
  const context = params.context && typeof params.context === 'object' && !Array.isArray(params.context)
1130
1146
  ? params.context
1131
1147
  : undefined;
1132
- const latest = listTerminalTrajectoryRuns(this.options.env.root, live.input.sessionId, live.controller.nativeSessionId)
1148
+ const latest = listTerminalTrajectoryRuns(this.options.env.root, live.input.sessionId, this.nativeOf(live))
1133
1149
  .find(run => findRun(this.options.env.root, run.id)?.trigger !== 'side-question');
1134
1150
  return service.start({
1135
1151
  sessionId: live.input.sessionId,
@@ -1174,6 +1190,18 @@ export class Terminals {
1174
1190
  const { root } = env;
1175
1191
  if (!input?.sessionId || !input.cwd || !input.sessionFolder)
1176
1192
  throw invalid('A terminal session needs an id, a working folder, and its own folder.');
1193
+ const carried = input.attach ? this.detachedClients.get(input.sessionId) ?? null : null;
1194
+ if (input.attach) {
1195
+ if (!carried)
1196
+ throw new HarnessError('not_found', 'There is no detached terminal session with this id.');
1197
+ if (carried.info.cwd !== input.cwd || carried.info.sessionFolder !== input.sessionFolder || (input.accountId && input.accountId !== carried.info.accountId)) {
1198
+ throw invalid('Reattach a terminal session in its own folder, on its own account.');
1199
+ }
1200
+ this.detachedClients.delete(input.sessionId);
1201
+ carried.stop();
1202
+ input.accountId = carried.info.accountId;
1203
+ input.nativeSessionId = carried.client.sessionFile ?? input.nativeSessionId ?? null;
1204
+ }
1177
1205
  await this.dispose(input.sessionId);
1178
1206
  const sessionId = input.sessionId;
1179
1207
  let acquiredWorktreeId = null;
@@ -1250,6 +1278,8 @@ export class Terminals {
1250
1278
  answerQuestions: (runId, batchId, answers) => this.options.runs.answerQuestions(runId, batchId, answers)
1251
1279
  })
1252
1280
  };
1281
+ if (carried)
1282
+ this.clients.set(sessionId, carried.client);
1253
1283
  token = this.socket.issue({
1254
1284
  sessionId,
1255
1285
  caller: () => ({ ...caller, runId: live.controller.activeRun?.runId ?? null }),
@@ -1356,6 +1386,8 @@ export class Terminals {
1356
1386
  return doctor.cancel(id);
1357
1387
  throw invalid('Unknown diagnostic operation.');
1358
1388
  }
1389
+ if (call.startsWith('terminal.live.'))
1390
+ return this.liveTerminal(live).call(call, params);
1359
1391
  if (call.startsWith('terminal.agents.'))
1360
1392
  return this.agentsCall(live, call, params);
1361
1393
  if (call.startsWith('terminal.subagents.'))
@@ -1387,7 +1419,7 @@ export class Terminals {
1387
1419
  if (call.startsWith('terminal.trajectory.'))
1388
1420
  return this.trajectoryCall(live, call, params);
1389
1421
  if (call === 'terminal.todo')
1390
- return latestTerminalTodo(this.options.env.root, live.input.sessionId, live.controller.nativeSessionId);
1422
+ return latestTerminalTodo(this.options.env.root, live.input.sessionId, this.nativeOf(live));
1391
1423
  if (call === 'terminal.permissions.get') {
1392
1424
  const policy = this.options.approvals.read();
1393
1425
  return { full: this.options.approvals.isAuto('pi'), globalTools: policy.globalTools.pi ?? [] };
@@ -1412,7 +1444,7 @@ export class Terminals {
1412
1444
  if (call.startsWith('terminal.remoteControl.'))
1413
1445
  return this.remoteCall(live, call, params);
1414
1446
  if (call !== 'terminal.resources')
1415
- return live.controller.control(call, params);
1447
+ return this.controlFor(live)(call, params);
1416
1448
  const current = await this.options.plugins?.loading({ cwd: input.cwd, agentDir: configDir(accounts.dir, preparedAccount), install: false, caller: { projectId: input.projectId ?? null } }) ?? null;
1417
1449
  const currentVariant = variant ? materialise(root, { dir: variant.dir, scope: variant.scope }, { trust: this.options.trust ?? null }) : null;
1418
1450
  if (resourceIdentity(current) !== resourceIdentity(loading) || (variant && (currentVariant?.fingerprint !== variant.fingerprint || JSON.stringify(currentVariant.extensionPaths) !== JSON.stringify(variant.extensionPaths)))) {
@@ -1473,6 +1505,19 @@ export class Terminals {
1473
1505
  await accounts.terminalEnd(account).catch(() => undefined);
1474
1506
  if (acquiredWorktreeId)
1475
1507
  await this.options.worktrees?.release(acquiredWorktreeId, sessionId).catch(() => undefined);
1508
+ // A reattach that failed leaves the reply running without a window, as before.
1509
+ if (carried) {
1510
+ if (this.clients.get(sessionId) === carried.client)
1511
+ this.clients.delete(sessionId);
1512
+ const stop = carried.client.onSettled(() => {
1513
+ if (this.detachedClients.get(sessionId)?.client !== carried.client)
1514
+ return;
1515
+ this.detachedClients.delete(sessionId);
1516
+ stop();
1517
+ void carried.client.dispose().catch(() => undefined);
1518
+ });
1519
+ this.detachedClients.set(sessionId, { client: carried.client, info: carried.info, stop });
1520
+ }
1476
1521
  throw error;
1477
1522
  }
1478
1523
  }
@@ -1546,10 +1591,13 @@ export class Terminals {
1546
1591
  return {
1547
1592
  sessionId,
1548
1593
  title: () => basename(live.input.cwd) || this.options.env.productName,
1549
- send: (text) => this.send({ sessionId, text }),
1550
- control: (call, params) => live.controller.control(call, params),
1594
+ send: (text) => {
1595
+ const client = this.clients.get(sessionId);
1596
+ return client ? client.send(text).then((result) => ({ runId: 'runId' in result ? result.runId : client.lastRun ?? '' })) : this.send({ sessionId, text });
1597
+ },
1598
+ control: (call, params) => this.controlFor(live)(call, params),
1551
1599
  history: () => {
1552
- const native = live.controller.nativeSessionId;
1600
+ const native = this.nativeOf(live);
1553
1601
  if (!native)
1554
1602
  return [];
1555
1603
  const runs = listRuns(root, { sessionId })
@@ -1592,10 +1640,116 @@ export class Terminals {
1592
1640
  throw new HarnessError('unknown_method', `Unknown terminal control ${call}.`);
1593
1641
  }
1594
1642
  /** The terminal process ended (or never started): its turn, tokens, and written credentials go with it. */
1643
+ /**
1644
+ * The native conversation a terminal's runs carry. A client terminal's runs are harness runs, which
1645
+ * record Pi's session id; an in-process terminal's record its history file (ADR-0083).
1646
+ */
1647
+ nativeOf(live) {
1648
+ const client = this.clients.get(live.input.sessionId);
1649
+ return client ? client.nativeSessionId : live.controller.nativeSessionId;
1650
+ }
1651
+ /** The history file a relaunch of this terminal resumes. */
1652
+ historyFileOf(live) {
1653
+ const client = this.clients.get(live.input.sessionId);
1654
+ return client ? client.sessionFile : live.controller.nativeSessionId;
1655
+ }
1656
+ /**
1657
+ * Where a terminal's decision and stop calls go: its controller, or, once the frontend is a client
1658
+ * of the harness, the live session's run (ADR-0083).
1659
+ */
1660
+ controlFor(live) {
1661
+ return (call, params) => {
1662
+ const client = this.clients.get(live.input.sessionId);
1663
+ const routed = client ? LIVE_DECISION_CALLS[call] : undefined;
1664
+ return routed ? client.call(routed, params) : live.controller.control(call, params);
1665
+ };
1666
+ }
1667
+ /** The client half of a terminal whose frontend runs no Pi of its own (ADR-0083). */
1668
+ liveTerminal(live) {
1669
+ const existing = this.clients.get(live.input.sessionId);
1670
+ if (existing)
1671
+ return existing;
1672
+ const sessions = this.options.sessionService;
1673
+ if (!sessions)
1674
+ throw invalid('This host does not run terminal turns in the harness.');
1675
+ const { input } = live;
1676
+ const client = new LiveTerminal({
1677
+ sessionId: input.sessionId,
1678
+ root: this.options.env.root,
1679
+ accountId: live.account.id,
1680
+ cwd: input.cwd,
1681
+ sessionDir: this.historyDir(input),
1682
+ caller: live.caller,
1683
+ // The version this terminal was prepared on stays pinned; a switch restarts the frontend (ADR-0065).
1684
+ variant: live.variant ? { dir: live.variant.dir, scope: live.variant.scope ?? undefined } : null,
1685
+ launch: { ...input.launch, standard: input.launch?.standard === null ? null : input.launch?.standard ?? { cwd: input.cwd } },
1686
+ connections: input.connections !== false,
1687
+ readOnly: !!input.readOnly,
1688
+ ...(input.subagents === null ? { subagents: null } : {}),
1689
+ allowTool: (target) => { this.options.approvals.allowTool('pi', target); },
1690
+ tools: async () => [...(input.launch?.tools ?? []), ...await this.worldToolNames(input, live.account)],
1691
+ commands: async () => {
1692
+ // The trusted extensions a turn would load, loaded only to read what they register.
1693
+ const pi = await loadPi();
1694
+ const account = this.options.accounts.context(live.account);
1695
+ const plugins = this.options.plugins;
1696
+ const loader = await loadResources(pi, {
1697
+ cwd: input.cwd, account, system: '', inline: [], variantExtensions: live.variant?.extensionPaths ?? [],
1698
+ resources: { ids: [], skillPaths: [], promptPaths: [] },
1699
+ ...(plugins ? { plugins: { loading: () => plugins.loading({ cwd: input.cwd, agentDir: account.configDir, install: false, caller: { projectId: input.projectId ?? null } }), loaded: () => undefined } } : {})
1700
+ }, () => undefined);
1701
+ // A plugin that cannot load stops the window, as it stops an in-process terminal's start.
1702
+ const { errors } = loader.getExtensions();
1703
+ if (errors.length)
1704
+ throw new Error(errors.map((error) => `${error.path}: ${error.error}`).join('\n'));
1705
+ return registeredCommands(loader.getExtensions().extensions);
1706
+ }
1707
+ }, sessions, this.options.runs);
1708
+ this.clients.set(input.sessionId, client);
1709
+ return client;
1710
+ }
1711
+ /**
1712
+ * The terminal's window closed. A client terminal whose reply is still running keeps it running in
1713
+ * the harness, without the window, until it settles; `prepare({ attach: true })` reconnects to it.
1714
+ * Anything else ends now, as `dispose` does.
1715
+ */
1716
+ async detach(sessionId) {
1717
+ const live = this.sessions.get(sessionId);
1718
+ const client = this.clients.get(sessionId);
1719
+ const activeRunId = client?.activeRun ?? null;
1720
+ if (!live || !client || !activeRunId) {
1721
+ await this.dispose(sessionId);
1722
+ return { detached: false, activeRunId: null };
1723
+ }
1724
+ // The window's binding goes; the client and its live session stay.
1725
+ this.clients.delete(sessionId);
1726
+ await this.dispose(sessionId);
1727
+ const info = {
1728
+ sessionId, cwd: live.input.cwd, sessionFolder: live.input.sessionFolder, accountId: live.account.id,
1729
+ activeRunId, sessionFile: client.sessionFile, detachedAt: new Date().toISOString()
1730
+ };
1731
+ const stop = client.onSettled(() => {
1732
+ const entry = this.detachedClients.get(sessionId);
1733
+ if (entry?.client !== client)
1734
+ return;
1735
+ this.detachedClients.delete(sessionId);
1736
+ entry.stop();
1737
+ void client.dispose().catch(() => undefined);
1738
+ });
1739
+ this.detachedClients.set(sessionId, { client, info, stop });
1740
+ return { detached: true, activeRunId };
1741
+ }
1742
+ /** Client terminals whose reply is running without a window. */
1743
+ detached() {
1744
+ return [...this.detachedClients.values()].map(({ client, info }) => ({ ...info, activeRunId: client.activeRun, sessionFile: client.sessionFile }));
1745
+ }
1595
1746
  async dispose(sessionId) {
1596
1747
  const live = this.sessions.get(sessionId);
1597
1748
  if (!live)
1598
1749
  return;
1750
+ const client = this.clients.get(sessionId);
1751
+ this.clients.delete(sessionId);
1752
+ await client?.dispose().catch(() => undefined);
1599
1753
  for (const [id, owner] of this.diagnosticOwners)
1600
1754
  if (owner === sessionId) {
1601
1755
  this.options.doctor?.cancel(id);
@@ -1619,6 +1773,9 @@ export class Terminals {
1619
1773
  await this.options.accounts.terminalEnd(live.account).catch((error) => log('terminal credentials', error instanceof Error ? error.message : String(error)));
1620
1774
  }
1621
1775
  async close() {
1776
+ const detached = [...this.detachedClients.values()];
1777
+ this.detachedClients.clear();
1778
+ await Promise.allSettled(detached.map(({ client, stop }) => { stop(); return client.dispose(); }));
1622
1779
  await Promise.allSettled([...this.sessions.keys()].map((sessionId) => this.dispose(sessionId)));
1623
1780
  await this.socket.close();
1624
1781
  }
@@ -67,3 +67,5 @@ export declare function createTerminalCommands(host: TerminalCommandHost): {
67
67
  skills: () => SkillEntry[];
68
68
  legacyAliases: () => string[];
69
69
  };
70
+ /** The history file a `/session` argument names: a path, or the session id in a file's header. */
71
+ export declare function sessionFileFor(argument: string, cwd: string, sessionDirectory: string): string;
@@ -1,4 +1,5 @@
1
- import { isAbsolute, relative, resolve } from 'node:path';
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { isAbsolute, join, relative, resolve } from 'node:path';
2
3
  import { SessionManager } from '@earendil-works/pi-coding-agent';
3
4
  import { CORE_COMMANDS, coreCommand } from "../../commands/registry.js";
4
5
  import { commandRoutes, promptCommandAvailable } from "../../commands/routes.js";
@@ -264,7 +265,8 @@ export function createTerminalCommands(host) {
264
265
  break;
265
266
  path = selected;
266
267
  }
267
- const target = resolve(host.cwd(), path);
268
+ // A conversation by its file, or by the session id its runs record (ADR-0083).
269
+ const target = sessionFileFor(path, host.cwd(), host.sessionDirectory());
268
270
  const within = relative(host.sessionDirectory(), target);
269
271
  if (within === '..' || within.startsWith('../') || within.startsWith('..\\') || isAbsolute(within)) {
270
272
  throw new Error('Resume sessions from this account history folder.');
@@ -472,3 +474,23 @@ export function createTerminalCommands(host) {
472
474
  }
473
475
  return { execute, completions, skills, legacyAliases };
474
476
  }
477
+ /** The history file a `/session` argument names: a path, or the session id in a file's header. */
478
+ export function sessionFileFor(argument, cwd, sessionDirectory) {
479
+ const named = resolve(cwd, argument);
480
+ if (existsSync(named) || /[\\/]|\.jsonl$/u.test(argument))
481
+ return named;
482
+ try {
483
+ for (const name of readdirSync(sessionDirectory)) {
484
+ if (!name.endsWith('.jsonl'))
485
+ continue;
486
+ const file = join(sessionDirectory, name);
487
+ const header = JSON.parse(readFileSync(file, 'utf8').split('\n', 1)[0]);
488
+ if (header.type === 'session' && header.id === argument)
489
+ return file;
490
+ }
491
+ }
492
+ catch {
493
+ // An unreadable folder or header names nothing.
494
+ }
495
+ return named;
496
+ }
@@ -0,0 +1,8 @@
1
+ import { type TerminalRuntime, type TerminalRuntimeHost } from "./runtime.js";
2
+ /**
3
+ * The terminal's runtime (ADR-0083): this client of the harness by default; `inprocess` runs the
4
+ * previous in-process Pi runtime, which keeps plugins' own terminal components.
5
+ */
6
+ export declare const HARNESS_RUNTIME_ENV = "XINGULAR_TERMINAL_RUNTIME";
7
+ export declare const harnessRuntimeSelected: (env?: NodeJS.ProcessEnv) => boolean;
8
+ export declare function createHarnessTerminalRuntime(args: string[], host: TerminalRuntimeHost): Promise<TerminalRuntime>;