one-code-extension 0.5.0 → 0.6.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.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/docs/guide/differences-from-claude-code.md +9 -6
  3. package/docs/guide/installation.md +3 -4
  4. package/docs/guide/troubleshooting.md +1 -1
  5. package/extensions/auto-mode/denials.ts +3 -2
  6. package/extensions/background/cron.ts +127 -0
  7. package/extensions/background/index.ts +135 -47
  8. package/extensions/background/loop-fire.ts +39 -2
  9. package/extensions/background/switch-guard.ts +10 -6
  10. package/extensions/background/wakeup.ts +21 -2
  11. package/extensions/bash/index.ts +8 -30
  12. package/extensions/btw/index.ts +32 -18
  13. package/extensions/cache-marks/index.ts +42 -0
  14. package/extensions/claude-context/index.ts +157 -172
  15. package/extensions/compaction/index.ts +11 -8
  16. package/extensions/context-budget/budget.ts +16 -2
  17. package/extensions/context-budget/index.ts +15 -14
  18. package/extensions/context-management/index.ts +11 -28
  19. package/extensions/effort/index.ts +70 -7
  20. package/extensions/file-tracker/index.ts +111 -44
  21. package/extensions/file-tracker/replay.ts +76 -49
  22. package/extensions/file-tracker/restore.ts +12 -9
  23. package/extensions/file-tracker/tracker.ts +14 -1
  24. package/extensions/hooks/index.ts +60 -76
  25. package/extensions/hooks/prompt-context.ts +59 -0
  26. package/extensions/hooks/trust.ts +13 -7
  27. package/extensions/ledger/index.ts +46 -0
  28. package/extensions/ledger/wire.ts +154 -0
  29. package/extensions/lib/agent-cron.ts +17 -1
  30. package/extensions/lib/agent-loader.ts +3 -2
  31. package/extensions/lib/anthropic-auth.ts +98 -0
  32. package/extensions/lib/child-extensions.ts +14 -4
  33. package/extensions/lib/context-facts.ts +75 -56
  34. package/extensions/lib/covered-messages.ts +23 -0
  35. package/extensions/lib/deferred.ts +152 -12
  36. package/extensions/lib/ledger/fold-memo.ts +49 -0
  37. package/extensions/lib/ledger/fold.ts +265 -0
  38. package/extensions/lib/ledger/layout.ts +72 -0
  39. package/extensions/lib/ledger/plan.ts +102 -0
  40. package/extensions/lib/ledger/records.ts +276 -0
  41. package/extensions/lib/ledger/state.ts +47 -0
  42. package/extensions/lib/ledger/validate.ts +141 -0
  43. package/extensions/lib/ledger/wire.ts +632 -0
  44. package/extensions/lib/local-command.ts +24 -6
  45. package/extensions/lib/model-catalog/huggingface.json +5 -1
  46. package/extensions/lib/model-catalog/models-dev.json +10535 -10299
  47. package/extensions/lib/model-catalog/openrouter.json +191 -393
  48. package/extensions/lib/notifications.ts +57 -17
  49. package/extensions/lib/pi-version.ts +25 -8
  50. package/extensions/lib/plan-mode-channels.ts +29 -7
  51. package/extensions/lib/previous-mark.ts +101 -0
  52. package/extensions/lib/process-state.ts +48 -0
  53. package/extensions/lib/process-tree.ts +3 -2
  54. package/extensions/lib/reminders.ts +327 -621
  55. package/extensions/lib/request-replay.ts +186 -78
  56. package/extensions/lib/side-call.ts +11 -0
  57. package/extensions/lib/subagent-handback.ts +0 -1
  58. package/extensions/lib/tool-additions.ts +2 -22
  59. package/extensions/lib/turn-budget-layout.ts +18 -2
  60. package/extensions/lsp/index.ts +6 -8
  61. package/extensions/mcp/announce.ts +63 -98
  62. package/extensions/mcp/index.ts +51 -90
  63. package/extensions/mcp/trust.ts +13 -6
  64. package/extensions/model-default/index.ts +86 -19
  65. package/extensions/permissions/index.ts +157 -48
  66. package/extensions/permissions/modes.ts +5 -0
  67. package/extensions/plan-mode/index.ts +142 -45
  68. package/extensions/plan-mode/run-state.ts +26 -0
  69. package/extensions/plugins/index.ts +4 -1
  70. package/extensions/powershell/index.ts +2 -0
  71. package/extensions/recap/index.ts +11 -14
  72. package/extensions/search-tools/index.ts +2 -0
  73. package/extensions/skill/announce.ts +30 -0
  74. package/extensions/skill/index.ts +65 -48
  75. package/extensions/skill/invoke.ts +0 -67
  76. package/extensions/subagents/agents.ts +30 -8
  77. package/extensions/subagents/fork-cache.ts +9 -3
  78. package/extensions/subagents/index.ts +180 -107
  79. package/extensions/subagents/outcome.ts +3 -0
  80. package/extensions/subagents/parent-mode.ts +50 -0
  81. package/extensions/subagents/pending-claim.ts +18 -1
  82. package/extensions/subagents/runner.ts +29 -2
  83. package/extensions/subagents/runs.ts +5 -0
  84. package/extensions/system-prompt/index.ts +40 -6
  85. package/extensions/system-prompt/template.ts +10 -1
  86. package/extensions/system-reminder/index.ts +174 -202
  87. package/extensions/tool-search/index.ts +241 -167
  88. package/extensions/workflow/agent-session.ts +4 -1
  89. package/extensions/workflow/index.ts +16 -4
  90. package/extensions/workflow/run-manager.ts +4 -0
  91. package/extensions/worktree/index.ts +50 -14
  92. package/package.json +5 -2
  93. package/extensions/lib/context-stack.ts +0 -81
  94. package/extensions/subagents/announce.ts +0 -69
  95. package/extensions/tool-search/announce.ts +0 -58
@@ -29,7 +29,7 @@ import type { Api, Message, Model } from "@earendil-works/pi-ai";
29
29
  import { type AgentToolResult, defineTool, getAgentDir, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
30
30
  import { type Static, Type } from "typebox";
31
31
  import { SUBAGENT_ACTIONS_CHANNEL, type SubagentActionsPayload } from "../auto-mode/actions.ts";
32
- import { type AgentDefinition, agentFileSources, BUNDLED_AGENTS_DIR, discoverAgents } from "./agents.ts";
32
+ import { type AgentDefinition, agentFileSources, agentSourcesStamp, BUNDLED_AGENTS_DIR, discoverAgents } from "./agents.ts";
33
33
  import { findConfigured, modelIdentity, modelSpec, supportsImageInput } from "../lib/model-policy.ts";
34
34
  import { MODEL_UNUSABLE_CHANNEL, type ModelUnusableEvent, withoutUnusable } from "../lib/model-unusable.ts";
35
35
  import { applicableSubagentDefault, loadSubagentDefault, persistSubagentModel, type SubagentDefault } from "./default-model.ts";
@@ -53,7 +53,7 @@ import { scanSkills } from "../lib/skill-scan.ts";
53
53
  import { type GuideInput, guideAgentDefinition, settingsSetup } from "./guide-agent.ts";
54
54
  import { MCP_STATUS_CHANNEL, MCP_STATUS_REQUEST_CHANNEL, type McpServerStatus, type McpStatusEvent, type McpStatusKind } from "../lib/mcp-status.ts";
55
55
 
56
- import { DEFER_CHANNEL } from "../lib/deferred.ts";
56
+ import { DEFER_CHANNEL, queryDeferred } from "../lib/deferred.ts";
57
57
  import { requestSystemPrompt } from "../lib/prompt-options.ts";
58
58
  import { captureMatches, LAST_REQUEST_CHANNEL, type RequestCapture } from "../lib/request-replay.ts";
59
59
  import { BTW_FORK_CHANNEL, btwForkDescription, btwForkName, btwForkRecord, btwForkTaskId, type BtwForkRequest, type BtwForkResult } from "../lib/btw-fork.ts";
@@ -63,10 +63,10 @@ import { type DescriptionForm, followDescriptionForm, registerVariantTool } from
63
63
  import { agentDescription } from "./agent-description.ts";
64
64
  import { prepareAgentArguments } from "./agent-args.ts";
65
65
  import { projectConfigDirName } from "../lib/config-mode.ts";
66
- import { pendingClaimReminder } from "./pending-claim.ts";
66
+ import { pendingClaimReminder, runningAfterCompactionReminder } from "./pending-claim.ts";
67
67
  import { watchPermissionBridge } from "../permissions/subagent-gate.ts";
68
68
  import { watchHookBridge } from "../hooks/subagent-bridge.ts";
69
- import { CONTEXT_ORDER, REMINDER_CHANNEL, type ReminderPayload } from "../lib/reminders.ts";
69
+ import { CONTEXT_ORDER, REMINDER_CHANNEL, RENDER_CHANNEL, type RenderRequest, type ReminderPayload, toldBlockFact } from "../lib/reminders.ts";
70
70
  import { type BackgroundTask, generateTaskId, TASK_REGISTER_CHANNEL } from "../background/registry.ts";
71
71
  import { type ChildAction } from "../auto-mode/actions.ts";
72
72
  import { type ChildOutcome, forkTaskMessage, OUTPUT_CAP, type RpcChildHandle } from "./outcome.ts";
@@ -112,8 +112,7 @@ import { createMarkdownProse } from "./prose.ts";
112
112
  import { asScrollableViewport, ViewportMirror } from "./viewport-mirror.ts";
113
113
  import { registerLocalCommand } from "../lib/local-command.ts";
114
114
  import { extendingCompletions } from "../lib/argument-hints.ts";
115
- import { CONTEXT_BASELINE_CHANNEL, restoredContext } from "../lib/context-stack.ts";
116
- import { emptySubagentBaseline, planSubagentAnnouncement, subagentBaseline, subagentBaselineFromStack, type SubagentBaseline } from "./announce.ts";
115
+ import { readStringsState, recordState } from "../lib/ledger/state.ts";
117
116
  import { panelUi } from "../lib/panel-focus.ts";
118
117
  import { dockRowsReporter, trackDockRows } from "../lib/dock-rows.ts";
119
118
 
@@ -229,6 +228,15 @@ const SubagentParams = Type.Object({
229
228
  });
230
229
 
231
230
 
231
+ /** The Agent tool's schema inside a non-fork child: the nested run's options only. */
232
+ const NESTED_AGENT_PARAMS = Type.Object({
233
+ subagent_type: Type.Optional(Type.String({ description: "An agent name from the list in this tool's description (default: general-purpose)" })),
234
+ task: Type.String({ description: "The task — a complete, self-contained instruction" }),
235
+ prompt: Type.Optional(Type.String({ description: "Alias of `task` (Claude Code's name for it)." })),
236
+ description: Type.Optional(Type.String({ description: "A short (3-5 word) description of the task." })),
237
+ name: Type.Optional(Type.String({ description: "Name for this run (default: <agent>-<n>)" })),
238
+ });
239
+
232
240
  /**
233
241
  * The anti-fabrication sentence, carried by the tool RESULT of a background
234
242
  * spawn and not only by the Agent tool's description. Claude Code puts it in the
@@ -274,6 +282,16 @@ function residentPending(resident: Resident): boolean {
274
282
  return !resident.handle.exited() && (resident.handle.busy() || resident.turnHandlers.length > 0 || resident.reviewing > 0);
275
283
  }
276
284
 
285
+ /** The three capability blocks subagents owns on message 1. */
286
+ type SubagentBlockKey = "subagent-models" | "subagent-agents" | "subagent-delegation";
287
+ const BLOCK_ORDER: Record<SubagentBlockKey, number> = {
288
+ "subagent-models": CONTEXT_ORDER.subagentModels,
289
+ "subagent-agents": CONTEXT_ORDER.agents,
290
+ "subagent-delegation": CONTEXT_ORDER.delegation,
291
+ };
292
+ /** The `one-code:state` key of the models this account refused (lib/model-unusable.ts). */
293
+ const REFUSED_MODELS_STATE = "subagents:refused-models";
294
+
277
295
  export default function subagentsExtension(pi: ExtensionAPI) {
278
296
  const requestTier = sessionModelTier(pi);
279
297
  const registry = new RunRegistry();
@@ -410,6 +428,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
410
428
  const rememberUnusable = (model: string) => {
411
429
  if (unusableModels.has(model)) return false;
412
430
  unusableModels.add(model);
431
+ // A refusal is a durable fact of the account, not UI prose: a resumed
432
+ // session must not pick the same model and announce it again.
433
+ recordState(pi, REFUSED_MODELS_STATE, [...unusableModels]);
413
434
  return true;
414
435
  };
415
436
  pi.events.on(MODEL_UNUSABLE_CHANNEL, (data) => {
@@ -455,6 +476,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
455
476
  if (lastCtx) emitModelStatus(lastCtx, lastCtx.model, model);
456
477
  },
457
478
  (path, agent) => pi.events.emit(CHILD_WROTE_CHANNEL, { path, ...(agent ? { agent } : {}) } satisfies ChildWrote),
479
+ () => queryDeferred(pi.events),
480
+ // The mode a fork's calls are checked in, read live at each of its requests (parent-mode.ts).
481
+ () => permissionMode,
458
482
  ).catch((error: unknown) => {
459
483
  runtimePromise = undefined;
460
484
  throw error;
@@ -519,13 +543,19 @@ export default function subagentsExtension(pi: ExtensionAPI) {
519
543
  };
520
544
  };
521
545
 
522
- const loadAgents = (cwd: string) => {
523
- // Plugin agents sit between bundled and user definitions, and are exposed
524
- // namespaced (`<plugin>:<agent>`) so two plugins can ship the same name.
546
+ // Plugin agents sit between bundled and user definitions, and are exposed
547
+ // namespaced (`<plugin>:<agent>`) so two plugins can ship the same name.
548
+ const agentSources = (cwd: string) => {
549
+ const plugins = discoverPlugins(defaultDiscoverRoots(getAgentDir(), cwd));
550
+ return { plugins, fileSources: agentFileSources(cwd, os.homedir(), plugins.agentDirs) };
551
+ };
552
+ type AgentSources = ReturnType<typeof agentSources>;
553
+
554
+ /** `sources` when the caller already resolved them (the catalog stamp). */
555
+ const loadAgents = (cwd: string, sources: AgentSources = agentSources(cwd)) => {
525
556
  // The code-defined guide ranks below every agent file, so one of the same
526
557
  // name replaces it.
527
- const plugins = discoverPlugins(defaultDiscoverRoots(getAgentDir(), cwd));
528
- const fileSources = agentFileSources(cwd, os.homedir(), plugins.agentDirs);
558
+ const { plugins, fileSources } = sources;
529
559
  // The prompt is built from the catalog it sits in, when a spawn reads it.
530
560
  let catalog: AgentDefinition[] = [];
531
561
  const guide = guideAgentDefinition(() => guideInput(cwd, catalog, plugins));
@@ -544,9 +574,11 @@ export default function subagentsExtension(pi: ExtensionAPI) {
544
574
  return a.source === "built-in" || a.source.startsWith(BUNDLED_AGENTS_DIR) ? " (Tools: *)" : " (Tools: All tools)";
545
575
  };
546
576
 
547
- const describeAgents = (cwd: string) => {
548
- const agents = loadAgents(cwd);
549
- const lines = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`);
577
+ /** One catalog row per agent, the shape every catalog the model reads uses. */
578
+ const catalogLines = (agents: AgentDefinition[]): string[] => agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`);
579
+
580
+ const describeAgents = (cwd: string, sources?: AgentSources) => {
581
+ const lines = catalogLines(loadAgents(cwd, sources));
550
582
  lines.push(`- ${FORK_AGENT}: clone this conversation, with its full context, to work on a task in parallel`);
551
583
  return lines.join("\n");
552
584
  };
@@ -619,29 +651,46 @@ export default function subagentsExtension(pi: ExtensionAPI) {
619
651
  return resolution;
620
652
  };
621
653
 
622
- // Message 1 is frozen once a request has gone out (decisions/caching.md):
623
- // after that, a model the account refused is corrected by a one-shot, not by
624
- // rewriting the menu and re-caching the conversation. A user's /model or
625
- // /subagent still rewrites it.
626
- let requestSent = false;
627
- let restoredSession = false;
628
- let capabilityBaseline: SubagentBaseline = emptySubagentBaseline();
629
- const publishCapabilityBaseline = (baseline: SubagentBaseline) => {
630
- pi.events.emit(CONTEXT_BASELINE_CHANNEL, { key: "subagents", value: baseline });
654
+ // The three blocks are live state: what an epoch's message 1 says. Inside an
655
+ // epoch message 1 is the record (extensions/ledger), so a refused model, a
656
+ // /model or a /subagent change rides the request's tail, decided at render
657
+ // time from the text the epoch last told, never a rewrite of the menu that
658
+ // would re-cache the conversation and drop its earlier thinking (findings §46).
659
+ /** The live text of each block; null when the block is absent. */
660
+ const liveBlocks: Record<SubagentBlockKey, string | null> = { "subagent-models": null, "subagent-agents": null, "subagent-delegation": null };
661
+ /** A model the account refused since the menu was last told: its narrow correction stands in for the whole menu. */
662
+ let refusal: { refused: string[]; replacement: Model<Api> | undefined } | undefined;
663
+ const publishBlock = (key: SubagentBlockKey, text: string | null) => {
664
+ liveBlocks[key] = text;
665
+ if (text === null) pi.events.emit(REMINDER_CHANNEL, { key, remove: true });
666
+ else pi.events.emit(REMINDER_CHANNEL, { text, key, placement: "first-prepend", order: BLOCK_ORDER[key], facts: [toldBlockFact(key, text)] });
631
667
  };
632
- pi.on("context", () => {
633
- requestSent = true;
668
+ pi.events.on(RENDER_CHANNEL, (data) => {
669
+ const request = data as RenderRequest;
670
+ if (request.epoch) {
671
+ refusal = undefined;
672
+ return;
673
+ }
674
+ for (const key of Object.keys(liveBlocks) as SubagentBlockKey[]) {
675
+ const text = liveBlocks[key];
676
+ // An absent block is told as its empty text; one never told needs no withdrawal.
677
+ const told = request.toldBlocks.get(key);
678
+ const fact = toldBlockFact(key, text ?? "");
679
+ if (told === fact.hash || (text === null && told === undefined)) continue;
680
+ const facts = [fact];
681
+ if (text === null) request.blocks.push({ text: "The subagent delegation guidance previously shown for this conversation is no longer active.", facts });
682
+ else if (key === "subagent-models" && refusal) request.blocks.push({ text: refusal.refused.map((model) => refusedModelNote(model, refusal?.replacement)).join("\n"), facts });
683
+ else request.blocks.push({ text, facts });
684
+ }
685
+ refusal = undefined;
634
686
  });
635
687
 
636
688
  /**
637
689
  * The every-turn menu reminder and the banner's subagent-default status.
638
- * Every-turn because reminders are transient per-request injections — that
639
- * scope survives compaction by construction — and keyed so a model change
640
- * replaces it: the very next LLM call, even mid-turn, carries the update.
641
- * `refused` names a model the account just refused: once a request has gone
642
- * out, that change rides a one-shot instead.
690
+ * `refused` names a model the account just refused: inside an epoch that
691
+ * change rides a narrow correction instead of the whole menu.
643
692
  */
644
- const emitModelStatus = (ctx: ExtensionContext, sessionModel = ctx.model, refused?: string) => {
693
+ const modelMenu = (ctx: ExtensionContext, sessionModel = ctx.model) => {
645
694
  const available = usableModels(ctx);
646
695
  const configured = applicableSubagentDefault(loadSubagentDefault(os.homedir()), sessionModel);
647
696
  const resolution = resolveAutoDefault(sessionModel, available, configured);
@@ -649,6 +698,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
649
698
  available, sessionModel, defaultModel: resolution.model, defaultSource: resolution.source, configured,
650
699
  requireImageInput: supportsImageInput(sessionModel),
651
700
  });
701
+ return { available, configured, resolution, text };
702
+ };
703
+ const emitModelStatus = (ctx: ExtensionContext, sessionModel = ctx.model, refused?: string) => {
704
+ const { available, configured, resolution, text } = modelMenu(ctx, sessionModel);
652
705
  for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
653
706
  // The user's own subagentModel setting is honoured, so say what it costs:
654
707
  // a newer model in its line, or a model far weaker than the session's.
@@ -657,61 +710,38 @@ export default function subagentsExtension(pi: ExtensionAPI) {
657
710
  notifyModelOnce(ctx, `${warning.text} ${warning.fix}`);
658
711
  }
659
712
  }
660
- // Keep the established narrow refusal correction: it does not claim the
661
- // entire menu was reprinted, and it remains useful on restored sessions.
662
- if (refused !== undefined && requestSent) {
663
- // The narrow correction is all the model needs now, but persist the
664
- // corrected complete menu as announcement history. Otherwise the next
665
- // process would compare the stale menu with the same live catalog and
666
- // append a spurious full-menu correction on every resume.
667
- capabilityBaseline = {
668
- ...capabilityBaseline,
669
- models: text,
670
- refusedModels: [...new Set([...capabilityBaseline.refusedModels, refused])],
671
- };
672
- publishCapabilityBaseline(capabilityBaseline);
673
- pi.events.emit(REMINDER_CHANNEL, { text: refusedModelNote(refused, resolution.model) });
674
- pi.events.emit(SUBAGENT_STATUS_CHANNEL, subagentStatusModel(configured, resolution));
675
- return;
676
- }
677
- const plan = planSubagentAnnouncement({ restored: restoredSession, baseline: capabilityBaseline, capability: "models", text });
678
- capabilityBaseline = plan.baseline;
679
- if (plan.kind !== "none") publishCapabilityBaseline(capabilityBaseline);
680
- if (plan.kind === "addendum") pi.events.emit(REMINDER_CHANNEL, { text: plan.text });
681
- else if (!restoredSession) pi.events.emit(REMINDER_CHANNEL, {
682
- text, scope: "every-turn", key: "subagent-models", placement: "first-prepend", order: CONTEXT_ORDER.subagentModels,
683
- });
713
+ // Every refusal since the menu was last told, each named, with the default as it is now.
714
+ if (refused !== undefined) refusal = { refused: [...(refusal?.refused ?? []).filter((model) => model !== refused), refused], replacement: resolution.model };
715
+ else if (refusal) refusal = { ...refusal, replacement: resolution.model };
716
+ publishBlock("subagent-models", text);
684
717
  pi.events.emit(SUBAGENT_STATUS_CHANNEL, subagentStatusModel(configured, resolution));
685
718
  };
686
719
 
720
+ /** The last catalog text and the stamp of the agent files it was built from. */
721
+ let catalogMemo: { stamp: string; text: string } | undefined;
687
722
  /**
688
723
  * The agent catalog as an every-turn reminder, as Claude Code does ("Available
689
724
  * agent types are listed in <system-reminder> messages") — without it the
690
- * model has to guess names or make a discovery call first. Keyed and
691
- * byte-stable within a session, so it costs nothing in prompt-cache terms.
725
+ * model has to guess names or make a discovery call first. Rebuilt only when
726
+ * the agent files changed: every run start asks, and stating the files is
727
+ * cheaper than reading and parsing each one. The sources are resolved once
728
+ * per check (the plugin discovery behind them is a process-wide memo), and a
729
+ * rebuild reads the same ones the stamp stated.
692
730
  */
693
- const emitAgentCatalog = (ctx: ExtensionContext) => {
694
- const text = `Available agent types for the Agent tool:\n${describeAgents(ctx.cwd)}\n\nWhen you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.`;
695
- const plan = planSubagentAnnouncement({ restored: restoredSession, baseline: capabilityBaseline, capability: "agents", text });
696
- capabilityBaseline = plan.baseline;
697
- if (plan.kind !== "none") publishCapabilityBaseline(capabilityBaseline);
698
- if (plan.kind === "addendum") pi.events.emit(REMINDER_CHANNEL, { text: plan.text });
699
- else if (!restoredSession) pi.events.emit(REMINDER_CHANNEL, {
700
- scope: "every-turn", key: "subagent-agents", text, placement: "first-prepend", order: CONTEXT_ORDER.agents,
701
- });
731
+ const agentCatalog = (ctx: ExtensionContext) => {
732
+ const sources = agentSources(ctx.cwd);
733
+ const stamp = `${ctx.cwd}\n${agentSourcesStamp(sources.fileSources)}`;
734
+ if (catalogMemo?.stamp !== stamp) {
735
+ catalogMemo = {
736
+ stamp,
737
+ text: `Available agent types for the Agent tool:\n${describeAgents(ctx.cwd, sources)}\n\nWhen you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.`,
738
+ };
739
+ }
740
+ return catalogMemo.text;
702
741
  };
742
+ const emitAgentCatalog = (ctx: ExtensionContext) => publishBlock("subagent-agents", agentCatalog(ctx));
703
743
 
704
- const emitDelegationSteer = () => {
705
- const text = requestTier() === "tiny" ? DELEGATION_STEER : null;
706
- const plan = planSubagentAnnouncement({ restored: restoredSession, baseline: capabilityBaseline, capability: "delegation", text });
707
- capabilityBaseline = plan.baseline;
708
- if (plan.kind !== "none") publishCapabilityBaseline(capabilityBaseline);
709
- if (plan.kind === "addendum") pi.events.emit(REMINDER_CHANNEL, { text: plan.text });
710
- else if (!restoredSession && text !== null) pi.events.emit(REMINDER_CHANNEL, {
711
- scope: "every-turn", key: "subagent-delegation", text, placement: "first-prepend", order: CONTEXT_ORDER.delegation,
712
- });
713
- else if (!restoredSession) pi.events.emit(REMINDER_CHANNEL, { key: "subagent-delegation", remove: true });
714
- };
744
+ const emitDelegationSteer = () => publishBlock("subagent-delegation", requestTier() === "tiny" ? DELEGATION_STEER : null);
715
745
 
716
746
  let shuttingDown = false;
717
747
 
@@ -725,6 +755,11 @@ export default function subagentsExtension(pi: ExtensionAPI) {
725
755
  const tier = requestTier();
726
756
  return tier === "cheap" || tier === "tiny";
727
757
  };
758
+ /** A run that is still working: a live child, or a resident with a turn pending. */
759
+ const stillRunning = (taskId: string): boolean => {
760
+ const resident = residents.get(taskId);
761
+ return (resident !== undefined && residentPending(resident)) || runningIds.has(taskId);
762
+ };
728
763
  pi.on("agent_start", () => {
729
764
  spawnedThisLoop.clear();
730
765
  parentBusy = true;
@@ -740,10 +775,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
740
775
  // lookups below would be discarded work on every turn that delegated.
741
776
  if (!backstopApplies()) return undefined;
742
777
  const pending = spawned
743
- .filter((taskId) => {
744
- const resident = residents.get(taskId);
745
- return (resident !== undefined && residentPending(resident)) || runningIds.has(taskId);
746
- })
778
+ .filter(stillRunning)
747
779
  .map((taskId) => registry.resolve(taskId))
748
780
  .filter((record): record is AgentRunRecord => record !== undefined)
749
781
  .map((record) => ({ name: record.name, taskId: record.taskId }));
@@ -753,17 +785,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
753
785
  });
754
786
 
755
787
  pi.on("session_start", (_event, ctx) => {
756
- const restored = restoredContext(pi.events);
757
- restoredSession = restored !== undefined;
758
- requestSent = restoredSession;
759
- // New snapshots carry exact structured text; old v1 snapshots can still
760
- // seed from their locked first-prepend blocks without rewriting them.
761
- capabilityBaseline = subagentBaseline(restored?.baselines.subagents)
762
- ?? subagentBaselineFromStack(restored?.stack ?? []);
763
- // A refusal correction is a real capability fact, not merely UI prose.
764
- // Restore its selection floor before rebuilding the menu, otherwise this
765
- // process can choose the same refused model and reissue a false correction.
766
- for (const model of capabilityBaseline.refusedModels) unusableModels.add(model);
788
+ // The account's refusals follow the branch: restore the selection floor
789
+ // before building the menu, or this process picks the refused model again.
790
+ for (const model of readStringsState(ctx, REFUSED_MODELS_STATE)) unusableModels.add(model);
767
791
  lastCtx = ctx;
768
792
  // A replaced session (/clear, /new, /resume) never reaches this instance
769
793
  // with the old one's agents: pi emits `session_shutdown` (reason "new" /
@@ -789,7 +813,55 @@ export default function subagentsExtension(pi: ExtensionAPI) {
789
813
  const live = liveUiCtx(lastCtx);
790
814
  if (live) emitModelStatus(live);
791
815
  });
792
- pi.on("session_tree", (_event, ctx) => reconstructRuns(ctx));
816
+ /**
817
+ * The main conversation's runs still going at the last compaction, and the
818
+ * compaction entry they belong to: named on the next request, as far as
819
+ * they are still going then.
820
+ */
821
+ let runningAtCompaction: { entryId: string | undefined; taskIds: string[] } | undefined;
822
+ // A compaction opens an epoch, which renders the three blocks from live state:
823
+ // refresh them, so a catalog that changed on disk since is current. The calls
824
+ // that started the background runs went into the summary, so the runs still
825
+ // going are named once on the new epoch's first request. Only the main
826
+ // conversation's own runs: a nested run reports to the agent that spawned it.
827
+ pi.on("session_compact", (event, ctx) => {
828
+ emitModelStatus(ctx);
829
+ emitAgentCatalog(ctx);
830
+ emitDelegationSteer();
831
+ const taskIds = [...registry.list()].filter((record) => (record.depth ?? 0) === 0 && stillRunning(record.taskId)).map((record) => record.taskId);
832
+ runningAtCompaction = taskIds.length > 0 ? { entryId: event?.compactionEntry?.id, taskIds } : undefined;
833
+ });
834
+ // Named when the request is built, not at the compaction: the next request can
835
+ // come much later (a manual /compact waits for the next prompt), and a run that
836
+ // finished meanwhile is not running. The run's own notification says it ended.
837
+ // A /tree to a branch without that compaction still shows the calls.
838
+ pi.on("turn_start", (_event, ctx) => {
839
+ const owed = runningAtCompaction;
840
+ if (!owed) return;
841
+ runningAtCompaction = undefined;
842
+ if (owed.entryId !== undefined && !ctx.sessionManager.getBranch().some((entry) => entry.id === owed.entryId)) return;
843
+ const going = owed.taskIds
844
+ .filter(stillRunning)
845
+ .map((taskId) => registry.resolve(taskId))
846
+ .filter((record): record is AgentRunRecord => record !== undefined)
847
+ .map((record) => ({ name: record.name, taskId: record.taskId }));
848
+ const text = runningAfterCompactionReminder(going);
849
+ if (text) pi.events.emit(REMINDER_CHANNEL, { text, key: "subagents-running-after-compaction", untilCompaction: true });
850
+ });
851
+ // The catalog is read from disk at each run's start, so an agent file added
852
+ // or deleted mid-session reaches the model: the render step announces a
853
+ // changed catalog, live minus what the epoch told (R3-M28).
854
+ pi.on("before_agent_start", (_event, ctx) => {
855
+ emitAgentCatalog(ctx);
856
+ });
857
+ // The selected branch's runs: the abandoned branch's finished ones are not
858
+ // addressable from it (SendMessage, list_agents), as on a resume. A run still
859
+ // going, a resident between turns and a nested run (recorded only in memory)
860
+ // stay reachable, and so do names held for spawns in flight.
861
+ pi.on("session_tree", (_event, ctx) => {
862
+ registry.retain((record) => liveRuns.get(record.taskId)?.status === "running" || residents.has(record.taskId) || parentTaskOf.has(record.taskId));
863
+ reconstructRuns(ctx);
864
+ });
793
865
  pi.on("session_shutdown", async () => {
794
866
  // Teardown — at process exit AND on /clear, /new, /resume (pi replaces the
795
867
  // session and re-runs this factory; afterwards every use of this
@@ -1440,9 +1512,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1440
1512
  const childCatalog = (cwd: string): string => {
1441
1513
  let text = childCatalogCache.get(cwd);
1442
1514
  if (text === undefined) {
1443
- text = loadAgents(cwd)
1444
- .map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`)
1445
- .join("\n");
1515
+ text = catalogLines(loadAgents(cwd)).join("\n");
1446
1516
  childCatalogCache.set(cwd, text);
1447
1517
  }
1448
1518
  return text;
@@ -1469,17 +1539,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1469
1539
  // accepted as `task`, `description` is tolerated, and the agent name is
1470
1540
  // matched case-insensitively — a nested subagent is just as likely to be
1471
1541
  // CC-trained.
1472
- parameters: Type.Object({
1473
- subagent_type: Type.Optional(Type.String({ description: "An agent name from the list in this tool's description (default: general-purpose)" })),
1474
- task: Type.String({ description: "The task — a complete, self-contained instruction" }),
1475
- prompt: Type.Optional(Type.String({ description: "Alias of `task` (Claude Code's name for it)." })),
1476
- description: Type.Optional(Type.String({ description: "A short (3-5 word) description of the task." })),
1477
- name: Type.Optional(Type.String({ description: "Name for this run (default: <agent>-<n>)" })),
1478
- }) as never,
1542
+ // A fork inherits the parent's tools block byte for byte (its first request
1543
+ // extends the parent's recorded one, decisions/caching.md), so its spawn
1544
+ // tool takes the parent's Agent schema: the ledger keeps the recorded
1545
+ // declaration only while the live schema matches it, and pi validates the
1546
+ // call against the live one. The options a nested run lacks are refused in
1547
+ // execute() with the fix named.
1548
+ parameters: (parentRecord.agent === FORK_AGENT ? SubagentParams : NESTED_AGENT_PARAMS) as never,
1479
1549
  prepareArguments: prepareAgentArguments as never,
1480
1550
  async execute(_toolCallId: string, params: unknown, signal?: AbortSignal) {
1481
1551
  const ctx = lastCtx;
1482
- const p = (params ?? {}) as { subagent_type?: unknown; task?: unknown; prompt?: unknown; name?: unknown; description?: unknown };
1552
+ const p = (params ?? {}) as { subagent_type?: unknown; task?: unknown; prompt?: unknown; name?: unknown; description?: unknown; model?: unknown; thinking?: unknown; isolation?: unknown; action?: unknown };
1483
1553
  const description = typeof p.description === "string" && p.description.trim() ? p.description.trim() : undefined;
1484
1554
  const agentName = typeof p.subagent_type === "string" ? p.subagent_type : "";
1485
1555
  const taskInput = typeof p.task === "string" ? p.task : typeof p.prompt === "string" ? p.prompt : "";
@@ -1488,10 +1558,13 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1488
1558
  if (!ctx) return err("The host session is not ready to spawn agents — retry shortly.");
1489
1559
  if (agentName.toLowerCase() === FORK_AGENT) return err('"fork" is only available to the main conversation — pick a named agent instead.');
1490
1560
  const agents = loadAgents(parentRecord.cwd);
1561
+ // Only a fork's schema (the parent's) offers these; a nested run has none of them.
1562
+ if (p.action === "list") return { content: [{ type: "text" as const, text: `Available agents:\n${catalogLines(agents).join("\n")}` }], details: {} };
1563
+ const unsupported = (["model", "thinking", "isolation"] as const).filter((key) => p[key] != null);
1564
+ if (unsupported.length > 0) return err(`${unsupported.map((key) => `\`${key}\``).join(", ")} ${unsupported.length === 1 ? "is" : "are"} not available to an Agent call made inside a subagent: a nested agent runs blocking, in this agent's directory, on its definition's model. Call again without ${unsupported.length === 1 ? "it" : "them"}.`);
1491
1565
  const agentDef = agents.find((a) => a.name.toLowerCase() === agentName.toLowerCase());
1492
1566
  if (!agentDef) {
1493
- const catalog = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`).join("\n");
1494
- return err(`${agentName ? `Unknown agent "${agentName}"` : "`subagent_type` is required"}. Available agents:\n${catalog}`);
1567
+ return err(`${agentName ? `Unknown agent "${agentName}"` : "`subagent_type` is required"}. Available agents:\n${catalogLines(agents).join("\n")}`);
1495
1568
  }
1496
1569
  if (!task) return err("`task` is required: a complete, self-contained instruction for the agent (Claude Code names this parameter `prompt` — either is accepted).");
1497
1570
 
@@ -1608,7 +1681,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1608
1681
  if (!resident || resident.handle.exited()) return;
1609
1682
  fire.delivered = true;
1610
1683
  // A send that fails after all ends the job, as a fire to an ended agent does.
1611
- void resident.handle.send(fire.prompt).catch(() => {
1684
+ void resident.handle.send(fire.text(resident.handle.conversation)).catch(() => {
1612
1685
  pi.events.emit(AGENT_CRON_CHANNEL, { op: "delete", agentId: fire.agentId, id: fire.jobId } satisfies AgentCronRequest);
1613
1686
  });
1614
1687
  });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ChildAction } from "../auto-mode/actions.ts";
8
+ import type { FireConversation } from "../lib/agent-cron.ts";
8
9
  import type { UsageTotals } from "./usage.ts";
9
10
 
10
11
  export const OUTPUT_CAP = 50_000;
@@ -72,6 +73,8 @@ export interface RpcChildHandle {
72
73
  * the caller never reports a delivery that did not happen.
73
74
  */
74
75
  send(message: string): Promise<"started" | "steered">;
76
+ /** The agent's own session: a cron fire's `/loop` sentinel reads and records its delivery there (lib/agent-cron.ts). */
77
+ conversation: FireConversation;
75
78
  busy(): boolean;
76
79
  exited(): boolean;
77
80
  /**
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A fork's permission mode, told as the parent's (lifecycle-grid-hunt round 3,
3
+ * R3-L15). A fork's tool calls are checked by the parent's gate, in the
4
+ * parent's live mode, but the fork has no permissions or plan-mode extension
5
+ * of its own: its history shows the mode only as the sticky block it
6
+ * inherited at spawn. So the mode is live state with a told fact, like every
7
+ * capability owner's: at each request the fork renders, a mode that differs
8
+ * from what the fork was told (or, before any notice, from the mode its
9
+ * branch recorded at spawn) is announced on the tail, once. An epoch's request
10
+ * always tells it: an epoch renders the history from the stored messages, which
11
+ * hold neither the inherited block nor an earlier notice.
12
+ *
13
+ * An inline child extension; the getter reads the parent's live mode.
14
+ */
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { readState } from "../lib/ledger/state.ts";
17
+ import { RENDER_CHANNEL, type RenderRequest, toldBlockFact } from "../lib/reminders.ts";
18
+ import { PERMISSION_MODE_STATE } from "../permissions/modes.ts";
19
+ import { normalizePermissionMode } from "../permissions/settings.ts";
20
+
21
+ /** The told fact's key. */
22
+ const PARENT_MODE_FACT = "parent-permission-mode";
23
+
24
+ export function parentModeText(mode: string): string {
25
+ const plan = mode === "plan" ? " Only read-only tools run now: do not edit files or change state." : "";
26
+ return `The main session's permission mode is now "${mode}", and your tool calls are checked against it from here on.${plan}`;
27
+ }
28
+
29
+ export function parentModeExtension(getMode: () => string) {
30
+ return (pi: ExtensionAPI) => {
31
+ /** The mode the fork's branch recorded: the parent's at spawn, which the inherited history shows. */
32
+ let atSpawn: string | undefined;
33
+ pi.on("session_start", (_event, ctx) => {
34
+ atSpawn = normalizePermissionMode(readState(ctx, PERMISSION_MODE_STATE));
35
+ });
36
+ pi.events.on(RENDER_CHANNEL, (data) => {
37
+ const request = data as RenderRequest;
38
+ const live = getMode();
39
+ const fact = toldBlockFact(PARENT_MODE_FACT, live);
40
+ // The fork has no stack block to carry the mode, so an epoch tells it again.
41
+ if (request.epoch) {
42
+ request.blocks.push({ text: parentModeText(live), facts: [fact] });
43
+ return;
44
+ }
45
+ const told = request.toldBlocks.get(PARENT_MODE_FACT);
46
+ if (told === fact.hash || (told === undefined && atSpawn === live)) return;
47
+ request.blocks.push({ text: parentModeText(live), facts: [fact] });
48
+ });
49
+ };
50
+ }
@@ -28,6 +28,23 @@ export interface PendingRun {
28
28
  taskId: string;
29
29
  }
30
30
 
31
+ /** The runs by name and task id, comma-separated. */
32
+ function namedRuns(runs: readonly PendingRun[]): string {
33
+ return runs.map((run) => `${run.name} (task ${run.taskId})`).join(", ");
34
+ }
35
+
36
+ /**
37
+ * The background agents still working when a compaction summarized the calls
38
+ * that started them: the new epoch's history no longer shows them, so a model
39
+ * that is not told could start the same work again or report a result it
40
+ * never got (lifecycle-grid-hunt round 3, R3-M27; Claude Code re-attaches its
41
+ * task status after a compaction).
42
+ */
43
+ export function runningAfterCompactionReminder(runs: readonly PendingRun[]): string | undefined {
44
+ if (runs.length === 0) return undefined;
45
+ return `Background agents you started before this conversation was compacted are still running: ${namedRuns(runs)}. Each one notifies you when it finishes; do not start the same work again. Use task_output to check on one, or task_stop to end it.`;
46
+ }
47
+
31
48
  /**
32
49
  * The one-shot for a turn that ended with runs still pending, or undefined when
33
50
  * there is nothing to say. Named runs, so a model with several in flight knows
@@ -35,7 +52,7 @@ export interface PendingRun {
35
52
  */
36
53
  export function pendingClaimReminder(runs: readonly PendingRun[]): string | undefined {
37
54
  if (runs.length === 0) return undefined;
38
- const named = runs.map((run) => `${run.name} (task ${run.taskId})`).join(", ");
55
+ const named = namedRuns(runs);
39
56
  const subject = runs.length === 1 ? "It has" : "They have";
40
57
  return (
41
58
  `You ended that turn while ${named} ${runs.length === 1 ? "was" : "were"} still running. ${subject} not reported yet, ` +