@visual-z/xeno 0.1.3 → 0.2.0
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 +19 -0
- package/README.md +143 -33
- package/docs/install.md +18 -6
- package/docs/providers.md +147 -0
- package/package.json +8 -2
- package/src/apps/cli.ts +42 -14
- package/src/apps/commands.ts +32 -4
- package/src/apps/completions.ts +9 -6
- package/src/apps/export-cli.ts +24 -0
- package/src/apps/extension-ui.ts +330 -0
- package/src/apps/input.ts +16 -1
- package/src/apps/model-catalog.ts +42 -0
- package/src/apps/project-trust.ts +49 -0
- package/src/apps/render.ts +1 -0
- package/src/apps/resource-browser.ts +75 -0
- package/src/apps/resources.ts +25 -0
- package/src/apps/session-browser.ts +113 -0
- package/src/apps/session-picker.ts +55 -0
- package/src/apps/shell.ts +1177 -378
- package/src/apps/startup.ts +400 -268
- package/src/core/action-defaults.ts +306 -0
- package/src/core/attachment-input.ts +17 -0
- package/src/core/catalog-merge.ts +19 -0
- package/src/core/event-validation.ts +19 -2
- package/src/core/events.ts +45 -2
- package/src/core/export.ts +6 -2
- package/src/core/extension-flags.ts +14 -0
- package/src/core/images.ts +46 -0
- package/src/core/json-event.ts +2 -0
- package/src/core/keymap.ts +43 -119
- package/src/core/model-replay.ts +22 -0
- package/src/core/model-scope.ts +53 -0
- package/src/core/packages.ts +47 -0
- package/src/core/progress.ts +9 -0
- package/src/core/project.ts +21 -3
- package/src/core/prompts.ts +33 -5
- package/src/core/queue.ts +18 -0
- package/src/core/resource-selection.ts +36 -0
- package/src/{runtime → core}/resume.ts +15 -22
- package/src/core/session-archive.ts +4 -0
- package/src/core/session-browser.ts +82 -0
- package/src/core/session-entry.ts +56 -0
- package/src/core/session-position.ts +26 -0
- package/src/core/settings.ts +169 -154
- package/src/core/theme.ts +15 -6
- package/src/core/thinking.ts +26 -0
- package/src/core/tokens.ts +2 -0
- package/src/core/transcript.ts +15 -5
- package/src/runtime/auth.ts +116 -11
- package/src/runtime/catalog.ts +54 -151
- package/src/runtime/clipboard.ts +114 -0
- package/src/runtime/extension-loader.ts +37 -0
- package/src/runtime/extension-ui.ts +55 -0
- package/src/runtime/extensions.ts +273 -0
- package/src/runtime/images.ts +76 -0
- package/src/runtime/loop.ts +213 -83
- package/src/runtime/model-config.ts +232 -0
- package/src/runtime/model-data.LICENSE +27 -0
- package/src/runtime/model-data.ts +21 -0
- package/src/runtime/model-replay.ts +10 -0
- package/src/runtime/model-scope.ts +20 -0
- package/src/runtime/model-sources.ts +151 -0
- package/src/runtime/model.ts +74 -11
- package/src/runtime/models-fetch.ts +6 -104
- package/src/runtime/oauth.ts +4 -3
- package/src/runtime/openai-responses.ts +12 -4
- package/src/runtime/openai.ts +13 -4
- package/src/runtime/package-manager.ts +125 -0
- package/src/runtime/pi-provider.ts +235 -0
- package/src/runtime/progress.ts +26 -0
- package/src/runtime/provider-access.ts +21 -0
- package/src/runtime/provider-protocols.ts +29 -0
- package/src/runtime/provider-request.ts +73 -0
- package/src/runtime/provider.ts +26 -6
- package/src/runtime/rich-text.ts +36 -0
- package/src/runtime/tools.ts +41 -5
- package/src/store/command.ts +7 -1
- package/src/store/context-files.ts +1 -1
- package/src/store/log.ts +65 -0
- package/src/store/models.ts +28 -9
- package/src/store/observations.ts +4 -2
- package/src/store/packages.ts +98 -0
- package/src/store/recovery.ts +8 -0
- package/src/store/resource-index.ts +170 -0
- package/src/store/resources.ts +75 -142
- package/src/store/session-archive.ts +162 -0
- package/src/store/session-browser.ts +68 -0
- package/src/store/session-position.ts +27 -0
- package/src/store/sessions.ts +87 -66
- package/src/store/trust.ts +13 -0
- package/src/store/workspace.ts +6 -0
- package/src/tui/chat-viewport.ts +47 -0
- package/src/tui/editor.ts +62 -15
- package/src/tui/host.ts +50 -9
- package/src/tui/input.ts +8 -2
- package/src/tui/select.ts +24 -6
- package/src/tui/settings.ts +4 -0
- package/src/tui/system-theme.ts +700 -0
- package/src/tui/transcript.ts +63 -11
- package/src/tui/tree.ts +161 -95
- package/src/vendor/pi-rich/LICENSE.upstream +21 -0
- package/src/vendor/pi-rich/UPSTREAM.json +17 -0
- package/src/vendor/pi-rich/clipboard-command.ts +44 -0
- package/src/vendor/pi-rich/exif-orientation.ts +182 -0
- package/src/vendor/pi-rich/html.ts +51 -0
- package/src/vendor/pi-rich/image-convert.ts +49 -0
- package/src/vendor/pi-rich/image-resize-core.ts +164 -0
- package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
- package/src/vendor/pi-rich/image-resize.ts +123 -0
- package/src/vendor/pi-rich/mime.ts +116 -0
- package/src/vendor/pi-rich/photon.ts +139 -0
- package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
- package/src/vendor/pi-tui/UPSTREAM.json +50 -0
- package/src/vendor/pi-tui/VENDOR.md +7 -5
- package/src/vendor/pi-tui/autocomplete.ts +35 -10
- package/src/vendor/pi-tui/colors.ts +367 -0
- package/src/vendor/pi-tui/components/box.ts +8 -9
- package/src/vendor/pi-tui/components/editor.ts +4 -2
- package/src/vendor/pi-tui/components/markdown.ts +8 -2
- package/src/vendor/pi-tui/index.ts +31 -1
- package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
- package/src/vendor/pi-tui/native/clipboard.h +208 -0
- package/src/vendor/pi-tui/native/darwin/README.md +22 -0
- package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
- package/src/vendor/pi-tui/native/linux/README.md +23 -0
- package/src/vendor/pi-tui/native/linux/build.sh +65 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
- package/src/vendor/pi-tui/native/napi.h +91 -0
- package/src/vendor/pi-tui/native/win32/README.md +31 -0
- package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
- package/src/vendor/pi-tui/native-platform.ts +2 -0
- package/src/vendor/pi-tui/oklab.ts +233 -0
- package/src/vendor/pi-tui/terminal-colors.ts +26 -8
- package/src/vendor/pi-tui/terminal-image.ts +43 -8
- package/src/vendor/pi-tui/terminal.ts +16 -9
- package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
- package/src/vendor/pi-tui/tui.ts +112 -75
- package/src/vendor/pi-tui/utils.ts +89 -49
- package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
package/src/runtime/loop.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { LogEvent, AssistantBlock, NodeRef, CutReason } from "../core/events.ts";
|
|
22
|
+
import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
|
|
23
|
+
import { hydrateImages } from "./images.ts";
|
|
22
24
|
import type { Handoff } from "../core/handoff.ts";
|
|
23
25
|
import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
|
|
24
26
|
import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
|
|
@@ -43,9 +45,10 @@ import { churnReview } from "../core/churn.ts";
|
|
|
43
45
|
import { deadlineNotice } from "../core/deadline.ts";
|
|
44
46
|
import { statusOf } from "./retry.ts";
|
|
45
47
|
import { assertNever } from "../core/exhaustive.ts";
|
|
48
|
+
import { isLogEvent } from "../core/event-validation.ts";
|
|
46
49
|
import {
|
|
47
50
|
initNode, appendEvent, readNode, closeNode, digestNode, handoffPath, nodePath, manifestPath,
|
|
48
|
-
listNodes, readCursor, writeCursor,
|
|
51
|
+
listNodes, readCursor, readCursorAt, writeCursor,
|
|
49
52
|
} from "../store/log.ts";
|
|
50
53
|
import { replayContext } from "../store/replay.ts";
|
|
51
54
|
import { recoverCut } from "../store/recovery.ts";
|
|
@@ -66,8 +69,16 @@ import {
|
|
|
66
69
|
type ToolContext, type Annotation, type ToolResult,
|
|
67
70
|
} from "./tools.ts";
|
|
68
71
|
import { diagnosePrefix, totalUsage, type UsageSample, type UsageTotals } from "./usage.ts";
|
|
69
|
-
import { planResume } from "
|
|
72
|
+
import { planResume } from "../core/resume.ts";
|
|
70
73
|
import type { Phase } from "../core/queue.ts";
|
|
74
|
+
import { sameModel } from "../core/session-position.ts";
|
|
75
|
+
import type { Effort } from "./model.ts";
|
|
76
|
+
import { providerReplayKey, nativeReplay } from "./model-replay.ts";
|
|
77
|
+
import { needsReplayCheckpoint } from "../core/model-replay.ts";
|
|
78
|
+
import { resolveHistoryPosition } from "../store/session-position.ts";
|
|
79
|
+
import { observeCommand } from "../store/command.ts";
|
|
80
|
+
import { headTailView } from "../core/observation-view.ts";
|
|
81
|
+
import { withProgress } from "./progress.ts";
|
|
71
82
|
|
|
72
83
|
/** Ids and timestamps come from here so the loop stays the only impure part. */
|
|
73
84
|
export interface Clock {
|
|
@@ -90,6 +101,7 @@ export interface LoopEvent {
|
|
|
90
101
|
| "text_delta"
|
|
91
102
|
| "thinking_delta"
|
|
92
103
|
| "discard"
|
|
104
|
+
| "progress"
|
|
93
105
|
| "tool_call"
|
|
94
106
|
| "tool_result"
|
|
95
107
|
| "usage"
|
|
@@ -110,6 +122,7 @@ export interface LoopEvent {
|
|
|
110
122
|
/** branch_start: wall-clock milliseconds, for asserting the stagger. */
|
|
111
123
|
at?: number;
|
|
112
124
|
outcome?: ExecutionOutcome;
|
|
125
|
+
progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string };
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
/**
|
|
@@ -191,6 +204,7 @@ export interface SessionConfig {
|
|
|
191
204
|
* usable without the store -- a read-only branch has nothing to snapshot.
|
|
192
205
|
*/
|
|
193
206
|
blobDir?: string;
|
|
207
|
+
imageOptions?: { readonly autoResize?: boolean; readonly blockImages?: boolean };
|
|
194
208
|
/** Directory names the scan skips, relative to the workspace root. */
|
|
195
209
|
snapshotIgnore?: readonly string[];
|
|
196
210
|
}
|
|
@@ -236,6 +250,8 @@ async function digestedRef(dir: string, node: number): Promise<NodeRef> {
|
|
|
236
250
|
*/
|
|
237
251
|
export interface RestorePoint {
|
|
238
252
|
node: number;
|
|
253
|
+
/** The user message is the editable navigation target; idle snapshots have none. */
|
|
254
|
+
userId?: string;
|
|
239
255
|
/** Last event of the exchange. Inclusive: the exchange itself is kept. */
|
|
240
256
|
atEvent: string;
|
|
241
257
|
/** The prompt that opened the exchange, for a caller that has to show it. */
|
|
@@ -335,7 +351,7 @@ export class Session {
|
|
|
335
351
|
const existing = await listNodes(dir);
|
|
336
352
|
const recordedCursor = await readCursor(dir);
|
|
337
353
|
const cursor = recordedCursor === null ? null : await recoverCut(dir, recordedCursor);
|
|
338
|
-
const plan = planResume(cursor, model, existing.length > 0);
|
|
354
|
+
const plan = planResume(cursor, model, existing.length > 0, this.provider.config.spec.provider);
|
|
339
355
|
|
|
340
356
|
switch (plan.kind) {
|
|
341
357
|
case "refuse":
|
|
@@ -383,6 +399,7 @@ export class Session {
|
|
|
383
399
|
else delete this.config.snapshotIgnore;
|
|
384
400
|
}
|
|
385
401
|
if (plan.cursor.workspace !== undefined) this.config.tools.root = plan.cursor.workspace;
|
|
402
|
+
if (plan.cursor.blob_dir !== undefined) this.config.blobDir = plan.cursor.blob_dir;
|
|
386
403
|
for (const id of existing) {
|
|
387
404
|
for (const event of (await readNode(dir, id)).events) {
|
|
388
405
|
this.allocatedEventIds.add(event.id);
|
|
@@ -430,6 +447,9 @@ export class Session {
|
|
|
430
447
|
return assertNever(plan, "Session.start");
|
|
431
448
|
}
|
|
432
449
|
|
|
450
|
+
if (cursor?.needs_model_checkpoint || needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
|
|
451
|
+
await this.performCut("model_checkpoint", model);
|
|
452
|
+
}
|
|
433
453
|
await this.saveCursor();
|
|
434
454
|
}
|
|
435
455
|
|
|
@@ -439,8 +459,76 @@ export class Session {
|
|
|
439
459
|
* a fork that rewrote the original would leave the user with one line
|
|
440
460
|
* when they thought they had two.
|
|
441
461
|
*/
|
|
442
|
-
async fork(destLogDir: string, untilEvent?: string): Promise<void> {
|
|
443
|
-
await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent);
|
|
462
|
+
async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
|
|
463
|
+
await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async setThinking(effort: Effort): Promise<void> {
|
|
467
|
+
this.provider.config.effort = effort;
|
|
468
|
+
await this.saveCursor();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Pi user bash shares process ownership and archival with tools, never their authority or model dispatch. */
|
|
472
|
+
async runUserBash(command: string, excludeFromContext: boolean, signal?: AbortSignal,
|
|
473
|
+
onOutput?: (chunk: Uint8Array) => void): Promise<Extract<LogEvent, { t: "user_bash" }>> {
|
|
474
|
+
const owned = this.execution.begin(signal);
|
|
475
|
+
try {
|
|
476
|
+
if (!command.trim()) throw new Error("empty user command");
|
|
477
|
+
for await (const event of this.snapshotTurn([], false, "idle")) {
|
|
478
|
+
if (event.kind === "snapshot_failed") throw new Error(event.reason);
|
|
479
|
+
}
|
|
480
|
+
const captured = await observeCommand({ command, cwd: this.config.tools.root,
|
|
481
|
+
outputStore: join(this.config.logDir, "artifacts"), maxOutputBytes: this.config.tools.maxOutputBytes,
|
|
482
|
+
timeoutMs: this.config.tools.timeoutMs, signal: owned.signal, ...(onOutput ? { onOutput } : {}) });
|
|
483
|
+
const outcome = executionOutcome(captured.stopped === "cancelled" ? "cancelled" : captured.stopped === "timeout" ? "timeout"
|
|
484
|
+
: captured.exit === 0 ? "tool_completed" : "tool_failed");
|
|
485
|
+
const preview = headTailView(captured.head, captured.tail, captured.ref.bytes, this.config.tools.maxOutputBytes,
|
|
486
|
+
omitted => `\n[${omitted} bytes omitted; complete output: ${captured.ref.path}]\n`);
|
|
487
|
+
const content = `${preview}\nexit ${captured.exit} · ${outcome.status} · ${outcome.reason}`;
|
|
488
|
+
const event: Extract<LogEvent, { t: "user_bash" }> = { t: "user_bash", id: this.clock.nextId(), at: this.clock.now(),
|
|
489
|
+
command, content, exit: captured.exit, excludeFromContext, outcome, content_ref: captured.ref };
|
|
490
|
+
await this.record(event);
|
|
491
|
+
for await (const snapshot of this.snapshotTurn([])) {
|
|
492
|
+
if (snapshot.kind === "snapshot_failed") throw new Error(snapshot.reason);
|
|
493
|
+
}
|
|
494
|
+
await this.saveCursor();
|
|
495
|
+
return event;
|
|
496
|
+
} catch (error) {
|
|
497
|
+
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
498
|
+
reason: "user command failed before completion; inspect the workspace before retrying", outcome: executionOutcome("execution_error") });
|
|
499
|
+
throw error;
|
|
500
|
+
} finally { owned.finish(); }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async userMessages(): Promise<{ id: string; node: number; text: string; images?: readonly ImageRef[] }[]> {
|
|
504
|
+
const messages: Awaited<ReturnType<Session["userMessages"]>> = [];
|
|
505
|
+
for (const node of await listNodes(this.config.logDir)) {
|
|
506
|
+
for (const event of (await readNode(this.config.logDir, node)).events) {
|
|
507
|
+
if (event.t === "user") messages.push({ id: event.id, node, ...userInputData(event) });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return messages;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Pi keeps the selected runtime model when navigating; N.8 checkpoints incompatible context. */
|
|
514
|
+
async navigateTo(eventId: string): Promise<{ editorText?: string; images?: readonly ImageRef[] }> {
|
|
515
|
+
const dir = this.config.logDir;
|
|
516
|
+
const users = await this.userMessages();
|
|
517
|
+
const target = await resolveHistoryPosition(dir, eventId, users.some(user => user.id === eventId) ? "before" : "at");
|
|
518
|
+
for await (const event of this.snapshotTurn([], false, "idle")) {
|
|
519
|
+
if (event.kind === "snapshot_failed") throw new Error(event.reason);
|
|
520
|
+
}
|
|
521
|
+
const node = this.state.nextNode++;
|
|
522
|
+
await initNode(dir, node, target.parent, this.clock.now(), target.basis);
|
|
523
|
+
const cursor = await readCursor(dir);
|
|
524
|
+
if (!cursor) throw new Error("missing current session cursor");
|
|
525
|
+
const next = { ...cursor, node, next_node: this.state.nextNode, updated_at: this.clock.now(),
|
|
526
|
+
needs_model_checkpoint: target.parent !== null && (target.selection === null || !sameModel(target.selection, cursor)) };
|
|
527
|
+
delete next.at_event;
|
|
528
|
+
await writeCursor(dir, next);
|
|
529
|
+
await this.start();
|
|
530
|
+
const images = users.find(user => user.id === eventId)?.images;
|
|
531
|
+
return target.editorText === undefined ? {} : { editorText: target.editorText, ...(images?.length ? { images } : {}) };
|
|
444
532
|
}
|
|
445
533
|
|
|
446
534
|
/**
|
|
@@ -470,18 +558,31 @@ export class Session {
|
|
|
470
558
|
* set; switchModel is the other caller that wants it.
|
|
471
559
|
*/
|
|
472
560
|
async switchModel(next: Provider): Promise<void> {
|
|
473
|
-
if (this.isHalfRound()) {
|
|
561
|
+
if (this.running || this.isHalfRound()) {
|
|
474
562
|
throw new Error(
|
|
475
563
|
"cannot switch models mid-turn: a tool_use is still unpaired. " +
|
|
476
564
|
"Wait for the turn to finish so every call has a result",
|
|
477
565
|
);
|
|
478
566
|
}
|
|
479
567
|
|
|
480
|
-
await this.performCut("model_checkpoint", next.config.spec.id);
|
|
568
|
+
await this.performCut("model_checkpoint", next.config.spec.id, undefined, next.config.spec.provider, next.config.effort);
|
|
481
569
|
this.provider = next;
|
|
482
570
|
await this.saveCursor();
|
|
483
571
|
}
|
|
484
572
|
|
|
573
|
+
/** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
|
|
574
|
+
async reloadResources(frozen: FrozenPrefix): Promise<boolean> {
|
|
575
|
+
if (JSON.stringify(frozen) === JSON.stringify(this.config.frozen)) return false;
|
|
576
|
+
if (this.running || this.isHalfRound()) throw new Error("cannot reload resources while a turn is active or a tool call is unpaired");
|
|
577
|
+
const contextRef = await this.persistContext(frozen);
|
|
578
|
+
await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
|
|
579
|
+
this.provider.config.spec.provider, this.provider.config.effort, contextRef);
|
|
580
|
+
this.config.frozen = frozen;
|
|
581
|
+
this.config.tools.allow = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
|
|
582
|
+
await this.saveCursor();
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
|
|
485
586
|
/**
|
|
486
587
|
* The same pairing check switchModel already used. A user message
|
|
487
588
|
* between tool_use and tool_result is the same unpairable request a
|
|
@@ -503,11 +604,11 @@ export class Session {
|
|
|
503
604
|
* offered a line — writing it would be the pairing bug this guards.
|
|
504
605
|
*/
|
|
505
606
|
private async pullSteering(
|
|
506
|
-
pull?: (phase: Phase) => Promise<
|
|
507
|
-
): Promise<
|
|
607
|
+
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
608
|
+
): Promise<UserInput | null> {
|
|
508
609
|
if (pull === undefined || this.isHalfRound()) return null;
|
|
509
610
|
const text = await pull("after_tools");
|
|
510
|
-
if (text === null || text.length === 0) return null;
|
|
611
|
+
if (text === null || typeof text === "string" && text.length === 0) return null;
|
|
511
612
|
return text;
|
|
512
613
|
}
|
|
513
614
|
|
|
@@ -518,11 +619,9 @@ export class Session {
|
|
|
518
619
|
* resuming from -- a crash, a kill, a closed laptop -- do not run exit
|
|
519
620
|
* handlers.
|
|
520
621
|
*/
|
|
521
|
-
private async
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const contextRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify({
|
|
525
|
-
frozen: this.config.frozen, gates: this.config.tools.gates,
|
|
622
|
+
private async persistContext(frozen = this.config.frozen): Promise<string> {
|
|
623
|
+
return putBlob(join(this.config.logDir, "artifacts"), JSON.stringify({
|
|
624
|
+
frozen, gates: this.config.tools.gates,
|
|
526
625
|
workNamespace: this.config.workNamespace ?? "main",
|
|
527
626
|
maxParallelTools: this.config.maxParallelTools ?? 3,
|
|
528
627
|
...(this.config.boundary ? { boundary: this.config.boundary } : {}),
|
|
@@ -532,13 +631,24 @@ export class Session {
|
|
|
532
631
|
...(this.config.guidance === undefined ? {} : { guidance: this.config.guidance }),
|
|
533
632
|
...(this.config.snapshotIgnore ? { snapshotIgnore: this.config.snapshotIgnore } : {}),
|
|
534
633
|
}));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private async saveCursor(model = this.provider.config.spec.id, provider = this.provider.config.spec.provider,
|
|
637
|
+
thinking = this.provider.config.effort, contextRef?: string): Promise<void> {
|
|
638
|
+
// Branches share storage but never own the parent's active cursor.
|
|
639
|
+
if ((this.config.branchDepth ?? 0) > 0) return;
|
|
640
|
+
const last = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
|
|
535
641
|
await writeCursor(this.config.logDir, {
|
|
536
642
|
node: this.state.node,
|
|
537
643
|
next_node: this.state.nextNode,
|
|
538
|
-
model
|
|
644
|
+
model,
|
|
645
|
+
provider,
|
|
646
|
+
thinking,
|
|
647
|
+
...(last ? { at_event: last.id } : {}),
|
|
648
|
+
...(this.config.blobDir === undefined ? {} : { blob_dir: this.config.blobDir }),
|
|
539
649
|
updated_at: this.clock.now(),
|
|
540
650
|
workspace: this.config.tools.root,
|
|
541
|
-
context_ref: contextRef,
|
|
651
|
+
context_ref: contextRef ?? await this.persistContext(),
|
|
542
652
|
});
|
|
543
653
|
}
|
|
544
654
|
|
|
@@ -548,6 +658,15 @@ export class Session {
|
|
|
548
658
|
if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
|
|
549
659
|
}
|
|
550
660
|
|
|
661
|
+
async appendExtension(entry: Omit<Extract<LogEvent, { t: "extension" }>, "t" | "id" | "at">): Promise<void> {
|
|
662
|
+
// Messages are only pairable between tools. Private extension state may
|
|
663
|
+
// be recorded during execution because it never enters model context.
|
|
664
|
+
if (entry.inContext && (this.running || this.isHalfRound())) throw new Error("Custom messages must be queued until the active turn finishes");
|
|
665
|
+
const event = { ...entry, t: "extension" as const, id: this.clock.nextId(), at: this.clock.now() };
|
|
666
|
+
if (!isLogEvent(event)) throw new Error("Invalid extension entry");
|
|
667
|
+
await this.record(event);
|
|
668
|
+
}
|
|
669
|
+
|
|
551
670
|
get totals(): UsageTotals {
|
|
552
671
|
return totalUsage(this.state.usage);
|
|
553
672
|
}
|
|
@@ -568,9 +687,9 @@ export class Session {
|
|
|
568
687
|
* available, and would also lie about a call that may have had effects).
|
|
569
688
|
*/
|
|
570
689
|
async *send(
|
|
571
|
-
userText:
|
|
690
|
+
userText: UserInput,
|
|
572
691
|
signal?: AbortSignal,
|
|
573
|
-
pull?: (phase: Phase) => Promise<
|
|
692
|
+
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
574
693
|
): AsyncGenerator<LoopEvent> {
|
|
575
694
|
const owned = this.execution.begin(signal);
|
|
576
695
|
const events = this.run(userText, owned.signal, pull);
|
|
@@ -580,10 +699,25 @@ export class Session {
|
|
|
580
699
|
for (;;) {
|
|
581
700
|
const item = await events.next();
|
|
582
701
|
if (item.done) { exhausted = true; break; }
|
|
583
|
-
if (item.value.kind === "done")
|
|
702
|
+
if (item.value.kind === "done") {
|
|
703
|
+
terminal = true;
|
|
704
|
+
if (item.value.outcome && item.value.outcome.reason !== "model_end") await this.record({
|
|
705
|
+
t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
706
|
+
reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
584
709
|
yield item.value;
|
|
585
710
|
}
|
|
586
|
-
if (!terminal)
|
|
711
|
+
if (!terminal) {
|
|
712
|
+
const outcome = executionOutcome("snapshot_failed");
|
|
713
|
+
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason: "workspace snapshot failed", outcome });
|
|
714
|
+
yield { kind: "done", reason: "stopped: workspace snapshot failed", outcome };
|
|
715
|
+
}
|
|
716
|
+
} catch (error) {
|
|
717
|
+
// Raw provider exceptions can contain credentials; persist only the execution fact.
|
|
718
|
+
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
719
|
+
reason: "request failed before completion; no complete response was committed", outcome: executionOutcome("provider_error") });
|
|
720
|
+
throw error;
|
|
587
721
|
} finally {
|
|
588
722
|
// Closing a consumer is not a user cancellation. Preserve the existing
|
|
589
723
|
// crash/recovery points; only an explicit signal can claim cancellation.
|
|
@@ -596,12 +730,22 @@ export class Session {
|
|
|
596
730
|
}
|
|
597
731
|
|
|
598
732
|
private async *run(
|
|
599
|
-
userText:
|
|
733
|
+
userText: UserInput,
|
|
600
734
|
signal?: AbortSignal,
|
|
601
|
-
pull?: (phase: Phase) => Promise<
|
|
735
|
+
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
602
736
|
): AsyncGenerator<LoopEvent> {
|
|
737
|
+
if (needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
|
|
738
|
+
if (this.isHalfRound()) throw new Error("Cannot change a wire profile while tool results are pending");
|
|
739
|
+
await this.performCut("model_checkpoint");
|
|
740
|
+
}
|
|
741
|
+
const blocked = this.config.imageOptions?.blockImages ?? false;
|
|
742
|
+
// A privacy change uses the existing mechanical checkpoint (D5/D8); it never edits old messages.
|
|
743
|
+
if (blocked && this.state.events.some(e => (e.t === "user" || e.t === "tool_result") && e.images?.some(image => !image.blocked))) {
|
|
744
|
+
if (this.isHalfRound()) throw new Error("Cannot change image transmission while tool results are pending");
|
|
745
|
+
await this.performCut("model_checkpoint");
|
|
746
|
+
}
|
|
603
747
|
await this.record({
|
|
604
|
-
t: "user", id: this.clock.nextId(), at: this.clock.now(),
|
|
748
|
+
t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(userText, blocked),
|
|
605
749
|
});
|
|
606
750
|
// Idle changes belong to the new prompt. Recording them before `user`
|
|
607
751
|
// would silently move the preceding exchange's restore point forward.
|
|
@@ -748,6 +892,7 @@ export class Session {
|
|
|
748
892
|
|
|
749
893
|
await this.record({
|
|
750
894
|
t: "assistant", id: this.clock.nextId(), at: this.clock.now(), blocks: result.blocks, stop_reason: result.stopReason,
|
|
895
|
+
replay_key: providerReplayKey(this.provider.config.spec),
|
|
751
896
|
});
|
|
752
897
|
|
|
753
898
|
for (const b of result.blocks) {
|
|
@@ -767,7 +912,7 @@ export class Session {
|
|
|
767
912
|
}
|
|
768
913
|
const extra = await this.pullSteering(pull);
|
|
769
914
|
if (extra !== null) {
|
|
770
|
-
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(),
|
|
915
|
+
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked) });
|
|
771
916
|
continue;
|
|
772
917
|
}
|
|
773
918
|
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
@@ -790,7 +935,7 @@ export class Session {
|
|
|
790
935
|
const extra = await this.pullSteering(pull);
|
|
791
936
|
if (extra !== null) {
|
|
792
937
|
await this.record({
|
|
793
|
-
t: "user", id: this.clock.nextId(), at: this.clock.now(),
|
|
938
|
+
t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
|
|
794
939
|
});
|
|
795
940
|
continue;
|
|
796
941
|
}
|
|
@@ -861,7 +1006,7 @@ export class Session {
|
|
|
861
1006
|
// `branch`/`resolve` act on the tree. Each still produces an ordinary
|
|
862
1007
|
// tool_result so the turn stays pairable. They go through the same
|
|
863
1008
|
// gate as read/write/edit/bash, which runTool applies itself.
|
|
864
|
-
const toolContext = { ...this.config.tools, ...(signal ? { signal } : {}) };
|
|
1009
|
+
const toolContext = { ...this.config.tools, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"), imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
|
|
865
1010
|
if (call.name === "note" || call.name === "branch" || call.name === "resolve" || call.name === "history") {
|
|
866
1011
|
const gated = await gateTool(toolContext, call.name, call.input);
|
|
867
1012
|
if (gated) {
|
|
@@ -879,7 +1024,10 @@ export class Session {
|
|
|
879
1024
|
out = await this.runResolve(call.input);
|
|
880
1025
|
}
|
|
881
1026
|
} else {
|
|
882
|
-
|
|
1027
|
+
const decoder = new TextDecoder();
|
|
1028
|
+
out = yield* withProgress<ToolResult, LoopEvent>(emit => runTool({ ...toolContext, callId: call.call_id,
|
|
1029
|
+
onOutput: chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } }),
|
|
1030
|
+
}, call.name, call.input));
|
|
883
1031
|
}
|
|
884
1032
|
}
|
|
885
1033
|
if (out.accept) for (const event of await out.accept()) yield event;
|
|
@@ -906,6 +1054,7 @@ export class Session {
|
|
|
906
1054
|
...(out.diagnostic !== undefined ? { diagnostic: out.diagnostic } : {}),
|
|
907
1055
|
...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
|
|
908
1056
|
...(out.content_ref ? { content_ref: out.content_ref } : {}),
|
|
1057
|
+
...(out.images?.length ? { images: submissionImages(out.images, blocked) } : {}),
|
|
909
1058
|
...(out.effect ? { effect: out.effect } : {}),
|
|
910
1059
|
});
|
|
911
1060
|
completedThisTurn.push(call.call_id);
|
|
@@ -930,7 +1079,7 @@ export class Session {
|
|
|
930
1079
|
const extra = await this.pullSteering(pull);
|
|
931
1080
|
if (extra !== null) {
|
|
932
1081
|
await this.record({
|
|
933
|
-
t: "user", id: this.clock.nextId(), at: this.clock.now(),
|
|
1082
|
+
t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
|
|
934
1083
|
});
|
|
935
1084
|
}
|
|
936
1085
|
}
|
|
@@ -1014,62 +1163,27 @@ export class Session {
|
|
|
1014
1163
|
messages: readonly Message[],
|
|
1015
1164
|
signal?: AbortSignal,
|
|
1016
1165
|
): AsyncGenerator<LoopEvent, Turn> {
|
|
1017
|
-
const
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
};
|
|
1022
|
-
|
|
1023
|
-
const onDelta = (delta: StreamDelta): void => {
|
|
1166
|
+
const hydrated = await hydrateImages(messages, this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1167
|
+
this.provider.config.spec.input?.includes("image") ?? false);
|
|
1168
|
+
return yield* withProgress<Turn, LoopEvent>(emit => this.provider.send(
|
|
1169
|
+
this.config.frozen, hydrated, this.state.node, signal, (delta: StreamDelta): void => {
|
|
1024
1170
|
switch (delta.kind) {
|
|
1025
1171
|
case "text":
|
|
1026
|
-
|
|
1172
|
+
emit({ kind: "text_delta", text: delta.text });
|
|
1027
1173
|
break;
|
|
1028
1174
|
case "thinking":
|
|
1029
|
-
|
|
1175
|
+
emit({ kind: "thinking_delta", text: delta.text });
|
|
1030
1176
|
break;
|
|
1031
1177
|
case "discard":
|
|
1032
|
-
|
|
1178
|
+
emit({ kind: "discard" });
|
|
1179
|
+
break;
|
|
1180
|
+
case "tool": case "retry":
|
|
1181
|
+
emit({ kind: "progress", progress: delta });
|
|
1033
1182
|
break;
|
|
1034
1183
|
default:
|
|
1035
1184
|
return assertNever(delta, "onDelta");
|
|
1036
1185
|
}
|
|
1037
|
-
|
|
1038
|
-
};
|
|
1039
|
-
|
|
1040
|
-
const pending = this.provider
|
|
1041
|
-
.send(this.config.frozen, messages, this.state.node, signal, onDelta)
|
|
1042
|
-
.then(
|
|
1043
|
-
(turn) => {
|
|
1044
|
-
box.settled = { ok: true, turn };
|
|
1045
|
-
notify?.();
|
|
1046
|
-
},
|
|
1047
|
-
(error: unknown) => {
|
|
1048
|
-
box.settled = { ok: false, error };
|
|
1049
|
-
notify?.();
|
|
1050
|
-
},
|
|
1051
|
-
);
|
|
1052
|
-
|
|
1053
|
-
while (box.settled === null || queue.length > 0) {
|
|
1054
|
-
if (queue.length > 0) {
|
|
1055
|
-
yield queue.shift()!;
|
|
1056
|
-
continue;
|
|
1057
|
-
}
|
|
1058
|
-
await new Promise<void>((resolve) => {
|
|
1059
|
-
if (box.settled !== null || queue.length > 0) {
|
|
1060
|
-
resolve();
|
|
1061
|
-
return;
|
|
1062
|
-
}
|
|
1063
|
-
notify = resolve;
|
|
1064
|
-
});
|
|
1065
|
-
notify = null;
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
await pending;
|
|
1069
|
-
const end = box.settled;
|
|
1070
|
-
if (end === null) throw new Error("provider send settled without a result");
|
|
1071
|
-
if (!end.ok) throw end.error;
|
|
1072
|
-
return end.turn;
|
|
1186
|
+
}));
|
|
1073
1187
|
}
|
|
1074
1188
|
|
|
1075
1189
|
/**
|
|
@@ -1166,11 +1280,11 @@ export class Session {
|
|
|
1166
1280
|
private executionMode(call: ToolCall): "parallel" | "exclusive" {
|
|
1167
1281
|
if (call.name === "branch" && typeof call.input === "object" && call.input !== null &&
|
|
1168
1282
|
(call.input as Record<string, unknown>)["writes"] !== true) return "parallel";
|
|
1169
|
-
return toolDefinition(call.name)?.execution ?? "exclusive";
|
|
1283
|
+
return toolDefinition(call.name, this.config.tools)?.execution ?? "exclusive";
|
|
1170
1284
|
}
|
|
1171
1285
|
|
|
1172
1286
|
private async *prepareConcurrentCall(call: ToolCall, branchIndex: number, signal?: AbortSignal): AsyncGenerator<LoopEvent, () => Promise<DeferredToolResult>> {
|
|
1173
|
-
const ctx = { ...this.config.tools, ...(signal ? { signal } : {}) };
|
|
1287
|
+
const ctx = { ...this.config.tools, callId: call.call_id, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"), imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
|
|
1174
1288
|
if (call.name !== "branch") {
|
|
1175
1289
|
const prepared = await prepareTool(ctx, call.name, call.input);
|
|
1176
1290
|
return typeof prepared === "function" ? prepared : async () => prepared;
|
|
@@ -1544,11 +1658,11 @@ export class Session {
|
|
|
1544
1658
|
|
|
1545
1659
|
const points: RestorePoint[] = [];
|
|
1546
1660
|
for (const n of all) {
|
|
1547
|
-
let open: { prompt: string; changed: number; last: string } | null = null;
|
|
1661
|
+
let open: { prompt: string; changed: number; last: string; userId: string } | null = null;
|
|
1548
1662
|
const close = () => {
|
|
1549
1663
|
if (open) {
|
|
1550
1664
|
points.push({
|
|
1551
|
-
node: n.node, atEvent: open.last, prompt: open.prompt,
|
|
1665
|
+
node: n.node, atEvent: open.last, prompt: open.prompt, userId: open.userId,
|
|
1552
1666
|
changed: open.changed, onPath: onPath.has(open.last),
|
|
1553
1667
|
});
|
|
1554
1668
|
}
|
|
@@ -1564,7 +1678,7 @@ export class Session {
|
|
|
1564
1678
|
}
|
|
1565
1679
|
if (e.t === "user") {
|
|
1566
1680
|
close();
|
|
1567
|
-
open = { prompt: e.text, changed: 0, last: e.id };
|
|
1681
|
+
open = { prompt: e.text, changed: 0, last: e.id, userId: e.id };
|
|
1568
1682
|
continue;
|
|
1569
1683
|
}
|
|
1570
1684
|
if (!open) continue;
|
|
@@ -1637,6 +1751,7 @@ export class Session {
|
|
|
1637
1751
|
// what every mode needs -- to apply, or to say what it is NOT applying.
|
|
1638
1752
|
const plan = await planRestoreToNode(dir, target, undefined, root, this.ignore);
|
|
1639
1753
|
if (!plan.ok) return { ok: false, error: plan.error };
|
|
1754
|
+
const historical = await readCursorAt(dir, point.node, point.atEvent);
|
|
1640
1755
|
// Preserve idle edits before moving away from them. An unobserved file is
|
|
1641
1756
|
// not disposable just because the target predates it (D8/appendix H).
|
|
1642
1757
|
for await (const event of this.snapshotTurn([], false, "idle")) {
|
|
@@ -1723,9 +1838,18 @@ export class Session {
|
|
|
1723
1838
|
for await (const result of this.snapshotTurn([], true)) {
|
|
1724
1839
|
if (result.kind === "snapshot_failed") throw new Error(result.reason);
|
|
1725
1840
|
}
|
|
1841
|
+
const incompatible = historical === null || !sameModel(historical, {
|
|
1842
|
+
model: this.provider.config.spec.id, provider: this.provider.config.spec.provider,
|
|
1843
|
+
});
|
|
1844
|
+
if (incompatible) {
|
|
1845
|
+
// Persist the guard before opening the new model context, including on crash recovery.
|
|
1846
|
+
const current = await readCursor(dir);
|
|
1847
|
+
if (current) await writeCursor(dir, { ...current, node, next_node: this.state.nextNode, needs_model_checkpoint: true });
|
|
1848
|
+
await this.performCut("model_checkpoint", this.provider.config.spec.id);
|
|
1849
|
+
}
|
|
1726
1850
|
await this.saveCursor();
|
|
1727
1851
|
|
|
1728
|
-
return { ok: true, node, ...moved };
|
|
1852
|
+
return { ok: true, node: this.state.node, ...moved };
|
|
1729
1853
|
}
|
|
1730
1854
|
|
|
1731
1855
|
/**
|
|
@@ -1932,7 +2056,8 @@ export class Session {
|
|
|
1932
2056
|
* would make a model switch wait for minNodeWorkTokens, and the new model
|
|
1933
2057
|
* would see the old thinking blocks in the meantime.
|
|
1934
2058
|
*/
|
|
1935
|
-
private async performCut(reason: CutReason, nextModel = this.provider.config.spec.id, retainedFrom?: string
|
|
2059
|
+
private async performCut(reason: CutReason, nextModel = this.provider.config.spec.id, retainedFrom?: string,
|
|
2060
|
+
nextProvider = this.provider.config.spec.provider, nextThinking = this.provider.config.effort, nextContextRef?: string): Promise<void> {
|
|
1936
2061
|
const dir = this.config.logDir;
|
|
1937
2062
|
const node = this.state.node;
|
|
1938
2063
|
|
|
@@ -1951,6 +2076,9 @@ export class Session {
|
|
|
1951
2076
|
reason,
|
|
1952
2077
|
next_node: this.state.nextNode,
|
|
1953
2078
|
next_model: nextModel,
|
|
2079
|
+
next_provider: nextProvider,
|
|
2080
|
+
next_thinking: nextThinking,
|
|
2081
|
+
...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
|
|
1954
2082
|
handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
1955
2083
|
...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
|
|
1956
2084
|
});
|
|
@@ -1978,6 +2106,8 @@ export class Session {
|
|
|
1978
2106
|
// its derived state does. Relying on the default here would silently make
|
|
1979
2107
|
// every boundary a fork and replay the whole history.
|
|
1980
2108
|
await initNode(dir, this.state.node, self, this.clock.now(), "handoff");
|
|
1981
|
-
|
|
2109
|
+
// The durable successor already belongs to the target provider, even if
|
|
2110
|
+
// the process exits before switchModel installs its in-memory adapter.
|
|
2111
|
+
await this.saveCursor(nextModel, nextProvider, nextThinking, nextContextRef);
|
|
1982
2112
|
}
|
|
1983
2113
|
}
|