pi-crew 0.5.2 → 0.5.5
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 +67 -0
- package/docs/bugs/cross-session-notification-leakage.md +82 -0
- package/docs/coding-agent-optimization.md +268 -0
- package/docs/deep-review-report.md +384 -0
- package/docs/distillation/cybersecurity-patterns.md +294 -0
- package/docs/migration-v0.4-v0.5.md +191 -0
- package/docs/optimization-plan.md +642 -0
- package/docs/pi-mono-opportunities.md +969 -0
- package/docs/pi-mono-review.md +291 -0
- package/docs/skills/REFERENCE.md +144 -0
- package/package.json +7 -6
- package/skills/artifact-analysis-loop/SKILL.md +302 -0
- package/skills/async-worker-recovery/SKILL.md +19 -1
- package/skills/child-pi-spawning/SKILL.md +19 -6
- package/skills/context-artifact-hygiene/SKILL.md +19 -2
- package/skills/delegation-patterns/SKILL.md +68 -3
- package/skills/detection-pipeline-design/SKILL.md +285 -0
- package/skills/event-log-tracing/SKILL.md +20 -6
- package/skills/git-master/SKILL.md +20 -6
- package/skills/hunting-investigation-loop/SKILL.md +401 -0
- package/skills/incident-playbook-construction/SKILL.md +383 -0
- package/skills/live-agent-lifecycle/SKILL.md +20 -6
- package/skills/mailbox-interactive/SKILL.md +19 -6
- package/skills/model-routing-context/SKILL.md +19 -1
- package/skills/multi-perspective-review/SKILL.md +19 -4
- package/skills/observability-reliability/SKILL.md +19 -2
- package/skills/orchestration/SKILL.md +20 -2
- package/skills/ownership-session-security/SKILL.md +20 -2
- package/skills/pi-extension-lifecycle/SKILL.md +20 -2
- package/skills/post-mortem/SKILL.md +7 -2
- package/skills/read-only-explorer/SKILL.md +20 -6
- package/skills/requirements-to-task-packet/SKILL.md +23 -3
- package/skills/resource-discovery-config/SKILL.md +20 -2
- package/skills/runtime-state-reader/SKILL.md +20 -2
- package/skills/safe-bash/SKILL.md +21 -6
- package/skills/scrutinize/SKILL.md +20 -2
- package/skills/secure-agent-orchestration-review/SKILL.md +29 -2
- package/skills/security-review/SKILL.md +560 -0
- package/skills/state-mutation-locking/SKILL.md +22 -2
- package/skills/systematic-debugging/SKILL.md +8 -6
- package/skills/threat-hypothesis-framework/SKILL.md +175 -0
- package/skills/ui-render-performance/SKILL.md +20 -2
- package/skills/verification-before-done/SKILL.md +17 -2
- package/skills/widget-rendering/SKILL.md +21 -6
- package/skills/workspace-isolation/SKILL.md +20 -6
- package/skills/worktree-isolation/SKILL.md +20 -6
- package/src/agents/agent-config.ts +40 -1
- package/src/config/config.ts +22 -5
- package/src/config/role-tools.ts +82 -0
- package/src/config/types.ts +4 -0
- package/src/extension/crew-cleanup.ts +114 -0
- package/src/extension/register.ts +15 -3
- package/src/extension/team-tool/run.ts +7 -7
- package/src/observability/event-bus.ts +60 -0
- package/src/runtime/background-runner.ts +8 -2
- package/src/runtime/child-pi.ts +122 -34
- package/src/runtime/crew-agent-runtime.ts +1 -0
- package/src/runtime/foreground-control.ts +87 -17
- package/src/runtime/pi-args.ts +11 -1
- package/src/runtime/pi-json-output.ts +31 -0
- package/src/runtime/progress-tracker.ts +124 -0
- package/src/runtime/skill-effectiveness.ts +473 -0
- package/src/runtime/skill-instructions.ts +37 -3
- package/src/runtime/task-runner.ts +91 -17
- package/src/runtime/team-runner.ts +11 -11
- package/src/runtime/tool-progress.ts +10 -3
- package/src/runtime/verification-gates.ts +367 -0
- package/src/schema/team-tool-schema.ts +7 -0
- package/src/state/decision-ledger.ts +92 -43
- package/src/state/event-log.ts +136 -10
- package/src/state/hook-instinct-bridge.ts +5 -5
- package/src/state/state-store.ts +3 -1
- package/src/state/types.ts +4 -0
- package/src/types/new-api-types.ts +34 -0
- package/src/ui/agent-management-overlay.ts +5 -1
- package/src/ui/crew-widget.ts +29 -15
- package/src/ui/powerbar-publisher.ts +100 -7
- package/src/ui/tool-render.ts +15 -15
- package/src/utils/session-utils.ts +52 -0
- package/src/worktree/worktree-manager.ts +32 -13
package/src/state/event-log.ts
CHANGED
|
@@ -63,12 +63,17 @@ let appendCounter = 0;
|
|
|
63
63
|
|
|
64
64
|
/** Simple cross-process lock for an eventsPath to prevent JSONL interleave on concurrent append.
|
|
65
65
|
* Detects stale locks by checking the owner PID written inside the lock directory.
|
|
66
|
+
*
|
|
67
|
+
* @deprecated Prefer `appendEventAsync()` for callers in async contexts. The sync lock
|
|
68
|
+
* uses `sleepSync` which blocks the event loop and prevents AbortSignal handlers from firing.
|
|
66
69
|
*/
|
|
67
70
|
export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
|
|
71
|
+
// Ensure parent directory exists before attempting lock
|
|
72
|
+
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
68
73
|
const lockDir = `${eventsPath}.lock`;
|
|
69
74
|
const pidFile = path.join(lockDir, "pid");
|
|
70
75
|
const start = Date.now();
|
|
71
|
-
const timeout =
|
|
76
|
+
const timeout = 120000; // 120s timeout for slow CI environments
|
|
72
77
|
const staleMs = 10000;
|
|
73
78
|
let acquired = false;
|
|
74
79
|
while (true) {
|
|
@@ -79,6 +84,8 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
|
|
|
79
84
|
break;
|
|
80
85
|
} catch {
|
|
81
86
|
if (Date.now() - start > timeout) {
|
|
87
|
+
// Log error and continue without lock — lock is held by live process.
|
|
88
|
+
// Stale detection will clean up dead locks on next attempt.
|
|
82
89
|
logInternalError("event-log.lock-timeout", new Error(`Event log lock timeout for ${eventsPath}`), `lockDir=${lockDir}`);
|
|
83
90
|
break;
|
|
84
91
|
}
|
|
@@ -112,9 +119,15 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
|
|
|
112
119
|
}
|
|
113
120
|
}
|
|
114
121
|
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
|
|
122
|
+
function evictOldestSequenceCacheEntries(): void {
|
|
123
|
+
// Batch evict oldest 50% of entries when cache is full
|
|
124
|
+
const toEvict = Math.ceil(MAX_SEQUENCE_CACHE_ENTRIES / 2);
|
|
125
|
+
let evicted = 0;
|
|
126
|
+
for (const key of sequenceCache.keys()) {
|
|
127
|
+
if (evicted >= toEvict) break;
|
|
128
|
+
sequenceCache.delete(key);
|
|
129
|
+
evicted++;
|
|
130
|
+
}
|
|
118
131
|
}
|
|
119
132
|
|
|
120
133
|
export function sequencePath(eventsPath: string): string {
|
|
@@ -174,10 +187,116 @@ export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId"
|
|
|
174
187
|
return createHash("sha256").update(JSON.stringify({ type: event.type, runId: event.runId, taskId: event.taskId, data: event.data ?? null })).digest("hex").slice(0, 16);
|
|
175
188
|
}
|
|
176
189
|
|
|
190
|
+
/**
|
|
191
|
+
* @deprecated Prefer `appendEventAsync()` in async contexts. The sync lock uses
|
|
192
|
+
* `sleepSync` which blocks the Node.js event loop, preventing AbortSignal handlers
|
|
193
|
+
* from firing and degrading live-agent responsiveness.
|
|
194
|
+
*/
|
|
177
195
|
export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEvent {
|
|
178
196
|
return withEventLogLockSync(eventsPath, () => appendEventInsideLock(eventsPath, event));
|
|
179
197
|
}
|
|
180
198
|
|
|
199
|
+
// --- Async write queue (non-blocking alternative to withEventLogLockSync) ---
|
|
200
|
+
const asyncQueues = new Map<string, Promise<unknown>>();
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Append an event to the event log using non-blocking async I/O.
|
|
204
|
+
*
|
|
205
|
+
* Uses a per-eventsPath promise-chain queue to ensure sequential writes without
|
|
206
|
+
* blocking the Node.js event loop. This allows AbortSignal handlers and other
|
|
207
|
+
* async operations to proceed while events are being persisted.
|
|
208
|
+
*
|
|
209
|
+
* For callers that are already in an async context (team-runner, task-runner,
|
|
210
|
+
* foreground-control, etc.), prefer this over the sync `appendEvent()`.
|
|
211
|
+
*/
|
|
212
|
+
export async function appendEventAsync(eventsPath: string, event: AppendTeamEvent): Promise<TeamEvent> {
|
|
213
|
+
const queueKey = eventsPath;
|
|
214
|
+
const prev = asyncQueues.get(queueKey) ?? Promise.resolve();
|
|
215
|
+
const next = prev.then(async (): Promise<TeamEvent> => {
|
|
216
|
+
// Ensure directory exists
|
|
217
|
+
await fs.promises.mkdir(path.dirname(eventsPath), { recursive: true });
|
|
218
|
+
|
|
219
|
+
// Build metadata (same logic as appendEventInsideLock)
|
|
220
|
+
const baseMetadata = event.metadata;
|
|
221
|
+
let metadata: TeamEventMetadata = {
|
|
222
|
+
seq: baseMetadata?.seq ?? nextSequence(eventsPath),
|
|
223
|
+
provenance: baseMetadata?.provenance ?? "team_runner",
|
|
224
|
+
...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
|
|
225
|
+
...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
|
|
226
|
+
...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
|
|
227
|
+
...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
|
|
228
|
+
...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
|
|
229
|
+
...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
|
|
230
|
+
...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
|
|
231
|
+
...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
|
|
232
|
+
...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
|
|
233
|
+
};
|
|
234
|
+
const fullEvent: TeamEvent = {
|
|
235
|
+
time: new Date().toISOString(),
|
|
236
|
+
...event,
|
|
237
|
+
metadata,
|
|
238
|
+
};
|
|
239
|
+
if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
|
|
240
|
+
metadata = { ...metadata, fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent) };
|
|
241
|
+
fullEvent.metadata = metadata;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Overflow handling: same logic as sync path
|
|
245
|
+
const isTerminal = TERMINAL_EVENT_TYPES.has(fullEvent.type);
|
|
246
|
+
let skippedDueToSize = false;
|
|
247
|
+
if (!isTerminal && fs.existsSync(eventsPath)) {
|
|
248
|
+
const stat = fs.statSync(eventsPath);
|
|
249
|
+
if (stat.size > MAX_EVENTS_BYTES) {
|
|
250
|
+
try {
|
|
251
|
+
compactEventLog(eventsPath);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
|
|
254
|
+
}
|
|
255
|
+
if (fs.existsSync(eventsPath)) {
|
|
256
|
+
const afterCompact = fs.statSync(eventsPath);
|
|
257
|
+
if (afterCompact.size > MAX_EVENTS_BYTES) {
|
|
258
|
+
rotateEventLog(eventsPath);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
|
|
265
|
+
logInternalError("event-log.size-limit", new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`), `eventsPath=${eventsPath}`);
|
|
266
|
+
skippedDueToSize = true;
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!skippedDueToSize) {
|
|
273
|
+
const line = JSON.stringify(redactSecrets(fullEvent)) + "\n";
|
|
274
|
+
await fs.promises.appendFile(eventsPath, line, { encoding: "utf-8" });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
appendCounter++;
|
|
278
|
+
if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
|
|
279
|
+
try { compactEventLog(eventsPath); } catch (error) { logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`); }
|
|
280
|
+
}
|
|
281
|
+
try { emitFromTeamEvent(fullEvent); } catch (error) { logInternalError("event-log.emit", error); }
|
|
282
|
+
|
|
283
|
+
const seq = fullEvent.metadata?.seq ?? 0;
|
|
284
|
+
try {
|
|
285
|
+
const stat = fs.statSync(eventsPath);
|
|
286
|
+
if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
|
|
287
|
+
evictOldestSequenceCacheEntries();
|
|
288
|
+
}
|
|
289
|
+
sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
|
|
290
|
+
persistSequence(eventsPath, seq);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
|
|
293
|
+
}
|
|
294
|
+
return fullEvent;
|
|
295
|
+
});
|
|
296
|
+
asyncQueues.set(queueKey, next.catch(() => {}));
|
|
297
|
+
return next;
|
|
298
|
+
}
|
|
299
|
+
|
|
181
300
|
/**
|
|
182
301
|
* Body of `appendEvent` assuming the caller already holds
|
|
183
302
|
* `withEventLogLockSync` for `eventsPath`. Used by `appendEventBuffered` to
|
|
@@ -254,7 +373,7 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
|
|
|
254
373
|
try {
|
|
255
374
|
const stat = fs.statSync(eventsPath);
|
|
256
375
|
if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
|
|
257
|
-
|
|
376
|
+
evictOldestSequenceCacheEntries();
|
|
258
377
|
}
|
|
259
378
|
sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
|
|
260
379
|
persistSequence(eventsPath, seq);
|
|
@@ -283,6 +402,12 @@ const bufferedTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
|
283
402
|
const DEFAULT_BUFFER_MS = 20;
|
|
284
403
|
|
|
285
404
|
export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent, bufferMs = DEFAULT_BUFFER_MS): Promise<TeamEvent> {
|
|
405
|
+
// FIX: Terminal events must bypass buffer to ensure they're written immediately.
|
|
406
|
+
// Previously, terminal events like task.failed could be lost on process crash.
|
|
407
|
+
if (TERMINAL_EVENT_TYPES.has(event.type)) {
|
|
408
|
+
// For terminal events, write synchronously to ensure durability
|
|
409
|
+
return Promise.resolve(appendEvent(eventsPath, event));
|
|
410
|
+
}
|
|
286
411
|
return new Promise<TeamEvent>((resolve, reject) => {
|
|
287
412
|
const queue = bufferedQueues.get(eventsPath) ?? [];
|
|
288
413
|
queue.push({ event, resolve, reject });
|
|
@@ -325,12 +450,13 @@ export function flushEventLogBuffer(): void {
|
|
|
325
450
|
}
|
|
326
451
|
|
|
327
452
|
/**
|
|
328
|
-
*
|
|
329
|
-
* the
|
|
330
|
-
* (high-frequency `task.progress`).
|
|
453
|
+
* Schedule an async event append without waiting for the result.
|
|
454
|
+
* Uses the non-blocking async queue to avoid blocking the event loop.
|
|
455
|
+
* Use only for events whose return value is ignored (high-frequency `task.progress`).
|
|
456
|
+
* Errors are logged via logInternalError.
|
|
331
457
|
*/
|
|
332
|
-
export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEvent,
|
|
333
|
-
|
|
458
|
+
export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEvent, _bufferMs = DEFAULT_BUFFER_MS): void {
|
|
459
|
+
appendEventAsync(eventsPath, event).catch((error) => logInternalError("event-log.fire-and-forget", error, eventsPath));
|
|
334
460
|
}
|
|
335
461
|
|
|
336
462
|
// Auto-flush on process exit so buffered events do not silently leak.
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
import { crewHooks } from "../runtime/crew-hooks.ts";
|
|
7
7
|
|
|
8
8
|
// Lazy-initialized store and paths
|
|
9
|
-
let storeInstance: import("./instinct-store").InstinctStore | null = null;
|
|
10
|
-
let pathsInstance: typeof import("../utils/paths") | null = null;
|
|
9
|
+
let storeInstance: import("./instinct-store.js").InstinctStore | null = null;
|
|
10
|
+
let pathsInstance: typeof import("../utils/paths.js") | null = null;
|
|
11
11
|
|
|
12
12
|
async function getStore() {
|
|
13
13
|
if (!storeInstance) {
|
|
14
|
-
const { InstinctStore } = await import("./instinct-store");
|
|
15
|
-
const paths = await import("../utils/paths");
|
|
14
|
+
const { InstinctStore } = await import("./instinct-store.js");
|
|
15
|
+
const paths = await import("../utils/paths.js");
|
|
16
16
|
storeInstance = new InstinctStore(paths.projectCrewRoot(process.cwd()));
|
|
17
17
|
}
|
|
18
18
|
return storeInstance;
|
|
@@ -20,7 +20,7 @@ async function getStore() {
|
|
|
20
20
|
|
|
21
21
|
async function getPaths() {
|
|
22
22
|
if (!pathsInstance) {
|
|
23
|
-
pathsInstance = await import("../utils/paths");
|
|
23
|
+
pathsInstance = await import("../utils/paths.js");
|
|
24
24
|
}
|
|
25
25
|
return pathsInstance;
|
|
26
26
|
}
|
package/src/state/state-store.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { assertSafePathId, resolveContainedRelativePath, resolveRealContainedPat
|
|
|
12
12
|
import { withRunLock } from "./locks.ts";
|
|
13
13
|
import type { TeamConfig } from "../teams/team-config.ts";
|
|
14
14
|
import type { WorkflowConfig } from "../workflows/workflow-config.ts";
|
|
15
|
+
import { toPiSessionId } from "../utils/session-utils.ts";
|
|
15
16
|
|
|
16
17
|
export interface RunPaths {
|
|
17
18
|
runId: string;
|
|
@@ -32,7 +33,7 @@ interface ManifestCacheEntry {
|
|
|
32
33
|
cachedAt?: number;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
const MANIFEST_CACHE_TTL_MS =
|
|
36
|
+
const MANIFEST_CACHE_TTL_MS = 30 * 1000; // 30 seconds (FIX: reduced from 5 minutes for faster state updates)
|
|
36
37
|
const manifestCache = new Map<string, ManifestCacheEntry>();
|
|
37
38
|
|
|
38
39
|
function setManifestCache(stateRoot: string, entry: ManifestCacheEntry): void {
|
|
@@ -148,6 +149,7 @@ export function createRunManifest(params: {
|
|
|
148
149
|
const manifest: TeamRunManifest = {
|
|
149
150
|
schemaVersion: 1,
|
|
150
151
|
runId: paths.runId,
|
|
152
|
+
sessionId: toPiSessionId(paths.runId),
|
|
151
153
|
team: params.team.name,
|
|
152
154
|
workflow: params.workflow?.name,
|
|
153
155
|
goal: params.goal,
|
package/src/state/types.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { WorkerHeartbeatState } from "../runtime/worker-heartbeat.ts";
|
|
|
4
4
|
import type { CrewAgentProgress } from "../runtime/crew-agent-runtime.ts";
|
|
5
5
|
import type { RolloutEntry, CoherenceMark } from "./decision-ledger.ts";
|
|
6
6
|
export type { RolloutEntry, CoherenceMark };
|
|
7
|
+
export type { CrewAgentProgress };
|
|
7
8
|
|
|
8
9
|
export type { TeamRunStatus, TeamTaskStatus } from "./contracts.ts";
|
|
9
10
|
|
|
@@ -25,6 +26,7 @@ export interface VerificationCommandResult {
|
|
|
25
26
|
cmd: string;
|
|
26
27
|
status: "passed" | "failed" | "not_run";
|
|
27
28
|
exitCode?: number | null;
|
|
29
|
+
durationMs?: number;
|
|
28
30
|
outputArtifact?: ArtifactDescriptor;
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -156,6 +158,8 @@ export interface CrewAttentionEventData {
|
|
|
156
158
|
export interface TeamRunManifest {
|
|
157
159
|
schemaVersion: 1;
|
|
158
160
|
runId: string;
|
|
161
|
+
/** pi session ID aligned with run ID for cross-referencing (e.g., "crew-team20260528") */
|
|
162
|
+
sessionId?: string;
|
|
159
163
|
team: string;
|
|
160
164
|
workflow?: string;
|
|
161
165
|
goal: string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type imports from pi v0.77.0
|
|
3
|
+
*/
|
|
4
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
AgentSessionEvent,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
// Note: AgentEvent is not exported by pi-coding-agent v0.77.0
|
|
11
|
+
// Using AgentEndEvent and AgentStartEvent instead
|
|
12
|
+
|
|
13
|
+
// Type guards for pi-crew usage
|
|
14
|
+
export function isToolEvent(event: AgentSessionEvent): boolean {
|
|
15
|
+
return event.type === "tool_execution_start" ||
|
|
16
|
+
event.type === "tool_execution_update" ||
|
|
17
|
+
event.type === "tool_execution_end";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
|
|
21
|
+
return event.type === "agent_start" || event.type === "agent_end";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isCompactionEvent(event: AgentSessionEvent): boolean {
|
|
25
|
+
return event.type === "compaction_start" || event.type === "compaction_end";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isRetryEvent(event: AgentSessionEvent): boolean {
|
|
29
|
+
return event.type === "auto_retry_start" || event.type === "auto_retry_end";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isQueueEvent(event: AgentSessionEvent): boolean {
|
|
33
|
+
return event.type === "queue_update";
|
|
34
|
+
}
|
|
@@ -38,6 +38,8 @@ function sourceIcon(source: ResourceSource): string {
|
|
|
38
38
|
case "user": return "👤";
|
|
39
39
|
case "project": return "📂";
|
|
40
40
|
case "git": return "🔗";
|
|
41
|
+
case "dynamic": return "⚡";
|
|
42
|
+
default: return "❓";
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -47,6 +49,8 @@ function sourceLabel(source: ResourceSource): string {
|
|
|
47
49
|
case "user": return "user";
|
|
48
50
|
case "project": return "project";
|
|
49
51
|
case "git": return "git";
|
|
52
|
+
case "dynamic": return "dynamic";
|
|
53
|
+
default: return "unknown";
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
|
|
@@ -61,7 +65,7 @@ export interface AgentOverlayState {
|
|
|
61
65
|
export function createAgentOverlayState(entries: AgentEntry[], maxVisible = 20): AgentOverlayState {
|
|
62
66
|
return {
|
|
63
67
|
entries: entries.sort((a, b) => {
|
|
64
|
-
const order: Record<ResourceSource, number> = { project: 0, user: 1, git: 2, builtin: 3 };
|
|
68
|
+
const order: Record<ResourceSource, number> = { project: 0, user: 1, git: 2, builtin: 3, dynamic: 4 };
|
|
65
69
|
const diff = (order[a.source] ?? 4) - (order[b.source] ?? 4);
|
|
66
70
|
return diff !== 0 ? diff : a.name.localeCompare(b.name);
|
|
67
71
|
}),
|
package/src/ui/crew-widget.ts
CHANGED
|
@@ -23,14 +23,25 @@ import { SUBAGENT_SPINNER_FRAMES, spinnerBucket, spinnerFrame } from "./spinner.
|
|
|
23
23
|
|
|
24
24
|
const SPINNER = SUBAGENT_SPINNER_FRAMES;
|
|
25
25
|
const TOOL_LABELS: Record<string, string> = {
|
|
26
|
-
|
|
26
|
+
head: "reading",
|
|
27
27
|
bash: "running command",
|
|
28
28
|
edit: "editing",
|
|
29
29
|
write: "writing",
|
|
30
|
-
|
|
30
|
+
grep: "searching",
|
|
31
31
|
find: "finding files",
|
|
32
32
|
ls: "listing",
|
|
33
33
|
};
|
|
34
|
+
|
|
35
|
+
const TOOL_ICONS: Record<string, string> = {
|
|
36
|
+
read: "📖",
|
|
37
|
+
bash: ">",
|
|
38
|
+
edit: "✏",
|
|
39
|
+
write: "📝",
|
|
40
|
+
grep: "🔍",
|
|
41
|
+
find: "📁",
|
|
42
|
+
ls: "📋",
|
|
43
|
+
agent: "🤖",
|
|
44
|
+
};
|
|
34
45
|
const LEGACY_WIDGET_KEY = "pi-crew";
|
|
35
46
|
const WIDGET_KEY = "pi-crew-active";
|
|
36
47
|
const STATUS_KEY = "pi-crew";
|
|
@@ -90,16 +101,16 @@ function describeLiveActivity(handle: LiveAgentHandle): string {
|
|
|
90
101
|
if (act.activeTools.size > 0) {
|
|
91
102
|
const groups = new Map<string, number>();
|
|
92
103
|
for (const toolName of act.activeTools.values()) {
|
|
93
|
-
|
|
94
|
-
groups.set(label, (groups.get(label) ?? 0) + 1);
|
|
104
|
+
groups.set(toolName, (groups.get(toolName) ?? 0) + 1);
|
|
95
105
|
}
|
|
96
106
|
const parts: string[] = [];
|
|
97
|
-
for (const [
|
|
107
|
+
for (const [toolName, count] of groups) {
|
|
108
|
+
const icon = TOOL_ICONS[toolName] ?? "?";
|
|
109
|
+
const label = TOOL_LABELS[toolName] ?? toolName;
|
|
98
110
|
if (count > 1) {
|
|
99
|
-
|
|
100
|
-
parts.push(`${label} ${count} ${noun}`);
|
|
111
|
+
parts.push(`${icon}${count} ${label}s`);
|
|
101
112
|
} else {
|
|
102
|
-
parts.push(label);
|
|
113
|
+
parts.push(`${icon} ${label}`);
|
|
103
114
|
}
|
|
104
115
|
}
|
|
105
116
|
return parts.join(", ") + "…";
|
|
@@ -241,14 +252,17 @@ export function activeWidgetRuns(cwd: string, manifestCache?: ManifestCache, sna
|
|
|
241
252
|
function statusSummary(runs: WidgetRun[]): string {
|
|
242
253
|
const agents = runs.flatMap((item) => item.agents);
|
|
243
254
|
const runningAgents = agents.filter((agent) => agent.status === "running").length;
|
|
244
|
-
const queuedAgents = agents.filter((agent) => agent.status === "queued").length;
|
|
245
|
-
const waitingAgents = agents.filter((agent) => agent.status === "waiting").length;
|
|
255
|
+
const queuedAgents = agents.filter((agent) => agent.status === "queued" || agent.status === "waiting").length;
|
|
246
256
|
const completedAgents = agents.filter((agent) => agent.status === "completed").length;
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
257
|
+
const totalAgents = agents.length;
|
|
258
|
+
const totalRuns = runs.length;
|
|
259
|
+
const model = agents.find((a) => a.model)?.model?.split("/").at(-1);
|
|
260
|
+
const parts = [`⚙ ${runningAgents}r`];
|
|
261
|
+
if (queuedAgents > 0) parts.push(`${queuedAgents}q`);
|
|
262
|
+
if (completedAgents > 0) parts.push(`${completedAgents}/${totalAgents}done`);
|
|
263
|
+
if (totalRuns > 1) parts.push(`${totalRuns}runs`);
|
|
264
|
+
if (model) parts.push(model);
|
|
265
|
+
return parts.join(" · ");
|
|
252
266
|
}
|
|
253
267
|
|
|
254
268
|
export function notificationBadge(count: number | undefined, env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -12,6 +12,8 @@ import type { ManifestCache } from "../runtime/manifest-cache.ts";
|
|
|
12
12
|
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
|
13
13
|
import { notificationBadge } from "./crew-widget.ts";
|
|
14
14
|
import { RenderCoalescer } from "./render-coalescer.ts";
|
|
15
|
+
import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
|
|
16
|
+
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
15
17
|
|
|
16
18
|
type EventBus = { emit?: (event: string, data: unknown) => void; listenerCount?: (event: string) => number } | undefined;
|
|
17
19
|
type StatusContext = { hasUI?: boolean; ui?: { setStatus?: (key: string, text: string | undefined) => void } } | undefined;
|
|
@@ -63,6 +65,7 @@ export function registerPiCrewPowerbarSegments(events: EventBus, config?: CrewUi
|
|
|
63
65
|
if (config?.powerbar === false) return;
|
|
64
66
|
safeEmit(events, "powerbar:register-segment", { id: "pi-crew-active", label: "pi-crew active agents" });
|
|
65
67
|
safeEmit(events, "powerbar:register-segment", { id: "pi-crew-progress", label: "pi-crew run progress" });
|
|
68
|
+
safeEmit(events, "powerbar:register-segment", { id: "pi-crew-steps", label: "pi-crew workflow steps" });
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: CrewUiConfig, manifestCache?: ManifestCache, snapshotCache?: RunSnapshotCache, ctx?: StatusContext, notificationCount = 0, preloadedManifests?: TeamRunManifest[]): void {
|
|
@@ -90,9 +93,10 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
|
|
|
90
93
|
if (!active.length) {
|
|
91
94
|
lastActiveKey = undefined;
|
|
92
95
|
lastProgressKey = undefined;
|
|
96
|
+
lastStepsKey = undefined;
|
|
93
97
|
safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
|
|
94
98
|
safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
|
|
95
|
-
|
|
99
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-steps" });
|
|
96
100
|
return;
|
|
97
101
|
}
|
|
98
102
|
const agents = active.flatMap((item) => item.agents);
|
|
@@ -108,13 +112,33 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
|
|
|
108
112
|
const model = config?.showModel === false ? undefined : agents.find((agent) => agent.model)?.model?.split("/").at(-1);
|
|
109
113
|
const tokenText = config?.showTokens === false || !tokenTotal ? undefined : compactTokens(tokenTotal);
|
|
110
114
|
const liveRunning = listLiveAgents().filter((a) => a.status === "running").length;
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
// Always show consistent status: running count + queued count from live tasks only
|
|
116
|
+
// Avoid snapshot cache for counts to prevent UI jumping
|
|
117
|
+
const runningCount = agents.filter((a) => a.status === "running").length;
|
|
118
|
+
// Count queued/waiting tasks directly from tasks array (not snapshot) for consistency
|
|
119
|
+
const queuedCount = active.reduce((sum, item) => sum + item.tasks.reduce((s, t) => s + (t.status === "queued" || t.status === "waiting" ? 1 : 0), 0), 0);
|
|
120
|
+
// Format: "1 running", "2 running · 1 queued", "3 queued", "idle"
|
|
121
|
+
const runningLabel = runningCount === 1 ? "1 running" : `${runningCount} running`;
|
|
122
|
+
const queuedLabel = queuedCount === 1 ? "1 queued" : `${queuedCount} queued`;
|
|
123
|
+
const crewStatus = runningCount > 0 && queuedCount > 0 ? `${runningLabel} · ${queuedLabel}` : runningCount > 0 ? runningLabel : queuedCount > 0 ? queuedLabel : "idle";
|
|
124
|
+
const liveSuffix = liveRunning > 0 ? ` (${liveRunning} live)` : "";
|
|
125
|
+
const notificationText = notificationBadge(notificationCount);
|
|
126
|
+
// Always show model + tokens as suffix when available (for activePayload consistency)
|
|
127
|
+
const suffixParts = [model, tokenText].filter(Boolean);
|
|
128
|
+
const activeSuffix = suffixParts.length > 0 ? suffixParts.join(" · ") : undefined;
|
|
129
|
+
// Progress always includes token count for consistency
|
|
113
130
|
const progressSuffix = `${completed}/${total}${tokenText ? ` · ${tokenText}` : ""}`;
|
|
131
|
+
// Build complete, always-consistent fallback text AND event payload to prevent UI flickering
|
|
132
|
+
// Both fallback and events must use the SAME format - no conditional display
|
|
133
|
+
// Format: "⚙ 1 running · 1 queued · model · 30k · 0/1" (never changes based on availability)
|
|
134
|
+
const progressPart = `${completed}/${total}`;
|
|
135
|
+
const allParts = [`⚙ ${crewStatus}`, model ?? "", tokenText ?? "", progressPart].filter(Boolean);
|
|
136
|
+
const unifiedText = allParts.join(" · ");
|
|
137
|
+
// activePayload.text includes notification badge for event payload
|
|
114
138
|
const activePayload = {
|
|
115
139
|
id: "pi-crew-active",
|
|
116
140
|
icon: "⚙",
|
|
117
|
-
text:
|
|
141
|
+
text: `⚙ ${crewStatus}${liveSuffix}${notificationText}${activeSuffix ? ` · ${activeSuffix}` : ""}`,
|
|
118
142
|
suffix: activeSuffix,
|
|
119
143
|
color: running ? "accent" : "warning",
|
|
120
144
|
} as const;
|
|
@@ -126,12 +150,15 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
|
|
|
126
150
|
color: completed === total ? "success" : "accent",
|
|
127
151
|
barSegments: 8,
|
|
128
152
|
} as const;
|
|
153
|
+
// Build step progress: "explorer > planner > executor > verifier" with current step highlighted
|
|
154
|
+
const stepsPayload = buildStepsPayload(active, tasks);
|
|
129
155
|
// 1.8: dedup per segment using a key over every visible field. Previously
|
|
130
156
|
// the dedup string only carried text/suffix/running, so changes to `bar`
|
|
131
157
|
// (progress %) or `color` could be swallowed and stale UI emitted again
|
|
132
158
|
// later as a single noisy burst.
|
|
133
159
|
const activeKey = powerbarKey(activePayload);
|
|
134
160
|
const progressKey = powerbarKey(progressPayload);
|
|
161
|
+
const stepsKey = powerbarKey(stepsPayload);
|
|
135
162
|
if (activeKey !== lastActiveKey) {
|
|
136
163
|
lastActiveKey = activeKey;
|
|
137
164
|
safeEmit(events, "powerbar:update", activePayload);
|
|
@@ -140,14 +167,21 @@ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: Cre
|
|
|
140
167
|
lastProgressKey = progressKey;
|
|
141
168
|
safeEmit(events, "powerbar:update", progressPayload);
|
|
142
169
|
}
|
|
143
|
-
if (
|
|
170
|
+
if (stepsKey !== lastStepsKey) {
|
|
171
|
+
lastStepsKey = stepsKey;
|
|
172
|
+
safeEmit(events, "powerbar:update", stepsPayload);
|
|
173
|
+
}
|
|
174
|
+
// Never call setStatusFallback - crew-widget manages "pi-crew" status with its own widget format
|
|
175
|
+
// Powerbar only emits events; it does not set status directly
|
|
144
176
|
}
|
|
145
177
|
|
|
146
178
|
// --- Dedup state: skip emit if segment data unchanged ---
|
|
147
179
|
let lastActiveKey: string | undefined;
|
|
148
180
|
let lastProgressKey: string | undefined;
|
|
181
|
+
let lastStepsKey: string | undefined;
|
|
149
182
|
|
|
150
183
|
interface PowerbarPayloadShape {
|
|
184
|
+
id?: string;
|
|
151
185
|
text?: string;
|
|
152
186
|
suffix?: string;
|
|
153
187
|
bar?: number;
|
|
@@ -160,6 +194,63 @@ function powerbarKey(payload: PowerbarPayloadShape): string {
|
|
|
160
194
|
return `${payload.text ?? ""}|${payload.suffix ?? ""}|${payload.bar ?? ""}|${payload.color ?? ""}|${payload.icon ?? ""}|${payload.barSegments ?? ""}`;
|
|
161
195
|
}
|
|
162
196
|
|
|
197
|
+
interface ActiveItem {
|
|
198
|
+
run: TeamRunManifest;
|
|
199
|
+
agents: ReturnType<typeof readCrewAgents>;
|
|
200
|
+
tasks: TeamTaskState[];
|
|
201
|
+
snapshot?: RunUiSnapshot;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build the workflow steps segment showing: ✓explore › →plan › ○execute › ○verify
|
|
206
|
+
* with the current/active step highlighted using → arrow.
|
|
207
|
+
*/
|
|
208
|
+
function buildStepsPayload(active: ActiveItem[], allTasks: TeamTaskState[]): PowerbarPayloadShape {
|
|
209
|
+
if (!active.length) {
|
|
210
|
+
return { id: "pi-crew-steps" };
|
|
211
|
+
}
|
|
212
|
+
const run = active[0]!.run;
|
|
213
|
+
const workflowName = run.workflow ?? "default";
|
|
214
|
+
// Load workflow steps
|
|
215
|
+
const workflows = allWorkflows(discoverWorkflows(run.cwd));
|
|
216
|
+
const workflow = workflows.find((w) => w.name === workflowName);
|
|
217
|
+
if (!workflow || workflow.steps.length === 0) {
|
|
218
|
+
return { id: "pi-crew-steps", text: workflowName };
|
|
219
|
+
}
|
|
220
|
+
// Build step status map from tasks
|
|
221
|
+
const stepStatus = new Map<string, "completed" | "running" | "pending">();
|
|
222
|
+
for (const task of allTasks) {
|
|
223
|
+
if (!task.stepId) continue;
|
|
224
|
+
if (!stepStatus.has(task.stepId)) {
|
|
225
|
+
if (task.status === "completed") {
|
|
226
|
+
stepStatus.set(task.stepId, "completed");
|
|
227
|
+
} else if (task.status === "running" || task.status === "queued" || task.status === "waiting") {
|
|
228
|
+
stepStatus.set(task.stepId, "running");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Format: "✓explore › →plan › ○execute › ○verify"
|
|
233
|
+
// ✓ = completed, → = running (current), ○ = pending
|
|
234
|
+
const stepParts: string[] = [];
|
|
235
|
+
for (const step of workflow.steps) {
|
|
236
|
+
const status = stepStatus.get(step.id) ?? "pending";
|
|
237
|
+
const icon = status === "completed" ? "✓" : status === "running" ? "→" : "○";
|
|
238
|
+
// Shorten long step names
|
|
239
|
+
const stepName = step.id.length > 10 ? step.id.slice(0, 9) + "…" : step.id;
|
|
240
|
+
stepParts.push(`${icon}${stepName}`);
|
|
241
|
+
}
|
|
242
|
+
const stepsText = stepParts.join(" › ");
|
|
243
|
+
// Color: accent if running step exists, success if all complete, dim otherwise
|
|
244
|
+
const hasRunningStep = [...stepStatus.values()].includes("running");
|
|
245
|
+
const allComplete = stepStatus.size === workflow.steps.length && ![...stepStatus.values()].includes("running");
|
|
246
|
+
const color = allComplete ? "success" : hasRunningStep ? "accent" : "dim";
|
|
247
|
+
return {
|
|
248
|
+
id: "pi-crew-steps",
|
|
249
|
+
text: stepsText,
|
|
250
|
+
color,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
163
254
|
// --- Coalesced powerbar update ---
|
|
164
255
|
|
|
165
256
|
interface PowerbarUpdateArgs {
|
|
@@ -206,16 +297,18 @@ export function disposePowerbarCoalescer(): void {
|
|
|
206
297
|
powerbarCoalescer.dispose();
|
|
207
298
|
}
|
|
208
299
|
|
|
209
|
-
export function clearPiCrewPowerbar(events: EventBus
|
|
300
|
+
export function clearPiCrewPowerbar(events: EventBus): void {
|
|
210
301
|
lastActiveKey = undefined;
|
|
211
302
|
lastProgressKey = undefined;
|
|
303
|
+
lastStepsKey = undefined;
|
|
212
304
|
safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
|
|
213
305
|
safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
|
|
214
|
-
|
|
306
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-steps" });
|
|
215
307
|
}
|
|
216
308
|
|
|
217
309
|
/** Reset dedup state on session lifecycle events. */
|
|
218
310
|
export function resetPowerbarDedupState(): void {
|
|
219
311
|
lastActiveKey = undefined;
|
|
220
312
|
lastProgressKey = undefined;
|
|
313
|
+
lastStepsKey = undefined;
|
|
221
314
|
}
|