@hheei/pi-subagents 0.6.0 → 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
@@ -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 };
@@ -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) => {
@@ -1177,25 +1183,20 @@ export class SubagentManager {
1177
1183
  const delivered = await this.contactParent(childId, reason, payload.message, record);
1178
1184
  if ("reason" in delivered)
1179
1185
  throw new BridgeError("delivery_failed", delivered.reason);
1180
- if (reason === "success") {
1181
- const projector = this.#projectors.get(childId);
1182
- if (projector !== undefined) {
1183
- projector.syncState("done");
1184
- }
1185
- await this.#update(childId, (current) => ({
1186
- ...current,
1187
- state: "done",
1188
- }));
1189
- }
1190
- else if (reason === "blocked") {
1186
+ if (reason === "success" || reason === "blocked") {
1187
+ const terminalState = reason === "success" ? "done" : "blocked";
1191
1188
  const projector = this.#projectors.get(childId);
1192
1189
  if (projector !== undefined) {
1193
- projector.syncState("blocked");
1190
+ projector.syncState(terminalState);
1194
1191
  }
1195
- await this.#update(childId, (current) => ({
1196
- ...current,
1197
- state: "blocked",
1198
- }));
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);
1199
1200
  }
1200
1201
  else {
1201
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hheei/pi-subagents",
3
- "version": "0.6.0",
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",