pi-crew 0.9.64 → 0.9.65
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/CHANGELOG.md +13 -0
- package/README.md +46 -1
- package/dist/index.mjs +316 -299
- package/package.json +3 -2
- package/scripts/analyze-run.mjs +1333 -0
- package/scripts/pty_probe.py +10 -8
- package/scripts/resource-sampler.mjs +482 -0
- package/skills/real-test-pi-crew/SKILL.md +6 -6
- package/src/observability/event-to-metric.ts +29 -0
- package/src/observability/metrics-primitives.ts +41 -3
- package/src/runtime/README.md +1 -1
- package/src/runtime/broker/crew-broker.ts +0 -16
- package/src/runtime/effectiveness.ts +23 -1
- package/src/runtime/merge-gate.ts +202 -0
- package/src/runtime/model/model-fallback.ts +11 -0
- package/src/runtime/model/provider-extensions.ts +31 -12
- package/src/runtime/output/progress-tracker.ts +3 -33
- package/src/runtime/scratchpad/engine.ts +40 -2
- package/src/runtime/scratchpad/snapshot-hmac.ts +161 -0
- package/src/runtime/team-runner.ts +128 -203
- package/src/schema/team-tool-schema.ts +2 -0
- package/src/teams/discover-teams.ts +2 -0
- package/src/teams/team-config.ts +7 -0
- package/src/teams/team-serializer.ts +1 -0
- package/src/ui/mascot.ts +1 -14
- package/teams/default.team.md +1 -0
- package/teams/fast-fix.team.md +1 -0
- package/src/observability/event-bus.ts +0 -86
- package/src/plugins/plugin-define.ts +0 -6
- package/src/plugins/plugin-registry.ts +0 -32
- package/src/plugins/plugins/index.ts +0 -3
- package/src/plugins/plugins/nextjs.ts +0 -19
- package/src/plugins/plugins/vite.ts +0 -10
- package/src/plugins/plugins/vitest.ts +0 -9
- package/src/runtime/child-pi/child-pi-pool.ts +0 -68
- package/src/runtime/iteration-hooks.ts +0 -305
package/src/runtime/README.md
CHANGED
|
@@ -7,7 +7,7 @@ clusters are extracted into subdirectories; the remaining files stay at the root
|
|
|
7
7
|
|
|
8
8
|
| Subdir | Phase | Cluster | Key files |
|
|
9
9
|
|--------|-------|---------|-----------|
|
|
10
|
-
| [`child-pi/`](./child-pi/) | B-1 | child Pi worker spawn/lifecycle/steering/transcript | `child-pi.ts`, `child-pi-spawn.ts`, `child-pi-kill.ts`, `child-pi-constants.ts`, `child-pi-steering.ts`, `child-pi-streams.ts`, `child-pi-transcript.ts
|
|
10
|
+
| [`child-pi/`](./child-pi/) | B-1 | child Pi worker spawn/lifecycle/steering/transcript | `child-pi.ts`, `child-pi-spawn.ts`, `child-pi-kill.ts`, `child-pi-constants.ts`, `child-pi-steering.ts`, `child-pi-streams.ts`, `child-pi-transcript.ts` |
|
|
11
11
|
| [`broker/`](./broker/) | B-2 | crew broker server/client/auth (child-pi ↔ parent IPC) | `crew-broker.ts`, `crew-broker-client.ts`, `crew-broker-child.ts`, `crew-broker-tokens.ts`, `broker-issuer.ts` |
|
|
12
12
|
| [`task-runner/`](./task-runner/) | (pre-existing) | per-task execution (pre-execution, child-executor) | `child-executor.ts`, `pre-execution.ts`, ... |
|
|
13
13
|
| [`live-session/`](./live-session/) | 3 | live agent control/manager, session runtime, IRC, health, extension bridge | `live-session-runtime.ts`, `live-agent-manager.ts`, `live-agent-control.ts`, `live-control-realtime.ts`, `live-irc.ts`, `live-session-health.ts`, `live-extension-bridge.ts`, `intercom-bridge.ts` |
|
|
@@ -357,22 +357,6 @@ export class CrewBroker {
|
|
|
357
357
|
this.resolvedSocketPath = null;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
-
/**
|
|
361
|
-
* Non-throwing enqueue entry point for the post-append mailbox observer
|
|
362
|
-
* (Phase 1) or any other in-process producer. Phase 0 accepts `notifyMessage`
|
|
363
|
-
* as a no-op shape so the lifecycle controller can install a single
|
|
364
|
-
* observer regardless of broker state.
|
|
365
|
-
*
|
|
366
|
-
* Fanout goes ONLY to authenticated connections matching the recipient.
|
|
367
|
-
* Phase 0 keeps this as a typed no-op (`not-implemented` would be
|
|
368
|
-
* inappropriate here — the caller is in-process and shouldn't be
|
|
369
|
-
* punished for testing the broker skeleton).
|
|
370
|
-
*/
|
|
371
|
-
notifyMessage(_message: unknown): void {
|
|
372
|
-
// Phase 0: no fanout. Phase 1 replaces this with the single
|
|
373
|
-
// post-durable mailbox observer fanout.
|
|
374
|
-
}
|
|
375
|
-
|
|
376
360
|
// ------------------------------------------------------------------------
|
|
377
361
|
// Connection lifecycle
|
|
378
362
|
// ------------------------------------------------------------------------
|
|
@@ -26,6 +26,24 @@ export function taskHasObservableWorkerActivity(task: TeamTaskState): boolean {
|
|
|
26
26
|
);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* True when the task completed but its result artifact is an EMPTY file
|
|
31
|
+
* (0 bytes). Closes the F4 monitoring gap (real-test-2026-08-10 finding):
|
|
32
|
+
* a child worker absorbed by a rate-limit (429) or a model-not-found
|
|
33
|
+
* failure still emits transcript/usage events, so
|
|
34
|
+
* {@link taskHasObservableWorkerActivity} reports "activity" — but the
|
|
35
|
+
* actual result content is empty. A completed task with an empty result
|
|
36
|
+
* has done no real work and must not count toward run effectiveness.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately only flags `sizeBytes === 0` (a written-but-empty file).
|
|
39
|
+
* A missing resultArtifact stays non-flagging (some tasks legitimately
|
|
40
|
+
* produce no result file), and an undefined sizeBytes stays non-flagging
|
|
41
|
+
* (legacy tasks — do not false-positive on absent metadata).
|
|
42
|
+
*/
|
|
43
|
+
export function taskHasEmptyResult(task: TeamTaskState): boolean {
|
|
44
|
+
return Boolean(task.resultArtifact && task.resultArtifact.sizeBytes === 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
export function resolveEffectivenessGuardMode(
|
|
30
48
|
runtimeConfig: CrewRuntimeConfig | undefined,
|
|
31
49
|
manifest?: TeamRunManifest,
|
|
@@ -43,7 +61,11 @@ export function evaluateRunEffectiveness(input: {
|
|
|
43
61
|
runtimeConfig?: CrewRuntimeConfig;
|
|
44
62
|
}): RunEffectivenessSummary {
|
|
45
63
|
const completedTasks = input.tasks.filter((task) => task.status === "completed");
|
|
46
|
-
|
|
64
|
+
// F4: a completed task with an EMPTY result artifact (0 bytes) is
|
|
65
|
+
// treated exactly like no-observed-work — the worker may have spun up
|
|
66
|
+
// and streamed transcript events, but it produced no real output
|
|
67
|
+
// (e.g. a 429 rate-limit absorbed by the fallback chain).
|
|
68
|
+
const noObservedWorkTasks = completedTasks.filter((task) => !taskHasObservableWorkerActivity(task) || taskHasEmptyResult(task));
|
|
47
69
|
const needsAttentionTasks = input.tasks.filter((task) => task.agentProgress?.activityState === "needs_attention");
|
|
48
70
|
const workerExecution: WorkerExecutionState = input.executeWorkers ? "enabled" : "disabled/scaffold";
|
|
49
71
|
const guardMode = resolveEffectivenessGuardMode(input.runtimeConfig, input.manifest);
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monotonic merge gate for parallel task updates.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from team-runner.ts (2026-08-10, improvement-plan Tier 2
|
|
5
|
+
* "team-runner split" — self-contained portion). The merge gate protects
|
|
6
|
+
* terminal task states from being regressed by stale parallel worker
|
|
7
|
+
* snapshots: every terminal->non-terminal transition is rejected, plus a
|
|
8
|
+
* small set of bespoke policies (P2 completed integrity, P3
|
|
9
|
+
* waiting->running stale-snapshot regression) that are stricter than the
|
|
10
|
+
* lifecycle table on the parallel-merge path.
|
|
11
|
+
*
|
|
12
|
+
* Exhaustively tested in test/unit/team-runner-should-merge-table.test.ts.
|
|
13
|
+
*/
|
|
14
|
+
import { TEAM_TASK_STATUSES, TEAM_TERMINAL_TASK_STATUSES, type TeamTaskStatus } from "../state/contracts.ts";
|
|
15
|
+
import type { TeamTaskState } from "../state/types.ts";
|
|
16
|
+
import { refreshTaskGraphQueues } from "./scheduling/task-graph-scheduler.ts";
|
|
17
|
+
|
|
18
|
+
export function isNonTerminalTaskStatus(status: TeamTaskState["status"]): boolean {
|
|
19
|
+
return status === "queued" || status === "running" || status === "waiting";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function safeFinishedAt(task: TeamTaskState): number {
|
|
23
|
+
if (!task.finishedAt) return -Infinity;
|
|
24
|
+
const ms = new Date(task.finishedAt).getTime();
|
|
25
|
+
return Number.isNaN(ms) ? Infinity : ms;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns true when the current task has a malformed finishedAt (NaN/Infinity)
|
|
30
|
+
* and the updated task has a valid finite finishedAt. Malformed finishedAt
|
|
31
|
+
* should be replaced rather than persisting corruption.
|
|
32
|
+
*/
|
|
33
|
+
export function isMalformedFinishedAtReplacement(currentTime: number, updatedTime: number): boolean {
|
|
34
|
+
return !Number.isFinite(currentTime) && Number.isFinite(updatedTime);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* RT-16: status-level gate for shouldMergeTaskUpdate. Returns the stable
|
|
39
|
+
* "from->to" key used by REJECTED_STATUS_MERGE_TRANSITIONS.
|
|
40
|
+
*/
|
|
41
|
+
export function statusMergeKey(from: TeamTaskStatus, to: TeamTaskStatus): string {
|
|
42
|
+
return `${from}->${to}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* RT-16 — derived merge-gate transition table.
|
|
47
|
+
*
|
|
48
|
+
* The set of old->new status pairs that shouldMergeTaskUpdate must REJECT based
|
|
49
|
+
* solely on the status transition (before any field-level comparison). It
|
|
50
|
+
* replaces the former 13 hand-written status guards that hand-duplicated the
|
|
51
|
+
* lifecycle table. Built once from the single source-of-truth table
|
|
52
|
+
* (TEAM_TASK_STATUSES + TEAM_TERMINAL_TASK_STATUSES — the terminal half of
|
|
53
|
+
* TEAM_TASK_STATUS_TRANSITIONS) plus two merge-specific policies that are
|
|
54
|
+
* STRICTER than the lifecycle table on the parallel-merge path:
|
|
55
|
+
*
|
|
56
|
+
* P1 Terminal preservation — every terminal->non-terminal pair is rejected.
|
|
57
|
+
* The lifecycle table permits retries (e.g. completed->queued), but a stale
|
|
58
|
+
* worker snapshot must never resurrect a settled task.
|
|
59
|
+
* P2 Completed integrity — five terminal->terminal flips that touch the
|
|
60
|
+
* "completed" success terminal are rejected: completed->failed,
|
|
61
|
+
* completed->needs_attention, failed->completed, cancelled->completed,
|
|
62
|
+
* needs_attention->completed. (completed may still move to
|
|
63
|
+
* cancelled/skipped; that is intentionally allowed, so these are NOT simply
|
|
64
|
+
* "every illegal terminal->terminal flip".)
|
|
65
|
+
* P3 waiting->running regression — the single stale-snapshot case.
|
|
66
|
+
*
|
|
67
|
+
* The decision for every old->new pair is byte-for-byte identical to the former
|
|
68
|
+
* 7 status guards (verified exhaustively in
|
|
69
|
+
* test/unit/team-runner-should-merge-table.test.ts).
|
|
70
|
+
*/
|
|
71
|
+
export const REJECTED_STATUS_MERGE_TRANSITIONS: ReadonlySet<string> = (() => {
|
|
72
|
+
const rejected = new Set<string>();
|
|
73
|
+
// P1 — terminal preservation: reject every terminal->non-terminal pair.
|
|
74
|
+
for (const from of TEAM_TASK_STATUSES) {
|
|
75
|
+
if (!TEAM_TERMINAL_TASK_STATUSES.has(from)) continue;
|
|
76
|
+
for (const to of TEAM_TASK_STATUSES) {
|
|
77
|
+
if (!TEAM_TERMINAL_TASK_STATUSES.has(to)) rejected.add(statusMergeKey(from, to));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// P3 — waiting->running stale-snapshot regression.
|
|
81
|
+
rejected.add(statusMergeKey("waiting", "running"));
|
|
82
|
+
// P2 — completed integrity flips (bespoke terminal->terminal policy).
|
|
83
|
+
const completedIntegrityFlips: ReadonlyArray<[TeamTaskStatus, TeamTaskStatus]> = [
|
|
84
|
+
["completed", "failed"],
|
|
85
|
+
["completed", "needs_attention"],
|
|
86
|
+
["failed", "completed"],
|
|
87
|
+
["cancelled", "completed"],
|
|
88
|
+
["needs_attention", "completed"],
|
|
89
|
+
];
|
|
90
|
+
for (const [from, to] of completedIntegrityFlips) rejected.add(statusMergeKey(from, to));
|
|
91
|
+
return rejected;
|
|
92
|
+
})();
|
|
93
|
+
|
|
94
|
+
export function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskState): boolean {
|
|
95
|
+
// RT-16: status-level gate — reject stale/dangerous transitions via the
|
|
96
|
+
// derived transition table (REJECTED_STATUS_MERGE_TRANSITIONS) instead of
|
|
97
|
+
// hand-written guards. Parallel workers receive the same input snapshot; a
|
|
98
|
+
// later result may still carry stale copies. The table encodes three
|
|
99
|
+
// merge-specific policies stricter than the lifecycle table: terminal
|
|
100
|
+
// preservation (no terminal->non-terminal resurrection), completed integrity
|
|
101
|
+
// (no flipping the "completed" success terminal to/from failed or
|
|
102
|
+
// needs_attention), and the waiting->running stale-snapshot regression.
|
|
103
|
+
if (REJECTED_STATUS_MERGE_TRANSITIONS.has(statusMergeKey(current.status, updated.status))) return false;
|
|
104
|
+
// Guard: when current is "running" but has resultArtifact (another worker already
|
|
105
|
+
// completed it), a stale updated with status="running" and no resultArtifact
|
|
106
|
+
// must not overwrite the actual completed state.
|
|
107
|
+
if (current.status === updated.status && updated.status === "running" && current.resultArtifact && !updated.resultArtifact)
|
|
108
|
+
return false;
|
|
109
|
+
// Guard: when current is "completed" and has resultArtifact but updated is also
|
|
110
|
+
// "completed" without resultArtifact, block the stale update from overwriting
|
|
111
|
+
// a task that successfully produced output.
|
|
112
|
+
if (current.status === updated.status && current.status === "completed" && current.resultArtifact && !updated.resultArtifact)
|
|
113
|
+
return false;
|
|
114
|
+
// Prevent a stale completed task from overwriting a fresher one.
|
|
115
|
+
// Restructure to handle undefined current.finishedAt as a special case:
|
|
116
|
+
// - undefined current + valid updated: allow the update
|
|
117
|
+
// - valid current + undefined updated: block the update (don't lose completion time)
|
|
118
|
+
// - both undefined: finishedAt guard does not apply, fall through to heartbeat check
|
|
119
|
+
// - both valid: compare timestamps as before
|
|
120
|
+
if (current.finishedAt !== undefined && updated.finishedAt !== undefined) {
|
|
121
|
+
const currentTime = safeFinishedAt(current);
|
|
122
|
+
const updatedTime = safeFinishedAt(updated);
|
|
123
|
+
// Malformed finishedAt (NaN) is treated as Infinity — invalid state should be
|
|
124
|
+
// replaced rather than persisting corruption. Log warning for visibility.
|
|
125
|
+
if (!Number.isFinite(currentTime)) {
|
|
126
|
+
console.warn(`[merge-gate] Task ${current.id} has malformed finishedAt: ${current.finishedAt}`);
|
|
127
|
+
}
|
|
128
|
+
if (isMalformedFinishedAtReplacement(currentTime, updatedTime)) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (updatedTime < currentTime) return false;
|
|
132
|
+
}
|
|
133
|
+
// Block if updated is trying to establish a terminal status without a finishedAt
|
|
134
|
+
// timestamp. Heartbeat-only updates (status='running', no finishedAt) are
|
|
135
|
+
// allowed if heartbeat has changed (checked separately in hasMeaningfulUpdate).
|
|
136
|
+
if (!updated.finishedAt && !isNonTerminalTaskStatus(updated.status)) return false;
|
|
137
|
+
// Explicitly enumerate all fields that constitute a meaningful update so that
|
|
138
|
+
// adding a new important field requires updating this list (rather than silently
|
|
139
|
+
// losing data if a field is forgotten in the boolean OR chain below).
|
|
140
|
+
const hasMeaningfulUpdate =
|
|
141
|
+
updated.status !== current.status ||
|
|
142
|
+
updated.finishedAt !== current.finishedAt ||
|
|
143
|
+
updated.startedAt !== current.startedAt ||
|
|
144
|
+
Boolean(updated.resultArtifact) !== Boolean(current.resultArtifact) ||
|
|
145
|
+
(Boolean(updated.resultArtifact) && updated.resultArtifact !== current.resultArtifact) ||
|
|
146
|
+
Boolean(updated.error) ||
|
|
147
|
+
Boolean(updated.modelAttempts?.length) ||
|
|
148
|
+
Boolean(updated.usage) ||
|
|
149
|
+
Boolean(updated.attempts?.length) ||
|
|
150
|
+
updated.heartbeat?.lastSeenAt !== current.heartbeat?.lastSeenAt ||
|
|
151
|
+
updated.jsonEvents !== current.jsonEvents ||
|
|
152
|
+
updated.agentProgress?.lastActivityAt !== current.agentProgress?.lastActivityAt;
|
|
153
|
+
return hasMeaningfulUpdate;
|
|
154
|
+
}
|
|
155
|
+
/** Exposed for the exhaustive status-merge table test (RT-16). */
|
|
156
|
+
export const __test__shouldMergeTaskUpdate = shouldMergeTaskUpdate;
|
|
157
|
+
|
|
158
|
+
// H4 fix: rename to descriptive name. Kept __test__ as alias for backward
|
|
159
|
+
// compat test imports.
|
|
160
|
+
// FIX (perf P10): replace O(N×M) .find() + .map() inside nested loops with a
|
|
161
|
+
// single-pass Map-based merge. Build an index of `merged` once, then for each
|
|
162
|
+
// incoming updated task do O(1) lookup; the final pass reassembles `merged`
|
|
163
|
+
// preserving original order. For a 20-task run × 5-batch merger with
|
|
164
|
+
// ~10 updates per result, this reduces from O(50×20) = 1000 ops to O(120).
|
|
165
|
+
// Behavior is unchanged: skipped updates (shouldMergeTaskUpdate=false) still
|
|
166
|
+
// leave the existing task in place.
|
|
167
|
+
export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], results: Array<{ tasks: TeamTaskState[] }>): TeamTaskState[] {
|
|
168
|
+
// Index current merged state by id for O(1) lookup during the merge pass.
|
|
169
|
+
const indexById = new Map<string, TeamTaskState>();
|
|
170
|
+
for (const task of base) indexById.set(task.id, task);
|
|
171
|
+
|
|
172
|
+
let skipped = 0;
|
|
173
|
+
for (const result of results) {
|
|
174
|
+
for (const updated of result.tasks) {
|
|
175
|
+
const current = indexById.get(updated.id);
|
|
176
|
+
if (!current) continue;
|
|
177
|
+
if (!shouldMergeTaskUpdate(current, updated)) {
|
|
178
|
+
// Log skipped merges for visibility into rejected parallel updates.
|
|
179
|
+
// In distributed systems with parallel workers, rejected merges may
|
|
180
|
+
// indicate bugs (wrong status, timestamp corruption) if they accumulate.
|
|
181
|
+
console.debug("[merge-gate] Skipping stale merge for task", updated.id, {
|
|
182
|
+
currentStatus: current.status,
|
|
183
|
+
updatedStatus: updated.status,
|
|
184
|
+
currentFinishedAt: current.finishedAt,
|
|
185
|
+
updatedFinishedAt: updated.finishedAt,
|
|
186
|
+
});
|
|
187
|
+
skipped += 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
indexById.set(updated.id, updated);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Reassemble in original `base` order so downstream snapshots stay stable.
|
|
194
|
+
const merged = base.map((task) => indexById.get(task.id) ?? task);
|
|
195
|
+
// `skipped` is intentional visibility — currently no caller reads it but
|
|
196
|
+
// we'd rather leave the count available for future instrumentation than
|
|
197
|
+
// remove the cumulative silent-rejection signal it provides.
|
|
198
|
+
void skipped;
|
|
199
|
+
return refreshTaskGraphQueues(merged);
|
|
200
|
+
}
|
|
201
|
+
/** @deprecated Use mergeTaskUpdatesPreservingTerminal. Kept for backward test import compat. */
|
|
202
|
+
export const __test__mergeTaskUpdates = mergeTaskUpdatesPreservingTerminal;
|
|
@@ -365,6 +365,17 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
|
365
365
|
/safety/i,
|
|
366
366
|
/is[_ ]?overloaded/i,
|
|
367
367
|
/\b408\b/,
|
|
368
|
+
//
|
|
369
|
+
// EPIPE / broken-pipe. In the child-pi worker path this typically means
|
|
370
|
+
// the child `pi` process exited (crash or early exit) while the parent
|
|
371
|
+
// was still writing to its stdin — spawning a fresh child on the next
|
|
372
|
+
// model in the fallback chain usually recovers. In the network path it
|
|
373
|
+
// is a transient pipe close. Both are retryable on a different model.
|
|
374
|
+
// See docs/failure-mode-inventory.md EPIPE gap; NON_RETRYABLE patterns
|
|
375
|
+
// (auth/billing) are checked first, so an auth error mentioning EPIPE
|
|
376
|
+
// stays non-retryable.
|
|
377
|
+
/epipe/i,
|
|
378
|
+
/broken pipe/i,
|
|
368
379
|
];
|
|
369
380
|
|
|
370
381
|
// These patterns indicate auth/key/billing issues that will never succeed on retry.
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import * as fs from "node:fs";
|
|
24
24
|
import * as path from "node:path";
|
|
25
|
-
import { userPiRoot } from "../../utils/paths.ts";
|
|
25
|
+
import { packageRoot, userPiRoot } from "../../utils/paths.ts";
|
|
26
26
|
|
|
27
27
|
export interface DiscoveredProviderExtension {
|
|
28
28
|
/** Package specifier as written in settings.json packages (e.g. "npm:pi-commandcode-provider"). */
|
|
@@ -95,12 +95,17 @@ function resolvePackageEntry(pkgDir: string): string | undefined {
|
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
97
|
* Discover provider extension entry points from Pi's installed package registry.
|
|
98
|
-
* Reads `~/.pi/agent/settings.json` → `packages`
|
|
99
|
-
*
|
|
100
|
-
*
|
|
98
|
+
* Reads `~/.pi/agent/settings.json` → `packages` and resolves each spec:
|
|
99
|
+
* - `npm:<name>` → `~/.pi/agent/npm/node_modules/<name>/`
|
|
100
|
+
* - local path spec → resolved relative to the settings.json dir (the way
|
|
101
|
+
* `pi install <local-path>` records them), e.g. "../../src/foo"
|
|
102
|
+
* - git:/file: specs → skipped (not resolvable on disk)
|
|
101
103
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
+
* Both npm: and local-path specs are SANCTIONED channels — the user wrote them
|
|
105
|
+
* into settings.json (directly or via `pi install`), so they are trusted at the
|
|
106
|
+
* same level. This is distinct from project-sourced AGENT extensions
|
|
107
|
+
* (`.crew/agents/*.md` `extensions:` frontmatter), which are repo-adjacent
|
|
108
|
+
* untrusted data and stay gated by SEC-1 in discover-agents.ts.
|
|
104
109
|
*/
|
|
105
110
|
export function discoverProviderExtensions(settingsPath?: string): DiscoveredProviderExtension[] {
|
|
106
111
|
const root = userPiRoot();
|
|
@@ -125,13 +130,27 @@ export function discoverProviderExtensions(settingsPath?: string): DiscoveredPro
|
|
|
125
130
|
const npmBase = path.join(baseDir, "npm", "node_modules");
|
|
126
131
|
for (const spec of settings.packages ?? []) {
|
|
127
132
|
if (typeof spec !== "string") continue;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
133
|
+
let pkgDir: string;
|
|
134
|
+
if (spec.startsWith("npm:")) {
|
|
135
|
+
// Scoped packages: "@scope/name" → "@scope/name"; plain: "name".
|
|
136
|
+
pkgDir = path.join(npmBase, spec.slice(4));
|
|
137
|
+
} else if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)) {
|
|
138
|
+
// Local path spec — resolve relative to the settings.json dir, matching
|
|
139
|
+
// how `pi install <local-path>` records it. Same trust level as npm:
|
|
140
|
+
// (user wrote it into settings.json). Not to be confused with project
|
|
141
|
+
// AGENT extensions (.crew/agents/* frontmatter) — those stay SEC-1 gated.
|
|
142
|
+
pkgDir = path.resolve(baseDir, spec);
|
|
143
|
+
} else {
|
|
144
|
+
// git:/file:/http: specs etc. — not resolvable on disk, skip.
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
134
147
|
if (!fs.existsSync(pkgDir)) continue;
|
|
148
|
+
// Skip self: pi-crew's own package is a settings package (the orchestrator
|
|
149
|
+
// extension the parent loads), but a child WORKER must not re-load it — it
|
|
150
|
+
// would register the team tool / observability / MCP wiring intended for
|
|
151
|
+
// the orchestrator process, not a worker. Provider + adapter extensions
|
|
152
|
+
// (pi-other-provider, pi-mcp-adapter, pi-rlm, ...) stay.
|
|
153
|
+
if (path.resolve(pkgDir) === path.resolve(packageRoot())) continue;
|
|
135
154
|
const entryPath = resolvePackageEntry(pkgDir);
|
|
136
155
|
if (entryPath) out.push({ spec, entryPath });
|
|
137
156
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { crewEventBus } from "../../observability/event-bus.ts";
|
|
3
2
|
|
|
4
3
|
export interface AgentProgress {
|
|
5
4
|
toolCalls: number;
|
|
@@ -25,7 +24,7 @@ export class ProgressTracker {
|
|
|
25
24
|
subscribe: (listener: (event: AgentSessionEvent) => void) => () => void;
|
|
26
25
|
},
|
|
27
26
|
agentId: string,
|
|
28
|
-
|
|
27
|
+
_runId: string,
|
|
29
28
|
): AgentProgress {
|
|
30
29
|
if (this.sessions.has(agentId)) {
|
|
31
30
|
return this.sessions.get(agentId)!.progress;
|
|
@@ -42,26 +41,19 @@ export class ProgressTracker {
|
|
|
42
41
|
};
|
|
43
42
|
|
|
44
43
|
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
45
|
-
this.handleEvent(event, progress
|
|
44
|
+
this.handleEvent(event, progress);
|
|
46
45
|
});
|
|
47
46
|
|
|
48
47
|
this.sessions.set(agentId, { unsubscribe, progress });
|
|
49
48
|
return progress;
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
private handleEvent(event: AgentSessionEvent, progress: AgentProgress
|
|
51
|
+
private handleEvent(event: AgentSessionEvent, progress: AgentProgress): void {
|
|
53
52
|
switch (event.type) {
|
|
54
53
|
case "tool_execution_start":
|
|
55
54
|
progress.toolCalls++;
|
|
56
55
|
progress.currentTool = event.toolName;
|
|
57
56
|
progress.toolStartTime = Date.now();
|
|
58
|
-
crewEventBus.emit({
|
|
59
|
-
type: "agent:progress",
|
|
60
|
-
runId,
|
|
61
|
-
agentId,
|
|
62
|
-
payload: { ...progress },
|
|
63
|
-
timestamp: Date.now(),
|
|
64
|
-
});
|
|
65
57
|
break;
|
|
66
58
|
|
|
67
59
|
case "tool_execution_end":
|
|
@@ -69,21 +61,7 @@ export class ProgressTracker {
|
|
|
69
61
|
progress.toolStartTime = null;
|
|
70
62
|
if (event.isError) {
|
|
71
63
|
progress.errors.push(String(event.result ?? "Unknown error"));
|
|
72
|
-
crewEventBus.emit({
|
|
73
|
-
type: "agent:error",
|
|
74
|
-
runId,
|
|
75
|
-
agentId,
|
|
76
|
-
payload: String(event.result ?? "Unknown error"),
|
|
77
|
-
timestamp: Date.now(),
|
|
78
|
-
});
|
|
79
64
|
}
|
|
80
|
-
crewEventBus.emit({
|
|
81
|
-
type: "agent:progress",
|
|
82
|
-
runId,
|
|
83
|
-
agentId,
|
|
84
|
-
payload: { ...progress },
|
|
85
|
-
timestamp: Date.now(),
|
|
86
|
-
});
|
|
87
65
|
break;
|
|
88
66
|
|
|
89
67
|
case "turn_start":
|
|
@@ -92,13 +70,6 @@ export class ProgressTracker {
|
|
|
92
70
|
|
|
93
71
|
case "agent_end":
|
|
94
72
|
progress.status = "completed";
|
|
95
|
-
crewEventBus.emit({
|
|
96
|
-
type: "agent:complete",
|
|
97
|
-
runId,
|
|
98
|
-
agentId,
|
|
99
|
-
payload: { ...progress },
|
|
100
|
-
timestamp: Date.now(),
|
|
101
|
-
});
|
|
102
73
|
break;
|
|
103
74
|
|
|
104
75
|
case "agent_start":
|
|
@@ -120,5 +91,4 @@ export class ProgressTracker {
|
|
|
120
91
|
}
|
|
121
92
|
}
|
|
122
93
|
|
|
123
|
-
// Export singleton instance
|
|
124
94
|
export const globalProgressTracker = new ProgressTracker();
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* grace, truncateWithMarker, the childClosed race guard — is ported 1:1.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { type ChildProcess, spawn } from "node:child_process";
|
|
20
|
+
import { type ChildProcess, spawn, spawnSync } from "node:child_process";
|
|
21
21
|
import { randomUUID } from "node:crypto";
|
|
22
22
|
import { closeSync, constants as fsConstants, fstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { dirname } from "node:path";
|
|
@@ -198,6 +198,15 @@ export class EngineManager {
|
|
|
198
198
|
...(this.options.env ?? {}),
|
|
199
199
|
[NONCE_ENV]: this.nonce,
|
|
200
200
|
},
|
|
201
|
+
// Run the guest as its own session leader so descendants
|
|
202
|
+
// (cell-spawned subprocesses) share its process group. teardown
|
|
203
|
+
// then uses `process.kill(-pid, ...)` (POSIX) or `taskkill /T`
|
|
204
|
+
// (Windows) to clean up the whole tree — closing the
|
|
205
|
+
// "cell-subprocess orphan on session_shutdown" gap declared in
|
|
206
|
+
// docs/failure-mode-inventory.md (D.4 narrowed).
|
|
207
|
+
// stdio pipes are still owned by the parent; `detached` only
|
|
208
|
+
// affects process-group / kill-on-parent-exit semantics.
|
|
209
|
+
detached: true,
|
|
201
210
|
// fd 3 carries protocol traffic so stdout/stderr stay pure user output.
|
|
202
211
|
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
203
212
|
});
|
|
@@ -303,7 +312,36 @@ export class EngineManager {
|
|
|
303
312
|
this.engineState = "shutdown";
|
|
304
313
|
liveEngines.delete(this);
|
|
305
314
|
this.failAllPending(new Error("Engine has been shut down"));
|
|
306
|
-
|
|
315
|
+
// Kill the whole process group / job tree so cell-spawned
|
|
316
|
+
// subprocesses (grandchildren of the host) do not orphan when the
|
|
317
|
+
// session shuts down. Best-effort: fall back to a single-pid kill
|
|
318
|
+
// if the group/tree kill fails (already dead, EPERM, etc.).
|
|
319
|
+
const child = this.child;
|
|
320
|
+
const pid = child?.pid;
|
|
321
|
+
if (pid !== undefined && pid > 0) {
|
|
322
|
+
try {
|
|
323
|
+
if (process.platform === "win32") {
|
|
324
|
+
// taskkill /T /F kills the entire process tree rooted at pid.
|
|
325
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
326
|
+
} else {
|
|
327
|
+
// Negative pid = signal the entire process group.
|
|
328
|
+
// Requires the guest to be its own session leader (detached:true above).
|
|
329
|
+
process.kill(-pid, "SIGKILL");
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
try {
|
|
333
|
+
child?.kill("SIGKILL");
|
|
334
|
+
} catch {
|
|
335
|
+
/* already gone — nothing to clean up */
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} else if (child) {
|
|
339
|
+
try {
|
|
340
|
+
child.kill("SIGKILL");
|
|
341
|
+
} catch {
|
|
342
|
+
/* already gone */
|
|
343
|
+
}
|
|
344
|
+
}
|
|
307
345
|
this.child = undefined;
|
|
308
346
|
this.protocolReader?.close();
|
|
309
347
|
this.protocolReader = undefined;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot HMAC helper — opt-in integrity for scratchpad snapshots.
|
|
3
|
+
*
|
|
4
|
+
* Closes the E.2 gap declared in docs/improvement-plan-2026-08-09.md and
|
|
5
|
+
* docs/runtime/scratchpad/README.md:120 ("v8.deserialize of restore content
|
|
6
|
+
* is unauthenticated (no HMAC)"). The threat model — a same-uid attacker
|
|
7
|
+
* plants a crafted V8 blob at the snapshot path to run deserialize gadgets
|
|
8
|
+
* in the guest — does not cross the existing same-uid boundary, but HMAC
|
|
9
|
+
* hardening is required before snapshots ever land in a shared/networked
|
|
10
|
+
* store.
|
|
11
|
+
*
|
|
12
|
+
* Migration window (see docs/decisions/2026-08-10-scratchpad-snapshot-hmac.md):
|
|
13
|
+
*
|
|
14
|
+
* Phase 1 (this module): HMAC sign-on-write + verify-on-read are OPT-IN
|
|
15
|
+
* via PI_CREW_SNAPSHOT_HMAC_KEY. When the key is unset, behaviour is
|
|
16
|
+
* unchanged (snapshots remain unsigned). When the key is set, writes
|
|
17
|
+
* attach a signature and reads verify it; an unsigned/failed snapshot
|
|
18
|
+
* is ACCEPTED with a warning so existing snapshots remain readable.
|
|
19
|
+
* Phase 2 (after one release): unsigned snapshots are REJECTED when the
|
|
20
|
+
* key is set (configurable via PI_CREW_SNAPSHOT_HMAC_STRICT=1).
|
|
21
|
+
* Phase 3 (after snapshots move to a shared store): the key becomes
|
|
22
|
+
* required and unsigned snapshots are always rejected.
|
|
23
|
+
*
|
|
24
|
+
* Wire-up into the actual write/read paths is intentionally deferred to a
|
|
25
|
+
* follow-up that audits the snapshot envelope format (V8 base64 vs raw
|
|
26
|
+
* bytes) and the writeArtifact redaction interaction. See ADR for the plan.
|
|
27
|
+
*/
|
|
28
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Env var holding the HMAC secret. When unset, HMAC is disabled (Phase 0
|
|
32
|
+
* behaviour — snapshots unsigned). When set, sign-on-write and
|
|
33
|
+
* verify-on-read are enabled.
|
|
34
|
+
*/
|
|
35
|
+
export const SNAPSHOT_HMAC_KEY_ENV = "PI_CREW_SNAPSHOT_HMAC_KEY";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Opt-in strict mode (Phase 2): when the key is set AND this is "1",
|
|
39
|
+
* unsigned or signature-mismatched snapshots are REJECTED on read instead
|
|
40
|
+
* of accepted with a warning.
|
|
41
|
+
*/
|
|
42
|
+
export const SNAPSHOT_HMAC_STRICT_ENV = "PI_CREW_SNAPSHOT_HMAC_STRICT";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Header prefix for an inline signature. When snapshots are signed, the
|
|
46
|
+
* signature is prepended to the blob as `PI_CREW_SIG=<hex>\n` so a single
|
|
47
|
+
* read yields both the signature and the payload. (Sidecar files would
|
|
48
|
+
* require writeArtifact coordination that the envelope format does not
|
|
49
|
+
* currently support.)
|
|
50
|
+
*/
|
|
51
|
+
export const SNAPSHOT_SIG_PREFIX = "PI_CREW_SIG=";
|
|
52
|
+
|
|
53
|
+
export function getSnapshotHmacKey(env: NodeJS.ProcessEnv = process.env): Buffer | undefined {
|
|
54
|
+
const raw = env[SNAPSHOT_HMAC_KEY_ENV];
|
|
55
|
+
if (!raw) return undefined;
|
|
56
|
+
// Accept hex-encoded keys directly; otherwise encode the string as utf8.
|
|
57
|
+
// A key shorter than 32 bytes is rejected to prevent trivial brute-force.
|
|
58
|
+
const buf = /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0 ? Buffer.from(raw, "hex") : Buffer.from(raw, "utf8");
|
|
59
|
+
if (buf.length < 32) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`${SNAPSHOT_HMAC_KEY_ENV} must be at least 32 bytes (got ${buf.length}); use a longer key or generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return buf;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isSnapshotHmacStrict(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
68
|
+
return env[SNAPSHOT_HMAC_STRICT_ENV] === "1" || env[SNAPSHOT_HMAC_STRICT_ENV] === "true";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compute the HMAC-SHA256 signature of `content` under `key`. Returns a
|
|
73
|
+
* lowercase hex string.
|
|
74
|
+
*/
|
|
75
|
+
export function signSnapshot(content: Buffer | string, key: Buffer): string {
|
|
76
|
+
return createHmac("sha256", key).update(content).digest("hex");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Constant-time signature comparison. Both signatures must be lowercase
|
|
81
|
+
* hex of the same length; anything else returns false without throwing.
|
|
82
|
+
*/
|
|
83
|
+
export function snapshotSignatureMatches(content: Buffer | string, signature: string, key: Buffer): boolean {
|
|
84
|
+
const expected = signSnapshot(content, key);
|
|
85
|
+
const a = Buffer.from(expected);
|
|
86
|
+
const b = Buffer.from(signature);
|
|
87
|
+
if (a.length !== b.length) return false;
|
|
88
|
+
return timingSafeEqual(a, b);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Outcome of {@link verifySnapshotPayload}. Callers decide how to react
|
|
93
|
+
* based on the strict-mode flag.
|
|
94
|
+
*/
|
|
95
|
+
export type SnapshotVerifyOutcome =
|
|
96
|
+
| { kind: "unsigned"; strict: boolean }
|
|
97
|
+
| { kind: "verified" }
|
|
98
|
+
| { kind: "mismatch"; strict: boolean }
|
|
99
|
+
| { kind: "hmac-disabled" };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Verify a snapshot payload that may or may not carry an inline signature.
|
|
103
|
+
*
|
|
104
|
+
* @param payload The raw bytes read from disk (possibly including the
|
|
105
|
+
* `PI_CREW_SIG=<hex>\n` prefix).
|
|
106
|
+
* @param key The HMAC key, or `undefined` when HMAC is disabled.
|
|
107
|
+
* @param strict When true, unsigned/mismatched payloads are reported as
|
|
108
|
+
* rejectable. When false (the Phase 1 default), the caller is expected
|
|
109
|
+
* to accept the payload with a warning.
|
|
110
|
+
*/
|
|
111
|
+
export function verifySnapshotPayload(payload: Buffer, key: Buffer | undefined, strict: boolean): SnapshotVerifyOutcome {
|
|
112
|
+
if (!key) return { kind: "hmac-disabled" };
|
|
113
|
+
const prefixStr = SNAPSHOT_SIG_PREFIX;
|
|
114
|
+
if (payload.length < prefixStr.length + 1 || payload.subarray(0, prefixStr.length).toString("utf8") !== prefixStr) {
|
|
115
|
+
return { kind: "unsigned", strict };
|
|
116
|
+
}
|
|
117
|
+
const newlineIdx = payload.indexOf(0x0a, prefixStr.length);
|
|
118
|
+
if (newlineIdx < 0) return { kind: "unsigned", strict };
|
|
119
|
+
const sigHex = payload.subarray(prefixStr.length, newlineIdx).toString("utf8");
|
|
120
|
+
const body = payload.subarray(newlineIdx + 1);
|
|
121
|
+
return snapshotSignatureMatches(body, sigHex, key) ? { kind: "verified" } : { kind: "mismatch", strict };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Strip the inline signature prefix and return the bare payload. Returns
|
|
126
|
+
* the original buffer when no prefix is present. Used by read paths that
|
|
127
|
+
* have already called {@link verifySnapshotPayload} and decided to accept.
|
|
128
|
+
*/
|
|
129
|
+
export function stripSnapshotSignature(payload: Buffer): Buffer {
|
|
130
|
+
if (payload.length < SNAPSHOT_SIG_PREFIX.length) return payload;
|
|
131
|
+
if (payload.subarray(0, SNAPSHOT_SIG_PREFIX.length).toString("utf8") !== SNAPSHOT_SIG_PREFIX) return payload;
|
|
132
|
+
const newlineIdx = payload.indexOf(0x0a, SNAPSHOT_SIG_PREFIX.length);
|
|
133
|
+
if (newlineIdx < 0) return payload;
|
|
134
|
+
return payload.subarray(newlineIdx + 1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Attach an inline signature to a payload. Returns a new buffer shaped as
|
|
139
|
+
* `PI_CREW_SIG=<hex>\n<payload>`. Used by write paths that have HMAC
|
|
140
|
+
* enabled.
|
|
141
|
+
*/
|
|
142
|
+
export function attachSnapshotSignature(payload: Buffer, key: Buffer): Buffer {
|
|
143
|
+
const sig = signSnapshot(payload, key);
|
|
144
|
+
return Buffer.concat([Buffer.from(`${SNAPSHOT_SIG_PREFIX}${sig}\n`, "utf8"), payload]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Should the caller reject the snapshot based on the verify outcome?
|
|
149
|
+
* Centralises the strict-vs-migration decision so callers stay simple.
|
|
150
|
+
*/
|
|
151
|
+
export function shouldRejectSnapshot(outcome: SnapshotVerifyOutcome): boolean {
|
|
152
|
+
switch (outcome.kind) {
|
|
153
|
+
case "hmac-disabled":
|
|
154
|
+
return false;
|
|
155
|
+
case "verified":
|
|
156
|
+
return false;
|
|
157
|
+
case "unsigned":
|
|
158
|
+
case "mismatch":
|
|
159
|
+
return outcome.strict;
|
|
160
|
+
}
|
|
161
|
+
}
|