@saifymatteo/pi-extensions 0.0.0-stage → 0.1.0

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 (183) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +151 -2
  4. package/inventory.json +73 -0
  5. package/package.json +74 -4
  6. package/packages/pi-bg-bash/LICENSE +661 -0
  7. package/packages/pi-bg-bash/NOTICE.md +3 -0
  8. package/packages/pi-bg-bash/README.md +41 -0
  9. package/packages/pi-bg-bash/examples/settings.example.json +6 -0
  10. package/packages/pi-bg-bash/package.json +38 -0
  11. package/packages/pi-bg-bash/src/index.ts +969 -0
  12. package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
  13. package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
  14. package/packages/pi-claude-usage/LICENSE +661 -0
  15. package/packages/pi-claude-usage/NOTICE.md +3 -0
  16. package/packages/pi-claude-usage/README.md +27 -0
  17. package/packages/pi-claude-usage/package.json +35 -0
  18. package/packages/pi-claude-usage/src/index.ts +130 -0
  19. package/packages/pi-clipboard-image/LICENSE +661 -0
  20. package/packages/pi-clipboard-image/NOTICE.md +3 -0
  21. package/packages/pi-clipboard-image/README.md +27 -0
  22. package/packages/pi-clipboard-image/package.json +36 -0
  23. package/packages/pi-clipboard-image/src/index.ts +527 -0
  24. package/packages/pi-codeblock-borders/LICENSE +661 -0
  25. package/packages/pi-codeblock-borders/NOTICE.md +3 -0
  26. package/packages/pi-codeblock-borders/README.md +30 -0
  27. package/packages/pi-codeblock-borders/package.json +35 -0
  28. package/packages/pi-codeblock-borders/src/index.ts +302 -0
  29. package/packages/pi-codegraph/LICENSE +661 -0
  30. package/packages/pi-codegraph/NOTICE.md +5 -0
  31. package/packages/pi-codegraph/README.md +35 -0
  32. package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
  33. package/packages/pi-codegraph/package.json +37 -0
  34. package/packages/pi-codegraph/src/index.ts +573 -0
  35. package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
  36. package/packages/pi-compact-tools/LICENSE +661 -0
  37. package/packages/pi-compact-tools/NOTICE.md +3 -0
  38. package/packages/pi-compact-tools/README.md +27 -0
  39. package/packages/pi-compact-tools/package.json +36 -0
  40. package/packages/pi-compact-tools/src/index.ts +1161 -0
  41. package/packages/pi-decisions/LICENSE +661 -0
  42. package/packages/pi-decisions/NOTICE.md +3 -0
  43. package/packages/pi-decisions/README.md +37 -0
  44. package/packages/pi-decisions/examples/service-models.example.json +27 -0
  45. package/packages/pi-decisions/package.json +37 -0
  46. package/packages/pi-decisions/src/index.ts +364 -0
  47. package/packages/pi-decisions/src/lib/service-models.ts +95 -0
  48. package/packages/pi-decisions/test/service-models.test.mjs +71 -0
  49. package/packages/pi-load-pi-dotenv/LICENSE +661 -0
  50. package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
  51. package/packages/pi-load-pi-dotenv/README.md +27 -0
  52. package/packages/pi-load-pi-dotenv/package.json +32 -0
  53. package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
  54. package/packages/pi-model-go/LICENSE +661 -0
  55. package/packages/pi-model-go/NOTICE.md +5 -0
  56. package/packages/pi-model-go/README.md +33 -0
  57. package/packages/pi-model-go/package.json +36 -0
  58. package/packages/pi-model-go/src/index.ts +171 -0
  59. package/packages/pi-model-go/src/lib/dialog.ts +31 -0
  60. package/packages/pi-model-go/src/lib/model-go.ts +239 -0
  61. package/packages/pi-model-go/test/behavior.test.mjs +17 -0
  62. package/packages/pi-prestart-working/LICENSE +661 -0
  63. package/packages/pi-prestart-working/NOTICE.md +3 -0
  64. package/packages/pi-prestart-working/README.md +25 -0
  65. package/packages/pi-prestart-working/package.json +35 -0
  66. package/packages/pi-prestart-working/src/index.ts +86 -0
  67. package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
  68. package/packages/pi-root-guard/LICENSE +661 -0
  69. package/packages/pi-root-guard/NOTICE.md +3 -0
  70. package/packages/pi-root-guard/README.md +27 -0
  71. package/packages/pi-root-guard/package.json +32 -0
  72. package/packages/pi-root-guard/src/index.ts +202 -0
  73. package/packages/pi-session-finder/LICENSE +661 -0
  74. package/packages/pi-session-finder/NOTICE.md +3 -0
  75. package/packages/pi-session-finder/README.md +33 -0
  76. package/packages/pi-session-finder/package.json +36 -0
  77. package/packages/pi-session-finder/src/index.ts +306 -0
  78. package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
  79. package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
  80. package/packages/pi-session-id/LICENSE +661 -0
  81. package/packages/pi-session-id/NOTICE.md +3 -0
  82. package/packages/pi-session-id/README.md +31 -0
  83. package/packages/pi-session-id/package.json +35 -0
  84. package/packages/pi-session-id/src/index.ts +20 -0
  85. package/packages/pi-session-id/test/behavior.test.mjs +25 -0
  86. package/packages/pi-session-nav/LICENSE +661 -0
  87. package/packages/pi-session-nav/NOTICE.md +3 -0
  88. package/packages/pi-session-nav/README.md +32 -0
  89. package/packages/pi-session-nav/package.json +36 -0
  90. package/packages/pi-session-nav/src/index.ts +203 -0
  91. package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
  92. package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
  93. package/packages/pi-session-rename/LICENSE +661 -0
  94. package/packages/pi-session-rename/NOTICE.md +3 -0
  95. package/packages/pi-session-rename/README.md +31 -0
  96. package/packages/pi-session-rename/package.json +35 -0
  97. package/packages/pi-session-rename/src/index.ts +456 -0
  98. package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
  99. package/packages/pi-session-retention/LICENSE +661 -0
  100. package/packages/pi-session-retention/NOTICE.md +3 -0
  101. package/packages/pi-session-retention/README.md +62 -0
  102. package/packages/pi-session-retention/package.json +36 -0
  103. package/packages/pi-session-retention/src/README.md +42 -0
  104. package/packages/pi-session-retention/src/core.ts +333 -0
  105. package/packages/pi-session-retention/src/dialog.ts +118 -0
  106. package/packages/pi-session-retention/src/index.ts +311 -0
  107. package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
  108. package/packages/pi-session-retention/src/safety.ts +50 -0
  109. package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
  110. package/packages/pi-skill-usage-log/LICENSE +661 -0
  111. package/packages/pi-skill-usage-log/NOTICE.md +3 -0
  112. package/packages/pi-skill-usage-log/README.md +32 -0
  113. package/packages/pi-skill-usage-log/package.json +36 -0
  114. package/packages/pi-skill-usage-log/src/index.ts +316 -0
  115. package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
  116. package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
  117. package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
  118. package/packages/pi-tool-usage-log/LICENSE +661 -0
  119. package/packages/pi-tool-usage-log/NOTICE.md +5 -0
  120. package/packages/pi-tool-usage-log/README.md +32 -0
  121. package/packages/pi-tool-usage-log/package.json +36 -0
  122. package/packages/pi-tool-usage-log/src/index.ts +233 -0
  123. package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
  124. package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
  125. package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
  126. package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
  127. package/packages/pi-tts/LICENSE +661 -0
  128. package/packages/pi-tts/NOTICE.md +3 -0
  129. package/packages/pi-tts/README.md +37 -0
  130. package/packages/pi-tts/examples/service-models.example.json +27 -0
  131. package/packages/pi-tts/package.json +37 -0
  132. package/packages/pi-tts/src/index.ts +135 -0
  133. package/packages/pi-tts/src/lib/service-models.ts +95 -0
  134. package/packages/pi-tts/test/tts-output.test.mjs +47 -0
  135. package/packages/pi-tts/test/tts.test.mjs +57 -0
  136. package/packages/pi-user-image-previews/LICENSE +661 -0
  137. package/packages/pi-user-image-previews/NOTICE.md +3 -0
  138. package/packages/pi-user-image-previews/README.md +27 -0
  139. package/packages/pi-user-image-previews/package.json +36 -0
  140. package/packages/pi-user-image-previews/src/index.ts +168 -0
  141. package/packages/pi-vision/LICENSE +661 -0
  142. package/packages/pi-vision/NOTICE.md +5 -0
  143. package/packages/pi-vision/README.md +43 -0
  144. package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
  145. package/packages/pi-vision/package.json +38 -0
  146. package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
  147. package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
  148. package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
  149. package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
  150. package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
  151. package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
  152. package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
  153. package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
  154. package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
  155. package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
  156. package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
  157. package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
  158. package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
  159. package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
  160. package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
  161. package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
  162. package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
  163. package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
  164. package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
  165. package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  166. package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
  167. package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
  168. package/packages/pi-vision/src/index.ts +728 -0
  169. package/packages/pi-vision/test/vision.test.mjs +30 -0
  170. package/packages/pi-wait/LICENSE +661 -0
  171. package/packages/pi-wait/NOTICE.md +3 -0
  172. package/packages/pi-wait/README.md +37 -0
  173. package/packages/pi-wait/package.json +37 -0
  174. package/packages/pi-wait/src/index.ts +88 -0
  175. package/packages/pi-wait/src/lib/async-work.ts +27 -0
  176. package/packages/pi-wait/test/wait.test.mjs +43 -0
  177. package/packages/pi-zvec/LICENSE +661 -0
  178. package/packages/pi-zvec/NOTICE.md +3 -0
  179. package/packages/pi-zvec/README.md +37 -0
  180. package/packages/pi-zvec/examples/zvec.example.json +5 -0
  181. package/packages/pi-zvec/package.json +37 -0
  182. package/packages/pi-zvec/src/index.ts +754 -0
  183. package/packages/pi-zvec/test/zvec.test.mjs +19 -0
@@ -0,0 +1,969 @@
1
+ /**
2
+ * bg-bash — background execution hooked directly into the built-in bash/powershell tools.
3
+ *
4
+ * Overrides the built-in `bash` (and `powershell` on Windows) tool with a same-name
5
+ * registration, adding two optional parameters the model sees in the tool schema:
6
+ *
7
+ * background: true -> run the command detached; returns a job id and output path
8
+ * immediately instead of blocking the turn. The agent should end
9
+ * its turn (or keep working) right away; the user can interact.
10
+ * Completions are held while busy, then batched into a safe settle
11
+ * draft, or sent as custom "bg-bash-result" messages when idle.
12
+ * Explicit wait leases temporarily own delivery without killing work.
13
+ * Offered drafts/queues are not receipts: only matching branch messages
14
+ * confirm delivery, and unconfirmed outcomes remain queryable.
15
+ * job: "<id>" -> OPTIONAL manual check: job "7" = status + output tail,
16
+ * "7:out" = more output, "7:kill" = kill the job.
17
+ * The latest 20 completions stay queryable until shutdown;
18
+ * killing a completed job is a no-op.
19
+ *
20
+ * A `tool_call` guard blocks the "sleep to wait for a result" anti-pattern
21
+ * (e.g. `sleep 30 && curl ...`) with an instructional error, so the model
22
+ * learns the convention at the moment it tries to misuse sleep. Retry loops
23
+ * (`while ... sleep ...`) are still allowed.
24
+ *
25
+ * Codemode scripts inherit everything automatically: scripts call tools through
26
+ * the same registry, so `tools.bash({ command, background: true })` works, and
27
+ * nested calls go through the `tool_call` guard too.
28
+ *
29
+ * Background jobs survive the turn and are watched by timers inside the pi
30
+ * process (timers are not available to codemode scripts, but extensions have
31
+ * them). Timers are cleared in session_shutdown; the detached OS processes
32
+ * themselves keep writing to their temp files either way.
33
+ *
34
+ * Default timeout injection (replaces the external pi-timeout package): the
35
+ * tool_call guard also injects a default timeout into foreground
36
+ * bash/powershell calls that don't set one, so a forgotten long command can't
37
+ * hang the turn forever. The value comes from runTimeout.maxSeconds in
38
+ * settings.json (project .pi/settings.json overrides the global one), default
39
+ * 120s. Explicit timeout values are always respected; background jobs are
40
+ * never capped (the watcher manages them). Codemode scripts get a default
41
+ * 300s deadline via an injected "// @options" line unless the script sets its
42
+ * own timeout_ms (default 300s, configurable via runTimeout.codemodeMaxSeconds
43
+ * in settings.json). A killed call returns the built-in timeout error (partial
44
+ * output + cancelled), which teaches the model to retry with background:true.
45
+ *
46
+ * Watcher hardening: the completion watcher polls the status file, but a
47
+ * job's subshell can die before writing it (process-tree kill by a parent
48
+ * harness, crash, manual taskkill). When the tracked pid is no longer alive
49
+ * and no status file exists, the job is delivered as "finished, exit unknown"
50
+ * instead of being polled forever. The aliveness check runs against the real
51
+ * Windows pid (resolved via MSYS ps at launch); on Windows, bash's $! is a
52
+ * synthetic MSYS pid that Node's process.kill cannot see, so a job without a
53
+ * resolved winpid is only ever finished by its status file appearing.
54
+ *
55
+ * Calls that set background:true are drawn with an inverted "Background" chip
56
+ * prefixed to the shell call line in the transcript, and the delivered result
57
+ * rows carry the same chip, so a detached launch is marked in both the collapsed
58
+ * and expanded states.
59
+ *
60
+ * Load: install this package, or load a built copy from the agent's
61
+ * extensions directory (`pi -e ./bg-bash.ts`).
62
+ */
63
+
64
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
65
+ import type { Lease, ProviderDiscovery, WorkResult } from "./lib/async-work";
66
+ import { createBashTool, createPowerShellTool } from "@earendil-works/pi-coding-agent";
67
+ import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
68
+ import { homedir, tmpdir } from "node:os";
69
+ import { join } from "node:path";
70
+ import { Type } from "typebox";
71
+ import { Box, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Job registry (module state; jobs survive turns, not app restarts)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ interface BgJob {
78
+ id: number;
79
+ tool: "bash" | "powershell";
80
+ command: string;
81
+ outPath: string;
82
+ errPath?: string; // powershell only (Start-Process cannot merge streams)
83
+ statusPath: string;
84
+ scriptPath?: string; // powershell only
85
+ pid: number | null;
86
+ winPid?: number | null; // bash on Windows: real Windows pid (MSYS $! is synthetic)
87
+ startedAt: number;
88
+ timer?: ReturnType<typeof setInterval>;
89
+ result?: { exit: string; secs: number };
90
+ sessionFile?: string;
91
+ ctx: ExtensionContext;
92
+ outcome?: WorkResult;
93
+ completion: Promise<WorkResult>;
94
+ resolve: (result: WorkResult) => void;
95
+ owner?: string;
96
+ consumed?: boolean;
97
+ submitted?: boolean;
98
+ deliveryId?: string;
99
+ confirmed?: boolean;
100
+ scope: number;
101
+ }
102
+
103
+ const jobs = new Map<number, BgJob>();
104
+ const MAX_COMPLETED_JOBS = 20;
105
+ let nextJobId = 1;
106
+ let currentCtx: ExtensionContext | undefined;
107
+ let sessionEpoch = new AbortController();
108
+ let sessionScope = 0;
109
+ let idleFlush: ReturnType<typeof setTimeout> | undefined;
110
+
111
+ function currentSession(ctx: ExtensionContext): boolean {
112
+ return !!currentCtx && !sessionEpoch.signal.aborted &&
113
+ ctx.sessionManager.getSessionFile() === currentCtx.sessionManager.getSessionFile();
114
+ }
115
+
116
+ function currentJob(job: BgJob): boolean {
117
+ return !!currentCtx && job.scope === sessionScope && currentSession(job.ctx) &&
118
+ job.sessionFile === currentCtx.sessionManager.getSessionFile();
119
+ }
120
+
121
+ function invalidateSession(ctx?: ExtensionContext, replace = false): void {
122
+ if (idleFlush !== undefined) clearTimeout(idleFlush);
123
+ idleFlush = undefined;
124
+ sessionEpoch.abort(new Error("Shell wait invalidated by session change"));
125
+ sessionEpoch = new AbortController();
126
+ if (replace) sessionScope++;
127
+ currentCtx = ctx;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Schema: built-in params plus background/job
132
+ // ---------------------------------------------------------------------------
133
+
134
+ const bgSchema = Type.Object({
135
+ command: Type.String({ description: "Shell command to execute" }),
136
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
137
+ background: Type.Optional(
138
+ Type.Boolean({
139
+ description:
140
+ "Run the command detached in the background: returns a job id and output path immediately instead of blocking this call. The command's output is not lost - it is saved to a file you can read later.",
141
+ }),
142
+ ),
143
+ job: Type.Optional(
144
+ Type.String({
145
+ description:
146
+ `Background job id returned by a previous background:true call. "7" = status + last 40 output lines, "7:out" = last 80 output lines, "7:kill" = kill a running job (no-op if completed). The latest ${MAX_COMPLETED_JOBS} completed jobs remain queryable until shutdown. When set, command is ignored.`,
147
+ }),
148
+ ),
149
+ });
150
+
151
+ const DEFAULT_SHELL_TIMEOUT_S = 120;
152
+ const DEFAULT_CODEMODE_TIMEOUT_S = 300;
153
+
154
+ const GUIDELINE =
155
+ "Long-running or blocking commands (builds, dev servers, installs, test suites, network waits): set " +
156
+ "background:true — you get a job id immediately while the command runs detached, and its exit code and " +
157
+ "output tail are delivered automatically when safe. Do independent work after dispatch; when blocked, " +
158
+ "use wait with explicit shell:<id>/agent:<id> jobs, or end your turn for push delivery. Do not answer redundant known result messages individually. On-demand checks without waiting: " +
159
+ "job:\"<id>\" (status), \"<id>:out\" (more output), \"<id>:kill\". Use wait instead of sleep or polling — " +
160
+ "sleep-waits and poll loops are blocked. Foreground calls without an explicit timeout are killed after " +
161
+ `${DEFAULT_SHELL_TIMEOUT_S}s (runTimeout.maxSeconds) - set timeout for a different cap, or use background:true ` +
162
+ "for anything that may run longer.";
163
+
164
+ // Codemode gets its own, higher default: image generation can legitimately
165
+ // run for minutes.
166
+ const CODEMODE_GUIDELINE =
167
+ `Codemode scripts get a default ${DEFAULT_CODEMODE_TIMEOUT_S}s deadline (timeout_ms, configurable via ` +
168
+ `runTimeout.codemodeMaxSeconds) unless the script starts with its own // @options line setting timeout_ms. ` +
169
+ `For longer work, set your own deadline or run the long step via tools.bash with background:true.`;
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Default timeout injection (replaces the external pi-timeout package)
173
+ // ---------------------------------------------------------------------------
174
+
175
+ // Timeout settings: runTimeout.maxSeconds caps foreground shell calls and
176
+ // runTimeout.codemodeMaxSeconds caps codemode scripts (project
177
+ // .pi/settings.json overrides the global one), positive numbers only - the
178
+ // built-in tool rejects non-positive timeouts with a validation error.
179
+ let cachedShellTimeoutS: number | undefined;
180
+ let cachedCodemodeTimeoutS: number | undefined;
181
+
182
+ function runTimeoutSetting(key: "maxSeconds" | "codemodeMaxSeconds"): number | undefined {
183
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
184
+ let value: unknown;
185
+ for (const path of [join(agentDir, "settings.json"), join(process.cwd(), ".pi", "settings.json")]) {
186
+ try {
187
+ const settings = JSON.parse(readFileSync(path, "utf8")) as { runTimeout?: Record<string, unknown> };
188
+ const entry = settings?.runTimeout?.[key];
189
+ if (typeof entry === "number") value = entry;
190
+ } catch {
191
+ // Missing or invalid file - keep whatever the previous file set.
192
+ }
193
+ }
194
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
195
+ }
196
+
197
+ function shellTimeoutSeconds(): number {
198
+ return (cachedShellTimeoutS ??= runTimeoutSetting("maxSeconds") ?? DEFAULT_SHELL_TIMEOUT_S);
199
+ }
200
+
201
+ function codemodeTimeoutSeconds(): number {
202
+ return (cachedCodemodeTimeoutS ??= runTimeoutSetting("codemodeMaxSeconds") ?? DEFAULT_CODEMODE_TIMEOUT_S);
203
+ }
204
+
205
+ type ShellToolInput = { command?: unknown; timeout?: unknown; background?: unknown; job?: unknown };
206
+
207
+ function injectShellTimeout(input: ShellToolInput): void {
208
+ // Background jobs are watcher-managed and never capped; explicit timeouts
209
+ // and job-management calls (input.job) are always respected.
210
+ if (input.background === true || input.job !== undefined) return;
211
+ if (typeof input.command !== "string" || input.timeout !== undefined) return;
212
+ input.timeout = shellTimeoutSeconds();
213
+ }
214
+
215
+ // Codemode has no default deadline; give scripts one by injecting or merging
216
+ // a "// @options" first line unless the script already sets timeout_ms.
217
+ const OPTIONS_LINE_RE = /^\/\/\s*@options:\s*(\{.*\})\s*$/;
218
+
219
+ function injectCodemodeTimeout(input: { code?: unknown }): void {
220
+ if (typeof input.code !== "string") return;
221
+ const timeoutMs = codemodeTimeoutSeconds() * 1000;
222
+ const lines = input.code.split("\n");
223
+ const first = lines.findIndex((line) => line.trim() !== "");
224
+ if (first !== -1 && OPTIONS_LINE_RE.test(lines[first])) {
225
+ try {
226
+ const opts = JSON.parse(lines[first].match(OPTIONS_LINE_RE)![1]) as Record<string, unknown>;
227
+ if (opts && typeof opts === "object" && opts.timeout_ms === undefined) {
228
+ opts.timeout_ms = timeoutMs;
229
+ lines[first] = `// @options: ${JSON.stringify(opts)}`;
230
+ input.code = lines.join("\n");
231
+ }
232
+ } catch {
233
+ // Unparsable options line - leave the script untouched.
234
+ }
235
+ return;
236
+ }
237
+ input.code = `// @options: {"timeout_ms": ${timeoutMs}}\n` + input.code;
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Launch wrappers (delegate to the real tool so shell config, env, cwd,
242
+ // truncation and rendering all stay pi's own)
243
+ // ---------------------------------------------------------------------------
244
+
245
+ function bashLaunchWrap(command: string, outPath: string, statusPath: string): string {
246
+ // Subshell: run the command, capture its exit code into the status file,
247
+ // all output into the out file, background the subshell, print its pid.
248
+ // On MSYS bash (Git Bash) $! is a synthetic MSYS pid Node's process.kill
249
+ // cannot check (a Windows pid lookup on it fails or hits an unrelated
250
+ // process by reuse), so the wrapper resolves the WINPID via ps. On
251
+ // macOS/Linux bash $! is already a real pid: report it as-is so the
252
+ // watcher's aliveness check keeps working there (BSD ps has different
253
+ // columns, so the MSYS ps lookup must not run there).
254
+ const resolvePid =
255
+ process.platform === "win32"
256
+ ? `BG_WINPID=$(ps -p $BG_PID 2>/dev/null | awk -v p=$BG_PID '$1==p{print $4}'); `
257
+ : `BG_WINPID=$BG_PID; `;
258
+ return (
259
+ `( ${command}\necho $? > "${statusPath}" ) > "${outPath}" 2>&1 & ` +
260
+ `BG_PID=$!; ` +
261
+ resolvePid +
262
+ `echo "__PI_BG_PID:$BG_PID __PI_BG_WINPID:\${BG_WINPID:-0}"`
263
+ );
264
+ }
265
+
266
+ function psScriptFor(command: string, statusPath: string): string {
267
+ return [
268
+ "$ErrorActionPreference = 'Continue'",
269
+ command,
270
+ `"exit=$LASTEXITCODE ok=$?" | Set-Content -LiteralPath '${statusPath}'`,
271
+ "",
272
+ ].join("\n");
273
+ }
274
+
275
+ function psLaunchWrap(scriptPath: string, outPath: string, errPath: string): string {
276
+ return (
277
+ "$exe = Get-Command pwsh -ErrorAction SilentlyContinue; " +
278
+ "if (-not $exe) { $exe = Get-Command powershell -ErrorAction Stop }; " +
279
+ "$p = Start-Process -FilePath $exe.Source -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File', " +
280
+ `'${scriptPath}' -RedirectStandardOutput '${outPath}' -RedirectStandardError '${errPath}' ` +
281
+ "-WindowStyle Hidden -PassThru; \"__PI_BG_PID:$($p.Id)\""
282
+ );
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Check / kill commands (also executed through the real tool)
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function bashStatusCmd(job: BgJob): string {
290
+ return (
291
+ `if [ -f "${job.statusPath}" ]; then echo "DONE (exit $(cat "${job.statusPath}"))"; ` +
292
+ `else echo "RUNNING ($(( $(date +%s) - ${job.startedAt} ))s)"; fi; ` +
293
+ `echo "--- last output ---"; tail -n 40 "${job.outPath}" 2>/dev/null || echo "(no output yet)"`
294
+ );
295
+ }
296
+
297
+ function bashOutCmd(job: BgJob): string {
298
+ return `tail -n 80 "${job.outPath}" 2>/dev/null || echo "(no output yet)"`;
299
+ }
300
+
301
+ function bashKillCmd(job: BgJob): string {
302
+ // Kill children first (pkill -P), then the subshell itself, so the command
303
+ // inside the subshell does not survive as an orphan.
304
+ return `pkill -P ${job.pid ?? 0} 2>/dev/null; kill ${job.pid ?? 0} 2>/dev/null && echo "kill signal sent to pid ${job.pid}" || echo "process ${job.pid} already gone"`;
305
+ }
306
+
307
+ function psStatusCmd(job: BgJob): string {
308
+ return (
309
+ `if (Test-Path -LiteralPath '${job.statusPath}') { "DONE - " + (Get-Content -LiteralPath '${job.statusPath}' -Raw).Trim() } ` +
310
+ `else { "RUNNING ($([int]((Get-Date) - ([datetime]'${new Date(job.startedAt).toISOString()}')).TotalSeconds))s" }; ` +
311
+ `"--- last output ---"; if (Test-Path -LiteralPath '${job.outPath}') { Get-Content -LiteralPath '${job.outPath}' -Tail 40 } ` +
312
+ `else { "(no output yet)" }; ` +
313
+ `"--- stderr ---"; if (Test-Path -LiteralPath '${job.errPath}') { Get-Content -LiteralPath '${job.errPath}' -Tail 20 }`
314
+ );
315
+ }
316
+
317
+ function psOutCmd(job: BgJob): string {
318
+ return (
319
+ `if (Test-Path -LiteralPath '${job.outPath}') { Get-Content -LiteralPath '${job.outPath}' -Tail 80 } ` +
320
+ `else { "(no output yet)" }`
321
+ );
322
+ }
323
+
324
+ function psKillCmd(job: BgJob): string {
325
+ return (
326
+ `Stop-Process -Id ${job.pid ?? 0} -Force -ErrorAction SilentlyContinue; ` +
327
+ `if (Get-Process -Id ${job.pid ?? 0} -ErrorAction SilentlyContinue) { "still running" } else { "killed or already gone" }`
328
+ );
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Completion watcher: poll the status file, hold outcomes while busy, and
333
+ // deliver only at an actionable settle boundary or an idle fallback. A wait
334
+ // lease owns delivery until commit/release. Submitted drafts and native queues
335
+ // cannot be safely retracted/retried; branch receipts alone confirm delivery.
336
+ // ---------------------------------------------------------------------------
337
+
338
+ function tailFile(path: string, lines: number): string {
339
+ try {
340
+ const content = readFileSync(path, "utf8");
341
+ const all = content.split("\n");
342
+ return all.slice(Math.max(0, all.length - lines)).join("\n").trimEnd();
343
+ } catch {
344
+ return "(no output)";
345
+ }
346
+ }
347
+
348
+ function stopWatcher(job: BgJob) {
349
+ if (job.timer) {
350
+ clearInterval(job.timer);
351
+ job.timer = undefined;
352
+ }
353
+ }
354
+
355
+ // signal 0 probes existence without signaling; EPERM means the process exists
356
+ // but we may not signal it. Anything else (ESRCH) means it is gone.
357
+ function processAlive(pid: number): boolean {
358
+ try {
359
+ process.kill(pid, 0);
360
+ return true;
361
+ } catch (err) {
362
+ return (err as NodeJS.ErrnoException).code === "EPERM";
363
+ }
364
+ }
365
+
366
+ // Render a finished-message body with left padding matching other custom
367
+ // messages, no background box - compact-tools aesthetic.
368
+ function shortenPath(path: string): string {
369
+ const home = homedir();
370
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
371
+ }
372
+
373
+ // Delete leftover pi-bg-* temp files (out/status/err) older than maxAgeMs.
374
+ // Runs at session_start and session_shutdown; files younger than the cutoff
375
+ // stay readable for agents still referencing them from delivered messages.
376
+ function sweepOldJobFiles(maxAgeMs = 24 * 60 * 60 * 1000) {
377
+ try {
378
+ const dir = tmpdir();
379
+ const cutoff = Date.now() - maxAgeMs;
380
+ for (const name of readdirSync(dir)) {
381
+ if (!name.startsWith("pi-bg-")) continue;
382
+ const path = join(dir, name);
383
+ try {
384
+ if (statSync(path).mtimeMs < cutoff) unlinkSync(path);
385
+ } catch {
386
+ // Vanished mid-sweep or locked - skip.
387
+ }
388
+ }
389
+ } catch {
390
+ // No temp dir access - cleanup is best-effort.
391
+ }
392
+ }
393
+
394
+ function trimHistory() {
395
+ // Returning a draft / sendMessage is only an offer, not branch acceptance.
396
+ try {
397
+ if (currentCtx) {
398
+ const receipts = new Set(currentCtx.sessionManager.getBranch().flatMap((entry) =>
399
+ entry.type === "message" && entry.message.role === "custom" && entry.message.customType === "bg-bash-result"
400
+ ? [(entry.message.details as { deliveryId?: string } | undefined)?.deliveryId] : []));
401
+ for (const job of jobs.values()) {
402
+ if (currentJob(job) && job.submitted && job.deliveryId && receipts.has(job.deliveryId)) job.confirmed = true;
403
+ }
404
+ }
405
+ } catch {
406
+ // Receipt lookup is best-effort during navigation; retain unconfirmed offers.
407
+ }
408
+ const completed = [...jobs.values()].filter((job) => job.result && !job.owner && (job.consumed || job.confirmed));
409
+ for (const oldest of completed.slice(0, -MAX_COMPLETED_JOBS)) jobs.delete(oldest.id);
410
+ }
411
+
412
+ // One transition for watcher, claim and query: never read/deliver the same completion twice.
413
+ function refreshCompletion(job: BgJob): void {
414
+ if (job.result) return;
415
+ const statusExists = existsSync(job.statusPath);
416
+ const unknownExit = !statusExists && job.winPid != null && job.winPid > 0 && !processAlive(job.winPid);
417
+ if (!statusExists && !unknownExit) return;
418
+ let exit = "?";
419
+ if (!unknownExit) {
420
+ try { exit = readFileSync(job.statusPath, "utf8").trim(); } catch { return; }
421
+ // A status file can be visible before its writer writes the exit code.
422
+ if (!exit) return;
423
+ }
424
+ stopWatcher(job);
425
+ const secs = Math.max(1, Math.floor(Date.now() / 1000) - job.startedAt);
426
+ const tail = tailFile(job.outPath, 40);
427
+ const errTail = job.errPath ? tailFile(job.errPath, 20) : "";
428
+ const exitLabel = unknownExit ? "unknown - the process died before writing its status file" : exit;
429
+ job.result = { exit: exitLabel, secs };
430
+ job.outcome = {
431
+ source: "shell", id: job.id,
432
+ content: `[bg-bash] Background job #${job.id} finished (exit ${exitLabel}) after ${secs}s.` +
433
+ `
434
+ Command: ${job.command}` +
435
+ `
436
+ --- output (last 40 lines; full file: ${job.outPath}) ---
437
+ ${tail || "(no output)"}` +
438
+ (errTail ? `
439
+ --- stderr ---
440
+ ${errTail}` : ""),
441
+ details: { jobId: job.id, exit, secs, command: job.command, outPath: job.outPath, tail, errTail },
442
+ };
443
+ jobs.delete(job.id);
444
+ jobs.set(job.id, job);
445
+ job.resolve(job.outcome);
446
+ trimHistory();
447
+ }
448
+
449
+ function resultDraft(job: BgJob) {
450
+ job.deliveryId ??= crypto.randomUUID();
451
+ return { type: "custom_message" as const, customType: "bg-bash-result", content: job.outcome!.content,
452
+ display: true, details: { ...job.outcome!.details, deliveryId: job.deliveryId } };
453
+ }
454
+
455
+ function pushIfIdle(pi: ExtensionAPI, job: BgJob) {
456
+ try {
457
+ if (!job.outcome || job.owner || job.consumed || job.submitted ||
458
+ !currentJob(job) || !currentCtx!.isIdle() || currentCtx!.hasPendingMessages()) return;
459
+ // Mark first: an accepted native queue cannot safely be retracted by wait.
460
+ job.submitted = true;
461
+ const { type: _type, ...message } = resultDraft(job);
462
+ pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" });
463
+ } catch {
464
+ // A synchronous throw may follow acceptance. Keep the offer protected and
465
+ // queryable; neither wait nor automatic delivery may blindly retry it.
466
+ }
467
+ trimHistory();
468
+ }
469
+
470
+ function startWatcher(pi: ExtensionAPI, job: BgJob) {
471
+ job.timer = setInterval(() => {
472
+ refreshCompletion(job);
473
+ pushIfIdle(pi, job);
474
+ }, 1500);
475
+ }
476
+
477
+ function claimJobs(pi: ExtensionAPI, epoch: AbortSignal, sessionFile: string | undefined, ids: number[], owner: string): Lease {
478
+ if (epoch.aborted || epoch !== sessionEpoch.signal || !currentCtx) throw new Error("Shell provider invalidated by session change");
479
+ if (!owner || !ids.length || new Set(ids).size !== ids.length || ids.some((id) => !Number.isSafeInteger(id) || id <= 0))
480
+ throw new Error("Invalid shell job IDs or owner");
481
+ const claimed = ids.map((id) => {
482
+ const job = jobs.get(id);
483
+ if (!job || !currentJob(job) || job.sessionFile !== sessionFile) throw new Error(`Unknown shell job ${id} for this session`);
484
+ if (job.consumed) throw new Error(`shell:${id} result already consumed`);
485
+ if (job.submitted) throw new Error(`shell:${id} result already submitted externally; native queues cannot be retracted safely`);
486
+ if (job.owner) throw new Error(`shell:${id} already claimed by ${job.owner}`);
487
+ return job;
488
+ });
489
+ for (const job of claimed) { job.owner = owner; refreshCompletion(job); }
490
+ let active = true;
491
+ let invalidate!: () => void;
492
+ const result = new Promise<WorkResult[]>((resolve, reject) => {
493
+ invalidate = () => {
494
+ if (active) for (const job of claimed) job.owner = undefined;
495
+ active = false;
496
+ reject(new Error("Shell wait invalidated by session change"));
497
+ };
498
+ epoch.addEventListener("abort", invalidate, { once: true });
499
+ Promise.all(claimed.map((job) => job.completion)).then((outcomes) => {
500
+ if (epoch.aborted) return;
501
+ if (!active) epoch.removeEventListener("abort", invalidate);
502
+ resolve(outcomes);
503
+ });
504
+ });
505
+ // A later provider may fail group acquisition before wait observes this promise.
506
+ // Keep the original rejection visible to callers without an unhandled rejection.
507
+ void result.catch(() => {});
508
+ return {
509
+ result,
510
+ commit() {
511
+ if (!active) return;
512
+ active = false;
513
+ if (claimed.every((job) => job.outcome)) epoch.removeEventListener("abort", invalidate);
514
+ for (const job of claimed) { job.consumed = true; job.owner = undefined; }
515
+ trimHistory();
516
+ },
517
+ release() {
518
+ if (!active) return;
519
+ active = false;
520
+ if (claimed.every((job) => job.outcome)) epoch.removeEventListener("abort", invalidate);
521
+ for (const job of claimed) { job.owner = undefined; pushIfIdle(pi, job); }
522
+ trimHistory();
523
+ },
524
+ };
525
+ }
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Tool override
529
+ // ---------------------------------------------------------------------------
530
+
531
+ type ShellTool = ReturnType<typeof createBashTool>;
532
+ type ShellParams = { command: string; timeout?: number; background?: boolean; job?: string };
533
+
534
+ function registerBgTool(pi: ExtensionAPI, toolName: "bash" | "powershell", builtIn: ShellTool) {
535
+ builtIn.description =
536
+ `Execute a ${toolName} command in the current working directory. Returns stdout and stderr. ` +
537
+ `Output is truncated to the last lines/KB; if truncated, full output is saved to a temp file. ` +
538
+ `For long-running or blocking commands (builds, servers, installs, tests, waits), set background:true: ` +
539
+ `the call returns a job id immediately, the command runs detached, and its exit code and output tail are ` +
540
+ `delivered to you automatically as a new message when it finishes. A background call can be followed up ` +
541
+ `with job:"<id>" (status + output tail), "<id>:out" (more output), or "<id>:kill". ` +
542
+ `The latest ${MAX_COMPLETED_JOBS} completed jobs remain queryable until shutdown; killing one is a no-op. ` +
543
+ `Do independent work first; when blocked use wait({jobs:["shell:<id>"]}) or end your turn. Sleep-waits and poll loops are blocked. ` +
544
+ `Foreground calls without an explicit timeout are killed after ${shellTimeoutSeconds()}s - set timeout for ` +
545
+ `a different cap, or use background:true for anything that may run longer.`;
546
+
547
+ pi.registerTool({
548
+ name: toolName,
549
+ label: toolName,
550
+ description: builtIn.description,
551
+ parameters: bgSchema,
552
+ promptSnippet: (builtIn as { promptSnippet?: string }).promptSnippet,
553
+ promptGuidelines: [...((builtIn as { promptGuidelines?: string[] }).promptGuidelines ?? []), GUIDELINE],
554
+
555
+ async execute(
556
+ _toolCallId: string,
557
+ params: ShellParams,
558
+ signal: AbortSignal | undefined,
559
+ onUpdate: any,
560
+ ctx: any,
561
+ ) {
562
+ const exec = (command: string, timeout?: number) =>
563
+ (builtIn.execute as (...args: unknown[]) => unknown)(_toolCallId, { command, timeout }, signal, onUpdate, ctx);
564
+
565
+ // Job management: build the check command and run it through the real tool.
566
+ if (params.job) {
567
+ const m = params.job.match(/^(\d+)(?::(out|kill))?$/);
568
+ const job = m ? jobs.get(Number(m[1])) : undefined;
569
+ if (!job || !currentSession(ctx) || !currentJob(job)) {
570
+ const known = [...jobs.values()].filter(currentJob).map((job) => String(job.id)).join(", ") || "none";
571
+ throw new Error(`Unknown background job "${params.job}". Known jobs: ${known}.`);
572
+ }
573
+ const action = m![2] ?? "status";
574
+ // The watcher and queries use one transition; queries can consume before any enqueue.
575
+ refreshCompletion(job);
576
+ if (job.result) {
577
+ if (!job.owner && !job.submitted) job.consumed = true;
578
+ trimHistory();
579
+ const errTail = action === "status" && job.errPath ? tailFile(job.errPath, 20) : "";
580
+ const text = action === "kill"
581
+ ? `Background job #${job.id} already finished; no kill signal sent.`
582
+ : action === "out"
583
+ ? tailFile(job.outPath, 80)
584
+ : `DONE (exit ${job.result.exit}) after ${job.result.secs}s.\n--- last output ---\n${tailFile(job.outPath, 40)}` +
585
+ (errTail ? `\n--- stderr ---\n${errTail}` : "");
586
+ return { content: [{ type: "text", text }], details: { bgJob: job.id, bgOutputPath: job.outPath, ...job.result } } as never;
587
+ }
588
+ const cmd =
589
+ job.tool === "bash"
590
+ ? action === "out"
591
+ ? bashOutCmd(job)
592
+ : action === "kill"
593
+ ? bashKillCmd(job)
594
+ : bashStatusCmd(job)
595
+ : action === "out"
596
+ ? psOutCmd(job)
597
+ : action === "kill"
598
+ ? psKillCmd(job)
599
+ : psStatusCmd(job);
600
+ if (action === "kill") {
601
+ if (job.owner) throw new Error(`shell:${job.id} is claimed; release the wait before killing it`);
602
+ stopWatcher(job);
603
+ jobs.delete(job.id);
604
+ }
605
+ return exec(cmd, 15) as never;
606
+ }
607
+
608
+ // Background launch.
609
+ if (params.background) {
610
+ if (!currentSession(ctx)) throw new Error("Shell session invalidated");
611
+ const epoch = sessionEpoch.signal;
612
+ const id = nextJobId++;
613
+ const stamp = `${id}-${Date.now()}`;
614
+ let resolve!: (result: WorkResult) => void;
615
+ const completion = new Promise<WorkResult>((done) => { resolve = done; });
616
+ const job: BgJob = {
617
+ id, completion, resolve, ctx, scope: sessionScope, sessionFile: ctx.sessionManager.getSessionFile(),
618
+ tool: toolName,
619
+ command: params.command,
620
+ outPath: join(tmpdir(), `pi-bg-${stamp}.out`),
621
+ statusPath: join(tmpdir(), `pi-bg-${stamp}.status`),
622
+ pid: null,
623
+ startedAt: Math.floor(Date.now() / 1000),
624
+ };
625
+ if (toolName === "bash") {
626
+ const launch = bashLaunchWrap(params.command, job.outPath, job.statusPath);
627
+ const result = await exec(launch, 15) as { content?: Array<{ text?: string }> };
628
+ const text = result?.content?.map((c) => c.text ?? "").join("") ?? "";
629
+ const pid = text.match(/__PI_BG_PID:(\d+)/)?.[1];
630
+ const winPid = text.match(/__PI_BG_WINPID:(\d+)/)?.[1];
631
+ job.pid = pid ? Number(pid) : null;
632
+ job.winPid = winPid ? Number(winPid) : null;
633
+ } else {
634
+ job.scriptPath = join(tmpdir(), `pi-bg-${stamp}.ps1`);
635
+ job.errPath = join(tmpdir(), `pi-bg-${stamp}.err`);
636
+ writeFileSync(job.scriptPath, psScriptFor(params.command, job.statusPath), "utf8");
637
+ const result = await exec(psLaunchWrap(job.scriptPath, job.outPath, job.errPath), 30) as {
638
+ content?: Array<{ text?: string }>;
639
+ };
640
+ const text = result?.content?.map((c) => c.text ?? "").join("") ?? "";
641
+ const pid = text.match(/__PI_BG_PID:(\d+)/)?.[1];
642
+ job.pid = pid ? Number(pid) : null;
643
+ // Start-Process returns a real Windows pid: aliveness is directly
644
+ // checkable, unlike the MSYS bash case.
645
+ job.winPid = job.pid;
646
+ }
647
+ jobs.set(id, job);
648
+ if (!epoch.aborted && currentJob(job)) startWatcher(pi, job);
649
+ return {
650
+ content: [
651
+ {
652
+ type: "text",
653
+ text:
654
+ `Background job #${id} started${job.pid ? ` (pid ${job.pid})` : ""}.\n` +
655
+ `Output file: ${job.outPath}\n\n` +
656
+ `Its exit code and output tail will be delivered to you automatically as a new message.\n` +
657
+ `End your turn now or keep working on something else; on-demand checks: job:"${id}" (status), "${id}:out" (output), "${id}:kill".`,
658
+ },
659
+ ],
660
+ details: { bgJob: id, bgOutputPath: job.outPath },
661
+ } as never;
662
+ }
663
+
664
+ // Normal foreground call - identical to the built-in tool.
665
+ return exec(params.command, params.timeout) as never;
666
+ },
667
+ });
668
+ }
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // sleep-wait guard: block "use sleep to wait for a result" calls with a
672
+ // self-teaching error. Retry loops (while/until ... sleep/Start-Sleep) pass.
673
+ // ---------------------------------------------------------------------------
674
+
675
+ const BASH_WAIT_PATTERNS = [
676
+ /^\s*sleep\s+[\d.]/, // sleep 30 ...
677
+ /(?:&&|\|\||;|&)\s*sleep\s+[\d.]+\s*(?:&&|$)/, // ... && sleep 30 / sleep 30 && ...
678
+ ];
679
+ const PS_WAIT_PATTERNS = [
680
+ /^\s*Start-Sleep\b/i,
681
+ /(?:;|\|)\s*Start-Sleep\b/i,
682
+ ];
683
+
684
+ function isWaitForSleep(toolName: string, command: string): boolean {
685
+ const isBash = toolName === "bash";
686
+ const patterns = isBash ? BASH_WAIT_PATTERNS : PS_WAIT_PATTERNS;
687
+ // Retry loops (while/until/for ... do ... done) are a legitimate place for sleep.
688
+ if (isBash && /\b(while|until|for)\b/.test(command) && /\bdo\b/.test(command) && /\bdone\b/.test(command)) return false;
689
+ if (!isBash && (/\b(while|foreach)\b/.test(command) || /\bfor\s*\(/.test(command))) return false;
690
+ return patterns.some((re) => re.test(command));
691
+ }
692
+
693
+ // ---------------------------------------------------------------------------
694
+ // "Background" call badge
695
+ // ---------------------------------------------------------------------------
696
+
697
+ // Prepends a purple "Background" chip to every bash/powershell call that sets
698
+ // background:true, marking a detached launch in the transcript. Uses the same
699
+ // inverted fill as the result rows below so the two read as one visual family.
700
+ // Registered as a tool renderer resolver (not a per-tool renderCall) so it also
701
+ // covers the powershell tool, which is registered later on session_start, and
702
+ // delegates non-background calls to the shell renderer untouched. Note: a
703
+ // resolver registered earlier that fully owns bash/powershell without calling
704
+ // next() (compact-tools does this for known names) would shadow this badge.
705
+ function registerBackgroundCallBadge(pi: ExtensionAPI): void {
706
+ pi.registerToolRenderer((toolName, next) => {
707
+ if (toolName !== "bash" && toolName !== "powershell") return next();
708
+ const base = next();
709
+ const baseRenderCall = base?.renderCall;
710
+ // No underlying call renderer (e.g. the HTML-export path resolves the
711
+ // bare registered definition): leave rendering to the host's default
712
+ // instead of substituting an empty component.
713
+ if (!baseRenderCall) return base;
714
+ return {
715
+ ...base,
716
+ renderCall(args, theme, context) {
717
+ const comp = baseRenderCall(args, theme, context);
718
+ if ((args as { background?: boolean } | undefined)?.background !== true) return comp;
719
+ // Reuse the underlying call component rather than re-deriving the shell
720
+ // line, so its formatting (compact-tools' collapsed cap, pi's command
721
+ // fallback and timeout suffix) stays the single source of truth; only
722
+ // prefix the badge. Underlying renderers may return a pi-tui Text (with
723
+ // setText) or a duck-typed component with a mutable `text` field and a
724
+ // cache to invalidate (compact-tools' ShellCallLine), so handle both.
725
+ const line = comp as unknown as { text?: unknown; setText?: (t: string) => void; invalidate?: () => void };
726
+ if (typeof line.text !== "string") return comp;
727
+ const badged = `${theme.inverse(theme.bold(" Background "))} ${line.text}`;
728
+ if (typeof line.setText === "function") {
729
+ line.setText(badged);
730
+ } else {
731
+ line.text = badged;
732
+ line.invalidate?.();
733
+ }
734
+ return comp;
735
+ },
736
+ };
737
+ });
738
+ }
739
+
740
+ // ---------------------------------------------------------------------------
741
+ // Extension entry point
742
+ // ---------------------------------------------------------------------------
743
+
744
+ // Collapsed cap for the delivered result line: at most this many visual
745
+ // (wrapped) lines, mirroring compact-tools' shell call-line behavior — the
746
+ // overflow is cut off at terminal width with a dim hint naming the hidden
747
+ // line count. Measured in rendered lines so it adapts to terminal width.
748
+ const MAX_RESULT_LINES = 1;
749
+
750
+ // Cut text to maxLines visual lines at the given width, appending the same
751
+ // "… (+N more lines)" hint compact-tools uses.
752
+ function capLines(text: string, maxLines: number, width: number, theme: any): string[] {
753
+ const lines = new Text(text, 0, 0).render(width);
754
+ if (lines.length <= maxLines) return lines;
755
+ const hidden = lines.length - maxLines;
756
+ const hint = theme.fg("dim", ` … (+${hidden} more line${hidden === 1 ? "" : "s"})`);
757
+ const kept = lines.slice(0, maxLines);
758
+ const last = kept[kept.length - 1].replace(/\s+$/, "");
759
+ const avail = Math.max(0, width - visibleWidth(hint));
760
+ kept[kept.length - 1] = avail > 0 ? truncateToWidth(last, avail, "") + hint : truncateToWidth(last, width, "…");
761
+ return kept;
762
+ }
763
+
764
+ interface BgResultDetails {
765
+ jobId?: number;
766
+ exit?: string;
767
+ secs?: number;
768
+ command?: string;
769
+ outPath?: string;
770
+ tail?: string;
771
+ errTail?: string;
772
+ }
773
+
774
+ // Stateful rendered row for a finished bg job, mirroring how
775
+ // ToolExecutionComponent behaves: flush single-line collapsed view with the
776
+ // cap hint, click-to-expand via handleMouse (same event contract tool rows
777
+ // use), and setExpanded for ctrl+o (app.tools.expand) which the host message
778
+ // component forwards. Purple customMessageBg keeps it visually distinct from
779
+ // synchronous tool rows (user request).
780
+ class BgResultRow {
781
+ private expanded: boolean;
782
+ private cachedWidth?: number;
783
+ private cachedLines?: string[];
784
+ private message: any;
785
+ private theme: any;
786
+
787
+ constructor(message: any, theme: any, expanded = false) {
788
+ this.message = message;
789
+ this.theme = theme;
790
+ this.expanded = expanded;
791
+ }
792
+
793
+ setExpanded(expanded: boolean): void {
794
+ if (this.expanded !== expanded) {
795
+ this.expanded = expanded;
796
+ this.cachedLines = undefined;
797
+ }
798
+ }
799
+
800
+ invalidate(): void {
801
+ this.cachedLines = undefined;
802
+ }
803
+
804
+ handleMouse(event: { type?: string; button?: string }): { handled: boolean } | undefined {
805
+ if (event.type !== "click" || event.button !== "left") return undefined;
806
+ this.setExpanded(!this.expanded);
807
+ return { handled: true };
808
+ }
809
+
810
+ render(width: number): string[] {
811
+ if (this.cachedLines === undefined || this.cachedWidth !== width) {
812
+ const theme = this.theme;
813
+ const d = (this.message.details ?? {}) as BgResultDetails;
814
+ const ok = d.exit === "0";
815
+ const icon = theme.fg(ok ? "success" : "error", ok ? "✓" : "✗");
816
+ const badge = theme.inverse(theme.bold(" Background "));
817
+ const cmd =
818
+ `${badge} ${icon} ${theme.fg("toolTitle", theme.bold(`$ ${d.command ?? ""}`))}` +
819
+ theme.fg("dim", ` #${d.jobId ?? "?"} · exit ${d.exit ?? "?"} · ${d.secs ?? "?"}s`);
820
+
821
+ if (!this.expanded) {
822
+ this.cachedLines = capLines(cmd, MAX_RESULT_LINES, width, theme);
823
+ } else {
824
+ const lines = [cmd, ""];
825
+ for (const line of (d.tail ?? "").split("\n")) {
826
+ if (line.trim()) lines.push(theme.fg("toolOutput", line));
827
+ }
828
+ if (d.errTail) {
829
+ for (const line of d.errTail.split("\n")) {
830
+ if (line.trim()) lines.push(theme.fg("error", line));
831
+ }
832
+ }
833
+ if (d.outPath) lines.push("", theme.fg("dim", shortenPath(d.outPath)));
834
+ this.cachedLines = new Text(lines.join("\n"), 0, 0).render(width);
835
+ }
836
+ this.cachedWidth = width;
837
+ }
838
+ return this.cachedLines;
839
+ }
840
+ }
841
+
842
+ export default function (pi: ExtensionAPI) {
843
+ // Badge background launches in the transcript (any bash/powershell call).
844
+ registerBackgroundCallBadge(pi);
845
+
846
+ // Override bash immediately (exists in every session).
847
+ registerBgTool(pi, "bash", createBashTool(process.cwd()));
848
+
849
+ // Housekeeping: remove stale pi-bg-* temp files from past sessions.
850
+ sweepOldJobFiles();
851
+
852
+ // Style bg-bash result deliveries like a compact-tools shell row on the
853
+ // purple customMessage background: collapsed = single capped ✓/✗ status line,
854
+ // click or ctrl+o expands to command, output tail, stderr, and the dimmed
855
+ // full-output path. Both states start with the inverted "Background" chip.
856
+ // BgResultRow owns its own expanded state for clicks;
857
+ // the host forwards ctrl+o via setExpanded. Box adds the outputPad
858
+ // left/right padding tinted purple plus one padding line above and below.
859
+ pi.registerMessageRenderer("bg-bash-result", (message, { expanded, outputPad }, theme) => {
860
+ const box = new Box(outputPad, 1, (t) => theme.bg("customMessageBg", t));
861
+ box.addChild(new BgResultRow(message, theme, expanded));
862
+ return box;
863
+ });
864
+
865
+ pi.events.on("async-work:providers:v1", (data) => {
866
+ const discovery = data as ProviderDiscovery;
867
+ if (!currentCtx || discovery.sessionFile !== currentCtx.sessionManager.getSessionFile()) return;
868
+ const epoch = sessionEpoch.signal;
869
+ const sessionFile = discovery.sessionFile;
870
+ discovery.add({ source: "shell", claim: (ids, owner) => claimJobs(pi, epoch, sessionFile, ids, owner) });
871
+ });
872
+
873
+ pi.on("agent_before_settle", (event, ctx) => {
874
+ if (!currentSession(ctx) || event.outcome !== "completed" || event.continue ||
875
+ event.context.pendingMessages.length || ctx.hasPendingMessages()) return;
876
+ trimHistory();
877
+ const pending = [...jobs.values()].filter((job) => currentJob(job) &&
878
+ job.outcome && !job.owner && !job.consumed && !job.submitted);
879
+ if (!pending.length) return;
880
+ const entries = pending.map(resultDraft);
881
+ for (const job of pending) job.submitted = true;
882
+ trimHistory();
883
+ return { entries: [...(event.entries ?? []), ...entries], continue: true };
884
+ });
885
+
886
+ pi.on("agent_settled", (event, ctx) => {
887
+ trimHistory();
888
+ if (!currentSession(ctx) || (event as { outcome?: string }).outcome !== "completed" || idleFlush !== undefined) return;
889
+ const epoch = sessionEpoch.signal;
890
+ // Notification-only: defer until Pi has left the busy lifecycle. One shot,
891
+ // no polling, and never replay an offer overwritten by a later handler.
892
+ idleFlush = setTimeout(() => {
893
+ idleFlush = undefined;
894
+ if (epoch.aborted || epoch !== sessionEpoch.signal || !currentSession(ctx) ||
895
+ !ctx.isIdle() || ctx.hasPendingMessages()) return;
896
+ for (const job of jobs.values()) pushIfIdle(pi, job);
897
+ }, 0);
898
+ });
899
+
900
+ // Pi 1.3 reports session switches through session_start, not session_switch.
901
+ pi.on("session_tree", (_event, ctx) => invalidateSession(ctx));
902
+
903
+ // Override powershell only where pi actually provides it (Windows).
904
+ let psRegistered = false;
905
+ pi.on("session_start", async (_event, ctx) => {
906
+ invalidateSession(ctx, true);
907
+ if (psRegistered) return;
908
+ if (pi.getAllTools().some((t) => t.name === "powershell")) {
909
+ psRegistered = true;
910
+ registerBgTool(pi, "powershell", createPowerShellTool(process.cwd()));
911
+ }
912
+ });
913
+
914
+ // Teach the conventions once per agent run, on top of the tool descriptions.
915
+ pi.on("before_agent_start", async (event) => {
916
+ const guidelines = event.systemPromptOptions.promptGuidelines;
917
+ if (!guidelines.includes(GUIDELINE)) guidelines.push(GUIDELINE);
918
+ if (!guidelines.includes(CODEMODE_GUIDELINE)) guidelines.push(CODEMODE_GUIDELINE);
919
+ });
920
+
921
+ // Stop all completion watchers on shutdown; the detached processes keep running.
922
+ pi.on("session_shutdown", async () => {
923
+ invalidateSession(undefined, true);
924
+ for (const job of jobs.values()) stopWatcher(job);
925
+ jobs.clear();
926
+ sweepOldJobFiles();
927
+ });
928
+
929
+ // Guard every bash/powershell call (default timeout injection + sleep-wait
930
+ // block) and give codemode scripts a default deadline. Covers model-issued
931
+ // calls and nested calls made by codemode scripts and other tools via
932
+ // ctx.executeTool (nested calls go through tool_call).
933
+ pi.on("tool_call", async (event) => {
934
+ const input = event.input as (ShellToolInput & { code?: unknown }) | undefined;
935
+ if (!input || typeof input !== "object") return;
936
+ if (event.toolName === "bash" || event.toolName === "powershell") {
937
+ injectShellTimeout(input);
938
+ if (typeof input.command === "string" && input.background !== true && isWaitForSleep(event.toolName, input.command)) {
939
+ return {
940
+ block: true,
941
+ reason:
942
+ `Sleep-waiting wastes the whole turn: run the long command with background:true (you get a job id immediately) ` +
943
+ `and end your turn or keep working - the result is delivered to you automatically when the job finishes. ` +
944
+ `Use wait with explicit job references when blocked; do independent work after dispatch.`,
945
+ };
946
+ }
947
+ } else if (event.toolName === "codemode") {
948
+ injectCodemodeTimeout(input);
949
+ }
950
+ });
951
+
952
+ // Human-facing job list.
953
+ pi.registerCommand("bg", {
954
+ description: "List background jobs started via bash/powershell background:true",
955
+ handler: async (_args, ctx) => {
956
+ const visible = currentSession(ctx) ? [...jobs.values()].filter(currentJob) : [];
957
+ if (visible.length === 0) {
958
+ ctx.ui.notify("No background jobs.", "info");
959
+ return;
960
+ }
961
+ const lines = visible.map((job) => {
962
+ const secs = job.result?.secs ?? Math.floor(Date.now() / 1000) - job.startedAt;
963
+ const done = job.result ? `done, exit ${job.result.exit}` : existsSync(job.statusPath) ? "done" : "running";
964
+ return `#${job.id} [${job.tool}] ${done} ${secs}s pid=${job.pid ?? "?"} :: ${job.command.slice(0, 80)}`;
965
+ });
966
+ ctx.ui.notify(`Background jobs:\n${lines.join("\n")}`, "info");
967
+ },
968
+ });
969
+ }