@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.
- package/CHANGELOG.md +43 -0
- package/dist/cli.js +23375 -19883
- package/dist/sdk/cli.js +16 -1
- package/dist/sdk/harness.js +2 -1
- package/dist/sdk/prompt/reporting.d.ts +1 -1
- package/dist/sdk/prompt/reporting.js +8 -3
- package/dist/sdk/server/methods.js +12 -3
- package/dist/sdk/server/serve.d.ts +5 -2
- package/dist/sdk/server/serve.js +10 -3
- package/dist/sdk/server/socket.d.ts +28 -0
- package/dist/sdk/server/socket.js +122 -0
- package/dist/sdk/sessions/contracts.d.ts +14 -1
- package/dist/sdk/sessions/contracts.js +9 -0
- package/dist/sdk/sessions/extensionUi.js +4 -4
- package/dist/sdk/sessions/live.d.ts +101 -0
- package/dist/sdk/sessions/live.js +265 -0
- package/dist/sdk/sessions/registry.d.ts +12 -0
- package/dist/sdk/sessions/registry.js +3 -0
- package/dist/sdk/sessions/runner.d.ts +25 -3
- package/dist/sdk/sessions/runner.js +171 -31
- package/dist/sdk/sessions/sessions.d.ts +49 -0
- package/dist/sdk/sessions/sessions.js +165 -8
- package/dist/sdk/{terminal → sessions}/stallWatch.d.ts +4 -0
- package/dist/sdk/{terminal → sessions}/stallWatch.js +10 -4
- package/dist/sdk/sessions/subagentService.js +1 -1
- package/dist/sdk/terminal/controller.js +1 -3
- package/dist/sdk/terminal/daemon.d.ts +44 -0
- package/dist/sdk/terminal/daemon.js +264 -0
- package/dist/sdk/terminal/liveTerminal.d.ts +96 -0
- package/dist/sdk/terminal/liveTerminal.js +436 -0
- package/dist/sdk/terminal/standalone.d.ts +19 -1
- package/dist/sdk/terminal/standalone.js +75 -25
- package/dist/sdk/terminal/terminals.d.ts +43 -0
- package/dist/sdk/terminal/terminals.js +171 -14
- package/dist/sdk/terminal/tui/commands.d.ts +2 -0
- package/dist/sdk/terminal/tui/commands.js +24 -2
- package/dist/sdk/terminal/tui/harnessRuntime.d.ts +8 -0
- package/dist/sdk/terminal/tui/harnessRuntime.js +771 -0
- package/dist/sdk/terminal/tui/thinkingLevels.d.ts +11 -0
- package/dist/sdk/terminal/tui/thinkingLevels.js +31 -0
- package/dist/terminal-opentui.js +2013 -975
- package/dist/terminal-pi.js +9 -0
- package/docs/architecture/02-sessions-and-runs.md +61 -3
- package/docs/architecture/04-prompt.md +8 -6
- package/docs/architecture/10-server-protocol.md +23 -1
- package/docs/architecture/11-terminal.md +67 -3
- package/docs/architecture/12-storage.md +3 -1
- package/docs/architecture/14-surfaces-and-parity.md +4 -0
- package/docs/architecture/15-remote-control.md +3 -1
- package/docs/architecture/README.md +5 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +9 -2
- package/docs/decisions/ADR-0083-one-live-session-runtime.md +256 -0
- package/docs/parity.md +4 -3
- package/docs/releases/2.0.1-unstable.md +63 -0
- package/docs/terminal-tui.md +10 -5
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
1550
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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>;
|