@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/apps/ui/app.ts
CHANGED
|
@@ -37,6 +37,8 @@ import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, resumeCommand, sessionExitStatus } from
|
|
|
37
37
|
import { lastAssistantText } from "../../core/export.ts";
|
|
38
38
|
import type { PermissionRequest } from "../../core/permissions.ts";
|
|
39
39
|
import type { ApprovalChoice } from "../../runtime/tools.ts";
|
|
40
|
+
import { formatExternalEffects, type ExternalEffect } from "../../core/external-effects.ts";
|
|
41
|
+
import { hostExternalOptions } from "../../runtime/turn-effects.ts";
|
|
40
42
|
import { attachImageBytes, attachImageFile } from "../../runtime/images.ts";
|
|
41
43
|
import { readClipboard, writeClipboard, type ClipboardContent } from "../../runtime/clipboard.ts";
|
|
42
44
|
import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../../runtime/rich-text.ts";
|
|
@@ -49,12 +51,14 @@ import { updateNotice } from "../../core/update-check.ts";
|
|
|
49
51
|
import { assertNever } from "../../core/exhaustive.ts";
|
|
50
52
|
import { isUpgrade } from "../../core/version.ts";
|
|
51
53
|
import type { Effort } from "../../runtime/model.ts";
|
|
52
|
-
import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
|
|
54
|
+
import { readNode, readCursor, setSessionLabel, forkedAt } from "../../store/log.ts";
|
|
53
55
|
import { readNodeHistory } from "../../store/restore.ts";
|
|
56
|
+
import { branchPanelLines, type BranchRow, type BranchRowState } from "../../tui/ui/branch-panel.ts";
|
|
57
|
+
import type { ExecutionOutcome as BranchOutcome } from "../../core/outcome.ts";
|
|
54
58
|
import { sessionsDir } from "../../store/sessions.ts";
|
|
55
59
|
import { readGitStatus } from "../../store/git-status.ts";
|
|
56
60
|
import type { GitStatus } from "../../core/git-status.ts";
|
|
57
|
-
import { activityStatus } from "../../core/activity-status.ts";
|
|
61
|
+
import { activityStatus, retryLine, type RetryFacts } from "../../core/activity-status.ts";
|
|
58
62
|
import { goalStatus } from "../../core/goal-view.ts";
|
|
59
63
|
import { generationSpeed } from "../../runtime/usage.ts";
|
|
60
64
|
import { executionOutcome, type ExecutionOutcome } from "../../core/outcome.ts";
|
|
@@ -125,6 +129,21 @@ function endpointHost(baseUrl: string | undefined): string | null {
|
|
|
125
129
|
try { return baseUrl ? new URL(baseUrl).host : null; } catch { return null; }
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
/** Parent commits that change which branches exist or how they ended. */
|
|
133
|
+
const BRANCH_LIFECYCLE = new Set(["spawn", "joined", "adopt", "discard"]);
|
|
134
|
+
/** A joined branch's row: the outcome it joined with; absence stays unknown, never success. */
|
|
135
|
+
function rowState(outcome: BranchOutcome | undefined): BranchRowState {
|
|
136
|
+
if (!outcome) return "unknown";
|
|
137
|
+
switch (outcome.status) {
|
|
138
|
+
case "completed": return "done";
|
|
139
|
+
case "cancelled": return "cancelled";
|
|
140
|
+
case "stopped": return "stopped";
|
|
141
|
+
case "failed": return "failed";
|
|
142
|
+
case "unknown": return "unknown";
|
|
143
|
+
default: return assertNever(outcome.status, "branch row outcome");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
128
147
|
export class InteractiveApp implements Ui {
|
|
129
148
|
readonly app: App;
|
|
130
149
|
readonly screen: Screen;
|
|
@@ -149,13 +168,22 @@ export class InteractiveApp implements Ui {
|
|
|
149
168
|
private readonly work = new Set<Promise<void>>();
|
|
150
169
|
private sessionTransition = false;
|
|
151
170
|
private transcriptItems: TranscriptItem[] = [];
|
|
171
|
+
/** Unresolved branches as rows under the box (feat-branch-panel); resolving one (D16) removes its row. */
|
|
172
|
+
private branchRows: BranchRow[] = [];
|
|
173
|
+
/** The focused branch row, or undefined while the prompt has focus. */
|
|
174
|
+
private branchFocus: number | undefined;
|
|
175
|
+
/** Rows cleared with x: this view only, the log keeps the branch. */
|
|
176
|
+
private readonly clearedBranches = new Set<number>();
|
|
177
|
+
/** The branch the transcript shows instead of the main session, and its own items. */
|
|
178
|
+
private viewing: { node: number; purpose: string } | undefined;
|
|
179
|
+
private branchItems: TranscriptItem[] = [];
|
|
152
180
|
private ancestors: LogEvent[] = [];
|
|
153
181
|
private historyDir = "";
|
|
154
182
|
private historyNode = -1;
|
|
155
183
|
private historyLength = -1;
|
|
156
184
|
private notices: Notice[] = [];
|
|
157
185
|
private noticeSerial = 0;
|
|
158
|
-
private live: { thinking: string; text: string; tools: Map<string, { name: string; arguments: string }>; output: string; bash: string | null; retryUntil: number;
|
|
186
|
+
private live: { thinking: string; text: string; tools: Map<string, { name: string; arguments: string }>; output: string; bash: string | null; retryUntil: number; retry: RetryFacts | null } = emptyLive();
|
|
159
187
|
private retryTimer: ReturnType<typeof setInterval> | undefined;
|
|
160
188
|
private bashDecoder = new TextDecoder();
|
|
161
189
|
private branch: string | null;
|
|
@@ -194,6 +222,8 @@ export class InteractiveApp implements Ui {
|
|
|
194
222
|
private files: ReturnType<typeof fileChanges> = [];
|
|
195
223
|
private prompt: string | null = null;
|
|
196
224
|
private startedAt: string | undefined;
|
|
225
|
+
/** Set for a forked session: its clock starts at the fork, not at the copied parent history (fix-fork-start-time). */
|
|
226
|
+
private forkedAt: string | null = null;
|
|
197
227
|
private budgetKey = "";
|
|
198
228
|
private budgetCache: ContextBudget | undefined;
|
|
199
229
|
/** An extension's own terminal title wins over ours; ours is written only when it changes. */
|
|
@@ -450,6 +480,64 @@ export class InteractiveApp implements Ui {
|
|
|
450
480
|
return { width: this.renderer.width, height: this.renderer.height, lines: text.split("\n") };
|
|
451
481
|
}
|
|
452
482
|
|
|
483
|
+
// ---------------------------------------------------------------- branches
|
|
484
|
+
/** Rows from the handoff's branch records (D16); running state comes from the owned handles. */
|
|
485
|
+
private async refreshBranches(): Promise<void> {
|
|
486
|
+
const session = this.app.session;
|
|
487
|
+
let statuses: Awaited<ReturnType<typeof session.branchStatuses>>;
|
|
488
|
+
try { statuses = await session.branchStatuses(); } catch { return; }
|
|
489
|
+
if (this.app.session !== session) return;
|
|
490
|
+
const running = new Set(session.runningBranches);
|
|
491
|
+
this.branchRows = statuses.filter(item => item.resolution === null && !this.clearedBranches.has(item.node))
|
|
492
|
+
// The log's join wins over a handle: a user branch's job is still settling when it records its own join.
|
|
493
|
+
.map(item => ({ node: item.node, purpose: item.purpose,
|
|
494
|
+
state: item.handoff_ref ? rowState(item.outcome) : running.has(item.node) || item.status === "running" || item.status === "ready" ? "running" : "unknown" }));
|
|
495
|
+
if (this.branchFocus !== undefined) this.branchFocus = this.branchRows.length ? Math.min(this.branchFocus, this.branchRows.length - 1) : undefined;
|
|
496
|
+
this.paintBranches();
|
|
497
|
+
}
|
|
498
|
+
private paintBranches(): void { this.screen.setBranches(branchPanelLines(this.branchRows, this.branchFocus)); }
|
|
499
|
+
/** Keys while a row is focused. Undefined hands the key on: typing never gets swallowed by the panel. */
|
|
500
|
+
private branchKey(key: KeyPress): boolean | undefined {
|
|
501
|
+
const b = this.keymap().byAction, at = this.branchFocus!, row = this.branchRows[at];
|
|
502
|
+
if (!row) { this.branchFocus = undefined; this.paintBranches(); return undefined; }
|
|
503
|
+
if (bound(b, "list.up", key)) { this.branchFocus = at > 0 ? at - 1 : undefined; this.paintBranches(); return true; }
|
|
504
|
+
if (bound(b, "list.down", key)) { this.branchFocus = Math.min(this.branchRows.length - 1, at + 1); this.paintBranches(); return true; }
|
|
505
|
+
if (bound(b, "list.confirm", key)) { this.branchFocus = undefined; this.paintBranches(); this.openBranchView(row); return true; }
|
|
506
|
+
if (bound(b, "list.cancel", key)) { this.branchFocus = undefined; this.paintBranches(); return true; }
|
|
507
|
+
if (key.chord === "x") { this.stopOrClearBranch(row); return true; }
|
|
508
|
+
this.branchFocus = undefined; this.paintBranches(); return undefined;
|
|
509
|
+
}
|
|
510
|
+
private stopOrClearBranch(row: BranchRow): void {
|
|
511
|
+
const session = this.app.session;
|
|
512
|
+
if (row.state !== "running") { this.clearedBranches.add(row.node); this.track(() => this.refreshBranches()); return; }
|
|
513
|
+
if (session.userBranches.includes(row.node)) { session.cancelUserBranch(row.node); return; }
|
|
514
|
+
// A model's background branch: the same coordinator the /branches command uses, which needs an idle parent.
|
|
515
|
+
if (this.inflight) { this.toast("the turn owns this branch; esc interrupts it", "warning"); return; }
|
|
516
|
+
this.track(async () => { await session.resolveBranch(row.node, "cancel"); await this.refreshBranches(); });
|
|
517
|
+
}
|
|
518
|
+
private openBranchView(row: BranchRow): void {
|
|
519
|
+
this.viewing = { node: row.node, purpose: row.purpose }; this.branchItems = [];
|
|
520
|
+
this.screen.setViewing(`branch ${row.node} · ${row.purpose}`);
|
|
521
|
+
this.screen.messages.scrollToBottom();
|
|
522
|
+
this.track(() => this.syncBranchView());
|
|
523
|
+
}
|
|
524
|
+
private closeBranchView(): void {
|
|
525
|
+
this.viewing = undefined; this.branchItems = [];
|
|
526
|
+
this.screen.setViewing(undefined);
|
|
527
|
+
this.screen.messages.scrollToBottom();
|
|
528
|
+
this.paint();
|
|
529
|
+
}
|
|
530
|
+
/** The branch node's own events (basis none: no parent replay), projected like the main transcript. */
|
|
531
|
+
private async syncBranchView(): Promise<void> {
|
|
532
|
+
const viewing = this.viewing;
|
|
533
|
+
if (!viewing) return;
|
|
534
|
+
const history = await readNodeHistory(this.app.logDir, viewing.node, undefined, "context");
|
|
535
|
+
if (this.viewing !== viewing) return;
|
|
536
|
+
if (!history.ok) { this.notice(`Cannot load branch ${viewing.node}: ${history.error}`, "error"); return; }
|
|
537
|
+
this.branchItems = projectTranscript(history.nodes.flatMap(part => part.events), new Set(this.app.extensions?.entryRenderers.keys()));
|
|
538
|
+
this.paint();
|
|
539
|
+
}
|
|
540
|
+
|
|
453
541
|
// --------------------------------------------------------------- transcript
|
|
454
542
|
async syncTranscript(): Promise<void> {
|
|
455
543
|
this.bindControl();
|
|
@@ -464,6 +552,7 @@ export class InteractiveApp implements Ui {
|
|
|
464
552
|
this.clearStream();
|
|
465
553
|
void this.refreshGit();
|
|
466
554
|
this.sessionName = (await readCursor(dir))?.name ?? this.sessionName;
|
|
555
|
+
this.forkedAt = await forkedAt(dir).catch(() => null);
|
|
467
556
|
this.thinkingExpanded = this.app.ui?.settings.hideThinkingBlock === false;
|
|
468
557
|
void this.refreshFileIndex();
|
|
469
558
|
}
|
|
@@ -481,7 +570,7 @@ export class InteractiveApp implements Ui {
|
|
|
481
570
|
this.historyLength = physical.events.length;
|
|
482
571
|
this.files = fileChanges(this.transcriptItems);
|
|
483
572
|
this.prompt = firstPrompt(this.transcriptItems);
|
|
484
|
-
this.startedAt = events.find(event => typeof event.at === "string")?.at ?? physical.header?.created_at;
|
|
573
|
+
this.startedAt = this.forkedAt ?? events.find(event => typeof event.at === "string")?.at ?? physical.header?.created_at;
|
|
485
574
|
this.requestDerived();
|
|
486
575
|
}
|
|
487
576
|
this.paint();
|
|
@@ -498,7 +587,7 @@ export class InteractiveApp implements Ui {
|
|
|
498
587
|
if (progress.kind === "retry") {
|
|
499
588
|
this.stopRetry();
|
|
500
589
|
this.live.retryUntil = Date.now() + progress.delayMs;
|
|
501
|
-
this.live.
|
|
590
|
+
this.live.retry = { attempt: progress.attempt, seconds: 0, ...(progress.max === undefined ? {} : { max: progress.max }), ...(progress.reason === undefined ? {} : { reason: progress.reason }) };
|
|
502
591
|
this.retryTimer = setInterval(() => this.paint(), 200);
|
|
503
592
|
} else {
|
|
504
593
|
this.stopRetry();
|
|
@@ -538,10 +627,12 @@ export class InteractiveApp implements Ui {
|
|
|
538
627
|
// A changed thinking label changes every thinking view; start the caches over.
|
|
539
628
|
if (this.extensions.hiddenThinkingLabel !== this.cacheLabel) { this.cacheLabel = this.extensions.hiddenThinkingLabel; this.clearViewCache(); }
|
|
540
629
|
const width = Math.max(20, this.screen.width - 8);
|
|
541
|
-
|
|
542
|
-
|
|
630
|
+
// A shown branch is its own log: the main session's notices and live stream do not belong in it.
|
|
631
|
+
const viewing = this.viewing !== undefined;
|
|
632
|
+
const items = projectItems(viewing ? this.branchItems : this.transcriptItems, {
|
|
633
|
+
notices: viewing ? [] : this.notices,
|
|
543
634
|
style: () => app.ui?.style,
|
|
544
|
-
...(live ? { live } : {}),
|
|
635
|
+
...(live && !viewing ? { live } : {}),
|
|
545
636
|
cache: this.viewCache,
|
|
546
637
|
customTool: item => this.extensions.toolRenderer(item.name, item.input, item.result, item.callId),
|
|
547
638
|
customMessage: item => this.extensions.messageRenderer(item.customType, item.text, item.data, item.entry),
|
|
@@ -561,9 +652,9 @@ export class InteractiveApp implements Ui {
|
|
|
561
652
|
}
|
|
562
653
|
return text === item.text ? item : { ...item, text };
|
|
563
654
|
});
|
|
564
|
-
const home = !this.transcriptItems.some(item => item.kind === "user" || item.kind === "assistant" || item.kind === "tool" || item.kind === "shell" || item.kind === "custom") && !this.inflight;
|
|
655
|
+
const home = !viewing && !this.transcriptItems.some(item => item.kind === "user" || item.kind === "assistant" || item.kind === "tool" || item.kind === "shell" || item.kind === "custom") && !this.inflight;
|
|
565
656
|
const clock = this.startedAt ? sessionClock(this.startedAt, new Date()) : undefined;
|
|
566
|
-
const view = projectWithStart(items, clock);
|
|
657
|
+
const view = projectWithStart(items, clock, this.forkedAt !== null);
|
|
567
658
|
this.screen.setGreeting(home ? {
|
|
568
659
|
version: XENO_VERSION, model: app.credential.ok ? spec.name : null, thinking: app.providerConfig.effort,
|
|
569
660
|
workspace: displayPath(app.workspace, homedir()), branch: this.branch,
|
|
@@ -594,7 +685,7 @@ export class InteractiveApp implements Ui {
|
|
|
594
685
|
...(pending ? { tool: pending.name } : this.executionKind === "bash" ? { tool: "bash" } : {}),
|
|
595
686
|
...(this.lastOutcome ? { outcome: this.lastOutcome } : this.controlSnapshot?.goal?.state.execution ? { outcome: this.controlSnapshot.goal.state.execution } : {}),
|
|
596
687
|
...(this.controlSnapshot?.goal ? { goal: this.controlSnapshot.goal.state.status } : {}),
|
|
597
|
-
...(this.live.retryUntil ? { retry: {
|
|
688
|
+
...(this.live.retryUntil && this.live.retry ? { retry: { ...this.live.retry, seconds: (this.live.retryUntil - Date.now()) / 1000 } } : {}) });
|
|
598
689
|
return {
|
|
599
690
|
version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
|
|
600
691
|
workspaceShort: shortPath(displayPath(app.workspace, homedir())), branch: this.branch,
|
|
@@ -681,7 +772,7 @@ export class InteractiveApp implements Ui {
|
|
|
681
772
|
const l = this.live;
|
|
682
773
|
if (!l.thinking && !l.text && !l.tools.size && !l.output && l.bash === null && !l.retryUntil) return undefined;
|
|
683
774
|
return { thinking: l.thinking, text: l.text, tools: [...l.tools].map(([id, tool]) => ({ id, ...tool })), output: l.output, bash: l.bash,
|
|
684
|
-
...(l.retryUntil ? { retry:
|
|
775
|
+
...(l.retryUntil && l.retry ? { retry: retryLine({ ...l.retry, seconds: (l.retryUntil - Date.now()) / 1000 }) } : {}) };
|
|
685
776
|
}
|
|
686
777
|
|
|
687
778
|
/** The context budget changes only with the log or the model; it is not recounted on every paint. */
|
|
@@ -767,6 +858,7 @@ export class InteractiveApp implements Ui {
|
|
|
767
858
|
if (bound(b, "app.suspend", key)) { this.suspend(); return true; }
|
|
768
859
|
if (this.screen.dialogs.open) return this.screen.dialogs.key(key);
|
|
769
860
|
if (this.screen.handleControlKey(key)) return true;
|
|
861
|
+
if (this.branchFocus !== undefined) { const handled = this.branchKey(key); if (handled !== undefined) return handled; }
|
|
770
862
|
const composer = this.screen.composer;
|
|
771
863
|
if (composer.completing) {
|
|
772
864
|
if (key.chord === "up") { composer.moveCompletion(-1); return true; }
|
|
@@ -794,6 +886,7 @@ export class InteractiveApp implements Ui {
|
|
|
794
886
|
else this.toast("press ctrl+c again to quit");
|
|
795
887
|
this.lastInterrupt = Date.now(); this.paint(); return true;
|
|
796
888
|
}
|
|
889
|
+
if (bound(b, "app.interrupt", key) && this.viewing) { this.closeBranchView(); return true; }
|
|
797
890
|
if (bound(b, "app.interrupt", key)) {
|
|
798
891
|
if (this.inflight) this.interrupt();
|
|
799
892
|
else if (!this.screen.messages.atBottom) this.screen.messages.scrollToBottom();
|
|
@@ -826,7 +919,12 @@ export class InteractiveApp implements Ui {
|
|
|
826
919
|
if (bound(b, "editor.newLine", key)) { composer.insert("\n"); return true; }
|
|
827
920
|
if (bound(b, "editor.submit", key)) { const text = composer.text; this.track(() => this.submit(text, "steering")); return true; }
|
|
828
921
|
if (bound(b, "editor.historyPrevious", key)) return composer.historyStep(-1);
|
|
829
|
-
if (bound(b, "editor.historyNext", key))
|
|
922
|
+
if (bound(b, "editor.historyNext", key)) {
|
|
923
|
+
if (composer.historyStep(1)) return true;
|
|
924
|
+
// Nothing newer to recall: Down continues into the branch rows under the box (Claude Code's footer).
|
|
925
|
+
if (composer.onLastRow && composer.historyPosition === undefined && this.branchRows.length) { this.branchFocus = 0; this.paintBranches(); return true; }
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
830
928
|
for (const [chord, shortcut] of this.app.extensions?.shortcuts ?? []) if (chord === key.chord) {
|
|
831
929
|
this.runExtensionAction(() => shortcut.handler(this.hooks.extensionContext(this.app, this))); return true;
|
|
832
930
|
}
|
|
@@ -1139,7 +1237,13 @@ export class InteractiveApp implements Ui {
|
|
|
1139
1237
|
const cell = resolution && this.renderer.terminalWidth > 0 && this.renderer.terminalHeight > 0
|
|
1140
1238
|
? { width: resolution.width / this.renderer.terminalWidth, height: resolution.height / this.renderer.terminalHeight } : DEFAULT_CELL;
|
|
1141
1239
|
return { mode: show ? "picture" : "chip", protocol: effective, cell, widthCells: terminal?.imageWidthCells ?? 60,
|
|
1142
|
-
...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha) };
|
|
1240
|
+
...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha), open: sha => this.openImage(sha) };
|
|
1241
|
+
}
|
|
1242
|
+
/** A click on a picture (feat-tui-image-full-size): the system viewer shows what the thumbnail cannot. */
|
|
1243
|
+
private openImage(sha256: string): void {
|
|
1244
|
+
void this.hooks.openImage(this.app, sha256)
|
|
1245
|
+
.then(reason => { if (reason) this.notice(reason, "warning"); })
|
|
1246
|
+
.catch(error => this.notice(describeError(error), "warning"));
|
|
1143
1247
|
}
|
|
1144
1248
|
private applyPictures(): void {
|
|
1145
1249
|
if (this.disposed) return;
|
|
@@ -1211,6 +1315,9 @@ export class InteractiveApp implements Ui {
|
|
|
1211
1315
|
if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
|
|
1212
1316
|
if (notice.method === "turn/completed") {
|
|
1213
1317
|
this.inflight = false; this.executionKind = "idle"; this.clearStream();
|
|
1318
|
+
// feat-external-effects-summary: a remote-driven turn shows the same line a local one does.
|
|
1319
|
+
const outside = formatExternalEffects((notice.params["externalEffects"] ?? []) as ExternalEffect[], hostExternalOptions());
|
|
1320
|
+
if (outside) this.notice(outside, "warning");
|
|
1214
1321
|
const outcome = notice.params["outcome"] as import("../../core/outcome.ts").ExecutionOutcome | undefined;
|
|
1215
1322
|
this.turn = this.continueRemoteTurn(control, outcome).catch(error => this.notice(describeError(error), "error"));
|
|
1216
1323
|
}
|
|
@@ -1223,9 +1330,21 @@ export class InteractiveApp implements Ui {
|
|
|
1223
1330
|
else this.track(() => this.syncTranscript());
|
|
1224
1331
|
}
|
|
1225
1332
|
}
|
|
1333
|
+
// A background branch (/review-code) joins while no turn runs; nothing else would redraw the transcript.
|
|
1334
|
+
if (notice.method === "item/completed" && !this.inflight && !this.abort) this.track(() => this.syncTranscript());
|
|
1335
|
+
if (notice.method === "item/completed" && BRANCH_LIFECYCLE.has((notice.params["item"] as { type?: string } | undefined)?.type ?? "")) this.track(() => this.refreshBranches());
|
|
1226
1336
|
if (notice.method !== "turn/event") { this.derivedKey = ""; this.requestDerived(); this.paint(); }
|
|
1227
1337
|
});
|
|
1228
|
-
|
|
1338
|
+
// A shown branch follows its own commits; an ended one changes its row.
|
|
1339
|
+
const unsubscribeBranches = this.app.session.subscribeBranchRecords(item => {
|
|
1340
|
+
if (this.disposed) return;
|
|
1341
|
+
if (this.viewing?.node === item.node) this.track(() => this.syncBranchView());
|
|
1342
|
+
// Its first prompt means the child is live (the spawn notice can arrive before its handle exists).
|
|
1343
|
+
if (item.type === "outcome" || item.type === "user") this.track(() => this.refreshBranches());
|
|
1344
|
+
});
|
|
1345
|
+
this.viewing = undefined; this.branchItems = []; this.branchFocus = undefined; this.clearedBranches.clear(); this.screen.setViewing(undefined);
|
|
1346
|
+
this.track(() => this.refreshBranches());
|
|
1347
|
+
this.stopControl = () => { detachInput(); unsubscribe(); unsubscribeBranches(); };
|
|
1229
1348
|
}
|
|
1230
1349
|
|
|
1231
1350
|
private async continueRemoteTurn(control: SessionControl, outcome?: import("../../core/outcome.ts").ExecutionOutcome): Promise<void> {
|
|
@@ -1482,14 +1601,16 @@ export class InteractiveApp implements Ui {
|
|
|
1482
1601
|
const scan = new AbortController();
|
|
1483
1602
|
const items = (query = ""): ListItem[] => selectSessions(rows, query, sort, false, sessionFuzzy).map(row => ({
|
|
1484
1603
|
value: row.path, label: " ".repeat(Math.min(8, row.depth)) + (row.depth ? "↳ " : "") + (row.name?.trim() || row.text?.split("\n").find(Boolean)?.slice(0, 90) || row.id),
|
|
1485
|
-
description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.
|
|
1604
|
+
description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.forked_at ? `forked ${row.forked_at.slice(0, 16).replace("T", " ")}` : "",
|
|
1605
|
+
row.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · "),
|
|
1486
1606
|
...(row.path === this.app.logDir ? { hint: "● current" } : {}),
|
|
1487
1607
|
}));
|
|
1488
1608
|
const load = async () => {
|
|
1489
1609
|
rows = [];
|
|
1490
1610
|
const roots = scope === "current" ? [root] : await sessionRoots(root);
|
|
1491
1611
|
for await (const batch of scanSessionRoots(roots, scan.signal)) {
|
|
1492
|
-
|
|
1612
|
+
// Empty sessions are hidden, except the one in use (fix-skip-empty-sessions).
|
|
1613
|
+
rows.push(...batch.sessions.filter((row): row is Row => row.ok && (row.conversation !== false || row.path === this.app.logDir)));
|
|
1493
1614
|
if (this.screen.dialogs.top === spec) this.screen.dialogs.list?.setItems(items(this.screen.dialogs.list.query));
|
|
1494
1615
|
}
|
|
1495
1616
|
};
|
|
@@ -1600,7 +1721,7 @@ export function trustItems(workspace: string): ListItem[] {
|
|
|
1600
1721
|
];
|
|
1601
1722
|
}
|
|
1602
1723
|
|
|
1603
|
-
function emptyLive() { return { thinking: "", text: "", tools: new Map<string, { name: string; arguments: string }>(), output: "", bash: null as string | null, retryUntil: 0,
|
|
1724
|
+
function emptyLive() { return { thinking: "", text: "", tools: new Map<string, { name: string; arguments: string }>(), output: "", bash: null as string | null, retryUntil: 0, retry: null as RetryFacts | null }; }
|
|
1604
1725
|
|
|
1605
1726
|
function noticeOf(id: string, after: number, lines: string[], tone: Tone): Notice & { lines: string[]; tone: Tone } {
|
|
1606
1727
|
const prefix = tone === "error" ? "\x1b[31m" : tone === "warning" ? "\x1b[33m" : tone === "success" ? "\x1b[32m" : "";
|
|
@@ -1672,8 +1793,10 @@ async function printExit(app: App): Promise<void> {
|
|
|
1672
1793
|
}
|
|
1673
1794
|
|
|
1674
1795
|
/** The session-start rule first, then the projection (refactor-tui-dock-layout DoD 7). */
|
|
1675
|
-
function projectWithStart(items: ReturnType<typeof projectItems>, clock: string | undefined): ReturnType<typeof projectItems> {
|
|
1676
|
-
|
|
1796
|
+
function projectWithStart(items: ReturnType<typeof projectItems>, clock: string | undefined, forked = false): ReturnType<typeof projectItems> {
|
|
1797
|
+
// The label is part of rev: a view keyed on rev alone would keep "session" after switching into a fork.
|
|
1798
|
+
const label = `${forked ? "forked" : "session"} · ${clock}`;
|
|
1799
|
+
return clock ? [{ kind: "segment", id: "session-start", rev: label, label }, ...items] : items;
|
|
1677
1800
|
}
|
|
1678
1801
|
|
|
1679
1802
|
/** "~/Projects/workspace/xeno-tui" → "~/P/w/xeno-tui": every directory but the last cut to its first letter. */
|
package/src/apps/ui/project.ts
CHANGED
|
@@ -123,5 +123,5 @@ function shortRef(ref: string): string { return ref.split("/").pop() ?? ref; }
|
|
|
123
123
|
|
|
124
124
|
/** What the view needs of a logged image: its address, name and pixel size. Never the bytes (D8). */
|
|
125
125
|
export function imageView(image: ImageRef): ImageView {
|
|
126
|
-
return { sha256: image.sha256, name: image.name, width: image.width, height: image.height };
|
|
126
|
+
return { sha256: image.sha256, name: image.name, width: image.width, height: image.height, ...(image.original ? { full: image.original.sha256 } : {}) };
|
|
127
127
|
}
|
|
@@ -102,13 +102,15 @@ export async function pickStartupSession(opts: { workspace: string; sessionDir?:
|
|
|
102
102
|
let scope: "current" | "all" = "current";
|
|
103
103
|
const items = (query = ""): ListItem[] => selectSessions(rows, query, "threaded", false, (q, text) => { const score = fuzzyMatch(text, q); return { matches: score !== null, score: score ?? Infinity }; })
|
|
104
104
|
.map(row => ({ value: row.path, label: " ".repeat(Math.min(8, row.depth)) + (row.depth ? "↳ " : "") + (row.name?.trim() || row.text?.split("\n").find(Boolean)?.slice(0, 90) || row.id),
|
|
105
|
-
description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.
|
|
105
|
+
description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.forked_at ? `forked ${row.forked_at.slice(0, 16).replace("T", " ")}` : "",
|
|
106
|
+
row.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · ") }));
|
|
106
107
|
try {
|
|
107
108
|
let spec: DialogSpec & { kind: "list" } | undefined;
|
|
108
109
|
const load = async () => {
|
|
109
110
|
rows = [];
|
|
110
111
|
for await (const batch of scanSessionRoots(scope === "current" ? [root] : await sessionRoots(root), scan.signal)) {
|
|
111
|
-
|
|
112
|
+
// A session left without a word is not something to resume (fix-skip-empty-sessions).
|
|
113
|
+
rows.push(...batch.sessions.filter((row): row is Session => row.ok && row.conversation !== false));
|
|
112
114
|
if (spec && host.dialogs.top === spec) host.dialogs.list?.setItems(items(host.dialogs.list.query));
|
|
113
115
|
}
|
|
114
116
|
};
|
package/src/apps/ui/surface.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface TurnSurface {
|
|
|
20
20
|
pushProgress?(progress: NonNullable<LoopEvent["progress"]>): void;
|
|
21
21
|
pullQueued?(phase: Phase): Promise<UserInput | null>;
|
|
22
22
|
observeEvent?(event: LoopEvent): void;
|
|
23
|
+
/** A plain-text notice in the message stream (the interactive app); line mode prints instead. */
|
|
24
|
+
notice?(text: string, tone?: "info" | "success" | "warning" | "error"): void;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export interface Ui extends TurnSurface {
|
|
@@ -9,9 +9,24 @@ export interface ActivityFacts {
|
|
|
9
9
|
readonly goal?: string;
|
|
10
10
|
readonly phase?: "connecting" | "thinking" | "streaming" | "tool";
|
|
11
11
|
readonly tool?: string;
|
|
12
|
-
readonly retry?:
|
|
12
|
+
readonly retry?: RetryFacts;
|
|
13
13
|
readonly queued: number;
|
|
14
14
|
}
|
|
15
|
+
/** One pending retry: which attempt, out of how many, how long until it, and why. */
|
|
16
|
+
export interface RetryFacts { readonly attempt: number; readonly max?: number; readonly seconds: number; readonly reason?: string }
|
|
17
|
+
|
|
18
|
+
const attemptText = (retry: RetryFacts): string => retry.max === undefined ? `${retry.attempt}` : `${retry.attempt}/${retry.max}`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The stream-area retry line. The cause appears from the third attempt (or
|
|
22
|
+
* the last, for a smaller budget), as in Claude Code: one or two retries are
|
|
23
|
+
* noise, and naming them would send the user to check a network that is fine.
|
|
24
|
+
*/
|
|
25
|
+
export function retryLine(retry: RetryFacts): string {
|
|
26
|
+
const named = retry.reason !== undefined && retry.attempt >= Math.min(3, retry.max ?? 3);
|
|
27
|
+
return `retry ${attemptText(retry)} in ${Math.max(0, retry.seconds).toFixed(1)}s · ${named ? `${retry.reason} · ` : ""}esc to cancel`;
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
export interface ActivityStatus { readonly state: ProgramStatus["state"]; readonly label: string; readonly detail: string; readonly queued: number }
|
|
16
31
|
|
|
17
32
|
const STOP_CAUSE: Partial<Record<ExecutionOutcome["reason"], string>> = {
|
|
@@ -29,7 +44,7 @@ export function activityStatus(facts: ActivityFacts): ActivityStatus {
|
|
|
29
44
|
: !facts.waiting && facts.goal === "awaiting_human" ? "Awaiting review" : "Awaiting input");
|
|
30
45
|
if (status.state === "error") return view("Failed");
|
|
31
46
|
if (!facts.running) return view(status.state === "done" ? "Turn finished" : facts.outcome?.status === "cancelled" ? "Cancelled" : "Ready");
|
|
32
|
-
if (facts.retry) return view("Retrying", `attempt ${facts.retry
|
|
47
|
+
if (facts.retry) return view("Retrying", `attempt ${attemptText(facts.retry)} · ${Math.max(0, Math.ceil(facts.retry.seconds))}s`);
|
|
33
48
|
if (facts.phase === "tool") return view("Running tool", facts.tool ?? "Tool execution");
|
|
34
49
|
if (facts.phase === "thinking") return view("Thinking");
|
|
35
50
|
if (facts.phase === "streaming") return view("Responding");
|
|
@@ -65,18 +65,12 @@ const NEGATIVE_ANSWERS: Readonly<Record<string, string>> = {
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* not classified. Under pipefail an earlier pipeline stage can also set the
|
|
73
|
-
* status; the exit is kept in the receipt either way, and the label states
|
|
74
|
-
* the program's meaning rather than claiming success. Anything this scanner
|
|
75
|
-
* cannot read with certainty -- unbalanced quotes, subshells, compound
|
|
76
|
-
* statements, substitutions -- yields null.
|
|
68
|
+
* The final simple command of `command` and the separator before it, or
|
|
69
|
+
* null when this scanner cannot read the command with certainty --
|
|
70
|
+
* unbalanced quotes, subshells, compound statements, substitutions or a
|
|
71
|
+
* background `&`. Only top-level `;`, newline, `&&`, `||` and `|` split.
|
|
77
72
|
*/
|
|
78
|
-
|
|
79
|
-
if (exit !== 1) return null;
|
|
73
|
+
function finalStage(command: string): { separator: string; words: string[] } | null {
|
|
80
74
|
let quote: "'" | "\"" | null = null, start = 0, separator = "";
|
|
81
75
|
for (let i = 0; i < command.length; i++) {
|
|
82
76
|
const c = command[i]!;
|
|
@@ -93,16 +87,64 @@ export function negativeAnswer(command: string, exit: number): string | null {
|
|
|
93
87
|
if (c === ";" || c === "\n" || c === "|") { separator = c; start = i + 1; continue; }
|
|
94
88
|
if (c === "&" && command[i + 1] !== ">" && command[i - 1] !== ">") return null;
|
|
95
89
|
}
|
|
96
|
-
if (quote !== null
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
90
|
+
if (quote !== null) return null;
|
|
91
|
+
return { separator, words: command.slice(start).trim().split(/\s+/).filter(word => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const programName = (words: readonly string[]) => (words[0] ?? "").replace(/^.*\//, "");
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What exit 1 of the command's final simple command means, or null.
|
|
98
|
+
*
|
|
99
|
+
* Only the last segment after a top-level `;`, newline, `||` or `|` is read.
|
|
100
|
+
* After `&&` the 1 may come from the command that short-circuited, so it is
|
|
101
|
+
* not classified. Under pipefail an earlier pipeline stage can also set the
|
|
102
|
+
* status; the exit is kept in the receipt either way, and the label states
|
|
103
|
+
* the program's meaning rather than claiming success. Anything this scanner
|
|
104
|
+
* cannot read with certainty -- unbalanced quotes, subshells, compound
|
|
105
|
+
* statements, substitutions -- yields null.
|
|
106
|
+
*/
|
|
107
|
+
export function negativeAnswer(command: string, exit: number): string | null {
|
|
108
|
+
if (exit !== 1) return null;
|
|
109
|
+
const stage = finalStage(command);
|
|
110
|
+
if (stage === null || stage.separator === "&&") return null;
|
|
111
|
+
const program = programName(stage.words);
|
|
112
|
+
if (program === "git") return stage.words[1] === "grep" ? "no match" : null;
|
|
100
113
|
return Object.hasOwn(NEGATIVE_ANSWERS, program) ? NEGATIVE_ANSWERS[program]! : null;
|
|
101
114
|
}
|
|
102
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Readers that may stop reading before their input ends (`head -n`, `sed Nq`,
|
|
118
|
+
* `awk '{exit}'`, `grep -m` / `-q`), closing the pipe on the writer before it.
|
|
119
|
+
*/
|
|
120
|
+
const EARLY_READERS: ReadonlySet<string> = new Set(["head", "sed", "awk", "gawk", "mawk", "grep", "egrep", "fgrep", "rg"]);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What exit 141 means when an early reader ends the pipeline, or null.
|
|
124
|
+
*
|
|
125
|
+
* Measured (pipe-early-close card): under `bash -o pipefail`, `seq 1 1000000
|
|
126
|
+
* | head -1` exits 141 -- pipefail reports the rightmost nonzero stage, here
|
|
127
|
+
* the writer that SIGPIPE killed after head closed its input, while every
|
|
128
|
+
* stage to its right, the final reader included, exited 0. The output the
|
|
129
|
+
* command asked for is complete. Only a final stage joined by a top-level
|
|
130
|
+
* `|` is read: after `;`, `&&` or `||` the 141 is the last command's own.
|
|
131
|
+
*/
|
|
132
|
+
export function earlyCloseAnswer(command: string, exit: number): string | null {
|
|
133
|
+
if (exit !== 141) return null;
|
|
134
|
+
const stage = finalStage(command);
|
|
135
|
+
if (stage === null || stage.separator !== "|") return null;
|
|
136
|
+
const program = programName(stage.words);
|
|
137
|
+
return EARLY_READERS.has(program) ? `${program} closed the pipe early; the earlier stage's SIGPIPE is not a failure` : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The program's own meaning of a nonzero exit that is an answer, not a fault. */
|
|
141
|
+
export function commandAnswer(command: string, exit: number): string | null {
|
|
142
|
+
return negativeAnswer(command, exit) ?? earlyCloseAnswer(command, exit);
|
|
143
|
+
}
|
|
144
|
+
|
|
103
145
|
/** A trap can exit zero after cancellation: successful exit alone does not erase the stop. */
|
|
104
146
|
export function commandCompletion(exit: number, stopped?: "cancelled" | "timeout", cleanup?: ToolDiagnostic, command?: string) {
|
|
105
|
-
const answered = exit === 0 || command !== undefined &&
|
|
147
|
+
const answered = exit === 0 || command !== undefined && commandAnswer(command, exit) !== null;
|
|
106
148
|
return { ok: stopped === undefined && answered && cleanup === undefined,
|
|
107
149
|
outcome: executionOutcome(stopped ?? (answered && cleanup === undefined ? "tool_completed" : "tool_failed")),
|
|
108
150
|
...(cleanup ? { warnings: [cleanup] } : {}) };
|
|
@@ -69,7 +69,7 @@ export function requestCompletion(state: RequestState, remainingRequests: number
|
|
|
69
69
|
if (state.used_tools && text.length === 0) gaps.push("the final reply is empty");
|
|
70
70
|
// Strict verification counts only configured gates (O.11); say so, since a successful `cat` would otherwise look like a check.
|
|
71
71
|
if (freshCheck && freshCheck.status !== "pass") gaps.push(freshCheck.gate_only
|
|
72
|
-
? "files were changed but no configured verification gate (a test, typecheck, lint, build or check command) has passed since the last edit; reading or printing files does not count, and a gate counts only when run as
|
|
72
|
+
? "files were changed but no configured verification gate (a test, typecheck, lint, build or check command) has passed since the last edit; reading or printing files does not count, and a gate counts only when run as plain commands joined by && (trailing output redirects to fixed files are fine); ;, ||, pipes, $() and variables hide it"
|
|
73
73
|
: "files were changed but no command has succeeded since the last edit");
|
|
74
74
|
if (acceptance?.status === "needs_work") gaps.push(`declared acceptance criteria need evidence: ${[
|
|
75
75
|
...missingCommands.map(item => `${item.id}=${item.status}`),
|
package/src/core/derive.ts
CHANGED
|
@@ -425,7 +425,15 @@ export function deriveHandoff(input: DeriveInput): Handoff {
|
|
|
425
425
|
const processes = deriveProcesses(prev?.processes ?? [], events, input.self.node);
|
|
426
426
|
|
|
427
427
|
const hasRemaining = inFlight.length > 0 || blocked.length > 0 || openQuestions.length > 0;
|
|
428
|
-
|
|
428
|
+
// A next action is a statement about the current state of the work, so it
|
|
429
|
+
// follows the evidence rule above: only one this segment recorded or
|
|
430
|
+
// refreshed counts. A carried one stays listed but no longer suppresses the
|
|
431
|
+
// boundary and continuation reminders that ask the model to confirm it.
|
|
432
|
+
const thisSegment = new Set(events.map((e) => e.id));
|
|
433
|
+
const freshUnscoped = new Set(events.flatMap((e) => e.t === "next_action" && e.work_id === undefined ? [e.what] : []));
|
|
434
|
+
const freshWork = new Set(workItems.filter((item) => thisSegment.has(item.updated_at)).map((item) => item.id));
|
|
435
|
+
const hasNextAction = inFlight.some((i) => i.next_action.trim().length > 0 &&
|
|
436
|
+
(i.work_id === undefined ? freshUnscoped.has(i.what) : freshWork.has(i.work_id)));
|
|
429
437
|
|
|
430
438
|
return {
|
|
431
439
|
handoff_version: HANDOFF_VERSION,
|
|
@@ -25,6 +25,14 @@ export function validNodeRef(value: unknown): value is NodeRef {
|
|
|
25
25
|
(value["at_event"] === undefined || typeof value["at_event"] === "string");
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** A label is a short phrase or class name; anything longer or with control characters is not one the harness wrote. */
|
|
29
|
+
function validFailureCause(value: unknown): boolean {
|
|
30
|
+
return isRecord(value) && (value["source"] === "provider" || value["source"] === "harness") &&
|
|
31
|
+
typeof value["label"] === "string" && /^[^\u0000-\u001f\u007f]{1,64}$/.test(value["label"]) &&
|
|
32
|
+
(value["status"] === undefined || Number.isSafeInteger(value["status"])) &&
|
|
33
|
+
(value["retries"] === undefined || natural(value["retries"]));
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
export function isLogEvent(value: unknown): value is LogEvent {
|
|
29
37
|
if (!isRecord(value) || typeof value["id"] !== "string" || value["id"].length === 0 || typeof value["at"] !== "string") return false;
|
|
30
38
|
if (value["outcome"] !== undefined && !isExecutionOutcome(value["outcome"])) return false;
|
|
@@ -58,6 +66,14 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
58
66
|
value["reason"] === "disposed" && isRecord(output) && typeof output["path"] === "string" &&
|
|
59
67
|
typeof output["sha256"] === "string" && /^[a-f0-9]{64}$/.test(output["sha256"]) && natural(output["bytes"]);
|
|
60
68
|
}
|
|
69
|
+
case "approval": {
|
|
70
|
+
const user = ["once", "command", "tool", "deny"].includes(String(value["decision"]));
|
|
71
|
+
const rule = value["decision"] === "allow" || value["decision"] === "deny";
|
|
72
|
+
return has("call_id", "tool") && value["call_id"] !== "" && value["tool"] !== "" &&
|
|
73
|
+
optionalString(value["command"]) && optionalString(value["rule"]) &&
|
|
74
|
+
(value["source"] === "user" ? user : value["source"] === "no_prompt" ? value["decision"] === "deny" :
|
|
75
|
+
["session_rule", "config", "auto"].includes(String(value["source"])) && rule);
|
|
76
|
+
}
|
|
61
77
|
case "skill_loaded": return has("name", "path", "sha256", "artifact") &&
|
|
62
78
|
["sha256", "artifact"].every(key => /^[a-f0-9]{64}$/.test(String(value[key]))) &&
|
|
63
79
|
["inline", "fork", "read"].includes(String(value["mode"])) && ["user", "model"].includes(String(value["origin"]));
|
|
@@ -81,7 +97,7 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
81
97
|
}
|
|
82
98
|
case "extension": return has("customType", "content") && typeof value["display"] === "boolean" && typeof value["inContext"] === "boolean" && (value["data"] === undefined || isJsonValue(value["data"]));
|
|
83
99
|
case "user": return has("text") && (value["request"] === undefined || typeof value["request"] === "string" && value["request"] !== "");
|
|
84
|
-
case "outcome": return has("reason") && isExecutionOutcome(value["outcome"]);
|
|
100
|
+
case "outcome": return has("reason") && isExecutionOutcome(value["outcome"]) && (value["failure"] === undefined || validFailureCause(value["failure"]));
|
|
85
101
|
case "user_bash": {
|
|
86
102
|
const ref = value["content_ref"];
|
|
87
103
|
const outcome = value["outcome"];
|
|
@@ -92,7 +108,7 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
92
108
|
isRecord(ref) && typeof ref["sha256"] === "string" &&
|
|
93
109
|
typeof ref["path"] === "string" && natural(ref["bytes"]);
|
|
94
110
|
}
|
|
95
|
-
case "guidance": return has("text") && ["continuation", "repetition", "budget", "boundary", "stall", "output_recovery", "completion_review", "churn", "deadline", "runtime_acceptance", "goal"].includes(String(value["reason"])) && strings(value["sources"]);
|
|
111
|
+
case "guidance": return has("text") && ["continuation", "repetition", "budget", "boundary", "stall", "output_recovery", "completion_review", "churn", "deadline", "runtime_acceptance", "goal", "secret", "branch"].includes(String(value["reason"])) && strings(value["sources"]);
|
|
96
112
|
case "interrupt": return has("user_id");
|
|
97
113
|
case "assistant": return (value["stop_reason"] === null || optionalString(value["stop_reason"])) &&
|
|
98
114
|
(value["replay_key"] === undefined || typeof value["replay_key"] === "string" && /^[a-f0-9]{64}$/.test(value["replay_key"])) &&
|
|
@@ -156,7 +172,7 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
156
172
|
return effect["kind"] === "edit" && typeof effect["path"] === "string" && typeof effect["sha256_after"] === "string" &&
|
|
157
173
|
(effect["sha256_before"] === null || typeof effect["sha256_before"] === "string");
|
|
158
174
|
}
|
|
159
|
-
case "cut": return has("handoff_ref") && (value["working_set_version"] === undefined || value["working_set_version"] === 2) && ["trigger", "hard_limit", "model_checkpoint"].includes(String(value["reason"])) &&
|
|
175
|
+
case "cut": return has("handoff_ref") && (value["working_set_version"] === undefined || value["working_set_version"] === 2 || value["working_set_version"] === 3) && ["trigger", "hard_limit", "model_checkpoint"].includes(String(value["reason"])) &&
|
|
160
176
|
(value["guidance_tools"] === undefined || Array.isArray(value["guidance_tools"]) && value["guidance_tools"].every(item => typeof item === "string")) &&
|
|
161
177
|
optionalString(value["retained_from"]) && optionalString(value["next_context_ref"]) &&
|
|
162
178
|
(value["next_node"] === undefined || natural(value["next_node"])) && optionalString(value["next_model"]) &&
|