pi-jar 0.1.4 → 0.1.6

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.
@@ -11,7 +11,11 @@ export const BUILTIN_ROLES = [
11
11
  { role: "plan", label: "Architect", usedBy: "plan mode" },
12
12
  { role: "implement", label: "Builder", usedBy: "goal implement rounds and approved plans" },
13
13
  { role: "advisor", label: "Advisor", usedBy: "second opinions, stuck-work gates and the goal audit" },
14
- { role: "task", label: "Subtask", usedBy: "delegated tasks" },
14
+ { role: "moderator", label: "Moderator", usedBy: "main-agent multi-agent coordination" },
15
+ { role: "scout", label: "Scout", usedBy: "cheap delegated discovery and codebase scouting" },
16
+ { role: "worker", label: "Worker", usedBy: "sandboxed delegated implementation" },
17
+ { role: "reviewer", label: "Reviewer", usedBy: "forked delegated review and verification" },
18
+ { role: "task", label: "Subtask", usedBy: "legacy or explicitly generic delegated tasks" },
15
19
  { role: "commit", label: "Commit", usedBy: "/jar commit messages" }
16
20
  ] as const;
17
21
  export const MODEL_ROLES = BUILTIN_ROLES.map((item) => item.role);
@@ -24,7 +28,7 @@ export interface RoleTag { name?: string; color?: string }
24
28
  export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag>; fallbacks?: Record<string, string[]> }
25
29
  export interface RoleRow {
26
30
  role: string; label: string; usedBy?: string; custom: boolean;
27
- spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string;
31
+ spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string; fallbacks: string[];
28
32
  }
29
33
 
30
34
  export const ROLE_FILE = "pi-jar-roles.json";
@@ -180,6 +184,23 @@ export class ModelRoleManager {
180
184
  return result && !("error" in result) ? result : undefined;
181
185
  }
182
186
 
187
+ /** Primary role target followed by its configured fallbacks, de-duplicated in priority order. */
188
+ resolveCandidates(role: string): ResolvedRole[] {
189
+ const candidates: ResolvedRole[] = [];
190
+ const primary = this.resolve(role);
191
+ if (primary) candidates.push(primary);
192
+ for (const spec of this.fallbackSpecs(role)) {
193
+ try { candidates.push(this.resolveSpec(spec)); } catch { /* malformed alias targets are skipped */ }
194
+ }
195
+ const seen = new Set<string>();
196
+ return candidates.filter((item) => {
197
+ const key = item.provider + "/" + item.model + ":" + (item.thinking ?? "");
198
+ if (seen.has(key)) return false;
199
+ seen.add(key);
200
+ return true;
201
+ });
202
+ }
203
+
183
204
  get(role: string): RoleAssignment | undefined {
184
205
  const resolved = this.resolve(role);
185
206
  if (!resolved) return undefined;
@@ -196,7 +217,7 @@ export class ModelRoleManager {
196
217
  ];
197
218
  return rows.map((row) => {
198
219
  const result = resolveRole(roles, row.role);
199
- return { ...row, spec: roles[row.role], scope: this.scopeOf(row.role),
220
+ return { ...row, spec: roles[row.role], scope: this.scopeOf(row.role), fallbacks: this.fallbackSpecs(row.role),
200
221
  ...(result && "error" in result ? { error: result.error } : result ? { resolved: result } : {}) };
201
222
  });
202
223
  }
@@ -219,39 +240,46 @@ export class ModelRoleManager {
219
240
  }
220
241
 
221
242
  async activate(role: string, ctx: ExtensionContext, quiet = false): Promise<boolean> {
222
- const result = resolveRole(this.merged(), role);
223
- if (result && "error" in result) {
224
- if (!quiet) ctx.ui.notify("Role " + role + ": " + result.error, "warning");
225
- return false;
226
- }
227
- if (!result) {
243
+ const primary = resolveRole(this.merged(), role);
244
+ const primaryError = primary && "error" in primary ? primary.error : undefined;
245
+ const candidates = this.resolveCandidates(role);
246
+ if (!candidates.length) {
247
+ if (primaryError) {
248
+ if (!quiet) ctx.ui.notify("Role " + role + ": " + primaryError, "warning");
249
+ return false;
250
+ }
228
251
  if (!quiet) ctx.ui.notify("Role " + role + " follows the current model; assign one with /roles", "info");
229
252
  this.active = role;
230
253
  this.status(ctx);
231
254
  return true;
232
255
  }
233
- const model = ctx.modelRegistry.find(result.provider, result.model);
234
- if (!model) {
235
- if (!quiet) ctx.ui.notify("Model not found for role " + role + ": " + result.provider + "/" + result.model, "warning");
236
- return false;
237
- }
238
- let changed: boolean;
239
- this.applying = true;
240
- try { changed = await this.pi.setModel(model); } finally { this.applying = false; }
241
- if (!changed) {
242
- if (!quiet) ctx.ui.notify("No configured authentication for " + result.provider + "/" + result.model, "warning");
243
- return false;
256
+
257
+ const failures: string[] = [];
258
+ for (let index = 0; index < candidates.length; index++) {
259
+ const candidate = candidates[index]!;
260
+ const model = ctx.modelRegistry.find(candidate.provider, candidate.model);
261
+ if (!model) { failures.push(candidate.provider + "/" + candidate.model + " not found"); continue; }
262
+ let changed: boolean;
263
+ this.applying = true;
264
+ try { changed = await this.pi.setModel(model); } finally { this.applying = false; }
265
+ if (!changed) { failures.push(candidate.provider + "/" + candidate.model + " unavailable"); continue; }
266
+ if (candidate.thinking) this.withApplying(() => this.pi.setThinkingLevel(candidate.thinking!));
267
+ this.active = role;
268
+ this.status(ctx);
269
+ if (!quiet) {
270
+ ctx.ui.notify("Role " + role + " · " + candidate.provider + "/" + candidate.model
271
+ + (candidate.thinking ? " · " + candidate.thinking : "") + (index ? " · fallback " + index : ""), "info");
272
+ }
273
+ return true;
244
274
  }
245
- if (result.thinking) this.withApplying(() => this.pi.setThinkingLevel(result.thinking!));
246
- this.active = role;
247
- this.status(ctx);
248
- if (!quiet) ctx.ui.notify("Role " + role + " · " + result.provider + "/" + result.model + (result.thinking ? " · " + result.thinking : ""), "info");
249
- return true;
275
+
276
+ if (!quiet) ctx.ui.notify("No usable model for role " + role + ": " + failures.join("; "), "warning");
277
+ return false;
250
278
  }
251
279
 
252
280
  /** Switch to a role for a bounded workflow; the returned function restores the previous model. */
253
281
  async activateTemporary(role: string, ctx: ExtensionContext): Promise<() => Promise<void>> {
254
- if (!this.resolve(role)) return async () => {};
282
+ if (!this.resolveCandidates(role).length) return async () => {};
255
283
  const previousModel = ctx.model;
256
284
  const previousThinking = this.pi.getThinkingLevel();
257
285
  const previousActive = this.active;
@@ -276,7 +304,7 @@ export class ModelRoleManager {
276
304
 
277
305
  /** Activate the next assigned role in the cycle order. */
278
306
  async cycle(ctx: ExtensionContext): Promise<string | undefined> {
279
- const order = this.cycleOrder().filter((role) => this.resolve(role));
307
+ const order = this.cycleOrder().filter((role) => this.resolveCandidates(role).length > 0);
280
308
  if (!order.length) { ctx.ui.notify("No roles in the cycle order are assigned; configure them with /roles", "info"); return undefined; }
281
309
  const next = order[(order.indexOf(this.active ?? "") + 1) % order.length]!;
282
310
  return await this.activate(next, ctx) ? next : undefined;
@@ -322,7 +350,7 @@ export class ModelRoleManager {
322
350
  if (scope === "project") this.project = next; else this.global = next;
323
351
  }
324
352
 
325
- register(openUi?: (ctx: ExtensionContext) => Promise<void>): void {
353
+ register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean } = {}): void {
326
354
  const manual = (_event: unknown, ctx: ExtensionContext) => {
327
355
  if (this.applying) return;
328
356
  this.manualEpoch++;
@@ -333,7 +361,8 @@ export class ModelRoleManager {
333
361
  this.pi.on("session_start", async (_event, ctx) => {
334
362
  this.load(ctx.cwd);
335
363
  this.active = undefined;
336
- if (this.resolve("default")) await this.activate("default", ctx, true);
364
+ // Delegated children already received their role model/effort via CLI arguments.
365
+ if (options.activateDefault?.() !== false && this.resolveCandidates("default").length) await this.activate("default", ctx, true);
337
366
  });
338
367
 
339
368
  this.pi.registerCommand("roles", {
package/src/plan.ts CHANGED
@@ -75,7 +75,6 @@ export class PlanMode {
75
75
  private reminders = 0;
76
76
  private reviewing = false;
77
77
  private compacting = false;
78
- private contextDirty = true;
79
78
  private compactGeneration = 0;
80
79
  private executionRestore: (() => Promise<void>) | undefined;
81
80
  private executionRunning = false;
@@ -137,7 +136,6 @@ export class PlanMode {
137
136
  try { this.ensureDirectory(ctx); }
138
137
  catch (error) { ctx.ui.notify("Plan mode unavailable: cannot create plan directory: " + (error as Error).message, "error"); return; }
139
138
  this.enabled = true;
140
- this.contextDirty = true;
141
139
  this.pendingReview = false;
142
140
  this.reminders = 0;
143
141
  this.toolsBefore = this.pi.getActiveTools();
@@ -152,7 +150,6 @@ export class PlanMode {
152
150
  private async leave(ctx: ExtensionContext, persist = true): Promise<void> {
153
151
  const wasEnabled = this.enabled;
154
152
  this.enabled = false;
155
- this.contextDirty = true;
156
153
  this.compacting = false;
157
154
  this.pendingReview = false;
158
155
  this.compactGeneration++;
@@ -417,14 +414,13 @@ export class PlanMode {
417
414
 
418
415
  this.pi.on("before_agent_start", async () => {
419
416
  if (!this.enabled) return;
420
- this.contextDirty = true;
421
417
  return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
422
418
  });
423
419
 
424
420
  // Keep plan-only prompt messages bounded. When plan mode is active, retain only the newest
425
- // context and newest reminder; when it is off, remove all stale plan prompt messages.
421
+ // context and newest reminder; when it is off, remove all stale plan prompt messages. Context
422
+ // rewrites apply to a single LLM call, so this runs on every call.
426
423
  this.pi.on("context", async (event) => {
427
- if (!this.contextDirty) return;
428
424
  let latestContext = -1;
429
425
  let latestReminder = -1;
430
426
  let needsPrune = false;
@@ -438,7 +434,6 @@ export class PlanMode {
438
434
  else latestReminder = index;
439
435
  }
440
436
  }
441
- this.contextDirty = false;
442
437
  if (!needsPrune) return;
443
438
  return { messages: event.messages.filter((raw, index) => {
444
439
  const type = (raw as { customType?: string }).customType;
@@ -460,7 +455,6 @@ export class PlanMode {
460
455
  return;
461
456
  }
462
457
  this.reminders++;
463
- this.contextDirty = true;
464
458
  return {
465
459
  entries: [{ type: "custom_message", customType: "pi-jar.plan-reminder", display: false,
466
460
  content: `[PI-JAR PLAN MODE] You ended the turn without ${PLAN_SUBMIT_TOOL}. If the plan is ready, write it to the plan directory and call ${PLAN_SUBMIT_TOOL}. If you need a decision from the user, use jar_ask. Do not ask for approval in chat.` }],
@@ -480,8 +474,8 @@ export class PlanMode {
480
474
  if (this.enabled && this.pendingReview) await this.review(ctx);
481
475
  });
482
476
 
483
- this.pi.on("session_start", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
484
- this.pi.on("session_tree", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
477
+ this.pi.on("session_start", async (_event, ctx) => { await this.restore(sessionBranch(ctx), ctx); });
478
+ this.pi.on("session_tree", async (_event, ctx) => { await this.restore(sessionBranch(ctx), ctx); });
485
479
  this.pi.on("session_shutdown", async (_event, ctx) => { if (this.enabled) await this.leave(ctx, false); });
486
480
  }
487
481
  }
package/src/roles-ui.ts CHANGED
@@ -4,10 +4,11 @@ import { promptText } from "./dialogs.ts";
4
4
  import { isRoleName, normalizeSpec, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
5
5
  import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
6
6
 
7
- type RoleAction = "model" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
7
+ type RoleAction = "model" | "fallback" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
8
8
  const ACTIONS: { action: RoleAction; key: string; hint: string; label: string }[] = [
9
9
  { action: "activate", key: "\r", hint: "⏎", label: "Activate this role now" },
10
10
  { action: "model", key: "m", hint: "m", label: "Assign a model" },
11
+ { action: "fallback", key: "f", hint: "f", label: "Set fallback model" },
11
12
  { action: "alias", key: "a", hint: "a", label: "Alias another role (@role)" },
12
13
  { action: "thinking", key: "t", hint: "t", label: "Set thinking effort" },
13
14
  { action: "scope", key: "s", hint: "s", label: "Move between global and project" },
@@ -28,6 +29,7 @@ function detail(row: RoleRow, active: string | undefined): [ThemeColor, string][
28
29
  [target[0], "Model " + target[1]],
29
30
  ["muted", "Alias " + (row.resolved && row.resolved.via.length > 1 ? row.resolved.via.join(" → ") : "—")],
30
31
  ["muted", "Effort " + (row.resolved?.thinking ?? "follow current")],
32
+ ["muted", "Fallback " + (row.fallbacks.length ? row.fallbacks.join(" → ") : "—")],
31
33
  ["muted", "Scope " + (row.scope ?? "unset") + (row.scope === "project" ? " (.pi/pi-jar-roles.json)" : row.scope === "global" ? " (agent dir)" : "")],
32
34
  ["dim", ""],
33
35
  ["dim", "Specs: provider/model[:effort] · @role[:effort] · *"],
@@ -41,7 +43,6 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
41
43
  let scroll = 0;
42
44
  let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
43
45
  let width = 80;
44
- let actionsTop = -1;
45
46
  const rows = () => roles.list();
46
47
  const finish = (action: RoleAction) => done({ action, index: selected });
47
48
  return {
@@ -67,9 +68,9 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
67
68
  if (event.type !== "click" || event.button !== "left") return;
68
69
  if (event.y === 0 && event.x >= width - 3) { done(undefined); return { handled: true }; }
69
70
  const row = event.y - layout.top;
70
- // Actions are listed in the detail pane, one per row.
71
- const actionIndex = row - actionsTop;
72
- if (row >= 0 && row < layout.rows && event.x >= layout.bodyX && actionIndex >= 0 && actionIndex < ACTIONS.length) {
71
+ // Actions stay in the footer so even short terminals can click every action.
72
+ const actionIndex = event.y - layout.footerTop;
73
+ if (event.x >= 2 && event.x < width - 2 && actionIndex >= 0 && actionIndex < ACTIONS.length) {
73
74
  finish(ACTIONS[actionIndex]!.action); return { handled: true };
74
75
  }
75
76
  if (row >= 0 && row < layout.rows && (layout.leftWidth === 0 || event.x < layout.leftWidth + 3)) {
@@ -81,7 +82,7 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
81
82
  width = Math.max(24, available);
82
83
  const list = rows();
83
84
  selected = Math.max(0, Math.min(list.length - 1, selected));
84
- const height = Math.min(contentRows(5, 8), Math.max(12 + ACTIONS.length + 2, list.length));
85
+ const height = Math.min(contentRows(ACTIONS.length + 4, 4), Math.max(13, list.length));
85
86
  if (selected < scroll) scroll = selected;
86
87
  if (selected >= scroll + height) scroll = selected - height + 1;
87
88
  const active = roles.activeRole();
@@ -97,11 +98,9 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
97
98
  const header = narrow ? [theme.fg("accent", `‹ ${selected + 1}/${list.length} ${row.role} ›`)] : [];
98
99
  const actions = optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.hint))
99
100
  .map((line, at) => ACTIONS[at]!.action === "delete" && !row.custom ? theme.fg("dim", stripTerminalSequences(line)) : line);
100
- // Actions come right after the facts; the spec help (last three lines) goes last.
101
- const facts = info.slice(0, -3), help = info.slice(-3);
102
- const body = [...header, ...facts, theme.fg("accent", "Actions"), ...actions, ...help];
103
- actionsTop = header.length + facts.length + 1;
101
+ const body = [...header, ...info];
104
102
  const split = splitFrame(theme, width, "pi-jar · roles", narrow ? [] : left, body, [
103
+ ...actions,
105
104
  theme.fg("dim", "↑↓ choose role · press a key or click an action · Esc close")
106
105
  ], height);
107
106
  layout = split.layout;
@@ -149,6 +148,15 @@ async function applyAction(ctx: ExtensionContext, roles: ModelRoleManager, row:
149
148
  if (selected) roles.update(row.role, selected + effort(), scope);
150
149
  return;
151
150
  }
151
+ case "fallback": {
152
+ const models = ctx.modelRegistry.getAvailable().slice().sort((a, b) => (a.provider + "/" + a.id).localeCompare(b.provider + "/" + b.id));
153
+ if (!models.length) { ctx.ui.notify("No authenticated models are currently available", "warning"); return; }
154
+ const choices = ["Clear fallback", ...models.map((model) => model.provider + "/" + model.id)];
155
+ const selected = await ctx.ui.select("Fallback model for " + row.role, choices);
156
+ if (!selected) return;
157
+ roles.updateFallbacks(row.role, selected === "Clear fallback" ? [] : [selected], scope);
158
+ return;
159
+ }
152
160
  case "alias": {
153
161
  const targets = roles.list().filter((item) => item.role !== row.role).map((item) => "@" + item.role);
154
162
  const selected = await ctx.ui.select("Alias " + row.role + " to", targets);
@@ -2,7 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
4
4
  import { ICON_SETS } from "./icons.ts";
5
- import { GOAL_ROUND_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
5
+ import { GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
6
6
 
7
7
  const LABELS: Record<FooterField, string> = {
8
8
  model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory",
@@ -35,25 +35,37 @@ export async function openJarSettings(
35
35
  let width = 64;
36
36
  const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
37
37
  name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
38
- const fields = () => page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 5 : 7;
38
+ let choosing: "goalRounds" | "maxSubagents" | undefined;
39
+ const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : GOAL_ROUND_CHOICES;
40
+ const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 6 : 7;
41
+ const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : 2; choosing = undefined; };
39
42
  const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
40
- const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - 9));
43
+ const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
44
+ const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - (explainPool() ? 10 : 9)));
41
45
  const firstVisible = () => Math.min(Math.max(0, selected - pageSize() + 1), Math.max(0, fields() - pageSize()));
42
46
  const visibleCount = () => Math.min(pageSize(), fields());
43
47
  const apply = (index: number) => {
44
48
  const state = current();
45
- if (page === "footer") {
49
+ if (choosing) {
50
+ if (choosing === "maxSubagents") update({ ...state, maxSubagents: MAX_SUBAGENT_CHOICES[index]! });
51
+ else update({ ...state, goalRounds: GOAL_ROUND_CHOICES[index]! });
52
+ finishChoice();
53
+ } else if (page === "footer") {
46
54
  const field = FOOTER_FIELDS[index];
47
55
  if (field) update({ ...state, footer: { ...state.footer, [field]: !state.footer[field] } });
48
56
  } else if (page === "pi") {
49
57
  if (index === 0 && pi) { try { pi.setFullscreen(!pi.get().fullscreen); ctx.ui.notify("Pi TUI mode saved; restart Pi to apply", "info"); } catch (error) { piError(error); } }
50
58
  else if (index === 1 && pi) { try { pi.setCopyOnSelect(!pi.get().copyOnSelect); } catch (error) { piError(error); } }
51
59
  else if (index === 2) {
52
- const at = GOAL_ROUND_CHOICES.indexOf(state.goalRounds as never);
53
- update({ ...state, goalRounds: GOAL_ROUND_CHOICES[(at + 1) % GOAL_ROUND_CHOICES.length]! });
60
+ choosing = "goalRounds";
61
+ selected = Math.max(0, GOAL_ROUND_CHOICES.indexOf(state.goalRounds as never));
54
62
  }
55
- else if (index === 3) update({ ...state, advisor: !state.advisor });
56
- else if (index === 4) update({ ...state, advisorGates: !state.advisorGates });
63
+ else if (index === 3) {
64
+ choosing = "maxSubagents";
65
+ selected = Math.max(0, MAX_SUBAGENT_CHOICES.indexOf(state.maxSubagents));
66
+ }
67
+ else if (index === 4) update({ ...state, advisor: !state.advisor });
68
+ else if (index === 5) update({ ...state, advisorGates: !state.advisorGates });
57
69
  } else if (index === 0) {
58
70
  const at = Math.max(0, accents.indexOf(state.accent));
59
71
  update({ ...state, accent: accents[(at + 1) % accents.length]! });
@@ -77,7 +89,12 @@ export async function openJarSettings(
77
89
  return {
78
90
  invalidate() {},
79
91
  handleInput(data: string) {
80
- if (matchesKey(data, Key.escape) || data === "q") { done(); return; }
92
+ if (matchesKey(data, Key.escape) || data === "q") {
93
+ if (choosing) { finishChoice(); tui.requestRender(); } else done();
94
+ return;
95
+ }
96
+ if (choosing && (matchesKey(data, Key.tab) || matchesKey(data, Key.right)
97
+ || matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left))) return;
81
98
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
82
99
  page = PAGES[(PAGES.indexOf(page) + 1) % PAGES.length]!;
83
100
  selected = 0;
@@ -97,6 +114,7 @@ export async function openJarSettings(
97
114
  if (event.type !== "click" || event.button !== "left") return;
98
115
  if (event.y === 0 && event.x >= width - 4) { done(); return { handled: true }; }
99
116
  if (event.y === 1) {
117
+ choosing = undefined;
100
118
  if (event.x >= 2 && event.x < Math.min(width - 2, 19)) { page = "appearance"; selected = 0; }
101
119
  else if (event.x >= 19 && event.x < Math.min(width - 2, 32)) { page = "footer"; selected = 0; }
102
120
  else if (event.x >= 32 && event.x < width - 2) { page = "pi"; selected = 0; }
@@ -121,14 +139,17 @@ export async function openJarSettings(
121
139
  + " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]"));
122
140
  const prefs = pi?.get();
123
141
  const divider = theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 2)) + "┤");
124
- const allRows = page === "footer"
142
+ const allRows = choosing
143
+ ? choices().map((choice, index) => row(index, String(choice), state[choosing!] === choice ? "CURRENT" : "", true))
144
+ : page === "footer"
125
145
  ? FOOTER_FIELDS.map((field, index) => row(index, LABELS[field], state.footer[field] ? "ON" : "OFF", state.footer[field]))
126
146
  : page === "pi" ? [
127
147
  row(0, "Mouse clicks (Pi fullscreen, restart)", !prefs ? "N/A" : prefs.fullscreen ? "ON" : "OFF", !!prefs?.fullscreen),
128
148
  row(1, "Copy on select", !prefs ? "N/A" : prefs.copyOnSelect ? "ON" : "OFF", !!prefs?.copyOnSelect),
129
149
  row(2, "Goal auto rounds", String(state.goalRounds), true),
130
- row(3, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
131
- row(4, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates)
150
+ row(3, "Max subagents (retained live pool)", String(state.maxSubagents), true),
151
+ row(4, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
152
+ row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates)
132
153
  ] : [
133
154
  row(0, "Accent", state.accent === "follow" ? "FOLLOW PI" : state.accent.toUpperCase(), true),
134
155
  row(1, "Motion", state.animations ? "ON" : "OFF", state.animations),
@@ -141,11 +162,16 @@ export async function openJarSettings(
141
162
  const start = firstVisible();
142
163
  const rows = allRows.slice(start, start + pageSize());
143
164
  return [top, tabs, divider,
144
- line(theme.fg("accent", page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
145
- line(theme.fg("dim", page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
165
+ line(theme.fg("accent", choosing === "maxSubagents" ? " MAX SUBAGENTS · RETAINED LIVE POOL"
166
+ : choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
167
+ line(theme.fg("dim", explainPool() ? " Live pool includes idle/paused subagents"
168
+ : choosing ? " Select a value; Enter/click to save, Esc to cancel"
169
+ : page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
146
170
  : page === "pi" ? " Fullscreen mode enables clicks and copy-on-select" : " Accent cycles through loaded themes")),
147
171
  ...rows,
148
- line(theme.fg("dim", " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
172
+ line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
173
+ : " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
174
+ ...(explainPool() ? [line(theme.fg("dim", " Lowering does not terminate existing subagents"))] : []),
149
175
  theme.fg("dim", "╰" + "─".repeat(Math.max(0, width - 2)) + "╯")];
150
176
  }
151
177
  };
package/src/settings.ts CHANGED
@@ -17,6 +17,8 @@ export interface JarVisualSettings {
17
17
  suggestions: boolean;
18
18
  /** Automatic implement/audit rounds per user message while a goal is active. */
19
19
  goalRounds: number;
20
+ /** Retained live subagent pool, including idle/paused agents; lowering does not terminate them. */
21
+ maxSubagents: MaxSubagents;
20
22
  /** The jar_advisor second-opinion tool and /advisor. */
21
23
  advisor: boolean;
22
24
  /** Consult the advisor automatically when the agent repeats a tool call or keeps failing. */
@@ -27,10 +29,15 @@ export interface JarVisualSettings {
27
29
  }
28
30
 
29
31
  export const GOAL_ROUND_CHOICES = [4, 8, 12, 20] as const;
32
+ export const MAX_SUBAGENT_CHOICES = [2, 4, 6, 8, 16] as const;
33
+ export type MaxSubagents = (typeof MAX_SUBAGENT_CHOICES)[number];
34
+ export function isMaxSubagents(value: unknown): value is MaxSubagents {
35
+ return MAX_SUBAGENT_CHOICES.some((choice) => choice === value);
36
+ }
30
37
 
31
38
  export const SETTINGS_FILE = "pi-jar-settings.json";
32
39
  export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
33
- return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
40
+ return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 4, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
34
41
  }
35
42
 
36
43
  /** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
@@ -60,6 +67,7 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
60
67
  suggestions: typeof value.suggestions === "boolean" ? value.suggestions : fallback.suggestions,
61
68
  goalRounds: typeof value.goalRounds === "number" && Number.isInteger(value.goalRounds) && value.goalRounds >= 1 && value.goalRounds <= 50
62
69
  ? value.goalRounds : fallback.goalRounds,
70
+ maxSubagents: isMaxSubagents(value.maxSubagents) ? value.maxSubagents : fallback.maxSubagents,
63
71
  advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
64
72
  advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
65
73
  icons: ICON_SETS.some((set) => set === value.icons) ? value.icons as IconSet : fallback.icons,
package/src/status.ts CHANGED
@@ -73,7 +73,8 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
73
73
  const id = rawKey.slice(ROLE_PREFIX.length);
74
74
  if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(id)) continue;
75
75
  const role = cachedRole(id, rawValue);
76
- if (!role || role.expiresAt == null || role.expiresAt <= now || role.expiresAt > now + ROLE_TTL_MS) {
76
+ const ttl = id.startsWith("delegate-") ? 24 * 60 * 60_000 : ROLE_TTL_MS;
77
+ if (!role || role.expiresAt == null || role.expiresAt <= now || role.expiresAt > now + ttl) {
77
78
  extras.push(`${id}: unavailable`);
78
79
  continue;
79
80
  }
package/src/task-tool.ts CHANGED
@@ -4,7 +4,7 @@ import { Type } from "typebox";
4
4
  import type { Todo, TodoInput, TodoStatus } from "./tasks.ts";
5
5
  import { TODO_STATUSES, TodoStore, todoMark, todoProgress, todoTotals } from "./tasks.ts";
6
6
 
7
- const ACTIONS = ["write", "list", "append", "remove", "replace", "add", "start", "done", "open", "edit", "delete"] as const;
7
+ const ACTIONS = ["write", "list", "append", "remove", "update", "add", "start", "done", "open", "edit", "delete"] as const;
8
8
  const Action = Type.Unsafe<(typeof ACTIONS)[number]>({ type: "string", enum: [...ACTIONS] });
9
9
  const Status = Type.Unsafe<(typeof TODO_STATUSES)[number]>({ type: "string", enum: [...TODO_STATUSES] });
10
10
  const Content = Type.String({ description: "Imperative task title, e.g. \"Run the tests\"." });
@@ -19,10 +19,10 @@ const Parameters = Type.Object({
19
19
  subtasks: Type.Optional(Type.Array(Type.Object({ id: Type.Optional(Type.String()), content: Content, status: Status, activeForm: ActiveForm }), {
20
20
  description: "Optional steps of this task (one level deep). The task's status then follows its subtasks."
21
21
  }))
22
- }), { description: "For append: new tasks only. For replace: exactly one replacement task. With write (or no action): the complete list, replacing it." })),
22
+ }), { description: "For append: new tasks only. For update: exactly one updated task. With write (or no action): the complete list, replacing it." })),
23
23
  action: Type.Optional(Action),
24
- id: Type.Optional(Type.String({ description: "Stable task id for replace/remove/start/done/open/edit/delete. On a parent, start runs its first open subtask; done/open apply to all its subtasks." })),
25
- title: Type.Optional(Type.String({ description: "Task title for append/add/edit/replace." })),
24
+ id: Type.Optional(Type.String({ description: "Stable task id for update/remove/start/done/open/edit/delete. On a parent, start runs its first open subtask; done/open apply to all its subtasks." })),
25
+ title: Type.Optional(Type.String({ description: "Task title for append/add/edit/update." })),
26
26
  status: Type.Optional(Status),
27
27
  activeForm: ActiveForm,
28
28
  parent: Type.Optional(Type.String({ description: "For append/add: id of a top-level task to add the new task under as a subtask." })),
@@ -42,6 +42,8 @@ interface TaskToolDetails {
42
42
  changed?: string;
43
43
  /** Full stable id index, including items outside the bounded preview. */
44
44
  ids?: { id: string; title: string; parentId?: string }[];
45
+ /** Optional deliberately small render surface for terse mutations such as start/done. */
46
+ display?: Todo[];
45
47
  truncated?: boolean;
46
48
  /** Subtask counts of previewed parents when the preview cuts the list. */
47
49
  progress?: { id: string; done: number; total: number }[];
@@ -85,11 +87,13 @@ export function registerTaskTool(
85
87
  pi.registerTool({
86
88
  name: "jar_todo",
87
89
  label: "tasks",
88
- description: "Maintain session tasks incrementally: append adds todos (or title), remove deletes id and its subtasks, replace updates only id using title/status/activeForm or exactly one todos item. Omitted replacement fields/subtasks are preserved. append with parent adds subtasks. Stable IDs are returned for every task. Legacy write (or todos without action) replaces the entire list. add/delete and start/done/open/edit/list remain supported.",
89
- promptSnippet: "Track multi-step work with jar_todo: append new tasks, replace/remove by stable id, keep one leaf in_progress, complete tasks as they finish.",
90
+ description: "Maintain session tasks: write establishes a fresh complete task plan, update changes one existing task by stable id, append adds genuinely new work, and remove deletes one task tree. start/done/open/edit remain concise status operations. Stable IDs are returned for every task.",
91
+ promptSnippet: "Track multi-step work with jar_todo: write the task plan first, update/remove existing tasks by stable id, append only when new work arrives mid-run, and keep one leaf in_progress.",
90
92
  promptGuidelines: [
91
93
  "Use jar_todo proactively, without waiting for the user, for any task with three or more distinct steps, when the user gives several tasks, or right after receiving new instructions. Skip it for single trivial requests and pure questions.",
92
- "Prefer incremental actions: append with todos adds only new tasks; replace with id updates only that task; remove with id deletes only that task tree. Use the returned stable IDs. write with todos is legacy full-list replacement and removes omitted tasks. Each todos item has content, status, and optional activeForm/subtasks.",
94
+ "Prefer write when establishing the task plan for a new request. If the existing checklist is fully completed, start the next request with a fresh write rather than appending onto old completed work.",
95
+ "Use update with a stable id to revise one existing task and remove to delete one task tree. Use append mainly when the user steers or adds genuinely new requirements while an active checklist is already in progress; do not use append as the default way to create a new plan.",
96
+ "write with todos is the complete current task plan and removes omitted tasks. Each item has content, status, and optional activeForm/subtasks.",
93
97
  "Break a large task into `subtasks` (same shape, one level deep). A parent's status follows its subtasks: it is completed when all of them are. Progress counts leaf tasks (subtasks and tasks without subtasks).",
94
98
  "Keep exactly one leaf task in_progress at a time. Mark a task in_progress before you start it and completed immediately after it is verified; do not batch completions.",
95
99
  "Only mark a task completed when it is fully done. If tests fail, work is partial or you are blocked, keep it in_progress and add a task for what must be resolved. Remove tasks that are no longer relevant."
@@ -109,7 +113,7 @@ export function registerTaskTool(
109
113
  if (!Array.isArray(params.todos)) throw new Error();
110
114
  inputs = params.todos.map(toInput);
111
115
  } catch { error = "todos must be an array of task objects with array subtasks."; }
112
- if (!["write", "append", "replace"].includes(action)) error = "todos may only be used with write, append, or replace; no tasks were changed.";
116
+ if (!["write", "append", "update"].includes(action)) error = "todos may only be used with write, append, or update; no tasks were changed.";
113
117
  }
114
118
  if (!error) switch (action) {
115
119
  case "list":
@@ -125,15 +129,15 @@ export function registerTaskTool(
125
129
  if (!error) changedId = current.all().find((item) => !before.has(item.id))?.id;
126
130
  break;
127
131
  }
128
- case "replace": {
129
- if (!params.id) { error = "replace needs a valid existing task id."; break; }
130
- if (inputs && (inputs.length !== 1 || params.title !== undefined || params.status !== undefined || params.activeForm !== undefined)) { error = "replace accepts exactly one todos item OR title/status/activeForm fields."; break; }
132
+ case "update": {
133
+ if (!params.id) { error = "update needs a valid existing task id."; break; }
134
+ if (inputs && (inputs.length !== 1 || params.title !== undefined || params.status !== undefined || params.activeForm !== undefined)) { error = "update accepts exactly one todos item OR title/status/activeForm fields."; break; }
131
135
  const replacement = inputs?.[0] ?? {
132
136
  ...(params.title !== undefined ? { title: params.title } : {}),
133
137
  ...(params.status !== undefined ? { status: params.status } : {}),
134
138
  ...(params.activeForm !== undefined ? { activeForm: params.activeForm } : {})
135
139
  };
136
- error = current.replace(params.id, replacement);
140
+ error = current.update(params.id, replacement);
137
141
  if (!error) changedId = params.id;
138
142
  break;
139
143
  }
@@ -154,7 +158,7 @@ export function registerTaskTool(
154
158
  if (!item) error = `${action} needs a valid id from jar_todo list.`;
155
159
  else if (action === "start" && progress.total && progress.done === progress.total) error = `every subtask of ${item.id} is completed; open it or add a subtask first.`;
156
160
  else if (!current.setStatus(item.id, action === "start" ? "in_progress" : action === "done" ? "completed" : "pending")) error = "Could not save the task.";
157
- else changedId = item.id;
161
+ else changedId = action === "start" ? (current.current()?.id ?? item.id) : item.id;
158
162
  break;
159
163
  }
160
164
  case "edit":
@@ -177,6 +181,8 @@ export function registerTaskTool(
177
181
  const group = root ? items.filter((item) => item.id === root || item.parentId === root) : [];
178
182
  const mutationItems = action === "write" || action === "append" ? items : group;
179
183
  const mutationPreview = mutationItems.slice(0, 8);
184
+ const terseMutation = action === "start" || action === "done";
185
+ const terseItem = terseMutation && changedId ? items.find((item) => item.id === changedId) : undefined;
180
186
  // Keep task prose bounded while still returning every addressable id in canonical order.
181
187
  const idIndex = mutationItems.length > 8
182
188
  ? "\nTask IDs (list order): " + items.map((item, index) => `${index + 1}=${item.id}`).join(", ") + "\nUse list for all task titles."
@@ -185,7 +191,11 @@ export function registerTaskTool(
185
191
  ? `Could not ${action} tasks: ${error} (${summary(items)}).`
186
192
  : action === "list"
187
193
  ? `Tasks (${summary(items)}):\n${lines(items)}`
188
- : `Task list updated (${summary(items)}).${mutationPreview.length ? "\n" + mutationPreview.map((item) => line(items, item)).join("\n") : ""}${idIndex}\n${REMINDER}`;
194
+ : action === "start" && terseItem
195
+ ? `Started task:\n${line(items, terseItem)}`
196
+ : action === "done" && terseItem
197
+ ? `Completed task:\n${line(items, terseItem)}`
198
+ : `Task list updated (${summary(items)}).${mutationPreview.length ? "\n" + mutationPreview.map((item) => line(items, item)).join("\n") : ""}${idIndex}\n${REMINDER}`;
189
199
  // A subagent's parent mirrors the checklist from these details, so children send it whole.
190
200
  const preview = process.env.PI_JAR_CHILD ? items : items.slice(0, 8);
191
201
  const truncated = preview.length < items.length;
@@ -198,6 +208,7 @@ export function registerTaskTool(
198
208
  details: {
199
209
  action, items: preview, total: state.total, done: state.done, count: items.length,
200
210
  ids: items.map(({ id, title, parentId }) => ({ id, title, ...(parentId ? { parentId } : {}) })),
211
+ ...(terseItem ? { display: [{ ...terseItem }] } : {}),
201
212
  ...(state.current ? { current: state.current } : {}),
202
213
  ...(truncated ? { truncated: true } : {}),
203
214
  ...(progress.length ? { progress } : {}),
@@ -218,15 +229,19 @@ export function registerTaskTool(
218
229
  if (isPartial) return new Text(theme.fg("warning", "Updating tasks…"), 0, 0);
219
230
  const details = result.details as TaskToolDetails | undefined;
220
231
  const items = details?.items ?? [];
232
+ const displayItems = details?.display ?? items;
221
233
  // Older results predate `count`, when `total` counted every item.
222
234
  const count = details?.count ?? details?.total ?? items.length;
223
235
  const totals = details?.total === undefined ? todoTotals(items) : { done: details.done, total: details.total };
224
236
  if (!count) return new Text(theme.fg("dim", "No tracked tasks."), 0, 0);
225
237
  const known = new Map((details?.progress ?? []).map((value) => [value.id, value]));
226
- const shown = expanded ? items : items.slice(0, 8);
227
- let text = theme.fg("dim", `${totals.done}/${totals.total} done${details?.current ? " · now: " + details.current : ""}`);
228
- for (const item of shown) text += "\n" + todoRow(item, (color, value) => theme.fg(color, value), (value) => theme.bold(value), known.get(item.id) ?? todoProgress(items, item.id));
229
- if (details?.truncated || shown.length < count) text += "\n" + theme.fg("dim", ` … +${Math.max(0, count - shown.length)} more · /jar tasks`);
238
+ const shown = details?.display ? displayItems : expanded ? items : items.slice(0, 8);
239
+ let text = details?.display ? "" : theme.fg("dim", `${totals.done}/${totals.total} done${details?.current ? " · now: " + details.current : ""}`);
240
+ for (const item of shown) {
241
+ const row = todoRow(item, (color, value) => theme.fg(color, value), (value) => theme.bold(value), known.get(item.id) ?? todoProgress(items, item.id));
242
+ text += (text ? "\n" : "") + row;
243
+ }
244
+ if (!details?.display && (details?.truncated || shown.length < count)) text += "\n" + theme.fg("dim", ` … +${Math.max(0, count - shown.length)} more · /jar tasks`);
230
245
  return new Text(text, 0, 0);
231
246
  }
232
247
  });