@signalridge/pi-subagents 1.13.0 → 1.15.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.15.0
4
+ ### Minor Changes
5
+
6
+ - a4dc1de: Enforce missing per-invocation safety budgets on resumed agents, including foreground/background calls, nested resumes, mentions, and reused managed threads. Share fresh/resumed turn, token, and tool-call enforcement and terminal outcome mapping; preserve configured grace and tool timeouts. Resume inherits the original invocation's turn budget when not overridden, respects authoritative frontmatter/default policy, validates explicit limits like spawn, and does not let zero erase a configured cap. Nested resumes resolve only their branch-local frontmatter through an in-process budget-policy capability; internal callers without it retain the admitted invocation budget rather than consulting the root registry. Authoritative frontmatter zero remains unlimited after a finite earlier invocation. A retained session's finite or unlimited policy survives later default changes; one manager-resolved cap drives enforcement and queued/running UI counters. Activity updates are invocation-owned and lifetime usage is counted only once.
7
+
8
+ Dispose grouped notifications and pending batch/nudge timers synchronously at confirmed shutdown, retire invocation-owned foreground progress/spinner callbacks before any teardown await, fence captured delivery callbacks, and preserve managed lifecycle/usage receipts while cooperative child teardown completes. Refused/failed resume admissions retire their own UI callbacks, while admitted lifetimes own final cleanup; mention notifications are fenced after asynchronous admission. Vetoable navigation remains unchanged.
9
+
10
+ Selective upstream parity references: tintinweb/pi-subagents `d7c9ec0b027b91c7ab9da0fa5f4ae203474a5bdf`, `ca93a5247e72e7053fcd10bea7125d6c4205f949` (unreleased after the 0.22 tag), and `83be963a969eb359af29cbbb4d4af2d01748bfb5` (released in 0.22). The integrated engine is not imported; managed protocol v4 and payloads are unchanged. Event-based budgets cannot forcibly terminate a provider or tool that ignores abort.
11
+
12
+ ### Patch Changes
13
+
14
+ - a4dc1de: Distinguish expandable Workflow groups from individual agents with a dedicated `▸` type mark across all run outcomes. Remove the duplicate trailing blank row before the footer while preserving Fleet navigation, exact-owner conversation opening, and the existing Workflow detail UI.
15
+
16
+ ## 1.14.0
17
+ ### Minor Changes
18
+
19
+ - 0ec64d1: Gate admitted child edit, write, and bash operations through a synchronously captured root human approval service. Preserve tool admission and legacy ask-tools policy, reject canonical shadowing and duplicate child approval resources, retain ownership across resume, and exclude human review time without resetting execution budgets. Managed protocol and journal formats remain v4.
20
+
21
+ ### Patch Changes
22
+
23
+ - 74b65a5: Bound edit call rendering without IO or diff work; retain captured service liveness and exact-name admission through post-approval IO; reject live child canonical conflicts even when SDK metadata still wins. Replace delegated bash dispatch with an owned POSIX backend guarded after CWD preparation, preserving the SDK output layer while owning process-group/IPC cleanup. Preserve native editor paste/cursor/Undo state with an admitted non-capturing UI bridge, and hide unfocused reviews so foreign native questions remain visible and answerable.
24
+
25
+ Refuse Bun/compiled application and unknown interpreter capabilities before approval or subprocess startup instead of executing the host application as a supervisor. Use lossless hard wrapping, whole-grapheme and native-normalized Thai/Lao escaping, and explicit compact-preview whitespace/backslashes so spacing marks and distinct commands remain inspectable through actual terminal output.
26
+ - 0925139: Bound synchronous file-review diff work before generation, capture bash execution options and environments, require exact per-name canonical review bindings, and close owned review overlays by identity. Recheck cooperative adapter admission after child tool-call normalizers and carry the same captured approval-service and branch-owner guard through background startup to command dispatch. Preserve existing v1 approval and v4 managed contracts; document review limits and trusted-extension boundaries.
27
+ - Updated dependencies [74b65a5]
28
+ - Updated dependencies [0ec64d1]
29
+ - Updated dependencies [0925139]
30
+ - @signalridge/pi-approval-core@1.3.0
31
+
3
32
  ## 1.13.0
4
33
  ### Minor Changes
5
34
 
package/README.md CHANGED
@@ -55,6 +55,8 @@ Derived from [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents)
55
55
 
56
56
  The container-first FleetView and bounded drill-down patterns are independently adapted from tintinweb/pi-subagents at [`221df02323c6b6350ea8474579e0ee0f6cc84cea`](https://github.com/tintinweb/pi-subagents/tree/221df02323c6b6350ea8474579e0ee0f6cc84cea/src/ui) and [`fd8d81618d80ae83a3faf40cb87dae99cca33264`](https://github.com/tintinweb/pi-subagents/tree/fd8d81618d80ae83a3faf40cb87dae99cca33264/src/ui). Collision-only child-label discriminators are inspired by [nicobailon/pi-subagents `69673c7fbf3351e54a5b0786a7e9d9fff625a749`](https://github.com/nicobailon/pi-subagents/blob/69673c7fbf3351e54a5b0786a7e9d9fff625a749/src/tui/render-helpers.ts). The workflow engine and phase inspector remain owned by `pi-workflows`, not copied into this package. A separate optional, validated UI protocol supplies authoritative run names, statuses and observed phases and opens that owner's inspector. Without that peer, groups retain the observation-only child list. No workflow source imports, inferred DAG state or second queue/model policy are introduced. The managed protocol remains v4.
57
57
 
58
+ Resume-budget and shutdown parity fixes selectively adapt upstream work at [`d7c9ec0`](https://github.com/tintinweb/pi-subagents/commit/d7c9ec0b027b91c7ab9da0fa5f4ae203474a5bdf), [`ca93a52`](https://github.com/tintinweb/pi-subagents/commit/ca93a5247e72e7053fcd10bea7125d6c4205f949) (unreleased after the 0.22 tag), and [`83be963`](https://github.com/tintinweb/pi-subagents/commit/83be963a969eb359af29cbbb4d4af2d01748bfb5) (released in 0.22). They retain this fork's manager-owned sessions, resource budgets, activity accounting, and protocol v4; the integrated upstream engine is not imported.
59
+
58
60
  The opt-in parent clarification broker is locally implemented. It uses the existing main session, not an upstream LLM arbitrator, cloned parent history, private UI automation, or an additional Agent per question. Permission gates remain human-owned.
59
61
 
60
62
  ## Lint policy
@@ -114,6 +116,14 @@ Foreground parent-mode calls are rejected **before spawn**: a main model blocked
114
116
 
115
117
  Install the changed package and reload/restart Pi to use this feature; an already running older installed package does not acquire new tools or parameters from checkout edits.
116
118
 
119
+ ### Root-owned operation approvals
120
+
121
+ When a root approval service is present at child creation, admitted canonical `edit`, `write`, and `bash` tools use `@signalridge/pi-approval-core` reviewed SDK definitions. Each operation awaits the root human service with its exact final inputs, file diff or command, working directory, and a distinct `child:<agent-id>` source. Ordinary parent clarification answers never authorize operations. No service means existing behavior is unchanged; malformed or duplicate services refuse before child allocation. A captured service cannot reconnect after retirement or replacement.
122
+
123
+ Tool admission, denylist, `ask_tools`, project trust, and isolation still apply first: approval adds no tools, including to internal zero-tool generation. The gate survives in-memory resume, steering, and nested `ctx.executeTool` calls. Noncanonical command/mutation-shaped tools require exactly one live cooperative adapter matching the effective schema identity. Known command or path-plus-edit/content schema properties are classified before invocation even when optional. Admission is also rechecked after each downstream public `tool_call` handler against the native mutable arguments and the originally captured execution name/schema; a normalizer cannot add a command or rename the event to bypass it. These post-handler checks never repeat `ask_tools` human prompts and retain the same shared execution deadline. Conflicting canonical extension tools are refused rather than silently replacing the reviewed binding. While root review is pending, only human waiting time is excluded from the child's default tool timeout; execution retains its remaining budget and native bash timeouts still apply after approval. Cancellation and divergent root/child branch ownership deny execution.
124
+
125
+ Inherited `pi-approvals` resources are excluded before factory loading when the root service is captured, including package aliases. Explicit selection is rejected with guidance to integrate approvals in the root session. Children do not load another approval extension or forward their UI to impersonate a human; no UI/model mode is enabled automatically. Known-shape admission does not interpret arbitrary extension semantics or unknown schemas; a timed-out hook can still perform its own side effects outside the executor. This is an in-process trusted extension integration, not an OS sandbox for arbitrary extension code. Managed protocol and journal formats remain v4.
126
+
117
127
  ### Scheduling
118
128
 
119
129
  Add a `schedule` field to register the agent to fire later instead of running now:
@@ -461,6 +471,10 @@ Instead of hard-aborting at the turn limit, agents get a graceful shutdown:
461
471
  2. Up to 5 grace turns to finish cleanly
462
472
  3. Hard abort only after the grace period
463
473
 
474
+ Each resumed prompt gets its own counters, not an unlimited continuation or a deduction from prior turns. This applies to foreground/background resumes, nested children, mentions, and reused managed threads. Agent frontmatter remains authoritative; without a per-resume override, the original invocation's turn limit is inherited, falling back to configured defaults. Resume overrides use the same non-negative safe-integer validation as spawning; caller zero cannot disable an inherited/configured cap. An authoritative frontmatter zero remains unlimited, even after an earlier finite invocation. Nested resumes reload the same branch-local definitions as nested spawns, never the root registry; an internal resume without scoped policy retains its admitted invocation budget. A retained session's admitted turn policy, including unlimited, survives later project-default changes; the manager supplies that same effective cap to queued and running progress trackers. The existing token/tool-call caps and tool timeout also apply to resumed prompts. Budgets are event-based: an already-running provider/tool can overshoot within one event or ignore abort; these are not process-isolation guarantees.
475
+
476
+ Confirmed shutdown retires grouped completion, batch-finalization, held-nudge timers, and invocation-owned foreground spinners before awaiting child teardown. Captured delivery callbacks cannot wake the main model or publish foreground progress afterward, even while a provider ignores abort. Refused or failed resume admissions retire their own UI callbacks; an admitted run owns cleanup until settlement. Mention continuations cannot publish notifications after their root/runtime retires. Managed lifecycle/usage receipts remain available during cooperative shutdown; vetoable navigation attempts do not dispose these resources.
477
+
464
478
  | Status | Meaning | UI treatment |
465
479
  |--------|---------|-------------|
466
480
  | `completed` | Finished naturally | `completed` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-subagents",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "Signalridge's managed subagent runtime with workflow-owned orchestration RPC.",
5
5
  "author": "tintinweb and signalridge contributors",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  "@sinclair/typebox": "*"
32
32
  },
33
33
  "dependencies": {
34
+ "@signalridge/pi-approval-core": "^1.3.0",
34
35
  "croner": "^10.0.1",
35
36
  "@signalridge/pi-subagents-protocol": "^1.6.0",
36
37
  "@signalridge/pi-ui": "^1.4.0",
@@ -35,15 +35,19 @@ import {
35
35
  import {
36
36
  INTERNAL_AGENT_CONFIG_OVERRIDE,
37
37
  INTERNAL_PARENT_POLICY_SNAPSHOT,
38
+ INTERNAL_RESUME_BUDGET_POLICY,
38
39
  type InternalAgentConfigOverride,
39
40
  type ParentPolicySnapshot,
41
+ type ResumeBudgetPolicy,
40
42
  } from "./internal-run.js";
43
+ import { resolveMaxTurns } from "./invocation-config.js";
41
44
  import { assignHandle, handleBase } from "./mention.js";
42
45
  import {
43
46
  assertParentModelRuntimeAvailable,
44
47
  parentModelSessionOptions,
45
48
  } from "./model-runtime-bridge.js";
46
49
  import type { ParentQuestionHandler } from "./parent-questions.js";
50
+ import { selectResumeTurnPolicy } from "./run-budget-policy.js";
47
51
  import { shutdownAndDisposeSession } from "./session-lifecycle.js";
48
52
  import type { TierThinking } from "./settings.js";
49
53
  import type {
@@ -315,6 +319,21 @@ function snapshotCleanupFailure(
315
319
  });
316
320
  }
317
321
 
322
+ /** Hard budgets take precedence over provider failure, then soft wrap-up. */
323
+ function applyRunOutcome(
324
+ record: AgentRecord,
325
+ outcome: { aborted?: boolean; steered?: boolean; failure?: string },
326
+ ): void {
327
+ if (outcome.aborted) {
328
+ record.status = "aborted";
329
+ } else if (outcome.failure) {
330
+ record.status = "error";
331
+ record.error = outcome.failure;
332
+ } else {
333
+ record.status = outcome.steered ? "steered" : "completed";
334
+ }
335
+ }
336
+
318
337
  interface SpawnArgs {
319
338
  pi: ExtensionAPI;
320
339
  ctx: ExtensionContext;
@@ -1021,10 +1040,27 @@ export type ManagedSpawnCallbacks = Pick<
1021
1040
  | "onCompaction"
1022
1041
  | "onSpawned"
1023
1042
  > & {
1043
+ /** Reused thread's effective display budget, before queue admission. */
1044
+ onResumeBudgetResolved?: (maxTurns: number | undefined) => void;
1024
1045
  /** Reused thread's actual run boundary, before native steering or prompt. */
1025
1046
  onResumeStarting?: (record: AgentRecord, startMessageIndex: number) => void;
1026
1047
  };
1027
1048
 
1049
+ export interface ResumeOptions {
1050
+ readonly [INTERNAL_RESUME_BUDGET_POLICY]?: ResumeBudgetPolicy;
1051
+ isBackground?: boolean;
1052
+ maxTurns?: number;
1053
+ onToolActivity?: (activity: ToolActivity) => void;
1054
+ onTurnEnd?: (count: number) => void;
1055
+ onTextDelta?: (delta: string, fullText: string) => void;
1056
+ onAssistantUsage?: SpawnOptions["onAssistantUsage"];
1057
+ onCompaction?: (info: CompactionInfo) => void;
1058
+ /** Effective display cap, published synchronously before queue admission. */
1059
+ onBudgetResolved?: (maxTurns: number | undefined) => void;
1060
+ /** Called at actual admission, including a queued background resume. */
1061
+ onStarted?: () => void;
1062
+ }
1063
+
1028
1064
  interface ResumeTerminalSnapshot {
1029
1065
  status: AgentRecord["status"];
1030
1066
  startedAt: number;
@@ -1863,9 +1899,13 @@ export class AgentManager {
1863
1899
  // only filter excludes only explicit `false`, so undefined agents — which
1864
1900
  // have no inline surface — stay visible instead of vanishing.
1865
1901
  isBackground: options.isBackground,
1866
- invocation: options.invocation
1867
- ? cloneFrozenData(options.invocation)
1868
- : undefined,
1902
+ invocation:
1903
+ options.invocation || options.maxTurns !== undefined
1904
+ ? cloneFrozenData({
1905
+ ...options.invocation,
1906
+ maxTurns: options.maxTurns ?? options.invocation?.maxTurns,
1907
+ })
1908
+ : undefined,
1869
1909
  depth: options.depth ?? 1,
1870
1910
  parentAgentId: options.parentAgentId,
1871
1911
  ...(ancestorAgentIds ? { ancestorAgentIds } : {}),
@@ -2176,9 +2216,13 @@ export class AgentManager {
2176
2216
  }
2177
2217
  void this.resume(threadedId, normalized.prompt, undefined, {
2178
2218
  isBackground: true,
2219
+ maxTurns: policy.maxTurns,
2220
+ onBudgetResolved: callbacks?.onResumeBudgetResolved,
2179
2221
  onStarted: () => {
2180
2222
  void Promise.resolve(callbacks?.onResumeStarting?.(record, record.session!.messages.length)).catch(() => {});
2181
2223
  },
2224
+ onTurnEnd: callbacks?.onTurnEnd,
2225
+ onTextDelta: callbacks?.onTextDelta,
2182
2226
  onToolActivity: callbacks?.onToolActivity,
2183
2227
  onAssistantUsage: callbacks?.onAssistantUsage,
2184
2228
  onCompaction: callbacks?.onCompaction
@@ -2560,14 +2604,7 @@ export class AgentManager {
2560
2604
  // Precedence: a hard abort keeps "aborted"; then a failed final turn
2561
2605
  // (provider error that pi resolved instead of rejecting, #144) is an
2562
2606
  // honest "error" — not a completion with an empty or stale result.
2563
- if (aborted) {
2564
- record.status = "aborted";
2565
- } else if (failure) {
2566
- record.status = "error";
2567
- record.error = failure;
2568
- } else {
2569
- record.status = steered ? "steered" : "completed";
2570
- }
2607
+ applyRunOutcome(record, { aborted, steered, failure });
2571
2608
  }
2572
2609
  record.completedAt ??= Date.now();
2573
2610
  detach();
@@ -3094,21 +3131,7 @@ export class AgentManager {
3094
3131
  id: string,
3095
3132
  prompt: string,
3096
3133
  signal?: AbortSignal,
3097
- options?: {
3098
- isBackground?: boolean;
3099
- onToolActivity?: (activity: {
3100
- type: "start" | "end";
3101
- toolName: string;
3102
- }) => void;
3103
- onAssistantUsage?: (usage: {
3104
- input: number;
3105
- output: number;
3106
- cacheWrite: number;
3107
- }) => void;
3108
- onCompaction?: (info: unknown) => void;
3109
- /** Called once a queued background resume has acquired its run slot. */
3110
- onStarted?: () => void;
3111
- },
3134
+ options?: ResumeOptions,
3112
3135
  ): Promise<AgentRecord | undefined> {
3113
3136
  if (this.disposed) return undefined;
3114
3137
  const record = this.agents.get(id);
@@ -3124,6 +3147,28 @@ export class AgentManager {
3124
3147
  record.invocation?.inheritContext === true,
3125
3148
  );
3126
3149
 
3150
+ if (
3151
+ options?.maxTurns !== undefined &&
3152
+ (!Number.isSafeInteger(options.maxTurns) || options.maxTurns < 0)
3153
+ ) {
3154
+ throw new Error("maxTurns must be a non-negative safe integer");
3155
+ }
3156
+ // Nested definitions belong to their branch, never the global root registry.
3157
+ // Without the scoped tool's capability, retain the admitted invocation policy.
3158
+ const policy = record.parentAgentId === undefined
3159
+ ? getAgentConfig(record.type)
3160
+ : options?.[INTERNAL_RESUME_BUDGET_POLICY] ?? record.invocation;
3161
+ const maxTurns = selectResumeTurnPolicy(record.session, resolveMaxTurns(
3162
+ policy,
3163
+ // A caller's zero cannot erase the original/configured safety policy.
3164
+ options?.maxTurns || record.invocation?.maxTurns,
3165
+ ));
3166
+ try {
3167
+ options?.onBudgetResolved?.(maxTurns === 0 ? undefined : maxTurns);
3168
+ } catch {
3169
+ // UI observers cannot alter the authoritative policy or admission.
3170
+ }
3171
+ options = { ...options, maxTurns };
3127
3172
  const snapshot = this.resumeSnapshot(record);
3128
3173
  if (options?.isBackground) {
3129
3174
  // A background resume is one lifecycle, including time spent queued. Keep
@@ -3177,18 +3222,31 @@ export class AgentManager {
3177
3222
  record.result = undefined;
3178
3223
  record.error = undefined;
3179
3224
  try {
3180
- const { text, failure } = await resumeAgent(record.session, prompt, {
3225
+ options.onStarted?.();
3226
+ const { text, failure, aborted, steered } = await resumeAgent(record.session, prompt, {
3227
+ maxTurns,
3228
+ onTurnEnd: (count) => {
3229
+ if (!record.detached) options.onTurnEnd?.(count);
3230
+ },
3231
+ onTextDelta: (delta, text) => {
3232
+ if (!record.detached) options.onTextDelta?.(delta, text);
3233
+ },
3181
3234
  onToolActivity: (activity) => {
3182
- if (!record.detached && activity.type === "end") record.toolUses++;
3235
+ if (record.detached) return;
3236
+ if (activity.type === "end") record.toolUses++;
3237
+ options.onToolActivity?.(activity);
3183
3238
  },
3184
3239
  onAssistantUsage: (usage) => {
3185
- if (!record.detached) addUsage(record.lifetimeUsage, usage);
3240
+ if (record.detached) return;
3241
+ addUsage(record.lifetimeUsage, usage);
3242
+ options.onAssistantUsage?.(usage);
3186
3243
  },
3187
3244
  onCompaction: (info) => {
3188
3245
  if (record.detached) return;
3189
3246
  record.compactionCount++;
3190
3247
  this.syncManagedRecord(record);
3191
3248
  this.onCompact?.(record, info);
3249
+ options.onCompaction?.(info);
3192
3250
  },
3193
3251
  signal: control.controller.signal,
3194
3252
  });
@@ -3197,8 +3255,7 @@ export class AgentManager {
3197
3255
  !control.controller.signal.aborted &&
3198
3256
  (record.status as AgentRecord["status"]) !== "stopped"
3199
3257
  ) {
3200
- record.status = failure ? "error" : "completed";
3201
- if (failure) record.error = failure;
3258
+ applyRunOutcome(record, { aborted, steered, failure });
3202
3259
  record.result = text;
3203
3260
  record.completedAt = Date.now();
3204
3261
  }
@@ -3242,21 +3299,7 @@ export class AgentManager {
3242
3299
  record: AgentRecord,
3243
3300
  prompt: string,
3244
3301
  control: ResumeControl,
3245
- options?: {
3246
- isBackground?: boolean;
3247
- onToolActivity?: (activity: {
3248
- type: "start" | "end";
3249
- toolName: string;
3250
- }) => void;
3251
- onAssistantUsage?: (usage: {
3252
- input: number;
3253
- output: number;
3254
- cacheWrite: number;
3255
- }) => void;
3256
- onCompaction?: (info: unknown) => void;
3257
- /** Called once a queued background resume has acquired its run slot. */
3258
- onStarted?: () => void;
3259
- },
3302
+ options?: ResumeOptions,
3260
3303
  ): Promise<string> {
3261
3304
  const snapshot = control.snapshot;
3262
3305
  let slotHeld = false;
@@ -3305,13 +3348,22 @@ export class AgentManager {
3305
3348
  }
3306
3349
 
3307
3350
  try {
3308
- const { text, failure } = await resumeAgent(record.session!, prompt, {
3351
+ const { text, failure, aborted, steered } = await resumeAgent(record.session!, prompt, {
3352
+ maxTurns: options?.maxTurns,
3353
+ onTurnEnd: (count) => {
3354
+ if (!record.detached) options?.onTurnEnd?.(count);
3355
+ },
3356
+ onTextDelta: (delta, text) => {
3357
+ if (!record.detached) options?.onTextDelta?.(delta, text);
3358
+ },
3309
3359
  onToolActivity: (activity) => {
3310
- if (!record.detached && activity.type === "end") record.toolUses++;
3360
+ if (record.detached) return;
3361
+ if (activity.type === "end") record.toolUses++;
3311
3362
  options?.onToolActivity?.(activity);
3312
3363
  },
3313
3364
  onAssistantUsage: (usage) => {
3314
- if (!record.detached) addUsage(record.lifetimeUsage, usage);
3365
+ if (record.detached) return;
3366
+ addUsage(record.lifetimeUsage, usage);
3315
3367
  options?.onAssistantUsage?.(usage);
3316
3368
  },
3317
3369
  onCompaction: (info) => {
@@ -3328,8 +3380,7 @@ export class AgentManager {
3328
3380
  !control.controller.signal.aborted &&
3329
3381
  (record.status as AgentRecord["status"]) !== "stopped"
3330
3382
  ) {
3331
- record.status = failure ? "error" : "completed";
3332
- if (failure) record.error = failure;
3383
+ applyRunOutcome(record, { aborted, steered, failure });
3333
3384
  record.result = text;
3334
3385
  record.completedAt = Date.now();
3335
3386
  }