@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
@@ -25,7 +25,10 @@ import type { AssistantBlock } from "../core/events.ts";
25
25
  import { assertNever } from "../core/exhaustive.ts";
26
26
  import { coherentTurn, type Provider, type ProviderConfig, type Turn, type ToolSchema, type OnDelta } from "./provider.ts";
27
27
  import { DEFAULT_COMPAT, type Compat, type Effort, type ModelSpec } from "./model.ts";
28
- import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
28
+ import { withRetry, retryLabel, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
29
+ import { ConnectionReuse } from "./connection-reuse.ts";
30
+ import { watchIdle, type IdleWatch } from "./stream-idle.ts";
31
+
29
32
  import type { ProviderSendOptions } from "./provider-tool-policy.ts";
30
33
 
31
34
  /** One message as Chat Completions wants it. */
@@ -411,8 +414,10 @@ export class OpenAICompletionsProvider implements Provider {
411
414
  readonly config: ProviderConfig;
412
415
  private readonly apiKey: string;
413
416
  private readonly fetchImpl: typeof fetch;
417
+ private readonly connections = new ConnectionReuse();
414
418
  private readonly retry: RetryPolicy;
415
419
  private readonly onRetry: ((attempt: number, delayMs: number, error: unknown) => void) | undefined;
420
+ private readonly streamIdleMs: number | undefined;
416
421
 
417
422
  constructor(
418
423
  config: ProviderConfig,
@@ -421,6 +426,8 @@ export class OpenAICompletionsProvider implements Provider {
421
426
  fetchImpl?: typeof fetch;
422
427
  retry?: RetryPolicy;
423
428
  onRetry?: (attempt: number, delayMs: number, error: unknown) => void;
429
+ /** Silence after which one attempt is abandoned; tests shorten it (stream-idle.ts). */
430
+ streamIdleMs?: number;
424
431
  } = {},
425
432
  ) {
426
433
  this.config = config;
@@ -428,6 +435,7 @@ export class OpenAICompletionsProvider implements Provider {
428
435
  this.fetchImpl = options.fetchImpl ?? fetch;
429
436
  this.retry = options.retry ?? DEFAULT_RETRY;
430
437
  this.onRetry = options.onRetry;
438
+ this.streamIdleMs = options.streamIdleMs;
431
439
  }
432
440
 
433
441
  send(
@@ -441,12 +449,12 @@ export class OpenAICompletionsProvider implements Provider {
441
449
  // The clock starts inside the retried request, so a reported duration is
442
450
  // one attempt rather than the sum of the ones that failed.
443
451
  return withRetry(
444
- () => this.attempt(frozen, messages, node, signal, onDelta, options),
452
+ () => watchIdle(signal, this.streamIdleMs, idle => this.attempt(frozen, messages, node, idle, onDelta, options)).catch((error: unknown) => { this.connections.note(error); throw error; }),
445
453
  this.retry,
446
454
  undefined,
447
455
  (attempt, delayMs, error) => {
448
456
  onDelta?.({ kind: "discard" });
449
- onDelta?.({ kind: "retry", attempt, delayMs });
457
+ onDelta?.({ kind: "retry", attempt, delayMs, max: this.retry.maxRetries, reason: retryLabel(error) });
450
458
  this.onRetry?.(attempt, delayMs, error);
451
459
  },
452
460
  signal,
@@ -457,7 +465,7 @@ export class OpenAICompletionsProvider implements Provider {
457
465
  frozen: FrozenPrefix,
458
466
  messages: readonly Message[],
459
467
  node: number,
460
- signal?: AbortSignal,
468
+ idle: IdleWatch,
461
469
  onDelta?: OnDelta,
462
470
  options?: ProviderSendOptions,
463
471
  ): Promise<Turn> {
@@ -487,10 +495,10 @@ export class OpenAICompletionsProvider implements Provider {
487
495
  authorization: `Bearer ${this.apiKey}`,
488
496
  },
489
497
  body: JSON.stringify(payload),
498
+ signal: idle.signal,
490
499
  };
491
- if (signal !== undefined) init.signal = signal;
492
500
 
493
- const response = await this.fetchImpl(`${baseUrl.replace(/\/$/, "")}/chat/completions`, init);
501
+ const response = await this.fetchImpl(`${baseUrl.replace(/\/$/, "")}/chat/completions`, this.connections.init(init));
494
502
 
495
503
  if (!response.ok) {
496
504
  const text = await response.text();
@@ -501,7 +509,7 @@ export class OpenAICompletionsProvider implements Provider {
501
509
  throw new Error(`${baseUrl} returned a streaming 200 with no body`);
502
510
  }
503
511
 
504
- const assembled = await readCompletionsSse(response.body, onDelta);
512
+ const assembled = await readCompletionsSse(idle.body(response.body), onDelta);
505
513
  return coherentTurn(parseResponse(assembled, node, Date.now() - started), assembled.usage !== undefined);
506
514
  }
507
515
  }
@@ -1,11 +1,18 @@
1
1
  /** R13: an indirect content reader must retain the same read-resource policy. */
2
- import { decideWithSessionRules, type PermissionAction, type PermissionRule } from "../core/permissions.ts";
2
+ import { explainDecision, type DecisionExplanation, type PermissionAction, type PermissionRule } from "../core/permissions.ts";
3
3
  import { permissionPaths } from "../store/permission-paths.ts";
4
4
 
5
5
  export async function permissionResourceAction(root: string, path: string, configured: readonly PermissionRule[],
6
6
  remembered: readonly PermissionRule[] = [], tool = "read"): Promise<PermissionAction> {
7
+ return (await permissionResourceDecision(root, path, configured, remembered, tool)).action;
8
+ }
9
+
10
+ /** The same verdict, with the rule that produced it, for the durable approval record. */
11
+ export async function permissionResourceDecision(root: string, path: string, configured: readonly PermissionRule[],
12
+ remembered: readonly PermissionRule[] = [], tool = "read"): Promise<DecisionExplanation> {
7
13
  const paths = await permissionPaths(root, [path]);
8
- const read = decideWithSessionRules(configured, remembered, { tool: "read", paths });
9
- const specific = decideWithSessionRules(configured, remembered, { tool, paths });
10
- return read === "deny" || specific === "deny" ? "deny" : read === "ask" || specific === "ask" ? "ask" : "allow";
14
+ const read = explainDecision(configured, remembered, { tool: "read", paths });
15
+ const specific = explainDecision(configured, remembered, { tool, paths });
16
+ const rank = (action: PermissionAction) => action === "deny" ? 2 : action === "ask" ? 1 : 0;
17
+ return rank(specific.action) > rank(read.action) ? specific : read;
11
18
  }
@@ -21,7 +21,10 @@ import { grokVersionHint } from "./oauth.ts";
21
21
  import type { ResolveEffects } from "./auth.ts";
22
22
  import { imageData } from "./images.ts";
23
23
  import { coherentTurn, FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
24
- import { DEFAULT_RETRY, withRetry, type RetryPolicy } from "./retry.ts";
24
+ import { DEFAULT_RETRY, retryLabel, withRetry, type RetryPolicy } from "./retry.ts";
25
+ import { ConnectionReuse } from "./connection-reuse.ts";
26
+ import { watchIdle, type IdleWatch } from "./stream-idle.ts";
27
+
25
28
  import { HttpError, StreamError } from "./openai.ts";
26
29
  import type { UsageSample } from "./usage.ts";
27
30
  import { disablePayloadTools, type ProviderSendOptions } from "./provider-tool-policy.ts";
@@ -197,7 +200,8 @@ function piModel(spec: ModelSpec, baseUrl: string): Model<Api> {
197
200
  contextWindow: spec.contextWindow ?? 131_072, maxTokens: spec.maxTokens ?? 16_000,
198
201
  cost: { input: spec.price?.input ?? 0, output: spec.price?.output ?? 0, cacheRead: spec.price?.cacheRead ?? 0, cacheWrite: spec.price?.cacheWrite ?? 0 },
199
202
  ...(spec.thinkingLevelMap ? { thinkingLevelMap: spec.thinkingLevelMap } : {}),
200
- ...(spec.nativeCompat || spec.compat ? { compat: { ...spec.compat, ...spec.nativeCompat } } : {}),
203
+ ...(spec.nativeCompat || spec.compat || spec.api === "anthropic-messages"
204
+ ? { compat: { ...spec.compat, ...spec.nativeCompat, ...(spec.api === "anthropic-messages" ? { allowEmptySignature: true } : {}) } } : {}),
201
205
  ...(spec.samplingParams ? { samplingParams: spec.samplingParams } : {}) } as Model<Api>;
202
206
  }
203
207
 
@@ -214,6 +218,20 @@ function thinkingOf(raw: unknown): Extract<PiBlock, { type: "thinking" }> | null
214
218
  return null;
215
219
  }
216
220
 
221
+ /**
222
+ * Reasoning never enters the visible channel. Pi turns thinking it cannot replay natively into a
223
+ * text part, and a model shown its own reasoning as earlier visible output starts writing its
224
+ * reasoning as visible output (an unsigned Gemini block via an Anthropic gateway snowballed into
225
+ * monologue in every later reply). An unsigned block here was sent that way by the server --
226
+ * interrupted streams throw and are never logged -- so anthropic-messages replays it verbatim
227
+ * (`allowEmptySignature` in piModel, as the native adapter does). Bedrock has no unsigned
228
+ * reasoning form, so there the block is dropped. Cross-model text conversion cannot occur: a
229
+ * model switch cuts the node (Session.switchModel) and every replayed message names this model.
230
+ */
231
+ function replaysAsReasoning(api: Api, block: Extract<PiBlock, { type: "thinking" }>): boolean {
232
+ return api !== "bedrock-converse-stream" || block.redacted === true || !!block.thinkingSignature?.trim();
233
+ }
234
+
217
235
  /** Pure conversion of an already hydrated projection; no second transcript is retained. */
218
236
  export function piContext(frozen: FrozenPrefix, messages: readonly Message[], spec: ModelSpec): Context {
219
237
  const out: PiMessage[] = [];
@@ -228,7 +246,7 @@ export function piContext(frozen: FrozenPrefix, messages: readonly Message[], sp
228
246
  const wire = record(block.wire) ? block.wire : {};
229
247
  content.push({ type: "text", text: block.text, ...(typeof wire["textSignature"] === "string" ? { textSignature: wire["textSignature"] } : {}) }); break;
230
248
  }
231
- case "thinking": { const thinking = thinkingOf(block.raw); if (thinking) content.push(thinking); break; }
249
+ case "thinking": { const thinking = thinkingOf(block.raw); if (thinking && replaysAsReasoning(spec.api, thinking)) content.push(thinking); break; }
232
250
  case "tool_use": {
233
251
  names.set(block.id, block.name);
234
252
  const wire = record(block.wire) ? block.wire : {};
@@ -299,9 +317,12 @@ export function piBlocks(message: AssistantMessage, originalArguments: ReadonlyM
299
317
  export class PiProvider implements Provider {
300
318
  readonly config: ProviderConfig;
301
319
  private readonly apiKey: string;
320
+ private readonly connections = new ConnectionReuse();
302
321
  private readonly options: {
303
322
  fetch?: typeof fetch; effects?: ResolveEffects; oauth?: boolean; sessionId?: string;
304
323
  retry?: RetryPolicy; onRetry?: (attempt: number, delayMs: number, error: unknown) => void;
324
+ /** Silence after which one attempt is abandoned; tests shorten it (stream-idle.ts). */
325
+ streamIdleMs?: number;
305
326
  };
306
327
  constructor(config: ProviderConfig, apiKey: string, options: PiProvider["options"] = {}) {
307
328
  this.config = config; this.apiKey = apiKey; this.options = options;
@@ -310,14 +331,17 @@ export class PiProvider implements Provider {
310
331
  send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, policy?: ProviderSendOptions): Promise<Turn> {
311
332
  // Bedrock's Pi adapter leaves retry ownership with the AWS SDK; never multiply that loop.
312
333
  const retry = this.options.retry ?? (this.config.spec.api === "bedrock-converse-stream" ? { ...DEFAULT_RETRY, maxRetries: 0 } : DEFAULT_RETRY);
313
- return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta, policy), retry, undefined,
314
- (attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs }); this.options.onRetry?.(attempt, delayMs, error); }, signal);
334
+ return withRetry(() => watchIdle(signal, this.options.streamIdleMs, idle => this.attempt(frozen, messages, node, idle, signal, onDelta, policy)).catch((error: unknown) => { this.connections.note(error); throw error; }), retry, undefined,
335
+ (attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs, max: retry.maxRetries, reason: retryLabel(error) }); this.options.onRetry?.(attempt, delayMs, error); }, signal);
315
336
  }
316
337
 
317
- private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, policy?: ProviderSendOptions): Promise<Turn> {
338
+ private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, idle: IdleWatch, signal?: AbortSignal, onDelta?: OnDelta, policy?: ProviderSendOptions): Promise<Turn> {
318
339
  signal?.throwIfAborted();
319
340
  const started = Date.now();
320
- const request = providerRequest(this.config.spec, this.apiKey, this.options.effects, this.options.fetch, this.options.oauth);
341
+ // Wrapped innermost: providerRequest rebuilds the call as a Request and
342
+ // passes no init, so a keepalive set any further out would be dropped.
343
+ const request = providerRequest(this.config.spec, this.apiKey, this.options.effects,
344
+ this.connections.wrap(this.options.fetch ?? globalThis.fetch), this.options.oauth);
321
345
  const model = piModel(this.config.spec, request.baseUrl);
322
346
  let status: number | undefined;
323
347
  let responseHeaders = new Headers();
@@ -334,8 +358,9 @@ export class PiProvider implements Provider {
334
358
  ...(this.options.sessionId === undefined ? {} : { sessionId: this.options.sessionId }),
335
359
  env: request.env, headers: request.headers, ...(nativeHttp ? {} : { fetch: observedFetch }), maxRetries: 0, transport: "sse",
336
360
  maxTokens: this.config.maxTokens, ...(this.config.effort !== "off" ? { reasoning: this.config.effort } : {}),
337
- ...(signal ? { signal } : {}), onResponse: response => { status = response.status; responseHeaders = new Headers(response.headers); },
361
+ signal: idle.signal, onResponse: response => { idle.touch(); status = response.status; responseHeaders = new Headers(response.headers); },
338
362
  onProviderStreamEvent: event => {
363
+ idle.touch();
339
364
  originalArguments.observe(event);
340
365
  hasKnownUsage ||= nativeUsageReported(model.api, event);
341
366
  },
@@ -350,7 +375,7 @@ export class PiProvider implements Provider {
350
375
  // Pi emits fragments; xeno's progress contract replaces the displayed argument buffer.
351
376
  // Keep buffers per content block so interleaved calls and retries cannot share partial JSON.
352
377
  const toolArguments = new Map<number, string>();
353
- for await (const event of APIS[model.api].streamSimple(model, normalizeContext(piContext(frozen, messages, this.config.spec)), options)) {
378
+ for await (const event of idle.events(APIS[model.api].streamSimple(model, normalizeContext(piContext(frozen, messages, this.config.spec)), options))) {
354
379
  switch (event.type) {
355
380
  case "text_delta": onDelta?.({ kind: "text", text: event.delta }); break;
356
381
  case "thinking_delta": onDelta?.({ kind: "thinking", text: event.delta }); break;
@@ -38,7 +38,10 @@ import { assertNever } from "../core/exhaustive.ts";
38
38
  import { isToolStopWithoutCall } from "../core/response-recovery.ts";
39
39
  import type { UsageSample } from "./usage.ts";
40
40
  import { type Effort, type ModelSpec, CLAUDE_OPUS_5 } from "./model.ts";
41
- import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
41
+ import { withRetry, retryLabel, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
42
+ import { ConnectionReuse } from "./connection-reuse.ts";
43
+ import { watchIdle, type IdleWatch } from "./stream-idle.ts";
44
+
42
45
  import { anthropicOAuthHeaders } from "./oauth.ts";
43
46
  import { XENO_VERSION } from "./version.ts";
44
47
  import type { ProviderSendOptions } from "./provider-tool-policy.ts";
@@ -96,7 +99,9 @@ export type StreamDelta =
96
99
  | { kind: "text"; text: string }
97
100
  | { kind: "thinking"; text: string }
98
101
  | { kind: "tool"; id: string; name: string; arguments: string }
99
- | { kind: "retry"; attempt: number; delayMs: number }
102
+ // `max` is the policy's budget and `reason` a few words from retryLabel;
103
+ // optional so a view written against the older shape still renders.
104
+ | { kind: "retry"; attempt: number; delayMs: number; max?: number; reason?: string }
100
105
  | { kind: "discard" };
101
106
 
102
107
  export type OnDelta = (delta: StreamDelta) => void;
@@ -228,6 +233,8 @@ function buildAnthropicClient(
228
233
  export class AnthropicProvider implements Provider {
229
234
  readonly config: ProviderConfig;
230
235
  private readonly client: AnthropicTransport;
236
+ private readonly connections = new ConnectionReuse();
237
+ private readonly streamIdleMs: number | undefined;
231
238
  private readonly retry: RetryPolicy;
232
239
  private readonly onRetry: ((attempt: number, delayMs: number, error: unknown) => void) | undefined;
233
240
 
@@ -247,6 +254,8 @@ export class AnthropicProvider implements Provider {
247
254
  fetch?: typeof fetch;
248
255
  /** OAuth access token: Bearer + oauth-2025-04-20, never x-api-key (D18). */
249
256
  oauth?: boolean;
257
+ /** Silence after which one attempt is abandoned; tests shorten it (stream-idle.ts). */
258
+ streamIdleMs?: number;
250
259
  } = {},
251
260
  ) {
252
261
  this.config = config;
@@ -256,9 +265,11 @@ export class AnthropicProvider implements Provider {
256
265
  // it, so the usage report would quietly stop adding up. The SDK timer
257
266
  // also ignores AbortSignal, so an outer wait is the only one Escape
258
267
  // can actually cancel.
259
- this.client = options.client ?? buildAnthropicClient(apiKey, { ...options, ...(config.spec.baseUrl ? { baseUrl: config.spec.baseUrl } : {}) });
268
+ this.client = options.client ?? buildAnthropicClient(apiKey, { ...options, fetch: this.connections.wrap(options.fetch ?? fetch),
269
+ ...(config.spec.baseUrl ? { baseUrl: config.spec.baseUrl } : {}) });
260
270
  this.retry = options.retry ?? DEFAULT_RETRY;
261
271
  this.onRetry = options.onRetry;
272
+ this.streamIdleMs = options.streamIdleMs;
262
273
  }
263
274
 
264
275
  send(
@@ -270,14 +281,14 @@ export class AnthropicProvider implements Provider {
270
281
  options?: ProviderSendOptions,
271
282
  ): Promise<Turn> {
272
283
  return withRetry(
273
- () => this.attempt(frozen, messages, node, signal, onDelta, options),
284
+ () => watchIdle(signal, this.streamIdleMs, idle => this.attempt(frozen, messages, node, idle, onDelta, options)).catch((error: unknown) => { this.connections.note(error); throw error; }),
274
285
  this.retry,
275
286
  undefined,
276
287
  (attempt, delayMs, error) => {
277
288
  // The next attempt starts from empty. Without this the UI concatenates
278
289
  // two openings of the same sentence and has no sign a retry happened.
279
290
  onDelta?.({ kind: "discard" });
280
- onDelta?.({ kind: "retry", attempt, delayMs });
291
+ onDelta?.({ kind: "retry", attempt, delayMs, max: this.retry.maxRetries, reason: retryLabel(error) });
281
292
  this.onRetry?.(attempt, delayMs, error);
282
293
  },
283
294
  signal,
@@ -288,7 +299,7 @@ export class AnthropicProvider implements Provider {
288
299
  frozen: FrozenPrefix,
289
300
  messages: readonly Message[],
290
301
  node: number,
291
- signal?: AbortSignal,
302
+ idle: IdleWatch,
292
303
  onDelta?: OnDelta,
293
304
  options?: ProviderSendOptions,
294
305
  ): Promise<Turn> {
@@ -322,11 +333,11 @@ export class AnthropicProvider implements Provider {
322
333
  : {}),
323
334
  messages: messages.map(toWireMessage),
324
335
  },
325
- signal ? { signal } : undefined,
336
+ { signal: idle.signal },
326
337
  );
327
338
 
328
339
  const assembled = isAsyncIterable(response)
329
- ? await consumeAnthropicStream(response, onDelta)
340
+ ? await consumeAnthropicStream(idle.events(response), onDelta)
330
341
  : {
331
342
  blocks: response.content.map(fromWireBlock),
332
343
  stopReason: response.stop_reason,
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { isContextOverflow } from "../core/overflow.ts";
22
+ import type { FailureCause } from "../core/events.ts";
22
23
 
23
24
  /**
24
25
  * Words providers and relays use for "try again": timeouts, unavailable or
@@ -28,11 +29,123 @@ import { isContextOverflow } from "../core/overflow.ts";
28
29
  */
29
30
  const TRANSIENT_STREAM = /time[d ]?out|timed out|unavailable|overload|rate.?limit|too many requests|connection (?:reset|closed|refused)|ECONNRESET|temporar|\b5\d\d\b|internal.?server|bad gateway|service.?unavailable/i;
30
31
 
32
+ /**
33
+ * Connection failures, named by code rather than guessed from prose.
34
+ *
35
+ * Bun's fetch reports every one of these as a plain Error carrying `code`
36
+ * (measured on 1.3.10: a reset before the headers, one halfway through the
37
+ * body and a refused port all arrive that way), never as the TypeError the
38
+ * WHATWG spec describes -- so the TypeError rule below never sees them. SDKs
39
+ * wrap the same error as `cause`. The two sets follow Claude Code's split:
40
+ * "stale" is a connection that existed and died, "network_down" is one that
41
+ * could never be opened. Both are worth another attempt.
42
+ */
43
+ const STALE_CONNECTION_CODES = new Set([
44
+ "ECONNRESET", "EPIPE", "ECONNABORTED", "ETIMEDOUT", "ConnectionClosed",
45
+ "ERR_SOCKET_CLOSED", "UND_ERR_SOCKET", "UND_ERR_CLOSED",
46
+ ]);
47
+ const NETWORK_DOWN_CODES = new Set([
48
+ "ECONNREFUSED", "ENOTFOUND", "ENETUNREACH", "ENETDOWN", "EHOSTUNREACH", "EHOSTDOWN",
49
+ "EAI_AGAIN", "ConnectionRefused", "FailedToOpenSocket", "ERR_PROXY_TUNNEL",
50
+ ]);
51
+
52
+ /**
53
+ * The same failures in the words Bun, Node and undici use, for adapters that
54
+ * forward only the message (pi-ai turns a dropped stream into errorMessage
55
+ * and the code is gone by the time it reaches here).
56
+ */
57
+ const STALE_CONNECTION_TEXT = /socket connection was closed unexpectedly|socket hang up|other side closed|\bECONNRESET\b|\bEPIPE\b/i;
58
+ const NETWORK_DOWN_TEXT = /unable to connect\. is the computer able to access the url|\bECONNREFUSED\b|\bENOTFOUND\b|\bEAI_AGAIN\b/i;
59
+
60
+ export type ConnectionFailure = "stale" | "network_down";
61
+
62
+ function codeOf(value: unknown): string | undefined {
63
+ if (typeof value !== "object" || value === null) return undefined;
64
+ const code = (value as { code?: unknown }).code;
65
+ return typeof code === "string" ? code : undefined;
66
+ }
67
+
68
+ /** TLS verification codes from Node, Bun and undici; Claude Code also leaves these unretried. */
69
+ const CERTIFICATE_CODE = /^(?:CERT_|UNABLE_TO_VERIFY_|UNABLE_TO_GET_ISSUER_CERT)|^(?:SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|ERR_TLS_CERT_ALTNAME_INVALID)$/;
70
+
71
+ function certificateFailure(error: unknown): boolean {
72
+ const cause = typeof error === "object" && error !== null ? (error as { cause?: unknown }).cause : undefined;
73
+ return [codeOf(error), codeOf(cause)].some((code) => code !== undefined && CERTIFICATE_CODE.test(code));
74
+ }
75
+
76
+ /**
77
+ * A few words for the retry line: what went wrong, not the full message.
78
+ * Bun's text runs to a sentence about `verbose: true`, which the status line
79
+ * cannot hold; the full message still reaches the user if the retries run out.
80
+ */
81
+ export function retryLabel(error: unknown): string {
82
+ const status = statusOf(error);
83
+ if (status === 429) return "rate limited";
84
+ if (status !== undefined) return `HTTP ${status}`;
85
+ const connection = connectionFailure(error);
86
+ if (connection === "stale") return "connection lost";
87
+ if (connection === "network_down") return "network unreachable";
88
+ if (isStreamIdle(error)) return "stream stalled";
89
+ if (isMalformedResponse(error)) return "malformed response";
90
+ if (isStreamFailure(error)) return "stream error";
91
+ return error instanceof TypeError ? "connection failed" : "request failed";
92
+ }
93
+
94
+ /**
95
+ * What a failed request leaves in the log. `retries` is present only for a
96
+ * provider failure: it is what the adapter reported, not what the policy allowed.
97
+ * A harness error contributes its class name alone -- the message may quote a
98
+ * path, a command or a credential, and the class is chosen by code.
99
+ */
100
+ export function failureCause(error: unknown, provider: { retries: number } | undefined): FailureCause {
101
+ if (provider) {
102
+ const status = statusOf(error);
103
+ return { source: "provider", label: retryLabel(error), ...(status === undefined ? {} : { status }), retries: provider.retries };
104
+ }
105
+ const name = error instanceof Error ? error.name : "";
106
+ return { source: "harness", label: /^[A-Za-z][\w.]{0,63}$/.test(name) ? name : "Error" };
107
+ }
108
+
109
+ /** The words a person reads: "HTTP 503 after 3 retries", "connection lost". */
110
+ export function describeFailure(cause: FailureCause): string {
111
+ if (cause.source === "harness") return `xeno internal error (${cause.label})`;
112
+ const retries = cause.retries ?? 0;
113
+ return retries === 0 ? cause.label : `${cause.label} after ${retries} ${retries === 1 ? "retry" : "retries"}`;
114
+ }
115
+
116
+ /** Whether this error is a connection that died or one that never opened. */
117
+ export function connectionFailure(error: unknown): ConnectionFailure | undefined {
118
+ const cause = typeof error === "object" && error !== null ? (error as { cause?: unknown }).cause : undefined;
119
+ for (const code of [codeOf(error), codeOf(cause)]) {
120
+ if (code === undefined) continue;
121
+ if (STALE_CONNECTION_CODES.has(code)) return "stale";
122
+ if (NETWORK_DOWN_CODES.has(code)) return "network_down";
123
+ }
124
+ for (const value of [error, cause]) {
125
+ if (!(value instanceof Error)) continue;
126
+ if (STALE_CONNECTION_TEXT.test(value.message)) return "stale";
127
+ if (NETWORK_DOWN_TEXT.test(value.message)) return "network_down";
128
+ }
129
+ return undefined;
130
+ }
131
+
31
132
  /** Structural, so retry.ts need not import an adapter to recognise its error. */
32
133
  function isStreamFailure(error: unknown): error is Error & { code?: string } {
33
134
  return error instanceof Error && error.name === "StreamError";
34
135
  }
35
136
 
137
+ /** stream-idle.ts StreamIdleError: an attempt abandoned after the stream went silent. */
138
+ function isStreamIdle(error: unknown): boolean {
139
+ return error instanceof Error && error.name === "StreamIdleError";
140
+ }
141
+
142
+ /**
143
+ * Retries a silent stream gets. Fewer than the general budget because each
144
+ * one has already cost the idle limit (300s): ten would be fifty minutes.
145
+ * Codex CLI allows 5 stream retries and Claude Code 1; 2 is an assumption.
146
+ */
147
+ const STREAM_IDLE_RETRIES = 2;
148
+
36
149
  /** provider.ts MalformedResponseError: a self-contradictory 200, structural for the same reason. */
37
150
  function isMalformedResponse(error: unknown): boolean {
38
151
  return error instanceof Error && error.name === "MalformedResponseError";
@@ -40,9 +153,12 @@ function isMalformedResponse(error: unknown): boolean {
40
153
 
41
154
  export interface RetryPolicy {
42
155
  /**
43
- * Attempts after the first. Four means a request can take about 15s of
44
- * backoff before failing, which covers a rate-limit window without making a
45
- * broken request take a minute to report itself.
156
+ * Attempts after the first. Ten, doubling to a 32s ceiling, is Claude
157
+ * Code's budget: about 160s of backoff before failing. Four (~15s) gave up
158
+ * inside an ordinary network blip or proxy reconnect (Horizyn 2026-10-12,
159
+ * feat-retry-budget-and-progress). The wait is visible -- a countdown and
160
+ * attempt n/max -- and Escape ends it, so a longer budget is not a silent
161
+ * hang. The ceiling is an assumption carried over, not measured here.
46
162
  */
47
163
  maxRetries: number;
48
164
  /**
@@ -53,7 +169,18 @@ export interface RetryPolicy {
53
169
  maxRetryDelayMs: number;
54
170
  }
55
171
 
56
- export const DEFAULT_RETRY: RetryPolicy = { maxRetries: 4, maxRetryDelayMs: 60_000 };
172
+ export const DEFAULT_RETRY: RetryPolicy = { maxRetries: 10, maxRetryDelayMs: 60_000 };
173
+
174
+ /** Longest backoff between attempts when the server names no delay. */
175
+ const BACKOFF_CEILING_MS = 32_000;
176
+
177
+ /**
178
+ * 0.5s doubling to the ceiling, then flat. `jitter` in [0, 1) only shortens
179
+ * the wait, by at most a quarter (see retryDecision).
180
+ */
181
+ function backoffMs(attempt: number, jitter: number): number {
182
+ return Math.min(500 * 2 ** attempt, BACKOFF_CEILING_MS) * (1 - jitter * 0.25);
183
+ }
57
184
 
58
185
  export type RetryDecision =
59
186
  | { retry: true; delayMs: number }
@@ -140,6 +267,25 @@ export function retryDecision(
140
267
  return { retry: false, reason: "the prompt exceeds the model's context window" };
141
268
  }
142
269
 
270
+ // A certificate is the same on every attempt; retrying only delays the one
271
+ // message that sends the user to their proxy or CA bundle. Checked before
272
+ // the TypeError rule, which undici uses to wrap exactly this failure.
273
+ if (status === undefined && certificateFailure(error)) {
274
+ return { retry: false, reason: "the server's certificate could not be verified" };
275
+ }
276
+
277
+ if (isStreamIdle(error)) {
278
+ return attempt < STREAM_IDLE_RETRIES
279
+ ? { retry: true, delayMs: backoffMs(attempt, jitter) }
280
+ : { retry: false, reason: `the stream went silent ${attempt + 1} times` };
281
+ }
282
+
283
+ // A connection that died or never opened says nothing about the request,
284
+ // whichever wrapper carried it here.
285
+ if (status === undefined && connectionFailure(error) !== undefined) {
286
+ return { retry: true, delayMs: backoffMs(attempt, jitter) };
287
+ }
288
+
143
289
  // A provider that knows whether its own error is worth retrying gets the
144
290
  // last word, in both directions.
145
291
  // A failure reported inside a 200 stream carries no status. Relays and
@@ -151,8 +297,7 @@ export function retryDecision(
151
297
  if (isStreamFailure(error) && !TRANSIENT_STREAM.test(`${error.code ?? ""} ${error.message}`)) {
152
298
  return { retry: false, reason: "the stream reported a non-transient error" };
153
299
  }
154
- const base = Math.min(0.5 * 2 ** attempt, 8) * 1000;
155
- return { retry: true, delayMs: base * (1 - jitter * 0.25) };
300
+ return { retry: true, delayMs: backoffMs(attempt, jitter) };
156
301
  }
157
302
 
158
303
  const advice = headers?.get("x-should-retry");
@@ -188,9 +333,7 @@ export function retryDecision(
188
333
  return { retry: true, delayMs: requested };
189
334
  }
190
335
 
191
- // 0.5s doubling to a ceiling of 8s, then flat.
192
- const base = Math.min(0.5 * 2 ** attempt, 8) * 1000;
193
- return { retry: true, delayMs: base * (1 - jitter * 0.25) };
336
+ return { retry: true, delayMs: backoffMs(attempt, jitter) };
194
337
  }
195
338
 
196
339
  export interface RetryEffects {
@@ -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
  }