@kushalkhemka/opensec-pi-subagents 0.19.7 → 0.19.8

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 (51) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +7 -4
  3. package/dist/agent-manager.js +64 -36
  4. package/dist/agent-runner.d.ts +25 -0
  5. package/dist/agent-runner.js +48 -2
  6. package/dist/index.js +66 -5
  7. package/dist/schedule-store.d.ts +5 -1
  8. package/dist/schedule-store.js +28 -8
  9. package/dist/ui/conversation-viewer.js +9 -7
  10. package/dist/ui/terminal-text.d.ts +15 -0
  11. package/dist/ui/terminal-text.js +33 -0
  12. package/dist/ui/workflow-card.d.ts +2 -0
  13. package/dist/ui/workflow-card.js +17 -2
  14. package/dist/ui/workflow-dialog.js +2 -1
  15. package/dist/workflow/host.js +18 -5
  16. package/dist/workflow/json-schema.d.ts +6 -0
  17. package/dist/workflow/json-schema.js +89 -0
  18. package/dist/workflow/meta.d.ts +11 -1
  19. package/dist/workflow/meta.js +99 -17
  20. package/dist/workflow/progress.d.ts +32 -0
  21. package/dist/workflow/progress.js +82 -0
  22. package/dist/workflow/runtime.d.ts +22 -2
  23. package/dist/workflow/runtime.js +37 -7
  24. package/dist/workflow/saved.js +16 -9
  25. package/dist/workflow/script-file.d.ts +18 -0
  26. package/dist/workflow/script-file.js +27 -0
  27. package/dist/workflow/task.js +6 -4
  28. package/dist/workflow/tool-description.d.ts +1 -1
  29. package/dist/workflow/tool-description.js +2 -0
  30. package/dist/workflow/worker-source.d.ts +10 -6
  31. package/dist/workflow/worker-source.js +57 -6
  32. package/docs/workflows.md +3 -3
  33. package/package.json +1 -1
  34. package/src/agent-manager.ts +54 -35
  35. package/src/agent-runner.ts +58 -2
  36. package/src/index.ts +64 -5
  37. package/src/schedule-store.ts +29 -7
  38. package/src/ui/conversation-viewer.ts +9 -7
  39. package/src/ui/terminal-text.ts +36 -0
  40. package/src/ui/workflow-card.ts +18 -2
  41. package/src/ui/workflow-dialog.ts +2 -1
  42. package/src/workflow/host.ts +17 -5
  43. package/src/workflow/json-schema.ts +79 -0
  44. package/src/workflow/meta.ts +115 -18
  45. package/src/workflow/progress.ts +83 -0
  46. package/src/workflow/runtime.ts +51 -8
  47. package/src/workflow/saved.ts +15 -8
  48. package/src/workflow/script-file.ts +29 -0
  49. package/src/workflow/task.ts +13 -4
  50. package/src/workflow/tool-description.ts +2 -0
  51. package/src/workflow/worker-source.ts +57 -6
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.19.8] - 2026-10-03
11
+
12
+ The Node build now carries the same fixes as the Bun build (0.19.5-bun.x).
13
+
14
+ ### Fixed
15
+ - **Workflows:** an async expression in `meta` could freeze Pi for good (also while the call was still streaming); the call-line name cache grew with every streamed prefix (~1 GB for a 95 KB script); a `log()` flood crashed progress handling; children of a finished run kept running; a paused run could be resumed while still alive; script paths that are not regular files (FIFO, `/dev/zero`) blocked or exhausted memory; schema `pattern`s with nested quantifiers are refused; top-level script errors name the script line.
16
+ - **Terminal escapes** (OSC 52 clipboard writes, screen clears, CR) in child output, gate output and logs are stripped before rendering.
17
+ - **`ext:` tool narrowing** is enforced on Pi 1.0 deferred/codemode tool-to-tool calls too.
18
+ - `get_subagent_result` with `wait: true` no longer reports "still running" while the worktree is being created.
19
+ - A corrupt schedule file is kept as `.corrupt` instead of overwritten, and waiting for the schedule lock no longer spins a core.
20
+ - A canceled session switch no longer stops the scheduler; a foreground resume can be stopped.
21
+ - The agents widget timer stops on session shutdown.
22
+ - Refuses to load next to `opensec-pi-swarm` (same tools and events); the first one loaded stays active and the other warns once.
23
+
24
+ ### Changed
25
+ - Workflow scripts are documented as running with full host access: the vm is not a sandbox.
26
+ - README: pi-bolt users install with `pi-bolt install npm:@kushalkhemka/opensec-pi-subagents@bun`; note on the `~/.pi/agent` directory shared by pi-bolt and Node Pi.
27
+
10
28
  ## [0.19.7] - 2026-10-03
11
29
 
12
30
  ### Changed
package/README.md CHANGED
@@ -18,14 +18,17 @@
18
18
  | Runtime | Command |
19
19
  |---|---|
20
20
  | **Pi on Node** (`latest`) | `pi install npm:@kushalkhemka/opensec-pi-subagents` |
21
- | **pi-bolt / Pi on Bun** (`bun`) | `pi install npm:@kushalkhemka/opensec-pi-subagents@bun` |
21
+ | **pi-bolt** (`bun`) | `pi-bolt install npm:@kushalkhemka/opensec-pi-subagents@bun` |
22
22
 
23
- > Pick the one that matches the Pi you run: `pi-bolt` (or Pi started with Bun) → `@bun`; Pi on Node → the plain name.
23
+ > Pick the one that matches the Pi you run: `pi-bolt` → `@bun`; Pi on Node → the plain name.
24
24
  > The builds are maintained per runtime and can differ in details (the Bun README lists what differs). These are private packages: log in to npm with an account that can read
25
25
  > `@kushalkhemka` first (`npm login`), or put a read token in `~/.npmrc`.
26
+ >
27
+ > pi-bolt and Node Pi share `~/.pi/agent`, so installing the `@bun` build with either command replaces the Node
28
+ > build for both. To run both side by side, give one of them its own agent dir with `PI_CODING_AGENT_DIR`.
26
29
 
27
- Restart Pi or run `/reload` after installing. Update with `pi update --extensions`; remove with
28
- `pi remove npm:@kushalkhemka/opensec-pi-subagents`.
30
+ Restart Pi or run `/reload` after installing. Update with `pi update --extensions` (pi-bolt: `pi-bolt update --extensions`); remove with
31
+ `pi remove npm:@kushalkhemka/opensec-pi-subagents` (pi-bolt: `pi-bolt remove npm:@kushalkhemka/opensec-pi-subagents`).
29
32
 
30
33
  ### Requirements
31
34
 
@@ -937,47 +937,75 @@ export class AgentManager {
937
937
  }
938
938
  return record;
939
939
  }
940
- // Foreground resume: run inline and return the settled record.
940
+ // Foreground resume returns its result inline, but still participates in
941
+ // lifecycle observers (widget, fleet list) like every other run.
942
+ if (record.status === "running" || record.status === "queued")
943
+ return undefined;
944
+ record.isBackground = false;
945
+ record.resultConsumed = true;
941
946
  record.status = "running";
942
947
  record.startedAt = Date.now();
943
948
  record.completedAt = undefined;
944
949
  record.result = undefined;
945
950
  record.error = undefined;
946
- try {
947
- const { text, failure } = await resumeAgent(record.session, prompt, {
948
- onToolActivity: (activity) => {
949
- if (activity.type === "end")
950
- record.toolUses++;
951
- options?.onToolActivity?.(activity);
952
- },
953
- onAssistantUsage: (usage) => {
954
- addUsage(record.lifetimeUsage, usage);
955
- this.onUsage?.(record, usage);
956
- options?.onAssistantUsage?.(usage);
957
- },
958
- onCompaction: (info) => {
959
- record.compactionCount++;
960
- this.onCompact?.(record, info);
961
- options?.onCompaction?.(info);
962
- },
963
- signal,
964
- });
965
- // Same contract as the spawn path (#144): a failed final turn is an
966
- // error, not a completion — but the resumed text stays available.
967
- record.status = failure ? "error" : "completed";
968
- if (failure)
969
- record.error = failure;
970
- record.result = text;
971
- record.completedAt = Date.now();
972
- }
973
- catch (err) {
974
- record.status = "error";
975
- record.error = err instanceof Error ? err.message : String(err);
976
- record.completedAt = Date.now();
977
- }
978
- // Same contract as the spawn settle paths: children spawned during the
979
- // resumed turn must not outlive it — nothing else can see or reach them.
980
- this.abortOwnedChildren(id);
951
+ const session = record.session;
952
+ const abortController = new AbortController();
953
+ record.abortController = abortController;
954
+ const onParentAbort = () => this.abort(id);
955
+ // Keep the promise on the record as well as awaiting it inline: result
956
+ // tools and waitForAll must join this run, not the already settled spawn.
957
+ const promise = (async () => {
958
+ try {
959
+ this.onStart?.(record);
960
+ if (signal?.aborted)
961
+ this.abort(id);
962
+ else
963
+ signal?.addEventListener("abort", onParentAbort, { once: true });
964
+ const { text, failure } = abortController.signal.aborted ? { text: "" } : await resumeAgent(session, prompt, {
965
+ onToolActivity: (activity) => {
966
+ if (activity.type === "end")
967
+ record.toolUses++;
968
+ options?.onToolActivity?.(activity);
969
+ },
970
+ onAssistantUsage: (usage) => {
971
+ addUsage(record.lifetimeUsage, usage);
972
+ this.onUsage?.(record, usage);
973
+ options?.onAssistantUsage?.(usage);
974
+ },
975
+ onCompaction: (info) => {
976
+ record.compactionCount++;
977
+ this.onCompact?.(record, info);
978
+ options?.onCompaction?.(info);
979
+ },
980
+ signal: abortController.signal,
981
+ });
982
+ if (record.status !== "stopped") {
983
+ record.status = failure ? "error" : "completed";
984
+ if (failure)
985
+ record.error = failure;
986
+ }
987
+ record.result = text;
988
+ record.completedAt ??= Date.now();
989
+ }
990
+ catch (err) {
991
+ if (record.status !== "stopped") {
992
+ record.status = "error";
993
+ record.error = err instanceof Error ? err.message : String(err);
994
+ }
995
+ record.completedAt ??= Date.now();
996
+ }
997
+ finally {
998
+ signal?.removeEventListener("abort", onParentAbort);
999
+ this.abortOwnedChildren(id);
1000
+ try {
1001
+ this.onComplete?.(record);
1002
+ }
1003
+ catch { /* completion observers do not replace the inline result */ }
1004
+ }
1005
+ return record.result ?? "";
1006
+ })();
1007
+ record.promise = promise;
1008
+ await promise;
981
1009
  return record;
982
1010
  }
983
1011
  /**
@@ -67,6 +67,29 @@ export declare function parseExtSelectors(entries: string[]): {
67
67
  extNames: Set<string>;
68
68
  narrowing: Map<string, Set<string>>;
69
69
  };
70
+ /** The loader path pi gives that inline extension. */
71
+ export declare const TOOL_SCOPE_GUARD_PATH = "<inline:subagent-tool-scope>";
72
+ export interface ToolScopeGuard {
73
+ /** Passed to the child loader's `extensionFactories`. */
74
+ extension: {
75
+ name: string;
76
+ hidden: boolean;
77
+ factory: (pi: ExtensionAPI) => void;
78
+ };
79
+ /** Bind the scope check once it can be derived (after the loader reloads). */
80
+ setScope(inScope: () => Set<string>): void;
81
+ }
82
+ /**
83
+ * A `tool_call` handler that blocks out-of-scope tools for every call path.
84
+ *
85
+ * Replacing `session.agent.beforeToolCall` only covers calls the model makes
86
+ * directly. Pi 1.0 also lets tools call tools (`ctx.executeTool()`, used by
87
+ * codemode and `deferred` tools), and those calls go straight to the session's
88
+ * `tool_call` handlers against every registered codemode/deferred tool, active
89
+ * or not — so an `ext:`-narrowed subagent could still reach a tool it was
90
+ * never given. Extension `tool_call` handlers run on both paths.
91
+ */
92
+ export declare function createToolScopeGuard(): ToolScopeGuard;
70
93
  /**
71
94
  * Keep a subagent's tool scope correct as extensions register tools over time.
72
95
  *
@@ -107,6 +130,8 @@ export declare function installExtensionToolScope(session: AgentSession, ctx: {
107
130
  * seeded from `toolNames`.
108
131
  */
109
132
  readmitToolNames: Set<string>;
133
+ /** The child's inline `tool_call` guard, bound to the same scope. */
134
+ guard?: ToolScopeGuard;
110
135
  }): void;
111
136
  /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
112
137
  export declare function normalizeMaxTurns(n: number | undefined): number | undefined;
@@ -101,6 +101,9 @@ function extensionPackageName(extPath) {
101
101
  */
102
102
  export function extensionCanonicalNames(extPath) {
103
103
  const canonical = extensionCanonicalName(extPath);
104
+ // Inline extensions (`<inline:name>`) have no file, so no owning package.
105
+ if (extPath.startsWith("<inline:"))
106
+ return [canonical];
104
107
  const pkg = extensionPackageName(extPath);
105
108
  return pkg && pkg !== canonical ? [canonical, pkg] : [canonical];
106
109
  }
@@ -180,6 +183,39 @@ export function parseExtSelectors(entries) {
180
183
  }
181
184
  return { extNames, narrowing };
182
185
  }
186
+ /** Name of the inline extension that enforces a subagent's tool scope. */
187
+ const TOOL_SCOPE_GUARD_NAME = "subagent-tool-scope";
188
+ /** The loader path pi gives that inline extension. */
189
+ export const TOOL_SCOPE_GUARD_PATH = `<inline:${TOOL_SCOPE_GUARD_NAME}>`;
190
+ /**
191
+ * A `tool_call` handler that blocks out-of-scope tools for every call path.
192
+ *
193
+ * Replacing `session.agent.beforeToolCall` only covers calls the model makes
194
+ * directly. Pi 1.0 also lets tools call tools (`ctx.executeTool()`, used by
195
+ * codemode and `deferred` tools), and those calls go straight to the session's
196
+ * `tool_call` handlers against every registered codemode/deferred tool, active
197
+ * or not — so an `ext:`-narrowed subagent could still reach a tool it was
198
+ * never given. Extension `tool_call` handlers run on both paths.
199
+ */
200
+ export function createToolScopeGuard() {
201
+ let inScope;
202
+ return {
203
+ extension: {
204
+ name: TOOL_SCOPE_GUARD_NAME,
205
+ hidden: true,
206
+ factory: (pi) => {
207
+ pi.on("tool_call", (event) => {
208
+ if (!inScope || inScope().has(event.toolName))
209
+ return undefined;
210
+ return { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` };
211
+ });
212
+ },
213
+ },
214
+ setScope(fn) {
215
+ inScope = fn;
216
+ },
217
+ };
218
+ }
183
219
  /**
184
220
  * Keep a subagent's tool scope correct as extensions register tools over time.
185
221
  *
@@ -206,7 +242,7 @@ export function parseExtSelectors(entries) {
206
242
  * static `allowedToolNames` allowlist already gates the registry itself.
207
243
  */
208
244
  export function installExtensionToolScope(session, ctx) {
209
- const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames } = ctx;
245
+ const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames, guard } = ctx;
210
246
  // The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
211
247
  // selector is present, extension tools become an explicit allowlist — a loaded
212
248
  // extension not named by a selector contributes nothing (its handlers still ran),
@@ -215,6 +251,8 @@ export function installExtensionToolScope(session, ctx) {
215
251
  const keep = new Set(toolNames.filter((t) => !disallowedSet?.has(t)));
216
252
  const optInActive = extNames.size > 0;
217
253
  for (const extension of loader.getExtensions().extensions) {
254
+ if (extension.path === TOOL_SCOPE_GUARD_PATH)
255
+ continue;
218
256
  const canons = extensionCanonicalNames(extension.path);
219
257
  if (optInActive && !canons.some((c) => extNames.has(c)))
220
258
  continue;
@@ -256,6 +294,7 @@ export function installExtensionToolScope(session, ctx) {
256
294
  if (event.type === "turn_end")
257
295
  renarrow();
258
296
  });
297
+ guard?.setScope(inScope);
259
298
  const priorBeforeToolCall = session.agent.beforeToolCall;
260
299
  session.agent.beforeToolCall = async (context, signal) => {
261
300
  if (!inScope().has(context.toolCall.name)) {
@@ -535,10 +574,12 @@ export async function runAgent(ctx, type, prompt, options) {
535
574
  const extensionsOverride = noExtensions || (loadAll && !hasExcludes)
536
575
  ? undefined
537
576
  : (base) => {
538
- discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
577
+ discoveredNames = new Set(base.extensions.filter((e) => e.path !== TOOL_SCOPE_GUARD_PATH).flatMap((e) => extensionCanonicalNames(e.path)));
539
578
  return {
540
579
  ...base,
541
580
  extensions: base.extensions.filter((e) => {
581
+ if (e.path === TOOL_SCOPE_GUARD_PATH)
582
+ return true; // ours, not a user extension
542
583
  const canons = extensionCanonicalNames(e.path);
543
584
  if (canons.some((n) => excludeNames.has(n)))
544
585
  return false; // exclude wins
@@ -546,11 +587,15 @@ export async function runAgent(ctx, type, prompt, options) {
546
587
  }),
547
588
  };
548
589
  };
590
+ // Only with extensions loaded: under `noExtensions` the static allowlist
591
+ // already gates the registry itself (see installExtensionToolScope).
592
+ const toolScopeGuard = noExtensions ? undefined : createToolScopeGuard();
549
593
  const loader = new DefaultResourceLoader({
550
594
  cwd: configCwd,
551
595
  agentDir,
552
596
  noExtensions,
553
597
  additionalExtensionPaths,
598
+ extensionFactories: toolScopeGuard ? [toolScopeGuard.extension] : undefined,
554
599
  extensionsOverride,
555
600
  noSkills,
556
601
  noPromptTemplates: true,
@@ -818,6 +863,7 @@ export async function runAgent(ctx, type, prompt, options) {
818
863
  extNames,
819
864
  narrowing,
820
865
  readmitToolNames,
866
+ guard: toolScopeGuard,
821
867
  });
822
868
  }
823
869
  options.onSessionCreated?.(session);
package/dist/index.js CHANGED
@@ -51,6 +51,7 @@ import { extractMeta, workflowCallName } from "./workflow/meta.js";
51
51
  import { elapsedMs } from "./workflow/progress.js";
52
52
  import { runWorkflow } from "./workflow/runtime.js";
53
53
  import { resolveWorkflowScript } from "./workflow/saved.js";
54
+ import { readWorkflowScriptFile } from "./workflow/script-file.js";
54
55
  import { completeWorkflowTask, createWorkflowTask, failWorkflowTask, formatWorkflowNotification, resolveResumeTarget, updateWorkflowProgressBatch, workflowResultText, workflowRunId } from "./workflow/task.js";
55
56
  import { fullWorkflowToolDescription } from "./workflow/tool-description.js";
56
57
  import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
@@ -254,12 +255,57 @@ export const WORKFLOW_FILE_FLAG = "subagents-workflow-file";
254
255
  * {@link WORKFLOW_ENTRY_TYPE} imports it from here.
255
256
  */
256
257
  export { FOREIGN_WORKFLOW_TOOL_NAMES, WORKFLOW_ENTRY_TYPE, workflowEntryData };
258
+ const PACKAGE_NAME = "@kushalkhemka/opensec-pi-subagents";
259
+ /** Shared with opensec-pi-swarm: which of the two packages is active in this process. */
260
+ const OWNER_KEY = Symbol.for("pi-subagents:owner");
261
+ const CONFLICT_WARNED_KEY = Symbol.for("pi-subagents:owner-conflict-warned");
262
+ /**
263
+ * Claim the process for this package, or report that the other fork holds it
264
+ * and return undefined. A claim by this same package (another activation) is
265
+ * shared, counted, and released by the last of them. Returns the release.
266
+ */
267
+ function claimSubagentsOwner(pi, packageName) {
268
+ const global = globalThis;
269
+ const owner = global[OWNER_KEY];
270
+ if (owner && owner.package !== packageName) {
271
+ const message = `${packageName} is disabled: ${owner.package} is already loaded and registers the same tools. ` +
272
+ "They cannot run together; uninstall one of them.";
273
+ pi.on("session_start", (_event, ctx) => {
274
+ if (ctx.hasUI)
275
+ ctx.ui.notify(message, "warning");
276
+ else if (!global[CONFLICT_WARNED_KEY]) {
277
+ global[CONFLICT_WARNED_KEY] = true;
278
+ console.warn(`[pi-subagents] ${message}`);
279
+ }
280
+ });
281
+ return undefined;
282
+ }
283
+ const claim = owner ?? { package: packageName, activations: 0 };
284
+ claim.activations++;
285
+ global[OWNER_KEY] = claim;
286
+ let released = false;
287
+ // Called on shutdown, so the next activation (/reload, /new) can claim.
288
+ return () => {
289
+ if (released)
290
+ return;
291
+ released = true;
292
+ if (global[OWNER_KEY] === claim && --claim.activations <= 0)
293
+ delete global[OWNER_KEY];
294
+ };
295
+ }
257
296
  export default function (pi) {
258
297
  // Child AgentSessions load normal extensions. Re-entering this extension there
259
298
  // would create another manager and leak handlers. Nested orchestration is
260
299
  // injected as scoped custom tools by the existing manager instead.
261
300
  if (inChildSessionContext())
262
301
  return;
302
+ // opensec-pi-subagents and opensec-pi-swarm (a fork of it) register the same
303
+ // tool names, `subagents:*` RPC events and process-global manager registry,
304
+ // so only one of them can be active in a process: the first to load claims
305
+ // it, and the other disables itself with a warning.
306
+ const releaseOwner = claimSubagentsOwner(pi, PACKAGE_NAME);
307
+ if (!releaseOwner)
308
+ return;
263
309
  // ---- Register custom notification renderer ----
264
310
  pi.registerMessageRenderer("subagent-notification", (message, { expanded }, theme) => {
265
311
  const d = message.details;
@@ -1004,8 +1050,9 @@ export default function (pi) {
1004
1050
  return { action: "handled" };
1005
1051
  });
1006
1052
  pi.on("session_before_switch", () => {
1007
- manager.clearCompleted(true);
1008
- scheduler.stop();
1053
+ // This event can be canceled, or target-session validation can fail after
1054
+ // it returns. Pi confirms replacement by emitting session_shutdown; keep
1055
+ // the current schedules and agent records intact until then.
1009
1056
  });
1010
1057
  // On shutdown, abort all agents immediately and clean up.
1011
1058
  // If the session is going down, there's nothing left to consume agent results.
@@ -1021,6 +1068,7 @@ export default function (pi) {
1021
1068
  if (ownsManagerRegistry && globalThis[MANAGER_KEY] === registryEntry) {
1022
1069
  delete globalThis[MANAGER_KEY];
1023
1070
  }
1071
+ releaseOwner();
1024
1072
  scheduler.stop();
1025
1073
  // Before abortAll, and not folded into it: a workflow owns a worker thread
1026
1074
  // as well as its children, and only its own signal terminates that.
@@ -1035,6 +1083,13 @@ export default function (pi) {
1035
1083
  clearTimeout(timer);
1036
1084
  pendingNudges.clear();
1037
1085
  fleet.dispose();
1086
+ // Its 80 ms refresh interval otherwise outlives the session across /reload.
1087
+ // The interval is cleared first; the UI calls after it may meet a context
1088
+ // that is already gone.
1089
+ try {
1090
+ widget.dispose();
1091
+ }
1092
+ catch { /* stale UI context at shutdown */ }
1038
1093
  // Awaited: it emits `session_shutdown` into every retained child session so
1039
1094
  // extensions bound there can release what they armed in `session_start` (#242).
1040
1095
  // pi awaits this handler, and the process exits right after — unawaited, those
@@ -2251,8 +2306,11 @@ Terse command-style prompts produce shallow, generic work.
2251
2306
  title: Type.Optional(Type.String({ description: "Ignored — set the workflow title in the script's `meta` block." })),
2252
2307
  description: Type.Optional(Type.String({ description: "Ignored — set the workflow description in the script's `meta` block." })),
2253
2308
  }),
2254
- renderCall(args, theme) {
2255
- return new Text(`${theme.fg("toolTitle", "▸ ")}${theme.bold(theme.fg("toolTitle", "SubagentWorkflow"))} ${theme.fg("muted", workflowCallName(args))}`, 0, 0);
2309
+ renderCall(args, theme, context) {
2310
+ // Parse `meta` only once the arguments stop streaming (or on a replayed
2311
+ // call, which arrives with its result): every delta is a new prefix.
2312
+ const parse = !context || context.argsComplete || context.executionStarted || !context.isPartial;
2313
+ return new Text(`${theme.fg("toolTitle", "▸ ")}${theme.bold(theme.fg("toolTitle", "SubagentWorkflow"))} ${theme.fg("muted", workflowCallName(args, { parse }))}`, 0, 0);
2256
2314
  },
2257
2315
  renderResult(result, _options, theme, renderContext) {
2258
2316
  const text = result.content[0]?.type === "text" ? result.content[0].text : "";
@@ -2465,7 +2523,7 @@ Terse command-style prompts produce shallow, generic work.
2465
2523
  const path = isAbsolute(flag.trim()) ? flag.trim() : join(ctx.cwd, flag.trim());
2466
2524
  let script;
2467
2525
  try {
2468
- script = readFileSync(path, "utf-8");
2526
+ script = readWorkflowScriptFile(path);
2469
2527
  }
2470
2528
  catch (err) {
2471
2529
  report(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, "warning");
@@ -2531,6 +2589,9 @@ Terse command-style prompts produce shallow, generic work.
2531
2589
  while (record.status === "queued") {
2532
2590
  await abortable(new Promise((resolve) => setTimeout(resolve, QUEUE_WAIT_POLL_MS)), signal);
2533
2591
  }
2592
+ // A worktree agent has no promise until its copy is made; a failed
2593
+ // start lands on the record as an error, so the rejection is dropped.
2594
+ await abortable(manager.awaitStartup(record.id).catch(() => { }), signal);
2534
2595
  if (record.promise)
2535
2596
  await abortable(record.promise, signal);
2536
2597
  }
@@ -19,7 +19,11 @@ export declare class ScheduleStore {
19
19
  constructor(filePath: string);
20
20
  /** Create the backing directory lazily — only when we're about to persist. */
21
21
  private ensureDir;
22
- /** Load from disk into the in-memory cache. Silent on parse errors. */
22
+ /**
23
+ * Load from disk into the in-memory cache. A file that does not parse is
24
+ * moved aside to `<file>.corrupt` (with a warning) before the next save
25
+ * replaces it, so a hand-edit gone wrong can be recovered.
26
+ */
23
27
  private load;
24
28
  /** Atomic write via temp file + rename (POSIX-atomic). */
25
29
  private save;
@@ -13,6 +13,10 @@ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileS
13
13
  import { dirname, join } from "node:path";
14
14
  const LOCK_RETRY_MS = 50;
15
15
  const LOCK_MAX_RETRIES = 100;
16
+ /** Block this thread for `ms` without spinning (synchronous, like the lock). */
17
+ function sleepSync(ms) {
18
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
19
+ }
16
20
  function isProcessRunning(pid) {
17
21
  try {
18
22
  process.kill(pid, 0);
@@ -38,8 +42,9 @@ function acquireLock(lockPath) {
38
42
  }
39
43
  }
40
44
  catch { /* ignore — try again */ }
41
- const start = Date.now();
42
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
45
+ // Sleep instead of spinning: the holder is another Pi process, and a
46
+ // busy loop would burn a core for every retry it waits.
47
+ sleepSync(LOCK_RETRY_MS);
43
48
  continue;
44
49
  }
45
50
  throw e;
@@ -70,17 +75,32 @@ export class ScheduleStore {
70
75
  ensureDir() {
71
76
  mkdirSync(dirname(this.filePath), { recursive: true });
72
77
  }
73
- /** Load from disk into the in-memory cache. Silent on parse errors. */
78
+ /**
79
+ * Load from disk into the in-memory cache. A file that does not parse is
80
+ * moved aside to `<file>.corrupt` (with a warning) before the next save
81
+ * replaces it, so a hand-edit gone wrong can be recovered.
82
+ */
74
83
  load() {
75
84
  if (!existsSync(this.filePath))
76
85
  return;
86
+ let data;
77
87
  try {
78
- const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
79
- this.jobs.clear();
80
- for (const j of data.jobs ?? [])
81
- this.jobs.set(j.id, j);
88
+ data = JSON.parse(readFileSync(this.filePath, "utf-8"));
89
+ }
90
+ catch (err) {
91
+ const backup = `${this.filePath}.corrupt`;
92
+ try {
93
+ renameSync(this.filePath, backup);
94
+ console.warn(`[pi-subagents] Schedule file ${this.filePath} is not valid JSON (${err instanceof Error ? err.message : String(err)}); moved it to ${backup}.`);
95
+ }
96
+ catch (moveErr) {
97
+ console.warn(`[pi-subagents] Schedule file ${this.filePath} is not valid JSON and could not be moved aside:`, moveErr);
98
+ }
99
+ return;
82
100
  }
83
- catch { /* corrupt — start fresh, next save rewrites */ }
101
+ this.jobs.clear();
102
+ for (const j of Array.isArray(data?.jobs) ? data.jobs : [])
103
+ this.jobs.set(j.id, j);
84
104
  }
85
105
  /** Atomic write via temp file + rename (POSIX-atomic). */
86
106
  save() {
@@ -10,6 +10,7 @@ import { renderAgentName } from "../agent-color.js";
10
10
  import { extractText } from "../context.js";
11
11
  import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent } from "../usage.js";
12
12
  import { buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-widget.js";
13
+ import { sanitizeTerminalText } from "./terminal-text.js";
13
14
  import { createViewerKeys } from "./viewer-keys.js";
14
15
  /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
15
16
  const CHROME_LINES_BASE = 6;
@@ -321,7 +322,7 @@ export class ConversationViewer {
321
322
  const cost = this.showCost ? formatCost(getLifetimeCost(this.record.lifetimeUsage)) : "";
322
323
  if (cost)
323
324
  headerParts.push(cost);
324
- lines.push(row(`${statusIcon} ${renderAgentName(this.record.type, th, { bold: true })}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`));
325
+ lines.push(row(`${statusIcon} ${renderAgentName(this.record.type, th, { bold: true })}${modeTag} ${th.fg("muted", sanitizeTerminalText(this.record.description))} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`));
325
326
  const invocationLine = this.invocationLine();
326
327
  if (invocationLine)
327
328
  lines.push(row(invocationLine));
@@ -390,11 +391,12 @@ export class ConversationViewer {
390
391
  }
391
392
  /** Wrap `text` literally — the pre-Markdown path, and the fallback from it. */
392
393
  rawLines(text, width, dim) {
393
- const lines = wrapTextWithAnsi(text, width);
394
+ const lines = wrapTextWithAnsi(sanitizeTerminalText(text), width);
394
395
  return dim ? lines.map(l => this.theme.fg("dim", l)) : lines;
395
396
  }
396
397
  /** Render `text` as Markdown, reusing this message's component instance. */
397
- markdownLines(msg, text, width, dim) {
398
+ markdownLines(msg, raw, width, dim) {
399
+ const text = sanitizeTerminalText(raw);
398
400
  let entry = this.markdownCache.get(msg);
399
401
  if (!entry) {
400
402
  entry = {
@@ -508,7 +510,7 @@ export class ConversationViewer {
508
510
  if (needsSeparator)
509
511
  lines.push(th.fg("dim", "───"));
510
512
  lines.push(th.fg("accent", "[User]"));
511
- for (const line of wrapTextWithAnsi(text.trim(), width)) {
513
+ for (const line of wrapTextWithAnsi(sanitizeTerminalText(text.trim()), width)) {
512
514
  lines.push(line);
513
515
  }
514
516
  }
@@ -532,7 +534,7 @@ export class ConversationViewer {
532
534
  : this.markdownLines(msg, text, width, false)));
533
535
  }
534
536
  for (const name of toolCalls) {
535
- lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
537
+ lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${sanitizeTerminalText(name)}]`), width));
536
538
  }
537
539
  }
538
540
  else if (msg.role === "toolResult") {
@@ -552,7 +554,7 @@ export class ConversationViewer {
552
554
  const bash = msg;
553
555
  if (needsSeparator)
554
556
  lines.push(th.fg("dim", "───"));
555
- lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
557
+ lines.push(truncateToWidth(th.fg("muted", ` $ ${sanitizeTerminalText(String(bash.command))}`), width));
556
558
  if (bash.output?.trim()) {
557
559
  // Same cap as a tool result, never Markdown: command output is the one
558
560
  // thing here that is definitionally not authored as Markdown.
@@ -571,7 +573,7 @@ export class ConversationViewer {
571
573
  if (this.record.status === "running" && this.activity) {
572
574
  const act = describeActivity(this.activity.activeTools, this.activity.responseText);
573
575
  lines.push("");
574
- lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
576
+ lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", sanitizeTerminalText(act)), width));
575
577
  }
576
578
  return lines.map(l => truncateToWidth(l, width));
577
579
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * terminal-text.ts — make untrusted text safe to write to the terminal.
3
+ *
4
+ * Tool results, agent output, gate output and a script's `log()` lines are all
5
+ * text nobody here wrote. Rendered as-is, an escape sequence in them drives the
6
+ * user's terminal: OSC 52 writes the clipboard, `ESC[2J` clears the screen, a
7
+ * bare `\r` overwrites the line it sits on. Pi's own renderers strip them the
8
+ * same way (`render-utils.ts`: `stripAnsi`, then `sanitizeBinaryOutput`, then
9
+ * drop `\r`); that code is not exported to extensions, so this mirrors it.
10
+ *
11
+ * Styling is applied by the renderer AFTER this runs, so nothing legitimate is
12
+ * lost: only the input's own escape sequences go.
13
+ */
14
+ /** `text` without escape sequences or control characters other than tab and newline. */
15
+ export declare function sanitizeTerminalText(text: string): string;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * terminal-text.ts — make untrusted text safe to write to the terminal.
3
+ *
4
+ * Tool results, agent output, gate output and a script's `log()` lines are all
5
+ * text nobody here wrote. Rendered as-is, an escape sequence in them drives the
6
+ * user's terminal: OSC 52 writes the clipboard, `ESC[2J` clears the screen, a
7
+ * bare `\r` overwrites the line it sits on. Pi's own renderers strip them the
8
+ * same way (`render-utils.ts`: `stripAnsi`, then `sanitizeBinaryOutput`, then
9
+ * drop `\r`); that code is not exported to extensions, so this mirrors it.
10
+ *
11
+ * Styling is applied by the renderer AFTER this runs, so nothing legitimate is
12
+ * lost: only the input's own escape sequences go.
13
+ */
14
+ /**
15
+ * OSC (`ESC ]` … terminator) and CSI-style sequences, after Pi's `ansiRegex`.
16
+ * The OSC body excludes the terminator's first characters rather than matching
17
+ * lazily, so an unterminated OSC costs one scan to the next ESC, not to the end
18
+ * of the input for every one of them.
19
+ */
20
+ const ANSI_SEQUENCE = /\u001B\][^\u0007\u001B\u009C]*(?:\u0007|\u001B\\|\u009C)|[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]/g;
21
+ /**
22
+ * C0 controls except tab and newline (so `\r` goes too), DEL, C1 controls, and
23
+ * U+FFF9..U+FFFB, which crash string-width. Every escape sequence starts with
24
+ * one of these, which is what makes the fast path below sound.
25
+ */
26
+ const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\uFFF9-\uFFFB]/g;
27
+ const HAS_CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\uFFF9-\uFFFB]/;
28
+ /** `text` without escape sequences or control characters other than tab and newline. */
29
+ export function sanitizeTerminalText(text) {
30
+ if (!HAS_CONTROL.test(text))
31
+ return text;
32
+ return text.replace(ANSI_SEQUENCE, "").replace(CONTROL, "");
33
+ }
@@ -151,6 +151,8 @@ export declare const REPLAYED_ANNOTATION = "from resume journal";
151
151
  export declare function agentStatSegments(entry: WorkflowAgentEntry): string[];
152
152
  /** Trim a line to `width`, cutting inside whichever segment crosses the edge. */
153
153
  export declare function clampLine(line: WorkflowCardLine, width: number): WorkflowCardLine;
154
+ /** Log messages the card shows, newest last. */
155
+ export declare const CARD_LOG_LINES = 20;
154
156
  /**
155
157
  * Build the card.
156
158
  *