@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.
- package/dist/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +20 -1
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +171 -69
- package/dist/agent/status.js +56 -0
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +401 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +717 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +65 -3
- package/dist/components/select.js +3 -3
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +204 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +508 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +108 -0
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +15 -0
- package/dist/tui/layout.js +314 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/overview.js +49 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +1121 -0
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/tui/views.js +66 -0
- package/dist/usage/collect.js +6 -6
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +5 -2
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +163 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { selectList } from "../components/index.js";
|
|
2
|
+
import { usageError } from "../errors/index.js";
|
|
3
|
+
import { findSession, isAmbiguous, listSessions } from "./list.js";
|
|
4
|
+
import { replaySession } from "./replay.js";
|
|
5
|
+
/** How many sessions the bare-`--resume` picker offers. */
|
|
6
|
+
export const PICKER_LIMIT = 10;
|
|
7
|
+
/** Short, stable id form — enough to identify a session, short enough to type. */
|
|
8
|
+
export function shortId(sessionId) {
|
|
9
|
+
return sessionId.slice(0, 8);
|
|
10
|
+
}
|
|
11
|
+
/** `2h ago`, `3d ago` — relative age for the picker and the sidebar. */
|
|
12
|
+
export function relativeAge(iso, now = Date.now()) {
|
|
13
|
+
const then = Date.parse(iso);
|
|
14
|
+
if (Number.isNaN(then))
|
|
15
|
+
return "unknown";
|
|
16
|
+
const seconds = Math.max(0, Math.floor((now - then) / 1000));
|
|
17
|
+
if (seconds < 60)
|
|
18
|
+
return "just now";
|
|
19
|
+
const minutes = Math.floor(seconds / 60);
|
|
20
|
+
if (minutes < 60)
|
|
21
|
+
return `${minutes}m ago`;
|
|
22
|
+
const hours = Math.floor(minutes / 60);
|
|
23
|
+
if (hours < 24)
|
|
24
|
+
return `${hours}h ago`;
|
|
25
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
26
|
+
}
|
|
27
|
+
/** One picker/sidebar row: `3f2a1b0c 2h ago fix the failing test (4 turns)`. */
|
|
28
|
+
export function describeSession(s, now = Date.now()) {
|
|
29
|
+
const turns = `${s.turns} turn${s.turns === 1 ? "" : "s"}`;
|
|
30
|
+
return `${shortId(s.sessionId)} ${relativeAge(s.updatedAt, now)} ${s.title} (${turns})`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Compare the session's recorded directory with the one we are resuming into.
|
|
34
|
+
* Returns a warning to print, or null when they agree.
|
|
35
|
+
*
|
|
36
|
+
* This is ruling 4's "warn loudly, never silently mismatch". The history is
|
|
37
|
+
* full of file paths, diffs and tool results that only mean anything relative
|
|
38
|
+
* to the directory they were produced in; replaying it somewhere else does not
|
|
39
|
+
* corrupt anything, but it does mean the model is reasoning about a tree that
|
|
40
|
+
* is not the one in front of it. So the resume PROCEEDS — the user asked for
|
|
41
|
+
* it, and refusing a resume because a repo moved would be worse — but it says
|
|
42
|
+
* exactly what is off.
|
|
43
|
+
*/
|
|
44
|
+
export function cwdMismatchWarning(state, cwd) {
|
|
45
|
+
if (state.meta.cwd === cwd)
|
|
46
|
+
return null;
|
|
47
|
+
return (`this session was recorded in ${state.meta.cwd}, but you are in ${cwd} — ` +
|
|
48
|
+
`its history refers to files and paths from the original directory`);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Load one session and collect its warnings. Throws a usage error when the
|
|
52
|
+
* file cannot be replayed at all (no meta line) — an unreadable session is
|
|
53
|
+
* worth failing loudly on, unlike an individual torn line.
|
|
54
|
+
*/
|
|
55
|
+
export function loadResume(session, cwd) {
|
|
56
|
+
let state;
|
|
57
|
+
try {
|
|
58
|
+
state = replaySession(session.file);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
throw usageError(`could not resume session ${shortId(session.sessionId)}`, [
|
|
62
|
+
`the log at ${session.file} is not readable: ${err.message}`,
|
|
63
|
+
"start a new session with `cruxy`",
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
const warnings = [];
|
|
67
|
+
const mismatch = cwdMismatchWarning(state, cwd);
|
|
68
|
+
if (mismatch)
|
|
69
|
+
warnings.push(mismatch);
|
|
70
|
+
if (state.skipped > 0) {
|
|
71
|
+
warnings.push(`${state.skipped} unreadable line${state.skipped === 1 ? "" : "s"} in the session log were skipped — ` +
|
|
72
|
+
`the restored history may be incomplete`);
|
|
73
|
+
}
|
|
74
|
+
return { session, state, warnings };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve `--resume <id>`. Fails loud on an unknown or ambiguous id rather than
|
|
78
|
+
* silently starting a new session — the user named something specific.
|
|
79
|
+
*/
|
|
80
|
+
export function resumeById(cwd, id) {
|
|
81
|
+
if (isAmbiguous(cwd, id)) {
|
|
82
|
+
const matches = listSessions(cwd)
|
|
83
|
+
.filter((s) => s.sessionId.startsWith(id))
|
|
84
|
+
.map((s) => shortId(s.sessionId));
|
|
85
|
+
throw usageError(`\`${id}\` matches more than one session`, [
|
|
86
|
+
`did you mean one of: ${matches.join(", ")}?`,
|
|
87
|
+
"run `cruxy --resume` to pick from a list",
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
const found = findSession(cwd, id);
|
|
91
|
+
if (!found) {
|
|
92
|
+
throw usageError(`no session \`${id}\` in this project`, [
|
|
93
|
+
"run `cruxy --resume` to pick from recent sessions",
|
|
94
|
+
"sessions are per-directory; check you are in the right one",
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
return loadResume(found, cwd);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Bare `--resume`: pick from the most recent sessions, or start a new one.
|
|
101
|
+
* Returns null when the user chose "new session" (or cancelled) — the caller
|
|
102
|
+
* then proceeds exactly as an unresumed run.
|
|
103
|
+
*/
|
|
104
|
+
export async function resumePicker(cwd, opts = {}) {
|
|
105
|
+
const sessions = listSessions(cwd, PICKER_LIMIT);
|
|
106
|
+
if (sessions.length === 0) {
|
|
107
|
+
opts.logger?.info("no saved sessions for this project — starting a new one");
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const now = opts.now ?? Date.now();
|
|
111
|
+
const rows = [
|
|
112
|
+
...sessions.map((session) => ({ kind: "session", session })),
|
|
113
|
+
{ kind: "new" },
|
|
114
|
+
];
|
|
115
|
+
const picked = await selectList(rows, {
|
|
116
|
+
title: "resume a session",
|
|
117
|
+
toLabel: (row) => row.kind === "new"
|
|
118
|
+
? "+ new session"
|
|
119
|
+
: describeSession(row.session, now),
|
|
120
|
+
// Non-interactive with no id named: starting fresh is the safe default,
|
|
121
|
+
// never an arbitrary session picked on the user's behalf.
|
|
122
|
+
defaultValue: { kind: "new" },
|
|
123
|
+
nonInteractiveHint: ["pass an id: `cruxy --resume <id>`"],
|
|
124
|
+
}, opts.io);
|
|
125
|
+
if (picked.kind === "cancelled" || picked.value.kind === "new")
|
|
126
|
+
return null;
|
|
127
|
+
return loadResume(picked.value.session, cwd);
|
|
128
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Session persistence (P2): the on-disk shape of a conversation.
|
|
4
|
+
*
|
|
5
|
+
* The file is an APPEND-ONLY event log (JSONL), not a snapshot of the message
|
|
6
|
+
* array. That distinction is the whole design:
|
|
7
|
+
*
|
|
8
|
+
* - `Session.messages` is rewritten in place — compaction replaces an older
|
|
9
|
+
* prefix with a synthetic summary pair (see `agent/session.ts`). A snapshot
|
|
10
|
+
* format would have to rewrite the whole file on every turn, which is O(n²)
|
|
11
|
+
* in turns and loses the history of what was compacted away.
|
|
12
|
+
* - An event log records the rewrite as an EVENT (`compaction`, carrying what
|
|
13
|
+
* it replaced), so the file only ever grows, and replay reconstructs the
|
|
14
|
+
* current array exactly. What the model can still see and what the user
|
|
15
|
+
* actually said stay separately recoverable.
|
|
16
|
+
*
|
|
17
|
+
* Replay is a fold over the events; see `replay.ts`.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Bump when the event shapes change incompatibly. Present on the `meta` line
|
|
21
|
+
* only — every subsequent line is self-describing via `kind`.
|
|
22
|
+
*/
|
|
23
|
+
export const SESSION_FILE_VERSION = 1;
|
|
24
|
+
/**
|
|
25
|
+
* FORWARD COMPATIBILITY — why every schema below is `.passthrough()`.
|
|
26
|
+
*
|
|
27
|
+
* This is the same decision, for the same recorded reason, as
|
|
28
|
+
* `usage/types.ts`: a `.strict()` schema means the first field a NEWER cruxy
|
|
29
|
+
* writes makes an OLDER binary reject the line. For usage that silently lost
|
|
30
|
+
* accounting; here it would silently lose a user's CONVERSATION, which is
|
|
31
|
+
* strictly worse. Downgrading a CLI, or running two versions against one home
|
|
32
|
+
* directory, is ordinary — losing a session to it is not acceptable.
|
|
33
|
+
*
|
|
34
|
+
* `.passthrough()` also PRESERVES unknown keys rather than stripping them, so
|
|
35
|
+
* anything that reads and re-emits an event hands a newer CLI's fields back
|
|
36
|
+
* intact.
|
|
37
|
+
*
|
|
38
|
+
* The tradeoff accepted, identically: a typo'd key is no longer a parse error.
|
|
39
|
+
* Worth it — every field the code actually reads is still fully validated, and
|
|
40
|
+
* a malformed LINE is skipped rather than taking the session down (see
|
|
41
|
+
* `replay.ts`).
|
|
42
|
+
*/
|
|
43
|
+
// ── message shapes (mirrors @cruxy/sdk, validated on the way back in) ────────
|
|
44
|
+
/**
|
|
45
|
+
* The SDK's content blocks, restated as schemas.
|
|
46
|
+
*
|
|
47
|
+
* These must round-trip EXACTLY. `tool_use` / `tool_result` pairing is
|
|
48
|
+
* load-bearing: the provider rejects a history where a `tool_use` has no
|
|
49
|
+
* matching `tool_result`, which is the entire reason `Session.findCut` walks
|
|
50
|
+
* back to a real user prompt before compacting. A replay that dropped, merged
|
|
51
|
+
* or reordered a block would produce a history that fails on the next turn —
|
|
52
|
+
* so nothing here is lossy, and unknown block types are preserved rather than
|
|
53
|
+
* filtered (a newer CLI's block must survive an older one reading the file).
|
|
54
|
+
*/
|
|
55
|
+
export const TextBlockSchema = z
|
|
56
|
+
.object({ type: z.literal("text"), text: z.string() })
|
|
57
|
+
.passthrough();
|
|
58
|
+
export const ToolUseBlockSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
type: z.literal("tool_use"),
|
|
61
|
+
id: z.string(),
|
|
62
|
+
name: z.string(),
|
|
63
|
+
input: z.unknown(),
|
|
64
|
+
})
|
|
65
|
+
.passthrough();
|
|
66
|
+
export const ToolResultBlockSchema = z
|
|
67
|
+
.object({
|
|
68
|
+
type: z.literal("tool_result"),
|
|
69
|
+
tool_use_id: z.string(),
|
|
70
|
+
content: z.string(),
|
|
71
|
+
is_error: z.boolean().optional(),
|
|
72
|
+
})
|
|
73
|
+
.passthrough();
|
|
74
|
+
/**
|
|
75
|
+
* A block of a kind this build knows, OR any other object carrying a string
|
|
76
|
+
* `type`. The fallback arm is deliberate: a newer cruxy that adds a block kind
|
|
77
|
+
* must not have its sessions truncated by an older one. The block is carried
|
|
78
|
+
* through untouched and handed back to the provider as-is.
|
|
79
|
+
*/
|
|
80
|
+
export const ContentBlockSchema = z.union([
|
|
81
|
+
TextBlockSchema,
|
|
82
|
+
ToolUseBlockSchema,
|
|
83
|
+
ToolResultBlockSchema,
|
|
84
|
+
z.object({ type: z.string() }).passthrough(),
|
|
85
|
+
]);
|
|
86
|
+
export const MessageSchema = z
|
|
87
|
+
.object({
|
|
88
|
+
role: z.enum(["user", "assistant"]),
|
|
89
|
+
content: z.union([z.string(), z.array(ContentBlockSchema)]),
|
|
90
|
+
})
|
|
91
|
+
.passthrough();
|
|
92
|
+
// ── events ───────────────────────────────────────────────────────────────────
|
|
93
|
+
/** One declared workspace root, recorded so a resume can validate against it. */
|
|
94
|
+
export const RootRefSchema = z
|
|
95
|
+
.object({ name: z.string(), path: z.string() })
|
|
96
|
+
.passthrough();
|
|
97
|
+
/**
|
|
98
|
+
* The first line of every session file: everything needed to identify the
|
|
99
|
+
* session and to decide whether resuming it HERE is safe.
|
|
100
|
+
*
|
|
101
|
+
* `cwd` and `roots` are metadata, never restored. Resuming a session recorded
|
|
102
|
+
* in another directory would silently point a history full of file paths, diffs
|
|
103
|
+
* and tool results at an unrelated tree — so the resume path compares and warns
|
|
104
|
+
* loudly rather than quietly proceeding (see `replay.ts`/`resume.ts`).
|
|
105
|
+
*/
|
|
106
|
+
export const SessionMetaSchema = z
|
|
107
|
+
.object({
|
|
108
|
+
kind: z.literal("meta"),
|
|
109
|
+
version: z.literal(SESSION_FILE_VERSION),
|
|
110
|
+
sessionId: z.string(),
|
|
111
|
+
startedAt: z.string(),
|
|
112
|
+
/** The primary root at session start — the directory history refers to. */
|
|
113
|
+
cwd: z.string(),
|
|
114
|
+
/** Every declared root (C.26). Multi-root sessions key on the primary, so
|
|
115
|
+
* this is what makes the asymmetry visible rather than silently dropped. */
|
|
116
|
+
roots: z.array(RootRefSchema).default([]),
|
|
117
|
+
cliVersion: z.string().optional(),
|
|
118
|
+
provider: z.string().optional(),
|
|
119
|
+
model: z.string().optional(),
|
|
120
|
+
})
|
|
121
|
+
.passthrough();
|
|
122
|
+
/**
|
|
123
|
+
* Messages appended since the last event. The ONLY growth path — every turn's
|
|
124
|
+
* user prompt, assistant blocks and tool results arrive through here.
|
|
125
|
+
*
|
|
126
|
+
* `runId` is the CheckpointGate's run id for the turn (see `gate.ts`), NOT a
|
|
127
|
+
* second id minted here: `cruxy rollback <id>` and this log must agree on what
|
|
128
|
+
* a turn is, or the undo unit means two different things. It is absent when
|
|
129
|
+
* checkpoints are disabled — the only case where no run id exists at all.
|
|
130
|
+
*/
|
|
131
|
+
export const AppendEventSchema = z
|
|
132
|
+
.object({
|
|
133
|
+
kind: z.literal("append"),
|
|
134
|
+
at: z.string(),
|
|
135
|
+
runId: z.string().optional(),
|
|
136
|
+
messages: z.array(MessageSchema),
|
|
137
|
+
})
|
|
138
|
+
.passthrough();
|
|
139
|
+
/**
|
|
140
|
+
* A compaction: the oldest `replaced` messages were folded into `summary`
|
|
141
|
+
* (the synthetic user/assistant pair carrying `COMPACTION_MARKER`).
|
|
142
|
+
*
|
|
143
|
+
* Recording the COUNT plus the replacement — rather than rewriting the array —
|
|
144
|
+
* is what keeps the file append-only. The replaced messages remain earlier in
|
|
145
|
+
* the log, so the full conversation is still recoverable even though the model
|
|
146
|
+
* can no longer see it.
|
|
147
|
+
*/
|
|
148
|
+
export const CompactionEventSchema = z
|
|
149
|
+
.object({
|
|
150
|
+
kind: z.literal("compaction"),
|
|
151
|
+
at: z.string(),
|
|
152
|
+
runId: z.string().optional(),
|
|
153
|
+
/** How many messages from the head were folded away. */
|
|
154
|
+
replaced: z.number().int().nonnegative(),
|
|
155
|
+
/** What replaced them (the synthetic pair). */
|
|
156
|
+
summary: z.array(MessageSchema),
|
|
157
|
+
})
|
|
158
|
+
.passthrough();
|
|
159
|
+
/** `/clear`: history dropped, session kept. Replay resets to an empty array. */
|
|
160
|
+
export const ClearEventSchema = z
|
|
161
|
+
.object({ kind: z.literal("clear"), at: z.string() })
|
|
162
|
+
.passthrough();
|
|
163
|
+
/**
|
|
164
|
+
* `/plan` toggled. The last one wins on replay.
|
|
165
|
+
*
|
|
166
|
+
* Superseded by {@link SessionModeEventSchema} (P5 track 3) and still READ, never
|
|
167
|
+
* written: sessions recorded before modes existed carry these, and dropping the
|
|
168
|
+
* case would silently resume them in manual. Expand-contract — the new writer
|
|
169
|
+
* emits `mode`, the reader understands both, and a pre-P5 log keeps meaning what
|
|
170
|
+
* it meant.
|
|
171
|
+
*/
|
|
172
|
+
export const PlanModeEventSchema = z
|
|
173
|
+
.object({
|
|
174
|
+
kind: z.literal("plan-mode"),
|
|
175
|
+
at: z.string(),
|
|
176
|
+
enabled: z.boolean(),
|
|
177
|
+
})
|
|
178
|
+
.passthrough();
|
|
179
|
+
/**
|
|
180
|
+
* The session mode changed (P5 track 3). The last one wins on replay, exactly
|
|
181
|
+
* like the event it replaces.
|
|
182
|
+
*
|
|
183
|
+
* `mode` is a plain string here rather than an enum so an unknown value — a log
|
|
184
|
+
* written by a newer build that added a mode — parses instead of poisoning the
|
|
185
|
+
* whole line. The fold decides what to do with one it does not recognise.
|
|
186
|
+
*/
|
|
187
|
+
export const SessionModeEventSchema = z
|
|
188
|
+
.object({
|
|
189
|
+
kind: z.literal("mode"),
|
|
190
|
+
at: z.string(),
|
|
191
|
+
mode: z.string(),
|
|
192
|
+
})
|
|
193
|
+
.passthrough();
|
|
194
|
+
/**
|
|
195
|
+
* One turn's token usage, as the provider reported it. Summed on replay to
|
|
196
|
+
* restore `Session.usage`.
|
|
197
|
+
*
|
|
198
|
+
* This is a COPY, deliberately. `~/.cruxy/usage/runs.json` keeps only the last
|
|
199
|
+
* 50 runs (`usage/store.ts`), while sessions are kept indefinitely — so a
|
|
200
|
+
* session will routinely outlive its own usage records. Without this the
|
|
201
|
+
* restored `Session.usage` would silently read 0 for an older conversation.
|
|
202
|
+
* `runId` still points at the usage store for the richer per-tier/cost
|
|
203
|
+
* breakdown WHEN it is still there; nothing here assumes it is.
|
|
204
|
+
*/
|
|
205
|
+
export const UsageEventSchema = z
|
|
206
|
+
.object({
|
|
207
|
+
kind: z.literal("usage"),
|
|
208
|
+
at: z.string(),
|
|
209
|
+
runId: z.string().optional(),
|
|
210
|
+
inputTokens: z.number().int().nonnegative(),
|
|
211
|
+
outputTokens: z.number().int().nonnegative(),
|
|
212
|
+
})
|
|
213
|
+
.passthrough();
|
|
214
|
+
/** Every event, discriminated on `kind`. */
|
|
215
|
+
export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
216
|
+
SessionMetaSchema,
|
|
217
|
+
AppendEventSchema,
|
|
218
|
+
CompactionEventSchema,
|
|
219
|
+
ClearEventSchema,
|
|
220
|
+
PlanModeEventSchema,
|
|
221
|
+
SessionModeEventSchema,
|
|
222
|
+
UsageEventSchema,
|
|
223
|
+
]);
|
|
@@ -437,6 +437,29 @@ class SubagentRenderer {
|
|
|
437
437
|
}
|
|
438
438
|
/** The plan executor owns the progress register (C.31) — never the child. */
|
|
439
439
|
progress() { }
|
|
440
|
+
/**
|
|
441
|
+
* Same ownership rule for the plan checklist (P3): a child running its own
|
|
442
|
+
* task must never overwrite the parent's plan on screen.
|
|
443
|
+
*/
|
|
444
|
+
setPlan() { }
|
|
445
|
+
/**
|
|
446
|
+
* Forwarded: a child's test run is a real outcome the user should see, unlike
|
|
447
|
+
* its assistant text. The label is not prefixed — the report carries the
|
|
448
|
+
* command it ran, which already identifies it.
|
|
449
|
+
*/
|
|
450
|
+
/**
|
|
451
|
+
* NOT forwarded, deliberately. A subagent may run on a different tier from
|
|
452
|
+
* the main loop (C.30 routes by task class), and the parent's model panel and
|
|
453
|
+
* header describe the SESSION's tier. Forwarding would let a subagent's tier
|
|
454
|
+
* overwrite it and linger after the subagent finished — the header would
|
|
455
|
+
* report a tier the conversation is not running on.
|
|
456
|
+
*/
|
|
457
|
+
servedRouting() {
|
|
458
|
+
// no-op
|
|
459
|
+
}
|
|
460
|
+
testResult(report) {
|
|
461
|
+
this.inner.testResult(report);
|
|
462
|
+
}
|
|
440
463
|
toolLifecycle(event) {
|
|
441
464
|
this.inner.toolLifecycle({
|
|
442
465
|
...event,
|
|
@@ -144,6 +144,14 @@ export function makeRunTestsTool(deps = {}) {
|
|
|
144
144
|
shell: ctx.config.shell,
|
|
145
145
|
});
|
|
146
146
|
budget.record(result.passed);
|
|
147
|
+
// The structured result goes to the renderer here, from the same object
|
|
148
|
+
// the payload below is built from — never from re-reading that payload.
|
|
149
|
+
try {
|
|
150
|
+
deps.onResult?.(result, resolved);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// A renderer problem is not a test-run problem.
|
|
154
|
+
}
|
|
147
155
|
const payload = renderResult(result, resolved, {
|
|
148
156
|
run: result.passed ? 0 : budget.spent,
|
|
149
157
|
max,
|
package/dist/tools/registry.js
CHANGED
|
@@ -3,7 +3,7 @@ import { listFilesTool } from "./list-files.js";
|
|
|
3
3
|
import { gitStatusTool } from "./git-status.js";
|
|
4
4
|
import { readFileTool, writeFileTool, editFileTool, applyPatchTool, globTool, grepFilesTool, } from "./file/index.js";
|
|
5
5
|
import { runCommandTool } from "./shell/index.js";
|
|
6
|
-
import { makeRunTestsTool } from "../testing/run-tests-tool.js";
|
|
6
|
+
import { makeRunTestsTool, } from "../testing/run-tests-tool.js";
|
|
7
7
|
import { searchCodebaseTool } from "./search-codebase.js";
|
|
8
8
|
import { listSkillsTool } from "./list-skills.js";
|
|
9
9
|
import { loadSkillTool } from "./load-skill.js";
|
|
@@ -55,7 +55,7 @@ function toInputSchema(schema) {
|
|
|
55
55
|
return json;
|
|
56
56
|
}
|
|
57
57
|
/** Build the default registry with every built-in tool registered. */
|
|
58
|
-
export function buildDefaultRegistry() {
|
|
58
|
+
export function buildDefaultRegistry(opts = {}) {
|
|
59
59
|
const registry = new ToolRegistry();
|
|
60
60
|
registry.register(listFilesTool);
|
|
61
61
|
registry.register(readFileTool);
|
|
@@ -67,7 +67,7 @@ export function buildDefaultRegistry() {
|
|
|
67
67
|
registry.register(gitStatusTool);
|
|
68
68
|
registry.register(runCommandTool);
|
|
69
69
|
// A fresh tool per registry — its iteration budget (C.13) is session-scoped.
|
|
70
|
-
registry.register(makeRunTestsTool());
|
|
70
|
+
registry.register(makeRunTestsTool(opts.onTestResult ? { onResult: opts.onTestResult } : {}));
|
|
71
71
|
registry.register(searchCodebaseTool);
|
|
72
72
|
registry.register(listSkillsTool);
|
|
73
73
|
registry.register(loadSkillTool);
|