@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +3680 -1947
  4. package/dist/sdk/cli.js +2 -0
  5. package/dist/sdk/commands/registry.d.ts +15 -0
  6. package/dist/sdk/commands/registry.js +2 -0
  7. package/dist/sdk/computer/screenshot.d.ts +32 -0
  8. package/dist/sdk/computer/screenshot.js +35 -0
  9. package/dist/sdk/context/attribution.d.ts +18 -0
  10. package/dist/sdk/context/attribution.js +295 -0
  11. package/dist/sdk/context/types.d.ts +94 -0
  12. package/dist/sdk/context/types.js +10 -0
  13. package/dist/sdk/index.d.ts +2 -1
  14. package/dist/sdk/index.js +2 -1
  15. package/dist/sdk/plugins/builtin.js +1 -0
  16. package/dist/sdk/prompt/compile.d.ts +42 -2
  17. package/dist/sdk/prompt/compile.js +87 -15
  18. package/dist/sdk/remote/addresses.d.ts +19 -0
  19. package/dist/sdk/remote/addresses.js +52 -0
  20. package/dist/sdk/remote/pairing.d.ts +59 -0
  21. package/dist/sdk/remote/pairing.js +160 -0
  22. package/dist/sdk/remote/seal.d.ts +28 -0
  23. package/dist/sdk/remote/seal.js +84 -0
  24. package/dist/sdk/remote/server.d.ts +28 -0
  25. package/dist/sdk/remote/server.js +158 -0
  26. package/dist/sdk/remote/service.d.ts +125 -0
  27. package/dist/sdk/remote/service.js +330 -0
  28. package/dist/sdk/remote/webClient.d.ts +4 -0
  29. package/dist/sdk/remote/webClient.js +444 -0
  30. package/dist/sdk/server/methods.js +3 -0
  31. package/dist/sdk/sessions/contracts.d.ts +4 -0
  32. package/dist/sdk/sessions/contracts.js +2 -0
  33. package/dist/sdk/sessions/planMode.d.ts +26 -1
  34. package/dist/sdk/sessions/planMode.js +48 -1
  35. package/dist/sdk/sessions/runner.d.ts +12 -0
  36. package/dist/sdk/sessions/runner.js +34 -2
  37. package/dist/sdk/sessions/sessions.js +1 -0
  38. package/dist/sdk/sessions/subagentService.d.ts +11 -1
  39. package/dist/sdk/sessions/subagentService.js +120 -38
  40. package/dist/sdk/sessions/subagents.d.ts +31 -0
  41. package/dist/sdk/sessions/subagents.js +45 -0
  42. package/dist/sdk/sessions/workSlice.d.ts +5 -9
  43. package/dist/sdk/sessions/workSlice.js +8 -149
  44. package/dist/sdk/terminal/client.d.ts +3 -1
  45. package/dist/sdk/terminal/client.js +15 -6
  46. package/dist/sdk/terminal/control.d.ts +1 -1
  47. package/dist/sdk/terminal/control.js +2 -2
  48. package/dist/sdk/terminal/controller.d.ts +22 -0
  49. package/dist/sdk/terminal/controller.js +104 -13
  50. package/dist/sdk/terminal/piExtension.js +106 -4
  51. package/dist/sdk/terminal/stallWatch.d.ts +13 -0
  52. package/dist/sdk/terminal/stallWatch.js +16 -0
  53. package/dist/sdk/terminal/standalone.d.ts +2 -0
  54. package/dist/sdk/terminal/standalone.js +9 -2
  55. package/dist/sdk/terminal/terminals.d.ts +14 -0
  56. package/dist/sdk/terminal/terminals.js +98 -2
  57. package/dist/sdk/terminal/tui/commandPages.d.ts +4 -1
  58. package/dist/sdk/terminal/tui/commandPages.js +12 -0
  59. package/dist/sdk/terminal/tui/commands.d.ts +3 -2
  60. package/dist/sdk/terminal/tui/commands.js +19 -5
  61. package/dist/sdk/terminal/tui/dialogs.d.ts +3 -2
  62. package/dist/sdk/terminal/tui/dialogs.js +4 -3
  63. package/dist/sdk/terminal/tui/keybindings.d.ts +1 -1
  64. package/dist/sdk/terminal/tui/keybindings.js +1 -1
  65. package/dist/sdk/terminal/tui/markdown.d.ts +16 -0
  66. package/dist/sdk/terminal/tui/markdown.js +55 -0
  67. package/dist/sdk/terminal/tui/modeChoices.d.ts +3 -2
  68. package/dist/sdk/terminal/tui/modeChoices.js +4 -4
  69. package/dist/sdk/terminal/tui/mouseReport.d.ts +30 -0
  70. package/dist/sdk/terminal/tui/mouseReport.js +80 -0
  71. package/dist/sdk/terminal/tui/pages/annotatePage.js +3 -2
  72. package/dist/sdk/terminal/tui/pages/browserPage.d.ts +1 -1
  73. package/dist/sdk/terminal/tui/pages/contextPage.d.ts +62 -0
  74. package/dist/sdk/terminal/tui/pages/contextPage.js +484 -0
  75. package/dist/sdk/terminal/tui/pages/remoteControlPage.d.ts +32 -0
  76. package/dist/sdk/terminal/tui/pages/remoteControlPage.js +170 -0
  77. package/dist/sdk/terminal/tui/pages/trajectoryPage.js +5 -4
  78. package/dist/sdk/terminal/tui/qr.d.ts +4 -0
  79. package/dist/sdk/terminal/tui/qr.js +35 -0
  80. package/dist/sdk/terminal/tui/runtime.js +8 -2
  81. package/dist/sdk/terminal/tui/search.js +3 -2
  82. package/dist/sdk/terminal/tui/sideQuestion.d.ts +1 -0
  83. package/dist/sdk/terminal/tui/sideQuestion.js +8 -3
  84. package/dist/sdk/terminal/tui/skillInvocation.d.ts +21 -2
  85. package/dist/sdk/terminal/tui/skillInvocation.js +31 -11
  86. package/dist/sdk/terminal/tui/status.d.ts +6 -0
  87. package/dist/sdk/terminal/tui/status.js +15 -1
  88. package/dist/sdk/terminal/tui/theme.d.ts +9 -0
  89. package/dist/sdk/terminal/tui/theme.js +9 -0
  90. package/dist/sdk/tools/defaultSkills.d.ts +19 -0
  91. package/dist/sdk/tools/defaultSkills.js +27 -1
  92. package/dist/sdk/tools/dispatch.js +8 -9
  93. package/dist/sdk/tools/spawnSubagent.d.ts +1 -1
  94. package/dist/sdk/tools/spawnSubagent.js +4 -2
  95. package/dist/terminal-opentui.js +1607 -492
  96. package/dist/terminal-pi.js +256 -144
  97. package/docs/README.md +1 -0
  98. package/docs/architecture/02-sessions-and-runs.md +55 -22
  99. package/docs/architecture/04-prompt.md +23 -4
  100. package/docs/architecture/07-tools-and-gate.md +4 -2
  101. package/docs/architecture/10-server-protocol.md +24 -0
  102. package/docs/architecture/11-terminal.md +52 -10
  103. package/docs/architecture/12-storage.md +5 -2
  104. package/docs/architecture/13-world-tools.md +17 -3
  105. package/docs/architecture/14-surfaces-and-parity.md +7 -3
  106. package/docs/architecture/15-remote-control.md +118 -0
  107. package/docs/architecture/README.md +3 -0
  108. package/docs/architecture/proposals/PROP-0049-context-attribution.md +409 -0
  109. package/docs/architecture/proposals/README.md +2 -1
  110. package/docs/decisions/ADR-0039-progress-aware-stall-guards.md +4 -0
  111. package/docs/decisions/ADR-0048-required-skills-from-a-message.md +42 -0
  112. package/docs/decisions/ADR-0050-approved-window-screenshots.md +19 -0
  113. package/docs/decisions/ADR-0051-terminal-decision-wait.md +10 -0
  114. package/docs/decisions/ADR-0052-per-profile-subagent-budgets.md +25 -0
  115. package/docs/decisions/ADR-0053-no-repeated-work-guard.md +45 -0
  116. package/docs/decisions/ADR-0054-terminal-reply-heartbeat-and-stall.md +52 -0
  117. package/docs/decisions/ADR-0055-plan-output-repair.md +51 -0
  118. package/docs/decisions/ADR-0056-remote-control.md +53 -0
  119. package/docs/decisions/ADR-0057-structured-plan-outcomes.md +39 -0
  120. package/docs/decisions/ADR-0058-ask-user-only-plan-clarification.md +36 -0
  121. package/docs/parity.md +14 -10
  122. package/docs/releases/1.3.1-unstable.3.md +54 -0
  123. package/docs/releases/1.3.1-unstable.4.md +56 -0
  124. package/docs/terminal-tui.md +43 -18
  125. 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 and names repeated work. Refusals request a checkpoint when available; without
145
- * one, they ask for a partial report and prohibit further tools. A repeated call or failing command
146
- * is named once, then ends the reply.
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 === 'stuck'
172
- ? stalledNote(limit, canCheckpoint)
173
- : kind === 'compaction-thrash'
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.clear();
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.pending.set(String(payload.questionId), { ...payload, type: 'question', requestId: payload.questionId, runId: event.runId });
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.pending.set(String(payload.approvalId), { ...payload, type: 'approval', requestId: payload.approvalId, runId: event.runId });
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.pending.delete(String(event.payload.approvalId));
232
+ this.deletePending(String(event.payload.approvalId));
158
233
  }
159
234
  else if (event.type === 'question.answered') {
160
- this.pending.delete(String(event.payload.questionId));
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.pending.set(key, {
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.pending.delete(key);
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
- if (event !== 'poll')
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 reason = typeof data.reason === 'string' ? data.reason : undefined;
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.pending.set(key, { type: 'question', requestId: key, ...(summary ? { summary } : {}), ...sketchMode, question: String(data.question ?? ''), options: data.options ?? [], multiSelect: !!data.multiSelect });
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.pending.delete(key));
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.pending.set(key, { type: 'approval', requestId: key, target: String(data.target ?? 'Action'), action: String(data.action ?? '{}') });
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.pending.delete(key);
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;