pi-crew 0.9.11 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +1 -135
  3. package/package.json +1 -1
  4. package/src/extension/crew-shortcuts.ts +29 -2
  5. package/src/extension/knowledge-injection.ts +236 -15
  6. package/src/extension/registration/commands.ts +61 -34
  7. package/src/extension/team-tool/chain-dispatch.ts +103 -0
  8. package/src/extension/team-tool/chain-executor.ts +400 -0
  9. package/src/extension/team-tool/run.ts +9 -0
  10. package/src/runtime/agent-memory.ts +5 -2
  11. package/src/runtime/background-runner.ts +49 -0
  12. package/src/runtime/chain-runner.ts +47 -8
  13. package/src/runtime/child-pi.ts +33 -0
  14. package/src/runtime/handoff-manager.ts +7 -0
  15. package/src/runtime/live-session-runtime.ts +10 -4
  16. package/src/runtime/pi-json-output.ts +5 -1
  17. package/src/runtime/process-status.ts +7 -2
  18. package/src/runtime/task-output-context.ts +36 -7
  19. package/src/runtime/task-runner/prompt-builder.ts +5 -1
  20. package/src/runtime/task-runner.ts +7 -0
  21. package/src/schema/team-tool-schema.ts +9 -0
  22. package/src/ui/crew-footer.ts +3 -3
  23. package/src/ui/crew-select-list.ts +1 -1
  24. package/src/ui/dashboard-panes/agents-pane.ts +26 -4
  25. package/src/ui/dashboard-panes/cancellation-pane.ts +23 -0
  26. package/src/ui/keybinding-map.ts +7 -3
  27. package/src/ui/live-conversation-overlay.ts +2 -2
  28. package/src/ui/live-run-sidebar.ts +18 -10
  29. package/src/ui/overlays/help-overlay.ts +166 -0
  30. package/src/ui/run-dashboard.ts +210 -70
  31. package/src/ui/status-colors.ts +45 -0
  32. package/src/ui/widget/index.ts +46 -3
  33. package/src/ui/widget/widget-formatters.ts +22 -7
  34. package/src/ui/widget/widget-renderer.ts +31 -27
  35. package/src/utils/visual.ts +3 -1
@@ -25,6 +25,19 @@ import type { executeTeamRun as ExecuteTeamRunFn } from "./team-runner.ts";
25
25
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
26
26
 
27
27
  let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
28
+
29
+ /** Maximum runtime for a single background run before the watchdog force-aborts
30
+ * it. Prevents zombie background-runner processes when a team run hangs forever
31
+ * (e.g. a hung child Pi process, a stuck lock, or a test that spawns a run
32
+ * without cleanup). Default 2h — generous for legitimate long runs (research
33
+ * workflows, goal-loops) but catches true zombies (observed: 10h+ stale test
34
+ * runs). Override via PI_CREW_MAX_RUN_MS env (milliseconds). The watchdog
35
+ * aborts via the shared AbortController, then force-exits after a grace
36
+ * period in case the abort signal does not propagate to all execution paths. */
37
+ const MAX_BACKGROUND_RUN_MS = (() => {
38
+ const env = Number.parseInt(process.env.PI_CREW_MAX_RUN_MS ?? "", 10);
39
+ return Number.isFinite(env) && env > 0 ? env : 2 * 60 * 60 * 1000;
40
+ })();
28
41
  async function executeTeamRun(
29
42
  ...args: Parameters<typeof ExecuteTeamRunFn>
30
43
  ): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
@@ -234,6 +247,7 @@ function runCleanup(
234
247
  stopParentGuard: () => void,
235
248
  stopHeartbeat: () => void,
236
249
  keepAlive: NodeJS.Timeout,
250
+ watchdogTimer: NodeJS.Timeout,
237
251
  exitDueToRejection: boolean,
238
252
  eventsPath?: string,
239
253
  ): void {
@@ -245,6 +259,9 @@ function runCleanup(
245
259
  // FIX: clearInterval FIRST, then kill children. This ensures the heartbeat
246
260
  // interval is always cleaned up even if terminateActiveChildPiProcesses throws.
247
261
  clearInterval(keepAlive);
262
+ // Clear the anti-zombie watchdog so a normally-completing run does not
263
+ // carry a pending force-exit timer into the exit path.
264
+ clearTimeout(watchdogTimer);
248
265
  // FIX Issues #1, #2, #4: Wrap child process termination in try/catch so errors
249
266
  // don't prevent the cleanup from completing. We log but don't re-throw since
250
267
  // we're already exiting.
@@ -563,6 +580,37 @@ async function main(): Promise<void> {
563
580
  // bounded by the 5s interval. The event loop exit is deferred at most 5s.
564
581
  const keepAlive = setInterval(() => {}, 5000);
565
582
 
583
+ // WATCHDOG (anti-zombie): if the run exceeds MAX_BACKGROUND_RUN_MS without
584
+ // completing, abort it and force-exit. Without this, a hung team run
585
+ // (stuck child Pi, deadlocked lock, test that never cleans up) leaves the
586
+ // background-runner alive forever because keepAlive holds the event loop.
587
+ // The watchdog fires once; it is cleared in the finally block via runCleanup.
588
+ const watchdogTimer = setTimeout(() => {
589
+ console.error(`[background-runner] WATCHDOG: run ${runId} exceeded ${MAX_BACKGROUND_RUN_MS}ms — aborting (zombie prevention)`);
590
+ try {
591
+ appendEvent(manifest.eventsPath, {
592
+ type: "async.watchdog_fired",
593
+ runId,
594
+ message: `Run exceeded ${MAX_BACKGROUND_RUN_MS}ms and was force-aborted to prevent a zombie background-runner process.`,
595
+ data: { maxRunMs: MAX_BACKGROUND_RUN_MS },
596
+ });
597
+ } catch { /* best-effort event log */ }
598
+ // Signal the finally block to exit(1) after cleanup.
599
+ exitDueToRejection = true;
600
+ // Abort the in-flight team run via the shared signal (propagates to
601
+ // executeTeamRun → child-pi → kills child processes).
602
+ abortController.abort();
603
+ // Hard-exit safety net: if the abort does not propagate within 15s
604
+ // (e.g. a hung native call), force-kill so the process cannot linger.
605
+ const forceExit = setTimeout(() => {
606
+ console.error(`[background-runner] WATCHDOG: abort did not propagate within grace period — force-exiting`);
607
+ stopParentGuard();
608
+ try { terminateActiveChildPiProcesses(); } catch { /* best-effort */ }
609
+ process.exit(1);
610
+ }, 15_000);
611
+ forceExit.unref();
612
+ }, MAX_BACKGROUND_RUN_MS);
613
+
566
614
  try {
567
615
  debugLog(`[background-runner] about to call discoverAgents`);
568
616
  const agents = allAgents(discoverAgents(cwd));
@@ -798,6 +846,7 @@ async function main(): Promise<void> {
798
846
  stopParentGuard,
799
847
  stopHeartbeat,
800
848
  keepAlive,
849
+ watchdogTimer,
801
850
  exitDueToRejection,
802
851
  manifest.eventsPath,
803
852
  );
@@ -496,25 +496,54 @@ export class ChainRunner {
496
496
  return context;
497
497
  }
498
498
 
499
- // Limit history size to prevent memory leak (H2)
500
- const limitedHandoffs = handoffs.slice(-ChainRunner.MAX_CHAIN_HISTORY_SIZE);
499
+ const notes: string[] = [];
500
+
501
+ // Limit history size to prevent memory leak (H2). Emit a marker so a dropped
502
+ // entry does not vanish silently — this was the only silent-loss path in the
503
+ // output-handling machinery (see research-findings/output-handling-deep-dive.md §F).
504
+ let limitedHandoffs = handoffs;
505
+ if (handoffs.length > ChainRunner.MAX_CHAIN_HISTORY_SIZE) {
506
+ const dropped = handoffs.length - ChainRunner.MAX_CHAIN_HISTORY_SIZE;
507
+ notes.push(`[chain history limited to last ${ChainRunner.MAX_CHAIN_HISTORY_SIZE} entries; ${dropped} older entr${dropped === 1 ? "y" : "ies"} omitted]`);
508
+ limitedHandoffs = handoffs.slice(-ChainRunner.MAX_CHAIN_HISTORY_SIZE);
509
+ }
501
510
 
502
- // Limit per-entry size to prevent memory issues from large artifacts
511
+ // Limit per-entry size to prevent memory issues from large artifacts.
503
512
  const filteredHandoffs = limitedHandoffs.filter(h => {
504
513
  const size = JSON.stringify(h).length;
505
514
  return size <= ChainRunner.MAX_HANDOFF_ENTRY_SIZE;
506
515
  });
516
+ const oversizedDropped = limitedHandoffs.length - filteredHandoffs.length;
517
+ if (oversizedDropped > 0) {
518
+ notes.push(`[${oversizedDropped} handoff entr${oversizedDropped === 1 ? "y" : "ies"} omitted (> ${ChainRunner.MAX_HANDOFF_ENTRY_SIZE} bytes each)]`);
519
+ }
507
520
 
508
- return {
509
- ...context,
510
- __chainHistory: filteredHandoffs.map(h => ({
521
+ // Track array-cap drops so capped lists do not vanish silently. Each field
522
+ // over its cap records the full count so a reader knows data was elided.
523
+ const arrayCapNotes: string[] = [];
524
+ const mapped = filteredHandoffs.map(h => {
525
+ const over: string[] = [];
526
+ if (h.filesCreated && h.filesCreated.length > 50) over.push(`filesCreated=${h.filesCreated.length}`);
527
+ if (h.filesModified && h.filesModified.length > 50) over.push(`filesModified=${h.filesModified.length}`);
528
+ if (h.decisions && h.decisions.length > 20) over.push(`decisions=${h.decisions.length}`);
529
+ if (h.nextSteps && h.nextSteps.length > 20) over.push(`nextSteps=${h.nextSteps.length}`);
530
+ if (over.length > 0) arrayCapNotes.push(`step ${h.taskId}: ${over.join(", ")} (capped)`);
531
+ return {
511
532
  step: h.taskId,
512
533
  outcome: h.outcome,
513
534
  filesCreated: h.filesCreated?.slice(0, 50), // Limit array size
514
535
  filesModified: h.filesModified?.slice(0, 50), // Limit array size
515
536
  decisions: h.decisions?.slice(0, 20), // Limit array size
516
537
  nextSteps: h.nextSteps?.slice(0, 20), // Limit array size
517
- })),
538
+ ...(h.outputText ? { outputText: h.outputText.slice(0, 5000) } : {}), // Size-capped worker output
539
+ };
540
+ });
541
+ if (arrayCapNotes.length > 0) notes.push(`[list caps applied: ${arrayCapNotes.join("; ")}]`);
542
+
543
+ return {
544
+ ...context,
545
+ __chainHistory: mapped,
546
+ ...(notes.length > 0 ? { __chainHistoryNotes: notes } : {}),
518
547
  };
519
548
  }
520
549
 
@@ -525,13 +554,23 @@ export class ChainRunner {
525
554
  config: ChainStep,
526
555
  context: Record<string, unknown>
527
556
  ): Promise<TaskResult> {
557
+ // Pass step config (team/workflow/model) into the packet context so the
558
+ // concrete ChainTaskRunner can resolve which team/workflow to run. Without
559
+ // this, a step parsed to a @teamName reference would lose its team because
560
+ // the only channel from executeStep to runTask is the TaskPacket. Purely
561
+ // additive — adds sibling keys alongside __chainHistory/Notes.
528
562
  const packet: TaskPacket = {
529
563
  taskId: `chain-${Date.now()}-${config.name}`,
530
564
  runId: "chain",
531
565
  goal: config.inlineGoal ?? config.name,
532
566
  summarizeThreshold: 3000,
533
567
  collapseContext: true,
534
- context,
568
+ context: {
569
+ ...context,
570
+ __chainStepTeam: config.team,
571
+ __chainStepWorkflow: config.workflow,
572
+ __chainStepModel: config.model,
573
+ },
535
574
  };
536
575
 
537
576
  return this.taskRunner.runTask(packet);
@@ -17,6 +17,7 @@ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
17
17
  import { registerChildProcess, unregisterChildProcess } from "../extension/crew-cleanup.ts";
18
18
  import { classifyProcessCrash } from "./crash-classification.ts";
19
19
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
20
+ import { extractText } from "./pi-json-output.ts";
20
21
 
21
22
  const POST_EXIT_STDIO_GUARD_MS = DEFAULT_CHILD_PI.postExitStdioGuardMs;
22
23
  const FINAL_DRAIN_MS = DEFAULT_CHILD_PI.finalDrainMs;
@@ -218,6 +219,13 @@ export interface ChildPiRunResult {
218
219
  stdout: string;
219
220
  stderr: string;
220
221
  error?: string;
222
+ /** RAW (uncapped) final assistant text, captured at stream-parse time BEFORE
223
+ * the 16K transcript compaction. This is the AUTHORITATIVE worker output —
224
+ * it becomes results/<id>.txt so downstream dependencies are not bounded by
225
+ * the transcript's telemetry cap. Undefined when no assistant text was seen
226
+ * (mock paths, error paths) — callers MUST fall back to transcript-derived
227
+ * finalText. See research-findings/output-handling-deep-dive.md §A. */
228
+ rawFinalText?: string;
221
229
  exitStatus?: WorkerExitStatus;
222
230
  /** True if the agent was hard-aborted (max_turns + grace exceeded). */
223
231
  aborted?: boolean;
@@ -512,6 +520,12 @@ function compactChildPiLine(line: string): { persistedLine: string; event?: unkn
512
520
  export class ChildPiLineObserver {
513
521
  private buffer = "";
514
522
  private readonly input: ChildPiRunInput;
523
+ /** RAW (uncapped) assistant-text fragments, accumulated in arrival order.
524
+ * Mirrors {@link parsePiJsonOutput}'s textEvents/finalText extraction but
525
+ * operates on the RAW event stream instead of the 16K-compacted transcript.
526
+ * This is the source of the AUTHORITATIVE result.txt; the transcript stays
527
+ * compacted (memory bound). */
528
+ private readonly rawTextEvents: string[] = [];
515
529
 
516
530
  constructor(input: ChildPiRunInput) {
517
531
  this.input = input;
@@ -531,8 +545,26 @@ export class ChildPiLineObserver {
531
545
  this.emitLine(line);
532
546
  }
533
547
 
548
+ /** Last non-empty RAW assistant text (mirrors {@link parsePiJsonOutput}'s
549
+ * finalText semantics but uncapped). Undefined when no assistant text was
550
+ * seen by this observer. {@link extractText} already drops empty fragments,
551
+ * so the last entry is the final assistant utterance. */
552
+ getRawFinalText(): string | undefined {
553
+ return this.rawTextEvents.length > 0 ? this.rawTextEvents[this.rawTextEvents.length - 1] : undefined;
554
+ }
555
+
534
556
  private emitLine(line: string): void {
535
557
  if (!line.trim()) return;
558
+ // Parse the RAW line once so we can BOTH compact it (telemetry transcript,
559
+ // 16K-capped memory bound) AND capture the uncapped assistant text for the
560
+ // authoritative result. Non-JSON lines contribute no assistant text.
561
+ try {
562
+ const rawParsed = JSON.parse(line);
563
+ const rawTexts = extractText(rawParsed);
564
+ if (rawTexts.length > 0) this.rawTextEvents.push(...rawTexts);
565
+ } catch {
566
+ // Not valid JSON — compactChildPiLine handles the raw-text fallback below.
567
+ }
536
568
  const compact = compactChildPiLine(line);
537
569
  if (compact.event !== undefined) {
538
570
  try {
@@ -919,6 +951,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
919
951
  try {
920
952
  resolve({
921
953
  ...result,
954
+ rawFinalText: lineObserver.getRawFinalText(),
922
955
  exitStatus: result.exitStatus ?? {
923
956
  exitCode: result.exitCode,
924
957
  cancelled: abortRequested,
@@ -99,6 +99,9 @@ export interface HandoffSummary {
99
99
 
100
100
  // Context snapshot
101
101
  contextSnapshot: string;
102
+
103
+ /** Worker output text propagated through the chain (read from resultArtifact). */
104
+ outputText?: string;
102
105
  }
103
106
 
104
107
  /**
@@ -121,6 +124,9 @@ export interface TaskResult {
121
124
  filesDeleted?: string[];
122
125
  decisions?: Decision[];
123
126
  error?: string;
127
+
128
+ /** Worker's textual output (read from resultArtifact during chain execution). */
129
+ outputText?: string;
124
130
  }
125
131
 
126
132
  /**
@@ -443,6 +449,7 @@ export class HandoffManager {
443
449
  },
444
450
 
445
451
  contextSnapshot,
452
+ ...(result.outputText ? { outputText: result.outputText } : {}),
446
453
  };
447
454
  }
448
455
 
@@ -4,8 +4,13 @@ import type { AgentConfig } from "../agents/agent-config.ts";
4
4
  import type { CrewRuntimeConfig } from "../config/config.ts";
5
5
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
6
6
  import { appendEvent } from "../state/event-log.ts";
7
- import { buildMemoryBlock } from "./agent-memory.ts";
8
7
  import { trackTaskUsage } from "./usage-tracker.ts";
8
+ // NOTE: buildMemoryBlock is intentionally NOT imported here. The agent memory
9
+ // block is injected via renderTaskPrompt().full (the USER prompt), which is
10
+ // shared by both the child-pi path (no system prompt) and the live-session
11
+ // path. Adding it to liveSystemPrompt() too duplicated the entire memory
12
+ // block (up to 200 lines) in both the user and system prompts. Keep memory
13
+ // in a single place: the shared user prompt. See G3 fix.
9
14
  import { createStreamingOutput, type StreamingOutputHandle } from "./streaming-output.ts";
10
15
  import { registerLiveAgent, disposeLiveAgentSession, terminateLiveAgent, updateLiveAgentStatus, trackLiveAgentToolStart, trackLiveAgentToolEnd, trackLiveAgentTurnEnd, trackLiveAgentResponseText, markLiveAgentCompleted } from "./live-agent-manager.ts";
11
16
  import { applyLiveAgentControlRequest, applyLiveAgentControlRequests, type LiveAgentControlCursor } from "./live-agent-control.ts";
@@ -371,8 +376,10 @@ function compressSessionToolDescriptions(session: LiveSessionLike): void {
371
376
  // is loaded and tree-shakeable, so adding the actual logic later is trivial.
372
377
  }
373
378
 
374
- function liveSystemPrompt(input: LiveSessionSpawnInput): string {
375
- const memory = input.agent.memory ? buildMemoryBlock(input.agent.name, input.agent.memory, input.task.cwd, Boolean(input.agent.tools?.some((tool) => tool === "write" || tool === "edit"))) : "";
379
+ export function liveSystemPrompt(input: LiveSessionSpawnInput): string {
380
+ // Agent MEMORY is intentionally omitted here it is already injected via
381
+ // renderTaskPrompt().full (the user prompt, shared with the child-pi path).
382
+ // See the import-block note above and the G3 fix.
376
383
  const role = input.task.role;
377
384
  const styleBlock = buildCommunicationStyle(role);
378
385
  const contractBlock = buildOutputContract(role);
@@ -390,7 +397,6 @@ function liveSystemPrompt(input: LiveSessionSpawnInput): string {
390
397
  sensitiveConstraint,
391
398
  "",
392
399
  input.agent.systemPrompt || "Follow the user task exactly and report verification evidence.",
393
- memory ? `\n${memory}` : "",
394
400
  ].filter(Boolean).join("\n");
395
401
  }
396
402
 
@@ -75,7 +75,11 @@ function textFromContent(content: unknown): string[] {
75
75
  return text;
76
76
  }
77
77
 
78
- function extractText(value: unknown): string[] {
78
+ /** Extract assistant-text fragments from a parsed Pi JSON event.
79
+ * Exported so {@link ChildPiLineObserver} can capture the RAW (uncapped)
80
+ * assistant text for the authoritative result, mirroring the extraction
81
+ * order this function uses inside {@link parsePiJsonOutput}. */
82
+ export function extractText(value: unknown): string[] {
79
83
  const obj = asRecord(value);
80
84
  if (!obj) return [];
81
85
  const message = asRecord(obj.message);
@@ -16,7 +16,11 @@ export interface ProcessLiveness {
16
16
  */
17
17
  const ORPHANED_ACTIVE_RUN_MS = 2 * 60 * 1000;
18
18
  /** How long a completed run stays visible in the widget after completion. */
19
- const COMPLETED_VISIBILITY_GRACE_MS = 8000;
19
+ const COMPLETED_VISIBILITY_GRACE_MS = 8_000;
20
+ /** Errors (failed/cancelled) linger far longer so a failed run leaves a visible
21
+ * trace in the crew widget for ~10 min (F-5). Successful completions vanish
22
+ * quickly to keep the widget quiet. */
23
+ const ERROR_VISIBILITY_GRACE_MS = 10 * 60 * 1000;
20
24
  /** Maximum age (ms) for an active run before it's considered stale.
21
25
  * After this time, PID-only liveness is unreliable due to PID recycling. */
22
26
  const STALE_ACTIVE_RUN_MS = 30 * 60 * 1000;
@@ -120,12 +124,13 @@ export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord
120
124
  }
121
125
  // Grace period: show completed runs for a few seconds so users see the result.
122
126
  if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") {
127
+ const grace = run.status === "completed" ? COMPLETED_VISIBILITY_GRACE_MS : ERROR_VISIBILITY_GRACE_MS;
123
128
  const lastAgentActivity = agents.reduce<number>((max, agent) => {
124
129
  const ts = agent.completedAt ?? agent.startedAt;
125
130
  const parsed = ts ? new Date(ts).getTime() : 0;
126
131
  return Number.isFinite(parsed) && parsed > max ? parsed : max;
127
132
  }, new Date(run.updatedAt).getTime());
128
- if (Number.isFinite(lastAgentActivity) && now - lastAgentActivity < COMPLETED_VISIBILITY_GRACE_MS) return true;
133
+ if (Number.isFinite(lastAgentActivity) && now - lastAgentActivity < grace) return true;
129
134
  return false;
130
135
  }
131
136
  if (!isActiveRunStatus(run.status)) return false;
@@ -14,6 +14,12 @@ export interface DependencyContextEntry {
14
14
  status: string;
15
15
  resultSummary: string;
16
16
  resultPath?: string;
17
+ /** Absolute path to the FULL (untruncated) result, teed when the inline
18
+ * resultSummary was materially truncated (>TEE_THRESHOLD_MULTIPLIER). The
19
+ * downstream worker can `read` this to recover the dropped middle. Mirrors
20
+ * the sharedReads recovery path so dependency injection is no longer
21
+ * circular (re-reading resultPath used to yield the same truncated text). */
22
+ fullOutputPath?: string;
17
23
  structuredResults?: Record<string, unknown>;
18
24
  artifactsProduced?: string[];
19
25
  usage?: { inputTokens: number; outputTokens: number; durationMs: number };
@@ -50,6 +56,16 @@ function containedExists(filePath: string, baseDir?: string): boolean {
50
56
  */
51
57
  export const MAX_RESULT_INLINE_BYTES = 32_000;
52
58
 
59
+ /**
60
+ * Tee-recovery multiplier (R2). A shared artifact is teed to disk — so the
61
+ * downstream worker can `read` the dropped middle — when its size exceeds
62
+ * this fraction of {@link MAX_RESULT_INLINE_BYTES}. Lowered from 2.0
63
+ * (64 KB) to 1.25 (40 KB) so the 32–64 KB band, where the head+tail split
64
+ * is already materially lossy for structured content, also gets a recovery
65
+ * path instead of losing the middle forever.
66
+ */
67
+ export const TEE_THRESHOLD_MULTIPLIER = 1.25;
68
+
53
69
  /**
54
70
  * Read a file and return its content, truncating to a head+tail slice if it
55
71
  * exceeds {@link MAX_RESULT_INLINE_BYTES} characters. Multi-byte UTF-8
@@ -110,10 +126,11 @@ function writeTeeFile(fullOutputPath: string, content: string): boolean {
110
126
  * content AND (when tee was actually written) the absolute path to the full
111
127
  * file. Returns undefined if the file cannot be read at all.
112
128
  *
113
- * Tee threshold: only when content.length > 2 * MAX_RESULT_INLINE_BYTES
114
- * (the head+tail is materially lossy small over-threshold files are not
115
- * teed because the inline content is mostly intact and the worker can live
116
- * with the 75/25 split). File content is read once and reused for both the
129
+ * Tee threshold: only when content.length > TEE_THRESHOLD_MULTIPLIER ×
130
+ * MAX_RESULT_INLINE_BYTES (R2: 1.25× = 40 KB; previously = 64 KB). Below
131
+ * the tee threshold the head+tail split is mostly intact and the worker can
132
+ * live with it; at/above the threshold the dropped middle is recoverable via
133
+ * the teed full file. File content is read once and reused for both the
117
134
  * pipeline (truncation) and the tee write (full file).
118
135
  *
119
136
  * Truncation behavior is unchanged from the P0-A pipeline: ANSI strip +
@@ -131,8 +148,10 @@ export function readIfSmallWithTee(
131
148
  const content = fs.readFileSync(safePath, "utf-8");
132
149
  if (content.length > maxChars) {
133
150
  let fullOutputPath: string | undefined;
134
- // Tee only when truncation is materially lossy (>2× threshold).
135
- if (opts.tee && content.length > maxChars * 2) {
151
+ // Tee when truncation is materially lossy (>TEE_THRESHOLD_MULTIPLIER ×
152
+ // threshold). R2: lowered from (64 KB) to 1.25× (40 KB) so the
153
+ // 32–64 KB band also gets a recovery path.
154
+ if (opts.tee && content.length > maxChars * TEE_THRESHOLD_MULTIPLIER) {
136
155
  if (writeTeeFile(opts.tee.fullOutputPath, content)) {
137
156
  fullOutputPath = opts.tee.fullOutputPath;
138
157
  }
@@ -267,13 +286,18 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
267
286
  const byStep = new Map(tasks.map((item) => [item.stepId, item]).filter((entry): entry is [string, TeamTaskState] => Boolean(entry[0])));
268
287
  const byId = new Map(tasks.map((item) => [item.id, item]));
269
288
  const dependencies = task.dependsOn.map((dep) => byStep.get(dep) ?? byId.get(dep)).filter((item): item is TeamTaskState => Boolean(item)).map((item) => {
270
- const resultText = item.resultArtifact ? readIfSmall(item.resultArtifact.path, manifest.artifactsRoot) : undefined;
289
+ const fullOutputPath = item.resultArtifact ? teePathForArtifact(manifest.artifactsRoot, task.id, item.id) : undefined;
290
+ const teeResult = item.resultArtifact
291
+ ? readIfSmallWithTee(item.resultArtifact.path, { baseDir: manifest.artifactsRoot, ...(fullOutputPath ? { tee: { fullOutputPath } } : {}) })
292
+ : undefined;
293
+ const resultText = teeResult?.content;
271
294
  return {
272
295
  taskId: item.id,
273
296
  role: item.role,
274
297
  status: item.status,
275
298
  resultSummary: resultText ?? "",
276
299
  resultPath: item.resultArtifact?.path,
300
+ ...(teeResult?.fullOutputPath ? { fullOutputPath: teeResult.fullOutputPath } : {}),
277
301
  structuredResults: resultText ? tryParseJson(resultText) : undefined,
278
302
  artifactsProduced: listTaskArtifacts(manifest, item.id),
279
303
  usage: aggregateUsage(item),
@@ -317,6 +341,11 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
317
341
  parts.push("# Dependency Outputs", "");
318
342
  for (const dep of context.dependencies) {
319
343
  parts.push(`## ${dep.taskId} (${dep.role})`, `Status: ${dep.status}`, dep.resultPath ? `Result artifact: ${dep.resultPath}` : "", "", dep.resultSummary?.trim() || "(no result output)", "");
344
+ // P1-A dependency tee-recovery hint: when the dependency's result was
345
+ // materially truncated (>1.25× MAX_RESULT_INLINE_BYTES) the full RAW
346
+ // content was teed to fullOutputPath. Mirrors the sharedReads hint so the
347
+ // downstream worker can read the dropped middle instead of re-deriving.
348
+ if (dep.fullOutputPath) parts.push(`Full output (if you need the missing middle): ${dep.fullOutputPath}`, "");
320
349
  if (dep.structuredResults) parts.push("Structured results:", JSON.stringify(dep.structuredResults, null, 2), "");
321
350
  if (dep.artifactsProduced?.length) parts.push(`Artifacts produced: ${dep.artifactsProduced.join(", ")}`, "");
322
351
  if (dep.usage) parts.push(`Usage: ${dep.usage.inputTokens} input tokens, ${dep.usage.outputTokens} output tokens, ${dep.usage.durationMs}ms`, "");
@@ -120,7 +120,11 @@ export async function renderTaskPrompt(manifest: TeamRunManifest, step: Workflow
120
120
  // O4: project knowledge (.crew/knowledge.md) — workers don't load the
121
121
  // pi-crew extension (spawned with --no-extensions), so before_agent_start
122
122
  // never fires for them. Inject here so every worker sees project knowledge.
123
- buildKnowledgeFragment(task.cwd),
123
+ buildKnowledgeFragment(task.cwd, {
124
+ goal: manifest.goal,
125
+ taskText: step.task,
126
+ role: step.role,
127
+ }),
124
128
  ].filter(Boolean).join("\n");
125
129
 
126
130
  // Dynamic suffix: goal, step, skills, task packet, dependency context, memory — changes per task
@@ -342,6 +342,7 @@ export async function runTeamTask(
342
342
  let error: string | undefined;
343
343
  let modelAttempts: ModelAttemptSummary[] | undefined;
344
344
  let parsedOutput: ParsedPiJsonOutput | undefined;
345
+ let rawFinalText: string | undefined;
345
346
  let finalStdout = "";
346
347
  let transcriptPath: string | undefined;
347
348
  let terminalEvidence: OperationTerminalEvidence[] = [];
@@ -717,6 +718,7 @@ export async function runTeamTask(
717
718
  childResult.stdout,
718
719
  );
719
720
  parsedOutput = parsePiJsonOutput(transcriptText);
721
+ rawFinalText = childResult.rawFinalText;
720
722
  error =
721
723
  childResult.error ||
722
724
  (childResult.exitCode && childResult.exitCode !== 0
@@ -836,6 +838,11 @@ export async function runTeamTask(
836
838
  kind: "result",
837
839
  relativePath: `results/${task.id}.txt`,
838
840
  content:
841
+ // Prefer the RAW (uncapped) final assistant text captured before the
842
+ // transcript's 16K compaction — this is the authoritative worker output.
843
+ // Fall back to transcript-derived finalText, then stdout/stderr, so a
844
+ // missing raw capture (mock/error path) never yields empty/garbage.
845
+ cleanResultText(rawFinalText) ??
839
846
  cleanResultText(parsedOutput?.finalText) ??
840
847
  cleanResultText(finalStdout) ??
841
848
  cleanResultText(finalStderr) ??
@@ -114,6 +114,12 @@ export const TeamToolParams = Type.Object({
114
114
  goal: Type.Optional(
115
115
  Type.String({ description: "High-level objective for a team run." }),
116
116
  ),
117
+ chain: Type.Optional(
118
+ Type.String({
119
+ description:
120
+ 'Chain expression: "step1 -> step2 -> step3". Runs each step as a sequential team run, passing handoff context forward via the goal text. Supports inline goals ("...") and @team references. e.g. chain=\'"Research X" -> "Analyze" -> "Write report"\'.',
121
+ }),
122
+ ),
117
123
  task: Type.Optional(
118
124
  Type.String({
119
125
  description: "Concrete task text for direct role/agent execution.",
@@ -370,6 +376,9 @@ export interface TeamToolParamsValue {
370
376
  role?: string;
371
377
  agent?: string;
372
378
  goal?: string;
379
+ /** Chain expression: "step1 -> step2 -> step3". Runs each step as a sequential
380
+ * team run with handoff context passed forward via the goal. */
381
+ chain?: string;
373
382
  task?: string;
374
383
  singleAgent?: boolean;
375
384
  runId?: string;
@@ -90,9 +90,9 @@ export class CrewFooter {
90
90
  ].join(" • ");
91
91
  const badges = this.data.badges?.length ? this.data.badges.map((badge) => `[${badge}]`).join(" ") : "";
92
92
  this.cacheLines = [
93
- this.theme.fg("dim", pad(truncate(firstParts.join(" • "), lineWidth, "..."), lineWidth)),
94
- this.theme.fg("dim", pad(truncate(usageLine, lineWidth, "..."), lineWidth)),
95
- this.theme.fg("dim", pad(truncate(badges, lineWidth, "..."), lineWidth)),
93
+ this.theme.fg("dim", pad(truncate(firstParts.join(" • "), lineWidth), lineWidth)),
94
+ this.theme.fg("dim", pad(truncate(usageLine, lineWidth), lineWidth)),
95
+ this.theme.fg("dim", pad(truncate(badges, lineWidth), lineWidth)),
96
96
  ];
97
97
  this.cacheKey = key;
98
98
  this.cacheWidth = width;
@@ -86,7 +86,7 @@ export class CrewSelectList<T = string> {
86
86
  const suffix = item.description ? this.theme.fg("dim", ` — ${item.description}`) : "";
87
87
  const raw = `${prefix}${item.label}${suffix}`;
88
88
  const line = index === this.selectedIndex ? this.theme.inverse?.(raw) ?? raw : raw;
89
- lines.push(pad(truncate(line, width, "..."), Math.max(1, width)));
89
+ lines.push(pad(truncate(line, width), Math.max(1, width)));
90
90
  }
91
91
  if (hasBottom) lines.push(this.theme.fg("muted", `↓ ${this.items.length - (this.scrollOffset + slots)} more`));
92
92
  return lines.slice(0, maxHeight);
@@ -6,6 +6,28 @@ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
6
6
  import { formatCost } from "../../state/usage.ts";
7
7
  import { listLiveAgents, listLiveAgentsByWorkspace, type LiveAgentHandle } from "../../runtime/live-agent-manager.ts";
8
8
  import { computeLiveDurationMs } from "../live-duration.ts";
9
+ import { visibleWidth } from "../../utils/visual.ts";
10
+
11
+ /**
12
+ * Fixed visible widths for the per-agent numeric metrics (finding V-1).
13
+ * Right-aligning each field to a constant width stops the right edge of the
14
+ * stats block from jittering every tick as values change width
15
+ * (e.g. `9.9s`→`10.0s`, `950`→`1.0k`, `$0.9`→`$1.0`).
16
+ */
17
+ const TOKENS_METRIC_WIDTH = 5; // `1.2k`, `12.3k`, `123`
18
+ const COST_METRIC_WIDTH = 7; // `$0.0500`, `$1.50`
19
+ const DURATION_METRIC_WIDTH = 6; // `3.0s`, `59.9s`, `120.0s`
20
+
21
+ /**
22
+ * Width-aware right-align (padStart) for a numeric metric so its column stays
23
+ * stable across ticks. Uses `visibleWidth` so wide/CJK glyphs never misalign
24
+ * the padding. Values wider than `width` are returned verbatim (no truncation)
25
+ * so outliers overflow gracefully instead of crashing the render.
26
+ */
27
+ function alignMetric(value: string, width: number): string {
28
+ const gap = width - visibleWidth(value);
29
+ return gap > 0 ? " ".repeat(gap) + value : value;
30
+ }
9
31
 
10
32
  /**
11
33
  * Returns true if this agent did real work (LLM call, tool use, or non-trivial duration).
@@ -89,12 +111,12 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
89
111
  const tokenTotal = (agent.usage?.input ?? 0) + (agent.usage?.output ?? 0) + (agent.usage?.cacheRead ?? 0) + (agent.usage?.cacheWrite ?? 0);
90
112
  if (tokenTotal > 0) {
91
113
  const tok = tokenTotal >= 1000 ? `${(tokenTotal / 1000).toFixed(1)}k` : `${tokenTotal}`;
92
- stats.push(tok);
114
+ stats.push(alignMetric(tok, TOKENS_METRIC_WIDTH));
93
115
  }
94
116
  // Per-agent cost (Round 17 BS-1): the data is already on task.usage.cost;
95
117
  // surface it live so the user sees $ burn per agent during a run.
96
118
  if (agent.usage?.cost && agent.usage.cost > 0) {
97
- stats.push(formatCost(agent.usage.cost));
119
+ stats.push(alignMetric(formatCost(agent.usage.cost), COST_METRIC_WIDTH));
98
120
  }
99
121
  if (liveHandle) {
100
122
  // Round 23 (BUG 1): the duration math here was naive —
@@ -104,13 +126,13 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
104
126
  // fired for EVERY running live agent in the dashboard. Use the shared,
105
127
  // validated computeLiveDurationMs (mirrors widget-formatters.ts).
106
128
  const ms = computeLiveDurationMs(liveHandle.activity);
107
- stats.push(`${(ms / 1000).toFixed(1)}s`);
129
+ stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
108
130
  if (options.showModel !== false && liveHandle.modelName && liveHandle.modelName !== "default") {
109
131
  stats.push(liveHandle.modelName);
110
132
  }
111
133
  } else if (agent.startedAt) {
112
134
  const ms = Date.now() - new Date(agent.startedAt).getTime();
113
- if (Number.isFinite(ms)) stats.push(`${(ms / 1000).toFixed(1)}s`);
135
+ if (Number.isFinite(ms)) stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
114
136
  }
115
137
 
116
138
  const statsStr = stats.length ? ` · ${stats.join(" ")}` : "";
@@ -40,4 +40,27 @@ export function renderCancellationPane(manifest: TeamRunManifest, tasks: TeamTas
40
40
  }
41
41
 
42
42
  return lines;
43
+ }
44
+
45
+ /**
46
+ * D-1 / L-2 — a one-line terminal-run reason for the dashboard detail row.
47
+ *
48
+ * Surfaces *why* a failed/cancelled/stopped run ended without forcing the
49
+ * user to switch panes. This also gives this module a real consumer (it was
50
+ * previously zero-importer dead code per the UI/UX review), wiring it in as
51
+ * the natural home for cancellation/failure reason display.
52
+ *
53
+ * Resolution order: structured `run.cancelled` event reason → first failed
54
+ * task's error → first policy-decision message.
55
+ */
56
+ export function summarizeTerminalReason(
57
+ manifest: TeamRunManifest,
58
+ tasks: TeamTaskState[],
59
+ cancellationReason?: string,
60
+ ): string | undefined {
61
+ if (cancellationReason) return cancellationReason;
62
+ const failed = tasks.find((task) => task.status === "failed" && task.error);
63
+ if (failed?.error) return failed.error;
64
+ if (manifest.policyDecisions?.length) return manifest.policyDecisions[0]?.message;
65
+ return undefined;
43
66
  }