@cruxy/cli 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +20 -1
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +171 -69
  7. package/dist/agent/status.js +56 -0
  8. package/dist/approval/classify.js +204 -0
  9. package/dist/approval/policy.js +41 -3
  10. package/dist/approval/prompt.js +49 -22
  11. package/dist/checkpoint/gate.js +12 -0
  12. package/dist/cli/commands/run.js +401 -227
  13. package/dist/cli/commands/usage.js +45 -45
  14. package/dist/cli/onboard.js +2 -1
  15. package/dist/cli/program.js +60 -18
  16. package/dist/cli/repl.js +67 -249
  17. package/dist/cli/session-commands.js +717 -0
  18. package/dist/cli/session-factory.js +198 -76
  19. package/dist/cli/suggest.js +77 -0
  20. package/dist/components/fuzzy.js +3 -3
  21. package/dist/components/input.js +17 -2
  22. package/dist/components/keys.js +65 -3
  23. package/dist/components/select.js +3 -3
  24. package/dist/config/effective.js +225 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.js +50 -20
  27. package/dist/config/project.js +53 -1
  28. package/dist/config/schema.js +49 -16
  29. package/dist/jobs/log-renderer.js +47 -0
  30. package/dist/onboarding/steps.js +13 -22
  31. package/dist/plan/approve.js +36 -24
  32. package/dist/plan/execute.js +9 -7
  33. package/dist/plan/render.js +10 -23
  34. package/dist/plan/service.js +4 -1
  35. package/dist/render/capabilities.js +30 -1
  36. package/dist/render/context-view.js +106 -0
  37. package/dist/render/diff.js +204 -12
  38. package/dist/render/index.js +31 -5
  39. package/dist/render/plain-renderer.js +38 -2
  40. package/dist/render/plan-view.js +108 -0
  41. package/dist/render/resize.js +7 -2
  42. package/dist/render/status-view.js +66 -0
  43. package/dist/render/test-view.js +89 -0
  44. package/dist/render/tty-renderer.js +40 -0
  45. package/dist/routing/index.js +1 -0
  46. package/dist/routing/router.js +13 -4
  47. package/dist/routing/session-model.js +109 -0
  48. package/dist/routing/types.js +14 -0
  49. package/dist/session/export.js +88 -0
  50. package/dist/session/index.js +20 -0
  51. package/dist/session/list.js +137 -0
  52. package/dist/session/log.js +137 -0
  53. package/dist/session/paths.js +73 -0
  54. package/dist/session/replay.js +169 -0
  55. package/dist/session/resume.js +128 -0
  56. package/dist/session/types.js +223 -0
  57. package/dist/subagent/orchestrator.js +23 -0
  58. package/dist/testing/run-tests-tool.js +8 -0
  59. package/dist/tools/registry.js +3 -3
  60. package/dist/tui/app.js +508 -0
  61. package/dist/tui/approval-overlay.js +160 -0
  62. package/dist/tui/context-gauge.js +48 -0
  63. package/dist/tui/git-status.js +108 -0
  64. package/dist/tui/git-view.js +121 -0
  65. package/dist/tui/index.js +15 -0
  66. package/dist/tui/layout.js +314 -0
  67. package/dist/tui/overlay.js +105 -0
  68. package/dist/tui/overview.js +49 -0
  69. package/dist/tui/palette.js +73 -0
  70. package/dist/tui/panels.js +235 -0
  71. package/dist/tui/renderer.js +1121 -0
  72. package/dist/tui/settings-view.js +282 -0
  73. package/dist/tui/supports.js +20 -0
  74. package/dist/tui/tasks-view.js +215 -0
  75. package/dist/tui/tool-versions.js +129 -0
  76. package/dist/tui/views.js +66 -0
  77. package/dist/usage/collect.js +6 -6
  78. package/dist/usage/index.js +10 -2
  79. package/dist/usage/report.js +76 -0
  80. package/dist/usage/summary.js +106 -17
  81. package/dist/usage/types.js +5 -2
  82. package/dist/usage/weighted.js +77 -0
  83. package/dist/utils/git.js +163 -4
  84. package/package.json +1 -1
  85. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,178 @@
1
+ import { COMPACTION_MARKER } from "./prompts.js";
2
+ /** Characters a single content block contributes to the estimate. */
3
+ function blockChars(block) {
4
+ switch (block.type) {
5
+ case "text":
6
+ return block.text.length;
7
+ case "tool_use":
8
+ return block.name.length + JSON.stringify(block.input).length;
9
+ case "tool_result":
10
+ return block.content.length;
11
+ }
12
+ }
13
+ /** Characters one message contributes. Block structure and role labels are ignored. */
14
+ export function messageChars(message) {
15
+ if (typeof message.content === "string")
16
+ return message.content.length;
17
+ let chars = 0;
18
+ for (const block of message.content)
19
+ chars += blockChars(block);
20
+ return chars;
21
+ }
22
+ /** Chars → tokens, the one place the chars/4 heuristic is applied. */
23
+ export function charsToTokens(chars) {
24
+ return Math.ceil(chars / 4);
25
+ }
26
+ /**
27
+ * Estimate the token footprint of a message list with a cheap chars/4 heuristic
28
+ * — no tokenizer dependency. Good enough to decide *when* to compact; exact
29
+ * counts are deferred to a later phase. Counts only textual payload (block
30
+ * structure and role labels are negligible and ignored).
31
+ */
32
+ export function estimateTokens(messages) {
33
+ let chars = 0;
34
+ for (const message of messages)
35
+ chars += messageChars(message);
36
+ return charsToTokens(chars);
37
+ }
38
+ /** Compute a reading from a history — pure, and shared by the gauge and `/context`. */
39
+ export function readContext(messages, budget) {
40
+ // Deliberately the SAME expression `compactIfOverThreshold` branches on: the
41
+ // reserve covers the system prompt and tool schemas that `estimateTokens`
42
+ // never sees, and omitting it here would under-report by ~4.5k tokens and let
43
+ // the panel read "comfortable" while the seam was about to compact.
44
+ const used = estimateTokens(messages) + budget.reserveTokens;
45
+ const total = budget.maxTokens;
46
+ return {
47
+ used,
48
+ total,
49
+ fraction: total <= 0 ? 1 : Math.min(1, Math.max(0, used / total)),
50
+ compactAt: Math.round(budget.compactThreshold * total),
51
+ };
52
+ }
53
+ /**
54
+ * Choose the boundary between the summarized prefix and the kept-recent tail.
55
+ *
56
+ * Tool-call integrity is the constraint: a `tool_use` (assistant) and its
57
+ * matching `tool_result` (the next user message) must never straddle the cut,
58
+ * or the next provider call breaks. A real user *prompt* (`role:"user"` with
59
+ * string content) only occurs at a completed turn boundary, where every prior
60
+ * tool exchange is already resolved — so the kept region must begin there. The
61
+ * synthetic compaction-summary user message is also string content, so a repeat
62
+ * compaction always finds at least the previous summary as a clean cut.
63
+ *
64
+ * Start from `length - keepRecentMessages` and walk *backwards* to the nearest
65
+ * such prompt: this keeps at least the recent floor and lands clean. Returns
66
+ * the cut index, or `null` if no safe boundary leaves a non-empty prefix (e.g.
67
+ * a single long in-progress turn — nothing safe to compact).
68
+ *
69
+ * Extracted from `Session` (P6 track 3) so `/context` can report what compaction
70
+ * would do by calling the function that decides what it does. A second
71
+ * implementation for the explanation would be free to be subtly wrong exactly
72
+ * where it mattered.
73
+ */
74
+ export function findCut(messages, keepRecentMessages) {
75
+ const start = messages.length - keepRecentMessages;
76
+ for (let i = start; i >= 1; i--) {
77
+ const message = messages[i];
78
+ if (message.role === "user" && typeof message.content === "string")
79
+ return i;
80
+ }
81
+ return null;
82
+ }
83
+ /** Which part a message belongs to. */
84
+ function classify(message) {
85
+ if (typeof message.content === "string") {
86
+ // The synthetic pair a previous compaction spliced in. Worth its own bucket:
87
+ // "40% of your context is a summary of earlier context" is a different fact
88
+ // from "40% is your prompts", and it is the one that says compaction has
89
+ // already run.
90
+ return message.content.startsWith(COMPACTION_MARKER)
91
+ ? "summaries"
92
+ : "prompts";
93
+ }
94
+ if (message.role === "user")
95
+ return "tool results";
96
+ return message.content.some((b) => b.type === "tool_use")
97
+ ? "tool calls"
98
+ : "assistant";
99
+ }
100
+ /** A one-line excerpt of a message, for recognition rather than reading. */
101
+ function excerpt(message, max = 60) {
102
+ let text;
103
+ if (typeof message.content === "string") {
104
+ text = message.content;
105
+ }
106
+ else {
107
+ const first = message.content[0];
108
+ text =
109
+ first === undefined
110
+ ? ""
111
+ : first.type === "text"
112
+ ? first.text
113
+ : first.type === "tool_use"
114
+ ? first.name
115
+ : first.content;
116
+ }
117
+ const flat = text.replace(/\s+/g, " ").trim();
118
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
119
+ }
120
+ /**
121
+ * Analyse a history against a budget. Pure; no I/O, no theme, no rendering.
122
+ *
123
+ * @param topN how many individual messages to name as the largest contributors.
124
+ */
125
+ export function contextReport(messages, budget, topN = 5) {
126
+ const charsByPart = new Map();
127
+ const contributors = [];
128
+ for (const [index, message] of messages.entries()) {
129
+ const chars = messageChars(message);
130
+ const part = classify(message);
131
+ const bucket = charsByPart.get(part) ?? { chars: 0, messages: 0 };
132
+ bucket.chars += chars;
133
+ bucket.messages += 1;
134
+ charsByPart.set(part, bucket);
135
+ contributors.push({ chars, message, index });
136
+ }
137
+ const parts = [...charsByPart.entries()]
138
+ .map(([part, b]) => ({
139
+ part,
140
+ tokens: charsToTokens(b.chars),
141
+ messages: b.messages,
142
+ }))
143
+ .filter((p) => p.tokens > 0)
144
+ .sort((a, b) => b.tokens - a.tokens);
145
+ const largest = contributors
146
+ .filter((c) => c.chars > 0)
147
+ .sort((a, b) => b.chars - a.chars)
148
+ .slice(0, topN)
149
+ .map((c) => ({
150
+ label: classify(c.message),
151
+ excerpt: excerpt(c.message),
152
+ tokens: charsToTokens(c.chars),
153
+ position: c.index + 1,
154
+ }));
155
+ const cut = findCut(messages, budget.keepRecentMessages);
156
+ const reading = readContext(messages, budget);
157
+ return {
158
+ reading,
159
+ messages: messages.length,
160
+ reserveTokens: budget.reserveTokens,
161
+ parts,
162
+ largest,
163
+ compaction: {
164
+ cut,
165
+ droppedMessages: cut ?? 0,
166
+ droppedTokens: cut === null ? 0 : estimateTokens(messages.slice(0, cut)),
167
+ keptMessages: cut === null ? messages.length : messages.length - cut,
168
+ keptTokens: cut === null
169
+ ? estimateTokens(messages)
170
+ : estimateTokens(messages.slice(cut)),
171
+ // Compared against the UNROUNDED product, exactly as
172
+ // `compactIfOverThreshold` does — `compactAt` is rounded for display, and
173
+ // a report claiming to say what compaction would do must not disagree
174
+ // with the seam over a fraction of a token.
175
+ overThreshold: reading.used > budget.compactThreshold * budget.maxTokens,
176
+ },
177
+ };
178
+ }
@@ -1,3 +1,4 @@
1
1
  export * from "./loop.js";
2
+ export * from "./mode.js";
2
3
  export * from "./session.js";
3
4
  export * from "./prompts.js";
@@ -24,6 +24,11 @@ export async function runAgent(args) {
24
24
  // gateway does not offer throws CRUXY_E_ROUTING_TIER_UNAVAILABLE here — so a
25
25
  // misrouted run never reaches provider.stream (no request sent with the wrong
26
26
  // tier), and never silently falls back to a different one.
27
+ //
28
+ // Resolving per RUN, not per iteration, is still right after P6 track 1 made
29
+ // the choice mutable: a `/model` mid-turn is impossible (the input loop has
30
+ // released stdin and is awaiting `session.send`), and a run that changed model
31
+ // between its own iterations would attribute one history to two tiers.
27
32
  const routed = args.router
28
33
  ? resolveTaskModel(args.router, args.taskClass ?? "main-turn")
29
34
  : null;
@@ -38,7 +43,11 @@ export async function runAgent(args) {
38
43
  }
39
44
  }
40
45
  /** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
41
- async function driveLoop(args, renderer, routed) {
46
+ async function driveLoop(args, renderer,
47
+ // `tier` is optional because a router may decline and send the request as
48
+ // `auto` — no tier was chosen client-side, so there is none to report until
49
+ // the gateway's routing frame arrives. See `resolveTaskModel`.
50
+ routed) {
42
51
  const { provider, registry, config, ctx } = args;
43
52
  const { logger } = ctx;
44
53
  // Work on a copy so we never mutate the caller's array as a side effect; the
@@ -142,6 +151,16 @@ async function driveLoop(args, renderer, routed) {
142
151
  case "routing":
143
152
  servedTier = ev.routing.tier;
144
153
  routingMode = ev.routing.mode;
154
+ // Publish to the renderer HERE, not at the `setPhase` above. That one
155
+ // fires before `provider.stream` is called, so the served tier is not
156
+ // knowable yet and it can only carry `routed?.tier` — what this run
157
+ // ASKED for. This frame is the first moment the backend's answer
158
+ // exists, and it is the answer worth showing: it resolves `auto`, and
159
+ // it reflects a downgrade the client never chose.
160
+ renderer?.servedRouting({
161
+ tier: ev.routing.tier,
162
+ ...(ev.routing.mode !== undefined ? { mode: ev.routing.mode } : {}),
163
+ });
145
164
  break;
146
165
  case "text_delta":
147
166
  turnText += ev.text;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Session modes (P5 track 3) — the one piece of state that says how much a turn
3
+ * does without asking.
4
+ *
5
+ * Two facts govern that, and before P5 they were kept in different places and at
6
+ * different lifetimes: plan mode lived on the `Session` (runtime, journaled,
7
+ * restored on resume) while auto-approve was a config key read from disk. The
8
+ * config key was also dead — nothing consumed `agent.autoApprove`, and
9
+ * `ApprovalConfigSchema` in the same file stated flatly that no auto-approve
10
+ * mode exists "not behind a footgun flag". One file promised something another
11
+ * refused to do.
12
+ *
13
+ * A mode is the cross product of the two, named. That is what makes it a
14
+ * SUBSUMPTION rather than a third source of truth: `Session` holds exactly one
15
+ * mode and derives both booleans from it, so there is no state in which plan
16
+ * mode and the mode indicator can disagree.
17
+ *
18
+ * Auto-approve is deliberately a RUNTIME mode and only that — never a config
19
+ * key. A flag on disk silently disarms every approval in every session that
20
+ * loads it, with nothing on screen to say so; a mode is chosen in the session it
21
+ * affects, shown while it is active, and gone when the session ends.
22
+ */
23
+ /**
24
+ * Cycle order, and the order every list of modes uses. Shift+Tab walks this
25
+ * ring; the last entry wraps to the first.
26
+ */
27
+ export const SESSION_MODES = [
28
+ "manual",
29
+ "auto-approve",
30
+ "plan",
31
+ "full-auto",
32
+ ];
33
+ /** The default for a session that says nothing — the pre-P5 behaviour exactly. */
34
+ export const DEFAULT_MODE = "manual";
35
+ /** The next mode in the ring. Total: every mode has a successor. */
36
+ export function nextMode(mode) {
37
+ const i = SESSION_MODES.indexOf(mode);
38
+ return SESSION_MODES[(i + 1) % SESSION_MODES.length];
39
+ }
40
+ /** Whether this mode proposes a plan before executing (C.31). */
41
+ export function modePlans(mode) {
42
+ return mode === "plan" || mode === "full-auto";
43
+ }
44
+ /** Whether this mode allows gated actions without prompting. */
45
+ export function modeAutoApproves(mode) {
46
+ return mode === "auto-approve" || mode === "full-auto";
47
+ }
48
+ /** Short label for the status line and the mode indicator. */
49
+ export const MODE_LABELS = {
50
+ manual: "manual",
51
+ "auto-approve": "auto-approve",
52
+ plan: "plan",
53
+ "full-auto": "full-auto",
54
+ };
55
+ /**
56
+ * One line saying what the mode will actually do, shown when it changes.
57
+ *
58
+ * A mode that skips approvals has to SAY it skips approvals at the moment it is
59
+ * switched on. The whole objection to the config flag was that it disarmed the
60
+ * gate with nothing on screen; a runtime mode that announced itself as vaguely
61
+ * as "auto-approve on" would reproduce the same problem more slowly.
62
+ *
63
+ * These lines must not overstate the mode either. Saying "including destructive
64
+ * ones" when the ceiling stops exactly those would teach the user to expect a
65
+ * silence that never comes — and, worse, teach them the gate is gone when it is
66
+ * not. Each auto line names the suppression AND its limit.
67
+ */
68
+ export function modeDescription(mode) {
69
+ switch (mode) {
70
+ case "manual":
71
+ return "every action asks first";
72
+ case "auto-approve":
73
+ return "reversible actions run WITHOUT asking — irreversible ones still ask";
74
+ case "plan":
75
+ return "propose a plan for approval, then ask before each action";
76
+ case "full-auto":
77
+ return "propose a plan, then run it WITHOUT asking — irreversible actions still ask";
78
+ }
79
+ }
80
+ /**
81
+ * Parse a mode from user input (`/mode <name>`), or null when it names nothing.
82
+ * Never guesses: a typo must not silently arm auto-approve.
83
+ */
84
+ export function parseMode(text) {
85
+ const want = text.trim().toLowerCase();
86
+ return SESSION_MODES.find((m) => m === want) ?? null;
87
+ }
88
+ /**
89
+ * Recover a mode from the two booleans an older session journaled.
90
+ *
91
+ * Sessions recorded before P5 carry `plan-mode` events and nothing else, so
92
+ * auto-approve reads false — which is right: it was not a thing that could be
93
+ * on. This is what lets a pre-P5 session resume into the mode it actually had.
94
+ */
95
+ export function modeFromFlags(plans, autoApproves) {
96
+ if (plans && autoApproves)
97
+ return "full-auto";
98
+ if (plans)
99
+ return "plan";
100
+ if (autoApproves)
101
+ return "auto-approve";
102
+ return "manual";
103
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * cruxy-code agent prompts.
2
+ * cruxy agent prompts.
3
3
  *
4
4
  * These are ORIGINAL prompts written for cruxy — not copied from any other
5
5
  * tool. The system prompt is assembled at runtime from a static core plus a
@@ -1,39 +1,19 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { loadProjectInstructions } from "../config/index.js";
3
- import { resolveTaskModel } from "../routing/index.js";
3
+ import { resolveTaskModel, } from "../routing/index.js";
4
4
  import { UsageCollector, accumulateCacheTokens, } from "../usage/index.js";
5
5
  import { Budget } from "./budget.js";
6
+ import { estimateTokens, findCut } from "./context.js";
6
7
  import { runAgent, } from "./loop.js";
8
+ import { DEFAULT_MODE, modeAutoApproves, modePlans, nextMode, parseMode, } from "./mode.js";
7
9
  import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
8
10
  /**
9
- * Estimate the token footprint of a message list with a cheap chars/4 heuristic
10
- * no tokenizer dependency. Good enough to decide *when* to compact; exact
11
- * counts are deferred to a later phase. Counts only textual payload (block
12
- * structure and role labels are negligible and ignored).
11
+ * Re-exported from `agent/context.ts`, where the estimate now lives beside the
12
+ * cut-point search and the budget reading (P6 track 3) one module for
13
+ * everything that measures the window, so the seam that acts on the numbers and
14
+ * the surfaces that explain them cannot drift.
13
15
  */
14
- export function estimateTokens(messages) {
15
- let chars = 0;
16
- for (const msg of messages) {
17
- if (typeof msg.content === "string") {
18
- chars += msg.content.length;
19
- continue;
20
- }
21
- for (const block of msg.content) {
22
- switch (block.type) {
23
- case "text":
24
- chars += block.text.length;
25
- break;
26
- case "tool_use":
27
- chars += block.name.length + JSON.stringify(block.input).length;
28
- break;
29
- case "tool_result":
30
- chars += block.content.length;
31
- break;
32
- }
33
- }
34
- }
35
- return Math.ceil(chars / 4);
36
- }
16
+ export { estimateTokens } from "./context.js";
37
17
  /**
38
18
  * Owns the state of one multi-turn conversation: the running message history and
39
19
  * the usage accumulated across turns. Each `send` continues from the prior
@@ -52,43 +32,150 @@ export class Session {
52
32
  usage = { input_tokens: 0, output_tokens: 0 };
53
33
  /** Stable id for this session (C.22), so a run's usage record groups with the
54
34
  * other runs of the same interactive session (`cruxy usage --session`). */
55
- sessionId = randomUUID();
35
+ sessionId;
56
36
  /** The most recent run's usage record (C.22) — the one-shot path reads it to
57
37
  * print the end-of-run summary. */
58
38
  lastRun;
59
39
  args;
60
40
  /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
61
41
  projectInstructions;
62
- /** Mutable so `/plan` can toggle plan mode mid-session. */
63
- planMode;
42
+ /**
43
+ * The session's mode (P5 track 3) — the ONE piece of state saying how much a
44
+ * turn does without asking. Mutable so `/mode` and Shift+Tab can change it
45
+ * mid-session.
46
+ *
47
+ * Plan mode is DERIVED from this, not stored beside it. Before P5 the session
48
+ * held a `planMode` boolean while auto-approve was a (dead) config key, which
49
+ * is two sources of truth for one question and exactly the shape of bug the
50
+ * tier work had just finished removing.
51
+ */
52
+ mode;
53
+ /**
54
+ * How many messages of the CURRENT history the recorder already holds (P2).
55
+ *
56
+ * This is what lets an append-only log track an array that is rewritten in
57
+ * place. The invariant: a replay of the log so far reproduces exactly the
58
+ * first `recordedCount` messages of the live history. So an append is
59
+ * `slice(recordedCount)`, and a compaction updates the watermark to the
60
+ * post-compaction length rather than re-emitting anything.
61
+ *
62
+ * It is only ever advanced at points where the history is COHERENT — a
63
+ * tool_use and its tool_result are never split across a flush — because the
64
+ * loop calls the compaction seam at the top of an iteration, after the
65
+ * previous iteration fully resolved its tool calls.
66
+ */
67
+ recordedCount = 0;
64
68
  constructor(args) {
65
69
  this.args = args;
66
70
  this.projectInstructions = args.projectInstructions ?? null;
67
- // Plan mode requires a wired runner; without one it stays off (no half-on
68
- // state where the plan directive is injected but nothing orchestrates it).
69
- this.planMode = (args.planMode ?? false) && args.planRunner !== undefined;
71
+ this.mode = this.resolveMode(args.mode ?? DEFAULT_MODE);
72
+ // Resume (P2): adopt the replayed state verbatim. The history is already
73
+ // recorded in the log we are continuing, so the watermark starts at its
74
+ // full length — otherwise the first flush would append the whole restored
75
+ // conversation a second time.
76
+ const restore = args.restore;
77
+ this.sessionId = restore?.sessionId ?? randomUUID();
78
+ if (restore) {
79
+ this.messages = restore.messages;
80
+ this.usage.input_tokens = restore.usage.input_tokens;
81
+ this.usage.output_tokens = restore.usage.output_tokens;
82
+ this.recordedCount = restore.messages.length;
83
+ this.mode = this.resolveMode(restore.mode);
84
+ }
85
+ }
86
+ /**
87
+ * Resolve a requested mode to one this session can actually honour.
88
+ *
89
+ * A planning mode needs a wired `planRunner`; without one it degrades to the
90
+ * non-planning mode with the same approval behaviour rather than half-engaging
91
+ * (plan directive injected, nothing orchestrating it). Auto-approve is
92
+ * unaffected — it needs nothing wired.
93
+ *
94
+ * Unknown strings (a journal from a newer build, a hand-edited log) fall back
95
+ * to the default. Failing closed matters here specifically: the fallback must
96
+ * be the mode that asks MORE, never one that asks less.
97
+ */
98
+ resolveMode(want) {
99
+ const mode = parseMode(String(want)) ?? DEFAULT_MODE;
100
+ if (!modePlans(mode) || this.args.planRunner !== undefined)
101
+ return mode;
102
+ return mode === "full-auto" ? "auto-approve" : "manual";
103
+ }
104
+ /**
105
+ * Record everything appended to `current` since the last flush.
106
+ *
107
+ * Callers must only pass a history whose first `recordedCount` messages are
108
+ * unchanged — every call site satisfies this because the only operation that
109
+ * rewrites the head is compaction, which updates the watermark itself.
110
+ */
111
+ flushRecorded(current) {
112
+ if (!this.args.recorder)
113
+ return;
114
+ if (current.length <= this.recordedCount)
115
+ return;
116
+ this.args.recorder.append(current.slice(this.recordedCount));
117
+ this.recordedCount = current.length;
70
118
  }
71
119
  /** The ambient tool capabilities (gate + sandbox + cwd/config). Exposed so a
72
120
  * shell-bound custom slash command (C.19) runs through the SAME gated path. */
73
121
  get toolContext() {
74
122
  return this.args.ctx;
75
123
  }
124
+ /**
125
+ * The tool catalogue advertised to the model this session — the one
126
+ * `registerRuntimeTools` built, so it reflects which optional families
127
+ * (memory, LSP, web, MCP, subagents, jobs) are actually enabled. Exposed
128
+ * read-only, for `/status` to count.
129
+ */
130
+ get toolRegistry() {
131
+ return this.args.registry;
132
+ }
76
133
  /** The background-job manager (C.28), or undefined when jobs are disabled.
77
134
  * The REPL uses it to service paused-job approvals and drive `/jobs`; `cruxy
78
135
  * run` uses it to cancel every live job on exit. */
79
136
  get jobs() {
80
137
  return this.args.jobs;
81
138
  }
82
- /** Whether plan mode is currently on. */
83
- getPlanMode() {
84
- return this.planMode;
139
+ /**
140
+ * The session's live model choice (P6 track 1), or undefined when tiers do not
141
+ * apply — a bring-your-own provider has no cruxy tiers to choose between, and
142
+ * `/model` says so rather than offering a menu that could not take effect.
143
+ *
144
+ * Exposed as the object rather than proxied through `getModel`/`setModel`
145
+ * accessors, because the renderer needs to SUBSCRIBE to it, not just read it:
146
+ * the model panel and the status line have to react to a change within the
147
+ * same paint, and a getter pair cannot carry that.
148
+ */
149
+ get model() {
150
+ return this.args.model;
151
+ }
152
+ /** The session's current mode. */
153
+ getMode() {
154
+ return this.mode;
85
155
  }
86
156
  /**
87
- * Toggle plan mode. Only takes effect when a `planRunner` was wired (built by
88
- * the session factory); without one, plan mode stays off.
157
+ * Set the mode. Returns the EFFECTIVE mode, which differs from the request
158
+ * when no plan runner is wired so a caller reports what happened rather
159
+ * than what it asked for.
89
160
  */
90
- setPlanMode(enabled) {
91
- this.planMode = enabled && this.args.planRunner !== undefined;
161
+ setMode(mode) {
162
+ this.mode = this.resolveMode(mode);
163
+ // Record the effective value, not the request: the log must say what the
164
+ // session actually did.
165
+ this.args.recorder?.mode(this.mode);
166
+ return this.mode;
167
+ }
168
+ /** Advance one step around the mode ring (Shift+Tab). Returns the new mode. */
169
+ cycleMode() {
170
+ return this.setMode(nextMode(this.mode));
171
+ }
172
+ /** Whether this session proposes a plan before executing (C.31). */
173
+ getPlanMode() {
174
+ return modePlans(this.mode);
175
+ }
176
+ /** Whether gated actions run without a prompt in this session. */
177
+ getAutoApprove() {
178
+ return modeAutoApproves(this.mode);
92
179
  }
93
180
  /**
94
181
  * Run one user turn: append the prompt, compact if the history has grown past
@@ -107,6 +194,9 @@ export class Session {
107
194
  for (const tool of this.args.registry.list())
108
195
  tool.onTurnStart?.();
109
196
  this.messages.push({ role: "user", content: userPrompt });
197
+ // Record the user's turn before anything can fail (P2): a turn that dies in
198
+ // the provider still leaves what the user asked for on disk.
199
+ this.flushRecorded(this.messages);
110
200
  // Usage telemetry (C.22): one collector per run. `onReq` is threaded into
111
201
  // every real model request this turn drives — the main loop, compaction, and
112
202
  // (in plan mode) the propose + execution steps — so usage is captured exactly
@@ -137,7 +227,7 @@ export class Session {
137
227
  // Plan mode (C.31) delegates the whole turn to the injected runner: propose a
138
228
  // plan, approve/revise, then execute step-by-step. Falls back to the normal
139
229
  // single-shot loop when off or unwired, so existing behavior is untouched.
140
- const result = this.planMode && this.args.planRunner
230
+ const result = modePlans(this.mode) && this.args.planRunner
141
231
  ? await this.args.planRunner({
142
232
  messages: this.messages,
143
233
  projectInstructions: this.projectInstructions,
@@ -167,6 +257,15 @@ export class Session {
167
257
  this.messages = result.messages;
168
258
  this.usage.input_tokens += result.usage.input_tokens;
169
259
  this.usage.output_tokens += result.usage.output_tokens;
260
+ // Persist what the turn produced (P2). The loop's mid-iteration flushes
261
+ // (via the compaction seam) already recorded most of it; this catches the
262
+ // tail after the final iteration. Usage is copied into the session log
263
+ // rather than referenced, because the usage store keeps only its newest 50
264
+ // runs while sessions are kept indefinitely.
265
+ this.flushRecorded(this.messages);
266
+ if (result.usage.input_tokens > 0 || result.usage.output_tokens > 0) {
267
+ this.args.recorder?.usage(result.usage.input_tokens, result.usage.output_tokens);
268
+ }
170
269
  // Publish the run's usage record (C.22): stash it for the one-shot summary
171
270
  // and hand it to the persistence sink. Building the record never touches the
172
271
  // network and never blocks the turn's result.
@@ -191,6 +290,11 @@ export class Session {
191
290
  /** Drop the conversation history but keep the session (for `/clear`). */
192
291
  clear() {
193
292
  this.messages = [];
293
+ // The log keeps every earlier message — `clear` is an event, not an
294
+ // erasure. Replay honours it, so a resumed session starts empty exactly as
295
+ // the live one did, while the transcript of what was said survives.
296
+ this.args.recorder?.clear();
297
+ this.recordedCount = 0;
194
298
  }
195
299
  /**
196
300
  * Compact `this.messages` only when it has grown past threshold, adopting the
@@ -212,6 +316,16 @@ export class Session {
212
316
  * unchanged — cheap, no model call.
213
317
  */
214
318
  async compactLoopHistory(messages, onRequestUsage) {
319
+ // Flush BEFORE compacting (P2). The loop owns this array and only ever
320
+ // appends to it between calls, so everything since the last flush is a pure
321
+ // append — and it has to be on disk before a compaction event can refer to
322
+ // a prefix length, or the recorded `replaced` count would be measured
323
+ // against a shorter history than the one being compacted.
324
+ //
325
+ // The loop calls this at the TOP of each iteration, after the previous
326
+ // iteration resolved all its tool calls, so the history flushed here is
327
+ // always coherent: no tool_use is ever recorded without its tool_result.
328
+ this.flushRecorded(messages);
215
329
  const result = await this.compactIfOverThreshold(messages, onRequestUsage);
216
330
  return result.messages;
217
331
  }
@@ -257,7 +371,12 @@ export class Session {
257
371
  * the summary's usage into the session total.
258
372
  */
259
373
  async runCompaction(messages, onRequestUsage) {
260
- const cut = this.findCut(messages);
374
+ // Self-contained sync (P2): whatever array is about to be rewritten must be
375
+ // fully recorded first, so the `replaced` count in the compaction event is
376
+ // measured against the same history a replay will have reconstructed. A
377
+ // no-op when the caller already flushed, which every caller does.
378
+ this.flushRecorded(messages);
379
+ const cut = findCut(messages, this.args.config.context.keepRecentMessages);
261
380
  if (cut === null)
262
381
  return { messages, compacted: null };
263
382
  const prefix = messages.slice(0, cut);
@@ -286,37 +405,20 @@ export class Session {
286
405
  content: `${COMPACTION_MARKER} Summary of the conversation so far:\n\n${synopsis}`,
287
406
  },
288
407
  ];
408
+ // Record the rewrite as an EVENT (P2): the log stays append-only, and the
409
+ // messages that were folded away remain readable earlier in the file even
410
+ // though the model can no longer see them. The watermark moves to the
411
+ // post-compaction length so the next append is measured against the new
412
+ // array, not the old one.
413
+ if (this.args.recorder) {
414
+ this.args.recorder.compaction(prefix.length, summaryMessages);
415
+ this.recordedCount = summaryMessages.length + kept.length;
416
+ }
289
417
  return {
290
418
  messages: [...summaryMessages, ...kept],
291
419
  compacted: prefix.length,
292
420
  };
293
421
  }
294
- /**
295
- * Choose the boundary between the summarized prefix and the kept-recent tail.
296
- *
297
- * Tool-call integrity is the constraint: a `tool_use` (assistant) and its
298
- * matching `tool_result` (the next user message) must never straddle the cut,
299
- * or the next provider call breaks. A real user *prompt* (`role:"user"` with
300
- * string content) only occurs at a completed turn boundary, where every prior
301
- * tool exchange is already resolved — so the kept region must begin there. The
302
- * synthetic compaction-summary user message is also string content, so a
303
- * repeat compaction always finds at least the previous summary as a clean cut.
304
- *
305
- * Start from `length - keepRecentMessages` and walk *backwards* to the nearest
306
- * such prompt: this keeps at least the recent floor and lands clean. Returns
307
- * the cut index, or `null` if no safe boundary leaves a non-empty prefix
308
- * (e.g. a single long in-progress turn — nothing safe to compact).
309
- */
310
- findCut(messages) {
311
- const { keepRecentMessages } = this.args.config.context;
312
- const start = messages.length - keepRecentMessages;
313
- for (let i = start; i >= 1; i--) {
314
- const msg = messages[i];
315
- if (msg.role === "user" && typeof msg.content === "string")
316
- return i;
317
- }
318
- return null;
319
- }
320
422
  /**
321
423
  * Summarize a prefix via a standalone, tool-less provider call over a rendered
322
424
  * transcript. Throws on a stream error or empty output so callers fail open.