@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/package.json +1 -1
  3. package/src/apps/review.ts +59 -59
  4. package/src/apps/shell.ts +20 -5
  5. package/src/apps/startup.ts +4 -4
  6. package/src/apps/ui/app.ts +143 -20
  7. package/src/apps/ui/project.ts +1 -1
  8. package/src/apps/ui/standalone.ts +4 -2
  9. package/src/apps/ui/surface.ts +2 -0
  10. package/src/core/activity-status.ts +17 -2
  11. package/src/core/command-contract.ts +58 -16
  12. package/src/core/completion-review.ts +1 -1
  13. package/src/core/derive.ts +9 -1
  14. package/src/core/event-validation.ts +19 -3
  15. package/src/core/events.ts +38 -2
  16. package/src/core/evidence.ts +149 -7
  17. package/src/core/export.ts +2 -0
  18. package/src/core/external-effects.ts +488 -0
  19. package/src/core/handoff.ts +24 -3
  20. package/src/core/history-query.ts +1 -1
  21. package/src/core/history.ts +124 -20
  22. package/src/core/observation-view.ts +24 -0
  23. package/src/core/path-suggestions.ts +59 -0
  24. package/src/core/permissions.ts +60 -0
  25. package/src/core/prompt-policy.ts +4 -2
  26. package/src/core/secrets.ts +188 -0
  27. package/src/core/stop-report.ts +25 -3
  28. package/src/core/tool-failure.ts +11 -3
  29. package/src/core/transcript.ts +3 -0
  30. package/src/core/work-state.ts +7 -2
  31. package/src/core/working-set.ts +34 -6
  32. package/src/runtime/connection-reuse.ts +40 -0
  33. package/src/runtime/exec-sessions.ts +66 -18
  34. package/src/runtime/history-tools.ts +12 -2
  35. package/src/runtime/loop.ts +264 -73
  36. package/src/runtime/open-image.ts +42 -0
  37. package/src/runtime/openai-responses.ts +15 -7
  38. package/src/runtime/openai.ts +15 -7
  39. package/src/runtime/permission-resources.ts +11 -4
  40. package/src/runtime/pi-provider.ts +34 -9
  41. package/src/runtime/provider.ts +19 -8
  42. package/src/runtime/retry.ts +152 -9
  43. package/src/runtime/secrets.ts +79 -0
  44. package/src/runtime/session-control.ts +9 -1
  45. package/src/runtime/stream-idle.ts +80 -0
  46. package/src/runtime/tools.ts +97 -27
  47. package/src/runtime/turn-effects.ts +31 -0
  48. package/src/store/context-files.ts +5 -1
  49. package/src/store/file-checkpoints.ts +1 -1
  50. package/src/store/log.ts +20 -0
  51. package/src/store/replay.ts +4 -3
  52. package/src/store/secrets.ts +44 -0
  53. package/src/store/sessions.ts +49 -6
  54. package/src/tui/ui/branch-panel.ts +47 -0
  55. package/src/tui/ui/composer.ts +27 -28
  56. package/src/tui/ui/image-size.ts +6 -2
  57. package/src/tui/ui/items.ts +3 -1
  58. package/src/tui/ui/pictures.ts +14 -1
  59. package/src/tui/ui/prompt-history.ts +60 -0
  60. package/src/tui/ui/screen.ts +34 -3
@@ -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; retryAttempt: number } = emptyLive();
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.retryAttempt = progress.attempt;
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
- const items = projectItems(this.transcriptItems, {
542
- notices: this.notices,
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: { attempt: this.live.retryAttempt, seconds: (this.live.retryUntil - Date.now()) / 1000 } } : {}) });
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: `retry ${l.retryAttempt} in ${Math.max(0, (l.retryUntil - Date.now()) / 1000).toFixed(1)}s · esc to cancel` } : {}) };
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)) return composer.historyStep(1);
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
- this.stopControl = () => { detachInput(); unsubscribe(); };
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.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · "),
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
- rows.push(...batch.sessions.filter((row): row is Row => row.ok));
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, retryAttempt: 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
- return clock ? [{ kind: "segment", id: "session-start", rev: clock, label: `session · ${clock}` }, ...items] : items;
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. */
@@ -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.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · ") }));
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
- rows.push(...batch.sessions.filter((row): row is Session => row.ok));
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
  };
@@ -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?: { readonly attempt: number; readonly seconds: number };
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.attempt} · ${Math.max(0, Math.ceil(facts.retry.seconds))}s`);
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
- * What exit 1 of the command's final simple command means, or null.
69
- *
70
- * Only the last segment after a top-level `;`, newline, `||` or `|` is read.
71
- * After `&&` the 1 may come from the command that short-circuited, so it is
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
- export function negativeAnswer(command: string, exit: number): string | null {
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 || separator === "&&") return null;
97
- const words = command.slice(start).trim().split(/\s+/).filter(word => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
98
- const program = (words[0] ?? "").replace(/^.*\//, "");
99
- if (program === "git") return words[1] === "grep" ? "no match" : null;
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 && negativeAnswer(command, exit) !== null;
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 its own plain command, without redirects, pipes, $(), ; or &&"
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}`),
@@ -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
- const hasNextAction = inFlight.some((i) => i.next_action.trim().length > 0);
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"]) &&