pi-famulus 0.1.3 → 0.1.4

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.
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Zero pi dependency; the output-file stat is injected so tests can drive it.
7
7
  */
8
- import { formatSubagentOverrun, type SubagentOverrunInfo } from "../format";
8
+ import { formatSubagentOverrunBatch, type SubagentOverrunInfo } from "../format";
9
9
  import type { FormattedWake, OverrunShell } from "../wake";
10
10
  import type { ConversationTurn } from "./types";
11
11
 
@@ -125,7 +125,7 @@ export interface OverrunNotifierDeps {
125
125
  now: () => number;
126
126
  registry: {
127
127
  list(): { runId: string; children: { childId: string; name: string }[] }[];
128
- handle(childId: string): { conversation(): ConversationTurn[] } | undefined;
128
+ handle(childId: string): { conversation(): ConversationTurn[]; collectDueOverrun?(now: number): OverrunTick | undefined } | undefined;
129
129
  };
130
130
  shells: ChildShellTracker;
131
131
  stat: OverrunInfoDeps["stat"];
@@ -153,24 +153,39 @@ export function createOverrunNotifier(deps: OverrunNotifierDeps): (tick: Overrun
153
153
  stat: deps.stat,
154
154
  };
155
155
  return (tick) => {
156
- const info = buildOverrunInfo(infoDeps, tick);
157
- if (!info) return;
158
- const shell = info.shell;
159
- deps.logEvent("agent.overrun", {
160
- child_id: info.childId,
161
- run_id: info.runId,
162
- reminder: info.reminder,
163
- elapsed_ms: info.elapsedMs,
164
- budget_ms: info.budgetMs,
165
- ...(shell
166
- ? {
167
- shell_task_id: shell.taskId,
168
- shell_elapsed_ms: shell.elapsedMs,
169
- ...(shell.outputBytes !== null ? { output_bytes: shell.outputBytes } : {}),
170
- ...(shell.growing !== null ? { growing: shell.growing } : {}),
171
- }
172
- : {}),
173
- });
174
- deps.notify(formatSubagentOverrun(info));
156
+ const infos: SubagentOverrunInfo[] = [];
157
+ const primary = buildOverrunInfo(infoDeps, tick);
158
+ if (!primary) return;
159
+ infos.push(primary);
160
+ const now = deps.now();
161
+ for (const run of deps.registry.list()) {
162
+ for (const child of run.children) {
163
+ if (child.childId === tick.childId) continue;
164
+ const due = deps.registry.handle(child.childId)?.collectDueOverrun?.(now);
165
+ if (due) {
166
+ const info = buildOverrunInfo(infoDeps, due);
167
+ if (info) infos.push(info);
168
+ }
169
+ }
170
+ }
171
+ for (const info of infos) {
172
+ const shell = info.shell;
173
+ deps.logEvent("agent.overrun", {
174
+ child_id: info.childId,
175
+ run_id: info.runId,
176
+ reminder: info.reminder,
177
+ elapsed_ms: info.elapsedMs,
178
+ budget_ms: info.budgetMs,
179
+ ...(shell
180
+ ? {
181
+ shell_task_id: shell.taskId,
182
+ shell_elapsed_ms: shell.elapsedMs,
183
+ ...(shell.outputBytes !== null ? { output_bytes: shell.outputBytes } : {}),
184
+ ...(shell.growing !== null ? { growing: shell.growing } : {}),
185
+ }
186
+ : {}),
187
+ });
188
+ }
189
+ deps.notify(formatSubagentOverrunBatch(infos));
175
190
  };
176
191
  }
@@ -11,12 +11,12 @@
11
11
  * allowlist plus injected custom tools (child bash, M4 comms), and wraps it
12
12
  * into a ChildSessionAdapter.
13
13
  *
14
- * Child sessions receive an isolated DefaultResourceLoader with extensions
15
- * disabled; createAgentSession binds whatever the loader returns. Thus the
16
- * subagent tool is never present in a child session — the depth-1 cap holds
17
- * by construction.
14
+ * Child sessions discover user resources and built-in extensions normally.
15
+ * A session-local event bus identifies child loaders before the famulus
16
+ * extension initializes, preventing recursive parent-tool registration.
18
17
  */
19
18
  import type {
19
+ ExtensionAPI,
20
20
  ExtensionContext,
21
21
  ModelRegistry,
22
22
  ToolDefinition,
@@ -28,7 +28,8 @@ import {
28
28
  type ModelCandidate,
29
29
  } from "./model-spec";
30
30
  import { turnsFromMessages } from "./conversation";
31
- import type { ChildRunRequest, ChildSessionAdapter, CreateSessionFn } from "./types";
31
+ import { createTokenUsageAccumulator, normalizedTokenUsage } from "./usage";
32
+ import type { ChildRunRequest, ChildSessionAdapter, ChildSessionEvent, CreateSessionFn } from "./types";
32
33
 
33
34
  /** Structural subset of the pi module namespace we rely on. */
34
35
  type PiModule = typeof import("@earendil-works/pi-coding-agent");
@@ -37,6 +38,34 @@ type PiAgentSession = Awaited<ReturnType<PiModule["createAgentSession"]>>["sessi
37
38
 
38
39
  type Model = NonNullable<ExtensionContext["model"]>;
39
40
 
41
+ export function createPiUsageAdapter() {
42
+ const totals = createTokenUsageAccumulator();
43
+ return {
44
+ tokenUsage: () => totals.snapshot(),
45
+ adapt(event: {
46
+ type: string;
47
+ toolCallId?: string;
48
+ message?: {
49
+ role?: string;
50
+ usage?: { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown };
51
+ };
52
+ }): ChildSessionEvent {
53
+ const message = event.message;
54
+ let usage: ReturnType<typeof normalizedTokenUsage> | undefined;
55
+ if (event.type === "message_end" && message?.usage) {
56
+ usage = normalizedTokenUsage(message.usage);
57
+ totals.add(usage);
58
+ }
59
+ return {
60
+ type: event.type,
61
+ ...(typeof event.toolCallId === "string" ? { toolCallId: event.toolCallId } : {}),
62
+ ...(typeof message?.role === "string" ? { role: message.role } : {}),
63
+ ...(usage ? { usage } : {}),
64
+ };
65
+ },
66
+ };
67
+ }
68
+
40
69
  export interface PiRuntimeDeps {
41
70
  /** Parent session model registry (ctx.modelRegistry). */
42
71
  getModelRegistry: () => ModelRegistry | null;
@@ -58,6 +87,10 @@ export interface PiRuntimeDeps {
58
87
  getScopedModels?: () => readonly { model: Model; thinkingLevel?: string }[];
59
88
  /** Working directory for child sessions. */
60
89
  getCwd: () => string;
90
+ /** Global resource directory; defaults to pi.getAgentDir(). */
91
+ getAgentDir?: () => string;
92
+ /** Preserve the parent's project trust decision. */
93
+ getProjectTrusted?: () => boolean;
61
94
  /**
62
95
  * Custom tools injected into every child session (e.g. the no-background
63
96
  * bash variant, M4 contact_supervisor). Called per child request.
@@ -156,11 +189,17 @@ function resolveModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel
156
189
  /** Wrap a real AgentSession into the pi-free ChildSessionAdapter. */
157
190
  function wrapSession(
158
191
  session: PiAgentSession,
159
- extras: { warning?: string; resolvedModel?: string } = {},
192
+ extras: { warning?: string; resolvedModel?: string; effectiveThinkingLevel?: string } = {},
160
193
  ): ChildSessionAdapter {
194
+ const usageAdapter = createPiUsageAdapter();
195
+ let disposal: Promise<void> | undefined;
196
+ // Children receive all queued steering messages in one drain/turn rather
197
+ // than the interactive one-at-a-time default, which can strand wake bursts.
198
+ session.setSteeringMode("all");
161
199
  return {
162
200
  ...(extras.warning !== undefined ? { warning: extras.warning } : {}),
163
201
  ...(extras.resolvedModel !== undefined ? { resolvedModel: extras.resolvedModel } : {}),
202
+ ...(extras.effectiveThinkingLevel !== undefined ? { effectiveThinkingLevel: extras.effectiveThinkingLevel } : {}),
164
203
  // pi ≥0.99 returns QueuedInputDisposition ("handled" | "queued") from
165
204
  // input methods; the adapter contract stays Promise<void>.
166
205
  prompt: async (text) => { await session.prompt(text); },
@@ -180,33 +219,64 @@ function wrapSession(
180
219
  };
181
220
  },
182
221
  getConversation: () => turnsFromMessages(session.messages),
222
+ tokenUsage: () => usageAdapter.tokenUsage(),
183
223
  getActiveToolNames: () => session.getActiveToolNames(),
184
224
  getSystemPrompt: () => session.systemPrompt,
185
225
  isStreaming: () => session.isStreaming,
186
- subscribe: (listener) => session.subscribe((event) => listener({ type: event.type })),
187
- dispose: () => session.dispose(),
226
+ getSteeringMode: () => session.steeringMode,
227
+ subscribe: (listener) => session.subscribe((event) => {
228
+ listener(usageAdapter.adapt(event as Parameters<typeof usageAdapter.adapt>[0]));
229
+ }),
230
+ dispose: () => disposal ??= (async () => {
231
+ try {
232
+ await session.abort();
233
+ await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
234
+ } finally {
235
+ session.dispose();
236
+ }
237
+ })(),
188
238
  };
189
239
  }
190
240
 
191
- /** Options passed to createAgentSession. Tested without loading pi. */
192
- export function childResourceLoaderOptions(input: { cwd: string; agentDir: string }): {
241
+ const CHILD_ROLE_CHANNEL = "pi-famulus:child-role";
242
+
243
+ /** The loader's bus answers synchronously, before any extension initialization. */
244
+ export function isFamulusChildSession(pi: Pick<ExtensionAPI, "events">): boolean {
245
+ if (!pi.events) {
246
+ throw new Error("pi-famulus requires the extension event bus to identify child sessions safely");
247
+ }
248
+ const probe = { child: false };
249
+ pi.events.emit(CHILD_ROLE_CHANNEL, probe);
250
+ return probe.child;
251
+ }
252
+
253
+ /** Keep configuration scopes and paths intact; never persist child overrides. */
254
+ export async function createChildResources(input: {
193
255
  cwd: string;
194
256
  agentDir: string;
195
- noExtensions: true;
196
- noSkills: true;
197
- noPromptTemplates: true;
198
- noThemes: true;
199
- noContextFiles: true;
200
- } {
201
- return {
257
+ projectTrusted: boolean;
258
+ }) {
259
+ const pi = await importPi();
260
+ const settingsManager = pi.SettingsManager.create(input.cwd, input.agentDir, {
261
+ projectTrusted: input.projectTrusted,
262
+ });
263
+ const eventBus = pi.createEventBus();
264
+ eventBus.on(CHILD_ROLE_CHANNEL, (probe) => {
265
+ if (probe && typeof probe === "object" && "child" in probe) probe.child = true;
266
+ });
267
+ const resourceLoader = new pi.DefaultResourceLoader({
202
268
  cwd: input.cwd,
203
269
  agentDir: input.agentDir,
204
- noExtensions: true,
205
- noSkills: true,
206
- noPromptTemplates: true,
207
- noThemes: true,
208
- noContextFiles: true,
209
- };
270
+ settingsManager,
271
+ eventBus,
272
+ extensionFactories: [
273
+ { name: "codemode", factory: pi.createCodemodeExtension(), builtin: true, replaceable: true },
274
+ { name: "tool-search", factory: pi.createToolSearchExtension(), builtin: true, replaceable: true },
275
+ { name: "mcp", factory: pi.createMcpExtension(), builtin: true, replaceable: true },
276
+ ],
277
+ });
278
+ await resourceLoader.reload();
279
+ return { resourceLoader, settingsManager };
210
280
  }
211
281
 
212
282
  export function childSessionCreateOptions(input: {
@@ -222,13 +292,18 @@ export function childSessionCreateOptions(input: {
222
292
  model: unknown;
223
293
  thinkingLevel: unknown;
224
294
  tools?: string[];
295
+ excludeTools?: string[];
225
296
  customTools?: unknown[];
226
297
  modelRuntime?: unknown;
227
298
  resourceLoader?: unknown;
228
299
  } {
229
- const tools = input.tools && input.tools.length > 0 ? [...input.tools] : undefined;
230
- const customTools = input.customTools?.map((customTool) => {
300
+ const tools = input.tools !== undefined ? [...input.tools] : undefined;
301
+ const customTools = input.customTools?.filter((customTool) => {
302
+ const name = (customTool as { name?: unknown }).name;
303
+ return name !== "bash" || tools === undefined || tools.includes("bash");
304
+ }).map((customTool) => {
231
305
  const tool = customTool as { name?: unknown; promptGuidelines?: string[] };
306
+ if (tool.name === "bash") return { ...tool, defaultActive: false };
232
307
  if (tool.name === "contact_supervisor") {
233
308
  return {
234
309
  ...tool,
@@ -247,7 +322,11 @@ export function childSessionCreateOptions(input: {
247
322
  cwd: input.cwd,
248
323
  model: input.model,
249
324
  thinkingLevel: input.thinkingLevel,
250
- ...(tools ? { tools } : {}),
325
+ ...(tools !== undefined ? { tools } : {}),
326
+ // pi 1.0.4+ otherwise retains MCP tools behind non-MCP allowlists.
327
+ ...(tools !== undefined && !tools.some((name) => name.startsWith("mcp__"))
328
+ ? { excludeTools: ["mcp__*"] }
329
+ : {}),
251
330
  ...(customTools && customTools.length > 0 ? { customTools } : {}),
252
331
  ...(input.modelRuntime ? { modelRuntime: input.modelRuntime } : {}),
253
332
  ...(input.resourceLoader ? { resourceLoader: input.resourceLoader } : {}),
@@ -267,31 +346,37 @@ export function createPiSessionFn(deps: PiRuntimeDeps): CreateSessionFn {
267
346
  req.agent.thinking ??
268
347
  deps.getParentThinkingLevel();
269
348
  const cwd = deps.getCwd();
270
- const resourceLoader = new pi.DefaultResourceLoader(
271
- childResourceLoaderOptions({ cwd, agentDir: pi.getAgentDir() }),
272
- );
273
- await resourceLoader.reload();
349
+ const agentDir = deps.getAgentDir?.() ?? pi.getAgentDir();
350
+ const { resourceLoader, settingsManager } = await createChildResources({
351
+ cwd,
352
+ agentDir,
353
+ projectTrusted: deps.getProjectTrusted?.() ?? false,
354
+ });
274
355
  const customTools = deps.customTools?.(req) ?? [];
275
356
  const modelRuntime = deps.getModelRuntime?.();
276
357
  const options = childSessionCreateOptions({
277
358
  cwd,
278
359
  model: resolved.model,
279
360
  thinkingLevel,
280
- tools: req.agent.tools.length > 0 ? [...req.agent.tools] : undefined,
361
+ tools: req.agent.tools,
281
362
  customTools: customTools.length > 0 ? customTools : undefined,
282
363
  modelRuntime,
283
364
  resourceLoader,
284
365
  });
285
366
  const { session } = await pi.createAgentSession({
286
367
  ...options,
368
+ agentDir,
369
+ settingsManager,
287
370
  sessionManager: pi.SessionManager.inMemory(cwd),
288
371
  } as never);
372
+ await session.bindExtensions({});
289
373
  const resolvedModel = resolved.model
290
374
  ? `${resolved.model.provider}/${resolved.model.id}`
291
375
  : undefined;
292
376
  return wrapSession(session, {
293
377
  ...(resolved.warning !== undefined ? { warning: resolved.warning } : {}),
294
378
  ...(resolvedModel !== undefined ? { resolvedModel } : {}),
379
+ ...(typeof thinkingLevel === "string" ? { effectiveThinkingLevel: thinkingLevel } : {}),
295
380
  });
296
381
  };
297
382
  }