@cruxy/cli 0.7.0 → 0.9.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/README.md +46 -13
- package/dist/agent/loop.d.ts +35 -6
- package/dist/agent/loop.js +84 -10
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +8 -0
- package/dist/agent/session.d.ts +6 -4
- package/dist/agent/session.js +6 -5
- package/dist/approval/classify.js +26 -0
- package/dist/approval/prompt.d.ts +9 -0
- package/dist/approval/prompt.js +2 -77
- package/dist/checkpoint/capture.d.ts +17 -0
- package/dist/checkpoint/capture.js +73 -0
- package/dist/checkpoint/git-store.d.ts +61 -0
- package/dist/checkpoint/git-store.js +171 -0
- package/dist/checkpoint/index.d.ts +6 -0
- package/dist/checkpoint/index.js +6 -0
- package/dist/checkpoint/restore.d.ts +23 -0
- package/dist/checkpoint/restore.js +195 -0
- package/dist/checkpoint/service.d.ts +80 -0
- package/dist/checkpoint/service.js +276 -0
- package/dist/checkpoint/shadow-store.d.ts +23 -0
- package/dist/checkpoint/shadow-store.js +93 -0
- package/dist/checkpoint/types.d.ts +117 -0
- package/dist/checkpoint/types.js +18 -0
- package/dist/cli/commands/checkpoint.d.ts +7 -0
- package/dist/cli/commands/checkpoint.js +31 -0
- package/dist/cli/commands/rollback.d.ts +10 -0
- package/dist/cli/commands/rollback.js +51 -0
- package/dist/cli/commands/run.js +24 -10
- package/dist/cli/onboard.js +9 -4
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.d.ts +10 -4
- package/dist/cli/repl.js +26 -12
- package/dist/cli/session-factory.d.ts +15 -1
- package/dist/cli/session-factory.js +104 -18
- package/dist/config/schema.d.ts +133 -0
- package/dist/config/schema.js +40 -0
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +86 -0
- package/dist/errors/types.d.ts +7 -0
- package/dist/errors/types.js +16 -0
- package/dist/indexing/walker.d.ts +11 -0
- package/dist/indexing/walker.js +11 -6
- package/dist/plan/execute.d.ts +8 -0
- package/dist/plan/execute.js +36 -22
- package/dist/plan/service.d.ts +2 -1
- package/dist/plan/service.js +7 -3
- package/dist/plan/submit-plan.d.ts +4 -4
- package/dist/render/capabilities.d.ts +12 -0
- package/dist/render/capabilities.js +27 -0
- package/dist/render/diff.d.ts +19 -0
- package/dist/render/diff.js +107 -0
- package/dist/render/highlight.d.ts +47 -0
- package/dist/render/highlight.js +265 -0
- package/dist/render/index.d.ts +15 -0
- package/dist/render/index.js +21 -0
- package/dist/render/plain-renderer.d.ts +38 -0
- package/dist/render/plain-renderer.js +87 -0
- package/dist/render/state.d.ts +31 -0
- package/dist/render/state.js +83 -0
- package/dist/render/tty-renderer.d.ts +83 -0
- package/dist/render/tty-renderer.js +276 -0
- package/dist/render/types.d.ts +160 -0
- package/dist/render/types.js +1 -0
- package/dist/subagent/budget.d.ts +34 -0
- package/dist/subagent/budget.js +57 -0
- package/dist/subagent/index.d.ts +5 -0
- package/dist/subagent/index.js +5 -0
- package/dist/subagent/orchestrator.d.ts +67 -0
- package/dist/subagent/orchestrator.js +241 -0
- package/dist/subagent/registry-scope.d.ts +28 -0
- package/dist/subagent/registry-scope.js +63 -0
- package/dist/subagent/spawn-tool.d.ts +29 -0
- package/dist/subagent/spawn-tool.js +94 -0
- package/dist/subagent/types.d.ts +55 -0
- package/dist/subagent/types.js +1 -0
- package/dist/tools/types.d.ts +20 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,6 +58,12 @@ export CRUXY_API_KEY=cxy_live_... # …or just use an env var (always wins)
|
|
|
58
58
|
validated, and never auto-executed.
|
|
59
59
|
- **Agent** — streaming output, multi-turn interactive sessions, context
|
|
60
60
|
compaction, and awareness of git state and project instructions (`CRUXY.md`).
|
|
61
|
+
- **Streaming render** — flicker-free live output: a single in-place status line
|
|
62
|
+
(spinner while the model thinks / tools run), append-only committed text,
|
|
63
|
+
syntax-highlighted code fences, tool-call notes, and diffs drawn by the same
|
|
64
|
+
renderer as the approval prompt. Degrades cleanly: piped/CI output is plain
|
|
65
|
+
append-only text with zero ANSI (chrome on stderr), `NO_COLOR` drops color,
|
|
66
|
+
and `CRUXY_NO_SPINNER=1` stills the animation.
|
|
61
67
|
- **Plan mode** (opt-in: `cruxy run --plan`, `/plan`, or `agent.planMode`) — the
|
|
62
68
|
agent proposes a structured, step-by-step plan; you approve it once, then it
|
|
63
69
|
executes with live per-step status. Approving consents to the _shape_ of the
|
|
@@ -110,6 +116,33 @@ before anything runs. On a protected branch (`main`/`master`/configured via
|
|
|
110
116
|
`git.protectedBranches`) cruxy branches off first; the base defaults to
|
|
111
117
|
`git.defaultBase`, then the repo's default branch, then `main`.
|
|
112
118
|
|
|
119
|
+
### Checkpoints & rollback
|
|
120
|
+
|
|
121
|
+
Before an agent run's first file mutation, cruxy snapshots the working tree
|
|
122
|
+
(tracked + untracked non-ignored files; gitignored paths and the secrets
|
|
123
|
+
denylist are never captured). `cruxy rollback` undoes the whole run — creates,
|
|
124
|
+
edits, deletes — in one operation:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
cruxy checkpoint list # saved checkpoints, newest first
|
|
128
|
+
cruxy rollback # restore the most recent checkpoint
|
|
129
|
+
cruxy rollback <id> # restore a specific one
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Rollback is destructive, so it previews exactly what will change (including
|
|
133
|
+
anything that changed _outside_ the run — surfaced, never silently clobbered)
|
|
134
|
+
and always asks for approval; it cannot be session-granted and refuses to run
|
|
135
|
+
non-interactively (`CRUXY_E_ROLLBACK_APPROVAL_REQUIRED`).
|
|
136
|
+
|
|
137
|
+
In a git repo, snapshots go into the git object database via a temporary index
|
|
138
|
+
— HEAD, your index, the stash, and every ref are untouched, and nothing shows
|
|
139
|
+
up in `git status`. Outside a repo, a content-addressed shadow copy under
|
|
140
|
+
`.cruxy/checkpoints/` is used. Retention is bounded (`checkpoint.retention`,
|
|
141
|
+
default 10; disable with `checkpoint.enabled = false`).
|
|
142
|
+
|
|
143
|
+
**Boundary:** checkpoints cover working-tree files only. Commits, pushes, and
|
|
144
|
+
PRs made during a run are never undone — the rollback preview says so.
|
|
145
|
+
|
|
113
146
|
## Errors & exit codes
|
|
114
147
|
|
|
115
148
|
Every user-facing error prints a title, the cause (when known), concrete next
|
|
@@ -118,19 +151,19 @@ steps, and a stable code (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`). Pass `--verbose`
|
|
|
118
151
|
`NO_COLOR` disables color. Exit codes are stable per category, so scripts can
|
|
119
152
|
branch on them:
|
|
120
153
|
|
|
121
|
-
| Exit | Category | Example codes
|
|
122
|
-
| ---- | ---------- |
|
|
123
|
-
| `0` | success | —
|
|
124
|
-
| `1` | internal | `CRUXY_E_INTERNAL`
|
|
125
|
-
| `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH`, `CRUXY_E_PLAN_INVALID`, `CRUXY_E_PLAN_REVISION_LIMIT` |
|
|
126
|
-
| `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID`
|
|
127
|
-
| `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH`
|
|
128
|
-
| `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED`
|
|
129
|
-
| `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API`
|
|
130
|
-
| `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE`
|
|
131
|
-
| `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED`
|
|
132
|
-
| `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND`
|
|
133
|
-
| `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED`
|
|
154
|
+
| Exit | Category | Example codes |
|
|
155
|
+
| ---- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
156
|
+
| `0` | success | — |
|
|
157
|
+
| `1` | internal | `CRUXY_E_INTERNAL` |
|
|
158
|
+
| `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH`, `CRUXY_E_PLAN_INVALID`, `CRUXY_E_PLAN_REVISION_LIMIT`, `CRUXY_E_CHECKPOINT_NOT_FOUND` |
|
|
159
|
+
| `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
|
|
160
|
+
| `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
|
|
161
|
+
| `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
|
|
162
|
+
| `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
|
|
163
|
+
| `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE`, `CRUXY_E_CHECKPOINT_FAILED` |
|
|
164
|
+
| `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
|
|
165
|
+
| `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
|
|
166
|
+
| `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED`, `CRUXY_E_ROLLBACK_APPROVAL_REQUIRED` |
|
|
134
167
|
|
|
135
168
|
The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
|
|
136
169
|
provider-agnostic, built over `fetch`, with no vendor SDKs.
|
package/dist/agent/loop.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
|
+
import type { StreamRenderer } from "../render/index.js";
|
|
3
4
|
import type { ToolContext } from "../tools/index.js";
|
|
4
5
|
import { ToolRegistry } from "../tools/index.js";
|
|
5
6
|
export interface RunAgentArgs {
|
|
@@ -17,12 +18,13 @@ export interface RunAgentArgs {
|
|
|
17
18
|
/** Ambient capabilities handed to each tool. */
|
|
18
19
|
ctx: ToolContext;
|
|
19
20
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* The render seam (U.2): assistant text streams through `renderer.write`
|
|
22
|
+
* delta by delta (each non-empty segment closed via `endSegment`), and
|
|
23
|
+
* tool-call progress is surfaced as transient `status` + committed `note`
|
|
24
|
+
* lines. When omitted, behavior is unchanged (one buffered print per turn,
|
|
25
|
+
* no tool-call chrome). The loop never touches stdout directly.
|
|
24
26
|
*/
|
|
25
|
-
|
|
27
|
+
renderer?: StreamRenderer;
|
|
26
28
|
/** Git context (branch + dirty) for the system prompt's Environment section. */
|
|
27
29
|
git?: {
|
|
28
30
|
branch: string;
|
|
@@ -32,6 +34,31 @@ export interface RunAgentArgs {
|
|
|
32
34
|
projectInstructions?: string | null;
|
|
33
35
|
/** Plan mode's propose phase (C.31): inject the plan-first system directive. */
|
|
34
36
|
planMode?: boolean;
|
|
37
|
+
/** Subagent runs (C.14): inject the bounded-subtask system directive. */
|
|
38
|
+
subagent?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Optional hard budget (C.14): checked before every model turn; a non-null
|
|
41
|
+
* reason stops the loop with `stop: "budget"` and the partial history. The
|
|
42
|
+
* in-flight turn (model call + its tool executions) always completes, so
|
|
43
|
+
* histories stay coherent — overshoot is bounded by one turn.
|
|
44
|
+
*/
|
|
45
|
+
budget?: LoopBudget;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The budget seam for {@link runAgent}: implementations track their own caps
|
|
49
|
+
* (iterations, tokens, wall clock — see `subagent/budget.ts`); the loop only
|
|
50
|
+
* asks "may I start another turn?". Kept a one-method interface so future
|
|
51
|
+
* callers (C.22 cost tracking) can slot in without touching the loop again.
|
|
52
|
+
*/
|
|
53
|
+
export interface LoopBudget {
|
|
54
|
+
/**
|
|
55
|
+
* Return a human-readable reason to stop *before* the next model turn, or
|
|
56
|
+
* `null` to continue. `iterations` = model turns completed so far.
|
|
57
|
+
*/
|
|
58
|
+
exceeded(state: {
|
|
59
|
+
iterations: number;
|
|
60
|
+
usage: Usage;
|
|
61
|
+
}): string | null;
|
|
35
62
|
}
|
|
36
63
|
export interface AgentResult {
|
|
37
64
|
/** The full conversation, including assistant tool calls and tool results. */
|
|
@@ -39,7 +66,9 @@ export interface AgentResult {
|
|
|
39
66
|
/** Number of model turns consumed. */
|
|
40
67
|
iterations: number;
|
|
41
68
|
/** Why the loop ended. */
|
|
42
|
-
stop: "completed" | "max_iterations";
|
|
69
|
+
stop: "completed" | "max_iterations" | "budget";
|
|
70
|
+
/** Which cap tripped, when `stop === "budget"` (from {@link LoopBudget}). */
|
|
71
|
+
stopReason?: string;
|
|
43
72
|
/** Accumulated token usage (stashed for cost tracking in C.22). */
|
|
44
73
|
usage: Usage;
|
|
45
74
|
}
|
package/dist/agent/loop.js
CHANGED
|
@@ -10,10 +10,23 @@ import { buildSystemPrompt } from "./prompts.js";
|
|
|
10
10
|
* silently running tool-less.
|
|
11
11
|
*/
|
|
12
12
|
export async function runAgent(args) {
|
|
13
|
-
const { provider,
|
|
13
|
+
const { provider, config, renderer } = args;
|
|
14
14
|
if (!provider.supportsTools) {
|
|
15
15
|
throw providerUnsupported(config.model.provider);
|
|
16
16
|
}
|
|
17
|
+
renderer?.beginTurn();
|
|
18
|
+
try {
|
|
19
|
+
return await driveLoop(args, renderer);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
// Always leave the terminal clean: no orphaned status line, no held text —
|
|
23
|
+
// even when a provider error aborts the turn mid-stream.
|
|
24
|
+
renderer?.endTurn();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
|
|
28
|
+
async function driveLoop(args, renderer) {
|
|
29
|
+
const { provider, registry, config, ctx } = args;
|
|
17
30
|
const { logger } = ctx;
|
|
18
31
|
// Work on a copy so we never mutate the caller's array as a side effect; the
|
|
19
32
|
// extended history is returned for the caller to adopt.
|
|
@@ -33,15 +46,38 @@ export async function runAgent(args) {
|
|
|
33
46
|
git: args.git ?? null,
|
|
34
47
|
projectInstructions: args.projectInstructions ?? null,
|
|
35
48
|
planMode: args.planMode ?? false,
|
|
49
|
+
subagent: args.subagent ?? false,
|
|
36
50
|
});
|
|
37
51
|
let iterations = 0;
|
|
38
52
|
for (let i = 0; i < maxIterations; i++) {
|
|
53
|
+
// Budget check before committing to another model turn (C.14): a tripped
|
|
54
|
+
// cap returns the history as it stands — always at a clean turn boundary,
|
|
55
|
+
// because the previous iteration fully resolved its tool calls.
|
|
56
|
+
const budgetReason = args.budget?.exceeded({ iterations, usage }) ?? null;
|
|
57
|
+
if (budgetReason !== null) {
|
|
58
|
+
return {
|
|
59
|
+
messages,
|
|
60
|
+
iterations,
|
|
61
|
+
stop: "budget",
|
|
62
|
+
stopReason: budgetReason,
|
|
63
|
+
usage,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
39
66
|
iterations = i + 1;
|
|
40
67
|
const tools = registry.toToolSpecs();
|
|
41
68
|
// ── Consume one model turn ──────────────────────────────────────────────
|
|
42
69
|
let turnText = "";
|
|
43
70
|
const pending = new Map();
|
|
44
71
|
const toolUses = [];
|
|
72
|
+
// Live progress while waiting on the model; dismissed by the first delta.
|
|
73
|
+
// Token context is whatever the loop has actually accumulated (U.4): zero
|
|
74
|
+
// on the first turn → no figure shown, never a fabricated number.
|
|
75
|
+
renderer?.setPhase({
|
|
76
|
+
kind: "thinking",
|
|
77
|
+
tokens: usage.input_tokens + usage.output_tokens > 0
|
|
78
|
+
? { input: usage.input_tokens, output: usage.output_tokens }
|
|
79
|
+
: undefined,
|
|
80
|
+
});
|
|
45
81
|
for await (const ev of provider.stream({
|
|
46
82
|
system,
|
|
47
83
|
messages,
|
|
@@ -50,7 +86,7 @@ export async function runAgent(args) {
|
|
|
50
86
|
switch (ev.type) {
|
|
51
87
|
case "text_delta":
|
|
52
88
|
turnText += ev.text;
|
|
53
|
-
|
|
89
|
+
renderer?.write(ev.text);
|
|
54
90
|
break;
|
|
55
91
|
case "tool_use_start":
|
|
56
92
|
pending.set(ev.index, { id: ev.id, name: ev.name });
|
|
@@ -83,13 +119,13 @@ export async function runAgent(args) {
|
|
|
83
119
|
}
|
|
84
120
|
// ── Record the assistant turn ───────────────────────────────────────────
|
|
85
121
|
if (turnText) {
|
|
86
|
-
// Streaming (
|
|
87
|
-
// so close the segment
|
|
88
|
-
//
|
|
89
|
-
// turn, or an approval prompt starts on its own line. Otherwise
|
|
90
|
-
// whole buffered block (no-
|
|
91
|
-
if (
|
|
92
|
-
|
|
122
|
+
// Streaming (renderer set): the text already reached the user delta by
|
|
123
|
+
// delta, so close the segment through the *same* renderer — it flushes any
|
|
124
|
+
// held partial line and terminates with one newline, so tool output, the
|
|
125
|
+
// next turn, or an approval prompt starts on its own line. Otherwise
|
|
126
|
+
// render the whole buffered block (no-renderer path, unchanged).
|
|
127
|
+
if (renderer)
|
|
128
|
+
renderer.endSegment();
|
|
93
129
|
else
|
|
94
130
|
logger.print(turnText);
|
|
95
131
|
}
|
|
@@ -105,13 +141,51 @@ export async function runAgent(args) {
|
|
|
105
141
|
// ── Execute each tool call, collecting one tool_result per call ──────────
|
|
106
142
|
const toolResults = [];
|
|
107
143
|
for (const call of toolUses) {
|
|
108
|
-
|
|
144
|
+
const label = describeToolCall(call);
|
|
145
|
+
// Semantic lifecycle (U.4): start paints the live state (+ elapsed on
|
|
146
|
+
// long calls), end commits the ✓/✗ trail note. Same information as the
|
|
147
|
+
// old status/note pair, now typed and duration-aware.
|
|
148
|
+
renderer?.toolLifecycle({ event: "start", label });
|
|
149
|
+
const result = await runToolCall(call, registry, ctx);
|
|
150
|
+
renderer?.toolLifecycle({ event: "end", label, ok: !result.is_error });
|
|
151
|
+
toolResults.push(result);
|
|
109
152
|
}
|
|
110
153
|
messages.push({ role: "user", content: toolResults });
|
|
111
154
|
}
|
|
112
155
|
logger.warn(`reached maxIterations (${maxIterations}) without completing`);
|
|
113
156
|
return { messages, iterations, stop: "max_iterations", usage };
|
|
114
157
|
}
|
|
158
|
+
/** Input keys worth surfacing in tool-call chrome, in preference order. */
|
|
159
|
+
const HINT_KEYS = [
|
|
160
|
+
"path",
|
|
161
|
+
"file_path",
|
|
162
|
+
"command",
|
|
163
|
+
"pattern",
|
|
164
|
+
"query",
|
|
165
|
+
"url",
|
|
166
|
+
"task",
|
|
167
|
+
];
|
|
168
|
+
/** Longest hint shown before truncation — chrome, not information of record. */
|
|
169
|
+
const HINT_MAX = 60;
|
|
170
|
+
/**
|
|
171
|
+
* A short human label for a tool call ("read_file src/x.ts"): the tool name
|
|
172
|
+
* plus the first recognizable scalar argument, if any. Best-effort — unknown
|
|
173
|
+
* shapes fall back to the bare name.
|
|
174
|
+
*/
|
|
175
|
+
function describeToolCall(call) {
|
|
176
|
+
const input = call.input;
|
|
177
|
+
if (typeof input === "object" && input !== null) {
|
|
178
|
+
for (const key of HINT_KEYS) {
|
|
179
|
+
const value = input[key];
|
|
180
|
+
if (typeof value === "string" && value !== "") {
|
|
181
|
+
const flat = value.replace(/\s+/g, " ").trim();
|
|
182
|
+
const hint = flat.length > HINT_MAX ? flat.slice(0, HINT_MAX - 1) + "…" : flat;
|
|
183
|
+
return `${call.name} ${hint}`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return call.name;
|
|
188
|
+
}
|
|
115
189
|
/**
|
|
116
190
|
* Dispatch a single reassembled tool call to its tool and shape the outcome as
|
|
117
191
|
* a `tool_result` block. Unknown tools and invalid arguments become `is_error`
|
package/dist/agent/prompts.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface PromptContext {
|
|
|
30
30
|
projectInstructions?: string | null;
|
|
31
31
|
/** Plan mode's propose phase (C.31): inject the plan-first directive. */
|
|
32
32
|
planMode?: boolean;
|
|
33
|
+
/** Subagent run (C.14): inject the bounded-subtask directive. */
|
|
34
|
+
subagent?: boolean;
|
|
33
35
|
}
|
|
34
36
|
/** Assemble the full system prompt for a session. */
|
|
35
37
|
export declare function buildSystemPrompt(ctx: PromptContext): string;
|
package/dist/agent/prompts.js
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
const PLAN_MODE_SECTION = `## Plan mode
|
|
11
11
|
You are in plan mode. Do NOT edit files, run commands, or take any side-effecting action yet.
|
|
12
12
|
First investigate with the read-only tools if you need to, then call \`submit_plan\` with an ordered list of steps — each with a title, a one-line rationale, and a kind (read | mutate | destructive). Cover the whole task; keep steps concrete and minimal. After you call \`submit_plan\`, stop and end your turn — the user reviews and approves the plan before you execute it.`;
|
|
13
|
+
/** The subagent directive (C.14), injected only into a spawned subagent's runs. */
|
|
14
|
+
const SUBAGENT_SECTION = `## Subagent context
|
|
15
|
+
You are a subagent: a scoped worker handling ONE bounded subtask for a parent agent, under a hard iteration and token budget. Only your final message is returned to the parent — its transcript does not include your intermediate steps. Therefore:
|
|
16
|
+
- Stay strictly within the given subtask; do not expand scope or start follow-on work.
|
|
17
|
+
- Work efficiently — prefer few, well-chosen tool calls over exhaustive exploration.
|
|
18
|
+
- End with a concise, self-contained summary of what you found or changed (concrete file paths, identifiers, outcomes). That summary IS your deliverable.`;
|
|
13
19
|
/**
|
|
14
20
|
* The static core of cruxy's behaviour. Phrased as direct instruction to the
|
|
15
21
|
* model. Keep this tight — every line earns its place; vague prose dilutes the
|
|
@@ -80,6 +86,8 @@ export function buildSystemPrompt(ctx) {
|
|
|
80
86
|
const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
|
|
81
87
|
if (ctx.planMode)
|
|
82
88
|
sections.push(PLAN_MODE_SECTION);
|
|
89
|
+
if (ctx.subagent)
|
|
90
|
+
sections.push(SUBAGENT_SECTION);
|
|
83
91
|
if (ctx.projectInstructions?.trim()) {
|
|
84
92
|
sections.push(`## Project instructions\nThe following came from this project's configuration; honor it unless it conflicts with the rules above:\n\n${ctx.projectInstructions.trim()}`);
|
|
85
93
|
}
|
package/dist/agent/session.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
|
+
import type { StreamRenderer } from "../render/index.js";
|
|
3
4
|
import type { ToolContext } from "../tools/index.js";
|
|
4
5
|
import type { ToolRegistry } from "../tools/index.js";
|
|
5
6
|
import { type AgentResult } from "./loop.js";
|
|
@@ -12,7 +13,7 @@ import { type AgentResult } from "./loop.js";
|
|
|
12
13
|
export type PlanRunner = (args: {
|
|
13
14
|
messages: Message[];
|
|
14
15
|
projectInstructions: string | null;
|
|
15
|
-
|
|
16
|
+
renderer?: StreamRenderer;
|
|
16
17
|
}) => Promise<AgentResult>;
|
|
17
18
|
export interface SessionArgs {
|
|
18
19
|
/** A constructed provider to stream from. */
|
|
@@ -76,10 +77,11 @@ export declare class Session {
|
|
|
76
77
|
* the threshold, drive the agent loop over the full history, adopt the
|
|
77
78
|
* extended history, and accumulate usage. Returns the turn's `AgentResult`.
|
|
78
79
|
*
|
|
79
|
-
* `
|
|
80
|
-
* caller
|
|
80
|
+
* `renderer`, when supplied, receives assistant text deltas and tool-call
|
|
81
|
+
* progress as they stream so the caller sees the turn live (see the REPL);
|
|
82
|
+
* history is unaffected.
|
|
81
83
|
*/
|
|
82
|
-
send(userPrompt: string,
|
|
84
|
+
send(userPrompt: string, renderer?: StreamRenderer): Promise<AgentResult>;
|
|
83
85
|
/**
|
|
84
86
|
* Re-read project instructions (CRUXY.md / AGENTS.md) from the working
|
|
85
87
|
* directory so edits take effect without restarting. Returns the new text, or
|
package/dist/agent/session.js
CHANGED
|
@@ -74,10 +74,11 @@ export class Session {
|
|
|
74
74
|
* the threshold, drive the agent loop over the full history, adopt the
|
|
75
75
|
* extended history, and accumulate usage. Returns the turn's `AgentResult`.
|
|
76
76
|
*
|
|
77
|
-
* `
|
|
78
|
-
* caller
|
|
77
|
+
* `renderer`, when supplied, receives assistant text deltas and tool-call
|
|
78
|
+
* progress as they stream so the caller sees the turn live (see the REPL);
|
|
79
|
+
* history is unaffected.
|
|
79
80
|
*/
|
|
80
|
-
async send(userPrompt,
|
|
81
|
+
async send(userPrompt, renderer) {
|
|
81
82
|
this.messages.push({ role: "user", content: userPrompt });
|
|
82
83
|
// Compact *before* the agent call so the turn runs against a bounded history.
|
|
83
84
|
await this.maybeCompact();
|
|
@@ -88,7 +89,7 @@ export class Session {
|
|
|
88
89
|
? await this.args.planRunner({
|
|
89
90
|
messages: this.messages,
|
|
90
91
|
projectInstructions: this.projectInstructions,
|
|
91
|
-
|
|
92
|
+
renderer,
|
|
92
93
|
})
|
|
93
94
|
: await runAgent({
|
|
94
95
|
messages: this.messages,
|
|
@@ -96,7 +97,7 @@ export class Session {
|
|
|
96
97
|
// After the spread so a mid-session `/reload` wins over the initial value.
|
|
97
98
|
projectInstructions: this.projectInstructions,
|
|
98
99
|
planMode: false, // the plan directive belongs only to the runner's propose phase
|
|
99
|
-
|
|
100
|
+
renderer,
|
|
100
101
|
});
|
|
101
102
|
this.messages = result.messages;
|
|
102
103
|
this.usage.input_tokens += result.usage.input_tokens;
|
|
@@ -18,6 +18,8 @@ export function classify(action, cwd) {
|
|
|
18
18
|
return shellRequest(action, root);
|
|
19
19
|
case "vcs":
|
|
20
20
|
return vcsRequest(action, root);
|
|
21
|
+
case "rollback":
|
|
22
|
+
return rollbackRequest(action, root);
|
|
21
23
|
default:
|
|
22
24
|
return {
|
|
23
25
|
action,
|
|
@@ -85,6 +87,30 @@ function vcsRequest(action, root) {
|
|
|
85
87
|
cwd: root,
|
|
86
88
|
};
|
|
87
89
|
}
|
|
90
|
+
// ── rollback (restore checkpoint) ──────────────────────────────────────────────
|
|
91
|
+
/**
|
|
92
|
+
* A checkpoint restore (C.32): overwrite the working tree with a pre-run
|
|
93
|
+
* snapshot. Always `destructive` (it reverts edits, deletes created files, and
|
|
94
|
+
* recreates deleted ones in one operation) and never session-grantable — scope
|
|
95
|
+
* `none`, so every rollback is a deliberate, one-off approval. The preview
|
|
96
|
+
* carries the full blast radius; the summary names the checkpoint.
|
|
97
|
+
*/
|
|
98
|
+
function rollbackRequest(action, root) {
|
|
99
|
+
const preview = action.preview?.type === "rollback" ? action.preview : undefined;
|
|
100
|
+
const summary = preview
|
|
101
|
+
? `rollback: restore checkpoint ${preview.checkpointId} (${preview.files.length} file${preview.files.length === 1 ? "" : "s"})`
|
|
102
|
+
: "rollback: restore a checkpoint";
|
|
103
|
+
return {
|
|
104
|
+
action,
|
|
105
|
+
tier: "destructive",
|
|
106
|
+
scope: { kind: "none" },
|
|
107
|
+
summary,
|
|
108
|
+
targets: preview
|
|
109
|
+
? preview.files.map((f) => path.resolve(root, f.path))
|
|
110
|
+
: [],
|
|
111
|
+
cwd: root,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
88
114
|
// ── file (write / edit / patch) ────────────────────────────────────────────────
|
|
89
115
|
function fileRequest(action, tier, root) {
|
|
90
116
|
const targets = fileTargets(action, root);
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { ApprovalRequest } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The interactive prompt: render a pending action (a real diff for file edits,
|
|
4
|
+
* the exact command + cwd for shell) and read a 4-way choice. Rendering is data
|
|
5
|
+
* → string so it's testable; color is gated on `io.color` (NO_COLOR / non-TTY
|
|
6
|
+
* aware). **Default-deny**: EOF / Ctrl-C / any unrecognized key → reject.
|
|
7
|
+
*
|
|
8
|
+
* Diff/preview rendering is the shared implementation in `render/diff.ts` —
|
|
9
|
+
* the streaming path and this prompt draw the same bytes for the same change.
|
|
10
|
+
*/
|
|
2
11
|
/** The four user choices (plus the implicit default-deny). */
|
|
3
12
|
export type PromptChoice = {
|
|
4
13
|
kind: "once";
|
package/dist/approval/prompt.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import pc from "picocolors";
|
|
3
|
-
|
|
4
|
-
const PREVIEW_MAX_LINES = 40;
|
|
3
|
+
import { renderActionPreview } from "../render/diff.js";
|
|
5
4
|
/**
|
|
6
5
|
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
7
6
|
* read a follow-up line (reason / instruction). Anything else — including EOF —
|
|
@@ -53,7 +52,7 @@ function detail(request, c) {
|
|
|
53
52
|
` ${c.dim(`in ${request.cwd}`)}`,
|
|
54
53
|
].join("\n");
|
|
55
54
|
}
|
|
56
|
-
return
|
|
55
|
+
return renderActionPreview(request.action.preview, c);
|
|
57
56
|
}
|
|
58
57
|
/** The choices line, including a short label of what an `a` grant would cover. */
|
|
59
58
|
function choices(scope, c) {
|
|
@@ -71,80 +70,6 @@ function scopeLabel(scope) {
|
|
|
71
70
|
return `changes under ${path.basename(scope.root)}/`;
|
|
72
71
|
return null;
|
|
73
72
|
}
|
|
74
|
-
// ── diff rendering (shared with the old C.6 renderer) ──────────────────────────
|
|
75
|
-
function diffLines(oldStr, newStr, c) {
|
|
76
|
-
const removed = oldStr.split("\n").map((l) => c.red(`- ${l}`));
|
|
77
|
-
const added = newStr.split("\n").map((l) => c.green(`+ ${l}`));
|
|
78
|
-
return [...removed, ...added];
|
|
79
|
-
}
|
|
80
|
-
function renderPatchFiles(files, c) {
|
|
81
|
-
const out = [];
|
|
82
|
-
for (const file of files) {
|
|
83
|
-
if (file.op === "delete") {
|
|
84
|
-
out.push(c.red(`delete ${file.path}`));
|
|
85
|
-
}
|
|
86
|
-
else if (file.op === "create") {
|
|
87
|
-
out.push(c.green(`create ${file.path}`));
|
|
88
|
-
out.push(...file.lines.map((l) => c.green(`+ ${l}`)));
|
|
89
|
-
if (file.omittedLines > 0)
|
|
90
|
-
out.push(c.dim(` ...${file.omittedLines} more lines`));
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
out.push(c.yellow(`update ${file.path}`));
|
|
94
|
-
for (const hunk of file.hunks)
|
|
95
|
-
out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return out;
|
|
99
|
-
}
|
|
100
|
-
/** Render a `vcs` pull-request publish plan: branch, commit, and PR body. */
|
|
101
|
-
function renderPrPreview(preview, c) {
|
|
102
|
-
const out = [];
|
|
103
|
-
out.push(`${c.bold("branch")} ${c.green(preview.branch)} → ${preview.base}`);
|
|
104
|
-
out.push("");
|
|
105
|
-
out.push(c.bold("commit"));
|
|
106
|
-
out.push(` ${preview.commitSubject}`);
|
|
107
|
-
for (const line of bodyLines(preview.commitBody))
|
|
108
|
-
out.push(c.dim(` ${line}`));
|
|
109
|
-
out.push("");
|
|
110
|
-
out.push(`${c.bold("pull request")} ${preview.prTitle}`);
|
|
111
|
-
for (const line of bodyLines(preview.prBody))
|
|
112
|
-
out.push(c.dim(` ${line}`));
|
|
113
|
-
return out;
|
|
114
|
-
}
|
|
115
|
-
/** Split a multi-line body into trimmed-of-trailing lines, dropping a trailing blank. */
|
|
116
|
-
function bodyLines(body) {
|
|
117
|
-
const lines = body.replace(/\s+$/, "").split("\n");
|
|
118
|
-
return lines.length === 1 && lines[0] === "" ? [] : lines;
|
|
119
|
-
}
|
|
120
|
-
function renderPreview(preview, c) {
|
|
121
|
-
if (!preview)
|
|
122
|
-
return "";
|
|
123
|
-
let lines;
|
|
124
|
-
if (preview.type === "edit") {
|
|
125
|
-
lines = diffLines(preview.oldStr, preview.newStr, c);
|
|
126
|
-
}
|
|
127
|
-
else if (preview.type === "patch") {
|
|
128
|
-
lines = renderPatchFiles(preview.files, c);
|
|
129
|
-
}
|
|
130
|
-
else if (preview.type === "pr") {
|
|
131
|
-
lines = renderPrPreview(preview, c);
|
|
132
|
-
}
|
|
133
|
-
else {
|
|
134
|
-
const header = preview.exists
|
|
135
|
-
? c.yellow("OVERWRITE existing")
|
|
136
|
-
: c.green("create");
|
|
137
|
-
const body = preview.lines.map((l) => ` ${l}`);
|
|
138
|
-
if (preview.omittedLines > 0)
|
|
139
|
-
body.push(c.dim(` ...${preview.omittedLines} more lines`));
|
|
140
|
-
lines = [header, ...body];
|
|
141
|
-
}
|
|
142
|
-
if (lines.length > PREVIEW_MAX_LINES) {
|
|
143
|
-
const hidden = lines.length - PREVIEW_MAX_LINES;
|
|
144
|
-
lines = [...lines.slice(0, PREVIEW_MAX_LINES), c.dim(`...${hidden} more`)];
|
|
145
|
-
}
|
|
146
|
-
return lines.map((l) => ` ${l}`).join("\n");
|
|
147
|
-
}
|
|
148
73
|
// ── default stdin-backed PromptIO ──────────────────────────────────────────────
|
|
149
74
|
/** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
|
|
150
75
|
export function defaultPromptIO(color) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CaptureFile } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Snapshot-scope enumeration (C.32): every regular file the agent could touch —
|
|
4
|
+
* tracked + untracked-non-ignored — and nothing it must never see:
|
|
5
|
+
* • gitignored paths (they are not the run's undo unit and may be huge),
|
|
6
|
+
* • the C.17 secrets denylist ({@link isSecretPath} — a checkpoint must never
|
|
7
|
+
* copy a secret into `.cruxy/` or the git object DB),
|
|
8
|
+
* • `.cruxy/` itself (a checkpoint of the checkpoints would recurse),
|
|
9
|
+
* • symlinks and other non-regular files (restore writes plain files only).
|
|
10
|
+
*
|
|
11
|
+
* In a git repo the file list comes from `git ls-files` (read-only), which
|
|
12
|
+
* honors `.gitignore`, `.git/info/exclude`, and the user's global excludes
|
|
13
|
+
* exactly. Outside a repo, the indexing walker enumerates with its gitignore
|
|
14
|
+
* emulation — with binaries included and no size cap, because a snapshot that
|
|
15
|
+
* skips files cannot restore them.
|
|
16
|
+
*/
|
|
17
|
+
export declare function captureFiles(root: string, gitWorkTree: boolean): Promise<CaptureFile[]>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { promises as fsp } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { runGitCapture } from "../vcs/git.js";
|
|
4
|
+
import { isSecretPath, walkRepo } from "../indexing/walker.js";
|
|
5
|
+
import { GLOBAL_DIR_NAME } from "../constants.js";
|
|
6
|
+
/**
|
|
7
|
+
* Snapshot-scope enumeration (C.32): every regular file the agent could touch —
|
|
8
|
+
* tracked + untracked-non-ignored — and nothing it must never see:
|
|
9
|
+
* • gitignored paths (they are not the run's undo unit and may be huge),
|
|
10
|
+
* • the C.17 secrets denylist ({@link isSecretPath} — a checkpoint must never
|
|
11
|
+
* copy a secret into `.cruxy/` or the git object DB),
|
|
12
|
+
* • `.cruxy/` itself (a checkpoint of the checkpoints would recurse),
|
|
13
|
+
* • symlinks and other non-regular files (restore writes plain files only).
|
|
14
|
+
*
|
|
15
|
+
* In a git repo the file list comes from `git ls-files` (read-only), which
|
|
16
|
+
* honors `.gitignore`, `.git/info/exclude`, and the user's global excludes
|
|
17
|
+
* exactly. Outside a repo, the indexing walker enumerates with its gitignore
|
|
18
|
+
* emulation — with binaries included and no size cap, because a snapshot that
|
|
19
|
+
* skips files cannot restore them.
|
|
20
|
+
*/
|
|
21
|
+
export async function captureFiles(root, gitWorkTree) {
|
|
22
|
+
const absRoot = path.resolve(root);
|
|
23
|
+
const candidates = gitWorkTree
|
|
24
|
+
? await gitCandidates(absRoot)
|
|
25
|
+
: await walkerCandidates(absRoot);
|
|
26
|
+
const files = [];
|
|
27
|
+
for (const relPath of candidates) {
|
|
28
|
+
if (relPath === "" || isExcluded(relPath))
|
|
29
|
+
continue;
|
|
30
|
+
const absPath = path.join(absRoot, ...relPath.split("/"));
|
|
31
|
+
// lstat: a symlink must be seen as a symlink, not its target.
|
|
32
|
+
let stat;
|
|
33
|
+
try {
|
|
34
|
+
stat = await fsp.lstat(absPath);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
continue; // listed but gone (e.g. tracked file deleted from the worktree)
|
|
38
|
+
}
|
|
39
|
+
if (!stat.isFile())
|
|
40
|
+
continue;
|
|
41
|
+
files.push({ path: relPath, absPath });
|
|
42
|
+
}
|
|
43
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
44
|
+
return files;
|
|
45
|
+
}
|
|
46
|
+
/** Never capture cruxy's own state dir or a secret-bearing path. */
|
|
47
|
+
function isExcluded(relPath) {
|
|
48
|
+
return (relPath === GLOBAL_DIR_NAME ||
|
|
49
|
+
relPath.startsWith(`${GLOBAL_DIR_NAME}/`) ||
|
|
50
|
+
isSecretPath(relPath));
|
|
51
|
+
}
|
|
52
|
+
/** Tracked + untracked-non-ignored, straight from git (paths relative to root). */
|
|
53
|
+
async function gitCandidates(absRoot) {
|
|
54
|
+
const res = runGitCapture(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], absRoot);
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
// The caller decided this is a work tree; a failing ls-files means git is
|
|
57
|
+
// in a state we can't reason about — let the store fallback handle it.
|
|
58
|
+
throw new Error(`git ls-files failed: ${res.stderr.trim()}`);
|
|
59
|
+
}
|
|
60
|
+
// -z output: NUL-separated, no quoting, trailing NUL yields one empty entry.
|
|
61
|
+
return [...new Set(res.stdout.split("\0"))];
|
|
62
|
+
}
|
|
63
|
+
/** Walker enumeration for non-git dirs: gitignore-style ignores, no content filters. */
|
|
64
|
+
async function walkerCandidates(absRoot) {
|
|
65
|
+
const out = [];
|
|
66
|
+
for await (const entry of walkRepo(absRoot, {
|
|
67
|
+
maxFileBytes: Number.MAX_SAFE_INTEGER,
|
|
68
|
+
includeBinary: true,
|
|
69
|
+
})) {
|
|
70
|
+
out.push(entry.relPath);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|