one-code-extension 0.3.1 → 0.4.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 (113) hide show
  1. package/README.md +88 -10
  2. package/docs/guide/README.md +1 -0
  3. package/docs/guide/configuration.md +2 -2
  4. package/docs/guide/differences-from-claude-code.md +13 -4
  5. package/docs/guide/hooks.md +14 -2
  6. package/docs/guide/installation.md +54 -6
  7. package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
  8. package/docs/guide/reference.md +5 -2
  9. package/docs/guide/sessions-and-context.md +18 -0
  10. package/docs/guide/terminal-interface.md +1 -1
  11. package/docs/guide/tools.md +24 -2
  12. package/docs/guide/troubleshooting.md +14 -4
  13. package/docs/guide/windows.md +184 -0
  14. package/extensions/auto-mode/actions.ts +3 -1
  15. package/extensions/auto-mode/paths.ts +21 -23
  16. package/extensions/auto-mode/safety-floor.ts +44 -6
  17. package/extensions/auto-mode/shell-analysis.ts +25 -10
  18. package/extensions/auto-mode/transcript.ts +15 -5
  19. package/extensions/background/index.ts +83 -34
  20. package/extensions/background/monitor-batch.ts +7 -5
  21. package/extensions/background/wakeup.ts +18 -24
  22. package/extensions/bash/background.ts +19 -10
  23. package/extensions/bash/guards.ts +36 -16
  24. package/extensions/bash/index.ts +36 -166
  25. package/extensions/branding/index.ts +35 -4
  26. package/extensions/btw/index.ts +207 -0
  27. package/extensions/btw/panel.ts +179 -0
  28. package/extensions/btw/prompt.ts +45 -0
  29. package/extensions/clear/index.ts +15 -0
  30. package/extensions/compaction/fit.ts +9 -6
  31. package/extensions/doctor/build.ts +22 -2
  32. package/extensions/doctor/dependencies.ts +63 -31
  33. package/extensions/doctor/fix-prompt.ts +12 -4
  34. package/extensions/doctor/index.ts +1 -0
  35. package/extensions/doctor/models.ts +2 -1
  36. package/extensions/doctor/report.ts +2 -3
  37. package/extensions/doctor/update-lookup.ts +19 -11
  38. package/extensions/effort/index.ts +2 -1
  39. package/extensions/footer/footer-line.ts +19 -0
  40. package/extensions/footer/index.ts +25 -5
  41. package/extensions/hooks/executor.ts +84 -18
  42. package/extensions/hooks/index.ts +1 -0
  43. package/extensions/hooks/matcher.ts +23 -9
  44. package/extensions/hooks/settings.ts +15 -0
  45. package/extensions/lib/branch-restore.ts +12 -0
  46. package/extensions/lib/claude-context.ts +2 -3
  47. package/extensions/lib/claude-settings.ts +19 -0
  48. package/extensions/lib/command-launch.ts +71 -0
  49. package/extensions/lib/deferred.ts +173 -33
  50. package/extensions/lib/git.ts +16 -0
  51. package/extensions/lib/keys.ts +38 -0
  52. package/extensions/lib/local-command.ts +112 -0
  53. package/extensions/lib/model-facts.generated.json +263 -68
  54. package/extensions/lib/model-policy.ts +13 -0
  55. package/extensions/lib/model-tier.ts +16 -5
  56. package/extensions/lib/notifications.ts +547 -53
  57. package/extensions/lib/paths.ts +130 -2
  58. package/extensions/lib/pi-ai-estimate.ts +57 -26
  59. package/extensions/lib/pi-version.ts +1 -1
  60. package/extensions/lib/process-tree.ts +131 -5
  61. package/extensions/lib/reminders.ts +80 -27
  62. package/extensions/lib/scratchpad.ts +11 -10
  63. package/extensions/lib/shell-spawn.ts +298 -0
  64. package/extensions/lib/shell-tool.ts +252 -0
  65. package/extensions/lib/shell-tools.ts +13 -0
  66. package/extensions/lib/side-call.ts +76 -0
  67. package/extensions/lib/tui-render.ts +62 -11
  68. package/extensions/lib/update-check.mjs +155 -0
  69. package/extensions/lib/usage-bus.ts +1 -1
  70. package/extensions/lib/which.ts +62 -0
  71. package/extensions/lib/worktree-channel.ts +18 -0
  72. package/extensions/lib/worktree-isolation.ts +2 -1
  73. package/extensions/lsp/client.ts +14 -3
  74. package/extensions/lsp/index.ts +12 -5
  75. package/extensions/lsp/install-hints.ts +33 -3
  76. package/extensions/lsp/plugin-servers.ts +2 -2
  77. package/extensions/lsp/servers.ts +29 -7
  78. package/extensions/mcp/index.ts +3 -2
  79. package/extensions/memory/entries.ts +9 -6
  80. package/extensions/memory/index.ts +3 -2
  81. package/extensions/memory/open-external.ts +4 -1
  82. package/extensions/model-default/index.ts +21 -2
  83. package/extensions/permissions/index.ts +57 -28
  84. package/extensions/permissions/matcher.ts +120 -33
  85. package/extensions/permissions/modes.ts +14 -6
  86. package/extensions/permissions/powershell-rules.ts +549 -0
  87. package/extensions/permissions/session-grant.ts +9 -9
  88. package/extensions/plugins/index.ts +3 -2
  89. package/extensions/plugins/install/install.ts +1 -1
  90. package/extensions/powershell/description.ts +139 -0
  91. package/extensions/powershell/guards.ts +91 -0
  92. package/extensions/powershell/index.ts +89 -0
  93. package/extensions/powershell/policy.ts +91 -0
  94. package/extensions/recap/index.ts +10 -24
  95. package/extensions/recap/prompt.ts +3 -3
  96. package/extensions/session-title/index.ts +120 -0
  97. package/extensions/session-title/title.ts +111 -0
  98. package/extensions/skill/index.ts +40 -18
  99. package/extensions/skill/invoke.ts +1 -0
  100. package/extensions/subagents/hand-back-review.ts +10 -12
  101. package/extensions/subagents/index.ts +221 -78
  102. package/extensions/subagents/model-select.ts +75 -7
  103. package/extensions/system-prompt/environment.ts +14 -1
  104. package/extensions/system-reminder/index.ts +9 -0
  105. package/extensions/tasks/index.ts +2 -1
  106. package/extensions/tool-search/index.ts +41 -20
  107. package/extensions/workflow/agent-session.ts +6 -4
  108. package/extensions/workflow/index.ts +28 -5
  109. package/extensions/worktree/guards.ts +5 -4
  110. package/extensions/worktree/index.ts +15 -2
  111. package/extensions/worktree/rewrite.ts +17 -0
  112. package/package.json +7 -4
  113. package/types/app.d.ts +18 -2
@@ -6,18 +6,19 @@
6
6
  * long-running work over TASK_REGISTER_CHANNEL at runtime, so task_output and
7
7
  * task_stop address every background task in the session regardless of which
8
8
  * extension started it. Events and completions are delivered as steered
9
- * system notifications (lib/notifications.ts), never as user input.
9
+ * task notifications (lib/notifications.ts), never as user input; a fired
10
+ * wakeup or loop tick re-invokes the session with its prompt verbatim.
10
11
  */
11
12
 
12
- import { spawn } from "node:child_process";
13
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { Type } from "typebox";
15
15
  import { whenAborted } from "../lib/abort.ts";
16
16
  import { DEFER_CHANNEL } from "../lib/deferred.ts";
17
17
  import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
18
- import { detachedSpawnOptions, KILL_GRACE_MS, stopProcessTree } from "../lib/process-tree.ts";
18
+ import { detachedSpawnOptions, KILL_GRACE_MS, stopProcessTree, waitForChildExit } from "../lib/process-tree.ts";
19
19
  import { sessionAlive } from "../lib/session-lifecycle.ts";
20
- import { ccToolRenderers, customMessageText, liveUiCtx, notificationComponent } from "../lib/tui-render.ts";
20
+ import { bashSpawn, spawnShellCommand } from "../lib/shell-spawn.ts";
21
+ import { ccToolRenderers, customMessageText, liveUiCtx, notificationComponent, scheduledTaskComponent } from "../lib/tui-render.ts";
21
22
  import {
22
23
  type BackgroundTask,
23
24
  BackgroundRegistry,
@@ -35,16 +36,28 @@ import {
35
36
  MIN_DELAY_SECONDS,
36
37
  parseLoopArgs,
37
38
  } from "./wakeup.ts";
38
- import { createTaskNotifier, oneShotNote, sessionOutlivesTurn, systemNotification } from "../lib/notifications.ts";
39
+ import {
40
+ createTaskNotifier,
41
+ monitorEndedSummary,
42
+ monitorEventSummary,
43
+ oneShotNote,
44
+ sessionOutlivesTurn,
45
+ TASK_OUTPUT_DELIVERED_CHANNEL,
46
+ type TaskOutputDelivered,
47
+ taskNotification,
48
+ taskStatusOf,
49
+ } from "../lib/notifications.ts";
39
50
  import {
40
51
  batchSize,
41
52
  emptyBatch,
42
- formatMonitorBatch,
53
+ formatMonitorEvents,
43
54
  MONITOR_BATCH_BUSY_MS,
55
+ MONITOR_BATCH_MAX_CHARS,
44
56
  MONITOR_BATCH_IDLE_MS,
45
57
  type MonitorBatch,
46
58
  pushEvent,
47
59
  } from "./monitor-batch.ts";
60
+ import { registerLocalCommand } from "../lib/local-command.ts";
48
61
 
49
62
  const OUTPUT_CAP = 30_000;
50
63
  const STORED_OUTPUT_CAP = 200_000;
@@ -103,16 +116,26 @@ export default function backgroundExtension(pi: ExtensionAPI) {
103
116
  live.ui.setWidget("cc-background", running > 0 ? [` background tasks: ${running} running`] : undefined);
104
117
  };
105
118
 
106
- // Harness-injected notifications carry anti-confabulation framing for the
107
- // model; the transcript shows a compact headline instead (ctrl+o expands).
108
- // One registration covers every emitter of the type (bash uses it too).
109
- for (const customType of ["task-notification", "wakeup", "loop"]) {
119
+ // The wire frames are model-facing; the transcript shows a compact headline
120
+ // instead (ctrl+o expands). One registration covers every emitter of
121
+ // `task-notification` (bash uses it too). A fired wakeup or loop tick is the
122
+ // prompt verbatim, shown behind Claude Code's "Running scheduled task" line.
123
+ pi.registerMessageRenderer("task-notification", (message, { expanded }, theme) =>
124
+ notificationComponent(theme, customMessageText(message.content), expanded),
125
+ );
126
+ for (const customType of ["wakeup", "loop"]) {
110
127
  pi.registerMessageRenderer(customType, (message, { expanded }, theme) =>
111
- notificationComponent(theme, customMessageText(message.content), expanded),
128
+ scheduledTaskComponent(theme, customMessageText(message.content), (message as { timestamp?: number }).timestamp ?? Date.now(), expanded),
112
129
  );
113
130
  }
131
+ // The dynamic /loop's opening turn is the user's command, not a fired task.
132
+ pi.registerMessageRenderer("loop-start", (message, { expanded }, theme) =>
133
+ notificationComponent(theme, customMessageText(message.content), expanded),
134
+ );
114
135
 
115
- const notify = createTaskNotifier(pi);
136
+ // A monitor's end the model already read through task_output is withdrawn
137
+ // like a shell's (lib/notifications.ts).
138
+ const notify = createTaskNotifier(pi, { withdrawOnDelivery: true });
116
139
 
117
140
  pi.registerTool({
118
141
  name: "monitor",
@@ -137,7 +160,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
137
160
  ),
138
161
  ),
139
162
  }),
140
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
163
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
141
164
  lastCtx = ctx;
142
165
  if (Boolean(params.command) === Boolean(params.ws)) {
143
166
  return {
@@ -170,10 +193,12 @@ export default function backgroundExtension(pi: ExtensionAPI) {
170
193
  if (batchSize(pending) === 0) return;
171
194
  const batch = pending;
172
195
  pending = emptyBatch();
173
- notify("task-notification", systemNotification(formatMonitorBatch(id, params.description, batch)), {
174
- taskId: id,
175
- events: batchSize(batch),
176
- });
196
+ // CC's mid-run monitor batch: no status, the lines in `<event>`.
197
+ notify(
198
+ "task-notification",
199
+ taskNotification({ kind: "monitor", taskId: id, summary: monitorEventSummary(params.description), result: formatMonitorEvents(id, batch) }),
200
+ { taskId: id, events: batchSize(batch) },
201
+ );
177
202
  };
178
203
 
179
204
  const onEvent = (line: string) => {
@@ -200,9 +225,19 @@ export default function backgroundExtension(pi: ExtensionAPI) {
200
225
  if (!alive() || oneShot) return;
201
226
  flush();
202
227
  updateWidget();
228
+ // CC's monitor end: the recent tail rides `<event>` under the ended summary.
229
+ const ccStatus = taskStatusOf(finalStatus);
230
+ const recent = tail(stored, MONITOR_BATCH_MAX_CHARS).trim();
203
231
  notify(
204
232
  "task-notification",
205
- systemNotification(`Monitor ${id} (${params.description}) ${finalStatus}${note ? ` — ${note}` : ""} after ${eventCount} event(s).`),
233
+ taskNotification({
234
+ kind: "monitor",
235
+ taskId: id,
236
+ toolUseId: toolCallId,
237
+ status: ccStatus,
238
+ summary: monitorEndedSummary(params.description, ccStatus, eventCount > 0, note),
239
+ result: recent || undefined,
240
+ }),
206
241
  { taskId: id, status: finalStatus },
207
242
  );
208
243
  };
@@ -214,13 +249,22 @@ export default function backgroundExtension(pi: ExtensionAPI) {
214
249
  // leader — with zsh a `cmd; echo` or a pipeline otherwise lives on as an
215
250
  // orphan holding the stdout pipe, so the task never finishes and, in a
216
251
  // one-shot run, the process cannot exit (LIFECYCLE-REVIEW M2, measured).
217
- const child = spawn(process.env.SHELL || "/bin/sh", ["-c", params.command], {
252
+ // The session's bash (Git Bash on Windows) — lib/shell-spawn.ts.
253
+ const bash = bashSpawn();
254
+ if (!bash.spawn) {
255
+ return {
256
+ content: [{ type: "text", text: `Cannot start the monitor: ${bash.error ?? "no bash shell was found"}` }],
257
+ details: {},
258
+ isError: true,
259
+ };
260
+ }
261
+ const child = spawnShellCommand(bash.spawn, params.command, {
218
262
  cwd: ctx.cwd,
219
263
  ...detachedSpawnOptions(),
220
264
  stdio: ["ignore", "pipe", "pipe"],
221
265
  });
222
266
  let buffer = "";
223
- child.stdout.on("data", (chunk: Buffer) => {
267
+ child.stdout?.on("data", (chunk: Buffer) => {
224
268
  buffer += chunk.toString();
225
269
  let idx: number;
226
270
  while ((idx = buffer.indexOf("\n")) !== -1) {
@@ -229,15 +273,17 @@ export default function backgroundExtension(pi: ExtensionAPI) {
229
273
  if (line) onEvent(line);
230
274
  }
231
275
  });
232
- child.stderr.on("data", (chunk: Buffer) => {
276
+ child.stderr?.on("data", (chunk: Buffer) => {
233
277
  stored = tail(`${stored}${chunk.toString()}`, STORED_OUTPUT_CAP);
234
278
  });
235
- child.on("error", (error) => end("failed", error.message));
236
- child.on("close", (code) =>
237
- end(
238
- stopRequested ? "stopped" : code === 0 ? "completed" : "failed",
239
- code !== null && code !== 0 ? `exit code ${code}` : undefined,
240
- ),
279
+ // Exit plus a short stdio grace, not `close` (lib/process-tree.ts).
280
+ waitForChildExit(child).then(
281
+ ({ code }) =>
282
+ end(
283
+ stopRequested ? "stopped" : code === 0 ? "completed" : "failed",
284
+ code !== null && code !== 0 ? `exit code ${code}` : undefined,
285
+ ),
286
+ (error: Error) => end("failed", error.message),
241
287
  );
242
288
  stop = () => {
243
289
  stopRequested = true;
@@ -328,7 +374,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
328
374
  content: [
329
375
  {
330
376
  type: "text",
331
- text: `Monitor ${id} started (${params.description}). Events arrive as system notifications; stop with task_stop, inspect with task_output.`,
377
+ text: `Monitor ${id} started (${params.description}). Events arrive as task notifications; stop with task_stop, inspect with task_output.`,
332
378
  },
333
379
  ],
334
380
  details: { taskId: id },
@@ -341,7 +387,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
341
387
  label: "Task Output",
342
388
  ...ccToolRenderers("Task Output"),
343
389
  description:
344
- "Retrieve output from a running or finished background task (monitor, background subagent, or background bash) by task id. block=true (default) waits up to `timeout` ms for completion; block=false returns the current status immediately. You never need this just to learn that a task finished — completion arrives as a system notification with the status and the last 2 KB of output; call this for the rest of the output, when your next step needs the result now, or for a mid-run peek.",
390
+ "Retrieve output from a running or finished background task (monitor, background subagent, or background bash) by task id. block=true (default) waits up to `timeout` ms for completion; block=false returns the current status immediately. You never need this just to learn that a task finished — completion arrives as a task notification naming the output file; call this for the output itself, when your next step needs the result now, or for a mid-run peek.",
345
391
  parameters: Type.Object({
346
392
  task_id: Type.String({ description: "The task id to get output from" }),
347
393
  block: Type.Optional(Type.Boolean({ description: "Wait for completion (default true)" })),
@@ -377,6 +423,9 @@ export default function backgroundExtension(pi: ExtensionAPI) {
377
423
  updateWidget();
378
424
  const header = formatTaskLine(task);
379
425
  const body = tail(task.output(), OUTPUT_CAP) || "(no output yet)";
426
+ // The model now holds a finished task's output: a completion notification
427
+ // still waiting to go out for it is redundant (lib/notifications.ts).
428
+ if (task.status !== "running") pi.events.emit(TASK_OUTPUT_DELIVERED_CHANNEL, { taskId: task.id } satisfies TaskOutputDelivered);
380
429
  return {
381
430
  content: [{ type: "text", text: `${header}\n\n${body}` }],
382
431
  details: { taskId: task.id, status: task.status, logPath: task.logPath },
@@ -430,7 +479,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
430
479
  title: (a) => (a?.stop ? "stop" : a?.delaySeconds !== undefined ? `${a.delaySeconds}s` : undefined),
431
480
  }),
432
481
  description:
433
- "Schedule when to resume work on a self-paced recurring task — the dynamic mode of the /loop command. After `delaySeconds` (clamped to [60, 3600]) the given prompt is delivered as a system notification and a new turn starts. One wakeup is pending at a time — scheduling again replaces it; {stop: true} ends the loop and cancels any pending wakeup.\n\nPass the same task back via `prompt` each turn so the next firing repeats it. Set `noop: true` when nothing changed this tick (you checked and there's nothing to report); `noop: false` when something happened worth keeping. Pick `delaySeconds` from what you're actually waiting for: poll external state (a CI run, a deploy) at the rate it changes; for a quiet idle heartbeat prefer a long delay (1200s+). Do NOT schedule a short wakeup just to poll background work you started here — its completion already notifies you.",
482
+ "Schedule when to resume work on a self-paced recurring task — the dynamic mode of the /loop command. After `delaySeconds` (clamped to [60, 3600]) the harness re-invokes you with the given prompt, verbatim, as the next turn's input. One wakeup is pending at a time — scheduling again replaces it; {stop: true} ends the loop and cancels any pending wakeup.\n\nPass the same task back via `prompt` each turn so the next firing repeats it. Set `noop: true` when nothing changed this tick (you checked and there's nothing to report); `noop: false` when something happened worth keeping. Pick `delaySeconds` from what you're actually waiting for: poll external state (a CI run, a deploy) at the rate it changes; for a quiet idle heartbeat prefer a long delay (1200s+). Do NOT schedule a short wakeup just to poll background work you started here — its completion already notifies you.",
434
483
  parameters: Type.Object({
435
484
  delaySeconds: Type.Optional(Type.Number({ description: "Seconds from now to wake up, clamped to [60, 3600]. Required unless stop is true" })),
436
485
  prompt: Type.Optional(Type.String({ description: "The task to continue when the wakeup fires. Required unless stop is true" })),
@@ -488,9 +537,9 @@ export default function backgroundExtension(pi: ExtensionAPI) {
488
537
  pi.events.emit(DEFER_CHANNEL, { name, keywords });
489
538
  }
490
539
 
491
- pi.registerCommand("background", {
540
+ registerLocalCommand(pi, "background", {
492
541
  description: "List background tasks (monitors, background subagents)",
493
- handler: async (_args, ctx) => {
542
+ handler: async (args, ctx) => {
494
543
  const tasks = registry.list();
495
544
  ctx.ui.notify(tasks.length ? tasks.map(formatTaskLine).join("\n") : "No background tasks.", "info");
496
545
  },
@@ -509,7 +558,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
509
558
  }
510
559
  };
511
560
 
512
- pi.registerCommand("loop", {
561
+ registerLocalCommand(pi, "loop", {
513
562
  description: "Repeat a task on a loop: `/loop 5m <task>` (fixed interval) or `/loop <task>` (self-paced). `/loop stop` ends it.",
514
563
  handler: async (args, ctx) => {
515
564
  lastCtx = ctx;
@@ -580,7 +629,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
580
629
  activatedWakeupTool = true;
581
630
  }
582
631
  ctx.ui.notify(`Self-paced loop started: ${parsed.task}. Stop with /loop stop.`, "info");
583
- notify("loop", buildDynamicLoopPrompt(parsed.task), {});
632
+ notify("loop-start", buildDynamicLoopPrompt(parsed.task), {});
584
633
  },
585
634
  });
586
635
 
@@ -37,9 +37,12 @@ export function batchSize(batch: MonitorBatch): number {
37
37
  return batch.shown.length + batch.overflow;
38
38
  }
39
39
 
40
- /** The notification body for one flushed batch. */
41
- export function formatMonitorBatch(id: string, description: string, batch: MonitorBatch): string {
42
- const total = batchSize(batch);
40
+ /**
41
+ * The `<event>` body for one flushed batch: the lines, then how many more
42
+ * arrived (the full stream stays behind `task_output`). The monitor's
43
+ * description rides the notification's `<summary>`, not this text.
44
+ */
45
+ export function formatMonitorEvents(id: string, batch: MonitorBatch): string {
43
46
  const lines: string[] = [];
44
47
  let chars = 0;
45
48
  let hidden = batch.overflow;
@@ -52,7 +55,6 @@ export function formatMonitorBatch(id: string, description: string, batch: Monit
52
55
  lines.push(shown);
53
56
  chars += shown.length + 1;
54
57
  }
55
- const head = `Monitor ${id} (${description}) emitted ${total} event(s):`;
56
58
  const more = hidden > 0 ? `\n… +${hidden} more line(s) not shown — task_output ${id} has the full stream` : "";
57
- return `${head}\n${lines.join("\n")}${more}`;
59
+ return `${lines.join("\n")}${more}`;
58
60
  }
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * schedule_wakeup helpers (pure) — Claude Code's self-paced /loop timer.
3
+ *
4
+ * A fired wakeup or loop tick is a RE-INVOCATION, not a notification: Claude
5
+ * Code enqueues the scheduled prompt verbatim as the next turn's input, behind
6
+ * a dim "Running scheduled task" line in the TUI (findings §21), so the model
7
+ * reads its own task back exactly as it wrote it. These builders therefore
8
+ * return the prompt as-is; the TUI shows the line (`scheduledTaskComponent`).
3
9
  */
4
10
 
5
- import { systemNotification } from "../lib/notifications.ts";
6
-
7
11
  export const MIN_DELAY_SECONDS = 60;
8
12
  export const MAX_DELAY_SECONDS = 3600;
9
13
 
@@ -42,37 +46,27 @@ export function parseLoopArgs(raw: string): ParsedLoop {
42
46
  return { task: raw.trim() };
43
47
  }
44
48
 
45
- /** A loop follow-up: an instruction line, a blank, then the task — system-framed. */
46
- function framedLoop(lead: string, task: string): string {
47
- return systemNotification([lead, "", task].join("\n"));
48
- }
49
-
50
- /** The follow-up a fixed-interval `/loop` tick delivers. */
49
+ /** The turn input a fixed-interval `/loop` tick delivers: the task, verbatim (CC's cron fire). */
51
50
  export function buildLoopMessage(task: string): string {
52
- return framedLoop(
53
- "Loop tick — run the task below now. This repeats automatically on the interval and keeps firing until the user runs `/loop stop`.",
54
- task,
55
- );
51
+ return task;
56
52
  }
57
53
 
58
- /** The opening message for a dynamic (self-paced) `/loop` — instructs the model to drive schedule_wakeup. */
54
+ /**
55
+ * The opening turn of a dynamic (self-paced) `/loop`: the user's command, so it
56
+ * reads as their instruction — drive schedule_wakeup, pass the task back each
57
+ * time. Unframed: it is the user's `/loop`, not a harness event.
58
+ */
59
59
  export function buildDynamicLoopPrompt(task: string): string {
60
- return framedLoop(
60
+ return [
61
61
  "Self-paced loop started. Work the task below now. When this iteration is done, call schedule_wakeup to schedule the next one — choose delaySeconds by what you're waiting for, and pass the same task back as `prompt`. End the loop with schedule_wakeup {stop: true} when the task is complete or the user says to stop.",
62
+ "",
62
63
  task,
63
- );
64
+ ].join("\n");
64
65
  }
65
66
 
66
- /** The follow-up message a fired wakeup delivers. Framed so it never reads as user input. */
67
+ /** The turn input a fired wakeup delivers: the scheduled prompt, verbatim (CC re-invokes with it). */
67
68
  export function buildWakeupMessage(request: WakeupRequest): string {
68
- return systemNotification(
69
- [
70
- `Scheduled wake-up fired (reason given when scheduled: ${request.reason}).`,
71
- "Continue the task below; when done, either schedule the next wake-up with schedule_wakeup or end the loop with schedule_wakeup {stop: true}.",
72
- "",
73
- request.prompt,
74
- ].join("\n"),
75
- );
69
+ return request.prompt;
76
70
  }
77
71
 
78
72
  export function describeSchedule(request: WakeupRequest): string {
@@ -12,11 +12,11 @@
12
12
  * notification and task_output can never disagree.
13
13
  */
14
14
 
15
- import { spawn } from "node:child_process";
16
15
  import { createWriteStream } from "node:fs";
17
16
  import type { BackgroundTask } from "../background/registry.ts";
18
17
  import { whenAborted } from "../lib/abort.ts";
19
- import { detachedSpawnOptions, KILL_GRACE_MS, stopProcessTree } from "../lib/process-tree.ts";
18
+ import { detachedSpawnOptions, KILL_GRACE_MS, stopProcessTree, waitForChildExit } from "../lib/process-tree.ts";
19
+ import { bashSpawnOrThrow, type ShellSpawn, spawnShellCommand } from "../lib/shell-spawn.ts";
20
20
 
21
21
  export const STORED_OUTPUT_CAP = 200_000;
22
22
 
@@ -45,12 +45,18 @@ export interface StartBackgroundBashOptions {
45
45
  /** Kill the process tree after this many seconds. */
46
46
  timeoutSeconds?: number;
47
47
  logPath?: string;
48
+ /**
49
+ * The interpreter to run `command` under. Default: the session's bash
50
+ * (lib/shell-spawn.ts — Git Bash on Windows, `CLAUDE_CODE_GIT_BASH_PATH`
51
+ * honoured); the powershell extension passes its own spec.
52
+ */
53
+ shell?: ShellSpawn;
48
54
  onFinished(task: BackgroundTask, summary: BashFinishSummary): void;
49
55
  }
50
56
 
51
57
  export function startBackgroundBash(options: StartBackgroundBashOptions): BackgroundTask {
52
58
  // Own process group, so stop/timeout can signal the whole tree (lib/process-tree.ts).
53
- const child = spawn(process.env.SHELL || "/bin/sh", ["-c", options.command], {
59
+ const child = spawnShellCommand(options.shell ?? bashSpawnOrThrow(), options.command, {
54
60
  cwd: options.cwd,
55
61
  ...detachedSpawnOptions(),
56
62
  stdio: ["ignore", "pipe", "pipe"],
@@ -80,6 +86,7 @@ export function startBackgroundBash(options: StartBackgroundBashOptions): Backgr
80
86
 
81
87
  const task: BackgroundTask = {
82
88
  id: options.id,
89
+ // "bash" for a PowerShell run too: the kind is what the shell panel lists.
83
90
  kind: "bash",
84
91
  description: options.description,
85
92
  command: options.command,
@@ -124,13 +131,15 @@ export function startBackgroundBash(options: StartBackgroundBashOptions): Backgr
124
131
  else complete();
125
132
  };
126
133
 
127
- child.on("error", (error) => {
128
- stored = stored ? `${stored}\n${error.message}` : error.message;
129
- end("failed", null, null);
130
- });
131
- child.on("close", (code, signal) => {
132
- end(stopRequested ? "stopped" : timedOut || code !== 0 ? "failed" : "completed", code, signal);
133
- });
134
+ // Exit plus a short stdio grace, not `close`: a descendant the stop missed
135
+ // must not keep the task "running" for its whole life (lib/process-tree.ts).
136
+ waitForChildExit(child).then(
137
+ ({ code, signal }) => end(stopRequested ? "stopped" : timedOut || code !== 0 ? "failed" : "completed", code, signal),
138
+ (error: Error) => {
139
+ stored = stored ? `${stored}\n${error.message}` : error.message;
140
+ end("failed", null, null);
141
+ },
142
+ );
134
143
 
135
144
  return task;
136
145
  }
@@ -34,7 +34,7 @@ import {
34
34
  type Token,
35
35
  } from "../auto-mode/shell-analysis.ts";
36
36
 
37
- const clip = (text: string, max = 200): string => (text.length > max ? `${text.slice(0, max)}…` : text);
37
+ export const clip = (text: string, max = 200): string => (text.length > max ? `${text.slice(0, max)}…` : text);
38
38
 
39
39
  export function bashGuardReason(command: string, opts: { background: boolean }): string | undefined {
40
40
  // A heredoc/here-string body is data, but parseCommand tokenizes it as live
@@ -72,34 +72,54 @@ function sleepSeconds(args: Token[]): number | undefined {
72
72
  // A segment's raw slice can keep half of the operator that ended the previous
73
73
  // one (`a && b` → second raw is `& b`) — strip it for a clean echo.
74
74
 
75
- function waitReason(segments: Segment[]): string | undefined {
76
- // Measure the run of LEADING sleep segments: `sleep 1 && sleep 1 && …` is
77
- // one wait spelled as a chain, so the pacing exemption applies only to a
78
- // single provably-short sleep. Unprovable durations (`sleep "$DELAY"`)
79
- // count as long — the model controls the literal.
75
+ /**
76
+ * The wait guard's grammar-independent half, shared with the PowerShell
77
+ * guards: measure the run of LEADING sleep statements — `sleep 1 && sleep 1 &&
78
+ * …` is one wait spelled as a chain, so the pacing exemption applies only to a
79
+ * single provably-short sleep. Unprovable durations (`sleep "$DELAY"`) count
80
+ * as long: the model controls the literal. `sleepSeconds` returns undefined
81
+ * for a non-sleep statement.
82
+ */
83
+ export function leadingSleepReason<T>(
84
+ statements: T[],
85
+ sleepSeconds: (statement: T) => number | undefined | null,
86
+ raw: (statement: T) => string,
87
+ opts: { sleepName: string; joiner: string; untilExample: string },
88
+ ): string | undefined {
80
89
  let leadSleeps = 0;
81
90
  let total: number | undefined = 0;
82
- for (const seg of segments) {
83
- const { command: cmd, args } = resolvePayload(leadTokens(seg));
84
- if (cmd !== "sleep") break;
91
+ for (const statement of statements) {
92
+ const seconds = sleepSeconds(statement);
93
+ if (seconds === null) break; // not a sleep
85
94
  leadSleeps++;
86
- const seconds = sleepSeconds(args);
87
95
  total = total === undefined || seconds === undefined ? undefined : total + seconds;
88
96
  }
89
97
  if (leadSleeps === 0) return undefined;
90
98
  if (leadSleeps === 1 && total !== undefined && total < 2) return undefined;
91
- const rest = segments.slice(leadSleeps).map((seg) => seg.raw).filter(Boolean).join("; ");
92
- const shown = segments.slice(0, leadSleeps).map((seg) => seg.raw).join(" && ");
99
+ const rest = statements.slice(leadSleeps).map(raw).filter(Boolean).join("; ");
100
+ const shown = statements.slice(0, leadSleeps).map(raw).join(opts.joiner);
93
101
  const echo = rest ? `\`${shown}\` followed by: ${clip(rest)}` : `standalone \`${shown}\``;
94
102
  return (
95
- `Blocked: ${echo}. A foreground sleep stalls the whole session while it runs. ` +
96
- "To wait for a command you started, run it with run_in_background: true — its completion arrives as a system notification on its own, so you never need to poll. " +
97
- "To wait for a condition, use the monitor tool with an until-loop (e.g. `until <check>; do sleep 2; done`) — monitor is deferred, load it with tool_search select:monitor. " +
103
+ `Blocked: ${echo}. A foreground ${opts.sleepName} stalls the whole session while it runs. ` +
104
+ "To wait for a command you started, run it with run_in_background: true — its completion arrives as a task notification on its own, so you never need to poll. " +
105
+ `To wait for a condition, use the monitor tool with an until-loop (e.g. \`${opts.untilExample}\`) — monitor is deferred, load it with tool_search select:monitor. ` +
98
106
  "If you genuinely need a delay (rate limiting, deliberate pacing), keep it under 2 seconds. " +
99
107
  "Do not chain shorter sleeps to work around this block."
100
108
  );
101
109
  }
102
110
 
111
+ function waitReason(segments: Segment[]): string | undefined {
112
+ return leadingSleepReason(
113
+ segments,
114
+ (seg) => {
115
+ const { command: cmd, args } = resolvePayload(leadTokens(seg));
116
+ return cmd === "sleep" ? sleepSeconds(args) : null;
117
+ },
118
+ (seg) => seg.raw,
119
+ { sleepName: "sleep", joiner: " && ", untilExample: "until <check>; do sleep 2; done" },
120
+ );
121
+ }
122
+
103
123
  // ---------------------------------------------------------------------------
104
124
  // Poll-loop guard
105
125
 
@@ -173,7 +193,7 @@ export function hasBackgroundAmp(command: string): boolean {
173
193
  function orphanReason(command: string, segments: Segment[]): string | undefined {
174
194
  const message = (via: string) =>
175
195
  `Blocked: this command detaches a process with ${via}, leaving an orphan this session cannot manage — its output and exit status would be lost. ` +
176
- "Run it with run_in_background: true instead: it returns a task id immediately, completion arrives as a system notification, output stays readable with task_output, and it can be stopped with task_stop. " +
196
+ "Run it with run_in_background: true instead: it returns a task id immediately, completion arrives as a task notification, output stays readable with task_output, and it can be stopped with task_stop. " +
177
197
  "If you need shell-level parallelism inside one command, end it with `wait` so the children are reaped.";
178
198
 
179
199
  for (const seg of segments) {