@kushalkhemka/opensec-pi-subagents 0.19.7 → 0.19.9
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.
- package/CHANGELOG.md +28 -0
- package/README.md +7 -4
- package/dist/agent-manager.js +64 -36
- package/dist/agent-runner.d.ts +25 -0
- package/dist/agent-runner.js +48 -2
- package/dist/index.js +66 -5
- package/dist/schedule-store.d.ts +5 -1
- package/dist/schedule-store.js +28 -8
- package/dist/ui/conversation-viewer.js +9 -7
- package/dist/ui/terminal-text.d.ts +15 -0
- package/dist/ui/terminal-text.js +33 -0
- package/dist/ui/workflow-card.d.ts +2 -0
- package/dist/ui/workflow-card.js +17 -2
- package/dist/ui/workflow-dialog.js +2 -1
- package/dist/workflow/host.js +18 -5
- package/dist/workflow/json-schema.d.ts +6 -0
- package/dist/workflow/json-schema.js +89 -0
- package/dist/workflow/meta.d.ts +11 -1
- package/dist/workflow/meta.js +99 -17
- package/dist/workflow/progress.d.ts +32 -0
- package/dist/workflow/progress.js +82 -0
- package/dist/workflow/runtime.d.ts +22 -2
- package/dist/workflow/runtime.js +37 -7
- package/dist/workflow/saved.js +16 -9
- package/dist/workflow/script-file.d.ts +18 -0
- package/dist/workflow/script-file.js +27 -0
- package/dist/workflow/task.js +6 -4
- package/dist/workflow/tool-description.d.ts +1 -1
- package/dist/workflow/tool-description.js +2 -0
- package/dist/workflow/worker-source.d.ts +10 -6
- package/dist/workflow/worker-source.js +57 -6
- package/docs/workflows.md +3 -3
- package/package.json +18 -8
- package/src/agent-manager.ts +54 -35
- package/src/agent-runner.ts +58 -2
- package/src/index.ts +64 -5
- package/src/schedule-store.ts +29 -7
- package/src/ui/conversation-viewer.ts +9 -7
- package/src/ui/terminal-text.ts +36 -0
- package/src/ui/workflow-card.ts +18 -2
- package/src/ui/workflow-dialog.ts +2 -1
- package/src/workflow/host.ts +17 -5
- package/src/workflow/json-schema.ts +79 -0
- package/src/workflow/meta.ts +115 -18
- package/src/workflow/progress.ts +83 -0
- package/src/workflow/runtime.ts +51 -8
- package/src/workflow/saved.ts +15 -8
- package/src/workflow/script-file.ts +29 -0
- package/src/workflow/task.ts +13 -4
- package/src/workflow/tool-description.ts +2 -0
- package/src/workflow/worker-source.ts +57 -6
- package/bun.lock +0 -591
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.19.9] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **No load warning on Pi 1.0.** `@sinclair/typebox` and `typebox` were listed in `dependencies`, which Pi 1.0 reports as host-provided packages that can bypass its extension loader. They are now optional `"*"` peer dependencies (Pi supplies both on 0.87.1 and 1.0) and stay devDependencies for tests and typecheck.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- The development pins of `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` move from 0.84.2 to 1.0.0, so `npm test` and `prepublishOnly` run the e2e tests that need Pi 1.0 APIs.
|
|
19
|
+
|
|
20
|
+
## [0.19.8] - 2026-10-03
|
|
21
|
+
|
|
22
|
+
The Node build now carries the same fixes as the Bun build (0.19.5-bun.x).
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **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.
|
|
26
|
+
- **Terminal escapes** (OSC 52 clipboard writes, screen clears, CR) in child output, gate output and logs are stripped before rendering.
|
|
27
|
+
- **`ext:` tool narrowing** is enforced on Pi 1.0 deferred/codemode tool-to-tool calls too.
|
|
28
|
+
- `get_subagent_result` with `wait: true` no longer reports "still running" while the worktree is being created.
|
|
29
|
+
- A corrupt schedule file is kept as `.corrupt` instead of overwritten, and waiting for the schedule lock no longer spins a core.
|
|
30
|
+
- A canceled session switch no longer stops the scheduler; a foreground resume can be stopped.
|
|
31
|
+
- The agents widget timer stops on session shutdown.
|
|
32
|
+
- Refuses to load next to `opensec-pi-swarm` (same tools and events); the first one loaded stays active and the other warns once.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- Workflow scripts are documented as running with full host access: the vm is not a sandbox.
|
|
36
|
+
- 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.
|
|
37
|
+
|
|
10
38
|
## [0.19.7] - 2026-10-03
|
|
11
39
|
|
|
12
40
|
### 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
|
|
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`
|
|
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
|
|
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
|
|
package/dist/agent-manager.js
CHANGED
|
@@ -937,47 +937,75 @@ export class AgentManager {
|
|
|
937
937
|
}
|
|
938
938
|
return record;
|
|
939
939
|
}
|
|
940
|
-
// Foreground resume
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
/**
|
package/dist/agent-runner.d.ts
CHANGED
|
@@ -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;
|
package/dist/agent-runner.js
CHANGED
|
@@ -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
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
package/dist/schedule-store.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/schedule-store.js
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|