@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
@@ -18,11 +18,12 @@
18
18
  * request.
19
19
  */
20
20
 
21
- import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent } from "../core/events.ts";
21
+ import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent, FailureCause } from "../core/events.ts";
22
22
  import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
23
23
  import { hydrateImages } from "./images.ts";
24
24
  import type { Handoff } from "../core/handoff.ts";
25
25
  import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
26
+ import { CURRENT_WORKING_SET_VERSION } from "../core/working-set.ts";
26
27
  import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
27
28
  import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
28
29
  import { projectContext, toolCallIdentityViolations } from "../core/project.ts";
@@ -42,8 +43,9 @@ import {
42
43
  } from "../core/context-policy.ts";
43
44
  import { retainedContext, selectRecentContext } from "../core/context-window.ts";
44
45
  import { advanceStall, emptyStallState, stallDecision, DEFAULT_STALL_LIMIT, type StallState } from "../core/stall.ts";
46
+ import { questionRequest, validateQuestionReply } from "../core/question.ts";
45
47
  import { DEFAULT_OUTPUT_RECOVERY_LIMIT, isCompleteResponse, incompleteToolText, knownUnexecutedTool, outputRecoveryNotice, validateOutputRecoveryLimit } from "../core/response-recovery.ts";
46
- import { join, resolve } from "node:path";
48
+ import { join, relative, resolve } from "node:path";
47
49
  import { randomUUID } from "node:crypto";
48
50
  import { pendingToolCalls } from "../core/execution.ts";
49
51
  import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
@@ -75,7 +77,11 @@ import { skillTools, type Skill } from "../core/skills.ts";
75
77
  import type { WorkItem } from "../core/work-state.ts";
76
78
  import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceCriterion, type AcceptanceState } from "../core/acceptance.ts";
77
79
  import { deadlineNotice } from "../core/deadline.ts";
78
- import { statusOf } from "./retry.ts";
80
+ import { describeFailure, failureCause, statusOf } from "./retry.ts";
81
+ import { XENO_VERSION } from "./version.ts";
82
+ import { SECRET_GUIDANCE, SecretKeeper } from "./secrets.ts";
83
+ import { secretEnvironmentValues } from "../core/secrets.ts";
84
+ import { copyVault } from "../store/secrets.ts";
79
85
  import { assertNever } from "../core/exhaustive.ts";
80
86
  import { isLogEvent } from "../core/event-validation.ts";
81
87
  import {
@@ -91,7 +97,7 @@ import { FailedResponseError, type Provider, type Turn, type StreamDelta } from
91
97
  import { putBlob, getBlob } from "../store/blobs.ts";
92
98
  import { hydrateProcessReceipts } from "../store/process-receipts.ts";
93
99
  import { readNodeHistory } from "../store/restore.ts";
94
- import { createWorktree, removeWorktree } from "../store/worktree.ts";
100
+ import { createWorktree, listWorktrees, mainWorktree, persistentWorktreeParent, removeWorktree } from "../store/worktree.ts";
95
101
  import {
96
102
  parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
97
103
  BRANCH_TOOL_NAMES, WRITING_BRANCH_TOOL_NAMES,
@@ -113,6 +119,7 @@ import { changedPaths, invertFileChanges, sameVersion, type CheckpointEvent, typ
113
119
  import type { GitCheckpoint } from "../store/git-checkpoints.ts";
114
120
  import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
115
121
  import { planCheckpointRestore } from "../store/checkpoint-history.ts";
122
+ import { configDir } from "../store/config.ts";
116
123
 
117
124
  /** Ids and timestamps come from here so the loop stays the only impure part. */
118
125
  export interface Clock {
@@ -175,6 +182,8 @@ export interface LoopEvent {
175
182
  /** branch_start: wall-clock milliseconds, for asserting the stagger. */
176
183
  at?: number;
177
184
  outcome?: ExecutionOutcome;
185
+ /** done: credential-free cause of a failed request, recorded on its outcome. */
186
+ failure?: FailureCause;
178
187
  /** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
179
188
  request?: RequestState;
180
189
  progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string }
@@ -212,6 +221,11 @@ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
212
221
  };
213
222
 
214
223
  export interface SessionConfig {
224
+ /**
225
+ * Where secret-named environment values are read from (S.9). Defaults to
226
+ * process.env; a test supplies its own so it never depends on the machine.
227
+ */
228
+ secretEnvironment?: Readonly<Record<string, string | undefined>>;
215
229
  /** Runtime-only output contract, explicitly selected by the current caller. */
216
230
  validateOutput?: (text: string) => { ok: boolean; error?: string };
217
231
  /** Persist a selector, never persisted profile code or authorization (D6/D13). */
@@ -379,7 +393,15 @@ export class Session {
379
393
  private stall: StallState | null = null;
380
394
  private recovery: RecoveryState | null = null;
381
395
  private processWake = new AbortController();
396
+ /**
397
+ * The exception the last provider request ended with, compared by identity
398
+ * in send()'s catch. Wrapping the error instead would break every check that
399
+ * reads it (FailedResponseError, statusOf, context overflow).
400
+ */
401
+ private providerFailure: { error: unknown; retries: number } | undefined;
382
402
  private pendingAdoption: number | null = null;
403
+ /** A worktree enter/exit moves the root only once its tool round is paired, like adoption. */
404
+ private pendingRoot: string | null = null;
383
405
  private readonly allocatedEventIds = new Set<string>();
384
406
  private recordTail: Promise<void> = Promise.resolve();
385
407
  private readonly recordListeners = new Set<(item: CommittedItem) => void>();
@@ -393,6 +415,8 @@ export class Session {
393
415
  readonly clock: Clock;
394
416
  state: SessionState;
395
417
  private readonly allocation: { next: number };
418
+ /** S.9: the vault behind placeholders. A branch is handed its parent's, like `allocation`. */
419
+ private secrets: SecretKeeper;
396
420
 
397
421
  constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
398
422
  if (config.acceptanceContract) parseAcceptanceContract(config.acceptanceContract);
@@ -402,6 +426,7 @@ export class Session {
402
426
  schedulingWindow([], 0, config.maxParallelTools ?? 3);
403
427
  validateRequestBudget(config.maxTurns, config.stallLimit ?? DEFAULT_STALL_LIMIT);
404
428
  this.allocation = allocation;
429
+ this.secrets = new SecretKeeper(config.logDir, secretEnvironmentValues(config.secretEnvironment ?? process.env));
405
430
  this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache(),
406
431
  processWait: { wake: () => this.processWake.signal } } };
407
432
  // A child or restored Session must never inherit another live invocation scope.
@@ -415,6 +440,8 @@ export class Session {
415
440
  // Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
416
441
  if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
417
442
  this.config.tools.outputStore ??= join(config.logDir, "artifacts");
443
+ // Read at call time, so a branch that is handed its parent's keeper expands the parent's values.
444
+ this.config.tools.expandSecrets = input => this.secrets.expand(input);
418
445
  this.config.tools.historyEvents = async () => {
419
446
  const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
420
447
  if (!history.ok) throw Error(history.error);
@@ -494,6 +521,7 @@ export class Session {
494
521
  async start(): Promise<void> {
495
522
  this.resetDerivedToolState();
496
523
  const dir = this.config.logDir;
524
+ await this.secrets.load();
497
525
  const model = this.provider.config.spec.id;
498
526
  const existing = await listNodes(dir);
499
527
  const recordedCursor = await readCursor(dir);
@@ -603,6 +631,7 @@ export class Session {
603
631
  */
604
632
  async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
605
633
  await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
634
+ await copyVault(this.config.logDir, destLogDir);
606
635
  if (untilEvent !== undefined) {
607
636
  const cursor = await readCursor(destLogDir);
608
637
  if (!cursor) throw new Error("missing fork cursor");
@@ -745,8 +774,18 @@ export class Session {
745
774
  * must see that a person changed models, not that the node filled up.
746
775
  * boundary.ts already produces this reason when checkpointRequested is
747
776
  * set; switchModel is the other caller that wants it.
777
+ *
778
+ * An empty projection is the exception (fix-switch-model-empty-segment).
779
+ * With no events there is no old model's block to keep away from the new
780
+ * one, and the frozen prefix does not change here, so the cursor alone
781
+ * records the switch. Cutting anyway sealed an empty segment and opened
782
+ * the next from an empty handoff, which the model read as earlier work to
783
+ * resume. The check is the projection, not the node file: an inherit-basis
784
+ * node can be empty on disk while still projecting ancestor thinking.
785
+ *
786
+ * Returns whether a segment was cut, so the caller can say so truthfully.
748
787
  */
749
- async switchModel(next: Provider): Promise<void> {
788
+ async switchModel(next: Provider): Promise<boolean> {
750
789
  if (this.running || this.isHalfRound()) {
751
790
  throw new Error(
752
791
  "cannot switch models mid-turn: a tool_use is still unpaired. " +
@@ -754,9 +793,12 @@ export class Session {
754
793
  );
755
794
  }
756
795
 
757
- await this.performCut("model_checkpoint", next.config.spec.id, undefined, next.config.spec.provider, next.config.effort);
796
+ const cut = this.state.events.length > 0;
797
+ if (cut) await this.performCut("model_checkpoint", next.config.spec.id, undefined, next.config.spec.provider, next.config.effort);
798
+ else this.state.anchor = null;
758
799
  this.provider = next;
759
800
  await this.saveCursor();
801
+ return cut;
760
802
  }
761
803
 
762
804
  /** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
@@ -886,6 +928,7 @@ export class Session {
886
928
  updated_at: this.clock.now(),
887
929
  workspace: this.config.tools.root,
888
930
  context_ref: contextRef ?? await this.persistContext(),
931
+ xeno_version: XENO_VERSION,
889
932
  });
890
933
  }
891
934
 
@@ -898,6 +941,10 @@ export class Session {
898
941
  // D8: runtime callers may retain mutable results. The in-memory projection
899
942
  // must own the same JSON value that was appended, independent of later edits.
900
943
  event = JSON.parse(JSON.stringify(event)) as LogEvent;
944
+ // S.9: credentials become placeholders before anything is written, so no
945
+ // projection, handoff, export or history page can carry the value.
946
+ const redacted = await this.secrets.redact(event, this.lastAssistantText());
947
+ event = redacted.event;
901
948
  const saved = this.recordTail.then(async () => {
902
949
  await appendEvent(this.config.logDir, this.state.node, event);
903
950
  this.state.events.push(event);
@@ -909,6 +956,9 @@ export class Session {
909
956
  });
910
957
  this.recordTail = saved.catch(() => {});
911
958
  await saved;
959
+ if (redacted.added > 0 && !this.state.events.some(item => item.t === "guidance" && item.reason === "secret")) {
960
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "secret", text: SECRET_GUIDANCE, sources: [event.id] });
961
+ }
912
962
  if (event.t === "tool_result" && event.ok && event.effect?.kind === "read") {
913
963
  const path = resolve(this.config.tools.root, event.effect.path);
914
964
  const skill = this.config.skills?.find(item => item.filePath === path);
@@ -921,6 +971,19 @@ export class Session {
921
971
  }
922
972
  }
923
973
 
974
+ /** The text the user is answering, for recognising a solicited credential. */
975
+ private lastAssistantText(): string | undefined {
976
+ for (let i = this.state.events.length - 1; i >= 0; i--) {
977
+ const event = this.state.events[i]!;
978
+ if (event.t === "user") return undefined;
979
+ if (event.t === "assistant") {
980
+ const text = event.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n");
981
+ if (text.trim()) return text;
982
+ }
983
+ }
984
+ return undefined;
985
+ }
986
+
924
987
  private allowedNames(): string[] {
925
988
  return this.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" &&
926
989
  (this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name)) ? [tool.name] : []);
@@ -996,7 +1059,7 @@ export class Session {
996
1059
 
997
1060
  private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
998
1061
  return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
999
- ...(this.config.workingSet ? { working_set_version: 2 as const } : {}), guidance_tools: tools });
1062
+ ...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}), guidance_tools: tools });
1000
1063
  }
1001
1064
 
1002
1065
  async acceptanceState(): Promise<AcceptanceState | undefined> {
@@ -1252,6 +1315,7 @@ export class Session {
1252
1315
  if (item.value.outcome && item.value.outcome.reason !== "model_end") await this.record({
1253
1316
  t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
1254
1317
  reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
1318
+ ...(item.value.failure ? { failure: item.value.failure } : {}),
1255
1319
  });
1256
1320
  const request = await this.requestState();
1257
1321
  if (request) item.value.request = request;
@@ -1266,9 +1330,14 @@ export class Session {
1266
1330
  yield { kind: "done", reason: "stopped: request ended without a terminal outcome", outcome, ...(request ? { request } : {}) };
1267
1331
  }
1268
1332
  } catch (error) {
1269
- // Raw provider exceptions can contain credentials; persist only the execution fact.
1333
+ // Raw exceptions can contain credentials; persist the category, never the message.
1334
+ // Only the provider request's own exception is a provider error: this catch
1335
+ // also sees failures in history reads and in the harness itself.
1336
+ const provider = this.providerFailure?.error === error ? this.providerFailure : undefined;
1337
+ const failure = failureCause(error, provider);
1270
1338
  await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
1271
- reason: "request failed before completion; no complete response was committed", outcome: executionOutcome("provider_error") });
1339
+ reason: `request failed before completion (${describeFailure(failure)}); no complete response was committed`,
1340
+ outcome: executionOutcome(provider ? "provider_error" : "execution_error"), failure });
1272
1341
  throw error;
1273
1342
  } finally {
1274
1343
  // Closing a consumer is not a user cancellation. Preserve the existing
@@ -1337,8 +1406,8 @@ export class Session {
1337
1406
  return;
1338
1407
  }
1339
1408
 
1340
- if (yield* this.checkStall()) {
1341
- yield { kind: "done", reason: await report("no-progress allowance exhausted", turn), outcome: executionOutcome("stalled") };
1409
+ if (yield* this.checkStall(request, signal)) {
1410
+ yield { kind: "done", reason: await report("repeated tool cycle without new evidence", turn), outcome: executionOutcome("stalled") };
1342
1411
  return;
1343
1412
  }
1344
1413
  if (phase === "finalize" && !supportsToolDisabling(this.provider.config.spec.api)) {
@@ -1400,7 +1469,9 @@ export class Session {
1400
1469
  }
1401
1470
  if (phase === "finalize") {
1402
1471
  yield { kind: "discard" };
1403
- yield { kind: "done", reason: await report("final response failed; no additional request was made", turn + 1), outcome: executionOutcome("provider_error") };
1472
+ const failure = failureCause(error, this.providerFailure?.error === error ? this.providerFailure : { retries: 0 });
1473
+ yield { kind: "done", reason: await report(`final response failed (${describeFailure(failure)}); no additional request was made`, turn + 1),
1474
+ outcome: executionOutcome("provider_error"), failure };
1404
1475
  return;
1405
1476
  }
1406
1477
  if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
@@ -1633,6 +1704,7 @@ export class Session {
1633
1704
  // granularity the research warns against.
1634
1705
  const declarationNode = this.state.node;
1635
1706
  if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
1707
+ if (this.pendingRoot !== null) await this.applyPendingRoot();
1636
1708
  if (this.pendingAdoption !== null) await this.adoptPendingBranch();
1637
1709
 
1638
1710
  // Between complete exchanges is the only safe place to cut.
@@ -1732,8 +1804,9 @@ export class Session {
1732
1804
  ): AsyncGenerator<LoopEvent, Turn> {
1733
1805
  const hydrated = await hydrateImages(messages, this.config.blobDir ?? join(this.config.logDir, "blobs"),
1734
1806
  this.provider.config.spec.input?.includes("image") ?? false);
1735
- return yield* withProgress<Turn, LoopEvent>(emit => this.provider.send(
1736
- this.config.frozen, hydrated, this.state.node, signal, (delta: StreamDelta): void => {
1807
+ let retries = 0;
1808
+ this.providerFailure = undefined;
1809
+ const progress = (emit: (event: LoopEvent) => void) => (delta: StreamDelta): void => {
1737
1810
  switch (delta.kind) {
1738
1811
  case "text":
1739
1812
  emit({ kind: "text_delta", text: delta.text });
@@ -1744,13 +1817,25 @@ export class Session {
1744
1817
  case "discard":
1745
1818
  emit({ kind: "discard" });
1746
1819
  break;
1747
- case "tool": case "retry":
1820
+ case "retry":
1821
+ retries = Math.max(retries, delta.attempt);
1822
+ emit({ kind: "progress", progress: delta });
1823
+ break;
1824
+ case "tool":
1748
1825
  emit({ kind: "progress", progress: delta });
1749
1826
  break;
1750
1827
  default:
1751
1828
  return assertNever(delta, "onDelta");
1752
1829
  }
1753
- }, options));
1830
+ };
1831
+ try {
1832
+ return yield* withProgress<Turn, LoopEvent>(emit => this.provider.send(
1833
+ this.config.frozen, hydrated, this.state.node, signal, progress(emit), options));
1834
+ } catch (error) {
1835
+ // The outcome needs to know this exception came from the request, and after how many retries.
1836
+ this.providerFailure = { error, retries };
1837
+ throw error;
1838
+ }
1754
1839
  }
1755
1840
 
1756
1841
  /**
@@ -1824,7 +1909,10 @@ export class Session {
1824
1909
  let context: ToolContext = { ...this.config.tools, ...(this.readOnlyRequest ? { workflowMode: "plan" as const, planHint: READ_ONLY_HINT } : {}),
1825
1910
  imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
1826
1911
  advice: this.allowedNames(),
1827
- imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
1912
+ imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}),
1913
+ // feat-approval-events: the broker has already recorded this call's
1914
+ // invocation, and its result comes after execution, so the order is fixed.
1915
+ onApproval: approval => this.record({ t: "approval", id: this.clock.nextId(), at: this.clock.now(), ...approval }) };
1828
1916
  if (native) context = nativeCommandContext(context);
1829
1917
  const decoder = new TextDecoder();
1830
1918
  context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
@@ -1931,7 +2019,7 @@ export class Session {
1931
2019
  const forkSkill = call.name === "read" && typeof call.input === "object" && call.input !== null ? this.config.skills?.find(skill =>
1932
2020
  skill.context === "fork" && skill.filePath === resolve(ctx.root, String((call.input as Record<string, unknown>)["path"]))) : undefined;
1933
2021
  const discovery = call.name === DISCOVERY_TOOL_NAME && ctx.extensions?.get(call.name) === DISCOVERY_TOOL;
1934
- if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
2022
+ if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target", "worktree"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
1935
2023
  const branchIndex = forkSkill || call.name === "branch" ? nextBranch() : 0;
1936
2024
  return prepareNormalizedTool(ctx, call.name, call.input, async () => {
1937
2025
  if (discovery) return async () => searchDeferredTools(ctx, call.input as Record<string, unknown>, this.allowedNames());
@@ -1950,6 +2038,7 @@ export class Session {
1950
2038
  return { ...prepared.out, ...(prepared.annotation ? { annotation: prepared.annotation } : {}) };
1951
2039
  };
1952
2040
  if (call.name === "resolve") return () => this.runResolve(call.input);
2041
+ if (call.name === "worktree") return () => this.runWorktree(call.input, ctx.pathRoot);
1953
2042
  if (forkSkill?.disableModelInvocation) return async () => ({ ok: false, content: "this skill requires explicit user invocation" });
1954
2043
  if (forkSkill) await this.recordSkill(forkSkill, "fork", "model");
1955
2044
  const task = this.state.events.findLast(event => event.t === "user");
@@ -2086,6 +2175,7 @@ export class Session {
2086
2175
  );
2087
2176
  branch.state.node = node;
2088
2177
  branch.state.nextNode = this.state.nextNode;
2178
+ branch.secrets = this.secrets;
2089
2179
  if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
2090
2180
  this.children.set(node, branch);
2091
2181
  yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
@@ -2163,6 +2253,50 @@ export class Session {
2163
2253
  };
2164
2254
  }
2165
2255
 
2256
+ private async runWorktree(input: unknown, pathRoot?: string): Promise<ToolResult> {
2257
+ const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
2258
+ const root = this.config.tools.root;
2259
+ // A branch already runs in its own checkout and never owns the parent's root.
2260
+ if ((this.config.branchDepth ?? 0) > 0) return { content: "worktree is unavailable inside a branch; the branch already has its own checkout", ok: false };
2261
+ if (this.pendingRoot !== null) return { content: "one worktree switch per tool round; the previous one applies after this round", ok: false };
2262
+ const main = await mainWorktree(root);
2263
+ if (main === null) return { content: `${root} is not inside a git repository, so there is nothing to make a worktree of`, ok: false };
2264
+ const outside = (path: string) => pathRoot !== undefined && relative(pathRoot, path).startsWith("..");
2265
+ if (args["action"] === "exit") {
2266
+ if (main === root) return { content: `already in the main checkout ${main}; nothing to exit`, ok: false };
2267
+ if (outside(main)) return { content: `the main checkout ${main} is outside --path-root ${pathRoot}`, ok: false };
2268
+ const branch = (await listWorktrees(main)).find(wt => wt.path === root)?.branch ?? null;
2269
+ this.pendingRoot = main;
2270
+ return { ok: true, content: [`main ${main}`, `kept ${root}${branch ? ` on ${branch.replace(/^refs\/heads\//, "")}` : ""}`,
2271
+ "The session root moves to the main checkout after this tool round. Nothing was merged or deleted; " +
2272
+ `remove the worktree yourself with \`git worktree remove ${root}\` once its branch is merged or no longer needed.`].join("\n") };
2273
+ }
2274
+ if (args["action"] !== "enter") return { content: "worktree action must be enter or exit", ok: false };
2275
+ const name = typeof args["name"] === "string" ? args["name"] : "";
2276
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || name.includes("..")) {
2277
+ return { content: "worktree name must be 1-64 letters, digits, dot, dash or underscore, starting with a letter or digit", ok: false };
2278
+ }
2279
+ const parent = persistentWorktreeParent(configDir(), main);
2280
+ if (outside(parent)) return { content: `xeno's worktree directory ${parent} is outside --path-root ${pathRoot}`, ok: false };
2281
+ const created = await createWorktree(root, name, { location: { parent, leaf: name }, includeChanges: args["include_changes"] === true });
2282
+ if (!created.ok) return { content: `refused: ${created.error}`, ok: false };
2283
+ this.pendingRoot = created.path;
2284
+ return { ok: true, content: [`worktree ${created.path}`, `branch ${created.branch}`, `from ${created.snapshot.head}`,
2285
+ `The session root moves there after this tool round; relative paths, edits and commands then resolve in it. ` +
2286
+ `The working directory in the system prompt still names ${root}. ` +
2287
+ (args["include_changes"] === true ? "It starts from the current uncommitted files." : "It starts from HEAD; uncommitted files in the main checkout were not copied.")].join("\n"),
2288
+ data: { path: created.path, branch: created.branch, head: created.snapshot.head } };
2289
+ }
2290
+
2291
+ private async applyPendingRoot(): Promise<void> {
2292
+ const root = this.pendingRoot;
2293
+ this.pendingRoot = null;
2294
+ if (root === null) return;
2295
+ this.config.tools.root = root;
2296
+ this.installWorkspace();
2297
+ await this.saveCursor();
2298
+ }
2299
+
2166
2300
  private async runResolve(input: unknown): Promise<ToolResult> {
2167
2301
  const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
2168
2302
  const node = Number(args["node"]);
@@ -2555,7 +2689,14 @@ export class Session {
2555
2689
  return recoveryReview(this.recovery, tools);
2556
2690
  }
2557
2691
 
2558
- private async *checkStall(): AsyncGenerator<LoopEvent, boolean> {
2692
+ /**
2693
+ * S.8: a repeated cycle is pointed out to the model once; the next one is
2694
+ * the user's call when a question client can answer -- continue, stop, or
2695
+ * new instructions -- and ends the request only without one (headless) or
2696
+ * when the user stops it. The answer is recorded as an event, never kept
2697
+ * as loop state: a continue is a `stall` guidance, instructions are steering.
2698
+ */
2699
+ private async *checkStall(request: string, signal?: AbortSignal): AsyncGenerator<LoopEvent, boolean> {
2559
2700
  const limit = this.config.stallLimit ?? DEFAULT_STALL_LIMIT;
2560
2701
  if (limit === 0) return false;
2561
2702
  if (this.stall === null) {
@@ -2564,13 +2705,42 @@ export class Session {
2564
2705
  this.stall = advanceStall(emptyStallState(), history.nodes.flatMap(({ node, events }) => events.map((event) => ({ node, event }))));
2565
2706
  }
2566
2707
  const decision = stallDecision(this.stall, limit);
2567
- if (decision.stop) {
2568
- yield { kind: "stop_and_ask", reason: `${decision.unproductive} total tool exchanges without new evidence (${decision.consecutive} consecutive; ${decision.failures} failed receipts; ${decision.emptyWaits} empty waits); change the approach or provide new input` };
2569
- return true;
2708
+ if (decision.cycle === null) return false;
2709
+ const pattern = decision.cycle === 1 ? "the same tool call" : `a cycle of ${decision.cycle} tool calls`;
2710
+ const facts = `${pattern} repeated ${decision.repeats} times without new evidence (${decision.failures} failed receipts; ${decision.emptyWaits} empty waits)`;
2711
+ if (decision.warn) {
2712
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
2713
+ text: `Harness: ${facts}. Change the method, run a meaningful check, or give the final result with unresolved limits. If the pattern repeats again, the user decides whether the request continues.` });
2714
+ return false;
2570
2715
  }
2571
- if (decision.warn) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
2572
- text: `Harness: ${decision.unproductive} total tool exchanges without new evidence or fresh verification (${decision.consecutive} consecutive; ${decision.failures} failed receipts; ${decision.emptyWaits} empty waits). Change the method, run a meaningful check, or give the final result with unresolved limits. The harness will stop at ${limit} total unproductive exchanges. Novel observations reset the streak, not the spent allowance. Notes and repeated observations do not count as progress.` });
2573
- return false;
2716
+ const answer = await this.askAboutCycle(facts, signal);
2717
+ if (answer?.kind === "continue") {
2718
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
2719
+ text: `Harness: the user reviewed this (${facts}) and chose to continue. Do not repeat the same calls expecting a different result; change the method or finish with the result and its limits.` });
2720
+ return false;
2721
+ }
2722
+ if (answer?.kind === "steer") {
2723
+ await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, text: answer.text });
2724
+ return false;
2725
+ }
2726
+ yield { kind: "stop_and_ask", reason: `${facts}; change the approach or provide new input` };
2727
+ return true;
2728
+ }
2729
+
2730
+ /** Undefined means nobody answered: no question client (headless), cancelled, or an invalid reply. */
2731
+ private async askAboutCycle(facts: string, signal?: AbortSignal): Promise<{ kind: "continue" } | { kind: "stop" } | { kind: "steer"; text: string } | undefined> {
2732
+ const slot = this.config.tools.questionSlot, adapter = slot?.current, turn = slot?.activeTurn;
2733
+ if (!adapter || !turn || signal?.aborted) return undefined;
2734
+ const request = questionRequest(turn, `stall-${this.clock.nextId()}`, [{ id: "stall", header: "Repeated tool calls",
2735
+ question: `The agent ${facts}. Continue, stop, or type instructions for it?`, multiple: false, custom: true,
2736
+ options: [{ id: "continue", label: "Continue", description: "Let the agent keep working; it is told to change its method." },
2737
+ { id: "stop", label: "Stop", description: "End this request; the log and evidence stay." }] }]);
2738
+ try {
2739
+ const reply = validateQuestionReply(request, await adapter(structuredClone(request), signal ?? new AbortController().signal));
2740
+ const [answer] = reply.answers, text = answer?.text?.trim();
2741
+ if (text) return { kind: "steer", text };
2742
+ return answer?.choices[0] === "continue" ? { kind: "continue" } : answer?.choices[0] === "stop" ? { kind: "stop" } : undefined;
2743
+ } catch { return undefined; }
2574
2744
  }
2575
2745
 
2576
2746
  /** A steering signal releases only the wait; cancellation owns process termination. */
@@ -2795,7 +2965,7 @@ export class Session {
2795
2965
  next_thinking: nextThinking,
2796
2966
  ...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
2797
2967
  handoff_tokens: DEFAULT_HANDOFF_TOKENS,
2798
- ...(this.config.workingSet ? { working_set_version: 2 as const } : {}),
2968
+ ...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}),
2799
2969
  guidance_tools: guidanceTools,
2800
2970
  ...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
2801
2971
  });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Open a stored image in the system viewer (docs/tasks/feat-tui-image-full-size.md).
3
+ *
4
+ * Blobs are content-addressed and have no extension, and viewers recognise a
5
+ * file by its extension, so the verified bytes are copied once to
6
+ * `<tmpdir>/xeno-images/<sha256>.<ext>` and that file is opened. The name is
7
+ * the hash: opening the same picture again reuses the file, and a file under
8
+ * that name can only hold those bytes because we never write it twice.
9
+ */
10
+ import { mkdir, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { getBlobBytes } from "../store/blobs.ts";
14
+ import { MAX_IMAGE_SOURCE_BYTES } from "../core/image-limits.ts";
15
+ import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
16
+
17
+ const EXTENSIONS: Readonly<Record<string, string>> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp" };
18
+
19
+ /** The platform's "open with the default application" command. */
20
+ function launchViewer(path: string): void {
21
+ Bun.spawn([process.platform === "darwin" ? "open" : "xdg-open", path], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
22
+ }
23
+
24
+ /** Opens the blob; returns why it could not, or null once the viewer was started. */
25
+ export async function openImageFile(blobDir: string, sha256: string,
26
+ options: { dir?: string; launch?: (path: string) => void } = {}): Promise<string | null> {
27
+ let bytes: Uint8Array | null;
28
+ try { bytes = await getBlobBytes(blobDir, sha256, MAX_IMAGE_SOURCE_BYTES); } catch (error) { return `Cannot open image: ${error instanceof Error ? error.message : String(error)}`; }
29
+ if (bytes === null) return "Cannot open image: it is no longer in the session's blob store";
30
+ const mime = detectSupportedImageMimeType(bytes);
31
+ const extension = mime === null ? undefined : EXTENSIONS[mime];
32
+ if (extension === undefined) return "Cannot open image: unrecognised image format";
33
+ const dir = options.dir ?? join(tmpdir(), "xeno-images");
34
+ const path = join(dir, `${sha256}.${extension}`);
35
+ try {
36
+ await mkdir(dir, { recursive: true, mode: 0o700 });
37
+ // `wx`: an existing file is a previous copy of these same bytes.
38
+ await writeFile(path, bytes, { flag: "wx", mode: 0o600 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; });
39
+ (options.launch ?? launchViewer)(path);
40
+ } catch (error) { return `Cannot open image: ${error instanceof Error ? error.message : String(error)}`; }
41
+ return null;
42
+ }
@@ -23,7 +23,10 @@ import { imageData } from "./images.ts";
23
23
  import { assertNever } from "../core/exhaustive.ts";
24
24
  import type { OnDelta, Provider, ProviderConfig, ToolSchema, Turn } from "./provider.ts";
25
25
  import { pushSse, HttpError, StreamError } from "./openai.ts";
26
- import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
26
+ import { withRetry, retryLabel, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
27
+ import { ConnectionReuse } from "./connection-reuse.ts";
28
+ import { watchIdle, type IdleWatch } from "./stream-idle.ts";
29
+
27
30
  import type { ProviderSendOptions } from "./provider-tool-policy.ts";
28
31
 
29
32
  /** What this adapter parks in a thinking block's raw: the reasoning item, whole. */
@@ -254,25 +257,30 @@ export class OpenAIResponsesProvider implements Provider {
254
257
  readonly config: ProviderConfig;
255
258
  private readonly apiKey: string;
256
259
  private readonly fetchImpl: typeof fetch;
260
+ private readonly connections = new ConnectionReuse();
257
261
  private readonly retry: RetryPolicy;
258
262
  private readonly onRetry: ((attempt: number, delayMs: number, error: unknown) => void) | undefined;
263
+ private readonly streamIdleMs: number | undefined;
259
264
 
260
265
  constructor(config: ProviderConfig, apiKey: string, options: {
261
266
  fetchImpl?: typeof fetch; retry?: RetryPolicy; onRetry?: (attempt: number, delayMs: number, error: unknown) => void;
267
+ /** Silence after which one attempt is abandoned; tests shorten it (stream-idle.ts). */
268
+ streamIdleMs?: number;
262
269
  } = {}) {
263
270
  this.config = config;
264
271
  this.apiKey = apiKey;
265
272
  this.fetchImpl = options.fetchImpl ?? fetch;
266
273
  this.retry = options.retry ?? DEFAULT_RETRY;
267
274
  this.onRetry = options.onRetry;
275
+ this.streamIdleMs = options.streamIdleMs;
268
276
  }
269
277
 
270
278
  send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, options?: ProviderSendOptions): Promise<Turn> {
271
- return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta, options), this.retry, undefined,
272
- (attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs }); this.onRetry?.(attempt, delayMs, error); }, signal);
279
+ return withRetry(() => watchIdle(signal, this.streamIdleMs, idle => this.attempt(frozen, messages, node, idle, onDelta, options)).catch((error: unknown) => { this.connections.note(error); throw error; }), this.retry, undefined,
280
+ (attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs, max: this.retry.maxRetries, reason: retryLabel(error) }); this.onRetry?.(attempt, delayMs, error); }, signal);
273
281
  }
274
282
 
275
- private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, options?: ProviderSendOptions): Promise<Turn> {
283
+ private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, idle: IdleWatch, onDelta?: OnDelta, options?: ProviderSendOptions): Promise<Turn> {
276
284
  const started = Date.now();
277
285
  const baseUrl = this.config.spec.baseUrl;
278
286
  if (baseUrl === undefined) throw new Error(`model ${this.config.spec.id} has no baseUrl to send to`);
@@ -282,8 +290,8 @@ export class OpenAIResponsesProvider implements Provider {
282
290
  body: JSON.stringify({ ...buildResponsesRequest(this.config, frozen, messages), stream: true,
283
291
  ...(options?.toolUse === "disabled" ? { tool_choice: "none" } : {}) }),
284
292
  };
285
- if (signal !== undefined) init.signal = signal;
286
- const response = await this.fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, init);
293
+ init.signal = idle.signal;
294
+ const response = await this.fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, this.connections.init(init));
287
295
  if (!response.ok) {
288
296
  const text = await response.text();
289
297
  let detail = text.slice(0, 200);
@@ -291,6 +299,6 @@ export class OpenAIResponsesProvider implements Provider {
291
299
  throw new HttpError(response.status, response.headers, `HTTP ${response.status}: ${detail}`);
292
300
  }
293
301
  if (response.body === null) throw new Error(`${baseUrl} returned a streaming 200 with no body`);
294
- return readResponsesSse(response.body, node, started, onDelta);
302
+ return readResponsesSse(idle.body(response.body), node, started, onDelta);
295
303
  }
296
304
  }