@xingular/harness 1.3.1-unstable.2 → 1.3.1-unstable.3

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 (76) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +1217 -701
  4. package/dist/sdk/computer/screenshot.d.ts +32 -0
  5. package/dist/sdk/computer/screenshot.js +35 -0
  6. package/dist/sdk/context/attribution.d.ts +18 -0
  7. package/dist/sdk/context/attribution.js +295 -0
  8. package/dist/sdk/context/types.d.ts +94 -0
  9. package/dist/sdk/context/types.js +10 -0
  10. package/dist/sdk/index.d.ts +2 -1
  11. package/dist/sdk/index.js +2 -1
  12. package/dist/sdk/plugins/builtin.js +1 -0
  13. package/dist/sdk/prompt/compile.d.ts +42 -2
  14. package/dist/sdk/prompt/compile.js +87 -15
  15. package/dist/sdk/server/methods.js +3 -0
  16. package/dist/sdk/sessions/contracts.d.ts +4 -0
  17. package/dist/sdk/sessions/contracts.js +2 -0
  18. package/dist/sdk/sessions/runner.d.ts +10 -0
  19. package/dist/sdk/sessions/runner.js +17 -0
  20. package/dist/sdk/sessions/subagentService.d.ts +11 -1
  21. package/dist/sdk/sessions/subagentService.js +120 -38
  22. package/dist/sdk/sessions/subagents.d.ts +31 -0
  23. package/dist/sdk/sessions/subagents.js +45 -0
  24. package/dist/sdk/terminal/client.d.ts +1 -1
  25. package/dist/sdk/terminal/client.js +13 -6
  26. package/dist/sdk/terminal/control.d.ts +1 -1
  27. package/dist/sdk/terminal/control.js +2 -2
  28. package/dist/sdk/terminal/controller.d.ts +9 -0
  29. package/dist/sdk/terminal/controller.js +48 -11
  30. package/dist/sdk/terminal/piExtension.js +35 -1
  31. package/dist/sdk/terminal/terminals.d.ts +2 -0
  32. package/dist/sdk/terminal/terminals.js +11 -0
  33. package/dist/sdk/terminal/tui/commands.d.ts +1 -2
  34. package/dist/sdk/terminal/tui/commands.js +1 -4
  35. package/dist/sdk/terminal/tui/dialogs.d.ts +3 -2
  36. package/dist/sdk/terminal/tui/dialogs.js +4 -3
  37. package/dist/sdk/terminal/tui/keybindings.d.ts +1 -1
  38. package/dist/sdk/terminal/tui/keybindings.js +1 -1
  39. package/dist/sdk/terminal/tui/modeChoices.d.ts +3 -2
  40. package/dist/sdk/terminal/tui/modeChoices.js +4 -4
  41. package/dist/sdk/terminal/tui/mouseReport.d.ts +30 -0
  42. package/dist/sdk/terminal/tui/mouseReport.js +80 -0
  43. package/dist/sdk/terminal/tui/pages/annotatePage.js +3 -2
  44. package/dist/sdk/terminal/tui/pages/browserPage.d.ts +1 -1
  45. package/dist/sdk/terminal/tui/pages/contextPage.d.ts +54 -0
  46. package/dist/sdk/terminal/tui/pages/contextPage.js +393 -0
  47. package/dist/sdk/terminal/tui/pages/trajectoryPage.js +5 -4
  48. package/dist/sdk/terminal/tui/runtime.js +8 -2
  49. package/dist/sdk/terminal/tui/search.js +3 -2
  50. package/dist/sdk/terminal/tui/skillInvocation.d.ts +21 -2
  51. package/dist/sdk/terminal/tui/skillInvocation.js +31 -11
  52. package/dist/sdk/tools/defaultSkills.d.ts +19 -0
  53. package/dist/sdk/tools/defaultSkills.js +27 -1
  54. package/dist/sdk/tools/dispatch.js +8 -9
  55. package/dist/sdk/tools/spawnSubagent.d.ts +1 -1
  56. package/dist/sdk/tools/spawnSubagent.js +4 -2
  57. package/dist/terminal-opentui.js +570 -240
  58. package/dist/terminal-pi.js +142 -28
  59. package/docs/README.md +1 -0
  60. package/docs/architecture/02-sessions-and-runs.md +9 -0
  61. package/docs/architecture/04-prompt.md +13 -4
  62. package/docs/architecture/07-tools-and-gate.md +1 -1
  63. package/docs/architecture/11-terminal.md +12 -2
  64. package/docs/architecture/13-world-tools.md +17 -3
  65. package/docs/architecture/14-surfaces-and-parity.md +3 -3
  66. package/docs/architecture/README.md +1 -0
  67. package/docs/architecture/proposals/PROP-0049-context-attribution.md +382 -0
  68. package/docs/architecture/proposals/README.md +1 -0
  69. package/docs/decisions/ADR-0048-required-skills-from-a-message.md +42 -0
  70. package/docs/decisions/ADR-0050-approved-window-screenshots.md +19 -0
  71. package/docs/decisions/ADR-0051-terminal-decision-wait.md +10 -0
  72. package/docs/decisions/ADR-0052-per-profile-subagent-budgets.md +25 -0
  73. package/docs/parity.md +6 -6
  74. package/docs/releases/1.3.1-unstable.3.md +54 -0
  75. package/docs/terminal-tui.md +28 -15
  76. package/package.json +1 -1
@@ -46,18 +46,18 @@ function putIdle(path, stream) {
46
46
  entries.add(entry);
47
47
  idle.set(path, entries);
48
48
  }
49
- function request(connection, body, what, timeoutMs) {
49
+ function request(connection, body, what, timeoutMs, signal) {
50
50
  const reused = takeIdle(connection.socket);
51
- return attempt(connection, body, what, timeoutMs, reused).catch((error) => {
51
+ return attempt(connection, body, what, timeoutMs, reused, signal).catch((error) => {
52
52
  // An idle connection the harness had already closed fails before any reply; one fresh retry.
53
53
  if (reused && error instanceof StaleConnection)
54
- return attempt(connection, body, what, timeoutMs, undefined);
54
+ return attempt(connection, body, what, timeoutMs, undefined, signal);
55
55
  throw error;
56
56
  });
57
57
  }
58
58
  class StaleConnection extends Error {
59
59
  }
60
- function attempt(connection, body, what, timeoutMs, reused) {
60
+ function attempt(connection, body, what, timeoutMs, reused, signal) {
61
61
  return new Promise((resolve, reject) => {
62
62
  const stream = reused ?? createConnection(connection.socket);
63
63
  let buffer = '';
@@ -91,6 +91,7 @@ function attempt(connection, body, what, timeoutMs, reused) {
91
91
  return;
92
92
  settled = true;
93
93
  clearTimeout(timer);
94
+ signal?.removeEventListener('abort', onAbort);
94
95
  stream.off('connect', send);
95
96
  stream.off('data', onData);
96
97
  stream.off('error', onError);
@@ -107,6 +108,12 @@ function attempt(connection, body, what, timeoutMs, reused) {
107
108
  stream.destroy();
108
109
  finish(() => reject(new SocketError('timeout', `The harness did not answer ${what}.`)));
109
110
  }, timeoutMs);
111
+ const onAbort = () => { stream.destroy(); finish(() => reject(new SocketError('aborted', `${what} was cancelled.`))); };
112
+ if (signal?.aborted) {
113
+ onAbort();
114
+ return;
115
+ }
116
+ signal?.addEventListener('abort', onAbort, { once: true });
110
117
  stream.on('data', onData);
111
118
  stream.on('error', onError);
112
119
  stream.on('close', onClose);
@@ -117,8 +124,8 @@ function attempt(connection, body, what, timeoutMs, reused) {
117
124
  });
118
125
  }
119
126
  /** 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);
127
+ export function socketCall(connection, call, params = {}, timeoutMs = 120_000, control, signal) {
128
+ return request(connection, { call, params, ...(control ? { control } : {}) }, call, timeoutMs, signal);
122
129
  }
123
130
  /** Telemetry and decisions from the native `pi` process. Approvals may wait on a person, so the default wait is long. */
124
131
  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;
@@ -71,8 +74,14 @@ export declare class TerminalController {
71
74
  private readonly seen;
72
75
  private readonly commands;
73
76
  constructor(hooks: ControllerHooks);
77
+ private pendingChanged;
78
+ private setPending;
79
+ private deletePending;
80
+ private waitPending;
74
81
  get nativeSessionId(): string | null;
75
82
  get activeRun(): ActiveRun | null;
83
+ /** The run this terminal is showing, or the last one it finished: `/context` reads it after a reply. */
84
+ get lastRun(): string | null;
76
85
  state(): {
77
86
  ready: boolean;
78
87
  dirty: boolean;
@@ -26,7 +26,10 @@ const ACK_TIMEOUT_MS = 25_000;
26
26
  export class TerminalController {
27
27
  hooks;
28
28
  run = null;
29
+ lastRunId = null;
29
30
  pending = new Map();
31
+ pendingRevision = 0;
32
+ pendingWaiters = new Set();
30
33
  subagents = new SubagentMonitor();
31
34
  subagentParentRunId = null;
32
35
  ready = false;
@@ -43,12 +46,39 @@ export class TerminalController {
43
46
  this.hooks = hooks;
44
47
  this.nativeId = hooks.nativeSessionId;
45
48
  }
49
+ pendingChanged() {
50
+ this.pendingRevision += 1;
51
+ for (const wake of this.pendingWaiters)
52
+ wake();
53
+ }
54
+ setPending(key, value) {
55
+ this.pending.set(key, value);
56
+ this.pendingChanged();
57
+ }
58
+ deletePending(key) {
59
+ if (this.pending.delete(key))
60
+ this.pendingChanged();
61
+ }
62
+ async waitPending(revision) {
63
+ if (revision === this.pendingRevision && !this.disposed) {
64
+ await new Promise(resolve => {
65
+ const wake = () => { clearTimeout(timer); this.pendingWaiters.delete(wake); resolve(); };
66
+ const timer = setTimeout(wake, 20_000);
67
+ this.pendingWaiters.add(wake);
68
+ });
69
+ }
70
+ return { revision: this.pendingRevision, requests: [...this.pending.values()] };
71
+ }
46
72
  get nativeSessionId() {
47
73
  return this.nativeId;
48
74
  }
49
75
  get activeRun() {
50
76
  return this.run && !this.run.finished ? this.run : null;
51
77
  }
78
+ /** The run this terminal is showing, or the last one it finished: `/context` reads it after a reply. */
79
+ get lastRun() {
80
+ return this.activeRun?.runId ?? this.lastRunId;
81
+ }
52
82
  state() {
53
83
  return { ready: this.ready, dirty: this.dirty, active: !!this.activeRun || !!this.delivery };
54
84
  }
@@ -67,6 +97,7 @@ export class TerminalController {
67
97
  throw new Error('The native prompt did not match the reserved delivery. Inspect the terminal before continuing.');
68
98
  const run = this.hooks.begin({ reservedRunId: delivery?.runId ?? null, nativeTurnId, text, ...launch });
69
99
  this.run = run;
100
+ this.lastRunId = run.runId;
70
101
  this.subagentParentRunId = run.runId;
71
102
  this.subagents.begin(run.runId);
72
103
  run.onInterrupt(() => void this.commands.push({ type: 'interrupt' }));
@@ -80,7 +111,10 @@ export class TerminalController {
80
111
  this.run?.finish(outcome, message ? { code: 'terminal_attention', message } : undefined, reason);
81
112
  this.run = null;
82
113
  this.seen.clear();
83
- this.pending.clear();
114
+ if (this.pending.size) {
115
+ this.pending.clear();
116
+ this.pendingChanged();
117
+ }
84
118
  }
85
119
  /** Hands the native process an instruction and waits until it acknowledges the prompt. */
86
120
  async send(runId, text) {
@@ -145,19 +179,19 @@ export class TerminalController {
145
179
  return;
146
180
  if (event.type === 'question.requested') {
147
181
  const payload = event.payload;
148
- this.pending.set(String(payload.questionId), { ...payload, type: 'question', requestId: payload.questionId, runId: event.runId });
182
+ this.setPending(String(payload.questionId), { ...payload, type: 'question', requestId: payload.questionId, runId: event.runId });
149
183
  }
150
184
  else if (event.type === 'approval.requested') {
151
185
  const payload = event.payload;
152
186
  if (!payload.automatic && (descendant || payload.target === 'workspace-write')) {
153
- this.pending.set(String(payload.approvalId), { ...payload, type: 'approval', requestId: payload.approvalId, runId: event.runId });
187
+ this.setPending(String(payload.approvalId), { ...payload, type: 'approval', requestId: payload.approvalId, runId: event.runId });
154
188
  }
155
189
  }
156
190
  else if (event.type === 'approval.resolved') {
157
- this.pending.delete(String(event.payload.approvalId));
191
+ this.deletePending(String(event.payload.approvalId));
158
192
  }
159
193
  else if (event.type === 'question.answered') {
160
- this.pending.delete(String(event.payload.questionId));
194
+ this.deletePending(String(event.payload.questionId));
161
195
  }
162
196
  }
163
197
  /** A child event that belongs to the active terminal parent run. */
@@ -197,6 +231,8 @@ export class TerminalController {
197
231
  }
198
232
  if (call === 'terminal.pending')
199
233
  return { requests: [...this.pending.values()] };
234
+ if (call === 'terminal.pending.wait')
235
+ return this.waitPending(data.revision);
200
236
  if (call === 'terminal.interrupt')
201
237
  return this.activeRun && this.hooks.interrupt ? this.hooks.interrupt(this.activeRun.runId) : {};
202
238
  if (call === 'terminal.policy') {
@@ -292,7 +328,7 @@ export class TerminalController {
292
328
  rememberable: policy.rememberable === true,
293
329
  standing: policy.standing
294
330
  });
295
- this.pending.set(key, {
331
+ this.setPending(key, {
296
332
  type: 'approval', requestId: key, runId: run.runId, target: tool, action,
297
333
  operation, rememberable: policy.rememberable === true, direct: true
298
334
  });
@@ -301,7 +337,7 @@ export class TerminalController {
301
337
  return { decision: await response };
302
338
  }
303
339
  finally {
304
- this.pending.delete(key);
340
+ this.deletePending(key);
305
341
  }
306
342
  }
307
343
  async accept(event, data) {
@@ -380,9 +416,9 @@ export class TerminalController {
380
416
  const summary = typeof data.summary === 'string' && data.summary.trim() ? data.summary.trim() : undefined;
381
417
  const sketchMode = data.uiSketch === undefined ? {} : { uiSketch: data.uiSketch };
382
418
  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 });
419
+ this.setPending(key, { type: 'question', requestId: key, ...(summary ? { summary } : {}), ...sketchMode, question: String(data.question ?? ''), options: data.options ?? [], multiSelect: !!data.multiSelect });
384
420
  this.seen.set(key, response);
385
- return response.finally(() => this.pending.delete(key));
421
+ return response.finally(() => this.deletePending(key));
386
422
  }
387
423
  const response = run.requestApproval({
388
424
  requestId: key,
@@ -391,13 +427,13 @@ export class TerminalController {
391
427
  operation: { tool: String(data.tool ?? data.target), args: data.args ?? {} },
392
428
  rememberable: false
393
429
  });
394
- this.pending.set(key, { type: 'approval', requestId: key, target: String(data.target ?? 'Action'), action: String(data.action ?? '{}') });
430
+ this.setPending(key, { type: 'approval', requestId: key, target: String(data.target ?? 'Action'), action: String(data.action ?? '{}') });
395
431
  this.seen.set(key, response);
396
432
  try {
397
433
  return { decision: await response };
398
434
  }
399
435
  finally {
400
- this.pending.delete(key);
436
+ this.deletePending(key);
401
437
  }
402
438
  }
403
439
  if (event === 'usage')
@@ -425,6 +461,7 @@ export class TerminalController {
425
461
  if (this.disposed)
426
462
  return;
427
463
  this.disposed = true;
464
+ this.pendingChanged();
428
465
  this.ready = false;
429
466
  this.delivery?.reject(new Error('The terminal stopped before prompt acknowledgement.'));
430
467
  this.delivery = null;
@@ -2,8 +2,10 @@
2
2
  // session does to the harness, asks before each tool call, takes instructions the harness
3
3
  // queues for it, and gives the session the harness's own tools. It runs in the `pi` process,
4
4
  // so everything it knows about the harness comes through the session socket.
5
+ import { readFileSync } from 'node:fs';
5
6
  import { createBashTool, createEditTool, createPowerShellTool, createWriteTool } from '@earendil-works/pi-coding-agent';
6
7
  import { Type } from 'typebox';
8
+ import { bodyOf } from "../core/frontmatter.js";
7
9
  import { connectionFromEnv, socketCall, terminalCall } from "./client.js";
8
10
  import { controlCall } from "./control.js";
9
11
  import { SPAWN_SUBAGENT, SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, subagentHistory } from "../sessions/subagents.js";
@@ -17,9 +19,10 @@ import { SESSION_NAME, deriveSessionName, sessionNameTool } from "../tools/sessi
17
19
  import { CAPABILITY_DISCOVERY, describeCapabilities } from "../tools/getCapabilities.js";
18
20
  import { PLAN_INSTRUCTIONS, planRefusal } from "../sessions/planMode.js";
19
21
  import { compactionCheckpoint } from "../sessions/compactionCheckpoint.js";
20
- import { sessionStateText } from "../prompt/compile.js";
22
+ import { requiredSkillsText, sessionStateText } from "../prompt/compile.js";
21
23
  import { pruneToolImages } from "../world/images.js";
22
24
  import { parseInitialBashCommand } from "../sessions/initialBash.js";
25
+ import { skillInvocations } from "./tui/skillInvocation.js";
23
26
  import { inspectWorkspaceWrite } from "../sessions/workspaceBoundary.js";
24
27
  import { createWorkspaceShellTool } from "../core/workspaceBashTool.js";
25
28
  import { createWorkspaceBashOperations, runWithWorkspaceWriteScope } from "../core/workspaceSandbox.js";
@@ -298,6 +301,37 @@ export default async function (pi, planRun, getSession) {
298
301
  if (notice)
299
302
  return { message: { customType: SUBAGENT_RESULTS, content: notice.text, display: false, details: { [SUBAGENT_RECEIPT]: notice.receipt } } };
300
303
  });
304
+ // A `$name` anywhere in the message makes that skill required for this request. The skill's text is
305
+ // delivered as a hidden request-scoped message, so the person's own wording reaches the model unchanged.
306
+ pi.on('before_agent_start', async (event) => {
307
+ if (event.messageType !== 'user' || /^\s*!/u.test(event.prompt))
308
+ return;
309
+ if (pi.getSettings?.().enableSkillCommands === false)
310
+ return;
311
+ const skills = (pi.getCommands?.() ?? [])
312
+ .filter((command) => command.source === 'skill')
313
+ .map((command) => ({
314
+ name: command.name.replace(/^skill:/, ''),
315
+ path: command.sourceInfo.path,
316
+ ...(command.sourceInfo.baseDir ? { baseDir: command.sourceInfo.baseDir } : {})
317
+ }));
318
+ if (!skills.length)
319
+ return;
320
+ const names = skillInvocations(event.prompt, new Set(skills.map((skill) => skill.name)));
321
+ if (!names.length)
322
+ return;
323
+ const entries = names.map((name) => {
324
+ const skill = skills.find((candidate) => candidate.name === name);
325
+ try {
326
+ return { name, path: skill.path, baseDir: skill.baseDir, body: bodyOf(readFileSync(skill.path, 'utf8')) };
327
+ }
328
+ catch {
329
+ return { name, path: skill.path, body: '' };
330
+ }
331
+ });
332
+ const content = requiredSkillsText(entries);
333
+ return content ? { message: { customType: 'xingular-required-skills', content, display: false, details: { skills: names } } } : undefined;
334
+ });
301
335
  // A new user turn starts with an empty todo. The previous list is context, not this run's list.
302
336
  // A substantive first turn also gets a provisional name when the terminal policy allows it.
303
337
  pi.on('before_agent_start', async (event, ctx) => {
@@ -198,6 +198,8 @@ export declare class Terminals {
198
198
  private agentsCall;
199
199
  /** `/worktree`: the managed worktrees of the repository this session works in. */
200
200
  private worktreesCall;
201
+ /** `/context`: what the session's latest request was made of, and one row's text on request. */
202
+ private contextCall;
201
203
  /** `/usage:analysis` and `/usage:limit`: usage for this session's own account, project or session. */
202
204
  private usageCall;
203
205
  /** `/annotate:notes` and `/annotate:`: Web annotations and their visible capture browser. */
@@ -21,6 +21,7 @@ import { defaultSkillPaths } from "../tools/defaultSkills.js";
21
21
  import { materialise } from "../variant/materialise.js";
22
22
  import * as componentLibrary from "../variant/components.js";
23
23
  import * as variantStore from "../variant/store.js";
24
+ import { attribution, rowText } from "../context/attribution.js";
24
25
  import { compile, copyToRun, record, render, saveForSession } from "../prompt/compile.js";
25
26
  import { isActiveStatus } from "../sessions/contracts.js";
26
27
  import { findRun, loadRun, readEvents, recordedSubagents, saveRun, updateRun } from "../sessions/runStore.js";
@@ -575,6 +576,14 @@ export class Terminals {
575
576
  throw new HarnessError('unknown_method', `Unknown call ${call}.`);
576
577
  }
577
578
  }
579
+ /** `/context`: what the session's latest request was made of, and one row's text on request. */
580
+ contextCall(live, params) {
581
+ const runId = live.controller.lastRun;
582
+ if (!runId)
583
+ return { attribution: null, text: null };
584
+ const text = typeof params.section === 'string' && params.section ? rowText(this.options.env, runId, params.section) : null;
585
+ return { attribution: attribution(this.options.env, runId), text };
586
+ }
578
587
  /** `/usage:analysis` and `/usage:limit`: usage for this session's own account, project or session. */
579
588
  async usageCall(live, call, params) {
580
589
  const usage = this.options.usage;
@@ -1290,6 +1299,8 @@ export class Terminals {
1290
1299
  }
1291
1300
  if (call.startsWith('terminal.usage.'))
1292
1301
  return this.usageCall(live, call, params);
1302
+ if (call === 'terminal.context')
1303
+ return this.contextCall(live, params);
1293
1304
  if (call !== 'terminal.resources')
1294
1305
  return live.controller.control(call, params);
1295
1306
  const current = await this.options.plugins?.loading({ cwd: input.cwd, agentDir: configDir(accounts.dir, preparedAccount), install: false, caller: { projectId: input.projectId ?? null } }) ?? null;
@@ -1,5 +1,5 @@
1
1
  import { type AgentSession, type ExtensionUIContext } from '@earendil-works/pi-coding-agent';
2
- import { type SkillEntry } from "./skillInvocation.js";
2
+ import type { SkillEntry } from "./skillInvocation.js";
3
3
  import type { TerminalRuntime } from "./runtime.js";
4
4
  import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from "../trajectory.js";
5
5
  import type { TodoEntry } from "../opentui/model.js";
@@ -47,6 +47,5 @@ export declare function createTerminalCommands(host: TerminalCommandHost): {
47
47
  execute: (text: string) => Promise<boolean>;
48
48
  completions: () => TerminalCommandCompletion[];
49
49
  skills: () => SkillEntry[];
50
- expandSkill: (text: string) => string;
51
50
  legacyAliases: () => string[];
52
51
  };
@@ -10,7 +10,6 @@ import { TreePicker } from "./treePicker.js";
10
10
  import { createCommandPage } from "./commandPages.js";
11
11
  import { pageOverlayOptions } from "./dialogGeometry.js";
12
12
  import { ResumeSessionsPage } from "./sessionPicker.js";
13
- import { expandSkillInvocation } from "./skillInvocation.js";
14
13
  import { groupTerminalCommands } from "../commandHelp.js";
15
14
  import { createBuiltinPluginCommandHandlers } from "./pluginCommands.js";
16
15
  export { parseAnnotate } from "./pluginCommands.js";
@@ -43,8 +42,6 @@ export function createTerminalCommands(host) {
43
42
  return [];
44
43
  return session.resourceLoader.getSkills().skills.map((skill) => ({ name: skill.name, description: skill.description }));
45
44
  };
46
- /** The text to send: a leading `$name` of a loaded skill becomes the `/skill:name` form Pi expands. */
47
- const expandSkill = (text) => expandSkillInvocation(text, new Set(skills().map((skill) => skill.name)));
48
45
  async function openPage(id, options = {}) {
49
46
  let close = () => { };
50
47
  const page = createCommandPage(host, id, { ...options, close: () => close() });
@@ -486,5 +483,5 @@ export function createTerminalCommands(host) {
486
483
  }
487
484
  return true;
488
485
  }
489
- return { execute, completions, skills, expandSkill, legacyAliases };
486
+ return { execute, completions, skills, legacyAliases };
490
487
  }
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionUIContext, ExtensionUIDialogOptions, KeybindingsManager, Theme } from '@earendil-works/pi-coding-agent';
2
- import { Input, type Component, type Focusable, type SelectListTheme, type TUI, type TuiMouseEventResult } from '@earendil-works/pi-tui';
2
+ import { type Component, type Focusable, type SelectListTheme, type TUI, type TuiMouseEventResult } from '@earendil-works/pi-tui';
3
3
  import type { TerminalMouseInput } from "./mouse.js";
4
+ import { MouseSafeInput } from "./mouseReport.js";
4
5
  export interface GroupedSelectOption {
5
6
  label: string;
6
7
  group?: string;
@@ -12,7 +13,7 @@ export type TerminalDialogOptions = ExtensionUIDialogOptions & {
12
13
  export type DialogVariant = 'block' | 'page';
13
14
  export declare function selectTheme(theme: Theme): SelectListTheme;
14
15
  /** Input keeps the original value; only its rendered cells are masked. */
15
- export declare class SecretInput extends Input {
16
+ export declare class SecretInput extends MouseSafeInput {
16
17
  render(width: number): string[];
17
18
  }
18
19
  /** Choices as a `FilterList`, with the standard search field visible for every list. */
@@ -1,8 +1,9 @@
1
- import { Editor, Input, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
1
+ import { Editor, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
2
  import { CENTER_DIALOG_PAD, CHROME_ROWS, blockFrame, centerDialogChromeRows, centerDialogContentWidth, centerDialogFrame, centerDialogVerticalPad, contentWidth, pageFrame, safeText, selectionText } from "./chrome.js";
3
3
  import { centeredDialogHost, dialogFits, pageOverlayOptions, pageRows } from "./dialogGeometry.js";
4
4
  import { FilterList } from "./list.js";
5
5
  import { searchFieldRowsFor } from "./search.js";
6
+ import { MouseSafeInput } from "./mouseReport.js";
6
7
  export function selectTheme(theme) {
7
8
  return {
8
9
  selectedPrefix: text => selectionText(theme, text), selectedText: text => selectionText(theme, text.replace(/^→/, '❯')),
@@ -11,7 +12,7 @@ export function selectTheme(theme) {
11
12
  };
12
13
  }
13
14
  /** Input keeps the original value; only its rendered cells are masked. */
14
- export class SecretInput extends Input {
15
+ export class SecretInput extends MouseSafeInput {
15
16
  render(width) {
16
17
  const lines = super.render(width);
17
18
  if (!this.getValue())
@@ -280,7 +281,7 @@ export class TerminalDialogs {
280
281
  }
281
282
  input(title, placeholder, opts, secret = false) {
282
283
  return this.show(done => {
283
- const InputType = secret ? SecretInput : Input;
284
+ const InputType = secret ? SecretInput : MouseSafeInput;
284
285
  const input = new InputType({ prompt: secret ? '' : '> ', placeholder, placeholderStyle: text => this.theme().fg('dim', text) });
285
286
  input.onSubmit = done;
286
287
  input.onEscape = () => done(undefined);
@@ -4,7 +4,7 @@ declare module '@earendil-works/pi-tui' {
4
4
  interface Keybindings {
5
5
  'app.thinking.increase': true;
6
6
  'app.thinking.decrease': true;
7
- 'app.message.followUp': true;
7
+ 'app.message.steer': true;
8
8
  }
9
9
  }
10
10
  export declare const HARNESS_KEYBINDINGS: KeybindingDefinitions;
@@ -11,7 +11,7 @@ const actions = {
11
11
  'app.model.cycleForward': 'ctrl+p', 'app.model.cycleBackward': 'alt+p',
12
12
  'app.model.select': 'ctrl+l', 'app.tools.expand': 'ctrl+o', 'app.thinking.toggle': 'ctrl+t',
13
13
  'app.editor.external': 'ctrl+g', 'app.message.copy': 'ctrl+x',
14
- 'app.message.followUp': ['super+enter', 'ctrl+enter', 'alt+enter'], 'app.message.dequeue': 'alt+up',
14
+ 'app.message.steer': ['super+enter', 'ctrl+enter', 'alt+enter'], 'app.message.dequeue': 'alt+up',
15
15
  'app.clipboard.pasteImage': process.platform === 'darwin' ? ['ctrl+v', 'alt+v'] : 'ctrl+v',
16
16
  'app.session.toggleNamedFilter': 'ctrl+n', 'app.session.togglePath': 'ctrl+p',
17
17
  'app.session.toggleSort': 'ctrl+s', 'app.session.rename': 'ctrl+r',
@@ -34,9 +34,10 @@ export declare class ModeChoices {
34
34
  quietly<T>(action: () => T | Promise<T>): Promise<T>;
35
35
  /**
36
36
  * Applies a mode's saved model and effort to the session. A model the account cannot use is
37
- * skipped, and Pi clamps an effort the model does not support.
37
+ * skipped, and Pi clamps an effort the model does not support. A mode with no effort of its own
38
+ * returns to `fallback`, so a level chosen in another mode does not stay with this one.
38
39
  */
39
- apply(session: ChoiceSession, mode: ComposerMode, modelFor: (reference: string) => ChoiceModel | undefined): Promise<void>;
40
+ apply(session: ChoiceSession, mode: ComposerMode, modelFor: (reference: string) => ChoiceModel | undefined, fallback: AgentSession['thinkingLevel']): Promise<void>;
40
41
  /** Records a selection the person made in `mode`. Skipped while the terminal is making its own change. */
41
42
  record(mode: ComposerMode, patch: ChoicePatch): Promise<void>;
42
43
  }
@@ -31,9 +31,10 @@ export class ModeChoices {
31
31
  }
32
32
  /**
33
33
  * Applies a mode's saved model and effort to the session. A model the account cannot use is
34
- * skipped, and Pi clamps an effort the model does not support.
34
+ * skipped, and Pi clamps an effort the model does not support. A mode with no effort of its own
35
+ * returns to `fallback`, so a level chosen in another mode does not stay with this one.
35
36
  */
36
- async apply(session, mode, modelFor) {
37
+ async apply(session, mode, modelFor, fallback) {
37
38
  const choice = this.resolve(mode);
38
39
  await this.quietly(async () => {
39
40
  const model = choice.model ? modelFor(choice.model) : undefined;
@@ -45,8 +46,7 @@ export class ModeChoices {
45
46
  // Not usable on this account (no key, or no longer offered): keep the session's model.
46
47
  }
47
48
  }
48
- if (choice.effort)
49
- session.setThinkingLevel(choice.effort, { persist: false });
49
+ session.setThinkingLevel((choice.effort ?? fallback), { persist: false });
50
50
  });
51
51
  }
52
52
  /** Records a selection the person made in `mode`. Skipped while the terminal is making its own change. */
@@ -0,0 +1,30 @@
1
+ import { Input } from '@earendil-works/pi-tui';
2
+ /** A leaked report is echoed within one read; ten characters in this window is 50 characters a second. */
3
+ export declare const MOUSE_REPORT_TAIL_WINDOW_MS = 200;
4
+ export interface MouseReportMatch {
5
+ /** The whole report the keys spelled, without its `ESC[` prefix. */
6
+ report: string;
7
+ /** What a text field already held before the completing sequence. */
8
+ inserted: string;
9
+ }
10
+ /**
11
+ * Follows the printable characters of one report. A caller that receives the completing sequence
12
+ * before insertion removes `inserted`; a caller that receives it after insertion removes `report`.
13
+ */
14
+ export declare class MouseReportTail {
15
+ private text;
16
+ private startedAt;
17
+ push(sequence: string, nowMs?: number): MouseReportMatch | undefined;
18
+ reset(): void;
19
+ }
20
+ /** A single-line input that drops a leaked mouse report instead of keeping it as text. */
21
+ export declare class MouseSafeInput extends Input {
22
+ private readonly mouseTail;
23
+ handleInput(data: string): void;
24
+ setValue(value: string): void;
25
+ }
26
+ /**
27
+ * Removes reports from text that is about to be printed. A bare `<\d;\d;\d>M` stays: once recorded it
28
+ * is content, and rewriting it would misreport what a run received.
29
+ */
30
+ export declare function stripMouseReports(text: string): string;
@@ -0,0 +1,80 @@
1
+ // A mouse report that the input parser split across its timeout loses its `ESC[` prefix: the orphaned
2
+ // prefix is flushed as an unknown response and the rest is decoded as keystrokes. These helpers keep
3
+ // that tail out of text fields, without touching what a person types or pastes.
4
+ import { Input } from '@earendil-works/pi-tui';
5
+ /** A leaked report is echoed within one read; ten characters in this window is 50 characters a second. */
6
+ export const MOUSE_REPORT_TAIL_WINDOW_MS = 200;
7
+ const REPORT = /^<\d{1,3};\d{1,4};\d{1,4}[Mm]$/;
8
+ const PARTIAL = /^<\d{0,3}(;\d{0,4}){0,2}$/;
9
+ const WHOLE = /^(?:\x1b\[)?<\d{1,3};\d{1,4};\d{1,4}[Mm]$/;
10
+ const SGR_REPORT = /\x1b\[<\d+;\d+;\d+[Mm]|\\u001b\[<\d+;\d+;\d+[Mm]|\\x1b\[<\d+;\d+;\d+[Mm]/g;
11
+ const X10_REPORT = /\x1b\[M[\u0020-\u00ff]{3}/g;
12
+ /**
13
+ * Follows the printable characters of one report. A caller that receives the completing sequence
14
+ * before insertion removes `inserted`; a caller that receives it after insertion removes `report`.
15
+ */
16
+ export class MouseReportTail {
17
+ text = '';
18
+ startedAt = 0;
19
+ push(sequence, nowMs = Date.now()) {
20
+ if (WHOLE.test(sequence)) {
21
+ this.reset();
22
+ return { report: sequence.replace(/^\x1b\[/, ''), inserted: '' };
23
+ }
24
+ if (sequence.length !== 1) {
25
+ this.reset();
26
+ return undefined;
27
+ }
28
+ if (sequence === '<') {
29
+ this.text = '<';
30
+ this.startedAt = nowMs;
31
+ return undefined;
32
+ }
33
+ if (!this.text)
34
+ return undefined;
35
+ if (nowMs - this.startedAt > MOUSE_REPORT_TAIL_WINDOW_MS) {
36
+ this.reset();
37
+ return undefined;
38
+ }
39
+ const next = this.text + sequence;
40
+ if (REPORT.test(next)) {
41
+ const inserted = this.text;
42
+ this.reset();
43
+ return { report: next, inserted };
44
+ }
45
+ if (!PARTIAL.test(next)) {
46
+ this.reset();
47
+ return undefined;
48
+ }
49
+ this.text = next;
50
+ return undefined;
51
+ }
52
+ reset() {
53
+ this.text = '';
54
+ this.startedAt = 0;
55
+ }
56
+ }
57
+ /** A single-line input that drops a leaked mouse report instead of keeping it as text. */
58
+ export class MouseSafeInput extends Input {
59
+ mouseTail = new MouseReportTail();
60
+ handleInput(data) {
61
+ super.handleInput(data);
62
+ const match = this.mouseTail.push(data);
63
+ if (!match)
64
+ return;
65
+ const value = this.getValue();
66
+ if (value.endsWith(match.report))
67
+ this.setValue(value.slice(0, -match.report.length));
68
+ }
69
+ setValue(value) {
70
+ this.mouseTail.reset();
71
+ super.setValue(value);
72
+ }
73
+ }
74
+ /**
75
+ * Removes reports from text that is about to be printed. A bare `<\d;\d;\d>M` stays: once recorded it
76
+ * is content, and rewriting it would misreport what a run received.
77
+ */
78
+ export function stripMouseReports(text) {
79
+ return text.replace(SGR_REPORT, '').replace(X10_REPORT, '');
80
+ }
@@ -1,4 +1,5 @@
1
- import { Input, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
1
+ import { matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
2
+ import { MouseSafeInput } from "../mouseReport.js";
2
3
  import { FilterList } from "../list.js";
3
4
  import { normalizeAddress, normalizeDevTools } from "../annotateCommand.js";
4
5
  import { describeSend, sendUnsent, unsent } from "../annotateSend.js";
@@ -185,7 +186,7 @@ export class AnnotatePage {
185
186
  return 'Load the bridge script in the app (shown at the top), then reload the app.';
186
187
  }
187
188
  openField(kind) {
188
- const input = new Input({ prompt: '', placeholder: FIELDS[kind].placeholder, placeholderStyle: text => this.options.theme().fg('dim', text) });
189
+ const input = new MouseSafeInput({ prompt: '', placeholder: FIELDS[kind].placeholder, placeholderStyle: text => this.options.theme().fg('dim', text) });
189
190
  this.field = { kind, input };
190
191
  this.syncFocus();
191
192
  this.options.changed();
@@ -52,7 +52,7 @@ export declare class BrowserPage implements TerminalPage {
52
52
  get focused(): boolean;
53
53
  set focused(value: boolean);
54
54
  tabs(): {
55
- id: "skills" | "tools" | "connections" | "plugins" | "variant" | "library" | "overview" | "foundation";
55
+ id: "skills" | "tools" | "connections" | "plugins" | "variant" | "library" | "foundation" | "overview";
56
56
  title: string;
57
57
  }[];
58
58
  activeTab(): string;