@visual-z/xeno 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/package.json +1 -1
- package/src/apps/review.ts +59 -59
- package/src/apps/shell.ts +20 -5
- package/src/apps/startup.ts +4 -4
- package/src/apps/ui/app.ts +143 -20
- 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 +17 -2
- package/src/core/command-contract.ts +58 -16
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- 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 +124 -20
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +60 -0
- package/src/core/prompt-policy.ts +4 -2
- package/src/core/secrets.ts +188 -0
- package/src/core/stop-report.ts +25 -3
- package/src/core/tool-failure.ts +11 -3
- package/src/core/transcript.ts +3 -0
- 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 +66 -18
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +264 -73
- 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 +97 -27
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +1 -1
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/tui/ui/branch-panel.ts +47 -0
- package/src/tui/ui/composer.ts +27 -28
- 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/prompt-history.ts +60 -0
- package/src/tui/ui/screen.ts +34 -3
package/src/runtime/loop.ts
CHANGED
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
* request.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent } from "../core/events.ts";
|
|
21
|
+
import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent, FailureCause } from "../core/events.ts";
|
|
22
22
|
import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
|
|
23
23
|
import { hydrateImages } from "./images.ts";
|
|
24
24
|
import type { Handoff } from "../core/handoff.ts";
|
|
25
25
|
import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
|
|
26
|
+
import { CURRENT_WORKING_SET_VERSION } from "../core/working-set.ts";
|
|
26
27
|
import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
|
|
27
28
|
import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
|
|
28
29
|
import { projectContext, toolCallIdentityViolations } from "../core/project.ts";
|
|
@@ -76,7 +77,11 @@ import { skillTools, type Skill } from "../core/skills.ts";
|
|
|
76
77
|
import type { WorkItem } from "../core/work-state.ts";
|
|
77
78
|
import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceCriterion, type AcceptanceState } from "../core/acceptance.ts";
|
|
78
79
|
import { deadlineNotice } from "../core/deadline.ts";
|
|
79
|
-
import { statusOf } from "./retry.ts";
|
|
80
|
+
import { describeFailure, failureCause, statusOf } from "./retry.ts";
|
|
81
|
+
import { XENO_VERSION } from "./version.ts";
|
|
82
|
+
import { SECRET_GUIDANCE, SecretKeeper } from "./secrets.ts";
|
|
83
|
+
import { secretEnvironmentValues } from "../core/secrets.ts";
|
|
84
|
+
import { copyVault } from "../store/secrets.ts";
|
|
80
85
|
import { assertNever } from "../core/exhaustive.ts";
|
|
81
86
|
import { isLogEvent } from "../core/event-validation.ts";
|
|
82
87
|
import {
|
|
@@ -177,6 +182,8 @@ export interface LoopEvent {
|
|
|
177
182
|
/** branch_start: wall-clock milliseconds, for asserting the stagger. */
|
|
178
183
|
at?: number;
|
|
179
184
|
outcome?: ExecutionOutcome;
|
|
185
|
+
/** done: credential-free cause of a failed request, recorded on its outcome. */
|
|
186
|
+
failure?: FailureCause;
|
|
180
187
|
/** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
|
|
181
188
|
request?: RequestState;
|
|
182
189
|
progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string }
|
|
@@ -214,6 +221,11 @@ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
|
|
|
214
221
|
};
|
|
215
222
|
|
|
216
223
|
export interface SessionConfig {
|
|
224
|
+
/**
|
|
225
|
+
* Where secret-named environment values are read from (S.9). Defaults to
|
|
226
|
+
* process.env; a test supplies its own so it never depends on the machine.
|
|
227
|
+
*/
|
|
228
|
+
secretEnvironment?: Readonly<Record<string, string | undefined>>;
|
|
217
229
|
/** Runtime-only output contract, explicitly selected by the current caller. */
|
|
218
230
|
validateOutput?: (text: string) => { ok: boolean; error?: string };
|
|
219
231
|
/** Persist a selector, never persisted profile code or authorization (D6/D13). */
|
|
@@ -365,6 +377,15 @@ export type CommittedItem = { node: number; id: string; type: LogEvent["t"] };
|
|
|
365
377
|
export class Session {
|
|
366
378
|
private readonly execOwner = randomUUID();
|
|
367
379
|
private readonly jobs = new BranchJobs<DeferredToolResult>();
|
|
380
|
+
/**
|
|
381
|
+
* Branches the user started (/review-code), owned apart from `jobs`: an Esc
|
|
382
|
+
* on a parent turn cancels the model's branches, never these. Their joins
|
|
383
|
+
* wait in `pendingJoins` until the parent is idle and pairable.
|
|
384
|
+
*/
|
|
385
|
+
private readonly userJobs = new BranchJobs<Extract<LogEvent, { t: "joined" }>>();
|
|
386
|
+
private readonly pendingJoins: { joined: LogEvent; note: string; usage: readonly UsageSample[] }[] = [];
|
|
387
|
+
/** Spawned but not yet handed to `userJobs` (its prelude is still recording): already running to an observer. */
|
|
388
|
+
private readonly startingUserBranches = new Set<number>();
|
|
368
389
|
private readonly children = new Map<number, Session>();
|
|
369
390
|
private cleanup: Promise<void> | null = null;
|
|
370
391
|
private closing: Promise<void> | null = null;
|
|
@@ -381,12 +402,19 @@ export class Session {
|
|
|
381
402
|
private stall: StallState | null = null;
|
|
382
403
|
private recovery: RecoveryState | null = null;
|
|
383
404
|
private processWake = new AbortController();
|
|
405
|
+
/**
|
|
406
|
+
* The exception the last provider request ended with, compared by identity
|
|
407
|
+
* in send()'s catch. Wrapping the error instead would break every check that
|
|
408
|
+
* reads it (FailedResponseError, statusOf, context overflow).
|
|
409
|
+
*/
|
|
410
|
+
private providerFailure: { error: unknown; retries: number } | undefined;
|
|
384
411
|
private pendingAdoption: number | null = null;
|
|
385
412
|
/** A worktree enter/exit moves the root only once its tool round is paired, like adoption. */
|
|
386
413
|
private pendingRoot: string | null = null;
|
|
387
414
|
private readonly allocatedEventIds = new Set<string>();
|
|
388
415
|
private recordTail: Promise<void> = Promise.resolve();
|
|
389
416
|
private readonly recordListeners = new Set<(item: CommittedItem) => void>();
|
|
417
|
+
private readonly branchRecordListeners = new Set<(item: CommittedItem) => void>();
|
|
390
418
|
readonly config: SessionConfig;
|
|
391
419
|
/**
|
|
392
420
|
* Replaced by switchModel. Not readonly: a live session can change which
|
|
@@ -397,6 +425,8 @@ export class Session {
|
|
|
397
425
|
readonly clock: Clock;
|
|
398
426
|
state: SessionState;
|
|
399
427
|
private readonly allocation: { next: number };
|
|
428
|
+
/** S.9: the vault behind placeholders. A branch is handed its parent's, like `allocation`. */
|
|
429
|
+
private secrets: SecretKeeper;
|
|
400
430
|
|
|
401
431
|
constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
|
|
402
432
|
if (config.acceptanceContract) parseAcceptanceContract(config.acceptanceContract);
|
|
@@ -406,6 +436,7 @@ export class Session {
|
|
|
406
436
|
schedulingWindow([], 0, config.maxParallelTools ?? 3);
|
|
407
437
|
validateRequestBudget(config.maxTurns, config.stallLimit ?? DEFAULT_STALL_LIMIT);
|
|
408
438
|
this.allocation = allocation;
|
|
439
|
+
this.secrets = new SecretKeeper(config.logDir, secretEnvironmentValues(config.secretEnvironment ?? process.env));
|
|
409
440
|
this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache(),
|
|
410
441
|
processWait: { wake: () => this.processWake.signal } } };
|
|
411
442
|
// A child or restored Session must never inherit another live invocation scope.
|
|
@@ -419,6 +450,8 @@ export class Session {
|
|
|
419
450
|
// Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
|
|
420
451
|
if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
|
|
421
452
|
this.config.tools.outputStore ??= join(config.logDir, "artifacts");
|
|
453
|
+
// Read at call time, so a branch that is handed its parent's keeper expands the parent's values.
|
|
454
|
+
this.config.tools.expandSecrets = input => this.secrets.expand(input);
|
|
422
455
|
this.config.tools.historyEvents = async () => {
|
|
423
456
|
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
424
457
|
if (!history.ok) throw Error(history.error);
|
|
@@ -498,6 +531,7 @@ export class Session {
|
|
|
498
531
|
async start(): Promise<void> {
|
|
499
532
|
this.resetDerivedToolState();
|
|
500
533
|
const dir = this.config.logDir;
|
|
534
|
+
await this.secrets.load();
|
|
501
535
|
const model = this.provider.config.spec.id;
|
|
502
536
|
const existing = await listNodes(dir);
|
|
503
537
|
const recordedCursor = await readCursor(dir);
|
|
@@ -607,6 +641,7 @@ export class Session {
|
|
|
607
641
|
*/
|
|
608
642
|
async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
|
|
609
643
|
await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
|
|
644
|
+
await copyVault(this.config.logDir, destLogDir);
|
|
610
645
|
if (untilEvent !== undefined) {
|
|
611
646
|
const cursor = await readCursor(destLogDir);
|
|
612
647
|
if (!cursor) throw new Error("missing fork cursor");
|
|
@@ -749,8 +784,18 @@ export class Session {
|
|
|
749
784
|
* must see that a person changed models, not that the node filled up.
|
|
750
785
|
* boundary.ts already produces this reason when checkpointRequested is
|
|
751
786
|
* set; switchModel is the other caller that wants it.
|
|
787
|
+
*
|
|
788
|
+
* An empty projection is the exception (fix-switch-model-empty-segment).
|
|
789
|
+
* With no events there is no old model's block to keep away from the new
|
|
790
|
+
* one, and the frozen prefix does not change here, so the cursor alone
|
|
791
|
+
* records the switch. Cutting anyway sealed an empty segment and opened
|
|
792
|
+
* the next from an empty handoff, which the model read as earlier work to
|
|
793
|
+
* resume. The check is the projection, not the node file: an inherit-basis
|
|
794
|
+
* node can be empty on disk while still projecting ancestor thinking.
|
|
795
|
+
*
|
|
796
|
+
* Returns whether a segment was cut, so the caller can say so truthfully.
|
|
752
797
|
*/
|
|
753
|
-
async switchModel(next: Provider): Promise<
|
|
798
|
+
async switchModel(next: Provider): Promise<boolean> {
|
|
754
799
|
if (this.running || this.isHalfRound()) {
|
|
755
800
|
throw new Error(
|
|
756
801
|
"cannot switch models mid-turn: a tool_use is still unpaired. " +
|
|
@@ -758,9 +803,12 @@ export class Session {
|
|
|
758
803
|
);
|
|
759
804
|
}
|
|
760
805
|
|
|
761
|
-
|
|
806
|
+
const cut = this.state.events.length > 0;
|
|
807
|
+
if (cut) await this.performCut("model_checkpoint", next.config.spec.id, undefined, next.config.spec.provider, next.config.effort);
|
|
808
|
+
else this.state.anchor = null;
|
|
762
809
|
this.provider = next;
|
|
763
810
|
await this.saveCursor();
|
|
811
|
+
return cut;
|
|
764
812
|
}
|
|
765
813
|
|
|
766
814
|
/** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
|
|
@@ -890,10 +938,15 @@ export class Session {
|
|
|
890
938
|
updated_at: this.clock.now(),
|
|
891
939
|
workspace: this.config.tools.root,
|
|
892
940
|
context_ref: contextRef ?? await this.persistContext(),
|
|
941
|
+
xeno_version: XENO_VERSION,
|
|
893
942
|
});
|
|
894
943
|
}
|
|
895
944
|
|
|
896
945
|
/** Q24: publish identities only after durable append, including non-model user actions. */
|
|
946
|
+
/** Commits inside this session's branches (their node numbers), for a client showing one live. */
|
|
947
|
+
subscribeBranchRecords(listener: (item: CommittedItem) => void): () => void {
|
|
948
|
+
this.branchRecordListeners.add(listener); return () => { this.branchRecordListeners.delete(listener); };
|
|
949
|
+
}
|
|
897
950
|
subscribeRecords(listener: (item: CommittedItem) => void): () => void {
|
|
898
951
|
this.recordListeners.add(listener); return () => { this.recordListeners.delete(listener); };
|
|
899
952
|
}
|
|
@@ -902,6 +955,10 @@ export class Session {
|
|
|
902
955
|
// D8: runtime callers may retain mutable results. The in-memory projection
|
|
903
956
|
// must own the same JSON value that was appended, independent of later edits.
|
|
904
957
|
event = JSON.parse(JSON.stringify(event)) as LogEvent;
|
|
958
|
+
// S.9: credentials become placeholders before anything is written, so no
|
|
959
|
+
// projection, handoff, export or history page can carry the value.
|
|
960
|
+
const redacted = await this.secrets.redact(event, this.lastAssistantText());
|
|
961
|
+
event = redacted.event;
|
|
905
962
|
const saved = this.recordTail.then(async () => {
|
|
906
963
|
await appendEvent(this.config.logDir, this.state.node, event);
|
|
907
964
|
this.state.events.push(event);
|
|
@@ -913,6 +970,9 @@ export class Session {
|
|
|
913
970
|
});
|
|
914
971
|
this.recordTail = saved.catch(() => {});
|
|
915
972
|
await saved;
|
|
973
|
+
if (redacted.added > 0 && !this.state.events.some(item => item.t === "guidance" && item.reason === "secret")) {
|
|
974
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "secret", text: SECRET_GUIDANCE, sources: [event.id] });
|
|
975
|
+
}
|
|
916
976
|
if (event.t === "tool_result" && event.ok && event.effect?.kind === "read") {
|
|
917
977
|
const path = resolve(this.config.tools.root, event.effect.path);
|
|
918
978
|
const skill = this.config.skills?.find(item => item.filePath === path);
|
|
@@ -925,6 +985,19 @@ export class Session {
|
|
|
925
985
|
}
|
|
926
986
|
}
|
|
927
987
|
|
|
988
|
+
/** The text the user is answering, for recognising a solicited credential. */
|
|
989
|
+
private lastAssistantText(): string | undefined {
|
|
990
|
+
for (let i = this.state.events.length - 1; i >= 0; i--) {
|
|
991
|
+
const event = this.state.events[i]!;
|
|
992
|
+
if (event.t === "user") return undefined;
|
|
993
|
+
if (event.t === "assistant") {
|
|
994
|
+
const text = event.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n");
|
|
995
|
+
if (text.trim()) return text;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return undefined;
|
|
999
|
+
}
|
|
1000
|
+
|
|
928
1001
|
private allowedNames(): string[] {
|
|
929
1002
|
return this.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" &&
|
|
930
1003
|
(this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name)) ? [tool.name] : []);
|
|
@@ -1000,7 +1073,7 @@ export class Session {
|
|
|
1000
1073
|
|
|
1001
1074
|
private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
|
|
1002
1075
|
return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
1003
|
-
...(this.config.workingSet ? { working_set_version:
|
|
1076
|
+
...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}), guidance_tools: tools });
|
|
1004
1077
|
}
|
|
1005
1078
|
|
|
1006
1079
|
async acceptanceState(): Promise<AcceptanceState | undefined> {
|
|
@@ -1152,6 +1225,69 @@ export class Session {
|
|
|
1152
1225
|
} finally { owned.finish(); }
|
|
1153
1226
|
}
|
|
1154
1227
|
|
|
1228
|
+
/**
|
|
1229
|
+
* D14 for a branch the user opened rather than the model. Returns once
|
|
1230
|
+
* `spawn` is recorded; the child runs in the background with the caller's
|
|
1231
|
+
* explicit config (its own prompt, tools and roots: nothing of the parent's
|
|
1232
|
+
* transcript, so a review stays independent of the work it judges). When it
|
|
1233
|
+
* ends, `joined` plus one guidance line reach the parent at its next idle,
|
|
1234
|
+
* pairable point -- the line is how the parent model learns to read it.
|
|
1235
|
+
*/
|
|
1236
|
+
async startUserBranch(spec: {
|
|
1237
|
+
purpose: string; prompt: string; config: SessionConfig; provider?: Provider;
|
|
1238
|
+
/** Events the branch records before its first request (a review's premise receipt). */
|
|
1239
|
+
prelude?: (branch: Session) => Promise<void>;
|
|
1240
|
+
/** The guidance the parent model gets once the branch joined. */
|
|
1241
|
+
note: (node: number, joined: Extract<LogEvent, { t: "joined" }>) => string;
|
|
1242
|
+
}): Promise<number> {
|
|
1243
|
+
if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
|
|
1244
|
+
const node = this.state.nextNode++;
|
|
1245
|
+
this.startingUserBranches.add(node);
|
|
1246
|
+
try { return await this.launchUserBranch(node, spec); } finally { this.startingUserBranches.delete(node); }
|
|
1247
|
+
}
|
|
1248
|
+
private async launchUserBranch(node: number, spec: Parameters<Session["startUserBranch"]>[0]): Promise<number> {
|
|
1249
|
+
const sourceEvent = this.state.events.findLast(event => event.t === "user")?.id;
|
|
1250
|
+
const { ref, branch } = await this.openBranch({ node, purpose: spec.purpose, background: true, ...(sourceEvent ? { sourceEvent } : {}),
|
|
1251
|
+
inputWorkspace: { kind: "shared_readonly" }, worktree: null, config: spec.config, ...(spec.provider ? { provider: spec.provider } : {}) });
|
|
1252
|
+
// Not in `children`: the parent's cancel() disposes those, and an Esc must not stop this one.
|
|
1253
|
+
try { await spec.prelude?.(branch); await this.saveCursor(); }
|
|
1254
|
+
catch (error) { await branch.dispose(); throw error; }
|
|
1255
|
+
const controller = new AbortController();
|
|
1256
|
+
this.userJobs.start(node, controller, async () => {
|
|
1257
|
+
const { outcome, text } = await this.runChild(node, branch, spec.prompt, true, controller.signal, true);
|
|
1258
|
+
const { joined } = await this.closeBranch(ref, branch, outcome, text);
|
|
1259
|
+
if (joined.t !== "joined") throw new Error("a closed branch must produce a joined event");
|
|
1260
|
+
this.pendingJoins.push({ joined, note: spec.note(node, joined), usage: [...branch.state.usage] });
|
|
1261
|
+
await this.flushUserJoins();
|
|
1262
|
+
return joined;
|
|
1263
|
+
}, () => {});
|
|
1264
|
+
return node;
|
|
1265
|
+
}
|
|
1266
|
+
/** User branches still running, so a client can show and stop them. */
|
|
1267
|
+
get userBranches(): readonly number[] {
|
|
1268
|
+
return [...this.startingUserBranches, ...this.userJobs.nodes().filter(node => this.userJobs.state(node) === "running")];
|
|
1269
|
+
}
|
|
1270
|
+
/** Every branch with a live child here: the model's (foreground or background) and the user's. */
|
|
1271
|
+
get runningBranches(): readonly number[] { return [...new Set([...this.children.keys(), ...this.userBranches])]; }
|
|
1272
|
+
cancelUserBranch(node: number): void { this.userJobs.cancel(node); }
|
|
1273
|
+
/**
|
|
1274
|
+
* The branch's join once it ended -- recorded, or still waiting for the
|
|
1275
|
+
* parent to be idle and pairable. Undefined if its finalization failed.
|
|
1276
|
+
*/
|
|
1277
|
+
async whenUserBranch(node: number): Promise<Extract<LogEvent, { t: "joined" }> | undefined> {
|
|
1278
|
+
await this.userJobs.drain(node); return (await this.userJobs.wait(node, 0))?.value;
|
|
1279
|
+
}
|
|
1280
|
+
/** Appends joins that waited for an idle, pairable parent. Never mid-turn: the guidance enters model context. */
|
|
1281
|
+
private async flushUserJoins(): Promise<void> {
|
|
1282
|
+
while (this.pendingJoins.length > 0 && !this.running && !this.cleanup && !this.isHalfRound()) {
|
|
1283
|
+
const item = this.pendingJoins.shift()!;
|
|
1284
|
+
this.state.usage.push(...item.usage);
|
|
1285
|
+
await this.record(item.joined);
|
|
1286
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "branch", text: item.note, sources: [item.joined.id] });
|
|
1287
|
+
await this.saveCursor();
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1155
1291
|
private async drainProcesses(): Promise<void> {
|
|
1156
1292
|
const results = await disposeExecSessions(this.execOwner, this.config.tools.outputStore!, this.config.tools.maxOutputBytes);
|
|
1157
1293
|
await this.config.tools.workspace?.abandonCommands();
|
|
@@ -1170,7 +1306,9 @@ export class Session {
|
|
|
1170
1306
|
dispose(): Promise<void> {
|
|
1171
1307
|
if (this.closing) return this.closing;
|
|
1172
1308
|
this.cancel();
|
|
1173
|
-
|
|
1309
|
+
// User branches outlive interrupts, not the session. Their joins are still recorded.
|
|
1310
|
+
this.userJobs.cancel();
|
|
1311
|
+
this.closing = this.whenIdle().then(async () => { await this.userJobs.drain(); await this.flushUserJoins(); }).then(() => this.rootTools?.close());
|
|
1174
1312
|
return this.closing;
|
|
1175
1313
|
}
|
|
1176
1314
|
|
|
@@ -1256,6 +1394,7 @@ export class Session {
|
|
|
1256
1394
|
if (item.value.outcome && item.value.outcome.reason !== "model_end") await this.record({
|
|
1257
1395
|
t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
1258
1396
|
reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
|
|
1397
|
+
...(item.value.failure ? { failure: item.value.failure } : {}),
|
|
1259
1398
|
});
|
|
1260
1399
|
const request = await this.requestState();
|
|
1261
1400
|
if (request) item.value.request = request;
|
|
@@ -1270,9 +1409,14 @@ export class Session {
|
|
|
1270
1409
|
yield { kind: "done", reason: "stopped: request ended without a terminal outcome", outcome, ...(request ? { request } : {}) };
|
|
1271
1410
|
}
|
|
1272
1411
|
} catch (error) {
|
|
1273
|
-
// Raw
|
|
1412
|
+
// Raw exceptions can contain credentials; persist the category, never the message.
|
|
1413
|
+
// Only the provider request's own exception is a provider error: this catch
|
|
1414
|
+
// also sees failures in history reads and in the harness itself.
|
|
1415
|
+
const provider = this.providerFailure?.error === error ? this.providerFailure : undefined;
|
|
1416
|
+
const failure = failureCause(error, provider);
|
|
1274
1417
|
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
1275
|
-
reason:
|
|
1418
|
+
reason: `request failed before completion (${describeFailure(failure)}); no complete response was committed`,
|
|
1419
|
+
outcome: executionOutcome(provider ? "provider_error" : "execution_error"), failure });
|
|
1276
1420
|
throw error;
|
|
1277
1421
|
} finally {
|
|
1278
1422
|
// Closing a consumer is not a user cancellation. Preserve the existing
|
|
@@ -1287,6 +1431,8 @@ export class Session {
|
|
|
1287
1431
|
if (owned.signal.aborted) this.cancel();
|
|
1288
1432
|
owned.finish();
|
|
1289
1433
|
if (owned.signal.aborted) await this.whenIdle();
|
|
1434
|
+
// A user branch that ended mid-turn joins now that the parent is idle.
|
|
1435
|
+
if (this.pendingJoins.length > 0) await this.flushUserJoins().catch(() => {});
|
|
1290
1436
|
}
|
|
1291
1437
|
}
|
|
1292
1438
|
}
|
|
@@ -1404,7 +1550,9 @@ export class Session {
|
|
|
1404
1550
|
}
|
|
1405
1551
|
if (phase === "finalize") {
|
|
1406
1552
|
yield { kind: "discard" };
|
|
1407
|
-
|
|
1553
|
+
const failure = failureCause(error, this.providerFailure?.error === error ? this.providerFailure : { retries: 0 });
|
|
1554
|
+
yield { kind: "done", reason: await report(`final response failed (${describeFailure(failure)}); no additional request was made`, turn + 1),
|
|
1555
|
+
outcome: executionOutcome("provider_error"), failure };
|
|
1408
1556
|
return;
|
|
1409
1557
|
}
|
|
1410
1558
|
if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
|
|
@@ -1737,8 +1885,9 @@ export class Session {
|
|
|
1737
1885
|
): AsyncGenerator<LoopEvent, Turn> {
|
|
1738
1886
|
const hydrated = await hydrateImages(messages, this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1739
1887
|
this.provider.config.spec.input?.includes("image") ?? false);
|
|
1740
|
-
|
|
1741
|
-
|
|
1888
|
+
let retries = 0;
|
|
1889
|
+
this.providerFailure = undefined;
|
|
1890
|
+
const progress = (emit: (event: LoopEvent) => void) => (delta: StreamDelta): void => {
|
|
1742
1891
|
switch (delta.kind) {
|
|
1743
1892
|
case "text":
|
|
1744
1893
|
emit({ kind: "text_delta", text: delta.text });
|
|
@@ -1749,13 +1898,25 @@ export class Session {
|
|
|
1749
1898
|
case "discard":
|
|
1750
1899
|
emit({ kind: "discard" });
|
|
1751
1900
|
break;
|
|
1752
|
-
case "
|
|
1901
|
+
case "retry":
|
|
1902
|
+
retries = Math.max(retries, delta.attempt);
|
|
1903
|
+
emit({ kind: "progress", progress: delta });
|
|
1904
|
+
break;
|
|
1905
|
+
case "tool":
|
|
1753
1906
|
emit({ kind: "progress", progress: delta });
|
|
1754
1907
|
break;
|
|
1755
1908
|
default:
|
|
1756
1909
|
return assertNever(delta, "onDelta");
|
|
1757
1910
|
}
|
|
1758
|
-
}
|
|
1911
|
+
};
|
|
1912
|
+
try {
|
|
1913
|
+
return yield* withProgress<Turn, LoopEvent>(emit => this.provider.send(
|
|
1914
|
+
this.config.frozen, hydrated, this.state.node, signal, progress(emit), options));
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
// The outcome needs to know this exception came from the request, and after how many retries.
|
|
1917
|
+
this.providerFailure = { error, retries };
|
|
1918
|
+
throw error;
|
|
1919
|
+
}
|
|
1759
1920
|
}
|
|
1760
1921
|
|
|
1761
1922
|
/**
|
|
@@ -1829,7 +1990,10 @@ export class Session {
|
|
|
1829
1990
|
let context: ToolContext = { ...this.config.tools, ...(this.readOnlyRequest ? { workflowMode: "plan" as const, planHint: READ_ONLY_HINT } : {}),
|
|
1830
1991
|
imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1831
1992
|
advice: this.allowedNames(),
|
|
1832
|
-
imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {})
|
|
1993
|
+
imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}),
|
|
1994
|
+
// feat-approval-events: the broker has already recorded this call's
|
|
1995
|
+
// invocation, and its result comes after execution, so the order is fixed.
|
|
1996
|
+
onApproval: approval => this.record({ t: "approval", id: this.clock.nextId(), at: this.clock.now(), ...approval }) };
|
|
1833
1997
|
if (native) context = nativeCommandContext(context);
|
|
1834
1998
|
const decoder = new TextDecoder();
|
|
1835
1999
|
context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
|
|
@@ -2036,7 +2200,6 @@ export class Session {
|
|
|
2036
2200
|
|
|
2037
2201
|
const node = this.state.nextNode++;
|
|
2038
2202
|
this.state.branchesOpened += 1;
|
|
2039
|
-
const ref = await this.nodeRefFor(node);
|
|
2040
2203
|
// A fork point must include any preceding tool effects in this batch.
|
|
2041
2204
|
|
|
2042
2205
|
// A writing branch needs its own checkout, or parallel branches overwrite
|
|
@@ -2056,20 +2219,9 @@ export class Session {
|
|
|
2056
2219
|
this.state.worktrees.set(node, worktree);
|
|
2057
2220
|
}
|
|
2058
2221
|
|
|
2059
|
-
await this.
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
input: { source_event: sourceEvent, tools: allowed, max_turns: Math.min(this.config.maxTurns, 15), workspace: inputWorkspace },
|
|
2063
|
-
...(worktree ? { workspace: worktree } : {}),
|
|
2064
|
-
});
|
|
2065
|
-
|
|
2066
|
-
// basis "none": the branch starts fresh. The parent pointer is provenance.
|
|
2067
|
-
await initNode(this.config.logDir, node, {
|
|
2068
|
-
...await this.nodeRefFor(this.state.node), at_event: this.state.events.at(-1)!.id,
|
|
2069
|
-
}, this.clock.now(), "none");
|
|
2070
|
-
|
|
2071
|
-
const branch = new Session(
|
|
2072
|
-
{
|
|
2222
|
+
const { ref, branch } = await this.openBranch({
|
|
2223
|
+
node, purpose, background: args["background"] === true, sourceEvent, inputWorkspace, worktree,
|
|
2224
|
+
config: {
|
|
2073
2225
|
...this.config,
|
|
2074
2226
|
acceptanceContract: null,
|
|
2075
2227
|
frozen: { system: this.config.frozen.system, tools: branchTools(writes, allowed) },
|
|
@@ -2086,40 +2238,14 @@ export class Session {
|
|
|
2086
2238
|
workNamespace: `branch-${node}`,
|
|
2087
2239
|
maxTurns: Math.min(this.config.maxTurns, 15),
|
|
2088
2240
|
},
|
|
2089
|
-
|
|
2090
|
-
this.clock,
|
|
2091
|
-
this.allocation,
|
|
2092
|
-
);
|
|
2093
|
-
branch.state.node = node;
|
|
2094
|
-
branch.state.nextNode = this.state.nextNode;
|
|
2241
|
+
});
|
|
2095
2242
|
if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
|
|
2096
2243
|
this.children.set(node, branch);
|
|
2097
2244
|
yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
|
|
2098
2245
|
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
for await (const e of branch.send(prompt, signal)) {
|
|
2103
|
-
if (e.kind === "turn_start" || e.kind === "discard") finalParts = [];
|
|
2104
|
-
if (e.kind === "assistant_text") finalParts.push(e.text ?? "");
|
|
2105
|
-
if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
|
|
2106
|
-
}
|
|
2107
|
-
} catch {
|
|
2108
|
-
// Persist a bounded failure classification, never a provider's raw error body.
|
|
2109
|
-
outcome = executionOutcome(signal?.aborted ? "cancelled" : "execution_error");
|
|
2110
|
-
await branch.answerUnpairedTools("the branch execution failed; this tool's outcome is unknown; inspect before retrying");
|
|
2111
|
-
} finally { try { await branch.dispose(); } finally { this.children.delete(node); } }
|
|
2112
|
-
if (args["background"] === true) await branch.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), outcome, reason: "background branch execution ended" });
|
|
2113
|
-
// Node numbers the branch allocated are taken.
|
|
2114
|
-
|
|
2115
|
-
const tip = branch.state.node;
|
|
2116
|
-
const branchContext = await replayContext(this.config.logDir, tip);
|
|
2117
|
-
// Re-digest now: `ref` was taken at spawn, before this node had events.
|
|
2118
|
-
const handoff = deriveHandoff({
|
|
2119
|
-
prev: branchContext.fullHandoff, depth: branchContext.depth, events: branchContext.events, self: await this.nodeRefFor(tip),
|
|
2120
|
-
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
|
|
2121
|
-
});
|
|
2122
|
-
await closeNode(this.config.logDir, tip, boundHandoff(handoff), (await readNode(this.config.logDir, tip)).events.length, this.clock.now());
|
|
2246
|
+
const { outcome, text } = await this.runChild(node, branch, prompt, args["background"] === true, signal);
|
|
2247
|
+
const closed = await this.closeBranch(ref, branch, outcome, text);
|
|
2248
|
+
const { tip, summary, receipt, receiptRef, joined } = closed;
|
|
2123
2249
|
|
|
2124
2250
|
// The worktree stays until the branch is resolved: its uncommitted work
|
|
2125
2251
|
// is the branch's result, and removing it here would discard exactly
|
|
@@ -2128,19 +2254,6 @@ export class Session {
|
|
|
2128
2254
|
? `\n\nIts changes are in ${worktree.path} on branch ${worktree.branch}, ` +
|
|
2129
2255
|
"not in your files. Adopting continues in that checkout without merging it into this one."
|
|
2130
2256
|
: "";
|
|
2131
|
-
const text = finalParts.join("\n");
|
|
2132
|
-
const summary = text.trim() || "(the branch produced no closing message)";
|
|
2133
|
-
const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
|
|
2134
|
-
if (!receiptHistory.ok) throw new Error(receiptHistory.error);
|
|
2135
|
-
const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome, handoff, nodes: receiptHistory.nodes });
|
|
2136
|
-
const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
|
|
2137
|
-
const joined: LogEvent = {
|
|
2138
|
-
t: "joined", id: this.clock.nextId(), at: this.clock.now(),
|
|
2139
|
-
branch: ref, tip: await this.nodeRefFor(tip), handoff_ref: handoffPath(this.config.logDir, tip), summary,
|
|
2140
|
-
model: branch.provider.config.spec.id,
|
|
2141
|
-
outcome,
|
|
2142
|
-
receipt_ref: receiptRef,
|
|
2143
|
-
};
|
|
2144
2257
|
|
|
2145
2258
|
// Only the summary crosses back. The branch's own calls stay in its node,
|
|
2146
2259
|
// which is the entire reason to branch rather than read in place.
|
|
@@ -2169,6 +2282,84 @@ export class Session {
|
|
|
2169
2282
|
};
|
|
2170
2283
|
}
|
|
2171
2284
|
|
|
2285
|
+
/**
|
|
2286
|
+
* D14: the parent records `spawn`, the branch gets its own node file with
|
|
2287
|
+
* basis "none" (it starts fresh; the parent pointer is provenance only).
|
|
2288
|
+
* Shared by model-spawned branches and user-started ones (/review-code).
|
|
2289
|
+
*/
|
|
2290
|
+
private async openBranch(spec: {
|
|
2291
|
+
/** Omitted for a user command in an empty session: the spawn is then its own request. */
|
|
2292
|
+
node: number; purpose: string; background: boolean; sourceEvent?: string;
|
|
2293
|
+
inputWorkspace: { kind: "shared_readonly" } | { kind: "git_tree"; head: string; tree: string };
|
|
2294
|
+
worktree: { path: string; branch: string } | null; config: SessionConfig; provider?: Provider;
|
|
2295
|
+
}): Promise<{ ref: NodeRef; branch: Session }> {
|
|
2296
|
+
const { node, purpose, worktree } = spec;
|
|
2297
|
+
const ref = await this.nodeRefFor(node), id = this.clock.nextId();
|
|
2298
|
+
await this.record({
|
|
2299
|
+
t: "spawn", id, at: this.clock.now(), branch: ref, purpose,
|
|
2300
|
+
...(spec.background ? { background: true as const } : {}),
|
|
2301
|
+
input: { source_event: spec.sourceEvent ?? id, tools: spec.config.tools.allow ?? [], max_turns: spec.config.maxTurns, workspace: spec.inputWorkspace },
|
|
2302
|
+
...(worktree ? { workspace: worktree } : {}),
|
|
2303
|
+
});
|
|
2304
|
+
await initNode(this.config.logDir, node, {
|
|
2305
|
+
...await this.nodeRefFor(this.state.node), at_event: this.state.events.at(-1)!.id,
|
|
2306
|
+
}, this.clock.now(), "none");
|
|
2307
|
+
const branch = new Session(spec.config, spec.provider ?? (this.config.branchProvider ? this.config.branchProvider() : this.provider), this.clock, this.allocation);
|
|
2308
|
+
branch.state.node = node;
|
|
2309
|
+
branch.state.nextNode = this.state.nextNode;
|
|
2310
|
+
branch.secrets = this.secrets;
|
|
2311
|
+
branch.subscribeRecords(item => { for (const listener of this.branchRecordListeners) { try { listener(item); } catch { /* observers never own the append */ } } });
|
|
2312
|
+
return { ref, branch };
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/** Runs the child to its end. Its closing text is the last turn's text; a throw becomes a bounded outcome. */
|
|
2316
|
+
private async runChild(node: number, branch: Session, prompt: string, background: boolean, signal?: AbortSignal, keepRecorded = false): Promise<{ outcome: ExecutionOutcome; text: string }> {
|
|
2317
|
+
let finalParts: string[] = [];
|
|
2318
|
+
let outcome = executionOutcome("unknown");
|
|
2319
|
+
try {
|
|
2320
|
+
for await (const e of branch.send(prompt, signal)) {
|
|
2321
|
+
if (e.kind === "turn_start" || e.kind === "discard") finalParts = [];
|
|
2322
|
+
if (e.kind === "assistant_text") finalParts.push(e.text ?? "");
|
|
2323
|
+
if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
|
|
2324
|
+
}
|
|
2325
|
+
} catch {
|
|
2326
|
+
// Persist a bounded failure classification, never a provider's raw error body.
|
|
2327
|
+
// A model's branch joins as execution_error (its caller sees a failed tool). A user branch keeps the
|
|
2328
|
+
// classification its own send recorded (provider_error vs execution_error), as the detached review did.
|
|
2329
|
+
const recorded = keepRecorded ? branch.state.events.findLast(event => event.t === "outcome") : undefined;
|
|
2330
|
+
outcome = signal?.aborted ? executionOutcome("cancelled") : recorded?.t === "outcome" ? recorded.outcome : executionOutcome("execution_error");
|
|
2331
|
+
await branch.answerUnpairedTools("the branch execution failed; this tool's outcome is unknown; inspect before retrying");
|
|
2332
|
+
} finally { try { await branch.dispose(); } finally { this.children.delete(node); } }
|
|
2333
|
+
if (background) await branch.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), outcome, reason: "background branch execution ended" });
|
|
2334
|
+
return { outcome, text: finalParts.join("\n") };
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/** Closes the branch's tip with its derived handoff and builds the `joined` the parent will record (D15). */
|
|
2338
|
+
private async closeBranch(ref: NodeRef, branch: Session, outcome: ExecutionOutcome, text: string) {
|
|
2339
|
+
// Node numbers the branch allocated are taken.
|
|
2340
|
+
const tip = branch.state.node;
|
|
2341
|
+
const branchContext = await replayContext(this.config.logDir, tip);
|
|
2342
|
+
// Re-digest now: `ref` was taken at spawn, before this node had events.
|
|
2343
|
+
const handoff = deriveHandoff({
|
|
2344
|
+
prev: branchContext.fullHandoff, depth: branchContext.depth, events: branchContext.events, self: await this.nodeRefFor(tip),
|
|
2345
|
+
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
|
|
2346
|
+
});
|
|
2347
|
+
await closeNode(this.config.logDir, tip, boundHandoff(handoff), (await readNode(this.config.logDir, tip)).events.length, this.clock.now());
|
|
2348
|
+
const summary = text.trim() || "(the branch produced no closing message)";
|
|
2349
|
+
const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
|
|
2350
|
+
if (!receiptHistory.ok) throw new Error(receiptHistory.error);
|
|
2351
|
+
const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome, handoff, nodes: receiptHistory.nodes });
|
|
2352
|
+
const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
|
|
2353
|
+
const joined: LogEvent = {
|
|
2354
|
+
t: "joined", id: this.clock.nextId(), at: this.clock.now(),
|
|
2355
|
+
branch: ref, tip: await this.nodeRefFor(tip), handoff_ref: handoffPath(this.config.logDir, tip), summary,
|
|
2356
|
+
model: branch.provider.config.spec.id,
|
|
2357
|
+
outcome,
|
|
2358
|
+
receipt_ref: receiptRef,
|
|
2359
|
+
};
|
|
2360
|
+
return { tip, summary, receipt, receiptRef, joined };
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2172
2363
|
private async runWorktree(input: unknown, pathRoot?: string): Promise<ToolResult> {
|
|
2173
2364
|
const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
|
|
2174
2365
|
const root = this.config.tools.root;
|
|
@@ -2881,7 +3072,7 @@ export class Session {
|
|
|
2881
3072
|
next_thinking: nextThinking,
|
|
2882
3073
|
...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
|
|
2883
3074
|
handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
2884
|
-
...(this.config.workingSet ? { working_set_version:
|
|
3075
|
+
...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}),
|
|
2885
3076
|
guidance_tools: guidanceTools,
|
|
2886
3077
|
...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
|
|
2887
3078
|
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a stored image in the system viewer (docs/tasks/feat-tui-image-full-size.md).
|
|
3
|
+
*
|
|
4
|
+
* Blobs are content-addressed and have no extension, and viewers recognise a
|
|
5
|
+
* file by its extension, so the verified bytes are copied once to
|
|
6
|
+
* `<tmpdir>/xeno-images/<sha256>.<ext>` and that file is opened. The name is
|
|
7
|
+
* the hash: opening the same picture again reuses the file, and a file under
|
|
8
|
+
* that name can only hold those bytes because we never write it twice.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { getBlobBytes } from "../store/blobs.ts";
|
|
14
|
+
import { MAX_IMAGE_SOURCE_BYTES } from "../core/image-limits.ts";
|
|
15
|
+
import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
|
|
16
|
+
|
|
17
|
+
const EXTENSIONS: Readonly<Record<string, string>> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp" };
|
|
18
|
+
|
|
19
|
+
/** The platform's "open with the default application" command. */
|
|
20
|
+
function launchViewer(path: string): void {
|
|
21
|
+
Bun.spawn([process.platform === "darwin" ? "open" : "xdg-open", path], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Opens the blob; returns why it could not, or null once the viewer was started. */
|
|
25
|
+
export async function openImageFile(blobDir: string, sha256: string,
|
|
26
|
+
options: { dir?: string; launch?: (path: string) => void } = {}): Promise<string | null> {
|
|
27
|
+
let bytes: Uint8Array | null;
|
|
28
|
+
try { bytes = await getBlobBytes(blobDir, sha256, MAX_IMAGE_SOURCE_BYTES); } catch (error) { return `Cannot open image: ${error instanceof Error ? error.message : String(error)}`; }
|
|
29
|
+
if (bytes === null) return "Cannot open image: it is no longer in the session's blob store";
|
|
30
|
+
const mime = detectSupportedImageMimeType(bytes);
|
|
31
|
+
const extension = mime === null ? undefined : EXTENSIONS[mime];
|
|
32
|
+
if (extension === undefined) return "Cannot open image: unrecognised image format";
|
|
33
|
+
const dir = options.dir ?? join(tmpdir(), "xeno-images");
|
|
34
|
+
const path = join(dir, `${sha256}.${extension}`);
|
|
35
|
+
try {
|
|
36
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
37
|
+
// `wx`: an existing file is a previous copy of these same bytes.
|
|
38
|
+
await writeFile(path, bytes, { flag: "wx", mode: 0o600 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; });
|
|
39
|
+
(options.launch ?? launchViewer)(path);
|
|
40
|
+
} catch (error) { return `Cannot open image: ${error instanceof Error ? error.message : String(error)}`; }
|
|
41
|
+
return null;
|
|
42
|
+
}
|