@dungle-scrubs/tallow 0.8.21 → 0.8.23

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 (217) hide show
  1. package/dist/cli.js +35 -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 +2 -0
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +82 -0
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/sdk.d.ts +17 -0
  10. package/dist/sdk.d.ts.map +1 -1
  11. package/dist/sdk.js +68 -1
  12. package/dist/sdk.js.map +1 -1
  13. package/dist/workspace-transition-relay.d.ts +40 -7
  14. package/dist/workspace-transition-relay.d.ts.map +1 -1
  15. package/dist/workspace-transition-relay.js +81 -16
  16. package/dist/workspace-transition-relay.js.map +1 -1
  17. package/extensions/__integration__/background-task-widget-ownership.test.ts +216 -0
  18. package/extensions/__integration__/claude-hooks-compat.test.ts +156 -0
  19. package/extensions/__integration__/slash-command-bridge.test.ts +169 -23
  20. package/extensions/_shared/atomic-write.ts +1 -1
  21. package/extensions/_shared/bordered-box.ts +102 -0
  22. package/extensions/_shared/interop-events.ts +5 -0
  23. package/extensions/_shared/pid-registry.ts +1 -1
  24. package/extensions/agent-commands-tool/index.ts +4 -1
  25. package/extensions/background-task-tool/__tests__/lifecycle.test.ts +50 -25
  26. package/extensions/background-task-tool/index.ts +139 -221
  27. package/extensions/bash-tool-enhanced/index.ts +1 -75
  28. package/extensions/cd-tool/index.ts +2 -2
  29. package/extensions/context-fork/spawn.ts +4 -1
  30. package/extensions/health/index.ts +6 -6
  31. package/extensions/hooks/__tests__/claude-compat.test.ts +35 -0
  32. package/extensions/hooks/__tests__/subprocess-hardening.test.ts +73 -0
  33. package/extensions/hooks/index.ts +27 -4
  34. package/extensions/loop/__tests__/loop.test.ts +168 -4
  35. package/extensions/loop/extension.json +6 -5
  36. package/extensions/loop/index.ts +242 -31
  37. package/extensions/plan-mode-tool/__tests__/agent-end-execution.test.ts +373 -0
  38. package/extensions/plan-mode-tool/index.ts +103 -41
  39. package/extensions/prompt-suggestions/__tests__/editor-compatibility.test.ts +42 -0
  40. package/extensions/prompt-suggestions/index.ts +41 -6
  41. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +267 -671
  42. package/extensions/slash-command-bridge/extension.json +1 -1
  43. package/extensions/slash-command-bridge/index.ts +230 -116
  44. package/extensions/subagent-tool/index.ts +2 -2
  45. package/extensions/subagent-tool/process.ts +4 -5
  46. package/extensions/tasks/commands/register-tasks-extension.ts +41 -0
  47. package/extensions/teams-tool/__tests__/peer-messaging.test.ts +29 -24
  48. package/extensions/teams-tool/dashboard.ts +3 -5
  49. package/extensions/teams-tool/dispatch/auto-dispatch.ts +18 -1
  50. package/extensions/teams-tool/tools/teammate-tools.ts +9 -6
  51. package/extensions/wezterm-pane-control/__tests__/index.test.ts +88 -4
  52. package/extensions/wezterm-pane-control/index.ts +113 -8
  53. package/package.json +6 -4
  54. package/packages/tallow-tui/README.md +51 -0
  55. package/packages/tallow-tui/dist/autocomplete.d.ts +48 -0
  56. package/packages/tallow-tui/dist/autocomplete.d.ts.map +1 -0
  57. package/packages/tallow-tui/dist/autocomplete.js +564 -0
  58. package/packages/tallow-tui/dist/autocomplete.js.map +1 -0
  59. package/packages/tallow-tui/dist/border-styles.d.ts +32 -0
  60. package/packages/tallow-tui/dist/border-styles.d.ts.map +1 -0
  61. package/packages/tallow-tui/dist/border-styles.js +46 -0
  62. package/packages/tallow-tui/dist/border-styles.js.map +1 -0
  63. package/packages/tallow-tui/dist/components/bordered-box.d.ts +52 -0
  64. package/packages/tallow-tui/dist/components/bordered-box.d.ts.map +1 -0
  65. package/packages/tallow-tui/dist/components/bordered-box.js +89 -0
  66. package/packages/tallow-tui/dist/components/bordered-box.js.map +1 -0
  67. package/packages/tallow-tui/dist/components/box.d.ts +22 -0
  68. package/packages/tallow-tui/dist/components/box.d.ts.map +1 -0
  69. package/packages/tallow-tui/dist/components/box.js +104 -0
  70. package/packages/tallow-tui/dist/components/box.js.map +1 -0
  71. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts +22 -0
  72. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts.map +1 -0
  73. package/packages/tallow-tui/dist/components/cancellable-loader.js +35 -0
  74. package/packages/tallow-tui/dist/components/cancellable-loader.js.map +1 -0
  75. package/packages/tallow-tui/dist/components/editor.d.ts +240 -0
  76. package/packages/tallow-tui/dist/components/editor.d.ts.map +1 -0
  77. package/packages/tallow-tui/dist/components/editor.js +1766 -0
  78. package/packages/tallow-tui/dist/components/editor.js.map +1 -0
  79. package/packages/tallow-tui/dist/components/image.d.ts +126 -0
  80. package/packages/tallow-tui/dist/components/image.d.ts.map +1 -0
  81. package/packages/tallow-tui/dist/components/image.js +245 -0
  82. package/packages/tallow-tui/dist/components/image.js.map +1 -0
  83. package/packages/tallow-tui/dist/components/input.d.ts +37 -0
  84. package/packages/tallow-tui/dist/components/input.d.ts.map +1 -0
  85. package/packages/tallow-tui/dist/components/input.js +439 -0
  86. package/packages/tallow-tui/dist/components/input.js.map +1 -0
  87. package/packages/tallow-tui/dist/components/loader.d.ts +88 -0
  88. package/packages/tallow-tui/dist/components/loader.d.ts.map +1 -0
  89. package/packages/tallow-tui/dist/components/loader.js +146 -0
  90. package/packages/tallow-tui/dist/components/loader.js.map +1 -0
  91. package/packages/tallow-tui/dist/components/markdown.d.ts +95 -0
  92. package/packages/tallow-tui/dist/components/markdown.d.ts.map +1 -0
  93. package/packages/tallow-tui/dist/components/markdown.js +633 -0
  94. package/packages/tallow-tui/dist/components/markdown.js.map +1 -0
  95. package/packages/tallow-tui/dist/components/select-list.d.ts +32 -0
  96. package/packages/tallow-tui/dist/components/select-list.d.ts.map +1 -0
  97. package/packages/tallow-tui/dist/components/select-list.js +156 -0
  98. package/packages/tallow-tui/dist/components/select-list.js.map +1 -0
  99. package/packages/tallow-tui/dist/components/settings-list.d.ts +50 -0
  100. package/packages/tallow-tui/dist/components/settings-list.d.ts.map +1 -0
  101. package/packages/tallow-tui/dist/components/settings-list.js +189 -0
  102. package/packages/tallow-tui/dist/components/settings-list.js.map +1 -0
  103. package/packages/tallow-tui/dist/components/spacer.d.ts +12 -0
  104. package/packages/tallow-tui/dist/components/spacer.d.ts.map +1 -0
  105. package/packages/tallow-tui/dist/components/spacer.js +23 -0
  106. package/packages/tallow-tui/dist/components/spacer.js.map +1 -0
  107. package/packages/tallow-tui/dist/components/text.d.ts +19 -0
  108. package/packages/tallow-tui/dist/components/text.d.ts.map +1 -0
  109. package/packages/tallow-tui/dist/components/text.js +91 -0
  110. package/packages/tallow-tui/dist/components/text.js.map +1 -0
  111. package/packages/tallow-tui/dist/components/truncated-text.d.ts +13 -0
  112. package/packages/tallow-tui/dist/components/truncated-text.d.ts.map +1 -0
  113. package/packages/tallow-tui/dist/components/truncated-text.js +51 -0
  114. package/packages/tallow-tui/dist/components/truncated-text.js.map +1 -0
  115. package/packages/tallow-tui/dist/editor-component.d.ts +50 -0
  116. package/packages/tallow-tui/dist/editor-component.d.ts.map +1 -0
  117. package/packages/tallow-tui/dist/editor-component.js +2 -0
  118. package/packages/tallow-tui/dist/editor-component.js.map +1 -0
  119. package/packages/tallow-tui/dist/fuzzy.d.ts +16 -0
  120. package/packages/tallow-tui/dist/fuzzy.d.ts.map +1 -0
  121. package/packages/tallow-tui/dist/fuzzy.js +107 -0
  122. package/packages/tallow-tui/dist/fuzzy.js.map +1 -0
  123. package/packages/tallow-tui/dist/index.d.ts +25 -0
  124. package/packages/tallow-tui/dist/index.d.ts.map +1 -0
  125. package/packages/tallow-tui/dist/index.js +35 -0
  126. package/packages/tallow-tui/dist/index.js.map +1 -0
  127. package/packages/tallow-tui/dist/keybindings.d.ts +39 -0
  128. package/packages/tallow-tui/dist/keybindings.d.ts.map +1 -0
  129. package/packages/tallow-tui/dist/keybindings.js +114 -0
  130. package/packages/tallow-tui/dist/keybindings.js.map +1 -0
  131. package/packages/tallow-tui/dist/keys.d.ts +168 -0
  132. package/packages/tallow-tui/dist/keys.d.ts.map +1 -0
  133. package/packages/tallow-tui/dist/keys.js +971 -0
  134. package/packages/tallow-tui/dist/keys.js.map +1 -0
  135. package/packages/tallow-tui/dist/kill-ring.d.ts +28 -0
  136. package/packages/tallow-tui/dist/kill-ring.d.ts.map +1 -0
  137. package/packages/tallow-tui/dist/kill-ring.js +44 -0
  138. package/packages/tallow-tui/dist/kill-ring.js.map +1 -0
  139. package/packages/tallow-tui/dist/stdin-buffer.d.ts +48 -0
  140. package/packages/tallow-tui/dist/stdin-buffer.d.ts.map +1 -0
  141. package/packages/tallow-tui/dist/stdin-buffer.js +317 -0
  142. package/packages/tallow-tui/dist/stdin-buffer.js.map +1 -0
  143. package/packages/tallow-tui/dist/terminal-image.d.ts +161 -0
  144. package/packages/tallow-tui/dist/terminal-image.d.ts.map +1 -0
  145. package/packages/tallow-tui/dist/terminal-image.js +460 -0
  146. package/packages/tallow-tui/dist/terminal-image.js.map +1 -0
  147. package/packages/tallow-tui/dist/terminal.d.ts +102 -0
  148. package/packages/tallow-tui/dist/terminal.d.ts.map +1 -0
  149. package/packages/tallow-tui/dist/terminal.js +263 -0
  150. package/packages/tallow-tui/dist/terminal.js.map +1 -0
  151. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts +14 -0
  152. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts.map +1 -0
  153. package/packages/tallow-tui/dist/test-utils/capability-env.js +55 -0
  154. package/packages/tallow-tui/dist/test-utils/capability-env.js.map +1 -0
  155. package/packages/tallow-tui/dist/tui.d.ts +239 -0
  156. package/packages/tallow-tui/dist/tui.d.ts.map +1 -0
  157. package/packages/tallow-tui/dist/tui.js +1058 -0
  158. package/packages/tallow-tui/dist/tui.js.map +1 -0
  159. package/packages/tallow-tui/dist/undo-stack.d.ts +17 -0
  160. package/packages/tallow-tui/dist/undo-stack.d.ts.map +1 -0
  161. package/packages/tallow-tui/dist/undo-stack.js +25 -0
  162. package/packages/tallow-tui/dist/undo-stack.js.map +1 -0
  163. package/packages/tallow-tui/dist/utils.d.ts +96 -0
  164. package/packages/tallow-tui/dist/utils.d.ts.map +1 -0
  165. package/packages/tallow-tui/dist/utils.js +843 -0
  166. package/packages/tallow-tui/dist/utils.js.map +1 -0
  167. package/packages/tallow-tui/package.json +24 -0
  168. package/packages/tallow-tui/src/__tests__/__snapshots__/render.test.ts.snap +121 -0
  169. package/packages/tallow-tui/src/__tests__/editor-border.test.ts +72 -0
  170. package/packages/tallow-tui/src/__tests__/editor-change-listener.test.ts +121 -0
  171. package/packages/tallow-tui/src/__tests__/editor-ghost-text.test.ts +112 -0
  172. package/packages/tallow-tui/src/__tests__/fuzzy.test.ts +91 -0
  173. package/packages/tallow-tui/src/__tests__/image-component.test.ts +113 -0
  174. package/packages/tallow-tui/src/__tests__/keys.test.ts +141 -0
  175. package/packages/tallow-tui/src/__tests__/render.test.ts +179 -0
  176. package/packages/tallow-tui/src/__tests__/stdin-buffer.test.ts +82 -0
  177. package/packages/tallow-tui/src/__tests__/terminal-image.test.ts +363 -0
  178. package/packages/tallow-tui/src/__tests__/tui-diff-regression.test.ts +454 -0
  179. package/packages/tallow-tui/src/__tests__/tui-render-scheduling.test.ts +256 -0
  180. package/packages/tallow-tui/src/__tests__/utils.test.ts +259 -0
  181. package/packages/tallow-tui/src/autocomplete.ts +716 -0
  182. package/packages/tallow-tui/src/border-styles.ts +60 -0
  183. package/packages/tallow-tui/src/components/bordered-box.ts +113 -0
  184. package/packages/tallow-tui/src/components/box.ts +137 -0
  185. package/packages/tallow-tui/src/components/cancellable-loader.ts +40 -0
  186. package/packages/tallow-tui/src/components/editor.ts +2143 -0
  187. package/packages/tallow-tui/src/components/image.ts +315 -0
  188. package/packages/tallow-tui/src/components/input.ts +522 -0
  189. package/packages/tallow-tui/src/components/loader.ts +187 -0
  190. package/packages/tallow-tui/src/components/markdown.ts +780 -0
  191. package/packages/tallow-tui/src/components/select-list.ts +197 -0
  192. package/packages/tallow-tui/src/components/settings-list.ts +264 -0
  193. package/packages/tallow-tui/src/components/spacer.ts +28 -0
  194. package/packages/tallow-tui/src/components/text.ts +113 -0
  195. package/packages/tallow-tui/src/components/truncated-text.ts +65 -0
  196. package/packages/tallow-tui/src/editor-component.ts +92 -0
  197. package/packages/tallow-tui/src/fuzzy.ts +133 -0
  198. package/packages/tallow-tui/src/index.ts +118 -0
  199. package/packages/tallow-tui/src/keybindings.ts +183 -0
  200. package/packages/tallow-tui/src/keys.ts +1189 -0
  201. package/packages/tallow-tui/src/kill-ring.ts +46 -0
  202. package/packages/tallow-tui/src/stdin-buffer.ts +386 -0
  203. package/packages/tallow-tui/src/terminal-image.ts +619 -0
  204. package/packages/tallow-tui/src/terminal.ts +350 -0
  205. package/packages/tallow-tui/src/test-utils/capability-env.ts +56 -0
  206. package/packages/tallow-tui/src/tui.ts +1336 -0
  207. package/packages/tallow-tui/src/undo-stack.ts +28 -0
  208. package/packages/tallow-tui/src/utils.ts +948 -0
  209. package/packages/tallow-tui/tsconfig.build.json +21 -0
  210. package/runtime/agent-runner.ts +20 -0
  211. package/runtime/atomic-write.ts +8 -0
  212. package/runtime/otel.ts +12 -0
  213. package/runtime/resolve-module.ts +23 -0
  214. package/runtime/runtime-path-provider.ts +12 -0
  215. package/runtime/runtime-provenance.ts +17 -0
  216. package/runtime/workspace-transition-relay.ts +21 -0
  217. package/runtime/workspace-transition.ts +29 -0
@@ -5,7 +5,7 @@
5
5
  "whenToUse": "Use when the model should invoke slash commands through a tool.",
6
6
  "capabilities": {
7
7
  "tools": ["run_slash_command"],
8
- "events": ["agent_end", "before_agent_start", "session_before_switch"]
8
+ "events": ["before_agent_start", "session_before_switch", "turn_end", "turn_start"]
9
9
  },
10
10
  "permissionSurface": {
11
11
  "filesystem": "none",
@@ -9,15 +9,20 @@
9
9
  * registration, with auto-generated tool schemas and full command handler access.
10
10
  */
11
11
 
12
- import type { ContextUsage, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
+ import type {
13
+ ContextUsage,
14
+ ExtensionAPI,
15
+ ExtensionContext,
16
+ TurnEndEvent,
17
+ } from "@mariozechner/pi-coding-agent";
13
18
  import { Text } from "@mariozechner/pi-tui";
14
19
  import { Type } from "@sinclair/typebox";
15
20
 
16
21
  /**
17
- * Deferred compact request — set by the tool handler, consumed by the
18
- * `agent_end` hook. Deferring avoids the spinner-hang bug where
19
- * `ctx.compact()` aborts the agent mid-tool-call, orphaning the tool
20
- * execution UI component. See plans 95 and 98 for full analysis.
22
+ * Deferred compact request — set by the tool handler, consumed on the first
23
+ * safe assistant `turn_end` after the tool result. Deferring avoids the
24
+ * spinner-hang bug where `ctx.compact()` aborts the agent mid-tool-call,
25
+ * orphaning the tool execution UI component. See plans 95, 98, and 191.
21
26
  */
22
27
  let pendingCompact: { customInstructions?: string } | null = null;
23
28
 
@@ -35,7 +40,7 @@ let resumingAfterCompact = false;
35
40
  * `flushCompactionQueue` or user input already prompted the agent), or
36
41
  * on session switch. See plan 159, bug 1.
37
42
  */
38
- let continuationTimer: ReturnType<typeof setTimeout> | null = null;
43
+ let continuationTimer: SchedulerHandle | null = null;
39
44
 
40
45
  /** Spinner frames for compact progress status updates. */
41
46
  const COMPACT_PROGRESS_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -43,9 +48,39 @@ const COMPACT_PROGRESS_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦"
43
48
  /** Interval cadence for compact progress status updates. */
44
49
  const COMPACT_PROGRESS_INTERVAL_MS = 1000;
45
50
 
51
+ type SchedulerHandle = unknown;
52
+
53
+ /** Timer scheduler used by compact UI and continuation timers. */
54
+ export interface SlashCommandBridgeTimerScheduler {
55
+ readonly now: () => number;
56
+ readonly setInterval: (callback: () => void, intervalMs: number) => SchedulerHandle;
57
+ readonly clearInterval: (handle: SchedulerHandle | null) => void;
58
+ readonly setTimeout: (callback: () => void, delayMs: number) => SchedulerHandle;
59
+ readonly clearTimeout: (handle: SchedulerHandle | null) => void;
60
+ }
61
+
62
+ /** Default runtime timer scheduler. */
63
+ const DEFAULT_TIMER_SCHEDULER: SlashCommandBridgeTimerScheduler = {
64
+ now: () => Date.now(),
65
+ setInterval: (callback, intervalMs) => setInterval(callback, intervalMs),
66
+ clearInterval: (handle) => {
67
+ if (handle) {
68
+ clearInterval(handle as ReturnType<typeof setInterval>);
69
+ }
70
+ },
71
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
72
+ clearTimeout: (handle) => {
73
+ if (handle) {
74
+ clearTimeout(handle as ReturnType<typeof setTimeout>);
75
+ }
76
+ },
77
+ };
78
+
79
+ let timerScheduler: SlashCommandBridgeTimerScheduler = DEFAULT_TIMER_SCHEDULER;
80
+
46
81
  /** Module-level heartbeat state for deferred compact UI updates. */
47
82
  const compactProgressState: {
48
- interval: ReturnType<typeof setInterval> | null;
83
+ interval: SchedulerHandle | null;
49
84
  spinnerIndex: number;
50
85
  startedAt: number;
51
86
  } = {
@@ -55,52 +90,114 @@ const compactProgressState: {
55
90
  };
56
91
 
57
92
  /**
58
- * Starts compact progress heartbeat updates in the footer status.
93
+ * Starts compact progress heartbeat updates as an inline widget above the editor.
59
94
  *
60
95
  * This helper is idempotent: it always clears any previous heartbeat before
61
96
  * starting a new one, preventing duplicate intervals after retries.
62
97
  *
63
- * @param ctx - Extension context used to update footer status
98
+ * @param ctx - Extension context used to render the compact progress widget
64
99
  * @returns Nothing
65
100
  */
66
101
  function startCompactProgress(ctx: ExtensionContext): void {
67
- stopCompactProgress();
102
+ stopCompactProgress(ctx);
68
103
 
69
- if (!ctx.ui?.setStatus) {
104
+ if (!ctx.ui?.setWidget) {
70
105
  return;
71
106
  }
72
107
 
73
- compactProgressState.startedAt = Date.now();
108
+ compactProgressState.startedAt = timerScheduler.now();
74
109
  compactProgressState.spinnerIndex = 0;
75
110
 
76
- const renderStatus = () => {
77
- const elapsedSeconds = Math.floor((Date.now() - compactProgressState.startedAt) / 1000);
111
+ const renderWidget = () => {
112
+ const elapsedSeconds = Math.floor(
113
+ (timerScheduler.now() - compactProgressState.startedAt) / 1000
114
+ );
78
115
  const frame =
79
116
  COMPACT_PROGRESS_FRAMES[compactProgressState.spinnerIndex] ?? COMPACT_PROGRESS_FRAMES[0];
80
- ctx.ui?.setStatus?.("compact", `🧹 ${frame} compacting · ${elapsedSeconds}s`);
117
+ ctx.ui?.setWidget?.("compact-progress", [
118
+ `🧹 ${frame} Compacting session · ${elapsedSeconds}s`,
119
+ ]);
81
120
  compactProgressState.spinnerIndex =
82
121
  (compactProgressState.spinnerIndex + 1) % COMPACT_PROGRESS_FRAMES.length;
83
122
  };
84
123
 
85
- renderStatus();
86
- compactProgressState.interval = setInterval(renderStatus, COMPACT_PROGRESS_INTERVAL_MS);
124
+ renderWidget();
125
+ compactProgressState.interval = timerScheduler.setInterval(
126
+ renderWidget,
127
+ COMPACT_PROGRESS_INTERVAL_MS
128
+ );
87
129
  }
88
130
 
89
131
  /**
90
- * Stops compact progress heartbeat updates and resets module-level state.
132
+ * Stops compact progress heartbeat updates, clears the inline widget, and
133
+ * resets module-level state.
91
134
  *
135
+ * @param ctx - Extension context used to clear the widget (optional for test/cleanup paths)
92
136
  * @returns Nothing
93
137
  */
94
- function stopCompactProgress(): void {
138
+ function stopCompactProgress(ctx?: ExtensionContext): void {
95
139
  if (compactProgressState.interval) {
96
- clearInterval(compactProgressState.interval);
140
+ timerScheduler.clearInterval(compactProgressState.interval);
97
141
  compactProgressState.interval = null;
142
+ // Only clear the widget when a heartbeat was actually running.
143
+ // Avoids a spurious undefined update when startCompactProgress
144
+ // calls this as an idempotent reset before starting fresh.
145
+ ctx?.ui?.setWidget?.("compact-progress", undefined);
98
146
  }
99
147
 
100
148
  compactProgressState.startedAt = 0;
101
149
  compactProgressState.spinnerIndex = 0;
102
150
  }
103
151
 
152
+ /**
153
+ * Cancels the pending continuation timer, if one exists.
154
+ *
155
+ * @returns Nothing
156
+ */
157
+ function clearContinuationTimer(): void {
158
+ if (!continuationTimer) {
159
+ return;
160
+ }
161
+
162
+ timerScheduler.clearTimeout(continuationTimer);
163
+ continuationTimer = null;
164
+ }
165
+
166
+ /**
167
+ * Resets module-level compact state between runs or tests.
168
+ *
169
+ * @returns Nothing
170
+ */
171
+ function clearCompactRuntimeState(): void {
172
+ pendingCompact = null;
173
+ resumingAfterCompact = false;
174
+ stopCompactProgress();
175
+ clearContinuationTimer();
176
+ }
177
+
178
+ /**
179
+ * Installs a deterministic timer scheduler for tests.
180
+ *
181
+ * @param scheduler - Test scheduler implementation
182
+ * @returns Nothing
183
+ */
184
+ export function setSlashCommandBridgeSchedulerForTests(
185
+ scheduler: SlashCommandBridgeTimerScheduler
186
+ ): void {
187
+ clearCompactRuntimeState();
188
+ timerScheduler = scheduler;
189
+ }
190
+
191
+ /**
192
+ * Resets test scheduler/state overrides back to runtime defaults.
193
+ *
194
+ * @returns Nothing
195
+ */
196
+ export function resetSlashCommandBridgeStateForTests(): void {
197
+ clearCompactRuntimeState();
198
+ timerScheduler = DEFAULT_TIMER_SCHEDULER;
199
+ }
200
+
104
201
  /**
105
202
  * Commands the model is allowed to invoke.
106
203
  * Maps command name → whether it's executable from tool context.
@@ -177,6 +274,101 @@ function formatContextUsage(usage: KnownContextUsage): string {
177
274
  return lines.join("\n");
178
275
  }
179
276
 
277
+ /**
278
+ * Returns true when the current turn_end is the first safe compaction boundary.
279
+ *
280
+ * The compact tool always finishes on a `toolUse` turn first. The model then
281
+ * gets one more assistant turn to finish its response after seeing the tool
282
+ * result. Consuming the request on `agent_end` is too late: `session.prompt()`
283
+ * returns before prior `agent_end` extension work fully drains, so a stale
284
+ * `agent_end` from the previous run can steal a newer compact request. The
285
+ * first assistant `turn_end` whose stop reason is not `toolUse` is the proven
286
+ * boundary for starting deferred compaction exactly once.
287
+ *
288
+ * @param event - Turn lifecycle event
289
+ * @returns True when deferred compaction should start now
290
+ */
291
+ function shouldStartDeferredCompactOnTurnEnd(event: TurnEndEvent): boolean {
292
+ return event.message.role === "assistant" && event.message.stopReason !== "toolUse";
293
+ }
294
+
295
+ /**
296
+ * Starts the deferred compact flow and wires continuation/error cleanup.
297
+ *
298
+ * @param pi - Extension API used to enqueue the hidden continuation message
299
+ * @param ctx - Extension context with compact/UI capabilities
300
+ * @param options - Deferred compact request options
301
+ * @returns Nothing
302
+ */
303
+ function startDeferredCompact(
304
+ pi: ExtensionAPI,
305
+ ctx: ExtensionContext,
306
+ options: { customInstructions?: string }
307
+ ): void {
308
+ // Show explicit UI feedback while compaction runs. Without this,
309
+ // users only see the deferred tool message and no live progress signal.
310
+ ctx.ui?.setWorkingMessage?.("Compacting session…");
311
+ startCompactProgress(ctx);
312
+
313
+ ctx.compact({
314
+ customInstructions: options.customInstructions,
315
+ onComplete: () => {
316
+ stopCompactProgress(ctx);
317
+
318
+ // Transition from compaction indicators to resuming indicators.
319
+ // setWorkingMessage queues as pendingWorkingMessage (no loader
320
+ // exists after executeCompaction stops it). Applied automatically
321
+ // when agent_start creates the loader. The inline widget covers
322
+ // the brief gap before the loader appears.
323
+ resumingAfterCompact = true;
324
+ ctx.ui?.setWorkingMessage?.("Resuming task…");
325
+ ctx.ui?.setWidget?.("compact-progress", ["⏳ Resuming after compaction…"]);
326
+
327
+ // Always schedule continuation. Safety nets prevent duplicate prompts:
328
+ // 1. turn_start listener cancels if flushCompactionQueue already started a turn
329
+ // 2. isIdle() check at timer expiry skips if agent is streaming
330
+ // 3. sendCustomMessage queues as steering if agent started mid-delay
331
+ //
332
+ // Previously gated on hasCompactionQueuedMessages(), but that method
333
+ // checked both the compaction queue AND session steering — causing a
334
+ // false positive when steering messages were queued before compact.
335
+ // flushCompactionQueue only processes compactionQueuedMessages, so
336
+ // session steering messages were orphaned. See plan 160.
337
+ //
338
+ // 200ms gives session.prompt()'s async setup (API key resolution,
339
+ // compaction check) time to settle. The turn_start listener cancels
340
+ // this timer if a turn starts before it fires (defense-in-depth).
341
+ continuationTimer = timerScheduler.setTimeout(() => {
342
+ continuationTimer = null;
343
+ if (ctx.isIdle()) {
344
+ pi.sendMessage(
345
+ {
346
+ customType: "compact-continue",
347
+ content:
348
+ "Session compaction is complete. Continue with the task " +
349
+ "you were working on before compaction was triggered.",
350
+ display: false,
351
+ },
352
+ { triggerTurn: true }
353
+ );
354
+ } else {
355
+ // User sent a message during compaction — their turn is
356
+ // handling things, clean up our indicators.
357
+ resumingAfterCompact = false;
358
+ ctx.ui?.setWidget?.("compact-progress", undefined);
359
+ ctx.ui?.setWorkingMessage?.();
360
+ }
361
+ }, 200);
362
+ },
363
+ onError: () => {
364
+ stopCompactProgress(ctx);
365
+ ctx.ui?.setWorkingMessage?.();
366
+ // Framework's executeCompaction handles error/cancel
367
+ // display. No continuation on failure — user decides.
368
+ },
369
+ });
370
+ }
371
+
180
372
  /**
181
373
  * Registers the slash-command-bridge tool and context injection.
182
374
  *
@@ -299,8 +491,8 @@ WHEN NOT TO USE:
299
491
 
300
492
  case "compact": {
301
493
  // Don't call ctx.compact() here — it aborts the agent mid-tool-call,
302
- // orphaning the tool execution spinner (plan 95/98). Defer to the
303
- // agent_end hook so the tool completes normally first.
494
+ // orphaning the tool execution spinner (plan 95/98). Defer to a
495
+ // proven turn_end boundary so the tool completes normally first.
304
496
  pendingCompact = { customInstructions: undefined };
305
497
 
306
498
  return {
@@ -350,93 +542,23 @@ WHEN NOT TO USE:
350
542
  // ── Deferred compact ─────────────────────────────────────────
351
543
 
352
544
  /**
353
- * Fires compact after the agent finishes its turn. This avoids the
354
- * spinner-hang caused by aborting the agent mid-tool-execution.
355
- * The tool sets `pendingCompact`, then agent_end picks it up.
356
- *
357
- * After compaction completes, checks whether the agent is idle. When the
358
- * model triggered compaction (vs. the user typing during it), the
359
- * framework's `flushCompactionQueue` finds no queued messages and the
360
- * agent sits at the input prompt — even though the model promised to
361
- * continue. We fix this by sending a hidden continuation message via
362
- * `pi.sendMessage()` with `triggerTurn: true` to re-prompt the agent.
545
+ * Fires compact on the first safe assistant `turn_end` after the compact tool
546
+ * returns. The immediate `toolUse` turn is too early because the model has not
547
+ * finished its post-tool response yet, while `agent_end` is too late because a
548
+ * stale `agent_end` from the previous run can steal a newer pending request.
363
549
  *
364
- * A short `setTimeout` allows `flushCompactionQueue`'s fire-and-forget
365
- * async path to settle first, so we don't conflict with user-queued
366
- * messages that already restarted the agent.
367
- *
368
- * @see Plan 98 — deferred compact to agent_end (introduced idle-after-compact)
550
+ * @see Plan 98 deferred compact moved out of the tool handler
369
551
  * @see Plan 157 — auto-continue after model-triggered compaction
552
+ * @see Plan 191 — stale prior agent_end could consume a later compact request
370
553
  */
371
- pi.on("agent_end", (_event, ctx) => {
372
- if (!pendingCompact) return;
554
+ pi.on("turn_end", (event, ctx) => {
555
+ if (!pendingCompact || !shouldStartDeferredCompactOnTurnEnd(event)) {
556
+ return;
557
+ }
373
558
 
374
559
  const options = pendingCompact;
375
560
  pendingCompact = null;
376
-
377
- // Show explicit UI feedback while compaction runs. Without this,
378
- // users only see the deferred tool message and no live progress signal.
379
- ctx.ui?.setWorkingMessage?.("Compacting session…");
380
- startCompactProgress(ctx);
381
-
382
- ctx.compact({
383
- customInstructions: options.customInstructions,
384
- onComplete: () => {
385
- stopCompactProgress();
386
-
387
- // Transition from compaction indicators to resuming indicators.
388
- // setWorkingMessage queues as pendingWorkingMessage (no loader
389
- // exists after executeCompaction stops it). Applied automatically
390
- // when agent_start creates the loader. Footer status is visible
391
- // immediately — covers the brief gap before the loader appears.
392
- resumingAfterCompact = true;
393
- ctx.ui?.setWorkingMessage?.("Resuming task…");
394
- ctx.ui?.setStatus?.("compact", "⏳ resuming");
395
-
396
- // Always schedule continuation. Safety nets prevent duplicate prompts:
397
- // 1. turn_start listener cancels if flushCompactionQueue already started a turn
398
- // 2. isIdle() check at timer expiry skips if agent is streaming
399
- // 3. sendCustomMessage queues as steering if agent started mid-delay
400
- //
401
- // Previously gated on hasCompactionQueuedMessages(), but that method
402
- // checked both the compaction queue AND session steering — causing a
403
- // false positive when steering messages were queued before compact.
404
- // flushCompactionQueue only processes compactionQueuedMessages, so
405
- // session steering messages were orphaned. See plan 160.
406
- //
407
- // 200ms gives session.prompt()'s async setup (API key resolution,
408
- // compaction check) time to settle. The turn_start listener cancels
409
- // this timer if a turn starts before it fires (defense-in-depth).
410
- continuationTimer = setTimeout(() => {
411
- continuationTimer = null;
412
- if (ctx.isIdle()) {
413
- pi.sendMessage(
414
- {
415
- customType: "compact-continue",
416
- content:
417
- "Session compaction is complete. Continue with the task " +
418
- "you were working on before compaction was triggered.",
419
- display: false,
420
- },
421
- { triggerTurn: true }
422
- );
423
- } else {
424
- // User sent a message during compaction — their turn is
425
- // handling things, clean up our indicators.
426
- resumingAfterCompact = false;
427
- ctx.ui?.setStatus?.("compact", undefined);
428
- ctx.ui?.setWorkingMessage?.();
429
- }
430
- }, 200);
431
- },
432
- onError: () => {
433
- stopCompactProgress();
434
- ctx.ui?.setWorkingMessage?.();
435
- ctx.ui?.setStatus?.("compact", undefined);
436
- // Framework's executeCompaction handles error/cancel
437
- // display. No continuation on failure — user decides.
438
- },
439
- });
561
+ startDeferredCompact(pi, ctx, options);
440
562
  });
441
563
 
442
564
  /**
@@ -450,13 +572,10 @@ WHEN NOT TO USE:
450
572
  * now active and showing the pending working message ("Resuming task…").
451
573
  */
452
574
  pi.on("turn_start", (_event, ctx) => {
453
- if (continuationTimer) {
454
- clearTimeout(continuationTimer);
455
- continuationTimer = null;
456
- }
575
+ clearContinuationTimer();
457
576
  if (!resumingAfterCompact) return;
458
577
  resumingAfterCompact = false;
459
- ctx.ui?.setStatus?.("compact", undefined);
578
+ ctx.ui?.setWidget?.("compact-progress", undefined);
460
579
  });
461
580
 
462
581
  /**
@@ -464,13 +583,8 @@ WHEN NOT TO USE:
464
583
  * session switches before the turn ends.
465
584
  */
466
585
  pi.on("session_before_switch", (_event, ctx) => {
467
- pendingCompact = null;
468
- resumingAfterCompact = false;
469
- stopCompactProgress();
470
- if (continuationTimer) {
471
- clearTimeout(continuationTimer);
472
- continuationTimer = null;
473
- }
474
- ctx.ui?.setStatus?.("compact", undefined);
586
+ clearCompactRuntimeState();
587
+ ctx.ui?.setWidget?.("compact-progress", undefined);
588
+ ctx.ui?.setWorkingMessage?.();
475
589
  });
476
590
  }
@@ -143,11 +143,11 @@ export default function (pi: ExtensionAPI) {
143
143
  publishSubagentSnapshot(pi.events);
144
144
 
145
145
  // Request telemetry handle for subprocess trace context injection.
146
- const { TELEMETRY_API_CHANNELS } = await import("../../src/otel.js");
146
+ const { TELEMETRY_API_CHANNELS } = await import("../../runtime/otel.js");
147
147
  const onTelemetryApi = (payload: unknown): void => {
148
148
  if (payload && typeof payload === "object" && "handle" in payload) {
149
149
  setTelemetryHandle(
150
- (payload as { handle: import("../../src/otel.js").TelemetryHandle }).handle
150
+ (payload as { handle: import("../../runtime/otel.js").TelemetryHandle }).handle
151
151
  );
152
152
  }
153
153
  };
@@ -12,12 +12,11 @@ import * as path from "node:path";
12
12
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
13
13
  import type { Message } from "@mariozechner/pi-ai";
14
14
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
- import { DEFAULT_AGENT_RUNNER_ENV, spawnWithResolvedAgentRunner } from "../../src/agent-runner.js";
16
15
  import {
17
- injectTraceContextToEnv,
18
- TELEMETRY_API_CHANNELS,
19
- type TelemetryHandle,
20
- } from "../../src/otel.js";
16
+ DEFAULT_AGENT_RUNNER_ENV,
17
+ spawnWithResolvedAgentRunner,
18
+ } from "../../runtime/agent-runner.js";
19
+ import { injectTraceContextToEnv, type TelemetryHandle } from "../../runtime/otel.js";
21
20
  import { extractPreview, isInlineResultsEnabled } from "../_shared/inline-preview.js";
22
21
  import {
23
22
  emitWorktreeLifecycleEvent,
@@ -11,6 +11,7 @@ import { Key, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
11
  import { Type } from "@sinclair/typebox";
12
12
  import { getIcon, getSpinner } from "../../_icons/index.js";
13
13
  import {
14
+ emitInteropEvent,
14
15
  INTEROP_EVENT_NAMES,
15
16
  onInteropEvent,
16
17
  requestInteropState,
@@ -106,6 +107,7 @@ export function registerTasksExtension(
106
107
  let backgroundTasks: BgTaskView[] = [];
107
108
  let activeTeams: TeamWidgetView[] = [];
108
109
  let teamDashboardActive = false;
110
+ let lastBackgroundTaskPresenterState: boolean | null = null;
109
111
 
110
112
  if (tasksAnimationInterval) clearInterval(tasksAnimationInterval);
111
113
  tasksAnimationInterval = undefined;
@@ -573,10 +575,40 @@ export function registerTasksExtension(
573
575
  );
574
576
  }
575
577
 
578
+ /**
579
+ * Determine whether the tasks extension currently owns background-task
580
+ * widget presentation.
581
+ *
582
+ * The tasks widget can safely suppress the standalone background-task-tool
583
+ * widget whenever this main dashboard is visible and the team dashboard is
584
+ * not active.
585
+ *
586
+ * @returns True when the tasks widget should be the canonical presenter
587
+ */
588
+ function shouldPresentBackgroundTasks(): boolean {
589
+ return !isSubagent && state.visible && !teamDashboardActive;
590
+ }
591
+
592
+ /**
593
+ * Publish background-task presenter ownership for background-task-tool.
594
+ *
595
+ * @returns void
596
+ */
597
+ function publishBackgroundTaskPresenterState(): void {
598
+ const nextState = shouldPresentBackgroundTasks();
599
+ if (lastBackgroundTaskPresenterState === nextState) return;
600
+ lastBackgroundTaskPresenterState = nextState;
601
+ emitInteropEvent(pi.events, INTEROP_EVENT_NAMES.backgroundTasksPresenterState, {
602
+ active: nextState,
603
+ });
604
+ }
605
+
576
606
  function updateWidget(ctx: ExtensionContext): void {
577
607
  // Subagents have no UI — skip all widget rendering
578
608
  if (isSubagent) return;
579
609
 
610
+ publishBackgroundTaskPresenterState();
611
+
580
612
  // If every task is completed and the 2s completion window has passed,
581
613
  // clear the list. This covers extension reloads where the original
582
614
  // setTimeout callback was lost before it could run.
@@ -1891,6 +1923,7 @@ Before calling manage_tasks complete/update, call manage_tasks list first so ind
1891
1923
  backgroundTasks = [];
1892
1924
  activeTeams = [];
1893
1925
  teamDashboardActive = false;
1926
+ lastBackgroundTaskPresenterState = null;
1894
1927
  lastBgCount = 0;
1895
1928
  lastBgTaskCount = 0;
1896
1929
 
@@ -1990,11 +2023,15 @@ Before calling manage_tasks complete/update, call manage_tasks list first so ind
1990
2023
  updateWidget(ctx);
1991
2024
  }
1992
2025
  );
2026
+ const unsubStateRequest = onInteropEvent(pi.events, INTEROP_EVENT_NAMES.stateRequest, () => {
2027
+ publishBackgroundTaskPresenterState();
2028
+ });
1993
2029
  interopEventsCleanup = () => {
1994
2030
  unsubSubagents();
1995
2031
  unsubBackgroundTasks();
1996
2032
  unsubTeams();
1997
2033
  unsubDashboardState();
2034
+ unsubStateRequest();
1998
2035
  };
1999
2036
 
2000
2037
  legacyInteropBridgeCleanup?.();
@@ -2098,6 +2135,10 @@ Before calling manage_tasks complete/update, call manage_tasks list first so ind
2098
2135
 
2099
2136
  // Cleanup on session end
2100
2137
  pi.on("session_shutdown", async () => {
2138
+ emitInteropEvent(pi.events, INTEROP_EVENT_NAMES.backgroundTasksPresenterState, {
2139
+ active: false,
2140
+ });
2141
+ lastBackgroundTaskPresenterState = false;
2101
2142
  if (tasksAnimationInterval) {
2102
2143
  clearInterval(tasksAnimationInterval);
2103
2144
  tasksAnimationInterval = undefined;