@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.
- package/CHANGELOG.md +66 -0
- package/docs/install.md +25 -0
- package/package.json +1 -1
- package/src/apps/cli.ts +4 -1
- package/src/apps/shell.ts +13 -3
- package/src/apps/startup.ts +31 -6
- package/src/apps/ui/app.ts +32 -14
- package/src/apps/ui/project.ts +1 -1
- package/src/apps/ui/standalone.ts +4 -2
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/activity-status.ts +27 -3
- package/src/core/command-contract.ts +121 -5
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/environment.ts +7 -0
- package/src/core/event-validation.ts +19 -3
- package/src/core/events.ts +38 -2
- package/src/core/evidence.ts +149 -7
- package/src/core/export.ts +2 -0
- package/src/core/external-effects.ts +488 -0
- package/src/core/handoff.ts +24 -3
- package/src/core/history-query.ts +1 -1
- package/src/core/history.ts +131 -21
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +64 -3
- package/src/core/prompt-policy.ts +11 -2
- package/src/core/regex-dialect.ts +25 -0
- package/src/core/ripgrep-release.ts +64 -0
- package/src/core/secrets.ts +188 -0
- package/src/core/stall.ts +63 -18
- package/src/core/stop-report.ts +46 -4
- package/src/core/tool-contract.ts +2 -0
- package/src/core/tool-failure.ts +25 -4
- package/src/core/transcript.ts +3 -0
- package/src/core/update-check.ts +2 -1
- package/src/core/work-state.ts +7 -2
- package/src/core/working-set.ts +34 -6
- package/src/runtime/connection-reuse.ts +40 -0
- package/src/runtime/exec-sessions.ts +69 -19
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +196 -26
- package/src/runtime/open-image.ts +42 -0
- package/src/runtime/openai-responses.ts +15 -7
- package/src/runtime/openai.ts +15 -7
- package/src/runtime/permission-resources.ts +11 -4
- package/src/runtime/pi-provider.ts +34 -9
- package/src/runtime/provider.ts +19 -8
- package/src/runtime/retry.ts +152 -9
- package/src/runtime/secrets.ts +79 -0
- package/src/runtime/session-control.ts +9 -1
- package/src/runtime/stream-idle.ts +80 -0
- package/src/runtime/tools.ts +139 -31
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/command.ts +3 -1
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +6 -3
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/search.ts +9 -5
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/store/tool-binaries.ts +90 -0
- package/src/store/worktree.ts +55 -10
- package/src/tui/ui/image-size.ts +6 -2
- package/src/tui/ui/items.ts +3 -1
- package/src/tui/ui/pictures.ts +14 -1
- package/src/tui/ui/screen.ts +6 -3
- package/src/tui/ui/status.ts +16 -0
package/src/runtime/openai.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
9
|
-
const specific =
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/runtime/provider.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/runtime/retry.ts
CHANGED
|
@@ -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.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|