@visual-z/xeno 0.8.2 → 0.8.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.
- package/CHANGELOG.md +42 -0
- package/package.json +1 -1
- package/src/apps/shell.ts +13 -3
- package/src/apps/startup.ts +4 -4
- package/src/apps/ui/app.ts +32 -14
- package/src/apps/ui/project.ts +1 -1
- package/src/apps/ui/standalone.ts +4 -2
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/activity-status.ts +17 -2
- package/src/core/command-contract.ts +58 -16
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/event-validation.ts +19 -3
- package/src/core/events.ts +38 -2
- package/src/core/evidence.ts +149 -7
- package/src/core/export.ts +2 -0
- package/src/core/external-effects.ts +488 -0
- package/src/core/handoff.ts +24 -3
- package/src/core/history-query.ts +1 -1
- package/src/core/history.ts +124 -20
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +60 -0
- package/src/core/prompt-policy.ts +4 -2
- package/src/core/secrets.ts +188 -0
- package/src/core/stop-report.ts +25 -3
- package/src/core/tool-failure.ts +11 -3
- package/src/core/transcript.ts +3 -0
- package/src/core/work-state.ts +7 -2
- package/src/core/working-set.ts +34 -6
- package/src/runtime/connection-reuse.ts +40 -0
- package/src/runtime/exec-sessions.ts +66 -18
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +98 -14
- package/src/runtime/open-image.ts +42 -0
- package/src/runtime/openai-responses.ts +15 -7
- package/src/runtime/openai.ts +15 -7
- package/src/runtime/permission-resources.ts +11 -4
- package/src/runtime/pi-provider.ts +34 -9
- package/src/runtime/provider.ts +19 -8
- package/src/runtime/retry.ts +152 -9
- package/src/runtime/secrets.ts +79 -0
- package/src/runtime/session-control.ts +9 -1
- package/src/runtime/stream-idle.ts +80 -0
- package/src/runtime/tools.ts +97 -27
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +1 -1
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/tui/ui/image-size.ts +6 -2
- package/src/tui/ui/items.ts +3 -1
- package/src/tui/ui/pictures.ts +14 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session's live view of its secret vault (S.9). Rules and the inverse
|
|
3
|
+
* live in core/secrets.ts; this owns the mutable entries, their durable
|
|
4
|
+
* append, and the walk over an event's fields.
|
|
5
|
+
*
|
|
6
|
+
* One keeper per session tree: a branch shares its parent's, because both
|
|
7
|
+
* write the same log and a placeholder number must mean one value in it.
|
|
8
|
+
*/
|
|
9
|
+
import { expandText, isSolicitedSecret, mapStrings, placeholder, redactText, type SecretEntry } from "../core/secrets.ts";
|
|
10
|
+
import type { LogEvent } from "../core/events.ts";
|
|
11
|
+
import { appendVault, readVault } from "../store/secrets.ts";
|
|
12
|
+
|
|
13
|
+
// Fields whose bytes a provider or the store verifies: rewriting them breaks
|
|
14
|
+
// replay (signed thinking, raw provider blocks) or identity, and none of them
|
|
15
|
+
// carries text a person typed or a tool printed.
|
|
16
|
+
const VERIFIED_FIELDS = new Set(["id", "at", "raw", "wire", "signature", "replay_key", "sha256", "call_id", "user_id", "parent_call_id"]);
|
|
17
|
+
|
|
18
|
+
export class SecretKeeper {
|
|
19
|
+
private entries: SecretEntry[] = [];
|
|
20
|
+
private pending: Promise<void> = Promise.resolve();
|
|
21
|
+
|
|
22
|
+
private readonly dir: string;
|
|
23
|
+
private readonly candidates: readonly { value: string; rule: string }[];
|
|
24
|
+
|
|
25
|
+
constructor(dir: string, candidates: readonly { value: string; rule: string }[] = []) {
|
|
26
|
+
this.dir = dir; this.candidates = candidates;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async load(): Promise<void> {
|
|
30
|
+
const stored = await readVault(this.dir);
|
|
31
|
+
// Entries this keeper already assigned (none before start) stay; stored ones fill in.
|
|
32
|
+
for (const entry of stored) if (!this.entries.some(known => known.n === entry.n)) this.entries.push(entry);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns the event to write and whether this call added vault entries. The
|
|
37
|
+
* entries are in memory at once -- the next event numbers after them -- and
|
|
38
|
+
* durable before the returned promise resolves, so no written event ever
|
|
39
|
+
* holds a placeholder the vault cannot expand.
|
|
40
|
+
*/
|
|
41
|
+
async redact(event: LogEvent, previousAssistantText: string | undefined): Promise<{ event: LogEvent; added: number }> {
|
|
42
|
+
const added: SecretEntry[] = [];
|
|
43
|
+
const redact = (text: string): string => {
|
|
44
|
+
const result = redactText(text, [...this.entries, ...added], this.candidates);
|
|
45
|
+
added.push(...result.added);
|
|
46
|
+
return result.text;
|
|
47
|
+
};
|
|
48
|
+
const skip = (key: string) => VERIFIED_FIELDS.has(key);
|
|
49
|
+
let next: LogEvent;
|
|
50
|
+
if (event.t === "checkpoint") next = event; // file fingerprints and paths, never projected
|
|
51
|
+
else if (event.t === "assistant") {
|
|
52
|
+
// Thinking is replayed under the provider's signature; a changed byte is a rejected request.
|
|
53
|
+
next = { ...event, blocks: event.blocks.map(block => block.type === "thinking" ? block : mapStrings(block, redact, skip) as typeof block) };
|
|
54
|
+
} else if (event.t === "user" && isSolicitedSecret(event.text, previousAssistantText)) {
|
|
55
|
+
// The whole message is the credential, however short: it was asked for.
|
|
56
|
+
const value = event.text.trim();
|
|
57
|
+
const entry = this.entries.find(known => known.value === value) ??
|
|
58
|
+
{ n: this.entries.reduce((max, known) => Math.max(max, known.n), 0) + 1, value, rule: "solicited" };
|
|
59
|
+
if (!this.entries.includes(entry)) added.push(entry);
|
|
60
|
+
next = { ...mapStrings(event, redact, key => skip(key) || key === "text") as LogEvent & { t: "user" }, text: placeholder(entry.n) };
|
|
61
|
+
} else next = mapStrings(event, redact, skip) as LogEvent;
|
|
62
|
+
if (added.length > 0) {
|
|
63
|
+
this.entries.push(...added);
|
|
64
|
+
const write = this.pending.then(() => appendVault(this.dir, added));
|
|
65
|
+
this.pending = write.catch(() => {});
|
|
66
|
+
await write;
|
|
67
|
+
}
|
|
68
|
+
return { event: next, added: added.length };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Tool input as the tool should run it: placeholders become values. */
|
|
72
|
+
expand(input: unknown): unknown {
|
|
73
|
+
return this.entries.length === 0 ? input : mapStrings(input, text => expandText(text, this.entries));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const SECRET_GUIDANCE = "Values written as [[xeno-secret-N]] are credentials xeno hid from you. " +
|
|
78
|
+
"Use the placeholder exactly as written in tool inputs; xeno substitutes the real value when the tool runs " +
|
|
79
|
+
"and hides it again in the output. Do not ask the user to repeat a hidden value.";
|
|
@@ -13,6 +13,8 @@ import type { Session, LoopEvent, RequestOptions } from "./loop.ts";
|
|
|
13
13
|
import type { ApprovalChoice } from "./tools.ts";
|
|
14
14
|
import { validateQuestionReply, type QuestionRequest, type QuestionReply } from "../core/question.ts";
|
|
15
15
|
import type { QuestionSlot } from "./question-tool.ts";
|
|
16
|
+
import { requestExternalEffects } from "./turn-effects.ts";
|
|
17
|
+
import type { ExternalEffect } from "../core/external-effects.ts";
|
|
16
18
|
|
|
17
19
|
export type ControlNotice = { method: string; params: { threadId: string; streamId: string; sequence: number; turnId?: string; [key: string]: unknown } };
|
|
18
20
|
export type PendingApproval = { id: string; turnId: string; request: PermissionRequest };
|
|
@@ -41,6 +43,8 @@ export class SessionControl {
|
|
|
41
43
|
private waiting: ControlSnapshot["waiting"] = null;
|
|
42
44
|
private changing = false;
|
|
43
45
|
private inputSource: ((phase: Phase) => Promise<UserInput | null>) | undefined;
|
|
46
|
+
/** What the last finished turn did outside the workspace, as sent with its turn/completed. */
|
|
47
|
+
lastExternalEffects: readonly ExternalEffect[] = [];
|
|
44
48
|
|
|
45
49
|
constructor(session: Session) {
|
|
46
50
|
this.session = session;
|
|
@@ -144,7 +148,11 @@ export class SessionControl {
|
|
|
144
148
|
if (state?.request.event === active.id) outcome = state.execution;
|
|
145
149
|
}
|
|
146
150
|
} catch { /* unreadable storage stays unknown, never an invented completion */ }
|
|
147
|
-
|
|
151
|
+
// feat-external-effects-summary: a projection of this request's receipts, never a stored event.
|
|
152
|
+
let externalEffects: ExternalEffect[] = [];
|
|
153
|
+
try { externalEffects = requestExternalEffects(this.session); } catch { /* the list is advisory; a read failure shows none */ }
|
|
154
|
+
this.lastExternalEffects = externalEffects;
|
|
155
|
+
this.emit("turn/completed", { ...(outcome ? { outcome } : {}), steeringUndelivered: active.queue.length, externalEffects });
|
|
148
156
|
this.active = null;
|
|
149
157
|
}
|
|
150
158
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abandons one attempt whose stream has gone silent.
|
|
3
|
+
*
|
|
4
|
+
* Without this a stream the network silently dropped waits until the user
|
|
5
|
+
* presses Escape (Horizyn 2026-10-12: a request sat 5m39s before the socket
|
|
6
|
+
* error surfaced). OpenAI's Codex CLI (`stream_idle_timeout_ms`, default
|
|
7
|
+
* 300000) and Claude Code (300s) both cut a stream that delivers nothing for
|
|
8
|
+
* five minutes against the same backends, so a live stream -- extended
|
|
9
|
+
* thinking included -- is not that quiet. The limit is theirs, not measured
|
|
10
|
+
* here (feat-stream-idle-watchdog).
|
|
11
|
+
*
|
|
12
|
+
* The clock starts when the request is sent, so it also bounds the wait for
|
|
13
|
+
* the first byte, and restarts on anything received: body bytes where the
|
|
14
|
+
* adapter owns fetch, stream events (pings included) where an SDK does.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const STREAM_IDLE_MS = 300_000;
|
|
18
|
+
|
|
19
|
+
/** retry.ts recognises this by name, so it need not import an adapter. */
|
|
20
|
+
export class StreamIdleError extends Error {
|
|
21
|
+
constructor(ms: number) {
|
|
22
|
+
super(`no data from the provider for ${Math.round(ms / 1000)}s; the stream was abandoned`);
|
|
23
|
+
this.name = "StreamIdleError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class IdleWatch {
|
|
28
|
+
/** Pass this, not the caller's, to the request: it also aborts on silence. */
|
|
29
|
+
readonly signal: AbortSignal;
|
|
30
|
+
private readonly controller = new AbortController();
|
|
31
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
+
private expired = false;
|
|
33
|
+
private readonly outer: AbortSignal | undefined;
|
|
34
|
+
private readonly ms: number;
|
|
35
|
+
|
|
36
|
+
constructor(outer: AbortSignal | undefined, ms: number = STREAM_IDLE_MS) {
|
|
37
|
+
this.outer = outer; this.ms = ms;
|
|
38
|
+
this.signal = outer === undefined ? this.controller.signal : AbortSignal.any([outer, this.controller.signal]);
|
|
39
|
+
this.touch();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
touch(): void {
|
|
43
|
+
if (this.expired) return;
|
|
44
|
+
clearTimeout(this.timer);
|
|
45
|
+
this.timer = setTimeout(() => {
|
|
46
|
+
this.expired = true;
|
|
47
|
+
this.controller.abort(new StreamIdleError(this.ms));
|
|
48
|
+
}, this.ms);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A body that restarts the clock on every chunk. */
|
|
52
|
+
body(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
53
|
+
return stream.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
|
|
54
|
+
transform: (chunk, sink) => { this.touch(); sink.enqueue(chunk); },
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** An event stream that restarts the clock on every event. */
|
|
59
|
+
async *events<T>(source: AsyncIterable<T>): AsyncGenerator<T> {
|
|
60
|
+
for await (const event of source) { this.touch(); yield event; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whatever the abort surfaced as -- AbortError, a closed body, the SDK's
|
|
65
|
+
* own wrapper -- silence becomes StreamIdleError. The caller's own abort
|
|
66
|
+
* stays an abort: Escape must not start another attempt.
|
|
67
|
+
*/
|
|
68
|
+
rethrow(error: unknown): never {
|
|
69
|
+
if (this.expired && this.outer?.aborted !== true) throw new StreamIdleError(this.ms);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
dispose(): void { clearTimeout(this.timer); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Runs one attempt under its own idle watch, cleared however it ends. */
|
|
77
|
+
export async function watchIdle<T>(outer: AbortSignal | undefined, ms: number | undefined, run: (idle: IdleWatch) => Promise<T>): Promise<T> {
|
|
78
|
+
const idle = new IdleWatch(outer, ms);
|
|
79
|
+
try { return await run(idle); } catch (error) { return idle.rethrow(error); } finally { idle.dispose(); }
|
|
80
|
+
}
|
package/src/runtime/tools.ts
CHANGED
|
@@ -23,17 +23,17 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
23
23
|
import { createHash } from "node:crypto";
|
|
24
24
|
import { hostname } from "node:os";
|
|
25
25
|
import { resolve, relative, isAbsolute, join, sep } from "node:path";
|
|
26
|
-
import type { Effect, LineRange, ArtifactRef } from "../core/events.ts";
|
|
26
|
+
import type { Effect, LineRange, ArtifactRef, ApprovalEvent } from "../core/events.ts";
|
|
27
27
|
import { archiveObservation, readObservation, type ArchivedObservation } from "../store/observations.ts";
|
|
28
28
|
import { blobAddress } from "../store/blobs.ts";
|
|
29
|
-
import { commandHeader, headTailView } from "../core/observation-view.ts";
|
|
29
|
+
import { commandHeader, foldCarriageReturns, headTailView } from "../core/observation-view.ts";
|
|
30
30
|
import {
|
|
31
|
-
yieldWait, findSession, closedSession, runningSessions, startSession, pollSession,
|
|
31
|
+
yieldWait, emptyYieldWait, findSession, closedSession, runningSessions, startSession, pollSession, isControlInput,
|
|
32
32
|
} from "./exec-sessions.ts";
|
|
33
33
|
import { recoverProcessReceipt, type ProcessReceipt, type latestProcessReceipt } from "../core/process-receipt.ts";
|
|
34
34
|
import { observeCommand, observeArgvCommand } from "../store/command.ts";
|
|
35
35
|
import { executionOutcome, type ExecutionOutcome } from "../core/outcome.ts";
|
|
36
|
-
import { commandCompletion, commandTimeout,
|
|
36
|
+
import { commandCompletion, commandTimeout, commandAnswer, outputBytesForTokens } from "../core/command-contract.ts";
|
|
37
37
|
import { linesWithin, previewReadObservation, readObservationText, type ObservationReceipt } from "../core/read-observation.ts";
|
|
38
38
|
import { checkFilePrecondition, isFileDigest, isFileReadDescriptor, type FileReadDescriptor, type FilePrecondition } from "../core/file-precondition.ts";
|
|
39
39
|
import { previewSearchObservation, searchObservationText } from "../core/search-observation.ts";
|
|
@@ -46,6 +46,8 @@ import { closestName, inputError, isJsonValue, minimalInput, type JsonValue, typ
|
|
|
46
46
|
import { failureDiagnostic, failureFromError, scopeFailureGuidance, NOT_STARTED, READ_ATTEMPTED, UNKNOWN_EFFECT, type FailureFacts } from "../core/tool-failure.ts";
|
|
47
47
|
import { planEdits, type EditSpec } from "../core/edit-plan.ts";
|
|
48
48
|
import { toView } from "../core/text-normalize.ts";
|
|
49
|
+
import { nearestPaths } from "../core/path-suggestions.ts";
|
|
50
|
+
import { buildFileIndex, MAX_INDEX_ENTRIES } from "../store/file-index.ts";
|
|
49
51
|
import { repairToolInput } from "../core/tool-input.ts";
|
|
50
52
|
import { HISTORY_SECTIONS } from "../core/history.ts";
|
|
51
53
|
import { RECOVERY_SECTIONS } from "../core/recovery-sources.ts";
|
|
@@ -53,8 +55,11 @@ import { queryData } from "../core/data-query.ts";
|
|
|
53
55
|
import { parseQueryInput, MAX_SQL_CHARS, SQL_QUERY_HELP } from "../core/sql-query.ts";
|
|
54
56
|
import { readQuerySources } from "../store/query-source.ts";
|
|
55
57
|
import {
|
|
58
|
+
approvalNeeded,
|
|
56
59
|
askWithoutTtyMessage,
|
|
57
60
|
deniedMessage,
|
|
61
|
+
describeRule,
|
|
62
|
+
explainDecision,
|
|
58
63
|
permissionRequest,
|
|
59
64
|
type PermissionRequest,
|
|
60
65
|
type PermissionRule,
|
|
@@ -76,7 +81,7 @@ import { beginCommandArtifacts, prepareArtifactArguments, commandArtifactSummary
|
|
|
76
81
|
import { MAX_COMMAND_ARTIFACTS } from "../core/command-artifacts.ts";
|
|
77
82
|
import { checkSourceCancellation } from "../store/source-cancellation.ts";
|
|
78
83
|
import { canonicalPermissionRequest } from "../store/permission-paths.ts";
|
|
79
|
-
import { permissionResourceAction } from "./permission-resources.ts";
|
|
84
|
+
import { permissionResourceAction, permissionResourceDecision } from "./permission-resources.ts";
|
|
80
85
|
import { parsePatch, patchPaths } from "../core/patch.ts";
|
|
81
86
|
import { decideWithSessionRules } from "../core/permissions.ts";
|
|
82
87
|
|
|
@@ -111,6 +116,8 @@ export type InstructionCheck =
|
|
|
111
116
|
| { kind: "attach"; instructions: { keys: readonly string[]; text: string } };
|
|
112
117
|
|
|
113
118
|
export interface ToolContext {
|
|
119
|
+
/** S.9: placeholders back to values, applied after admission and only to what the tool itself runs. */
|
|
120
|
+
expandSecrets?: (input: unknown) => unknown;
|
|
114
121
|
historyEvents?: () => Promise<import("../core/events.ts").LogEvent[]>;
|
|
115
122
|
checkpointBlobs?: string;
|
|
116
123
|
frozenSystem?: () => string;
|
|
@@ -218,8 +225,17 @@ export interface ToolContext {
|
|
|
218
225
|
* said no.
|
|
219
226
|
*/
|
|
220
227
|
askSlot?: { current?: (request: PermissionRequest) => Promise<ApprovalChoice> };
|
|
228
|
+
/**
|
|
229
|
+
* Receives the gate's verdict for a call that needed one, and is awaited
|
|
230
|
+
* before the call may start (feat-approval-events). The session appends it
|
|
231
|
+
* as an `approval` event; absent, nothing is recorded.
|
|
232
|
+
*/
|
|
233
|
+
onApproval?: (approval: ApprovalRecord) => Promise<void>;
|
|
221
234
|
}
|
|
222
235
|
|
|
236
|
+
/** The `approval` event without the identity the log assigns. */
|
|
237
|
+
export type ApprovalRecord = Omit<ApprovalEvent, "t" | "id" | "at">;
|
|
238
|
+
|
|
223
239
|
export interface NestedInvocationOptions { signal?: AbortSignal }
|
|
224
240
|
|
|
225
241
|
/**
|
|
@@ -232,7 +248,9 @@ export type ApprovalChoice = "once" | "command" | "tool" | "deny";
|
|
|
232
248
|
|
|
233
249
|
export const DEFAULT_TOOL_CONTEXT: Omit<ToolContext, "root"> = {
|
|
234
250
|
// Runner prefixes are resolved in command position so `python3 -m pytest`
|
|
235
|
-
// works without letting `echo pytest` forge a gate (D11 / M.2).
|
|
251
|
+
// works without letting `echo pytest` forge a gate (D11 / M.2). make, just
|
|
252
|
+
// and task are not keys: their targets are matched against these keys
|
|
253
|
+
// (`make test check-shell`), because a bare `make` runs an unknown goal.
|
|
236
254
|
gates: { pytest: "pytest", tsc: "typecheck", cargo: "cargo", jest: "test", vitest: "test", go: "go",
|
|
237
255
|
test: "test", typecheck: "typecheck", check: "check", build: "build", lint: "lint", gate: "gate" },
|
|
238
256
|
// Five minutes covers typical compiles and installs; longer work asks for
|
|
@@ -263,7 +281,8 @@ function caughtFailure(error: unknown, facts: FailureFacts, prefix = ""): ToolRe
|
|
|
263
281
|
* the first word -- `python3 -m pytest`, `npx vitest run`, `uv run pytest` --
|
|
264
282
|
* so first-token matching misses real gates. But matching any token anywhere
|
|
265
283
|
* would let `echo pytest` count as a passing test suite, which is precisely
|
|
266
|
-
* the fake evidence the rest of this design exists to reject.
|
|
284
|
+
* the fake evidence the rest of this design exists to reject. Each segment
|
|
285
|
+
* of an `&&` chain is matched the same way; see classifyGate.
|
|
267
286
|
*/
|
|
268
287
|
export function matchGate(ctx: ToolContext, command: string): string | undefined {
|
|
269
288
|
return classifyGate(ctx.gates, command);
|
|
@@ -284,6 +303,29 @@ function confine(ctx: ToolContext, path: string): string | { error: string } {
|
|
|
284
303
|
return abs;
|
|
285
304
|
}
|
|
286
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Assumed budget for listing the workspace after a failed lookup. The hint is
|
|
308
|
+
* a convenience on a failure path: past this the failure is returned without
|
|
309
|
+
* it rather than held up.
|
|
310
|
+
*/
|
|
311
|
+
const PATH_SUGGESTION_DEADLINE_MS = 2_000;
|
|
312
|
+
|
|
313
|
+
/** A not_found for a workspace path names the closest existing paths (missing-path-candidates card). */
|
|
314
|
+
async function withPathSuggestions(ctx: ToolContext, path: string, result: ToolResult): Promise<ToolResult> {
|
|
315
|
+
if (result.ok || result.diagnostic?.code !== "not_found") return result;
|
|
316
|
+
const abs = isAbsolute(path) ? resolve(path) : resolve(ctx.root, path);
|
|
317
|
+
if (!within(ctx.root, abs)) return result;
|
|
318
|
+
let listed: readonly string[];
|
|
319
|
+
try {
|
|
320
|
+
listed = (await buildFileIndex(ctx.root, { maxEntries: MAX_INDEX_ENTRIES, deadline: performance.now() + PATH_SUGGESTION_DEADLINE_MS,
|
|
321
|
+
...(ctx.signal ? { signal: ctx.signal } : {}) })).paths;
|
|
322
|
+
} catch { return result; } // No listing, no hint; the failure itself stands.
|
|
323
|
+
const near = nearestPaths(relative(ctx.root, abs).split(sep).join("/"), listed.map(item => item.split(sep).join("/")));
|
|
324
|
+
if (near.length === 0) return result;
|
|
325
|
+
const hint = "\nDid you mean one of these existing paths?\n" + near.map(item => ` ${item}`).join("\n");
|
|
326
|
+
return { ...result, content: result.content + hint, diagnostic: { ...result.diagnostic, message: result.diagnostic.message + hint } };
|
|
327
|
+
}
|
|
328
|
+
|
|
287
329
|
function within(base: string, abs: string): boolean {
|
|
288
330
|
const rel = relative(base, abs);
|
|
289
331
|
return !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
|
|
@@ -679,7 +721,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
679
721
|
properties: {
|
|
680
722
|
session_id: { type: "integer", minimum: 1 },
|
|
681
723
|
chars: { type: "string", description: "Characters to write; empty to poll" },
|
|
682
|
-
yield_time_ms: { type: "integer", description: "Bounded wait when sending input or using low-level polling
|
|
724
|
+
yield_time_ms: { type: "integer", description: "Bounded wait when sending input or using low-level polling, effective range 100-30000; an agent's empty wait returns at exit or at the end of a window with new output, window default 10000, effective range 5000-300000; values outside are clamped" },
|
|
683
725
|
max_output_tokens: { type: "integer", minimum: 1, description: "Lower this call's inline output preview to about this many tokens; full output stays recoverable" },
|
|
684
726
|
},
|
|
685
727
|
required: ["session_id"],
|
|
@@ -856,7 +898,7 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
856
898
|
checkSourceCancellation(ctx.signal);
|
|
857
899
|
text = Buffer.from(bytes).toString("utf8");
|
|
858
900
|
} catch (err) {
|
|
859
|
-
return caughtFailure(err, READ_ATTEMPTED, `cannot read ${path}: `);
|
|
901
|
+
return withPathSuggestions(ctx, path, caughtFailure(err, READ_ATTEMPTED, `cannot read ${path}: `));
|
|
860
902
|
}
|
|
861
903
|
|
|
862
904
|
if (ctx.imageStore && detectSupportedImageMimeType(bytes)) {
|
|
@@ -1001,7 +1043,7 @@ async function toolEdit(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1001
1043
|
const version = saved.bytes === null ? null : sha256(saved.bytes);
|
|
1002
1044
|
const checked = await filePrecondition(ctx, input, abs, version);
|
|
1003
1045
|
if (!checked.ok) return checked.result;
|
|
1004
|
-
if (saved.bytes === null) return failure("not_found", `cannot read ${path}: file does not exist`, READ_ATTEMPTED);
|
|
1046
|
+
if (saved.bytes === null) return withPathSuggestions(ctx, path, failure("not_found", `cannot read ${path}: file does not exist`, READ_ATTEMPTED));
|
|
1005
1047
|
const recover = { tool: "read", input: { path } };
|
|
1006
1048
|
let before: string;
|
|
1007
1049
|
try { before = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(saved.bytes); }
|
|
@@ -1076,8 +1118,9 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1076
1118
|
return {
|
|
1077
1119
|
// The artifact starts with `exit N\n`; the view replaces that line with
|
|
1078
1120
|
// the full header and pages the rest, so offsets stay artifact offsets.
|
|
1079
|
-
|
|
1080
|
-
|
|
1121
|
+
// Folding only shrinks the view; the artifact and its offsets keep every byte.
|
|
1122
|
+
content: header + artifactSummary + "\n" + foldCarriageReturns(observationView(captured,
|
|
1123
|
+
ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, captured.prefixBytes, adviceTools(ctx))),
|
|
1081
1124
|
content_ref: captured.ref,
|
|
1082
1125
|
...commandCompletion(exit, captured.stopped, captured.cleanup, command),
|
|
1083
1126
|
data: { exit, output: captured.ref, ...(captured.cleanup ? { cleanup: "unknown" } : {}), ...(artifacts ? { artifacts } : {}) },
|
|
@@ -1130,19 +1173,22 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
|
|
|
1130
1173
|
const session = findSession(id, ctx.execOwner);
|
|
1131
1174
|
if (session === undefined) {
|
|
1132
1175
|
const closed = closedSession(id, ctx.execOwner);
|
|
1133
|
-
|
|
1176
|
+
// Ctrl-C / Ctrl-D to a closed handle asks for what its exit already did: answer as a poll.
|
|
1177
|
+
const input = isControlInput(chars) ? "" : chars;
|
|
1178
|
+
if (closed) return receiptResult(ctx, { ...closed.receipt, replayed: true }, input);
|
|
1134
1179
|
const original = await ctx.observedProcess?.(id);
|
|
1135
|
-
if (original) return receiptResult(ctx, recoverProcessReceipt(original.receipt, original.origin),
|
|
1180
|
+
if (original) return receiptResult(ctx, recoverProcessReceipt(original.receipt, original.origin), input);
|
|
1136
1181
|
const running = runningSessions(ctx.execOwner).map((s) => s.id).join(", ");
|
|
1137
1182
|
return { ...failure("unknown_effect", `no running session ${id}; no observation in this session's active history` +
|
|
1138
1183
|
(running.length > 0 ? `; running sessions: ${running}` : ""), UNKNOWN_EFFECT),
|
|
1139
1184
|
data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
|
|
1140
1185
|
}
|
|
1141
1186
|
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1142
|
-
const
|
|
1187
|
+
const owner = chars.length === 0 ? ctx.processWait : undefined;
|
|
1188
|
+
const wait = owner ? emptyYieldWait(input["yield_time_ms"]) : yieldWait(input["yield_time_ms"]);
|
|
1143
1189
|
const view = { ...ctx, maxOutputBytes: outputBytesForTokens(input["max_output_tokens"], ctx.maxOutputBytes) };
|
|
1144
1190
|
return sessionResult(view, session.command, session.timeoutMs, await pollSession(session, outputStore, view.maxOutputBytes, wait.ms, chars, ctx.signal,
|
|
1145
|
-
|
|
1191
|
+
owner ? { wake: owner.wake(), onWaiting: active => ctx.onProcessWait?.(id, active), windowMs: wait.ms } : undefined), wait.note);
|
|
1146
1192
|
}
|
|
1147
1193
|
|
|
1148
1194
|
/** A successful historical query proves only that the receipt was found, never a new command exit. */
|
|
@@ -1172,17 +1218,24 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
|
|
|
1172
1218
|
? `poll or send input with write_stdin(session_id=${id})`
|
|
1173
1219
|
: "process-control capability unavailable; execution remains unfinished; do not replay")
|
|
1174
1220
|
: commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed` + answerLabel(command, exit, taken.stopped);
|
|
1175
|
-
const
|
|
1221
|
+
const unsent = taken.unsent === undefined ? "" : " · input was not sent: the process had already exited";
|
|
1222
|
+
const header = shown + waitNote + unsent;
|
|
1176
1223
|
const artifactSummary = commandArtifactSummary(taken.receipt.artifacts);
|
|
1177
|
-
const content = header + artifactSummary + "\n" + observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx));
|
|
1224
|
+
const content = header + artifactSummary + "\n" + foldCarriageReturns(observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx)));
|
|
1178
1225
|
const warnings = taken.receipt.capture === "partial" ? [failureDiagnostic("unknown_effect", "output capture is partial; full_output contains only captured bytes", UNKNOWN_EFFECT)] : [];
|
|
1179
1226
|
if (exit === null) {
|
|
1180
1227
|
return { ok: true, content, content_ref: stored.ref, content_sha256: stored.ref.sha256, data: taken.receipt, ...(warnings.length ? { warnings } : {}) };
|
|
1181
1228
|
}
|
|
1182
1229
|
const gate = matchGate(ctx, command);
|
|
1183
1230
|
const observation = await commandFingerprint(ctx);
|
|
1231
|
+
// Refused input fails this call, yet the exit it observed is still recorded:
|
|
1232
|
+
// the next poll only replays, so withholding the effect would lose it (D11).
|
|
1233
|
+
const completion = taken.unsent === "refused"
|
|
1234
|
+
? { ok: false, outcome: executionOutcome("tool_failed"),
|
|
1235
|
+
diagnostic: failureDiagnostic("not_found", "process had already exited; input was not sent; its exit and unread output are in this result", NOT_STARTED) }
|
|
1236
|
+
: commandCompletion(exit, taken.stopped, undefined, command);
|
|
1184
1237
|
return {
|
|
1185
|
-
...
|
|
1238
|
+
...completion,
|
|
1186
1239
|
content, content_ref: stored.ref, content_sha256: stored.ref.sha256,
|
|
1187
1240
|
data: taken.receipt, ...(warnings.length ? { warnings } : {}),
|
|
1188
1241
|
effect: { kind: "command", cmd: command, exit, ...(gate !== undefined ? { gate } : {}),
|
|
@@ -1190,9 +1243,9 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
|
|
|
1190
1243
|
};
|
|
1191
1244
|
}
|
|
1192
1245
|
|
|
1193
|
-
/** The program's own meaning of exit
|
|
1246
|
+
/** The program's own meaning of an answering exit (grep 1: no match; 141 after head), appended to the header; the exit itself stays. */
|
|
1194
1247
|
function answerLabel(command: string, exit: number, stopped?: "cancelled" | "timeout"): string {
|
|
1195
|
-
const meaning = stopped === undefined ?
|
|
1248
|
+
const meaning = stopped === undefined ? commandAnswer(command, exit) : null;
|
|
1196
1249
|
return meaning === null ? "" : ` · ${meaning}`;
|
|
1197
1250
|
}
|
|
1198
1251
|
|
|
@@ -1390,26 +1443,41 @@ export async function gateTool(
|
|
|
1390
1443
|
|
|
1391
1444
|
const permissionInput = name === "apply_patch" && input && typeof input === "object" && "patchText" in input ? { paths: patchPaths(parsePatch(String(input.patchText))) } : input;
|
|
1392
1445
|
const request = await canonicalPermissionRequest(ctx.root, name, permissionInput);
|
|
1393
|
-
let
|
|
1446
|
+
let explained = explainDecision(ctx.rules, ctx.sessionRules ?? [], request);
|
|
1394
1447
|
if (definition?.access === "read" && request.paths) for (const path of request.paths) {
|
|
1395
|
-
const resource = await
|
|
1396
|
-
if (resource === "deny") {
|
|
1397
|
-
if (resource === "ask" &&
|
|
1448
|
+
const resource = await permissionResourceDecision(ctx.root, path, ctx.rules, ctx.sessionRules ?? [], name);
|
|
1449
|
+
if (resource.action === "deny") { explained = resource; break; }
|
|
1450
|
+
if (resource.action === "ask" && explained.action === "allow") explained = resource;
|
|
1398
1451
|
}
|
|
1452
|
+
const decision = explained.action;
|
|
1453
|
+
// feat-approval-events: the verdict is durable before the call can start, so
|
|
1454
|
+
// the log answers "why was this allowed" -- including automatic allows.
|
|
1455
|
+
const record = async (verdict: ApprovalRecord["decision"], source: ApprovalRecord["source"]) => {
|
|
1456
|
+
if (!ctx.onApproval || ctx.callId === undefined || !approvalNeeded(request, decision)) return;
|
|
1457
|
+
await ctx.onApproval({ call_id: ctx.callId, tool: request.tool, decision: verdict, source,
|
|
1458
|
+
...(request.command !== undefined ? { command: request.command } : {}),
|
|
1459
|
+
...(explained.rule ? { rule: describeRule(explained.rule) } : {}) });
|
|
1460
|
+
};
|
|
1399
1461
|
switch (decision) {
|
|
1400
1462
|
case "allow":
|
|
1463
|
+
// An empty rule list consulted no policy (tool tests, trusted host execution).
|
|
1464
|
+
if (explained.source !== "none") await record("allow", explained.source);
|
|
1401
1465
|
return null;
|
|
1402
1466
|
case "deny":
|
|
1467
|
+
await record("deny", "config");
|
|
1403
1468
|
return failure("permission_denied", deniedMessage(request));
|
|
1404
1469
|
case "ask": {
|
|
1405
1470
|
const ask = ctx.ask ?? ctx.askSlot?.current;
|
|
1406
1471
|
if (ask === undefined) {
|
|
1472
|
+
await record("deny", "no_prompt");
|
|
1407
1473
|
return failure("permission_denied", askWithoutTtyMessage(request));
|
|
1408
1474
|
}
|
|
1409
1475
|
let choice: ApprovalChoice | undefined;
|
|
1410
1476
|
try { choice = await waitForAdmission(ask(request), ctx.signal); }
|
|
1411
1477
|
catch (error) { return caughtFailure(error, NOT_STARTED, "approval unavailable: "); }
|
|
1478
|
+
// A withdrawn prompt is not a decision; the result already says cancelled.
|
|
1412
1479
|
if (choice === undefined || ctx.signal?.aborted) return cancelledTool();
|
|
1480
|
+
await record(choice, "user");
|
|
1413
1481
|
switch (choice) {
|
|
1414
1482
|
case "once":
|
|
1415
1483
|
return null;
|
|
@@ -1501,6 +1569,8 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
|
|
|
1501
1569
|
try {
|
|
1502
1570
|
if (!begun) await scoped(() => ctx.extensionHooks?.event("tool_execution_start", { ...lifecycle, args: observerValue(input) }));
|
|
1503
1571
|
await ctx.drainNested?.();
|
|
1572
|
+
// Admission, the durable invocation and every hook saw the placeholder; only the tool sees the value.
|
|
1573
|
+
const runInput = ctx.expandSecrets ? ctx.expandSecrets(input) : input;
|
|
1504
1574
|
const execute = async () => {
|
|
1505
1575
|
const executionContext: ToolContext = { ...ctx, onOutput: chunk => {
|
|
1506
1576
|
ctx.onOutput?.(chunk);
|
|
@@ -1511,11 +1581,11 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
|
|
|
1511
1581
|
}
|
|
1512
1582
|
} };
|
|
1513
1583
|
observed = owned ? finishToolResult(ctx.signal?.aborted ? cancelledTool() : await owned(),
|
|
1514
|
-
toolDefinition(name, ctx)?.access === "read" ? READ_ATTEMPTED : UNKNOWN_EFFECT) : await dispatchTool(executionContext, name,
|
|
1584
|
+
toolDefinition(name, ctx)?.access === "read" ? READ_ATTEMPTED : UNKNOWN_EFFECT) : await dispatchTool(executionContext, name, runInput);
|
|
1515
1585
|
return observed;
|
|
1516
1586
|
};
|
|
1517
1587
|
const command = ["bash", "environment", "target", "exec_command", "write_stdin", NATIVE_COMMAND_NAME].includes(name);
|
|
1518
|
-
const operation = () => ctx.workspace && command ? ctx.workspace.command(name,
|
|
1588
|
+
const operation = () => ctx.workspace && command ? ctx.workspace.command(name, runInput, execute, ctx.signal) : execute();
|
|
1519
1589
|
// Progress handlers may enqueue native commands. They run only after this
|
|
1520
1590
|
// process drains, before the outer receipt; awaiting them inside it deadlocks.
|
|
1521
1591
|
observed = command && ctx.runOwnedCommand ? await ctx.runOwnedCommand(operation) : await operation();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The current request's operations outside the workspace, read from the log
|
|
3
|
+
* when a request ends (feat-external-effects-summary). The derivation is the
|
|
4
|
+
* pure core function; this file only supplies what core may not read: the
|
|
5
|
+
* home directory and the platform's temporary directory.
|
|
6
|
+
* Nothing is recorded -- the list is a projection and is recomputed on demand.
|
|
7
|
+
*/
|
|
8
|
+
import { realpathSync } from "node:fs";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
|
+
import { externalEffects, type ExternalEffect, type ExternalOptions } from "../core/external-effects.ts";
|
|
11
|
+
import { currentRequestEvents } from "../core/request-state.ts";
|
|
12
|
+
import type { Session } from "./loop.ts";
|
|
13
|
+
|
|
14
|
+
/** macOS reports /var/folders/... while processes see /private/var/folders/...; both are temporary. */
|
|
15
|
+
export function hostExternalOptions(): ExternalOptions {
|
|
16
|
+
const temp = tmpdir();
|
|
17
|
+
let real = temp;
|
|
18
|
+
try { real = realpathSync(temp); } catch { /* an unreadable temp root is still the reported one */ }
|
|
19
|
+
return { home: homedir(), temp: [...new Set([temp, real])] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Synchronous on purpose: SessionControl computes this between the turn's last
|
|
24
|
+
* event and releasing the turn, and an await there lets a disposal observe a
|
|
25
|
+
* turn that has already ended. The cost is one known gap: a request that
|
|
26
|
+
* crossed a segment boundary lists only what ran after the last cut (D8 keeps
|
|
27
|
+
* the earlier part in the sealed segment, not in memory). Silence, not a guess.
|
|
28
|
+
*/
|
|
29
|
+
export function requestExternalEffects(session: Session, options: ExternalOptions = hostExternalOptions()): ExternalEffect[] {
|
|
30
|
+
return externalEffects(currentRequestEvents(session.state.events), session.config.tools.root, options);
|
|
31
|
+
}
|
|
@@ -390,7 +390,11 @@ export async function discoverTargetInstructions(options: TargetInstructionOptio
|
|
|
390
390
|
const root = resolve(options.workspace), directories = new Set<string>();
|
|
391
391
|
for (const path of options.paths) {
|
|
392
392
|
const absolute = resolve(root, path);
|
|
393
|
-
|
|
393
|
+
// No workspace scope applies outside it, and another project's AGENTS.md
|
|
394
|
+
// is not this workspace's authority. Whether the target may be touched at
|
|
395
|
+
// all is the path rules' decision; throwing here pre-empted it with an
|
|
396
|
+
// internal step's name (outside-path-instructions card).
|
|
397
|
+
if (!isInside(absolute, root)) continue;
|
|
394
398
|
for (const directory of projectDirectories(root, dirname(absolute))) directories.add(directory);
|
|
395
399
|
}
|
|
396
400
|
const ordered = [...directories].sort((a, b) => a.split(sep).length - b.split(sep).length || a.localeCompare(b));
|
|
@@ -11,7 +11,7 @@ export async function editablePath(root: string, path: string): Promise<string>
|
|
|
11
11
|
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(rel) === rel) throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path}`, NOT_STARTED);
|
|
12
12
|
// Session logs and blobs are the evidence; an edit there could rewrite it.
|
|
13
13
|
if (rel.split(sep).some(part => part === ".git" || part === ".xeno")) {
|
|
14
|
-
throw new ToolFault("permission_denied",
|
|
14
|
+
throw new ToolFault("permission_denied", `${path} is inside .git or .xeno, which edit tools never change; this is not a permission problem and no approval will allow it`, NOT_STARTED, { next: STATE_PATH_GUIDANCE });
|
|
15
15
|
}
|
|
16
16
|
let current = base;
|
|
17
17
|
for (const part of rel.split(sep)) {
|
package/src/store/log.ts
CHANGED
|
@@ -125,6 +125,11 @@ export interface Cursor {
|
|
|
125
125
|
workspace?: string;
|
|
126
126
|
/** Content-addressed frozen context; credentials and approvals are excluded. */
|
|
127
127
|
context_ref?: string;
|
|
128
|
+
/**
|
|
129
|
+
* The xeno release that wrote this cursor. Every start writes one, so the
|
|
130
|
+
* append-only metadata journal is a timeline of the versions a session ran under.
|
|
131
|
+
*/
|
|
132
|
+
xeno_version?: string;
|
|
128
133
|
/**
|
|
129
134
|
* A human name. Optional because most sessions never get one, and a log
|
|
130
135
|
* written before names existed has none. Absent rather than empty: an
|
|
@@ -554,6 +559,21 @@ export async function readCursorAt(dir: string, node: number, atEvent?: string):
|
|
|
554
559
|
return stateAtPosition(states, node, events.map(event => event.id), atEvent);
|
|
555
560
|
}
|
|
556
561
|
|
|
562
|
+
/**
|
|
563
|
+
* When this session was forked, or null for an original (fix-fork-start-time).
|
|
564
|
+
* copyAncestryTo keeps the parent's nodes and their timestamps, then writes
|
|
565
|
+
* the fork cursor and, right after it, the parent_session record; that
|
|
566
|
+
* cursor's updated_at is the fork moment. The last parent_session record is
|
|
567
|
+
* this session's own: a copied parent that was itself a fork brings its
|
|
568
|
+
* record along in the copied journal.
|
|
569
|
+
*/
|
|
570
|
+
export async function forkedAt(dir: string): Promise<string | null> {
|
|
571
|
+
const records = await readMetadata(dir) ?? [];
|
|
572
|
+
const own = records.findLastIndex(record => record.kind === "parent_session");
|
|
573
|
+
const cursor = records.slice(0, Math.max(0, own)).findLast(record => record.kind === "cursor");
|
|
574
|
+
return own >= 0 && cursor?.kind === "cursor" ? cursor.cursor.updated_at : null;
|
|
575
|
+
}
|
|
576
|
+
|
|
557
577
|
/** D3: the replaceable manifest is a projection of committed metadata events. */
|
|
558
578
|
export async function readManifest(dir: string): Promise<Manifest> {
|
|
559
579
|
return serialized(dir, () => readManifestUnlocked(dir));
|
package/src/store/replay.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { deriveHandoff } from "../core/derive.ts";
|
|
8
8
|
import type { Handoff } from "../core/handoff.ts";
|
|
9
9
|
import type { CutEvent, LogEvent } from "../core/events.ts";
|
|
10
|
-
import { boundHandoff, boundRecoveryHandoff } from "../core/history.ts";
|
|
10
|
+
import { boundHandoff, boundRecoveryHandoff, boundRecoveryHandoffV3 } from "../core/history.ts";
|
|
11
11
|
import { retainedContext } from "../core/context-window.ts";
|
|
12
12
|
import { digestNode, nodePath, readAncestry, readManifest, type Manifest } from "./log.ts";
|
|
13
13
|
import { readNodeHistory } from "./restore.ts";
|
|
@@ -27,10 +27,11 @@ export interface ReplayedContext {
|
|
|
27
27
|
export async function carriedHandoff(dir: string, node: number, full: Handoff,
|
|
28
28
|
policy: Pick<CutEvent, "handoff_tokens" | "working_set_version" | "guidance_tools">, manifest?: Manifest): Promise<Handoff> {
|
|
29
29
|
if (policy.handoff_tokens === undefined) return full;
|
|
30
|
-
|
|
30
|
+
// The recorded version selects the algorithm; a new default never reinterprets an old cut.
|
|
31
|
+
if (policy.working_set_version === 2 || policy.working_set_version === 3) {
|
|
31
32
|
const history = await readNodeHistory(dir, node, manifest, "context");
|
|
32
33
|
if (!history.ok) throw new Error(history.error);
|
|
33
|
-
return boundRecoveryHandoff(full, history.nodes, policy.handoff_tokens, policy.guidance_tools);
|
|
34
|
+
return (policy.working_set_version === 3 ? boundRecoveryHandoffV3 : boundRecoveryHandoff)(full, history.nodes, policy.handoff_tokens, policy.guidance_tools);
|
|
34
35
|
}
|
|
35
36
|
return boundHandoff(full, policy.handoff_tokens);
|
|
36
37
|
}
|