@visual-z/xeno 0.7.2 → 0.8.0

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 (108) hide show
  1. package/CHANGELOG.md +43 -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 +38 -6
  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.ts +200 -0
  35. package/src/core/history.ts +1 -11
  36. package/src/core/json-event.ts +0 -6
  37. package/src/core/outcome.ts +0 -1
  38. package/src/core/program-status.ts +11 -2
  39. package/src/core/progress.ts +14 -8
  40. package/src/core/project.ts +6 -16
  41. package/src/core/read-bounds.ts +11 -2
  42. package/src/core/read-observation.ts +24 -0
  43. package/src/core/request-budget.ts +13 -3
  44. package/src/core/request-state.ts +178 -19
  45. package/src/core/response-recovery.ts +2 -1
  46. package/src/core/session-entry.ts +8 -3
  47. package/src/core/session-exit.ts +19 -3
  48. package/src/core/session-format.ts +169 -0
  49. package/src/core/settings.ts +7 -27
  50. package/src/core/stall.ts +30 -7
  51. package/src/core/stop-report.ts +4 -3
  52. package/src/core/transcript.ts +22 -2
  53. package/src/runtime/blind-check.ts +36 -0
  54. package/src/runtime/counter-check.ts +74 -0
  55. package/src/runtime/exec-sessions.ts +11 -0
  56. package/src/runtime/foundation.ts +46 -8
  57. package/src/runtime/loop.ts +239 -430
  58. package/src/runtime/oauth.ts +1 -3
  59. package/src/runtime/provider-request.ts +2 -1
  60. package/src/runtime/runtime-target.ts +2 -2
  61. package/src/runtime/session-control.ts +4 -4
  62. package/src/runtime/tools.ts +44 -42
  63. package/src/runtime/version.ts +12 -0
  64. package/src/runtime/workspace-lifecycle.ts +31 -0
  65. package/src/store/blind-checks.ts +32 -0
  66. package/src/store/checkpoint-history.ts +1 -12
  67. package/src/store/file-index.ts +88 -7
  68. package/src/store/log.ts +25 -27
  69. package/src/store/recovery.ts +1 -3
  70. package/src/store/replay.ts +2 -2
  71. package/src/store/restore.ts +10 -202
  72. package/src/store/search.ts +7 -5
  73. package/src/store/session-archive.ts +0 -1
  74. package/src/store/sessions.ts +31 -33
  75. package/src/store/workspace-clone.ts +42 -0
  76. package/src/store/workspace.ts +10 -157
  77. package/src/tui/ui/status.ts +6 -2
  78. package/src/core/benchmark.ts +0 -195
  79. package/src/core/evaluation-coverage.ts +0 -74
  80. package/src/core/experiment.ts +0 -173
  81. package/src/core/harness-comparison.ts +0 -90
  82. package/src/core/harness-diagnostics.ts +0 -206
  83. package/src/core/native-eval.ts +0 -25
  84. package/src/core/policy-evaluation.ts +0 -55
  85. package/src/core/query-eval-contract.ts +0 -14
  86. package/src/core/recall-analysis.ts +0 -90
  87. package/src/core/recall-trial.ts +0 -153
  88. package/src/core/recovery-corpus.ts +0 -17
  89. package/src/core/recovery-evaluation.ts +0 -60
  90. package/src/core/tool-mode-protocol.ts +0 -25
  91. package/src/core/trial-lifecycle.ts +0 -14
  92. package/src/core/window-trial.ts +0 -65
  93. package/src/index.ts +0 -10
  94. package/src/runtime/benchmark.ts +0 -187
  95. package/src/runtime/harness-comparison.ts +0 -376
  96. package/src/runtime/harness-eval.ts +0 -462
  97. package/src/runtime/native-eval-fixtures.ts +0 -33
  98. package/src/runtime/native-eval.ts +0 -146
  99. package/src/runtime/query-eval-verifier.ts +0 -28
  100. package/src/runtime/recall-trial.ts +0 -226
  101. package/src/runtime/recovery-coverage.ts +0 -71
  102. package/src/runtime/recovery-evidence.ts +0 -76
  103. package/src/runtime/recovery-fixtures.ts +0 -116
  104. package/src/runtime/tool-mode-eval.ts +0 -146
  105. package/src/runtime/trial-control.ts +0 -45
  106. package/src/runtime/window-trial.ts +0 -141
  107. package/src/store/git-branch.ts +0 -35
  108. 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
@@ -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
 
@@ -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
 
@@ -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;
@@ -1,22 +1,31 @@
1
1
  /**
2
- * Candidate paths for @-file expansion.
2
+ * Candidate paths for @-file expansion and the walk search engine.
3
3
  *
4
4
  * A glob on every keystroke is a stall in a large repo (the card's rejected
5
5
  * alternative). Scan once, cache for the session. The snapshot scanner is a
6
6
  * different job -- it hashes contents and refuses to truncate -- so this walk
7
7
  * is its own function rather than a mode flag on scanWorkspace.
8
+ *
9
+ * Inside a Git work tree the candidates are Git's own listing: tracked plus
10
+ * untracked files that are not ignored (S.6 item 4). Claude Code's Grep,
11
+ * OpenCode (`rg --files`) and Pi (`fd`) follow .gitignore the same way; a
12
+ * walk that ignores it let an ignored worktree's build output fill the whole
13
+ * budget before the product's directories were reached
14
+ * (research-file-index-truncation).
8
15
  */
9
16
 
10
- import { readdir } from "node:fs/promises";
17
+ import { lstat, readdir } from "node:fs/promises";
11
18
  import type { Dirent } from "node:fs";
12
- import { join, relative, sep } from "node:path";
19
+ import { dirname, join, relative, sep } from "node:path";
13
20
  import { DEFAULT_IGNORE } from "./workspace.ts";
21
+ import { gitBytes } from "./workspace-observation.ts";
14
22
 
15
23
  /**
16
- * Cap on indexed paths. Assumed, not measured: enough to fuzzy-search a
17
- * large repo; past this the picker is hiding a missing filter, not serving
18
- * one. Hitting it truncates and says so -- a silent cut would make @ miss
19
- * files that exist.
24
+ * Cap on paths from an unfiltered directory walk, outside Git. Assumed, not
25
+ * measured: enough to fuzzy-search a large tree; past this the walk is hiding
26
+ * a missing filter, not serving one. Hitting it truncates and says so -- a
27
+ * silent cut would make @ miss files that exist. A Git listing is already the
28
+ * filtered candidate set and is not capped unless the caller asks.
20
29
  */
21
30
  export const MAX_INDEX_ENTRIES = 20_000;
22
31
 
@@ -38,7 +47,79 @@ function isIgnored(rel: string, ignore: readonly string[]): boolean {
38
47
  return ignore.some((i) => parts.includes(i));
39
48
  }
40
49
 
50
+ /** Git judges its own ignore rules; an error or a path outside any work tree is not ignored. */
51
+ export async function gitIgnored(path: string, signal?: AbortSignal): Promise<boolean> {
52
+ try {
53
+ const info = await lstat(path);
54
+ await gitBytes(info.isDirectory() ? path : dirname(path), ["check-ignore", "-q", "--", path], undefined, signal);
55
+ return true;
56
+ } catch { return false; }
57
+ }
58
+
59
+ /** Git's listing under `dir`, relative to it, or null when `dir` is not a work tree that Git lists. */
60
+ async function gitListing(dir: string, signal?: AbortSignal): Promise<string[] | null> {
61
+ try {
62
+ if (new TextDecoder().decode(await gitBytes(dir, ["rev-parse", "--is-inside-work-tree"], undefined, signal)).trim() !== "true") return null;
63
+ // Naming an ignored directory is an explicit request: walk it instead.
64
+ if (await gitIgnored(dir, signal)) return null;
65
+ const listed = new TextDecoder().decode(await gitBytes(dir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], undefined, signal));
66
+ // An untracked nested repository is listed once, as "name/".
67
+ return [...new Set(listed.split("\0").filter(Boolean).map(path => path.replace(/\/$/, "")))].sort();
68
+ } catch { return null; }
69
+ }
70
+
71
+ /** Assumed: enough concurrent lstat calls to hide latency without exhausting descriptors. */
72
+ const LSTAT_BATCH = 256;
73
+
74
+ async function indexGitListing(root: string, listed: readonly string[], opts: IndexOptions): Promise<FileIndex> {
75
+ const ignore = opts.ignore ?? DEFAULT_IGNORE;
76
+ const max = opts.maxEntries ?? Infinity;
77
+ const paths: string[] = [], issues: { code: string; path?: string }[] = [];
78
+ // The index can name a path under a directory since replaced by a symlink;
79
+ // following it would read outside the tree, so every ancestor is checked once.
80
+ const directories = new Map<string, Promise<boolean>>([["", Promise.resolve(true)]]);
81
+ const plainDirectory = (rel: string): Promise<boolean> => {
82
+ let known = directories.get(rel);
83
+ if (known === undefined) {
84
+ const parent = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
85
+ known = plainDirectory(parent).then(async ok => ok && await lstat(join(root, rel)).then(info => info.isDirectory() && !info.isSymbolicLink(), () => false));
86
+ directories.set(rel, known);
87
+ }
88
+ return known;
89
+ };
90
+ for (let offset = 0; offset < listed.length; offset += LSTAT_BATCH) {
91
+ if (opts.signal?.aborted || opts.deadline !== undefined && performance.now() >= opts.deadline) {
92
+ issues.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); return { paths, truncated: true, issues };
93
+ }
94
+ const batch = listed.slice(offset, offset + LSTAT_BATCH).filter(rel => !isIgnored(rel.split("/").join(sep), ignore));
95
+ const kinds = await Promise.all(batch.map(async rel => {
96
+ const parent = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
97
+ if (!await plainDirectory(parent)) return "skip";
98
+ // A tracked path deleted from the work tree is not a candidate.
99
+ const info = await lstat(join(root, rel)).catch(() => null);
100
+ return info === null || info.isSymbolicLink() ? "skip" : info.isDirectory() ? "nested" : info.isFile() ? "file" : "skip";
101
+ }));
102
+ for (let i = 0; i < batch.length; i++) {
103
+ const rel = batch[i]!;
104
+ if (kinds[i] === "nested") {
105
+ // A submodule or nested repository: its own listing, or a walk if it has none.
106
+ const nested = await buildFileIndex(join(root, rel), { ...opts, ...(max === Infinity ? {} : { maxEntries: max - paths.length }) });
107
+ paths.push(...nested.paths.map(path => `${rel}/${path}`));
108
+ issues.push(...(nested.issues ?? []).map(issue => issue.path === undefined ? issue : { ...issue, path: `${rel}/${issue.path}` }));
109
+ if (nested.truncated) return { paths, truncated: true, issues };
110
+ continue;
111
+ }
112
+ if (kinds[i] !== "file") continue;
113
+ if (paths.length >= max) { issues.push({ code: "file_index_limit" }); return { paths, truncated: true, issues }; }
114
+ paths.push(rel);
115
+ }
116
+ }
117
+ return { paths, truncated: false, ...(issues.length ? { issues } : {}) };
118
+ }
119
+
41
120
  export async function buildFileIndex(root: string, opts: IndexOptions = {}): Promise<FileIndex> {
121
+ const listed = await gitListing(root, opts.signal);
122
+ if (listed !== null) return indexGitListing(root, listed, opts);
42
123
  const ignore = opts.ignore ?? DEFAULT_IGNORE;
43
124
  const max = opts.maxEntries ?? MAX_INDEX_ENTRIES;
44
125
  const paths: string[] = [];