@hheei/pi-subagents 0.5.3 → 0.7.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/README.md CHANGED
@@ -16,7 +16,7 @@ children and `/subagents stop [id]` to stop one without conflicting with other e
16
16
  | Tool | Purpose |
17
17
  | --- | --- |
18
18
  | `agent_spawn({ task, agent, cwd?, title? })` | Spawn an independent background subagent task. Runs in a new Herdr tab (or cmux surface) targeting the parent's workspace when a host is available, and headless in the background otherwise. **Prefer reusing existing subagents** via `agent_send` for related/follow-up work to maintain context and maximize prompt cache and memory efficiency. Tool description lists available agent names and roles, excluding disabled or hidden definitions. Deferred by default; discover via `tool_search({ query: "agent" })`. Do not poll for completion; results return automatically. |
19
- | `agent_send({ id, message, mode? })` | Send `steer`, `follow_up`, or `auto` input to a specific child. **Primary tool for assigning follow-up or functionally related tasks** to existing subagents: idle, blocked, done, or errored children resume their existing session automatically with historical context intact. A child whose runtime has ended is resumed the same way; spawn a new child only for new work. Stop first only when send says the previous process is still running. Deferred by default; discover via `tool_search({ query: "agent" })`. |
19
+ | `agent_send({ id, message, mode? })` | Send input to a specific child. `mode: "steer"` (the default) enters the current turn once its tool calls are in, before the next model request; `mode: "follow_up"` waits until the child has no tool calls or steering left, then continues as a new turn. **Primary tool for assigning follow-up or functionally related tasks** to existing subagents: idle, blocked, done, or errored children resume their existing session automatically with historical context intact. A child whose runtime has ended is resumed the same way; spawn a new child only for new work. Stop first only when send says the previous process is still running. Deferred by default; discover via `tool_search({ query: "agent" })`. |
20
20
  | `agent_get({ id, keys? })` | Inspect one subagent by id. `keys` is a comma-separated selection of `status` (default), `progress` (default), and `diagnostic`; unknown keys fail. The view never returns the child's task parameters or terminal result, which arrive automatically as reports. From the second successful inspection of the same child in this parent runtime, the result adds a short reminder that results return automatically and must not be polled. Deferred by default; discover via `tool_search({ query: "agent" })`. |
21
21
  | `agent_stop({ id })` | Stop one subagent's runtime by id. The child keeps its session, so a later `agent_send` can resume it. Deferred by default; discover via `tool_search({ query: "agent" })`. |
22
22
 
@@ -37,6 +37,8 @@ Subagents communicate with the parent through the `commands` table in `state.sql
37
37
  - **Completion by Direct Output**: Subagents do **not** need to call any reporting tool upon successful completion. The subagent simply outputs its final answer as normal assistant text.
38
38
  - **Settlement Batching**: When a subagent settles, the harness captures the trailing assistant text and delivers it to the parent conversation as a `subagent-report`. Reports flush within 500ms when no other child is working, and are batched for up to 10 seconds while another child is still running.
39
39
  - **Blockers & Decisions**: The child registers `contact_parent({ message, reason? })` strictly for reporting when it is blocked or urgently requires a parent decision midway (`reason` defaults to `'blocked'`). Blocked reports wake the parent immediately.
40
+ - **Direct `/compact` Command**: Sending `"/compact"` (or `"/compact [instructions]"`) via `agent_send` intercepts the command on the child side without passing it as prompt text to the LLM. The child executes native context compaction and reports completion status back to the parent via `contact_parent`. Duplicate `/compact` requests while a compaction is already in flight are safely acknowledged and ignored.
41
+ - **Context Trim Hint**: When a subagent completes a task with context usage exceeding 100,000 tokens, its report includes a hint advising the parent to trim memory via `agent_send({ id, message: "/compact" })` if the child session will be retained for future work.
40
42
  - **Automatic Retry**: Pi's own auto-retry is observed, not invented: reports are suppressed while a retry is in flight, and a retry that succeeds clears the previous error instead of reporting it.
41
43
  - **Panel Failure Auto-Close**: When a subagent running in a panel encounters a fatal error or reports a blocker, the manager starts a 15-second countdown after notifying the parent, automatically closing the panel tab to avoid workspace clutter unless new instructions are dispatched.
42
44
  ## Compact status and reports
@@ -50,7 +52,7 @@ Use `/subagents` for complete status and management. `agent_send` can resume a d
50
52
  ## Host Integration and Isolation
51
53
 
52
54
  - **Herdr Workspace Affinity**: When spawning in Herdr, tabs are explicitly created in the parent process's current workspace (`--workspace <WORKSPACE_ID>`), preventing child tabs from jumping to whichever workspace happens to have user focus.
53
- - **Hindsight Memory Isolation**: `PI_HINDSIGHT_DISABLE=1` is set in the child environment and memory auto-registration/recall/writeback are disabled. The child bridge blocks all `mcp__hindsight__*` calls (including nested codemode calls and future server tools), even if that server is configured in a file. Memory recall prompt blocks are stripped. This boundary applies to the reserved `hindsight` server name, not arbitrary external servers registered under other names.
55
+ - **Memory Isolation**: Both `PI_HINDSIGHT_DISABLE=1` and `PI_OBSERVATIONAL_MEMORY_DISABLE=1` are injected into child processes. Hindsight memory auto-registration, recall, and writeback are disabled, and the child bridge blocks all `mcp__hindsight__*` calls. Background Observational Memory workers (observation, reflection, compaction triggers) are suppressed in passive mode.
54
56
 
55
57
  ## Agent definitions
56
58
 
@@ -198,6 +198,20 @@ export function registerChildControl(pi, options) {
198
198
  }
199
199
  return ctx;
200
200
  };
201
+ const reportCompaction = (reason, message) => {
202
+ void client
203
+ .report("contact_parent", {
204
+ type: "pi_subagent_report",
205
+ parentSessionId: options.identity.parentSessionId,
206
+ childId: options.identity.subagentId,
207
+ runtimeIdentity: options.identity.runtimeIdentity,
208
+ reason,
209
+ message,
210
+ })
211
+ .catch((err) => {
212
+ options.diagnose?.(`failed to report compaction ${reason}: ${errorMessage(err)}`);
213
+ });
214
+ };
201
215
  const deliverPrompt = (payload, deliverAs) => {
202
216
  const message = promptMessage(payload);
203
217
  // A message needs a session to land in: while the process is between sessions the parent
@@ -206,6 +220,23 @@ export function registerChildControl(pi, options) {
206
220
  if (path === undefined) {
207
221
  throw new BridgeError("session_not_ready", "This child has no session file path");
208
222
  }
223
+ const trimmed = message.trim();
224
+ if (trimmed === "/compact" || trimmed.startsWith("/compact ")) {
225
+ if (isCompacting) {
226
+ return { accepted: true, path, compacting: true };
227
+ }
228
+ const customInstructions = trimmed.slice(8).trim() || undefined;
229
+ requireContext().compact({
230
+ ...(customInstructions ? { customInstructions } : {}),
231
+ onComplete: (result) => {
232
+ reportCompaction("success", `Context compacted (${result.tokensBefore} -> ${result.estimatedTokensAfter} tokens).`);
233
+ },
234
+ onError: (err) => {
235
+ reportCompaction("blocked", `Compaction failed: ${err.message}`);
236
+ },
237
+ });
238
+ return { accepted: true, path, compacting: true };
239
+ }
209
240
  if (isCompacting) {
210
241
  pendingCompactionDeliveries.push({ message, deliverAs });
211
242
  return { accepted: true, path, queuedForCompaction: true };
package/dist/domain.d.ts CHANGED
@@ -8,7 +8,16 @@ export declare function normalizeSubagentState(value: unknown): SubagentState |
8
8
  * is created, because it decides who holds the process for the rest of the child's life.
9
9
  */
10
10
  export type Presentation = "panel" | "background";
11
- export type SendMode = "steer" | "follow_up" | "auto";
11
+ /**
12
+ * How `agent_send` hands its message to a child.
13
+ *
14
+ * `steer` enters the current assistant turn once its tool calls are in, before the next model
15
+ * request, so it redirects work already in flight. `follow_up` waits until the child's run has no
16
+ * tool calls or steering left, then continues as a new turn in the same run. There is no "auto":
17
+ * the two queues already are the two situations, so the caller picks instead of guessing from the
18
+ * child's state.
19
+ */
20
+ export type SendMode = "steer" | "follow_up";
12
21
  export type VisualSubagentState = SubagentState;
13
22
  export declare function toVisualSubagentState(state: SubagentState, interrupted?: string): VisualSubagentState;
14
23
  export declare const VISUAL_SUBAGENT_GLYPH: Record<VisualSubagentState, string>;
@@ -192,6 +192,7 @@ export function buildLaunchSpec(options) {
192
192
  // Empty is the defined "derive the title in the child" case, mirroring the task channel below.
193
193
  [CHILD_TITLE_ENV_KEY]: config.title ?? "",
194
194
  PI_HINDSIGHT_DISABLE: "1",
195
+ PI_OBSERVATIONAL_MEMORY_DISABLE: "1",
195
196
  ...(config.codemodeOnly ? { PI_CODEMODE_MODE: "only" } : {}),
196
197
  ...(mcpExcluded && mcpExcluded.length > 0
197
198
  ? { PI_SUBAGENT_MCP_EXCLUDE: mcpExcluded.join(",") }
package/dist/manager.js CHANGED
@@ -570,13 +570,19 @@ export class SubagentManager {
570
570
  else {
571
571
  message = text ?? "";
572
572
  }
573
+ const reason = isError ? "error" : isBlocked ? "blocked" : "success";
574
+ if (reason === "success" &&
575
+ facts?.contextTokens !== undefined &&
576
+ facts.contextTokens >= 100_000) {
577
+ const kTokens = `${Math.round(facts.contextTokens / 1000)}k`;
578
+ message = `${message}\n\n[Context: ~${kTokens} tokens. If retaining this subagent for future tasks, send "/compact" via agent_send to trim memory.]`;
579
+ }
573
580
  const lastDelivered = this.#lastDeliveredTurn.get(id);
574
581
  if (lastDelivered !== undefined &&
575
582
  lastDelivered.workVersion === workVersion &&
576
583
  lastDelivered.message === message) {
577
584
  return;
578
585
  }
579
- const reason = isError ? "error" : isBlocked ? "blocked" : "success";
580
586
  const projector = this.#projectors.get(id);
581
587
  const updatedRecord = !failed
582
588
  ? await this.#update(id, (current) => {
@@ -923,7 +929,7 @@ export class SubagentManager {
923
929
  return failed;
924
930
  }
925
931
  }
926
- send(id, rawMessage, mode = "auto", signal) {
932
+ send(id, rawMessage, mode = "steer", signal) {
927
933
  const message = stripHindsightContent(rawMessage);
928
934
  this.#bumpActivity(id);
929
935
  this.#clearSettledPanelClose(id);
@@ -994,15 +1000,10 @@ export class SubagentManager {
994
1000
  }
995
1001
  }
996
1002
  const state = isResumed ? "running" : record.state;
997
- const operation = isResumed
998
- ? "prompt"
999
- : mode === "auto"
1000
- ? state === "running"
1001
- ? "steer"
1002
- : "follow_up"
1003
- : mode;
1004
- if (operation === undefined)
1005
- return failure("send", "Cannot infer send mode from unknown state", id, state);
1003
+ // A resume already starts a fresh run, so the mode has nothing to decide: the message is
1004
+ // that run's prompt. Otherwise the caller's mode is the request, with no state guessing:
1005
+ // a running child steers at its next model step, an idle one simply starts a run.
1006
+ const operation = isResumed ? "prompt" : mode;
1006
1007
  if (signal?.aborted)
1007
1008
  return failure("send", "Send was cancelled", id, state);
1008
1009
  this.#clearSettledPanelClose(id);
@@ -1182,25 +1183,20 @@ export class SubagentManager {
1182
1183
  const delivered = await this.contactParent(childId, reason, payload.message, record);
1183
1184
  if ("reason" in delivered)
1184
1185
  throw new BridgeError("delivery_failed", delivered.reason);
1185
- if (reason === "success") {
1186
- const projector = this.#projectors.get(childId);
1187
- if (projector !== undefined) {
1188
- projector.syncState("done");
1189
- }
1190
- await this.#update(childId, (current) => ({
1191
- ...current,
1192
- state: "done",
1193
- }));
1194
- }
1195
- else if (reason === "blocked") {
1186
+ if (reason === "success" || reason === "blocked") {
1187
+ const terminalState = reason === "success" ? "done" : "blocked";
1196
1188
  const projector = this.#projectors.get(childId);
1197
1189
  if (projector !== undefined) {
1198
- projector.syncState("blocked");
1190
+ projector.syncState(terminalState);
1199
1191
  }
1200
- await this.#update(childId, (current) => ({
1201
- ...current,
1202
- state: "blocked",
1203
- }));
1192
+ await this.#update(childId, (current) => {
1193
+ const { unacknowledgedInput: _input, ...clean } = current;
1194
+ return {
1195
+ ...clean,
1196
+ state: terminalState,
1197
+ };
1198
+ });
1199
+ this.#scheduleSettledPanelClose(childId);
1204
1200
  }
1205
1201
  else {
1206
1202
  const projector = this.#projectors.get(childId);
package/dist/state.d.ts CHANGED
@@ -12,6 +12,7 @@ export interface TranscriptFacts {
12
12
  readonly stopReason?: string;
13
13
  readonly interrupted?: string;
14
14
  readonly containsInput: boolean;
15
+ readonly contextTokens?: number;
15
16
  }
16
17
  export interface StateProjector {
17
18
  applyEvent(event: unknown): StateSnapshot;
package/dist/state.js CHANGED
@@ -123,12 +123,26 @@ function lastCompletedAssistant(entries) {
123
123
  }
124
124
  return last;
125
125
  }
126
+ function lastAssistantContextTokens(message) {
127
+ if (message === undefined)
128
+ return undefined;
129
+ const usage = record(message.usage);
130
+ if (usage === undefined)
131
+ return undefined;
132
+ const input = finite(usage.input) ?? 0;
133
+ const output = finite(usage.output) ?? 0;
134
+ const cacheRead = finite(usage.cacheRead) ?? 0;
135
+ const cacheWrite = finite(usage.cacheWrite) ?? 0;
136
+ const total = input + output + cacheRead + cacheWrite;
137
+ return total > 0 ? total : undefined;
138
+ }
126
139
  /** What the parent projects from a session. The transcript itself never crosses the bridge. */
127
140
  export function transcriptFacts(entries, expectedInput) {
128
141
  const last = lastCompletedAssistant(entries);
129
142
  const summary = summarizeCurrentBranch(entries);
130
143
  const stopReason = typeof last?.stopReason === "string" ? last.stopReason : undefined;
131
144
  const interrupted = last === undefined ? undefined : diagnostic(last);
145
+ const contextTokens = lastAssistantContextTokens(last);
132
146
  const containsInput = expectedInput !== undefined &&
133
147
  entries.some((entry) => {
134
148
  const value = record(entry);
@@ -140,6 +154,7 @@ export function transcriptFacts(entries, expectedInput) {
140
154
  ...(summary === undefined ? {} : { summary }),
141
155
  ...(stopReason === undefined ? {} : { stopReason }),
142
156
  ...(interrupted === undefined ? {} : { interrupted }),
157
+ ...(contextTokens === undefined ? {} : { contextTokens }),
143
158
  containsInput,
144
159
  };
145
160
  }
@@ -169,6 +184,7 @@ export function parseTranscriptFacts(value) {
169
184
  const summary = typeof value.summary === "string" ? value.summary : undefined;
170
185
  const stopReason = typeof value.stopReason === "string" ? value.stopReason : undefined;
171
186
  const interrupted = typeof value.interrupted === "string" ? value.interrupted : undefined;
187
+ const contextTokens = usageNumber(value.contextTokens);
172
188
  return {
173
189
  usage: {
174
190
  inputTokens,
@@ -181,6 +197,7 @@ export function parseTranscriptFacts(value) {
181
197
  ...(summary === undefined ? {} : { summary }),
182
198
  ...(stopReason === undefined ? {} : { stopReason }),
183
199
  ...(interrupted === undefined ? {} : { interrupted }),
200
+ ...(contextTokens === undefined ? {} : { contextTokens }),
184
201
  containsInput: value.containsInput,
185
202
  };
186
203
  }
@@ -55,7 +55,7 @@ function spawnPresentation(args, result) {
55
55
  function sendPresentation(args, result) {
56
56
  const record = isRecord(args) ? args : undefined;
57
57
  const id = stringField(record, "id") ?? "agent";
58
- const mode = stringField(record, "mode") ?? "auto";
58
+ const mode = stringField(record, "mode") ?? "steer";
59
59
  const message = stringField(record, "message");
60
60
  const confirmation = result === undefined ? undefined : agentResultText(result);
61
61
  const copy = message === undefined
package/dist/tools.js CHANGED
@@ -18,7 +18,9 @@ const spawnSchema = Type.Object({
18
18
  const sendSchema = Type.Object({
19
19
  id: Type.String({ minLength: 1, description: "Subagent id, e.g. agent-1" }),
20
20
  message: Type.String({ minLength: 1 }),
21
- mode: Type.Optional(Type.Union([Type.Literal("steer"), Type.Literal("follow_up"), Type.Literal("auto")])),
21
+ mode: Type.Optional(Type.Union([Type.Literal("steer"), Type.Literal("follow_up")], {
22
+ description: "steer (the default) enters the child's current turn once its tool calls are in, before the next model request, so it redirects work already in flight; an idle child just starts a run. follow_up waits until the child has no tool calls or steering left, then continues as a new turn. Use follow_up for work that must not interrupt what the child is doing now.",
23
+ })),
22
24
  });
23
25
  const idSchema = Type.Object({
24
26
  id: Type.String({ minLength: 1, description: "Subagent id, e.g. agent-1" }),
@@ -66,7 +68,7 @@ function getSpawnDescription(agents) {
66
68
  formatAvailableAgents(agents),
67
69
  ].join("\n");
68
70
  }
69
- const SEND_DESCRIPTION = "Send input, steer, or follow-up instructions to an existing subagent (e.g. 'agent-1'), resuming its session when it is idle, blocked, done, or errored. A child whose runtime has ended is resumed the same way: send to that id, do not spawn a replacement. Stop it first only when send reports the previous process is still running. Results are returned automatically when the task finishes; do not poll agent_get. Related subagent tools: agent_spawn, agent_get, agent_stop.";
71
+ const SEND_DESCRIPTION = "Send input to an existing subagent (e.g. 'agent-1'), resuming its session when it is idle, blocked, done, or errored. A child whose runtime has ended is resumed the same way: send to that id, do not spawn a replacement. Stop it first only when send reports the previous process is still running. Results are returned automatically when the task finishes; do not poll agent_get. Related subagent tools: agent_spawn, agent_get, agent_stop.";
70
72
  const GET_DESCRIPTION = "Inspect one subagent's status, progress, or diagnostic details by id (e.g. 'agent-1'). Use keys to choose fields; defaults to 'status,progress'. Results are returned automatically; use this for inspection, not completion polling. Related subagent tools: agent_spawn, agent_send, agent_stop.";
71
73
  const STOP_DESCRIPTION = "Stop a subagent's runtime by id (e.g. 'agent-1'). The child keeps its session, so a later agent_send can resume it. Related subagent tools: agent_spawn, agent_send, agent_get.";
72
74
  const CONTACT_DESCRIPTION = "Notify the parent session when you are BLOCKED and cannot continue without parent decision or intervention. Do NOT call this on success or task completion: simply output your final answer directly as normal text in your response, and the harness will automatically capture and report your result to the parent.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hheei/pi-subagents",
3
- "version": "0.5.3",
3
+ "version": "0.7.0",
4
4
  "description": "Independent background Pi subagent sessions with durable identity and RPC control.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@hheei/pi-ext-core": "^0.6.2"
38
+ "@hheei/pi-ext-core": "^0.6.4"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@earendil-works/pi-coding-agent": ">=1.3.0",