@xingular/harness 1.6.0-unstable → 2.0.1-unstable

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/cli.js +23375 -19883
  3. package/dist/sdk/cli.js +16 -1
  4. package/dist/sdk/harness.js +2 -1
  5. package/dist/sdk/prompt/reporting.d.ts +1 -1
  6. package/dist/sdk/prompt/reporting.js +8 -3
  7. package/dist/sdk/server/methods.js +12 -3
  8. package/dist/sdk/server/serve.d.ts +5 -2
  9. package/dist/sdk/server/serve.js +10 -3
  10. package/dist/sdk/server/socket.d.ts +28 -0
  11. package/dist/sdk/server/socket.js +122 -0
  12. package/dist/sdk/sessions/contracts.d.ts +14 -1
  13. package/dist/sdk/sessions/contracts.js +9 -0
  14. package/dist/sdk/sessions/extensionUi.js +4 -4
  15. package/dist/sdk/sessions/live.d.ts +101 -0
  16. package/dist/sdk/sessions/live.js +265 -0
  17. package/dist/sdk/sessions/registry.d.ts +12 -0
  18. package/dist/sdk/sessions/registry.js +3 -0
  19. package/dist/sdk/sessions/runner.d.ts +25 -3
  20. package/dist/sdk/sessions/runner.js +171 -31
  21. package/dist/sdk/sessions/sessions.d.ts +49 -0
  22. package/dist/sdk/sessions/sessions.js +165 -8
  23. package/dist/sdk/{terminal → sessions}/stallWatch.d.ts +4 -0
  24. package/dist/sdk/{terminal → sessions}/stallWatch.js +10 -4
  25. package/dist/sdk/sessions/subagentService.js +1 -1
  26. package/dist/sdk/terminal/controller.js +1 -3
  27. package/dist/sdk/terminal/daemon.d.ts +44 -0
  28. package/dist/sdk/terminal/daemon.js +264 -0
  29. package/dist/sdk/terminal/liveTerminal.d.ts +96 -0
  30. package/dist/sdk/terminal/liveTerminal.js +436 -0
  31. package/dist/sdk/terminal/standalone.d.ts +19 -1
  32. package/dist/sdk/terminal/standalone.js +75 -25
  33. package/dist/sdk/terminal/terminals.d.ts +43 -0
  34. package/dist/sdk/terminal/terminals.js +171 -14
  35. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  36. package/dist/sdk/terminal/tui/commands.js +24 -2
  37. package/dist/sdk/terminal/tui/harnessRuntime.d.ts +8 -0
  38. package/dist/sdk/terminal/tui/harnessRuntime.js +771 -0
  39. package/dist/sdk/terminal/tui/thinkingLevels.d.ts +11 -0
  40. package/dist/sdk/terminal/tui/thinkingLevels.js +31 -0
  41. package/dist/terminal-opentui.js +2013 -975
  42. package/dist/terminal-pi.js +9 -0
  43. package/docs/architecture/02-sessions-and-runs.md +61 -3
  44. package/docs/architecture/04-prompt.md +8 -6
  45. package/docs/architecture/10-server-protocol.md +23 -1
  46. package/docs/architecture/11-terminal.md +67 -3
  47. package/docs/architecture/12-storage.md +3 -1
  48. package/docs/architecture/14-surfaces-and-parity.md +4 -0
  49. package/docs/architecture/15-remote-control.md +3 -1
  50. package/docs/architecture/README.md +5 -0
  51. package/docs/decisions/ADR-0001-owned-terminal.md +9 -2
  52. package/docs/decisions/ADR-0083-one-live-session-runtime.md +256 -0
  53. package/docs/parity.md +4 -3
  54. package/docs/releases/2.0.1-unstable.md +63 -0
  55. package/docs/terminal-tui.md +10 -5
  56. package/package.json +1 -1
@@ -10,6 +10,7 @@ import { CONTEXT_MESSAGE_TYPE, CONTEXT_SNAPSHOT_KEY } from "../prompt/compile.js
10
10
  import { PLAN_REPAIR_CUSTOM_TYPE, PLAN_REPAIR_INSTRUCTIONS, finalizePlanOutcome } from "./planMode.js";
11
11
  import { MAX_CHECKPOINT_CHECKS, MAX_CHECKPOINT_FILES, MAX_CHECKPOINT_SUMMARY } from "./contracts.js";
12
12
  import { withTransientRetry } from "../pi/retryPolicy.js";
13
+ import { STALL_CHECK_MS, onlyWaitingForChildren, replyStalled } from "./stallWatch.js";
13
14
  import { activeToolBeat, derivedReason } from "./workSlice.js";
14
15
  import { goalStopOutcome, handoffFields, stopCheckpointReason, stopSummary } from "./goalStop.js";
15
16
  import { TODO_WRITE } from "../tools/todo.js";
@@ -53,6 +54,8 @@ export function resultSize(result) {
53
54
  return content.reduce((total, part) => total + (part?.type === 'text' && typeof part.text === 'string' ? part.text.length : 0), 0);
54
55
  }
55
56
  const MAX_EVIDENCE_CHARS = 24_000;
57
+ /** How long a stalled turn gets to unwind after its abort before the session is closed under it. */
58
+ const STALL_ABORT_GRACE_MS = 30_000;
56
59
  const MAX_SYSTEM_PROMPT_CHARS = 100_000;
57
60
  const MAX_TOOL_SPECS = 120;
58
61
  function limitedText(value, maximum = MAX_EVIDENCE_CHARS) {
@@ -197,20 +200,58 @@ export async function runTurn(run, session, params) {
197
200
  const slice = params.slice?.state ?? null;
198
201
  const ledger = { files: new Map(), checks: [], lastText: '', todo: [] };
199
202
  const bashCommands = new Map();
200
- const heartbeat = slice
201
- ? setInterval(() => {
202
- run.emit('heartbeat', {
203
- steps: slice.steps,
204
- elapsedMs: Date.now() - slice.startedAt,
205
- lastTool: slice.lastTool,
206
- compactions: slice.compactions,
207
- slice: slice.slice,
208
- maxToolCalls: params.slice?.maxToolCalls ?? 0,
209
- ...activeToolBeat(toolsInFlight)
210
- });
211
- }, 30_000)
212
- : null;
213
- heartbeat?.unref?.();
203
+ const startedAt = Date.now();
204
+ // Every reply reports liveness (INV-RUN-14): the sweep settles a record that went quiet, and a long
205
+ // tool or a person's pause must not look like one.
206
+ const heartbeat = setInterval(() => {
207
+ run.emit('heartbeat', {
208
+ steps: slice?.steps ?? 0,
209
+ elapsedMs: Date.now() - (slice?.startedAt ?? startedAt),
210
+ lastTool: slice?.lastTool ?? null,
211
+ compactions: slice?.compactions ?? 0,
212
+ slice: slice?.slice ?? 1,
213
+ maxToolCalls: params.slice?.maxToolCalls ?? 0,
214
+ ...activeToolBeat(toolsInFlight)
215
+ });
216
+ }, 30_000);
217
+ heartbeat.unref?.();
218
+ // A reply whose model request never came back, or whose tool never returned, is ended with the
219
+ // reason `stalled` instead of holding its session for ever (the provider's own timeout is longer).
220
+ // A person's pending decision and a wait for subagents (each watched by its own run) are not silence.
221
+ let progressAt = Date.now();
222
+ let stalled = false;
223
+ // The session goes back (or is disposed) once, by the run's end or, when a stalled turn will not unwind, by the grace timer.
224
+ let handedBack = false;
225
+ const handBack = (close) => {
226
+ if (handedBack)
227
+ return;
228
+ handedBack = true;
229
+ if (params.release)
230
+ params.release(close);
231
+ else
232
+ session.dispose();
233
+ };
234
+ let abortGrace = null;
235
+ const stallGuard = setInterval(() => {
236
+ if (run.finished || stalled)
237
+ return;
238
+ const at = Date.now();
239
+ const tools = [...toolsInFlight.values()];
240
+ if (run.waiting || onlyWaitingForChildren(tools.map((tool) => tool.name))) {
241
+ progressAt = at;
242
+ return;
243
+ }
244
+ if (!replyStalled({ now: at, progressAt, runningTools: tools.map((tool) => tool.at) }))
245
+ return;
246
+ stalled = true;
247
+ aborted = true;
248
+ run.finish('interrupted', undefined, 'stalled', 'stalled');
249
+ session.abortBash?.();
250
+ void Promise.resolve(session.abort()).catch(() => undefined);
251
+ abortGrace = setTimeout(() => handBack(true), STALL_ABORT_GRACE_MS);
252
+ abortGrace.unref?.();
253
+ }, STALL_CHECK_MS);
254
+ stallGuard.unref?.();
214
255
  const systemPrompt = limitedText(session.systemPrompt ?? params.system, MAX_SYSTEM_PROMPT_CHARS);
215
256
  const contextUpdate = limitedText(params.update?.content ?? '', MAX_SYSTEM_PROMPT_CHARS);
216
257
  const toolSpecs = contextToolSpecs(session);
@@ -294,6 +335,7 @@ export async function runTurn(run, session, params) {
294
335
  };
295
336
  }
296
337
  const unsubscribe = session.subscribe((event) => {
338
+ progressAt = Date.now();
297
339
  switch (event.type) {
298
340
  case 'message_start': {
299
341
  const { message } = event;
@@ -468,6 +510,9 @@ export async function runTurn(run, session, params) {
468
510
  case 'auto_retry_end': {
469
511
  const retry = event;
470
512
  run.emit('retry.ended', { attempt: numberField(retry, 'attempt', 1), success: retry.success === true, ...(stringField(retry, 'finalError') ? { error: stringField(retry, 'finalError').slice(0, 1000) } : {}) });
513
+ // The request that failed was retried and answered: the reply did not fail.
514
+ if (retry.success === true)
515
+ lastError = null;
471
516
  break;
472
517
  }
473
518
  case 'compaction_start': {
@@ -510,6 +555,11 @@ export async function runTurn(run, session, params) {
510
555
  // The delivered snapshot is saved with the message, so the next turn compares against what Pi really holds.
511
556
  await session.sendCustomMessage({ customType: CONTEXT_MESSAGE_TYPE, content: params.update.content, display: false, details: { [CONTEXT_SNAPSHOT_KEY]: params.update.snapshot } }, { triggerTurn: false });
512
557
  }
558
+ for (const note of params.hiddenNotes ?? []) {
559
+ if (!session.sendCustomMessage)
560
+ throw new HarnessError('provider_error', 'The Pi session cannot receive a hidden note.');
561
+ await session.sendCustomMessage({ customType: note.customType, content: note.content, display: false, details: {} }, { triggerTurn: false });
562
+ }
513
563
  const command = /^\/(\S+)/.exec(params.text)?.[1];
514
564
  if (params.preamble && command) {
515
565
  if (!session.sendCustomMessage)
@@ -661,10 +711,12 @@ export async function runTurn(run, session, params) {
661
711
  run.finish('failed', { code: 'provider_error', message: error instanceof Error ? error.message : String(error) });
662
712
  }
663
713
  finally {
664
- if (heartbeat)
665
- clearInterval(heartbeat);
714
+ clearInterval(heartbeat);
715
+ clearInterval(stallGuard);
716
+ if (abortGrace)
717
+ clearTimeout(abortGrace);
666
718
  unsubscribe();
667
- session.dispose();
719
+ handBack(stalled);
668
720
  }
669
721
  }
670
722
  /** Runs one session command and returns what to tell the person. */
@@ -764,7 +816,11 @@ export function registeredBy(loader) {
764
816
  .map((extension) => ({ path: extension.resolvedPath || extension.path, hooks: names(extension.handlers), tools: names(extension.tools), commands: names(extension.commands), flags: names(extension.flags), shortcuts: names(extension.shortcuts) }));
765
817
  }
766
818
  /** Opens a Pi session for one turn: the account's runtime, the compiled prompt, and the gated tools. */
767
- export const piSessionFactory = async ({ params, allow, gate, tools, runtime: loadRuntime, extensions: host }) => {
819
+ export const piSessionFactory = async (input) => {
820
+ const { params, allow, runtime: loadRuntime, extensions: host } = input;
821
+ // What belongs to the current run. A live session (ADR-0083) swaps it at the start of every run,
822
+ // so everything below that is per run reads it here rather than closing over the first run's.
823
+ let bound = input;
768
824
  const pi = await loadPi();
769
825
  const { Type } = await import('typebox');
770
826
  const runtime = await loadRuntime();
@@ -778,9 +834,9 @@ export const piSessionFactory = async ({ params, allow, gate, tools, runtime: lo
778
834
  thinkingLevel ??= resolved.thinkingLevel;
779
835
  }
780
836
  const workspaceBash = createWorkspaceBashOperations(params.workspaceRoot, {
781
- bypassWorkspaceBoundary: (request) => gate.workspaceBoundaryBypass?.('bash', { command: request.command, cwd: request.cwd, workspaceRoot: request.workspaceRoot }) ?? Promise.resolve(false),
782
- requestWorkspaceWriteScope: (request) => gate.requestWorkspaceWriteScope?.('bash', { command: request.command, workspaceWriteScope: 'outside' }, request.reason) ?? Promise.resolve(false),
783
- workspaceWriteScope: () => params.plan ? 'read-only' : undefined
837
+ bypassWorkspaceBoundary: (request) => bound.gate.workspaceBoundaryBypass?.('bash', { command: request.command, cwd: request.cwd, workspaceRoot: request.workspaceRoot }) ?? Promise.resolve(false),
838
+ requestWorkspaceWriteScope: (request) => bound.gate.requestWorkspaceWriteScope?.('bash', { command: request.command, workspaceWriteScope: 'outside' }, request.reason) ?? Promise.resolve(false),
839
+ workspaceWriteScope: () => bound.params.plan ? 'read-only' : undefined
784
840
  });
785
841
  const gatedExtension = (api) => {
786
842
  const writeAuthorizations = new Map();
@@ -807,10 +863,11 @@ export const piSessionFactory = async ({ params, allow, gate, tools, runtime: lo
807
863
  }
808
864
  catch (error) {
809
865
  const reason = error instanceof Error ? error.message : 'The active project workspace could not be verified.';
810
- params.onRefusal?.(event.toolCallId, event.toolName, { code: 'workspace', reason });
866
+ bound.params.onRefusal?.(event.toolCallId, event.toolName, { code: 'workspace', reason });
811
867
  return { block: true, reason };
812
868
  }
813
869
  }
870
+ const gate = bound.gate;
814
871
  const refusal = gate.checkDetailed ? await gate.checkDetailed(event.toolName, event.input) : null;
815
872
  if (!gate.checkDetailed) {
816
873
  const reason = await gate.check(event.toolName, event.input);
@@ -818,7 +875,7 @@ export const piSessionFactory = async ({ params, allow, gate, tools, runtime: lo
818
875
  return { block: true, reason };
819
876
  }
820
877
  if (refusal) {
821
- params.onRefusal?.(event.toolCallId, event.toolName, refusal);
878
+ bound.params.onRefusal?.(event.toolCallId, event.toolName, refusal);
822
879
  return { block: true, reason: refusal.reason };
823
880
  }
824
881
  if (authorization) {
@@ -831,22 +888,33 @@ export const piSessionFactory = async ({ params, allow, gate, tools, runtime: lo
831
888
  const agentDir = params.account.configDir;
832
889
  const automaticSettings = params.automatic ? pi.SettingsManager.inMemory({}) : undefined;
833
890
  const settingsManager = withTransientRetry(automaticSettings ?? pi.SettingsManager.create(params.cwd, agentDir));
834
- const resourceLoader = await loadResources(pi, params, gatedExtension, automaticSettings);
891
+ // The run's own extensions (lineage, subagents, checkpoint, slice, goal, first-party hooks) are
892
+ // loaded in slots that can be emptied and filled with the next run's (ADR-0083).
893
+ const slots = (params.inline ?? []).map((extension) => runSlot(extension));
894
+ let activated = null;
895
+ const resourceLoader = await loadResources(pi, {
896
+ ...params,
897
+ inline: slots.map((slot) => slot.extension),
898
+ activatedPlugins: (ids) => { activated = ids; bound.params.activatedPlugins?.(ids); }
899
+ }, gatedExtension, automaticSettings);
835
900
  const loaded = registeredBy(resourceLoader);
836
901
  // A tool an extension brought is offered to the model, and asks before every call: the person
837
902
  // trusted the code to load, and still decides each thing it does through a tool.
838
903
  const extensionTools = [...new Set(loaded.flatMap((extension) => extension.tools))].filter((name) => !allow.includes(name) && !params.restrictTools);
839
- gate.ask?.([...new Set(loaded.flatMap((extension) => extension.tools))]);
904
+ const askedTools = [...new Set(loaded.flatMap((extension) => extension.tools))];
905
+ bound.gate.ask?.(askedTools);
840
906
  let supportsImageInput = false;
841
- const customTools = tools
907
+ const customTools = input.tools
842
908
  .filter((tool) => allow.includes(tool.name))
843
909
  .map((spec) => pi.defineTool({
844
910
  name: spec.name,
845
911
  label: spec.label,
846
912
  description: spec.description,
847
913
  parameters: spec.parameters(Type),
848
- execute: async (_toolCallId, input) => {
849
- const result = await spec.execute(input);
914
+ execute: async (_toolCallId, toolInput) => {
915
+ // The current run's tool of this name: it is bound to that run's record and gate.
916
+ const current = bound.tools.find((tool) => tool.name === spec.name) ?? spec;
917
+ const result = await current.execute(toolInput);
850
918
  const output = typeof result === 'string' ? { text: result } : result;
851
919
  const images = [...(output.image ? [output.image] : []), ...(output.images ?? [])];
852
920
  if (images.length && !supportsImageInput)
@@ -867,14 +935,21 @@ export const piSessionFactory = async ({ params, allow, gate, tools, runtime: lo
867
935
  customTools: customTools,
868
936
  resourceLoader,
869
937
  settingsManager,
870
- sessionManager: params.historyFile ? pi.SessionManager.open(params.historyFile, params.sessionDir, params.cwd) : pi.SessionManager.continueRecent(params.cwd, params.sessionDir)
938
+ sessionManager: params.historyFile
939
+ ? pi.SessionManager.open(params.historyFile, params.sessionDir, params.cwd)
940
+ : params.newHistory ? pi.SessionManager.create(params.cwd, params.sessionDir) : pi.SessionManager.continueRecent(params.cwd, params.sessionDir)
871
941
  });
872
942
  if (loaded.length && host) {
943
+ // Dialogs and notices go to whichever run is current; between runs nobody can answer.
944
+ const current = {
945
+ ask: (question) => bound.extensions ? bound.extensions.ask(question) : Promise.reject(new HarnessError('run_not_active', 'No reply is running in this session.')),
946
+ notice: (notice) => bound.extensions?.notice(notice)
947
+ };
873
948
  // Without this an extension never hears that its session started and has nobody to ask.
874
949
  await session.bindExtensions({
875
- uiContext: extensionUi(host),
950
+ uiContext: extensionUi(current),
876
951
  mode: 'rpc',
877
- onError: (error) => host.notice({ kind: 'error', text: `${error.event}: ${error.error}`.slice(0, 1000), extension: error.extensionPath })
952
+ onError: (error) => current.notice({ kind: 'error', text: `${error.event}: ${error.error}`.slice(0, 1000), extension: error.extensionPath })
878
953
  });
879
954
  }
880
955
  const piSession = session;
@@ -896,5 +971,70 @@ export const piSessionFactory = async ({ params, allow, gate, tools, runtime: lo
896
971
  }
897
972
  });
898
973
  };
974
+ const rebindable = slots.every((slot) => slot.rebindable());
975
+ if (rebindable && !params.automatic) {
976
+ Object.defineProperty(piSession, 'rebind', {
977
+ configurable: true,
978
+ value: async (next) => {
979
+ const names = (next.params.inline ?? []).map((extension) => extension.name);
980
+ if (names.length !== slots.length || names.some((name, index) => name !== slots[index].name)) {
981
+ throw new HarnessError('session_pin_mismatch', 'The next run loads different extensions than this live session.');
982
+ }
983
+ bound = next;
984
+ for (const [index, slot] of slots.entries())
985
+ await slot.fill(next.params.inline[index]);
986
+ next.gate.ask?.(askedTools);
987
+ if (activated)
988
+ next.params.activatedPlugins?.(activated);
989
+ }
990
+ });
991
+ }
899
992
  return piSession;
900
993
  };
994
+ /**
995
+ * One run-scoped extension in a live session. Pi keeps an extension's handlers in its own table and
996
+ * `on` returns the way to remove one, so a slot removes the last run's handlers and runs the next
997
+ * run's factory against the same extension. Only `on` is tracked: a run-scoped extension that
998
+ * registers tools or commands keeps them, and makes the session one that is not reused.
999
+ */
1000
+ function runSlot(extension) {
1001
+ let api = null;
1002
+ let offs = [];
1003
+ let registers = false;
1004
+ const tracked = (target) => new Proxy(target, {
1005
+ get(object, key, receiver) {
1006
+ if (key === 'on') {
1007
+ return (event, handler) => {
1008
+ const off = object.on(event, handler);
1009
+ if (typeof off === 'function')
1010
+ offs.push(off);
1011
+ else
1012
+ registers = true;
1013
+ return off;
1014
+ };
1015
+ }
1016
+ if (typeof key === 'string' && key.startsWith('register'))
1017
+ registers = true;
1018
+ return Reflect.get(object, key, receiver);
1019
+ }
1020
+ });
1021
+ return {
1022
+ name: extension.name,
1023
+ extension: {
1024
+ name: extension.name,
1025
+ factory: (target) => {
1026
+ api = target;
1027
+ return extension.factory(tracked(target));
1028
+ }
1029
+ },
1030
+ rebindable: () => api !== null && !registers,
1031
+ async fill(next) {
1032
+ if (!api)
1033
+ throw new HarnessError('session_pin_mismatch', 'This live session never loaded its run extensions.');
1034
+ for (const off of offs.splice(0))
1035
+ off();
1036
+ offs = [];
1037
+ await next.factory(tracked(api));
1038
+ }
1039
+ };
1040
+ }
@@ -15,6 +15,7 @@ import { type WorkSliceInput } from "./workSlice.js";
15
15
  import type { ToolPolicy } from "./policy.js";
16
16
  import type { ActiveRun, RunRegistry } from "./registry.js";
17
17
  import { type Effort, type SessionCommand, type SessionFactory, type ToolGate } from "./runner.js";
18
+ import { LiveSessions, type LiveSessionStatus } from "./live.js";
18
19
  /** The fields of a run record the caller decides; the harness fills in the rest. */
19
20
  export type RunFields = Pick<RunRecord, 'id' | 'sessionId'> & Partial<Pick<RunRecord, 'mode' | 'harness' | 'sessionType' | 'projectId' | 'agentId' | 'trigger' | 'goal' | 'attachmentCount' | 'resumedFromRunId' | 'parentRunId' | 'relation' | 'worktreeId' | 'handoffId' | 'profile' | 'title'>>;
20
21
  export interface SessionRunInput {
@@ -116,6 +117,29 @@ export interface SessionRunInput {
116
117
  * with a summary of the branch left behind, or export it to HTML. It pins the account like a turn.
117
118
  */
118
119
  command?: SessionCommand;
120
+ /**
121
+ * Keep the Pi session open after this run (ADR-0083), and reuse an open one when this run's pins
122
+ * equal its own. Steer the running reply with `runs.steer`, queue the next message with
123
+ * `sessions.followUp`, and close it with `sessions.close`. Never for unattended or child runs.
124
+ */
125
+ live?: boolean;
126
+ /** Start a new native history in `sessionDir` instead of continuing its newest one. */
127
+ newHistory?: boolean;
128
+ /**
129
+ * How a plan run withholds what it may not use. `gate` (the default) keeps every tool in the
130
+ * request, so the prompt and its cache stay as they were, and refuses the calls at the gate.
131
+ * `active` sends only the plan's tools, as the terminal does (ADR-0083).
132
+ */
133
+ planTools?: 'gate' | 'active';
134
+ /**
135
+ * Hidden messages saved in the native history before the request, as the context update is: the
136
+ * skills a person required with `$name`, or a goal note. The person's own text reaches the model
137
+ * unchanged. At most 8, of at most 20,000 characters each.
138
+ */
139
+ hiddenNotes?: Array<{
140
+ customType: string;
141
+ content: string;
142
+ }>;
119
143
  }
120
144
  /** The connected-apps broker, as a session sees it. */
121
145
  export interface SessionBroker {
@@ -146,12 +170,37 @@ export interface SessionsOptions {
146
170
  acquire(id: string, holder: string): Promise<unknown>;
147
171
  release(id: string, holder: string): Promise<void>;
148
172
  } | null;
173
+ /** How long an idle live session stays open (ADR-0083). */
174
+ liveIdleMs?: number;
149
175
  }
150
176
  export declare class Sessions {
151
177
  private readonly options;
152
178
  private readonly providers;
153
179
  private readonly factory;
180
+ /** Pi sessions kept open between the runs of a conversation (ADR-0083). */
181
+ readonly live: LiveSessions;
182
+ /** The last live run's input per session, which a follow-up repeats with its own text. */
183
+ private readonly liveInputs;
154
184
  constructor(options: SessionsOptions);
185
+ /** The live sessions open now. */
186
+ liveSessions(): LiveSessionStatus[];
187
+ /**
188
+ * Sends a message as the next run of a live session: now when it is idle, or after its active
189
+ * run settles. The run repeats the last live run's input with this text.
190
+ */
191
+ followUp(sessionId: string, text: string): Promise<{
192
+ runId: string;
193
+ } | {
194
+ queued: number;
195
+ }>;
196
+ /** Closes a live session now, or as soon as its active run settles. */
197
+ closeLive(sessionId: string): {
198
+ closed: boolean;
199
+ afterRun: string | null;
200
+ };
201
+ /** Closes every live session; the harness calls it when it shuts down. */
202
+ close(): void;
203
+ private runFollowUp;
155
204
  private get env();
156
205
  /** Adds tools an application provides. Providers are asked in the order they were added. */
157
206
  provide(provider: ToolProvider): void;
@@ -37,8 +37,9 @@ import { compactionCheckpoint, COMPACTION_CHECKPOINT_EXTENSION } from "./compact
37
37
  import { createSliceState, parseWorkSlice, sliceOf, workSliceGuard, WorkSliceError, WORK_SLICE_EXTENSION } from "./workSlice.js";
38
38
  import { piSessionFactory, runTurn, summarize } from "./runner.js";
39
39
  import { loadRun, readEvents, runDir, saveRun, updateRun } from "./runStore.js";
40
+ import { livePin, LiveSessions } from "./live.js";
40
41
  import { combineWorkspaceScope, inspectRunWorkspaceWrite } from "./workspaceBoundary.js";
41
- import { PLAN_INSTRUCTIONS, PLAN_RESEARCH_SUBAGENT_INSTRUCTIONS, planRefusal, planSection } from "./planMode.js";
42
+ import { PLAN_INSTRUCTIONS, PLAN_RESEARCH_SUBAGENT_INSTRUCTIONS, planModeTools, planRefusal, planSection } from "./planMode.js";
42
43
  import { CONTINUATION_SECTION_ID, continuationOf, continuationSection } from "./continuation.js";
43
44
  import { deliveredSnapshot } from "../prompt/compile.js";
44
45
  import { profileSection } from "../agents/profiles.js";
@@ -105,9 +106,79 @@ export class Sessions {
105
106
  options;
106
107
  providers = [];
107
108
  factory;
109
+ /** Pi sessions kept open between the runs of a conversation (ADR-0083). */
110
+ live;
111
+ /** The last live run's input per session, which a follow-up repeats with its own text. */
112
+ liveInputs = new Map();
108
113
  constructor(options) {
109
114
  this.options = options;
110
115
  this.factory = options.factory ?? piSessionFactory;
116
+ this.live = new LiveSessions({
117
+ idleMs: options.liveIdleMs,
118
+ record: (runId) => {
119
+ try {
120
+ return loadRun(this.env.root, runId);
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ },
126
+ followUp: async (sessionId, _lastRunId, text) => { await this.runFollowUp(sessionId, text); },
127
+ log: (message) => log(message)
128
+ });
129
+ }
130
+ /** The live sessions open now. */
131
+ liveSessions() {
132
+ return this.live.list();
133
+ }
134
+ /**
135
+ * Sends a message as the next run of a live session: now when it is idle, or after its active
136
+ * run settles. The run repeats the last live run's input with this text.
137
+ */
138
+ async followUp(sessionId, text) {
139
+ if (typeof sessionId !== 'string' || !sessionId)
140
+ throw invalid('A follow-up needs a session id.');
141
+ if (typeof text !== 'string')
142
+ throw invalid('A follow-up needs text.');
143
+ const queued = this.live.queue(sessionId, text);
144
+ if ('queued' in queued)
145
+ return queued;
146
+ return { runId: await this.runFollowUp(sessionId, text) };
147
+ }
148
+ /** Closes a live session now, or as soon as its active run settles. */
149
+ closeLive(sessionId) {
150
+ const result = this.live.close(sessionId);
151
+ if (!result.afterRun)
152
+ this.liveInputs.delete(sessionId);
153
+ return result;
154
+ }
155
+ /** Closes every live session; the harness calls it when it shuts down. */
156
+ close() {
157
+ this.live.closeAll();
158
+ this.liveInputs.clear();
159
+ }
160
+ async runFollowUp(sessionId, text) {
161
+ const template = this.liveInputs.get(sessionId);
162
+ if (!template)
163
+ throw new HarnessError('not_found', 'This session has no live run to follow.');
164
+ const status = this.live.status(sessionId);
165
+ const next = {
166
+ ...structuredClone(template),
167
+ run: { ...template.run, id: randomUUID(), resumedFromRunId: null },
168
+ text,
169
+ // The follow-up continues exactly the history the live session writes, once it has a file.
170
+ resume: status?.sessionFile ? { sessionFile: status.sessionFile, nativeSessionId: status.nativeSessionId } : template.resume ?? null,
171
+ live: true
172
+ };
173
+ next.initialBashCommand = undefined;
174
+ next.initialBashContext = undefined;
175
+ next.images = undefined;
176
+ next.command = undefined;
177
+ next.continueFrom = null;
178
+ next.goal = undefined;
179
+ next.workSlice = template.workSlice;
180
+ const { runId } = await this.run(next);
181
+ return runId;
111
182
  }
112
183
  get env() {
113
184
  return this.options.env;
@@ -353,6 +424,20 @@ export class Sessions {
353
424
  }
354
425
  async start(input) {
355
426
  const { root } = this.env;
427
+ if (input.live) {
428
+ if (input.automatic || input.run.relation || (input.depth ?? 0) > 0 || (input.run.trigger != null && input.run.trigger !== 'user')) {
429
+ throw invalid('A live session is only for a person\'s own attended top-level runs.');
430
+ }
431
+ if (this.live.busy(input.run.sessionId))
432
+ throw new HarnessError('session_busy', 'This session is already replying. Steer it with runs.steer, or queue the message with sessions.followUp.');
433
+ }
434
+ // A run that opens its own session on this conversation's history reads and writes the file the
435
+ // live session holds in memory: the live session yields first (ADR-0083).
436
+ else if (input.run.relation == null && (input.depth ?? 0) === 0)
437
+ this.live.yieldHistory({ sessionId: input.run.sessionId, sessionDir: input.sessionDir });
438
+ // A new conversation never continues the open one.
439
+ if (input.live && input.newHistory)
440
+ this.live.yieldHistory({ sessionId: input.run.sessionId });
356
441
  if ((input.initialBashCommand !== undefined && input.initialBashCommand !== null) || (input.initialBashContext !== undefined && input.initialBashContext !== null)) {
357
442
  const request = input.text.trimStart();
358
443
  const requestedCommand = /^!\s*([\s\S]+)$/u.exec(request)?.[1];
@@ -374,7 +459,9 @@ export class Sessions {
374
459
  || input.initialBashContext.ok !== undefined && typeof input.initialBashContext.ok !== 'boolean')) {
375
460
  throw invalid('A prior Bash result must be bounded and must not be combined with a new initial Bash command.');
376
461
  }
377
- if (input.automatic || input.plan || input.resume || input.continueFrom || input.run.relation || input.run.trigger != null && input.run.trigger !== 'user') {
462
+ // A live session's turn is always a new turn on the saved history it resumes (ADR-0083); a
463
+ // host's retry of an earlier turn says so with initialBashContext instead.
464
+ if (input.automatic || input.plan || (input.resume && !input.live) || input.continueFrom || input.run.relation || input.run.trigger != null && input.run.trigger !== 'user') {
378
465
  throw invalid('An initial Bash command is available only for a new interactive user turn.');
379
466
  }
380
467
  }
@@ -392,7 +479,12 @@ export class Sessions {
392
479
  const workspaceRoot = previousRun?.workspaceRoot
393
480
  ? verifyWorkspaceRoot(previousRun.workspaceRoot)
394
481
  : resolveWorkspaceRoot(previousRun?.workingDirectory ?? input.cwd);
395
- const historyFile = selectHistory({ sessionDir: input.sessionDir, resume: input.resume });
482
+ if (input.newHistory && (input.resume || input.continueFrom || input.command))
483
+ throw invalid('A new history cannot also resume, continue or run a command on a saved one.');
484
+ if (input.hiddenNotes !== undefined && (!Array.isArray(input.hiddenNotes) || input.hiddenNotes.length > 8 || input.hiddenNotes.some((note) => !note || typeof note.customType !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(note.customType) || typeof note.content !== 'string' || !note.content || note.content.length > 20_000))) {
485
+ throw invalid('Hidden notes are at most 8 messages, each with a lower-case type and up to 20,000 characters.');
486
+ }
487
+ const historyFile = input.newHistory ? null : selectHistory({ sessionDir: input.sessionDir, resume: input.resume });
396
488
  if (input.command)
397
489
  checkCommand(input.command, historyFile, input);
398
490
  const account = this.options.accounts.resolve(input.accountId, input.poolId);
@@ -423,9 +515,10 @@ export class Sessions {
423
515
  ? await this.options.resolveCapabilities(caller, requestedTools, input.cwd)
424
516
  : { version: 1, tools: requestedTools, connectors: [], contributions: [] };
425
517
  const requested = input.capabilities ? parseCapabilitySet(input.capabilities) : available;
518
+ const allowedTools = available.tools.filter((tool) => requested.tools.includes(tool));
426
519
  const capabilities = {
427
520
  version: 1,
428
- tools: available.tools.filter((tool) => requested.tools.includes(tool)),
521
+ tools: input.plan && input.planTools === 'active' ? planModeTools(allowedTools, [], [ASK_USER]) : allowedTools,
429
522
  connectors: available.connectors.filter((id) => requested.connectors.includes(id)),
430
523
  contributions: available.contributions.filter((id) => requested.contributions.includes(id))
431
524
  };
@@ -667,6 +760,7 @@ export class Sessions {
667
760
  workspaceRoot,
668
761
  sessionDir: input.sessionDir,
669
762
  historyFile,
763
+ ...(input.newHistory ? { newHistory: true } : {}),
670
764
  model: input.model ?? null,
671
765
  effort: input.effort ?? null,
672
766
  text: goalStart ?? input.text,
@@ -701,9 +795,10 @@ export class Sessions {
701
795
  ...(goalController ? { goal: goalHandle } : {}),
702
796
  // A command sends no request of its own, so the context update waits for the next turn.
703
797
  update: input.command ? null : update,
798
+ ...(input.hiddenNotes?.length && !input.command ? { hiddenNotes: input.hiddenNotes } : {}),
704
799
  ...(input.command ? { command: input.command } : {})
705
800
  };
706
- pi = await this.factory({
801
+ const factoryInput = {
707
802
  params,
708
803
  allow,
709
804
  gate,
@@ -711,15 +806,67 @@ export class Sessions {
711
806
  runtime: () => this.options.accounts.runtime(context),
712
807
  // An unattended run loads no extension, so it has nobody to relay for.
713
808
  ...(input.automatic ? {} : { extensions: { ask: (question) => run.requestAnswer(question), notice: (notice) => run.emit('extension.notice', notice) } })
714
- });
809
+ };
810
+ // A live session serves this run only when everything the run pins is what it was opened with.
811
+ const liveId = input.live ? input.run.sessionId : null;
812
+ const pin = liveId ? livePin({
813
+ account: account.id,
814
+ configDir: context.configDir,
815
+ variantScope: variant?.scope ?? null,
816
+ variantVersion: variant?.fingerprint ?? null,
817
+ variantExtensions: params.variantExtensions,
818
+ resources: params.resources,
819
+ capabilities,
820
+ cwd: input.cwd,
821
+ workspaceRoot,
822
+ sessionDir: input.sessionDir,
823
+ plan: Boolean(input.plan),
824
+ repairPlan: Boolean(params.repairPlan),
825
+ allow,
826
+ tools: tools.map((tool) => tool.name),
827
+ gated: input.tools.gated ?? [],
828
+ system,
829
+ model: input.model ?? null,
830
+ effort: input.effort ?? null,
831
+ inline: (params.inline ?? []).map((extension) => extension.name),
832
+ providers: input.providers !== false
833
+ }) : null;
834
+ const reused = liveId && pin ? this.live.claim(liveId, pin, runId, historyFile, input.resume?.nativeSessionId) : null;
835
+ if (reused) {
836
+ pi = reused;
837
+ try {
838
+ await reused.rebind(factoryInput);
839
+ }
840
+ catch (error) {
841
+ this.live.discard(liveId, reused);
842
+ throw error;
843
+ }
844
+ }
845
+ else {
846
+ pi = await this.factory(factoryInput);
847
+ if (liveId && pin && pi.rebind)
848
+ this.live.hold(liveId, pin, pi, runId, input.sessionDir);
849
+ }
850
+ const held = liveId && this.live.status(liveId)?.activeRunId === runId ? liveId : null;
851
+ const opened0 = pi;
852
+ const discard = () => {
853
+ if (held)
854
+ this.live.discard(held, opened0);
855
+ else
856
+ opened0.dispose();
857
+ };
715
858
  if (run.finished) {
716
- pi.dispose();
859
+ discard();
717
860
  return { runId, nativeSessionId: null };
718
861
  }
719
862
  if ((input.requiresImageInput || input.images?.length) && !pi.model?.input.includes('image')) {
863
+ discard();
864
+ pi = undefined;
720
865
  throw new HarnessError('image_input_unsupported', 'Choose a model that supports images, or remove the image attachments and send again.');
721
866
  }
722
867
  if (input.resume?.nativeSessionId && pi.sessionId !== input.resume.nativeSessionId) {
868
+ discard();
869
+ pi = undefined;
723
870
  run.finish('failed', { code: 'native_resume_unavailable', message: 'The saved conversation could not be restored.' });
724
871
  throw new HarnessError('native_resume_unavailable', 'The saved conversation could not be restored.');
725
872
  }
@@ -750,11 +897,21 @@ export class Sessions {
750
897
  if (openedModel)
751
898
  this.options.subagents.resolvedModel(runId, openedModel, pi.thinkingLevel);
752
899
  params.settle = () => this.options.subagents.verify(runId, opened.sessionManager ? subagentHistory(opened.sessionManager.getBranch()) : undefined);
900
+ if (held) {
901
+ this.live.started(held, runId);
902
+ this.liveInputs.set(held, structuredClone(input));
903
+ params.release = (close) => this.live.release(held, runId, close);
904
+ }
753
905
  void runTurn(run, pi, params).then(() => (sliceEnabled && !input.command ? this.continueSlice({ root, runId, source: input, policy: slicePolicy }) : undefined));
754
906
  return { runId, nativeSessionId: pi.sessionId };
755
907
  }
756
908
  catch (error) {
757
- pi?.dispose();
909
+ if (pi) {
910
+ if (input.live && this.live.status(input.run.sessionId)?.activeRunId === runId)
911
+ this.live.discard(input.run.sessionId, pi);
912
+ else
913
+ pi.dispose();
914
+ }
758
915
  if (error instanceof HarnessError && error.code === 'native_resume_unavailable')
759
916
  throw error;
760
917
  const message = error instanceof Error ? error.message : String(error);