@dungle-scrubs/tallow 0.9.3 → 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 (207) 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 -10
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +285 -148
  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 +6 -16
  38. package/extensions/__integration__/teams-runtime.test.ts +4 -1
  39. package/extensions/_icons/index.ts +2 -4
  40. package/extensions/_shared/__tests__/image-metadata.test.ts +33 -0
  41. package/extensions/_shared/__tests__/terminal-links.test.ts +18 -0
  42. package/extensions/_shared/image-metadata.ts +99 -0
  43. package/extensions/_shared/inline-preview.ts +1 -1
  44. package/extensions/_shared/pid-registry.ts +0 -1
  45. package/extensions/_shared/terminal-links.ts +22 -0
  46. package/extensions/ask-user-question-tool/index.ts +0 -3
  47. package/extensions/clear/__tests__/clear.test.ts +270 -3
  48. package/extensions/command-expansion/index.ts +1 -1
  49. package/extensions/context-files/index.ts +5 -1
  50. package/extensions/context-fork/__tests__/context-fork.test.ts +94 -1
  51. package/extensions/context-fork/extension.json +1 -1
  52. package/extensions/context-fork/index.ts +32 -0
  53. package/extensions/edit-tool-enhanced/index.ts +2 -1
  54. package/extensions/hooks/index.ts +33 -11
  55. package/extensions/loop/index.ts +14 -1
  56. package/extensions/lsp/index.ts +64 -13
  57. package/extensions/lsp/package.json +2 -2
  58. package/extensions/permissions/__tests__/permissions.test.ts +4 -4
  59. package/extensions/random-spinner/index.ts +7 -642
  60. package/extensions/read-tool-enhanced/index.ts +6 -8
  61. package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +4 -5
  62. package/extensions/render-stabilizer/index.ts +6 -6
  63. package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +1 -1
  64. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +26 -0
  65. package/extensions/slash-command-bridge/index.ts +14 -2
  66. package/extensions/subagent-tool/index.ts +1 -1
  67. package/extensions/subagent-tool/model-resolver.ts +274 -7
  68. package/extensions/tasks/__tests__/state-ui.test.ts +3 -3
  69. package/extensions/tasks/__tests__/widget-subagents.test.ts +2 -2
  70. package/extensions/tasks/commands/register-tasks-extension.ts +10 -10
  71. package/extensions/tasks/state/index.ts +1 -1
  72. package/extensions/tasks/ui/index.ts +2 -7
  73. package/extensions/teams-tool/tools/register-extension.ts +1 -3
  74. package/extensions/web-search-tool/index.ts +2 -1
  75. package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +21 -6
  76. package/extensions/write-tool-enhanced/index.ts +2 -1
  77. package/node_modules/@mariozechner/pi-tui/README.md +56 -34
  78. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts +18 -13
  79. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts.map +1 -1
  80. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js +182 -113
  81. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js.map +1 -1
  82. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js +3 -3
  83. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js.map +1 -1
  84. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts +45 -36
  85. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts.map +1 -1
  86. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js +489 -325
  87. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js.map +1 -1
  88. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts +1 -99
  89. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts.map +1 -1
  90. package/node_modules/@mariozechner/pi-tui/dist/components/image.js +17 -192
  91. package/node_modules/@mariozechner/pi-tui/dist/components/image.js.map +1 -1
  92. package/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts.map +1 -1
  93. package/node_modules/@mariozechner/pi-tui/dist/components/input.js +57 -60
  94. package/node_modules/@mariozechner/pi-tui/dist/components/input.js.map +1 -1
  95. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts +2 -69
  96. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts.map +1 -1
  97. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js +5 -102
  98. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js.map +1 -1
  99. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts.map +1 -1
  100. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js +111 -53
  101. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js.map +1 -1
  102. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts +19 -1
  103. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts.map +1 -1
  104. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js +78 -67
  105. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js.map +1 -1
  106. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts +0 -2
  107. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts.map +1 -1
  108. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js +12 -23
  109. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js.map +1 -1
  110. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +8 -10
  111. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  112. package/node_modules/@mariozechner/pi-tui/dist/index.js +6 -9
  113. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  114. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +108 -238
  115. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  116. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +108 -365
  117. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  118. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +33 -48
  119. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  120. package/node_modules/@mariozechner/pi-tui/dist/keys.js +239 -155
  121. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  122. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts +14 -94
  123. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts.map +1 -1
  124. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js +44 -186
  125. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js.map +1 -1
  126. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +13 -58
  127. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  128. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +78 -111
  129. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  130. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +24 -110
  131. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  132. package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -435
  133. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  134. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts +0 -18
  135. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts.map +1 -1
  136. package/node_modules/@mariozechner/pi-tui/dist/utils.js +251 -119
  137. package/node_modules/@mariozechner/pi-tui/dist/utils.js.map +1 -1
  138. package/node_modules/@mariozechner/pi-tui/package.json +6 -6
  139. package/node_modules/@mariozechner/pi-tui/src/__tests__/__snapshots__/render.test.ts.snap +3 -40
  140. package/node_modules/@mariozechner/pi-tui/src/__tests__/image-component.test.ts +71 -81
  141. package/node_modules/@mariozechner/pi-tui/src/__tests__/render.test.ts +0 -33
  142. package/node_modules/@mariozechner/pi-tui/src/__tests__/terminal-image.test.ts +93 -334
  143. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +1 -1
  144. package/node_modules/@mariozechner/pi-tui/src/__tests__/utils.test.ts +11 -196
  145. package/node_modules/@mariozechner/pi-tui/src/autocomplete.ts +228 -142
  146. package/node_modules/@mariozechner/pi-tui/src/components/cancellable-loader.ts +3 -3
  147. package/node_modules/@mariozechner/pi-tui/src/components/editor.ts +624 -390
  148. package/node_modules/@mariozechner/pi-tui/src/components/image.ts +17 -227
  149. package/node_modules/@mariozechner/pi-tui/src/components/input.ts +71 -63
  150. package/node_modules/@mariozechner/pi-tui/src/components/loader.ts +5 -137
  151. package/node_modules/@mariozechner/pi-tui/src/components/markdown.ts +143 -52
  152. package/node_modules/@mariozechner/pi-tui/src/components/select-list.ts +136 -70
  153. package/node_modules/@mariozechner/pi-tui/src/components/settings-list.ts +11 -23
  154. package/node_modules/@mariozechner/pi-tui/src/index.ts +17 -36
  155. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +148 -421
  156. package/node_modules/@mariozechner/pi-tui/src/keys.ts +253 -181
  157. package/node_modules/@mariozechner/pi-tui/src/terminal-image.ts +51 -252
  158. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +78 -133
  159. package/node_modules/@mariozechner/pi-tui/src/tui.ts +202 -478
  160. package/node_modules/@mariozechner/pi-tui/src/utils.ts +289 -125
  161. package/node_modules/@mariozechner/pi-tui/tsconfig.build.json +1 -0
  162. package/package.json +13 -13
  163. package/packages/tallow-tui/node_modules/@types/mime-types/README.md +8 -2
  164. package/packages/tallow-tui/node_modules/@types/mime-types/index.d.ts +6 -0
  165. package/packages/tallow-tui/node_modules/@types/mime-types/package.json +9 -3
  166. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup-data.js +18 -0
  167. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup.js +116 -384
  168. package/packages/tallow-tui/node_modules/get-east-asian-width/package.json +5 -4
  169. package/packages/tallow-tui/node_modules/get-east-asian-width/utilities.js +24 -0
  170. package/packages/tallow-tui/node_modules/marked/README.md +5 -4
  171. package/packages/tallow-tui/node_modules/marked/bin/main.js +10 -8
  172. package/packages/tallow-tui/node_modules/marked/bin/marked.js +2 -1
  173. package/packages/tallow-tui/node_modules/marked/lib/marked.d.ts +156 -125
  174. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js +67 -2179
  175. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js.map +3 -3
  176. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js +67 -2201
  177. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js.map +3 -3
  178. package/packages/tallow-tui/node_modules/marked/man/marked.1 +4 -2
  179. package/packages/tallow-tui/node_modules/marked/man/marked.1.md +2 -1
  180. package/packages/tallow-tui/node_modules/marked/package.json +26 -34
  181. package/runtime/model-metadata-overrides.ts +10 -1
  182. package/runtime/pid-schema.ts +26 -6
  183. package/skills/tallow-expert/SKILL.md +1 -3
  184. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts +0 -32
  185. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts.map +0 -1
  186. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js +0 -46
  187. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js.map +0 -1
  188. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts +0 -52
  189. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts.map +0 -1
  190. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js +0 -89
  191. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js.map +0 -1
  192. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts +0 -14
  193. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts.map +0 -1
  194. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js +0 -55
  195. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js.map +0 -1
  196. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-change-listener.test.ts +0 -121
  197. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-ghost-text.test.ts +0 -112
  198. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +0 -134
  199. package/node_modules/@mariozechner/pi-tui/src/__tests__/settings-list.test.ts +0 -49
  200. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +0 -555
  201. package/node_modules/@mariozechner/pi-tui/src/border-styles.ts +0 -60
  202. package/node_modules/@mariozechner/pi-tui/src/components/bordered-box.ts +0 -113
  203. package/node_modules/@mariozechner/pi-tui/src/test-utils/capability-env.ts +0 -56
  204. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs +0 -2211
  205. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs.map +0 -7
  206. package/packages/tallow-tui/node_modules/marked/lib/marked.d.cts +0 -728
  207. package/packages/tallow-tui/node_modules/marked/marked.min.js +0 -69
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Wrap visible text in an OSC 8 terminal hyperlink.
3
+ *
4
+ * @param url - Target URL
5
+ * @param text - Visible link text
6
+ * @returns OSC 8 hyperlink sequence
7
+ */
8
+ export function hyperlink(url: string, text: string): string {
9
+ return `\u001b]8;;${url}\u0007${text}\u001b]8;;\u0007`;
10
+ }
11
+
12
+ /**
13
+ * Wrap a file path in an OSC 8 hyperlink using the file:// protocol.
14
+ *
15
+ * @param filePath - Absolute or relative file path
16
+ * @param displayText - Optional visible text override
17
+ * @returns File path wrapped in a file:// OSC 8 hyperlink
18
+ */
19
+ export function fileLink(filePath: string, displayText?: string): string {
20
+ const url = `file://${encodeURI(filePath)}`;
21
+ return hyperlink(url, displayText ?? filePath);
22
+ }
@@ -9,7 +9,6 @@ import {
9
9
  Editor,
10
10
  type EditorTheme,
11
11
  Key,
12
- Loader,
13
12
  matchesKey,
14
13
  Text,
15
14
  truncateToWidth,
@@ -153,8 +152,6 @@ WHEN NOT TO USE:
153
152
  { label: "Type something.", isOther: true },
154
153
  ];
155
154
 
156
- ctx.ui.setWorkingMessage(Loader.HIDE);
157
-
158
155
  const result = await ctx.ui.custom<{
159
156
  answer: string;
160
157
  wasCustom: boolean;
@@ -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 }> = [];
@@ -32,7 +165,141 @@ describe("clear extension", () => {
32
165
  registerClear(pi);
33
166
 
34
167
  const newSession = mock(() => Promise.resolve());
35
- await handler!("", { newSession });
168
+ await handler?.("", { newSession });
169
+ expect(newSession).toHaveBeenCalledTimes(1);
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
+
36
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
+ }
37
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
  }