@herbertgao/pi-subagents 0.22.0-local.2 → 0.22.1-local.1

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/docs/rpc.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Driving subagents from another extension
2
2
 
3
- Another pi extension can spawn a subagent, listen for subagent completion, read the result and stop the run — all over the `pi.events` bus, without importing this package directly. Four request/reply channels (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`, `subagents:rpc:consume`), eleven lifecycle events, and one in-process registry at `Symbol.for("pi-subagents:manager")`.
3
+ Another pi extension can spawn a subagent, listen for subagent completion, read the result, steer and stop the run — all over the `pi.events` bus, without importing this package directly. Five request/reply channels (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`, `subagents:rpc:consume`, `subagents:rpc:steer`), the lifecycle events in README's [event table](../README.md#events), one per-message usage event (`subagents:usage`), one terminal `subagents:disposed`, and one in-process registry at `Symbol.for("pi-subagents:manager")`.
4
4
 
5
5
  The thing worth understanding up front is that **the bus is in-process.** Every "RPC" call here is a synchronous `pi.events.emit` into the same event loop, and every reply comes back the same way. That single fact explains most of what follows: why `signal` and the `on*` callbacks work on a spawn payload at all, why a `consume` fired inside a `subagents:completed` handler lands _before_ the notification decision has been made, and why none of this survives a real process boundary.
6
6
 
@@ -88,6 +88,8 @@ Every failure reaches the caller as `{ success: false, error }`, where `error` i
88
88
  | `Agent is owned by another agent or workflow` | stop handler |
89
89
  | `Agent is not running` | stop handler. The record exists, so it has already settled |
90
90
  | `Agent not found or still running` | consume handler |
91
+ | `Steer message must be a non-empty string` | steer handler — `message` missing, not a string, or whitespace |
92
+ | `Agent not found` / `Agent is owned by another agent or workflow` / `Agent is not running` | steer handler — same lookup, ownership guard and settled check as stop |
91
93
 
92
94
  Three things the table cannot show:
93
95
 
@@ -97,14 +99,16 @@ Three things the table cannot show:
97
99
 
98
100
  ## Ownership
99
101
 
100
- `isTopLevelAgent(record)` is `parentAgentId === undefined && workflowId === undefined` (`src/agent-manager.ts`). `subagents:rpc:stop` enforces it in its handler (`src/cross-extension-rpc.ts`): a nested child or a workflow's agent is owned by something that is _waiting on it_, and aborting it out from under that owner turns another extension's stop into a failed step. It is defence in depth rather than a live hole — no RPC hands out agent ids, so a caller has no ordinary way to name one it does not own.
102
+ `isTopLevelAgent(record)` is `parentAgentId === undefined && workflowId === undefined` (`src/agent-manager.ts`). `subagents:rpc:stop` and `subagents:rpc:steer` enforce it through one shared check (`assertOwned` in `src/cross-extension-rpc.ts`): a nested child or a workflow's agent is owned by something that is _waiting on it_, and aborting or redirecting it out from under that owner turns another extension's request into a failed step. It is defence in depth rather than a live hole — no RPC hands out agent ids, so a caller has no ordinary way to name one it does not own.
101
103
 
102
104
  Two asymmetries to know about, stated as they are:
103
105
 
104
106
  - **Stop takes an id only**: its `getRecord` is the manager's plain lookup. Consume takes an id _or_ an `@handle`: its `consumeResult` (both wired in the `session_start` handler, `src/index.ts`) goes through `resolveAgentRef`.
105
107
  - **Consume checks `parentAgentId` but not `workflowId`** (`consumeResult`, `src/index.ts`). A workflow-owned agent's result can be marked consumed over the bus even though the same agent cannot be stopped.
106
108
 
107
- The same predicate silently scopes the events. **Every lifecycle event is top-level only** — `subagents:started`, `:completed`, `:failed` and `:compacted` all return early for nested and workflow-owned agents (the `isTopLevelAgent` checks in the `AgentManager` callbacks, `src/index.ts`). A workflow's children are invisible on the bus: you will see the workflow's own agents come and go without a single event.
109
+ The same predicate silently scopes the events. **Every lifecycle event is top-level only** — `subagents:started`, `:completed`, `:failed`, `:steered` and `:compacted` all return early for nested and workflow-owned agents (the `isTopLevelAgent` checks in the `AgentManager` callbacks, `src/index.ts`). `subagents:usage` is the one event that is not scoped: it fires for every agent's messages, nested and workflow-owned included, so a ledger sees a workflow's children spend even though it never sees them start or finish.
110
+
111
+ **`subagents:disposed` is the last event of a session.** It is emitted at the end of this extension's `session_shutdown`, after every agent is stopped and every child session has shut down, so no `subagents:usage` can follow it. A listener gets only its synchronous prefix on quit: `pi.events.emit` does not await listeners, and pi calls `process.exit` once the shutdown handlers return. Flush with `writeFileSync`, not `fs.promises`. Fields, and the other events, are in README's [event table](../README.md#events).
108
112
 
109
113
  ## The notification race
110
114
 
@@ -128,6 +132,12 @@ Consumption is not terminal. An `@handle` steer un-consumes the record (the ment
128
132
 
129
133
  One related thing that lives nowhere else: on every top-level settle, pi-subagents writes a session entry — not an event — via `pi.appendEntry("subagents:record", …)` (the manager's completion callback, `src/index.ts`), carrying `id`, `type`, `description`, `status`, `result`, `error`, `startedAt` and `completedAt`. It exists for cross-extension history reconstruction. It is append-only history, not something to react to.
130
134
 
135
+ ## Steering a running agent
136
+
137
+ `subagents:rpc:steer` `{ requestId, agentId, message }` delivers `message` into a running or queued agent's conversation, the bus-side half of the `steer_subagent` tool. It exists for the caller that spawned an agent over the bus and then learns something that agent needs mid-run — a second CI failure on the head it is already fixing, a review that landed while it works — and would otherwise have to choose between spawning a duplicate agent for the same work and dropping the fact.
138
+
139
+ It takes an id only, enforces the same top-level ownership guard as stop, and answers `Agent is not running` for a record that has settled. A message queued before the agent's session exists is flushed when the session is created, exactly as the tool's steer is. A successful steer emits `subagents:steered` `{ id, message }`, as every steer path does. Like `consume`, it is outside the `ping` version handshake: an older build has no handler and the request times out on your side, so give it a timeout and treat expiry as "not available here".
140
+
131
141
  ## The manager registry
132
142
 
133
143
  `globalThis[Symbol.for("pi-subagents:manager")]` (`MANAGER_KEY` and `registryEntry`, `src/index.ts`) is a second integration surface — the standard Node cross-package singleton pattern, no bus involved:
@@ -147,9 +157,9 @@ Prefer the bus. The registry has no reply envelope, no version, and no availabil
147
157
 
148
158
  `subagents:rpc:ping` replies `{ version: PROTOCOL_VERSION }`, currently `2` (`src/cross-extension-rpc.ts`). The constant was introduced already equal to `2` in 0.5.0; "v1" is a retroactive name for the pre-envelope contract, where spawn replied with a bare `{ id }` or `{ error }`, stop replied `{ success: boolean }` with no message, and each handler caught its own errors.
149
159
 
150
- Everything added since shipped **without a bump**, because all of it is additive: stop's ownership refusal, string-`model` resolution ([#59](https://github.com/tintinweb/pi-subagents/pull/59)/[#60](https://github.com/tintinweb/pi-subagents/issues/60)), `scopeModels` enforcement ([#240](https://github.com/tintinweb/pi-subagents/issues/240)), and the whole `consume` channel.
160
+ Everything added since shipped **without a bump**, because all of it is additive: stop's ownership refusal, string-`model` resolution ([#59](https://github.com/tintinweb/pi-subagents/pull/59)/[#60](https://github.com/tintinweb/pi-subagents/issues/60)), `scopeModels` enforcement ([#240](https://github.com/tintinweb/pi-subagents/issues/240)), and the whole `consume` and `steer` channels.
151
161
 
152
- > A `ping` that answers `2` does not tell you whether `consume` exists, whether model scope is enforced, or whether stop checks ownership.
162
+ > A `ping` that answers `2` does not tell you whether `consume` or `steer` exists, whether model scope is enforced, or whether stop checks ownership.
153
163
 
154
164
  So: send `consume` unconditionally and ignore the outcome — an older build has no handler and simply keeps notifying, which is exactly why it was left outside the handshake. And treat every error envelope as authoritative rather than trying to predict which checks are in force.
155
165
 
@@ -167,7 +177,7 @@ This document has no test of its own, so it is worth knowing which claims are ac
167
177
 
168
178
  | Test | Level | Pins |
169
179
  | ------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
170
- | `test/cross-extension-rpc.test.ts` | Mocked `SpawnCapable` | Envelope shape, per-channel error strings, model resolution and scope enforcement |
180
+ | `test/cross-extension-rpc.test.ts` | Mocked `SpawnCapable` | Envelope shape, per-channel error strings (including steer's), model resolution and scope enforcement |
171
181
  | `test/rpc-lifecycle-gating.test.ts` | Real extension factory | Nothing wired at factory time, everything once at `session_start`, and live widget activity for RPC spawns ([#142](https://github.com/tintinweb/pi-subagents/issues/142)/[#181](https://github.com/tintinweb/pi-subagents/pull/181)) |
172
182
  | `test/rpc-result-consumption.test.ts` | Real delivery path | The notification firing, and not firing, around `consume` |
173
183
 
@@ -179,4 +189,4 @@ Not pinned anywhere, so treat them as descriptions rather than contracts: the `S
179
189
 
180
190
  Read it for the shape of the whole loop: waiting on `subagents:ready`, keeping an id-keyed map of outstanding spawns, resolving each from the `subagents:completed` / `subagents:failed` handler, and consuming the result in the same synchronous handler that reports it.
181
191
 
182
- If what you want is many coordinated agents rather than one, hand a script to [`SubagentWorkflow`](workflows.md) instead of fanning out over `subagents:rpc:spawn` — and note that a workflow's agents are not yours: they emit no lifecycle events, and `subagents:rpc:stop` refuses them.
192
+ If what you want is many coordinated agents rather than one, hand a script to [`SubagentWorkflow`](workflows.md) instead of fanning out over `subagents:rpc:spawn` — and note that a workflow's agents are not yours: they emit no lifecycle events — only `subagents:usage` — and `subagents:rpc:stop` refuses them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herbertgao/pi-subagents",
3
- "version": "0.22.0-local.2",
3
+ "version": "0.22.1-local.1",
4
4
  "description": "Claude Code-style autonomous subagents and workflow orchestration for Pi, with HerbertGao-maintained UI extensions.",
5
5
  "keywords": [
6
6
  "agent",
@@ -69,10 +69,10 @@
69
69
  },
70
70
  "x-upstream": {
71
71
  "package": "@tintinweb/pi-subagents",
72
- "version": "0.22.0",
73
- "reviewedVersion": "0.22.0",
72
+ "version": "0.22.1",
73
+ "reviewedVersion": "0.22.1",
74
74
  "repository": "https://github.com/tintinweb/pi-subagents",
75
- "commit": "85a704cb8f35c96c0cabfa665b572bab296942e5",
75
+ "commit": "fc089c4b7ab0f1185453b2e53027997c1f637e38",
76
76
  "trackVersion": true
77
77
  }
78
78
  }
@@ -49,6 +49,12 @@ import {
49
49
 
50
50
  export type OnAgentComplete = (record: AgentRecord) => void
51
51
  export type OnAgentStart = (record: AgentRecord) => void
52
+ /**
53
+ * Fired by `steer()` once a message is delivered or queued. Every steer path
54
+ * goes through it except the steer tools (`steer_subagent` in index.ts, and its
55
+ * nested twin in nested-tools.ts), which await the session directly.
56
+ */
57
+ export type OnAgentSteered = (record: AgentRecord, message: string) => void
52
58
  export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void
53
59
  /**
54
60
  * Fired once per assistant `message_end`, for EVERY agent this manager owns —
@@ -351,6 +357,14 @@ interface ResumeOptions {
351
357
  * record — the historical behavior.
352
358
  */
353
359
  isBackground?: boolean
360
+ /**
361
+ * Explicit turn limit for the resumed run: this, else the limit the agent
362
+ * was spawned with, else its type's own `max_turns`, else the default —
363
+ * counted afresh from the resume prompt (#368).
364
+ */
365
+ maxTurns?: number
366
+ /** Called at the end of each resumed turn with the count since the resume. */
367
+ onTurnEnd?: (turnCount: number) => void
354
368
  /** Called on tool start/end with activity info (for streaming progress to UI). */
355
369
  onToolActivity?: (activity: ToolActivity) => void
356
370
  /** Called once per assistant message_end with that message's usage delta. */
@@ -420,6 +434,7 @@ export class AgentManager {
420
434
  private onStart?: OnAgentStart
421
435
  private onCompact?: OnAgentCompact
422
436
  private onUsage?: OnAgentUsage
437
+ private onSteered?: OnAgentSteered
423
438
  private maxConcurrent: number
424
439
  private maxConcurrentForeground = DEFAULT_MAX_CONCURRENT_FOREGROUND
425
440
  /** Base repos worktrees were created from — so dispose() can prune them all,
@@ -476,11 +491,13 @@ export class AgentManager {
476
491
  onStart?: OnAgentStart,
477
492
  onCompact?: OnAgentCompact,
478
493
  onUsage?: OnAgentUsage,
494
+ onSteered?: OnAgentSteered,
479
495
  ) {
480
496
  this.onComplete = onComplete
481
497
  this.onStart = onStart
482
498
  this.onCompact = onCompact
483
499
  this.onUsage = onUsage
500
+ this.onSteered = onSteered
484
501
  this.maxConcurrent = maxConcurrent
485
502
  // Cleanup consumed completed agents after 10 minutes
486
503
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000)
@@ -972,28 +989,18 @@ export class AgentManager {
972
989
  structuredJson,
973
990
  structuredRetried,
974
991
  }) => {
975
- // Don't overwrite status if externally stopped via abort()
976
- if (record.status !== "stopped") {
977
- // Precedence: a hard abort keeps "aborted"; then a failed final turn
978
- // (provider error that pi resolved instead of rejecting, #144) is an
979
- // honest "error" — not a completion with an empty or stale result.
980
- if (aborted) {
981
- record.status = "aborted"
982
- } else if (failure) {
983
- record.status = "error"
984
- record.error = failure
985
- } else {
986
- record.status = steered ? "steered" : "completed"
987
- }
988
- }
989
- record.result = responseText
992
+ this.settleOutcome(record, {
993
+ text: responseText,
994
+ failure,
995
+ aborted,
996
+ steered,
997
+ })
990
998
  // Kept beside `result`, never inside it: `result` is prose meant for a
991
999
  // reader — it is previewed, transcribed, and appended to below — while
992
1000
  // this is a machine-readable payload one caller asked for by schema.
993
1001
  record.structuredJson = structuredJson
994
1002
  record.structuredRetried = structuredRetried
995
1003
  record.session = session
996
- record.completedAt ??= Date.now()
997
1004
 
998
1005
  detach()
999
1006
 
@@ -1383,36 +1390,46 @@ export class AgentManager {
1383
1390
  record.completedAt = undefined
1384
1391
  record.result = undefined
1385
1392
  record.error = undefined
1393
+ // Fresh controller, as on the detached path: `abort()` fires whatever
1394
+ // `record.abortController` holds, and the spawn's has long settled — so a
1395
+ // stop from `/agents` or the caller's Esc would mark the record stopped
1396
+ // while the resumed prompt ran on to completion.
1397
+ const abortController = new AbortController()
1398
+ record.abortController = abortController
1399
+ this.armParentAbort(record, signal)
1400
+ // `addEventListener` never fires on a signal that is already aborted, and
1401
+ // the runner skips the prompt only when ITS signal is — stop now, so that
1402
+ // holds for a resume issued on a call that was cancelled first.
1403
+ if (signal?.aborted) this.abort(id)
1386
1404
 
1387
1405
  try {
1388
- const { text, failure } = await resumeAgent(record.session, prompt, {
1389
- onToolActivity: (activity) => {
1390
- if (activity.type === "end") record.toolUses++
1391
- options?.onToolActivity?.(activity)
1392
- },
1393
- onAssistantUsage: (usage) => {
1394
- addUsage(record.lifetimeUsage, usage)
1395
- this.onUsage?.(record, usage)
1396
- options?.onAssistantUsage?.(usage)
1397
- },
1398
- onCompaction: (info) => {
1399
- record.compactionCount++
1400
- this.onCompact?.(record, info)
1401
- options?.onCompaction?.(info)
1402
- },
1403
- signal,
1404
- })
1405
- // Same contract as the spawn path (#144): a failed final turn is an
1406
- // error, not a completion — but the resumed text stays available.
1407
- record.status = failure ? "error" : "completed"
1408
- if (failure) record.error = failure
1409
- record.result = text
1410
- record.completedAt = Date.now()
1406
+ this.settleOutcome(
1407
+ record,
1408
+ await resumeAgent(record.session, prompt, {
1409
+ type: record.type,
1410
+ maxTurns: options?.maxTurns ?? record.invocation?.maxTurns,
1411
+ onTurnEnd: options?.onTurnEnd,
1412
+ onToolActivity: (activity) => {
1413
+ if (activity.type === "end") record.toolUses++
1414
+ options?.onToolActivity?.(activity)
1415
+ },
1416
+ onAssistantUsage: (usage) => {
1417
+ addUsage(record.lifetimeUsage, usage)
1418
+ this.onUsage?.(record, usage)
1419
+ options?.onAssistantUsage?.(usage)
1420
+ },
1421
+ onCompaction: (info) => {
1422
+ record.compactionCount++
1423
+ this.onCompact?.(record, info)
1424
+ options?.onCompaction?.(info)
1425
+ },
1426
+ signal: abortController.signal,
1427
+ }),
1428
+ )
1411
1429
  } catch (err) {
1412
- record.status = "error"
1413
- record.error = err instanceof Error ? err.message : String(err)
1414
- record.completedAt = Date.now()
1430
+ this.failResume(record, err)
1415
1431
  }
1432
+ this.detachParentAbort(record)
1416
1433
 
1417
1434
  // Same contract as the spawn settle paths: children spawned during the
1418
1435
  // resumed turn must not outlive it — nothing else can see or reach them.
@@ -1421,6 +1438,46 @@ export class AgentManager {
1421
1438
  return record
1422
1439
  }
1423
1440
 
1441
+ /**
1442
+ * Write a run's outcome onto its record — a spawn's or a resume's, with one
1443
+ * precedence: an external stop keeps "stopped"; a hard abort (turn limit +
1444
+ * grace) is "aborted"; a failed final turn (a provider error pi resolved
1445
+ * instead of rejecting, #144) is an "error", not a completion with an empty
1446
+ * or stale result; otherwise "steered" or "completed". The run's text stays
1447
+ * available in every case.
1448
+ */
1449
+ private settleOutcome(
1450
+ record: AgentRecord,
1451
+ {
1452
+ text,
1453
+ failure,
1454
+ aborted,
1455
+ steered,
1456
+ }: Awaited<ReturnType<typeof resumeAgent>>,
1457
+ ): void {
1458
+ if (record.status !== "stopped") {
1459
+ if (aborted) {
1460
+ record.status = "aborted"
1461
+ } else if (failure) {
1462
+ record.status = "error"
1463
+ record.error = failure
1464
+ } else {
1465
+ record.status = steered ? "steered" : "completed"
1466
+ }
1467
+ }
1468
+ record.result = text
1469
+ record.completedAt ??= Date.now()
1470
+ }
1471
+
1472
+ /** A resume whose runner rejected: an error, unless an external stop got there first. */
1473
+ private failResume(record: AgentRecord, err: unknown): void {
1474
+ if (record.status !== "stopped") {
1475
+ record.status = "error"
1476
+ record.error = err instanceof Error ? err.message : String(err)
1477
+ }
1478
+ record.completedAt ??= Date.now()
1479
+ }
1480
+
1424
1481
  /**
1425
1482
  * Start a background resume run: detached, settling and notifying like
1426
1483
  * startAgent's background path. Invoked immediately, or from drainQueue when
@@ -1490,6 +1547,9 @@ export class AgentManager {
1490
1547
  }
1491
1548
 
1492
1549
  const promise = resumeAgent(record.session, prompt, {
1550
+ type: record.type,
1551
+ maxTurns: options.maxTurns ?? record.invocation?.maxTurns,
1552
+ onTurnEnd: options.onTurnEnd,
1493
1553
  onToolActivity: (activity) => {
1494
1554
  if (activity.type === "end") record.toolUses++
1495
1555
  options.onToolActivity?.(activity)
@@ -1506,25 +1566,13 @@ export class AgentManager {
1506
1566
  },
1507
1567
  signal: abortController.signal,
1508
1568
  })
1509
- .then(({ text, failure }) => {
1510
- // Don't overwrite status if externally stopped via abort().
1511
- if (record.status !== "stopped") {
1512
- // Same contract as the spawn path (#144): a failed final turn is an
1513
- // error, not a completion — but the resumed text stays available.
1514
- record.status = failure ? "error" : "completed"
1515
- if (failure) record.error = failure
1516
- }
1517
- record.result = text
1518
- record.completedAt ??= Date.now()
1569
+ .then((outcome) => {
1570
+ this.settleOutcome(record, outcome)
1519
1571
  settle()
1520
- return text
1572
+ return outcome.text
1521
1573
  })
1522
1574
  .catch((err) => {
1523
- if (record.status !== "stopped") {
1524
- record.status = "error"
1525
- record.error = err instanceof Error ? err.message : String(err)
1526
- }
1527
- record.completedAt ??= Date.now()
1575
+ this.failResume(record, err)
1528
1576
  settle()
1529
1577
  return ""
1530
1578
  })
@@ -1538,7 +1586,10 @@ export class AgentManager {
1538
1586
  * current tool execution and appears as a user message. If the session isn't
1539
1587
  * ready yet, the message is queued on `pendingSteers` and flushed when the
1540
1588
  * session is created. Returns false if the agent can't accept steering
1541
- * (unknown id, or no longer running/queued).
1589
+ * (unknown id, or no longer running/queued). Fires `onSteered` on either
1590
+ * accepted path, so the UI viewers, an `@handle` and the RPC all report
1591
+ * the steer the same way without each emitting by hand. The steer tools
1592
+ * bypass this method because they await delivery.
1542
1593
  */
1543
1594
  steer(id: string, message: string): boolean {
1544
1595
  const record = this.agents.get(id)
@@ -1550,6 +1601,7 @@ export class AgentManager {
1550
1601
  if (!record.pendingSteers) record.pendingSteers = []
1551
1602
  record.pendingSteers.push(message)
1552
1603
  }
1604
+ this.onSteered?.(record, message)
1553
1605
  return true
1554
1606
  }
1555
1607