@visual-z/xeno 0.7.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +36 -9
  3. package/docs/foundations.md +1 -1
  4. package/package.json +3 -1
  5. package/src/apps/cli.ts +4 -3
  6. package/src/apps/commands.ts +8 -0
  7. package/src/apps/goal-flow.ts +70 -0
  8. package/src/apps/render.ts +0 -2
  9. package/src/apps/session-hub.ts +6 -5
  10. package/src/apps/session-lifecycle.ts +64 -0
  11. package/src/apps/shell.ts +89 -34
  12. package/src/apps/startup.ts +32 -31
  13. package/src/apps/ui/app.ts +105 -13
  14. package/src/apps/ui/control-model.ts +18 -1
  15. package/src/apps/ui/surface.ts +2 -0
  16. package/src/apps/update-check.ts +1 -1
  17. package/src/core/acceptance.ts +41 -9
  18. package/src/core/blind-checks.ts +72 -0
  19. package/src/core/branch-receipt.ts +3 -1
  20. package/src/core/checkpoints.ts +21 -0
  21. package/src/core/churn.ts +2 -2
  22. package/src/core/completion-review.ts +18 -11
  23. package/src/core/continuation.ts +11 -10
  24. package/src/core/control-view.ts +5 -8
  25. package/src/core/deadline.ts +2 -1
  26. package/src/core/derive.ts +0 -7
  27. package/src/core/environment.ts +2 -2
  28. package/src/core/event-validation.ts +19 -6
  29. package/src/core/events.ts +41 -32
  30. package/src/core/evidence-ladder.ts +62 -0
  31. package/src/core/evidence-links.ts +0 -10
  32. package/src/core/evidence.ts +1 -2
  33. package/src/core/export.ts +6 -11
  34. package/src/core/goal-view.ts +64 -0
  35. package/src/core/goal.ts +200 -0
  36. package/src/core/history.ts +1 -11
  37. package/src/core/json-event.ts +0 -6
  38. package/src/core/outcome.ts +0 -1
  39. package/src/core/program-status.ts +11 -2
  40. package/src/core/progress.ts +14 -8
  41. package/src/core/project.ts +6 -16
  42. package/src/core/read-bounds.ts +11 -2
  43. package/src/core/read-observation.ts +24 -0
  44. package/src/core/request-budget.ts +13 -3
  45. package/src/core/request-state.ts +178 -19
  46. package/src/core/response-recovery.ts +15 -2
  47. package/src/core/session-entry.ts +8 -3
  48. package/src/core/session-exit.ts +19 -3
  49. package/src/core/session-format.ts +169 -0
  50. package/src/core/settings.ts +7 -27
  51. package/src/core/stall.ts +30 -7
  52. package/src/core/stop-report.ts +4 -3
  53. package/src/core/transcript.ts +22 -2
  54. package/src/runtime/blind-check.ts +36 -0
  55. package/src/runtime/counter-check.ts +74 -0
  56. package/src/runtime/exec-sessions.ts +11 -0
  57. package/src/runtime/foundation.ts +46 -8
  58. package/src/runtime/loop.ts +239 -430
  59. package/src/runtime/oauth.ts +1 -3
  60. package/src/runtime/openai.ts +2 -2
  61. package/src/runtime/pi-provider.ts +3 -3
  62. package/src/runtime/provider-request.ts +2 -1
  63. package/src/runtime/provider.ts +33 -3
  64. package/src/runtime/retry.ts +9 -2
  65. package/src/runtime/runtime-target.ts +2 -2
  66. package/src/runtime/session-control.ts +4 -4
  67. package/src/runtime/tools.ts +44 -42
  68. package/src/runtime/usage.ts +28 -0
  69. package/src/runtime/version.ts +12 -0
  70. package/src/runtime/workspace-lifecycle.ts +31 -0
  71. package/src/store/blind-checks.ts +32 -0
  72. package/src/store/checkpoint-history.ts +1 -12
  73. package/src/store/file-index.ts +88 -7
  74. package/src/store/log.ts +25 -27
  75. package/src/store/recovery.ts +1 -3
  76. package/src/store/replay.ts +2 -2
  77. package/src/store/restore.ts +10 -202
  78. package/src/store/search.ts +7 -5
  79. package/src/store/session-archive.ts +0 -1
  80. package/src/store/sessions.ts +31 -33
  81. package/src/store/workspace-clone.ts +42 -0
  82. package/src/store/workspace.ts +10 -157
  83. package/src/tui/ui/duration.ts +16 -0
  84. package/src/tui/ui/layout.ts +4 -4
  85. package/src/tui/ui/screen.ts +13 -7
  86. package/src/tui/ui/status.ts +99 -17
  87. package/src/core/benchmark.ts +0 -195
  88. package/src/core/evaluation-coverage.ts +0 -74
  89. package/src/core/experiment.ts +0 -173
  90. package/src/core/harness-comparison.ts +0 -90
  91. package/src/core/harness-diagnostics.ts +0 -206
  92. package/src/core/native-eval.ts +0 -25
  93. package/src/core/policy-evaluation.ts +0 -55
  94. package/src/core/query-eval-contract.ts +0 -14
  95. package/src/core/recall-analysis.ts +0 -90
  96. package/src/core/recall-trial.ts +0 -153
  97. package/src/core/recovery-corpus.ts +0 -17
  98. package/src/core/recovery-evaluation.ts +0 -60
  99. package/src/core/tool-mode-protocol.ts +0 -25
  100. package/src/core/trial-lifecycle.ts +0 -14
  101. package/src/core/window-trial.ts +0 -65
  102. package/src/index.ts +0 -10
  103. package/src/runtime/benchmark.ts +0 -187
  104. package/src/runtime/harness-comparison.ts +0 -376
  105. package/src/runtime/harness-eval.ts +0 -462
  106. package/src/runtime/native-eval-fixtures.ts +0 -33
  107. package/src/runtime/native-eval.ts +0 -146
  108. package/src/runtime/query-eval-verifier.ts +0 -28
  109. package/src/runtime/recall-trial.ts +0 -226
  110. package/src/runtime/recovery-coverage.ts +0 -71
  111. package/src/runtime/recovery-evidence.ts +0 -76
  112. package/src/runtime/recovery-fixtures.ts +0 -116
  113. package/src/runtime/tool-mode-eval.ts +0 -146
  114. package/src/runtime/trial-control.ts +0 -45
  115. package/src/runtime/window-trial.ts +0 -141
  116. package/src/store/git-branch.ts +0 -35
  117. package/src/tui/ui/testing.ts +0 -5
@@ -16,9 +16,7 @@
16
16
  import { createServer, type Server } from "node:http";
17
17
  import { pollDeviceCode } from "./oauth-device.ts";
18
18
  import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
19
-
20
- /** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
21
- export const XENO_VERSION = "0.7.2";
19
+ import { XENO_VERSION } from "./version.ts";
22
20
 
23
21
  /**
24
22
  * Minutes subtracted from server expiry so a request issued at the edge
@@ -23,7 +23,7 @@ import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
23
23
  import { imageData } from "./images.ts";
24
24
  import type { AssistantBlock } from "../core/events.ts";
25
25
  import { assertNever } from "../core/exhaustive.ts";
26
- import type { Provider, ProviderConfig, Turn, ToolSchema, OnDelta } from "./provider.ts";
26
+ import { coherentTurn, type Provider, type ProviderConfig, type Turn, type ToolSchema, type OnDelta } from "./provider.ts";
27
27
  import { DEFAULT_COMPAT, type Compat, type Effort, type ModelSpec } from "./model.ts";
28
28
  import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
29
29
  import type { ProviderSendOptions } from "./provider-tool-policy.ts";
@@ -502,7 +502,7 @@ export class OpenAICompletionsProvider implements Provider {
502
502
  }
503
503
 
504
504
  const assembled = await readCompletionsSse(response.body, onDelta);
505
- return parseResponse(assembled, node, Date.now() - started);
505
+ return coherentTurn(parseResponse(assembled, node, Date.now() - started), assembled.usage !== undefined);
506
506
  }
507
507
  }
508
508
 
@@ -20,7 +20,7 @@ import { providerRequest } from "./provider-request.ts";
20
20
  import { grokVersionHint } from "./oauth.ts";
21
21
  import type { ResolveEffects } from "./auth.ts";
22
22
  import { imageData } from "./images.ts";
23
- import { FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
23
+ import { coherentTurn, FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
24
24
  import { DEFAULT_RETRY, withRetry, type RetryPolicy } from "./retry.ts";
25
25
  import { HttpError, StreamError } from "./openai.ts";
26
26
  import type { UsageSample } from "./usage.ts";
@@ -392,7 +392,7 @@ export class PiProvider implements Provider {
392
392
  if (identityErrors.length) throw new ProviderIntegrityError(identityErrors, hasKnownUsage ? usage : undefined);
393
393
  // The SDK has already classified native STOP/end_turn/toolUse/length values. The loop consumes
394
394
  // xeno's closed completion vocabulary, never a raw native spelling that could skip tool execution.
395
- return { blocks: piBlocks(completed, originalArguments.values), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
396
- usage };
395
+ return coherentTurn({ blocks: piBlocks(completed, originalArguments.values), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
396
+ usage }, hasKnownUsage);
397
397
  }
398
398
  }
@@ -1,7 +1,8 @@
1
1
  import { providerEnvironment, resolveKeyValue, type ResolveEffects } from "./auth.ts";
2
2
  import { providerAccess } from "./provider-access.ts";
3
3
  import type { ModelSpec } from "./model.ts";
4
- import { XENO_VERSION, GROK_PROVIDER, GROK_BASE_URL, GROK_PROTOCOL_VERSION } from "./oauth.ts";
4
+ import { GROK_PROVIDER, GROK_BASE_URL, GROK_PROTOCOL_VERSION } from "./oauth.ts";
5
+ import { XENO_VERSION } from "./version.ts";
5
6
 
6
7
  const SDK_IDENTITY_HEADERS = ["x-app", "editor-version", "editor-plugin-version", "copilot-integration-id", "http-referer", "x-title"];
7
8
 
@@ -35,10 +35,12 @@ import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
35
35
  import { imageData } from "./images.ts";
36
36
  import type { AssistantBlock } from "../core/events.ts";
37
37
  import { assertNever } from "../core/exhaustive.ts";
38
+ import { isToolStopWithoutCall } from "../core/response-recovery.ts";
38
39
  import type { UsageSample } from "./usage.ts";
39
40
  import { type Effort, type ModelSpec, CLAUDE_OPUS_5 } from "./model.ts";
40
41
  import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
41
42
  import { anthropicOAuthHeaders } from "./oauth.ts";
43
+ import { XENO_VERSION } from "./version.ts";
42
44
  import type { ProviderSendOptions } from "./provider-tool-policy.ts";
43
45
 
44
46
  export interface ToolSchema {
@@ -159,6 +161,27 @@ export class FailedResponseError extends Error {
159
161
  }
160
162
  }
161
163
 
164
+ /**
165
+ * A 200 response that contradicts itself, such as a tool stop with no tool
166
+ * call. retry.ts treats it like a transient stream failure: a fresh sample is
167
+ * the remedy, and exhausting retries must surface as a provider error rather
168
+ * than reach the loop as a silent final answer. The output token count tells
169
+ * "generated but lost in transit" apart from "nothing generated".
170
+ */
171
+ export class MalformedResponseError extends FailedResponseError {
172
+ constructor(detail: string, usage?: UsageSample) {
173
+ super(`malformed provider response: ${detail}${usage ? ` (${usage.output_tokens} output tokens)` : ""}`, usage);
174
+ this.name = "MalformedResponseError";
175
+ }
176
+ }
177
+
178
+ /** Every adapter returns through this, so no dialect can hand the loop a contradictory turn. */
179
+ export function coherentTurn(turn: Turn, usageKnown = true): Turn {
180
+ if (isToolStopWithoutCall(turn.stopReason, turn.blocks))
181
+ throw new MalformedResponseError(`stop_reason ${JSON.stringify(turn.stopReason)} without a tool call`, usageKnown ? turn.usage : undefined);
182
+ return turn;
183
+ }
184
+
162
185
  /** Thrown for a refusal, which arrives as HTTP 200 rather than an error. */
163
186
  export class RefusalError extends FailedResponseError {
164
187
  readonly category: string | null;
@@ -176,6 +199,12 @@ function buildAnthropicClient(
176
199
  apiKey: string | undefined,
177
200
  options: { fetch?: typeof fetch; oauth?: boolean; baseUrl?: string },
178
201
  ): AnthropicTransport {
202
+ // D18: the User-Agent, not SDK headers, identifies xeno on the wire. Left alone
203
+ // the SDK sends `Anthropic/JS <version>`, which model gateways fold into "custom
204
+ // client" attribution; provider-request.ts already stamps xeno's UA on the Pi
205
+ // path. Constructor defaultHeaders merge after the SDK's built-in UA, so this
206
+ // entry wins without touching per-request headers.
207
+ const identity = { "User-Agent": `xeno/${XENO_VERSION}` };
179
208
  if (options.oauth === true && apiKey) {
180
209
  // authToken is Bearer. Passing apiKey here would send x-api-key, which
181
210
  // an OAuth token is not accepted as.
@@ -183,7 +212,7 @@ function buildAnthropicClient(
183
212
  maxRetries: 0,
184
213
  ...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
185
214
  authToken: apiKey,
186
- defaultHeaders: anthropicOAuthHeaders(),
215
+ defaultHeaders: { ...anthropicOAuthHeaders(), ...identity },
187
216
  ...(options.fetch ? { fetch: options.fetch } : {}),
188
217
  });
189
218
  }
@@ -191,6 +220,7 @@ function buildAnthropicClient(
191
220
  maxRetries: 0,
192
221
  ...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
193
222
  ...(apiKey ? { apiKey } : {}),
223
+ defaultHeaders: identity,
194
224
  ...(options.fetch ? { fetch: options.fetch } : {}),
195
225
  });
196
226
  }
@@ -319,11 +349,11 @@ export class AnthropicProvider implements Provider {
319
349
  throw new RefusalError(details?.category ?? null, details?.explanation ?? null, assembled.usageKnown ? usage : undefined);
320
350
  }
321
351
 
322
- return {
352
+ return coherentTurn({
323
353
  blocks: assembled.blocks,
324
354
  stopReason: assembled.stopReason,
325
355
  usage,
326
- };
356
+ }, assembled.usageKnown);
327
357
  }
328
358
  }
329
359
 
@@ -33,6 +33,11 @@ function isStreamFailure(error: unknown): error is Error & { code?: string } {
33
33
  return error instanceof Error && error.name === "StreamError";
34
34
  }
35
35
 
36
+ /** provider.ts MalformedResponseError: a self-contradictory 200, structural for the same reason. */
37
+ function isMalformedResponse(error: unknown): boolean {
38
+ return error instanceof Error && error.name === "MalformedResponseError";
39
+ }
40
+
36
41
  export interface RetryPolicy {
37
42
  /**
38
43
  * Attempts after the first. Four means a request can take about 15s of
@@ -140,8 +145,10 @@ export function retryDecision(
140
145
  // A failure reported inside a 200 stream carries no status. Relays and
141
146
  // Anthropic's own `overloaded_error` use it for exactly the conditions a
142
147
  // 5xx would signal; anything else in a stream is a real rejection.
143
- if (status === undefined && isStreamFailure(error)) {
144
- if (!TRANSIENT_STREAM.test(`${error.code ?? ""} ${error.message}`)) {
148
+ if (status === undefined && (isStreamFailure(error) || isMalformedResponse(error))) {
149
+ // A contradictory response is a bad sample, not a rejection of the request:
150
+ // resend it like a dropped stream, and let the bound turn it into an error.
151
+ if (isStreamFailure(error) && !TRANSIENT_STREAM.test(`${error.code ?? ""} ${error.message}`)) {
145
152
  return { retry: false, reason: "the stream reported a non-transient error" };
146
153
  }
147
154
  const base = Math.min(0.5 * 2 ** attempt, 8) * 1000;
@@ -13,7 +13,7 @@ import { executionOutcome } from "../core/outcome.ts";
13
13
  import { archiveCommandArtifacts, artifactExclusions, observeArtifactFiles, observeArtifactInputs } from "../store/command-artifacts.ts";
14
14
  import { readObservation } from "../store/observations.ts";
15
15
  import { targetDirectory, targetProgram, observeRuntimeIdentity } from "../store/runtime-target.ts";
16
- import { findSession, startSession, pollSession } from "./exec-sessions.ts";
16
+ import { findSession, startSession, pollSession, yieldWait } from "./exec-sessions.ts";
17
17
  import { sessionResult, type ToolContext, type ToolResult } from "./tools.ts";
18
18
 
19
19
  type History = readonly { node: number; events: readonly LogEvent[] }[];
@@ -63,7 +63,7 @@ export class RuntimeTargets {
63
63
  const live: LiveTarget = { request: state.request, contract: state.contract_event, build: receipt.build!.event,
64
64
  instance: { id: randomUUID(), session: session.id, pid: session.pid, source, entry: { path: entryPath, sha256: entry.sha256 }, program } };
65
65
  this.live.set(target.id, live);
66
- const taken = await pollSession(session, archive, ctx.maxOutputBytes, input.yield_time_ms ?? 1000, "", ctx.signal);
66
+ const taken = await pollSession(session, archive, ctx.maxOutputBytes, yieldWait(input.yield_time_ms ?? 1000).ms, "", ctx.signal);
67
67
  raw.push(taken.stored.ref);
68
68
  processResult = await sessionResult(ctx, session.command, session.timeoutMs, taken);
69
69
  }
@@ -9,7 +9,7 @@ import type { PermissionRequest } from "../core/permissions.ts";
9
9
  import type { ExecutionOutcome } from "../core/outcome.ts";
10
10
  import type { LogEvent } from "../core/events.ts";
11
11
  import { readNodeHistory } from "../store/restore.ts";
12
- import type { Session, LoopEvent } from "./loop.ts";
12
+ 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";
@@ -87,7 +87,7 @@ export class SessionControl {
87
87
  // Ancestors are sealed. The live segment must share the cursor's publication
88
88
  // boundary: a disk read can finish between append and in-memory publication.
89
89
  const parts = history.nodes.map(part => part.node === node ? { node, events: structuredClone(s.state.events) } : part);
90
- const projectedGoal = goalView(parts);
90
+ const projectedGoal = goalView(parts, { strict: s.config.strictVerification === true, blind: s.config.blindChecks !== undefined });
91
91
  const goal = this.active && projectedGoal?.state.request.event !== this.active.id ? null : projectedGoal;
92
92
  return { version: "xeno-control/0.1", threadId: this.threadId, streamId: this.streamId, sequence: this.sequence, node,
93
93
  activeTurnId: this.activeTurnId, goal, waiting: this.waiting, workflowMode: s.config.tools.workflowMode ?? "build",
@@ -107,7 +107,7 @@ export class SessionControl {
107
107
  }
108
108
 
109
109
  /** Both local and remote turns pass through this iterator; Session remains the execution owner. */
110
- async *send(input: UserInput, signal?: AbortSignal, pull?: (phase: Phase) => Promise<UserInput | null>): AsyncGenerator<LoopEvent> {
110
+ async *send(input: UserInput, signal?: AbortSignal, pull?: (phase: Phase) => Promise<UserInput | null>, options?: RequestOptions): AsyncGenerator<LoopEvent> {
111
111
  if (this.busy) throw Error("a turn is already running");
112
112
  const active = { id: this.session.clock.nextId(), abort: new AbortController(), queue: [] as UserInput[] };
113
113
  this.active = active;
@@ -123,7 +123,7 @@ export class SessionControl {
123
123
  const text = data.map(value => value.text).join("\n\n"), images = data.flatMap(value => value.images ?? []);
124
124
  return images.length ? { text, images } : text || null;
125
125
  };
126
- for await (const event of this.session.send(input, signals, steering, active.id)) {
126
+ for await (const event of this.session.send(input, signals, steering, active.id, options)) {
127
127
  this.emit("turn/event", { event });
128
128
  if (event.kind === "done") outcome = event.outcome;
129
129
  yield event;
@@ -28,20 +28,20 @@ import { archiveObservation, readObservation, type ArchivedObservation } from ".
28
28
  import { blobAddress } from "../store/blobs.ts";
29
29
  import { commandHeader, headTailView } from "../core/observation-view.ts";
30
30
  import {
31
- DEFAULT_YIELD_MS, MAX_YIELD_MS, MIN_YIELD_MS, findSession, closedSession, runningSessions, startSession, pollSession,
31
+ yieldWait, findSession, closedSession, runningSessions, startSession, pollSession,
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
36
  import { commandCompletion, commandTimeout } from "../core/command-contract.ts";
37
- import { previewReadObservation, readObservationText, type ObservationReceipt } from "../core/read-observation.ts";
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";
40
40
  import { waitForAdmission } from "./execution-control.ts";
41
41
  import { assertNever } from "../core/exhaustive.ts";
42
42
  import { artifactRecovery, toolDescription, type ToolGuidance } from "../core/prompt-policy.ts";
43
43
  import { classifyGate } from "../core/evidence.ts";
44
- import { readBounds } from "../core/read-bounds.ts";
44
+ import { readBounds, readNotice } from "../core/read-bounds.ts";
45
45
  import { closestName, inputError, isJsonValue, minimalInput, type JsonValue, type ToolDiagnostic } from "../core/tool-contract.ts";
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";
@@ -66,7 +66,7 @@ import { imageDescription } from "../core/images.ts";
66
66
  import { attachImageBytes } from "./images.ts";
67
67
  import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
68
68
  import { find as findFiles, grep, ls, MAX_FILE_BYTES, MAX_SEARCH_CAPTURE_BYTES, type SearchResult } from "../store/search.ts";
69
- import { scanWorkspace, workspaceVersion, DEFAULT_IGNORE } from "../store/workspace.ts";
69
+ import { DEFAULT_IGNORE } from "../store/workspace.ts";
70
70
  import { observeWorkspace } from "../store/workspace-observation.ts";
71
71
  import { readEditableFile, replaceFile } from "../store/file-checkpoints.ts";
72
72
  import type { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
@@ -98,14 +98,26 @@ export interface ToolResult {
98
98
  data?: JsonValue;
99
99
  diagnostic?: ToolDiagnostic;
100
100
  warnings?: readonly ToolDiagnostic[];
101
+ /** Scoped instructions attached to a read-only call that ran (S.6 item 3). */
102
+ instructions?: { keys: readonly string[]; text: string };
101
103
  }
102
104
 
105
+ /**
106
+ * What target-scoped instruction discovery decided for one call: hold a change
107
+ * back until the rules are read, or let a read-only call run and carry them.
108
+ */
109
+ export type InstructionCheck =
110
+ | { kind: "block"; result: ToolResult }
111
+ | { kind: "attach"; instructions: { keys: readonly string[]; text: string } };
112
+
103
113
  export interface ToolContext {
104
114
  historyEvents?: () => Promise<import("../core/events.ts").LogEvent[]>;
105
115
  checkpointBlobs?: string;
106
116
  frozenSystem?: () => string;
107
- instructionCheck?: (ctx: ToolContext, name: string, input: unknown) => Promise<ToolResult | null>;
117
+ instructionCheck?: (ctx: ToolContext, name: string, input: unknown) => Promise<InstructionCheck | null>;
108
118
  workflowMode?: "build" | "plan";
119
+ /** What a Plan-mode refusal tells the model instead of the default "select Build". */
120
+ planHint?: string;
109
121
  /** CLI owns its approval defaults; SDK callers retain their supplied host policy across reload. */
110
122
  appOwnsPermissions?: true;
111
123
  hostPermissionRules?: PermissionRule[];
@@ -121,8 +133,6 @@ export interface ToolContext {
121
133
  /** P.3: admitted execution owns file recovery; extensions cannot supply model-authored effects. */
122
134
  workspace?: WorkspaceLifecycle;
123
135
  ownedStatePaths?: readonly string[];
124
- /** Historical fingerprint compatibility, never enabled by the normal CLI. */
125
- legacyWorkspace?: boolean;
126
136
  /** O.15: optional disposable view; the reader still validates the original log. */
127
137
  historyCache?: HistoryCache;
128
138
  /** O.14: opaque process ownership is supplied by Session, never by model input. */
@@ -349,7 +359,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
349
359
  input_schema: { type: "object", required: ["action", "target"], properties: {
350
360
  action: { type: "string", enum: ["start", "inspect"] }, target: { type: "string" },
351
361
  session_id: { type: "integer", minimum: 1, description: "Inspect this exact previously returned handle; never adopts a process" },
352
- yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Milliseconds to wait for launch; default 10000, distinct from the hard timeout" },
362
+ yield_time_ms: { type: "integer", description: "Milliseconds to wait for launch; default 1000, distinct from the hard timeout; effective range 100-30000, values outside are clamped" },
353
363
  timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Hard process lifetime in seconds; defaults to the session command timeout" },
354
364
  } },
355
365
  },
@@ -620,7 +630,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
620
630
  artifacts: commandArtifactsSchema,
621
631
  workdir: { type: "string", description: "Directory to run in; default the workspace root" },
622
632
  tty: { type: "boolean", description: "Run in a pseudo-terminal; default false" },
623
- yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Milliseconds to wait before returning; default 10000" },
633
+ yield_time_ms: { type: "integer", description: "Milliseconds to wait before returning; default 10000; effective range 100-30000, values outside are clamped" },
624
634
  timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the session is killed; default 300" },
625
635
  },
626
636
  required: ["cmd"],
@@ -640,7 +650,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
640
650
  properties: {
641
651
  session_id: { type: "integer", minimum: 1 },
642
652
  chars: { type: "string", description: "Characters to write; empty to poll" },
643
- yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Bounded wait when sending input or using low-level polling; agent empty waits are owned until an observation changes" },
653
+ yield_time_ms: { type: "integer", description: "Bounded wait when sending input or using low-level polling; effective range 100-30000, values outside are clamped; agent empty waits are owned until an observation changes" },
644
654
  },
645
655
  required: ["session_id"],
646
656
  },
@@ -831,11 +841,16 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
831
841
  // cannot take the whole observation budget; the notice names the next start.
832
842
  const first = input["start"] ?? 1;
833
843
  const end = input["end"] === undefined && Number.isSafeInteger(first) ? (first as number) + READ_DEFAULT_LINES - 1 : input["end"];
834
- const selected = readBounds(lines.length, input["start"], end);
835
- if (!selected.ok) return failure("invalid_input", selected.message, READ_ATTEMPTED);
836
- const { start } = selected;
837
- const slice = lines.slice(start - 1, selected.end);
844
+ const bounds = readBounds(lines.length, input["start"], end);
845
+ if (!bounds.ok) return failure("invalid_input", bounds.message, READ_ATTEMPTED);
846
+ const { start } = bounds;
838
847
  const version = sha256(bytes);
848
+ // The selection is what fits whole, so the receipt's range is the range shown.
849
+ const sized = (count: number, handle: string) =>
850
+ `${readNotice(start, start + count - 1, lines.length, count < bounds.end - start + 1 ? ctx.maxOutputBytes : undefined)}\nsha256: ${version}\nread_handle: ${handle}\n`;
851
+ const shown = linesWithin(count => sized(count, "0".repeat(64)), lines.slice(start - 1, bounds.end), start, ctx.maxOutputBytes, undefined, adviceTools(ctx));
852
+ const selected = { end: start + shown - 1 };
853
+ const slice = lines.slice(start - 1, selected.end);
839
854
  const store = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
840
855
  async function* one(value: Uint8Array): AsyncGenerator<Uint8Array> { yield value; }
841
856
  // Archive actual bytes before either presentation cut. Re-reading the current file is not recovery.
@@ -844,7 +859,7 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
844
859
  const selection = { start, end: selected.end, total_lines: lines.length };
845
860
  const descriptor: FileReadDescriptor = { kind: "file_read", version: 1, source, selection };
846
861
  const handle = await archiveObservation(store, one(new TextEncoder().encode(JSON.stringify(descriptor))), 0);
847
- const header = `${selected.notice}\nsha256: ${version}\nread_handle: ${handle.ref.sha256}\n`;
862
+ const header = sized(shown, handle.ref.sha256);
848
863
  const full = new TextEncoder().encode(readObservationText(header, slice, start));
849
864
  const observation = await archiveObservation(store, one(full), 0);
850
865
  const preview = previewReadObservation(header, slice, start, observation.ref.sha256, ctx.maxOutputBytes, undefined, adviceTools(ctx));
@@ -1048,19 +1063,6 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
1048
1063
  };
1049
1064
  }
1050
1065
 
1051
- /**
1052
- * Derived output a command's fingerprint leaves out.
1053
- *
1054
- * The fingerprint keys verification evidence to the source state it ran
1055
- * against; build products and caches change on every build without the
1056
- * sources changing, and walking them (a Rust `target/` or a virtualenv) costs
1057
- * seconds after every command. Snapshots and rewind keep DEFAULT_IGNORE --
1058
- * whether to restore derived files is their decision, not this one.
1059
- */
1060
- const FINGERPRINT_IGNORE: readonly string[] = [...DEFAULT_IGNORE,
1061
- "target", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox",
1062
- ".gradle", ".next", ".turbo", ".cache"];
1063
-
1064
1066
  async function commitEdit(ctx: ToolContext, absolute: string, before: Awaited<ReturnType<typeof readEditableFile>>, after: Uint8Array): Promise<void> {
1065
1067
  const path = relative(ctx.root, absolute);
1066
1068
  if (ctx.workspace && path !== ".." && !path.startsWith(`..${sep}`)) await ctx.workspace.edit(path, before, after, ctx.signal);
@@ -1068,16 +1070,10 @@ async function commitEdit(ctx: ToolContext, absolute: string, before: Awaited<Re
1068
1070
  }
1069
1071
 
1070
1072
  async function commandFingerprint(ctx: ToolContext): Promise<{ workspace_sha256?: string; workspace_observed?: boolean; workspace_scope?: "source-v1" }> {
1071
- if (ctx.legacyWorkspace) {
1072
- const observed = await scanWorkspace(ctx.root, FINGERPRINT_IGNORE);
1073
- return observed.ok ? { workspace_sha256: workspaceVersion(observed.files) } : {};
1074
- }
1075
1073
  const observed = await observeWorkspace(ctx.root, ctx.signal, ctx.ownedStatePaths);
1076
1074
  return observed.ok ? { workspace_sha256: observed.fingerprint, workspace_observed: true, workspace_scope: "source-v1" } : { workspace_observed: false, workspace_scope: "source-v1" };
1077
1075
  }
1078
1076
 
1079
- const clampYield = (value: unknown) =>
1080
- Math.min(MAX_YIELD_MS, Math.max(MIN_YIELD_MS, typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_YIELD_MS));
1081
1077
 
1082
1078
  async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
1083
1079
  const command = String(input["cmd"] ?? "");
@@ -1093,7 +1089,8 @@ async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promi
1093
1089
  ...(artifactObserver ? { artifactObserver } : {}),
1094
1090
  ...(ctx.execOwner === undefined ? {} : { owner: ctx.execOwner }) });
1095
1091
  if ("error" in session) return failure("execution_failed", session.error);
1096
- return sessionResult(ctx, command, timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), "", ctx.signal));
1092
+ const wait = yieldWait(input["yield_time_ms"]);
1093
+ return sessionResult(ctx, command, timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, wait.ms, "", ctx.signal), wait.note);
1097
1094
  }
1098
1095
 
1099
1096
  async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
@@ -1111,8 +1108,9 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
1111
1108
  data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
1112
1109
  }
1113
1110
  const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
1114
- return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), chars, ctx.signal,
1115
- ctx.processWait && chars.length === 0 ? { wake: ctx.processWait.wake(), onWaiting: active => ctx.onProcessWait?.(id, active) } : undefined));
1111
+ const wait = yieldWait(input["yield_time_ms"]);
1112
+ return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, wait.ms, chars, ctx.signal,
1113
+ ctx.processWait && chars.length === 0 ? { wake: ctx.processWait.wake(), onWaiting: active => ctx.onProcessWait?.(id, active) } : undefined), wait.note);
1116
1114
  }
1117
1115
 
1118
1116
  /** A successful historical query proves only that the receipt was found, never a new command exit. */
@@ -1133,15 +1131,16 @@ function receiptResult(ctx: ToolContext, receipt: ProcessReceipt, chars: string)
1133
1131
  * carries no effect; only an observed exit does, exactly as bash's would (D11).
1134
1132
  */
1135
1133
  export async function sessionResult(ctx: ToolContext, command: string, timeoutMs: number,
1136
- taken: Awaited<ReturnType<typeof pollSession>>): Promise<ToolResult> {
1134
+ taken: Awaited<ReturnType<typeof pollSession>>, waitNote = ""): Promise<ToolResult> {
1137
1135
  const { stored, exit } = taken;
1138
1136
  const id = taken.receipt.session_id;
1139
1137
  if (taken.receipt.replayed) return receiptResult(ctx, taken.receipt, "");
1140
- const header = exit === null
1138
+ const shown = exit === null
1141
1139
  ? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed; ` + (adviceTools(ctx).includes("write_stdin")
1142
1140
  ? `poll or send input with write_stdin(session_id=${id})`
1143
1141
  : "process-control capability unavailable; execution remains unfinished; do not replay")
1144
1142
  : commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed`;
1143
+ const header = shown + waitNote;
1145
1144
  const artifactSummary = commandArtifactSummary(taken.receipt.artifacts);
1146
1145
  const content = header + artifactSummary + "\n" + observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx));
1147
1146
  const warnings = taken.receipt.capture === "partial" ? [failureDiagnostic("unknown_effect", "output capture is partial; full_output contains only captured bytes", UNKNOWN_EFFECT)] : [];
@@ -1338,7 +1337,7 @@ export async function gateTool(
1338
1337
  const definition = toolDefinition(name, ctx);
1339
1338
  if (ctx.workflowMode === "plan" && definition && definition.access !== "read" &&
1340
1339
  !(definition.access === "state" && (name === "note" || name === "question" && ctx.questionSlot)))
1341
- return failure("permission_denied", `${name} is unavailable in Plan mode; select Build at an idle checkpoint.`);
1340
+ return failure("permission_denied", `${name} is unavailable in Plan mode; ${ctx.planHint ?? "select Build at an idle checkpoint."}`);
1342
1341
  input = repairToolInput(name, input);
1343
1342
  let invalid: string | null = null;
1344
1343
  if (definition?.validateInput) {
@@ -1434,11 +1433,13 @@ function observerValue<T>(value: T): T {
1434
1433
  export async function prepareNormalizedTool(ctx: ToolContext, name: string, input: unknown,
1435
1434
  prepareAction?: () => Promise<() => Promise<ToolResult>>): Promise<ToolResult | (() => Promise<ToolResult>)> {
1436
1435
  const scoped = <T>(action: () => T): T => ctx.extensionHooks?.scope ? ctx.extensionHooks.scope(ctx, action) : action();
1436
+ let attached: ToolResult["instructions"];
1437
1437
  try {
1438
1438
  const gated = await gateTool(ctx, name, input);
1439
1439
  if (gated) return scopeToolAdvice(ctx, finishToolResult(gated));
1440
1440
  const instructions = await ctx.instructionCheck?.(ctx, name, input);
1441
- if (instructions) return scopeToolAdvice(ctx, finishToolResult(instructions));
1441
+ if (instructions?.kind === "block") return scopeToolAdvice(ctx, finishToolResult(instructions.result));
1442
+ if (instructions?.kind === "attach") attached = instructions.instructions;
1442
1443
  const veto = await scoped(() => ctx.extensionHooks?.before(name, observerValue(input), ctx.callId, ctx.parentCallId));
1443
1444
  await ctx.drainNested?.();
1444
1445
  if (veto) return scopeToolAdvice(ctx, finishToolResult(failure("permission_denied", veto)));
@@ -1488,7 +1489,8 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
1488
1489
  else observed = finishToolResult(caughtFailure(error, UNKNOWN_EFFECT));
1489
1490
  }
1490
1491
  await Promise.all(updates);
1491
- return scopeToolAdvice(ctx, warnings.length ? { ...observed, warnings: [...(observed.warnings ?? []), ...warnings] } : observed);
1492
+ const result = warnings.length ? { ...observed, warnings: [...(observed.warnings ?? []), ...warnings] } : observed;
1493
+ return scopeToolAdvice(ctx, attached ? { ...result, instructions: attached } : result);
1492
1494
  };
1493
1495
  }
1494
1496
 
@@ -88,6 +88,34 @@ export function totalUsage(samples: readonly UsageSample[]): UsageTotals {
88
88
  return t;
89
89
  }
90
90
 
91
+ /**
92
+ * Below this a request's speed is not shown: a response that returns at once
93
+ * (a test double, a local cache) would divide into thousands of tokens per
94
+ * second. Assumed, not measured.
95
+ */
96
+ export const SPEED_MIN_MS = 250;
97
+
98
+ /**
99
+ * Output tokens per second over a set of model requests, or undefined when
100
+ * none can be measured (feat-sidebar-goal-and-speed). OpenCode's built-in
101
+ * turnTokensPerSecond uses the same definition: output over the time the
102
+ * requests took, each request counted once. Not output over the time since
103
+ * the first visible output -- output counts include reasoning that providers
104
+ * do not always stream (OpenAI summaries arrive late, Anthropic can omit
105
+ * them), so that would keep reasoning tokens and drop reasoning time, and
106
+ * overstate. Whole request time never overstates; an overloaded upstream
107
+ * reads slow, which is what the user is waiting on.
108
+ */
109
+ export function generationSpeed(samples: readonly UsageSample[]): number | undefined {
110
+ let tokens = 0, ms = 0;
111
+ for (const sample of samples) {
112
+ if (sample.output_tokens <= 0 || !(sample.ms >= SPEED_MIN_MS)) continue;
113
+ tokens += sample.output_tokens;
114
+ ms += sample.ms;
115
+ }
116
+ return ms > 0 ? tokens / (ms / 1000) : undefined;
117
+ }
118
+
91
119
  /**
92
120
  * Cost in equivalent base-input tokens, the unit appendix A's model uses.
93
121
  *
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The one place xeno's version is known (S.4: a release edits package.json and
3
+ * nothing else). It is sent as the User-Agent (D18: xeno names itself
4
+ * honestly) and compared against npm for update notices.
5
+ *
6
+ * Read at load rather than imported: `bun run check` requires every import to
7
+ * be a literal relative `.ts` path. package.json ships in every install, so
8
+ * this resolves the same way from a checkout and from a global install.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+
12
+ export const XENO_VERSION: string = (JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }).version;
@@ -100,9 +100,40 @@ export class WorkspaceLifecycle {
100
100
  source: "git", phase: "unavailable", changes: {}, reason: "process ended without an observed checkpoint boundary" });
101
101
  this.pending.clear();
102
102
  }
103
+ /**
104
+ * A full source snapshot through the private Git checkpoint store, or null
105
+ * where Git checkpoints cannot run (not a Git working directory, over
106
+ * budget). Never touches the user's HEAD, index or stash (P.3).
107
+ */
108
+ async snapshot(signal?: AbortSignal): Promise<GitCheckpoint | null> {
109
+ try { return await this.git.capture(signal); } catch { return null; }
110
+ }
111
+
112
+ /** What changed between two snapshots, with both versions' bytes in the blob store. */
113
+ async snapshotChanges(before: GitCheckpoint, after: GitCheckpoint, signal?: AbortSignal): Promise<FileChanges> {
114
+ return this.git.changes(before, after, this.blobs, signal);
115
+ }
116
+
117
+ /**
118
+ * Records the workspace as a goal request begins, always: the state a
119
+ * counter-check must rebuild exactly before it may run (feat-counter-check).
120
+ */
121
+ async baseline(signal?: AbortSignal): Promise<void> {
122
+ const observation = await observeWorkspace(this.root, signal, this.ownedPaths);
123
+ const fingerprint = observation.ok ? observation.fingerprint : undefined;
124
+ await this.record({ t: "workspace_observation", scope: "source-v1", baseline: true,
125
+ changed: !observation.ok || this.fingerprint !== undefined && fingerprint !== this.fingerprint,
126
+ ...(observation.ok ? { fingerprint: observation.fingerprint } : { reason: observation.reason }) });
127
+ this.fingerprint = fingerprint;
128
+ }
129
+
103
130
  async observe(previous?: string, signal?: AbortSignal): Promise<void> {
104
131
  const observation = await observeWorkspace(this.root, signal, this.ownedPaths);
105
132
  const fingerprint = observation.ok ? observation.fingerprint : undefined;
133
+ // S.1: an observation identical to the last known fingerprint adds no fact.
134
+ // Failures, a first look without a baseline, and changes are still recorded.
135
+ const known = previous ?? this.fingerprint;
136
+ if (fingerprint !== undefined && known !== undefined && fingerprint === known) { this.fingerprint = fingerprint; return; }
106
137
  await this.record({ t: "workspace_observation", scope: "source-v1", changed: !observation.ok || (previous ?? this.fingerprint) !== undefined && fingerprint !== (previous ?? this.fingerprint),
107
138
  ...(observation.ok ? { fingerprint: observation.fingerprint } : { reason: observation.reason }) });
108
139
  this.fingerprint = fingerprint;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Reading a blind check directory (feat-blind-checks). It must sit outside
3
+ * the workspace -- a check inside it is a file the model can open -- and is
4
+ * frozen into the session with every file's digest, so a resumed session
5
+ * runs exactly the checks it started with.
6
+ */
7
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
8
+ import { isAbsolute, join, relative, resolve } from "node:path";
9
+ import { BLIND_BYTES_LIMIT, BLIND_FILE_LIMIT, parseBlindManifest, type BlindChecks } from "../core/blind-checks.ts";
10
+ import { blobAddress } from "./blobs.ts";
11
+
12
+ export async function loadBlindChecks(dir: string, workspace: string): Promise<BlindChecks> {
13
+ const given = resolve(workspace, dir), root = await realpath(given), work = await realpath(workspace);
14
+ const inside = relative(work, root);
15
+ if (inside === "" || !inside.startsWith("..") && !isAbsolute(inside)) throw Error("--blind-checks must be outside the workspace, where the model cannot read it");
16
+ const files: Record<string, string> = {};
17
+ let bytes = 0;
18
+ const walk = async (path: string): Promise<void> => {
19
+ for (const entry of await readdir(path, { withFileTypes: true })) {
20
+ const full = join(path, entry.name), rel = relative(root, full);
21
+ if (entry.isDirectory()) { await walk(full); continue; }
22
+ if (!entry.isFile()) throw Error(`blind check ${rel} is not a regular file`);
23
+ if (Object.keys(files).length >= BLIND_FILE_LIMIT) throw Error(`--blind-checks holds more than ${BLIND_FILE_LIMIT} files`);
24
+ bytes += (await stat(full)).size;
25
+ if (bytes > BLIND_BYTES_LIMIT) throw Error(`--blind-checks holds more than ${BLIND_BYTES_LIMIT} bytes`);
26
+ files[rel] = blobAddress(await readFile(full));
27
+ }
28
+ };
29
+ await walk(root);
30
+ const manifest = parseBlindManifest(JSON.parse(await readFile(join(root, "blind.json"), "utf8")));
31
+ return { dir: root, aliases: given === root ? [] : [given], manifest, files };
32
+ }
@@ -1,6 +1,6 @@
1
1
  /** File ancestry is independent of model-context ancestry (P.3). */
2
2
  import { readNodeHistory, type RestoreTarget } from "./restore.ts";
3
- import { planFileTransition, type CheckpointEvent, type FileVersion, type FileChanges } from "../core/checkpoints.ts";
3
+ import { planFileTransition, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
4
4
 
5
5
  export async function checkpointHistory(dir: string, target: RestoreTarget, visited = new Set<string>()): Promise<CheckpointEvent[]> {
6
6
  const position = typeof target === "number" ? { node: target } : target;
@@ -10,7 +10,6 @@ export async function checkpointHistory(dir: string, target: RestoreTarget, visi
10
10
  const history = await readNodeHistory(dir, position.node);
11
11
  if (!history.ok) throw new Error(history.error);
12
12
  let checkpoints: CheckpointEvent[] = [];
13
- const legacy = new Map<string, FileVersion>();
14
13
  for (const node of history.nodes) {
15
14
  let events = node.events;
16
15
  if (node.node === position.node && position.atEvent !== undefined) {
@@ -23,16 +22,6 @@ export async function checkpointHistory(dir: string, target: RestoreTarget, visi
23
22
  checkpoints = await checkpointHistory(dir, { node: event.files_from.node,
24
23
  ...(event.files_from.at_event ? { atEvent: event.files_from.at_event } : {}) }, next);
25
24
  } else if (event.t === "checkpoint") checkpoints.push(event);
26
- else if (event.t === "snapshot") {
27
- // Interpret historical full snapshots without rewriting them or scanning today's disk.
28
- const changes: FileChanges = Object.create(null) as FileChanges;
29
- for (const [path, sha256] of Object.entries({ ...event.added, ...event.changed })) {
30
- changes[path] = { before: legacy.get(path) ?? null, after: { sha256, mode: 0o644 } };
31
- legacy.set(path, changes[path]!.after);
32
- }
33
- for (const path of event.removed) { changes[path] = { before: legacy.get(path) ?? null, after: null }; legacy.delete(path); }
34
- checkpoints.push({ t: "checkpoint", id: event.id, at: event.at, operation: event.id, source: "git", phase: "applied", changes });
35
- }
36
25
  }
37
26
  }
38
27
  return checkpoints;