@cruxy/cli 0.14.0 → 0.17.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/loop.d.ts +26 -0
- package/dist/agent/loop.js +59 -3
- package/dist/agent/session.d.ts +17 -1
- package/dist/agent/session.js +23 -2
- package/dist/brand/index.d.ts +1 -0
- package/dist/brand/index.js +1 -0
- package/dist/brand/voice.d.ts +94 -0
- package/dist/brand/voice.js +127 -0
- package/dist/cli/commands/checkpoint.js +1 -1
- package/dist/cli/commands/hooks.d.ts +8 -0
- package/dist/cli/commands/hooks.js +83 -0
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/pr.js +10 -2
- package/dist/cli/commands/rollback.js +1 -1
- package/dist/cli/commands/run.js +13 -3
- package/dist/cli/commands/skills.js +2 -2
- package/dist/cli/program.js +5 -2
- package/dist/cli/repl.d.ts +2 -1
- package/dist/cli/repl.js +54 -3
- package/dist/cli/session-factory.d.ts +2 -2
- package/dist/cli/session-factory.js +13 -2
- package/dist/config/schema.d.ts +139 -46
- package/dist/config/schema.js +42 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.js +9 -0
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +98 -6
- package/dist/errors/types.d.ts +14 -0
- package/dist/errors/types.js +23 -0
- package/dist/hooks/config.d.ts +21 -0
- package/dist/hooks/config.js +253 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +6 -0
- package/dist/hooks/runner.d.ts +76 -0
- package/dist/hooks/runner.js +114 -0
- package/dist/hooks/service.d.ts +38 -0
- package/dist/hooks/service.js +49 -0
- package/dist/hooks/slash.d.ts +48 -0
- package/dist/hooks/slash.js +58 -0
- package/dist/hooks/trust.d.ts +46 -0
- package/dist/hooks/trust.js +106 -0
- package/dist/hooks/types.d.ts +147 -0
- package/dist/hooks/types.js +61 -0
- package/dist/onboarding/steps.js +1 -1
- package/dist/plan/service.d.ts +6 -0
- package/dist/plan/service.js +4 -0
- package/dist/render/state.js +4 -1
- package/dist/render/types.d.ts +7 -1
- package/dist/routing/index.d.ts +2 -0
- package/dist/routing/index.js +5 -0
- package/dist/routing/resolve.d.ts +17 -0
- package/dist/routing/resolve.js +18 -0
- package/dist/routing/router.d.ts +47 -0
- package/dist/routing/router.js +84 -0
- package/dist/routing/types.d.ts +42 -0
- package/dist/routing/types.js +27 -0
- package/dist/subagent/orchestrator.d.ts +6 -0
- package/dist/subagent/orchestrator.js +2 -0
- package/dist/subagent/types.d.ts +6 -0
- package/dist/tools/shell/exec.d.ts +53 -0
- package/dist/tools/shell/exec.js +128 -0
- package/dist/tools/shell/run-command.d.ts +4 -0
- package/dist/tools/shell/run-command.js +26 -116
- package/dist/vcs/generate.d.ts +3 -1
- package/dist/vcs/generate.js +4 -1
- package/package.json +2 -2
package/dist/onboarding/steps.js
CHANGED
|
@@ -17,7 +17,7 @@ const c = (io) => themeForColor(io.color);
|
|
|
17
17
|
*/
|
|
18
18
|
export async function acquireKeyStep(io, deps, provider) {
|
|
19
19
|
const col = c(io);
|
|
20
|
-
io.write(`\nYou'll need a
|
|
20
|
+
io.write(`\nYou'll need a cruxy API key. Create one at ${col.accent(CREATE_KEY_URL)}\n`);
|
|
21
21
|
for (let attempt = 1; attempt <= MAX_KEY_ATTEMPTS; attempt++) {
|
|
22
22
|
io.write(col.strong("Paste your API key: "));
|
|
23
23
|
const key = (await io.readSecret()).trim();
|
package/dist/plan/service.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { PromptIO } from "../approval/index.js";
|
|
|
4
4
|
import { ToolRegistry, type ToolContext } from "../tools/index.js";
|
|
5
5
|
import { type AgentResult } from "../agent/loop.js";
|
|
6
6
|
import type { StreamRenderer } from "../render/index.js";
|
|
7
|
+
import type { Router } from "../routing/index.js";
|
|
7
8
|
import { PlanExecutionPolicy } from "./policy.js";
|
|
8
9
|
/**
|
|
9
10
|
* Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
|
|
@@ -36,5 +37,10 @@ export interface PlanSessionArgs {
|
|
|
36
37
|
renderer?: StreamRenderer;
|
|
37
38
|
/** Revision cap (defaults to {@link MAX_PLAN_REVISIONS}). */
|
|
38
39
|
maxRevisions?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Multi-model routing (C.30). When set, the propose phase routes on `plan`
|
|
42
|
+
* and step execution on `main-turn`; omitted → the provider default.
|
|
43
|
+
*/
|
|
44
|
+
router?: Router;
|
|
39
45
|
}
|
|
40
46
|
export declare function runPlanSession(args: PlanSessionArgs): Promise<AgentResult>;
|
package/dist/plan/service.js
CHANGED
|
@@ -75,6 +75,8 @@ export async function runPlanSession(args) {
|
|
|
75
75
|
projectInstructions: args.projectInstructions,
|
|
76
76
|
renderer: args.renderer,
|
|
77
77
|
planMode: true,
|
|
78
|
+
router: args.router,
|
|
79
|
+
taskClass: "plan",
|
|
78
80
|
}));
|
|
79
81
|
if (!holder.plan) {
|
|
80
82
|
throw planInvalid("the model ended its turn without calling submit_plan");
|
|
@@ -106,6 +108,8 @@ export async function runPlanSession(args) {
|
|
|
106
108
|
git: args.git,
|
|
107
109
|
projectInstructions: args.projectInstructions,
|
|
108
110
|
renderer: args.renderer,
|
|
111
|
+
router: args.router,
|
|
112
|
+
taskClass: "main-turn",
|
|
109
113
|
}));
|
|
110
114
|
};
|
|
111
115
|
await executePlan(plan, {
|
package/dist/render/state.js
CHANGED
|
@@ -39,9 +39,12 @@ export function describePhase(phase, glyph = UNICODE_GLYPHS) {
|
|
|
39
39
|
case "thinking": {
|
|
40
40
|
const t = phase.tokens;
|
|
41
41
|
// Honest numbers only: no usage yet → no figure at all.
|
|
42
|
-
|
|
42
|
+
const base = t && t.input + t.output > 0
|
|
43
43
|
? `thinking${e} ${glyph.sep} tokens ${glyph.caretUp}${formatTokens(t.input)} ${glyph.caretDown}${formatTokens(t.output)}`
|
|
44
44
|
: `thinking${e}`;
|
|
45
|
+
// The real routing tier (C.30), appended honestly when present — same
|
|
46
|
+
// ` · ` joiner as the token counts.
|
|
47
|
+
return phase.tier ? `${base} ${glyph.sep} ${phase.tier}` : base;
|
|
45
48
|
}
|
|
46
49
|
case "calling-tool":
|
|
47
50
|
return `${phase.label}${e}`;
|
package/dist/render/types.d.ts
CHANGED
|
@@ -53,10 +53,16 @@ export interface TokenUsage {
|
|
|
53
53
|
* One phase is live at a time — it is a register, not a queue.
|
|
54
54
|
*/
|
|
55
55
|
export type RenderPhase =
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Waiting on the model. `tokens` = usage accumulated so far, omitted at 0.
|
|
58
|
+
* `tier` = the routing tier this turn runs on (C.30), shown only when routing
|
|
59
|
+
* is active — an honest signal of the real tier, never a fabricated one, and
|
|
60
|
+
* always a tier name (never an upstream model id, U.8).
|
|
61
|
+
*/
|
|
57
62
|
{
|
|
58
63
|
kind: "thinking";
|
|
59
64
|
tokens?: TokenUsage;
|
|
65
|
+
tier?: string;
|
|
60
66
|
}
|
|
61
67
|
/** A tool call is executing; `label` is the human form ("read_file src/x.ts"). */
|
|
62
68
|
| {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export { ConfigRouter, DEFAULT_TIER, routerForConfig, resolveTaskModel, } from "./router.js";
|
|
3
|
+
// `resolve.ts` (tier → wire model-id) is deliberately NOT re-exported: the
|
|
4
|
+
// mapping is internal to routing, so it can never be reached from a user-facing
|
|
5
|
+
// render path (the internal-mapping-isolation guarantee, U.8).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Tier } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tier → gateway wire model-id. This is the SINGLE place the mapping lives, and
|
|
4
|
+
* it is INTERNAL to the routing package (deliberately not re-exported from the
|
|
5
|
+
* barrel) so it can never be called from a user-facing render path.
|
|
6
|
+
*
|
|
7
|
+
* For the Cruxy gateway a tier IS the wire model id — the gateway maps the tier
|
|
8
|
+
* to a concrete upstream model SERVER-SIDE. So this returns the tier name
|
|
9
|
+
* unchanged: the output is always a tier, and no upstream model name can
|
|
10
|
+
* originate here. That property is what keeps the U.8 gag structural rather than
|
|
11
|
+
* a filter — there is no upstream id in the process to leak (see the tier-gag
|
|
12
|
+
* test, which asserts this output is always a MODEL_TIERS member).
|
|
13
|
+
*
|
|
14
|
+
* The seam exists for a future provider whose tiers map to distinct wire ids;
|
|
15
|
+
* that mapping would live here and here only.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveModelId(tier: Tier): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier → gateway wire model-id. This is the SINGLE place the mapping lives, and
|
|
3
|
+
* it is INTERNAL to the routing package (deliberately not re-exported from the
|
|
4
|
+
* barrel) so it can never be called from a user-facing render path.
|
|
5
|
+
*
|
|
6
|
+
* For the Cruxy gateway a tier IS the wire model id — the gateway maps the tier
|
|
7
|
+
* to a concrete upstream model SERVER-SIDE. So this returns the tier name
|
|
8
|
+
* unchanged: the output is always a tier, and no upstream model name can
|
|
9
|
+
* originate here. That property is what keeps the U.8 gag structural rather than
|
|
10
|
+
* a filter — there is no upstream id in the process to leak (see the tier-gag
|
|
11
|
+
* test, which asserts this output is always a MODEL_TIERS member).
|
|
12
|
+
*
|
|
13
|
+
* The seam exists for a future provider whose tiers map to distinct wire ids;
|
|
14
|
+
* that mapping would live here and here only.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveModelId(tier) {
|
|
17
|
+
return tier;
|
|
18
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { Router, RoutingConfig, TaskClass, Tier } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The tier a config resolves to when nothing else pins one down — mirrors the
|
|
5
|
+
* gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
|
|
6
|
+
* cruxy session lands on exactly the tier it does today.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_TIER: Tier;
|
|
9
|
+
/**
|
|
10
|
+
* The config-driven {@link Router}: maps a declared task class to a tier from
|
|
11
|
+
* `{ default, map }`, and fails loud when the resolved tier is not offered. It
|
|
12
|
+
* NEVER inspects prompt content — selection is purely `map[taskClass] ?? default`.
|
|
13
|
+
*/
|
|
14
|
+
export declare class ConfigRouter implements Router {
|
|
15
|
+
private readonly cfg;
|
|
16
|
+
private readonly offered;
|
|
17
|
+
/**
|
|
18
|
+
* @param cfg the resolved routing table (default tier + per-task map)
|
|
19
|
+
* @param offered the tiers this gateway/plan actually provides; a resolved
|
|
20
|
+
* tier outside this set fails loud. Defaults to all tiers — the
|
|
21
|
+
* seam a future entitlement check narrows (never a silent
|
|
22
|
+
* downgrade).
|
|
23
|
+
*/
|
|
24
|
+
constructor(cfg: RoutingConfig, offered?: Iterable<Tier>);
|
|
25
|
+
select(taskClass: TaskClass): Tier;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build a router from resolved config, or `null` when routing should stay
|
|
29
|
+
* inert. Routing is:
|
|
30
|
+
*
|
|
31
|
+
* - a cruxy-gateway concept — tiers do not apply to BYO providers, so non-cruxy
|
|
32
|
+
* providers get `null` (no override, their `model.model` is used unchanged);
|
|
33
|
+
* - opt-in — with no `routing.default` and an empty `routing.map`, this returns
|
|
34
|
+
* `null` so behavior (and the wire body, and the state line) is byte-identical
|
|
35
|
+
* to today. Multi-tier routing activates only once the user configures it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function routerForConfig(config: CruxyConfig): Router | null;
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a declared task class to `{ tier, model }`: the tier for honest
|
|
40
|
+
* surfacing (the U.4 state line), the wire model id for the request. The model
|
|
41
|
+
* id comes from the internal {@link resolveModelId} — callers never touch that
|
|
42
|
+
* mapping directly, so it stays the single source of truth.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveTaskModel(router: Router, taskClass: TaskClass): {
|
|
45
|
+
tier: Tier;
|
|
46
|
+
model: string;
|
|
47
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { MODEL_TIERS } from "../brand/voice.js";
|
|
2
|
+
import { routingTierUnavailable } from "../errors/index.js";
|
|
3
|
+
import { resolveModelId } from "./resolve.js";
|
|
4
|
+
/**
|
|
5
|
+
* The tier a config resolves to when nothing else pins one down — mirrors the
|
|
6
|
+
* gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
|
|
7
|
+
* cruxy session lands on exactly the tier it does today.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_TIER = "vaani";
|
|
10
|
+
/**
|
|
11
|
+
* The config-driven {@link Router}: maps a declared task class to a tier from
|
|
12
|
+
* `{ default, map }`, and fails loud when the resolved tier is not offered. It
|
|
13
|
+
* NEVER inspects prompt content — selection is purely `map[taskClass] ?? default`.
|
|
14
|
+
*/
|
|
15
|
+
export class ConfigRouter {
|
|
16
|
+
cfg;
|
|
17
|
+
offered;
|
|
18
|
+
/**
|
|
19
|
+
* @param cfg the resolved routing table (default tier + per-task map)
|
|
20
|
+
* @param offered the tiers this gateway/plan actually provides; a resolved
|
|
21
|
+
* tier outside this set fails loud. Defaults to all tiers — the
|
|
22
|
+
* seam a future entitlement check narrows (never a silent
|
|
23
|
+
* downgrade).
|
|
24
|
+
*/
|
|
25
|
+
constructor(cfg, offered = MODEL_TIERS) {
|
|
26
|
+
this.cfg = cfg;
|
|
27
|
+
this.offered = new Set(offered);
|
|
28
|
+
}
|
|
29
|
+
select(taskClass) {
|
|
30
|
+
// Explicit override, else the default — an unmapped/unknown class is not an
|
|
31
|
+
// error, it just takes the default (never a crash, never the cheapest).
|
|
32
|
+
const tier = this.cfg.map[taskClass] ?? this.cfg.default;
|
|
33
|
+
// Fail loud: a configured tier the gateway does not offer is a usage error
|
|
34
|
+
// to fix, NOT a silent substitution to some other tier (a user who asked for
|
|
35
|
+
// mira reasoning must never be quietly handed kavi).
|
|
36
|
+
if (!this.offered.has(tier)) {
|
|
37
|
+
throw routingTierUnavailable(tier, taskClass, [...this.offered]);
|
|
38
|
+
}
|
|
39
|
+
return tier;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The base tier implied by the session's `model.model`: a real tier passes
|
|
44
|
+
* through; `auto` (and any non-tier value) falls back to {@link DEFAULT_TIER}.
|
|
45
|
+
* Used so that when a user has pinned a single tier, an opt-in routing table
|
|
46
|
+
* that omits `routing.default` still defaults to THEIR tier, not a fixed one.
|
|
47
|
+
*/
|
|
48
|
+
function baseTierFromModel(model) {
|
|
49
|
+
return MODEL_TIERS.includes(model)
|
|
50
|
+
? model
|
|
51
|
+
: DEFAULT_TIER;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Build a router from resolved config, or `null` when routing should stay
|
|
55
|
+
* inert. Routing is:
|
|
56
|
+
*
|
|
57
|
+
* - a cruxy-gateway concept — tiers do not apply to BYO providers, so non-cruxy
|
|
58
|
+
* providers get `null` (no override, their `model.model` is used unchanged);
|
|
59
|
+
* - opt-in — with no `routing.default` and an empty `routing.map`, this returns
|
|
60
|
+
* `null` so behavior (and the wire body, and the state line) is byte-identical
|
|
61
|
+
* to today. Multi-tier routing activates only once the user configures it.
|
|
62
|
+
*/
|
|
63
|
+
export function routerForConfig(config) {
|
|
64
|
+
if (config.model.provider !== "cruxy")
|
|
65
|
+
return null;
|
|
66
|
+
const { default: def, map } = config.routing;
|
|
67
|
+
const configured = def !== undefined || Object.keys(map).length > 0;
|
|
68
|
+
if (!configured)
|
|
69
|
+
return null;
|
|
70
|
+
return new ConfigRouter({
|
|
71
|
+
default: def ?? baseTierFromModel(config.model.model),
|
|
72
|
+
map,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a declared task class to `{ tier, model }`: the tier for honest
|
|
77
|
+
* surfacing (the U.4 state line), the wire model id for the request. The model
|
|
78
|
+
* id comes from the internal {@link resolveModelId} — callers never touch that
|
|
79
|
+
* mapping directly, so it stays the single source of truth.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveTaskModel(router, taskClass) {
|
|
82
|
+
const tier = router.select(taskClass);
|
|
83
|
+
return { tier, model: resolveModelId(tier) };
|
|
84
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { MODEL_TIERS } from "../brand/voice.js";
|
|
2
|
+
/**
|
|
3
|
+
* Multi-model routing (C.30): route each unit of work to the right tier instead
|
|
4
|
+
* of running one model for everything. The caller DECLARES a {@link TaskClass}
|
|
5
|
+
* at the call site; a {@link Router} maps that class to a {@link Tier} via config
|
|
6
|
+
* — it never sniffs the prompt to guess difficulty. The tier→gateway model-id
|
|
7
|
+
* mapping is internal (see `resolve.ts`); only tier names ever appear in config,
|
|
8
|
+
* logs, errors, or the state line (the U.8 tier gag).
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The unit-of-work classes a caller can declare. Each is an explicit intent —
|
|
12
|
+
* NOT a difficulty the router infers. Unknown/unset resolves to the router's
|
|
13
|
+
* default tier, never a crash.
|
|
14
|
+
*/
|
|
15
|
+
export declare const TASK_CLASSES: readonly ["main-turn", "subagent", "plan", "commit-msg", "classify", "summarize"];
|
|
16
|
+
export type TaskClass = (typeof TASK_CLASSES)[number];
|
|
17
|
+
/** A routing tier — the ONLY model vocabulary the user ever sees (U.8). */
|
|
18
|
+
export type Tier = (typeof MODEL_TIERS)[number];
|
|
19
|
+
/**
|
|
20
|
+
* Selects a tier for a declared task class. Deliberately one method: the caller
|
|
21
|
+
* passes intent, the router returns a tier from its configured mapping. No
|
|
22
|
+
* prompt-content inspection, ever — difficulty detection is explicitly out of
|
|
23
|
+
* scope (a caller declares; the router does not guess).
|
|
24
|
+
*/
|
|
25
|
+
export interface Router {
|
|
26
|
+
/**
|
|
27
|
+
* Map a task class to its tier. Falls back to the configured default when the
|
|
28
|
+
* class has no explicit mapping; throws `CRUXY_E_ROUTING_TIER_UNAVAILABLE`
|
|
29
|
+
* (fail loud, never a silent substitution) when the resolved tier is not
|
|
30
|
+
* offered.
|
|
31
|
+
*/
|
|
32
|
+
select(taskClass: TaskClass): Tier;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The declarative routing table: a `default` tier plus per-task overrides. Lives
|
|
36
|
+
* in config (`routing.default`, `routing.map`). An empty map means every class
|
|
37
|
+
* resolves to `default` — a single tier, no traffic splitting.
|
|
38
|
+
*/
|
|
39
|
+
export interface RoutingConfig {
|
|
40
|
+
default: Tier;
|
|
41
|
+
map: Partial<Record<TaskClass, Tier>>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-model routing (C.30): route each unit of work to the right tier instead
|
|
3
|
+
* of running one model for everything. The caller DECLARES a {@link TaskClass}
|
|
4
|
+
* at the call site; a {@link Router} maps that class to a {@link Tier} via config
|
|
5
|
+
* — it never sniffs the prompt to guess difficulty. The tier→gateway model-id
|
|
6
|
+
* mapping is internal (see `resolve.ts`); only tier names ever appear in config,
|
|
7
|
+
* logs, errors, or the state line (the U.8 tier gag).
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The unit-of-work classes a caller can declare. Each is an explicit intent —
|
|
11
|
+
* NOT a difficulty the router infers. Unknown/unset resolves to the router's
|
|
12
|
+
* default tier, never a crash.
|
|
13
|
+
*/
|
|
14
|
+
export const TASK_CLASSES = [
|
|
15
|
+
/** An interactive main-agent turn. */
|
|
16
|
+
"main-turn",
|
|
17
|
+
/** A spawned subagent task (C.14). */
|
|
18
|
+
"subagent",
|
|
19
|
+
/** Plan proposal / revision (C.31). */
|
|
20
|
+
"plan",
|
|
21
|
+
/** Commit / pull-request text generation. */
|
|
22
|
+
"commit-msg",
|
|
23
|
+
/** A cheap one-shot classification. */
|
|
24
|
+
"classify",
|
|
25
|
+
/** Context compaction / summarization. */
|
|
26
|
+
"summarize",
|
|
27
|
+
];
|
|
@@ -2,6 +2,7 @@ import type { Provider } from "@cruxy/sdk";
|
|
|
2
2
|
import type { ApprovalDecision } from "../approval/types.js";
|
|
3
3
|
import type { CruxyConfig } from "../config/index.js";
|
|
4
4
|
import type { StreamRenderer } from "../render/index.js";
|
|
5
|
+
import type { Router } from "../routing/index.js";
|
|
5
6
|
import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
|
|
6
7
|
import type { SandboxService } from "../sandbox/index.js";
|
|
7
8
|
import type { SubagentResult, SubagentSpec } from "./types.js";
|
|
@@ -15,6 +16,11 @@ import type { SubagentResult, SubagentSpec } from "./types.js";
|
|
|
15
16
|
export interface SubagentOrchestratorDeps {
|
|
16
17
|
provider: Provider;
|
|
17
18
|
config: CruxyConfig;
|
|
19
|
+
/**
|
|
20
|
+
* Multi-model routing (C.30). When set, child runs route on their spec's task
|
|
21
|
+
* class (default `subagent`); omitted → the provider default (unchanged).
|
|
22
|
+
*/
|
|
23
|
+
router?: Router;
|
|
18
24
|
/** The parent's registry — the ceiling every child scope derives from. */
|
|
19
25
|
parentRegistry: ToolRegistry;
|
|
20
26
|
cwd: string;
|
package/dist/subagent/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Usage } from "@cruxy/sdk";
|
|
2
|
+
import type { TaskClass } from "../routing/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* Types for subagent orchestration (C.14): the main agent delegates a bounded
|
|
4
5
|
* subtask to a child agent that runs the SAME loop with its own fresh history,
|
|
@@ -35,6 +36,11 @@ export interface SubagentSpec {
|
|
|
35
36
|
* spawn can narrow its budget, never raise it past the configured ceilings.
|
|
36
37
|
*/
|
|
37
38
|
budget?: Partial<BudgetLimits>;
|
|
39
|
+
/**
|
|
40
|
+
* Routing task class for this spawn (C.30); defaults to `subagent`. A
|
|
41
|
+
* declaration at the spawn call site — not something the router guesses.
|
|
42
|
+
*/
|
|
43
|
+
taskClass?: TaskClass;
|
|
38
44
|
}
|
|
39
45
|
/**
|
|
40
46
|
* What the parent gets back — compact structured data, never the transcript.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ToolContext } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The ONE gated + sandboxed shell path (C.16 + C.19). Both `run_command` and the
|
|
4
|
+
* C.19 hook runner funnel through {@link runGatedShell}: a command reaches
|
|
5
|
+
* execution only after passing the SAME `ctx.requestApproval` gate and only
|
|
6
|
+
* through the SAME `ctx.sandbox` (or host) substrate. There is deliberately no
|
|
7
|
+
* second exec route, so a hook can never get a privileged path — proven by the
|
|
8
|
+
* fact that both callers invoke this exact function.
|
|
9
|
+
*
|
|
10
|
+
* The structured {@link ShellExecResult} carries the raw exit code (which
|
|
11
|
+
* `run_command`'s text `ToolResult` hides) so the hook runner can decide
|
|
12
|
+
* blocking pass/fail on exit 0 vs non-zero, while `run_command` maps the same
|
|
13
|
+
* result back to its byte-identical `ToolResult`.
|
|
14
|
+
*/
|
|
15
|
+
/** The raw outcome of executing a shell command (gate already passed). */
|
|
16
|
+
export interface ShellExecResult {
|
|
17
|
+
/** The wall-clock timeout tripped and the process tree was killed. */
|
|
18
|
+
timedOut: boolean;
|
|
19
|
+
/** Numeric exit code, or null when killed / signalled / unknown. */
|
|
20
|
+
exitCode: number | null;
|
|
21
|
+
/** Terminating signal (host path only); null under the sandbox. */
|
|
22
|
+
signal: string | null;
|
|
23
|
+
/** Combined stdout+stderr, capped to `shell.maxOutputBytes`. */
|
|
24
|
+
output: string;
|
|
25
|
+
/** Output was truncated at the cap. */
|
|
26
|
+
truncated: boolean;
|
|
27
|
+
/** The host process failed to *start* (spawn error). Sandbox start failures
|
|
28
|
+
* throw a coded error instead (propagated, never returned here). */
|
|
29
|
+
spawnError?: string;
|
|
30
|
+
}
|
|
31
|
+
/** The result of the gate + (if allowed) execution. */
|
|
32
|
+
export interface GatedShellOutcome {
|
|
33
|
+
/** The U.3 gate allowed the command. When false, nothing executed. */
|
|
34
|
+
approved: boolean;
|
|
35
|
+
/** The rejection feedback when `approved` is false. */
|
|
36
|
+
rejection?: string;
|
|
37
|
+
/** The execution result — present iff `approved`. */
|
|
38
|
+
exec?: ShellExecResult;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Gate `command` through `ctx.requestApproval`, then — only if allowed — execute
|
|
42
|
+
* it via the sandbox (when `ctx.sandbox` is set) or the bounded host spawn. A
|
|
43
|
+
* rejection returns `{ approved: false }` and runs nothing. A non-interactive
|
|
44
|
+
* gate throws `CRUXY_E_APPROVAL_REQUIRED` (propagated, never swallowed); a
|
|
45
|
+
* sandbox that can't run throws its coded error (fail loud, no host fallback).
|
|
46
|
+
*/
|
|
47
|
+
export declare function runGatedShell(command: string, ctx: ToolContext): Promise<GatedShellOutcome>;
|
|
48
|
+
/**
|
|
49
|
+
* Execute an already-approved command. Substrate is chosen SOLELY by
|
|
50
|
+
* `ctx.sandbox`: present → the container (C.16), never the host; absent → the
|
|
51
|
+
* bounded host spawn. No fallback path — a sandbox that can't run throws.
|
|
52
|
+
*/
|
|
53
|
+
export declare function execShell(command: string, ctx: ToolContext): Promise<ShellExecResult>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Gate `command` through `ctx.requestApproval`, then — only if allowed — execute
|
|
4
|
+
* it via the sandbox (when `ctx.sandbox` is set) or the bounded host spawn. A
|
|
5
|
+
* rejection returns `{ approved: false }` and runs nothing. A non-interactive
|
|
6
|
+
* gate throws `CRUXY_E_APPROVAL_REQUIRED` (propagated, never swallowed); a
|
|
7
|
+
* sandbox that can't run throws its coded error (fail loud, no host fallback).
|
|
8
|
+
*/
|
|
9
|
+
export async function runGatedShell(command, ctx) {
|
|
10
|
+
const decision = await ctx.requestApproval({ kind: "shell", command });
|
|
11
|
+
if (!decision.allow) {
|
|
12
|
+
return { approved: false, rejection: decision.feedback };
|
|
13
|
+
}
|
|
14
|
+
return { approved: true, exec: await execShell(command, ctx) };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Execute an already-approved command. Substrate is chosen SOLELY by
|
|
18
|
+
* `ctx.sandbox`: present → the container (C.16), never the host; absent → the
|
|
19
|
+
* bounded host spawn. No fallback path — a sandbox that can't run throws.
|
|
20
|
+
*/
|
|
21
|
+
export function execShell(command, ctx) {
|
|
22
|
+
return ctx.sandbox ? runSandboxed(command, ctx) : runBounded(command, ctx);
|
|
23
|
+
}
|
|
24
|
+
/** Run in the sandbox and normalize its neutral ExecResult (start failures
|
|
25
|
+
* throw coded errors from `sandbox.exec` and propagate — never caught here). */
|
|
26
|
+
async function runSandboxed(command, ctx) {
|
|
27
|
+
const { timeoutMs, maxOutputBytes } = ctx.config.shell;
|
|
28
|
+
const result = await ctx.sandbox.exec(command, {
|
|
29
|
+
cwd: ctx.cwd,
|
|
30
|
+
timeoutMs,
|
|
31
|
+
maxOutputBytes,
|
|
32
|
+
capture: "head",
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
timedOut: result.timedOut,
|
|
36
|
+
exitCode: result.exitCode,
|
|
37
|
+
signal: null,
|
|
38
|
+
output: result.output,
|
|
39
|
+
truncated: result.outputTruncated,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Spawn the command on the host, capture bounded output, enforce the timeout. */
|
|
43
|
+
function runBounded(command, ctx) {
|
|
44
|
+
const { timeoutMs, maxOutputBytes } = ctx.config.shell;
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
// `detached` makes the child its own process-group leader so the whole tree
|
|
47
|
+
// (the shell plus anything it spawns) can be killed on timeout.
|
|
48
|
+
const child = spawn(command, { shell: true, cwd: ctx.cwd, detached: true });
|
|
49
|
+
const chunks = [];
|
|
50
|
+
let captured = 0;
|
|
51
|
+
let truncated = false;
|
|
52
|
+
const capture = (buf) => {
|
|
53
|
+
if (truncated)
|
|
54
|
+
return;
|
|
55
|
+
const room = maxOutputBytes - captured;
|
|
56
|
+
if (buf.length <= room) {
|
|
57
|
+
chunks.push(buf);
|
|
58
|
+
captured += buf.length;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
if (room > 0) {
|
|
62
|
+
chunks.push(buf.subarray(0, room));
|
|
63
|
+
captured += room;
|
|
64
|
+
}
|
|
65
|
+
truncated = true;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
child.stdout?.on("data", capture);
|
|
69
|
+
child.stderr?.on("data", capture);
|
|
70
|
+
// A single guard so the timeout-kill and the natural close can't both fire.
|
|
71
|
+
let settled = false;
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
if (settled)
|
|
74
|
+
return;
|
|
75
|
+
settled = true;
|
|
76
|
+
killTree(child.pid);
|
|
77
|
+
resolve({
|
|
78
|
+
timedOut: true,
|
|
79
|
+
exitCode: null,
|
|
80
|
+
signal: null,
|
|
81
|
+
output: "",
|
|
82
|
+
truncated,
|
|
83
|
+
});
|
|
84
|
+
}, timeoutMs);
|
|
85
|
+
child.on("error", (err) => {
|
|
86
|
+
if (settled)
|
|
87
|
+
return;
|
|
88
|
+
settled = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
resolve({
|
|
91
|
+
timedOut: false,
|
|
92
|
+
exitCode: null,
|
|
93
|
+
signal: null,
|
|
94
|
+
output: "",
|
|
95
|
+
truncated: false,
|
|
96
|
+
spawnError: err.message,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
child.on("close", (code, signal) => {
|
|
100
|
+
if (settled)
|
|
101
|
+
return;
|
|
102
|
+
settled = true;
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
resolve({
|
|
105
|
+
timedOut: false,
|
|
106
|
+
exitCode: code,
|
|
107
|
+
signal: signal ?? null,
|
|
108
|
+
output: Buffer.concat(chunks).toString("utf8"),
|
|
109
|
+
truncated,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Kill the command's entire process group. POSIX-specific (negative pid targets
|
|
116
|
+
* the group); fine on our darwin/linux targets. Swallows errors — the process
|
|
117
|
+
* may already be gone.
|
|
118
|
+
*/
|
|
119
|
+
function killTree(pid) {
|
|
120
|
+
if (pid === undefined)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
process.kill(-pid, "SIGKILL");
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Already exited, or no group — nothing to kill.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -4,6 +4,10 @@ import type { Tool } from "../types.js";
|
|
|
4
4
|
* Run an arbitrary shell command in the project root. The highest-risk tool we
|
|
5
5
|
* ship: it is gated on `ctx.approve` (a denial runs nothing) and bounded by a
|
|
6
6
|
* timeout that kills the whole process tree plus a cap on captured output.
|
|
7
|
+
*
|
|
8
|
+
* Gate + execution live in the shared {@link runGatedShell} (also used by the
|
|
9
|
+
* C.19 hook runner — the single, un-bypassable shell path); this tool only maps
|
|
10
|
+
* the structured result back onto its `ToolResult` framing.
|
|
7
11
|
*/
|
|
8
12
|
export declare const runCommandTool: Tool<z.ZodObject<{
|
|
9
13
|
command: z.ZodString;
|