@arhen/pi-core-subagent 1.3.59 → 1.3.61

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
@@ -51,7 +51,7 @@ flowchart LR
51
51
  - **Proof is an exit code, never a self-report.** Tasks are asked for a runnable `Verify:` command; the leader checks `git diff --stat`. Agents auditing their own work score ~0. ([why](#why-9-is-a-verification-command-not-a-self-report))
52
52
  - **No ceremony without edges.** Six independent reviewers stay six independent reviewers — no waves, no gates, no graph vocabulary imposed on flat work.
53
53
  - **Agent files respected.** A spawn goal (name + task) that matches a user agent file's `description` (`.agents/agents`, `.claude/agents`, `.pi/agents` — project then home) loads that file — body = system prompt, frontmatter `model`/`tools` apply, file `model` validated against the pi model registry. File wins over inline; no match → on-demand definition.
54
- - **Two toolsets, plus explicit override.** Read-only (`read, grep, find, ls` — default) or write (`read, grep, find, ls, bash, edit, write` — `write: true`); `tools:` sets an explicit per-task allowlist.
54
+ - **Two toolsets, plus explicit override.** Read-only (`read, grep, find, ls, codemode` — default) or write (`read, grep, find, ls, bash, edit, write, codemode` — `write: true`); `tools:` sets an explicit per-task allowlist. Children run with `noExtensions`, so `codemode` is the one extension they get: it lets a child batch `tools.*` calls instead of spending a model turn per call.
55
55
  - **In-process** — children are `AgentSession`s in the same runtime. No process spawn, no context bleed.
56
56
  - **Zero parent-context injection.** No catalog, no context hook. 7 slim tools total.
57
57
  - **Throttled updates** — widget/stream updates coalesce to ~6/s; no per-event deep clones.
@@ -294,7 +294,7 @@ Background (default) + intercom — the run returns a runId immediately; you sta
294
294
  | `await_subagent` | block until a run finishes (optional `timeoutMs`) |
295
295
  | `reply_subagent` | answer a child's `ask_parent` question |
296
296
  | `steer_subagent` | inject a steering message into a running child's session (queues as steer if mid-turn; lands at its next model boundary) |
297
- | `resume_subagent` | revive a failed/aborted task in its original session (context + branch preserved); optional `model` swap and custom `message` |
297
+ | `resume_subagent` | revive a failed/aborted task in its original session (context + branch preserved); optional `model` swap, `thinking` override (the task's stored level is clamped to what the target model accepts), custom `message`. Waits for the reopened session to go idle before prompting, so a task killed mid-turn can still be resumed |
298
298
  | `subagent_cancel` | abort a running/queued run |
299
299
 
300
300
  ### Per-task fields
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.59",
3
+ "version": "1.3.61",
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/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
@@ -68,8 +68,8 @@ const DEFAULT_RUNTIME_MS = 3_600_000;
68
68
  const UNLIMITED_RUNTIME_MS = 21_600_000;
69
69
  const PARENT_REPLY_TIMEOUT_MS = 600_000;
70
70
  const PARKED_MSG_CAP = 24;
71
- const READONLY_TOOLS = ["read", "grep", "find", "ls"];
72
- 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"];
73
73
  const WRITE_CAPABLE = ["bash", "edit", "write"];
74
74
  const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
75
75
  const WIDGET_THROTTLE_MS = 150;
@@ -77,6 +77,25 @@ const WIDGET_THROTTLE_MS = 150;
77
77
  function newId(prefix: string): string {
78
78
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
79
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
+ }
80
99
  function emptyUsage(): UsageStats {
81
100
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
82
101
  }
@@ -143,6 +162,18 @@ function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage):
143
162
  export function cloneRun(run: RunSnapshot): RunSnapshot {
144
163
  return JSON.parse(JSON.stringify(run)) as RunSnapshot;
145
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
+
146
177
  export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
147
178
  if (!explicit?.trim()) return ctx.model;
148
179
  const ref = explicit.trim();
@@ -900,6 +931,7 @@ export class SubagentManager {
900
931
  cwd: childCwd,
901
932
  agentDir: getAgentDir(),
902
933
  noExtensions: true,
934
+ extensionFactories: await codemodeFactories(),
903
935
  appendSystemPromptOverride: (base) => [
904
936
  ...base,
905
937
  [prompt?.trim(), subagentInstruction].filter(Boolean).join("\n\n"),
@@ -978,6 +1010,9 @@ export class SubagentManager {
978
1010
  });
979
1011
 
980
1012
  const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS : UNLIMITED_RUNTIME_MS);
1013
+ // A resumed session can still be draining the turn that failed or timed out. Prompting while
1014
+ // its agent is mid-run is rejected outright ("already processing a prompt"), so wait first.
1015
+ if (resume) await child.waitForIdle();
981
1016
  const promptPromise = child.prompt(resume?.message ?? task.task, { source: "extension" });
982
1017
  const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise];
983
1018
  if (maxRuntimeMs > 0) {
@@ -1380,8 +1415,8 @@ export class SubagentManager {
1380
1415
  runId: string,
1381
1416
  taskId: string,
1382
1417
  ctx: ExtensionContext,
1383
- opts: { message?: string; model?: string } = {},
1384
- ): { ok: true; task: TaskSnapshot } | { ok: false; reason: string } {
1418
+ opts: { message?: string; model?: string; thinking?: string } = {},
1419
+ ): { ok: true; task: TaskSnapshot; note?: string } | { ok: false; reason: string } {
1385
1420
  const run = this.runs.get(runId);
1386
1421
  const task = run?.tasks.find((t) => t.id === taskId);
1387
1422
  if (!run || !task) return { ok: false, reason: `Unknown ${runId}/${taskId}.` };
@@ -1399,6 +1434,19 @@ export class SubagentManager {
1399
1434
 
1400
1435
  const tools = task.tools?.filter((t) => !(CHILD_TALK_TOOLS as readonly string[]).includes(t));
1401
1436
  const write = tools?.some((t) => WRITE_CAPABLE.includes(t)) ?? false;
1437
+ // A resume may swap the model, so the level stored on the task can be one the new model
1438
+ // rejects (a mode-clamped xhigh onto a model that only takes low|high|max). Clamp it, or take
1439
+ // the caller's explicit level and clamp that.
1440
+ let resumeModel: Model<Api> | undefined;
1441
+ try {
1442
+ resumeModel = resolveChildModel(ctx, opts.model ?? task.model);
1443
+ } catch {}
1444
+ const requestedThinking = (opts.thinking ?? task.thinking) as ThinkingLevel | undefined;
1445
+ const thinking = clampResumeThinking(resumeModel, requestedThinking);
1446
+ const thinkingNote =
1447
+ requestedThinking && thinking !== requestedThinking
1448
+ ? `thinking ${requestedThinking} → ${thinking} (${resumeModel?.provider}/${resumeModel?.id} does not accept ${requestedThinking})`
1449
+ : undefined;
1402
1450
  const input: TaskInput = {
1403
1451
  id: task.id,
1404
1452
  agent: task.agent,
@@ -1407,7 +1455,7 @@ export class SubagentManager {
1407
1455
  write,
1408
1456
  tools: tools?.length ? tools : undefined,
1409
1457
  model: opts.model ?? task.model,
1410
- thinking: task.thinking as TaskInput["thinking"],
1458
+ thinking: thinking as TaskInput["thinking"],
1411
1459
  needs: task.needs,
1412
1460
  };
1413
1461
  const resume: ResumeInput = {
@@ -1422,6 +1470,7 @@ export class SubagentManager {
1422
1470
  this.turnActivity = true;
1423
1471
  Object.assign(task, {
1424
1472
  status: "queued" as TaskStatus,
1473
+ thinking,
1425
1474
  error: undefined,
1426
1475
  endedAt: undefined,
1427
1476
  finalText: undefined,
@@ -1454,7 +1503,7 @@ export class SubagentManager {
1454
1503
  if (run.notifyPerTask) this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
1455
1504
  this.finishRunIfSettled(run, ctx);
1456
1505
  });
1457
- return { ok: true, task };
1506
+ return { ok: true, task, note: thinkingNote };
1458
1507
  }
1459
1508
 
1460
1509
  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(),