pi-crew 0.9.44 → 0.9.47
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 +136 -0
- package/README.md +38 -3
- package/dist/build-meta.json +349 -203
- package/dist/index.mjs +2229 -2968
- package/dist/index.mjs.map +4 -4
- package/docs/decisions/2026-07-21-broker-phase4-default-on.md +77 -0
- package/docs/decisions/2026-07-21-broker-windows-perms.md +91 -0
- package/docs/decisions/2026-07-22-broker-phase4-gated-on.md +99 -0
- package/docs/decisions/README.md +3 -0
- package/docs/publishing.md +26 -0
- package/package.json +3 -1
- package/scripts/build-bundle.mjs +7 -0
- package/scripts/postinstall.mjs +35 -1
- package/scripts/pty_probe.py +174 -0
- package/skills/real-test-pi-crew/SKILL.md +659 -0
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config.ts +42 -1
- package/src/config/defaults.ts +45 -1
- package/src/config/types.ts +19 -0
- package/src/extension/register.ts +6 -1
- package/src/extension/registration/context-builder.ts +4 -0
- package/src/extension/registration/lifecycle-handlers.ts +200 -6
- package/src/extension/registration/registration-types.ts +9 -0
- package/src/extension/registration/subagent-manager-setup.ts +178 -59
- package/src/extension/run-import.ts +21 -1
- package/src/extension/team-tool/api.ts +4 -2
- package/src/prompt/prompt-runtime.ts +108 -0
- package/src/runtime/async-runner.ts +9 -1
- package/src/runtime/broker-issuer.ts +37 -0
- package/src/runtime/child-pi-spawn.ts +53 -0
- package/src/runtime/child-pi.ts +42 -11
- package/src/runtime/crew-broker-child.ts +88 -0
- package/src/runtime/crew-broker-client.ts +673 -0
- package/src/runtime/crew-broker-tokens.ts +84 -0
- package/src/runtime/crew-broker.ts +1276 -0
- package/src/runtime/dynamic-workflow-context.ts +7 -3
- package/src/runtime/dynamic-workflow-runner.ts +1 -1
- package/src/runtime/manifest-cache.ts +30 -0
- package/src/runtime/plan-templates.ts +8 -6
- package/src/runtime/resilient-edit.ts +16 -15
- package/src/runtime/role-permission.ts +27 -2
- package/src/runtime/run-coalesced-task-group.ts +72 -15
- package/src/runtime/task-packet.ts +1 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/state/event-log.ts +88 -34
- package/src/state/locks.ts +53 -0
- package/src/state/mailbox.ts +208 -4
- package/src/state/run-metrics.ts +40 -12
- package/src/ui/key-utils.ts +42 -0
- package/src/ui/keybinding-map.ts +29 -3
- package/src/ui/live-run-sidebar.ts +1 -9
- package/src/ui/run-dashboard.ts +29 -9
- package/src/ui/settings-overlay.ts +42 -22
- package/src/utils/incremental-reader.ts +105 -0
- package/src/utils/ndjson.ts +115 -0
- package/src/utils/session-utils.ts +30 -0
- package/src/utils/socket-path.ts +127 -0
- package/src/utils/visual.ts +27 -91
- package/workflows/default.workflow.md +1 -1
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/src/runtime/auto-resume.ts +0 -100
- package/src/runtime/notebook-helpers.ts +0 -88
- package/src/runtime/orphan-sentinel.ts +0 -7
package/src/state/locks.ts
CHANGED
|
@@ -447,6 +447,59 @@ const lockCtx = new AsyncLocalStorage<Set<string>>();
|
|
|
447
447
|
// at the top of withFileLockSync for the full deadlock mechanism.
|
|
448
448
|
const fileLockHeldByUs = new Map<string, string>(); // lockFile -> token
|
|
449
449
|
|
|
450
|
+
// --- Async file lock (non-blocking alternative to withFileLockSync) ---
|
|
451
|
+
// Uses a promise-chain pattern to serialize per-path access without blocking
|
|
452
|
+
// the Node.js event loop. Unlike withFileLockSync (which uses O_EXCL +
|
|
453
|
+
// sleepSync for cross-process safety), this is **in-process only** —
|
|
454
|
+
// sufficient for single-process mailbox writes (team-runner is single-process).
|
|
455
|
+
// Mirrors the structure of withEventLogLockAsync in event-log.ts.
|
|
456
|
+
const fileAsyncLocks = new Map<string, Promise<unknown>>();
|
|
457
|
+
|
|
458
|
+
// FIND-02 follow-up (P3): re-entrance guard for the async file lock, mirroring
|
|
459
|
+
// the lockCtx pattern used by withRunLock. A future caller doing same-path
|
|
460
|
+
// nested withFileLockAsync(path, ...) inside another withFileLockAsync(path,
|
|
461
|
+
// ...) in the SAME async context would otherwise deadlock (the nested call
|
|
462
|
+
// chains after the outer's still-pending promise). AsyncLocalStorage scopes
|
|
463
|
+
// the held set to the current async context so cross-context callers still
|
|
464
|
+
// serialize via the promise chain (Phase 1 mailbox path is unaffected).
|
|
465
|
+
const fileAsyncLockCtx = new AsyncLocalStorage<Set<string>>();
|
|
466
|
+
|
|
467
|
+
export async function withFileLockAsync<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
|
468
|
+
// Re-entrant within the same async context — run fn() directly (no chaining).
|
|
469
|
+
// Same semantics as the sync guard in withFileLockSync (fileLockHeldByUs)
|
|
470
|
+
// and the async guard in withRunLock (lockCtx).
|
|
471
|
+
if (fileAsyncLockCtx.getStore()?.has(filePath)) {
|
|
472
|
+
return await fn();
|
|
473
|
+
}
|
|
474
|
+
// Merge with the parent context's held set so nested DIFFERENT-path locks
|
|
475
|
+
// also bypass correctly (prevents cross-path deadlock, matching withRunLock).
|
|
476
|
+
const prevHeld = fileAsyncLockCtx.getStore() ?? new Set<string>();
|
|
477
|
+
const held = new Set(prevHeld);
|
|
478
|
+
held.add(filePath);
|
|
479
|
+
const prev = fileAsyncLocks.get(filePath) ?? Promise.resolve();
|
|
480
|
+
// Chain fn after the previous holder. `next` may reject (propagating to the
|
|
481
|
+
// caller), but `stored` never rejects so subsequent waiters aren't blocked.
|
|
482
|
+
// fn() is wrapped in fileAsyncLockCtx.run so nested same-context calls see
|
|
483
|
+
// `held` and bypass the promise chain (re-entrant), while cross-context
|
|
484
|
+
// callers chain normally via `prev`.
|
|
485
|
+
const next = prev.then(() => fileAsyncLockCtx.run(held, () => fn()));
|
|
486
|
+
const stored = next.then(
|
|
487
|
+
() => undefined,
|
|
488
|
+
() => undefined,
|
|
489
|
+
);
|
|
490
|
+
fileAsyncLocks.set(filePath, stored);
|
|
491
|
+
try {
|
|
492
|
+
return await next;
|
|
493
|
+
} finally {
|
|
494
|
+
// Compare-and-delete: only remove our entry if it still points at `stored`.
|
|
495
|
+
// With 3+ overlapping callers, an earlier caller's finally would otherwise
|
|
496
|
+
// delete a later caller's promise, breaking mutual exclusion.
|
|
497
|
+
if (fileAsyncLocks.get(filePath) === stored) {
|
|
498
|
+
fileAsyncLocks.delete(filePath);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
450
503
|
export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, options: RunLockOptions = {}): T {
|
|
451
504
|
const filePath = lockPath(manifest);
|
|
452
505
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
package/src/state/mailbox.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { logInternalError } from "../utils/internal-error.ts";
|
|
|
5
5
|
import { redactSecrets } from "../utils/redaction.ts";
|
|
6
6
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
7
7
|
import { withEventLogLockSync } from "./event-log.ts";
|
|
8
|
-
import { withFileLockSync } from "./locks.ts";
|
|
8
|
+
import { withFileLockAsync, withFileLockSync } from "./locks.ts";
|
|
9
9
|
import type { TeamRunManifest } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
export type MailboxDirection = "inbox" | "outbox";
|
|
@@ -14,6 +14,47 @@ export type MailboxMessageKind = "message" | "steer" | "follow-up" | "response"
|
|
|
14
14
|
export type MailboxMessagePriority = "urgent" | "normal" | "low";
|
|
15
15
|
export type MailboxDeliveryMode = "interrupt" | "next_turn";
|
|
16
16
|
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Phase 1.3: post-append observer (single notification point)
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// A registry of callbacks invoked AFTER a durable mailbox append completes
|
|
21
|
+
// (both sync and async paths). The broker registers here to fan out live
|
|
22
|
+
// notifications to connected recipients. The notifier is non-throwing and
|
|
23
|
+
// never blocks the append — it queues work via queueMicrotask so a slow
|
|
24
|
+
// observer cannot stall the mailbox write path. Registration is idempotent.
|
|
25
|
+
|
|
26
|
+
export type MailboxAppendObserver = (message: MailboxMessage) => void;
|
|
27
|
+
|
|
28
|
+
const mailboxAppendObservers = new Set<MailboxAppendObserver>();
|
|
29
|
+
|
|
30
|
+
/** Register a post-append observer. Returns an unsubscribe function. */
|
|
31
|
+
export function registerMailboxAppendObserver(fn: MailboxAppendObserver): () => void {
|
|
32
|
+
mailboxAppendObservers.add(fn);
|
|
33
|
+
return () => {
|
|
34
|
+
mailboxAppendObservers.delete(fn);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Internal: invoked by appendMailboxMessage[Async] AFTER the durable write +
|
|
40
|
+
* delivery RMW have completed. Non-throwing; never blocks the caller.
|
|
41
|
+
*/
|
|
42
|
+
function notifyMailboxAppended(message: MailboxMessage): void {
|
|
43
|
+
if (mailboxAppendObservers.size === 0) return;
|
|
44
|
+
// Snapshot the message so a later mutation by the caller cannot affect
|
|
45
|
+
// what the observer sees.
|
|
46
|
+
const snapshot = { ...message };
|
|
47
|
+
queueMicrotask(() => {
|
|
48
|
+
for (const fn of mailboxAppendObservers) {
|
|
49
|
+
try {
|
|
50
|
+
fn(snapshot);
|
|
51
|
+
} catch {
|
|
52
|
+
/* observer must not break the append path */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
17
58
|
export interface MailboxMessage {
|
|
18
59
|
id: string;
|
|
19
60
|
runId: string;
|
|
@@ -377,19 +418,61 @@ function readAllInboxMessages(manifest: TeamRunManifest): MailboxMessage[] {
|
|
|
377
418
|
return readAllMessages(manifest, "inbox");
|
|
378
419
|
}
|
|
379
420
|
|
|
421
|
+
// FIND-01: in-process delivery cache to avoid O(N²) re-reads on every append.
|
|
422
|
+
// Keyed by delivery file path; invalidated by mtime check on read + updated on
|
|
423
|
+
// write. Team-runner is single-process so in-process caching is sufficient.
|
|
424
|
+
const deliveryCache = new Map<string, { mtimeMs: number; state: MailboxDeliveryState }>();
|
|
425
|
+
const MAX_DELIVERY_CACHE_ENTRIES = 256;
|
|
426
|
+
// R1 review fix: setDeliveryCacheEntry stores an immutable snapshot (deep
|
|
427
|
+
// copy of `messages`) so callers mutating the returned state cannot corrupt
|
|
428
|
+
// the cache (TOCTOU race), and bounds the map size with FIFO eviction to
|
|
429
|
+
// prevent unbounded growth across runs.
|
|
430
|
+
function setDeliveryCacheEntry(filePath: string, entry: { mtimeMs: number; state: MailboxDeliveryState }): void {
|
|
431
|
+
if (deliveryCache.size >= MAX_DELIVERY_CACHE_ENTRIES) {
|
|
432
|
+
const oldest = deliveryCache.keys().next().value;
|
|
433
|
+
if (oldest !== undefined) deliveryCache.delete(oldest);
|
|
434
|
+
}
|
|
435
|
+
deliveryCache.set(filePath, {
|
|
436
|
+
mtimeMs: entry.mtimeMs,
|
|
437
|
+
state: { ...entry.state, messages: { ...entry.state.messages } },
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
380
441
|
export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliveryState {
|
|
442
|
+
const filePath = deliveryFile(manifest);
|
|
443
|
+
let stat: fs.Stats;
|
|
444
|
+
try {
|
|
445
|
+
stat = fs.statSync(filePath);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
// R1 review fix: narrow to ENOENT so permission errors aren't silently
|
|
448
|
+
// treated as "missing file" (would wipe a valid cache entry).
|
|
449
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
450
|
+
deliveryCache.delete(filePath);
|
|
451
|
+
return { messages: {}, updatedAt: new Date().toISOString() };
|
|
452
|
+
}
|
|
453
|
+
const cached = deliveryCache.get(filePath);
|
|
454
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
455
|
+
// R2 review fix: return a copy so callers mutating the result cannot
|
|
456
|
+
// leak into the cached snapshot (residual TOCTOU: the cache holds the
|
|
457
|
+
// snapshot until the next write replaces it; without this copy, a
|
|
458
|
+
// pre-write mutation by one caller would be persisted into the
|
|
459
|
+
// post-write snapshot by the next writer's setDeliveryCacheEntry).
|
|
460
|
+
return { ...cached.state, messages: { ...cached.state.messages } };
|
|
461
|
+
}
|
|
381
462
|
try {
|
|
382
|
-
const raw = JSON.parse(fs.readFileSync(
|
|
463
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
|
|
383
464
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid delivery state.");
|
|
384
465
|
const obj = raw as Record<string, unknown>;
|
|
385
466
|
const messages: Record<string, MailboxMessageStatus> = {};
|
|
386
467
|
if (obj.messages && typeof obj.messages === "object" && !Array.isArray(obj.messages)) {
|
|
387
468
|
for (const [id, status] of Object.entries(obj.messages)) if (isStatus(status)) messages[id] = status;
|
|
388
469
|
}
|
|
389
|
-
|
|
470
|
+
const state: MailboxDeliveryState = {
|
|
390
471
|
messages,
|
|
391
472
|
updatedAt: typeof obj.updatedAt === "string" ? obj.updatedAt : new Date().toISOString(),
|
|
392
473
|
};
|
|
474
|
+
setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, state });
|
|
475
|
+
return state;
|
|
393
476
|
} catch {
|
|
394
477
|
return { messages: {}, updatedAt: new Date().toISOString() };
|
|
395
478
|
}
|
|
@@ -414,9 +497,20 @@ function writeDeliveryState(
|
|
|
414
497
|
// F4: mailbox delivery is informational — accept losing the very last write on
|
|
415
498
|
// a hard crash (the next message will overwrite it on disk). Cheaper fsync on
|
|
416
499
|
// the hot path; terminal/reply paths still pass full durability below.
|
|
417
|
-
|
|
500
|
+
const filePath = deliveryFile(manifest, true);
|
|
501
|
+
atomicWriteFile(filePath, `${JSON.stringify(redactSecrets(state), null, 2)}\n`, {
|
|
418
502
|
durability: options?.durability ?? "best-effort",
|
|
419
503
|
});
|
|
504
|
+
// FIND-01: update cache with post-write mtime so subsequent reads get a hit.
|
|
505
|
+
try {
|
|
506
|
+
const postStat = fs.statSync(filePath);
|
|
507
|
+
// setDeliveryCacheEntry stores an immutable snapshot (deep copy of
|
|
508
|
+
// messages) so subsequent read-modify-write callers mutating the
|
|
509
|
+
// returned state cannot corrupt the cache.
|
|
510
|
+
setDeliveryCacheEntry(filePath, { mtimeMs: postStat.mtimeMs, state });
|
|
511
|
+
} catch {
|
|
512
|
+
deliveryCache.delete(filePath);
|
|
513
|
+
}
|
|
420
514
|
}
|
|
421
515
|
|
|
422
516
|
/**
|
|
@@ -486,6 +580,7 @@ export function appendMailboxMessage(
|
|
|
486
580
|
// F4: complete transitions are terminal-ish — keep full durability.
|
|
487
581
|
writeDeliveryState(manifest, delivery, { durability: "full" });
|
|
488
582
|
});
|
|
583
|
+
notifyMailboxAppended(complete);
|
|
489
584
|
return complete;
|
|
490
585
|
}
|
|
491
586
|
|
|
@@ -541,6 +636,115 @@ export function appendFollowUpMessage(
|
|
|
541
636
|
});
|
|
542
637
|
}
|
|
543
638
|
|
|
639
|
+
/**
|
|
640
|
+
* FIND-02: Async variant of appendMailboxMessage for the live-session path.
|
|
641
|
+
* Uses withFileLockAsync (promise-chain, no sleepSync) instead of
|
|
642
|
+
* withEventLogLockSync/withFileLockSync, preventing event-loop stalls during
|
|
643
|
+
* steering/follow-up delivery. readDeliveryState/writeDeliveryState remain
|
|
644
|
+
* sync but are cheap thanks to the FIND-01 delivery cache.
|
|
645
|
+
*/
|
|
646
|
+
export async function appendMailboxMessageAsync(
|
|
647
|
+
manifest: TeamRunManifest,
|
|
648
|
+
message: Omit<MailboxMessage, "id" | "runId" | "createdAt" | "status"> & {
|
|
649
|
+
id?: string;
|
|
650
|
+
status?: MailboxMessageStatus;
|
|
651
|
+
},
|
|
652
|
+
): Promise<MailboxMessage> {
|
|
653
|
+
if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
|
|
654
|
+
else ensureRunMailbox(manifest);
|
|
655
|
+
const createdAt = new Date().toISOString();
|
|
656
|
+
const complete: MailboxMessage = {
|
|
657
|
+
id: message.id ?? `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
658
|
+
runId: manifest.runId,
|
|
659
|
+
direction: message.direction,
|
|
660
|
+
from: message.from,
|
|
661
|
+
to: message.to,
|
|
662
|
+
body: message.body,
|
|
663
|
+
createdAt,
|
|
664
|
+
status: message.status ?? "queued",
|
|
665
|
+
kind: message.kind,
|
|
666
|
+
priority: message.priority,
|
|
667
|
+
deliveryMode: message.deliveryMode,
|
|
668
|
+
taskId: message.taskId,
|
|
669
|
+
data: message.data,
|
|
670
|
+
replyTo: message.replyTo,
|
|
671
|
+
replyFrom: message.replyFrom,
|
|
672
|
+
replyDeadline: message.replyDeadline,
|
|
673
|
+
repliedAt: message.repliedAt,
|
|
674
|
+
replyContent: message.replyContent,
|
|
675
|
+
};
|
|
676
|
+
const mbFile = mailboxFile(manifest, complete.direction, complete.taskId);
|
|
677
|
+
await withFileLockAsync(mbFile, async () => {
|
|
678
|
+
await fs.promises.appendFile(mbFile, `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
|
|
679
|
+
rotateMailboxFileIfNeeded(mbFile);
|
|
680
|
+
});
|
|
681
|
+
// R1 review fix / plan §5 #6: delivery RMW uses the cross-process sync
|
|
682
|
+
// lock (withFileLockSync) so sync callers (acknowledgeMailboxMessage,
|
|
683
|
+
// replayPendingMailboxMessages) serialize against this async path. The
|
|
684
|
+
// body has no await, so sync locking is safe and restores the
|
|
685
|
+
// cross-process safety net that the async lock cannot provide.
|
|
686
|
+
withFileLockSync(deliveryFile(manifest, true), () => {
|
|
687
|
+
const delivery = readDeliveryState(manifest);
|
|
688
|
+
delivery.messages[complete.id] = complete.status;
|
|
689
|
+
delivery.updatedAt = createdAt;
|
|
690
|
+
writeDeliveryState(manifest, delivery, { durability: "full" });
|
|
691
|
+
});
|
|
692
|
+
notifyMailboxAppended(complete);
|
|
693
|
+
return complete;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export async function appendSteeringMessageAsync(
|
|
697
|
+
manifest: TeamRunManifest,
|
|
698
|
+
input: {
|
|
699
|
+
taskId: string;
|
|
700
|
+
body: string;
|
|
701
|
+
from?: string;
|
|
702
|
+
to?: string;
|
|
703
|
+
priority?: MailboxMessagePriority;
|
|
704
|
+
status?: MailboxMessageStatus;
|
|
705
|
+
data?: Record<string, unknown>;
|
|
706
|
+
},
|
|
707
|
+
): Promise<MailboxMessage> {
|
|
708
|
+
return appendMailboxMessageAsync(manifest, {
|
|
709
|
+
direction: "inbox",
|
|
710
|
+
from: input.from ?? "leader",
|
|
711
|
+
to: input.to ?? input.taskId,
|
|
712
|
+
taskId: input.taskId,
|
|
713
|
+
body: input.body,
|
|
714
|
+
kind: "steer",
|
|
715
|
+
priority: input.priority ?? "urgent",
|
|
716
|
+
deliveryMode: "interrupt",
|
|
717
|
+
status: input.status,
|
|
718
|
+
data: { ...(input.data ?? {}), kind: "steer" },
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export async function appendFollowUpMessageAsync(
|
|
723
|
+
manifest: TeamRunManifest,
|
|
724
|
+
input: {
|
|
725
|
+
taskId: string;
|
|
726
|
+
body: string;
|
|
727
|
+
from?: string;
|
|
728
|
+
to?: string;
|
|
729
|
+
priority?: MailboxMessagePriority;
|
|
730
|
+
status?: MailboxMessageStatus;
|
|
731
|
+
data?: Record<string, unknown>;
|
|
732
|
+
},
|
|
733
|
+
): Promise<MailboxMessage> {
|
|
734
|
+
return appendMailboxMessageAsync(manifest, {
|
|
735
|
+
direction: "inbox",
|
|
736
|
+
from: input.from ?? "leader",
|
|
737
|
+
to: input.to ?? input.taskId,
|
|
738
|
+
taskId: input.taskId,
|
|
739
|
+
body: input.body,
|
|
740
|
+
kind: "follow-up",
|
|
741
|
+
priority: input.priority ?? "normal",
|
|
742
|
+
deliveryMode: "next_turn",
|
|
743
|
+
status: input.status,
|
|
744
|
+
data: { ...(input.data ?? {}), kind: "follow-up" },
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
544
748
|
export function listMailboxByKind(manifest: TeamRunManifest, kind: MailboxMessageKind, direction?: MailboxDirection): MailboxMessage[] {
|
|
545
749
|
const messages = direction
|
|
546
750
|
? readAllMessages(manifest, direction)
|
package/src/state/run-metrics.ts
CHANGED
|
@@ -107,6 +107,16 @@ export function loadRunMetrics(cwd: string, runId: string): RunMetrics | undefin
|
|
|
107
107
|
* List recent metrics files up to `limit` entries (newest first).
|
|
108
108
|
* Returns an array of { runId, timestamp, taskCount, completedCount, failedCount, totalTokens, totalCost, durationMs, consistencyScore }.
|
|
109
109
|
* Gracefully skips files that cannot be read or parsed.
|
|
110
|
+
*
|
|
111
|
+
* FIND-04 perf: sort dirents by mtime descending BEFORE reading any of
|
|
112
|
+
* them, then read at most `limit` files. Malformed files within that
|
|
113
|
+
* selected window reduce the returned count; the scan does not continue
|
|
114
|
+
* past the window to backfill them. Previously the function read up
|
|
115
|
+
* to MAX_METRIC_FILES_TO_SCAN (500) files via loadRunMetrics() and only
|
|
116
|
+
* THEN sorted+sliced to `limit` (default 25) — wasting 475 readFileSync +
|
|
117
|
+
* JSON.parse calls on the hot dashboard path. The sort is O(N log N) and
|
|
118
|
+
* uses the run-id timestamp prefix as a cheap secondary signal when
|
|
119
|
+
* mtimes tie (e.g. multiple files saved in the same millisecond).
|
|
110
120
|
*/
|
|
111
121
|
export function getRunMetricsSummary(cwd: string, limit = 25): RunMetrics[] {
|
|
112
122
|
const dir = metricsDir(cwd);
|
|
@@ -117,21 +127,39 @@ export function getRunMetricsSummary(cwd: string, limit = 25): RunMetrics[] {
|
|
|
117
127
|
return [];
|
|
118
128
|
}
|
|
119
129
|
|
|
130
|
+
// Cap the directory-order candidate set before collecting mtimes. This
|
|
131
|
+
// is a safety valve against unbounded scan cost; the mtime sort below
|
|
132
|
+
// only reorders entries within this bounded candidate window.
|
|
133
|
+
const cap = Math.max(0, limit);
|
|
134
|
+
const sorted = entries
|
|
135
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
136
|
+
.slice(0, MAX_METRIC_FILES_TO_SCAN)
|
|
137
|
+
.map((entry) => {
|
|
138
|
+
const fullPath = path.join(dir, entry.name);
|
|
139
|
+
let mtimeMs = 0;
|
|
140
|
+
try {
|
|
141
|
+
mtimeMs = fs.statSync(fullPath).mtimeMs;
|
|
142
|
+
} catch {
|
|
143
|
+
// stat failure: fall through with mtimeMs=0; the sort still
|
|
144
|
+
// produces a deterministic order (filenames tiebreak).
|
|
145
|
+
}
|
|
146
|
+
return { name: entry.name, mtimeMs };
|
|
147
|
+
})
|
|
148
|
+
.sort((a, b) => {
|
|
149
|
+
const diff = b.mtimeMs - a.mtimeMs;
|
|
150
|
+
if (diff !== 0) return diff;
|
|
151
|
+
// Filename tiebreaker: the run-id prefix is a YYYYMMDDhhmmss
|
|
152
|
+
// timestamp (e.g. team_20260720050617_abc123) so a descending
|
|
153
|
+
// string compare yields newest-first within a tie group.
|
|
154
|
+
return b.name.localeCompare(a.name);
|
|
155
|
+
});
|
|
156
|
+
|
|
120
157
|
const metrics: RunMetrics[] = [];
|
|
121
|
-
for (const
|
|
122
|
-
|
|
123
|
-
const runId = entry.name.replace(/\.json$/, "");
|
|
158
|
+
for (const { name } of sorted.slice(0, cap)) {
|
|
159
|
+
const runId = name.replace(/\.json$/, "");
|
|
124
160
|
const m = loadRunMetrics(cwd, runId);
|
|
125
161
|
if (m) metrics.push(m);
|
|
126
|
-
if (metrics.length >= MAX_METRIC_FILES_TO_SCAN) break;
|
|
127
162
|
}
|
|
128
163
|
|
|
129
|
-
|
|
130
|
-
metrics.sort((a, b) => {
|
|
131
|
-
const diff = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
|
132
|
-
if (diff !== 0) return diff;
|
|
133
|
-
return b.runId.localeCompare(a.runId);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
return metrics.slice(0, limit);
|
|
164
|
+
return metrics;
|
|
137
165
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* key-utils.ts — Centralised key matching helpers.
|
|
3
|
+
*
|
|
4
|
+
* Pi 0.81+'s TUI input layer (`@earendil-works/pi-tui`) ships a `matchesKey()`
|
|
5
|
+
* helper that handles multiple terminal key encodings: legacy CSI escapes
|
|
6
|
+
* (`\x1b[A`), application cursor mode (`\x1bOA`), and Kitty keyboard protocol
|
|
7
|
+
* variants. Pi-crew components previously compared against raw escape bytes
|
|
8
|
+
* (`data === "\x1b[A"`), which silently failed on terminals that emit the
|
|
9
|
+
* alternate encodings. This module wraps `matchesKey` with a single
|
|
10
|
+
* `keyOf()` helper so overlay code reads naturally while picking up the
|
|
11
|
+
* terminal-aware match logic for free.
|
|
12
|
+
*
|
|
13
|
+
* Returns the canonical KeyId when matchesKey recognises it, otherwise the
|
|
14
|
+
* raw input string so callers can fall through to ASCII-letter shortcuts.
|
|
15
|
+
*/
|
|
16
|
+
import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
|
|
17
|
+
|
|
18
|
+
export type PiKeyName = KeyId | string;
|
|
19
|
+
|
|
20
|
+
const COMMON_IDS: readonly KeyId[] = [
|
|
21
|
+
"up",
|
|
22
|
+
"down",
|
|
23
|
+
"left",
|
|
24
|
+
"right",
|
|
25
|
+
"enter",
|
|
26
|
+
"escape",
|
|
27
|
+
"tab",
|
|
28
|
+
"shift+tab",
|
|
29
|
+
"space",
|
|
30
|
+
"backspace",
|
|
31
|
+
"home",
|
|
32
|
+
"end",
|
|
33
|
+
"pageUp",
|
|
34
|
+
"pageDown",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export function keyOf(data: string): KeyId | string {
|
|
38
|
+
for (const id of COMMON_IDS) {
|
|
39
|
+
if (matchesKey(data, id)) return id;
|
|
40
|
+
}
|
|
41
|
+
return data;
|
|
42
|
+
}
|
package/src/ui/keybinding-map.ts
CHANGED
|
@@ -23,9 +23,12 @@
|
|
|
23
23
|
* `use-global-shortcuts.ts:38-61`.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
|
|
27
|
+
import { keyOf } from "./key-utils.ts";
|
|
28
|
+
|
|
26
29
|
export const DASHBOARD_KEYS = {
|
|
27
|
-
close: ["q", "\u001b"],
|
|
28
|
-
select: ["\r", "\n", "
|
|
30
|
+
close: ["q", "escape", "\u001b"],
|
|
31
|
+
select: ["enter", "s", "\r", "\n", "tab", "\t", " "],
|
|
29
32
|
help: ["?"],
|
|
30
33
|
root: {
|
|
31
34
|
summary: ["u"],
|
|
@@ -48,7 +51,7 @@ export const DASHBOARD_KEYS = {
|
|
|
48
51
|
health: ["5"],
|
|
49
52
|
metrics: ["6"],
|
|
50
53
|
},
|
|
51
|
-
navigation: { up: ["k", "
|
|
54
|
+
navigation: { up: ["k", "up"], down: ["j", "down"] },
|
|
52
55
|
mailbox: {
|
|
53
56
|
ack: ["A"],
|
|
54
57
|
nudge: ["N"],
|
|
@@ -214,9 +217,32 @@ export { KEY_RESERVED };
|
|
|
214
217
|
* arg skipped the `activePane === ...` branches).
|
|
215
218
|
*/
|
|
216
219
|
export function dashboardActionForKey(data: string, activePane?: ActivePane): DashboardKeyAction | undefined {
|
|
220
|
+
// Two-pass dispatch to preserve case-sensitivity for plain ASCII keys
|
|
221
|
+
// while still normalizing escape sequences via matchesKey().
|
|
222
|
+
//
|
|
223
|
+
// Background: pi-tui's matchesKey() is case-insensitive, so matchesKey("d",
|
|
224
|
+
// "D") === true. A single-pass loop that intermixes exact + matchesKey
|
|
225
|
+
// checks would let the pane-scoped health-diagnostic-export binding
|
|
226
|
+
// (candidate "D") win over the unscoped agents binding (candidate "d")
|
|
227
|
+
// when activePane === "health" — collapsing the d/D case distinction.
|
|
228
|
+
//
|
|
229
|
+
// Pass 1 — exact string match (case-sensitive). Handles literal ASCII
|
|
230
|
+
// keystrokes ('d', 'D', 'q', 'S', …) and preserves their distinct meanings.
|
|
217
231
|
for (const binding of BINDINGS) {
|
|
218
232
|
if (binding.pane !== undefined && binding.pane !== activePane) continue;
|
|
219
233
|
if (binding.keys.includes(data)) return binding.action;
|
|
220
234
|
}
|
|
235
|
+
// Pass 2 — terminal-aware match for escape sequences / canonical KeyIds.
|
|
236
|
+
// Only reached when no exact ASCII match exists (data is e.g. '\x1b[A',
|
|
237
|
+
// '\x1bOA', or an app-cursor-mode variant). Uses matchesKey() to normalize
|
|
238
|
+
// legacy CSI, app-cursor-mode, and Kitty-protocol variants uniformly.
|
|
239
|
+
const key = keyOf(data);
|
|
240
|
+
for (const binding of BINDINGS) {
|
|
241
|
+
if (binding.pane !== undefined && binding.pane !== activePane) continue;
|
|
242
|
+
for (const candidate of binding.keys) {
|
|
243
|
+
if (key === candidate) return binding.action;
|
|
244
|
+
if (matchesKey(data, candidate as KeyId)) return binding.action;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
221
247
|
return undefined;
|
|
222
248
|
}
|
|
@@ -8,7 +8,6 @@ import type { TeamTaskState } from "../state/types.ts";
|
|
|
8
8
|
import { aggregateUsage, formatUsage } from "../state/usage.ts";
|
|
9
9
|
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
10
10
|
import { pad, truncate } from "../utils/visual.ts";
|
|
11
|
-
import { Box, Text } from "./layout-primitives.ts";
|
|
12
11
|
import { RenderScheduler } from "./render-scheduler.ts";
|
|
13
12
|
import { runEventBusAsRenderScheduler } from "./run-event-bus.ts";
|
|
14
13
|
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
|
@@ -16,17 +15,10 @@ import { spinnerBucket, spinnerFrame } from "./spinner.ts";
|
|
|
16
15
|
import { colorizeStatusGlyphs, iconForStatus } from "./status-colors.ts";
|
|
17
16
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
18
17
|
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
|
18
|
+
import { renderLines } from "./widget/widget-renderer.ts";
|
|
19
19
|
|
|
20
20
|
const TASK_READ_TTL_MS = 200;
|
|
21
21
|
|
|
22
|
-
function renderLines(lines: string[], width: number): string[] {
|
|
23
|
-
const box = new Box(0, 0);
|
|
24
|
-
for (const line of lines) {
|
|
25
|
-
box.addChild(new Text(line));
|
|
26
|
-
}
|
|
27
|
-
return box.render(width);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
22
|
type Done = (value: undefined) => void;
|
|
31
23
|
|
|
32
24
|
function line(text: string, width: number): string {
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -19,7 +19,6 @@ import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
|
|
|
19
19
|
import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
|
|
20
20
|
import { DynamicCrewBorder } from "./dynamic-border.ts";
|
|
21
21
|
import { dashboardActionForKey } from "./keybinding-map.ts";
|
|
22
|
-
import { Box, Text } from "./layout-primitives.ts";
|
|
23
22
|
import { HelpOverlay } from "./overlays/help-overlay.ts";
|
|
24
23
|
import { RenderScheduler } from "./render-scheduler.ts";
|
|
25
24
|
import { runEventBusAsRenderScheduler } from "./run-event-bus.ts";
|
|
@@ -28,6 +27,7 @@ import { spinnerBucket, spinnerFrame } from "./spinner.ts";
|
|
|
28
27
|
import { applyStatusColor, colorizeStatusGlyphs, iconForStatus, type RunStatus } from "./status-colors.ts";
|
|
29
28
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
30
29
|
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
|
30
|
+
import { renderLines } from "./widget/widget-renderer.ts";
|
|
31
31
|
|
|
32
32
|
/** S05 — wrap a pane render in try/catch so a single pane crash does not bring down the whole dashboard. */
|
|
33
33
|
function safeRenderPane(name: string, fn: () => string[]): string[] {
|
|
@@ -159,14 +159,6 @@ function formatAge(iso: string | undefined): string | undefined {
|
|
|
159
159
|
return `${Math.floor(ms / 3_600_000)}h`;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
function renderLines(lines: string[], width: number): string[] {
|
|
163
|
-
const box = new Box(0, 0);
|
|
164
|
-
for (const line of lines) {
|
|
165
|
-
box.addChild(new Text(line));
|
|
166
|
-
}
|
|
167
|
-
return box.render(width);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
162
|
function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?: RunSnapshotCache): string[] {
|
|
171
163
|
// P0-6: prefer the snapshot's `recentOutputLines` (no disk I/O) over reading the
|
|
172
164
|
// progress artifact on every render. The progress artifact content is captured
|
|
@@ -409,6 +401,11 @@ function countByStatus(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache
|
|
|
409
401
|
}
|
|
410
402
|
|
|
411
403
|
export class RunDashboard implements DashboardComponent {
|
|
404
|
+
// TEMP DIAGNOSTIC (remove after verifying keybind fix on Pi 0.81.1)
|
|
405
|
+
private static _instanceCounter = 0;
|
|
406
|
+
private readonly _instanceId: number;
|
|
407
|
+
// END TEMP DIAGNOSTIC
|
|
408
|
+
|
|
412
409
|
private selected = 0;
|
|
413
410
|
private runScrollOffset = 0;
|
|
414
411
|
private showFullProgress = false;
|
|
@@ -434,6 +431,14 @@ export class RunDashboard implements DashboardComponent {
|
|
|
434
431
|
theme: unknown = {},
|
|
435
432
|
options: RunDashboardOptions = {},
|
|
436
433
|
) {
|
|
434
|
+
// TEMP DIAGNOSTIC: log every constructor + handleInput + focus change
|
|
435
|
+
this._instanceId = ++RunDashboard._instanceCounter;
|
|
436
|
+
try {
|
|
437
|
+
process.stderr.write(
|
|
438
|
+
`[PI-CREW-DIAG] RunDashboard#${this._instanceId}.constructor runs=${runs.length} workspaceId=${options.workspaceId ?? "n/a"}\n`,
|
|
439
|
+
);
|
|
440
|
+
} catch {}
|
|
441
|
+
// END TEMP DIAGNOSTIC
|
|
437
442
|
// Filter runs by workspaceId for session isolation
|
|
438
443
|
// If workspaceId is provided, only show runs owned by that session or runs with no owner (legacy)
|
|
439
444
|
const filteredRuns = options.workspaceId
|
|
@@ -814,7 +819,22 @@ export class RunDashboard implements DashboardComponent {
|
|
|
814
819
|
return this.cachedLines;
|
|
815
820
|
}
|
|
816
821
|
|
|
822
|
+
// Pi 0.81+ requires the Focusable contract: a string-indexable `focused`
|
|
823
|
+
// marker that TUI toggles to track which component currently receives
|
|
824
|
+
// input. Without it, isFocusable() returns false and downstream
|
|
825
|
+
// dispatch may skip the component. Declared as an own property so
|
|
826
|
+
// `"focused" in component` is true.
|
|
827
|
+
public focused = false;
|
|
828
|
+
|
|
817
829
|
handleInput(data: string): void {
|
|
830
|
+
// TEMP DIAGNOSTIC (remove after verifying keybind fix on Pi 0.81.1)
|
|
831
|
+
if (process.env.PI_CREW_BROKER_DIAG_UI === "1") {
|
|
832
|
+
try {
|
|
833
|
+
process.stderr.write(
|
|
834
|
+
`[PI-CREW-DIAG] RunDashboard#${this._instanceId}.handleInput data=${JSON.stringify(data)} focused=${this.focused}\n`,
|
|
835
|
+
);
|
|
836
|
+
} catch {}
|
|
837
|
+
}
|
|
818
838
|
const action = dashboardActionForKey(data, this.activePane);
|
|
819
839
|
// K-1: "?" toggles the help overlay; while it is shown, any other key
|
|
820
840
|
// (including Esc) just dismisses it first instead of acting.
|