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.
- package/README.md +1 -1
- package/docs/guide/differences-from-claude-code.md +9 -6
- package/docs/guide/installation.md +3 -4
- package/docs/guide/troubleshooting.md +1 -1
- package/extensions/auto-mode/denials.ts +3 -2
- package/extensions/background/cron.ts +127 -0
- package/extensions/background/index.ts +135 -47
- package/extensions/background/loop-fire.ts +39 -2
- package/extensions/background/switch-guard.ts +10 -6
- package/extensions/background/wakeup.ts +21 -2
- package/extensions/bash/index.ts +8 -30
- package/extensions/btw/index.ts +32 -18
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-context/index.ts +157 -172
- package/extensions/compaction/index.ts +11 -8
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +15 -14
- package/extensions/context-management/index.ts +11 -28
- package/extensions/effort/index.ts +70 -7
- package/extensions/file-tracker/index.ts +111 -44
- package/extensions/file-tracker/replay.ts +76 -49
- package/extensions/file-tracker/restore.ts +12 -9
- package/extensions/file-tracker/tracker.ts +14 -1
- package/extensions/hooks/index.ts +60 -76
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/trust.ts +13 -7
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +17 -1
- package/extensions/lib/agent-loader.ts +3 -2
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/child-extensions.ts +14 -4
- package/extensions/lib/context-facts.ts +75 -56
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +152 -12
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +24 -6
- package/extensions/lib/model-catalog/huggingface.json +5 -1
- package/extensions/lib/model-catalog/models-dev.json +10535 -10299
- package/extensions/lib/model-catalog/openrouter.json +191 -393
- package/extensions/lib/notifications.ts +57 -17
- package/extensions/lib/pi-version.ts +25 -8
- package/extensions/lib/plan-mode-channels.ts +29 -7
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +3 -2
- package/extensions/lib/reminders.ts +327 -621
- package/extensions/lib/request-replay.ts +186 -78
- package/extensions/lib/side-call.ts +11 -0
- package/extensions/lib/subagent-handback.ts +0 -1
- package/extensions/lib/tool-additions.ts +2 -22
- package/extensions/lib/turn-budget-layout.ts +18 -2
- package/extensions/lsp/index.ts +6 -8
- package/extensions/mcp/announce.ts +63 -98
- package/extensions/mcp/index.ts +51 -90
- package/extensions/mcp/trust.ts +13 -6
- package/extensions/model-default/index.ts +86 -19
- package/extensions/permissions/index.ts +157 -48
- package/extensions/permissions/modes.ts +5 -0
- package/extensions/plan-mode/index.ts +142 -45
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plugins/index.ts +4 -1
- package/extensions/powershell/index.ts +2 -0
- package/extensions/recap/index.ts +11 -14
- package/extensions/search-tools/index.ts +2 -0
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/index.ts +65 -48
- package/extensions/skill/invoke.ts +0 -67
- package/extensions/subagents/agents.ts +30 -8
- package/extensions/subagents/fork-cache.ts +9 -3
- package/extensions/subagents/index.ts +180 -107
- package/extensions/subagents/outcome.ts +3 -0
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +29 -2
- package/extensions/subagents/runs.ts +5 -0
- package/extensions/system-prompt/index.ts +40 -6
- package/extensions/system-prompt/template.ts +10 -1
- package/extensions/system-reminder/index.ts +174 -202
- package/extensions/tool-search/index.ts +241 -167
- package/extensions/workflow/agent-session.ts +4 -1
- package/extensions/workflow/index.ts +16 -4
- package/extensions/workflow/run-manager.ts +4 -0
- package/extensions/worktree/index.ts +50 -14
- package/package.json +5 -2
- package/extensions/lib/context-stack.ts +0 -81
- package/extensions/subagents/announce.ts +0 -69
- 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 {
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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 =
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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(
|
|
633
|
-
|
|
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
|
-
*
|
|
639
|
-
*
|
|
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
|
|
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
|
-
//
|
|
661
|
-
|
|
662
|
-
if (
|
|
663
|
-
|
|
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.
|
|
691
|
-
*
|
|
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
|
|
694
|
-
const
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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(
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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, ` +
|