@dungle-scrubs/tallow 0.8.26 → 0.8.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/config.d.ts +1 -1
  2. package/dist/config.js +1 -1
  3. package/dist/interactive-mode-patch.d.ts +1 -0
  4. package/dist/interactive-mode-patch.d.ts.map +1 -1
  5. package/dist/interactive-mode-patch.js +40 -1
  6. package/dist/interactive-mode-patch.js.map +1 -1
  7. package/dist/pid-manager.d.ts +2 -9
  8. package/dist/pid-manager.d.ts.map +1 -1
  9. package/dist/pid-manager.js +1 -58
  10. package/dist/pid-manager.js.map +1 -1
  11. package/dist/pid-schema.d.ts +51 -0
  12. package/dist/pid-schema.d.ts.map +1 -0
  13. package/dist/pid-schema.js +70 -0
  14. package/dist/pid-schema.js.map +1 -0
  15. package/dist/sdk.js +4 -8
  16. package/dist/sdk.js.map +1 -1
  17. package/extensions/__integration__/audit-findings.test.ts +309 -0
  18. package/extensions/__integration__/tasks-runtime.test.ts +63 -12
  19. package/extensions/_shared/lazy-init.ts +88 -3
  20. package/extensions/_shared/pid-registry.ts +8 -82
  21. package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
  22. package/extensions/clear/__tests__/clear.test.ts +38 -0
  23. package/extensions/git-status/__tests__/git-status.test.ts +32 -0
  24. package/extensions/mcp-adapter-tool/index.ts +1 -1
  25. package/extensions/minimal-skill-display/__tests__/minimal-skill-display.test.ts +20 -0
  26. package/extensions/permissions/__tests__/permissions.test.ts +213 -0
  27. package/extensions/progress-indicator/__tests__/progress-indicator.test.ts +104 -0
  28. package/extensions/random-spinner/__tests__/random-spinner.test.ts +35 -0
  29. package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
  30. package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +5 -4
  31. package/extensions/subagent-tool/__tests__/process-liveness.test.ts +51 -0
  32. package/extensions/subagent-tool/__tests__/subprocess-args.test.ts +120 -0
  33. package/extensions/subagent-tool/formatting.ts +2 -0
  34. package/extensions/subagent-tool/index.ts +156 -95
  35. package/extensions/subagent-tool/process.ts +126 -32
  36. package/extensions/tasks/commands/register-tasks-extension.ts +64 -20
  37. package/extensions/tasks/extension.json +1 -0
  38. package/extensions/tasks/index.ts +2 -12
  39. package/extensions/tasks/state/index.ts +26 -0
  40. package/extensions/teams-tool/dashboard.ts +13 -1
  41. package/extensions/teams-tool/tools/register-extension.ts +10 -2
  42. package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -0
  43. package/extensions/wezterm-notify/__tests__/index.test.ts +49 -11
  44. package/extensions/wezterm-notify/index.ts +5 -3
  45. package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +296 -0
  46. package/package.json +3 -2
  47. package/runtime/pid-schema.ts +13 -0
  48. package/skills/tallow-expert/SKILL.md +1 -1
@@ -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
  /**
@@ -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
  /**
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import registerUpstream from "../index.js";
7
+
8
+ describe("upstream-check extension", () => {
9
+ test("does not register command when packages/tallow-tui is absent", () => {
10
+ const tmpDir = mkdtempSync(join(tmpdir(), "upstream-test-"));
11
+ const originalCwd = process.cwd();
12
+ try {
13
+ process.chdir(tmpDir);
14
+ const commands: string[] = [];
15
+ const pi = {
16
+ registerCommand: (name: string) => {
17
+ commands.push(name);
18
+ },
19
+ } as unknown as ExtensionAPI;
20
+
21
+ registerUpstream(pi);
22
+ expect(commands).not.toContain("upstream");
23
+ } finally {
24
+ process.chdir(originalCwd);
25
+ rmSync(tmpDir, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ test("registers upstream command when packages/tallow-tui exists", () => {
30
+ const tmpDir = mkdtempSync(join(tmpdir(), "upstream-test-"));
31
+ mkdirSync(join(tmpDir, "packages", "tallow-tui"), { recursive: true });
32
+ const originalCwd = process.cwd();
33
+ try {
34
+ process.chdir(tmpDir);
35
+ const commands: string[] = [];
36
+ const pi = {
37
+ registerCommand: (name: string) => {
38
+ commands.push(name);
39
+ },
40
+ } as unknown as ExtensionAPI;
41
+
42
+ registerUpstream(pi);
43
+ expect(commands).toContain("upstream");
44
+ } finally {
45
+ process.chdir(originalCwd);
46
+ rmSync(tmpDir, { recursive: true, force: true });
47
+ }
48
+ });
49
+ });
@@ -157,13 +157,44 @@ describe("wezterm-notify lifecycle", () => {
157
157
  const inputResult = rig.lifecycle.onInput();
158
158
 
159
159
  expect(inputResult).toEqual({ action: "continue" });
160
- expect(getStatusWrites(rig)).toEqual(["", "working", "done", ""]);
160
+ // agent_end is a no-op; input transitions working done
161
+ expect(getStatusWrites(rig)).toEqual(["", "working", "done"]);
161
162
  expect(getHeartbeatWrites(rig)).toEqual(["0"]);
162
163
  expect(rig.heartbeatStartCount).toBe(1);
163
164
  expect(rig.heartbeatStopCount).toBe(1);
164
165
  expect(rig.activeHeartbeatCount).toBe(0);
165
166
  });
166
167
 
168
+ it("stays working through tool execution between agent_end and next agent_start", () => {
169
+ const rig = createLifecycleRig();
170
+
171
+ rig.lifecycle.onSessionStart();
172
+ rig.lifecycle.onBeforeAgentStart();
173
+ rig.lifecycle.onAgentStart();
174
+ // Model returns a tool call — agent_end fires but tool is still executing
175
+ rig.lifecycle.onAgentEnd();
176
+ rig.tickHeartbeat();
177
+ rig.tickHeartbeat();
178
+
179
+ // Status should still be "working" — heartbeat should still be active
180
+ expect(getStatusWrites(rig)).toEqual(["", "working"]);
181
+ expect(rig.activeHeartbeatCount).toBe(1);
182
+
183
+ // Tool finishes, next turn starts
184
+ rig.lifecycle.onBeforeAgentStart();
185
+ rig.lifecycle.onAgentStart();
186
+ rig.lifecycle.onAgentEnd();
187
+
188
+ // Still working — no flicker to "done" between turns
189
+ expect(getStatusWrites(rig)).toEqual(["", "working"]);
190
+ expect(rig.activeHeartbeatCount).toBe(1);
191
+
192
+ // Final input prompt appears
193
+ rig.lifecycle.onInput();
194
+ expect(getStatusWrites(rig)).toEqual(["", "working", "done"]);
195
+ expect(rig.activeHeartbeatCount).toBe(0);
196
+ });
197
+
167
198
  it("coalesces duplicate starts without clear flicker", () => {
168
199
  const rig = createLifecycleRig();
169
200
 
@@ -173,26 +204,30 @@ describe("wezterm-notify lifecycle", () => {
173
204
  rig.lifecycle.onAgentStart();
174
205
  rig.tickHeartbeat();
175
206
  rig.tickHeartbeat();
207
+ // agent_end is a no-op — heartbeat keeps running
176
208
  rig.lifecycle.onAgentEnd();
177
209
 
178
- expect(getStatusWrites(rig)).toEqual(["", "working", "done"]);
210
+ expect(getStatusWrites(rig)).toEqual(["", "working"]);
179
211
  expect(getHeartbeatWrites(rig)).toEqual(["0", "1", "2"]);
180
212
  expect(rig.heartbeatStartCount).toBe(1);
181
- expect(rig.heartbeatStopCount).toBe(1);
182
- expect(rig.activeHeartbeatCount).toBe(0);
213
+ // Heartbeat still active (only input/shutdown stops it)
214
+ expect(rig.heartbeatStopCount).toBe(0);
215
+ expect(rig.activeHeartbeatCount).toBe(1);
183
216
  });
184
217
 
185
218
  it("does not permanently clear when input arrives before start", () => {
186
219
  const rig = createLifecycleRig();
187
220
 
188
221
  rig.lifecycle.onSessionStart();
222
+ // Input arrives while not working — no-op (already idle)
189
223
  const inputResult = rig.lifecycle.onInput();
190
224
  rig.lifecycle.onBeforeAgentStart();
191
225
  rig.lifecycle.onAgentStart();
192
226
  rig.lifecycle.onAgentEnd();
193
227
 
194
228
  expect(inputResult).toEqual({ action: "continue" });
195
- expect(getStatusWrites(rig)).toEqual(["", "working", "done"]);
229
+ // agent_end is a no-op, so status stays "working"
230
+ expect(getStatusWrites(rig)).toEqual(["", "working"]);
196
231
  });
197
232
 
198
233
  it("starts heartbeat once per interval and leaves no orphan timers", () => {
@@ -205,18 +240,21 @@ describe("wezterm-notify lifecycle", () => {
205
240
  expect(rig.heartbeatStartCount).toBe(1);
206
241
  expect(rig.activeHeartbeatCount).toBe(1);
207
242
 
243
+ // agent_end no longer stops heartbeat — stays active through tool execution
208
244
  rig.lifecycle.onAgentEnd();
245
+ expect(rig.heartbeatStopCount).toBe(0);
246
+ expect(rig.activeHeartbeatCount).toBe(1);
247
+
248
+ // input stops the heartbeat
249
+ rig.lifecycle.onInput();
209
250
  expect(rig.heartbeatStopCount).toBe(1);
210
251
  expect(rig.activeHeartbeatCount).toBe(0);
211
252
 
212
- const heartbeatAfterCompletion = getHeartbeatWrites(rig).length;
253
+ const heartbeatAfterInput = getHeartbeatWrites(rig).length;
213
254
  rig.tickHeartbeat();
214
- expect(getHeartbeatWrites(rig)).toHaveLength(heartbeatAfterCompletion);
215
-
216
- rig.lifecycle.onSessionShutdown();
217
- expect(rig.heartbeatStopCount).toBe(1);
218
- expect(rig.activeHeartbeatCount).toBe(0);
255
+ expect(getHeartbeatWrites(rig)).toHaveLength(heartbeatAfterInput);
219
256
 
257
+ // Session shutdown also stops heartbeat cleanly
220
258
  rig.lifecycle.onBeforeAgentStart();
221
259
  expect(rig.heartbeatStartCount).toBe(2);
222
260
  expect(rig.activeHeartbeatCount).toBe(1);
@@ -175,11 +175,13 @@ export function createWeztermNotifyLifecycle(
175
175
  enterWorking();
176
176
  },
177
177
  onAgentEnd: () => {
178
- leaveWorking("done");
178
+ // Intentional no-op: tools may still be executing after the model
179
+ // finishes generating (e.g. subagent parallel runs). Stay "working"
180
+ // until the input prompt appears, which signals the turn is truly done.
179
181
  },
180
182
  onInput: () => {
181
- if (!isWorking) {
182
- leaveWorking("");
183
+ if (isWorking) {
184
+ leaveWorking("done");
183
185
  }
184
186
 
185
187
  return { action: "continue" as const };