@xingular/harness 1.3.1-unstable.2 → 1.3.1-unstable.4
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 +82 -0
- package/README.md +1 -1
- package/dist/cli.js +3680 -1947
- package/dist/sdk/cli.js +2 -0
- package/dist/sdk/commands/registry.d.ts +15 -0
- package/dist/sdk/commands/registry.js +2 -0
- package/dist/sdk/computer/screenshot.d.ts +32 -0
- package/dist/sdk/computer/screenshot.js +35 -0
- package/dist/sdk/context/attribution.d.ts +18 -0
- package/dist/sdk/context/attribution.js +295 -0
- package/dist/sdk/context/types.d.ts +94 -0
- package/dist/sdk/context/types.js +10 -0
- package/dist/sdk/index.d.ts +2 -1
- package/dist/sdk/index.js +2 -1
- package/dist/sdk/plugins/builtin.js +1 -0
- package/dist/sdk/prompt/compile.d.ts +42 -2
- package/dist/sdk/prompt/compile.js +87 -15
- package/dist/sdk/remote/addresses.d.ts +19 -0
- package/dist/sdk/remote/addresses.js +52 -0
- package/dist/sdk/remote/pairing.d.ts +59 -0
- package/dist/sdk/remote/pairing.js +160 -0
- package/dist/sdk/remote/seal.d.ts +28 -0
- package/dist/sdk/remote/seal.js +84 -0
- package/dist/sdk/remote/server.d.ts +28 -0
- package/dist/sdk/remote/server.js +158 -0
- package/dist/sdk/remote/service.d.ts +125 -0
- package/dist/sdk/remote/service.js +330 -0
- package/dist/sdk/remote/webClient.d.ts +4 -0
- package/dist/sdk/remote/webClient.js +444 -0
- package/dist/sdk/server/methods.js +3 -0
- package/dist/sdk/sessions/contracts.d.ts +4 -0
- package/dist/sdk/sessions/contracts.js +2 -0
- package/dist/sdk/sessions/planMode.d.ts +26 -1
- package/dist/sdk/sessions/planMode.js +48 -1
- package/dist/sdk/sessions/runner.d.ts +12 -0
- package/dist/sdk/sessions/runner.js +34 -2
- package/dist/sdk/sessions/sessions.js +1 -0
- package/dist/sdk/sessions/subagentService.d.ts +11 -1
- package/dist/sdk/sessions/subagentService.js +120 -38
- package/dist/sdk/sessions/subagents.d.ts +31 -0
- package/dist/sdk/sessions/subagents.js +45 -0
- package/dist/sdk/sessions/workSlice.d.ts +5 -9
- package/dist/sdk/sessions/workSlice.js +8 -149
- package/dist/sdk/terminal/client.d.ts +3 -1
- package/dist/sdk/terminal/client.js +15 -6
- package/dist/sdk/terminal/control.d.ts +1 -1
- package/dist/sdk/terminal/control.js +2 -2
- package/dist/sdk/terminal/controller.d.ts +22 -0
- package/dist/sdk/terminal/controller.js +104 -13
- package/dist/sdk/terminal/piExtension.js +106 -4
- package/dist/sdk/terminal/stallWatch.d.ts +13 -0
- package/dist/sdk/terminal/stallWatch.js +16 -0
- package/dist/sdk/terminal/standalone.d.ts +2 -0
- package/dist/sdk/terminal/standalone.js +9 -2
- package/dist/sdk/terminal/terminals.d.ts +14 -0
- package/dist/sdk/terminal/terminals.js +98 -2
- package/dist/sdk/terminal/tui/commandPages.d.ts +4 -1
- package/dist/sdk/terminal/tui/commandPages.js +12 -0
- package/dist/sdk/terminal/tui/commands.d.ts +3 -2
- package/dist/sdk/terminal/tui/commands.js +19 -5
- package/dist/sdk/terminal/tui/dialogs.d.ts +3 -2
- package/dist/sdk/terminal/tui/dialogs.js +4 -3
- package/dist/sdk/terminal/tui/keybindings.d.ts +1 -1
- package/dist/sdk/terminal/tui/keybindings.js +1 -1
- package/dist/sdk/terminal/tui/markdown.d.ts +16 -0
- package/dist/sdk/terminal/tui/markdown.js +55 -0
- package/dist/sdk/terminal/tui/modeChoices.d.ts +3 -2
- package/dist/sdk/terminal/tui/modeChoices.js +4 -4
- package/dist/sdk/terminal/tui/mouseReport.d.ts +30 -0
- package/dist/sdk/terminal/tui/mouseReport.js +80 -0
- package/dist/sdk/terminal/tui/pages/annotatePage.js +3 -2
- package/dist/sdk/terminal/tui/pages/browserPage.d.ts +1 -1
- package/dist/sdk/terminal/tui/pages/contextPage.d.ts +62 -0
- package/dist/sdk/terminal/tui/pages/contextPage.js +484 -0
- package/dist/sdk/terminal/tui/pages/remoteControlPage.d.ts +32 -0
- package/dist/sdk/terminal/tui/pages/remoteControlPage.js +170 -0
- package/dist/sdk/terminal/tui/pages/trajectoryPage.js +5 -4
- package/dist/sdk/terminal/tui/qr.d.ts +4 -0
- package/dist/sdk/terminal/tui/qr.js +35 -0
- package/dist/sdk/terminal/tui/runtime.js +8 -2
- package/dist/sdk/terminal/tui/search.js +3 -2
- package/dist/sdk/terminal/tui/sideQuestion.d.ts +1 -0
- package/dist/sdk/terminal/tui/sideQuestion.js +8 -3
- package/dist/sdk/terminal/tui/skillInvocation.d.ts +21 -2
- package/dist/sdk/terminal/tui/skillInvocation.js +31 -11
- package/dist/sdk/terminal/tui/status.d.ts +6 -0
- package/dist/sdk/terminal/tui/status.js +15 -1
- package/dist/sdk/terminal/tui/theme.d.ts +9 -0
- package/dist/sdk/terminal/tui/theme.js +9 -0
- package/dist/sdk/tools/defaultSkills.d.ts +19 -0
- package/dist/sdk/tools/defaultSkills.js +27 -1
- package/dist/sdk/tools/dispatch.js +8 -9
- package/dist/sdk/tools/spawnSubagent.d.ts +1 -1
- package/dist/sdk/tools/spawnSubagent.js +4 -2
- package/dist/terminal-opentui.js +1607 -492
- package/dist/terminal-pi.js +256 -144
- package/docs/README.md +1 -0
- package/docs/architecture/02-sessions-and-runs.md +55 -22
- package/docs/architecture/04-prompt.md +23 -4
- package/docs/architecture/07-tools-and-gate.md +4 -2
- package/docs/architecture/10-server-protocol.md +24 -0
- package/docs/architecture/11-terminal.md +52 -10
- package/docs/architecture/12-storage.md +5 -2
- package/docs/architecture/13-world-tools.md +17 -3
- package/docs/architecture/14-surfaces-and-parity.md +7 -3
- package/docs/architecture/15-remote-control.md +118 -0
- package/docs/architecture/README.md +3 -0
- package/docs/architecture/proposals/PROP-0049-context-attribution.md +409 -0
- package/docs/architecture/proposals/README.md +2 -1
- package/docs/decisions/ADR-0039-progress-aware-stall-guards.md +4 -0
- package/docs/decisions/ADR-0048-required-skills-from-a-message.md +42 -0
- package/docs/decisions/ADR-0050-approved-window-screenshots.md +19 -0
- package/docs/decisions/ADR-0051-terminal-decision-wait.md +10 -0
- package/docs/decisions/ADR-0052-per-profile-subagent-budgets.md +25 -0
- package/docs/decisions/ADR-0053-no-repeated-work-guard.md +45 -0
- package/docs/decisions/ADR-0054-terminal-reply-heartbeat-and-stall.md +52 -0
- package/docs/decisions/ADR-0055-plan-output-repair.md +51 -0
- package/docs/decisions/ADR-0056-remote-control.md +53 -0
- package/docs/decisions/ADR-0057-structured-plan-outcomes.md +39 -0
- package/docs/decisions/ADR-0058-ask-user-only-plan-clarification.md +36 -0
- package/docs/parity.md +14 -10
- package/docs/releases/1.3.1-unstable.3.md +54 -0
- package/docs/releases/1.3.1-unstable.4.md +56 -0
- package/docs/terminal-tui.md +43 -18
- package/package.json +3 -1
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// A reply's work slice (PROP-0041): a bounded amount of work, then a handoff checkpoint, then a
|
|
2
|
-
// continuation. The guard here refuses the call that would run past the budget, once with a
|
|
3
|
-
// request for the checkpoint, and ends the reply if the model keeps calling tools. It also names
|
|
4
|
-
// repeated work before it becomes a loop. All decisions are deterministic; no model is consulted.
|
|
5
|
-
import { createHash } from 'node:crypto';
|
|
6
1
|
import { findRun } from "./runStore.js";
|
|
7
2
|
export const WORK_SLICE_EXTENSION = 'work-slice';
|
|
8
3
|
/** The foundation defaults: enough for one meaningful step of work, short enough to report. */
|
|
@@ -99,34 +94,6 @@ export function sliceLimitReached(policy, state, now = Date.now()) {
|
|
|
99
94
|
}
|
|
100
95
|
/** Tools a reply may still call while it writes its handoff. A plan update waits for the next slice. */
|
|
101
96
|
const HANDOFF_TOOLS = new Set(['checkpoint_write', 'session_name']);
|
|
102
|
-
/** Tools whose repetition is how waiting works, not a loop. */
|
|
103
|
-
const POLLING_TOOLS = new Set(['wait_subagents', 'subagents.list', 'subagents.status']);
|
|
104
|
-
/** Commands that read as a check for the repeated-failure rule. */
|
|
105
|
-
export function looksLikeCheck(command) {
|
|
106
|
-
return /\b(test|tests|typecheck|tsc|vitest|jest|cargo|check:|prove|lint|ci|build)\b/u.test(command);
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* A stable rendering of a value: object keys sorted, so argument order never changes the result.
|
|
110
|
-
* Depth is bounded so a cyclic or absurdly nested input cannot recurse forever.
|
|
111
|
-
*/
|
|
112
|
-
function canonical(value, depth = 0) {
|
|
113
|
-
if (depth > 8)
|
|
114
|
-
return '…';
|
|
115
|
-
if (value === null || typeof value !== 'object')
|
|
116
|
-
return JSON.stringify(value) ?? 'null';
|
|
117
|
-
if (Array.isArray(value))
|
|
118
|
-
return `[${value.map((item) => canonical(item, depth + 1)).join(',')}]`;
|
|
119
|
-
const entries = Object.entries(value).filter(([, item]) => item !== undefined);
|
|
120
|
-
entries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
121
|
-
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item, depth + 1)}`).join(',')}}`;
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* A call's identity: its name and every argument, hashed so the key stays small even when an
|
|
125
|
-
* argument is a whole file. A changed argument is a different call; only an identical one repeats.
|
|
126
|
-
* Reading a fixed handful of fields made every `todo_write` look identical, so a reply that
|
|
127
|
-
* reported its progress was told it was repeating work.
|
|
128
|
-
*/
|
|
129
|
-
const signature = (name, input) => `${name}\u0000${createHash('sha256').update(canonical(input ?? null)).digest('hex')}`;
|
|
130
97
|
const clip = (value, limit) => (value.length <= limit ? value : `${value.slice(0, limit - 1)}…`);
|
|
131
98
|
function handoffRequest(limit, state, checkpointAvailable = true) {
|
|
132
99
|
const handoff = checkpointAvailable
|
|
@@ -134,87 +101,28 @@ function handoffRequest(limit, state, checkpointAvailable = true) {
|
|
|
134
101
|
: 'Stop using tools and return a concise partial research report with findings, blockers and the next useful research step. The Harness records an evidence-derived checkpoint.';
|
|
135
102
|
return clip(`This reply reached its work-slice limit (${limit}; step ${state.steps}). ${handoff} Do not call other tools.`, 600);
|
|
136
103
|
}
|
|
137
|
-
function stalledNote(reason, checkpointAvailable = true) {
|
|
138
|
-
const handoff = checkpointAvailable
|
|
139
|
-
? 'Choose a different approach, read what the earlier attempt returned, or call checkpoint_write with the blocker so the next slice can decide.'
|
|
140
|
-
: 'Do not use more tools. Return concise findings and blockers in your final response; the Harness records an evidence-derived checkpoint.';
|
|
141
|
-
return clip(`You are repeating work: ${reason}. Do not repeat it. ${handoff}`, 600);
|
|
142
|
-
}
|
|
143
104
|
/**
|
|
144
|
-
* Bounds one reply
|
|
145
|
-
*
|
|
146
|
-
* is
|
|
105
|
+
* Bounds one reply. A call past the budget is refused, with one wrap-up request when a checkpoint
|
|
106
|
+
* is available, and an ended reply lets only a handoff call through. Three compactions in one reply
|
|
107
|
+
* end it too. Every other call is allowed, however often it is made.
|
|
147
108
|
*/
|
|
148
109
|
export function workSliceGuard(options) {
|
|
149
110
|
const { policy, state } = options;
|
|
150
|
-
const calls = new Map();
|
|
151
|
-
const checkCalls = new Set();
|
|
152
|
-
const edits = new Map();
|
|
153
|
-
const failures = new Map();
|
|
154
|
-
let nudged = false;
|
|
155
111
|
const checkpointAvailable = () => typeof options.checkpointAvailable === 'function'
|
|
156
112
|
? options.checkpointAvailable()
|
|
157
113
|
: options.checkpointAvailable !== false;
|
|
158
|
-
/** Repetition is judged inside one reply; the next reply starts from a clean count. */
|
|
159
|
-
const resetRepetition = () => {
|
|
160
|
-
calls.clear();
|
|
161
|
-
checkCalls.clear();
|
|
162
|
-
edits.clear();
|
|
163
|
-
failures.clear();
|
|
164
|
-
nudged = false;
|
|
165
|
-
};
|
|
166
114
|
const end = (kind, limit) => {
|
|
167
115
|
state.endedBy = kind;
|
|
168
116
|
state.endedByLimit = limit;
|
|
169
117
|
options.onEnd?.(kind, limit);
|
|
170
118
|
const canCheckpoint = checkpointAvailable();
|
|
171
|
-
const reason = kind === '
|
|
172
|
-
?
|
|
173
|
-
:
|
|
174
|
-
? handoffRequest('three context compactions in one reply', state, canCheckpoint)
|
|
175
|
-
: handoffRequest(limit, state, canCheckpoint);
|
|
119
|
+
const reason = kind === 'compaction-thrash'
|
|
120
|
+
? handoffRequest('three context compactions in one reply', state, canCheckpoint)
|
|
121
|
+
: handoffRequest(limit, state, canCheckpoint);
|
|
176
122
|
return { block: true, terminate: true, reason };
|
|
177
123
|
};
|
|
178
|
-
const nudge = (reason) => {
|
|
179
|
-
if (nudged)
|
|
180
|
-
return end('stuck', reason);
|
|
181
|
-
nudged = true;
|
|
182
|
-
const canCheckpoint = checkpointAvailable();
|
|
183
|
-
if (!canCheckpoint) {
|
|
184
|
-
state.endedBy = 'stuck';
|
|
185
|
-
state.endedByLimit = reason;
|
|
186
|
-
}
|
|
187
|
-
return { block: true, reason: stalledNote(reason, canCheckpoint) };
|
|
188
|
-
};
|
|
189
|
-
const repeated = (name, input) => {
|
|
190
|
-
if (POLLING_TOOLS.has(name))
|
|
191
|
-
return null;
|
|
192
|
-
const key = signature(name, input);
|
|
193
|
-
if (name === 'bash') {
|
|
194
|
-
const command = input && typeof input === 'object' ? String(input.command ?? '') : '';
|
|
195
|
-
if (command && looksLikeCheck(command))
|
|
196
|
-
checkCalls.add(key);
|
|
197
|
-
}
|
|
198
|
-
const count = (calls.get(key) ?? 0) + 1;
|
|
199
|
-
calls.set(key, count);
|
|
200
|
-
if (count < 3)
|
|
201
|
-
return null;
|
|
202
|
-
return name === 'bash' ? `the same Bash command ${count} times (steps ending at ${state.steps})` : `the same ${name} call ${count} times (steps ending at ${state.steps})`;
|
|
203
|
-
};
|
|
204
|
-
const failing = (name, input) => {
|
|
205
|
-
if (name !== 'bash')
|
|
206
|
-
return null;
|
|
207
|
-
const command = input && typeof input === 'object' ? String(input.command ?? '') : '';
|
|
208
|
-
if (!command || !looksLikeCheck(command))
|
|
209
|
-
return null;
|
|
210
|
-
const count = failures.get(command) ?? 0;
|
|
211
|
-
return count >= 2 ? `the check ${command} failed ${count} times` : null;
|
|
212
|
-
};
|
|
213
124
|
return (pi) => {
|
|
214
|
-
pi.on('agent_start', () =>
|
|
215
|
-
resetSliceState(state);
|
|
216
|
-
resetRepetition();
|
|
217
|
-
});
|
|
125
|
+
pi.on('agent_start', () => resetSliceState(state));
|
|
218
126
|
pi.on('message_end', (event) => {
|
|
219
127
|
const usage = event.message.usage;
|
|
220
128
|
if (event.message.role !== 'assistant' || !usage)
|
|
@@ -223,36 +131,8 @@ export function workSliceGuard(options) {
|
|
|
223
131
|
state.tokens += (usage.input ?? 0) + (usage.cacheWrite ?? 0) + (usage.output ?? 0);
|
|
224
132
|
});
|
|
225
133
|
pi.on('tool_result', (event) => {
|
|
226
|
-
if (event.toolName === 'checkpoint_write' && !event.isError)
|
|
134
|
+
if (event.toolName === 'checkpoint_write' && !event.isError)
|
|
227
135
|
state.checkpointWritten = true;
|
|
228
|
-
nudged = false;
|
|
229
|
-
}
|
|
230
|
-
if ((event.toolName === 'edit' || event.toolName === 'write') && event.isError === false) {
|
|
231
|
-
const path = event.input && typeof event.input === 'object' ? String(event.input.path ?? '') : '';
|
|
232
|
-
if (path) {
|
|
233
|
-
edits.set(path, (edits.get(path) ?? 0) + 1);
|
|
234
|
-
failures.clear();
|
|
235
|
-
for (const key of checkCalls)
|
|
236
|
-
calls.delete(key);
|
|
237
|
-
checkCalls.clear();
|
|
238
|
-
nudged = false;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
if (event.toolName !== 'bash')
|
|
242
|
-
return;
|
|
243
|
-
const command = event.input && typeof event.input === 'object' ? String(event.input.command ?? '') : '';
|
|
244
|
-
if (!command || !looksLikeCheck(command))
|
|
245
|
-
return;
|
|
246
|
-
if (event.isError) {
|
|
247
|
-
failures.set(command, (failures.get(command) ?? 0) + 1);
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
// A passing check is progress: the repetition rules start over.
|
|
251
|
-
failures.delete(command);
|
|
252
|
-
edits.clear();
|
|
253
|
-
calls.clear();
|
|
254
|
-
checkCalls.clear();
|
|
255
|
-
nudged = false;
|
|
256
136
|
});
|
|
257
137
|
pi.on('tool_call', (event) => {
|
|
258
138
|
const name = event.toolName;
|
|
@@ -284,27 +164,6 @@ export function workSliceGuard(options) {
|
|
|
284
164
|
state.refusedAt ??= state.steps;
|
|
285
165
|
return handoff ? undefined : { block: true, reason: handoffRequest(limit, state, canCheckpoint) };
|
|
286
166
|
}
|
|
287
|
-
if (handoff)
|
|
288
|
-
return undefined;
|
|
289
|
-
// Todo updates are bookkeeping, not evidence of progress or a stall. They still count above
|
|
290
|
-
// toward the reply's overall tool-call budget.
|
|
291
|
-
if (name === 'todo_write')
|
|
292
|
-
return undefined;
|
|
293
|
-
const edit = name === 'edit' || name === 'write';
|
|
294
|
-
const path = edit && event.input && typeof event.input === 'object' ? String(event.input.path ?? '') : '';
|
|
295
|
-
if (edit && path) {
|
|
296
|
-
const count = edits.get(path) ?? 0;
|
|
297
|
-
if (count >= 4) {
|
|
298
|
-
const reason = `${path} was edited ${count} times with no passing check between`;
|
|
299
|
-
return nudge(reason);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
const repeat = repeated(name, event.input);
|
|
303
|
-
const failure = failing(name, event.input);
|
|
304
|
-
const reason = repeat ?? failure;
|
|
305
|
-
if (!reason)
|
|
306
|
-
return undefined;
|
|
307
|
-
return nudge(reason);
|
|
308
167
|
});
|
|
309
168
|
};
|
|
310
169
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare const SOCKET_ENV = "XINGULAR_HARNESS_SOCKET";
|
|
2
2
|
export declare const TOKEN_ENV = "XINGULAR_HARNESS_TOKEN";
|
|
3
|
+
/** Set by `xagent --remote-control`: the terminal turns on `/remote-control` as it opens. */
|
|
4
|
+
export declare const REMOTE_CONTROL_ENV = "XINGULAR_HARNESS_REMOTE_CONTROL";
|
|
3
5
|
export declare class SocketError extends Error {
|
|
4
6
|
readonly code: string;
|
|
5
7
|
constructor(code: string, message: string);
|
|
@@ -10,6 +12,6 @@ export interface SocketConnection {
|
|
|
10
12
|
}
|
|
11
13
|
export declare function connectionFromEnv(env?: NodeJS.ProcessEnv): SocketConnection;
|
|
12
14
|
/** A memory, Wiki, recall, or connected-app call made on behalf of the session that owns the token. */
|
|
13
|
-
export declare function socketCall<T = unknown>(connection: SocketConnection, call: string, params?: Record<string, unknown>, timeoutMs?: number, control?: string): Promise<T>;
|
|
15
|
+
export declare function socketCall<T = unknown>(connection: SocketConnection, call: string, params?: Record<string, unknown>, timeoutMs?: number, control?: string, signal?: AbortSignal): Promise<T>;
|
|
14
16
|
/** Telemetry and decisions from the native `pi` process. Approvals may wait on a person, so the default wait is long. */
|
|
15
17
|
export declare function terminalCall<T = Record<string, unknown>>(event: string, data?: Record<string, unknown>, timeoutMs?: number): Promise<T>;
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { createConnection } from 'node:net';
|
|
6
6
|
export const SOCKET_ENV = 'XINGULAR_HARNESS_SOCKET';
|
|
7
7
|
export const TOKEN_ENV = 'XINGULAR_HARNESS_TOKEN';
|
|
8
|
+
/** Set by `xagent --remote-control`: the terminal turns on `/remote-control` as it opens. */
|
|
9
|
+
export const REMOTE_CONTROL_ENV = 'XINGULAR_HARNESS_REMOTE_CONTROL';
|
|
8
10
|
export class SocketError extends Error {
|
|
9
11
|
code;
|
|
10
12
|
constructor(code, message) {
|
|
@@ -46,18 +48,18 @@ function putIdle(path, stream) {
|
|
|
46
48
|
entries.add(entry);
|
|
47
49
|
idle.set(path, entries);
|
|
48
50
|
}
|
|
49
|
-
function request(connection, body, what, timeoutMs) {
|
|
51
|
+
function request(connection, body, what, timeoutMs, signal) {
|
|
50
52
|
const reused = takeIdle(connection.socket);
|
|
51
|
-
return attempt(connection, body, what, timeoutMs, reused).catch((error) => {
|
|
53
|
+
return attempt(connection, body, what, timeoutMs, reused, signal).catch((error) => {
|
|
52
54
|
// An idle connection the harness had already closed fails before any reply; one fresh retry.
|
|
53
55
|
if (reused && error instanceof StaleConnection)
|
|
54
|
-
return attempt(connection, body, what, timeoutMs, undefined);
|
|
56
|
+
return attempt(connection, body, what, timeoutMs, undefined, signal);
|
|
55
57
|
throw error;
|
|
56
58
|
});
|
|
57
59
|
}
|
|
58
60
|
class StaleConnection extends Error {
|
|
59
61
|
}
|
|
60
|
-
function attempt(connection, body, what, timeoutMs, reused) {
|
|
62
|
+
function attempt(connection, body, what, timeoutMs, reused, signal) {
|
|
61
63
|
return new Promise((resolve, reject) => {
|
|
62
64
|
const stream = reused ?? createConnection(connection.socket);
|
|
63
65
|
let buffer = '';
|
|
@@ -91,6 +93,7 @@ function attempt(connection, body, what, timeoutMs, reused) {
|
|
|
91
93
|
return;
|
|
92
94
|
settled = true;
|
|
93
95
|
clearTimeout(timer);
|
|
96
|
+
signal?.removeEventListener('abort', onAbort);
|
|
94
97
|
stream.off('connect', send);
|
|
95
98
|
stream.off('data', onData);
|
|
96
99
|
stream.off('error', onError);
|
|
@@ -107,6 +110,12 @@ function attempt(connection, body, what, timeoutMs, reused) {
|
|
|
107
110
|
stream.destroy();
|
|
108
111
|
finish(() => reject(new SocketError('timeout', `The harness did not answer ${what}.`)));
|
|
109
112
|
}, timeoutMs);
|
|
113
|
+
const onAbort = () => { stream.destroy(); finish(() => reject(new SocketError('aborted', `${what} was cancelled.`))); };
|
|
114
|
+
if (signal?.aborted) {
|
|
115
|
+
onAbort();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
110
119
|
stream.on('data', onData);
|
|
111
120
|
stream.on('error', onError);
|
|
112
121
|
stream.on('close', onClose);
|
|
@@ -117,8 +126,8 @@ function attempt(connection, body, what, timeoutMs, reused) {
|
|
|
117
126
|
});
|
|
118
127
|
}
|
|
119
128
|
/** A memory, Wiki, recall, or connected-app call made on behalf of the session that owns the token. */
|
|
120
|
-
export function socketCall(connection, call, params = {}, timeoutMs = 120_000, control) {
|
|
121
|
-
return request(connection, { call, params, ...(control ? { control } : {}) }, call, timeoutMs);
|
|
129
|
+
export function socketCall(connection, call, params = {}, timeoutMs = 120_000, control, signal) {
|
|
130
|
+
return request(connection, { call, params, ...(control ? { control } : {}) }, call, timeoutMs, signal);
|
|
122
131
|
}
|
|
123
132
|
/** Telemetry and decisions from the native `pi` process. Approvals may wait on a person, so the default wait is long. */
|
|
124
133
|
export function terminalCall(event, data = {}, timeoutMs = 600_000) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { type SocketConnection } from "./client.js";
|
|
2
2
|
/** A process owns the claim across native session replacement; it never enters the environment. */
|
|
3
3
|
export declare function claimTerminalControl(connection?: SocketConnection): Promise<string>;
|
|
4
|
-
export declare function controlCall<T = unknown>(call: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<T>;
|
|
4
|
+
export declare function controlCall<T = unknown>(call: string, params?: Record<string, unknown>, timeoutMs?: number, signal?: AbortSignal): Promise<T>;
|
|
@@ -18,8 +18,8 @@ export function claimTerminalControl(connection = connectionFromEnv()) {
|
|
|
18
18
|
}
|
|
19
19
|
return claim;
|
|
20
20
|
}
|
|
21
|
-
export async function controlCall(call, params = {}, timeoutMs = 120_000) {
|
|
21
|
+
export async function controlCall(call, params = {}, timeoutMs = 120_000, signal) {
|
|
22
22
|
const connection = connectionFromEnv();
|
|
23
23
|
const control = await claimTerminalControl(connection);
|
|
24
|
-
return socketCall(connection, call, params, timeoutMs, control);
|
|
24
|
+
return signal ? socketCall(connection, call, params, timeoutMs, control, signal) : socketCall(connection, call, params, timeoutMs, control);
|
|
25
25
|
}
|
|
@@ -57,7 +57,10 @@ export interface ControllerHooks {
|
|
|
57
57
|
export declare class TerminalController {
|
|
58
58
|
private readonly hooks;
|
|
59
59
|
private run;
|
|
60
|
+
private lastRunId;
|
|
60
61
|
private readonly pending;
|
|
62
|
+
private pendingRevision;
|
|
63
|
+
private readonly pendingWaiters;
|
|
61
64
|
private readonly subagents;
|
|
62
65
|
private subagentParentRunId;
|
|
63
66
|
private ready;
|
|
@@ -70,9 +73,20 @@ export declare class TerminalController {
|
|
|
70
73
|
private nativeId;
|
|
71
74
|
private readonly seen;
|
|
72
75
|
private readonly commands;
|
|
76
|
+
/** When the reply last produced something other than a heartbeat; silence is what a stall is. */
|
|
77
|
+
private progressAt;
|
|
78
|
+
private readonly runningTools;
|
|
79
|
+
private stallRequested;
|
|
80
|
+
private stallTimer;
|
|
73
81
|
constructor(hooks: ControllerHooks);
|
|
82
|
+
private pendingChanged;
|
|
83
|
+
private setPending;
|
|
84
|
+
private deletePending;
|
|
85
|
+
private waitPending;
|
|
74
86
|
get nativeSessionId(): string | null;
|
|
75
87
|
get activeRun(): ActiveRun | null;
|
|
88
|
+
/** The run this terminal is showing, or the last one it finished: `/context` reads it after a reply. */
|
|
89
|
+
get lastRun(): string | null;
|
|
76
90
|
state(): {
|
|
77
91
|
ready: boolean;
|
|
78
92
|
dirty: boolean;
|
|
@@ -81,6 +95,14 @@ export declare class TerminalController {
|
|
|
81
95
|
private requestId;
|
|
82
96
|
private begin;
|
|
83
97
|
private finish;
|
|
98
|
+
private watchProgress;
|
|
99
|
+
private stopStallWatch;
|
|
100
|
+
/**
|
|
101
|
+
* Ends a reply its native turn can no longer finish: the process is alive and keeps reporting, but
|
|
102
|
+
* the reply produced nothing for the whole window (a model request that never came back). A person
|
|
103
|
+
* may already be answering an approval, and a tool may still be working, so neither is cut short.
|
|
104
|
+
*/
|
|
105
|
+
private checkStall;
|
|
84
106
|
/** Hands the native process an instruction and waits until it acknowledges the prompt. */
|
|
85
107
|
send(runId: string, text: string): Promise<void>;
|
|
86
108
|
/** What the person typed into the terminal, so an instruction is never sent over their draft. */
|
|
@@ -7,6 +7,7 @@ import { HarnessError } from "../core/errors.js";
|
|
|
7
7
|
import { runStatusSchema } from "../sessions/contracts.js";
|
|
8
8
|
import { briefTitle } from "../sessions/subagents.js";
|
|
9
9
|
import { MAIN_PROFILE, SubagentMonitor } from "./subagentMonitor.js";
|
|
10
|
+
import { replyStalled } from "./stallWatch.js";
|
|
10
11
|
function monitorRun(record, relation, profile, runtime) {
|
|
11
12
|
return {
|
|
12
13
|
id: record.id,
|
|
@@ -23,10 +24,15 @@ function monitorRun(record, relation, profile, runtime) {
|
|
|
23
24
|
}
|
|
24
25
|
const READY_TIMEOUT_MS = 90_000;
|
|
25
26
|
const ACK_TIMEOUT_MS = 25_000;
|
|
27
|
+
/** How often a live reply's progress is checked against the stall windows. */
|
|
28
|
+
const STALL_CHECK_MS = 30_000;
|
|
26
29
|
export class TerminalController {
|
|
27
30
|
hooks;
|
|
28
31
|
run = null;
|
|
32
|
+
lastRunId = null;
|
|
29
33
|
pending = new Map();
|
|
34
|
+
pendingRevision = 0;
|
|
35
|
+
pendingWaiters = new Set();
|
|
30
36
|
subagents = new SubagentMonitor();
|
|
31
37
|
subagentParentRunId = null;
|
|
32
38
|
ready = false;
|
|
@@ -39,16 +45,48 @@ export class TerminalController {
|
|
|
39
45
|
nativeId;
|
|
40
46
|
seen = new Map();
|
|
41
47
|
commands = [];
|
|
48
|
+
/** When the reply last produced something other than a heartbeat; silence is what a stall is. */
|
|
49
|
+
progressAt = 0;
|
|
50
|
+
runningTools = new Map();
|
|
51
|
+
stallRequested = null;
|
|
52
|
+
stallTimer = null;
|
|
42
53
|
constructor(hooks) {
|
|
43
54
|
this.hooks = hooks;
|
|
44
55
|
this.nativeId = hooks.nativeSessionId;
|
|
45
56
|
}
|
|
57
|
+
pendingChanged() {
|
|
58
|
+
this.pendingRevision += 1;
|
|
59
|
+
for (const wake of this.pendingWaiters)
|
|
60
|
+
wake();
|
|
61
|
+
}
|
|
62
|
+
setPending(key, value) {
|
|
63
|
+
this.pending.set(key, value);
|
|
64
|
+
this.pendingChanged();
|
|
65
|
+
}
|
|
66
|
+
deletePending(key) {
|
|
67
|
+
if (this.pending.delete(key))
|
|
68
|
+
this.pendingChanged();
|
|
69
|
+
}
|
|
70
|
+
async waitPending(revision) {
|
|
71
|
+
if (revision === this.pendingRevision && !this.disposed) {
|
|
72
|
+
await new Promise(resolve => {
|
|
73
|
+
const wake = () => { clearTimeout(timer); this.pendingWaiters.delete(wake); resolve(); };
|
|
74
|
+
const timer = setTimeout(wake, 20_000);
|
|
75
|
+
this.pendingWaiters.add(wake);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return { revision: this.pendingRevision, requests: [...this.pending.values()] };
|
|
79
|
+
}
|
|
46
80
|
get nativeSessionId() {
|
|
47
81
|
return this.nativeId;
|
|
48
82
|
}
|
|
49
83
|
get activeRun() {
|
|
50
84
|
return this.run && !this.run.finished ? this.run : null;
|
|
51
85
|
}
|
|
86
|
+
/** The run this terminal is showing, or the last one it finished: `/context` reads it after a reply. */
|
|
87
|
+
get lastRun() {
|
|
88
|
+
return this.activeRun?.runId ?? this.lastRunId;
|
|
89
|
+
}
|
|
52
90
|
state() {
|
|
53
91
|
return { ready: this.ready, dirty: this.dirty, active: !!this.activeRun || !!this.delivery };
|
|
54
92
|
}
|
|
@@ -67,7 +105,9 @@ export class TerminalController {
|
|
|
67
105
|
throw new Error('The native prompt did not match the reserved delivery. Inspect the terminal before continuing.');
|
|
68
106
|
const run = this.hooks.begin({ reservedRunId: delivery?.runId ?? null, nativeTurnId, text, ...launch });
|
|
69
107
|
this.run = run;
|
|
108
|
+
this.lastRunId = run.runId;
|
|
70
109
|
this.subagentParentRunId = run.runId;
|
|
110
|
+
this.watchProgress();
|
|
71
111
|
this.subagents.begin(run.runId);
|
|
72
112
|
run.onInterrupt(() => void this.commands.push({ type: 'interrupt' }));
|
|
73
113
|
if (text)
|
|
@@ -77,10 +117,45 @@ export class TerminalController {
|
|
|
77
117
|
return run;
|
|
78
118
|
}
|
|
79
119
|
finish(outcome, message, reason) {
|
|
120
|
+
this.stopStallWatch();
|
|
80
121
|
this.run?.finish(outcome, message ? { code: 'terminal_attention', message } : undefined, reason);
|
|
81
122
|
this.run = null;
|
|
82
123
|
this.seen.clear();
|
|
83
|
-
this.pending.
|
|
124
|
+
if (this.pending.size) {
|
|
125
|
+
this.pending.clear();
|
|
126
|
+
this.pendingChanged();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
watchProgress() {
|
|
130
|
+
this.progressAt = Date.now();
|
|
131
|
+
this.runningTools.clear();
|
|
132
|
+
this.stallRequested = null;
|
|
133
|
+
if (this.stallTimer)
|
|
134
|
+
clearInterval(this.stallTimer);
|
|
135
|
+
this.stallTimer = setInterval(() => this.checkStall(), STALL_CHECK_MS);
|
|
136
|
+
this.stallTimer.unref?.();
|
|
137
|
+
}
|
|
138
|
+
stopStallWatch() {
|
|
139
|
+
if (this.stallTimer)
|
|
140
|
+
clearInterval(this.stallTimer);
|
|
141
|
+
this.stallTimer = null;
|
|
142
|
+
this.stallRequested = null;
|
|
143
|
+
this.runningTools.clear();
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Ends a reply its native turn can no longer finish: the process is alive and keeps reporting, but
|
|
147
|
+
* the reply produced nothing for the whole window (a model request that never came back). A person
|
|
148
|
+
* may already be answering an approval, and a tool may still be working, so neither is cut short.
|
|
149
|
+
*/
|
|
150
|
+
checkStall() {
|
|
151
|
+
const run = this.activeRun;
|
|
152
|
+
if (!run || this.stallRequested || this.pending.size)
|
|
153
|
+
return;
|
|
154
|
+
if (!replyStalled({ now: Date.now(), progressAt: this.progressAt, runningTools: [...this.runningTools.values()] }))
|
|
155
|
+
return;
|
|
156
|
+
this.stallRequested = run.runId;
|
|
157
|
+
this.hooks.telemetry('stall');
|
|
158
|
+
this.commands.push({ type: 'interrupt' });
|
|
84
159
|
}
|
|
85
160
|
/** Hands the native process an instruction and waits until it acknowledges the prompt. */
|
|
86
161
|
async send(runId, text) {
|
|
@@ -145,19 +220,19 @@ export class TerminalController {
|
|
|
145
220
|
return;
|
|
146
221
|
if (event.type === 'question.requested') {
|
|
147
222
|
const payload = event.payload;
|
|
148
|
-
this.
|
|
223
|
+
this.setPending(String(payload.questionId), { ...payload, type: 'question', requestId: payload.questionId, runId: event.runId });
|
|
149
224
|
}
|
|
150
225
|
else if (event.type === 'approval.requested') {
|
|
151
226
|
const payload = event.payload;
|
|
152
227
|
if (!payload.automatic && (descendant || payload.target === 'workspace-write')) {
|
|
153
|
-
this.
|
|
228
|
+
this.setPending(String(payload.approvalId), { ...payload, type: 'approval', requestId: payload.approvalId, runId: event.runId });
|
|
154
229
|
}
|
|
155
230
|
}
|
|
156
231
|
else if (event.type === 'approval.resolved') {
|
|
157
|
-
this.
|
|
232
|
+
this.deletePending(String(event.payload.approvalId));
|
|
158
233
|
}
|
|
159
234
|
else if (event.type === 'question.answered') {
|
|
160
|
-
this.
|
|
235
|
+
this.deletePending(String(event.payload.questionId));
|
|
161
236
|
}
|
|
162
237
|
}
|
|
163
238
|
/** A child event that belongs to the active terminal parent run. */
|
|
@@ -197,6 +272,8 @@ export class TerminalController {
|
|
|
197
272
|
}
|
|
198
273
|
if (call === 'terminal.pending')
|
|
199
274
|
return { requests: [...this.pending.values()] };
|
|
275
|
+
if (call === 'terminal.pending.wait')
|
|
276
|
+
return this.waitPending(data.revision);
|
|
200
277
|
if (call === 'terminal.interrupt')
|
|
201
278
|
return this.activeRun && this.hooks.interrupt ? this.hooks.interrupt(this.activeRun.runId) : {};
|
|
202
279
|
if (call === 'terminal.policy') {
|
|
@@ -292,7 +369,7 @@ export class TerminalController {
|
|
|
292
369
|
rememberable: policy.rememberable === true,
|
|
293
370
|
standing: policy.standing
|
|
294
371
|
});
|
|
295
|
-
this.
|
|
372
|
+
this.setPending(key, {
|
|
296
373
|
type: 'approval', requestId: key, runId: run.runId, target: tool, action,
|
|
297
374
|
operation, rememberable: policy.rememberable === true, direct: true
|
|
298
375
|
});
|
|
@@ -301,13 +378,14 @@ export class TerminalController {
|
|
|
301
378
|
return { decision: await response };
|
|
302
379
|
}
|
|
303
380
|
finally {
|
|
304
|
-
this.
|
|
381
|
+
this.deletePending(key);
|
|
305
382
|
}
|
|
306
383
|
}
|
|
307
384
|
async accept(event, data) {
|
|
308
385
|
if (this.disposed)
|
|
309
386
|
throw new Error('This terminal controller was disposed.');
|
|
310
|
-
|
|
387
|
+
// Streamed reply text is frequent and only feeds run events; it is not host telemetry.
|
|
388
|
+
if (event !== 'poll' && event !== 'delta')
|
|
311
389
|
this.hooks.telemetry(event);
|
|
312
390
|
if (event === 'startup') {
|
|
313
391
|
this.startupPhase = String(data.phase);
|
|
@@ -321,6 +399,9 @@ export class TerminalController {
|
|
|
321
399
|
}
|
|
322
400
|
if (event === 'poll')
|
|
323
401
|
return { command: this.commands.shift() ?? null };
|
|
402
|
+
// A heartbeat proves the turn is alive, not that it is working; only real events restart the clock.
|
|
403
|
+
if (event !== 'heartbeat')
|
|
404
|
+
this.progressAt = Date.now();
|
|
324
405
|
if (event === 'ready') {
|
|
325
406
|
if (!this.ready) {
|
|
326
407
|
this.dirty = false;
|
|
@@ -355,7 +436,8 @@ export class TerminalController {
|
|
|
355
436
|
if (event === 'settled') {
|
|
356
437
|
// A reply that stopped at its work-slice boundary is interrupted, not completed: the record
|
|
357
438
|
// says `slice_complete` (or `stalled`) so a host can continue it (PROP-0041).
|
|
358
|
-
const
|
|
439
|
+
const stalled = this.stallRequested !== null && this.stallRequested === this.run?.runId;
|
|
440
|
+
const reason = stalled ? 'stalled' : typeof data.reason === 'string' ? data.reason : undefined;
|
|
359
441
|
const outcome = data.error ? 'failed' : data.aborted || reason ? 'interrupted' : 'completed';
|
|
360
442
|
this.finish(outcome, data.error ? String(data.error) : undefined, reason);
|
|
361
443
|
return {};
|
|
@@ -380,9 +462,9 @@ export class TerminalController {
|
|
|
380
462
|
const summary = typeof data.summary === 'string' && data.summary.trim() ? data.summary.trim() : undefined;
|
|
381
463
|
const sketchMode = data.uiSketch === undefined ? {} : { uiSketch: data.uiSketch };
|
|
382
464
|
const response = run.requestAnswer({ requestId: key, ...(summary ? { summary } : {}), ...sketchMode, question: String(data.question ?? ''), options: data.options ?? [], multiSelect: !!data.multiSelect, allowText: true });
|
|
383
|
-
this.
|
|
465
|
+
this.setPending(key, { type: 'question', requestId: key, ...(summary ? { summary } : {}), ...sketchMode, question: String(data.question ?? ''), options: data.options ?? [], multiSelect: !!data.multiSelect });
|
|
384
466
|
this.seen.set(key, response);
|
|
385
|
-
return response.finally(() => this.
|
|
467
|
+
return response.finally(() => this.deletePending(key));
|
|
386
468
|
}
|
|
387
469
|
const response = run.requestApproval({
|
|
388
470
|
requestId: key,
|
|
@@ -391,26 +473,34 @@ export class TerminalController {
|
|
|
391
473
|
operation: { tool: String(data.tool ?? data.target), args: data.args ?? {} },
|
|
392
474
|
rememberable: false
|
|
393
475
|
});
|
|
394
|
-
this.
|
|
476
|
+
this.setPending(key, { type: 'approval', requestId: key, target: String(data.target ?? 'Action'), action: String(data.action ?? '{}') });
|
|
395
477
|
this.seen.set(key, response);
|
|
396
478
|
try {
|
|
397
479
|
return { decision: await response };
|
|
398
480
|
}
|
|
399
481
|
finally {
|
|
400
|
-
this.
|
|
482
|
+
this.deletePending(key);
|
|
401
483
|
}
|
|
402
484
|
}
|
|
403
485
|
if (event === 'usage')
|
|
404
486
|
run.emit('usage.updated', data);
|
|
487
|
+
else if (event === 'heartbeat')
|
|
488
|
+
run.emit('heartbeat', data);
|
|
405
489
|
else if (event === 'todo' || event === 'checklist')
|
|
406
490
|
run.emit('todo.updated', { items: data.items });
|
|
407
491
|
else if (event === 'checkpoint')
|
|
408
492
|
run.emit('checkpoint', data);
|
|
409
493
|
else if (event === 'file')
|
|
410
494
|
run.emit('file.changed', data);
|
|
495
|
+
else if (event === 'delta')
|
|
496
|
+
run.emit('message.delta', { messageId: String(data.id ?? ''), text: String(data.text ?? '') });
|
|
411
497
|
else if (event === 'message')
|
|
412
498
|
run.emit('message.completed', { messageId: data.id ?? randomUUID(), role: data.role ?? 'assistant', text: String(data.text) });
|
|
413
499
|
else if (event === 'tool') {
|
|
500
|
+
if (data.phase === 'start')
|
|
501
|
+
this.runningTools.set(String(data.id), Date.now());
|
|
502
|
+
else
|
|
503
|
+
this.runningTools.delete(String(data.id));
|
|
414
504
|
run.emit(data.phase === 'start' ? 'tool.started' : 'tool.completed', {
|
|
415
505
|
toolCallId: String(data.id),
|
|
416
506
|
name: String(data.name),
|
|
@@ -425,6 +515,7 @@ export class TerminalController {
|
|
|
425
515
|
if (this.disposed)
|
|
426
516
|
return;
|
|
427
517
|
this.disposed = true;
|
|
518
|
+
this.pendingChanged();
|
|
428
519
|
this.ready = false;
|
|
429
520
|
this.delivery?.reject(new Error('The terminal stopped before prompt acknowledgement.'));
|
|
430
521
|
this.delivery = null;
|