@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.
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/interactive-mode-patch.d.ts +1 -0
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +40 -1
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/pid-manager.d.ts +2 -9
- package/dist/pid-manager.d.ts.map +1 -1
- package/dist/pid-manager.js +1 -58
- package/dist/pid-manager.js.map +1 -1
- package/dist/pid-schema.d.ts +51 -0
- package/dist/pid-schema.d.ts.map +1 -0
- package/dist/pid-schema.js +70 -0
- package/dist/pid-schema.js.map +1 -0
- package/dist/sdk.js +4 -8
- package/dist/sdk.js.map +1 -1
- package/extensions/__integration__/audit-findings.test.ts +309 -0
- package/extensions/__integration__/tasks-runtime.test.ts +63 -12
- package/extensions/_shared/lazy-init.ts +88 -3
- package/extensions/_shared/pid-registry.ts +8 -82
- package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
- package/extensions/clear/__tests__/clear.test.ts +38 -0
- package/extensions/git-status/__tests__/git-status.test.ts +32 -0
- package/extensions/mcp-adapter-tool/index.ts +1 -1
- package/extensions/minimal-skill-display/__tests__/minimal-skill-display.test.ts +20 -0
- package/extensions/permissions/__tests__/permissions.test.ts +213 -0
- package/extensions/progress-indicator/__tests__/progress-indicator.test.ts +104 -0
- package/extensions/random-spinner/__tests__/random-spinner.test.ts +35 -0
- package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
- package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +5 -4
- package/extensions/subagent-tool/__tests__/process-liveness.test.ts +51 -0
- package/extensions/subagent-tool/__tests__/subprocess-args.test.ts +120 -0
- package/extensions/subagent-tool/formatting.ts +2 -0
- package/extensions/subagent-tool/index.ts +156 -95
- package/extensions/subagent-tool/process.ts +126 -32
- package/extensions/tasks/commands/register-tasks-extension.ts +64 -20
- package/extensions/tasks/extension.json +1 -0
- package/extensions/tasks/index.ts +2 -12
- package/extensions/tasks/state/index.ts +26 -0
- package/extensions/teams-tool/dashboard.ts +13 -1
- package/extensions/teams-tool/tools/register-extension.ts +10 -2
- package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -0
- package/extensions/wezterm-notify/__tests__/index.test.ts +49 -11
- package/extensions/wezterm-notify/index.ts +5 -3
- package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +296 -0
- package/package.json +3 -2
- package/runtime/pid-schema.ts +13 -0
- 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
|
-
|
|
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
|
|
79
|
-
* @param
|
|
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
|
-
|
|
84
|
-
|
|
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 ?
|
|
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
|
-
|
|
1920
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
210
|
+
expect(getStatusWrites(rig)).toEqual(["", "working"]);
|
|
179
211
|
expect(getHeartbeatWrites(rig)).toEqual(["0", "1", "2"]);
|
|
180
212
|
expect(rig.heartbeatStartCount).toBe(1);
|
|
181
|
-
|
|
182
|
-
expect(rig.
|
|
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
|
-
|
|
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
|
|
253
|
+
const heartbeatAfterInput = getHeartbeatWrites(rig).length;
|
|
213
254
|
rig.tickHeartbeat();
|
|
214
|
-
expect(getHeartbeatWrites(rig)).toHaveLength(
|
|
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
|
-
|
|
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 (
|
|
182
|
-
leaveWorking("");
|
|
183
|
+
if (isWorking) {
|
|
184
|
+
leaveWorking("done");
|
|
183
185
|
}
|
|
184
186
|
|
|
185
187
|
return { action: "continue" as const };
|