pi-crew 0.11.7 → 0.11.9
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/.githooks/pre-commit +22 -0
- package/.githooks/pre-push +56 -0
- package/CHANGELOG.md +196 -1
- package/dist/index.mjs +533 -515
- package/docs/publishing.md +29 -0
- package/docs/usage.md +1 -1
- package/index.ts +3 -3
- package/install.mjs +4 -1
- package/package.json +8 -5
- package/scripts/install-hooks.mjs +32 -0
- package/skills/real-test-pi-crew/SKILL.md +57 -18
- package/src/config/config-validation.ts +8 -1
- package/src/config/defaults.ts +8 -1
- package/src/config/env-vars.ts +16 -1
- package/src/config/types.ts +28 -4
- package/src/extension/command-completions.ts +44 -22
- package/src/extension/registration/foreground-run-controller.ts +19 -10
- package/src/extension/registration/lifecycle-handlers.ts +6 -491
- package/src/extension/registration/lifecycle.ts +7 -90
- package/src/extension/registration/render-loop.ts +532 -0
- package/src/extension/team-tool/doctor.ts +6 -0
- package/src/extension/team-tool/handle-settings.ts +4 -1
- package/src/runtime/child-pi/child-pi-spawn.ts +7 -0
- package/src/runtime/child-pi/child-pi.ts +37 -2
- package/src/runtime/child-pi/pi-version.ts +187 -0
- package/src/runtime/classifier/classifier-service.ts +298 -0
- package/src/runtime/dispatch-batch.ts +4 -0
- package/src/runtime/live-session/live-session-runtime.ts +31 -0
- package/src/runtime/model/pi-args.ts +62 -3
- package/src/runtime/process/zombie-scanner.ts +168 -7
- package/src/runtime/rpc/README.md +64 -25
- package/src/runtime/rpc/rpc-worker.ts +146 -26
- package/src/runtime/run-worker.ts +89 -15
- package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
- package/src/runtime/skill-instructions.ts +66 -1
- package/src/runtime/surface/resolve-surface.ts +34 -19
- package/src/runtime/task-runner/child-executor.ts +132 -0
- package/src/runtime/task-runner/pre-execution.ts +8 -0
- package/src/runtime/task-runner.ts +6 -0
- package/src/schema/config-schema.ts +20 -8
- package/src/schema/team-tool-schema.ts +4 -2
- package/src/state/contracts.ts +5 -0
- package/src/ui/dashboard-panes/agents-pane.ts +3 -2
- package/src/ui/dashboard-panes/health-pane.ts +6 -1
- package/src/ui/dashboard-panes/mailbox-pane.ts +12 -7
- package/src/ui/dashboard-panes/plan-pane.ts +5 -2
- package/src/ui/dashboard-panes/progress-pane.ts +8 -2
- package/src/ui/format-helpers.ts +11 -0
- package/src/ui/live-conversation-overlay.ts +5 -3
- package/src/ui/live-duration.ts +5 -4
- package/src/ui/live-run-sidebar.ts +90 -20
- package/src/ui/overlays/mailbox-compose-overlay.ts +31 -1
- package/src/ui/rail.ts +14 -1
- package/src/ui/run-action-dispatcher.ts +2 -1
- package/src/ui/run-dashboard.ts +62 -22
- package/src/ui/run-snapshot-cache.ts +32 -0
- package/src/ui/settings-overlay.ts +36 -3
- package/src/ui/tool-renderers/brief-mode.ts +2 -2
- package/src/ui/transcript-viewer.ts +4 -1
- package/src/ui/widget/index.ts +10 -2
- package/src/ui/widget/task-list.ts +22 -7
- package/src/ui/widget/widget-model.ts +5 -4
- package/src/ui/widget/widget-renderer.ts +212 -37
- package/src/utils/relative-time.ts +11 -3
- package/src/utils/socket-path.ts +48 -8
- package/src/utils/visual.ts +32 -2
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-version.ts — host `pi` binary version probe + hand-rolled comparison
|
|
3
|
+
* (U9, pi 1.0.4 adoption wave).
|
|
4
|
+
*
|
|
5
|
+
* WHY HAND-ROLLED (no `semver` dependency): pi-crew's package policy is ZERO
|
|
6
|
+
* new runtime dependencies (AGENTS.md / wave constraint). The comparison
|
|
7
|
+
* surface pi-crew actually needs is major.minor.patch + simple pre-release
|
|
8
|
+
* ordering + conservative null on malformed input — pulling in `semver` for
|
|
9
|
+
* that costs a dependency (plus its transitive tree) that every install pays
|
|
10
|
+
* for a decision made at most once per process. If a richer need appears
|
|
11
|
+
* (ranges, build-metadata ordering), REVISIT this decision explicitly — do
|
|
12
|
+
* not silently grow this file into a partial semver implementation.
|
|
13
|
+
*
|
|
14
|
+
* PROBE CONTRACT (probePiVersion):
|
|
15
|
+
* - Execs the SAME binary the stdio transport spawns: getPiSpawnCommand
|
|
16
|
+
* (PI_TEAMS_PI_BIN aware) with `--version` — probing a different install
|
|
17
|
+
* than the one we spawn would gate flags on the wrong version.
|
|
18
|
+
* - The exec is INJECTABLE (ExecPiVersionFn seam) so unit tests never spawn
|
|
19
|
+
* a real child; the default seam wraps node:child_process execFile with a
|
|
20
|
+
* 5s timeout (utf8).
|
|
21
|
+
* - Single-flight memoized per process: the FIRST call creates the promise;
|
|
22
|
+
* every later call (any args) returns the same one. Failures memoize too
|
|
23
|
+
* (null = unknown) — a missing/old pi binary does not re-probe per spawn.
|
|
24
|
+
* - NEVER rejects: spawn error / nonzero exit / timeout / unparseable
|
|
25
|
+
* stdout all resolve null. The result is a hint, never a failure channel.
|
|
26
|
+
*
|
|
27
|
+
* COMPARISON CONTRACT (comparePiVersion):
|
|
28
|
+
* - Strict `major.minor.patch[-prerelease][+build]` (official semver shape;
|
|
29
|
+
* leading zeros rejected). Build metadata is IGNORED per semver §10.
|
|
30
|
+
* - Malformed input (either side, incl. null/undefined) → null — callers
|
|
31
|
+
* treat null as "unknown" and conservatively emit NO version-gated flag.
|
|
32
|
+
* - Pre-release ordering (semver §11, simplified): release > pre-release;
|
|
33
|
+
* identifiers compared pairwise (numeric identifiers numerically and
|
|
34
|
+
* LOWER than alphanumeric; alphanumeric lexically); a shorter identifier
|
|
35
|
+
* list is lower when all preceding identifiers are equal.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { execFile } from "node:child_process";
|
|
39
|
+
import { promisify } from "node:util";
|
|
40
|
+
import { getPiSpawnCommand } from "../pi-spawn.ts";
|
|
41
|
+
|
|
42
|
+
/** Probe timeout (ms). `pi --version` is a fast print; 5s absorbs slow cold starts. */
|
|
43
|
+
export const PI_VERSION_PROBE_TIMEOUT_MS = 5_000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Injectable exec seam for {@link probePiVersion}. Mirrors the
|
|
47
|
+
* promisify(execFile) shape: resolves {stdout, stderr} on exit 0, REJECTS on
|
|
48
|
+
* spawn error / nonzero exit / timeout. Tests pass a fake — the default
|
|
49
|
+
* seam would spawn a real child (forbidden in unit tests).
|
|
50
|
+
*/
|
|
51
|
+
export type ExecPiVersionFn = (
|
|
52
|
+
file: string,
|
|
53
|
+
args: string[],
|
|
54
|
+
options: { timeout: number; encoding: "utf8" },
|
|
55
|
+
) => Promise<{ stdout?: string; stderr?: string }>;
|
|
56
|
+
|
|
57
|
+
const execFileAsync = promisify(execFile);
|
|
58
|
+
|
|
59
|
+
const defaultExec: ExecPiVersionFn = (file, args, options) => execFileAsync(file, args, options);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* ── Version-floor registry (U9) ─────────────────────────────────────────────
|
|
63
|
+
* Every argv flag pi-crew gates on the HOST pi binary's version is listed
|
|
64
|
+
* here so "which pi needs which flag" is greppable in ONE place. The emit
|
|
65
|
+
* sites live in src/runtime/model/pi-args.ts (parity non-hermetic block).
|
|
66
|
+
*
|
|
67
|
+
* - `1.0.4` → `--no-mcp`: disconnects ambient MCP servers outright ("no
|
|
68
|
+
* servers connect, no MCP tools or /mcp" — cli.md:198), strictly stronger
|
|
69
|
+
* than the U2-lite `mcp__*` --exclude-tools tool-cut. Emitted ONLY on
|
|
70
|
+
* parity (non-hermetic) spawns whose probed piVersion >= this floor.
|
|
71
|
+
*
|
|
72
|
+
* Future candidates: none yet. When adding one, append the floor constant
|
|
73
|
+
* here + the gate at the emit site — pre-floor hosts must NEVER see the flag
|
|
74
|
+
* (pi's strict parser rejects unknown options with a nonzero exit).
|
|
75
|
+
*/
|
|
76
|
+
export const PI_NO_MCP_FLOOR = "1.0.4";
|
|
77
|
+
|
|
78
|
+
// Probe-side extraction is LENIENT (tolerates "pi 1.0.4" / banners); the
|
|
79
|
+
// strict validation lives in parseVersion below. First version-shaped token
|
|
80
|
+
// in stdout wins.
|
|
81
|
+
const VERSION_TOKEN_RE = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/;
|
|
82
|
+
|
|
83
|
+
// Strict semver shape (official regex, numeric identifiers without leading
|
|
84
|
+
// zeros). Anything else is "unknown" — never a guess.
|
|
85
|
+
const STRICT_SEMVER_RE =
|
|
86
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
87
|
+
|
|
88
|
+
interface ParsedVersion {
|
|
89
|
+
major: number;
|
|
90
|
+
minor: number;
|
|
91
|
+
patch: number;
|
|
92
|
+
/** null = release (no pre-release). Build metadata is dropped at parse. */
|
|
93
|
+
prerelease: string[] | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseVersion(value: string | null | undefined): ParsedVersion | null {
|
|
97
|
+
if (typeof value !== "string") return null;
|
|
98
|
+
const match = STRICT_SEMVER_RE.exec(value.trim());
|
|
99
|
+
if (!match) return null;
|
|
100
|
+
const major = Number(match[1]);
|
|
101
|
+
const minor = Number(match[2]);
|
|
102
|
+
const patch = Number(match[3]);
|
|
103
|
+
const prerelease = match[4] === undefined ? null : match[4].split(".");
|
|
104
|
+
return { major, minor, patch, prerelease };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Semver §11 identifier compare: numeric < alphanumeric; numeric numeric-ly, alphanumeric lexically. */
|
|
108
|
+
function comparePrereleaseIdentifiers(a: string, b: string): -1 | 0 | 1 {
|
|
109
|
+
const aNumeric = /^\d+$/.test(a);
|
|
110
|
+
const bNumeric = /^\d+$/.test(b);
|
|
111
|
+
if (aNumeric && bNumeric) {
|
|
112
|
+
const delta = Number(a) - Number(b);
|
|
113
|
+
return delta < 0 ? -1 : delta > 0 ? 1 : 0;
|
|
114
|
+
}
|
|
115
|
+
if (aNumeric) return -1; // numeric identifiers have LOWER precedence
|
|
116
|
+
if (bNumeric) return 1;
|
|
117
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Compare two pi version strings. Returns -1 | 0 | 1, or null when EITHER
|
|
122
|
+
* side is malformed/unknown (conservative — callers must treat null as
|
|
123
|
+
* "emit nothing version-gated"). Build metadata is ignored (semver §10).
|
|
124
|
+
*/
|
|
125
|
+
export function comparePiVersion(a: string | null | undefined, b: string | null | undefined): -1 | 0 | 1 | null {
|
|
126
|
+
const pa = parseVersion(a);
|
|
127
|
+
const pb = parseVersion(b);
|
|
128
|
+
if (!pa || !pb) return null;
|
|
129
|
+
for (const key of ["major", "minor", "patch"] as const) {
|
|
130
|
+
if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1;
|
|
131
|
+
}
|
|
132
|
+
if (pa.prerelease === null && pb.prerelease === null) return 0;
|
|
133
|
+
if (pa.prerelease === null) return 1; // release > pre-release
|
|
134
|
+
if (pb.prerelease === null) return -1;
|
|
135
|
+
const ids = pa.prerelease;
|
|
136
|
+
const other = pb.prerelease;
|
|
137
|
+
for (let i = 0; i < Math.min(ids.length, other.length); i++) {
|
|
138
|
+
const cmp = comparePrereleaseIdentifiers(ids[i] as string, other[i] as string);
|
|
139
|
+
if (cmp !== 0) return cmp;
|
|
140
|
+
}
|
|
141
|
+
if (ids.length !== other.length) return ids.length < other.length ? -1 : 1; // larger set wins
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Floor gate: true ONLY when `version` parses and is >= `floor`. Unknown
|
|
147
|
+
* (null/undefined) or malformed versions are BELOW every floor — the
|
|
148
|
+
* conservative "never emit a flag the host may reject" posture.
|
|
149
|
+
*/
|
|
150
|
+
export function piVersionAtLeast(version: string | null | undefined, floor: string): boolean {
|
|
151
|
+
const cmp = comparePiVersion(version, floor);
|
|
152
|
+
return cmp !== null && cmp >= 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Memoized single-flight probe promise (null = unknown, failures included). */
|
|
156
|
+
let memoizedProbe: Promise<string | null> | null = null;
|
|
157
|
+
|
|
158
|
+
async function runPiVersionProbe(exec: ExecPiVersionFn): Promise<string | null> {
|
|
159
|
+
const { command, args } = getPiSpawnCommand(["--version"]);
|
|
160
|
+
const { stdout } = await exec(command, args, { timeout: PI_VERSION_PROBE_TIMEOUT_MS, encoding: "utf8" });
|
|
161
|
+
const text = typeof stdout === "string" ? stdout : "";
|
|
162
|
+
const hit = VERSION_TOKEN_RE.exec(text);
|
|
163
|
+
return hit ? hit[0] : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve the host pi binary's version (`pi --version`), single-flight
|
|
168
|
+
* memoized per process. The `exec` seam is consumed only by the call that
|
|
169
|
+
* actually creates the memo — later calls reuse the settled promise no
|
|
170
|
+
* matter what they pass. NEVER rejects; unknown resolves null.
|
|
171
|
+
*/
|
|
172
|
+
export function probePiVersion(exec: ExecPiVersionFn = defaultExec): Promise<string | null> {
|
|
173
|
+
if (!memoizedProbe) memoizedProbe = runPiVersionProbe(exec).catch(() => null);
|
|
174
|
+
return memoizedProbe;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @internal Test-only: drop the memoized probe so the next call re-probes with a fresh seam. */
|
|
178
|
+
export function __test_resetPiVersionMemo(): void {
|
|
179
|
+
memoizedProbe = null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @internal Test-only: is a probe memoized? (null = no probe has run yet —
|
|
183
|
+
* lets tests prove a code path never TOUCHED the probe, which the call
|
|
184
|
+
* counter alone cannot show once single-flight kicks in). */
|
|
185
|
+
export function __test_peekPiVersionMemo(): Promise<string | null> | null {
|
|
186
|
+
return memoizedProbe;
|
|
187
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2-1 (pi 1.0.4 adoption, 2026-10-07): host-process classifier seam.
|
|
3
|
+
*
|
|
4
|
+
* Thin never-rejects wrapper around the Pi extension-surface classifier API
|
|
5
|
+
* (`ctx.modelRegistry.classify()` — docs/models.md:137-138 of the 1.0.4
|
|
6
|
+
* release: "Extensions call classifiers through ctx.modelRegistry.classify(),
|
|
7
|
+
* without codemode"). HOST-PROCESS ONLY: the registry handle lives on the
|
|
8
|
+
* extension ctx in this process — child `pi -p` workers are separate
|
|
9
|
+
* processes with no ctx and must never import this path.
|
|
10
|
+
*
|
|
11
|
+
* Contract (adoption review §R2.3, R2-V probe p3b):
|
|
12
|
+
* - classifier unavailable (`getAvailableOfType("classifier")` → [] — the
|
|
13
|
+
* catalog lists models but no provider is credentialed) → caller fallback;
|
|
14
|
+
* - classify() throws → caller fallback;
|
|
15
|
+
* - result.stopReason !== "stop" (e.g. "error" + errorMessage
|
|
16
|
+
* "Provider is not configured: opencode" on this host) → caller fallback;
|
|
17
|
+
* - answer missing / not boolean-shaped → caller fallback;
|
|
18
|
+
* - classify() must NEVER throw up to the orchestrator.
|
|
19
|
+
*
|
|
20
|
+
* Every fallback mode logs ONCE per (mode, model) per process (log-once set)
|
|
21
|
+
* and increments `crew.classifier.calls_total{outcome}` when a metric
|
|
22
|
+
* registry is threaded — mirrors the dispatch-batch retry-counter pattern
|
|
23
|
+
* (dispatch-batch.ts:812). Defaults are DORMANT: callers gate on
|
|
24
|
+
* resolveClassifierEnabled() (runtime.classifierEnabled, default false).
|
|
25
|
+
*/
|
|
26
|
+
import { getCrewEnvBool, getCrewEnvString } from "../../config/env-vars.ts";
|
|
27
|
+
import type { MetricRegistry } from "../../observability/metric-registry.ts";
|
|
28
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
29
|
+
import { modelStringFromUnknown } from "../model/model-fallback.ts";
|
|
30
|
+
|
|
31
|
+
/** Default classifier model (runtime.classifierModel default). */
|
|
32
|
+
export const DEFAULT_CLASSIFIER_MODEL = "opencode/jev-1.13-free";
|
|
33
|
+
|
|
34
|
+
/** One typed yes/no question (codemode classify question shape, bool type). */
|
|
35
|
+
export interface ClassifierBoolQuestion {
|
|
36
|
+
type: "bool";
|
|
37
|
+
instructions: string;
|
|
38
|
+
/** Label per answer — helps the classifier ground the decision. */
|
|
39
|
+
criteria?: { true: string; false: string };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Request body for modelRegistry.classify(model, request). */
|
|
43
|
+
export interface ClassifyRequest {
|
|
44
|
+
state: Record<string, unknown>;
|
|
45
|
+
questions: Record<string, ClassifierBoolQuestion>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Structured result shape returned by modelRegistry.classify (never throws
|
|
49
|
+
* per model-registry.d.ts — but we still guard a throwing implementation). */
|
|
50
|
+
export interface ClassifyCallResult {
|
|
51
|
+
answers?: Record<string, unknown>;
|
|
52
|
+
stopReason?: string;
|
|
53
|
+
errorMessage?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Duck-typed host registry handle (mirrors ModelRegistryLike in
|
|
57
|
+
* model-fallback.ts — the runtime threads the real ctx.modelRegistry as
|
|
58
|
+
* `unknown`). NOTE: on the real 1.0.4 ModelRegistry, getAvailableOfType is
|
|
59
|
+
* ASYNC (returns a Promise — model-registry.d.ts) while getModelsOfType is
|
|
60
|
+
* sync; the duck type accepts both shapes and classifyBool awaits. */
|
|
61
|
+
export interface ClassifierModelRegistryLike {
|
|
62
|
+
getAvailableOfType?: (type: string) => Promise<unknown[]> | unknown[];
|
|
63
|
+
classify?: (model: unknown, request: ClassifyRequest) => Promise<ClassifyCallResult> | ClassifyCallResult;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ClassifierOutcomeReason =
|
|
67
|
+
| "classified"
|
|
68
|
+
| "registry_missing"
|
|
69
|
+
| "no_classifier_available"
|
|
70
|
+
| "classify_threw"
|
|
71
|
+
| "stop_reason_error"
|
|
72
|
+
| "answer_missing";
|
|
73
|
+
|
|
74
|
+
export interface ClassifyBoolInput {
|
|
75
|
+
/** Host-process model registry handle (ctx.modelRegistry, threaded unknown). */
|
|
76
|
+
modelRegistry: unknown;
|
|
77
|
+
/** Configured classifier model id ("provider/id"). */
|
|
78
|
+
classifierModel: string;
|
|
79
|
+
/** Key under `questions` carrying the bool question. */
|
|
80
|
+
questionKey: string;
|
|
81
|
+
question: ClassifierBoolQuestion;
|
|
82
|
+
/** JSON state the classifier reasons about (keep small + low-cardinality). */
|
|
83
|
+
state: Record<string, unknown>;
|
|
84
|
+
/** Caller-provided fallback decision — returned on EVERY failure mode. */
|
|
85
|
+
fallback: boolean;
|
|
86
|
+
/** Optional metric registry (counter created on demand, dispatch-batch style). */
|
|
87
|
+
metricRegistry?: MetricRegistry;
|
|
88
|
+
/** Extra low-cardinality labels for the calls_total counter. */
|
|
89
|
+
metricLabels?: Record<string, string>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ClassifyBoolResult {
|
|
93
|
+
/** Effective decision: the classifier answer, or the caller fallback. */
|
|
94
|
+
decision: boolean;
|
|
95
|
+
/** True ONLY when a real classifier answer informed the decision. */
|
|
96
|
+
fromClassifier: boolean;
|
|
97
|
+
/** Which branch produced the outcome (diagnostics + metric label). */
|
|
98
|
+
reason: ClassifierOutcomeReason;
|
|
99
|
+
/** Classifier model id actually used (undefined on every fallback path). */
|
|
100
|
+
usedModel: string | undefined;
|
|
101
|
+
/** Raw stopReason / errorMessage from the classify call, when one ran. */
|
|
102
|
+
stopReason: string | undefined;
|
|
103
|
+
errorMessage: string | undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Log-once state: `${reason}|${classifierModel}`. Module-level on purpose —
|
|
108
|
+
* per-call-site instances would re-log on every consumer invocation; the
|
|
109
|
+
* process-wide set matches the "log-once per failure mode" contract.
|
|
110
|
+
* @internal — test introspection/reset.
|
|
111
|
+
*/
|
|
112
|
+
const loggedFailureModes = new Set<string>();
|
|
113
|
+
|
|
114
|
+
/** @internal — test hook: reset the log-once set between test cases. */
|
|
115
|
+
export function __test__resetClassifierLogOnce(): void {
|
|
116
|
+
loggedFailureModes.clear();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @internal — test hook: observe the log-once set (assert no re-log). */
|
|
120
|
+
export function __test__classifierLoggedFailureModes(): string[] {
|
|
121
|
+
return [...loggedFailureModes];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function noteFailureMode(reason: ClassifierOutcomeReason, classifierModel: string, detail: string): void {
|
|
125
|
+
const key = `${reason}|${classifierModel}`;
|
|
126
|
+
if (loggedFailureModes.has(key)) return;
|
|
127
|
+
loggedFailureModes.add(key);
|
|
128
|
+
logInternalError("classifier-service", new Error(`classifier fallback: ${reason}`), detail, "warn");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function outcomeMetric(
|
|
132
|
+
metricRegistry: MetricRegistry | undefined,
|
|
133
|
+
reason: ClassifierOutcomeReason,
|
|
134
|
+
labels: Record<string, string> | undefined,
|
|
135
|
+
): void {
|
|
136
|
+
try {
|
|
137
|
+
metricRegistry?.counter("crew.classifier.calls_total", "Classifier calls by outcome").inc({
|
|
138
|
+
outcome: reason,
|
|
139
|
+
...labels,
|
|
140
|
+
});
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// Metric plumbing must never break the never-rejects contract.
|
|
143
|
+
logInternalError("classifier-service.metric", error, `outcome=${reason}`, "warn");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Normalize a registry handle into the duck-typed classifier surface. */
|
|
148
|
+
function asClassifierRegistry(modelRegistry: unknown): ClassifierModelRegistryLike | undefined {
|
|
149
|
+
if (!modelRegistry || typeof modelRegistry !== "object" || Array.isArray(modelRegistry)) return undefined;
|
|
150
|
+
return modelRegistry as ClassifierModelRegistryLike;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve classifier-enabled precedence: env PI_CREW_CLASSIFIER_ENABLED beats
|
|
155
|
+
* runtime.classifierEnabled beats default FALSE (dormant). Mirrors
|
|
156
|
+
* resolveHermeticWorkers (pi-args.ts) — env wins in either direction.
|
|
157
|
+
*/
|
|
158
|
+
export function resolveClassifierEnabled(explicit?: boolean): boolean {
|
|
159
|
+
const env = getCrewEnvBool("PI_CREW_CLASSIFIER_ENABLED");
|
|
160
|
+
if (env !== undefined) return env;
|
|
161
|
+
return explicit ?? false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resolve classifier-model precedence: env PI_CREW_CLASSIFIER_MODEL beats
|
|
166
|
+
* runtime.classifierModel beats DEFAULT_CLASSIFIER_MODEL. Blank env values
|
|
167
|
+
* are ignored (fall through to config/default) — conservative.
|
|
168
|
+
*/
|
|
169
|
+
export function resolveClassifierModel(explicit?: string): string {
|
|
170
|
+
const env = getCrewEnvString("PI_CREW_CLASSIFIER_MODEL");
|
|
171
|
+
if (env !== undefined && env.trim().length > 0) return env.trim();
|
|
172
|
+
return explicit && explicit.trim().length > 0 ? explicit.trim() : DEFAULT_CLASSIFIER_MODEL;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Ask ONE bool question of a host-process classifier. NEVER REJECTS — every
|
|
177
|
+
* failure mode (no registry, no credentialed classifier, thrown error,
|
|
178
|
+
* stopReason !== "stop", missing/non-boolean answer) returns the
|
|
179
|
+
* caller-provided fallback with {@link ClassifyBoolResult.fromClassifier}
|
|
180
|
+
* false, after a log-once note + metric counter increment.
|
|
181
|
+
*/
|
|
182
|
+
export async function classifyBool(input: ClassifyBoolInput): Promise<ClassifyBoolResult> {
|
|
183
|
+
const registry = asClassifierRegistry(input.modelRegistry);
|
|
184
|
+
if (!registry || typeof registry.getAvailableOfType !== "function" || typeof registry.classify !== "function") {
|
|
185
|
+
noteFailureMode("registry_missing", input.classifierModel, "model registry absent or lacks the classifier surface");
|
|
186
|
+
outcomeMetric(input.metricRegistry, "registry_missing", input.metricLabels);
|
|
187
|
+
return {
|
|
188
|
+
decision: input.fallback,
|
|
189
|
+
fromClassifier: false,
|
|
190
|
+
reason: "registry_missing",
|
|
191
|
+
usedModel: undefined,
|
|
192
|
+
stopReason: undefined,
|
|
193
|
+
errorMessage: undefined,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// Credentialed classifiers only — the catalog (getModelsOfType) can list
|
|
197
|
+
// models whose provider has no key on this host (R2.3: 6 catalog entries,
|
|
198
|
+
// 0 available). getAvailableOfType is the availability oracle (ASYNC on
|
|
199
|
+
// the real registry — model-registry.d.ts).
|
|
200
|
+
let available: unknown[];
|
|
201
|
+
try {
|
|
202
|
+
available = (await registry.getAvailableOfType("classifier")) ?? [];
|
|
203
|
+
} catch (error) {
|
|
204
|
+
noteFailureMode("classify_threw", input.classifierModel, `getAvailableOfType threw: ${String(error)}`);
|
|
205
|
+
outcomeMetric(input.metricRegistry, "classify_threw", input.metricLabels);
|
|
206
|
+
return {
|
|
207
|
+
decision: input.fallback,
|
|
208
|
+
fromClassifier: false,
|
|
209
|
+
reason: "classify_threw",
|
|
210
|
+
usedModel: undefined,
|
|
211
|
+
stopReason: undefined,
|
|
212
|
+
errorMessage: undefined,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (!Array.isArray(available) || available.length === 0) {
|
|
216
|
+
noteFailureMode(
|
|
217
|
+
"no_classifier_available",
|
|
218
|
+
input.classifierModel,
|
|
219
|
+
"getAvailableOfType('classifier') is empty — no credentialed classifier",
|
|
220
|
+
);
|
|
221
|
+
outcomeMetric(input.metricRegistry, "no_classifier_available", input.metricLabels);
|
|
222
|
+
return {
|
|
223
|
+
decision: input.fallback,
|
|
224
|
+
fromClassifier: false,
|
|
225
|
+
reason: "no_classifier_available",
|
|
226
|
+
usedModel: undefined,
|
|
227
|
+
stopReason: undefined,
|
|
228
|
+
errorMessage: undefined,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
// Prefer the configured model id; fall back to the first available
|
|
232
|
+
// candidate so a renamed catalog id still gets a usable classifier.
|
|
233
|
+
const requested = input.classifierModel.trim();
|
|
234
|
+
const normalized = available.map((entry) => modelStringFromUnknown(entry)).filter((id): id is string => id !== undefined);
|
|
235
|
+
const matchedIndex = normalized.indexOf(requested);
|
|
236
|
+
const chosenIndex = matchedIndex >= 0 ? matchedIndex : 0;
|
|
237
|
+
const chosen = available[chosenIndex];
|
|
238
|
+
const usedModel = normalized[chosenIndex] ?? requested;
|
|
239
|
+
try {
|
|
240
|
+
const result = await registry.classify(chosen, {
|
|
241
|
+
state: input.state,
|
|
242
|
+
questions: { [input.questionKey]: input.question },
|
|
243
|
+
});
|
|
244
|
+
const stopReason = typeof result?.stopReason === "string" ? result.stopReason : undefined;
|
|
245
|
+
const errorMessage = typeof result?.errorMessage === "string" ? result.errorMessage : undefined;
|
|
246
|
+
if (stopReason !== "stop") {
|
|
247
|
+
noteFailureMode(
|
|
248
|
+
"stop_reason_error",
|
|
249
|
+
input.classifierModel,
|
|
250
|
+
`classify stopReason=${stopReason ?? "undefined"}${errorMessage ? ` errorMessage=${errorMessage}` : ""}`,
|
|
251
|
+
);
|
|
252
|
+
outcomeMetric(input.metricRegistry, "stop_reason_error", input.metricLabels);
|
|
253
|
+
return {
|
|
254
|
+
decision: input.fallback,
|
|
255
|
+
fromClassifier: false,
|
|
256
|
+
reason: "stop_reason_error",
|
|
257
|
+
usedModel: undefined,
|
|
258
|
+
stopReason,
|
|
259
|
+
errorMessage,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const answer = result?.answers?.[input.questionKey];
|
|
263
|
+
if (typeof answer !== "boolean") {
|
|
264
|
+
noteFailureMode("answer_missing", input.classifierModel, `answers[${input.questionKey}] is ${typeof answer}, expected boolean`);
|
|
265
|
+
outcomeMetric(input.metricRegistry, "answer_missing", input.metricLabels);
|
|
266
|
+
return {
|
|
267
|
+
decision: input.fallback,
|
|
268
|
+
fromClassifier: false,
|
|
269
|
+
reason: "answer_missing",
|
|
270
|
+
usedModel: undefined,
|
|
271
|
+
stopReason,
|
|
272
|
+
errorMessage,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
outcomeMetric(input.metricRegistry, "classified", input.metricLabels);
|
|
276
|
+
return {
|
|
277
|
+
decision: answer,
|
|
278
|
+
fromClassifier: true,
|
|
279
|
+
reason: "classified",
|
|
280
|
+
usedModel,
|
|
281
|
+
stopReason,
|
|
282
|
+
errorMessage,
|
|
283
|
+
};
|
|
284
|
+
} catch (error) {
|
|
285
|
+
// The registry contract says classify never rejects, but a misbehaving
|
|
286
|
+
// implementation must still not take the orchestrator down with it.
|
|
287
|
+
noteFailureMode("classify_threw", input.classifierModel, `classify threw: ${String(error)}`);
|
|
288
|
+
outcomeMetric(input.metricRegistry, "classify_threw", input.metricLabels);
|
|
289
|
+
return {
|
|
290
|
+
decision: input.fallback,
|
|
291
|
+
fromClassifier: false,
|
|
292
|
+
reason: "classify_threw",
|
|
293
|
+
usedModel: undefined,
|
|
294
|
+
stopReason: undefined,
|
|
295
|
+
errorMessage: undefined,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -778,6 +778,10 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
|
|
|
778
778
|
parentContext: input.parentContext,
|
|
779
779
|
parentModel: input.parentModel,
|
|
780
780
|
modelRegistry: input.modelRegistry,
|
|
781
|
+
// P2-1: thread the host metric registry to the task-runner seam so the
|
|
782
|
+
// retry-triage classifier can count calls/decisions (same
|
|
783
|
+
// ExecuteTeamRunInput handle dispatch-batch itself uses at :812).
|
|
784
|
+
metricRegistry: input.metricRegistry,
|
|
781
785
|
modelOverride: input.modelOverride,
|
|
782
786
|
teamRoleModel: teamRole?.model,
|
|
783
787
|
teamRoleThinking: teamRole?.thinking,
|
|
@@ -322,6 +322,29 @@ function modelFromRegistry(modelRegistry: unknown, modelId: string | undefined):
|
|
|
322
322
|
}
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/**
|
|
326
|
+
* R3-12: resolve pi-crew's fallback chain into pi SDK `scopedModels` entries.
|
|
327
|
+
*
|
|
328
|
+
* SDK semantics (pi-coding-agent docs/sdk.md; dist/core/agent-session.js):
|
|
329
|
+
* `scopedModels` bounds the session's CYCLING scope and what extensions see
|
|
330
|
+
* through `getScopedModels` — it is NOT a hard access gate (`setModel` still
|
|
331
|
+
* accepts any credentialed model), so binding the fallback chain cannot break
|
|
332
|
+
* a legitimate user/model override. Candidates that don't resolve in the
|
|
333
|
+
* registry are skipped; an empty result means the caller passes NO option so
|
|
334
|
+
* pi keeps its default scope (all available models) — the "explicit
|
|
335
|
+
* non-empty" guard from the R3 spec.
|
|
336
|
+
*/
|
|
337
|
+
export function resolveScopedSessionModels(modelRegistry: unknown, candidates: readonly string[]): Array<{ model: unknown }> {
|
|
338
|
+
const scoped: Array<{ model: unknown }> = [];
|
|
339
|
+
for (const candidate of candidates) {
|
|
340
|
+
const model = modelFromRegistry(modelRegistry, candidate);
|
|
341
|
+
if (!model || typeof model !== "object") continue;
|
|
342
|
+
if (scoped.some((entry) => entry.model === model)) continue;
|
|
343
|
+
scoped.push({ model });
|
|
344
|
+
}
|
|
345
|
+
return scoped;
|
|
346
|
+
}
|
|
347
|
+
|
|
325
348
|
/**
|
|
326
349
|
* Round 18: when agent declares `model: false`, the inherited `parentModel`
|
|
327
350
|
* (= `ctx.model` from Pi runtime, set via `team-tool.ts:541/655`) is the
|
|
@@ -793,6 +816,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
793
816
|
// H1.a: teamRole.thinking takes precedence over agent.thinking.
|
|
794
817
|
const effectiveThinking = input.teamRoleThinking ?? input.agent.thinking;
|
|
795
818
|
|
|
819
|
+
// R3-12: bind the live session's model scope to pi-crew's fallback chain
|
|
820
|
+
// so model cycling (and the extension-visible scope) cannot drift outside
|
|
821
|
+
// the candidates the routing gate approved. Only bound when the chain
|
|
822
|
+
// resolves to at least one registry model (explicit non-empty guard).
|
|
823
|
+
const scopedSessionModels = resolveScopedSessionModels(input.modelRegistry, modelRouting.candidates);
|
|
824
|
+
|
|
796
825
|
// G1: Build custom tools (submit_result + irc)
|
|
797
826
|
const submitResultTool = createSubmitResultTool((result) => {
|
|
798
827
|
customToolYieldResult = result;
|
|
@@ -819,6 +848,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
819
848
|
...(input.modelRegistry ? { modelRegistry: input.modelRegistry } : {}),
|
|
820
849
|
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
821
850
|
...(effectiveThinking ? { thinkingLevel: effectiveThinking } : {}),
|
|
851
|
+
// R3-12: cycling/visibility scope = the fallback chain (see helper).
|
|
852
|
+
...(scopedSessionModels.length > 0 ? { scopedModels: scopedSessionModels } : {}),
|
|
822
853
|
customTools,
|
|
823
854
|
});
|
|
824
855
|
session = created.session;
|
|
@@ -5,8 +5,12 @@ import type { AgentConfig } from "../../agents/agent-config.ts";
|
|
|
5
5
|
import { getCrewEnvBool } from "../../config/env-vars.ts";
|
|
6
6
|
import { atomicWriteFile } from "../../state/atomic-write.ts";
|
|
7
7
|
import { hasRunStateLayout, packageRoot, userPiRoot } from "../../utils/paths.ts";
|
|
8
|
+
import { PI_NO_MCP_FLOOR, piVersionAtLeast } from "../child-pi/pi-version.ts";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
// U4 (pi 1.0.4 adoption): pi's CLI documents 7 thinking levels (docs/cli.md:70);
|
|
11
|
+
// `max` completes the surface — frontmatter `thinking: max` previously fell
|
|
12
|
+
// back to Pi's default via isValidThinkingLevel.
|
|
13
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
10
14
|
// FIX (2026-07-02): use packageRoot() instead of import.meta.url-relative path.
|
|
11
15
|
// The previous path.resolve(path.dirname(fileURLToPath(import.meta.url)),
|
|
12
16
|
// "..", "prompt", "prompt-runtime.ts") was correct in source but BROKEN
|
|
@@ -50,6 +54,15 @@ export interface BuildPiWorkerArgsInput {
|
|
|
50
54
|
* (`--session-dir`, SDK ≥ 0.30.0). Run-scoped per-worker dir, e.g.
|
|
51
55
|
* `<artifactsRoot>/sessions/<taskId>/` — lives and dies with run artifacts. */
|
|
52
56
|
sessionDir?: string;
|
|
57
|
+
/** R3-15 (Pi 1.0.0 deep-learn r3): worker task id for the human-facing
|
|
58
|
+
* session display name — emits `--name crew-<taskId>` (cli.md:100, `-n/--name`
|
|
59
|
+
* "Sets the session display name"). Cosmetic only: W2's deterministic
|
|
60
|
+
* --session-id stays the machine-readable correlation key. Emitted inside
|
|
61
|
+
* the sessionEnabled block (a name labels a persisted session; under
|
|
62
|
+
* --no-session nothing persists). NOT stripped for surface TUI panes — a
|
|
63
|
+
* display name is not headless-only, it labels the pane's session too
|
|
64
|
+
* (child-pi.ts stripHeadlessOnlyFlags stays untouched). */
|
|
65
|
+
taskId?: string;
|
|
53
66
|
maxDepth?: number;
|
|
54
67
|
skillPaths?: string[];
|
|
55
68
|
env?: NodeJS.ProcessEnv;
|
|
@@ -75,6 +88,16 @@ export interface BuildPiWorkerArgsInput {
|
|
|
75
88
|
* declarations, and `--skill` flags are unaffected (cli.md:186-191:
|
|
76
89
|
* explicit -e and --skill survive --no-extensions/--no-skills). */
|
|
77
90
|
hermeticWorkers?: boolean;
|
|
91
|
+
/** U9 (pi 1.0.4 version floor): the HOST pi binary's version as probed by
|
|
92
|
+
* `pi --version` (probePiVersion — single-flight memoized per process,
|
|
93
|
+
* injectable exec seam, never rejects), or null when unknown (probe
|
|
94
|
+
* failed/timed out/unparseable). PURE INPUT — the builder NEVER spawns:
|
|
95
|
+
* the async spawn path (runChildPi) awaits the memoized probe upstream and
|
|
96
|
+
* threads the resolved string|null down through here. Drives the
|
|
97
|
+
* version-gated flags registered in child-pi/pi-version.ts (`--no-mcp` at
|
|
98
|
+
* the 1.0.4 floor, parity spawns only); unknown/below-floor conservatively
|
|
99
|
+
* emits NOTHING — a pre-floor host's strict parser rejects unknown flags. */
|
|
100
|
+
piVersion?: string | null;
|
|
78
101
|
}
|
|
79
102
|
|
|
80
103
|
export interface BuildPiWorkerArgsResult {
|
|
@@ -315,6 +338,10 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
315
338
|
if (input.sessionEnabled !== false) {
|
|
316
339
|
if (input.sessionId) args.push("--session-id", input.sessionId);
|
|
317
340
|
if (input.sessionDir) args.push("--session-dir", input.sessionDir);
|
|
341
|
+
// R3-15: human-facing display name — `crew-<taskId>` makes crew workers
|
|
342
|
+
// distinguishable in pi's session picker (flag predates nothing: -n/--name
|
|
343
|
+
// is a long-standing CLI flag, cli.md:100 — no capability gate needed).
|
|
344
|
+
if (input.taskId) args.push("--name", `crew-${input.taskId}`);
|
|
318
345
|
}
|
|
319
346
|
|
|
320
347
|
const resolvedModel = input.model ?? input.agent.model;
|
|
@@ -367,8 +394,40 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
367
394
|
// B2 (fix round 1): `disallowedTools:` frontmatter is a
|
|
368
395
|
// declaration-driven denylist (opt-in like `tools:`) — NOT the
|
|
369
396
|
// role-based policy D5 removed.
|
|
370
|
-
|
|
371
|
-
|
|
397
|
+
// U2-lite (pi 1.0.4 `--tools` semantics drift): on 1.0.4, `--tools` no
|
|
398
|
+
// longer cuts MCP — ambient MCP servers stay connected on parity
|
|
399
|
+
// (NON-hermetic) spawns. Fold `mcp__*` into the exclude list to restore
|
|
400
|
+
// the pre-1.0.4 MCP cut. Kept OUT of hermetic spawns: --no-extensions
|
|
401
|
+
// already disables builtin:mcp (hermetic spawns are MCP-clean — the
|
|
402
|
+
// conflict verdict in docs/reviews/pi-1.0.4-upgrade-notes-2026-10-06.md),
|
|
403
|
+
// and the default loadout pins !--exclude-tools
|
|
404
|
+
// (test/unit/runtime/model/pi-args-loadout.test.ts). Cross-version-safe:
|
|
405
|
+
// on pre-1.0.4 hosts the `mcp__*` pattern matches no literal tool name
|
|
406
|
+
// → benign no-op. ONE flag value only — pi's strict parser rejects a
|
|
407
|
+
// duplicate --exclude-tools flag, so declared disallowedTools merge
|
|
408
|
+
// comma-sep ahead of the pattern. Stays inside the else of disableTools:
|
|
409
|
+
// `--no-tools` already locks the whole tool surface, an extra cut would
|
|
410
|
+
// be dead weight. Surface TUI panes strip argv flags after build
|
|
411
|
+
// (child-pi.ts stripHeadlessOnlyFlags), so this cut never reaches them.
|
|
412
|
+
const disallowed = new Set((input.agent.disallowedTools ?? []).map((t) => t.trim()).filter(Boolean));
|
|
413
|
+
const paritySpawn = !resolveHermeticWorkers(input.hermeticWorkers);
|
|
414
|
+
if (paritySpawn) disallowed.add("mcp__" + "*");
|
|
415
|
+
if (disallowed.size > 0) args.push("--exclude-tools", [...disallowed].join(","));
|
|
416
|
+
// U9 (version floor, registry PI_NO_MCP_FLOOR in child-pi/pi-version.ts;
|
|
417
|
+
// flag doc cli.md:198): parity spawn on a host pi >= 1.0.4 ALSO passes
|
|
418
|
+
// `--no-mcp` — "no servers connect, and there are no MCP tools or /mcp",
|
|
419
|
+
// strictly stronger than the mcp__* tool-cut above (servers never even
|
|
420
|
+
// handshake). Below the floor / unknown (null) / malformed piVersion →
|
|
421
|
+
// NO flag: pi's strict option parser rejects unknown flags with a
|
|
422
|
+
// nonzero exit, so guessing wrong would break every parity spawn. The
|
|
423
|
+
// mcp__* fold STAYS alongside — it is the cross-version-safe baseline
|
|
424
|
+
// (pre-1.0.4 hosts have no --no-mcp to lean on). Hermetic spawns never
|
|
425
|
+
// reach here (--no-extensions already makes them MCP-clean). Kept inside
|
|
426
|
+
// the else of disableTools like the fold (--no-tools already locks the
|
|
427
|
+
// whole surface). Surface TUI panes KEEP the flag — stripHeadlessOnlyFlags
|
|
428
|
+
// only strips --no-approve/--no-extensions, same treatment as
|
|
429
|
+
// --exclude-tools: parity semantics must not depend on spawn mode.
|
|
430
|
+
if (paritySpawn && piVersionAtLeast(input.piVersion, PI_NO_MCP_FLOOR)) args.push("--no-mcp");
|
|
372
431
|
}
|
|
373
432
|
// prompt-runtime extension luôn nạp (hạ tầng phối hợp — không phải cắt xén).
|
|
374
433
|
args.push("--extension", PROMPT_RUNTIME_EXTENSION_PATH);
|