@dungle-scrubs/tallow 0.9.4 → 0.9.6

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 (195) hide show
  1. package/dist/cli.js +7 -4
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/interactive-mode-patch.d.ts +24 -12
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +229 -146
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/interactive-reset.d.ts +49 -0
  10. package/dist/interactive-reset.d.ts.map +1 -0
  11. package/dist/interactive-reset.js +40 -0
  12. package/dist/interactive-reset.js.map +1 -0
  13. package/dist/pi-tui-editor-patch.d.ts +10 -0
  14. package/dist/pi-tui-editor-patch.d.ts.map +1 -0
  15. package/dist/pi-tui-editor-patch.js +159 -0
  16. package/dist/pi-tui-editor-patch.js.map +1 -0
  17. package/dist/pi-tui-patch.d.ts +2 -0
  18. package/dist/pi-tui-patch.d.ts.map +1 -0
  19. package/dist/pi-tui-patch.js +563 -0
  20. package/dist/pi-tui-patch.js.map +1 -0
  21. package/dist/pi-tui-settings-list-patch.d.ts +11 -0
  22. package/dist/pi-tui-settings-list-patch.d.ts.map +1 -0
  23. package/dist/pi-tui-settings-list-patch.js +38 -0
  24. package/dist/pi-tui-settings-list-patch.js.map +1 -0
  25. package/dist/reset-diagnostics.d.ts +69 -0
  26. package/dist/reset-diagnostics.d.ts.map +1 -0
  27. package/dist/reset-diagnostics.js +41 -0
  28. package/dist/reset-diagnostics.js.map +1 -0
  29. package/dist/sdk.d.ts +5 -21
  30. package/dist/sdk.d.ts.map +1 -1
  31. package/dist/sdk.js +180 -149
  32. package/dist/sdk.js.map +1 -1
  33. package/dist/workspace-transition-interactive.d.ts +1 -0
  34. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  35. package/dist/workspace-transition-interactive.js +7 -17
  36. package/dist/workspace-transition-interactive.js.map +1 -1
  37. package/extensions/__integration__/audit-findings.test.ts +4 -5
  38. package/extensions/_icons/index.ts +2 -4
  39. package/extensions/_shared/__tests__/image-metadata.test.ts +33 -0
  40. package/extensions/_shared/__tests__/terminal-links.test.ts +18 -0
  41. package/extensions/_shared/image-metadata.ts +99 -0
  42. package/extensions/_shared/inline-preview.ts +1 -1
  43. package/extensions/_shared/terminal-links.ts +22 -0
  44. package/extensions/ask-user-question-tool/index.ts +0 -3
  45. package/extensions/clear/__tests__/clear.test.ts +269 -2
  46. package/extensions/command-expansion/index.ts +1 -1
  47. package/extensions/context-files/index.ts +5 -1
  48. package/extensions/context-fork/__tests__/context-fork.test.ts +94 -1
  49. package/extensions/context-fork/extension.json +1 -1
  50. package/extensions/context-fork/index.ts +32 -0
  51. package/extensions/edit-tool-enhanced/index.ts +2 -1
  52. package/extensions/hooks/index.ts +33 -11
  53. package/extensions/loop/index.ts +14 -1
  54. package/extensions/lsp/index.ts +64 -13
  55. package/extensions/lsp/package.json +2 -2
  56. package/extensions/random-spinner/index.ts +7 -642
  57. package/extensions/read-tool-enhanced/index.ts +6 -8
  58. package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +2 -3
  59. package/extensions/render-stabilizer/index.ts +6 -6
  60. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +26 -0
  61. package/extensions/slash-command-bridge/index.ts +14 -2
  62. package/extensions/subagent-tool/model-resolver.ts +274 -7
  63. package/extensions/tasks/commands/register-tasks-extension.ts +9 -9
  64. package/extensions/teams-tool/tools/register-extension.ts +1 -3
  65. package/extensions/web-search-tool/index.ts +2 -1
  66. package/extensions/write-tool-enhanced/index.ts +2 -1
  67. package/node_modules/@mariozechner/pi-tui/README.md +56 -34
  68. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts +18 -13
  69. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts.map +1 -1
  70. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js +182 -113
  71. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js.map +1 -1
  72. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js +3 -3
  73. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js.map +1 -1
  74. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts +45 -36
  75. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts.map +1 -1
  76. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js +489 -325
  77. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js.map +1 -1
  78. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts +1 -99
  79. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts.map +1 -1
  80. package/node_modules/@mariozechner/pi-tui/dist/components/image.js +17 -192
  81. package/node_modules/@mariozechner/pi-tui/dist/components/image.js.map +1 -1
  82. package/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts.map +1 -1
  83. package/node_modules/@mariozechner/pi-tui/dist/components/input.js +57 -60
  84. package/node_modules/@mariozechner/pi-tui/dist/components/input.js.map +1 -1
  85. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts +2 -69
  86. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts.map +1 -1
  87. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js +5 -102
  88. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js.map +1 -1
  89. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts.map +1 -1
  90. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js +111 -53
  91. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js.map +1 -1
  92. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts +19 -1
  93. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts.map +1 -1
  94. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js +78 -67
  95. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js.map +1 -1
  96. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts +1 -25
  97. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts.map +1 -1
  98. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js +13 -50
  99. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js.map +1 -1
  100. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +8 -10
  101. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  102. package/node_modules/@mariozechner/pi-tui/dist/index.js +6 -9
  103. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  104. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +108 -238
  105. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  106. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +108 -365
  107. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  108. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +33 -48
  109. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  110. package/node_modules/@mariozechner/pi-tui/dist/keys.js +239 -155
  111. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  112. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts +14 -94
  113. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts.map +1 -1
  114. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js +44 -186
  115. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js.map +1 -1
  116. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +13 -58
  117. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  118. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +78 -111
  119. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  120. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +24 -110
  121. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  122. package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -435
  123. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  124. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts +0 -18
  125. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts.map +1 -1
  126. package/node_modules/@mariozechner/pi-tui/dist/utils.js +251 -119
  127. package/node_modules/@mariozechner/pi-tui/dist/utils.js.map +1 -1
  128. package/node_modules/@mariozechner/pi-tui/package.json +6 -6
  129. package/node_modules/@mariozechner/pi-tui/src/__tests__/__snapshots__/render.test.ts.snap +3 -40
  130. package/node_modules/@mariozechner/pi-tui/src/__tests__/image-component.test.ts +71 -81
  131. package/node_modules/@mariozechner/pi-tui/src/__tests__/render.test.ts +0 -33
  132. package/node_modules/@mariozechner/pi-tui/src/__tests__/terminal-image.test.ts +93 -334
  133. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +1 -1
  134. package/node_modules/@mariozechner/pi-tui/src/__tests__/utils.test.ts +11 -196
  135. package/node_modules/@mariozechner/pi-tui/src/autocomplete.ts +228 -142
  136. package/node_modules/@mariozechner/pi-tui/src/components/cancellable-loader.ts +3 -3
  137. package/node_modules/@mariozechner/pi-tui/src/components/editor.ts +624 -390
  138. package/node_modules/@mariozechner/pi-tui/src/components/image.ts +17 -227
  139. package/node_modules/@mariozechner/pi-tui/src/components/input.ts +71 -63
  140. package/node_modules/@mariozechner/pi-tui/src/components/loader.ts +5 -137
  141. package/node_modules/@mariozechner/pi-tui/src/components/markdown.ts +143 -52
  142. package/node_modules/@mariozechner/pi-tui/src/components/select-list.ts +136 -70
  143. package/node_modules/@mariozechner/pi-tui/src/components/settings-list.ts +12 -51
  144. package/node_modules/@mariozechner/pi-tui/src/index.ts +17 -36
  145. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +148 -421
  146. package/node_modules/@mariozechner/pi-tui/src/keys.ts +253 -181
  147. package/node_modules/@mariozechner/pi-tui/src/terminal-image.ts +51 -252
  148. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +78 -133
  149. package/node_modules/@mariozechner/pi-tui/src/tui.ts +202 -478
  150. package/node_modules/@mariozechner/pi-tui/src/utils.ts +289 -125
  151. package/node_modules/@mariozechner/pi-tui/tsconfig.build.json +1 -0
  152. package/package.json +13 -13
  153. package/packages/tallow-tui/node_modules/@types/mime-types/README.md +8 -2
  154. package/packages/tallow-tui/node_modules/@types/mime-types/index.d.ts +6 -0
  155. package/packages/tallow-tui/node_modules/@types/mime-types/package.json +9 -3
  156. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup-data.js +18 -0
  157. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup.js +116 -384
  158. package/packages/tallow-tui/node_modules/get-east-asian-width/package.json +5 -4
  159. package/packages/tallow-tui/node_modules/get-east-asian-width/utilities.js +24 -0
  160. package/packages/tallow-tui/node_modules/marked/README.md +5 -4
  161. package/packages/tallow-tui/node_modules/marked/bin/main.js +10 -8
  162. package/packages/tallow-tui/node_modules/marked/bin/marked.js +2 -1
  163. package/packages/tallow-tui/node_modules/marked/lib/marked.d.ts +156 -125
  164. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js +67 -2179
  165. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js.map +3 -3
  166. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js +67 -2201
  167. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js.map +3 -3
  168. package/packages/tallow-tui/node_modules/marked/man/marked.1 +4 -2
  169. package/packages/tallow-tui/node_modules/marked/man/marked.1.md +2 -1
  170. package/packages/tallow-tui/node_modules/marked/package.json +26 -34
  171. package/skills/tallow-expert/SKILL.md +1 -3
  172. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts +0 -32
  173. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts.map +0 -1
  174. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js +0 -46
  175. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js.map +0 -1
  176. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts +0 -52
  177. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts.map +0 -1
  178. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js +0 -89
  179. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js.map +0 -1
  180. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts +0 -14
  181. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts.map +0 -1
  182. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js +0 -55
  183. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js.map +0 -1
  184. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-change-listener.test.ts +0 -121
  185. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-ghost-text.test.ts +0 -112
  186. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +0 -134
  187. package/node_modules/@mariozechner/pi-tui/src/__tests__/settings-list.test.ts +0 -81
  188. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +0 -555
  189. package/node_modules/@mariozechner/pi-tui/src/border-styles.ts +0 -60
  190. package/node_modules/@mariozechner/pi-tui/src/components/bordered-box.ts +0 -113
  191. package/node_modules/@mariozechner/pi-tui/src/test-utils/capability-env.ts +0 -56
  192. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs +0 -2211
  193. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs.map +0 -7
  194. package/packages/tallow-tui/node_modules/marked/lib/marked.d.cts +0 -728
  195. package/packages/tallow-tui/node_modules/marked/marked.min.js +0 -69
@@ -1,7 +1,140 @@
1
- import { describe, expect, mock, test } from "bun:test";
2
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { AssistantMessage, ToolResultMessage, Usage } from "@mariozechner/pi-ai";
6
+ import type {
7
+ ExtensionAPI,
8
+ ExtensionContext,
9
+ ExtensionUIContext,
10
+ TurnEndEvent,
11
+ } from "@mariozechner/pi-coding-agent";
12
+ import {
13
+ getResetDiagnosticsForTests,
14
+ resetResetDiagnosticsForTests,
15
+ } from "../../../src/reset-diagnostics.js";
16
+ import { ExtensionHarness } from "../../../test-utils/extension-harness.js";
17
+ import { ManualTimerScheduler } from "../../../test-utils/manual-timer-scheduler.js";
18
+ import { registerContextForkExtension } from "../../context-fork/index.js";
19
+ import slashCommandBridge, {
20
+ resetSlashCommandBridgeStateForTests,
21
+ setSlashCommandBridgeSchedulerForTests,
22
+ } from "../../slash-command-bridge/index.js";
3
23
  import registerClear from "../index.js";
4
24
 
25
+ const ZERO_USAGE: Usage = {
26
+ input: 0,
27
+ output: 0,
28
+ cacheRead: 0,
29
+ cacheWrite: 0,
30
+ totalTokens: 0,
31
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
32
+ };
33
+
34
+ let harness: ExtensionHarness;
35
+ let scheduler: ManualTimerScheduler;
36
+
37
+ beforeEach(() => {
38
+ scheduler = new ManualTimerScheduler();
39
+ setSlashCommandBridgeSchedulerForTests(scheduler.runtime);
40
+ harness = ExtensionHarness.create();
41
+ });
42
+
43
+ afterEach(() => {
44
+ resetResetDiagnosticsForTests();
45
+ resetSlashCommandBridgeStateForTests();
46
+ });
47
+
48
+ /**
49
+ * Build a compact-lifecycle test context.
50
+ *
51
+ * @param overrides - Context overrides
52
+ * @returns Extension context
53
+ */
54
+ function buildContext(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
55
+ return {
56
+ ui: {} as ExtensionContext["ui"],
57
+ hasUI: false,
58
+ cwd: process.cwd(),
59
+ sessionManager: {} as ExtensionContext["sessionManager"],
60
+ modelRegistry: {} as ExtensionContext["modelRegistry"],
61
+ model: undefined,
62
+ isIdle: () => true,
63
+ abort: () => {},
64
+ hasPendingMessages: () => false,
65
+ shutdown: () => {},
66
+ getContextUsage: () => ({ contextWindow: 100, tokens: 90 }),
67
+ compact: () => {},
68
+ getSystemPrompt: () => "",
69
+ ...overrides,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Build a realistic assistant turn_end event for compact lifecycle tests.
75
+ *
76
+ * @param stopReason - Assistant stop reason for the completed turn
77
+ * @returns TurnEnd event payload
78
+ */
79
+ function buildAssistantTurnEnd(stopReason: AssistantMessage["stopReason"]): TurnEndEvent {
80
+ return {
81
+ type: "turn_end",
82
+ turnIndex: 0,
83
+ message: {
84
+ role: "assistant",
85
+ content: [],
86
+ api: "anthropic-messages",
87
+ provider: "mock",
88
+ model: "mock-model",
89
+ stopReason,
90
+ timestamp: Date.now(),
91
+ usage: { ...ZERO_USAGE },
92
+ },
93
+ toolResults: stopReason === "toolUse" ? [buildCompactToolResult()] : [],
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Build the compact tool result payload recorded on the tool-use turn.
99
+ *
100
+ * @returns Tool result message for `run_slash_command({ command: "compact" })`
101
+ */
102
+ function buildCompactToolResult(): ToolResultMessage<{ command: string }> {
103
+ return {
104
+ role: "toolResult",
105
+ toolCallId: "mock-tool-call",
106
+ toolName: "run_slash_command",
107
+ content: [
108
+ { type: "text", text: "Session compaction will begin after this response completes." },
109
+ ],
110
+ details: { command: "compact" },
111
+ isError: false,
112
+ timestamp: Date.now(),
113
+ };
114
+ }
115
+
116
+ interface Deferred<T> {
117
+ readonly promise: Promise<T>;
118
+ readonly reject: (error?: unknown) => void;
119
+ readonly resolve: (value: T) => void;
120
+ }
121
+
122
+ /**
123
+ * Create a deferred promise for controlling async completion timing in tests.
124
+ *
125
+ * @template T
126
+ * @returns Deferred promise controls
127
+ */
128
+ function createDeferred<T>(): Deferred<T> {
129
+ let reject!: (error?: unknown) => void;
130
+ let resolve!: (value: T) => void;
131
+ const promise = new Promise<T>((innerResolve, innerReject) => {
132
+ resolve = innerResolve;
133
+ reject = innerReject;
134
+ });
135
+ return { promise, reject, resolve };
136
+ }
137
+
5
138
  describe("clear extension", () => {
6
139
  test("registers /clear command", () => {
7
140
  const commands: Array<{ name: string; description: string }> = [];
@@ -35,4 +168,138 @@ describe("clear extension", () => {
35
168
  await handler?.("", { newSession });
36
169
  expect(newSession).toHaveBeenCalledTimes(1);
37
170
  });
171
+
172
+ test("/clear cancels pending compact continuation before it can restart work", async () => {
173
+ await harness.loadExtension(slashCommandBridge);
174
+ registerClear(harness.api);
175
+
176
+ const compactTool = harness.tools.get("run_slash_command");
177
+ const clearCommand = harness.commands.get("clear");
178
+ let compactOptions: Parameters<ExtensionContext["compact"]>[0];
179
+ const widgetUpdates: Array<{ key: string; content: string[] | undefined }> = [];
180
+ const workingMessages: Array<string | undefined> = [];
181
+ const ctx = buildContext({
182
+ hasUI: true,
183
+ ui: {
184
+ setWidget: (key: string, content?: string[]) => {
185
+ widgetUpdates.push({ key, content });
186
+ },
187
+ setWorkingMessage: (message?: string) => {
188
+ workingMessages.push(message);
189
+ },
190
+ } as ExtensionUIContext,
191
+ compact: (options) => {
192
+ compactOptions = options;
193
+ },
194
+ isIdle: () => true,
195
+ });
196
+
197
+ if (!compactTool?.execute || !clearCommand?.handler) {
198
+ throw new Error("expected compact tool and clear command to be registered");
199
+ }
200
+
201
+ await compactTool.execute("test-call-id", { command: "compact" }, undefined, undefined, ctx);
202
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("toolUse"), ctx);
203
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("stop"), ctx);
204
+ compactOptions?.onComplete?.();
205
+
206
+ const newSession = mock(async () => {
207
+ await harness.fireEvent(
208
+ "session_before_switch",
209
+ { type: "session_before_switch", reason: "new" },
210
+ ctx
211
+ );
212
+ });
213
+ await clearCommand.handler("", { ...ctx, newSession } as never);
214
+ scheduler.advanceBy(200);
215
+
216
+ expect(newSession).toHaveBeenCalledTimes(1);
217
+ expect(harness.sentMessages.some((message) => message.customType === "compact-continue")).toBe(
218
+ false
219
+ );
220
+ expect(widgetUpdates.at(-1)).toEqual({ key: "compact-progress", content: undefined });
221
+ expect(workingMessages.at(-1)).toBeUndefined();
222
+ });
223
+
224
+ test("/clear after deferred fork completion leaves the replacement session idle", async () => {
225
+ const commandDir = mkdtempSync(join(tmpdir(), "clear-fork-command-"));
226
+ const commandPath = join(commandDir, "review.md");
227
+ const deferred = createDeferred<{ duration: number; exitCode: number; output: string }>();
228
+ const workingMessages: string[] = [];
229
+ writeFileSync(commandPath, "Review the code.\n", "utf-8");
230
+
231
+ try {
232
+ registerContextForkExtension(harness.api, {
233
+ buildFrontmatterIndex: () =>
234
+ new Map([
235
+ [
236
+ "review",
237
+ {
238
+ context: "fork",
239
+ filePath: commandPath,
240
+ },
241
+ ],
242
+ ]),
243
+ loadAllAgents: () => new Map(),
244
+ routeForkedModel: async () => undefined,
245
+ spawnForkSubprocess: () => deferred.promise,
246
+ });
247
+ registerClear(harness.api);
248
+
249
+ const clearCommand = harness.commands.get("clear");
250
+ const ctx = buildContext({
251
+ hasUI: true,
252
+ ui: {
253
+ notify: () => {},
254
+ setWorkingMessage: (message?: string) => {
255
+ workingMessages.push(message ?? "");
256
+ },
257
+ } as ExtensionUIContext,
258
+ isIdle: () => true,
259
+ });
260
+
261
+ if (!clearCommand?.handler) {
262
+ throw new Error("expected clear command to be registered");
263
+ }
264
+
265
+ const [forkResult] = await harness.fireEvent("input", { text: "/review" }, ctx);
266
+ expect(forkResult).toEqual({ action: "handled" });
267
+ expect(harness.sentMessages).toHaveLength(1);
268
+ expect(harness.sentMessages[0]?.content).toContain("🔀 /review");
269
+
270
+ const newSession = mock(async () => {
271
+ await harness.fireEvent(
272
+ "session_before_switch",
273
+ { type: "session_before_switch", reason: "new" },
274
+ ctx
275
+ );
276
+ });
277
+ await clearCommand.handler("", { ...ctx, newSession } as never);
278
+ deferred.resolve({ duration: 5, exitCode: 0, output: "fork done" });
279
+ await Promise.resolve();
280
+ await new Promise((resolve) => setTimeout(resolve, 0));
281
+
282
+ expect(newSession).toHaveBeenCalledTimes(1);
283
+ expect(workingMessages).toContain("🔀 forking: /review");
284
+ expect(workingMessages.at(-1)).toBe("");
285
+ expect(harness.sentMessages).toHaveLength(1);
286
+ expect(harness.sentMessages.some((message) => message.options?.triggerTurn === true)).toBe(
287
+ false
288
+ );
289
+
290
+ const diagnostics = getResetDiagnosticsForTests();
291
+ expect(diagnostics.some((event) => event.kind === "deferred_cancelled")).toBe(true);
292
+ expect(
293
+ diagnostics.some(
294
+ (event) =>
295
+ event.kind === "deferred_dropped" &&
296
+ event.source === "context-fork" &&
297
+ event.reason === "session_generation_mismatch"
298
+ )
299
+ ).toBe(true);
300
+ } finally {
301
+ deferred.reject(new Error("cleanup"));
302
+ rmSync(commandDir, { force: true, recursive: true });
303
+ }
304
+ });
38
305
  });
@@ -418,7 +418,7 @@ export function registerCommandExpansionExtension(
418
418
 
419
419
  // Only process if it looks like a command with arguments
420
420
  const split = splitOuterCommand(text);
421
- if (!split || !split.args) {
421
+ if (!split?.args) {
422
422
  return { action: "continue" as const };
423
423
  }
424
424
 
@@ -943,7 +943,11 @@ export default function contextFilesExtension(pi: ExtensionAPI) {
943
943
  resetSessionState();
944
944
  });
945
945
 
946
- pi.on("session_switch", async () => {
946
+ (
947
+ pi as unknown as {
948
+ on: (event: string, handler: () => Promise<void>) => void;
949
+ }
950
+ ).on("session_switch", async () => {
947
951
  resetSessionState();
948
952
  });
949
953
 
@@ -6,7 +6,7 @@ import { ExtensionHarness } from "../../../test-utils/extension-harness.js";
6
6
  import { buildFrontmatterIndex } from "../frontmatter-index.js";
7
7
  import { registerContextForkExtension } from "../index.js";
8
8
  import { resolveModel } from "../model-resolver.js";
9
- import type { ForkOptions } from "../spawn.js";
9
+ import type { ForkOptions, ForkResult } from "../spawn.js";
10
10
  import { buildForkArgs } from "../spawn.js";
11
11
 
12
12
  // ── Model Resolver ──────────────────────────────────────────
@@ -503,3 +503,96 @@ describe("context-fork lazy resource initialization", () => {
503
503
  expect(agentLoads).toBe(2);
504
504
  });
505
505
  });
506
+
507
+ interface Deferred<T> {
508
+ readonly promise: Promise<T>;
509
+ readonly reject: (error?: unknown) => void;
510
+ readonly resolve: (value: T) => void;
511
+ }
512
+
513
+ /**
514
+ * Create a deferred promise for controlling async completion timing in tests.
515
+ *
516
+ * @template T
517
+ * @returns Deferred promise controls
518
+ */
519
+ function createDeferred<T>(): Deferred<T> {
520
+ let reject!: (error?: unknown) => void;
521
+ let resolve!: (value: T) => void;
522
+ const promise = new Promise<T>((innerResolve, innerReject) => {
523
+ resolve = innerResolve;
524
+ reject = innerReject;
525
+ });
526
+ return { promise, reject, resolve };
527
+ }
528
+
529
+ /**
530
+ * Build a minimal extension context with a mutable working-message log.
531
+ *
532
+ * @param workingMessages - Collector for working-message updates
533
+ * @returns Minimal extension context for context-fork tests
534
+ */
535
+ function buildTestContext(workingMessages: string[]): Record<string, unknown> {
536
+ return {
537
+ cwd: process.cwd(),
538
+ hasUI: true,
539
+ isIdle: () => true,
540
+ model: undefined,
541
+ ui: {
542
+ notify: () => {},
543
+ setWorkingMessage: (message?: string) => {
544
+ workingMessages.push(message ?? "");
545
+ },
546
+ },
547
+ };
548
+ }
549
+
550
+ describe("context-fork reset boundaries", () => {
551
+ test("ignores late fork completion after session_before_switch", async () => {
552
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fork-reset-test-"));
553
+ const commandPath = path.join(tmpDir, "review.md");
554
+ const deferred = createDeferred<ForkResult>();
555
+ const harness = ExtensionHarness.create();
556
+ const workingMessages: string[] = [];
557
+ fs.writeFileSync(commandPath, "Review the code.\n", "utf-8");
558
+
559
+ try {
560
+ registerContextForkExtension(harness.api, {
561
+ buildFrontmatterIndex: () =>
562
+ new Map([
563
+ [
564
+ "review",
565
+ {
566
+ context: "fork",
567
+ filePath: commandPath,
568
+ },
569
+ ],
570
+ ]),
571
+ loadAllAgents: () => new Map(),
572
+ routeForkedModel: async () => undefined,
573
+ spawnForkSubprocess: () => deferred.promise,
574
+ });
575
+
576
+ const ctx = buildTestContext(workingMessages);
577
+ const [result] = await harness.fireEvent("input", { text: "/review" }, ctx as never);
578
+ expect(result).toEqual({ action: "handled" });
579
+ expect(harness.sentMessages).toHaveLength(1);
580
+ expect(harness.sentMessages[0]?.content).toContain("🔀 /review");
581
+
582
+ await harness.fireEvent(
583
+ "session_before_switch",
584
+ { type: "session_before_switch", reason: "new" },
585
+ ctx as never
586
+ );
587
+ deferred.resolve({ duration: 5, exitCode: 0, output: "fork done" });
588
+ await Promise.resolve();
589
+ await new Promise((resolve) => setTimeout(resolve, 0));
590
+
591
+ expect(workingMessages).toContain("🔀 forking: /review");
592
+ expect(harness.sentMessages).toHaveLength(1);
593
+ } finally {
594
+ deferred.reject(new Error("cleanup"));
595
+ fs.rmSync(tmpDir, { recursive: true, force: true });
596
+ }
597
+ });
598
+ });
@@ -4,7 +4,7 @@
4
4
  "description": "Runs commands/skills with context: fork frontmatter in isolated pi subprocesses",
5
5
  "whenToUse": "Use when a command or skill must run in an isolated forked tallow subprocess.",
6
6
  "capabilities": {
7
- "events": ["input", "session_start"]
7
+ "events": ["input", "session_before_switch", "session_start"]
8
8
  },
9
9
  "permissionSurface": {
10
10
  "filesystem": "write",
@@ -23,6 +23,7 @@ import * as path from "node:path";
23
23
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
24
24
  import { stripFrontmatter } from "@mariozechner/pi-coding-agent";
25
25
  import { Text } from "@mariozechner/pi-tui";
26
+ import { recordResetDiagnostic } from "../../src/reset-diagnostics.js";
26
27
  import { createLazyInitializer } from "../_shared/lazy-init.js";
27
28
  import { isProjectTrusted } from "../_shared/project-trust.js";
28
29
  import { isShellInterpolationEnabled } from "../_shared/shell-policy.js";
@@ -326,6 +327,7 @@ export function registerContextForkExtension(
326
327
 
327
328
  let frontmatterIndex: FrontmatterIndex = new Map();
328
329
  let agents: Map<string, AgentConfig> = new Map();
330
+ let sessionGeneration = 0;
329
331
 
330
332
  const debug = (...args: unknown[]) => {
331
333
  if (process.env.DEBUG) {
@@ -375,9 +377,21 @@ export function registerContextForkExtension(
375
377
 
376
378
  // Reset lazy state on each session start so resources reflect on-disk changes.
377
379
  pi.on("session_start", async () => {
380
+ sessionGeneration += 1;
378
381
  resetResources();
379
382
  });
380
383
 
384
+ // Invalidate any in-flight fork completions before switching sessions.
385
+ pi.on("session_before_switch", async (_event, ctx) => {
386
+ sessionGeneration += 1;
387
+ ctx.ui?.setWorkingMessage?.();
388
+ recordResetDiagnostic({
389
+ kind: "deferred_cancelled",
390
+ reason: "session_before_switch",
391
+ source: "context-fork",
392
+ });
393
+ });
394
+
381
395
  // Register custom message renderer for fork results
382
396
  pi.registerMessageRenderer<ForkResultDetails>("fork-result", (message, _options, theme) => {
383
397
  const details = message.details;
@@ -505,6 +519,8 @@ export function registerContextForkExtension(
505
519
 
506
520
  // Mark as handled — prevent command-prompt/minimal-skill-display from processing
507
521
  // We continue the fork asynchronously via the promise below
522
+ const forkGeneration = sessionGeneration;
523
+ recordResetDiagnostic({ kind: "deferred_registered", source: "context-fork" });
508
524
  const forkPromise = dependencies.spawnForkSubprocess({
509
525
  content,
510
526
  cwd: ctx.cwd,
@@ -516,6 +532,14 @@ export function registerContextForkExtension(
516
532
 
517
533
  forkPromise
518
534
  .then((result) => {
535
+ if (forkGeneration !== sessionGeneration) {
536
+ recordResetDiagnostic({
537
+ kind: "deferred_dropped",
538
+ reason: "session_generation_mismatch",
539
+ source: "context-fork",
540
+ });
541
+ return;
542
+ }
519
543
  ctx.ui.setWorkingMessage();
520
544
 
521
545
  if (result.exitCode !== 0 && !result.output) {
@@ -550,6 +574,14 @@ export function registerContextForkExtension(
550
574
  );
551
575
  })
552
576
  .catch((err: unknown) => {
577
+ if (forkGeneration !== sessionGeneration) {
578
+ recordResetDiagnostic({
579
+ kind: "deferred_dropped",
580
+ reason: "session_generation_mismatch",
581
+ source: "context-fork",
582
+ });
583
+ return;
584
+ }
553
585
  ctx.ui.setWorkingMessage();
554
586
  const message = err instanceof Error ? err.message : String(err);
555
587
  ctx.ui.notify(`Fork /${commandName} error: ${message}`, "error");
@@ -14,9 +14,10 @@ import {
14
14
  renderDiff,
15
15
  type ThemeColor,
16
16
  } from "@mariozechner/pi-coding-agent";
17
- import { fileLink, hyperlink, Text } from "@mariozechner/pi-tui";
17
+ import { Text } from "@mariozechner/pi-tui";
18
18
  import { getIcon } from "../_icons/index.js";
19
19
  import { commandExistsOnPath, runGitCommandSync } from "../_shared/shell-policy.js";
20
+ import { fileLink, hyperlink } from "../_shared/terminal-links.js";
20
21
  import {
21
22
  appendSection,
22
23
  dimProcessOutputLine,
@@ -1610,11 +1610,40 @@ export default function (pi: ExtensionAPI) {
1610
1610
  }
1611
1611
  });
1612
1612
 
1613
- // Hook into session_switch fires after switching sessions
1614
- pi.on("session_switch", async (event) => {
1613
+ // session_switch/session_fork were removed upstream in favor of session_start
1614
+ // with a reason discriminator. Preserve hook compatibility here.
1615
+ pi.on("session_start", async (event) => {
1616
+ if (event.reason === "resume" || event.reason === "new") {
1617
+ await runHooks("session_switch", {
1618
+ reason: event.reason,
1619
+ previousSessionFile: event.previousSessionFile,
1620
+ });
1621
+ }
1622
+ if (event.reason === "fork") {
1623
+ await runHooks("session_fork", {
1624
+ previousSessionFile: event.previousSessionFile,
1625
+ });
1626
+ }
1627
+ });
1628
+ (
1629
+ pi as unknown as {
1630
+ on: (event: string, handler: (event: Record<string, unknown>) => Promise<void>) => void;
1631
+ }
1632
+ ).on("session_switch", async (event) => {
1615
1633
  await runHooks("session_switch", {
1616
- reason: event.reason,
1617
- previousSessionFile: event.previousSessionFile,
1634
+ reason: String(event.reason ?? "resume"),
1635
+ previousSessionFile:
1636
+ typeof event.previousSessionFile === "string" ? event.previousSessionFile : undefined,
1637
+ });
1638
+ });
1639
+ (
1640
+ pi as unknown as {
1641
+ on: (event: string, handler: (event: Record<string, unknown>) => Promise<void>) => void;
1642
+ }
1643
+ ).on("session_fork", async (event) => {
1644
+ await runHooks("session_fork", {
1645
+ previousSessionFile:
1646
+ typeof event.previousSessionFile === "string" ? event.previousSessionFile : undefined,
1618
1647
  });
1619
1648
  });
1620
1649
 
@@ -1631,13 +1660,6 @@ export default function (pi: ExtensionAPI) {
1631
1660
  }
1632
1661
  });
1633
1662
 
1634
- // Hook into session_fork — fires after forking
1635
- pi.on("session_fork", async (event) => {
1636
- await runHooks("session_fork", {
1637
- previousSessionFile: event.previousSessionFile,
1638
- });
1639
- });
1640
-
1641
1663
  // Hook into session_before_tree — fires before tree navigation (can cancel)
1642
1664
  pi.on("session_before_tree", async (event, ctx) => {
1643
1665
  const result = await runHooks(
@@ -798,7 +798,20 @@ export default function loopExtension(pi: ExtensionAPI): void {
798
798
  stopLoop(ctx, "Loop stopped (session shutdown)");
799
799
  });
800
800
 
801
- pi.on("session_switch", async (_event, ctx) => {
801
+ pi.on("session_start", async (event, ctx) => {
802
+ if (event.reason !== "startup") {
803
+ stopLoop(ctx, `Loop stopped (session ${event.reason})`);
804
+ }
805
+ });
806
+
807
+ (
808
+ pi as unknown as {
809
+ on: (
810
+ event: string,
811
+ handler: (event: unknown, ctx: ExtensionContext) => Promise<void>
812
+ ) => void;
813
+ }
814
+ ).on("session_switch", async (_event, ctx) => {
802
815
  stopLoop(ctx, "Loop stopped (session switch)");
803
816
  });
804
817
  }