@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/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # xeno changelog
2
2
 
3
+ ## 0.8.1 — 2026-10-11
4
+
5
+ The sidebar now follows your goal:
6
+ - The Goal block appears only for `/goal`. It lists every accepted check — ✓ passed, ✗ failed with its exit code, ○ not yet — with `guard` or `/accept <id>` beside it, then the evidence ladder and what the goal has used: requests, and tokens in this run.
7
+ - The heading reads working, needs you, met or stopped, with how long the goal has been running.
8
+ - A met goal stays as one line until your next message.
9
+ - A request without a goal shows nothing, or one `Checks` line once it has changed files: not run since the last edit, or the latest check with ✓ or ✗ and its exit code. A declared acceptance contract shows how many of its criteria hold.
10
+ - While a goal's criteria are being drafted the sidebar shows `Goal · drafting` and your objective, not the drafting instructions.
11
+ - Without a sidebar (narrower than 110 columns) the goal sits on the input box's lower edge: `goal 2/3 · needs you · 4m 12s`.
12
+ - Context and Usage fit in five rows instead of up to eight. Both budgets keep their names and shares. The node number, the observation and retry counts, the tool count when nothing failed and the `/control` hint moved out; they remain in `/control` and `/state`.
13
+ - Generation speed: Usage and the input box show tokens per second for the current turn, measured as OpenCode does: output over the time its model requests took. Reasoning that a provider does not stream is never left out of the time, so the figure does not overstate. Requests shorter than 250 ms are not counted.
14
+ - Running time reads `59s`, `1m 05s`, `1h 02m 05s` instead of `3725s`, and so does an update's progress against its limit.
15
+
16
+ Fixes:
17
+ - A gateway that answers "about to call a tool" with no tool call no longer ends the request silently. The response is retried automatically; once retries run out it is reported as a provider error.
18
+ - The Anthropic SDK path now sends `xeno/<version>` as its User-Agent, as the other paths do, so gateways attribute its traffic to xeno instead of a custom client.
19
+
20
+ ## 0.8.0 — 2026-10-10
21
+
22
+ xeno now proves completion with receipts, and proves the receipts test the change. Two changes need attention when upgrading:
23
+ - Sessions saved by 0.7.x and earlier no longer open. They are left on disk unmodified; start a new session with `--fresh`.
24
+ - A message no longer stops after 40 model requests by default. The stall watchdog ends a run that stops producing new evidence; pass `--max-turns N` for a hard cap.
25
+
26
+ Verification-first (research doc appendix S):
27
+ - `/goal <objective>` sets an explicit goal.
28
+ - The model drafts acceptance criteria in one read-only request: it may read the workspace, but cannot change it or run commands. You accept the criteria one by one.
29
+ - A draft that does not parse goes back to the model once with the reasons. A goal that cannot be checked here is reported with the model's reason, and no goal is set.
30
+ - Work continues until every accepted check has a fresh passing receipt.
31
+ - A check that fails the same way twice against an unchanged workspace is reported as possibly broken instead of being rerun. An accepted check is never rewritten.
32
+ - A reported blocker, a spent budget, the stall watchdog, or two goal checks answered without acting stop the loop and leave the goal active.
33
+ - `/goal` shows its status and `/goal clear` ends it.
34
+ - Passing goal checks are counter-checked. Each one is re-run in a copy-on-write clone with the request's changes undone, including changes made by shell commands; the undo comes from a snapshot taken when the goal request starts. A check that still passes is named as not testing the change.
35
+ - Guards: a command criterion with `"guard": true` protects what the goal must not break, such as "the tests are unchanged". It must pass before and after the work, and the counter-check expects it to pass without the changes too.
36
+ - Drafts propose guards, and the checklist and `/goal` mark them.
37
+ - A guard that held no longer counts as a check that does not test the change.
38
+ - `--blind-checks <dir>` adds hidden checks from outside the workspace. They run on a clone of the final result after the goal's checks pass, and a pass the model could have seen is reported as exposed.
39
+ - Every request now shows its evidence ladder in the sidebar and `/control`: ran, tests the change, independent, human accepted.
40
+ - Without a contract, a request now reports `not_applicable`, `unchecked`, or `checked` after its last change, instead of a constant `unverified`.
41
+ - A blocker reported by the model is listed but no longer changes the status or skips the completion check.
42
+ - A failed `/resume` no longer leaves the current session's integrations closed.
43
+ - xeno's state directory ignores itself, so `git status` and scope checks no longer see session files.
44
+
45
+ From a session review (research doc appendix S.6):
46
+ - There is no request cap per message by default, as in Claude Code, Codex, Gemini CLI and OpenCode. The stall watchdog stops a run after 12 exchanges without new evidence. `--max-turns N` and agent budgets still set a hard cap with a reserved final answer.
47
+ - A command that exits nonzero but prints something new counts as progress. An exchange that repeats the previous one exactly earns nothing from output that only changes with timing.
48
+ - A read, query or lsp call under a newly found subdirectory AGENTS.md runs, and the rules come with its result instead of costing a retry. Changes are still held back until the rules have been read. A batch of calls gets each file once.
49
+ - grep and find skip git-ignored files inside a Git work tree and are no longer cut off after the first 20,000 files. A path that names an ignored directory is still searched.
50
+ - A read too large for one result stops at a line boundary and names the next line. It no longer hides the middle while claiming the end of the file.
51
+ - An out-of-range `yield_time_ms` is clamped to 100–30000 ms instead of refusing the command, and the receipt names the wait actually taken.
52
+ - Exiting prints `resume with: xeno --resume <id>` instead of the session's full path when that id resolves from the current directory.
53
+
54
+ Breaking:
55
+ - Sessions written by earlier versions are refused with their location and are left unmodified. Start a new session with `--fresh`.
56
+ - Full-snapshot workspace tracking is gone; journaled checkpoints are the only file history.
57
+
58
+ Maintenance:
59
+ - Evaluation code moved to `eval/` and out of the npm package (351 → 322 files) and the default gate.
60
+ - The version now lives only in `package.json`.
61
+ - Eight settings that nothing applied were removed.
62
+
3
63
  ## 0.7.2 — 2026-10-10
4
64
 
5
65
  - `/update` now installs the newer release when xeno was installed with bun or npm, instead of only printing the command. The status line shows the target version, elapsed time against a 3-minute limit and the package manager's latest output; success asks for a restart, and a failure gives the reason and the command to run by hand. Automatic checks still never install, and `xeno update --check` still only checks.
package/README.md CHANGED
@@ -17,8 +17,8 @@ is ever summarized by a model, and nothing is ever rewritten in place.
17
17
  messages[] frozen system prefix + handoff + this node's events
18
18
  ```
19
19
 
20
- Version **0.7.2**. The on-disk session format will still change; `0.x` is that
21
- statement. Published as `@visual-z/xeno`; the license is `UNLICENSED` (readable and installable, no reuse granted).
20
+ The on-disk session format will still change; `0.x` is that statement, and
21
+ `xeno --version` reports the installed release. Published as `@visual-z/xeno`; the license is `UNLICENSED` (readable and installable, no reuse granted).
22
22
 
23
23
  ## Install
24
24
 
@@ -72,7 +72,7 @@ checks are reported separately from model accuracy.
72
72
  The explicit-policy follow-up separates segmented, continuous and adaptive context
73
73
  views. Its budget-limited result and negative cases are recorded in the same report;
74
74
  neither segmentation nor the experimental adaptive rule has established a quality advantage.
75
- `bun run scripts/compare-harness.ts smoke --output /absolute/new/smoke-directory`
75
+ `bun run eval/scripts/compare-harness.ts smoke --output /absolute/new/smoke-directory`
76
76
  also exercises xeno-windowed, xeno-continuous and an installed Pi CLI with shared
77
77
  confined tools, without paid model calls. The comparison Pi CLI runs on Node through
78
78
  an isolated local tool bridge; xeno's providers use the pinned Pi AI runtime described below.
@@ -85,8 +85,8 @@ This checks a controlled configuration, not stock Pi or an official leaderboard.
85
85
  The new single-factor protocol separately compares tool profiles, the carried
86
86
  working set, and strict verification. It runs four isolated scenarios through
87
87
  real sessions and grades files, fresh command evidence, and pending human review.
88
- Use `bun scripts/eval-harness.ts smoke --output /absolute/new/eval-directory`,
89
- then `bun scripts/eval-harness.ts audit --output /absolute/new/eval-directory`.
88
+ Use `bun eval/scripts/eval-harness.ts smoke --output /absolute/new/eval-directory`,
89
+ then `bun eval/scripts/eval-harness.ts audit --output /absolute/new/eval-directory`.
90
90
  Smoke has no paid requests and reports synthetic usage. `plan` and `run` require
91
91
  the same explicit numeric `--budget`; inputs, source, schemas, model settings,
92
92
  and paired order are frozen before spending. Started directories cannot be
@@ -123,11 +123,34 @@ xeno [-m <model>] [--provider <name>] [--api-key <key>]
123
123
  verification state and diagnostic progress across segment cuts and restarts.
124
124
  Short tasks need no `note` calls to associate evidence. Without a declared
125
125
  acceptance contract, successful commands remain evidence rather than verified
126
- task completion. Diagnostic novelty does not change the stall policy.
126
+ task completion. A command's new output counts as progress for the stall
127
+ watchdog whatever its exit code; an exchange that repeats the previous one
128
+ exactly earns none from output that only changes with timing.
129
+ - `/goal <objective>` sets an explicit goal:
130
+ - The model first drafts acceptance criteria in a single read-only request: it may read the workspace (read, find, grep) but cannot change it or run commands. Each criterion is a command that should fail until the goal is done, a human check, or a guard.
131
+ - A guard (`"guard": true` on a command) protects what the goal must not break, such as "the test file is unchanged" or "only plan.md changed". It passes before the work and must still pass after it. Guards are marked in the checklist.
132
+ - A draft that does not parse goes back to the model once with the reasons. If the model says the goal cannot be checked in this workspace, it gives the reason and no goal is set.
133
+ - You accept the criteria one by one: a checklist in the TUI, or `/goal accept all|1,3` in line mode.
134
+ - Work then starts from the objective and continues automatically until every accepted criterion has a fresh passing receipt (and human criteria have your `/accept`).
135
+ - It stops early, with the goal still active, when the model reports a blocker, answers two goal checks without acting, a `--max-turns` budget runs out, or the stall watchdog fires.
136
+ - `/goal` shows the goal and the status of each criterion; `/goal clear` ends it.
137
+ - "Met" comes from receipts and your decisions, never from the model saying it is done.
138
+ - When the accepted checks pass, each one is counter-checked: it is re-run in a disposable copy-on-write clone of the workspace with this request's file changes undone.
139
+ - It reports whether the check fails without the changes (it tests them) or still passes (it does not).
140
+ - A guard is expected to pass there. If it does, it "held" and does not count against the evidence ladder. If it fails there, it "did not hold": the check actually tests the change, and it counts as such.
141
+ - If every check is a guard that held, the ladder says that no check tests the change.
142
+ - The undo comes from a snapshot taken when the goal request starts (a private Git directory and index; your HEAD, index and stash are untouched), so changes made by shell commands are undone too.
143
+ - If the starting state cannot be rebuilt exactly, the result is "unavailable", for example outside a Git work tree when a shell command wrote files that no checkpoint recorded.
144
+ - The real workspace is never touched, and the counter-check never blocks the goal.
145
+ - `--blind-checks <dir>` adds hidden checks: a directory outside the workspace holding `blind.json` (`{"version":1,"checks":[{"id","description","command"}]}`) and any fixtures.
146
+ - The checks are frozen when the session starts and never shown to the model.
147
+ - When the goal's checks pass, they run against a clone of the final workspace, with the directory copied in as `.xeno-blind/`.
148
+ - The whole log is scanned first, and a pass after the model named the directory or read one of its files is reported as `exposed`, not independent.
127
149
  - `/acceptance` shows declared command checks and human criteria. `/accept ID`
128
150
  records your acceptance of a human criterion; subsequent edits invalidate it.
129
151
  Use `--acceptance-file` or a trusted project's `.xeno/acceptance.json` with
130
152
  `{ "version": 1, "id": "task", "criteria": [{ "id": "tests", "kind": "command", "description": "tests pass", "command": "bun test" }] }`.
153
+ Add `"guard": true` to a command criterion that must keep passing rather than one that shows the work was done.
131
154
  The declaration is frozen in the session. A normal execution exit does not
132
155
  imply that human acceptance has been granted.
133
156
  A contract may additionally declare `targets`, each with `id`, `build`
@@ -372,7 +395,9 @@ Shipped, not planned:
372
395
  `branch`, `resolve`, `grep`, `find`, `ls`, `history`, `query`. `grep` / `find` use `rg` /
373
396
  `fd` when those binaries are on `PATH`, otherwise a TypeScript walk;
374
397
  both paths share one output format. They do not follow symlinks out of
375
- the workspace.
398
+ the workspace. Inside a Git work tree they skip git-ignored files, as
399
+ Claude Code, OpenCode and Pi do; a path that names an ignored directory is
400
+ still searched.
376
401
  - `history` searches the active ancestry, retrieves original events, and pages
377
402
  immutable full observations. `query` filters and computes aggregates over
378
403
  complete CSV, JSON, JSONL or text files, with source hashes. It evaluates a
@@ -506,7 +531,9 @@ An empty `write_stdin` in an agent session waits for new output, command exit,
506
531
  hard timeout, cancellation or user steering. The process owner holds that wait;
507
532
  quiet commands do not consume model requests through periodic polling.
508
533
  Sending input and low-level process polling keep their bounded yield contract.
509
- For `--max-turns N`, budgets above one reserve the last request for a text answer.
534
+ There is no request cap by default, as in Claude Code, Codex, Gemini CLI and
535
+ OpenCode: the stall watchdog stops a run after 12 exchanges without new
536
+ evidence. For `--max-turns N`, budgets above one reserve the last request for a text answer.
510
537
  Tools stay frozen and are disabled for that request, with host enforcement.
511
538
  Unsupported protocols or a failed final response use an evidence-based stop
512
539
  report inside the same budget. `/usage` labels provider durations as model time.
@@ -632,7 +659,7 @@ diagnostic and 100 consecutive derivations with retained state. The cost formula
632
659
  models input caching at the stated rates; it omits task quality and cannot prove
633
660
  30k/40k is globally optimal. These legacy bounds retain their 5k work floor.
634
661
 
635
- `scripts/ab-suite.ts` now separates node, replay, round-preserving truncation and
662
+ `eval/scripts/ab-suite.ts` now separates node, replay, round-preserving truncation and
636
663
  observation masking. It uses a seeded shuffled schedule and includes output cost
637
664
  and failed outcomes. Historical A/B figures predate these corrections and cannot
638
665
  establish superiority over the corrected baselines. No new live-model result is
@@ -71,7 +71,7 @@ apply_patch 参数为 `{patchText,expected_sha256?}`,语法使用 `*** Begin P
71
71
 
72
72
  format 参数为 `{paths:[...]}`,只执行配置中的 literal argv;`$FILE` 替换为绝对路径。每条命令经真实拥有者、审批、退出码与检查点执行,不会在 edit 后暗中格式化。读到新的文件版本后再生成后续编辑。
73
73
 
74
- 启动从 Git 根到 cwd 加载目录规范。首次碰到目标文件的更深层 AGENTS.md / AGENTS.override.md 时,工具先返回带来源和 scope 的指令回执,原操作未执行;模型读到后可显式重试。回执追加到原始日志并按内容指纹去重,保留冻结前缀和旧事件。
74
+ 启动从 Git 根到 cwd 加载目录规范。首次碰到目标文件的更深层 AGENTS.md / AGENTS.override.md 时:read / query / lsp 照常执行,规范(带来源和 scope)附在同一个结果之后;write / edit / apply_patch / format 先返回指令回执,原操作未执行,模型读到后可显式重试。同一批调用里一份规范只下发一次,后到的修改只指向先附上规范的那次调用。规范追加到原始日志并按内容指纹去重,保留冻结前缀和旧事件。
75
75
 
76
76
  ## Diff、Review 与 LSP
77
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@visual-z/xeno",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "Segmented append-only agent log with pure context and handoff projections",
@@ -32,6 +32,8 @@
32
32
  "typecheck": "bun run scripts/typecheck.ts",
33
33
  "check": "bun run scripts/check-boundaries.ts",
34
34
  "gate": "bun test && bun run typecheck && bun run scripts/check-boundaries.ts",
35
+ "eval:test": "bun test ./eval/test",
36
+ "gate:full": "bun run gate && bun run eval:test",
35
37
  "verify-red": "bun run scripts/verify-red.ts",
36
38
  "regen-golden": "bun run scripts/regen-golden.ts",
37
39
  "measure": "bun run scripts/measure-handoff.ts",
package/src/apps/cli.ts CHANGED
@@ -24,6 +24,8 @@ import { sessionControl } from "../runtime/session-control.ts";
24
24
  import { listenControl } from "./control-transport.ts";
25
25
  import { runAppServer } from "./app-server.ts";
26
26
  import { runManualUpdate } from "./update-check.ts";
27
+ import { XENO_VERSION } from "../runtime/version.ts";
28
+ import { closeAppSession } from "./session-lifecycle.ts";
27
29
  import { runAcpServer } from "./acp-server.ts";
28
30
  import { runReview, reviewScopeFromText } from "./review.ts";
29
31
 
@@ -42,8 +44,7 @@ try {
42
44
  printCliHelp();
43
45
  } else if (args.version) {
44
46
  if (args.extensionArgv) throw new Error("Unknown flags for --version");
45
- const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json() as { version: string };
46
- console.log(pkg.version);
47
+ console.log(XENO_VERSION);
47
48
  } else if (args.exportFile) {
48
49
  if (args.extensionArgv) throw new Error("Unknown flags for offline export");
49
50
  console.log(`Exported ${await exportHtmlFile(args.exportFile, args.prompt || undefined, args.workspace)}`);
@@ -70,7 +71,7 @@ try {
70
71
  } else if (reviewMode) {
71
72
  input.restore();
72
73
  try { await runReview(app, reviewScopeFromText(args.prompt)); }
73
- finally { await app.session.dispose(); await app.extensions?.dispose(); }
74
+ finally { await closeAppSession(app); }
74
75
  } else {
75
76
  if (args.print) {
76
77
  // With a prompt already given, stdin is optional context: wait briefly for it.
@@ -31,6 +31,8 @@ export type Command =
31
31
  | { kind: "branches"; action?: "wait" | "cancel" | "status"; node?: number }
32
32
  | { kind: "processes" }
33
33
  | { kind: "accept"; criterion: string }
34
+ | { kind: "goal"; action: "status" } | { kind: "goal"; action: "clear" } | { kind: "goal"; action: "accept"; pick: string }
35
+ | { kind: "goal"; action: "set"; objective: string }
34
36
  | { kind: "model"; pattern?: string }
35
37
  | { kind: "scoped-models" }
36
38
  | { kind: "login"; provider?: string }
@@ -85,6 +87,7 @@ export const COMMANDS = [
85
87
  { name: "branches", description: "Inspect or join owned background work", argumentHint: "[wait|cancel|status node]", busy: "idle" },
86
88
  { name: "processes", description: "Show process observations and available handles", busy: "run" },
87
89
  { name: "accept", description: "Record your acceptance of a human criterion", argumentHint: "<criterion ID>", busy: "idle" },
90
+ { name: "goal", description: "Set a goal: the model drafts checks, you accept them, work continues until they pass", argumentHint: "<objective> | clear | accept all|1,3", busy: "idle" },
88
91
  { name: "export", description: "Export HTML or JSONL", argumentHint: "[file]", busy: "idle" },
89
92
  { name: "import", description: "Import a portable JSONL session", argumentHint: "<file>", busy: "idle" },
90
93
  { name: "debug", description: "Write local terminal diagnostics", argumentHint: "[file]", busy: "run" },
@@ -213,6 +216,11 @@ export function parseCommand(input: string, catalog?: SlashCatalog): Command {
213
216
  ? { kind: "branches", action, node } : { kind: "unknown", input: text };
214
217
  }
215
218
  case "/accept": return { kind: "accept", criterion: arg };
219
+ case "/goal":
220
+ if (!arg) return { kind: "goal", action: "status" };
221
+ if (arg === "clear") return { kind: "goal", action: "clear" };
222
+ if (rest[0] === "accept") return { kind: "goal", action: "accept", pick: rest.slice(1).join(" ") };
223
+ return { kind: "goal", action: "set", objective: arg };
216
224
  case "/model":
217
225
  return arg.length > 0 ? { kind: "model", pattern: arg } : { kind: "model" };
218
226
  case "/provider":
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The drafting half of /goal shared by the line shell and the TUI (S.2 / S.5):
3
+ * one read-only request, then the reply parsed into candidate criteria.
4
+ * Nothing here decides what is accepted; that is the user's pick.
5
+ */
6
+ import { draftReply, GOAL_DRAFT_RETRIES, goalDraftRequest, goalDraftRetry, parseGoalDraft } from "../core/goal.ts";
7
+ import type { AcceptanceCriterion } from "../core/acceptance.ts";
8
+ import type { RequestOptions } from "../runtime/loop.ts";
9
+ import type { App } from "./startup.ts";
10
+
11
+ export type PendingGoal = { objective: string; draft: string; criteria: AcceptanceCriterion[] };
12
+
13
+ /**
14
+ * Runs the draft request and reads the candidates from its reply; null when
15
+ * there is nothing to accept. `runDraft` answers false for a draft that was
16
+ * not sent or was cancelled. A draft that does not validate goes back to the
17
+ * model once with the parser's reasons (GOAL_DRAFT_RETRIES); a reported
18
+ * blocker is an answer and does not.
19
+ */
20
+ export async function draftGoal(app: App, objective: string,
21
+ runDraft: (prompt: string, options: RequestOptions) => Promise<boolean>): Promise<PendingGoal | null> {
22
+ const C = app.io.palette;
23
+ let prompt = goalDraftRequest(objective);
24
+ for (let attempt = 0; ; attempt++) {
25
+ const mark = app.session.state.events.at(-1)?.id;
26
+ if (!await runDraft(prompt, { readOnly: true })) return null;
27
+ const events = app.session.state.events;
28
+ // Only what this run recorded. A segment cut meanwhile removes the mark, and then the whole segment is new.
29
+ const reply = draftReply(events.slice(events.findIndex(event => event.id === mark) + 1));
30
+ if (!reply) { app.io.say(C.yellow(" the goal draft produced no reply; the goal was not set")); return null; }
31
+ const parsed = parseGoalDraft(reply.text);
32
+ for (const error of parsed.errors) app.io.say(C.yellow(` draft ${error}`));
33
+ if (parsed.errors.length > 0 && !parsed.blocked && attempt < GOAL_DRAFT_RETRIES) {
34
+ app.io.say(C.dim(" the draft goes back to the model once to be corrected"));
35
+ prompt = goalDraftRetry(parsed.errors);
36
+ continue;
37
+ }
38
+ if (parsed.criteria.length === 0) {
39
+ app.io.say(C.yellow(parsed.blocked ? ` the model says this goal cannot be checked here: ${parsed.blocked}. The goal was not set`
40
+ : " the draft has no usable criteria; the goal was not set. Run /goal again or rephrase the objective"));
41
+ return null;
42
+ }
43
+ return { objective, draft: reply.id, criteria: parsed.criteria };
44
+ }
45
+ }
46
+
47
+ /**
48
+ * One drafted criterion as the user decides on it, the same in the line shell
49
+ * and the TUI checklist: what runs, and whether it is a guard -- a check that
50
+ * must keep passing rather than one that shows the work was done (S.7).
51
+ */
52
+ export function criterionSummary(item: AcceptanceCriterion): string {
53
+ return item.kind === "human" ? `human · ${item.description}` : `${item.guard ? "guard · " : ""}${item.command} · ${item.description}`;
54
+ }
55
+
56
+ /** `all`, or 1-based positions such as `1,3`. Unknown positions are refused, never skipped. */
57
+ export function pickCriteria(criteria: readonly AcceptanceCriterion[], pick: string): { ok: true; ids: string[] } | { ok: false; error: string } {
58
+ const text = pick.trim();
59
+ if (text === "all") return { ok: true, ids: criteria.map(item => item.id) };
60
+ const positions = text.split(/[\s,]+/).filter(Boolean);
61
+ if (positions.length === 0) return { ok: false, error: "name the criteria to accept: /goal accept all, or /goal accept 1,3" };
62
+ const ids: string[] = [];
63
+ for (const position of positions) {
64
+ const index = Number(position);
65
+ if (!Number.isSafeInteger(index) || index < 1 || index > criteria.length) return { ok: false, error: `no drafted criterion ${position}` };
66
+ const id = criteria[index - 1]!.id;
67
+ if (!ids.includes(id)) ids.push(id);
68
+ }
69
+ return { ok: true, ids };
70
+ }
@@ -170,8 +170,6 @@ export function formatEvent(
170
170
  case "turn_start":
171
171
  case "branch_start":
172
172
  case "branch_done":
173
- case "snapshot_failed":
174
- case "undeclared_change":
175
173
  case "text_delta":
176
174
  case "thinking_delta":
177
175
  case "discard":
@@ -6,6 +6,7 @@ import { readCursor, writeCursor } from "../store/log.ts";
6
6
  import { SessionHub } from "../runtime/session-hub.ts";
7
7
  import { sessionControl, type SessionControl } from "../runtime/session-control.ts";
8
8
  import { openSessionFor, sessionDirFor, type App } from "./startup.ts";
9
+ import { appSessionFields, closeAppSession } from "./session-lifecycle.ts";
9
10
  import { interactiveHooks } from "./shell.ts";
10
11
  import { parseMcpServers } from "../core/mcp-config.ts";
11
12
  import { reloadAppResources } from "./resources.ts";
@@ -68,9 +69,8 @@ export function createAppSessionHub(initial: App): SessionHub {
68
69
  askSlot: {}, catalog: initial.catalog, auth: initial.auth, credentialOf: initial.credentialOf, effects: initial.effects,
69
70
  interactive: false, projectTrusted: initial.projectTrusted === true, settings: initial.ui?.settings ?? {},
70
71
  fallback: initial.providerConfig });
71
- const app: App = { ...initial, args, ...opened, loadedContext: opened.resources.loadedContext, ui: opened.resources.ui,
72
- slash: opened.resources.slash, extensions: opened.resources.extensions, resources: opened.resources.index,
73
- foundation: opened.resources.foundation, projectTrusted: opened.resources.trusted, askSlot: opened.session.config.tools.askSlot ?? {} };
72
+ const app: App = { ...initial, args, ...appSessionFields(opened), logDir, askSlot: opened.session.config.tools.askSlot ?? {} };
73
+ if (!opened.resources.foundation) delete app.foundation;
74
74
  const control: SessionControl = sessionControl(app.session);
75
75
  try {
76
76
  app.askSlot.current = request => control.ask(request);
@@ -79,7 +79,7 @@ export function createAppSessionHub(initial: App): SessionHub {
79
79
  apps.set(control.threadId, app);
80
80
  return control;
81
81
  } catch (error) {
82
- await app.session.dispose().catch(() => {}); await app.extensions?.dispose().catch(() => {}); throw error;
82
+ await closeAppSession(app).catch(() => {}); throw error;
83
83
  }
84
84
  };
85
85
  const result = construct();
@@ -89,7 +89,8 @@ export function createAppSessionHub(initial: App): SessionHub {
89
89
  },
90
90
  dispose: async control => {
91
91
  const app = apps.get(control.threadId);
92
- await control.session.dispose(); await app?.extensions?.dispose(); apps.delete(control.threadId);
92
+ if (app) await closeAppSession(app); else await control.session.dispose();
93
+ apps.delete(control.threadId);
93
94
  },
94
95
  });
95
96
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The one place an App gains, swaps and drops its session.
3
+ *
4
+ * Startup, the line and full-screen shells, and the app-server hub each used
5
+ * to carry their own copy of this: the field mapping from an opened session,
6
+ * the paired session/extension shutdown, and the swap. The copies drifted --
7
+ * resource reload restored released integrations on failure while a session
8
+ * switch did not, so a refused /resume left the current session's services
9
+ * closed (refactor-session-lifecycle).
10
+ */
11
+ import type { App, openSessionFor } from "./startup.ts";
12
+
13
+ type Opened = Awaited<ReturnType<typeof openSessionFor>>;
14
+ type SessionFields = Pick<App, "session" | "providerConfig" | "credential" | "workspace" | "blobDir" | "system" | "loadedContext" |
15
+ "slash" | "ui" | "resources" | "projectTrusted" | "extensions" | "foundation">;
16
+
17
+ /** Everything an App holds that belongs to its current session, from one opened session. */
18
+ export function appSessionFields(opened: Opened): SessionFields {
19
+ return {
20
+ session: opened.session, providerConfig: opened.providerConfig, credential: opened.credential,
21
+ workspace: opened.workspace, blobDir: opened.blobDir, system: opened.system,
22
+ loadedContext: opened.resources.loadedContext, slash: opened.resources.slash, ui: opened.resources.ui,
23
+ resources: opened.resources.index, projectTrusted: opened.resources.trusted,
24
+ extensions: opened.resources.extensions,
25
+ ...(opened.resources.foundation ? { foundation: opened.resources.foundation } : {}),
26
+ };
27
+ }
28
+
29
+ /** Session first: its last events may still reach extension hooks. */
30
+ export async function closeAppSession(app: Pick<App, "session" | "extensions">): Promise<void> {
31
+ await app.session.dispose();
32
+ await app.extensions?.dispose();
33
+ }
34
+
35
+ /**
36
+ * Moves an App to the session at `logDir`, or leaves it exactly as it was.
37
+ *
38
+ * The new session opens before the current one closes, so a refused or broken
39
+ * target never strands the user without a session. Integrations are released
40
+ * first because a new generation must not overlap an unresolved callback of
41
+ * the current one (Q.13); every failure after that restores them, the same
42
+ * rule reloadAppResources follows.
43
+ */
44
+ export async function switchAppSession(app: App, logDir: string, open: (app: App, logDir: string) => Promise<Opened>): Promise<void> {
45
+ app.session.cancel();
46
+ await app.session.whenIdle();
47
+ const extensions = app.extensions;
48
+ if (extensions && !await extensions.releaseIntegrations()) {
49
+ extensions.restoreIntegrations();
50
+ throw Error("An integration still owns unfinished work or unconfirmed cleanup. Wait for it to settle before switching sessions");
51
+ }
52
+ let opened: Opened;
53
+ try { opened = await open(app, logDir); }
54
+ catch (error) { extensions?.restoreIntegrations(); throw error; }
55
+ try { await closeAppSession(app); }
56
+ catch (error) {
57
+ // The current session could not close cleanly; do not also strand the new one.
58
+ await closeAppSession({ session: opened.session, extensions: opened.resources.extensions }).catch(() => {});
59
+ extensions?.restoreIntegrations();
60
+ throw error;
61
+ }
62
+ Object.assign(app, appSessionFields(opened), { logDir });
63
+ if (!opened.resources.foundation) delete app.foundation;
64
+ }