@xingular/harness 1.6.0-unstable → 2.0.1-unstable
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/dist/cli.js +23375 -19883
- package/dist/sdk/cli.js +16 -1
- package/dist/sdk/harness.js +2 -1
- package/dist/sdk/prompt/reporting.d.ts +1 -1
- package/dist/sdk/prompt/reporting.js +8 -3
- package/dist/sdk/server/methods.js +12 -3
- package/dist/sdk/server/serve.d.ts +5 -2
- package/dist/sdk/server/serve.js +10 -3
- package/dist/sdk/server/socket.d.ts +28 -0
- package/dist/sdk/server/socket.js +122 -0
- package/dist/sdk/sessions/contracts.d.ts +14 -1
- package/dist/sdk/sessions/contracts.js +9 -0
- package/dist/sdk/sessions/extensionUi.js +4 -4
- package/dist/sdk/sessions/live.d.ts +101 -0
- package/dist/sdk/sessions/live.js +265 -0
- package/dist/sdk/sessions/registry.d.ts +12 -0
- package/dist/sdk/sessions/registry.js +3 -0
- package/dist/sdk/sessions/runner.d.ts +25 -3
- package/dist/sdk/sessions/runner.js +171 -31
- package/dist/sdk/sessions/sessions.d.ts +49 -0
- package/dist/sdk/sessions/sessions.js +165 -8
- package/dist/sdk/{terminal → sessions}/stallWatch.d.ts +4 -0
- package/dist/sdk/{terminal → sessions}/stallWatch.js +10 -4
- package/dist/sdk/sessions/subagentService.js +1 -1
- package/dist/sdk/terminal/controller.js +1 -3
- package/dist/sdk/terminal/daemon.d.ts +44 -0
- package/dist/sdk/terminal/daemon.js +264 -0
- package/dist/sdk/terminal/liveTerminal.d.ts +96 -0
- package/dist/sdk/terminal/liveTerminal.js +436 -0
- package/dist/sdk/terminal/standalone.d.ts +19 -1
- package/dist/sdk/terminal/standalone.js +75 -25
- package/dist/sdk/terminal/terminals.d.ts +43 -0
- package/dist/sdk/terminal/terminals.js +171 -14
- package/dist/sdk/terminal/tui/commands.d.ts +2 -0
- package/dist/sdk/terminal/tui/commands.js +24 -2
- package/dist/sdk/terminal/tui/harnessRuntime.d.ts +8 -0
- package/dist/sdk/terminal/tui/harnessRuntime.js +771 -0
- package/dist/sdk/terminal/tui/thinkingLevels.d.ts +11 -0
- package/dist/sdk/terminal/tui/thinkingLevels.js +31 -0
- package/dist/terminal-opentui.js +2013 -975
- package/dist/terminal-pi.js +9 -0
- package/docs/architecture/02-sessions-and-runs.md +61 -3
- package/docs/architecture/04-prompt.md +8 -6
- package/docs/architecture/10-server-protocol.md +23 -1
- package/docs/architecture/11-terminal.md +67 -3
- package/docs/architecture/12-storage.md +3 -1
- package/docs/architecture/14-surfaces-and-parity.md +4 -0
- package/docs/architecture/15-remote-control.md +3 -1
- package/docs/architecture/README.md +5 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +9 -2
- package/docs/decisions/ADR-0083-one-live-session-runtime.md +256 -0
- package/docs/parity.md +4 -3
- package/docs/releases/2.0.1-unstable.md +63 -0
- package/docs/terminal-tui.md +10 -5
- package/package.json +1 -1
|
@@ -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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
665
|
-
|
|
714
|
+
clearInterval(heartbeat);
|
|
715
|
+
clearInterval(stallGuard);
|
|
716
|
+
if (abortGrace)
|
|
717
|
+
clearTimeout(abortGrace);
|
|
666
718
|
unsubscribe();
|
|
667
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
849
|
-
|
|
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
|
|
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(
|
|
950
|
+
uiContext: extensionUi(current),
|
|
876
951
|
mode: 'rpc',
|
|
877
|
-
onError: (error) =>
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|