@visual-z/xeno 0.8.2 → 0.8.4

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 (60) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/package.json +1 -1
  3. package/src/apps/review.ts +59 -59
  4. package/src/apps/shell.ts +20 -5
  5. package/src/apps/startup.ts +4 -4
  6. package/src/apps/ui/app.ts +143 -20
  7. package/src/apps/ui/project.ts +1 -1
  8. package/src/apps/ui/standalone.ts +4 -2
  9. package/src/apps/ui/surface.ts +2 -0
  10. package/src/core/activity-status.ts +17 -2
  11. package/src/core/command-contract.ts +58 -16
  12. package/src/core/completion-review.ts +1 -1
  13. package/src/core/derive.ts +9 -1
  14. package/src/core/event-validation.ts +19 -3
  15. package/src/core/events.ts +38 -2
  16. package/src/core/evidence.ts +149 -7
  17. package/src/core/export.ts +2 -0
  18. package/src/core/external-effects.ts +488 -0
  19. package/src/core/handoff.ts +24 -3
  20. package/src/core/history-query.ts +1 -1
  21. package/src/core/history.ts +124 -20
  22. package/src/core/observation-view.ts +24 -0
  23. package/src/core/path-suggestions.ts +59 -0
  24. package/src/core/permissions.ts +60 -0
  25. package/src/core/prompt-policy.ts +4 -2
  26. package/src/core/secrets.ts +188 -0
  27. package/src/core/stop-report.ts +25 -3
  28. package/src/core/tool-failure.ts +11 -3
  29. package/src/core/transcript.ts +3 -0
  30. package/src/core/work-state.ts +7 -2
  31. package/src/core/working-set.ts +34 -6
  32. package/src/runtime/connection-reuse.ts +40 -0
  33. package/src/runtime/exec-sessions.ts +66 -18
  34. package/src/runtime/history-tools.ts +12 -2
  35. package/src/runtime/loop.ts +264 -73
  36. package/src/runtime/open-image.ts +42 -0
  37. package/src/runtime/openai-responses.ts +15 -7
  38. package/src/runtime/openai.ts +15 -7
  39. package/src/runtime/permission-resources.ts +11 -4
  40. package/src/runtime/pi-provider.ts +34 -9
  41. package/src/runtime/provider.ts +19 -8
  42. package/src/runtime/retry.ts +152 -9
  43. package/src/runtime/secrets.ts +79 -0
  44. package/src/runtime/session-control.ts +9 -1
  45. package/src/runtime/stream-idle.ts +80 -0
  46. package/src/runtime/tools.ts +97 -27
  47. package/src/runtime/turn-effects.ts +31 -0
  48. package/src/store/context-files.ts +5 -1
  49. package/src/store/file-checkpoints.ts +1 -1
  50. package/src/store/log.ts +20 -0
  51. package/src/store/replay.ts +4 -3
  52. package/src/store/secrets.ts +44 -0
  53. package/src/store/sessions.ts +49 -6
  54. package/src/tui/ui/branch-panel.ts +47 -0
  55. package/src/tui/ui/composer.ts +27 -28
  56. package/src/tui/ui/image-size.ts +6 -2
  57. package/src/tui/ui/items.ts +3 -1
  58. package/src/tui/ui/pictures.ts +14 -1
  59. package/src/tui/ui/prompt-history.ts +60 -0
  60. package/src/tui/ui/screen.ts +34 -3
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The session's live view of its secret vault (S.9). Rules and the inverse
3
+ * live in core/secrets.ts; this owns the mutable entries, their durable
4
+ * append, and the walk over an event's fields.
5
+ *
6
+ * One keeper per session tree: a branch shares its parent's, because both
7
+ * write the same log and a placeholder number must mean one value in it.
8
+ */
9
+ import { expandText, isSolicitedSecret, mapStrings, placeholder, redactText, type SecretEntry } from "../core/secrets.ts";
10
+ import type { LogEvent } from "../core/events.ts";
11
+ import { appendVault, readVault } from "../store/secrets.ts";
12
+
13
+ // Fields whose bytes a provider or the store verifies: rewriting them breaks
14
+ // replay (signed thinking, raw provider blocks) or identity, and none of them
15
+ // carries text a person typed or a tool printed.
16
+ const VERIFIED_FIELDS = new Set(["id", "at", "raw", "wire", "signature", "replay_key", "sha256", "call_id", "user_id", "parent_call_id"]);
17
+
18
+ export class SecretKeeper {
19
+ private entries: SecretEntry[] = [];
20
+ private pending: Promise<void> = Promise.resolve();
21
+
22
+ private readonly dir: string;
23
+ private readonly candidates: readonly { value: string; rule: string }[];
24
+
25
+ constructor(dir: string, candidates: readonly { value: string; rule: string }[] = []) {
26
+ this.dir = dir; this.candidates = candidates;
27
+ }
28
+
29
+ async load(): Promise<void> {
30
+ const stored = await readVault(this.dir);
31
+ // Entries this keeper already assigned (none before start) stay; stored ones fill in.
32
+ for (const entry of stored) if (!this.entries.some(known => known.n === entry.n)) this.entries.push(entry);
33
+ }
34
+
35
+ /**
36
+ * Returns the event to write and whether this call added vault entries. The
37
+ * entries are in memory at once -- the next event numbers after them -- and
38
+ * durable before the returned promise resolves, so no written event ever
39
+ * holds a placeholder the vault cannot expand.
40
+ */
41
+ async redact(event: LogEvent, previousAssistantText: string | undefined): Promise<{ event: LogEvent; added: number }> {
42
+ const added: SecretEntry[] = [];
43
+ const redact = (text: string): string => {
44
+ const result = redactText(text, [...this.entries, ...added], this.candidates);
45
+ added.push(...result.added);
46
+ return result.text;
47
+ };
48
+ const skip = (key: string) => VERIFIED_FIELDS.has(key);
49
+ let next: LogEvent;
50
+ if (event.t === "checkpoint") next = event; // file fingerprints and paths, never projected
51
+ else if (event.t === "assistant") {
52
+ // Thinking is replayed under the provider's signature; a changed byte is a rejected request.
53
+ next = { ...event, blocks: event.blocks.map(block => block.type === "thinking" ? block : mapStrings(block, redact, skip) as typeof block) };
54
+ } else if (event.t === "user" && isSolicitedSecret(event.text, previousAssistantText)) {
55
+ // The whole message is the credential, however short: it was asked for.
56
+ const value = event.text.trim();
57
+ const entry = this.entries.find(known => known.value === value) ??
58
+ { n: this.entries.reduce((max, known) => Math.max(max, known.n), 0) + 1, value, rule: "solicited" };
59
+ if (!this.entries.includes(entry)) added.push(entry);
60
+ next = { ...mapStrings(event, redact, key => skip(key) || key === "text") as LogEvent & { t: "user" }, text: placeholder(entry.n) };
61
+ } else next = mapStrings(event, redact, skip) as LogEvent;
62
+ if (added.length > 0) {
63
+ this.entries.push(...added);
64
+ const write = this.pending.then(() => appendVault(this.dir, added));
65
+ this.pending = write.catch(() => {});
66
+ await write;
67
+ }
68
+ return { event: next, added: added.length };
69
+ }
70
+
71
+ /** Tool input as the tool should run it: placeholders become values. */
72
+ expand(input: unknown): unknown {
73
+ return this.entries.length === 0 ? input : mapStrings(input, text => expandText(text, this.entries));
74
+ }
75
+ }
76
+
77
+ export const SECRET_GUIDANCE = "Values written as [[xeno-secret-N]] are credentials xeno hid from you. " +
78
+ "Use the placeholder exactly as written in tool inputs; xeno substitutes the real value when the tool runs " +
79
+ "and hides it again in the output. Do not ask the user to repeat a hidden value.";
@@ -13,6 +13,8 @@ import type { Session, LoopEvent, RequestOptions } from "./loop.ts";
13
13
  import type { ApprovalChoice } from "./tools.ts";
14
14
  import { validateQuestionReply, type QuestionRequest, type QuestionReply } from "../core/question.ts";
15
15
  import type { QuestionSlot } from "./question-tool.ts";
16
+ import { requestExternalEffects } from "./turn-effects.ts";
17
+ import type { ExternalEffect } from "../core/external-effects.ts";
16
18
 
17
19
  export type ControlNotice = { method: string; params: { threadId: string; streamId: string; sequence: number; turnId?: string; [key: string]: unknown } };
18
20
  export type PendingApproval = { id: string; turnId: string; request: PermissionRequest };
@@ -41,6 +43,8 @@ export class SessionControl {
41
43
  private waiting: ControlSnapshot["waiting"] = null;
42
44
  private changing = false;
43
45
  private inputSource: ((phase: Phase) => Promise<UserInput | null>) | undefined;
46
+ /** What the last finished turn did outside the workspace, as sent with its turn/completed. */
47
+ lastExternalEffects: readonly ExternalEffect[] = [];
44
48
 
45
49
  constructor(session: Session) {
46
50
  this.session = session;
@@ -144,7 +148,11 @@ export class SessionControl {
144
148
  if (state?.request.event === active.id) outcome = state.execution;
145
149
  }
146
150
  } catch { /* unreadable storage stays unknown, never an invented completion */ }
147
- this.emit("turn/completed", { ...(outcome ? { outcome } : {}), steeringUndelivered: active.queue.length });
151
+ // feat-external-effects-summary: a projection of this request's receipts, never a stored event.
152
+ let externalEffects: ExternalEffect[] = [];
153
+ try { externalEffects = requestExternalEffects(this.session); } catch { /* the list is advisory; a read failure shows none */ }
154
+ this.lastExternalEffects = externalEffects;
155
+ this.emit("turn/completed", { ...(outcome ? { outcome } : {}), steeringUndelivered: active.queue.length, externalEffects });
148
156
  this.active = null;
149
157
  }
150
158
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Abandons one attempt whose stream has gone silent.
3
+ *
4
+ * Without this a stream the network silently dropped waits until the user
5
+ * presses Escape (Horizyn 2026-10-12: a request sat 5m39s before the socket
6
+ * error surfaced). OpenAI's Codex CLI (`stream_idle_timeout_ms`, default
7
+ * 300000) and Claude Code (300s) both cut a stream that delivers nothing for
8
+ * five minutes against the same backends, so a live stream -- extended
9
+ * thinking included -- is not that quiet. The limit is theirs, not measured
10
+ * here (feat-stream-idle-watchdog).
11
+ *
12
+ * The clock starts when the request is sent, so it also bounds the wait for
13
+ * the first byte, and restarts on anything received: body bytes where the
14
+ * adapter owns fetch, stream events (pings included) where an SDK does.
15
+ */
16
+
17
+ export const STREAM_IDLE_MS = 300_000;
18
+
19
+ /** retry.ts recognises this by name, so it need not import an adapter. */
20
+ export class StreamIdleError extends Error {
21
+ constructor(ms: number) {
22
+ super(`no data from the provider for ${Math.round(ms / 1000)}s; the stream was abandoned`);
23
+ this.name = "StreamIdleError";
24
+ }
25
+ }
26
+
27
+ export class IdleWatch {
28
+ /** Pass this, not the caller's, to the request: it also aborts on silence. */
29
+ readonly signal: AbortSignal;
30
+ private readonly controller = new AbortController();
31
+ private timer: ReturnType<typeof setTimeout> | undefined;
32
+ private expired = false;
33
+ private readonly outer: AbortSignal | undefined;
34
+ private readonly ms: number;
35
+
36
+ constructor(outer: AbortSignal | undefined, ms: number = STREAM_IDLE_MS) {
37
+ this.outer = outer; this.ms = ms;
38
+ this.signal = outer === undefined ? this.controller.signal : AbortSignal.any([outer, this.controller.signal]);
39
+ this.touch();
40
+ }
41
+
42
+ touch(): void {
43
+ if (this.expired) return;
44
+ clearTimeout(this.timer);
45
+ this.timer = setTimeout(() => {
46
+ this.expired = true;
47
+ this.controller.abort(new StreamIdleError(this.ms));
48
+ }, this.ms);
49
+ }
50
+
51
+ /** A body that restarts the clock on every chunk. */
52
+ body(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
53
+ return stream.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
54
+ transform: (chunk, sink) => { this.touch(); sink.enqueue(chunk); },
55
+ }));
56
+ }
57
+
58
+ /** An event stream that restarts the clock on every event. */
59
+ async *events<T>(source: AsyncIterable<T>): AsyncGenerator<T> {
60
+ for await (const event of source) { this.touch(); yield event; }
61
+ }
62
+
63
+ /**
64
+ * Whatever the abort surfaced as -- AbortError, a closed body, the SDK's
65
+ * own wrapper -- silence becomes StreamIdleError. The caller's own abort
66
+ * stays an abort: Escape must not start another attempt.
67
+ */
68
+ rethrow(error: unknown): never {
69
+ if (this.expired && this.outer?.aborted !== true) throw new StreamIdleError(this.ms);
70
+ throw error;
71
+ }
72
+
73
+ dispose(): void { clearTimeout(this.timer); }
74
+ }
75
+
76
+ /** Runs one attempt under its own idle watch, cleared however it ends. */
77
+ export async function watchIdle<T>(outer: AbortSignal | undefined, ms: number | undefined, run: (idle: IdleWatch) => Promise<T>): Promise<T> {
78
+ const idle = new IdleWatch(outer, ms);
79
+ try { return await run(idle); } catch (error) { return idle.rethrow(error); } finally { idle.dispose(); }
80
+ }
@@ -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, negativeAnswer, outputBytesForTokens } 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));
@@ -679,7 +721,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
679
721
  properties: {
680
722
  session_id: { type: "integer", minimum: 1 },
681
723
  chars: { type: "string", description: "Characters to write; empty to poll" },
682
- yield_time_ms: { type: "integer", description: "Bounded wait when sending input or using low-level polling; 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" },
683
725
  max_output_tokens: { type: "integer", minimum: 1, description: "Lower this call's inline output preview to about this many tokens; full output stays recoverable" },
684
726
  },
685
727
  required: ["session_id"],
@@ -856,7 +898,7 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
856
898
  checkSourceCancellation(ctx.signal);
857
899
  text = Buffer.from(bytes).toString("utf8");
858
900
  } catch (err) {
859
- return caughtFailure(err, READ_ATTEMPTED, `cannot read ${path}: `);
901
+ return withPathSuggestions(ctx, path, caughtFailure(err, READ_ATTEMPTED, `cannot read ${path}: `));
860
902
  }
861
903
 
862
904
  if (ctx.imageStore && detectSupportedImageMimeType(bytes)) {
@@ -1001,7 +1043,7 @@ async function toolEdit(ctx: ToolContext, input: Record<string, unknown>): Promi
1001
1043
  const version = saved.bytes === null ? null : sha256(saved.bytes);
1002
1044
  const checked = await filePrecondition(ctx, input, abs, version);
1003
1045
  if (!checked.ok) return checked.result;
1004
- if (saved.bytes === null) return failure("not_found", `cannot read ${path}: file does not exist`, READ_ATTEMPTED);
1046
+ if (saved.bytes === null) return withPathSuggestions(ctx, path, failure("not_found", `cannot read ${path}: file does not exist`, READ_ATTEMPTED));
1005
1047
  const recover = { tool: "read", input: { path } };
1006
1048
  let before: string;
1007
1049
  try { before = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(saved.bytes); }
@@ -1076,8 +1118,9 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
1076
1118
  return {
1077
1119
  // The artifact starts with `exit N\n`; the view replaces that line with
1078
1120
  // the full header and pages the rest, so offsets stay artifact offsets.
1079
- content: header + artifactSummary + "\n" + observationView(captured,
1080
- 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))),
1081
1124
  content_ref: captured.ref,
1082
1125
  ...commandCompletion(exit, captured.stopped, captured.cleanup, command),
1083
1126
  data: { exit, output: captured.ref, ...(captured.cleanup ? { cleanup: "unknown" } : {}), ...(artifacts ? { artifacts } : {}) },
@@ -1130,19 +1173,22 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
1130
1173
  const session = findSession(id, ctx.execOwner);
1131
1174
  if (session === undefined) {
1132
1175
  const closed = closedSession(id, ctx.execOwner);
1133
- 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);
1134
1179
  const original = await ctx.observedProcess?.(id);
1135
- if (original) return receiptResult(ctx, recoverProcessReceipt(original.receipt, original.origin), chars);
1180
+ if (original) return receiptResult(ctx, recoverProcessReceipt(original.receipt, original.origin), input);
1136
1181
  const running = runningSessions(ctx.execOwner).map((s) => s.id).join(", ");
1137
1182
  return { ...failure("unknown_effect", `no running session ${id}; no observation in this session's active history` +
1138
1183
  (running.length > 0 ? `; running sessions: ${running}` : ""), UNKNOWN_EFFECT),
1139
1184
  data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
1140
1185
  }
1141
1186
  const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
1142
- const wait = yieldWait(input["yield_time_ms"]);
1187
+ const owner = chars.length === 0 ? ctx.processWait : undefined;
1188
+ const wait = owner ? emptyYieldWait(input["yield_time_ms"]) : yieldWait(input["yield_time_ms"]);
1143
1189
  const view = { ...ctx, maxOutputBytes: outputBytesForTokens(input["max_output_tokens"], ctx.maxOutputBytes) };
1144
1190
  return sessionResult(view, session.command, session.timeoutMs, await pollSession(session, outputStore, view.maxOutputBytes, wait.ms, chars, ctx.signal,
1145
- ctx.processWait && chars.length === 0 ? { wake: ctx.processWait.wake(), onWaiting: active => ctx.onProcessWait?.(id, active) } : undefined), wait.note);
1191
+ owner ? { wake: owner.wake(), onWaiting: active => ctx.onProcessWait?.(id, active), windowMs: wait.ms } : undefined), wait.note);
1146
1192
  }
1147
1193
 
1148
1194
  /** A successful historical query proves only that the receipt was found, never a new command exit. */
@@ -1172,17 +1218,24 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
1172
1218
  ? `poll or send input with write_stdin(session_id=${id})`
1173
1219
  : "process-control capability unavailable; execution remains unfinished; do not replay")
1174
1220
  : commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed` + answerLabel(command, exit, taken.stopped);
1175
- const header = shown + waitNote;
1221
+ const unsent = taken.unsent === undefined ? "" : " · input was not sent: the process had already exited";
1222
+ const header = shown + waitNote + unsent;
1176
1223
  const artifactSummary = commandArtifactSummary(taken.receipt.artifacts);
1177
- const content = header + artifactSummary + "\n" + observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx));
1224
+ const content = header + artifactSummary + "\n" + foldCarriageReturns(observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx)));
1178
1225
  const warnings = taken.receipt.capture === "partial" ? [failureDiagnostic("unknown_effect", "output capture is partial; full_output contains only captured bytes", UNKNOWN_EFFECT)] : [];
1179
1226
  if (exit === null) {
1180
1227
  return { ok: true, content, content_ref: stored.ref, content_sha256: stored.ref.sha256, data: taken.receipt, ...(warnings.length ? { warnings } : {}) };
1181
1228
  }
1182
1229
  const gate = matchGate(ctx, command);
1183
1230
  const observation = await commandFingerprint(ctx);
1231
+ // Refused input fails this call, yet the exit it observed is still recorded:
1232
+ // the next poll only replays, so withholding the effect would lose it (D11).
1233
+ const completion = taken.unsent === "refused"
1234
+ ? { ok: false, outcome: executionOutcome("tool_failed"),
1235
+ diagnostic: failureDiagnostic("not_found", "process had already exited; input was not sent; its exit and unread output are in this result", NOT_STARTED) }
1236
+ : commandCompletion(exit, taken.stopped, undefined, command);
1184
1237
  return {
1185
- ...commandCompletion(exit, taken.stopped, undefined, command),
1238
+ ...completion,
1186
1239
  content, content_ref: stored.ref, content_sha256: stored.ref.sha256,
1187
1240
  data: taken.receipt, ...(warnings.length ? { warnings } : {}),
1188
1241
  effect: { kind: "command", cmd: command, exit, ...(gate !== undefined ? { gate } : {}),
@@ -1190,9 +1243,9 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
1190
1243
  };
1191
1244
  }
1192
1245
 
1193
- /** The program's own meaning of exit 1 (grep: no match), appended to the header; the exit itself stays. */
1246
+ /** The program's own meaning of an answering exit (grep 1: no match; 141 after head), appended to the header; the exit itself stays. */
1194
1247
  function answerLabel(command: string, exit: number, stopped?: "cancelled" | "timeout"): string {
1195
- const meaning = stopped === undefined ? negativeAnswer(command, exit) : null;
1248
+ const meaning = stopped === undefined ? commandAnswer(command, exit) : null;
1196
1249
  return meaning === null ? "" : ` · ${meaning}`;
1197
1250
  }
1198
1251
 
@@ -1390,26 +1443,41 @@ export async function gateTool(
1390
1443
 
1391
1444
  const permissionInput = name === "apply_patch" && input && typeof input === "object" && "patchText" in input ? { paths: patchPaths(parsePatch(String(input.patchText))) } : input;
1392
1445
  const request = await canonicalPermissionRequest(ctx.root, name, permissionInput);
1393
- let decision = decideWithSessionRules(ctx.rules, ctx.sessionRules ?? [], request);
1446
+ let explained = explainDecision(ctx.rules, ctx.sessionRules ?? [], request);
1394
1447
  if (definition?.access === "read" && request.paths) for (const path of request.paths) {
1395
- const resource = await permissionResourceAction(ctx.root, path, ctx.rules, ctx.sessionRules ?? [], name);
1396
- if (resource === "deny") { decision = "deny"; break; }
1397
- 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;
1398
1451
  }
1452
+ const decision = explained.action;
1453
+ // feat-approval-events: the verdict is durable before the call can start, so
1454
+ // the log answers "why was this allowed" -- including automatic allows.
1455
+ const record = async (verdict: ApprovalRecord["decision"], source: ApprovalRecord["source"]) => {
1456
+ if (!ctx.onApproval || ctx.callId === undefined || !approvalNeeded(request, decision)) return;
1457
+ await ctx.onApproval({ call_id: ctx.callId, tool: request.tool, decision: verdict, source,
1458
+ ...(request.command !== undefined ? { command: request.command } : {}),
1459
+ ...(explained.rule ? { rule: describeRule(explained.rule) } : {}) });
1460
+ };
1399
1461
  switch (decision) {
1400
1462
  case "allow":
1463
+ // An empty rule list consulted no policy (tool tests, trusted host execution).
1464
+ if (explained.source !== "none") await record("allow", explained.source);
1401
1465
  return null;
1402
1466
  case "deny":
1467
+ await record("deny", "config");
1403
1468
  return failure("permission_denied", deniedMessage(request));
1404
1469
  case "ask": {
1405
1470
  const ask = ctx.ask ?? ctx.askSlot?.current;
1406
1471
  if (ask === undefined) {
1472
+ await record("deny", "no_prompt");
1407
1473
  return failure("permission_denied", askWithoutTtyMessage(request));
1408
1474
  }
1409
1475
  let choice: ApprovalChoice | undefined;
1410
1476
  try { choice = await waitForAdmission(ask(request), ctx.signal); }
1411
1477
  catch (error) { return caughtFailure(error, NOT_STARTED, "approval unavailable: "); }
1478
+ // A withdrawn prompt is not a decision; the result already says cancelled.
1412
1479
  if (choice === undefined || ctx.signal?.aborted) return cancelledTool();
1480
+ await record(choice, "user");
1413
1481
  switch (choice) {
1414
1482
  case "once":
1415
1483
  return null;
@@ -1501,6 +1569,8 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
1501
1569
  try {
1502
1570
  if (!begun) await scoped(() => ctx.extensionHooks?.event("tool_execution_start", { ...lifecycle, args: observerValue(input) }));
1503
1571
  await ctx.drainNested?.();
1572
+ // Admission, the durable invocation and every hook saw the placeholder; only the tool sees the value.
1573
+ const runInput = ctx.expandSecrets ? ctx.expandSecrets(input) : input;
1504
1574
  const execute = async () => {
1505
1575
  const executionContext: ToolContext = { ...ctx, onOutput: chunk => {
1506
1576
  ctx.onOutput?.(chunk);
@@ -1511,11 +1581,11 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
1511
1581
  }
1512
1582
  } };
1513
1583
  observed = owned ? finishToolResult(ctx.signal?.aborted ? cancelledTool() : await owned(),
1514
- toolDefinition(name, ctx)?.access === "read" ? READ_ATTEMPTED : UNKNOWN_EFFECT) : await dispatchTool(executionContext, name, input);
1584
+ toolDefinition(name, ctx)?.access === "read" ? READ_ATTEMPTED : UNKNOWN_EFFECT) : await dispatchTool(executionContext, name, runInput);
1515
1585
  return observed;
1516
1586
  };
1517
1587
  const command = ["bash", "environment", "target", "exec_command", "write_stdin", NATIVE_COMMAND_NAME].includes(name);
1518
- const operation = () => ctx.workspace && command ? ctx.workspace.command(name, input, execute, ctx.signal) : execute();
1588
+ const operation = () => ctx.workspace && command ? ctx.workspace.command(name, runInput, execute, ctx.signal) : execute();
1519
1589
  // Progress handlers may enqueue native commands. They run only after this
1520
1590
  // process drains, before the outer receipt; awaiting them inside it deadlocks.
1521
1591
  observed = command && ctx.runOwnedCommand ? await ctx.runOwnedCommand(operation) : await operation();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The current request's operations outside the workspace, read from the log
3
+ * when a request ends (feat-external-effects-summary). The derivation is the
4
+ * pure core function; this file only supplies what core may not read: the
5
+ * home directory and the platform's temporary directory.
6
+ * Nothing is recorded -- the list is a projection and is recomputed on demand.
7
+ */
8
+ import { realpathSync } from "node:fs";
9
+ import { homedir, tmpdir } from "node:os";
10
+ import { externalEffects, type ExternalEffect, type ExternalOptions } from "../core/external-effects.ts";
11
+ import { currentRequestEvents } from "../core/request-state.ts";
12
+ import type { Session } from "./loop.ts";
13
+
14
+ /** macOS reports /var/folders/... while processes see /private/var/folders/...; both are temporary. */
15
+ export function hostExternalOptions(): ExternalOptions {
16
+ const temp = tmpdir();
17
+ let real = temp;
18
+ try { real = realpathSync(temp); } catch { /* an unreadable temp root is still the reported one */ }
19
+ return { home: homedir(), temp: [...new Set([temp, real])] };
20
+ }
21
+
22
+ /**
23
+ * Synchronous on purpose: SessionControl computes this between the turn's last
24
+ * event and releasing the turn, and an await there lets a disposal observe a
25
+ * turn that has already ended. The cost is one known gap: a request that
26
+ * crossed a segment boundary lists only what ran after the last cut (D8 keeps
27
+ * the earlier part in the sealed segment, not in memory). Silence, not a guess.
28
+ */
29
+ export function requestExternalEffects(session: Session, options: ExternalOptions = hostExternalOptions()): ExternalEffect[] {
30
+ return externalEffects(currentRequestEvents(session.state.events), session.config.tools.root, options);
31
+ }
@@ -390,7 +390,11 @@ export async function discoverTargetInstructions(options: TargetInstructionOptio
390
390
  const root = resolve(options.workspace), directories = new Set<string>();
391
391
  for (const path of options.paths) {
392
392
  const absolute = resolve(root, path);
393
- 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));
@@ -11,7 +11,7 @@ export async function editablePath(root: string, path: string): Promise<string>
11
11
  if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(rel) === rel) throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path}`, NOT_STARTED);
12
12
  // Session logs and blobs are the evidence; an edit there could rewrite it.
13
13
  if (rel.split(sep).some(part => part === ".git" || part === ".xeno")) {
14
- throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path} is inside .git or .xeno, which edit tools never change`, NOT_STARTED, { next: STATE_PATH_GUIDANCE });
14
+ throw new ToolFault("permission_denied", `${path} is inside .git or .xeno, which edit tools never change; this is not a permission problem and no approval will allow it`, NOT_STARTED, { next: STATE_PATH_GUIDANCE });
15
15
  }
16
16
  let current = base;
17
17
  for (const part of rel.split(sep)) {
package/src/store/log.ts CHANGED
@@ -125,6 +125,11 @@ export interface Cursor {
125
125
  workspace?: string;
126
126
  /** Content-addressed frozen context; credentials and approvals are excluded. */
127
127
  context_ref?: string;
128
+ /**
129
+ * The xeno release that wrote this cursor. Every start writes one, so the
130
+ * append-only metadata journal is a timeline of the versions a session ran under.
131
+ */
132
+ xeno_version?: string;
128
133
  /**
129
134
  * A human name. Optional because most sessions never get one, and a log
130
135
  * written before names existed has none. Absent rather than empty: an
@@ -554,6 +559,21 @@ export async function readCursorAt(dir: string, node: number, atEvent?: string):
554
559
  return stateAtPosition(states, node, events.map(event => event.id), atEvent);
555
560
  }
556
561
 
562
+ /**
563
+ * When this session was forked, or null for an original (fix-fork-start-time).
564
+ * copyAncestryTo keeps the parent's nodes and their timestamps, then writes
565
+ * the fork cursor and, right after it, the parent_session record; that
566
+ * cursor's updated_at is the fork moment. The last parent_session record is
567
+ * this session's own: a copied parent that was itself a fork brings its
568
+ * record along in the copied journal.
569
+ */
570
+ export async function forkedAt(dir: string): Promise<string | null> {
571
+ const records = await readMetadata(dir) ?? [];
572
+ const own = records.findLastIndex(record => record.kind === "parent_session");
573
+ const cursor = records.slice(0, Math.max(0, own)).findLast(record => record.kind === "cursor");
574
+ return own >= 0 && cursor?.kind === "cursor" ? cursor.cursor.updated_at : null;
575
+ }
576
+
557
577
  /** D3: the replaceable manifest is a projection of committed metadata events. */
558
578
  export async function readManifest(dir: string): Promise<Manifest> {
559
579
  return serialized(dir, () => readManifestUnlocked(dir));
@@ -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
  }