@dungle-scrubs/tallow 0.9.4 → 0.9.7

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 (212) hide show
  1. package/dist/cli.js +8 -5
  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/process-cleanup.js +1 -1
  26. package/dist/process-cleanup.js.map +1 -1
  27. package/dist/reset-diagnostics.d.ts +69 -0
  28. package/dist/reset-diagnostics.d.ts.map +1 -0
  29. package/dist/reset-diagnostics.js +41 -0
  30. package/dist/reset-diagnostics.js.map +1 -0
  31. package/dist/sdk.d.ts +7 -23
  32. package/dist/sdk.d.ts.map +1 -1
  33. package/dist/sdk.js +211 -174
  34. package/dist/sdk.js.map +1 -1
  35. package/dist/workspace-transition-interactive.d.ts +1 -0
  36. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  37. package/dist/workspace-transition-interactive.js +8 -18
  38. package/dist/workspace-transition-interactive.js.map +1 -1
  39. package/extensions/__integration__/audit-findings.test.ts +4 -5
  40. package/extensions/_icons/index.ts +2 -4
  41. package/extensions/_shared/__tests__/image-metadata.test.ts +33 -0
  42. package/extensions/_shared/__tests__/shell-policy.test.ts +19 -0
  43. package/extensions/_shared/__tests__/terminal-links.test.ts +18 -0
  44. package/extensions/_shared/image-metadata.ts +99 -0
  45. package/extensions/_shared/inline-preview.ts +1 -1
  46. package/extensions/_shared/shell-policy.ts +121 -1
  47. package/extensions/_shared/terminal-links.ts +22 -0
  48. package/extensions/ask-user-question-tool/index.ts +0 -3
  49. package/extensions/clear/__tests__/clear.test.ts +269 -2
  50. package/extensions/command-expansion/index.ts +9 -3
  51. package/extensions/context-files/index.ts +5 -1
  52. package/extensions/context-fork/__tests__/context-fork.test.ts +94 -1
  53. package/extensions/context-fork/extension.json +1 -1
  54. package/extensions/context-fork/frontmatter-index.ts +6 -1
  55. package/extensions/context-fork/index.ts +32 -0
  56. package/extensions/edit-tool-enhanced/index.ts +2 -1
  57. package/extensions/git-status/__tests__/git-status.test.ts +65 -2
  58. package/extensions/git-status/index.ts +268 -98
  59. package/extensions/hooks/index.ts +33 -11
  60. package/extensions/loop/index.ts +14 -1
  61. package/extensions/lsp/index.ts +64 -13
  62. package/extensions/lsp/package.json +2 -2
  63. package/extensions/minimal-skill-display/index.ts +7 -1
  64. package/extensions/random-spinner/index.ts +7 -642
  65. package/extensions/read-tool-enhanced/index.ts +13 -10
  66. package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +2 -3
  67. package/extensions/render-stabilizer/index.ts +6 -6
  68. package/extensions/rewind/__tests__/session-files.test.ts +115 -0
  69. package/extensions/rewind/__tests__/snapshots.test.ts +23 -0
  70. package/extensions/rewind/index.ts +5 -0
  71. package/extensions/rewind/session-files.ts +138 -0
  72. package/extensions/rewind/snapshots.ts +104 -5
  73. package/extensions/skill-commands/index.ts +6 -1
  74. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +26 -0
  75. package/extensions/slash-command-bridge/index.ts +14 -2
  76. package/extensions/subagent-tool/model-resolver.ts +274 -7
  77. package/extensions/subagent-tool/schema.ts +1 -2
  78. package/extensions/tasks/commands/register-tasks-extension.ts +9 -9
  79. package/extensions/teams-tool/tools/register-extension.ts +1 -3
  80. package/extensions/teams-tool/tools/teammate-tools.ts +1 -2
  81. package/extensions/web-search-tool/index.ts +2 -1
  82. package/extensions/wezterm-pane-control/index.ts +1 -2
  83. package/extensions/write-tool-enhanced/index.ts +2 -1
  84. package/node_modules/@mariozechner/pi-tui/README.md +56 -34
  85. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts +18 -13
  86. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts.map +1 -1
  87. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js +182 -113
  88. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js.map +1 -1
  89. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js +3 -3
  90. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js.map +1 -1
  91. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts +45 -36
  92. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts.map +1 -1
  93. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js +489 -325
  94. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js.map +1 -1
  95. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts +1 -99
  96. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts.map +1 -1
  97. package/node_modules/@mariozechner/pi-tui/dist/components/image.js +17 -192
  98. package/node_modules/@mariozechner/pi-tui/dist/components/image.js.map +1 -1
  99. package/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts.map +1 -1
  100. package/node_modules/@mariozechner/pi-tui/dist/components/input.js +57 -60
  101. package/node_modules/@mariozechner/pi-tui/dist/components/input.js.map +1 -1
  102. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts +2 -69
  103. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts.map +1 -1
  104. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js +5 -102
  105. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js.map +1 -1
  106. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts.map +1 -1
  107. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js +111 -53
  108. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js.map +1 -1
  109. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts +19 -1
  110. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts.map +1 -1
  111. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js +78 -67
  112. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js.map +1 -1
  113. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts +1 -25
  114. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts.map +1 -1
  115. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js +13 -50
  116. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js.map +1 -1
  117. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +8 -10
  118. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  119. package/node_modules/@mariozechner/pi-tui/dist/index.js +6 -9
  120. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  121. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +108 -238
  122. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  123. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +108 -365
  124. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  125. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +33 -48
  126. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  127. package/node_modules/@mariozechner/pi-tui/dist/keys.js +239 -155
  128. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  129. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts +14 -94
  130. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts.map +1 -1
  131. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js +44 -186
  132. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js.map +1 -1
  133. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +13 -58
  134. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  135. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +78 -111
  136. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  137. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +24 -110
  138. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  139. package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -435
  140. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  141. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts +0 -18
  142. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts.map +1 -1
  143. package/node_modules/@mariozechner/pi-tui/dist/utils.js +251 -119
  144. package/node_modules/@mariozechner/pi-tui/dist/utils.js.map +1 -1
  145. package/node_modules/@mariozechner/pi-tui/package.json +6 -6
  146. package/node_modules/@mariozechner/pi-tui/src/__tests__/__snapshots__/render.test.ts.snap +3 -40
  147. package/node_modules/@mariozechner/pi-tui/src/__tests__/image-component.test.ts +71 -81
  148. package/node_modules/@mariozechner/pi-tui/src/__tests__/render.test.ts +0 -33
  149. package/node_modules/@mariozechner/pi-tui/src/__tests__/terminal-image.test.ts +93 -334
  150. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +1 -1
  151. package/node_modules/@mariozechner/pi-tui/src/__tests__/utils.test.ts +11 -196
  152. package/node_modules/@mariozechner/pi-tui/src/autocomplete.ts +228 -142
  153. package/node_modules/@mariozechner/pi-tui/src/components/cancellable-loader.ts +3 -3
  154. package/node_modules/@mariozechner/pi-tui/src/components/editor.ts +624 -390
  155. package/node_modules/@mariozechner/pi-tui/src/components/image.ts +17 -227
  156. package/node_modules/@mariozechner/pi-tui/src/components/input.ts +71 -63
  157. package/node_modules/@mariozechner/pi-tui/src/components/loader.ts +5 -137
  158. package/node_modules/@mariozechner/pi-tui/src/components/markdown.ts +143 -52
  159. package/node_modules/@mariozechner/pi-tui/src/components/select-list.ts +136 -70
  160. package/node_modules/@mariozechner/pi-tui/src/components/settings-list.ts +12 -51
  161. package/node_modules/@mariozechner/pi-tui/src/index.ts +17 -36
  162. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +148 -421
  163. package/node_modules/@mariozechner/pi-tui/src/keys.ts +253 -181
  164. package/node_modules/@mariozechner/pi-tui/src/terminal-image.ts +51 -252
  165. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +78 -133
  166. package/node_modules/@mariozechner/pi-tui/src/tui.ts +202 -478
  167. package/node_modules/@mariozechner/pi-tui/src/utils.ts +289 -125
  168. package/node_modules/@mariozechner/pi-tui/tsconfig.build.json +1 -0
  169. package/package.json +13 -13
  170. package/packages/tallow-tui/node_modules/@types/mime-types/README.md +8 -2
  171. package/packages/tallow-tui/node_modules/@types/mime-types/index.d.ts +6 -0
  172. package/packages/tallow-tui/node_modules/@types/mime-types/package.json +9 -3
  173. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup-data.js +18 -0
  174. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup.js +116 -384
  175. package/packages/tallow-tui/node_modules/get-east-asian-width/package.json +5 -4
  176. package/packages/tallow-tui/node_modules/get-east-asian-width/utilities.js +24 -0
  177. package/packages/tallow-tui/node_modules/marked/README.md +5 -4
  178. package/packages/tallow-tui/node_modules/marked/bin/main.js +10 -8
  179. package/packages/tallow-tui/node_modules/marked/bin/marked.js +2 -1
  180. package/packages/tallow-tui/node_modules/marked/lib/marked.d.ts +156 -125
  181. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js +67 -2179
  182. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js.map +3 -3
  183. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js +67 -2201
  184. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js.map +3 -3
  185. package/packages/tallow-tui/node_modules/marked/man/marked.1 +4 -2
  186. package/packages/tallow-tui/node_modules/marked/man/marked.1.md +2 -1
  187. package/packages/tallow-tui/node_modules/marked/package.json +26 -34
  188. package/skills/tallow-expert/SKILL.md +3 -5
  189. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts +0 -32
  190. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts.map +0 -1
  191. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js +0 -46
  192. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js.map +0 -1
  193. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts +0 -52
  194. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts.map +0 -1
  195. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js +0 -89
  196. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js.map +0 -1
  197. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts +0 -14
  198. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts.map +0 -1
  199. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js +0 -55
  200. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js.map +0 -1
  201. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-change-listener.test.ts +0 -121
  202. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-ghost-text.test.ts +0 -112
  203. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +0 -134
  204. package/node_modules/@mariozechner/pi-tui/src/__tests__/settings-list.test.ts +0 -81
  205. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +0 -555
  206. package/node_modules/@mariozechner/pi-tui/src/border-styles.ts +0 -60
  207. package/node_modules/@mariozechner/pi-tui/src/components/bordered-box.ts +0 -113
  208. package/node_modules/@mariozechner/pi-tui/src/test-utils/capability-env.ts +0 -56
  209. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs +0 -2211
  210. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs.map +0 -7
  211. package/packages/tallow-tui/node_modules/marked/lib/marked.d.cts +0 -728
  212. package/packages/tallow-tui/node_modules/marked/marked.min.js +0 -69
@@ -14,7 +14,7 @@
14
14
  * - audit trail recording
15
15
  */
16
16
 
17
- import { spawnSync } from "node:child_process";
17
+ import { execFile, spawnSync } from "node:child_process";
18
18
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { dirname, isAbsolute, join } from "node:path";
@@ -1106,6 +1106,102 @@ export function runCommandSync(options: RunCommandOptions): ProcessRunResult {
1106
1106
  };
1107
1107
  }
1108
1108
 
1109
+ /**
1110
+ * Run a non-shell process asynchronously with centralized policy and audit logging.
1111
+ *
1112
+ * @param options - Process invocation options
1113
+ * @returns Structured process result including policy-blocked state
1114
+ */
1115
+ export async function runCommand(options: RunCommandOptions): Promise<ProcessRunResult> {
1116
+ const commandLine = [options.command, ...options.args].join(" ").trim();
1117
+ const verdict = evaluateCommand(commandLine, options.source, options.cwd);
1118
+ if (!verdict.allowed) {
1119
+ recordAudit({
1120
+ timestamp: Date.now(),
1121
+ command: verdict.normalizedCommand,
1122
+ source: options.source,
1123
+ trustLevel: verdict.trustLevel,
1124
+ cwd: options.cwd,
1125
+ outcome: "blocked",
1126
+ reason: verdict.reason,
1127
+ });
1128
+ return {
1129
+ ok: false,
1130
+ blocked: true,
1131
+ stdout: "",
1132
+ stderr: "",
1133
+ exitCode: null,
1134
+ reason: verdict.reason,
1135
+ };
1136
+ }
1137
+
1138
+ const timeoutMs = clampTimeout(options.timeoutMs);
1139
+ const startedAt = Date.now();
1140
+
1141
+ return await new Promise((resolve) => {
1142
+ execFile(
1143
+ options.command,
1144
+ [...options.args],
1145
+ {
1146
+ cwd: options.cwd,
1147
+ encoding: "utf-8",
1148
+ timeout: timeoutMs,
1149
+ maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
1150
+ },
1151
+ (error, stdout = "", stderr = "") => {
1152
+ const exitCode = typeof error?.code === "number" ? error.code : 0;
1153
+ const durationMs = Date.now() - startedAt;
1154
+ const normalizedStdout = stdout.toString();
1155
+ const normalizedStderr = stderr.toString();
1156
+ if (error) {
1157
+ const reason =
1158
+ error.killed && error.signal === "SIGTERM"
1159
+ ? `Command timed out after ${timeoutMs}ms`
1160
+ : error.message;
1161
+ recordAudit({
1162
+ timestamp: Date.now(),
1163
+ command: verdict.normalizedCommand,
1164
+ source: options.source,
1165
+ trustLevel: verdict.trustLevel,
1166
+ cwd: options.cwd,
1167
+ outcome: "failed",
1168
+ reason,
1169
+ exitCode: typeof error?.code === "number" ? error.code : null,
1170
+ durationMs,
1171
+ });
1172
+ resolve({
1173
+ ok: false,
1174
+ blocked: false,
1175
+ stdout: normalizedStdout,
1176
+ stderr: normalizedStderr,
1177
+ exitCode: typeof error?.code === "number" ? error.code : null,
1178
+ reason,
1179
+ });
1180
+ return;
1181
+ }
1182
+
1183
+ recordAudit({
1184
+ timestamp: Date.now(),
1185
+ command: verdict.normalizedCommand,
1186
+ source: options.source,
1187
+ trustLevel: verdict.trustLevel,
1188
+ cwd: options.cwd,
1189
+ outcome: "executed",
1190
+ exitCode,
1191
+ durationMs,
1192
+ });
1193
+ resolve({
1194
+ ok: true,
1195
+ blocked: false,
1196
+ stdout: normalizedStdout,
1197
+ stderr: normalizedStderr,
1198
+ exitCode,
1199
+ });
1200
+ }
1201
+ );
1202
+ });
1203
+ }
1204
+
1109
1205
  /**
1110
1206
  * Run a git command through centralized policy and process wrapper.
1111
1207
  *
@@ -1130,6 +1226,30 @@ export function runGitCommandSync(
1130
1226
  return result.stdout.trim();
1131
1227
  }
1132
1228
 
1229
+ /**
1230
+ * Run a git command asynchronously through centralized policy and process wrapper.
1231
+ *
1232
+ * @param args - Git arguments
1233
+ * @param cwd - Working directory
1234
+ * @param timeoutMs - Optional timeout override
1235
+ * @returns Trimmed stdout on success, null otherwise
1236
+ */
1237
+ export async function runGitCommand(
1238
+ args: readonly string[],
1239
+ cwd: string,
1240
+ timeoutMs?: number
1241
+ ): Promise<string | null> {
1242
+ const result = await runCommand({
1243
+ command: "git",
1244
+ args,
1245
+ cwd,
1246
+ source: "git-helper",
1247
+ timeoutMs,
1248
+ });
1249
+ if (!result.ok) return null;
1250
+ return result.stdout.trim();
1251
+ }
1252
+
1133
1253
  /**
1134
1254
  * Check whether an executable exists on PATH using policy-wrapped process execution.
1135
1255
  *
@@ -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 }> = [];
@@ -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
  });
@@ -19,7 +19,7 @@ import * as fs from "node:fs";
19
19
  import * as os from "node:os";
20
20
  import * as path from "node:path";
21
21
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
22
- import { loadSkills, stripFrontmatter } from "@mariozechner/pi-coding-agent";
22
+ import { getAgentDir, loadSkills, stripFrontmatter } from "@mariozechner/pi-coding-agent";
23
23
  import { createLazyInitializer } from "../_shared/lazy-init.js";
24
24
  import { isProjectTrusted } from "../_shared/project-trust.js";
25
25
 
@@ -52,7 +52,13 @@ interface CommandExpansionDependencies {
52
52
 
53
53
  /** Default dependency implementation for production runtime. */
54
54
  const DEFAULT_COMMAND_EXPANSION_DEPENDENCIES: CommandExpansionDependencies = {
55
- loadSkills,
55
+ loadSkills: () =>
56
+ loadSkills({
57
+ cwd: process.cwd(),
58
+ agentDir: getAgentDir(),
59
+ skillPaths: [],
60
+ includeDefaults: true,
61
+ }),
56
62
  loadPromptTemplates,
57
63
  };
58
64
 
@@ -418,7 +424,7 @@ export function registerCommandExpansionExtension(
418
424
 
419
425
  // Only process if it looks like a command with arguments
420
426
  const split = splitOuterCommand(text);
421
- if (!split || !split.args) {
427
+ if (!split?.args) {
422
428
  return { action: "continue" as const };
423
429
  }
424
430
 
@@ -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",
@@ -297,7 +297,12 @@ export function buildFrontmatterIndex(debugLog?: (msg: string) => void): Frontma
297
297
 
298
298
  // Skills: skill name as-is
299
299
  try {
300
- const { skills } = loadSkills();
300
+ const { skills } = loadSkills({
301
+ cwd: process.cwd(),
302
+ agentDir: getAgentDir(),
303
+ skillPaths: [],
304
+ includeDefaults: true,
305
+ });
301
306
  for (const skill of skills) {
302
307
  maybeAdd(skill.name, skill.filePath);
303
308
  }