pi-jar 0.2.2 → 0.2.3

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/src/delegate.ts CHANGED
@@ -28,7 +28,8 @@ import {
28
28
  export const DELEGATE_TOOL = "jar_delegate";
29
29
  /** Set in child processes so a subagent never delegates again. */
30
30
  export const CHILD_ENV = "PI_JAR_CHILD";
31
- export const MAX_DELEGATES = 4;
31
+ /** Retained pool when no limit is configured; matches the settings default (the opt-in Swarm profile uses 4). */
32
+ export const MAX_DELEGATES = 2;
32
33
  export const SUBAGENT_LIMIT_CHOICES = [2, 4, 6, 8, 16] as const;
33
34
  /** Read-only subagents also keep jar_todo so their checklist shows in the activity view. */
34
35
  export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
@@ -58,6 +59,8 @@ const STATUS_REFRESH_MS = 20_000;
58
59
  const LIVE_UPDATE_MS = 1000;
59
60
  /** Visible message that wakes the moderator when a resumed subagent's turn ends. */
60
61
  export const SUBAGENT_MESSAGE = "pi-jar.subagent";
62
+ /** An event carries at most this much of a report; the full report stays one `jar_subagent report` away. */
63
+ export const EVENT_SUMMARY_CHARS = 400;
61
64
  const NOTIFY_COALESCE_MS = 250;
62
65
  const MAX_EVENT_TEXT = 16_000;
63
66
  /** Transcript entries kept per run; tool output and text are clipped so a run stays well under 2 MiB. */
@@ -204,7 +207,7 @@ export function delegatePrompt(task: string, write: boolean, mode: DelegateExecu
204
207
  "Track multi-step work with jar_todo (when available) so your progress is visible. The moderator may steer, pause or resume you; preserve scope across those controls.",
205
208
  "Use jar_discuss when available for short cross-agent questions and answers. Keep discussion messages narrow; it is for coordination, not a transcript.",
206
209
  "Finish with a self-contained report in these sections:",
207
- "## Summary — the outcome in one to three sentences.",
210
+ `## Summary — the outcome in one to three sentences, under ${EVENT_SUMMARY_CHARS} characters: the moderator's notification shows only this.`,
208
211
  "## Details — findings or changes, with file paths (and line numbers where useful).",
209
212
  "## Verification — what you checked or ran, and the results.",
210
213
  "## Open issues — risks, unknowns and follow-ups, or \"none\".",
@@ -217,6 +220,22 @@ export function delegatePrompt(task: string, write: boolean, mode: DelegateExecu
217
220
  const cleanBlock = (value: string, limit: number): string => value.slice(0, limit * 2)
218
221
  .replace(ANSI, "").replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, " ").replace(/\n{3,}/g, "\n\n").trim().slice(0, limit);
219
222
 
223
+ /** One line of at most EVENT_SUMMARY_CHARS, marked with … when clipped. */
224
+ const summaryLine = (text: string): string => {
225
+ const line = cleanText(text, EVENT_SUMMARY_CHARS + 1);
226
+ return line.length > EVENT_SUMMARY_CHARS ? line.slice(0, EVENT_SUMMARY_CHARS - 1) + "…" : line;
227
+ };
228
+ const SUMMARY_HEADING = /^\s*#{1,6}\s*summary\b[\s:—–-]*(.*)$/i;
229
+ /** What an event shows of a report: its `## Summary` section (its opening when the child skipped the format) on one line. */
230
+ export function eventSummary(report: string): string {
231
+ const lines = report.split("\n");
232
+ const start = lines.findIndex((line) => SUMMARY_HEADING.test(line));
233
+ if (start < 0) return summaryLine(report);
234
+ const end = lines.findIndex((line, index) => index > start && /^\s*#{1,6}\s/.test(line));
235
+ const section = [SUMMARY_HEADING.exec(lines[start]!)![1]!, ...lines.slice(start + 1, end < 0 ? undefined : end)].join(" ");
236
+ return summaryLine(section.trim() ? section : report);
237
+ }
238
+
220
239
  /** Whether `promise` settles within `ms`; the timer never keeps the process alive. */
221
240
  const settlesWithin = (promise: Promise<unknown>, ms: number): Promise<boolean> => {
222
241
  let timer: NodeJS.Timeout | undefined;
@@ -831,7 +850,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
831
850
  try {
832
851
  // Cross a settled boundary so the active task's report cannot answer the question.
833
852
  if (run.state === "working" && !await pause()) return undefined;
834
- const instruction = "Moderator BTW: answer only this question in one compact paragraph. Preserve your assigned task scope for the next resume. Question: " + question;
853
+ const instruction = `Moderator BTW: answer only this question in one compact paragraph under ${EVENT_SUMMARY_CHARS} characters. Preserve your assigned task scope for the next resume. Question: ` + question;
835
854
  if (!beginPrompt(instruction, wasPaused)) return undefined;
836
855
  const answered = await settlesWithin(waitSettled(), 2 * 60_000);
837
856
  if (!answered) { void pause(); return undefined; }
@@ -1162,16 +1181,16 @@ export function runReport(run: DelegateRun, now = Date.now(), outputLimit = MAX_
1162
1181
  const DelegateModeSchema = Type.Union([Type.Literal("scout"), Type.Literal("fork"), Type.Literal("worktree")]);
1163
1182
  const Parameters = Type.Object({
1164
1183
  tasks: Type.Array(Type.Object({
1165
- task: Type.String({ description: "Task instruction. scout sees only this task; fork/worktree also inherit the parent's active conversation branch." }),
1184
+ task: Type.String({ description: "Task instruction. scout sees only this; fork/worktree also inherit your conversation." }),
1166
1185
  name: Type.Optional(Type.String({ description: "Short label, e.g. \"auth scout\"." })),
1167
- role: Type.Optional(Type.String({ description: "pi-jar role override. Defaults by mode: scout→scout, fork→reviewer, worktree→worker; then role fallbacks/current model." })),
1186
+ role: Type.Optional(Type.String({ description: "pi-jar role override; defaults by mode (scout→scout, fork→reviewer, worktree→worker), then role fallbacks/current model." })),
1168
1187
  mode: Type.Optional(DelegateModeSchema),
1169
1188
  tools: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 80 }), { minItems: 1, maxItems: 64, uniqueItems: true,
1170
- description: "Explicit child tool allowlist. scout/fork remain read-only; worktree accepts only sandbox-aware file tools. Browser/web tools may be enabled when active in the parent." })),
1171
- inheritTools: Type.Optional(Type.Boolean({ description: "Inherit the parent's currently active tool names, subject to mode safety restrictions. Recursive pi-jar delegation tools are always removed." }))
1189
+ description: "Explicit child tool allowlist; grant narrowly. scout/fork stay read-only; worktree accepts only path-guarded file tools; web/browser tools only when active here." })),
1190
+ inheritTools: Type.Optional(Type.Boolean({ description: "Inherit your active tools, filtered by mode safety; recursive delegation tools are always removed." }))
1172
1191
  }), { minItems: 1, maxItems: 16 }),
1173
1192
  mode: Type.Optional(DelegateModeSchema),
1174
- write: Type.Optional(Type.Boolean({ description: "Deprecated compatibility switch. true keeps the old shared-workspace editing mode; prefer mode=\"worktree\"." }))
1193
+ write: Type.Optional(Type.Boolean({ description: "Deprecated: true = legacy shared-workspace editing; prefer mode=\"worktree\"." }))
1175
1194
  });
1176
1195
 
1177
1196
  const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "stopped" ? "■" : state === "working" ? "●" : "○";
@@ -1200,7 +1219,7 @@ export interface DelegateController {
1200
1219
  export interface DelegateOptions {
1201
1220
  /** Snapshot names of tools active in the parent session. */
1202
1221
  getActiveToolNames?: () => readonly string[];
1203
- /** Allowed sizes: 2, 4, 6, 8, 16; defaults to 4. Read on every launch. */
1222
+ /** Allowed sizes: 2, 4, 6, 8, 16; defaults to MAX_DELEGATES. Read on every launch. */
1204
1223
  getMaxSubagents?: () => number;
1205
1224
  /** Shares the delegate lifecycle and capacity with scout orchestration. */
1206
1225
  onController?: (controller: DelegateController) => void;
@@ -1215,6 +1234,8 @@ export interface DelegateOptions {
1215
1234
  discussionRetire?: (key: string) => void;
1216
1235
  /** Unread discussion messages waiting for an agent, shown in the moderator's fleet line. */
1217
1236
  discussionUnread?: (key: string) => number;
1237
+ /** The moderator's unread discussion mail as one notice, marked read; it rides along with subagent events. */
1238
+ discussionDigest?: () => string | undefined;
1218
1239
  /** Settled quiet time before a retained child's process hibernates; defaults to HIBERNATE_AFTER_MS. */
1219
1240
  hibernateAfterMs?: number;
1220
1241
  }
@@ -1227,21 +1248,16 @@ const remainingTasks = (run: DelegateRun): string[] => {
1227
1248
  return leaves.filter((todo) => todo.status !== "completed").map((todo) => todo.title).slice(0, 20);
1228
1249
  };
1229
1250
 
1230
- const taskLabel = (task: string): string => {
1231
- const text = cleanText(task, 4096);
1232
- return text.length > 160 ? text.slice(0, 159) + "…" : text;
1233
- };
1234
-
1235
- const controlReport = (report: SubagentStopReport): string => [
1251
+ /** The stop handoff, compact like every event: summary, what changed and reached /diff, what is left. `kept` is
1252
+ * the workspace still holding unapplied changes, if any; a reconciled or discarded worktree is gone. */
1253
+ const controlReport = (report: SubagentStopReport, kept: string | undefined): string => [
1236
1254
  `${report.key} · ${report.name} · ${report.state}`,
1237
- // The moderator already holds the full task in its own jar_delegate call; echo only a label.
1238
- `task: ${taskLabel(report.task)}`,
1239
- `workspace: ${report.workspace ?? "none"}`,
1240
- `changed: ${report.changed.length ? report.changed.join(", ") : "none"}`,
1241
- `applied: ${report.applied.length ? report.applied.join(", ") : "none"}`,
1242
- `remaining: ${report.remaining.length ? report.remaining.join("; ") : "none explicitly tracked"}`,
1243
- report.error ? `error: ${report.error}` : "",
1244
- report.last ? `last: ${cleanBlock(report.last, 1200)}` : ""
1255
+ `summary: ${report.last ? eventSummary(report.last) : "(no report)"}`,
1256
+ `changed: ${report.changed.length ? fileList(report.changed, 10) : "none"} · applied: ${report.applied.length ? fileList(report.applied, 10) : "none"}`,
1257
+ kept ? `workspace kept: ${kept} (stop retries reconciliation; discard drops it)` : "",
1258
+ report.remaining.length ? `remaining: ${report.remaining.slice(0, 3).map((title) => cleanText(title, 80)).join("; ")}`
1259
+ + (report.remaining.length > 3 ? ` (+${report.remaining.length - 3} more)` : "") : "",
1260
+ report.error ? `error: ${cleanText(report.error, 300)}` : ""
1245
1261
  ].filter(Boolean).join("\n");
1246
1262
 
1247
1263
  /** Peek is task progress only, read from memory: no git, transcript or report reads. */
@@ -1266,9 +1282,19 @@ const peekRecord = (record: SubagentRecord): string => {
1266
1282
  ] : [])
1267
1283
  ].filter(Boolean).join("\n");
1268
1284
  };
1269
- /** The wake-up after a resumed turn: its progress plus the bounded report the turn produced. */
1270
- const settledRecord = (record: SubagentRecord): string =>
1271
- peekRecord(record) + (record.run.output ? `\nlast: ${cleanBlock(record.run.output, 1200)}` : "");
1285
+ /** A settled turn as its event shows it: state and progress, the report's summary, changed files. */
1286
+ const turnEvent = (record: SubagentRecord): string => {
1287
+ const run = record.run;
1288
+ const leaves = leafTodos(run.todos);
1289
+ const done = leaves.filter((todo) => todo.status === "completed").length;
1290
+ return [
1291
+ `${record.key} · ${run.name} · ${run.state}${leaves.length ? ` · ${done}/${leaves.length} tasks` : ""}`,
1292
+ `summary: ${run.output ? eventSummary(run.output) : "(no report)"}`,
1293
+ run.filesEdited.length ? `changed: ${fileList(run.filesEdited, 10)}` : "",
1294
+ run.error ? `error: ${cleanText(run.error, 300)}` : "",
1295
+ isRecovery(record) ? `workspace kept: ${run.workspace} (stop retries reconciliation; discard drops it)` : ""
1296
+ ].filter(Boolean).join("\n");
1297
+ };
1272
1298
 
1273
1299
  /** One queued moderator notification; several coalesce into a single pi-jar.subagent message. */
1274
1300
  interface SubagentEvent { record: SubagentRecord; kind: "turn" | "control"; label: string; content: string; detail: Record<string, unknown> }
@@ -1314,6 +1340,10 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1314
1340
  try { return Math.max(0, Math.floor(options.discussionUnread?.(key) ?? 0)) || 0; }
1315
1341
  catch { return 0; } // the count is decoration; the fleet line must still render
1316
1342
  };
1343
+ const discussionDigest = (): string | undefined => {
1344
+ try { return options.discussionDigest?.(); }
1345
+ catch (error) { console.error("pi-jar: could not read the moderator's discussion mail", error); return undefined; }
1346
+ };
1317
1347
  const retireDiscussion = (key: string) => {
1318
1348
  try { options.discussionRetire?.(key); } catch (error) { console.error("pi-jar: could not retire a subagent's discussion identity", error); }
1319
1349
  };
@@ -1328,10 +1358,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1328
1358
  }).join("\n");
1329
1359
  return { message: { customType: "pi-jar.moderator-context", display: false, content: [
1330
1360
  "[PI-JAR MODERATOR MODE]",
1331
- "You are the coordinator while retained subagents do delegated work. Decompose and route work, steer only to correct direction, use ask for terse BTW questions, and use jar_discuss for structured cross-agent Q/A.",
1332
- "Initial/resumed turns and asynchronous ask/pause/stop operations wake you with pi-jar.subagent events. Accepted receipts are not completion: do not poll peek or claim files applied before the final event. Keep responding to user steering or do other work.",
1333
- "Do not duplicate work already owned by a retained subagent. Stop completed workers to obtain their handoff and reconcile worktree changes into /diff. Synthesize the final answer from their reports and evidence.",
1334
- "Hibernated agents are idle with their process closed to save memory; resume and ask relaunch them with context and workspace intact.",
1361
+ "Retained subagents own their delegated tasks: don't duplicate their work. Their turn ends and ask/pause/stop results arrive as pi-jar.subagent events; an accepted receipt is not completion, so keep working and never poll peek.",
1362
+ "Hibernated agents are idle with the process closed; resume and ask relaunch them intact. Stop finished workers to reconcile worktree changes into /diff, then synthesize from their reports.",
1335
1363
  "Retained fleet:",
1336
1364
  fleet
1337
1365
  ].join("\n") } };
@@ -1370,9 +1398,12 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1370
1398
  pendingEvents.clear();
1371
1399
  if (!events.length) return;
1372
1400
  const summary = cleanText(`Subagents · ${events.map(event => event.label).join(" · ")}`, 160);
1401
+ // Discussion mail rides along with events delivered anyway; on its own it never wakes the moderator.
1402
+ const mail = discussionDigest();
1373
1403
  return {
1374
1404
  customType: SUBAGENT_MESSAGE, display: true, details: { summary, events: events.map(event => event.detail) },
1375
- content: [`[PI-JAR SUBAGENT] ${summary}`, ...events.map(event => event.content), "Review, then resume, ask or stop retained agents as needed."].join("\n\n")
1405
+ content: [`[PI-JAR SUBAGENT] ${summary}`, ...events.map(event => event.content), ...(mail ? [mail] : []),
1406
+ "Summaries only: jar_subagent report <agent> returns a full report. Resume, ask or stop retained agents as needed."].join("\n\n")
1376
1407
  };
1377
1408
  };
1378
1409
  const scheduleWake = () => {
@@ -1396,7 +1427,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1396
1427
  pi.registerMessageRenderer?.<{ summary?: string }>(SUBAGENT_MESSAGE, (message, { expanded }, theme) => {
1397
1428
  const text = typeof message.content === "string" ? message.content : "Subagents";
1398
1429
  const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Subagents";
1399
- return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for report"), 0, 0);
1430
+ return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for details"), 0, 0);
1400
1431
  });
1401
1432
  pi.on?.("agent_start", () => { moderatorActive = true; moderatorInterrupted = false; clearTimeout(wakeTimer); wakeTimer = undefined; });
1402
1433
  pi.on?.("turn_end", boundary);
@@ -1413,7 +1444,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1413
1444
  // Freeze reports at the settled boundary. A fast resume must not overwrite a queued
1414
1445
  // initial report, and multiple turns before delivery must each be delivered.
1415
1446
  queueEvent(`${key}:${record.run.resumes}`, { record, kind: "turn", label: `${record.run.name} turn ended`,
1416
- content: settledRecord(record), detail: { key, turn: record.run.resumes } });
1447
+ content: turnEvent(record), detail: { key, turn: record.run.resumes } });
1417
1448
  }
1418
1449
  }
1419
1450
  });
@@ -1421,14 +1452,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1421
1452
  const delegateTool: ToolDefinition<typeof Parameters> = {
1422
1453
  name: DELEGATE_TOOL,
1423
1454
  label: "delegate",
1424
- description: `Start retained session-scoped subagents within the configured retained pool limit and return immediately. scout is fresh/read-only and defaults to the scout role; fork inherits the parent conversation read-only and defaults to reviewer; worktree inherits context, defaults to worker, and edits in an isolated Git worktree. Each task may provide an explicit tools allowlist or inheritTools=true to filter the parent's active tools by mode. Turn completions arrive as pi-jar.subagent event messages; control workers with jar_subagent. Settled agents hibernate (process closed, context kept) until resumed. Worktree changes apply only when the moderator stops that agent; at most ${RECOVERY_LIMIT} worktree workspaces may be active or awaiting recovery at once.`,
1425
- promptSnippet: "Act as moderator: delegate parallel work, then continue responding to the user while subagents run. Their turn completions arrive as pi-jar.subagent event messages; steer, pause or stop them with jar_subagent without polling.",
1455
+ description: `Start retained subagents and return immediately; the pool limit counts idle, paused and hibernated agents. fork and worktree inherit this conversation; a worktree's isolated Git changes reach /diff only when you stop it, and at most ${RECOVERY_LIMIT} worktree workspaces may be active or awaiting recovery (resolve with jar_subagent stop or discard). Turn completions arrive as pi-jar.subagent events with a short summary and changed files; jar_subagent report returns a full report. Settled agents hibernate (process closed, context kept) until resumed.`,
1456
+ promptSnippet: "Start retained parallel subagents: scout (cheap read-only discovery), fork (read-only review with your context), worktree (isolated implementation).",
1426
1457
  promptGuidelines: [
1427
- "When useful work can be delegated, act as the moderator: decompose, assign, monitor, resolve disagreements, and synthesize. Do not redo a delegated implementation yourself while its worker is active.",
1428
- "Use scout for cheap independent discovery, fork/reviewer for context-aware review, and worktree/worker for implementation. Explicit task.role overrides these mode defaults. Grant optional task.tools narrowly; task.inheritTools snapshots active parent tools then filters by mode. Scout/fork stay read-only; worktree only accepts path-guarded tools.",
1429
- "Retained agents become idle after each turn and hibernate after a quiet period; resume and ask relaunch them with their context. Initial and resumed turn completions arrive as pi-jar.subagent event messages, so keep responding to user steering while they work; do not poll jar_subagent peek. Reuse them with jar_subagent resume instead of spawning replacements; pause them when priorities change; stop completed workers to reconcile their worktree into /diff.",
1430
- "Use jar_subagent ask for a brief BTW question to one worker. For agent-to-agent questions, have them use jar_discuss so answers stay structured and cheap.",
1431
- "Respect the configured pool limit, including idle, paused and hibernated agents. Stop agents you no longer need; resolve failed worktree recovery records (stop to retry, discard to drop) before starting more worktree agents."
1458
+ "Delegate only when there are ≥2 independent chunks that each need ≥10 tool calls; otherwise do the work yourself.",
1459
+ "After delegating, moderate: completions arrive as pi-jar.subagent events, so keep working and never poll; don't redo delegated work; reuse retained agents (jar_subagent resume) instead of spawning; stop finished workers to apply worktree changes into /diff."
1432
1460
  ],
1433
1461
  parameters: Parameters,
1434
1462
  async execute(_id, params, signal, onUpdate, ctx) {
@@ -1805,7 +1833,10 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1805
1833
  signal?.removeEventListener("abort", abortAll);
1806
1834
  publish();
1807
1835
  toolReturned = true;
1808
- return { content: [{ type: "text", text: runs.map((run) => runReport(run)).join("\n\n") + "\n\nSubagent launch is asynchronous; turn reports arrive as pi-jar.subagent events. You can continue working or steer the fleet with jar_subagent." }], details: details() };
1836
+ return { content: [{ type: "text", text: [
1837
+ `Subagent launch is asynchronous: ${plural(records.length, "agent")} started; turn reports arrive as pi-jar.subagent events. Keep working or steer with jar_subagent; don't poll.`,
1838
+ ...records.map(({ key, run }) => `- ${key} · ${run.name} · ${run.mode} · ${run.role}${run.model ? " · " + run.model : ""}`)
1839
+ ].join("\n") }], details: details() };
1809
1840
  }
1810
1841
  await startup;
1811
1842
  publish();
@@ -1866,7 +1897,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1866
1897
  if (!launched) throw new Error(result.content.map((part) => "text" in part ? part.text : "").join("\n"));
1867
1898
  const record = registry.get(`delegate-${launched}-1`);
1868
1899
  if (!record) throw new Error("Scout session was discarded");
1869
- // This launch is an internal democracy vote; its caller is already awaiting the result,
1900
+ // This launch is an internal council vote; its caller is already awaiting the result,
1870
1901
  // so the moderator does not need a duplicate unsolicited completion notification.
1871
1902
  watched.delete(record.key);
1872
1903
  deleteSettled(record.key);
@@ -1920,21 +1951,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1920
1951
  const status = result.isError ? "failed" : "completed";
1921
1952
  // The stop handoff supersedes any queued turn report for the same agent.
1922
1953
  if (operation.action === "stop") deleteSettled(record.key);
1954
+ const text = result.content.map(part => part.text).join("\n");
1923
1955
  queueEvent(operation.id, { record, kind: "control", label: `${operation.action} ${record.run.name} ${status}`,
1924
- content: `${operation.action} ${operation.id} ${status}:\n${result.content.map(part => part.text).join("\n")}`,
1956
+ // A stop handoff is already compact; any other result (a BTW answer, an error) is clipped like a summary.
1957
+ content: `${operation.action} ${operation.id} ${status}:\n${operation.action === "stop" ? text : summaryLine(text)}`,
1925
1958
  detail: { operationId: operation.id, action: operation.action, key: record.key, status, report: result.details } });
1926
1959
  };
1927
1960
 
1928
1961
  pi.registerTool({
1929
1962
  name: "jar_subagent",
1930
1963
  label: "subagent",
1931
- description: "Non-blocking moderator control for retained subagents. peek, steer, resume and discard return immediately. ask, pause and stop return an accepted operation receipt; the final answer or handoff arrives as a pi-jar.subagent event. Settled agents hibernate (process closed, context and workspace kept); resume and ask relaunch them transparently. Stop completion safely reconciles worktree changes; acceptance does not mean changes are applied yet. discard removes an unresolved worktree recovery workspace without applying it.",
1932
- promptSnippet: "Use jar_subagent as the moderator control plane. Prefer peek over rereading transcripts; reuse paused/idle/hibernated agents with resume; stop finished workers to reconcile their work.",
1964
+ description: "Non-blocking control for retained subagents. peek: task progress only (plus workspace and changed files of an unresolved worktree recovery). report: an agent's latest full report, for retained and recently finished agents. steer: redirect a working agent. resume: continue an idle, paused or hibernated agent (hibernated ones relaunch transparently). ask, pause and stop return an accepted receipt; the answer or handoff arrives as a pi-jar.subagent event. stop aborts any active turn, retires the agent, reports changes and remaining work, and reconciles worktree changes into /diff (on a failed recovery record it retries). discard removes an unresolved recovery workspace without applying it.",
1965
+ promptSnippet: "Moderator control for retained subagents: peek, report, steer, ask, pause, resume, stop, discard.",
1933
1966
  promptGuidelines: [
1934
- "peek returns only task progress, plus workspace and changed files for unresolved worktree recovery records. report returns an agent's latest full report when a result or event shows only part of it. Do not poll: initial/resumed turns and control completions send pi-jar.subagent events. Stop returns an accepted receipt first, then changed files and the full handoff in its completion event.",
1935
- "Use steer for active direction and ask for a brief BTW question. ask, pause and stop do not wait on the child; continue responding to the user until their completion event arrives.",
1936
- "Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement; a hibernated agent resumes exactly like an idle one.",
1937
- "Stop when an agent is no longer needed. Stop aborts any active turn, retires the process, reports progress/workspace/changes/remaining work, and reconciles safe worktree changes into /diff. On a failed/conflicted worktree recovery record, stop retries reconciliation; discard it only when its changes are not wanted."
1967
+ "Results arrive as pi-jar.subagent events; never poll. Reuse idle/paused/hibernated agents with resume instead of spawning; ask = one brief BTW question; pause keeps context without spending; read report only when an event summary is not enough."
1938
1968
  ],
1939
1969
  parameters: Type.Object({
1940
1970
  action: Type.Union([
@@ -2005,7 +2035,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
2005
2035
  }
2006
2036
  const report = await registry.stop(record.key);
2007
2037
  if (!report) return { content: [{ type: "text", text: "Subagent is already retired: " + record.run.name }] };
2008
- return { content: [{ type: "text", text: controlReport(report) }], details: report, isError: !!report.error };
2038
+ return { content: [{ type: "text", text: controlReport(report, record.run.workspace) }], details: report, isError: !!report.error };
2009
2039
  };
2010
2040
  // Internal registry APIs remain awaited for shutdown/reconciliation correctness. Only the
2011
2041
  // external tool boundary is detached, so parent steering never waits on a child turn or Git.
@@ -14,6 +14,8 @@ const MAX_DISCUSSION_AGENTS = 64;
14
14
  /** A list reply never exceeds this, however many messages match; each listed message is clipped too. */
15
15
  export const MAX_LIST_CHARS = 4000;
16
16
  export const MAX_LISTED_TEXT = 400;
17
+ /** A turn-start notice carries at most this much unread mail; the rest stays one `list` away. */
18
+ export const MAX_NOTICE_CHARS = 2000;
17
19
  /** Unread mail piggybacked on an ask/answer reply never exceeds this. */
18
20
  const MAX_PIGGYBACK_CHARS = 1000;
19
21
  const BROADCAST: Record<string, true> = { all: true, "*": true, everyone: true, broadcast: true };
@@ -168,11 +170,11 @@ export class DiscussionHub {
168
170
 
169
171
  /** Unread mail, oldest first within the reply budget; the cursor moves past what was shown, so paging
170
172
  * loses nothing. `since` rereads relevant messages newer than an id, read or not. */
171
- list(key: string, since?: number): ListResult {
173
+ list(key: string, since?: number, budget = MAX_LIST_CHARS): ListResult {
172
174
  const agent = this.agent(key);
173
175
  const items = this.messages.filter((message) => this.relevant(agent, message)
174
176
  && (since === undefined ? !this.read(agent, message) : message.id > since));
175
- const { views, shown } = this.select(items, MAX_LISTED_TEXT);
177
+ const { views, shown } = this.select(items, MAX_LISTED_TEXT, budget);
176
178
  this.markRead(agent, shown);
177
179
  const latest = this.messages.at(-1)?.id;
178
180
  return { messages: views, more: items.length - views.length, ...(latest ? { latest } : {}), unread: this.unreadOf(agent).length };
@@ -214,8 +216,9 @@ export class DiscussionHub {
214
216
  return { ...result, agents, unanswered: { total: open.length, questions } };
215
217
  }
216
218
 
217
- unread(key: string): UnreadResult {
218
- const unread = this.unreadOf(this.agent(key));
219
+ /** `except` leaves that sender's mail out, so the moderator's view of a subagent's mailbox never echoes its own messages. */
220
+ unread(key: string, except?: string): UnreadResult {
221
+ const unread = this.unreadOf(this.agent(key)).filter((message) => message.from !== except);
219
222
  return { unread: unread.length, ...(unread.length ? { newest: unread.at(-1)!.id } : {}) };
220
223
  }
221
224
 
@@ -334,13 +337,13 @@ export class DiscussionHub {
334
337
  }
335
338
 
336
339
  /** Oldest first into the budget; at least one message, so paging always progresses. */
337
- private select(items: readonly Stored[], limit: number): { views: MessageView[]; shown: Stored[] } {
340
+ private select(items: readonly Stored[], limit: number, budget = MAX_LIST_CHARS): { views: MessageView[]; shown: Stored[] } {
338
341
  const views: MessageView[] = [];
339
342
  let used = 0;
340
343
  for (const message of items) {
341
344
  const view = this.view(message, limit);
342
345
  const size = messageLine(view).length + 1;
343
- if (views.length && used + size > MAX_LIST_CHARS) break;
346
+ if (views.length && used + size > budget) break;
344
347
  views.push(view);
345
348
  used += size;
346
349
  }
package/src/discussion.ts CHANGED
@@ -6,8 +6,8 @@ import { join } from "node:path";
6
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
  import { Type } from "typebox";
8
8
  import {
9
- DiscussionError, DiscussionHub, MAX_DISCUSSION_BYTES, MAX_DISCUSSION_MESSAGES, MAX_DISCUSSION_TEXT, MAX_LIST_CHARS, messageLine,
10
- MODERATOR, parseDiscussionId, type ListResult, type PendingResult, type PostResult, type ThreadResult, type UnreadResult
9
+ DiscussionError, DiscussionHub, MAX_DISCUSSION_BYTES, MAX_DISCUSSION_MESSAGES, MAX_DISCUSSION_TEXT, MAX_LIST_CHARS, MAX_NOTICE_CHARS, messageLine,
10
+ MODERATOR, parseDiscussionId, type ListResult, type PendingResult, type PostResult, type ThreadResult
11
11
  } from "./discussion-hub.ts";
12
12
  import { cleanText } from "./status.ts";
13
13
 
@@ -44,7 +44,8 @@ export interface DiscussionTransport {
44
44
  list(since?: number): Promise<ListResult>;
45
45
  thread(questionId: number, since?: number): Promise<ThreadResult>;
46
46
  pending(): Promise<PendingResult>;
47
- unread(): Promise<UnreadResult>;
47
+ /** Unread mail for a turn-start notice, marked read: a list within the smaller notice budget. */
48
+ inbox(): Promise<ListResult>;
48
49
  }
49
50
 
50
51
  /** A 1600-character message JSON-escapes to well under this; anything larger is not from our client. */
@@ -65,7 +66,7 @@ const transportOf = (request: Request): DiscussionTransport => ({
65
66
  list: (since) => request("list", since === undefined ? {} : { since }) as Promise<ListResult>,
66
67
  thread: (questionId, since) => request("thread", since === undefined ? { questionId } : { questionId, since }) as Promise<ThreadResult>,
67
68
  pending: () => request("pending", {}) as Promise<PendingResult>,
68
- unread: () => request("unread", {}) as Promise<UnreadResult>
69
+ inbox: () => request("inbox", {}) as Promise<ListResult>
69
70
  });
70
71
 
71
72
  /** Shared by the tool adapter and the broker, so a bad id is rejected the same way on both sides. */
@@ -147,7 +148,12 @@ export class DiscussionBroker {
147
148
  this.notify();
148
149
  }
149
150
 
150
- unreadFor(key: string): number { return this.hub.has(key) ? this.hub.unread(key).unread : 0; }
151
+ /** Unread mail waiting for an agent, minus the moderator's own messages: the moderator's fleet line never
152
+ * echoes its broadcasts or questions back to it. */
153
+ unreadFor(key: string): number { return this.hub.has(key) ? this.hub.unread(key, MODERATOR).unread : 0; }
154
+
155
+ /** The moderator's unread mail as one notice, marked read, to ride along with subagent events delivered anyway. */
156
+ digest(): string | undefined { return inboxNotice(this.call(MODERATOR, "inbox", {}) as ListResult); }
151
157
 
152
158
  stats(): DiscussionStats {
153
159
  return {
@@ -254,7 +260,7 @@ export class DiscussionBroker {
254
260
  case "list": return this.hub.list(key, idArgument(args.since, "since", false));
255
261
  case "thread": return this.hub.thread(key, idArgument(args.questionId, "questionId", true)!, idArgument(args.since, "since", false));
256
262
  case "pending": return this.hub.pending(key);
257
- case "unread": return this.hub.unread(key);
263
+ case "inbox": return this.hub.list(key, undefined, MAX_NOTICE_CHARS);
258
264
  default: throw new DiscussionError("Unknown discussion operation.");
259
265
  }
260
266
  } catch (error) {
@@ -363,6 +369,16 @@ export function discussionClientFromEnv(env: NodeJS.ProcessEnv = process.env): D
363
369
 
364
370
  const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`;
365
371
 
372
+ /** Unread mail as one notice; it is marked read once fetched, so a message is never announced twice. */
373
+ export function inboxNotice(result: ListResult): string | undefined {
374
+ if (!result.messages.length) return undefined;
375
+ return [
376
+ "Discussion mail (now read; reply with jar_discuss answer):",
377
+ ...result.messages.map(messageLine),
378
+ ...(result.more ? [`(${plural(result.more, "more unread message")}; jar_discuss list)`] : [])
379
+ ].join("\n");
380
+ }
381
+
366
382
  export function registerDiscussionTool(pi: ExtensionAPI, transport: () => DiscussionTransport | undefined): void {
367
383
  const parameters = Type.Object({
368
384
  action: Type.Union([Type.Literal("ask"), Type.Literal("answer"), Type.Literal("list"), Type.Literal("thread"), Type.Literal("pending")]),
@@ -376,12 +392,8 @@ export function registerDiscussionTool(pi: ExtensionAPI, transport: () => Discus
376
392
  pi.registerTool?.<typeof parameters, Record<string, never>>({
377
393
  name: "jar_discuss",
378
394
  label: "discuss",
379
- description: `Parent-brokered Q/A between this session's moderator and subagents; not a transcript, and it never wakes anyone. ask/answer reply with the new id only. list returns only unread messages for you: questions to you, broadcasts, answers to your questions (≤${MAX_LIST_CHARS} chars).`,
380
- promptSnippet: "Ask or answer one concrete cross-agent question through the session discussion broker.",
381
- promptGuidelines: [
382
- "jar_discuss: one precise question or answer per call. ask to moderator, a subagent key or unique name, or omit to to broadcast; answer needs the questionId. Replies never echo your text.",
383
- "Answers to your questions also arrive on your next ask/answer. list shows only unread messages for you; thread reads one question in full; pending summarizes open items. Don't poll."
384
- ],
395
+ description: `Parent-brokered Q/A between this session's moderator and subagents; never wakes anyone. ask/answer reply with the new id (never your text) plus your unread mail when it fits. Unread mail (questions to you, broadcasts, answers to your questions; never your own) also arrives once at your next turn start. list: your unread (≤${MAX_LIST_CHARS} chars); thread: one question in full; pending: open counts.`,
396
+ promptSnippet: "One concise cross-agent question or answer per call; unread mail reaches you at turn start, so never poll.",
385
397
  parameters,
386
398
  async execute(_id, params) {
387
399
  const broker = transport();
@@ -441,19 +453,16 @@ export function registerDiscussionTool(pi: ExtensionAPI, transport: () => Discus
441
453
  }
442
454
  });
443
455
 
444
- // At a natural boundary only: a hidden notice joins the turn that is starting anyway. Discussion
445
- // traffic never sends a message or triggers a turn, so agents cannot wake each other into a loop.
446
- let notified: number | undefined;
456
+ // At a natural boundary only: unread mail joins the turn that is starting anyway, as one hidden message
457
+ // that already carries it, so reading it costs no list call. Discussion traffic never sends a message
458
+ // or triggers a turn, so agents cannot wake each other into a loop.
447
459
  pi.on?.("before_agent_start", async () => {
448
460
  const broker = transport();
449
461
  if (!broker) return;
450
- let state: UnreadResult;
462
+ let inbox: ListResult;
451
463
  // Advisory only: a broker failure surfaces on the agent's next jar_discuss call instead of delaying this turn.
452
- try { state = await broker.unread(); } catch { return; }
453
- // Once per newest unread message: ignored mail is not re-announced every turn.
454
- if (!state.unread || state.newest === notified) return;
455
- notified = state.newest;
456
- return { message: { customType: DISCUSSION_NOTICE, display: false,
457
- content: `You have ${plural(state.unread, "unread discussion message")}. Use jar_discuss list when relevant.` } };
464
+ try { inbox = await broker.inbox(); } catch { return; }
465
+ const content = inboxNotice(inbox);
466
+ if (content) return { message: { customType: DISCUSSION_NOTICE, display: false, content } };
458
467
  });
459
468
  }
package/src/footer.ts CHANGED
@@ -18,6 +18,8 @@ export interface FooterView extends JarStatus {
18
18
  cwd?: string;
19
19
  settings?: FooterSettings;
20
20
  context: string;
21
+ /** The last call's prompt is past the context budget: the context metric turns amber. */
22
+ overBudget?: boolean;
21
23
  goal?: string;
22
24
  /** pi-jar's own short indicators (changes to review), shown before extras. */
23
25
  chips?: readonly string[];
@@ -139,7 +141,7 @@ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits:
139
141
  const quota = view.quota;
140
142
  const windowList = [quota?.fiveHour && `5h ${Math.round(quota.fiveHour.used)}%`, quota?.week && `week ${Math.round(quota.week.used)}%`].filter((value): value is string => !!value);
141
143
  const windows = windowList.map((value, index) => index ? value : withIcon("quota", value));
142
- const cost = view.cost ? metric("cost", "cost", cleanText(view.cost, 20)) : undefined;
144
+ const cost = view.cost ? metric("cost", "cost", cleanText(view.cost, 32)) : undefined;
143
145
  const memory = view.memory ? metric("memory", "ram", view.memory) : undefined;
144
146
  const prefix = view.demo ? theme.fg("warning", "DEMO") : "";
145
147
  const effort = show.effort && view.effort ? theme.fg(EFFORT_COLORS[view.effort], THINKING_ICONS[view.effort] ? withIcon(THINKING_ICONS[view.effort]!, view.effort) : view.effort) : "";
@@ -151,6 +153,8 @@ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits:
151
153
  // Context is never dropped; optional metrics move to a second line on medium widths.
152
154
  const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && memory ? [memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
153
155
  let right = theme.fg("dim", metrics.join(" · "));
156
+ // Context always leads the metrics when shown, so only its own color changes past the budget.
157
+ if (view.overBudget && show.context) right = theme.fg("warning", context) + (metrics.length > 1 ? theme.fg("dim", " · " + metrics.slice(1).join(" · ")) : "");
154
158
  if (contentWidth < 52 && attention && contentWidth >= 26) {
155
159
  const room = Math.max(0, contentWidth - Math.min(visibleWidth(left), 12) - visibleWidth(right) - 4);
156
160
  right = theme.fg(primary?.state === "failed" ? "error" : "warning", truncateToWidth(attention, Math.min(room, 14))) + " " + right;
package/src/goal-loop.ts CHANGED
@@ -5,6 +5,7 @@ import type { Goal, GoalStore } from "./goals.ts";
5
5
  import type { ModelRoleManager } from "./model-roles.ts";
6
6
  import { isSafePlanCommand } from "./plan-utils.ts";
7
7
  import type { Todo, TodoStore } from "./tasks.ts";
8
+ import { setToolActive } from "./tool-activation.ts";
8
9
  import { projectWorkflowMessages } from "./workflow-context.ts";
9
10
 
10
11
  export const GOAL_TOOL = "jar_goal";
@@ -263,6 +264,8 @@ export class GoalLoop {
263
264
 
264
265
  this.pi.on("before_agent_start", async (_event, ctx) => {
265
266
  const goal = this.goal();
267
+ // jar_goal is only useful while a goal runs; inactive, it costs no schema or rules tokens.
268
+ setToolActive(this.pi, GOAL_TOOL, !!goal && goal.status !== "complete");
266
269
  if (!goal || goal.status === "complete") return;
267
270
  if (goal.status === "active" && !this.options.planActive()) await this.useRole(goal.phase === "audit" ? "advisor" : "implement", ctx);
268
271
  return { message: { customType: CONTEXT_TYPE, content: this.context(goal), display: false } };
@@ -12,7 +12,10 @@ const DEFAULT_RESERVE = 16_384;
12
12
  export interface InfoPanelDeps {
13
13
  side: SideUsage;
14
14
  quota(ctx: ExtensionContext): Quota | undefined;
15
+ quotaFailure(ctx: ExtensionContext): { error: string; retryInMs: number } | undefined;
15
16
  quotaEnabled(): boolean;
17
+ /** Start a limit lookup when nothing fresh is cached: opening `/usage` is an explicit request. */
18
+ refreshQuota(ctx: ExtensionContext): void;
16
19
  }
17
20
 
18
21
  function reserveTokens(ctx: ExtensionContext): number {
@@ -61,10 +64,12 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
61
64
  { name: "Usage", render: (width, fg) => {
62
65
  usage ??= collectUsage(sessionBranch(ctx) as never, deps.side.all());
63
66
  const quota = deps.quota(ctx);
67
+ const quotaFailure = quota ? undefined : deps.quotaFailure(ctx);
64
68
  return usageLines({
65
69
  stats: usage,
66
70
  ...(ctx.model?.provider ? { provider: ctx.model.provider } : {}),
67
71
  ...(quota ? { quota } : {}),
72
+ ...(quotaFailure ? { quotaFailure } : {}),
68
73
  quotaEnabled: deps.quotaEnabled(), now: Date.now()
69
74
  }, width, fg);
70
75
  } },
@@ -78,6 +83,7 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
78
83
  const open = async (ctx: ExtensionContext, tab: number) => {
79
84
  try {
80
85
  const pages = tabs(ctx);
86
+ if (tab === 0) deps.refreshQuota(ctx);
81
87
  if (ctx.hasUI && ctx.mode === "tui") await openPanel(ctx, pages, tab);
82
88
  else ctx.ui.notify(pages[tab]!.render(100, plain).join("\n"), "info");
83
89
  } catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
@@ -153,6 +153,30 @@ export function describeRole(row: RoleRow): string {
153
153
  return row.role + " · " + (row.spec?.startsWith("@") ? row.spec + " → " : "") + target + (row.scope === "project" ? " · project" : "");
154
154
  }
155
155
 
156
+ /** Delegated roles meant for a cheaper model than the main session: scouts discover, reviewers check. */
157
+ export const ECONOMY_ROLES = ["scout", "reviewer"] as const;
158
+
159
+ /**
160
+ * Economy roles whose subagents run on the main session model although a cheaper model is available, as
161
+ * jar_delegate picks it: the role's first available candidate, then the default role's, then the current
162
+ * model. The fix names @smol when the Fast role already points at a cheaper available model.
163
+ */
164
+ export function premiumRoleWarnings(roles: Pick<ModelRoleManager, "resolveCandidates">, ctx: Pick<ExtensionContext, "model" | "modelRegistry">): string[] {
165
+ const main = ctx.model;
166
+ if (!main) return [];
167
+ const mainId = main.provider + "/" + main.id;
168
+ const mainPrice = main.cost.input + main.cost.output;
169
+ const models = ctx.modelRegistry.getAvailable();
170
+ const cheaper = new Set(models.filter((model) => model.cost.input + model.cost.output < mainPrice).map((model) => model.provider + "/" + model.id));
171
+ // Without a cheaper option there is nothing to recommend: a main model that is already economical is fine.
172
+ if (!cheaper.size) return [];
173
+ const available = new Set(models.map((model) => model.provider + "/" + model.id));
174
+ const pick = (role: string) => roles.resolveCandidates(role).map((item) => item.provider + "/" + item.model).find((id) => available.has(id));
175
+ const fix = cheaper.has(pick("smol") ?? "") ? "@smol" : "provider/model";
176
+ return ECONOMY_ROLES.filter((role) => (pick(role) ?? pick("default") ?? mainId) === mainId)
177
+ .map((role) => `⚠ ${role} subagents run on the main model ${mainId}; use a mini/haiku-class model: /roles set ${role} ${fix}`);
178
+ }
179
+
156
180
  export class ModelRoleManager {
157
181
  private global: RoleConfig = readConfig(join(getAgentDir(), ROLE_FILE));
158
182
  private project: RoleConfig = { version: 2, roles: {} };
@@ -392,8 +416,8 @@ export class ModelRoleManager {
392
416
  const verb = (parts[0] ?? "").toLowerCase();
393
417
  if (!verb || verb === "list") {
394
418
  if (!verb && ctx.hasUI && ctx.mode === "tui" && openUi) { await openUi(ctx); return; }
395
- ctx.ui.notify(this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
396
- ? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")).join("\n"), "info");
419
+ ctx.ui.notify([...this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
420
+ ? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")), ...premiumRoleWarnings(this, ctx)].join("\n"), "info");
397
421
  return;
398
422
  }
399
423
  if (verb === "fallback") {
package/src/plan.ts CHANGED
@@ -11,6 +11,7 @@ import { extractApproachSteps, isSafePlanCommand, PLAN_TEMPLATE, planTextFromSte
11
11
  import { cleanText } from "./status.ts";
12
12
  import { sessionBranch } from "./session-branch.ts";
13
13
  import { type TodoStore } from "./tasks.ts";
14
+ import { setToolActive } from "./tool-activation.ts";
14
15
  import { projectWorkflowMessages } from "./workflow-context.ts";
15
16
 
16
17
  export const PLAN_ENTRY = "pi-jar.plan";
@@ -139,7 +140,8 @@ export class PlanMode {
139
140
  this.enabled = true;
140
141
  this.pendingReview = false;
141
142
  this.reminders = 0;
142
- this.toolsBefore = this.pi.getActiveTools();
143
+ // The submit tool is active only in plan mode; never restore it with the pre-plan loadout.
144
+ this.toolsBefore = this.pi.getActiveTools().filter((name) => name !== PLAN_SUBMIT_TOOL);
143
145
  this.pi.setActiveTools(this.planTools(this.toolsBefore));
144
146
  this.onEnter?.(ctx);
145
147
  this.restoreRole = await this.roles.activateTemporary("plan", ctx);
@@ -414,7 +416,7 @@ export class PlanMode {
414
416
  });
415
417
 
416
418
  this.pi.on("before_agent_start", async () => {
417
- if (!this.enabled) return;
419
+ if (!this.enabled) { setToolActive(this.pi, PLAN_SUBMIT_TOOL, false); return; }
418
420
  return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
419
421
  });
420
422