@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/docs/install.md +25 -0
  3. package/package.json +1 -1
  4. package/src/apps/cli.ts +4 -1
  5. package/src/apps/shell.ts +13 -3
  6. package/src/apps/startup.ts +31 -6
  7. package/src/apps/ui/app.ts +32 -14
  8. package/src/apps/ui/project.ts +1 -1
  9. package/src/apps/ui/standalone.ts +4 -2
  10. package/src/apps/ui/surface.ts +2 -0
  11. package/src/core/activity-status.ts +27 -3
  12. package/src/core/command-contract.ts +121 -5
  13. package/src/core/completion-review.ts +1 -1
  14. package/src/core/derive.ts +9 -1
  15. package/src/core/environment.ts +7 -0
  16. package/src/core/event-validation.ts +19 -3
  17. package/src/core/events.ts +38 -2
  18. package/src/core/evidence.ts +149 -7
  19. package/src/core/export.ts +2 -0
  20. package/src/core/external-effects.ts +488 -0
  21. package/src/core/handoff.ts +24 -3
  22. package/src/core/history-query.ts +1 -1
  23. package/src/core/history.ts +131 -21
  24. package/src/core/observation-view.ts +24 -0
  25. package/src/core/path-suggestions.ts +59 -0
  26. package/src/core/permissions.ts +64 -3
  27. package/src/core/prompt-policy.ts +11 -2
  28. package/src/core/regex-dialect.ts +25 -0
  29. package/src/core/ripgrep-release.ts +64 -0
  30. package/src/core/secrets.ts +188 -0
  31. package/src/core/stall.ts +63 -18
  32. package/src/core/stop-report.ts +46 -4
  33. package/src/core/tool-contract.ts +2 -0
  34. package/src/core/tool-failure.ts +25 -4
  35. package/src/core/transcript.ts +3 -0
  36. package/src/core/update-check.ts +2 -1
  37. package/src/core/work-state.ts +7 -2
  38. package/src/core/working-set.ts +34 -6
  39. package/src/runtime/connection-reuse.ts +40 -0
  40. package/src/runtime/exec-sessions.ts +69 -19
  41. package/src/runtime/history-tools.ts +12 -2
  42. package/src/runtime/loop.ts +196 -26
  43. package/src/runtime/open-image.ts +42 -0
  44. package/src/runtime/openai-responses.ts +15 -7
  45. package/src/runtime/openai.ts +15 -7
  46. package/src/runtime/permission-resources.ts +11 -4
  47. package/src/runtime/pi-provider.ts +34 -9
  48. package/src/runtime/provider.ts +19 -8
  49. package/src/runtime/retry.ts +152 -9
  50. package/src/runtime/secrets.ts +79 -0
  51. package/src/runtime/session-control.ts +9 -1
  52. package/src/runtime/stream-idle.ts +80 -0
  53. package/src/runtime/tools.ts +139 -31
  54. package/src/runtime/turn-effects.ts +31 -0
  55. package/src/store/command.ts +3 -1
  56. package/src/store/context-files.ts +5 -1
  57. package/src/store/file-checkpoints.ts +6 -3
  58. package/src/store/log.ts +20 -0
  59. package/src/store/replay.ts +4 -3
  60. package/src/store/search.ts +9 -5
  61. package/src/store/secrets.ts +44 -0
  62. package/src/store/sessions.ts +49 -6
  63. package/src/store/tool-binaries.ts +90 -0
  64. package/src/store/worktree.ts +55 -10
  65. package/src/tui/ui/image-size.ts +6 -2
  66. package/src/tui/ui/items.ts +3 -1
  67. package/src/tui/ui/pictures.ts +14 -1
  68. package/src/tui/ui/screen.ts +6 -3
  69. 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
+ }
@@ -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; effective range 100-30000, values outside are clamped; agent empty waits are owned until an observation changes" },
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 already on PATH, otherwise a TypeScript walk. " +
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
- content: header + artifactSummary + "\n" + observationView(captured,
1050
- ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, captured.prefixBytes, adviceTools(ctx)),
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
- return sessionResult(ctx, command, timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, wait.ms, "", ctx.signal), wait.note);
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
- if (closed) return receiptResult(ctx, { ...closed.receipt, replayed: true }, chars);
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), chars);
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 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);
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 header = shown + waitNote;
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
- ...commandCompletion(exit, taken.stopped),
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 decision = decideWithSessionRules(ctx.rules, ctx.sessionRules ?? [], request);
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 permissionResourceAction(ctx.root, path, ctx.rules, ctx.sessionRules ?? [], name);
1358
- if (resource === "deny") { decision = "deny"; break; }
1359
- if (resource === "ask" && decision === "allow") decision = "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, input);
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, input, execute, ctx.signal) : execute();
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
+ }
@@ -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
- if (!isInside(absolute, root)) throw new Error(`instruction target escapes workspace: ${path}`);
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
- rel.split(sep).some(part => part === ".git" || part === ".xeno")) throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path}`, NOT_STARTED);
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));
@@ -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
- if (policy.working_set_version === 2) {
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
  }