@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential redaction at the log boundary (S.9).
|
|
3
|
+
*
|
|
4
|
+
* The log is the only truth and every model request is a projection of it, so
|
|
5
|
+
* a credential that reaches the log is resent on every later request and
|
|
6
|
+
* survives every cut through `objective` and `user_turns` (D2). Masking at
|
|
7
|
+
* projection time would leave the original in an append-only file forever.
|
|
8
|
+
* Redaction therefore happens before an event is written: the secret is
|
|
9
|
+
* replaced by a placeholder, and the value lives in a separate per-session
|
|
10
|
+
* vault that no projection reads.
|
|
11
|
+
*
|
|
12
|
+
* The placeholder is an exact inverse: tools expand it back to the value when
|
|
13
|
+
* they run (OpenHands' SecretRegistry does the same with secret names), and
|
|
14
|
+
* any output that echoes a known value is masked again. A false positive
|
|
15
|
+
* therefore costs readability, never correctness -- an edit whose old_string
|
|
16
|
+
* holds a placeholder still matches the file.
|
|
17
|
+
*
|
|
18
|
+
* Only high-confidence shapes are detected (gitleaks prefix rules plus
|
|
19
|
+
* "secret-named key = value"); entropy detection is deliberately absent because
|
|
20
|
+
* its false positives on hashes and code are irreversible in a live view.
|
|
21
|
+
* A bare password has no shape, so it is recognised only by context: a short
|
|
22
|
+
* token the user sends right after the assistant asked for a credential.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface SecretEntry {
|
|
26
|
+
/** The placeholder's number: `[[xeno-secret-<n>]]`. */
|
|
27
|
+
n: number;
|
|
28
|
+
value: string;
|
|
29
|
+
/** Which rule found it, for the audit trail; never the value. */
|
|
30
|
+
rule: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type Vault = readonly SecretEntry[];
|
|
34
|
+
|
|
35
|
+
export const placeholder = (n: number): string => `[[xeno-secret-${n}]]`;
|
|
36
|
+
const PLACEHOLDER = /\[\[xeno-secret-(\d+)\]\]/g;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A known value shorter than this is too likely to occur by accident ("admin",
|
|
40
|
+
* "123456") to mask everywhere it appears. Assumed, not measured; Codex's
|
|
41
|
+
* key-assignment rule uses the same 8.
|
|
42
|
+
*/
|
|
43
|
+
export const MIN_SECRET_LENGTH = 8;
|
|
44
|
+
|
|
45
|
+
interface Rule { id: string; pattern: RegExp; /** Capture group holding the secret; 0 is the whole match. */ group: number }
|
|
46
|
+
|
|
47
|
+
// Prefix rules follow gitleaks' default config (b58d3f10) and Gemini CLI's
|
|
48
|
+
// environmentSanitization.ts; tskey- is added for Tailscale, which gitleaks lacks.
|
|
49
|
+
const RULES: readonly Rule[] = [
|
|
50
|
+
{ id: "pem-private-key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g, group: 0 },
|
|
51
|
+
{ id: "anthropic-key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, group: 0 },
|
|
52
|
+
{ id: "openai-key", pattern: /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}/g, group: 0 },
|
|
53
|
+
{ id: "github-token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g, group: 0 },
|
|
54
|
+
{ id: "gitlab-token", pattern: /\bglpat-[A-Za-z0-9_-]{20,}/g, group: 0 },
|
|
55
|
+
{ id: "slack-token", pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g, group: 0 },
|
|
56
|
+
{ id: "aws-access-key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, group: 0 },
|
|
57
|
+
{ id: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{35}/g, group: 0 },
|
|
58
|
+
{ id: "stripe-key", pattern: /\b[sr]k_live_[0-9A-Za-z]{24,}/g, group: 0 },
|
|
59
|
+
// tskey-<kind>-<id with a digit>-<secret>; documentation like "tskey-auth-reusable-" has no digit in the id.
|
|
60
|
+
{ id: "tailscale-key", pattern: /\btskey-[a-z]+-[A-Za-z0-9]*\d[A-Za-z0-9]*-[A-Za-z0-9]{10,}/g, group: 0 },
|
|
61
|
+
{ id: "npm-token", pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, group: 0 },
|
|
62
|
+
{ id: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, group: 0 },
|
|
63
|
+
{ id: "bearer-token", pattern: /\bBearer\s+([A-Za-z0-9._~+/=-]{20,})/g, group: 1 },
|
|
64
|
+
{ id: "url-password", pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:([^\s/@]{3,})@/gi, group: 1 },
|
|
65
|
+
// A secret-named key assigned a literal. The value class excludes quotes,
|
|
66
|
+
// brackets and parentheses so `token = getToken()` and `password: string`
|
|
67
|
+
// stay code; requiring a digit or 16+ characters keeps `secret: undefined`.
|
|
68
|
+
{ id: "secret-assignment", group: 1,
|
|
69
|
+
// Up to three prefix words, so access_token, HUILIANYI_REFRESH_TOKEN and db-password count.
|
|
70
|
+
pattern: /\b(?:[a-z0-9]+[_-]){0,3}(?:api[_-]?key|access[_-]?key|secret(?:[_-]?key)?|client[_-]?secret|token|password|passwd|pwd)["']?\s*[:=]\s*["']?([^\s"'`(){}[\]<>;,]{8,})/gi },
|
|
71
|
+
{ id: "secret-assignment-zh", group: 1, pattern: /(?:密码|口令|密钥|令牌)\s*(?:是|为|[::=])\s*["'“]?([^\s"'”,。,;;]{4,})/g },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const plausibleAssigned = (value: string): boolean =>
|
|
75
|
+
!/^(?:\$|process\.|os\.|env\.|self\.|this\.)/.test(value) && !value.startsWith("[[xeno-secret-") &&
|
|
76
|
+
// A pure number after a token-named key is a count (input_tokens=12000000), not a credential.
|
|
77
|
+
!/^\d+$/.test(value) &&
|
|
78
|
+
// Credentials carry digits; a digit-free value is a path, a member access or a placeholder word
|
|
79
|
+
// (replayed 2026-10-12 sessions: "internal/server/…", "auth.AccessToken"). A long mixed-case
|
|
80
|
+
// value without separators is the one digit-free exception.
|
|
81
|
+
(/\d/.test(value) && /[A-Za-z]/.test(value) || value.length >= 20 && /[A-Z]/.test(value) && /[a-z]/.test(value) && !/[./]/.test(value));
|
|
82
|
+
|
|
83
|
+
/** Every secret-shaped span in `text`, with the rule that found it. Overlaps resolve to the earliest, longest. */
|
|
84
|
+
export function findSecrets(text: string): { start: number; end: number; rule: string }[] {
|
|
85
|
+
const spans: { start: number; end: number; rule: string }[] = [];
|
|
86
|
+
for (const rule of RULES) {
|
|
87
|
+
for (const match of text.matchAll(rule.pattern)) {
|
|
88
|
+
const value = match[rule.group];
|
|
89
|
+
if (value === undefined || match.index === undefined) continue;
|
|
90
|
+
if (rule.id === "secret-assignment" && !plausibleAssigned(value)) continue;
|
|
91
|
+
const start = match.index + match[0].lastIndexOf(value);
|
|
92
|
+
spans.push({ start, end: start + value.length, rule: rule.id });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
spans.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
96
|
+
const kept: typeof spans = [];
|
|
97
|
+
for (const span of spans) if (kept.length === 0 || span.start >= kept.at(-1)!.end) kept.push(span);
|
|
98
|
+
return kept;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Replaces known values and newly found secrets with placeholders. Returns the
|
|
103
|
+
* vault entries it had to add; the caller persists them before the text is
|
|
104
|
+
* written anywhere, so a placeholder in the log always has a value to expand to.
|
|
105
|
+
*/
|
|
106
|
+
export function redactText(text: string, vault: Vault, candidates: readonly { value: string; rule: string }[] = []): { text: string; added: SecretEntry[] } {
|
|
107
|
+
if (text.length === 0) return { text, added: [] };
|
|
108
|
+
const added: SecretEntry[] = [];
|
|
109
|
+
let next = vault.reduce((max, entry) => Math.max(max, entry.n), 0) + 1;
|
|
110
|
+
// Candidates (secret-named environment values) join the vault only when they
|
|
111
|
+
// actually appear, so a placeholder number is never spent on a value no event holds.
|
|
112
|
+
const known: SecretEntry[] = [...vault];
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
if (candidate.value.length < MIN_SECRET_LENGTH || !text.includes(candidate.value) || known.some(entry => entry.value === candidate.value)) continue;
|
|
115
|
+
const entry = { n: next++, value: candidate.value, rule: candidate.rule };
|
|
116
|
+
known.push(entry); added.push(entry);
|
|
117
|
+
}
|
|
118
|
+
let out = maskKnown(text, known);
|
|
119
|
+
const spans = findSecrets(out);
|
|
120
|
+
if (spans.length === 0) return { text: out, added };
|
|
121
|
+
let result = "", at = 0;
|
|
122
|
+
for (const span of spans) {
|
|
123
|
+
const value = out.slice(span.start, span.end);
|
|
124
|
+
const existing = known.find(entry => entry.value === value);
|
|
125
|
+
const entry = existing ?? { n: next++, value, rule: span.rule };
|
|
126
|
+
if (!existing) { known.push(entry); added.push(entry); }
|
|
127
|
+
result += out.slice(at, span.start) + placeholder(entry.n);
|
|
128
|
+
at = span.end;
|
|
129
|
+
}
|
|
130
|
+
// Values found in this pass may occur again where a neighbouring match split
|
|
131
|
+
// them (two tokens with no delimiter); a second known-value pass closes that.
|
|
132
|
+
return { text: maskKnown(result + out.slice(at), known), added };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Longest first, so a value containing another is masked whole. A short value is masked only where a rule found it. */
|
|
136
|
+
function maskKnown(text: string, known: readonly SecretEntry[]): string {
|
|
137
|
+
let out = text;
|
|
138
|
+
for (const entry of known.filter(entry => entry.value.length >= MIN_SECRET_LENGTH).sort((a, b) => b.value.length - a.value.length)) {
|
|
139
|
+
if (out.includes(entry.value)) out = out.split(entry.value).join(placeholder(entry.n));
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The inverse of redactText for tool inputs. An unknown placeholder is left as written. */
|
|
145
|
+
export function expandText(text: string, vault: Vault): string {
|
|
146
|
+
if (!text.includes("[[xeno-secret-")) return text;
|
|
147
|
+
return text.replace(PLACEHOLDER, (whole, n: string) => vault.find(entry => entry.n === Number(n))?.value ?? whole);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ASKED_FOR_CREDENTIAL = /password|passcode|passphrase|credential|api[ _-]?key|access[ _-]?token|\btoken\b|密码|口令|密钥|凭据|凭证|令牌|验证码/i;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A bare password has no shape; what identifies it is the exchange. A message
|
|
154
|
+
* that is a single token with mixed character classes, sent right after the
|
|
155
|
+
* assistant asked for a credential, is that credential (the 2026-10-12 ticket
|
|
156
|
+
* session: the assistant asked for a login password, the user replied with it).
|
|
157
|
+
*/
|
|
158
|
+
export function isSolicitedSecret(userText: string, previousAssistantText: string | undefined): boolean {
|
|
159
|
+
const value = userText.trim();
|
|
160
|
+
if (previousAssistantText === undefined || !ASKED_FOR_CREDENTIAL.test(previousAssistantText)) return false;
|
|
161
|
+
if (!/^\S{6,128}$/.test(value) || value.startsWith("[[xeno-secret-")) return false;
|
|
162
|
+
// Letters with digits, or three character classes: "dsm612566" and "Pa$$word"
|
|
163
|
+
// qualify, an identifier like "OPENAI_API_KEY" or a branch name does not.
|
|
164
|
+
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter(pattern => pattern.test(value)).length;
|
|
165
|
+
const lettersAndDigits = /[A-Za-z]/.test(value) && /\d/.test(value);
|
|
166
|
+
return (lettersAndDigits || classes >= 3) && !/^[a-z]+:\/\//i.test(value) && !/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Applies `redact` to every string in a JSON value, preserving its shape. */
|
|
170
|
+
export function mapStrings(value: unknown, redact: (text: string) => string, skip: (key: string) => boolean = () => false): unknown {
|
|
171
|
+
if (typeof value === "string") return redact(value);
|
|
172
|
+
if (Array.isArray(value)) return value.map(item => mapStrings(item, redact, skip));
|
|
173
|
+
if (value !== null && typeof value === "object") {
|
|
174
|
+
const out: Record<string, unknown> = {};
|
|
175
|
+
for (const [key, item] of Object.entries(value)) out[key] = skip(key) ? item : mapStrings(item, redact, skip);
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Environment variables whose names say they hold a secret; their values are masked wherever they appear. */
|
|
182
|
+
export function secretEnvironmentValues(env: Readonly<Record<string, string | undefined>>): { value: string; rule: string }[] {
|
|
183
|
+
return Object.entries(env).flatMap(([name, value]) =>
|
|
184
|
+
value !== undefined && value.length >= MIN_SECRET_LENGTH && !value.startsWith("/") &&
|
|
185
|
+
/(?:^|_)(?:API_?KEY|KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIALS?|AUTH)(?:_|$)/i.test(name) &&
|
|
186
|
+
!/(?:^|_)(?:PATH|DIR|FILE|URL|HOST|PORT|USER|NAME|ID|MODE|TYPE)$/i.test(name)
|
|
187
|
+
? [{ value, rule: `env:${name}` }] : []).sort((a, b) => a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0);
|
|
188
|
+
}
|
package/src/core/stall.ts
CHANGED
|
@@ -6,23 +6,60 @@ import { deriveProgress, type ProgressEvidence } from "./progress.ts";
|
|
|
6
6
|
import { isProcessReceipt } from "./process-receipt.ts";
|
|
7
7
|
import { canonicalValue } from "./continuation.ts";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* S.8: the watchdog is now an on/off switch -- 0 disables it, any other
|
|
11
|
+
* allowed value enables it. Sessions persist this field (S.3); a stored 12
|
|
12
|
+
* still means "on" and is never reread as "twelve repeats".
|
|
13
|
+
*/
|
|
11
14
|
export const DEFAULT_STALL_LIMIT = 12;
|
|
15
|
+
/**
|
|
16
|
+
* How many times a cycle must repeat. A hypothesis, not measured: OpenCode's
|
|
17
|
+
* doom loop, Cline and Roo use 3; OpenHands 4 counting the first, novel,
|
|
18
|
+
* observation; Gemini 5. Lower asks too often, higher pays for more repeats.
|
|
19
|
+
*/
|
|
20
|
+
export const CYCLE_REPEATS = 3;
|
|
21
|
+
/** Longest cycle recognised; Gemini CLI's bound, and the user's (S.8). */
|
|
22
|
+
export const MAX_CYCLE = 5;
|
|
23
|
+
|
|
24
|
+
export interface StallExchange { readonly signature: string; readonly progressed: boolean }
|
|
12
25
|
export interface StallState {
|
|
13
26
|
evidence: readonly ProgressEvidence[];
|
|
14
27
|
pending: readonly LogEvent[];
|
|
15
28
|
pendingNode: number;
|
|
16
|
-
|
|
17
|
-
|
|
29
|
+
/** The request's latest exchanges, oldest first, at most MAX_CYCLE × CYCLE_REPEATS. */
|
|
30
|
+
recent: readonly StallExchange[];
|
|
18
31
|
failures: number;
|
|
19
32
|
emptyWaits: number;
|
|
20
|
-
|
|
33
|
+
/** A cycle was already pointed out to the model; the next one goes to the user. */
|
|
34
|
+
nudged: boolean;
|
|
21
35
|
/** The previous exchange's tool calls, canonical; empty after real user input. */
|
|
22
36
|
previous: string;
|
|
37
|
+
/**
|
|
38
|
+
* A completion review just asked the model to re-read the request and
|
|
39
|
+
* verify. Its answer is the harness's own request, so that one exchange
|
|
40
|
+
* stays out of the cycle window (fix-review-induced-stall).
|
|
41
|
+
*/
|
|
42
|
+
reviewed: boolean;
|
|
23
43
|
}
|
|
24
44
|
export function emptyStallState(): StallState {
|
|
25
|
-
return { evidence: [], pending: [], pendingNode: 0,
|
|
45
|
+
return { evidence: [], pending: [], pendingNode: 0, recent: [], failures: 0, emptyWaits: 0, nudged: false, previous: "", reviewed: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The shortest period k <= MAX_CYCLE such that the last k × repeats exchanges
|
|
50
|
+
* are one k-call cycle repeated, none of which brought new evidence -- or null.
|
|
51
|
+
* Distinct calls that each tell nothing new are not a loop: that is reading
|
|
52
|
+
* and re-checking, which a cumulative allowance wrongly punished (S.8).
|
|
53
|
+
*/
|
|
54
|
+
export function repeatedCycle(recent: readonly StallExchange[], repeats = CYCLE_REPEATS): number | null {
|
|
55
|
+
for (let k = 1; k <= MAX_CYCLE; k++) {
|
|
56
|
+
const n = k * repeats;
|
|
57
|
+
if (recent.length < n) return null;
|
|
58
|
+
const window = recent.slice(-n);
|
|
59
|
+
if (window.some(item => item.progressed)) continue;
|
|
60
|
+
if (window.every((item, i) => i < k || item.signature === window[i - k]!.signature)) return k;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
26
63
|
}
|
|
27
64
|
|
|
28
65
|
/** Name and arguments of every call, order-independent: what the model asked for, not what came back. */
|
|
@@ -38,7 +75,7 @@ export function finishStallExchange(state: StallState): StallState {
|
|
|
38
75
|
const progress = deriveProgress(state.evidence, state.pending, state.pendingNode, checks);
|
|
39
76
|
const signature = exchangeSignature(assistant);
|
|
40
77
|
const waits = new Set(assistant.blocks.flatMap(block => block.type === "tool_use" && block.name === "write_stdin" ? [block.call_id] : []));
|
|
41
|
-
// S.6 item 2:
|
|
78
|
+
// S.6 item 2: asking
|
|
42
79
|
// for exactly what the previous exchange asked for is a rerun on an
|
|
43
80
|
// unchanged premise, so output that differs only by timing (a failing test's
|
|
44
81
|
// "[123ms]") is not news. Reads and edits are content-addressed and keep
|
|
@@ -51,9 +88,12 @@ export function finishStallExchange(state: StallState): StallState {
|
|
|
51
88
|
(event.effect?.kind !== "command" && !isProcessReceipt(event.data))), state.pendingNode, checks).progressed
|
|
52
89
|
: progress.progressed;
|
|
53
90
|
const results = state.pending.filter(event => event.t === "tool_result");
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
91
|
+
// Waiting on a process that is still running is not another attempt: its
|
|
92
|
+
// own timeout bounds it and the user can interrupt (S.8). Only exchanges
|
|
93
|
+
// that try something enter the cycle window.
|
|
94
|
+
const waiting = results.length > 0 && results.every(result => waits.has(result.call_id) && isProcessReceipt(result.data) && result.data.running === true);
|
|
95
|
+
const recent = state.reviewed || waiting ? state.recent : [...state.recent, { signature, progressed: rerun }].slice(-MAX_CYCLE * CYCLE_REPEATS);
|
|
96
|
+
return { ...state, pending: [], evidence: progress.evidence, previous: signature, reviewed: false, recent,
|
|
57
97
|
failures: state.failures + results.filter(result => !result.ok).length,
|
|
58
98
|
emptyWaits: state.emptyWaits + results.filter(result => waits.has(result.call_id) && isProcessReceipt(result.data) &&
|
|
59
99
|
result.data.running === true && result.data.cursor !== undefined && result.data.cursor.start === result.data.cursor.end).length };
|
|
@@ -63,11 +103,16 @@ export function finishStallExchange(state: StallState): StallState {
|
|
|
63
103
|
export function advanceStall(state: StallState, entries: readonly { node: number; event: LogEvent }[]): StallState {
|
|
64
104
|
let result = state;
|
|
65
105
|
for (const { node, event } of entries) {
|
|
66
|
-
|
|
106
|
+
const review = event.t === "guidance" && event.reason === "completion_review";
|
|
107
|
+
const nudge = event.t === "guidance" && event.reason === "stall";
|
|
108
|
+
if (event.t === "assistant" || event.t === "user" || event.t === "cut" || review || nudge) {
|
|
67
109
|
result = finishStallExchange(result);
|
|
68
|
-
if (
|
|
110
|
+
if (review) result = { ...result, reviewed: true };
|
|
111
|
+
// A nudge, or the user's "continue", starts a fresh window: only a new
|
|
112
|
+
// full cycle after it counts, never the one already reported.
|
|
113
|
+
if (nudge) result = { ...result, nudged: true, recent: [] };
|
|
114
|
+
if (event.t === "user") result = { ...result, pending: [], recent: [], failures: 0, emptyWaits: 0, nudged: false, previous: "", reviewed: false };
|
|
69
115
|
if (event.t === "assistant") result = { ...result, pending: [event], pendingNode: node };
|
|
70
|
-
if (event.t === "guidance") result = { ...result, warned: true };
|
|
71
116
|
} else if (result.pending.length > 0) result = { ...result, pending: [...result.pending, event] };
|
|
72
117
|
}
|
|
73
118
|
return result;
|
|
@@ -75,9 +120,9 @@ export function advanceStall(state: StallState, entries: readonly { node: number
|
|
|
75
120
|
export function stallDecision(state: StallState, limit: number) {
|
|
76
121
|
if (!Number.isSafeInteger(limit) || limit < 0 || limit === 1) throw new RangeError("stall limit must be zero or an integer of at least two");
|
|
77
122
|
const current = finishStallExchange(state);
|
|
78
|
-
|
|
79
|
-
// the
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
warn:
|
|
123
|
+
const cycle = limit > 0 ? repeatedCycle(current.recent) : null;
|
|
124
|
+
// S.8: the first cycle is pointed out to the model; a second goes to the
|
|
125
|
+
// user when one can answer, and otherwise ends the request.
|
|
126
|
+
return { cycle, repeats: CYCLE_REPEATS, failures: current.failures, emptyWaits: current.emptyWaits,
|
|
127
|
+
warn: cycle !== null && !current.nudged, stop: cycle !== null && current.nudged };
|
|
83
128
|
}
|
package/src/core/stop-report.ts
CHANGED
|
@@ -8,7 +8,11 @@ export function stopReport(events: readonly LogEvent[], requests: number, limit:
|
|
|
8
8
|
const scoped = currentRequestEvents(events);
|
|
9
9
|
const current = scoped.length > 0 ? scoped : events;
|
|
10
10
|
const results = current.filter(event => event.t === "tool_result");
|
|
11
|
-
|
|
11
|
+
// Q.22 keeps ok:false on a recorded-but-unverified claim so the model does
|
|
12
|
+
// not read "recorded" as "done"; to a person reading why work stopped it is
|
|
13
|
+
// an unproven statement, not a broken tool.
|
|
14
|
+
const unverified = results.filter(unverifiedClaim);
|
|
15
|
+
const failures = results.filter(event => !event.ok && !unverifiedClaim(event));
|
|
12
16
|
const exits = results.filter(event => event.effect?.kind === "command" && event.effect.exit === 0).length;
|
|
13
17
|
const ids = new Set<number>();
|
|
14
18
|
for (const event of current) {
|
|
@@ -17,10 +21,48 @@ export function stopReport(events: readonly LogEvent[], requests: number, limit:
|
|
|
17
21
|
}
|
|
18
22
|
const running = [...ids].filter(id => latestProcessReceipt([{ node: 0, events: current }], id)?.receipt.running === true);
|
|
19
23
|
return [
|
|
20
|
-
|
|
21
|
-
`Recorded evidence: ${results.length} tool receipts; ${exits} successful command exits; ${failures.length} failed tool receipts. Task completion is not verified by this stop.`,
|
|
22
|
-
...(failures.length ? [`
|
|
24
|
+
requestLine(exitLine(cause), requests, limit),
|
|
25
|
+
`Recorded evidence: ${results.length} tool receipts; ${exits} successful command exits; ${failures.length} failed tool receipts${unverified.length ? `; ${unverified.length} unverified claims` : ""}. Task completion is not verified by this stop.`,
|
|
26
|
+
...(failures.length ? [`Failed tools: ${failedTools(current, failures)}.`] : []),
|
|
27
|
+
...(finalReplyGiven(current) ? ["A final reply was given before this stop; read it above — this stop does not mark it wrong."] : []),
|
|
23
28
|
...(running.length ? [`Last observed running: ${running.slice(0, 3).join(", ")}${running.length > 3 ? ` (+${running.length - 3})` : ""}; inspect the process receipt before replaying a command.`] : []),
|
|
24
29
|
"Next: inspect /state or the recorded tool output, then provide a focused continuation; completed commands should not be replayed just to recover their output.",
|
|
25
30
|
].join("\n");
|
|
26
31
|
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* S.6 removed the default cap. A bare "(80 model requests)" in the same
|
|
35
|
+
* brackets as "(3/7 model requests)" read as a ceiling that was hit
|
|
36
|
+
* (fix-stop-report-request-count), so an uncapped run says it is a count.
|
|
37
|
+
*/
|
|
38
|
+
function requestLine(cause: string, requests: number, limit: number): string {
|
|
39
|
+
if (limit !== Infinity) return `Stopped: ${cause} (${requests}/${limit} model requests).`;
|
|
40
|
+
return `Stopped: ${cause}. This request sent ${requests} model request${requests === 1 ? "" : "s"}; no request cap is set.`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The user reads this line; no command looks a receipt up by id, so ids told
|
|
45
|
+
* them nothing (fix-stop-report-readable). Tool names say where it went wrong.
|
|
46
|
+
*/
|
|
47
|
+
function failedTools(events: readonly LogEvent[], failures: readonly Extract<LogEvent, { t: "tool_result" }>[]): string {
|
|
48
|
+
const names = new Map<string, string>();
|
|
49
|
+
for (const event of events) if (event.t === "assistant") for (const block of event.blocks) if (block.type === "tool_use") names.set(block.call_id, block.name);
|
|
50
|
+
const counts = new Map<string, number>();
|
|
51
|
+
for (const failure of failures) { const name = names.get(failure.call_id) ?? "unknown"; counts.set(name, (counts.get(name) ?? 0) + 1); }
|
|
52
|
+
const ranked = [...counts].sort(([a, x], [b, y]) => y - x || (a < b ? -1 : a > b ? 1 : 0));
|
|
53
|
+
return ranked.slice(0, 3).map(([name, count]) => `${name} ×${count}`).join(", ") + (ranked.length > 3 ? ` (+${ranked.length - 3} more)` : "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function unverifiedClaim(event: Extract<LogEvent, { t: "tool_result" }>): boolean {
|
|
57
|
+
if (event.ok || !event.content.startsWith("{")) return false;
|
|
58
|
+
try {
|
|
59
|
+
const body = JSON.parse(event.content) as { recorded?: unknown; verification?: unknown };
|
|
60
|
+
return body.recorded === true && body.verification === "unverified";
|
|
61
|
+
} catch { return false; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A stop after the model already answered must not read as "the task failed". */
|
|
65
|
+
function finalReplyGiven(events: readonly LogEvent[]): boolean {
|
|
66
|
+
return events.some(event => event.t === "assistant" && !event.blocks.some(block => block.type === "tool_use") &&
|
|
67
|
+
event.blocks.some(block => block.type === "text" && block.text.trim().length > 0));
|
|
68
|
+
}
|
|
@@ -86,6 +86,8 @@ const SYNONYMS: Readonly<Record<string, readonly string[]>> = {
|
|
|
86
86
|
cmd: ["command"], script: ["command"],
|
|
87
87
|
text: ["content"], contents: ["content"], old_str: ["old_string"], new_str: ["new_string"],
|
|
88
88
|
query: ["pattern"], regex: ["pattern"], timeout_ms: ["timeout"], offset: ["start"], limit: ["end", "limit"],
|
|
89
|
+
// Different units from max_output_tokens, so a hint rather than a silent conversion.
|
|
90
|
+
max_output_chars: ["max_output_tokens"], max_output_bytes: ["max_output_tokens"], max_tokens: ["max_output_tokens"],
|
|
89
91
|
};
|
|
90
92
|
|
|
91
93
|
/** The candidate nearest to `name`, when it is near enough to be the likely intent. */
|
package/src/core/tool-failure.ts
CHANGED
|
@@ -21,26 +21,47 @@ const guidance: Record<ToolDiagnostic["code"], string> = {
|
|
|
21
21
|
command_failed: "Use the actual exit and output to diagnose the command; a nonzero exit alone does not identify its cause.",
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The refusal for `.git` and `.xeno` paths. It is a fixed rule protecting the
|
|
26
|
+
* session's own evidence, not a user policy, so the generic denial ("a user
|
|
27
|
+
* policy change is required") would send the model to ask for a setting that
|
|
28
|
+
* does not exist -- which is what a real session did before this text.
|
|
29
|
+
*/
|
|
30
|
+
/*
|
|
31
|
+
* The diagnostic code stays permission_denied because codes are a closed set
|
|
32
|
+
* validated on every logged tool_result (event-validation.ts); a new code
|
|
33
|
+
* would change the session format. A model still read that code as "wait for
|
|
34
|
+
* approval" and stopped (Horizyn mv3nsske), so the text has to say outright
|
|
35
|
+
* that approval is neither needed nor able to help.
|
|
36
|
+
*/
|
|
37
|
+
const STATE_PATH_RULE = "This path is repository or xeno session state, which edit tools never change. " +
|
|
38
|
+
"This is not a permission problem and no setting changes this: no user approval allows it, so do not wait for one. ";
|
|
39
|
+
export const STATE_PATH_GUIDANCE = STATE_PATH_RULE +
|
|
40
|
+
"To work in an isolated checkout, use the worktree tool instead of creating one under .git or .xeno.";
|
|
41
|
+
const STATE_PATH_GUIDANCE_WITHOUT_WORKTREE = STATE_PATH_RULE + "Edit files in a directory outside .git and .xeno.";
|
|
42
|
+
|
|
24
43
|
export function failureDiagnostic(code: ToolDiagnostic["code"], message: string, facts: FailureFacts,
|
|
25
|
-
extra: Pick<ToolDiagnostic, "recover" | "example"> = {}): ToolDiagnostic {
|
|
44
|
+
extra: Pick<ToolDiagnostic, "recover" | "example" | "next"> = {}): ToolDiagnostic {
|
|
26
45
|
const retry = code === "permission_denied" || code === "cancelled" ? "never"
|
|
27
46
|
: code === "unknown_effect" || facts.effects === "possible" ? "after_inspection"
|
|
28
47
|
: code === "transient" ? "backoff" : "after_change";
|
|
29
|
-
|
|
48
|
+
const { next = guidance[code], ...rest } = extra;
|
|
49
|
+
return { code, message, ...facts, retry, next, ...(code === "permission_denied" ? {} : rest) };
|
|
30
50
|
}
|
|
31
51
|
|
|
32
52
|
/** R10: only new advice is scoped; execution facts and historical records are never rewritten. */
|
|
33
53
|
export function scopeFailureGuidance(diagnostic: ToolDiagnostic, tools: readonly string[], recoverAllowed = true): ToolDiagnostic {
|
|
34
54
|
const { recover, ...facts } = diagnostic;
|
|
35
55
|
const next = diagnostic.execution !== undefined && diagnostic.code === "environment_missing" && !tools.includes("environment")
|
|
36
|
-
? { next: "Verify the working directory and required executable in the current executor using permitted observations. If the needed capability is unavailable, report that evidence gap before retrying." }
|
|
56
|
+
? { next: "Verify the working directory and required executable in the current executor using permitted observations. If the needed capability is unavailable, report that evidence gap before retrying." }
|
|
57
|
+
: diagnostic.next === STATE_PATH_GUIDANCE && !tools.includes("worktree") ? { next: STATE_PATH_GUIDANCE_WITHOUT_WORKTREE } : {};
|
|
37
58
|
return { ...facts, ...next, ...(recover && recoverAllowed && tools.includes(recover.tool) && diagnostic.code !== "permission_denied" ? { recover } : {}) };
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
/** Runtime and store throw only facts they own; the outer boundary must retain these facts. */
|
|
41
62
|
export class ToolFault extends Error {
|
|
42
63
|
readonly diagnostic: ToolDiagnostic;
|
|
43
|
-
constructor(code: ToolDiagnostic["code"], message: string, facts: FailureFacts, extra: Pick<ToolDiagnostic, "recover"> = {}) {
|
|
64
|
+
constructor(code: ToolDiagnostic["code"], message: string, facts: FailureFacts, extra: Pick<ToolDiagnostic, "recover" | "next"> = {}) {
|
|
44
65
|
super(message); this.name = "ToolFault";
|
|
45
66
|
this.diagnostic = failureDiagnostic(code, message, facts, extra);
|
|
46
67
|
}
|
package/src/core/transcript.ts
CHANGED
|
@@ -81,6 +81,9 @@ export function projectTranscript(events: readonly LogEvent[], entryTypes: Reado
|
|
|
81
81
|
case "goal": items.push({ ...identity, kind: "notice", text: event.action === "set"
|
|
82
82
|
? `Goal set · ${event.accepted.length} accepted${event.rejected.length ? `, ${event.rejected.length} rejected` : ""} · ${event.objective}`
|
|
83
83
|
: event.reason === "met" ? "Goal met · every accepted criterion has current evidence" : "Goal cleared" }); break;
|
|
84
|
+
// Approvals answer "why was this allowed" in /export and history; the person
|
|
85
|
+
// already saw the prompt, and a notice per auto-allowed command would bury the chat.
|
|
86
|
+
case "approval":
|
|
84
87
|
case "checkpoint": case "workspace_observation":
|
|
85
88
|
case "guidance": case "claim": case "decision": case "pitfall": case "blocked": case "question": case "next_action": case "work_status": case "work_owner": case "work_dependencies": case "evidence_link":
|
|
86
89
|
// These remain inspectable via /state and /export; they are not chat messages.
|
package/src/core/update-check.ts
CHANGED
|
@@ -46,7 +46,8 @@ export interface UpdatePlan {
|
|
|
46
46
|
readonly notice?: string;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/** Shared by every background network opt-out, so one variable means the same everywhere. */
|
|
50
|
+
export function truthy(value: string | undefined): boolean {
|
|
50
51
|
return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
|
|
51
52
|
}
|
|
52
53
|
|
package/src/core/work-state.ts
CHANGED
|
@@ -50,6 +50,10 @@ export function scopedWorkId(namespace: string, requested: string, items: readon
|
|
|
50
50
|
? requested : `${namespace}:${requested}`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function unknownWork(id: string): string {
|
|
54
|
+
return `unknown work_id ${id}; create it with a next note (kind=next) first`;
|
|
55
|
+
}
|
|
56
|
+
|
|
53
57
|
export function workChangeError(items: readonly WorkItem[], event: LogEvent): string | null {
|
|
54
58
|
if (!("work_id" in event) || event.work_id === undefined) return null;
|
|
55
59
|
const item = items.find(item => item.id === event.work_id);
|
|
@@ -57,9 +61,10 @@ export function workChangeError(items: readonly WorkItem[], event: LogEvent): st
|
|
|
57
61
|
const error = dependencyError(items, event.work_id, event.depends_on);
|
|
58
62
|
if (error) return error;
|
|
59
63
|
}
|
|
60
|
-
|
|
64
|
+
// Every unknown-id rejection names the way to make the id exist, or the model retries the same call.
|
|
65
|
+
if (event.t === "work_dependencies" && !item) return unknownWork(event.work_id);
|
|
61
66
|
if (event.t === "work_owner") {
|
|
62
|
-
if (!item) return
|
|
67
|
+
if (!item) return unknownWork(event.work_id);
|
|
63
68
|
if (event.operation === "release") return item.owner === event.owner ? null : "only the recorded owner can release work";
|
|
64
69
|
const ready = workReadiness(items, item.id, event.owner);
|
|
65
70
|
return ready.ready ? null : `work ${item.id} is ${ready.reason}; cannot claim it`;
|
package/src/core/working-set.ts
CHANGED
|
@@ -7,9 +7,17 @@ import { estimateTokens } from "./tokens.ts";
|
|
|
7
7
|
import { RECOVERY_SECTIONS, recoverySources, type RecoveryNodes, type RecoveryRanges, type RecoveryReference } from "./recovery-sources.ts";
|
|
8
8
|
import { recoveryInstructions } from "./prompt-policy.ts";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The cut format new sessions record. Live cuts, cold replay and interrupted-cut
|
|
12
|
+
* repair all select through `carriedHandoff`, so this is only the default for
|
|
13
|
+
* cuts not yet written; a recorded version is never reinterpreted.
|
|
14
|
+
*/
|
|
15
|
+
export const CURRENT_WORKING_SET_VERSION = 3;
|
|
16
|
+
|
|
10
17
|
export type WorkingSet = {
|
|
11
|
-
version: 1 | 2; source: NodeRef;
|
|
12
|
-
|
|
18
|
+
version: 1 | 2 | 3; source: NodeRef;
|
|
19
|
+
/** v3 omits `what`, `next_action` and `reason` when the carried `state.work_items` holds the same item. */
|
|
20
|
+
work: (Pick<WorkItem, "id" | "status" | "next_action" | "reason" | "depends_on" | "owner"> & { what?: string; readiness?: WorkReadiness })[];
|
|
13
21
|
references: ({ node?: number; event: string; kind: string; work?: string } & Partial<Omit<RecoveryReference, "node" | "event" | "kind" | "work">>)[];
|
|
14
22
|
omitted: { work: number; references: number };
|
|
15
23
|
recovery_sections?: RecoveryRanges;
|
|
@@ -50,20 +58,40 @@ export function workingSet(full: Handoff, budget: number): WorkingSet | null {
|
|
|
50
58
|
|
|
51
59
|
/** R9: a new projection version keeps the original v1 ordering byte-stable. */
|
|
52
60
|
export function recoveryWorkingSet(full: Handoff, nodes: RecoveryNodes, budget: number, tools?: readonly string[]): WorkingSet | null {
|
|
61
|
+
return sourceView(full, nodes, { version: 2, users: true,
|
|
62
|
+
fits: view => estimateTokens.count([], emitYaml(view)) <= budget,
|
|
63
|
+
recovery: tools === undefined ? "Read original user messages before continuing: history(node=source.node,recovery=users,offset=0). Other pages: failures, environment, next_actions; event=ID reads one original. Abandoned messages are cancelled; annotations are not user instructions. Observations are historical: recheck resource, host and current permissions before acting. history(work_id=ID) recovers work." : recoveryInstructions(tools) });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* v3 shares v2's source ordering. Two things differ, both decided by the caller
|
|
68
|
+
* that owns the whole carried handoff: `fits` measures the view inside that
|
|
69
|
+
* handoff rather than standalone, and `users: false` leaves user sources out
|
|
70
|
+
* because the carried `user_turns` already hold every one verbatim.
|
|
71
|
+
*/
|
|
72
|
+
export function recoveryWorkingSetV3(full: Handoff, nodes: RecoveryNodes, options: {
|
|
73
|
+
users: boolean; recovery: string; fits: (view: WorkingSet) => boolean;
|
|
74
|
+
}): WorkingSet | null {
|
|
75
|
+
return sourceView(full, nodes, { version: 3, ...options });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sourceView(full: Handoff, nodes: RecoveryNodes, options: {
|
|
79
|
+
version: 2 | 3; users: boolean; recovery: string; fits: (view: WorkingSet) => boolean;
|
|
80
|
+
}): WorkingSet | null {
|
|
53
81
|
const legacy = workingSet(full, Infinity);
|
|
54
82
|
if (!legacy) return null;
|
|
55
83
|
const sources = recoverySources(nodes);
|
|
56
|
-
const view: WorkingSet = { version:
|
|
84
|
+
const view: WorkingSet = { version: options.version, source: legacy.source, work: [], references: [],
|
|
57
85
|
omitted: { work: legacy.work.length, references: legacy.references.length + RECOVERY_SECTIONS.reduce((sum, section) => sum + sources[section].length, 0) },
|
|
58
86
|
recovery_sections: Object.fromEntries(RECOVERY_SECTIONS.map(section => [section, { total: sources[section].length, omitted: sources[section].length }])) as RecoveryRanges,
|
|
59
|
-
recovery:
|
|
60
|
-
const fits = () =>
|
|
87
|
+
recovery: options.recovery };
|
|
88
|
+
const fits = () => options.fits(view);
|
|
61
89
|
if (!fits()) return null;
|
|
62
90
|
const queues = RECOVERY_SECTIONS.map(section => ({ section, items: [...sources[section]], at: 0 }));
|
|
63
91
|
// The latest and first live user messages anchor correction and initial scope;
|
|
64
92
|
// remaining user sources are newest first. Cancelled turns stay pageable.
|
|
65
93
|
const live = sources.users.filter(ref => !ref.abandoned), first = live.at(-1);
|
|
66
|
-
queues[0]!.items = [...live.slice(0, 1), ...(first && first !== live[0] ? [first] : []),
|
|
94
|
+
queues[0]!.items = !options.users ? [] : [...live.slice(0, 1), ...(first && first !== live[0] ? [first] : []),
|
|
67
95
|
...live.slice(1, -1), ...sources.users.filter(ref => ref.abandoned)];
|
|
68
96
|
const takeReference = (reference: WorkingSet["references"][number], section?: keyof RecoveryRanges) => {
|
|
69
97
|
view.references.push(reference); view.omitted.references--;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a provider may keep reusing pooled connections.
|
|
3
|
+
*
|
|
4
|
+
* A socket that a proxy, NAT or server dropped while it sat in the pool fails
|
|
5
|
+
* the next request the same way -- Horizyn 2026-10-12 lost the first request
|
|
6
|
+
* after "continue" to it. Claude Code answers by turning keep-alive off for
|
|
7
|
+
* the rest of the process once a connection dies; this does the same per
|
|
8
|
+
* provider instance, so tests sharing a process cannot leak state into each
|
|
9
|
+
* other and a fresh session starts from the cheap path again
|
|
10
|
+
* (feat-fresh-connection-after-drop).
|
|
11
|
+
*
|
|
12
|
+
* Bun honours `keepalive: false` by opening a new socket per request
|
|
13
|
+
* (measured on 1.3.10: three default fetches share one socket, three with
|
|
14
|
+
* keepalive:false open three). The cost is a TLS handshake per request,
|
|
15
|
+
* negligible next to a model call. Google, Vertex and Bedrock go through
|
|
16
|
+
* pi-ai's native HTTP with no fetch hook, so this cannot reach them.
|
|
17
|
+
*/
|
|
18
|
+
import { connectionFailure } from "./retry.ts";
|
|
19
|
+
|
|
20
|
+
export class ConnectionReuse {
|
|
21
|
+
private fresh = false;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Called with every failed attempt, retried or final. Only a connection
|
|
25
|
+
* that existed and died counts: one that never opened left nothing stale
|
|
26
|
+
* in the pool.
|
|
27
|
+
*/
|
|
28
|
+
note(error: unknown): void {
|
|
29
|
+
if (connectionFailure(error) === "stale") this.fresh = true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
init(init: RequestInit | undefined): RequestInit | undefined {
|
|
33
|
+
return this.fresh ? { ...init, keepalive: false } : init;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A fetch that opens fresh connections once one has died. */
|
|
37
|
+
wrap(fetchImpl: typeof fetch): typeof fetch {
|
|
38
|
+
return Object.assign((input: RequestInfo | URL, init?: RequestInit) => fetchImpl(input, this.init(init)), { preconnect: fetchImpl.preconnect });
|
|
39
|
+
}
|
|
40
|
+
}
|