@arhen/pi-core-subagent 1.3.58 → 1.3.60

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
@@ -305,13 +305,15 @@ Background (default) + intercom — the run returns a runId immediately; you sta
305
305
 
306
306
  | Tool | Meaning |
307
307
  |---|---|
308
- | `ask_parent` | blocking question to the leader; parent answers via `reply_subagent` |
308
+ | `ask_parent` | blocking question to the leader; delivered mid-turn as a **steering** message labelled `[URGENT]` or `[not urgent]`, parent answers via `reply_subagent` |
309
309
  | `notify_parent` | one-way message to the leader |
310
310
  | `send_agent_message` | message to a sibling subagent's mailbox (`to` = its task id, or `"leader"`) |
311
311
  | `poll_agent_messages` | drain this subagent's mailbox |
312
312
 
313
313
  > **Intercom anti-deadlock:** children are told to never block indefinitely on intercom replies — an unanswered `ask_parent` times out after 10 minutes (the child is told to proceed with best judgment), and sibling polls are capped (~5 tries) with the same fallback. Gated siblings (later waves) may not be running yet — waiting on them is the top stall cause, so children are instructed not to.
314
314
 
315
+ > **Ask urgency:** `ask_parent` takes `urgent` (default `false`). Both variants steer into the leader's current turn so the question is never deferred to the end of a long turn. `[URGENT]` tells the leader to answer before its next step; `[not urgent]` tells it that the child keeps waiting, so it may finish its current step first. Failures steer for the same reason; completions and aborts queue as follow-ups.
316
+
315
317
  ## Commands
316
318
 
317
319
  - `/subagents` — list runs; `/subagents peek` (or `ctrl+shift+a`) — browsable pane
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.58",
3
+ "version": "1.3.60",
4
4
  "type": "module",
5
5
  "description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
6
6
  "license": "MIT",
package/src/child.ts CHANGED
@@ -6,7 +6,7 @@ import type { MailboxMessage } from "./mailbox.ts";
6
6
  export const CHILD_TALK_TOOLS = ["ask_parent", "notify_parent", "send_agent_message", "poll_agent_messages"] as const;
7
7
 
8
8
  export interface ChildHandlers {
9
- onAskParent(taskId: string, question: string): Promise<string>;
9
+ onAskParent(taskId: string, question: string, urgent: boolean): Promise<string>;
10
10
  onNotifyParent(taskId: string, message: string, level: "info" | "warning" | "error"): void;
11
11
  onSendMessage(taskId: string, to: string, text: string): boolean;
12
12
  onPollMailbox(taskId: string): MailboxMessage[];
@@ -18,18 +18,26 @@ export function createChildTools(taskId: string, handlers: ChildHandlers): ToolD
18
18
  name: "ask_parent",
19
19
  label: "Ask Parent",
20
20
  description:
21
- "Ask the parent agent a clarifying question and BLOCK until it replies (10 min cap — then proceed with best judgment). Use sparingly — only when you truly cannot proceed without information only the parent has. Prefer figuring it out yourself.",
21
+ "Ask the parent agent a clarifying question and BLOCK until it replies (10 min cap — then proceed with best judgment). Use sparingly — only when you truly cannot proceed without information only the parent has. Prefer figuring it out yourself. The parent sees the question mid-turn: set urgent when you cannot continue until it answers, leave it unset when the parent may finish its current step first.",
22
22
  promptSnippet: "Ask the parent agent a question when truly blocked.",
23
23
  promptGuidelines: [
24
24
  "Use ask_parent only as a last resort when blocked on information only the parent has.",
25
25
  "Ask one focused question at a time. The parent's reply resumes your work.",
26
+ "Set urgent: true only when you cannot keep working while waiting; otherwise the parent is told it may answer after its current step.",
26
27
  ],
27
28
  parameters: Type.Object({
28
29
  question: Type.String({ description: "A single, focused question for the parent agent" }),
30
+ urgent: Type.Optional(
31
+ Type.Boolean({
32
+ description:
33
+ "True when nothing else can proceed until the parent answers — it is told to stop and reply now",
34
+ default: false,
35
+ }),
36
+ ),
29
37
  }),
30
38
  async execute(_toolCallId, params) {
31
- const { question } = params as { question: string };
32
- const answer = await handlers.onAskParent(taskId, question);
39
+ const { question, urgent } = params as { question: string; urgent?: boolean };
40
+ const answer = await handlers.onAskParent(taskId, question, urgent === true);
33
41
  return { content: [{ type: "text" as const, text: answer || "(parent gave no answer)" }], details: {} };
34
42
  },
35
43
  },
package/src/format.ts CHANGED
@@ -250,6 +250,17 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
250
250
  : `Session file kept — resume_subagent(runId: "${run.id}", taskId: "${task.id}", model?: ...) revives it with full context. subagent_result for what it produced so far.`,
251
251
  ].join("\n");
252
252
  }
253
+ export function makeAskNotice(
254
+ run: RunSnapshot,
255
+ extra: { taskId?: string; agent?: string; question?: string; urgent?: boolean },
256
+ ): string {
257
+ const who = extra.agent ? `${extra.agent} (${extra.taskId ?? "task"})` : (extra.taskId ?? "a subagent");
258
+ const reply = `reply_subagent(runId: "${run.id}", taskId: "${extra.taskId ?? ""}", message: ...)`;
259
+ return extra.urgent
260
+ ? `[URGENT] Subagent ${who} is blocked and cannot continue until you answer: ${extra.question ?? ""}\nAnswer now, before your next step, with ${reply}.`
261
+ : `[not urgent] Subagent ${who} asks: ${extra.question ?? ""}\nIt waits while you keep working — finish your current step first if you want, then answer with ${reply}.`;
262
+ }
263
+
253
264
  export function makeNotice(run: RunSnapshot, kind: string): string {
254
265
  const lines = [
255
266
  `Background subagent run ${run.id} ${kind}: ${run.tasks.filter((t) => t.status === "completed").length}/${run.tasks.length} succeeded.`,
package/src/index.ts CHANGED
@@ -381,23 +381,24 @@ export default function (pi: ExtensionAPI) {
381
381
  name: "resume_subagent",
382
382
  label: "Resume Subagent",
383
383
  description:
384
- "Revive a failed/aborted task in its original session (full context + worktree branch preserved). Optional `model` swaps provider (e.g. after a rate limit); optional `message` replaces the default 'recap and continue' prompt. Refuses tasks that never started — respawn those.",
384
+ "Revive a failed/aborted task in its original session (full context + worktree branch preserved). Optional `model` swaps provider (e.g. after a rate limit); optional `thinking` sets the effort — the stored level is clamped to what the target model accepts, so a resume never dies on an unsupported effort; optional `message` replaces the default 'recap and continue' prompt. Refuses tasks that never started — respawn those.",
385
385
  parameters: ResumeParam,
386
386
  async execute(_id, params, _signal, _onUpdate, ctx) {
387
- const { runId, taskId, message, model } = params as {
387
+ const { runId, taskId, message, model, thinking } = params as {
388
388
  runId: string;
389
389
  taskId: string;
390
390
  message?: string;
391
391
  model?: string;
392
+ thinking?: string;
392
393
  };
393
- const res = manager.resumeTask(runId, taskId, ctx, { message, model });
394
+ const res = manager.resumeTask(runId, taskId, ctx, { message, model, thinking });
394
395
  if (!res.ok) return { content: [{ type: "text", text: res.reason }], isError: true, details: {} };
395
396
  const run = manager.getRun(runId);
396
397
  return {
397
398
  content: [
398
399
  {
399
400
  type: "text",
400
- text: `Resumed ${runId}/${taskId} (${res.task.agent})${model ? ` on ${model}` : ""} from ${res.task.sessionFile}${res.task.branch ? `, branch ${res.task.branch}` : ""}.\nNext: subagent_status("${runId}") to confirm it is running; completion will notify you.`,
401
+ text: `Resumed ${runId}/${taskId} (${res.task.agent})${model ? ` on ${model}` : ""} from ${res.task.sessionFile}${res.task.branch ? `, branch ${res.task.branch}` : ""}.${res.note ? ` Adjusted ${res.note}.` : ""}\nNext: subagent_status("${runId}") to confirm it is running; completion will notify you.`,
401
402
  },
402
403
  ],
403
404
  details: { run: run ? cloneRun(run) : undefined },
package/src/manager.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  getFirstText,
30
30
  isStartupFailure,
31
31
  isTalking,
32
+ makeAskNotice,
32
33
  makeNotice,
33
34
  makeTaskNotice,
34
35
  SubagentsWidget,
@@ -67,8 +68,8 @@ const DEFAULT_RUNTIME_MS = 3_600_000;
67
68
  const UNLIMITED_RUNTIME_MS = 21_600_000;
68
69
  const PARENT_REPLY_TIMEOUT_MS = 600_000;
69
70
  const PARKED_MSG_CAP = 24;
70
- const READONLY_TOOLS = ["read", "grep", "find", "ls"];
71
- const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
71
+ const READONLY_TOOLS = ["read", "grep", "find", "ls", "codemode"];
72
+ const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write", "codemode"];
72
73
  const WRITE_CAPABLE = ["bash", "edit", "write"];
73
74
  const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
74
75
  const WIDGET_THROTTLE_MS = 150;
@@ -76,6 +77,25 @@ const WIDGET_THROTTLE_MS = 150;
76
77
  function newId(prefix: string): string {
77
78
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
78
79
  }
80
+
81
+ type ChildExtensionFactories = ConstructorParameters<typeof DefaultResourceLoader>[0]["extensionFactories"];
82
+
83
+ /**
84
+ * Children run with `noExtensions`, so they get none of the configured extensions — codemode is the
85
+ * one exception, because it is how a child batches tool calls. `createCodemodeExtension()` is the
86
+ * supported factory (pi >= 1.0); hosts that do not export it simply give children no codemode.
87
+ */
88
+ async function codemodeFactories(): Promise<ChildExtensionFactories> {
89
+ try {
90
+ const host = (await import("@earendil-works/pi-coding-agent")) as unknown as {
91
+ createCodemodeExtension?: () => unknown;
92
+ };
93
+ const factory = host.createCodemodeExtension?.();
94
+ return factory ? ([factory] as ChildExtensionFactories) : [];
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
79
99
  function emptyUsage(): UsageStats {
80
100
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
81
101
  }
@@ -142,6 +162,18 @@ function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage):
142
162
  export function cloneRun(run: RunSnapshot): RunSnapshot {
143
163
  return JSON.parse(JSON.stringify(run)) as RunSnapshot;
144
164
  }
165
+ /**
166
+ * A resumed task keeps its stored thinking level, clamped to what the target model accepts — a
167
+ * resume that swaps model must not fail on an effort the new model does not define.
168
+ */
169
+ export function clampResumeThinking(
170
+ model: Model<Api> | undefined,
171
+ thinking: ThinkingLevel | undefined,
172
+ ): ThinkingLevel | undefined {
173
+ if (!thinking || !model) return thinking;
174
+ return clampThinkingLevel(model, thinking) as ThinkingLevel;
175
+ }
176
+
145
177
  export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
146
178
  if (!explicit?.trim()) return ctx.model;
147
179
  const ref = explicit.trim();
@@ -494,19 +526,21 @@ export class SubagentManager {
494
526
  private notifyParent(
495
527
  run: RunSnapshot,
496
528
  kind: "completed" | "failed" | "aborted" | "asked",
497
- extra?: { taskId?: string; question?: string },
529
+ extra?: { taskId?: string; agent?: string; question?: string; urgent?: boolean },
498
530
  ): void {
499
531
  if (kind !== "asked" && run.awaited) return;
500
532
  // single-task completed run: the task notice already said everything (failure paths may not have notified per-task)
501
533
  if (kind === "completed" && run.tasks.length === 1 && run.notifyPerTask) return;
502
- const body =
503
- kind === "asked"
504
- ? `A subagent is asking you a question (task ${extra?.taskId}): ${extra?.question ?? ""}\nReply with reply_subagent(runId: "${run.id}", taskId: "${extra?.taskId}", message: ...).`
505
- : makeNotice(run, kind);
534
+ const body = kind === "asked" ? makeAskNotice(run, extra ?? {}) : makeNotice(run, kind);
535
+ // asks steer: the leader sees the question during its turn and the urgent flag tells it whether to
536
+ // answer now or after the current step. Failures steer for the same reason — a broken result must
537
+ // not be consumed. Completions and aborts queue as follow-ups.
506
538
  try {
507
- this.pi.sendUserMessage(body, { deliverAs: kind === "failed" ? "steer" : "followUp" });
539
+ this.pi.sendUserMessage(body, {
540
+ deliverAs: kind === "completed" || kind === "aborted" ? "followUp" : "steer",
541
+ });
508
542
  } catch {}
509
- this.emit("subagent:notification", { runId: run.id, kind, body });
543
+ this.emit("subagent:notification", { runId: run.id, taskId: extra?.taskId, kind, body });
510
544
  }
511
545
 
512
546
  private widgetTui: TUI | null = null;
@@ -614,14 +648,14 @@ export class SubagentManager {
614
648
 
615
649
  private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
616
650
  return {
617
- onAskParent: async (_taskId, question) => {
651
+ onAskParent: async (_taskId, question, urgent) => {
618
652
  if (TERMINAL.includes(task.status)) {
619
653
  return "(your task has already ended — stop work and return immediately)";
620
654
  }
621
655
  this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
622
656
 
623
657
  if (!this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
624
- this.notifyParent(run, "asked", { taskId: task.id, question });
658
+ this.notifyParent(run, "asked", { taskId: task.id, agent: task.agent, question, urgent });
625
659
  }
626
660
 
627
661
  const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
@@ -891,12 +925,13 @@ export class SubagentManager {
891
925
  const worktreeNote = wt
892
926
  ? ` You work in an isolated git worktree (branch ${wt.branch})${task.stackedOn ? `, stacked on ${task.stackedOn} (its changes are already in your tree)` : ""}. Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes. node_modules is a SHARED symlink to the main checkout: never install, upgrade, or delete dependencies (no npm/bun/yarn/pnpm install, no \`rm -rf node_modules\`) — those writes escape your worktree and damage the user's project. If the task truly needs a dependency change, edit the manifest only and say so in your answer.`
893
927
  : "";
894
- const subagentInstruction = `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. An unanswered ask_parent times out after 10 minutes — proceed with your best judgment then. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${worktreeNote}`;
928
+ const subagentInstruction = `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has (set urgent: true only when nothing else can proceed while you wait); notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. An unanswered ask_parent times out after 10 minutes — proceed with your best judgment then. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${worktreeNote}`;
895
929
 
896
930
  const loader = new DefaultResourceLoader({
897
931
  cwd: childCwd,
898
932
  agentDir: getAgentDir(),
899
933
  noExtensions: true,
934
+ extensionFactories: await codemodeFactories(),
900
935
  appendSystemPromptOverride: (base) => [
901
936
  ...base,
902
937
  [prompt?.trim(), subagentInstruction].filter(Boolean).join("\n\n"),
@@ -1377,8 +1412,8 @@ export class SubagentManager {
1377
1412
  runId: string,
1378
1413
  taskId: string,
1379
1414
  ctx: ExtensionContext,
1380
- opts: { message?: string; model?: string } = {},
1381
- ): { ok: true; task: TaskSnapshot } | { ok: false; reason: string } {
1415
+ opts: { message?: string; model?: string; thinking?: string } = {},
1416
+ ): { ok: true; task: TaskSnapshot; note?: string } | { ok: false; reason: string } {
1382
1417
  const run = this.runs.get(runId);
1383
1418
  const task = run?.tasks.find((t) => t.id === taskId);
1384
1419
  if (!run || !task) return { ok: false, reason: `Unknown ${runId}/${taskId}.` };
@@ -1396,6 +1431,19 @@ export class SubagentManager {
1396
1431
 
1397
1432
  const tools = task.tools?.filter((t) => !(CHILD_TALK_TOOLS as readonly string[]).includes(t));
1398
1433
  const write = tools?.some((t) => WRITE_CAPABLE.includes(t)) ?? false;
1434
+ // A resume may swap the model, so the level stored on the task can be one the new model
1435
+ // rejects (a mode-clamped xhigh onto a model that only takes low|high|max). Clamp it, or take
1436
+ // the caller's explicit level and clamp that.
1437
+ let resumeModel: Model<Api> | undefined;
1438
+ try {
1439
+ resumeModel = resolveChildModel(ctx, opts.model ?? task.model);
1440
+ } catch {}
1441
+ const requestedThinking = (opts.thinking ?? task.thinking) as ThinkingLevel | undefined;
1442
+ const thinking = clampResumeThinking(resumeModel, requestedThinking);
1443
+ const thinkingNote =
1444
+ requestedThinking && thinking !== requestedThinking
1445
+ ? `thinking ${requestedThinking} → ${thinking} (${resumeModel?.provider}/${resumeModel?.id} does not accept ${requestedThinking})`
1446
+ : undefined;
1399
1447
  const input: TaskInput = {
1400
1448
  id: task.id,
1401
1449
  agent: task.agent,
@@ -1404,7 +1452,7 @@ export class SubagentManager {
1404
1452
  write,
1405
1453
  tools: tools?.length ? tools : undefined,
1406
1454
  model: opts.model ?? task.model,
1407
- thinking: task.thinking as TaskInput["thinking"],
1455
+ thinking: thinking as TaskInput["thinking"],
1408
1456
  needs: task.needs,
1409
1457
  };
1410
1458
  const resume: ResumeInput = {
@@ -1419,6 +1467,7 @@ export class SubagentManager {
1419
1467
  this.turnActivity = true;
1420
1468
  Object.assign(task, {
1421
1469
  status: "queued" as TaskStatus,
1470
+ thinking,
1422
1471
  error: undefined,
1423
1472
  endedAt: undefined,
1424
1473
  finalText: undefined,
@@ -1451,7 +1500,7 @@ export class SubagentManager {
1451
1500
  if (run.notifyPerTask) this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
1452
1501
  this.finishRunIfSettled(run, ctx);
1453
1502
  });
1454
- return { ok: true, task };
1503
+ return { ok: true, task, note: thinkingNote };
1455
1504
  }
1456
1505
 
1457
1506
  private finishRunIfSettled(run: RunSnapshot, ctx: ExtensionContext): void {
package/src/schemas.ts CHANGED
@@ -89,6 +89,12 @@ export const ResumeParam = Type.Object({
89
89
  "Model override for the resumed session (provider/model-id) — use when the original provider is rate-limited",
90
90
  }),
91
91
  ),
92
+ thinking: Type.Optional(
93
+ StringEnum(THINKING_LEVELS, {
94
+ description:
95
+ "Thinking level for the resumed session. Default: the task's stored level, clamped to what the target model accepts",
96
+ }),
97
+ ),
92
98
  });
93
99
  export const SteerParam = Type.Object({
94
100
  runId: Type.String(),