pi-jar 0.2.0 → 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/README.md CHANGED
@@ -56,6 +56,7 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
56
56
  | `/plan [request]` | Enter plan mode (optionally sending the request). `/plan review` reopens the plan view; `/plan off` exits. |
57
57
  | `/goal <outcome>` | Start goal mode. `/goal` edits, `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`. |
58
58
  | `/roles` | Role manager. `/roles set ROLE provider/model[:effort]\|@role [--project]`, `/roles clear ROLE`, `/roles <role>` activates, `/roles cycle`, `/roles list`. |
59
+ | `/profiles` | Create or switch profiles. `/profiles new` creates (name and theme, then roles), `/profiles <name>` switches. |
59
60
  | `/advisor [focus]` | Ask the advisor for a second opinion on the current work; the answer joins the conversation. |
60
61
  | `/usage` · `/context` | Usage (cost, tokens per model, plan limits) and context-window breakdown in one tabbed panel. |
61
62
  | `/jar commit [note]` | Draft a commit message for the staged changes with the `commit` role, edit it, and commit. Offers `git add -A` when nothing is staged. |
@@ -92,7 +93,7 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
92
93
 
93
94
  Profiles keep separate themes, appearance settings, and model-role configurations. **Default** preserves your existing setup.
94
95
 
95
- Open `/jar settings` (or **Ctrl+Alt+S**) and choose **Profiles** to create or select a custom profile, or change its theme. The wizard lets you name it, choose its theme, configure roles, and review before saving. Cancelling leaves your existing setup unchanged.
96
+ Run `/profiles` (or `/jar settings` → **Profiles**, **Ctrl+Alt+S**) to create a profile or switch to one. `/profiles new` creates directly and `/profiles <name>` switches. Creating asks only for a name and a theme; the new profile copies the current profile's appearance and model roles. In a fresh, idle session pi-jar then switches to it and opens the roles manager so you can set its models; otherwise it tells you to switch in a fresh session and run `/roles`. Cancelling leaves your existing setup unchanged.
96
97
 
97
98
  Press **Ctrl+Shift+Tab** to cycle profiles **only in a fresh, idle session**. Once the first prompt is sent, the session's profile is locked—even after the agent finishes—and restored when you resume it. Open a new session to switch. Active agents, subagents, and plan/goal workflows also block switching. Configure the selected profile's roles through `/roles`; changing one profile does not change another. The selected profile is remembered across restarts and shown on the welcome screen and beside the composer mascot: `mascot · (Profile) session_name`. Some terminals require an extended keyboard protocol to distinguish Ctrl+Shift+Tab.
98
99
 
@@ -65,7 +65,7 @@ Hidden context messages (`pi-jar.plan-context`, `pi-jar.plan-reminder`, `pi-jar.
65
65
  | `jar_delegate` | up to four parallel subagents (`tasks: [{task, name?, role?}]`, `write?`) |
66
66
  | `jar_advisor` | second opinion from the `advisor` role (`question?`, `draft?`) |
67
67
 
68
- `jar_shell` events arrive as a visible `pi-jar.shell` custom message that triggers (or queues) an agent turn; when a subagent turn started by `jar_subagent resume` ends, a visible `pi-jar.subagent` message does the same. `jar_delegate` runs child processes with `PI_JAR_CHILD=1`, which keeps pi-jar in the child from registering `jar_delegate` again, and publishes each running subagent through the role contract above as `pi-jar.role.delegate-<batch>-<n>`. The advisor publishes `pi-jar.role.advisor` while it is consulting, and its `/advisor` and gate answers arrive as visible `pi-jar.advisor` custom messages.
68
+ `jar_shell` events arrive as a visible `pi-jar.shell` custom message that triggers (or queues) an agent turn; subagent turn ends and `jar_subagent` control completions arrive as one coalesced, visible `pi-jar.subagent` message at the moderator's next turn boundary, or as a wake-up when it is idle. `jar_delegate` runs child processes with `PI_JAR_CHILD=1`, which keeps pi-jar in the child from registering `jar_delegate` again, and publishes each running subagent through the role contract above as `pi-jar.role.delegate-<batch>-<n>`. The advisor publishes `pi-jar.role.advisor` while it is consulting, and its `/advisor` and gate answers arrive as visible `pi-jar.advisor` custom messages.
69
69
 
70
70
  ## Commands that overlap other packages
71
71
 
package/docs/WORKFLOWS.md CHANGED
@@ -143,7 +143,7 @@ watch matches or process exits → pi-jar.shell message → agent wakes (o
143
143
  - `jar_delegate` shares a session-wide **Max subagents** setting in `/jar settings`: exactly 2, 4, 6, 8 or 16, default 4. All retained live children (including idle/paused and live legacy one-shots) count; the moderator does not. Concurrent/reentrant batches reserve capacity atomically and reject over-cap requests before any launch. Lowering the limit preserves the fleet and prevents further spawning until room is available. `scout` runs `pi --mode rpc --no-session`, is read-only and defaults to the `scout` role. `fork` uses `--fork <parent-session>`, is read-only and defaults to `reviewer`. `worktree` uses the same real context fork, defaults to `worker`, and runs in a detached disposable Git worktree. Explicit `task.role` overrides those defaults; each role uses its primary/fallback candidates before falling back to the parent's current model.
144
144
  - Retained children do not exit on `agent_settled`. They become `idle`, keeping the same RPC process and model context. `jar_subagent pause` sends `clear_queue` + `abort` and leaves the agent `paused`; `resume` sends a new prompt to that same child. `steer` redirects a working child. `ask` pauses an active task, crosses a settled boundary, then starts a compact BTW-only prompt, so the old task report cannot be mistaken for the answer. It preserves the prior paused state and assigned scope for later resume.
145
145
  - `jar_subagent peek` is the moderator's cheap status surface and reads only task progress from memory: key, name, state, done/total checklist leaves, the in-progress task, current activity and the next three open tasks (no git, transcript or report reads). `stop` is destructive/final: abort an active operation if necessary, close the child, return a handoff (workspace, changed/applied files, remaining work, last report), and finalize safe worktree changes.
146
- - The moderator never has to poll: when a turn started by `jar_subagent resume` ends, pi-jar coalesces the settled agents into one visible `pi-jar.subagent` message (their `peek` progress plus the turn's report, clipped to 1200 characters) that triggers or queues a moderator turn. `pause`, `ask` and `stop` cancel that wake-up because the moderator is handling the agent directly.
146
+ - The moderator never has to poll: settled turns and `pause`/`ask`/`stop` completions wait in pi-jar's own queue, never Pi's irrevocable follow-up queue. While the moderator is running they are coalesced into one visible `pi-jar.subagent` message (their `peek` progress plus the turn's report, clipped to 1200 characters) at the next turn boundary, so the final answer already reflects them instead of being followed by a fresh wake-up. Only events arriving while the moderator is idle wake a turn; an interrupted run keeps them for the next prompt. The message renders as a one-line summary (expand for the report). `pause`, `ask` and `stop` drop queued turn reports for the agent they control.
147
147
  - Nothing in the moderator path waits unbounded. `jar_subagent` returns as soon as the tool call is aborted (the child keeps its state); `pause` reports "not settled yet" after 15 s if the child ignores abort and still pauses when it settles; a working child that sends no RPC event for 10 minutes is killed as hung. After a child exits, its stdio gets 2 s to drain: a grandchild (MCP server, shell) that inherited the pipes cannot keep `stop` or session shutdown waiting for `close`.
148
148
  - Worktree setup snapshots the exact starting workspace: `HEAD` plus tracked dirty changes and non-ignored untracked files are committed only inside the detached worktree. The worktree persists across idle/pause/resume. On stop, the parent target must still byte/mode-match the snapshot and the change must be trackable by `/diff`; otherwise nothing is overwritten and the workspace/error is reported. Conflicted workspaces survive report-history trimming; after resolving parent drift, retry `stop`. Finalization waits for RPC stdio closure. Snapshot blobs preserve filesystem bytes without executing clean/smudge filters or hooks. The path guard handles Pi path normalization, rejects lexical/symlink/dangling-link escapes and Git control metadata, checks read-tool filename fallbacks (NFD, curly apostrophes, AM/PM spaces), and rechecks both source and destination ancestors during reconciliation. All replacements/rollback backups are prepared before parent mutation; failed multi-file application restores earlier files. Exceptional rollback failures explicitly report actual applied paths for recovery. Worktree children receive only `read,edit,write,grep,find,ls,jar_todo,jar_discuss`; shell tools are absent. This is filesystem-write isolation, not a network/process sandbox.
149
149
  - `jar_discuss` gives moderator and children a session-scoped structured paper. `ask` appends a question with stable id/from/to fields and `answer` references the question id (validated under the same lock as the write); both reply with the new id only, never echoing the text. Each process keeps an in-memory read cursor (the highest entry id it has been shown, reset when the paper path changes). `list` returns only unseen entries written by others — the newest that fit a 4000-character reply, each clipped to 400 characters, stating how many older entries it omitted — and advances the cursor; with nothing new it replies `No new entries (latest dN).` `since: "d12"` lists everything newer than that id instead (`since: "d0"` rereads the whole paper) and `questionId` reads one thread in full without moving the cursor. An `ask`/`answer` reply attaches unseen entries (≤1000 characters) only when all of them are relevant to the caller — answers to its questions or questions addressed to it — otherwise it adds `(N new entries; list to read)` and leaves the cursor alone, so nothing is skipped. Text is capped at 1600 characters per entry, at most 64 entries are retained, and the compact UTF-8 JSON paper is strictly bounded to 64 KiB with oldest-entry eviction and atomic rewrites. The file lock waits by yielding to the event loop (at most ~1 s), never blocking the UI. It is intentionally for terse inter-agent Q/A rather than another transcript; agents are told not to poll it.
@@ -19,6 +19,7 @@ import { FOOTER_FIELDS } from "../src/footer-settings.ts";
19
19
  import { defaultVisualSettings, loadVisualSettings, saveVisualSettings, type JarVisualSettings } from "../src/settings.ts";
20
20
  import { ProfileStore, PROFILE_FILE, PROFILE_ENTRY, conversationStarted, pinnedProfileId } from "../src/profiles.ts";
21
21
  import { openJarSettings, type PiPreferences } from "../src/settings-ui.ts";
22
+ import { openProfiles, type ProfileAction } from "../src/profile-ui.ts";
22
23
  import { pickSession } from "../src/session-ui.ts";
23
24
  import { fetchQuota, QuotaCache, type QuotaProvider } from "../src/quota.ts";
24
25
  import { ModelRoleManager } from "../src/model-roles.ts";
@@ -710,14 +711,19 @@ export default function piJar(pi: ExtensionAPI): void {
710
711
  };
711
712
 
712
713
  let switchingProfile = false;
713
- const canSwitchProfile = (ctx: ExtensionContext) => {
714
+ const profileSwitchBlocked = (ctx: ExtensionContext): string | undefined => {
714
715
  if (switchingProfile || advisor.isBusy() || !ctx.isIdle?.() || working.phase !== "idle" || planMode.isEnabled() || goals?.isActive() || subagents.running() > 0) {
715
- ctx.ui.notify("Finish the active agent/workflow before switching profiles", "warning"); return false;
716
+ return "Finish the active agent/workflow before switching profiles";
716
717
  }
717
718
  if (conversationStarted(profileEntries(ctx)) || pinnedProfileId(profileEntries(ctx)) !== undefined) {
718
- ctx.ui.notify("This session's profile is locked. Open a new session to switch profiles", "warning"); return false;
719
+ return "This session's profile is locked. Open a new session to switch profiles";
719
720
  }
720
- return true;
721
+ return undefined;
722
+ };
723
+ const canSwitchProfile = (ctx: ExtensionContext) => {
724
+ const blocked = profileSwitchBlocked(ctx);
725
+ if (blocked) ctx.ui.notify(blocked, "warning");
726
+ return !blocked;
721
727
  };
722
728
  const switchProfile = async (name: string, ctx: ExtensionContext) => {
723
729
  if (!profileStore || name === profileStore.activeName || !canSwitchProfile(ctx)) return;
@@ -754,29 +760,38 @@ export default function piJar(pi: ExtensionAPI): void {
754
760
  ctx.ui.notify(`Could not switch profile: ${(error as Error).message}`, "warning");
755
761
  } finally { switchingProfile = false; }
756
762
  };
763
+ const profileUi = (ctx: ExtensionContext, action?: ProfileAction) => {
764
+ if (!profileStore) { ctx.ui.notify("Profiles are unavailable in this session", "warning"); return Promise.resolve(); }
765
+ const store = profileStore;
766
+ return openProfiles(ctx, {
767
+ store, themes: ctx.ui.getAllThemes?.().map((theme) => theme.name) ?? [],
768
+ create: (name, theme) => store.add(name, theme, { ...visualSettings, accent: "follow" }, modelRoles.profileRoleConfig()),
769
+ switchBlocked: () => profileSwitchBlocked(ctx),
770
+ select: (name) => switchProfile(name, ctx),
771
+ openRoles: () => openRolesUi(ctx, modelRoles)
772
+ }, action);
773
+ };
757
774
  openSettings = async (ctx) => {
758
775
  if (settingsOpen) return;
759
776
  settingsOpen = true;
760
777
  try { await openJarSettings(ctx, () => visualSettings,
761
778
  (next) => applyVisualSettings(next, ctx), loadedAccents(ctx), piPreferences(ctx), profileStore ? {
762
- store: profileStore, themes: ctx.ui.getAllThemes?.().map((theme) => theme.name) ?? [],
763
- roles: () => modelRoles.profileRoleConfig(), roleManager: modelRoles,
764
- create: (name, theme, settings, roles) => { const profile = profileStore!.add(name, theme, { ...settings, accent: "follow" }, roles); ctx.ui.notify(`Created profile ${name}; select it in a fresh session`, "info"); return profile; },
765
- select: (name) => switchProfile(name, ctx),
766
- setTheme: (name) => {
767
- const previousTheme = ctx.ui.theme?.name;
768
- const next = { ...visualSettings, accent: "follow" as const };
769
- try { applyProfileTheme(name, ctx); profileStore!.update(profileStore!.activeName, { theme: name, settings: next }); }
770
- catch (error) { if (previousTheme) applyProfileTheme(previousTheme, ctx); throw error; }
771
- applyVisualSettings(next, ctx, { persist: false, accent: false });
772
- if (profileStore!.active().id === "default") {
773
- try { saveVisualSettings(getAgentDir(), next); }
774
- catch { ctx.ui.notify("Theme saved, but legacy Default preferences could not be updated", "warning"); }
775
- }
776
- }
779
+ activeName: () => profileStore?.activeName ?? "Default",
780
+ open: (action) => profileUi(ctx, action)
777
781
  } : undefined); }
778
782
  finally { settingsOpen = false; }
779
783
  };
784
+ pi.registerCommand("profiles", {
785
+ description: "Create or switch pi-jar profiles: /profiles, /profiles new, /profiles <name>",
786
+ handler: async (args, ctx) => {
787
+ const target = args.trim();
788
+ if (!target) return profileUi(ctx);
789
+ if (["new", "create"].includes(target.toLowerCase())) return profileUi(ctx, "create");
790
+ const profile = profileStore?.list().find((item) => item.name.toLocaleLowerCase() === target.toLocaleLowerCase());
791
+ if (!profile) { ctx.ui.notify("Unknown profile: " + target, "warning"); return; }
792
+ await switchProfile(profile.name, ctx);
793
+ }
794
+ });
780
795
 
781
796
  /** Callers are lifecycle handlers that repaint once through sampleFooter. */
782
797
  const updateCost = (ctx: ExtensionContext) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A warm, role-aware Pi terminal workspace with switchable profiles, animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/delegate.ts CHANGED
@@ -1258,6 +1258,9 @@ const peekRecord = (record: SubagentRecord): string => {
1258
1258
  const settledRecord = (record: SubagentRecord): string =>
1259
1259
  peekRecord(record) + (record.run.output ? `\nlast: ${cleanBlock(record.run.output, 1200)}` : "");
1260
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
+
1261
1264
  /**
1262
1265
  * Every active worktree agent may still end up needing recovery, so admission reserves a recovery slot for
1263
1266
  * it: unresolved recovery workspaces never exceed RECOVERY_LIMIT, and none is ever deleted to make room.
@@ -1336,29 +1339,58 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1336
1339
  (raw as { customType?: string }).customType !== "pi-jar.moderator-context" || (active && index === latest)) };
1337
1340
  });
1338
1341
 
1339
- /** 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. */
1340
1345
  const eventBusAvailable = typeof pi.sendMessage === "function";
1341
1346
  const watched = new Map<string, SubagentRecord>();
1342
- const settledQueue = new Map<string, { record: SubagentRecord; content: string; turn: number }>();
1347
+ const pendingEvents = new Map<string, SubagentEvent>();
1343
1348
  const deleteSettled = (key: string) => {
1344
- 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);
1345
1350
  };
1346
- let notifyTimer: NodeJS.Timeout | undefined;
1347
- const flushSettled = () => {
1348
- notifyTimer = undefined;
1349
- const records = [...settledQueue.values()].filter(({ record }) => registry.get(record.key) === record);
1350
- settledQueue.clear();
1351
- if (!records.length) return;
1352
- try {
1353
- pi.sendMessage?.({
1354
- customType: SUBAGENT_MESSAGE, display: true, details: {
1355
- keys: [...new Set(records.map(({ record }) => record.key))],
1356
- turns: records.map(({ record, turn }) => ({ key: record.key, turn }))
1357
- },
1358
- content: ["[PI-JAR SUBAGENT] A subagent turn ended. Review, then resume, ask or stop:", ...records.map(event => event.content)].join("\n\n")
1359
- }, { triggerTurn: true, deliverAs: "followUp" });
1360
- } 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?.();
1361
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 };
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(); });
1362
1394
  registry.subscribe(() => {
1363
1395
  if (!watched.size) return;
1364
1396
  for (const [key, record] of watched) {
@@ -1367,14 +1399,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1367
1399
  watched.delete(key);
1368
1400
  if (record.run.state !== "stopped") {
1369
1401
  // Freeze reports at the settled boundary. A fast resume must not overwrite a queued
1370
- // initial report, and multiple turns in the coalescing window must each be delivered.
1371
- 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 } });
1372
1405
  }
1373
1406
  }
1374
- if (settledQueue.size && !notifyTimer) {
1375
- notifyTimer = setTimeout(flushSettled, NOTIFY_COALESCE_MS);
1376
- notifyTimer.unref?.();
1377
- }
1378
1407
  });
1379
1408
 
1380
1409
  const delegateTool: ToolDefinition<typeof Parameters> = {
@@ -1874,13 +1903,12 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1874
1903
  const deliverControl = (record: SubagentRecord, operation: { id: string; action: string }, result: { content: Array<{ text: string }>; details?: unknown; isError?: boolean }) => {
1875
1904
  // A session switch discards the old fleet. Never wake a new session with stale handoffs.
1876
1905
  if (registry.get(record.key) !== record) return;
1877
- try {
1878
- pi.sendMessage({
1879
- customType: SUBAGENT_MESSAGE, display: true,
1880
- details: { operationId: operation.id, action: operation.action, key: record.key, status: result.isError ? "failed" : "completed", report: result.details },
1881
- content: `[PI-JAR SUBAGENT] ${operation.action} ${operation.id} completed:\n${result.content.map(part => part.text).join("\n")}`
1882
- }, { triggerTurn: true, deliverAs: "followUp" });
1883
- } 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 } });
1884
1912
  };
1885
1913
 
1886
1914
  pi.registerTool({
@@ -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
+ }
@@ -3,9 +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 { createProfileWizard } from "./profile-wizard.ts";
7
- import type { JarProfile, ProfileStore } from "./profiles.ts";
8
- import type { RoleConfig, ModelRoleManager } from "./model-roles.ts";
6
+ import type { ProfileAction } from "./profile-ui.ts";
9
7
 
10
8
  const LABELS: Record<FooterField, string> = {
11
9
  model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory",
@@ -29,13 +27,13 @@ export async function openJarSettings(
29
27
  update: (settings: JarVisualSettings) => void,
30
28
  availableAccents: readonly string[],
31
29
  pi?: PiPreferences,
32
- profiles?: { store: ProfileStore; themes: readonly string[]; roles: () => RoleConfig; roleManager?: ModelRoleManager; create: (name: string, theme: string, settings: JarVisualSettings, roles: RoleConfig) => JarProfile; select(name: string): Promise<void>; setTheme(name: string): void }
30
+ profiles?: { activeName(): string; open(action: ProfileAction): Promise<void> }
33
31
  ): Promise<void> {
34
32
  if (!ctx.hasUI || ctx.mode !== "tui") return;
35
33
  const PAGES: Page[] = profiles ? ["appearance", "footer", "pi", "profiles"] : ["appearance", "footer", "pi"];
36
34
  let lastPage: Page = "appearance";
37
35
  while (true) {
38
- const action = await ctx.ui.custom<"create" | "switch" | "theme" | undefined>((tui, theme, _keys, done) => {
36
+ const action = await ctx.ui.custom<ProfileAction | undefined>((tui, theme, _keys, done) => {
39
37
  let page: Page = lastPage;
40
38
  let selected = 0;
41
39
  let width = 64;
@@ -43,7 +41,7 @@ export async function openJarSettings(
43
41
  name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
44
42
  let choosing: "goalRounds" | "maxSubagents" | undefined;
45
43
  const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : GOAL_ROUND_CHOICES;
46
- const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 7 : page === "profiles" ? 3 : 7;
44
+ const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 7 : page === "profiles" ? 2 : 7;
47
45
  const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : 2; choosing = undefined; };
48
46
  const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
49
47
  const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
@@ -58,7 +56,7 @@ export async function openJarSettings(
58
56
  finishChoice();
59
57
  } else if (page === "profiles") {
60
58
  lastPage = page;
61
- done(index === 0 ? "switch" : index === 1 ? "theme" : "create");
59
+ done(index === 0 ? "switch" : "create");
62
60
  return;
63
61
  } else if (page === "footer") {
64
62
  const field = FOOTER_FIELDS[index];
@@ -165,9 +163,8 @@ export async function openJarSettings(
165
163
  row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates),
166
164
  row(6, "Context diet (prior-turn reasoning)", state.contextDiet ? "ON" : "OFF", state.contextDiet)
167
165
  ] : page === "profiles" ? [
168
- row(0, "Active profile", profiles!.store.activeName, true),
169
- row(1, "Theme", profiles!.store.active().theme, true),
170
- row(2, "Create profile…", "OPEN", true)
166
+ row(0, "Switch profile…", profiles!.activeName(), true),
167
+ row(1, "Create profile…", "OPEN", true)
171
168
  ] : [
172
169
  row(0, "Accent", state.accent === "follow" ? "FOLLOW PI" : state.accent.toUpperCase(), true),
173
170
  row(1, "Motion", state.animations ? "ON" : "OFF", state.animations),
@@ -186,7 +183,7 @@ export async function openJarSettings(
186
183
  : choosing ? " Select a value; Enter/click to save, Esc to cancel"
187
184
  : page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
188
185
  : page === "pi" ? " Fullscreen mode enables clicks and copy-on-select"
189
- : page === "profiles" ? " Switch only in a fresh, idle session" : " Accent cycles through loaded themes")),
186
+ : page === "profiles" ? " Switch only in a fresh, idle session; roles via /roles" : " Accent cycles through loaded themes")),
190
187
  ...rows,
191
188
  line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
192
189
  : " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
@@ -196,16 +193,9 @@ export async function openJarSettings(
196
193
  };
197
194
  });
198
195
  if (!action || !profiles) return;
199
- try {
200
- if (action === "create") {
201
- await createProfileWizard(ctx, profiles.store, { themes: profiles.themes, settings: { ...current(), accent: "follow" }, roles: profiles.roles(), roleManager: profiles.roleManager, create: profiles.create });
202
- } else if (action === "switch") {
203
- const name = await ctx.ui.select("Choose a profile", profiles.store.list().map((profile) => profile.name));
204
- if (name) await profiles.select(name);
205
- } else {
206
- const name = await ctx.ui.select("Profile theme", ["Follow Pi", ...profiles.themes.filter((name) => name !== "follow")]);
207
- if (name) profiles.setTheme(name === "Follow Pi" ? "follow" : name);
208
- }
209
- } catch (error) { ctx.ui.notify("Could not change profile: " + (error as Error).message, "error"); }
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;
210
200
  }
211
201
  }
@@ -1,61 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { JarVisualSettings } from "./settings.ts";
3
- import type { JarProfile, ProfileStore } from "./profiles.ts";
4
- import { isRoleName, normalizeSpec, type RoleConfig, type ModelRoleManager } from "./model-roles.ts";
5
-
6
- export interface ProfileWizardOptions {
7
- themes: readonly string[];
8
- settings: JarVisualSettings;
9
- roles: RoleConfig;
10
- roleManager?: ModelRoleManager;
11
- create(name: string, theme: string, settings: JarVisualSettings, roles: RoleConfig): JarProfile;
12
- }
13
-
14
- /** Every step is cancellable; creation happens only after the final confirmation. */
15
- export async function createProfileWizard(ctx: ExtensionContext, store: ProfileStore, options: ProfileWizardOptions): Promise<JarProfile | undefined> {
16
- if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
17
- const name = await ctx.ui.input("New profile name (1–32 characters)", "");
18
- if (name === undefined) return undefined;
19
- if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) { ctx.ui.notify("Profile name cannot contain control characters", "warning"); return undefined; }
20
- const normalized = name.trim().replace(/\s+/g, " ");
21
- if (!normalized || normalized.length > 32 || /[\\/\0]/.test(normalized)) {
22
- ctx.ui.notify("Profile name must be 1–32 characters and cannot contain slashes", "warning"); return undefined;
23
- }
24
- if (store.list().some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) {
25
- ctx.ui.notify("A profile with that name already exists", "warning"); return undefined;
26
- }
27
- if (!options.themes.length) { ctx.ui.notify("No themes are available for profiles", "warning"); return undefined; }
28
- const selectedTheme = await ctx.ui.select("Choose a theme for " + normalized, ["Follow Pi", ...options.themes.filter((item) => item !== "follow")]);
29
- if (!selectedTheme) return undefined;
30
- const theme = selectedTheme === "Follow Pi" ? "follow" : selectedTheme;
31
- const roles = structuredClone(options.roles);
32
- const roleNames = options.roleManager?.list().map((role) => role.role) ?? [];
33
- if (options.roleManager) {
34
- for (const role of roleNames) {
35
- const current = roles.roles[role];
36
- const answer = await ctx.ui.input(`${role} role assignment (blank keeps current${current ? `: ${current}` : ""}; none clears)`, "");
37
- if (answer === undefined) return undefined;
38
- if (!answer.trim()) continue;
39
- if (answer.trim().toLowerCase() === "none") { delete roles.roles[role]; continue; }
40
- const model = normalizeSpec(answer.trim());
41
- if (!model) { ctx.ui.notify("Enter a valid provider/model, @role, *, or none for " + role, "warning"); return undefined; }
42
- roles.roles[role] = model;
43
- }
44
- } else {
45
- const roleInput = await ctx.ui.input("Role assignments as role=provider/model, comma-separated (optional)", "");
46
- if (roleInput === undefined) return undefined;
47
- for (const assignment of roleInput.split(",").map((item) => item.trim()).filter(Boolean)) {
48
- const split = assignment.indexOf("=");
49
- if (split <= 0) { ctx.ui.notify("Use role=provider/model for each assignment", "warning"); return undefined; }
50
- const role = assignment.slice(0, split).trim().toLowerCase();
51
- const model = normalizeSpec(assignment.slice(split + 1).trim());
52
- if (!isRoleName(role) || !model) { ctx.ui.notify("Invalid role assignment: " + assignment, "warning"); return undefined; }
53
- roles.roles[role] = model;
54
- }
55
- }
56
- const roleSummary = Object.entries(roles.roles).map(([role, target]) => `${role} → ${target}`).join("\n") || "No roles assigned";
57
- const confirmed = await ctx.ui.confirm("Create profile", `${normalized}\nTheme: ${theme === "follow" ? "Follow Pi" : theme}\n${roleSummary}`);
58
- if (!confirmed) return undefined;
59
- try { return options.create(normalized, theme, options.settings, roles); }
60
- catch (error) { ctx.ui.notify("Could not create profile: " + (error as Error).message, "error"); return undefined; }
61
- }