@dungle-scrubs/tallow 0.8.26 → 0.8.28

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 (114) hide show
  1. package/README.md +42 -1
  2. package/dist/cli.js +7 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +1 -1
  5. package/dist/config.js +1 -1
  6. package/dist/interactive-mode-patch.d.ts +1 -0
  7. package/dist/interactive-mode-patch.d.ts.map +1 -1
  8. package/dist/interactive-mode-patch.js +40 -1
  9. package/dist/interactive-mode-patch.js.map +1 -1
  10. package/dist/model-metadata-overrides.d.ts +2 -5
  11. package/dist/model-metadata-overrides.d.ts.map +1 -1
  12. package/dist/model-metadata-overrides.js +23 -12
  13. package/dist/model-metadata-overrides.js.map +1 -1
  14. package/dist/pid-manager.d.ts +2 -9
  15. package/dist/pid-manager.d.ts.map +1 -1
  16. package/dist/pid-manager.js +1 -58
  17. package/dist/pid-manager.js.map +1 -1
  18. package/dist/pid-schema.d.ts +51 -0
  19. package/dist/pid-schema.d.ts.map +1 -0
  20. package/dist/pid-schema.js +70 -0
  21. package/dist/pid-schema.js.map +1 -0
  22. package/dist/sdk.d.ts.map +1 -1
  23. package/dist/sdk.js +24 -17
  24. package/dist/sdk.js.map +1 -1
  25. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  26. package/dist/workspace-transition-interactive.js +53 -3
  27. package/dist/workspace-transition-interactive.js.map +1 -1
  28. package/dist/workspace-transition.d.ts +2 -1
  29. package/dist/workspace-transition.d.ts.map +1 -1
  30. package/dist/workspace-transition.js +16 -4
  31. package/dist/workspace-transition.js.map +1 -1
  32. package/extensions/__integration__/audit-findings.test.ts +309 -0
  33. package/extensions/__integration__/cd-tool-guidelines.test.ts +46 -0
  34. package/extensions/__integration__/tasks-runtime.test.ts +63 -12
  35. package/extensions/__integration__/welcome-screen.test.ts +240 -0
  36. package/extensions/_shared/lazy-init.ts +88 -3
  37. package/extensions/_shared/pid-registry.ts +8 -82
  38. package/extensions/background-task-tool/index.ts +1 -1
  39. package/extensions/cd-tool/index.ts +4 -1
  40. package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
  41. package/extensions/clear/__tests__/clear.test.ts +38 -0
  42. package/extensions/edit-tool-enhanced/index.ts +3 -1
  43. package/extensions/git-status/__tests__/git-status.test.ts +32 -0
  44. package/extensions/health/__tests__/diagnostics.test.ts +25 -0
  45. package/extensions/health/index.ts +61 -0
  46. package/extensions/loop/__tests__/loop.test.ts +365 -1
  47. package/extensions/loop/index.ts +213 -3
  48. package/extensions/mcp-adapter-tool/index.ts +1 -1
  49. package/extensions/minimal-skill-display/__tests__/minimal-skill-display.test.ts +20 -0
  50. package/extensions/permissions/__tests__/permissions.test.ts +213 -0
  51. package/extensions/progress-indicator/__tests__/progress-indicator.test.ts +104 -0
  52. package/extensions/prompt-suggestions/__tests__/autocomplete.test.ts +111 -3
  53. package/extensions/prompt-suggestions/autocomplete.ts +23 -5
  54. package/extensions/prompt-suggestions/index.ts +62 -3
  55. package/extensions/random-spinner/__tests__/random-spinner.test.ts +35 -0
  56. package/extensions/read-tool-enhanced/index.ts +5 -1
  57. package/extensions/session-memory/index.ts +1 -1
  58. package/extensions/session-namer/index.ts +1 -1
  59. package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
  60. package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +9 -8
  61. package/extensions/subagent-tool/__tests__/process-liveness.test.ts +51 -0
  62. package/extensions/subagent-tool/__tests__/subprocess-args.test.ts +120 -0
  63. package/extensions/subagent-tool/formatting.ts +2 -0
  64. package/extensions/subagent-tool/index.ts +160 -97
  65. package/extensions/subagent-tool/process.ts +152 -40
  66. package/extensions/tasks/commands/register-tasks-extension.ts +64 -20
  67. package/extensions/tasks/extension.json +1 -0
  68. package/extensions/tasks/index.ts +2 -12
  69. package/extensions/tasks/state/index.ts +26 -0
  70. package/extensions/teams-tool/dashboard.ts +13 -1
  71. package/extensions/teams-tool/sessions/spawn.ts +2 -2
  72. package/extensions/teams-tool/tools/register-extension.ts +10 -2
  73. package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -0
  74. package/extensions/welcome-screen/__tests__/welcome-screen.test.ts +35 -0
  75. package/extensions/welcome-screen/extension.json +20 -0
  76. package/extensions/welcome-screen/index.ts +189 -0
  77. package/extensions/wezterm-notify/__tests__/index.test.ts +49 -11
  78. package/extensions/wezterm-notify/index.ts +5 -3
  79. package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +296 -0
  80. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +2 -2
  81. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  82. package/node_modules/@mariozechner/pi-tui/dist/index.js +2 -2
  83. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  84. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +309 -25
  85. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  86. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +392 -72
  87. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  88. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +30 -0
  89. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  90. package/node_modules/@mariozechner/pi-tui/dist/keys.js +50 -6
  91. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  92. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +27 -0
  93. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  94. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +59 -4
  95. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  96. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +9 -0
  97. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  98. package/node_modules/@mariozechner/pi-tui/dist/tui.js +50 -1
  99. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  100. package/node_modules/@mariozechner/pi-tui/package.json +1 -1
  101. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +134 -0
  102. package/node_modules/@mariozechner/pi-tui/src/__tests__/tmux-compat.test.ts +204 -0
  103. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +49 -0
  104. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +2 -0
  105. package/node_modules/@mariozechner/pi-tui/src/index.ts +11 -0
  106. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +478 -140
  107. package/node_modules/@mariozechner/pi-tui/src/keys.ts +84 -6
  108. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +69 -4
  109. package/node_modules/@mariozechner/pi-tui/src/tui.ts +64 -1
  110. package/package.json +11 -10
  111. package/runtime/config.ts +7 -0
  112. package/runtime/model-metadata-overrides.ts +7 -0
  113. package/runtime/pid-schema.ts +13 -0
  114. package/skills/tallow-expert/SKILL.md +7 -5
@@ -449,6 +449,7 @@ export interface ForegroundWatchdogThresholds {
449
449
  readonly killGraceMs: number;
450
450
  readonly startupTimeoutMs: number;
451
451
  readonly toolExecutionTimeoutMs: number;
452
+ readonly wallClockTimeoutMs: number;
452
453
  }
453
454
 
454
455
  /** Heartbeat state tracked by the foreground subagent liveness watchdog. */
@@ -464,7 +465,7 @@ export type WatchdogStatus =
464
465
  | {
465
466
  readonly elapsedMs: number;
466
467
  readonly kind: "stalled";
467
- readonly phase: "inactivity" | "startup" | "tool_execution";
468
+ readonly phase: "inactivity" | "startup" | "tool_execution" | "wall_clock";
468
469
  readonly timeoutMs: number;
469
470
  };
470
471
 
@@ -477,17 +478,33 @@ export const SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV = "TALLOW_SUBAGENT_INACTIVITY_TI
477
478
  /** Env var overriding the foreground timeout while a tool call is still running. */
478
479
  export const SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV = "TALLOW_SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS";
479
480
 
481
+ /** Env var overriding the total wall-clock timeout per foreground subagent. */
482
+ export const SUBAGENT_WALL_CLOCK_TIMEOUT_MS_ENV = "TALLOW_SUBAGENT_WALL_CLOCK_TIMEOUT_MS";
483
+
480
484
  /** Env var overriding the SIGTERM → SIGKILL grace window for stalled workers. */
481
485
  export const SUBAGENT_WATCHDOG_KILL_GRACE_MS_ENV = "TALLOW_SUBAGENT_WATCHDOG_KILL_GRACE_MS";
482
486
 
487
+ /** Env var overriding the total wall-clock timeout for the stalled-worker retry phase. */
488
+ export const SUBAGENT_RETRY_PHASE_TIMEOUT_MS_ENV = "TALLOW_SUBAGENT_RETRY_PHASE_TIMEOUT_MS";
489
+
483
490
  /** Default watchdog thresholds used by foreground subagents in runSingleAgent. */
484
491
  export const FOREGROUND_WATCHDOG_THRESHOLDS: ForegroundWatchdogThresholds = {
485
- inactivityTimeoutMs: 180_000,
492
+ inactivityTimeoutMs: 120_000, // 2 min without any heartbeat event
486
493
  killGraceMs: 5_000,
487
- startupTimeoutMs: 60_000,
488
- toolExecutionTimeoutMs: 600_000,
494
+ startupTimeoutMs: 30_000, // 30s to emit first event
495
+ toolExecutionTimeoutMs: 300_000, // 5 min per tool call (was 10 min)
496
+ wallClockTimeoutMs: 480_000, // 8 min total (was 15 min)
489
497
  };
490
498
 
499
+ /**
500
+ * Default total wall-clock timeout for the entire stalled-worker retry phase.
501
+ *
502
+ * Without a cap, N stalled retries × per-worker watchdog timeout can block
503
+ * the parent for 30+ minutes. This limits the total retry phase so the
504
+ * parent agent can recover sooner.
505
+ */
506
+ export const DEFAULT_RETRY_PHASE_TIMEOUT_MS = 180_000; // 3 minutes
507
+
491
508
  /** How often the foreground watchdog checks for stalled subagents. */
492
509
  const FOREGROUND_WATCHDOG_CHECK_INTERVAL_MS = 500;
493
510
 
@@ -532,9 +549,25 @@ export function resolveForegroundWatchdogThresholds(
532
549
  toolExecutionTimeoutMs:
533
550
  parseTimeoutOverrideMs(env[SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV]) ??
534
551
  FOREGROUND_WATCHDOG_THRESHOLDS.toolExecutionTimeoutMs,
552
+ wallClockTimeoutMs:
553
+ parseTimeoutOverrideMs(env[SUBAGENT_WALL_CLOCK_TIMEOUT_MS_ENV]) ??
554
+ FOREGROUND_WATCHDOG_THRESHOLDS.wallClockTimeoutMs,
535
555
  };
536
556
  }
537
557
 
558
+ /**
559
+ * Resolve the effective retry-phase wall-clock timeout from env overrides.
560
+ *
561
+ * @param env - Environment lookup map
562
+ * @returns Timeout in milliseconds for the entire stalled-worker retry phase
563
+ */
564
+ export function resolveRetryPhaseTimeoutMs(env: EnvLookup = process.env): number {
565
+ return (
566
+ parseTimeoutOverrideMs(env[SUBAGENT_RETRY_PHASE_TIMEOUT_MS_ENV]) ??
567
+ DEFAULT_RETRY_PHASE_TIMEOUT_MS
568
+ );
569
+ }
570
+
538
571
  /**
539
572
  * Return whether an event type counts as watchdog progress.
540
573
  * @param eventType - Raw child-process event type
@@ -619,6 +652,18 @@ export function evaluateWatchdogStatus(
619
652
  nowMs: number,
620
653
  thresholds: ForegroundWatchdogThresholds
621
654
  ): WatchdogStatus {
655
+ // Wall-clock timeout: hard cap on total execution time, regardless of activity.
656
+ // Catches "slow but active" agents that keep making tool calls without finishing.
657
+ const totalElapsedMs = nowMs - state.startedAtMs;
658
+ if (totalElapsedMs >= thresholds.wallClockTimeoutMs) {
659
+ return {
660
+ elapsedMs: totalElapsedMs,
661
+ kind: "stalled",
662
+ phase: "wall_clock",
663
+ timeoutMs: thresholds.wallClockTimeoutMs,
664
+ };
665
+ }
666
+
622
667
  if (state.lastHeartbeatAtMs === null) {
623
668
  const startupElapsedMs = nowMs - state.startedAtMs;
624
669
  if (startupElapsedMs >= thresholds.startupTimeoutMs) {
@@ -655,6 +700,15 @@ export function createStalledSubagentErrorMessage(
655
700
  stalledStatus: Extract<WatchdogStatus, { kind: "stalled" }>
656
701
  ): string {
657
702
  const timeoutSeconds = Math.max(1, Math.round(stalledStatus.timeoutMs / 1000));
703
+ const timeoutMinutes = Math.round(timeoutSeconds / 60);
704
+ if (stalledStatus.phase === "wall_clock") {
705
+ return (
706
+ `Subagent terminated after ${timeoutMinutes}m wall-clock timeout. ` +
707
+ "The agent was still active but exceeded the maximum allowed execution time. " +
708
+ "Action: break the task into smaller pieces, or increase " +
709
+ "TALLOW_SUBAGENT_WALL_CLOCK_TIMEOUT_MS for legitimately long work."
710
+ );
711
+ }
658
712
  const phaseDescription =
659
713
  stalledStatus.phase === "startup"
660
714
  ? "no startup activity was received"
@@ -743,6 +797,54 @@ export function terminateProcessWithGrace(
743
797
  /** Callback for streaming partial results during subagent execution. */
744
798
  export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
745
799
 
800
+ // ── Subprocess Arg Construction ──────────────────────────────────────────────
801
+
802
+ /** Options for building subprocess CLI arguments. */
803
+ export interface SubprocessArgsOptions {
804
+ /** Session file path for persistent teammates (omit for --no-session). */
805
+ session?: string;
806
+ /** Provider-qualified model display name (e.g. "anthropic/claude-sonnet-4-6"). */
807
+ modelDisplayName?: string;
808
+ /** Effective tool allowlist (already filtered by denylist). */
809
+ tools?: string[];
810
+ /** Skill names to pass via --skill flags. */
811
+ skills?: string[];
812
+ /** Path to temp file containing the system prompt. */
813
+ systemPromptPath?: string;
814
+ /** Task text (will be prefixed with "Task: "). */
815
+ task: string;
816
+ }
817
+
818
+ /**
819
+ * Build the CLI argument array for a subagent child process.
820
+ *
821
+ * **Invariant:** `-p <task>` is always the last pair in the array.
822
+ * Commander consumes the next token after `-p` as its required `<prompt>`
823
+ * value. Placing `-p` before other flags (e.g. `--no-session`) causes
824
+ * Commander to swallow the flag as the prompt text, leaving the real
825
+ * task as a stray positional argument.
826
+ *
827
+ * @param opts - Subprocess argument options
828
+ * @returns CLI argument array safe for child_process.spawn
829
+ */
830
+ export function buildSubprocessArgs(opts: SubprocessArgsOptions): string[] {
831
+ const args: string[] = opts.session
832
+ ? ["--mode", "json", "--session", opts.session]
833
+ : ["--mode", "json", "--no-session"];
834
+
835
+ if (opts.modelDisplayName) args.push("--model", opts.modelDisplayName);
836
+ if (opts.tools && opts.tools.length > 0) args.push("--tools", opts.tools.join(","));
837
+ if (opts.skills && opts.skills.length > 0) {
838
+ for (const skill of opts.skills) args.push("--skill", skill);
839
+ }
840
+ if (opts.systemPromptPath) args.push("--append-system-prompt", opts.systemPromptPath);
841
+
842
+ // CRITICAL: -p must be last — see JSDoc above.
843
+ args.push("-p", `Task: ${opts.task}`);
844
+
845
+ return args;
846
+ }
847
+
746
848
  // ── Background Spawning ──────────────────────────────────────────────────────
747
849
 
748
850
  /**
@@ -813,18 +915,6 @@ export async function spawnBackgroundSubagent(
813
915
  const agent = { ...resolved.agent, model: routing.model.id };
814
916
  const agentSource = resolved.resolution === "ephemeral" ? ("ephemeral" as const) : agent.source;
815
917
 
816
- const args: string[] = session
817
- ? ["--mode", "json", "-p", "--session", session]
818
- : ["--mode", "json", "-p", "--no-session"];
819
- // Use provider-qualified name (e.g. "openai-codex/gpt-5.1") so the child process
820
- // resolves to the exact provider the router selected, not just the first match.
821
- if (agent.model) args.push("--model", routing.model.displayName);
822
- const effectiveTools = computeEffectiveTools(agent.tools, agent.disallowedTools);
823
- if (effectiveTools && effectiveTools.length > 0) args.push("--tools", effectiveTools.join(","));
824
- if (agent.skills && agent.skills.length > 0) {
825
- for (const skill of agent.skills) args.push("--skill", skill);
826
- }
827
-
828
918
  let tmpPromptDir: string | undefined;
829
919
  let tmpPromptPath: string | undefined;
830
920
 
@@ -839,7 +929,6 @@ export async function spawnBackgroundSubagent(
839
929
  const tmp = writePromptToTempFile(agent.name, systemPrompt);
840
930
  tmpPromptDir = tmp.dir;
841
931
  tmpPromptPath = tmp.filePath;
842
- args.push("--append-system-prompt", tmpPromptPath);
843
932
  }
844
933
 
845
934
  let expandedTask: string;
@@ -850,7 +939,16 @@ export async function spawnBackgroundSubagent(
850
939
  const reason = error instanceof Error ? error.message : String(error);
851
940
  return `Failed to expand task references for ${agentName}: ${reason}`;
852
941
  }
853
- args.push(`Task: ${expandedTask}`);
942
+
943
+ const effectiveTools = computeEffectiveTools(agent.tools, agent.disallowedTools);
944
+ const args = buildSubprocessArgs({
945
+ session,
946
+ modelDisplayName: agent.model ? routing.model.displayName : undefined,
947
+ tools: effectiveTools && effectiveTools.length > 0 ? effectiveTools : undefined,
948
+ skills: agent.skills && agent.skills.length > 0 ? agent.skills : undefined,
949
+ systemPromptPath: tmpPromptPath,
950
+ task: expandedTask,
951
+ });
854
952
 
855
953
  const childEnv: Record<string, string> = { ...process.env, PI_IS_SUBAGENT: "1" } as Record<
856
954
  string,
@@ -951,8 +1049,13 @@ export async function spawnBackgroundSubagent(
951
1049
  buffer = lines.pop() || "";
952
1050
  for (const line of lines) {
953
1051
  if (!line.trim()) continue;
1052
+ // Strip leading terminal escape sequences — see foreground processLine.
1053
+ let cleaned = line;
1054
+ const jsonStart = cleaned.indexOf("{");
1055
+ if (jsonStart > 0) cleaned = cleaned.slice(jsonStart);
1056
+ else if (jsonStart < 0) continue;
954
1057
  try {
955
- const event = JSON.parse(line);
1058
+ const event = JSON.parse(cleaned);
956
1059
 
957
1060
  // Emit subagent_tool_call when tool starts
958
1061
  if (event.type === "tool_call_start") {
@@ -1022,13 +1125,18 @@ export async function spawnBackgroundSubagent(
1022
1125
 
1023
1126
  proc.on("close", (code) => {
1024
1127
  if (buffer.trim()) {
1025
- try {
1026
- const event = JSON.parse(buffer);
1027
- if (event.type === "message_end" && event.message) {
1028
- result.messages.push(event.message);
1128
+ let cleaned = buffer;
1129
+ const jsonStart = cleaned.indexOf("{");
1130
+ if (jsonStart > 0) cleaned = cleaned.slice(jsonStart);
1131
+ if (jsonStart >= 0) {
1132
+ try {
1133
+ const event = JSON.parse(cleaned);
1134
+ if (event.type === "message_end" && event.message) {
1135
+ result.messages.push(event.message);
1136
+ }
1137
+ } catch {
1138
+ /* ignore */
1029
1139
  }
1030
- } catch {
1031
- /* ignore */
1032
1140
  }
1033
1141
  }
1034
1142
  const finalOutput = getFinalOutput(result.messages);
@@ -1236,18 +1344,6 @@ export async function runSingleAgent(
1236
1344
  background: false,
1237
1345
  } satisfies SubagentStartEvent);
1238
1346
 
1239
- const args: string[] = session
1240
- ? ["--mode", "json", "-p", "--session", session]
1241
- : ["--mode", "json", "-p", "--no-session"];
1242
- // Use provider-qualified name so the child process resolves to the exact provider.
1243
- if (agent.model) args.push("--model", routing.model.displayName);
1244
- const fgEffectiveTools = computeEffectiveTools(agent.tools, agent.disallowedTools);
1245
- if (fgEffectiveTools && fgEffectiveTools.length > 0)
1246
- args.push("--tools", fgEffectiveTools.join(","));
1247
- if (agent.skills && agent.skills.length > 0) {
1248
- for (const skill of agent.skills) args.push("--skill", skill);
1249
- }
1250
-
1251
1347
  let tmpPromptDir: string | null = null;
1252
1348
  let tmpPromptPath: string | null = null;
1253
1349
 
@@ -1290,6 +1386,7 @@ export async function runSingleAgent(
1290
1386
  },
1291
1387
  model: agent.model,
1292
1388
  step,
1389
+ startTime: Date.now(),
1293
1390
  };
1294
1391
 
1295
1392
  /** Timestamp of the last emitted update, used for throttling. */
@@ -1327,11 +1424,18 @@ export async function runSingleAgent(
1327
1424
  const tmp = writePromptToTempFile(agent.name, fgSystemPrompt);
1328
1425
  tmpPromptDir = tmp.dir;
1329
1426
  tmpPromptPath = tmp.filePath;
1330
- args.push("--append-system-prompt", tmpPromptPath);
1331
1427
  }
1332
1428
 
1333
1429
  const expandedTask = await expandFileReferences(task, effectiveCwd);
1334
- args.push(`Task: ${expandedTask}`);
1430
+ const fgEffectiveTools = computeEffectiveTools(agent.tools, agent.disallowedTools);
1431
+ const args = buildSubprocessArgs({
1432
+ session,
1433
+ modelDisplayName: agent.model ? routing.model.displayName : undefined,
1434
+ tools: fgEffectiveTools && fgEffectiveTools.length > 0 ? fgEffectiveTools : undefined,
1435
+ skills: agent.skills && agent.skills.length > 0 ? agent.skills : undefined,
1436
+ systemPromptPath: tmpPromptPath ?? undefined,
1437
+ task: expandedTask,
1438
+ });
1335
1439
  let wasAborted = false;
1336
1440
 
1337
1441
  const fgChildEnv: Record<string, string> = {
@@ -1414,10 +1518,18 @@ export async function runSingleAgent(
1414
1518
 
1415
1519
  const processLine = (line: string) => {
1416
1520
  if (!line.trim()) return;
1521
+ // Strip leading terminal escape sequences (e.g. OSC 1337 SetUserVar)
1522
+ // that may leak into stdout when extensions write directly to
1523
+ // process.stdout in JSON-mode child processes. Without this,
1524
+ // JSON.parse silently fails and heartbeat events are lost.
1525
+ let cleaned = line;
1526
+ const jsonStart = cleaned.indexOf("{");
1527
+ if (jsonStart > 0) cleaned = cleaned.slice(jsonStart);
1528
+ else if (jsonStart < 0) return;
1417
1529
  // biome-ignore lint/suspicious/noExplicitAny: pi subagent JSON protocol has dynamic shape
1418
1530
  let event: Record<string, any>;
1419
1531
  try {
1420
- event = JSON.parse(line);
1532
+ event = JSON.parse(cleaned);
1421
1533
  } catch {
1422
1534
  return;
1423
1535
  }
@@ -32,6 +32,7 @@ import {
32
32
  import { findCompletedTasks } from "../parsing/index.js";
33
33
  import {
34
34
  type BgTaskView,
35
+ buildSessionTaskGroupName,
35
36
  cleanupStaleTeams,
36
37
  getTextContent,
37
38
  isAssistantMessage,
@@ -41,7 +42,7 @@ import {
41
42
  shouldClearOnAgentEnd,
42
43
  type Task,
43
44
  type TaskComment,
44
- type TaskListStore,
45
+ TaskListStore,
45
46
  type TaskStatus,
46
47
  type TasksState,
47
48
  type TeamWidgetView,
@@ -75,15 +76,17 @@ const agentIdentities = new Map<string, AgentIdentity>();
75
76
  * Registers task management tools, commands, and widget.
76
77
  *
77
78
  * @param pi - Extension API for registering tools, commands, and event handlers
78
- * @param store - Pre-constructed {@link TaskListStore} for file persistence
79
- * @param teamName - Active team name (or null for session-only mode)
79
+ * @param initialStore - Initial {@link TaskListStore} for file persistence
80
+ * @param initialTeamName - Active team name (or null for session-only mode)
80
81
  */
81
82
  export function registerTasksExtension(
82
83
  pi: ExtensionAPI,
83
- store: TaskListStore,
84
- teamName: string | null
84
+ initialStore: TaskListStore,
85
+ initialTeamName: string | null
85
86
  ): void {
86
87
  const isSubagent = process.env.PI_IS_SUBAGENT === "1";
88
+ let store = initialStore;
89
+ let teamName = initialTeamName;
87
90
  const state: TasksState = {
88
91
  tasks: [],
89
92
  visible: true,
@@ -1023,9 +1026,7 @@ export function registerTasksExtension(
1023
1026
  return `${t.id}. ${icon} ${t.subject}${blocked}${comments}`;
1024
1027
  })
1025
1028
  .join("\n");
1026
- const mode = store.isShared
1027
- ? ` [team: ${process.env.PI_TEAM_NAME}]`
1028
- : " [session-only]";
1029
+ const mode = store.isShared ? ` [team: ${teamName}]` : " [session-only]";
1029
1030
  ctx.ui.notify(`Tasks${mode}:\n${list}`, "info");
1030
1031
  break;
1031
1032
  }
@@ -1091,7 +1092,7 @@ export function registerTasksExtension(
1091
1092
  }
1092
1093
 
1093
1094
  case "team": {
1094
- const current = store.isShared ? process.env.PI_TEAM_NAME : "(none — session-only)";
1095
+ const current = store.isShared ? teamName : "(none — session-only)";
1095
1096
  const teamPath = store.path ?? "N/A";
1096
1097
  ctx.ui.notify(`Shared task group: ${current}\nPath: ${teamPath}`, "info");
1097
1098
  break;
@@ -1916,8 +1917,37 @@ Before calling manage_tasks complete/update, call manage_tasks list first so ind
1916
1917
  let lastBgCount = 0;
1917
1918
  let lastBgTaskCount = 0;
1918
1919
 
1919
- // Restore state on session start
1920
- pi.on("session_start", async (_event, ctx) => {
1920
+ /** Resolve the shared task-group name for the current session context.
1921
+ * @param ctx - Active extension context
1922
+ * @returns Shared task-group name, or null for session-only mode
1923
+ */
1924
+ function resolveTaskGroupName(ctx: ExtensionContext): string | null {
1925
+ if (isSubagent) return process.env.PI_TEAM_NAME ?? teamName;
1926
+ const sessionId = ctx.sessionManager.getSessionId?.();
1927
+ if (!sessionId) return null;
1928
+ return buildSessionTaskGroupName(sessionId, ctx.cwd);
1929
+ }
1930
+ /** Rebind the file-backed task store for the current session.
1931
+ * @param ctx - Active extension context
1932
+ * @returns Nothing
1933
+ */
1934
+ function syncTaskGroupStore(ctx: ExtensionContext): void {
1935
+ const nextTeamName = resolveTaskGroupName(ctx);
1936
+ store.close();
1937
+ if (nextTeamName !== teamName) {
1938
+ store = new TaskListStore(nextTeamName);
1939
+ teamName = nextTeamName;
1940
+ }
1941
+ if (!isSubagent) {
1942
+ if (teamName) process.env.PI_TEAM_NAME = teamName;
1943
+ else delete process.env.PI_TEAM_NAME;
1944
+ }
1945
+ }
1946
+
1947
+ /** Reset runtime widget/session state before loading a different session.
1948
+ * @returns Nothing
1949
+ */
1950
+ function resetSessionState(): void {
1921
1951
  foregroundSubagents = [];
1922
1952
  backgroundSubagents = [];
1923
1953
  backgroundTasks = [];
@@ -1926,7 +1956,21 @@ Before calling manage_tasks complete/update, call manage_tasks list first so ind
1926
1956
  lastBackgroundTaskPresenterState = null;
1927
1957
  lastBgCount = 0;
1928
1958
  lastBgTaskCount = 0;
1959
+ agentActivity.clear();
1960
+ agentIdentities.clear();
1961
+ state.tasks = [];
1962
+ state.visible = true;
1963
+ state.nextId = 1;
1964
+ state.activeTaskId = null;
1965
+ }
1929
1966
 
1967
+ /** Restore tasks/widget state for the current session or switched session.
1968
+ * @param ctx - Active extension context
1969
+ * @returns Nothing
1970
+ */
1971
+ function restoreSessionState(ctx: ExtensionContext): void {
1972
+ resetSessionState();
1973
+ syncTaskGroupStore(ctx);
1930
1974
  // Restore meta state (visibility, nextId) from session entries
1931
1975
  const entries = ctx.sessionManager.getEntries();
1932
1976
  const stateEntry = entries
@@ -1947,14 +1991,11 @@ Before calling manage_tasks complete/update, call manage_tasks list first so ind
1947
1991
  // Load tasks: prefer file store (shared mode), fall back to session entries
1948
1992
  if (store.isShared) {
1949
1993
  loadFromStore();
1950
-
1951
- // Start watching for cross-session changes
1952
1994
  store.watch(() => {
1953
1995
  loadFromStore();
1954
1996
  updateWidget(ctx);
1955
1997
  });
1956
1998
  } else if (stateEntry?.data?.tasks) {
1957
- // Session-only mode: restore from entries, migrating old schema
1958
1999
  state.tasks = stateEntry.data.tasks.map((t) => ({
1959
2000
  id: (t.id as string) ?? String(state.nextId++),
1960
2001
  subject: (t.subject as string) ?? (t.title as string) ?? "Untitled",
@@ -1969,15 +2010,10 @@ Before calling manage_tasks complete/update, call manage_tasks list first so ind
1969
2010
  createdAt: (t.createdAt as number) ?? Date.now(),
1970
2011
  completedAt: t.completedAt as number | undefined,
1971
2012
  }));
1972
- // Recalculate nextId
1973
2013
  const maxId = state.tasks.reduce((max, t) => Math.max(max, Number(t.id) || 0), 0);
1974
2014
  state.nextId = Math.max(state.nextId, maxId + 1);
1975
2015
  }
1976
2016
 
1977
- // Clear orphaned tasks on startup: at session_start no agents are running,
1978
- // so any in_progress tasks are leftovers from a dead session.
1979
- // Also clear if all tasks are already completed — the 2s auto-clear timer
1980
- // from a previous turn may have been killed by an extension reload.
1981
2017
  if (state.tasks.length > 0) {
1982
2018
  const orphaned = state.tasks.filter((t) => t.status === "in_progress");
1983
2019
  const allCompleted = state.tasks.every((t) => t.status === "completed");
@@ -1986,7 +2022,6 @@ Before calling manage_tasks complete/update, call manage_tasks list first so ind
1986
2022
  }
1987
2023
  }
1988
2024
 
1989
- // Clean up team directories older than 7 days
1990
2025
  cleanupStaleTeams(teamName);
1991
2026
 
1992
2027
  if (!isSubagent) {
@@ -2131,6 +2166,14 @@ Before calling manage_tasks complete/update, call manage_tasks list first so ind
2131
2166
 
2132
2167
  updateWidget(ctx);
2133
2168
  updateAgentBar(ctx);
2169
+ }
2170
+
2171
+ pi.on("session_start", async (_event, ctx) => {
2172
+ restoreSessionState(ctx);
2173
+ });
2174
+
2175
+ pi.on("session_switch", async (_event, ctx) => {
2176
+ restoreSessionState(ctx);
2134
2177
  });
2135
2178
 
2136
2179
  // Cleanup on session end
@@ -2150,6 +2193,7 @@ Before calling manage_tasks complete/update, call manage_tasks list first so ind
2150
2193
  legacyInteropBridgeCleanup?.();
2151
2194
  legacyInteropBridgeCleanup = undefined;
2152
2195
  store.close();
2196
+ if (!isSubagent) delete process.env.PI_TEAM_NAME;
2153
2197
  persistState();
2154
2198
  });
2155
2199
  }
@@ -11,6 +11,7 @@
11
11
  "before_agent_start",
12
12
  "session_shutdown",
13
13
  "session_start",
14
+ "session_switch",
14
15
  "subagent_start",
15
16
  "subagent_stop",
16
17
  "subagent_tool_call",
@@ -19,7 +19,6 @@
19
19
  * {@link registerTasksExtension}. Domain logic lives in sibling modules.
20
20
  */
21
21
 
22
- import { randomUUID } from "node:crypto";
23
22
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
24
23
  import { registerTasksExtension } from "./commands/register-tasks-extension.js";
25
24
  import { TaskListStore } from "./state/index.js";
@@ -31,7 +30,7 @@ export type { AgentIdentity } from "./agents/index.js";
31
30
  export { classifyAgent } from "./agents/index.js";
32
31
  export { _extractTasksFromText, escapeRegex, findCompletedTasks } from "./parsing/index.js";
33
32
  export type { Task, TaskComment, TaskStatus, TasksState } from "./state/index.js";
34
- export { shouldClearOnAgentEnd } from "./state/index.js";
33
+ export { buildSessionTaskGroupName, shouldClearOnAgentEnd } from "./state/index.js";
35
34
 
36
35
  // ── Entry point ──────────────────────────────────────────────────────────────
37
36
 
@@ -42,16 +41,7 @@ export { shouldClearOnAgentEnd } from "./state/index.js";
42
41
  */
43
42
  export default function tasksExtension(pi: ExtensionAPI): void {
44
43
  const isSubagent = process.env.PI_IS_SUBAGENT === "1";
45
-
46
- // Auto-generate a shared task-group name so subagents can coordinate via a
47
- // file-backed store. PI_TEAM_NAME stays as the env var for backward compatibility.
48
- const teamName =
49
- process.env.PI_TEAM_NAME ?? (isSubagent ? null : `task-group-${randomUUID().slice(0, 8)}`);
50
- if (teamName && !process.env.PI_TEAM_NAME) {
51
- // Set on process.env so child subagents inherit it automatically
52
- process.env.PI_TEAM_NAME = teamName;
53
- }
54
-
44
+ const teamName = isSubagent ? (process.env.PI_TEAM_NAME ?? null) : null;
55
45
  const store = new TaskListStore(teamName);
56
46
  registerTasksExtension(pi, store, teamName);
57
47
  }
@@ -6,6 +6,7 @@
6
6
  * locking and `fs.watch`, and pure predicates that operate on task arrays.
7
7
  */
8
8
 
9
+ import { createHash } from "node:crypto";
9
10
  import type { FSWatcher } from "node:fs";
10
11
  import {
11
12
  existsSync,
@@ -39,6 +40,31 @@ export const TEAM_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
39
40
  /** Minimum width for side-by-side layout (tasks left, subagents right). */
40
41
  export const MIN_SIDE_BY_SIDE_WIDTH = 120;
41
42
 
43
+ /** Prefix used for session-scoped shared task-group directories. */
44
+ const SESSION_TASK_GROUP_PREFIX = "task-group";
45
+
46
+ /**
47
+ * Build a stable task-group name for a session/cwd pair.
48
+ *
49
+ * Main sessions use this to isolate task boards per session while keeping the
50
+ * same group name when the user reopens the same session later. Subagents
51
+ * inherit the resolved name via `PI_TEAM_NAME`.
52
+ *
53
+ * @param sessionId - Current session identifier
54
+ * @param cwd - Current working directory for the session
55
+ * @returns Stable, filesystem-safe task-group name
56
+ */
57
+ export function buildSessionTaskGroupName(sessionId: string, cwd: string): string {
58
+ const safeSessionId =
59
+ sessionId
60
+ .replace(/[^a-zA-Z0-9._-]/g, "_")
61
+ .replace(/_+/g, "_")
62
+ .replace(/^_+|_+$/g, "")
63
+ .slice(0, 24) || "session";
64
+ const digest = createHash("sha256").update(`${sessionId}:${cwd}`).digest("hex").slice(0, 12);
65
+ return `${SESSION_TASK_GROUP_PREFIX}-${safeSessionId}-${digest}`;
66
+ }
67
+
42
68
  // ── Task Types ───────────────────────────────────────────────────────────────
43
69
 
44
70
  /** Lifecycle state of a task. */
@@ -692,7 +692,19 @@ export class TeamDashboardEditor extends CustomEditor {
692
692
  );
693
693
 
694
694
  const footer = this.renderFooter(width);
695
- return [...merged, footer];
695
+ const contentLines = [...merged, footer];
696
+
697
+ // Pad to fill the terminal height. On the alternate screen the TUI
698
+ // renders all children (header, chat history, widgets, editor, footer)
699
+ // and the visible viewport is the last `rows` lines. Without padding,
700
+ // stale conversation content bleeds through at the top of the dashboard.
701
+ // Prepending blank lines pushes that content off-screen.
702
+ const targetLines = this.tui.terminal.rows;
703
+ while (contentLines.length < targetLines) {
704
+ contentLines.unshift("");
705
+ }
706
+
707
+ return contentLines;
696
708
  }
697
709
 
698
710
  /**
@@ -15,7 +15,7 @@ import {
15
15
  SessionManager,
16
16
  SettingsManager,
17
17
  } from "@mariozechner/pi-coding-agent";
18
- import { applyKnownModelMetadataOverrides } from "../../../src/model-metadata-overrides.js";
18
+ import { applyKnownModelMetadataOverrides } from "../../../runtime/model-metadata-overrides.js";
19
19
  import { getTallowHomeDir, getTallowPath } from "../../_shared/tallow-paths.js";
20
20
  import {
21
21
  type AgentConfig,
@@ -255,7 +255,7 @@ export async function spawnTeammateSession(
255
255
  // Use the user's tallow auth and model config so teammates inherit
256
256
  // API keys and custom model definitions from the main session.
257
257
  const authStorage = AuthStorage.create(getTallowPath("auth.json"));
258
- const modelRegistry = new ModelRegistry(authStorage, getTallowPath("models.json"));
258
+ const modelRegistry = ModelRegistry.create(authStorage, getTallowPath("models.json"));
259
259
  applyKnownModelMetadataOverrides(modelRegistry);
260
260
  const model = modelRegistry.find(resolvedModel.provider, resolvedModel.id);
261
261
  if (!model) {
@@ -232,8 +232,11 @@ export function registerTeamsToolExtension(pi: ExtensionAPI): void {
232
232
  */
233
233
  function handleDashboardEscape(ctx: ExtensionContext): void {
234
234
  if (!dashboardEnabled) return;
235
+ const hasActiveTeams = (getTeams() as Map<string, unknown>).size > 0;
235
236
  disableDashboard(ctx, false);
236
- ctx.ui.notify("Team dashboard disabled. Teammates keep running.", "info");
237
+ if (hasActiveTeams) {
238
+ ctx.ui.notify("Team dashboard disabled. Teammates keep running.", "info");
239
+ }
237
240
  }
238
241
 
239
242
  /**
@@ -283,7 +286,12 @@ export function registerTeamsToolExtension(pi: ExtensionAPI): void {
283
286
  ctx.ui.setEditorComponent(undefined);
284
287
  ctx.ui.setWorkingMessage();
285
288
  ctx.ui.setStatus("team-dashboard", undefined);
286
- if (notify) ctx.ui.notify("Team dashboard disabled.", "info");
289
+ // Only notify when there are active teams — the notification is
290
+ // confusing when it appears during unrelated flows (e.g. subagent
291
+ // parallel) because the dashboard auto-disabled as a side effect.
292
+ if (notify && (getTeams() as Map<string, unknown>).size > 0) {
293
+ ctx.ui.notify("Team dashboard disabled.", "info");
294
+ }
287
295
  }
288
296
 
289
297
  /**