@visual-z/xeno 0.8.1 → 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 +66 -0
- package/docs/install.md +25 -0
- package/package.json +1 -1
- package/src/apps/cli.ts +4 -1
- package/src/apps/shell.ts +13 -3
- package/src/apps/startup.ts +31 -6
- 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 +27 -3
- package/src/core/command-contract.ts +121 -5
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/environment.ts +7 -0
- 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 +131 -21
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +64 -3
- package/src/core/prompt-policy.ts +11 -2
- package/src/core/regex-dialect.ts +25 -0
- package/src/core/ripgrep-release.ts +64 -0
- package/src/core/secrets.ts +188 -0
- package/src/core/stall.ts +63 -18
- package/src/core/stop-report.ts +46 -4
- package/src/core/tool-contract.ts +2 -0
- package/src/core/tool-failure.ts +25 -4
- package/src/core/transcript.ts +3 -0
- package/src/core/update-check.ts +2 -1
- 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 +69 -19
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +196 -26
- 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 +139 -31
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/command.ts +3 -1
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +6 -3
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/search.ts +9 -5
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/store/tool-binaries.ts +90 -0
- package/src/store/worktree.ts +55 -10
- 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
- package/src/tui/ui/screen.ts +6 -3
- package/src/tui/ui/status.ts +16 -0
|
@@ -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 } from "../core/command-contract.ts";
|
|
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));
|
|
@@ -571,6 +613,34 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
571
613
|
required: ["node", "resolution"],
|
|
572
614
|
},
|
|
573
615
|
},
|
|
616
|
+
{
|
|
617
|
+
/**
|
|
618
|
+
* The model's way into an isolated checkout of this repository.
|
|
619
|
+
*
|
|
620
|
+
* Edits are confined to the session root, so isolation the model builds
|
|
621
|
+
* itself with `git worktree add` is unusable: outside the root is refused,
|
|
622
|
+
* and inside `.xeno` is session state. This creates the checkout where
|
|
623
|
+
* xeno keeps them and moves the root after the tool round, the way an
|
|
624
|
+
* adopted branch does; the cursor records the root, so resume stays there.
|
|
625
|
+
*/
|
|
626
|
+
name: "worktree",
|
|
627
|
+
access: "state", execution: "exclusive", branch: "none",
|
|
628
|
+
example: { action: "enter", name: "fix-parser" },
|
|
629
|
+
description:
|
|
630
|
+
"enter: create a git worktree of this repository on a new branch xeno/<name> from HEAD, kept under xeno's config directory, " +
|
|
631
|
+
"and move this session's root there after the current tool round, so later edits and commands run in the isolated checkout. " +
|
|
632
|
+
"Use it when the shared working tree has changes by others or must stay untouched. include_changes copies the current uncommitted files. " +
|
|
633
|
+
"exit: move the root back to the repository's main checkout. The worktree and its branch are kept; nothing is merged or deleted.",
|
|
634
|
+
input_schema: {
|
|
635
|
+
type: "object",
|
|
636
|
+
properties: {
|
|
637
|
+
action: { type: "string", enum: ["enter", "exit"] },
|
|
638
|
+
name: { type: "string", description: "enter: letters, digits, dot, dash or underscore; becomes the branch xeno/<name>" },
|
|
639
|
+
include_changes: { type: "boolean", description: "enter: start from the current uncommitted files instead of HEAD; default false" },
|
|
640
|
+
},
|
|
641
|
+
required: ["action"],
|
|
642
|
+
},
|
|
643
|
+
},
|
|
574
644
|
{
|
|
575
645
|
name: "bash",
|
|
576
646
|
example: { command: "bun test", workdir: ".", timeout: 300 },
|
|
@@ -631,6 +701,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
631
701
|
workdir: { type: "string", description: "Directory to run in; default the workspace root" },
|
|
632
702
|
tty: { type: "boolean", description: "Run in a pseudo-terminal; default false" },
|
|
633
703
|
yield_time_ms: { type: "integer", description: "Milliseconds to wait before returning; default 10000; effective range 100-30000, values outside are clamped" },
|
|
704
|
+
max_output_tokens: { type: "integer", minimum: 1, description: "Lower this call's inline output preview to about this many tokens; full output stays recoverable" },
|
|
634
705
|
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the session is killed; default 300" },
|
|
635
706
|
},
|
|
636
707
|
required: ["cmd"],
|
|
@@ -650,7 +721,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
650
721
|
properties: {
|
|
651
722
|
session_id: { type: "integer", minimum: 1 },
|
|
652
723
|
chars: { type: "string", description: "Characters to write; empty to poll" },
|
|
653
|
-
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" },
|
|
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" },
|
|
654
726
|
},
|
|
655
727
|
required: ["session_id"],
|
|
656
728
|
},
|
|
@@ -663,7 +735,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
663
735
|
description:
|
|
664
736
|
"Search file contents for a regex. Stays inside the workspace, does " +
|
|
665
737
|
"not follow symlinks, and skips .git, node_modules and .xeno. Uses " +
|
|
666
|
-
"ripgrep when it is
|
|
738
|
+
"ripgrep when it is on PATH or in xeno's bin directory, otherwise a TypeScript walk. " +
|
|
667
739
|
"Reports scan completeness separately from preview truncation. Long matches and omitted results remain in an immutable history artifact; use its sha256 and byte offset to recover them. Unknown or partial coverage cannot prove absence.",
|
|
668
740
|
input_schema: {
|
|
669
741
|
type: "object",
|
|
@@ -826,7 +898,7 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
826
898
|
checkSourceCancellation(ctx.signal);
|
|
827
899
|
text = Buffer.from(bytes).toString("utf8");
|
|
828
900
|
} catch (err) {
|
|
829
|
-
return caughtFailure(err, READ_ATTEMPTED, `cannot read ${path}: `);
|
|
901
|
+
return withPathSuggestions(ctx, path, caughtFailure(err, READ_ATTEMPTED, `cannot read ${path}: `));
|
|
830
902
|
}
|
|
831
903
|
|
|
832
904
|
if (ctx.imageStore && detectSupportedImageMimeType(bytes)) {
|
|
@@ -971,7 +1043,7 @@ async function toolEdit(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
971
1043
|
const version = saved.bytes === null ? null : sha256(saved.bytes);
|
|
972
1044
|
const checked = await filePrecondition(ctx, input, abs, version);
|
|
973
1045
|
if (!checked.ok) return checked.result;
|
|
974
|
-
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));
|
|
975
1047
|
const recover = { tool: "read", input: { path } };
|
|
976
1048
|
let before: string;
|
|
977
1049
|
try { before = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(saved.bytes); }
|
|
@@ -1040,16 +1112,17 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1040
1112
|
const observation = await commandFingerprint(ctx);
|
|
1041
1113
|
const header = commandHeader({ exit, wallMs: captured.wallMs, timeoutMs,
|
|
1042
1114
|
...(requestedMs !== undefined ? { requestedMs } : {}),
|
|
1043
|
-
...(captured.stopped ? { stopped: captured.stopped } : {}) });
|
|
1115
|
+
...(captured.stopped ? { stopped: captured.stopped } : {}) }) + answerLabel(command, exit, captured.stopped);
|
|
1044
1116
|
const artifactSummary = commandArtifactSummary(artifacts);
|
|
1045
1117
|
|
|
1046
1118
|
return {
|
|
1047
1119
|
// The artifact starts with `exit N\n`; the view replaces that line with
|
|
1048
1120
|
// the full header and pages the rest, so offsets stay artifact offsets.
|
|
1049
|
-
|
|
1050
|
-
|
|
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))),
|
|
1051
1124
|
content_ref: captured.ref,
|
|
1052
|
-
...commandCompletion(exit, captured.stopped, captured.cleanup),
|
|
1125
|
+
...commandCompletion(exit, captured.stopped, captured.cleanup, command),
|
|
1053
1126
|
data: { exit, output: captured.ref, ...(captured.cleanup ? { cleanup: "unknown" } : {}), ...(artifacts ? { artifacts } : {}) },
|
|
1054
1127
|
effect: {
|
|
1055
1128
|
kind: "command",
|
|
@@ -1090,7 +1163,8 @@ async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1090
1163
|
...(ctx.execOwner === undefined ? {} : { owner: ctx.execOwner }) });
|
|
1091
1164
|
if ("error" in session) return failure("execution_failed", session.error);
|
|
1092
1165
|
const wait = yieldWait(input["yield_time_ms"]);
|
|
1093
|
-
|
|
1166
|
+
const view = { ...ctx, maxOutputBytes: outputBytesForTokens(input["max_output_tokens"], ctx.maxOutputBytes) };
|
|
1167
|
+
return sessionResult(view, command, timeoutMs, await pollSession(session, outputStore, view.maxOutputBytes, wait.ms, "", ctx.signal), wait.note);
|
|
1094
1168
|
}
|
|
1095
1169
|
|
|
1096
1170
|
async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
@@ -1099,18 +1173,22 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
|
|
|
1099
1173
|
const session = findSession(id, ctx.execOwner);
|
|
1100
1174
|
if (session === undefined) {
|
|
1101
1175
|
const closed = closedSession(id, ctx.execOwner);
|
|
1102
|
-
|
|
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);
|
|
1103
1179
|
const original = await ctx.observedProcess?.(id);
|
|
1104
|
-
if (original) return receiptResult(ctx, recoverProcessReceipt(original.receipt, original.origin),
|
|
1180
|
+
if (original) return receiptResult(ctx, recoverProcessReceipt(original.receipt, original.origin), input);
|
|
1105
1181
|
const running = runningSessions(ctx.execOwner).map((s) => s.id).join(", ");
|
|
1106
1182
|
return { ...failure("unknown_effect", `no running session ${id}; no observation in this session's active history` +
|
|
1107
1183
|
(running.length > 0 ? `; running sessions: ${running}` : ""), UNKNOWN_EFFECT),
|
|
1108
1184
|
data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
|
|
1109
1185
|
}
|
|
1110
1186
|
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1113
|
-
|
|
1187
|
+
const owner = chars.length === 0 ? ctx.processWait : undefined;
|
|
1188
|
+
const wait = owner ? emptyYieldWait(input["yield_time_ms"]) : yieldWait(input["yield_time_ms"]);
|
|
1189
|
+
const view = { ...ctx, maxOutputBytes: outputBytesForTokens(input["max_output_tokens"], ctx.maxOutputBytes) };
|
|
1190
|
+
return sessionResult(view, session.command, session.timeoutMs, await pollSession(session, outputStore, view.maxOutputBytes, wait.ms, chars, ctx.signal,
|
|
1191
|
+
owner ? { wake: owner.wake(), onWaiting: active => ctx.onProcessWait?.(id, active), windowMs: wait.ms } : undefined), wait.note);
|
|
1114
1192
|
}
|
|
1115
1193
|
|
|
1116
1194
|
/** A successful historical query proves only that the receipt was found, never a new command exit. */
|
|
@@ -1139,18 +1217,25 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
|
|
|
1139
1217
|
? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed; ` + (adviceTools(ctx).includes("write_stdin")
|
|
1140
1218
|
? `poll or send input with write_stdin(session_id=${id})`
|
|
1141
1219
|
: "process-control capability unavailable; execution remains unfinished; do not replay")
|
|
1142
|
-
: commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed
|
|
1143
|
-
const
|
|
1220
|
+
: commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed` + answerLabel(command, exit, taken.stopped);
|
|
1221
|
+
const unsent = taken.unsent === undefined ? "" : " · input was not sent: the process had already exited";
|
|
1222
|
+
const header = shown + waitNote + unsent;
|
|
1144
1223
|
const artifactSummary = commandArtifactSummary(taken.receipt.artifacts);
|
|
1145
|
-
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)));
|
|
1146
1225
|
const warnings = taken.receipt.capture === "partial" ? [failureDiagnostic("unknown_effect", "output capture is partial; full_output contains only captured bytes", UNKNOWN_EFFECT)] : [];
|
|
1147
1226
|
if (exit === null) {
|
|
1148
1227
|
return { ok: true, content, content_ref: stored.ref, content_sha256: stored.ref.sha256, data: taken.receipt, ...(warnings.length ? { warnings } : {}) };
|
|
1149
1228
|
}
|
|
1150
1229
|
const gate = matchGate(ctx, command);
|
|
1151
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);
|
|
1152
1237
|
return {
|
|
1153
|
-
...
|
|
1238
|
+
...completion,
|
|
1154
1239
|
content, content_ref: stored.ref, content_sha256: stored.ref.sha256,
|
|
1155
1240
|
data: taken.receipt, ...(warnings.length ? { warnings } : {}),
|
|
1156
1241
|
effect: { kind: "command", cmd: command, exit, ...(gate !== undefined ? { gate } : {}),
|
|
@@ -1158,6 +1243,12 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
|
|
|
1158
1243
|
};
|
|
1159
1244
|
}
|
|
1160
1245
|
|
|
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. */
|
|
1247
|
+
function answerLabel(command: string, exit: number, stopped?: "cancelled" | "timeout"): string {
|
|
1248
|
+
const meaning = stopped === undefined ? commandAnswer(command, exit) : null;
|
|
1249
|
+
return meaning === null ? "" : ` · ${meaning}`;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1161
1252
|
const utf8Length = (text: string) => new TextEncoder().encode(text).length;
|
|
1162
1253
|
|
|
1163
1254
|
/**
|
|
@@ -1352,26 +1443,41 @@ export async function gateTool(
|
|
|
1352
1443
|
|
|
1353
1444
|
const permissionInput = name === "apply_patch" && input && typeof input === "object" && "patchText" in input ? { paths: patchPaths(parsePatch(String(input.patchText))) } : input;
|
|
1354
1445
|
const request = await canonicalPermissionRequest(ctx.root, name, permissionInput);
|
|
1355
|
-
let
|
|
1446
|
+
let explained = explainDecision(ctx.rules, ctx.sessionRules ?? [], request);
|
|
1356
1447
|
if (definition?.access === "read" && request.paths) for (const path of request.paths) {
|
|
1357
|
-
const resource = await
|
|
1358
|
-
if (resource === "deny") {
|
|
1359
|
-
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;
|
|
1360
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
|
+
};
|
|
1361
1461
|
switch (decision) {
|
|
1362
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);
|
|
1363
1465
|
return null;
|
|
1364
1466
|
case "deny":
|
|
1467
|
+
await record("deny", "config");
|
|
1365
1468
|
return failure("permission_denied", deniedMessage(request));
|
|
1366
1469
|
case "ask": {
|
|
1367
1470
|
const ask = ctx.ask ?? ctx.askSlot?.current;
|
|
1368
1471
|
if (ask === undefined) {
|
|
1472
|
+
await record("deny", "no_prompt");
|
|
1369
1473
|
return failure("permission_denied", askWithoutTtyMessage(request));
|
|
1370
1474
|
}
|
|
1371
1475
|
let choice: ApprovalChoice | undefined;
|
|
1372
1476
|
try { choice = await waitForAdmission(ask(request), ctx.signal); }
|
|
1373
1477
|
catch (error) { return caughtFailure(error, NOT_STARTED, "approval unavailable: "); }
|
|
1478
|
+
// A withdrawn prompt is not a decision; the result already says cancelled.
|
|
1374
1479
|
if (choice === undefined || ctx.signal?.aborted) return cancelledTool();
|
|
1480
|
+
await record(choice, "user");
|
|
1375
1481
|
switch (choice) {
|
|
1376
1482
|
case "once":
|
|
1377
1483
|
return null;
|
|
@@ -1463,6 +1569,8 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
|
|
|
1463
1569
|
try {
|
|
1464
1570
|
if (!begun) await scoped(() => ctx.extensionHooks?.event("tool_execution_start", { ...lifecycle, args: observerValue(input) }));
|
|
1465
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;
|
|
1466
1574
|
const execute = async () => {
|
|
1467
1575
|
const executionContext: ToolContext = { ...ctx, onOutput: chunk => {
|
|
1468
1576
|
ctx.onOutput?.(chunk);
|
|
@@ -1473,11 +1581,11 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
|
|
|
1473
1581
|
}
|
|
1474
1582
|
} };
|
|
1475
1583
|
observed = owned ? finishToolResult(ctx.signal?.aborted ? cancelledTool() : await owned(),
|
|
1476
|
-
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);
|
|
1477
1585
|
return observed;
|
|
1478
1586
|
};
|
|
1479
1587
|
const command = ["bash", "environment", "target", "exec_command", "write_stdin", NATIVE_COMMAND_NAME].includes(name);
|
|
1480
|
-
const operation = () => ctx.workspace && command ? ctx.workspace.command(name,
|
|
1588
|
+
const operation = () => ctx.workspace && command ? ctx.workspace.command(name, runInput, execute, ctx.signal) : execute();
|
|
1481
1589
|
// Progress handlers may enqueue native commands. They run only after this
|
|
1482
1590
|
// process drains, before the outer receipt; awaiting them inside it deadlocks.
|
|
1483
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
|
+
}
|
package/src/store/command.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** O.1: own the process group, preserve partial observations, and drain before returning. */
|
|
2
2
|
import { captureCommandObservation } from "./observations.ts";
|
|
3
|
+
import { configDir } from "./config.ts";
|
|
4
|
+
import { xenoBinDir } from "./tool-binaries.ts";
|
|
3
5
|
import { commandArgv, commandEnvironment, COMMAND_TERMINATION_GRACE_MS } from "../core/command-contract.ts";
|
|
4
6
|
import { ToolFault, NOT_STARTED, failureDiagnostic } from "../core/tool-failure.ts";
|
|
5
7
|
|
|
@@ -44,7 +46,7 @@ async function executeCaptured(input: CommandCaptureInput, argv: string[], nativ
|
|
|
44
46
|
cwd: input.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", detached: true,
|
|
45
47
|
// Preserve the trusted native API's inherited environment; shell tools
|
|
46
48
|
// retain their explicit noninteractive policy and login-shell startup.
|
|
47
|
-
env: native ? process.env : commandEnvironment(process.env),
|
|
49
|
+
env: native ? process.env : commandEnvironment(process.env, xenoBinDir(configDir())),
|
|
48
50
|
}); } catch (error) {
|
|
49
51
|
const os = (error as { code?: string }).code;
|
|
50
52
|
throw new ToolFault(os === "ENOENT" || os === "ENOTDIR" ? "environment_missing" : os === "EACCES" || os === "EPERM" ? "permission_denied" : "execution_failed", String(error), NOT_STARTED);
|
|
@@ -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));
|
|
@@ -4,12 +4,15 @@ import { resolve, relative, dirname, join, sep } from "node:path";
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { blobAddress, putBlob, getBlobBytes, verifyBlob } from "./blobs.ts";
|
|
6
6
|
import { sameVersion, changedPaths, type FileVersion, type FileChanges } from "../core/checkpoints.ts";
|
|
7
|
-
import { ToolFault, NOT_STARTED, READ_ATTEMPTED, failureFromError } from "../core/tool-failure.ts";
|
|
7
|
+
import { ToolFault, NOT_STARTED, READ_ATTEMPTED, STATE_PATH_GUIDANCE, failureFromError } from "../core/tool-failure.ts";
|
|
8
8
|
|
|
9
9
|
export async function editablePath(root: string, path: string): Promise<string> {
|
|
10
10
|
const base = resolve(root), absolute = resolve(base, path), rel = relative(base, absolute);
|
|
11
|
-
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(rel) === rel
|
|
12
|
-
|
|
11
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(rel) === rel) throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path}`, NOT_STARTED);
|
|
12
|
+
// Session logs and blobs are the evidence; an edit there could rewrite it.
|
|
13
|
+
if (rel.split(sep).some(part => part === ".git" || part === ".xeno")) {
|
|
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
|
+
}
|
|
13
16
|
let current = base;
|
|
14
17
|
for (const part of rel.split(sep)) {
|
|
15
18
|
current = join(current, part);
|
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
|
}
|