pi-crew 0.9.26 → 0.9.27
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 +87 -0
- package/NOTICE.md +14 -0
- package/assets/crew-vibes.ttf +0 -0
- package/assets/runner-spritesheet.png +0 -0
- package/dist/build-meta.json +255 -40
- package/dist/index.mjs +1564 -241
- package/dist/index.mjs.map +4 -4
- package/docs/perf/optimization-plan-2026-07-verified.md +396 -0
- package/package.json +9 -2
- package/scripts/bench-check.mjs +96 -0
- package/scripts/bench-cold-start.mjs +216 -0
- package/scripts/build-bundle.mjs +84 -0
- package/scripts/build-crew-vibes-font.py +328 -0
- package/scripts/check-all-skills.ts +294 -0
- package/scripts/check-bundle-staleness.mjs +97 -0
- package/scripts/check-conflict-markers.mjs +91 -0
- package/scripts/check-lazy-imports.mjs +28 -0
- package/scripts/install-crew-vibes-font.mjs +91 -0
- package/scripts/postinstall.mjs +47 -0
- package/scripts/profile-startup.mjs +125 -0
- package/scripts/release-smoke.mjs +74 -0
- package/scripts/run-bench.mjs +47 -0
- package/scripts/run-real-chain.ts +41 -0
- package/scripts/test-issue-29-crash.ts +111 -0
- package/scripts/test-issue-29-e2e.ts +183 -0
- package/scripts/test-issue-29-real-runtime.ts +330 -0
- package/scripts/test-issue-29-real-tasks.ts +387 -0
- package/scripts/test-issue-29-team-tool.ts +105 -0
- package/scripts/test-runner.mjs +74 -0
- package/scripts/verify-flicker-fix.ts +109 -0
- package/scripts/verify-skill.ts +550 -0
- package/scripts/watch-bundle.mjs +259 -0
- package/scripts/watchdog-harness.ts +119 -0
- package/src/agents/discover-agents.ts +6 -1
- package/src/config/defaults.ts +6 -0
- package/src/extension/crew-vibes/cat-frames.ts +18 -0
- package/src/extension/crew-vibes/config.ts +195 -0
- package/src/extension/crew-vibes/figures.ts +94 -0
- package/src/extension/crew-vibes/font-detect.ts +58 -0
- package/src/extension/crew-vibes/index.ts +396 -0
- package/src/extension/crew-vibes/provider-usage.ts +330 -0
- package/src/extension/crew-vibes/render.ts +206 -0
- package/src/extension/crew-vibes/speed.ts +286 -0
- package/src/extension/knowledge-injection.ts +12 -3
- package/src/extension/management.ts +23 -3
- package/src/extension/register.ts +7 -0
- package/src/runtime/child-pi.ts +117 -10
- package/src/runtime/crew-agent-records.ts +10 -3
- package/src/runtime/retry-executor.ts +4 -1
- package/src/runtime/task-runner/state-helpers.ts +9 -30
- package/src/runtime/team-runner.ts +5 -3
- package/src/state/atomic-write.ts +153 -49
- package/src/state/event-log.ts +16 -10
- package/src/state/locks.ts +7 -8
- package/src/state/mailbox.ts +15 -4
- package/src/state/state-store.ts +39 -10
- package/src/teams/discover-teams.ts +56 -1
- package/src/utils/safe-paths.ts +2 -1
- package/src/workflows/discover-workflows.ts +72 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { SpeedConfig } from "./config.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Token-speed engine ported from pi-speeed (MIT, attribution in NOTICE.md).
|
|
5
|
+
* Tracks output tok/s with a sliding window and suppresses unreliable
|
|
6
|
+
* burst-only readings. No stats/font handling — just the live + session avg.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type TokenEvent = { time: number; tokens: number };
|
|
10
|
+
|
|
11
|
+
const COMPACTION_THRESHOLD = 5000;
|
|
12
|
+
|
|
13
|
+
export type EngineConfig = Pick<SpeedConfig, "slidingWindowMs" | "minReliableDurationMs" | "maxDisplayTokS">;
|
|
14
|
+
|
|
15
|
+
export function estimateTokensFromDelta(text: string): number {
|
|
16
|
+
if (!text) return 0;
|
|
17
|
+
const matches = text.match(/\w+|[^\s\w]/g);
|
|
18
|
+
return matches ? matches.length : 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class TokenSpeedEngine {
|
|
22
|
+
private _isStreaming = false;
|
|
23
|
+
private _tokenCount = 0;
|
|
24
|
+
private _startTime = 0;
|
|
25
|
+
private _endTime = 0;
|
|
26
|
+
private _events: TokenEvent[] = [];
|
|
27
|
+
private _windowStartIndex = 0;
|
|
28
|
+
private _lastStableTokS = 0;
|
|
29
|
+
private _lastUsageOutput = 0;
|
|
30
|
+
private _config: EngineConfig;
|
|
31
|
+
|
|
32
|
+
constructor(config: EngineConfig) {
|
|
33
|
+
this._config = config;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
updateConfig(config: EngineConfig): void {
|
|
37
|
+
this._config = config;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get isStreaming(): boolean {
|
|
41
|
+
return this._isStreaming;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get tokenCount(): number {
|
|
45
|
+
return this._tokenCount;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get elapsedMs(): number {
|
|
49
|
+
if (this._startTime === 0) return 0;
|
|
50
|
+
return this._isStreaming ? Date.now() - this._startTime : this._endTime - this._startTime;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get avgTokS(): number {
|
|
54
|
+
const elapsedSec = this.elapsedMs / 1000;
|
|
55
|
+
if (elapsedSec <= 0) return 0;
|
|
56
|
+
return this._tokenCount / elapsedSec;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
sanitizeTokS(value: number | null, durationMs = this.elapsedMs): number | null {
|
|
60
|
+
if (value === null || !Number.isFinite(value) || value <= 0) return null;
|
|
61
|
+
if (durationMs < this._config.minReliableDurationMs) return null;
|
|
62
|
+
if (value > this._config.maxDisplayTokS) return null;
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get tokS(): number {
|
|
67
|
+
const candidate = this.rawTokS;
|
|
68
|
+
const stable = this.sanitizeTokS(candidate);
|
|
69
|
+
if (stable !== null) this._lastStableTokS = stable;
|
|
70
|
+
return this._lastStableTokS;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get rawTokS(): number {
|
|
74
|
+
if (this.elapsedMs < this._config.slidingWindowMs) return this.avgTokS;
|
|
75
|
+
if (!this._isStreaming) return this.avgTokS;
|
|
76
|
+
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const windowStart = now - this._config.slidingWindowMs;
|
|
79
|
+
|
|
80
|
+
while (this._windowStartIndex < this._events.length && this._events[this._windowStartIndex].time < windowStart) {
|
|
81
|
+
this._windowStartIndex++;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (this._windowStartIndex >= this._events.length) return this.avgTokS;
|
|
85
|
+
|
|
86
|
+
let windowTokenCount = 0;
|
|
87
|
+
for (let i = this._windowStartIndex; i < this._events.length; i++) {
|
|
88
|
+
windowTokenCount += this._events[i].tokens;
|
|
89
|
+
}
|
|
90
|
+
if (windowTokenCount === 0) return this.avgTokS;
|
|
91
|
+
|
|
92
|
+
const windowDuration = (now - this._events[this._windowStartIndex].time) / 1000;
|
|
93
|
+
if (windowDuration <= 0) return 0;
|
|
94
|
+
|
|
95
|
+
return windowTokenCount / windowDuration;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
start(): void {
|
|
99
|
+
this._tokenCount = 0;
|
|
100
|
+
this._isStreaming = true;
|
|
101
|
+
this._startTime = Date.now();
|
|
102
|
+
this._endTime = this._startTime;
|
|
103
|
+
this._events = [];
|
|
104
|
+
this._windowStartIndex = 0;
|
|
105
|
+
this._lastStableTokS = 0;
|
|
106
|
+
this._lastUsageOutput = 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
stop(): void {
|
|
110
|
+
this._isStreaming = false;
|
|
111
|
+
this._endTime = Date.now();
|
|
112
|
+
this._events = [];
|
|
113
|
+
this._windowStartIndex = 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
recordDelta(delta: string, usageOutput?: number): void {
|
|
117
|
+
if (!this._isStreaming) return;
|
|
118
|
+
if (usageOutput !== undefined && usageOutput > 0) {
|
|
119
|
+
// usageOutput may be cumulative across message_update events. Track the
|
|
120
|
+
// delta to avoid inflating _tokenCount by N× the true total.
|
|
121
|
+
const increment = usageOutput - this._lastUsageOutput;
|
|
122
|
+
this._lastUsageOutput = usageOutput;
|
|
123
|
+
this.recordTokens(Math.max(0, increment));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.recordTokens(estimateTokensFromDelta(delta));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
reconcileTotal(tokens: number): void {
|
|
130
|
+
if (tokens > 0) this._tokenCount = tokens;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
recordTokens(tokens: number): void {
|
|
134
|
+
if (!this._isStreaming || tokens <= 0) return;
|
|
135
|
+
this._tokenCount += tokens;
|
|
136
|
+
this._events.push({ time: Date.now(), tokens });
|
|
137
|
+
if (this._windowStartIndex >= COMPACTION_THRESHOLD) this.compact();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private compact(): void {
|
|
141
|
+
if (this._windowStartIndex === 0) return;
|
|
142
|
+
this._events = this._events.slice(this._windowStartIndex);
|
|
143
|
+
this._windowStartIndex = 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type CompletedMessageSpeed = {
|
|
148
|
+
outputTokens: number;
|
|
149
|
+
durationMs: number;
|
|
150
|
+
tokS: number | null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function isSuccessfulStop(stopReason: string | undefined): boolean {
|
|
154
|
+
return stopReason !== "error" && stopReason !== "aborted";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export class SpeedTracker {
|
|
158
|
+
private readonly engine: TokenSpeedEngine;
|
|
159
|
+
private lastStableTokS: number | null = null;
|
|
160
|
+
private sessionOutputTokens = 0;
|
|
161
|
+
private sessionDurationMs = 0;
|
|
162
|
+
|
|
163
|
+
constructor(config: SpeedConfig) {
|
|
164
|
+
this.engine = new TokenSpeedEngine(config);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
updateConfig(config: SpeedConfig): void {
|
|
168
|
+
this.engine.updateConfig(config);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get isStreaming(): boolean {
|
|
172
|
+
return this.engine.isStreaming;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get lastTokS(): number | null {
|
|
176
|
+
return this.lastStableTokS;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
resetSession(): void {
|
|
180
|
+
this.sessionOutputTokens = 0;
|
|
181
|
+
this.sessionDurationMs = 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
startMessage(): void {
|
|
185
|
+
this.engine.start();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
recordDelta(delta: string, usageOutput?: number): void {
|
|
189
|
+
this.engine.recordDelta(delta, usageOutput);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
stopMessage(): void {
|
|
193
|
+
if (this.engine.isStreaming) this.engine.stop();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
liveTokS(): number | null {
|
|
197
|
+
const speed = this.engine.tokS;
|
|
198
|
+
return speed > 0 ? speed : this.lastStableTokS;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
sessionAvgTokS(): number | null {
|
|
202
|
+
return this.sessionDurationMs > 0 ? this.sessionOutputTokens / (this.sessionDurationMs / 1000) : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
finishMessage(outputTokens: number, stopReason: string | undefined): CompletedMessageSpeed | null {
|
|
206
|
+
if (!this.engine.isStreaming) return null;
|
|
207
|
+
|
|
208
|
+
this.engine.reconcileTotal(outputTokens);
|
|
209
|
+
const durationMs = this.engine.elapsedMs;
|
|
210
|
+
const tokens = this.engine.tokenCount;
|
|
211
|
+
const rawAvgTokS = durationMs > 0 ? tokens / (durationMs / 1000) : null;
|
|
212
|
+
const tokS = this.engine.sanitizeTokS(rawAvgTokS, durationMs);
|
|
213
|
+
this.lastStableTokS = tokS;
|
|
214
|
+
this.engine.stop();
|
|
215
|
+
|
|
216
|
+
if (tokS !== null && isSuccessfulStop(stopReason)) {
|
|
217
|
+
this.sessionOutputTokens += tokens;
|
|
218
|
+
this.sessionDurationMs += durationMs;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { outputTokens: tokens, durationMs, tokS };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export class SpeedAnimator {
|
|
226
|
+
private from: number | null = null;
|
|
227
|
+
private target: number | null = null;
|
|
228
|
+
private startedAt = 0;
|
|
229
|
+
private durationMs: number;
|
|
230
|
+
|
|
231
|
+
constructor(durationMs: number) {
|
|
232
|
+
this.durationMs = durationMs;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
updateDuration(durationMs: number): void {
|
|
236
|
+
this.durationMs = durationMs;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
reset(value: number | null = null, now = Date.now()): void {
|
|
240
|
+
this.from = value;
|
|
241
|
+
this.target = value;
|
|
242
|
+
this.startedAt = now;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
setTarget(target: number | null, now = Date.now()): number | null {
|
|
246
|
+
if (target === null) {
|
|
247
|
+
this.reset(null, now);
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const current = this.value(now);
|
|
252
|
+
if (current === null) {
|
|
253
|
+
this.reset(target, now);
|
|
254
|
+
return target;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (this.target !== null && Math.abs(target - this.target) < 0.05) return current;
|
|
258
|
+
|
|
259
|
+
this.from = current;
|
|
260
|
+
this.target = target;
|
|
261
|
+
this.startedAt = now;
|
|
262
|
+
return current;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
value(now = Date.now()): number | null {
|
|
266
|
+
if (this.target === null) return null;
|
|
267
|
+
if (this.from === null || this.durationMs <= 0) return this.target;
|
|
268
|
+
|
|
269
|
+
const progress = Math.max(0, Math.min(1, (now - this.startedAt) / this.durationMs));
|
|
270
|
+
if (progress >= 1) {
|
|
271
|
+
this.from = this.target;
|
|
272
|
+
return this.target;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return this.from + (this.target - this.from) * progress;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
isAnimating(now = Date.now()): boolean {
|
|
279
|
+
return (
|
|
280
|
+
this.target !== null &&
|
|
281
|
+
this.from !== null &&
|
|
282
|
+
Math.abs(this.target - this.from) >= 0.05 &&
|
|
283
|
+
now - this.startedAt < this.durationMs
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import * as fs from "node:fs";
|
|
24
24
|
import * as path from "node:path";
|
|
25
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
25
26
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
26
27
|
import type { BeforeAgentStartEvent, ExtensionAPI } from "./pi-api.ts";
|
|
27
28
|
|
|
@@ -259,6 +260,13 @@ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
|
|
|
259
260
|
knowledgeCache.delete(p);
|
|
260
261
|
return "";
|
|
261
262
|
}
|
|
263
|
+
// Security: reject symlinks to prevent information disclosure.
|
|
264
|
+
// An attacker could replace knowledge.md with a symlink to /etc/shadow
|
|
265
|
+
// or other sensitive files.
|
|
266
|
+
if (stat.isSymbolicLink) {
|
|
267
|
+
logInternalError("knowledge-injection.symlink", new Error(`Refusing to read symlink: ${p}`));
|
|
268
|
+
return "";
|
|
269
|
+
}
|
|
262
270
|
// P5 (Round 15): mtime+size cache. readKnowledge fires on every agent
|
|
263
271
|
// start (main session + every worker), re-reading the file each time.
|
|
264
272
|
// For a run with N workers this is N redundant readFileSync of the same
|
|
@@ -322,10 +330,11 @@ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
|
|
|
322
330
|
}
|
|
323
331
|
|
|
324
332
|
/** Stat helper returning undefined on error (file missing, perms, etc.). */
|
|
325
|
-
function tryStat(p: string): { mtimeMs: number; size: number } | undefined {
|
|
333
|
+
function tryStat(p: string): { mtimeMs: number; size: number; isSymbolicLink: boolean } | undefined {
|
|
326
334
|
try {
|
|
327
|
-
|
|
328
|
-
|
|
335
|
+
// Use lstatSync to detect symlinks (statSync follows them).
|
|
336
|
+
const s = fs.lstatSync(p);
|
|
337
|
+
return { mtimeMs: s.mtimeMs, size: s.size, isSymbolicLink: s.isSymbolicLink() };
|
|
329
338
|
} catch {
|
|
330
339
|
return undefined;
|
|
331
340
|
}
|
|
@@ -3,21 +3,32 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { AgentConfig, ResourceSource, RoutingMetadata } from "../agents/agent-config.ts";
|
|
5
5
|
import { serializeAgent } from "../agents/agent-serializer.ts";
|
|
6
|
-
import { discoverAgents } from "../agents/discover-agents.ts";
|
|
6
|
+
import { discoverAgents, invalidateAgentDiscoveryCache } from "../agents/discover-agents.ts";
|
|
7
7
|
import type { PiTeamsConfig } from "../config/config.ts";
|
|
8
8
|
import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
|
|
9
|
-
import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
|
|
9
|
+
import { allTeams, discoverTeams, invalidateTeamDiscoveryCache } from "../teams/discover-teams.ts";
|
|
10
10
|
import type { TeamConfig, TeamRole } from "../teams/team-config.ts";
|
|
11
11
|
import { serializeTeam } from "../teams/team-serializer.ts";
|
|
12
12
|
import { hasOwn, parseConfigObject, requireString, sanitizeName } from "../utils/names.ts";
|
|
13
13
|
import { projectCrewRoot, userPiRoot } from "../utils/paths.ts";
|
|
14
|
-
import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
|
|
14
|
+
import { allWorkflows, discoverWorkflows, invalidateWorkflowDiscoveryCache } from "../workflows/discover-workflows.ts";
|
|
15
15
|
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
16
16
|
import { serializeWorkflow } from "../workflows/workflow-serializer.ts";
|
|
17
17
|
import { enforceDestructiveIntent } from "./team-tool/intent-policy.ts";
|
|
18
18
|
import type { TeamToolDetails } from "./team-tool-types.ts";
|
|
19
19
|
import { type PiTeamsToolResult, toolResult } from "./tool-result.ts";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Invalidate all discovery caches that may have read the on-disk resource
|
|
23
|
+
* file we are about to (or just did) write. Called once per create/update/delete
|
|
24
|
+
* to keep the 5 s TTL caches in sync without forcing a per-render full rescan.
|
|
25
|
+
*/
|
|
26
|
+
function invalidateResourceCaches(): void {
|
|
27
|
+
invalidateAgentDiscoveryCache();
|
|
28
|
+
invalidateTeamDiscoveryCache();
|
|
29
|
+
invalidateWorkflowDiscoveryCache();
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
interface ManagementContext {
|
|
22
33
|
cwd: string;
|
|
23
34
|
config?: PiTeamsConfig;
|
|
@@ -385,6 +396,9 @@ export function handleCreate(params: TeamToolParamsValue, ctx: ManagementContext
|
|
|
385
396
|
true,
|
|
386
397
|
);
|
|
387
398
|
}
|
|
399
|
+
// F17/F15: bust the discovery caches so future powerbar/scheduler reads see
|
|
400
|
+
// the new file immediately (instead of after the 5 s TTL expires).
|
|
401
|
+
invalidateResourceCaches();
|
|
388
402
|
return result(`Created ${params.resource} '${name}' at ${filePath}.`);
|
|
389
403
|
}
|
|
390
404
|
|
|
@@ -507,9 +521,13 @@ export function handleUpdate(params: TeamToolParamsValue, ctx: ManagementContext
|
|
|
507
521
|
true,
|
|
508
522
|
);
|
|
509
523
|
}
|
|
524
|
+
// F17/F15: bust the discovery caches so the renamed/updated resource is
|
|
525
|
+
// visible immediately. updateReferencesForRename may have rewritten peer
|
|
526
|
+
// files too, so we invalidate AFTER that pass completes.
|
|
510
527
|
const updatedRefs = params.updateReferences
|
|
511
528
|
? updateReferencesForRename(ctx, params.resource!, current.name, nextName, source, false)
|
|
512
529
|
: [];
|
|
530
|
+
invalidateResourceCaches();
|
|
513
531
|
return result(
|
|
514
532
|
[
|
|
515
533
|
`Updated ${params.resource} at ${nextPath}. Backup: ${backupPath}.`,
|
|
@@ -546,5 +564,7 @@ export function handleDelete(params: TeamToolParamsValue, ctx: ManagementContext
|
|
|
546
564
|
true,
|
|
547
565
|
);
|
|
548
566
|
}
|
|
567
|
+
// F17/F15: bust the discovery caches so the deletion is visible immediately.
|
|
568
|
+
invalidateResourceCaches();
|
|
549
569
|
return result(`Deleted ${params.resource} at ${resolved.resource!.filePath}. Backup: ${backupPath}.`);
|
|
550
570
|
}
|
|
@@ -64,6 +64,7 @@ import { registerCrewAutocomplete } from "./crew-autocomplete.ts";
|
|
|
64
64
|
import { registerCleanupHandler } from "./crew-cleanup.ts";
|
|
65
65
|
import { registerCrewInputRouter } from "./crew-input-router.ts";
|
|
66
66
|
import { registerCrewShortcuts } from "./crew-shortcuts.ts";
|
|
67
|
+
import { registerCrewVibes } from "./crew-vibes/index.ts";
|
|
67
68
|
import { type PiCrewRpcHandle, registerPiCrewRpc } from "./cross-extension-rpc.ts";
|
|
68
69
|
import { registerKnowledgeInjection } from "./knowledge-injection.ts";
|
|
69
70
|
import { registerCrewMessageRenderers } from "./message-renderers.ts";
|
|
@@ -1545,4 +1546,10 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1545
1546
|
registerContextStatusInjection(pi, {
|
|
1546
1547
|
enabled: loadConfig(process.cwd()).config.reliability?.ambientStatusInjection !== false,
|
|
1547
1548
|
});
|
|
1549
|
+
|
|
1550
|
+
try {
|
|
1551
|
+
registerCrewVibes(pi);
|
|
1552
|
+
} catch (err) {
|
|
1553
|
+
console.warn("[pi-crew] crew-vibes initialization failed:", err instanceof Error ? err.message : err);
|
|
1554
|
+
}
|
|
1548
1555
|
}
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -31,6 +31,18 @@ const MAX_COMPACT_CONTENT_CHARS = DEFAULT_CHILD_PI.maxCompactContentChars;
|
|
|
31
31
|
const activeChildProcesses = new Map<number, ChildProcess>();
|
|
32
32
|
const childHardKillTimers = new Map<number, NodeJS.Timeout>();
|
|
33
33
|
|
|
34
|
+
// Periodic cleanup of dead child process entries to prevent memory leaks.
|
|
35
|
+
// If a child process never emits exit/close (zombie), the entry would leak.
|
|
36
|
+
setInterval(() => {
|
|
37
|
+
for (const [pid, child] of activeChildProcesses) {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 0); // Throws ESRCH if dead
|
|
40
|
+
} catch {
|
|
41
|
+
activeChildProcesses.delete(pid);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}, 60_000).unref();
|
|
45
|
+
|
|
34
46
|
/**
|
|
35
47
|
* SEC-1: Extract a redacted stderr/stdout excerpt for embedding in lifecycle
|
|
36
48
|
* events and error messages. The in-memory stdout/stderr accumulators receive
|
|
@@ -176,6 +188,10 @@ export interface ChildPiLifecycleEvent {
|
|
|
176
188
|
stderrExcerpt?: string;
|
|
177
189
|
/** Timestamp (ISO). */
|
|
178
190
|
ts: string;
|
|
191
|
+
/** F12: optional cause for `final_drain` events. `"stdout-quiet"` indicates
|
|
192
|
+
* the drain was triggered by the quiet-window early-exit rather than the
|
|
193
|
+
* default 5 s ceiling. Other drain reasons (default) leave this undefined. */
|
|
194
|
+
reason?: "stdout-quiet";
|
|
179
195
|
/** Phase-0 diagnostic (HB-003a): the signal that killed the child (when
|
|
180
196
|
* available). Was previously discarded after building the error string. */
|
|
181
197
|
signal?: string;
|
|
@@ -205,6 +221,9 @@ export interface ChildPiRunInput {
|
|
|
205
221
|
onLifecycleEvent?: (event: ChildPiLifecycleEvent) => void;
|
|
206
222
|
maxDepth?: number;
|
|
207
223
|
finalDrainMs?: number;
|
|
224
|
+
/** F12: early-exit the drain when stdout has been silent for this many ms
|
|
225
|
+
* after the final assistant event. Set to ≥ finalDrainMs to disable. */
|
|
226
|
+
finalDrainQuietMs?: number;
|
|
208
227
|
hardKillMs?: number;
|
|
209
228
|
responseTimeoutMs?: number;
|
|
210
229
|
/** Soft limit on assistant turns — inject steer at this count. */
|
|
@@ -569,17 +588,22 @@ function compactChildPiLine(line: string): {
|
|
|
569
588
|
export class ChildPiLineObserver {
|
|
570
589
|
private buffer = "";
|
|
571
590
|
private readonly input: ChildPiRunInput;
|
|
572
|
-
/** RAW
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
591
|
+
/** F9: bounded ring buffer for RAW assistant-text fragments. Consumers
|
|
592
|
+
* (getRawFinalText) only read the last element, but the legacy implementation
|
|
593
|
+
* accumulated every fragment unconditionally, which let a verbose/long-running
|
|
594
|
+
* worker grow this array linearly with output. We retain the last 2 entries:
|
|
595
|
+
* the consumer needs the last; we keep the second-to-last only as a defensive
|
|
596
|
+
* fence against a race where a final event arrives just after the consumer
|
|
597
|
+
* read (the previous "last" is still the most-recent pre-final text in that
|
|
598
|
+
* window). 2 is well below any plausible consumer's "tail-only" need while
|
|
599
|
+
* bounding memory. */
|
|
600
|
+
private static readonly MAX_RAW_TEXT_EVENTS = 2;
|
|
577
601
|
private readonly rawTextEvents: string[] = [];
|
|
578
|
-
/**
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
|
|
602
|
+
/** F9: bounded ring buffer for intermediate findings. The downstream digest
|
|
603
|
+
* (getIntermediateFindings) slices the last 20, but the array previously grew
|
|
604
|
+
* to 1000s of entries. We keep MAX_INTERMEDIATE_DIGEST_LINES + headroom so
|
|
605
|
+
* the public API behaviour is preserved (still returns "last 20 lines"). */
|
|
606
|
+
private static readonly MAX_INTERMEDIATE_FINDINGS = 32;
|
|
583
607
|
private readonly intermediateFindings: string[] = [];
|
|
584
608
|
|
|
585
609
|
constructor(input: ChildPiRunInput) {
|
|
@@ -633,12 +657,19 @@ export class ChildPiLineObserver {
|
|
|
633
657
|
const rawParsed = JSON.parse(line);
|
|
634
658
|
const rawTexts = extractText(rawParsed);
|
|
635
659
|
if (rawTexts.length > 0) {
|
|
660
|
+
// F9: trim from the front if the push would exceed the cap. Slice's
|
|
661
|
+
// second arg excludes the index, so this drops the oldest entries
|
|
662
|
+
// while keeping the freshly pushed tail.
|
|
636
663
|
this.rawTextEvents.push(...rawTexts);
|
|
664
|
+
const rawOverflow = this.rawTextEvents.length - ChildPiLineObserver.MAX_RAW_TEXT_EVENTS;
|
|
665
|
+
if (rawOverflow > 0) this.rawTextEvents.splice(0, rawOverflow);
|
|
637
666
|
// Also capture raw assistant text as intermediate findings — the last raw
|
|
638
667
|
// text may be a partial answer before the worker ran out of budget.
|
|
639
668
|
const last = rawTexts[rawTexts.length - 1];
|
|
640
669
|
if (last.trim().length > 0) {
|
|
641
670
|
this.intermediateFindings.push(last.trim());
|
|
671
|
+
const findingsOverflow = this.intermediateFindings.length - ChildPiLineObserver.MAX_INTERMEDIATE_FINDINGS;
|
|
672
|
+
if (findingsOverflow > 0) this.intermediateFindings.splice(0, findingsOverflow);
|
|
642
673
|
}
|
|
643
674
|
}
|
|
644
675
|
} catch {
|
|
@@ -663,6 +694,8 @@ export class ChildPiLineObserver {
|
|
|
663
694
|
// findings. This ensures we capture tool output even when no assistant text
|
|
664
695
|
// is emitted (budget exhausted on tool calls).
|
|
665
696
|
this.intermediateFindings.push(compact.displayLine!.trim());
|
|
697
|
+
const findingsOverflow = this.intermediateFindings.length - ChildPiLineObserver.MAX_INTERMEDIATE_FINDINGS;
|
|
698
|
+
if (findingsOverflow > 0) this.intermediateFindings.splice(0, findingsOverflow);
|
|
666
699
|
}
|
|
667
700
|
}
|
|
668
701
|
}
|
|
@@ -921,6 +954,10 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
921
954
|
// handler know a drain timer existed even after clearFinalDrainTimers() ran;
|
|
922
955
|
// spawnMonotonicMs gives us relative timing to distinguish a race from a crash.
|
|
923
956
|
let finalDrainArmed = false;
|
|
957
|
+
// F12: monotonic timestamp of the last stdout JSON event (any event —
|
|
958
|
+
// we want to know when stdout *stopped*, not when the final assistant
|
|
959
|
+
// event arrived). Updated on every onJsonEvent dispatch.
|
|
960
|
+
let lastStdoutActivityMonotonicMs = performance.now();
|
|
924
961
|
let finalDrainFiredMonotonicMs: number | undefined;
|
|
925
962
|
const spawnMonotonicMs = performance.now();
|
|
926
963
|
let finalAssistantEventMonotonicMs: number | undefined;
|
|
@@ -1141,10 +1178,80 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1141
1178
|
completeOperation(eventOpId);
|
|
1142
1179
|
throw err;
|
|
1143
1180
|
}
|
|
1181
|
+
// F12: capture monotonic timestamp BEFORE dispatching — any stdout
|
|
1182
|
+
// JSON event counts as activity. This lets the quiet-window
|
|
1183
|
+
// detection measure "time since last byte of stdout" accurately
|
|
1184
|
+
// regardless of what onJsonEvent does.
|
|
1185
|
+
lastStdoutActivityMonotonicMs = performance.now();
|
|
1144
1186
|
input.onJsonEvent?.(event);
|
|
1145
1187
|
if (!isFinalAssistantEvent(event) || childExited || settled || finalDrainTimer) return;
|
|
1146
1188
|
finalAssistantEventMonotonicMs = performance.now();
|
|
1147
1189
|
finalDrainArmed = true; // Phase-0 diagnostic: track that a drain timer was created.
|
|
1190
|
+
// F12: alongside the 5 s ceiling timer, start a polling watcher
|
|
1191
|
+
// that fires the drain early if stdout goes quiet for `quietMs`
|
|
1192
|
+
// after the final assistant event. Heavy children that emit a
|
|
1193
|
+
// stopReason=stop message_end and then sit idle will exit in
|
|
1194
|
+
// ~quietMs (default 800 ms) instead of up to up to 5 s. unref() so
|
|
1195
|
+
// the poller never holds the event loop on shutdown.
|
|
1196
|
+
// NOTE: The polling watcher is NOT explicitly cleared on process exit.
|
|
1197
|
+
// This is safe because: (1) it's unref()'d, so it won't prevent exit;
|
|
1198
|
+
// (2) the `settled || childExited` guard at the top prevents firing
|
|
1199
|
+
// after the child has exited; (3) sending SIGTERM to an already-
|
|
1200
|
+
// exiting process is harmless. The `finalDrainQuietMs` config allows
|
|
1201
|
+
// disabling this behavior (set >= finalDrainMs, e.g., 10000).
|
|
1202
|
+
const quietMs = input.finalDrainQuietMs ?? DEFAULT_CHILD_PI.finalDrainQuietMs;
|
|
1203
|
+
if (quietMs < (input.finalDrainMs ?? DEFAULT_CHILD_PI.finalDrainMs)) {
|
|
1204
|
+
const pollHandle = setInterval(() => {
|
|
1205
|
+
if (settled || childExited) {
|
|
1206
|
+
clearInterval(pollHandle);
|
|
1207
|
+
pollHandle.unref();
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
const sinceLast = performance.now() - lastStdoutActivityMonotonicMs;
|
|
1211
|
+
if (sinceLast >= quietMs) {
|
|
1212
|
+
clearInterval(pollHandle);
|
|
1213
|
+
pollHandle.unref();
|
|
1214
|
+
// Trigger the same drain path as the 5 s timer:
|
|
1215
|
+
// mark forced, fire final_drain lifecycle, SIGTERM.
|
|
1216
|
+
forcedFinalDrain = true;
|
|
1217
|
+
finalDrainFiredMonotonicMs = performance.now();
|
|
1218
|
+
input.onLifecycleEvent?.({
|
|
1219
|
+
type: "final_drain",
|
|
1220
|
+
pid: child.pid,
|
|
1221
|
+
ts: new Date().toISOString(),
|
|
1222
|
+
reason: "stdout-quiet",
|
|
1223
|
+
});
|
|
1224
|
+
try {
|
|
1225
|
+
child.kill(process.platform === "win32" ? undefined : "SIGTERM");
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
logInternalError("child-pi.quiet-drain-term", error, `pid=${child.pid}`);
|
|
1228
|
+
}
|
|
1229
|
+
// Mark for hard kill fallback so the existing timer is
|
|
1230
|
+
// still reaped if it ever fires later.
|
|
1231
|
+
hardKillTimer = setTimeout(() => {
|
|
1232
|
+
if (settled || childExited) return;
|
|
1233
|
+
try {
|
|
1234
|
+
hardKilled = true;
|
|
1235
|
+
input.onLifecycleEvent?.({
|
|
1236
|
+
type: "hard_kill",
|
|
1237
|
+
pid: child.pid,
|
|
1238
|
+
ts: new Date().toISOString(),
|
|
1239
|
+
});
|
|
1240
|
+
child.kill(process.platform === "win32" ? undefined : "SIGKILL");
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
logInternalError("child-pi.quiet-drain-hard-kill", error, `pid=${child.pid}`);
|
|
1243
|
+
}
|
|
1244
|
+
}, hardKillMs);
|
|
1245
|
+
hardKillTimer.unref();
|
|
1246
|
+
// Cancel the 5 s ceiling so we don't double-fire.
|
|
1247
|
+
if (finalDrainTimer) {
|
|
1248
|
+
clearTimeout(finalDrainTimer);
|
|
1249
|
+
finalDrainTimer = undefined;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}, 200);
|
|
1253
|
+
pollHandle.unref();
|
|
1254
|
+
}
|
|
1148
1255
|
finalDrainTimer = setTimeout(() => {
|
|
1149
1256
|
if (settled || childExited) return;
|
|
1150
1257
|
forcedFinalDrain = true;
|
|
@@ -338,7 +338,9 @@ export function upsertCrewAgent(manifest: TeamRunManifest, record: CrewAgentReco
|
|
|
338
338
|
|
|
339
339
|
export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
340
340
|
ensureAgentStateDir(manifest, record.taskId);
|
|
341
|
-
|
|
341
|
+
// F4: terminal agent status (completed/failed/cancelled/blocked) — keep full
|
|
342
|
+
// durability so the notifier/dashboard health sees the final state immediately.
|
|
343
|
+
atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record), { durability: "full" });
|
|
342
344
|
}
|
|
343
345
|
|
|
344
346
|
// 2.5 — coalesced variants. Buffer per-agent record + aggregate writes for
|
|
@@ -350,14 +352,19 @@ const AGENT_COALESCE_MS = 250;
|
|
|
350
352
|
export function saveCrewAgentsCoalesced(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
|
|
351
353
|
const filePath = agentsPath(manifest);
|
|
352
354
|
fs.mkdirSync(manifest.stateRoot, { recursive: true });
|
|
353
|
-
|
|
355
|
+
// F4: progress write — best-effort is safe because the terminal
|
|
356
|
+
// writeCrewAgentStatus above remains durable and the notifier watches the
|
|
357
|
+
// events.jsonl independently of these JSON files.
|
|
358
|
+
atomicWriteJsonCoalesced(filePath, redactSecrets(records), AGENT_COALESCE_MS, { durability: "best-effort" });
|
|
354
359
|
asyncAgentReaderCache.delete(filePath);
|
|
355
360
|
for (const record of records) writeCrewAgentStatusCoalesced(manifest, record);
|
|
356
361
|
}
|
|
357
362
|
|
|
358
363
|
export function writeCrewAgentStatusCoalesced(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
359
364
|
ensureAgentStateDir(manifest, record.taskId);
|
|
360
|
-
atomicWriteJsonCoalesced(agentStatusPath(manifest, record.taskId), redactSecrets(record), AGENT_COALESCE_MS
|
|
365
|
+
atomicWriteJsonCoalesced(agentStatusPath(manifest, record.taskId), redactSecrets(record), AGENT_COALESCE_MS, {
|
|
366
|
+
durability: "best-effort",
|
|
367
|
+
});
|
|
361
368
|
}
|
|
362
369
|
|
|
363
370
|
/** @internal Flush all coalesced agent writes synchronously. Hook into cleanup paths. */
|
|
@@ -33,7 +33,10 @@ function asError(error: unknown): Error {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function globToRegex(pattern: string): RegExp {
|
|
36
|
-
const escaped = pattern
|
|
36
|
+
const escaped = pattern
|
|
37
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
38
|
+
.replace(/\*/g, ".*")
|
|
39
|
+
.replace(/\?/g, ".");
|
|
37
40
|
return new RegExp(`^${escaped}$`, "i");
|
|
38
41
|
}
|
|
39
42
|
|