@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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** How often a live reply's progress is checked against the stall windows. */
|
|
2
|
+
export declare const STALL_CHECK_MS = 30000;
|
|
1
3
|
/** Silence with no tool running: the model request never came back. */
|
|
2
4
|
export declare const REPLY_STALL_MS: number;
|
|
3
5
|
/** One tool may run this long; past it the reply is wedged, not busy. */
|
|
@@ -11,6 +13,8 @@ export interface ReplyProgress {
|
|
|
11
13
|
}
|
|
12
14
|
/** Whether a live reply has gone too long without progress and should be ended. The oldest running tool counts. */
|
|
13
15
|
export declare function replyStalled({ now, progressAt, runningTools }: ReplyProgress): boolean;
|
|
16
|
+
/** Whether every tool still running only waits for subagents, which are watched by their own runs. */
|
|
17
|
+
export declare function onlyWaitingForChildren(runningToolNames: readonly string[]): boolean;
|
|
14
18
|
/** The tools that block a reply until its children settle. */
|
|
15
19
|
export declare const CHILD_WAIT_TOOLS: ReadonlySet<string>;
|
|
16
20
|
/** How long a child may stay unsettled after it was told to stop before its parent's own watch resumes. */
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// A
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// same reason, `stalled` (INV-RUN-14).
|
|
1
|
+
// A reply that produced nothing for too long is not working: the person should be told instead of
|
|
2
|
+
// watching a run that never settles. This is the turn-level rule, applied to every harness run
|
|
3
|
+
// (`runTurn`) and to the in-process terminal's reply; the run-store sweep (`reconcileStaleRuns`)
|
|
4
|
+
// settles a record whose owner stopped reporting, and all use the same reason, `stalled` (INV-RUN-14).
|
|
5
5
|
//
|
|
6
6
|
// A reply that waits for its subagents is not silent: each child is watched on its own clock and ended
|
|
7
7
|
// with the same reason when it goes quiet, so the wait returns and the parent carries on by itself.
|
|
8
|
+
/** How often a live reply's progress is checked against the stall windows. */
|
|
9
|
+
export const STALL_CHECK_MS = 30_000;
|
|
8
10
|
/** Silence with no tool running: the model request never came back. */
|
|
9
11
|
export const REPLY_STALL_MS = 15 * 60_000;
|
|
10
12
|
/** One tool may run this long; past it the reply is wedged, not busy. */
|
|
@@ -17,6 +19,10 @@ export function replyStalled({ now, progressAt, runningTools }) {
|
|
|
17
19
|
? now - Math.min(...runningTools) >= TOOL_STALL_MS
|
|
18
20
|
: now - progressAt >= REPLY_STALL_MS;
|
|
19
21
|
}
|
|
22
|
+
/** Whether every tool still running only waits for subagents, which are watched by their own runs. */
|
|
23
|
+
export function onlyWaitingForChildren(runningToolNames) {
|
|
24
|
+
return runningToolNames.length > 0 && runningToolNames.every((name) => CHILD_WAIT_TOOLS.has(name));
|
|
25
|
+
}
|
|
20
26
|
/** The tools that block a reply until its children settle. */
|
|
21
27
|
export const CHILD_WAIT_TOOLS = new Set(['wait_subagents', 'spawn_subagent']);
|
|
22
28
|
/** How long a child may stay unsettled after it was told to stop before its parent's own watch resumes. */
|
|
@@ -69,7 +69,7 @@ export class Subagents {
|
|
|
69
69
|
const ready = new Promise(resolve => { started = resolve; });
|
|
70
70
|
if (!deferUntilPrompt)
|
|
71
71
|
started();
|
|
72
|
-
this.parents.set(input.run.id, { ready, started, input: structuredClone({ ...input, text: '', images: undefined, requiresImageInput: false, preamble: null, resume: null }), accountId, capabilities: ceiling, stopped: new AbortController(), recovered: new Set(), launching: new Map() });
|
|
72
|
+
this.parents.set(input.run.id, { ready, started, input: structuredClone({ ...input, text: '', images: undefined, requiresImageInput: false, preamble: null, resume: null, live: undefined }), accountId, capabilities: ceiling, stopped: new AbortController(), recovered: new Set(), launching: new Map() });
|
|
73
73
|
}
|
|
74
74
|
runtimeSettings(runId) {
|
|
75
75
|
return this.runtimeSettingsByRun.get(runId) ?? null;
|
|
@@ -7,7 +7,7 @@ import { HarnessError } from "../core/errors.js";
|
|
|
7
7
|
import { isActiveStatus, runStatusSchema } from "../sessions/contracts.js";
|
|
8
8
|
import { briefTitle } from "../sessions/subagents.js";
|
|
9
9
|
import { MAIN_PROFILE, SubagentMonitor } from "./subagentMonitor.js";
|
|
10
|
-
import { childStalled, replyStalled, waitingOnChildren } from "
|
|
10
|
+
import { STALL_CHECK_MS, childStalled, replyStalled, waitingOnChildren } from "../sessions/stallWatch.js";
|
|
11
11
|
/** Stop codes the native extension may report for a reply it settled itself. */
|
|
12
12
|
const SETTLED_CODES = new Set(['slice_budget', 'compaction_thrash', 'needs_person', 'unfinished']);
|
|
13
13
|
function monitorRun(record, relation, profile, runtime) {
|
|
@@ -26,8 +26,6 @@ function monitorRun(record, relation, profile, runtime) {
|
|
|
26
26
|
}
|
|
27
27
|
const READY_TIMEOUT_MS = 90_000;
|
|
28
28
|
const ACK_TIMEOUT_MS = 25_000;
|
|
29
|
-
/** How often a live reply's progress is checked against the stall windows. */
|
|
30
|
-
const STALL_CHECK_MS = 30_000;
|
|
31
29
|
export class TerminalController {
|
|
32
30
|
hooks;
|
|
33
31
|
run = null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import type { InteractiveProfile } from "../core/interactiveProfile.js";
|
|
3
|
+
import { type HarnessClient } from "../server/socket.js";
|
|
4
|
+
import { type StandaloneDependencies, type StandalonePaths } from "./standalone.js";
|
|
5
|
+
import type { DetachedTerminal } from "./terminals.js";
|
|
6
|
+
export interface DaemonFiles {
|
|
7
|
+
socket: string;
|
|
8
|
+
lock: string;
|
|
9
|
+
log: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function daemonFiles(stateDir: string): DaemonFiles;
|
|
12
|
+
interface LockRecord {
|
|
13
|
+
pid: number;
|
|
14
|
+
startedAt: string;
|
|
15
|
+
socket: string;
|
|
16
|
+
}
|
|
17
|
+
/** The running daemon's lock record, or null when none is running (a stale lock is not one). */
|
|
18
|
+
export declare function daemonRecord(stateDir: string): LockRecord | null;
|
|
19
|
+
/** Takes the state folder's lock for this process, or refuses when another daemon holds it. */
|
|
20
|
+
export declare function acquireDaemonLock(stateDir: string): () => void;
|
|
21
|
+
/** Runs the long-lived harness in this process until it is stopped. */
|
|
22
|
+
export declare function runDaemon(paths: StandalonePaths, profile: InteractiveProfile, dependencies?: Pick<StandaloneDependencies, 'createHarness' | 'hostSecretStore'>): Promise<number>;
|
|
23
|
+
/** Connects to the daemon for these folders, starting it first when `start` is set and none runs. */
|
|
24
|
+
export declare function connectDaemon(paths: StandalonePaths, options?: {
|
|
25
|
+
start: boolean;
|
|
26
|
+
spawn?: typeof nodeSpawn;
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}): Promise<HarnessClient>;
|
|
29
|
+
export interface DaemonTerminalOptions {
|
|
30
|
+
paths: StandalonePaths;
|
|
31
|
+
profile: InteractiveProfile;
|
|
32
|
+
cwd: string;
|
|
33
|
+
spawn?: typeof nodeSpawn;
|
|
34
|
+
createSessionId?: () => string;
|
|
35
|
+
/** Reattach to this detached session instead of starting a new one. */
|
|
36
|
+
attach?: DetachedTerminal;
|
|
37
|
+
}
|
|
38
|
+
/** `xagent --daemon` and `xagent attach`: a terminal window on the long-lived harness. */
|
|
39
|
+
export declare function runDaemonTerminal(options: DaemonTerminalOptions): Promise<number>;
|
|
40
|
+
/** `xagent attach [session id]`: reconnects a window to a reply that kept running. */
|
|
41
|
+
export declare function runAttach(paths: StandalonePaths, profile: InteractiveProfile, sessionId: string | undefined, cwd: string): Promise<number>;
|
|
42
|
+
/** `xagent daemon <start | stop | status | run>`. */
|
|
43
|
+
export declare function runDaemonCommand(subcommand: string | undefined, paths: StandalonePaths, profile: InteractiveProfile): Promise<number>;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The long-lived harness for one data root and state folder (ADR-0083, phase 3). `xagent --daemon`
|
|
2
|
+
// starts it when it is not running and prepares its terminal there, so a reply keeps running when the
|
|
3
|
+
// window closes; `xagent attach` reconnects a window to it. It serves the server protocol on a
|
|
4
|
+
// socket in the state folder that only this user can open, and one lock keeps it the only harness
|
|
5
|
+
// writing for that state folder.
|
|
6
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from 'node:fs';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { log, setLogSink } from "../core/bus.js";
|
|
12
|
+
import { HarnessError } from "../core/errors.js";
|
|
13
|
+
import { connectHarness, serveSocket } from "../server/socket.js";
|
|
14
|
+
import { REMOTE_CONTROL_ENV } from "./client.js";
|
|
15
|
+
import { HARNESS_RUNTIME_ENV } from "./tui/harnessRuntime.js";
|
|
16
|
+
import { createStandaloneHarness, ensureStandaloneDefaults, spawnFrontend, terminalSessionFolder } from "./standalone.js";
|
|
17
|
+
export function daemonFiles(stateDir) {
|
|
18
|
+
return { socket: join(stateDir, 'harness.sock'), lock: join(stateDir, 'harness.lock'), log: join(stateDir, 'harness.log') };
|
|
19
|
+
}
|
|
20
|
+
function alive(pid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return error.code === 'EPERM';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** The running daemon's lock record, or null when none is running (a stale lock is not one). */
|
|
30
|
+
export function daemonRecord(stateDir) {
|
|
31
|
+
const { lock } = daemonFiles(stateDir);
|
|
32
|
+
if (!existsSync(lock))
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
const record = JSON.parse(readFileSync(lock, 'utf8'));
|
|
36
|
+
return typeof record.pid === 'number' && alive(record.pid) ? record : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Takes the state folder's lock for this process, or refuses when another daemon holds it. */
|
|
43
|
+
export function acquireDaemonLock(stateDir) {
|
|
44
|
+
const files = daemonFiles(stateDir);
|
|
45
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
46
|
+
try {
|
|
47
|
+
const descriptor = openSync(files.lock, 'wx', 0o600);
|
|
48
|
+
const record = { pid: process.pid, startedAt: new Date().toISOString(), socket: files.socket };
|
|
49
|
+
writeSync(descriptor, JSON.stringify(record));
|
|
50
|
+
closeSync(descriptor);
|
|
51
|
+
return () => {
|
|
52
|
+
try {
|
|
53
|
+
const current = JSON.parse(readFileSync(files.lock, 'utf8'));
|
|
54
|
+
if (current.pid === process.pid)
|
|
55
|
+
rmSync(files.lock, { force: true });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Already gone.
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code !== 'EEXIST')
|
|
64
|
+
throw error;
|
|
65
|
+
const running = daemonRecord(stateDir);
|
|
66
|
+
if (running)
|
|
67
|
+
throw new HarnessError('daemon_running', `A harness is already running for this state folder (process ${running.pid}).`);
|
|
68
|
+
// The process that held it is gone: the lock is stale.
|
|
69
|
+
rmSync(files.lock, { force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw new HarnessError('daemon_running', 'The harness lock could not be taken.');
|
|
73
|
+
}
|
|
74
|
+
/** Runs the long-lived harness in this process until it is stopped. */
|
|
75
|
+
export async function runDaemon(paths, profile, dependencies = {}) {
|
|
76
|
+
const files = daemonFiles(paths.stateDir);
|
|
77
|
+
// A first run may come before anything has made the state folder.
|
|
78
|
+
mkdirSync(paths.stateDir, { recursive: true, mode: 0o700 });
|
|
79
|
+
const release = acquireDaemonLock(paths.stateDir);
|
|
80
|
+
setLogSink((text) => {
|
|
81
|
+
try {
|
|
82
|
+
appendFileSync(files.log, `${new Date().toISOString()} [daemon] ${text}\n`);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Best effort.
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const harness = await createStandaloneHarness(paths, profile, dependencies);
|
|
89
|
+
// Plugins run here for every window. One plugin's uncaught error is logged; it does not end every
|
|
90
|
+
// session the harness holds, as it would end a single in-process terminal.
|
|
91
|
+
const survive = (error) => { log('uncaught error in the long-lived harness', error instanceof Error ? error.stack ?? error.message : String(error)); };
|
|
92
|
+
process.on('uncaughtException', survive);
|
|
93
|
+
process.on('unhandledRejection', survive);
|
|
94
|
+
try {
|
|
95
|
+
await harness.ready;
|
|
96
|
+
const variantScope = ensureStandaloneDefaults(harness, paths, profile);
|
|
97
|
+
const startedAt = new Date().toISOString();
|
|
98
|
+
let stopping = null;
|
|
99
|
+
const stopped = new Promise((resolveStop) => { stopping = resolveStop; });
|
|
100
|
+
const server = await serveSocket(harness, files.socket, {
|
|
101
|
+
'daemon.status': () => ({ pid: process.pid, startedAt, variantScope, live: harness.sessions.liveSessions(), detached: harness.terminals.detached() }),
|
|
102
|
+
'daemon.stop': () => {
|
|
103
|
+
setImmediate(() => stopping?.());
|
|
104
|
+
return { stopping: true };
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
process.once('SIGTERM', () => stopping?.());
|
|
108
|
+
process.once('SIGINT', () => stopping?.());
|
|
109
|
+
await stopped;
|
|
110
|
+
await server.close();
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await Promise.race([harness.close(), new Promise((done) => setTimeout(done, 5000))]);
|
|
115
|
+
process.off('uncaughtException', survive);
|
|
116
|
+
process.off('unhandledRejection', survive);
|
|
117
|
+
release();
|
|
118
|
+
setLogSink(null);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** The path flags that name these folders, for a daemon started in another process. */
|
|
122
|
+
function pathArguments(paths) {
|
|
123
|
+
return ['--data', paths.dataRoot, '--state', paths.stateDir, ...(paths.accountState ? ['--account-state', paths.accountState] : [])];
|
|
124
|
+
}
|
|
125
|
+
const cliPath = () => {
|
|
126
|
+
// The bundled CLI is the file this code was bundled into; from source it is the package's `dist/cli.js`.
|
|
127
|
+
const here = fileURLToPath(import.meta.url);
|
|
128
|
+
return here.endsWith('cli.js') ? here : resolve(here, '..', '..', '..', 'dist', 'cli.js');
|
|
129
|
+
};
|
|
130
|
+
/** Connects to the daemon for these folders, starting it first when `start` is set and none runs. */
|
|
131
|
+
export async function connectDaemon(paths, options = { start: true }) {
|
|
132
|
+
const files = daemonFiles(paths.stateDir);
|
|
133
|
+
const tryConnect = async () => {
|
|
134
|
+
if (!daemonRecord(paths.stateDir) || !existsSync(files.socket))
|
|
135
|
+
return null;
|
|
136
|
+
return connectHarness(files.socket).catch(() => null);
|
|
137
|
+
};
|
|
138
|
+
const existing = await tryConnect();
|
|
139
|
+
if (existing)
|
|
140
|
+
return existing;
|
|
141
|
+
if (!options.start)
|
|
142
|
+
throw new HarnessError('daemon_not_running', 'No harness is running for these folders. Start one with: xagent daemon start');
|
|
143
|
+
const child = (options.spawn ?? nodeSpawn)(process.execPath, [cliPath(), 'daemon', 'run', ...pathArguments(paths)], { detached: true, stdio: 'ignore', env: process.env });
|
|
144
|
+
child.unref();
|
|
145
|
+
const deadline = Date.now() + (options.timeoutMs ?? 20_000);
|
|
146
|
+
while (Date.now() < deadline) {
|
|
147
|
+
const client = await tryConnect();
|
|
148
|
+
if (client)
|
|
149
|
+
return client;
|
|
150
|
+
await new Promise((wait) => setTimeout(wait, 100));
|
|
151
|
+
}
|
|
152
|
+
throw new HarnessError('daemon_unavailable', `The harness did not start; see ${files.log}.`);
|
|
153
|
+
}
|
|
154
|
+
/** `xagent --daemon` and `xagent attach`: a terminal window on the long-lived harness. */
|
|
155
|
+
export async function runDaemonTerminal(options) {
|
|
156
|
+
const { paths, profile } = options;
|
|
157
|
+
const client = await connectDaemon(paths, { start: !options.attach, ...(options.spawn ? { spawn: options.spawn } : {}) });
|
|
158
|
+
const newSessionId = options.createSessionId ?? randomUUID;
|
|
159
|
+
let cwd = options.attach?.cwd ?? options.cwd;
|
|
160
|
+
let current = options.attach
|
|
161
|
+
? { sessionId: options.attach.sessionId, accountId: options.attach.accountId, nativeSessionId: null }
|
|
162
|
+
: { sessionId: newSessionId(), accountId: null, nativeSessionId: null };
|
|
163
|
+
let attach = Boolean(options.attach);
|
|
164
|
+
try {
|
|
165
|
+
const status = await client.call('daemon.status');
|
|
166
|
+
const variantScope = paths.variant ?? status.variantScope ?? profile.defaultVariantScope;
|
|
167
|
+
let variantVersion = null;
|
|
168
|
+
for (;;) {
|
|
169
|
+
const sessionFolder = options.attach && attach ? options.attach.sessionFolder : terminalSessionFolder(paths.dataRoot, cwd);
|
|
170
|
+
const launch = await client.call('terminal.prepare', {
|
|
171
|
+
...current, cwd, sessionFolder,
|
|
172
|
+
variant: { scope: variantScope, ...(variantVersion ? { version: variantVersion } : {}) },
|
|
173
|
+
...(attach ? { attach: true } : {})
|
|
174
|
+
}, 120_000);
|
|
175
|
+
attach = false;
|
|
176
|
+
const env = { ...launch.env, [HARNESS_RUNTIME_ENV]: 'harness', ...(paths.remoteControl ? { [REMOTE_CONTROL_ENV]: '1' } : {}) };
|
|
177
|
+
const code = await spawnFrontend({ ...launch, env }, cwd, options.spawn);
|
|
178
|
+
const next = await client.call('terminal.takeRelaunch', { sessionId: current.sessionId });
|
|
179
|
+
if (!next) {
|
|
180
|
+
const detached = await client.call('terminal.detach', { sessionId: current.sessionId });
|
|
181
|
+
if (detached.detached)
|
|
182
|
+
process.stdout.write(`The reply is still running in the harness. Reattach with: xagent attach ${current.sessionId}\n`);
|
|
183
|
+
return code;
|
|
184
|
+
}
|
|
185
|
+
if (next.cwd) {
|
|
186
|
+
process.stdout.write(`Opening ${next.cwd}.\n`);
|
|
187
|
+
cwd = next.cwd;
|
|
188
|
+
}
|
|
189
|
+
else if (next.variantVersion) {
|
|
190
|
+
process.stdout.write(`Switching this conversation to variant version ${next.variantVersion.slice(0, 8)}.\n`);
|
|
191
|
+
variantVersion = next.variantVersion;
|
|
192
|
+
}
|
|
193
|
+
else
|
|
194
|
+
process.stdout.write('Switching this session to another account.\n');
|
|
195
|
+
await client.call('terminal.dispose', { sessionId: current.sessionId });
|
|
196
|
+
current = { sessionId: newSessionId(), accountId: next.accountId, nativeSessionId: next.nativeSessionId };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
client.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** `xagent attach [session id]`: reconnects a window to a reply that kept running. */
|
|
204
|
+
export async function runAttach(paths, profile, sessionId, cwd) {
|
|
205
|
+
const client = await connectDaemon(paths, { start: false });
|
|
206
|
+
let detached;
|
|
207
|
+
try {
|
|
208
|
+
detached = (await client.call('terminal.detached')).sessions;
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
client.close();
|
|
212
|
+
}
|
|
213
|
+
const chosen = sessionId ? detached.find((entry) => entry.sessionId === sessionId) : detached.length === 1 ? detached[0] : undefined;
|
|
214
|
+
if (!chosen) {
|
|
215
|
+
if (!detached.length)
|
|
216
|
+
process.stdout.write('No reply is running without a window.\n');
|
|
217
|
+
else {
|
|
218
|
+
process.stdout.write('Choose a session to reattach:\n');
|
|
219
|
+
for (const entry of detached)
|
|
220
|
+
process.stdout.write(` xagent attach ${entry.sessionId} ${entry.cwd} (since ${entry.detachedAt})\n`);
|
|
221
|
+
}
|
|
222
|
+
return detached.length ? 2 : 1;
|
|
223
|
+
}
|
|
224
|
+
return runDaemonTerminal({ paths, profile, cwd, attach: chosen });
|
|
225
|
+
}
|
|
226
|
+
/** `xagent daemon <start | stop | status | run>`. */
|
|
227
|
+
export async function runDaemonCommand(subcommand, paths, profile) {
|
|
228
|
+
switch (subcommand) {
|
|
229
|
+
case 'run': return runDaemon(paths, profile);
|
|
230
|
+
case 'start': {
|
|
231
|
+
const client = await connectDaemon(paths, { start: true });
|
|
232
|
+
const status = await client.call('daemon.status');
|
|
233
|
+
client.close();
|
|
234
|
+
process.stdout.write(`The harness is running (process ${status.pid}).\n`);
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
case 'status': {
|
|
238
|
+
const record = daemonRecord(paths.stateDir);
|
|
239
|
+
if (!record) {
|
|
240
|
+
process.stdout.write('No harness is running for these folders.\n');
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
const client = await connectDaemon(paths, { start: false });
|
|
244
|
+
const status = await client.call('daemon.status');
|
|
245
|
+
client.close();
|
|
246
|
+
process.stdout.write(`The harness is running (process ${status.pid}, since ${status.startedAt}): ${status.live.length} live session(s), ${status.detached.length} reply(ies) without a window.\n`);
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
case 'stop': {
|
|
250
|
+
if (!daemonRecord(paths.stateDir)) {
|
|
251
|
+
process.stdout.write('No harness is running for these folders.\n');
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
const client = await connectDaemon(paths, { start: false });
|
|
255
|
+
await client.call('daemon.stop').catch(() => undefined);
|
|
256
|
+
await Promise.race([client.closed, new Promise((done) => setTimeout(done, 10_000))]);
|
|
257
|
+
process.stdout.write('The harness stopped.\n');
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
default:
|
|
261
|
+
process.stdout.write('Usage: xagent daemon <start | stop | status> [--data <dir>] [--state <dir>] [--account-state <dir>]\n');
|
|
262
|
+
return 2;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { CallerContext } from "../core/caller.js";
|
|
2
|
+
import type { VariantChoice } from "../variant/materialise.js";
|
|
3
|
+
import type { Launch } from "../prompt/compile.js";
|
|
4
|
+
import type { RunRegistry } from "../sessions/registry.js";
|
|
5
|
+
import type { SessionRunInput, Sessions } from "../sessions/sessions.js";
|
|
6
|
+
/** Pi's own tools a terminal session has active, as the in-process terminal has them. */
|
|
7
|
+
export declare const TERMINAL_NATIVE_TOOLS: string[];
|
|
8
|
+
export interface LiveTerminalContext {
|
|
9
|
+
sessionId: string;
|
|
10
|
+
root: string;
|
|
11
|
+
accountId: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
sessionDir: string;
|
|
14
|
+
caller: CallerContext;
|
|
15
|
+
variant: VariantChoice | null;
|
|
16
|
+
launch: Launch | undefined;
|
|
17
|
+
connections: boolean;
|
|
18
|
+
readOnly: boolean;
|
|
19
|
+
/** The terminal's subagent options; null switches them off. */
|
|
20
|
+
subagents?: SessionRunInput['subagents'];
|
|
21
|
+
/** Always allow a tool for every session of this profile, as the terminal's own approvals do. */
|
|
22
|
+
allowTool(target: string): void;
|
|
23
|
+
/** The session's other tools, as terminal preparation lists them: launch and switched-on world tools. */
|
|
24
|
+
tools?(): Promise<string[]>;
|
|
25
|
+
/** The plugin commands the session's trusted extensions register, for the palette before the first turn. */
|
|
26
|
+
commands?(): Promise<LiveCommand[]>;
|
|
27
|
+
}
|
|
28
|
+
/** A plugin command as Pi's extension runner lists it, with what its owner prefix is derived from. */
|
|
29
|
+
export interface LiveCommand {
|
|
30
|
+
name: string;
|
|
31
|
+
invocationName: string;
|
|
32
|
+
description: string;
|
|
33
|
+
sourceInfo: unknown;
|
|
34
|
+
}
|
|
35
|
+
/** Pi's names for the commands of loaded extensions: a name two extensions share gets `:2`, `:3`. */
|
|
36
|
+
export declare function registeredCommands(extensions: ReadonlyArray<{
|
|
37
|
+
commands: Map<string, {
|
|
38
|
+
name: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
sourceInfo?: unknown;
|
|
41
|
+
}>;
|
|
42
|
+
sourceInfo?: unknown;
|
|
43
|
+
}>): LiveCommand[];
|
|
44
|
+
export declare class LiveTerminal {
|
|
45
|
+
private readonly context;
|
|
46
|
+
private readonly sessions;
|
|
47
|
+
private readonly runs;
|
|
48
|
+
private readonly journal;
|
|
49
|
+
private seq;
|
|
50
|
+
private readonly waiters;
|
|
51
|
+
private pendingRevision;
|
|
52
|
+
private readonly pendingWaiters;
|
|
53
|
+
private activeRunId;
|
|
54
|
+
private lastRunId;
|
|
55
|
+
private readonly stops;
|
|
56
|
+
private disposed;
|
|
57
|
+
private readonly settledListeners;
|
|
58
|
+
constructor(context: LiveTerminalContext, sessions: Sessions, runs: RunRegistry);
|
|
59
|
+
/** The subagents of the active reply: their approvals and questions are this terminal's too. */
|
|
60
|
+
private children;
|
|
61
|
+
private isOwnChild;
|
|
62
|
+
private append;
|
|
63
|
+
private bumpPending;
|
|
64
|
+
private wait;
|
|
65
|
+
private known;
|
|
66
|
+
/** Pi's id of this terminal's conversation, as its harness runs record it. */
|
|
67
|
+
get nativeSessionId(): string | null;
|
|
68
|
+
/** The history file of this terminal's conversation, once it has one. */
|
|
69
|
+
get sessionFile(): string | null;
|
|
70
|
+
/** The reply running now, if one is. */
|
|
71
|
+
get activeRun(): string | null;
|
|
72
|
+
/** Called each time the terminal's replies have all settled and nothing else is queued. */
|
|
73
|
+
onSettled(listener: () => void): () => void;
|
|
74
|
+
/** The run this terminal is showing, or the last one it finished. */
|
|
75
|
+
get lastRun(): string | null;
|
|
76
|
+
call(call: string, params: Record<string, unknown>): Promise<unknown>;
|
|
77
|
+
private text;
|
|
78
|
+
private listed;
|
|
79
|
+
private state;
|
|
80
|
+
private events;
|
|
81
|
+
private started;
|
|
82
|
+
private run;
|
|
83
|
+
/** The decisions waiting; `ownDialogs` leaves out plugin dialogs the window shows itself. */
|
|
84
|
+
private pending;
|
|
85
|
+
private decision;
|
|
86
|
+
private approve;
|
|
87
|
+
private answer;
|
|
88
|
+
private answers;
|
|
89
|
+
/** A message from another surface of this terminal (remote control): a new reply, or a follow-up. */
|
|
90
|
+
send(text: string): Promise<{
|
|
91
|
+
runId: string;
|
|
92
|
+
} | {
|
|
93
|
+
queued: number;
|
|
94
|
+
}>;
|
|
95
|
+
dispose(): Promise<void>;
|
|
96
|
+
}
|