pi-jar 0.1.9 → 0.2.1

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
@@ -171,15 +171,18 @@ export function delegateArgs(model: string | undefined, thinking: string | undef
171
171
  : session.source ? ["--fork", session.source, "--session-dir", session.sessionDir]
172
172
  : ["--session-dir", session.sessionDir];
173
173
  const extensions = extensionFlags(argv);
174
+ const tools = session?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
175
+ const ownExtension = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
176
+ // Default read-only children need only core file tools and pi-jar. Avoid importing every
177
+ // personal extension (and opening unused MCP/provider connections) for each scout.
178
+ // Explicit extension flags and optional inherited tools retain the parent's full setup.
179
+ const lean = !write && extensions.length === 0 && tools?.every(tool => (READ_ONLY_TOOLS as readonly string[]).includes(tool));
180
+ if (lean) extensions.push("--no-extensions", "--extension", ownExtension);
174
181
  // Worktree cwd/session forks (resumed ones too) do not inherit project extension discovery from the parent.
175
- if (session?.source) {
176
- const ownExtension = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
177
- if (!extensions.includes(ownExtension)) extensions.push("--extension", ownExtension);
178
- }
182
+ else if (session?.source && !extensions.includes(ownExtension)) extensions.push("--extension", ownExtension);
179
183
  const args = ["--mode", "rpc", ...sessionArgs, ...extensions];
180
184
  if (model) args.push("--model", model);
181
185
  if (thinking) args.push("--thinking", thinking);
182
- const tools = session?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
183
186
  if (tools) args.push("--tools", tools.join(","));
184
187
  return args;
185
188
  }
@@ -846,12 +849,11 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
846
849
  // Hibernated: there is no process to stop, so the agent simply ends here.
847
850
  if (!proc) { finish("done"); return; }
848
851
  if (run.state === "working" && !proc.hibernating) {
849
- const waiting = waitSettled();
850
852
  send({ type: "clear_queue" });
851
- if (send({ type: "abort" })) {
852
- if (!await settlesWithin(waiting, 3000) && !finished) hardStop();
853
- } else hardStop();
853
+ if (!send({ type: "abort" })) hardStop();
854
854
  }
855
+ // EOF follows the queued abort in the same pipe. Pi can now dispose immediately;
856
+ // do not spend an abort grace and then another full process-exit grace serially.
855
857
  const stdin = proc.child.stdin;
856
858
  if (stdin && !stdin.writableEnded) stdin.end();
857
859
  const killer = setTimeout(() => { if (proc.child.exitCode === null) hardStop(); }, 3000);
@@ -1256,6 +1258,9 @@ const peekRecord = (record: SubagentRecord): string => {
1256
1258
  const settledRecord = (record: SubagentRecord): string =>
1257
1259
  peekRecord(record) + (record.run.output ? `\nlast: ${cleanBlock(record.run.output, 1200)}` : "");
1258
1260
 
1261
+ /** One queued moderator notification; several coalesce into a single pi-jar.subagent message. */
1262
+ interface SubagentEvent { record: SubagentRecord; kind: "turn" | "control"; label: string; content: string; detail: Record<string, unknown> }
1263
+
1259
1264
  /**
1260
1265
  * Every active worktree agent may still end up needing recovery, so admission reserves a recovery slot for
1261
1266
  * it: unresolved recovery workspaces never exceed RECOVERY_LIMIT, and none is ever deleted to make room.
@@ -1334,29 +1339,58 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1334
1339
  (raw as { customType?: string }).customType !== "pi-jar.moderator-context" || (active && index === latest)) };
1335
1340
  });
1336
1341
 
1337
- /** Native RPC drives registry state; Pi's message bus delivers frozen completion events to the moderator. */
1342
+ /** Native RPC drives registry state. Completion events wait in our own queue, never Pi's irrevocable
1343
+ * follow-up queue: while the moderator runs they join the next turn boundary so its final answer already
1344
+ * reflects them, instead of re-waking it after that answer. Only events arriving while idle wake a turn. */
1338
1345
  const eventBusAvailable = typeof pi.sendMessage === "function";
1339
1346
  const watched = new Map<string, SubagentRecord>();
1340
- const settledQueue = new Map<string, { record: SubagentRecord; content: string; turn: number }>();
1347
+ const pendingEvents = new Map<string, SubagentEvent>();
1341
1348
  const deleteSettled = (key: string) => {
1342
- for (const [eventKey, event] of settledQueue) if (event.record.key === key) settledQueue.delete(eventKey);
1349
+ for (const [id, event] of pendingEvents) if (event.kind === "turn" && event.record.key === key) pendingEvents.delete(id);
1343
1350
  };
1344
- let notifyTimer: NodeJS.Timeout | undefined;
1345
- const flushSettled = () => {
1346
- notifyTimer = undefined;
1347
- const records = [...settledQueue.values()].filter(({ record }) => registry.get(record.key) === record);
1348
- settledQueue.clear();
1349
- if (!records.length) return;
1350
- try {
1351
- pi.sendMessage?.({
1352
- customType: SUBAGENT_MESSAGE, display: true, details: {
1353
- keys: [...new Set(records.map(({ record }) => record.key))],
1354
- turns: records.map(({ record, turn }) => ({ key: record.key, turn }))
1355
- },
1356
- content: ["[PI-JAR SUBAGENT] A subagent turn ended. Review, then resume, ask or stop:", ...records.map(event => event.content)].join("\n\n")
1357
- }, { triggerTurn: true, deliverAs: "followUp" });
1358
- } catch (error) { console.error("pi-jar: could not deliver a subagent event", error); }
1351
+ let moderatorActive = false;
1352
+ let moderatorInterrupted = false;
1353
+ let wakeTimer: NodeJS.Timeout | undefined;
1354
+ const takeEvents = () => {
1355
+ clearTimeout(wakeTimer);
1356
+ wakeTimer = undefined;
1357
+ const events = [...pendingEvents.values()].filter(({ record }) => registry.get(record.key) === record);
1358
+ pendingEvents.clear();
1359
+ if (!events.length) return;
1360
+ const summary = cleanText(`Subagents · ${events.map(event => event.label).join(" · ")}`, 160);
1361
+ return {
1362
+ customType: SUBAGENT_MESSAGE, display: true, details: { summary, events: events.map(event => event.detail) },
1363
+ content: [`[PI-JAR SUBAGENT] ${summary}`, ...events.map(event => event.content), "Review, then resume, ask or stop retained agents as needed."].join("\n\n")
1364
+ };
1365
+ };
1366
+ const scheduleWake = () => {
1367
+ if (moderatorActive || moderatorInterrupted || wakeTimer || !pendingEvents.size) return;
1368
+ wakeTimer = setTimeout(() => {
1369
+ wakeTimer = undefined;
1370
+ if (moderatorActive || moderatorInterrupted) return;
1371
+ const message = takeEvents();
1372
+ if (!message) return;
1373
+ try { pi.sendMessage?.(message, { triggerTurn: true }); }
1374
+ catch (error) { console.error("pi-jar: could not deliver a subagent event", error); }
1375
+ }, NOTIFY_COALESCE_MS);
1376
+ wakeTimer.unref?.();
1377
+ };
1378
+ const queueEvent = (id: string, event: SubagentEvent) => { pendingEvents.set(id, event); scheduleWake(); };
1379
+ const boundary = (event: { outcome?: string; context?: { canContinue?: boolean } }) => {
1380
+ if (event.outcome !== "completed" || event.context?.canContinue === false) return;
1381
+ const message = takeEvents();
1382
+ if (message) return { entries: [{ type: "custom_message" as const, ...message }], continue: true };
1359
1383
  };
1384
+ pi.registerMessageRenderer?.<{ summary?: string }>(SUBAGENT_MESSAGE, (message, { expanded }, theme) => {
1385
+ const text = typeof message.content === "string" ? message.content : "Subagents";
1386
+ const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Subagents";
1387
+ return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for report"), 0, 0);
1388
+ });
1389
+ pi.on?.("agent_start", () => { moderatorActive = true; moderatorInterrupted = false; clearTimeout(wakeTimer); wakeTimer = undefined; });
1390
+ pi.on?.("turn_end", boundary);
1391
+ pi.on?.("agent_before_settle", (event) => { moderatorInterrupted = event.outcome !== "completed"; return boundary(event); });
1392
+ // An interrupted run keeps its events for the user's next prompt rather than auto-waking.
1393
+ pi.on?.("agent_settled", () => { moderatorActive = false; scheduleWake(); });
1360
1394
  registry.subscribe(() => {
1361
1395
  if (!watched.size) return;
1362
1396
  for (const [key, record] of watched) {
@@ -1365,14 +1399,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1365
1399
  watched.delete(key);
1366
1400
  if (record.run.state !== "stopped") {
1367
1401
  // Freeze reports at the settled boundary. A fast resume must not overwrite a queued
1368
- // initial report, and multiple turns in the coalescing window must each be delivered.
1369
- settledQueue.set(`${key}:${record.run.resumes}`, { record, turn: record.run.resumes, content: settledRecord(record) });
1402
+ // initial report, and multiple turns before delivery must each be delivered.
1403
+ queueEvent(`${key}:${record.run.resumes}`, { record, kind: "turn", label: `${record.run.name} turn ended`,
1404
+ content: settledRecord(record), detail: { key, turn: record.run.resumes } });
1370
1405
  }
1371
1406
  }
1372
- if (settledQueue.size && !notifyTimer) {
1373
- notifyTimer = setTimeout(flushSettled, NOTIFY_COALESCE_MS);
1374
- notifyTimer.unref?.();
1375
- }
1376
1407
  });
1377
1408
 
1378
1409
  const delegateTool: ToolDefinition<typeof Parameters> = {
@@ -1872,13 +1903,12 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1872
1903
  const deliverControl = (record: SubagentRecord, operation: { id: string; action: string }, result: { content: Array<{ text: string }>; details?: unknown; isError?: boolean }) => {
1873
1904
  // A session switch discards the old fleet. Never wake a new session with stale handoffs.
1874
1905
  if (registry.get(record.key) !== record) return;
1875
- try {
1876
- pi.sendMessage({
1877
- customType: SUBAGENT_MESSAGE, display: true,
1878
- details: { operationId: operation.id, action: operation.action, key: record.key, status: result.isError ? "failed" : "completed", report: result.details },
1879
- content: `[PI-JAR SUBAGENT] ${operation.action} ${operation.id} completed:\n${result.content.map(part => part.text).join("\n")}`
1880
- }, { triggerTurn: true, deliverAs: "followUp" });
1881
- } catch (error) { console.error("pi-jar: could not deliver a control event", error); }
1906
+ const status = result.isError ? "failed" : "completed";
1907
+ // The stop handoff supersedes any queued turn report for the same agent.
1908
+ if (operation.action === "stop") deleteSettled(record.key);
1909
+ queueEvent(operation.id, { record, kind: "control", label: `${operation.action} ${record.run.name} ${status}`,
1910
+ content: `${operation.action} ${operation.id} ${status}:\n${result.content.map(part => part.text).join("\n")}`,
1911
+ detail: { operationId: operation.id, action: operation.action, key: record.key, status, report: result.details } });
1882
1912
  };
1883
1913
 
1884
1914
  pi.registerTool({
@@ -139,7 +139,12 @@ function writeConfig(file: string, config: RoleConfig): void {
139
139
  }
140
140
  }
141
141
 
142
- export const projectRoleFile = (cwd: string) => join(cwd, ".pi", ROLE_FILE);
142
+ function profileRolePath(directory: string, profileId = "default"): string {
143
+ if (!/^[a-z0-9-]+$/.test(profileId)) throw new Error("Invalid profile ID");
144
+ return profileId === "default" ? join(directory, ROLE_FILE) : join(directory, "pi-jar-profiles", `${profileId}-roles.json`);
145
+ }
146
+ export const profileRoleFile = (directory: string, profileId?: string) => profileRolePath(directory, profileId);
147
+ export const projectRoleFile = (cwd: string, profileId?: string) => profileRolePath(join(cwd, ".pi"), profileId);
143
148
 
144
149
  export function describeRole(row: RoleRow): string {
145
150
  const target = row.error ? "⚠ " + row.error
@@ -152,6 +157,7 @@ export class ModelRoleManager {
152
157
  private global: RoleConfig = readConfig(join(getAgentDir(), ROLE_FILE));
153
158
  private project: RoleConfig = { version: 2, roles: {} };
154
159
  private cwd: string | undefined;
160
+ private profileId = "default";
155
161
  private active: string | undefined;
156
162
  private readonly pi: ExtensionAPI;
157
163
  /** True while pi-jar itself is switching models, so its own changes are not seen as manual. */
@@ -166,11 +172,21 @@ export class ModelRoleManager {
166
172
  }
167
173
 
168
174
  /** Reload global and project files; project assignments override global ones per role. */
169
- load(cwd?: string): void {
170
- this.global = readConfig(join(getAgentDir(), ROLE_FILE));
175
+ load(cwd?: string, profileRoles?: RoleConfig, profileId = "default"): void {
176
+ const profileFile = profileRoleFile(getAgentDir(), profileId);
177
+ this.global = profileRoles && !existsSync(profileFile) ? parseRoleConfig(profileRoles) : readConfig(profileFile);
171
178
  this.cwd = cwd;
172
- this.project = cwd ? readConfig(projectRoleFile(cwd)) : { version: 2, roles: {} };
179
+ this.profileId = profileId;
180
+ this.project = cwd ? readConfig(projectRoleFile(cwd, profileId)) : { version: 2, roles: {} };
181
+ }
182
+ activeProfileId(): string { return this.profileId; }
183
+ /** Restore the label only, after a failed profile transaction restored model and effort. */
184
+ restoreActiveRole(role: string | undefined, ctx: ExtensionContext): void {
185
+ if (role !== undefined && !isRoleName(role)) throw new Error("Invalid role name");
186
+ this.active = role;
187
+ this.status(ctx);
173
188
  }
189
+ profileRoleConfig(): RoleConfig { return structuredClone(this.global); }
174
190
 
175
191
  private merged(): Record<string, string> { return { ...this.global.roles, ...this.project.roles }; }
176
192
  activeRole(): string | undefined { return this.active; }
@@ -288,7 +304,7 @@ export class ModelRoleManager {
288
304
  const epoch = this.manualEpoch;
289
305
  return async () => {
290
306
  // A model or effort the user picked during the workflow wins over the saved one.
291
- if (this.manualEpoch !== epoch) { this.active = undefined; this.status(ctx); return; }
307
+ if (this.manualEpoch !== epoch) return;
292
308
  this.applying = true;
293
309
  try { if (previousModel) await this.pi.setModel(previousModel); this.pi.setThinkingLevel(previousThinking); }
294
310
  finally { this.applying = false; }
@@ -310,6 +326,9 @@ export class ModelRoleManager {
310
326
  return await this.activate(next, ctx) ? next : undefined;
311
327
  }
312
328
 
329
+ /** Invalidate temporary role restorations after a profile change. */
330
+ invalidateTemporaryRestores(): void { this.manualEpoch++; this.active = undefined; }
331
+
313
332
  /** Assign (or clear with undefined) a role in the chosen scope. */
314
333
  update(role: string, spec: string | undefined, scope: RoleScope = this.scopeOf(role) ?? "global"): void {
315
334
  if (!isRoleName(role)) throw new Error("Invalid role name: " + role);
@@ -320,7 +339,7 @@ export class ModelRoleManager {
320
339
  const roles = { ...target.roles };
321
340
  if (normalized) roles[role] = normalized; else delete roles[role];
322
341
  const next: RoleConfig = { ...target, version: 2, roles };
323
- writeConfig(scope === "project" ? projectRoleFile(this.cwd!) : join(getAgentDir(), ROLE_FILE), next);
342
+ writeConfig(scope === "project" ? projectRoleFile(this.cwd!, this.profileId) : profileRoleFile(getAgentDir(), this.profileId), next);
324
343
  if (scope === "project") this.project = next; else this.global = next;
325
344
  }
326
345
 
@@ -346,11 +365,11 @@ export class ModelRoleManager {
346
365
  if (scope === "project" && !this.cwd) throw new Error("Project roles need a working directory");
347
366
  const target = scope === "project" ? this.project : this.global;
348
367
  const next: RoleConfig = { ...target, fallbacks: { ...target.fallbacks, [role]: [...new Set(normalized)] } };
349
- writeConfig(scope === "project" ? projectRoleFile(this.cwd!) : join(getAgentDir(), ROLE_FILE), next);
368
+ writeConfig(scope === "project" ? projectRoleFile(this.cwd!, this.profileId) : profileRoleFile(getAgentDir(), this.profileId), next);
350
369
  if (scope === "project") this.project = next; else this.global = next;
351
370
  }
352
371
 
353
- register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean } = {}): void {
372
+ register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean; beforeSessionStart?: (ctx: ExtensionContext) => { roles?: RoleConfig; profileId?: string } | undefined } = {}): void {
354
373
  const manual = (_event: unknown, ctx: ExtensionContext) => {
355
374
  if (this.applying) return;
356
375
  this.manualEpoch++;
@@ -359,7 +378,8 @@ export class ModelRoleManager {
359
378
  this.pi.on("model_select", manual);
360
379
  this.pi.on("thinking_level_select", manual);
361
380
  this.pi.on("session_start", async (_event, ctx) => {
362
- this.load(ctx.cwd);
381
+ const profile = options.beforeSessionStart?.(ctx);
382
+ this.load(ctx.cwd, profile?.roles, profile?.profileId);
363
383
  this.active = undefined;
364
384
  // Delegated children already received their role model/effort via CLI arguments.
365
385
  if (options.activateDefault?.() !== false && this.resolveCandidates("default").length) await this.activate("default", ctx, true);
@@ -0,0 +1,58 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { JarProfile, ProfileStore } from "./profiles.ts";
3
+
4
+ export type ProfileAction = "create" | "switch";
5
+
6
+ export interface ProfileUiOptions {
7
+ store: ProfileStore;
8
+ themes: readonly string[];
9
+ /** Copies the active profile's settings and roles into a new profile. */
10
+ create(name: string, theme: string): JarProfile;
11
+ /** Why the current session cannot switch profiles, or undefined when it can. */
12
+ switchBlocked(): string | undefined;
13
+ select(name: string): Promise<void>;
14
+ openRoles(): Promise<void>;
15
+ }
16
+
17
+ const CREATE = "+ Create profile…";
18
+ const FOLLOW = "Follow Pi";
19
+
20
+ /** Name and theme are the only creation questions; roles are configured afterwards in the roles UI. */
21
+ export async function promptNewProfile(ctx: ExtensionContext, options: Pick<ProfileUiOptions, "store" | "themes" | "create">): Promise<JarProfile | undefined> {
22
+ const name = await ctx.ui.input("New profile name (1–32 characters)", "");
23
+ if (name === undefined) return undefined;
24
+ if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) { ctx.ui.notify("Profile name cannot contain control characters", "warning"); return undefined; }
25
+ const normalized = name.trim().replace(/\s+/g, " ");
26
+ if (!normalized || normalized.length > 32 || /[\\/\0]/.test(normalized)) {
27
+ ctx.ui.notify("Profile name must be 1–32 characters and cannot contain slashes", "warning"); return undefined;
28
+ }
29
+ if (options.store.list().some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) {
30
+ ctx.ui.notify("A profile with that name already exists", "warning"); return undefined;
31
+ }
32
+ const selected = await ctx.ui.select("Choose a theme for " + normalized, [FOLLOW, ...options.themes.filter((item) => item !== "follow")]);
33
+ if (!selected) return undefined;
34
+ try { return options.create(normalized, selected === FOLLOW ? "follow" : selected); }
35
+ catch (error) { ctx.ui.notify("Could not create profile: " + (error as Error).message, "error"); return undefined; }
36
+ }
37
+
38
+ /** /profiles and the settings Profiles tab: create a profile or switch to one, nothing else. */
39
+ export async function openProfiles(ctx: ExtensionContext, options: ProfileUiOptions, action?: ProfileAction): Promise<void> {
40
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
41
+ const active = options.store.activeName;
42
+ if (action !== "create") {
43
+ const labels = new Map(options.store.list().map((profile) => [profile.name === active ? `${profile.name} (active)` : profile.name, profile.name]));
44
+ const choice = await ctx.ui.select(action === "switch" ? "Switch profile" : "Profiles", action === "switch" ? [...labels.keys()] : [CREATE, ...labels.keys()]);
45
+ if (!choice) return;
46
+ if (choice !== CREATE) { await options.select(labels.get(choice)!); return; }
47
+ }
48
+ const profile = await promptNewProfile(ctx, options);
49
+ if (!profile) return;
50
+ if (options.switchBlocked()) {
51
+ ctx.ui.notify(`Created profile ${profile.name} with ${active}'s model roles. Switch to it in a fresh session with /profiles, then set its roles with /roles.`, "info");
52
+ return;
53
+ }
54
+ await options.select(profile.name);
55
+ if (options.store.activeName !== profile.name) return;
56
+ ctx.ui.notify(`Created profile ${profile.name}. Its model roles start as a copy of ${active}'s; set them now in Roles (later: /roles).`, "info");
57
+ await options.openRoles();
58
+ }
@@ -0,0 +1,148 @@
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isRoleName, normalizeSpec, parseRoleConfig, type RoleConfig } from "./model-roles.ts";
4
+ import { isMaxSubagents, defaultVisualSettings, loadVisualSettings, type JarVisualSettings } from "./settings.ts";
5
+ import { ACCENT_NAMES } from "./accent.ts";
6
+ import { FOOTER_FIELDS } from "./footer-settings.ts";
7
+ import { ICON_SETS } from "./icons.ts";
8
+
9
+ export interface JarProfile { id: string; name: string; theme: string; settings: JarVisualSettings; roles: RoleConfig }
10
+ interface ProfileDocument { version: 1; active: string; profiles: JarProfile[] }
11
+ export const PROFILE_FILE = "pi-jar-profiles.json";
12
+ export const DEFAULT_PROFILE = "Default";
13
+ export const PROFILE_ENTRY = "pi-jar.profile";
14
+ export function conversationStarted(entries: readonly unknown[]): boolean {
15
+ return entries.some((entry) => isPlainObject(entry) && (entry.role === "user"
16
+ || (entry.type === "message" && isPlainObject(entry.message) && entry.message.role === "user")));
17
+ }
18
+ export function pinnedProfileId(entries: readonly unknown[]): string | undefined {
19
+ for (const entry of [...entries].reverse()) {
20
+ if (isPlainObject(entry) && entry.type === "custom" && entry.customType === PROFILE_ENTRY
21
+ && isPlainObject(entry.data) && entry.data.version === 1 && typeof entry.data.id === "string") return entry.data.id;
22
+ }
23
+ return undefined;
24
+ }
25
+ const safeName = (value: string) => value.trim().replace(/\s+/g, " ");
26
+ const profileId = () => `profile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
27
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
28
+
29
+ function validProfileName(value: unknown): value is string {
30
+ return typeof value === "string" && value.length > 0 && value.length <= 32 && !/[\\/\0\u0001-\u001f\u007f-\u009f]/.test(value) && value.trim() === value;
31
+ }
32
+ function normalizeProfile(value: unknown): JarProfile | undefined {
33
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
34
+ const raw = value as Record<string, unknown>;
35
+ if (!validProfileName(raw.name) || typeof raw.theme !== "string" || !raw.theme || !isPlainObject(raw.settings)
36
+ || raw.settings.version !== 1 || !isPlainObject(raw.roles) || raw.roles.version !== 2 || !isPlainObject(raw.roles.roles)) return undefined;
37
+ const settings = raw.settings;
38
+ const footer = settings.footer;
39
+ if (!["accent", "animations", "ui", "composer", "mascot", "suggestions", "goalRounds", "maxSubagents", "advisor", "advisorGates", "icons", "footer"].every((key) => key in settings)
40
+ || !isPlainObject(settings.footer) || typeof settings.animations !== "boolean" || typeof settings.ui !== "boolean"
41
+ || typeof settings.composer !== "boolean" || typeof settings.mascot !== "boolean" || typeof settings.suggestions !== "boolean"
42
+ || typeof settings.advisor !== "boolean" || typeof settings.advisorGates !== "boolean"
43
+ || (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean") || !Number.isInteger(settings.goalRounds)
44
+ || (settings.goalRounds as number) < 1 || (settings.goalRounds as number) > 50
45
+ || !isMaxSubagents(settings.maxSubagents) || typeof settings.accent !== "string"
46
+ || !(settings.accent === "follow" || settings.accent === "default" || ACCENT_NAMES.includes(settings.accent as never))
47
+ || !ICON_SETS.includes(settings.icons as never)
48
+ || !isPlainObject(footer) || !FOOTER_FIELDS.every((field) => typeof footer[field] === "boolean")) return undefined;
49
+ if (raw.name === DEFAULT_PROFILE && raw.id !== "default") return undefined;
50
+ if (raw.name !== DEFAULT_PROFILE && (typeof raw.id !== "string" || !/^profile-[a-z0-9-]{8,64}$/.test(raw.id))) return undefined;
51
+ for (const [role, spec] of Object.entries(raw.roles.roles)) if (!isRoleName(role) || typeof spec !== "string" || !normalizeSpec(spec)) return undefined;
52
+ return { id: raw.id as string, name: raw.name, theme: raw.theme, settings: { ...raw.settings, contextDiet: settings.contextDiet ?? false } as unknown as JarVisualSettings, roles: parseRoleConfig(raw.roles) };
53
+ }
54
+ function atomicSave(file: string, document: ProfileDocument): string {
55
+ mkdirSync(join(file, ".."), { recursive: true });
56
+ const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
57
+ const text = JSON.stringify(document, null, 2) + "\n";
58
+ try { writeFileSync(temporary, text, { flag: "wx" }); renameSync(temporary, file); }
59
+ catch (error) { try { unlinkSync(temporary); } catch {} throw error; }
60
+ return text;
61
+ }
62
+
63
+ export class ProfileStore {
64
+ private document: ProfileDocument;
65
+ private savedContents: string | undefined;
66
+ readonly directory: string;
67
+ constructor(directory: string, initialTheme = "pi-jar-dark", initialRoles: RoleConfig = { version: 2, roles: {} }) {
68
+ this.directory = directory;
69
+ const file = join(directory, PROFILE_FILE);
70
+ let loaded: ProfileDocument | undefined;
71
+ let missing = false;
72
+ try {
73
+ this.savedContents = readFileSync(file, "utf8");
74
+ const raw: unknown = JSON.parse(this.savedContents);
75
+ if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.profiles) || typeof raw.active !== "string") throw new Error("Unsupported or malformed pi-jar profiles file");
76
+ const profiles: JarProfile[] = [];
77
+ const names = new Set<string>(); const ids = new Set<string>();
78
+ for (const entry of raw.profiles) {
79
+ const profile = normalizeProfile(entry);
80
+ if (!profile) throw new Error("Malformed profile entry");
81
+ const name = profile.name.toLocaleLowerCase();
82
+ if (names.has(name) || ids.has(profile.id)) throw new Error("Duplicate profile name or id");
83
+ names.add(name); ids.add(profile.id); profiles.push(profile);
84
+ }
85
+ if (profiles.filter((profile) => profile.name === DEFAULT_PROFILE && profile.id === "default").length !== 1) throw new Error("Missing or invalid Default profile");
86
+ const active = profiles.find((profile) => profile.name === raw.active || profile.id === raw.active)?.name;
87
+ if (!active) throw new Error("Active profile does not exist");
88
+ loaded = { version: 1, active, profiles };
89
+ } catch (error) {
90
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") missing = true;
91
+ else throw new Error(`Cannot load ${PROFILE_FILE}: ${(error as Error).message}`);
92
+ }
93
+ if (!loaded && missing) {
94
+ let legacy = defaultVisualSettings();
95
+ try { legacy = loadVisualSettings(directory); } catch { /* default settings */ }
96
+ loaded = { version: 1, active: DEFAULT_PROFILE, profiles: [{ id: "default", name: DEFAULT_PROFILE, theme: initialTheme, settings: legacy, roles: parseRoleConfig(initialRoles) }] };
97
+ }
98
+ this.document = loaded!;
99
+ if (missing) this.persist();
100
+ }
101
+ private persist(next = this.document): void {
102
+ const file = join(this.directory, PROFILE_FILE);
103
+ let current: string | undefined;
104
+ try { current = readFileSync(file, "utf8"); }
105
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
106
+ if (current !== this.savedContents) throw new Error("Profiles changed in another session or externally; reload before saving");
107
+ this.savedContents = atomicSave(file, next);
108
+ }
109
+ list(): JarProfile[] { return this.document.profiles.map((profile) => structuredClone(profile)); }
110
+ active(): JarProfile { return structuredClone(this.document.profiles.find((profile) => profile.name === this.document.active)!); }
111
+ get activeName(): string { return this.document.active; }
112
+ update(name: string, patch: Partial<Pick<JarProfile, "theme" | "settings">>): JarProfile {
113
+ const index = this.document.profiles.findIndex((profile) => profile.name === name);
114
+ if (index < 0) throw new Error("Unknown profile: " + name);
115
+ const profiles = [...this.document.profiles];
116
+ const profile = normalizeProfile(structuredClone({ ...profiles[index]!, ...patch }));
117
+ if (!profile) throw new Error("Invalid profile settings or theme");
118
+ profiles[index] = profile;
119
+ const next = { ...this.document, profiles };
120
+ this.persist(next);
121
+ this.document = next;
122
+ return structuredClone(profile);
123
+ }
124
+ add(name: string, theme: string, settings: JarVisualSettings, roles: RoleConfig = { version: 2, roles: {} }): JarProfile {
125
+ if (typeof name !== "string" || /[\u0000-\u001f\u007f-\u009f]/.test(name)) throw new Error("Profile name cannot contain control characters");
126
+ const normalized = safeName(name);
127
+ if (!validProfileName(normalized)) throw new Error("Profile name must be 1–32 characters and cannot contain slashes");
128
+ if (this.document.profiles.some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) throw new Error("A profile with that name already exists");
129
+ const id = profileId();
130
+ const profile = normalizeProfile({ id, name: normalized, theme, settings: structuredClone(settings), roles });
131
+ if (!profile) throw new Error("Invalid profile settings, theme, or roles");
132
+ const next = { ...this.document, profiles: [...this.document.profiles, profile] };
133
+ this.persist(next);
134
+ this.document = next;
135
+ return structuredClone(profile);
136
+ }
137
+ activate(name: string): JarProfile {
138
+ if (!this.document.profiles.some((profile) => profile.name === name)) throw new Error("Unknown profile: " + name);
139
+ const next = { ...this.document, active: name };
140
+ this.persist(next);
141
+ this.document = next;
142
+ return this.active();
143
+ }
144
+ cycle(): JarProfile {
145
+ const index = this.document.profiles.findIndex((profile) => profile.name === this.document.active);
146
+ return this.activate(this.document.profiles[(index + 1) % this.document.profiles.length]!.name);
147
+ }
148
+ }
@@ -3,6 +3,7 @@ import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } fr
3
3
  import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
4
4
  import { ICON_SETS } from "./icons.ts";
5
5
  import { GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
6
+ import type { ProfileAction } from "./profile-ui.ts";
6
7
 
7
8
  const LABELS: Record<FooterField, string> = {
8
9
  model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory",
@@ -10,8 +11,7 @@ const LABELS: Record<FooterField, string> = {
10
11
  extras: "Extension statuses", branch: "Git branch"
11
12
  };
12
13
 
13
- type Page = "appearance" | "footer" | "pi";
14
- const PAGES: Page[] = ["appearance", "footer", "pi"];
14
+ type Page = "appearance" | "footer" | "pi" | "profiles";
15
15
 
16
16
  /** Pi-level preferences pi-jar can toggle (written to Pi's own settings). */
17
17
  export interface PiPreferences {
@@ -26,18 +26,22 @@ export async function openJarSettings(
26
26
  current: () => JarVisualSettings,
27
27
  update: (settings: JarVisualSettings) => void,
28
28
  availableAccents: readonly string[],
29
- pi?: PiPreferences
29
+ pi?: PiPreferences,
30
+ profiles?: { activeName(): string; open(action: ProfileAction): Promise<void> }
30
31
  ): Promise<void> {
31
32
  if (!ctx.hasUI || ctx.mode !== "tui") return;
32
- await ctx.ui.custom<void>((tui, theme, _keys, done) => {
33
- let page: Page = "appearance";
33
+ const PAGES: Page[] = profiles ? ["appearance", "footer", "pi", "profiles"] : ["appearance", "footer", "pi"];
34
+ let lastPage: Page = "appearance";
35
+ while (true) {
36
+ const action = await ctx.ui.custom<ProfileAction | undefined>((tui, theme, _keys, done) => {
37
+ let page: Page = lastPage;
34
38
  let selected = 0;
35
39
  let width = 64;
36
40
  const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
37
41
  name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
38
42
  let choosing: "goalRounds" | "maxSubagents" | undefined;
39
43
  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;
44
+ const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 7 : page === "profiles" ? 2 : 7;
41
45
  const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : 2; choosing = undefined; };
42
46
  const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
43
47
  const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
@@ -50,6 +54,10 @@ export async function openJarSettings(
50
54
  if (choosing === "maxSubagents") update({ ...state, maxSubagents: MAX_SUBAGENT_CHOICES[index]! });
51
55
  else update({ ...state, goalRounds: GOAL_ROUND_CHOICES[index]! });
52
56
  finishChoice();
57
+ } else if (page === "profiles") {
58
+ lastPage = page;
59
+ done(index === 0 ? "switch" : "create");
60
+ return;
53
61
  } else if (page === "footer") {
54
62
  const field = FOOTER_FIELDS[index];
55
63
  if (field) update({ ...state, footer: { ...state.footer, [field]: !state.footer[field] } });
@@ -66,6 +74,7 @@ export async function openJarSettings(
66
74
  }
67
75
  else if (index === 4) update({ ...state, advisor: !state.advisor });
68
76
  else if (index === 5) update({ ...state, advisorGates: !state.advisorGates });
77
+ else if (index === 6) update({ ...state, contextDiet: !state.contextDiet });
69
78
  } else if (index === 0) {
70
79
  const at = Math.max(0, accents.indexOf(state.accent));
71
80
  update({ ...state, accent: accents[(at + 1) % accents.length]! });
@@ -90,7 +99,7 @@ export async function openJarSettings(
90
99
  invalidate() {},
91
100
  handleInput(data: string) {
92
101
  if (matchesKey(data, Key.escape) || data === "q") {
93
- if (choosing) { finishChoice(); tui.requestRender(); } else done();
102
+ if (choosing) { finishChoice(); tui.requestRender(); } else done(undefined);
94
103
  return;
95
104
  }
96
105
  if (choosing && (matchesKey(data, Key.tab) || matchesKey(data, Key.right)
@@ -112,19 +121,20 @@ export async function openJarSettings(
112
121
  tui.requestRender(); return { handled: true };
113
122
  }
114
123
  if (event.type !== "click" || event.button !== "left") return;
115
- if (event.y === 0 && event.x >= width - 4) { done(); return { handled: true }; }
124
+ if (event.y === 0 && event.x >= width - 4) { done(undefined); return { handled: true }; }
116
125
  if (event.y === 1) {
117
126
  choosing = undefined;
118
127
  if (event.x >= 2 && event.x < Math.min(width - 2, 19)) { page = "appearance"; selected = 0; }
119
128
  else if (event.x >= 19 && event.x < Math.min(width - 2, 32)) { page = "footer"; selected = 0; }
120
- else if (event.x >= 32 && event.x < width - 2) { page = "pi"; selected = 0; }
129
+ else if (event.x >= 32 && event.x < Math.min(width - 2, 41)) { page = "pi"; selected = 0; }
130
+ else if (profiles && event.x >= 41 && event.x < width - 2) { page = "profiles"; selected = 0; }
121
131
  else return;
122
132
  tui.requestRender(); return { handled: true, focus: true };
123
133
  }
124
134
  if (event.y >= 5 && event.y < 5 + visibleCount() && event.x >= 2 && event.x < width - 2) {
125
135
  selected = firstVisible() + event.y - 5; apply(selected); return { handled: true, focus: true };
126
136
  }
127
- if (event.y === 5 + visibleCount() && event.x >= 2 && event.x < width - 2) { done(); return { handled: true }; }
137
+ if (event.y === 5 + visibleCount() && event.x >= 2 && event.x < width - 2) { done(undefined); return { handled: true }; }
128
138
  },
129
139
  render(available: number): string[] {
130
140
  width = Math.max(8, available);
@@ -136,7 +146,8 @@ export async function openJarSettings(
136
146
  + "─".repeat(Math.max(0, width - 4 - visibleWidth(title))) + "×╮");
137
147
  const tabs = line(theme.fg(page === "appearance" ? "accent" : "muted", "[ Appearance ]")
138
148
  + " " + theme.fg(page === "footer" ? "accent" : "muted", "[ Footer ]")
139
- + " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]"));
149
+ + " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]")
150
+ + (profiles ? " " + theme.fg(page === "profiles" ? "accent" : "muted", "[ Profiles ]") : ""));
140
151
  const prefs = pi?.get();
141
152
  const divider = theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 2)) + "┤");
142
153
  const allRows = choosing
@@ -149,7 +160,11 @@ export async function openJarSettings(
149
160
  row(2, "Goal auto rounds", String(state.goalRounds), true),
150
161
  row(3, "Max subagents (retained live pool)", String(state.maxSubagents), true),
151
162
  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)
163
+ row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates),
164
+ row(6, "Context diet (prior-turn reasoning)", state.contextDiet ? "ON" : "OFF", state.contextDiet)
165
+ ] : page === "profiles" ? [
166
+ row(0, "Switch profile…", profiles!.activeName(), true),
167
+ row(1, "Create profile…", "OPEN", true)
153
168
  ] : [
154
169
  row(0, "Accent", state.accent === "follow" ? "FOLLOW PI" : state.accent.toUpperCase(), true),
155
170
  row(1, "Motion", state.animations ? "ON" : "OFF", state.animations),
@@ -163,11 +178,12 @@ export async function openJarSettings(
163
178
  const rows = allRows.slice(start, start + pageSize());
164
179
  return [top, tabs, divider,
165
180
  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")),
181
+ : choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : page === "profiles" ? " PROFILES" : " APPEARANCE & MOTION")),
167
182
  line(theme.fg("dim", explainPool() ? " Live pool includes idle/paused subagents"
168
183
  : choosing ? " Select a value; Enter/click to save, Esc to cancel"
169
184
  : page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
170
- : page === "pi" ? " Fullscreen mode enables clicks and copy-on-select" : " Accent cycles through loaded themes")),
185
+ : page === "pi" ? " Fullscreen mode enables clicks and copy-on-select"
186
+ : page === "profiles" ? " Switch only in a fresh, idle session; roles via /roles" : " Accent cycles through loaded themes")),
171
187
  ...rows,
172
188
  line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
173
189
  : " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
@@ -176,4 +192,10 @@ export async function openJarSettings(
176
192
  }
177
193
  };
178
194
  });
195
+ if (!action || !profiles) return;
196
+ try { await profiles.open(action); }
197
+ catch (error) { ctx.ui.notify("Could not change profile: " + (error as Error).message, "error"); }
198
+ // A new profile hands off to the roles UI; reopening settings over it would bury that step.
199
+ if (action === "create") return;
200
+ }
179
201
  }