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.
Files changed (66) hide show
  1. package/.githooks/pre-commit +22 -0
  2. package/.githooks/pre-push +56 -0
  3. package/CHANGELOG.md +196 -1
  4. package/dist/index.mjs +533 -515
  5. package/docs/publishing.md +29 -0
  6. package/docs/usage.md +1 -1
  7. package/index.ts +3 -3
  8. package/install.mjs +4 -1
  9. package/package.json +8 -5
  10. package/scripts/install-hooks.mjs +32 -0
  11. package/skills/real-test-pi-crew/SKILL.md +57 -18
  12. package/src/config/config-validation.ts +8 -1
  13. package/src/config/defaults.ts +8 -1
  14. package/src/config/env-vars.ts +16 -1
  15. package/src/config/types.ts +28 -4
  16. package/src/extension/command-completions.ts +44 -22
  17. package/src/extension/registration/foreground-run-controller.ts +19 -10
  18. package/src/extension/registration/lifecycle-handlers.ts +6 -491
  19. package/src/extension/registration/lifecycle.ts +7 -90
  20. package/src/extension/registration/render-loop.ts +532 -0
  21. package/src/extension/team-tool/doctor.ts +6 -0
  22. package/src/extension/team-tool/handle-settings.ts +4 -1
  23. package/src/runtime/child-pi/child-pi-spawn.ts +7 -0
  24. package/src/runtime/child-pi/child-pi.ts +37 -2
  25. package/src/runtime/child-pi/pi-version.ts +187 -0
  26. package/src/runtime/classifier/classifier-service.ts +298 -0
  27. package/src/runtime/dispatch-batch.ts +4 -0
  28. package/src/runtime/live-session/live-session-runtime.ts +31 -0
  29. package/src/runtime/model/pi-args.ts +62 -3
  30. package/src/runtime/process/zombie-scanner.ts +168 -7
  31. package/src/runtime/rpc/README.md +64 -25
  32. package/src/runtime/rpc/rpc-worker.ts +146 -26
  33. package/src/runtime/run-worker.ts +89 -15
  34. package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
  35. package/src/runtime/skill-instructions.ts +66 -1
  36. package/src/runtime/surface/resolve-surface.ts +34 -19
  37. package/src/runtime/task-runner/child-executor.ts +132 -0
  38. package/src/runtime/task-runner/pre-execution.ts +8 -0
  39. package/src/runtime/task-runner.ts +6 -0
  40. package/src/schema/config-schema.ts +20 -8
  41. package/src/schema/team-tool-schema.ts +4 -2
  42. package/src/state/contracts.ts +5 -0
  43. package/src/ui/dashboard-panes/agents-pane.ts +3 -2
  44. package/src/ui/dashboard-panes/health-pane.ts +6 -1
  45. package/src/ui/dashboard-panes/mailbox-pane.ts +12 -7
  46. package/src/ui/dashboard-panes/plan-pane.ts +5 -2
  47. package/src/ui/dashboard-panes/progress-pane.ts +8 -2
  48. package/src/ui/format-helpers.ts +11 -0
  49. package/src/ui/live-conversation-overlay.ts +5 -3
  50. package/src/ui/live-duration.ts +5 -4
  51. package/src/ui/live-run-sidebar.ts +90 -20
  52. package/src/ui/overlays/mailbox-compose-overlay.ts +31 -1
  53. package/src/ui/rail.ts +14 -1
  54. package/src/ui/run-action-dispatcher.ts +2 -1
  55. package/src/ui/run-dashboard.ts +62 -22
  56. package/src/ui/run-snapshot-cache.ts +32 -0
  57. package/src/ui/settings-overlay.ts +36 -3
  58. package/src/ui/tool-renderers/brief-mode.ts +2 -2
  59. package/src/ui/transcript-viewer.ts +4 -1
  60. package/src/ui/widget/index.ts +10 -2
  61. package/src/ui/widget/task-list.ts +22 -7
  62. package/src/ui/widget/widget-model.ts +5 -4
  63. package/src/ui/widget/widget-renderer.ts +212 -37
  64. package/src/utils/relative-time.ts +11 -3
  65. package/src/utils/socket-path.ts +48 -8
  66. 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
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
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
- const disallowed = (input.agent.disallowedTools ?? []).map((t) => t.trim()).filter(Boolean);
371
- if (disallowed.length > 0) args.push("--exclude-tools", [...new Set(disallowed)].join(","));
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);