@visual-z/xeno 0.7.2 → 0.8.1

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 (117) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +36 -9
  3. package/docs/foundations.md +1 -1
  4. package/package.json +3 -1
  5. package/src/apps/cli.ts +4 -3
  6. package/src/apps/commands.ts +8 -0
  7. package/src/apps/goal-flow.ts +70 -0
  8. package/src/apps/render.ts +0 -2
  9. package/src/apps/session-hub.ts +6 -5
  10. package/src/apps/session-lifecycle.ts +64 -0
  11. package/src/apps/shell.ts +89 -34
  12. package/src/apps/startup.ts +32 -31
  13. package/src/apps/ui/app.ts +105 -13
  14. package/src/apps/ui/control-model.ts +18 -1
  15. package/src/apps/ui/surface.ts +2 -0
  16. package/src/apps/update-check.ts +1 -1
  17. package/src/core/acceptance.ts +41 -9
  18. package/src/core/blind-checks.ts +72 -0
  19. package/src/core/branch-receipt.ts +3 -1
  20. package/src/core/checkpoints.ts +21 -0
  21. package/src/core/churn.ts +2 -2
  22. package/src/core/completion-review.ts +18 -11
  23. package/src/core/continuation.ts +11 -10
  24. package/src/core/control-view.ts +5 -8
  25. package/src/core/deadline.ts +2 -1
  26. package/src/core/derive.ts +0 -7
  27. package/src/core/environment.ts +2 -2
  28. package/src/core/event-validation.ts +19 -6
  29. package/src/core/events.ts +41 -32
  30. package/src/core/evidence-ladder.ts +62 -0
  31. package/src/core/evidence-links.ts +0 -10
  32. package/src/core/evidence.ts +1 -2
  33. package/src/core/export.ts +6 -11
  34. package/src/core/goal-view.ts +64 -0
  35. package/src/core/goal.ts +200 -0
  36. package/src/core/history.ts +1 -11
  37. package/src/core/json-event.ts +0 -6
  38. package/src/core/outcome.ts +0 -1
  39. package/src/core/program-status.ts +11 -2
  40. package/src/core/progress.ts +14 -8
  41. package/src/core/project.ts +6 -16
  42. package/src/core/read-bounds.ts +11 -2
  43. package/src/core/read-observation.ts +24 -0
  44. package/src/core/request-budget.ts +13 -3
  45. package/src/core/request-state.ts +178 -19
  46. package/src/core/response-recovery.ts +15 -2
  47. package/src/core/session-entry.ts +8 -3
  48. package/src/core/session-exit.ts +19 -3
  49. package/src/core/session-format.ts +169 -0
  50. package/src/core/settings.ts +7 -27
  51. package/src/core/stall.ts +30 -7
  52. package/src/core/stop-report.ts +4 -3
  53. package/src/core/transcript.ts +22 -2
  54. package/src/runtime/blind-check.ts +36 -0
  55. package/src/runtime/counter-check.ts +74 -0
  56. package/src/runtime/exec-sessions.ts +11 -0
  57. package/src/runtime/foundation.ts +46 -8
  58. package/src/runtime/loop.ts +239 -430
  59. package/src/runtime/oauth.ts +1 -3
  60. package/src/runtime/openai.ts +2 -2
  61. package/src/runtime/pi-provider.ts +3 -3
  62. package/src/runtime/provider-request.ts +2 -1
  63. package/src/runtime/provider.ts +33 -3
  64. package/src/runtime/retry.ts +9 -2
  65. package/src/runtime/runtime-target.ts +2 -2
  66. package/src/runtime/session-control.ts +4 -4
  67. package/src/runtime/tools.ts +44 -42
  68. package/src/runtime/usage.ts +28 -0
  69. package/src/runtime/version.ts +12 -0
  70. package/src/runtime/workspace-lifecycle.ts +31 -0
  71. package/src/store/blind-checks.ts +32 -0
  72. package/src/store/checkpoint-history.ts +1 -12
  73. package/src/store/file-index.ts +88 -7
  74. package/src/store/log.ts +25 -27
  75. package/src/store/recovery.ts +1 -3
  76. package/src/store/replay.ts +2 -2
  77. package/src/store/restore.ts +10 -202
  78. package/src/store/search.ts +7 -5
  79. package/src/store/session-archive.ts +0 -1
  80. package/src/store/sessions.ts +31 -33
  81. package/src/store/workspace-clone.ts +42 -0
  82. package/src/store/workspace.ts +10 -157
  83. package/src/tui/ui/duration.ts +16 -0
  84. package/src/tui/ui/layout.ts +4 -4
  85. package/src/tui/ui/screen.ts +13 -7
  86. package/src/tui/ui/status.ts +99 -17
  87. package/src/core/benchmark.ts +0 -195
  88. package/src/core/evaluation-coverage.ts +0 -74
  89. package/src/core/experiment.ts +0 -173
  90. package/src/core/harness-comparison.ts +0 -90
  91. package/src/core/harness-diagnostics.ts +0 -206
  92. package/src/core/native-eval.ts +0 -25
  93. package/src/core/policy-evaluation.ts +0 -55
  94. package/src/core/query-eval-contract.ts +0 -14
  95. package/src/core/recall-analysis.ts +0 -90
  96. package/src/core/recall-trial.ts +0 -153
  97. package/src/core/recovery-corpus.ts +0 -17
  98. package/src/core/recovery-evaluation.ts +0 -60
  99. package/src/core/tool-mode-protocol.ts +0 -25
  100. package/src/core/trial-lifecycle.ts +0 -14
  101. package/src/core/window-trial.ts +0 -65
  102. package/src/index.ts +0 -10
  103. package/src/runtime/benchmark.ts +0 -187
  104. package/src/runtime/harness-comparison.ts +0 -376
  105. package/src/runtime/harness-eval.ts +0 -462
  106. package/src/runtime/native-eval-fixtures.ts +0 -33
  107. package/src/runtime/native-eval.ts +0 -146
  108. package/src/runtime/query-eval-verifier.ts +0 -28
  109. package/src/runtime/recall-trial.ts +0 -226
  110. package/src/runtime/recovery-coverage.ts +0 -71
  111. package/src/runtime/recovery-evidence.ts +0 -76
  112. package/src/runtime/recovery-fixtures.ts +0 -116
  113. package/src/runtime/tool-mode-eval.ts +0 -146
  114. package/src/runtime/trial-control.ts +0 -45
  115. package/src/runtime/window-trial.ts +0 -141
  116. package/src/store/git-branch.ts +0 -35
  117. package/src/tui/ui/testing.ts +0 -5
package/src/apps/shell.ts CHANGED
@@ -6,10 +6,13 @@
6
6
  * by scripts, while the interactive app projects the durable transcript.
7
7
  */
8
8
 
9
+ import { criterionSummary, draftGoal, pickCriteria, type PendingGoal } from "./goal-flow.ts";
10
+ import { closeAppSession, switchAppSession } from "./session-lifecycle.ts";
9
11
  import { basename, dirname, join, resolve } from "node:path";
10
12
  import { tmpdir } from "node:os";
11
13
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
12
14
  import { assertNever } from "../core/exhaustive.ts";
15
+ import type { CounterVerdict } from "../core/acceptance.ts";
13
16
  import type { LogEvent } from "../core/events.ts";
14
17
  import type { Skill } from "../core/skills.ts";
15
18
  import { workReadiness } from "../core/work-state.ts";
@@ -29,7 +32,7 @@ import {
29
32
  type ContextDecision, type ContextLimits, type ContextPolicy, type GaugeLevel,
30
33
  } from "../core/context-policy.ts";
31
34
  import { anchoredCount, estimateTokens, type Anchor, type TokenCounter } from "../core/tokens.ts";
32
- import type { RestorePoint, RewindMode } from "../runtime/loop.ts";
35
+ import type { RestorePoint, RewindMode, RequestOptions } from "../runtime/loop.ts";
33
36
  import { createProvider, providerSessionId, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
34
37
  import { outputTokenBudget, type Effort, type ModelSpec } from "../runtime/model.ts";
35
38
  import { scopedModels } from "../runtime/model-scope.ts";
@@ -335,10 +338,10 @@ async function fileIndexFor(app: App, refresh = false): Promise<FileIndex> {
335
338
 
336
339
  export async function runPrompt(app: App, prompt: string): Promise<void> {
337
340
  await bindExtensions(app);
338
- if (app.shutdownRequested) { await app.session.dispose(); await app.extensions?.dispose(); return; }
341
+ if (app.shutdownRequested) { await closeAppSession(app); return; }
339
342
  let ok: boolean;
340
343
  try { ok = await run(app, prompt); }
341
- finally { await app.session.dispose(); await app.extensions?.dispose(); }
344
+ finally { await closeAppSession(app); }
342
345
  printUsage(app);
343
346
  // One-shot mode is scriptable, so a failed turn has to be a failed exit.
344
347
  if (!ok) process.exit(1);
@@ -387,8 +390,7 @@ async function runLines(app: App): Promise<void> {
387
390
  if (typeof outcome === "object") awaitingKeyFor = outcome.login;
388
391
  }
389
392
  await cancelPendingOAuth(app);
390
- await app.session.dispose();
391
- await app.extensions?.dispose();
393
+ await closeAppSession(app);
392
394
  printUsage(app);
393
395
  }
394
396
 
@@ -408,7 +410,8 @@ async function runTui(app: App, releaseEarlyTty?: () => void): Promise<void> {
408
410
  */
409
411
  export const interactiveHooks = {
410
412
  handleLine: (app: App, text: string, ui: Ui) => handleLine(app, text, ui),
411
- run: (app: App, prompt: string, signal: AbortSignal | undefined, ui: Ui | undefined, images: readonly ImageRef[] = [], skill?: Skill) => run(app, prompt, signal, ui, images, skill),
413
+ run: (app: App, prompt: string, signal: AbortSignal | undefined, ui: Ui | undefined, images: readonly ImageRef[] = [], skill?: Skill, options?: RequestOptions) =>
414
+ run(app, prompt, signal, ui, images, skill, options),
412
415
  parseLine: (app: App, text: string) => parseLine(app, text),
413
416
  bindExtensions: (app: App, ui?: Ui) => bindExtensions(app, ui),
414
417
  extensionContext: (app: App, ui?: Ui) => extensionContext(app, ui),
@@ -503,6 +506,9 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
503
506
  await app.session.acceptCriterion(command.criterion);
504
507
  await printAcceptance(app);
505
508
  return "continue";
509
+ case "goal":
510
+ await goalCommand(app, command, tui ?? undefined);
511
+ return "continue";
506
512
  case "help":
507
513
  printHelp(app);
508
514
  return "continue";
@@ -693,6 +699,70 @@ async function handleLine(app: App, text: string, tui: Ui): Promise<Outcome> {
693
699
  return execute(app, parseLine(app, text), tui);
694
700
  }
695
701
 
702
+ /**
703
+ * S.2 / S.5. Line mode keeps a drafted goal until `/goal accept` names the
704
+ * criteria to keep; the TUI asks with a checklist instead.
705
+ */
706
+ const pendingGoals = new WeakMap<App, PendingGoal>();
707
+
708
+ async function goalCommand(app: App, command: Extract<Command, { kind: "goal" }>, tui?: Ui): Promise<void> {
709
+ const C = app.io.palette;
710
+ switch (command.action) {
711
+ case "status": await printGoal(app); return;
712
+ case "clear": app.io.say(await app.session.clearGoal() ? C.green(" goal cleared") : C.dim(" no active goal")); return;
713
+ case "set": {
714
+ if (tui) { tui.startGoal(command.objective); return; }
715
+ const drafted = await draftGoal(app, command.objective, (prompt, options) => run(app, prompt, undefined, undefined, [], undefined, options));
716
+ if (!drafted) return;
717
+ pendingGoals.set(app, drafted);
718
+ app.io.say(C.bold("\ndrafted criteria") + C.dim(" (accept with /goal accept all, or /goal accept 1,3)"));
719
+ drafted.criteria.forEach((item, index) => app.io.say(` ${index + 1}. ${item.id} · ${criterionSummary(item)}`));
720
+ return;
721
+ }
722
+ case "accept": {
723
+ const drafted = pendingGoals.get(app);
724
+ if (!drafted) { app.io.say(C.yellow(" no drafted goal; run /goal <objective> first")); return; }
725
+ const picked = pickCriteria(drafted.criteria, command.pick);
726
+ if (!picked.ok) { app.io.say(C.yellow(` ${picked.error}`)); return; }
727
+ pendingGoals.delete(app);
728
+ await app.session.setGoal({ ...drafted, accepted: picked.ids });
729
+ app.io.say(C.green(` goal set with ${picked.ids.length} accepted criteria`));
730
+ await run(app, drafted.objective);
731
+ await printGoal(app);
732
+ return;
733
+ }
734
+ default: return assertNever(command, "goal command");
735
+ }
736
+ }
737
+
738
+ async function printGoal(app: App): Promise<void> {
739
+ const C = app.io.palette, goal = await app.session.goal();
740
+ if (!goal) { app.io.say(C.dim(" no active goal; /goal <objective> drafts one")); return; }
741
+ const request = await app.session.requestState();
742
+ const current = request?.acceptance?.contract_id === goal.contract.id ? request : undefined;
743
+ app.io.say(C.bold("\ngoal") + ` ${goal.objective}`);
744
+ for (const item of goal.contract.criteria) {
745
+ const criterion = current?.criteria.find(entry => entry.id === item.id), status = criterion?.status ?? "not_run";
746
+ const guard = item.kind === "command" && item.guard === true;
747
+ app.io.say(` ${status === "pass" || status === "accepted" ? C.green("✓") : C.yellow("○")} ${item.id}${guard ? " (guard)" : ""} · ${status} · ${item.description}` +
748
+ (criterion?.counter ? counterSuffix(C, criterion.counter) : ""));
749
+ }
750
+ for (const blocker of current?.reported_blockers ?? []) app.io.say(C.yellow(` reported blocker: ${blocker.reason}`));
751
+ if (current && current.evidence.independent !== "not_applicable") app.io.say(` blind checks ${current.evidence.independent}`);
752
+ }
753
+
754
+ /** One counter-check verdict after a criterion, in the words of its role (S.7). */
755
+ function counterSuffix(C: App["io"]["palette"], verdict: CounterVerdict): string {
756
+ switch (verdict) {
757
+ case "vacuous": return C.yellow(" · also passes without the changes; it does not test them");
758
+ case "discriminating": return C.green(" · fails without the changes");
759
+ case "held": return C.green(" · held before the changes too");
760
+ case "not_held": return C.dim(" · did not hold before the changes; it tests them rather than guarding");
761
+ case "unavailable": return C.dim(" · counter-check unavailable");
762
+ default: return assertNever(verdict, "counter verdict");
763
+ }
764
+ }
765
+
696
766
  function printUsage(app: App): void {
697
767
  const C = app.io.palette;
698
768
  const t = app.session.totals;
@@ -756,7 +826,10 @@ async function printState(app: App): Promise<void> {
756
826
  app.io.say(` command evidence ${request.commands.filter(command => command.status === "pass").length} current passes / ${request.commands.length} receipts`);
757
827
  app.io.say(` observations ${request.progress.observations.length} new; artifact changes ${request.progress.artifact_changes.length}; acceptance advances ${request.progress.acceptance_advances.length}`);
758
828
  app.io.say(` diagnostics ${request.progress.new_diagnostics.length} new; unchanged retries ${request.progress.unchanged_retries.length}; unknown ${request.progress.unknown_diagnostics.length}`);
759
- if (!request.acceptance) app.io.say(" task verification no declared acceptance contract; command success alone does not verify the task");
829
+ app.io.say(` evidence ran ${request.evidence.ran} · discriminating ${request.evidence.discriminating} · independent ${request.evidence.independent} · human ${request.evidence.human}`);
830
+ for (const gap of request.unmet) app.io.say(` unmet ${gap}`);
831
+ for (const blocker of request.reported_blockers) app.io.say(` reported blocker ${blocker.reason}`);
832
+ if (!request.acceptance) app.io.say(" task verification no declared contract; a fresh check shows only that the changes ran, not that the request was met");
760
833
  }
761
834
  if (s.handoff) {
762
835
  const h = s.handoff;
@@ -805,7 +878,7 @@ async function printAcceptance(app: App): Promise<void> {
805
878
  app.io.say(` build ${target.build_event ?? "none"}; instance ${target.binding ?? "unknown"}; observation ${target.observation ?? "none"}; ${target.reason ?? "start the declared target after its build"}`);
806
879
  }
807
880
  for (const item of state?.criteria ?? []) {
808
- app.io.say(` ${item.id}: ${item.status} · ${item.description}`);
881
+ app.io.say(` ${item.id}${item.kind === "command" && item.guard ? " (guard)" : ""}: ${item.status} · ${item.description}`);
809
882
  if (item.kind === "command") app.io.say(` command: ${item.command}; evidence: ${item.evidence?.event_id ?? "none"}`);
810
883
  else if (item.status !== "accepted") app.io.say(` /accept ${item.id} records your acceptance${item.target ? ` of the observed target ${item.target}` : ""}`);
811
884
  }
@@ -841,8 +914,7 @@ async function applySettingsCommand(
841
914
  app.io.say(app.io.palette.bold("\nsettings"));
842
915
  app.io.say(` theme ${s?.theme ?? "dark"}`);
843
916
  app.io.say(` thinking ${s?.thinking ?? app.providerConfig.effort}`);
844
- app.io.say(` statusBar ${s?.statusBar === false ? "off" : "on"}`);
845
- app.io.say(app.io.palette.dim(" /settings <theme|thinking|statusBar> <value> to change.\n"));
917
+ app.io.say(app.io.palette.dim(" /settings <theme|thinking> <value> to change; /settings alone opens every option.\n"));
846
918
  return "continue";
847
919
  }
848
920
 
@@ -858,7 +930,7 @@ async function commitSetting(app: App, field: string, value: string, tui: Ui | n
858
930
  if (effort) await applyThinking(app, effort, tui);
859
931
  return;
860
932
  }
861
- const parsed = settingPatch(key === "status" ? "statusBar" : field, value);
933
+ const parsed = settingPatch(field, value);
862
934
  if (parsed.diagnostics.length) { app.io.say(C.yellow(parsed.diagnostics.join("\n"))); return; }
863
935
  if (key === "theme") {
864
936
  const diagnostics: string[] = [];
@@ -1639,25 +1711,7 @@ async function resumeSession(app: App, idOrPath: string, tui?: Ui): Promise<void
1639
1711
 
1640
1712
  async function adoptLogDir(app: App, logDir: string): Promise<boolean> {
1641
1713
  try {
1642
- app.session.cancel(); await app.session.whenIdle();
1643
- if (app.extensions && !await app.extensions.releaseIntegrations()) throw Error("An integration still owns unfinished work or unconfirmed cleanup. Wait for it to settle before switching sessions.");
1644
- const opened = await openSessionFor({ ...app, logDir, fallback: app.providerConfig, interactive: true });
1645
- await app.session.dispose();
1646
- await app.extensions?.dispose();
1647
- app.session = opened.session;
1648
- app.providerConfig = opened.providerConfig;
1649
- app.credential = opened.credential;
1650
- app.logDir = logDir;
1651
- app.workspace = opened.workspace;
1652
- app.blobDir = opened.blobDir;
1653
- app.system = opened.system;
1654
- app.loadedContext = opened.resources.loadedContext;
1655
- app.slash = opened.resources.slash;
1656
- app.ui = opened.resources.ui;
1657
- app.resources = opened.resources.index;
1658
- app.projectTrusted = opened.resources.trusted;
1659
- app.extensions = opened.resources.extensions;
1660
- app.foundation = opened.resources.foundation;
1714
+ await switchAppSession(app, logDir, (current, target) => openSessionFor({ ...current, logDir: target, fallback: current.providerConfig, interactive: true }));
1661
1715
  } catch (err) {
1662
1716
  app.io.say(app.io.palette.red(` ${describeError(err)}`));
1663
1717
  return false;
@@ -1793,7 +1847,7 @@ async function nameSession(app: App, name: string, tui?: Ui): Promise<void> {
1793
1847
  }
1794
1848
 
1795
1849
  /** Runs a prompt. Returns false if the turn failed, having already said why. */
1796
- async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, images: readonly ImageRef[] = [], skill?: Skill): Promise<boolean> {
1850
+ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, images: readonly ImageRef[] = [], skill?: Skill, options?: RequestOptions): Promise<boolean> {
1797
1851
  if (app.extensions && !initializedExtensions.has(app.extensions)) await bindExtensions(app, tui);
1798
1852
  for (const result of await app.extensions?.emit("input", { text: prompt, images, source: "interactive" }) ?? []) {
1799
1853
  if (result["action"] === "handled") return true;
@@ -1873,7 +1927,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, ima
1873
1927
  return false;
1874
1928
  }
1875
1929
  try {
1876
- await runTurn(app, input, signal, tui, skill);
1930
+ await runTurn(app, input, signal, tui, skill, options);
1877
1931
  return true;
1878
1932
  } catch (error) {
1879
1933
  tui?.discardStream();
@@ -1895,6 +1949,7 @@ export async function runTurn(
1895
1949
  signal?: AbortSignal,
1896
1950
  tui?: TurnSurface,
1897
1951
  skill?: Skill,
1952
+ options?: RequestOptions,
1898
1953
  ): Promise<void> {
1899
1954
  // A pipe cannot unwrite bytes. Non-tty / -p therefore does not stream:
1900
1955
  // deltas and discard are ignored, and the final block prints once.
@@ -1914,7 +1969,7 @@ export async function runTurn(
1914
1969
  }
1915
1970
  await registry?.emit("agent_start");
1916
1971
  await registry?.emit("turn_start", { turnIndex: 0 });
1917
- try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : sessionControl(app.session).send(prompt, signal, pull)) {
1972
+ try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : sessionControl(app.session).send(prompt, signal, pull, options)) {
1918
1973
  // The log retains every response, but a script's final answer must be the
1919
1974
  // accepted last response. A rejected JSON document cannot be unwritten.
1920
1975
  if (e.kind === "turn_start" || e.kind === "discard") finalText = [];
@@ -1943,7 +1998,7 @@ export async function runTurn(
1943
1998
  if (e.kind === "discard") tui.discardStream();
1944
1999
  await tui.syncTranscript();
1945
2000
  if (["assistant_text", "thinking", "tool_call", "tool_result", "done", "cut"].includes(e.kind)) tui.commitStream();
1946
- if (e.kind === "snapshot_failed" || e.kind === "undeclared_change" || e.kind === "stop_and_ask") {
2001
+ if (e.kind === "stop_and_ask") {
1947
2002
  for (const line of formatEvent(e, app.io.palette, Math.max(20, (process.stdout.columns ?? 80) - 1), {}, app.ui?.style)) app.io.say(line);
1948
2003
  }
1949
2004
  }
@@ -1,3 +1,6 @@
1
+ import { loadBlindChecks } from "../store/blind-checks.ts";
2
+ import type { BlindChecks } from "../core/blind-checks.ts";
3
+ import { appSessionFields } from "./session-lifecycle.ts";
1
4
  import { release } from "node:os";
2
5
  import { codingWorkingProtocol } from "../core/continuation.ts";
3
6
  import { taskGuidance } from "../core/prompt-policy.ts";
@@ -40,6 +43,7 @@ import { readModelSources, readCatalogCacheText, writeCatalogCache, catalogCache
40
43
  import { refreshRemoteCatalog } from "../runtime/models-fetch.ts";
41
44
  import { readCursor, writeCursor, type Cursor } from "../store/log.ts";
42
45
  import { getBlob } from "../store/blobs.ts";
46
+ import { parseSessionContext } from "../core/session-format.ts";
43
47
  import { resolveLogDir, findSessionByPrefix, listSessions, sessionsDir, type ResolveLogOpts } from "../store/sessions.ts";
44
48
  import { recoverCut } from "../store/recovery.ts";
45
49
  import { planSessionEntry, restoreSessionModel } from "../core/session-entry.ts";
@@ -111,12 +115,12 @@ export function sessionEnvironment(workspace: string): SessionEnvironment {
111
115
  * Session policy a CLI coding session differs from a benchmark run in.
112
116
  * Benchmarks construct their own SessionConfig and keep the core defaults.
113
117
  */
114
- export function cliSessionConfig(args: Args): { continuationAfter: number; workingSet: true; workingSetVersion: 2; guidanceVersion: 1; strictVerification: true; timeBudgetMs?: number } {
118
+ export function cliSessionConfig(args: Args): { continuationAfter: number; workingSet: true; strictVerification: true; timeBudgetMs?: number } {
115
119
  // Never within a request. A real Kimi K3 run with the reminder at reply six
116
120
  // spent half its requests on notes after the work was already verified
117
121
  // (fix-cli-continuation-noise). The reminder still fires where it earns its
118
122
  // place: a resumed handoff that cannot continue, and boundary preparation.
119
- return { continuationAfter: Infinity, workingSet: true, workingSetVersion: 2, guidanceVersion: 1, strictVerification: true, ...(args.timeBudgetMs === undefined ? {} : { timeBudgetMs: args.timeBudgetMs }) };
123
+ return { continuationAfter: Infinity, workingSet: true, strictVerification: true, ...(args.timeBudgetMs === undefined ? {} : { timeBudgetMs: args.timeBudgetMs }) };
120
124
  }
121
125
 
122
126
  /**
@@ -190,6 +194,8 @@ export interface Args {
190
194
  /** New sessions default to coding; restored context keeps its frozen schemas. */
191
195
  toolProfile?: ToolProfile;
192
196
  acceptanceFile?: string;
197
+ /** feat-blind-checks: a directory outside the workspace holding blind.json and its fixtures. */
198
+ blindChecks?: string;
193
199
  excludeTools: string[];
194
200
  noTools: boolean;
195
201
  /** `--no-builtin-tools`. False when the flag was not passed. */
@@ -288,6 +294,7 @@ const KNOWN_FLAGS: readonly string[] = [
288
294
  "--tools", "-t",
289
295
  "--tool-profile",
290
296
  "--acceptance-file",
297
+ "--blind-checks",
291
298
  "--exclude-tools", "-xt",
292
299
  "--no-tools", "-nt",
293
300
  "--no-builtin-tools",
@@ -359,7 +366,9 @@ export function parseArgs(argv: string[], options: { deferExtensions?: boolean;
359
366
  let updateExtensions = false;
360
367
  const args: Args = {
361
368
  cliFlags: argv.filter(value => value.startsWith("-")),
362
- workspace: process.cwd(), logDir: "", maxTurns: 40, prompt: "",
369
+ // No default request cap (S.6 item 1): the stall watchdog ends a run that
370
+ // stops producing evidence; --max-turns or an agent budget sets a hard cap.
371
+ workspace: process.cwd(), logDir: "", maxTurns: Infinity, prompt: "",
363
372
  // Overridable so a node boundary can be forced in a short session --
364
373
  // otherwise cutting is only reachable after tens of thousands of tokens,
365
374
  // which makes the most interesting path the least exercised one.
@@ -443,6 +452,10 @@ export function parseArgs(argv: string[], options: { deferExtensions?: boolean;
443
452
  else if (a === "--system-prompt") args.systemPrompt = argv[++i] ?? "";
444
453
  else if (a === "--append-system-prompt") args.appendSystemPrompt.push(argv[++i] ?? "");
445
454
  else if (a === "--tools" || a === "-t") args.tools = parseToolList(argv[++i] ?? "");
455
+ else if (a === "--blind-checks") {
456
+ const path = argv[++i]; if (!path || path.startsWith("--")) throw new Error("--blind-checks requires a directory outside the workspace");
457
+ args.blindChecks = path;
458
+ }
446
459
  else if (a === "--acceptance-file") {
447
460
  const path = argv[++i]; if (!path || path.startsWith("--")) throw new Error("--acceptance-file requires a JSON path");
448
461
  args.acceptanceFile = path;
@@ -942,6 +955,7 @@ export function createSessionFor(opts: {
942
955
  extensions?: ExtensionRegistry;
943
956
  foundation?: FoundationRuntime;
944
957
  acceptanceContract?: AcceptanceContract;
958
+ blindChecks?: BlindChecks;
945
959
  skills?: readonly Skill[];
946
960
  }): Session {
947
961
  const { args, workspace, logDir, blobDir, system, providerConfig, credential, io, askSlot } = opts;
@@ -975,6 +989,7 @@ export function createSessionFor(opts: {
975
989
  ...cliSessionConfig(args),
976
990
  ...(opts.skills ? { skills: opts.skills } : {}),
977
991
  ...(opts.acceptanceContract ? { acceptanceContract: opts.acceptanceContract } : {}),
992
+ ...(opts.blindChecks ? { blindChecks: opts.blindChecks } : {}),
978
993
  // Explicit legacy thresholds still select that experiment unless overridden.
979
994
  contextPolicy: args.contextPolicy ?? (args.trigger > 0 ? "segmented" : DEFAULT_CONTEXT_POLICY),
980
995
  ...(args.trigger > 0
@@ -1115,26 +1130,16 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1115
1130
  if (priorCursor?.context_ref) {
1116
1131
  const savedContext = await getBlob(join(opts.logDir, "artifacts"), priorCursor.context_ref);
1117
1132
  if (!savedContext) throw Error("missing frozen session context");
1118
- const metadata: unknown = JSON.parse(savedContext);
1119
- if (metadata && typeof metadata === "object") {
1120
- if (!("agentProfile" in metadata) && !opts.args.cliFlags?.includes("--workflow") && "workflowMode" in metadata) {
1121
- if (metadata.workflowMode !== "build" && metadata.workflowMode !== "plan") throw Error("unsupported saved workflow mode");
1122
- opts.args.workflow = metadata.workflowMode;
1123
- }
1124
- if ("workflowToolProfile" in metadata && metadata.workflowToolProfile !== undefined) {
1125
- if (metadata.workflowToolProfile !== "foundation") throw Error("unsupported saved workflow tool profile");
1126
- if (!opts.args.cliFlags?.some(flag => ["--tool-profile", "--tools", "-t", "--no-tools", "--no-builtin-tools"].includes(flag))) opts.args.toolProfile = "foundation";
1127
- }
1128
- }
1129
- if (metadata && typeof metadata === "object" && "agentProfile" in metadata && metadata.agentProfile !== undefined) {
1130
- const role = metadata.agentProfile;
1131
- if (!role || typeof role !== "object" || !("name" in role) || typeof role.name !== "string" || !/^[a-z][a-z0-9_-]{0,63}$/.test(role.name) || !("workflowExplicit" in role) || typeof role.workflowExplicit !== "boolean") throw Error("unsupported saved agent profile selector");
1132
- if (!opts.args.cliFlags?.includes("--agent")) opts.args.agent = role.name;
1133
- if (role.workflowExplicit && !opts.args.cliFlags?.includes("--workflow") && "workflowMode" in metadata) {
1134
- if (metadata.workflowMode !== "build" && metadata.workflowMode !== "plan") throw Error("unsupported saved workflow mode");
1135
- opts.args.workflow = metadata.workflowMode; opts.args.cliFlags = [...opts.args.cliFlags ?? [], "--workflow"];
1133
+ // S.3: the same parser Session.start uses; there is one reader of this format.
1134
+ const saved = parseSessionContext(JSON.parse(savedContext));
1135
+ const explicit = (...flags: string[]) => opts.args.cliFlags?.some(flag => flags.includes(flag)) === true;
1136
+ if (saved.agentProfile) {
1137
+ if (!explicit("--agent")) opts.args.agent = saved.agentProfile.name;
1138
+ if (saved.agentProfile.workflowExplicit && saved.workflowMode && !explicit("--workflow")) {
1139
+ opts.args.workflow = saved.workflowMode; opts.args.cliFlags = [...opts.args.cliFlags ?? [], "--workflow"];
1136
1140
  }
1137
- }
1141
+ } else if (saved.workflowMode && !explicit("--workflow")) opts.args.workflow = saved.workflowMode;
1142
+ if (saved.workflowToolProfile && !explicit("--tool-profile", "--tools", "-t", "--no-tools", "--no-builtin-tools")) opts.args.toolProfile = "foundation";
1138
1143
  }
1139
1144
  const workspace = priorCursor?.workspace ?? opts.workspace;
1140
1145
  if (opts.args.pathRoot) {
@@ -1196,6 +1201,8 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1196
1201
  const oauth = (model: ModelSpec, got: Resolved) => got.ok && storedIsOAuth(opts.auth, model.provider) && opts.args.apiKey.length === 0;
1197
1202
  const acceptanceContract = priorCursor === null
1198
1203
  ? await loadAcceptanceContract(workspace, resources.trusted, opts.args.acceptanceFile) : undefined;
1204
+ // Frozen at session start like the contract; a resumed session reads its own copy from the saved context.
1205
+ const blindChecks = priorCursor === null && opts.args.blindChecks ? await loadBlindChecks(opts.args.blindChecks, workspace) : undefined;
1199
1206
  const session = createSessionFor({
1200
1207
  ...opts,
1201
1208
  system: resources.system,
@@ -1203,6 +1210,7 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1203
1210
  foundation: resources.foundation,
1204
1211
  skills: [...resources.slash.skills.values()],
1205
1212
  ...(acceptanceContract ? { acceptanceContract } : {}),
1213
+ ...(blindChecks ? { blindChecks } : {}),
1206
1214
  workspace: priorCursor?.workspace ?? opts.workspace,
1207
1215
  blobDir: priorCursor?.blob_dir ?? (priorCursor?.workspace && priorCursor.workspace !== opts.workspace
1208
1216
  ? statePaths(priorCursor.workspace, opts.args.stateDir).blobs : opts.blobDir),
@@ -1429,7 +1437,6 @@ export async function startup(args: Args, input?: StartupInput): Promise<App> {
1429
1437
  sessionOpts = { explicit: picked };
1430
1438
  }
1431
1439
  const resolved = await resolveLogDir(workspace, sessionOpts);
1432
- if (resolved.legacyNotice !== null && !args.logDir) io.say(C.yellow(` ${resolved.legacyNotice}`));
1433
1440
  logDir = resolved.logDir;
1434
1441
  } catch (err) {
1435
1442
  const detail = err instanceof Error ? err.message : String(err);
@@ -1483,13 +1490,7 @@ export async function startup(args: Args, input?: StartupInput): Promise<App> {
1483
1490
  tools: visible.map((t) => t.name),
1484
1491
  });
1485
1492
 
1486
- return {
1487
- args, session, workspace: opened.workspace, logDir, blobDir: opened.blobDir, system: opened.system, providerConfig, credential,
1488
- auth, effects, loadedContext: opened.resources.loadedContext, credentialOf, catalog, io, askSlot,
1489
- ui: opened.resources.ui, slash: opened.resources.slash, resources: opened.resources.index, projectTrusted: opened.resources.trusted,
1490
- extensions: opened.resources.extensions,
1491
- foundation: opened.resources.foundation,
1492
- };
1493
+ return { args, logDir, auth, effects, credentialOf, catalog, io, askSlot, ...appSessionFields(opened) };
1493
1494
  }
1494
1495
 
1495
1496
  /** The banner's words for the shared limits; the loop acts at exactly these numbers. */
@@ -1596,7 +1597,7 @@ export function printCliHelp(flags?: ReadonlyMap<string, ExtensionFlag>): void {
1596
1597
  " [--tool-profile coding|readonly|research|data|foundation|full]\n" +
1597
1598
  " [--workflow build|plan] [--agent name]\n" +
1598
1599
  " [--output-schema schema.json] [--output-last-message file]\n" +
1599
- " [--acceptance-file contract.json]\n" +
1600
+ " [--acceptance-file contract.json] [--blind-checks dir]\n" +
1600
1601
  " [--control-socket /private/directory/control.sock]\n" +
1601
1602
  " [--no-builtin-tools]\n" +
1602
1603
  " [--allow-tools names] [--yolo|-y] [--approve|--no-approve]\n" +
@@ -10,6 +10,10 @@
10
10
  * either rebuilt from the log (transcript) or deliberately lost on exit
11
11
  * (draft queue that was never sent, scroll position).
12
12
  */
13
+ import { ladderView } from "./control-model.ts";
14
+ import { criterionSummary, draftGoal } from "../goal-flow.ts";
15
+ import type { RequestOptions } from "../../runtime/loop.ts";
16
+ import { closeAppSession } from "../session-lifecycle.ts";
13
17
  import { homedir } from "node:os";
14
18
  import { basename, dirname, resolve } from "node:path";
15
19
  import { fileURLToPath } from "node:url";
@@ -29,7 +33,7 @@ import { DISPLAY_SETTINGS, settingValue, settingValues } from "../../core/settin
29
33
  import { THINKING_LEVELS, asThinkingLevel, supportedThinkingLevels } from "../../core/thinking.ts";
30
34
  import { builtinTheme } from "../../core/theme.ts";
31
35
  import { projectTranscript, type TranscriptItem } from "../../core/transcript.ts";
32
- import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, sessionExitStatus } from "../../core/session-exit.ts";
36
+ import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, resumeCommand, sessionExitStatus } from "../../core/session-exit.ts";
33
37
  import { lastAssistantText } from "../../core/export.ts";
34
38
  import type { PermissionRequest } from "../../core/permissions.ts";
35
39
  import type { ApprovalChoice } from "../../runtime/tools.ts";
@@ -37,7 +41,8 @@ import { attachImageBytes, attachImageFile } from "../../runtime/images.ts";
37
41
  import { readClipboard, writeClipboard, type ClipboardContent } from "../../runtime/clipboard.ts";
38
42
  import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../../runtime/rich-text.ts";
39
43
  import { SOURCE_PROVIDERS } from "../../runtime/model-data.ts";
40
- import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallbackServer, XENO_VERSION } from "../../runtime/oauth.ts";
44
+ import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallbackServer } from "../../runtime/oauth.ts";
45
+ import { XENO_VERSION } from "../../runtime/version.ts";
41
46
  import { reportedCost } from "../../runtime/usage.ts";
42
47
  import { UpdateChecks } from "../update-check.ts";
43
48
  import { updateNotice } from "../../core/update-check.ts";
@@ -46,9 +51,12 @@ import { isUpgrade } from "../../core/version.ts";
46
51
  import type { Effort } from "../../runtime/model.ts";
47
52
  import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
48
53
  import { readNodeHistory } from "../../store/restore.ts";
54
+ import { sessionsDir } from "../../store/sessions.ts";
49
55
  import { readGitStatus } from "../../store/git-status.ts";
50
56
  import type { GitStatus } from "../../core/git-status.ts";
51
57
  import { activityStatus } from "../../core/activity-status.ts";
58
+ import { goalStatus } from "../../core/goal-view.ts";
59
+ import { generationSpeed } from "../../runtime/usage.ts";
52
60
  import { executionOutcome, type ExecutionOutcome } from "../../core/outcome.ts";
53
61
  import { projectTrust } from "../../store/trust.ts";
54
62
  import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
@@ -155,6 +163,14 @@ export class InteractiveApp implements Ui {
155
163
  private gitRun: Promise<void> | undefined;
156
164
  private gitAgain = false;
157
165
  private attention: ControlSnapshot["waiting"] = null;
166
+ /** The objective of a /goal whose criteria are being drafted. */
167
+ private drafting: string | undefined;
168
+ /** Index of this turn's first usage sample; generation speed reads from here. */
169
+ private turnUsageStart = 0;
170
+ /** Where this process began counting a goal's tokens: usage samples are kept per process, not in the log. */
171
+ private goalUsage: { goal: string; start: number } | undefined;
172
+ /** The last goal view and what it was derived from; see goalAndChecks. */
173
+ private goalCache: { key: string; view: import("../../core/goal-view.ts").GoalStatus | null; goal?: string } | undefined;
158
174
  private lastOutcome: ExecutionOutcome | undefined;
159
175
  private paintTimer: ReturnType<typeof setTimeout> | undefined;
160
176
  /** Handoff-derived status (sidebar / top bar), refreshed single-flight and debounced (DoD 9). */
@@ -588,20 +604,61 @@ export class InteractiveApp implements Ui {
588
604
  ...(budget ? { context: { used: budget.used, trigger: budget.trigger, estimated: budget.estimated, segment: app.session.state.node, ...(spec.contextWindow ? { window: spec.contextWindow } : {}) } } : {}),
589
605
  ...(t.requests ? { usage: { input: t.prompt_tokens, output: t.output_tokens, cacheRate: t.cache_hit_rate,
590
606
  // Under half a cent reads as $0.00, which says nothing (OpenCode #15903; Grok's status line hides it the same way).
591
- cost: cost.kind === "usd" && cost.amount >= 0.005 ? `~$${cost.amount.toFixed(2)}` : cost.kind === "partial" && cost.known >= 0.005 ? `≥$${cost.known.toFixed(2)}` : null } } : {}),
607
+ cost: cost.kind === "usd" && cost.amount >= 0.005 ? `~$${cost.amount.toFixed(2)}` : cost.kind === "partial" && cost.known >= 0.005 ? `≥$${cost.known.toFixed(2)}` : null,
608
+ ...speedOf(app.session.state.usage.slice(this.turnUsageStart)) } } : {}),
592
609
  tools: { running: tools.filter(tool => tool.status === "pending").length, done: tools.filter(tool => tool.status === "ok").length, failed: tools.filter(tool => tool.status === "error").length },
593
610
  ...(this.derived ?? {}),
594
- ...(this.controlSnapshot?.goal ? { goal: { text: this.controlSnapshot.goal.text,
595
- status: this.controlSnapshot.goal.state.status,
596
- execution: this.inflight ? "running" : this.controlSnapshot.goal.state.execution?.reason ?? "idle",
597
- observations: this.controlSnapshot.goal.state.progress.observations.length,
598
- retries: this.controlSnapshot.goal.state.progress.unchanged_retries.length } } : {}),
611
+ ...this.goalAndChecks(),
599
612
  files: this.files,
600
613
  resources: { contextFiles: app.loadedContext.agents.map(file => basename(file.path)), skills: app.slash?.skills.size ?? 0, extensions: app.extensions?.loaded.length ?? 0 },
601
614
  ...(extension ? { extension } : {}),
602
615
  };
603
616
  }
604
617
 
618
+ /**
619
+ * The sidebar's evidence: an explicit goal's block, or -- without one -- a
620
+ * Checks line for a request that changed files or declared a contract
621
+ * (feat-sidebar-goal-and-speed). Both read the request ledger the control
622
+ * snapshot already derived. The goal view is a scan of the whole log, so it
623
+ * is cached per log length; only its clock is read on every paint.
624
+ */
625
+ private goalAndChecks(): Pick<StatusModel, "goal" | "checks"> {
626
+ const state = this.controlSnapshot?.goal?.state, session = this.app.session;
627
+ const key = `${this.app.logDir}:${session.state.node}:${this.ancestors.length}:${session.state.events.length}:${this.inflight}:${this.drafting ?? ""}:${state?.request.event ?? ""}:${state?.status ?? ""}`;
628
+ if (this.goalCache?.key !== key) {
629
+ const events = [...this.ancestors, ...session.state.events];
630
+ const set = events.findLast(event => event.t === "goal" && event.action === "set");
631
+ this.goalCache = { key, view: goalStatus(events, state, { running: this.inflight, ...(this.drafting !== undefined ? { drafting: this.drafting } : {}) }),
632
+ ...(set ? { goal: set.id } : {}) };
633
+ }
634
+ const view = this.goalCache.view;
635
+ if (view) {
636
+ // The session's own clock stamps the log, so a running goal's time is read on it too.
637
+ const now = Date.parse(session.clock.now());
638
+ const since = this.goalCache.goal ? session.state.usage.slice(this.goalUsageStart(this.goalCache.goal)) : [];
639
+ const tokens = since.reduce((total, sample) => total + sample.input_tokens + sample.cache_creation_input_tokens + sample.cache_read_input_tokens + sample.output_tokens, 0);
640
+ return { goal: { phase: view.phase, objective: view.objective, checks: view.checks, requests: view.requests,
641
+ elapsedMs: view.phase === "drafting" ? 0 : Math.max(0, (view.endedAt ?? now) - view.startedAt),
642
+ ...(state && view.phase !== "drafting" ? ladderView(state) : {}), ...(tokens ? { tokens } : {}) } };
643
+ }
644
+ if (state?.acceptance) {
645
+ // A declared contract without /goal: how many of its criteria hold; /acceptance lists them.
646
+ const items = state.acceptance.criteria, passed = items.filter(item => item.status === "pass" || item.status === "accepted").length;
647
+ return { checks: { status: passed === items.length ? "pass" : "partial", command: `${passed}/${items.length} passed · /acceptance` } };
648
+ }
649
+ if (!state?.changed) return {};
650
+ const fresh = state.criteria.find(item => item.kind === "fresh_check");
651
+ const run = fresh?.command ? state.commands.findLast(command => command.command === fresh.command) : state.commands.at(-1);
652
+ if (!fresh || fresh.status === "not_run" || !run) return { checks: { status: "unchecked" } };
653
+ return { checks: { status: fresh.status === "pass" ? "pass" : fresh.status === "stale" ? "stale" : "fail", command: run.command, exit: run.exit } };
654
+ }
655
+
656
+ /** Usage samples belong to this process; a goal's spend counts those recorded after this process first saw it. */
657
+ private goalUsageStart(goal: string): number {
658
+ if (this.goalUsage?.goal !== goal) this.goalUsage = { goal, start: this.app.session.state.usage.length };
659
+ return this.goalUsage.start;
660
+ }
661
+
605
662
  private writeTitle(title: string): void {
606
663
  if (title === this.writtenTitle || this.disposed) return;
607
664
  this.writtenTitle = title;
@@ -851,16 +908,44 @@ export class InteractiveApp implements Ui {
851
908
  this.paint();
852
909
  }
853
910
 
854
- private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill): Promise<void> {
911
+ /**
912
+ * S.2 / S.5. The draft runs as an ordinary, visible and cancellable turn;
913
+ * its checklist opens once the reply is in. Only the checked criteria
914
+ * become the goal, and the goal's own request starts from the objective.
915
+ */
916
+ startGoal(objective: string): void {
917
+ this.track(async () => {
918
+ // The draft request's own text is an instruction to the model, not the goal; the sidebar shows the objective.
919
+ this.drafting = objective;
920
+ const pending = await draftGoal(this.app, objective, async (prompt, options) => {
921
+ await this.runPromptTurn(prompt, [], undefined, options);
922
+ // A cancelled draft is not a draft to accept, nor one to send back.
923
+ return this.lastOutcome?.status !== "cancelled";
924
+ }).finally(() => { this.drafting = undefined; this.paint(); });
925
+ if (!pending) return;
926
+ this.push({ kind: "list", title: `Accept the checks for: ${objective}`, multi: true, checked: pending.criteria.map(item => item.id), search: false,
927
+ items: pending.criteria.map(item => ({ value: item.id, label: item.id, description: criterionSummary(item) })),
928
+ onSubmitChecked: async ids => {
929
+ if (ids.length === 0) { this.notice("No criteria accepted; the goal was not set.", "warning"); return; }
930
+ await this.app.session.setGoal({ ...pending, accepted: ids });
931
+ this.notice(`Goal set with ${ids.length} accepted ${ids.length === 1 ? "check" : "checks"}.`, "success");
932
+ this.turn = this.runPromptTurn(objective).catch(error => this.notice(describeError(error), "error"));
933
+ } });
934
+ });
935
+ }
936
+
937
+ private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill, options?: RequestOptions): Promise<void> {
855
938
  this.abort = new AbortController();
856
939
  this.inflight = true;
857
940
  this.lastOutcome = undefined;
941
+ // Speed is per turn: only this turn's model requests count (feat-sidebar-goal-and-speed).
942
+ this.turnUsageStart = this.app.session.state.usage.length;
858
943
  this.programResult = false;
859
944
  this.program?.update(programStatus({ running: true }));
860
945
  this.executionKind = text.startsWith("!") ? "bash" : "model";
861
946
  this.paint();
862
947
  try {
863
- const sent = await this.hooks.run(this.app, text, this.abort.signal, this, images, skill);
948
+ const sent = await this.hooks.run(this.app, text, this.abort.signal, this, images, skill, options);
864
949
  if (!sent && !this.app.credential.ok) {
865
950
  this.programResult = true; this.program?.update(programStatus({ running: false, waiting: "auth" }));
866
951
  }
@@ -1109,7 +1194,7 @@ export class InteractiveApp implements Ui {
1109
1194
  const detachInput = control.attachInput(phase => this.pullQueued(phase));
1110
1195
  const unsubscribe = control.subscribe(notice => {
1111
1196
  if (this.disposed) return;
1112
- if (notice.method === "turn/started") { this.lastOutcome = undefined; this.program?.update(programStatus({ running: true })); }
1197
+ if (notice.method === "turn/started") { this.lastOutcome = undefined; this.turnUsageStart = this.app.session.state.usage.length; this.program?.update(programStatus({ running: true })); }
1113
1198
  if (notice.method === "turn/error") {
1114
1199
  this.lastOutcome = executionOutcome("execution_error");
1115
1200
  this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
@@ -1558,7 +1643,7 @@ export async function runInteractive(app: App, hooks: Hooks, releaseEarlyTty?: (
1558
1643
  await ui.closed;
1559
1644
  await ui.settle();
1560
1645
  } finally {
1561
- try { await app.session.dispose(); await app.extensions?.dispose(); await hooks.cancelPendingOAuth(app); }
1646
+ try { await closeAppSession(app); await hooks.cancelPendingOAuth(app); }
1562
1647
  finally {
1563
1648
  ui.dispose();
1564
1649
  if (!renderer) r.destroy();
@@ -1582,7 +1667,8 @@ async function printExit(app: App): Promise<void> {
1582
1667
  }
1583
1668
  }
1584
1669
  process.stdout.write(`\n ${history.ok ? sessionExitStatus(events) : "session saved · history unavailable; inspect before continuing"}\n`);
1585
- process.stdout.write(`\n resume with: xeno --session ${JSON.stringify(app.logDir)}\n`);
1670
+ const path = resolve(app.logDir);
1671
+ process.stdout.write(`\n resume with: ${resumeCommand({ id: basename(path), directory: dirname(path), path }, resolve(sessionsDir(process.cwd())))}\n`);
1586
1672
  }
1587
1673
 
1588
1674
  /** The session-start rule first, then the projection (refactor-tui-dock-layout DoD 7). */
@@ -1591,6 +1677,12 @@ function projectWithStart(items: ReturnType<typeof projectItems>, clock: string
1591
1677
  }
1592
1678
 
1593
1679
  /** "~/Projects/workspace/xeno-tui" → "~/P/w/xeno-tui": every directory but the last cut to its first letter. */
1680
+ /** The turn's speed when one can be measured; nothing otherwise, so the row never shows a made-up figure. */
1681
+ function speedOf(samples: readonly import("../../runtime/usage.ts").UsageSample[]): { speed?: number } {
1682
+ const speed = generationSpeed(samples);
1683
+ return speed === undefined ? {} : { speed };
1684
+ }
1685
+
1594
1686
  export function shortPath(path: string): string {
1595
1687
  const parts = path.split("/");
1596
1688
  return parts.map((part, i) => i === parts.length - 1 || part === "~" || part === "" ? part : part.startsWith(".") ? part.slice(0, 2) : part.slice(0, 1)).join("/");