@dieulc/workflow 0.4.0 → 0.5.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/index.ts CHANGED
@@ -89,12 +89,24 @@ import {
89
89
  type WorkflowRole,
90
90
  } from "./permissions.ts";
91
91
  import {
92
+ COMMIT_AUTH_MAX_COMMITS,
93
+ COMMIT_AUTH_TTL_MS,
94
+ COMMIT_FLOW_TTL_MS,
95
+ armCommitAuthorization,
92
96
  commitConfigPaths,
97
+ decideCommitGate,
93
98
  describeCommitPolicyMode,
99
+ isAuthorizationActive,
100
+ isCommitConfirmation,
101
+ isPositiveCommitDecision,
94
102
  readCommitPolicyConfig,
103
+ startCommitFlow,
95
104
  stepCommitDecision,
96
105
  writeCommitPolicyConfig,
106
+ type CommitAuthorizationState,
107
+ type CommitGateHit,
97
108
  type CommitPolicyMode,
109
+ type PendingCommitFlow,
98
110
  type StepCommitCandidate,
99
111
  } from "./commit-policy.ts";
100
112
  import {
@@ -125,6 +137,24 @@ import {
125
137
  type RoleModel,
126
138
  type ThinkingLevel,
127
139
  } from "./roles.ts";
140
+ import {
141
+ defaultProfileConfig,
142
+ loadProfilesDetailed,
143
+ profilesFingerprint,
144
+ saveProfiles,
145
+ getProfile,
146
+ formatProfilesForDisplay,
147
+ describeProfile,
148
+ validateProfileName,
149
+ applyProfileOverlay,
150
+ missingProfileRoles,
151
+ readDefaultProfileSetting,
152
+ writeDefaultProfileSetting,
153
+ defaultProfileSettingPaths,
154
+ PROFILE_ROLES,
155
+ type ProfileConfig,
156
+ type Profile,
157
+ } from "./profiles.ts";
128
158
  import {
129
159
  applySubmittedPlan,
130
160
  formatReviewStatusLines,
@@ -382,11 +412,19 @@ export default function workflowExtension(pi: ExtensionAPI) {
382
412
  let todoItems: TodoItem[] = [];
383
413
  // Plan file the current todo list was derived from (drives merge vs replace).
384
414
  let todoPlanPath: string | undefined;
385
- // Armed while the user-invoked /commit flow's agent turn is running: its
386
- // `git commit` bypasses the commit-policy gate (staging already happened
387
- // extension-side via piExecGit, outside the tool gate). Cleared on
388
- // turn_end/agent_settled so the window stays one turn wide.
389
- let commitBypassActive = false;
415
+ // ── /commit flow authorization ──
416
+ // The /commit flow is multi-turn (read diff → confirm → commit), so a
417
+ // single-turn exemption is always disarmed before the commit runs. Instead:
418
+ // pendingCommitFlow — set when /commit injects its agent turn, cleared on
419
+ // the user's confirmation (or a negative/cancel answer),
420
+ // so only THIS flow's questionnaire can arm.
421
+ // commitAuthorization — armed only when the user confirms in that
422
+ // questionnaire; exempts ONLY `git commit` (never
423
+ // add/push) until the run settles, bounded by a TTL
424
+ // and a commit cap.
425
+ // Both are cleared on agent_settled, session_shutdown and session_tree.
426
+ let pendingCommitFlow: PendingCommitFlow | null = null;
427
+ let commitAuthorization: CommitAuthorizationState | null = null;
390
428
  // ── Deterministic per-step commits (allow mode) ──
391
429
  // In 'allow' mode the EXTENSION — not the model — owns commit timing:
392
430
  // exactly one commit per batch of newly completed plan steps, made at the
@@ -473,6 +511,232 @@ export default function workflowExtension(pi: ExtensionAPI) {
473
511
  void _e;
474
512
  }
475
513
  }
514
+
515
+ // ── Model profiles state (session-scoped activation) ───────────
516
+ let profilesConfig: ProfileConfig = defaultProfileConfig();
517
+ let profilesLoadedFor: string | undefined;
518
+ let profilesLoadedFingerprint: string | undefined;
519
+ /** Reason the last load skipped the user file — only corrupt/legacy warns. */
520
+ let profilesFallbackReason: string | undefined;
521
+ let profilesFallbackSeverity: "info" | "warning" | undefined;
522
+ let profilesFallbackNotified = false;
523
+ /** Active profile for THIS session — never global (concurrent sessions each pick their own). */
524
+ let activeProfileName: string | null = null;
525
+ /** True once the user picked explicitly in this session; otherwise workflow.defaultProfile applies. */
526
+ let profileExplicit = false;
527
+ /** Cache for the effective (profile-overlaid) roles — see effectiveRoles(). */
528
+ let effectiveRolesCache: {
529
+ base: RoleConfig;
530
+ profile: string | null;
531
+ roles: Role[];
532
+ } | null = null;
533
+
534
+ function invalidateEffectiveRoles(): void {
535
+ effectiveRolesCache = null;
536
+ }
537
+
538
+ /**
539
+ * The single profiles accessor, mirroring ensureRoleConfig: reloads when the
540
+ * working directory changes or either profiles.json changes on disk (mtime/
541
+ * size), so a `/profile` edit made in another window is picked up without a
542
+ * `/reload`. Non-destructive: a bad or missing file is never overwritten
543
+ * (see loadProfilesDetailed). A profile activated in this session that has
544
+ * vanished from disk is deactivated here (with the fallback warning).
545
+ */
546
+ function ensureProfilesConfig(cwd?: string): ProfileConfig {
547
+ const key = cwd ?? "";
548
+ const fingerprint = profilesFingerprint(key);
549
+ if (
550
+ profilesLoadedFor === key &&
551
+ profilesLoadedFingerprint === fingerprint
552
+ ) {
553
+ return profilesConfig;
554
+ }
555
+ const loaded = loadProfilesDetailed(key);
556
+ profilesConfig = loaded.config;
557
+ profilesLoadedFor = key;
558
+ profilesLoadedFingerprint = fingerprint;
559
+ profilesFallbackReason = loaded.usedDefaults ? loaded.reason : undefined;
560
+ profilesFallbackSeverity = loaded.usedDefaults
561
+ ? loaded.severity
562
+ : undefined;
563
+ profilesFallbackNotified = false;
564
+ invalidateEffectiveRoles();
565
+ // A profile selected in this session may have been deleted/renamed in
566
+ // another window — fall back to the base roles flow.
567
+ if (activeProfileName && !getProfile(profilesConfig, activeProfileName)) {
568
+ activeProfileName = null;
569
+ }
570
+ return profilesConfig;
571
+ }
572
+
573
+ /**
574
+ * Surface a profiles load problem once per load. A MISSING file is the
575
+ * normal "feature unused" state (info — never notified); only corrupt or
576
+ * incompatible files warn.
577
+ */
578
+ function notifyProfilesFallback(ctx?: ExtensionContext): void {
579
+ if (!profilesFallbackReason || profilesFallbackNotified) return;
580
+ profilesFallbackNotified = true;
581
+ if (profilesFallbackSeverity !== "warning") return;
582
+ try {
583
+ ctx?.ui.notify(`Profiles: ${profilesFallbackReason}`, "warning");
584
+ } catch (_e) {
585
+ void _e;
586
+ }
587
+ }
588
+
589
+ /** Resolve the session's active profile object (null when none/unknown). */
590
+ function resolveActiveProfile(): Profile | null {
591
+ if (!activeProfileName) return null;
592
+ return getProfile(profilesConfig, activeProfileName) ?? null;
593
+ }
594
+
595
+ /**
596
+ * The effective roles for THIS session: the file-backed base roles with the
597
+ * active profile's model overrides applied (see applyProfileOverlay). All
598
+ * model-RESOLUTION sites read through this; all EDITING sites (/role, role
599
+ * picker) keep using the base `roleConfig`. With no profile active the
600
+ * overlay is a shallow copy — behaviour is identical to the roles.json flow.
601
+ * Cache is keyed by the base config object identity (ensureRoleConfig
602
+ * reassigns it on file changes) + the active profile name, and invalidated
603
+ * by ensureProfilesConfig / setActiveProfile.
604
+ */
605
+ function effectiveRoles(): Role[] {
606
+ const base = roleConfig;
607
+ const profileName = activeProfileName;
608
+ const cached = effectiveRolesCache;
609
+ if (cached && cached.base === base && cached.profile === profileName) {
610
+ return cached.roles;
611
+ }
612
+ const roles = applyProfileOverlay(base.roles, resolveActiveProfile());
613
+ effectiveRolesCache = { base, profile: profileName, roles };
614
+ return roles;
615
+ }
616
+
617
+ /** getRole over the effective (profile-overlaid) roles. */
618
+ function getEffectiveRole(name: string): Role | undefined {
619
+ return getRole({ ...roleConfig, roles: effectiveRoles() }, name);
620
+ }
621
+
622
+ /** Footer label for the active profile ("" when none). */
623
+ function activeProfileLabel(): string {
624
+ return activeProfileName ? `⚡ ${activeProfileName}` : "";
625
+ }
626
+
627
+ /**
628
+ * /role display: the base roles (what /role edits) plus, when a profile is
629
+ * active, an explicit section naming the profile and its model overrides.
630
+ * Without a profile this is exactly the plain roles display.
631
+ */
632
+ function formatRolesDisplayWithProfile(): string {
633
+ const base = formatRolesForDisplay(roleConfig);
634
+ const profile = resolveActiveProfile();
635
+ if (!profile) return base;
636
+ const lines: string[] = [
637
+ base,
638
+ "",
639
+ `Active profile: ⚡ ${profile.name}${
640
+ profile.description ? ` — ${profile.description}` : ""
641
+ }`,
642
+ ];
643
+ for (const roleName of PROFILE_ROLES) {
644
+ const m = profile.roles[roleName];
645
+ if (m)
646
+ lines.push(
647
+ ` ${roleIcon(roleName)} ${roleName} → ${formatModelRef(m)} (${m.thinking}) (profile override)`,
648
+ );
649
+ }
650
+ lines.push(
651
+ ` /profile edit ${profile.name} to change the profile • /profile use none to deactivate`,
652
+ );
653
+ return lines.join("\n");
654
+ }
655
+
656
+ /** Multi-line notification describing what an activation resolves to. */
657
+ function profileActivationSummary(name: string): string {
658
+ const profile = getProfile(profilesConfig, name);
659
+ if (!profile) return `Profile "${name}" activated.`;
660
+ const lines = [`Profile ⚡ ${profile.name} activated:`];
661
+ for (const roleName of PROFILE_ROLES) {
662
+ const m = profile.roles[roleName];
663
+ if (m)
664
+ lines.push(
665
+ ` ${roleIcon(roleName)} ${roleName} → ${formatModelRef(m)} (${m.thinking})`,
666
+ );
667
+ }
668
+ const missing = missingProfileRoles(profile);
669
+ if (missing.length) {
670
+ lines.push(
671
+ ` ${missing.join(", ")}: base role models (not overridden by this profile)`,
672
+ );
673
+ }
674
+ if (workflowMode) {
675
+ lines.push(` Applied to ${workflowMode} mode now; explore/review pick it up on next use.`);
676
+ } else {
677
+ lines.push(
678
+ " Applies on the next /plan, /build, explore subagent, or review round.",
679
+ );
680
+ }
681
+ return lines.join("\n");
682
+ }
683
+
684
+ /**
685
+ * Activate/deactivate the session's profile. Session-scoped by design — the
686
+ * state rides the `workflow` session entry (persistState), never a global
687
+ * file, so two concurrent sessions can run different profiles. When the
688
+ * session is in plan/build mode the role model is re-applied immediately so
689
+ * the profile's planner/builder take effect right away; explorer/reviewer
690
+ * resolution picks the profile up on next subagent/review round.
691
+ */
692
+ async function setActiveProfile(
693
+ ctx: ExtensionContext,
694
+ name: string | null,
695
+ opts?: { explicit?: boolean; quiet?: boolean },
696
+ ): Promise<void> {
697
+ ensureProfilesConfig((ctx as any)?.cwd);
698
+ const trimmed = name ? String(name).trim() : null;
699
+ if (trimmed) {
700
+ const profile = getProfile(profilesConfig, trimmed);
701
+ if (!profile) {
702
+ ctx.ui.notify(
703
+ `Unknown profile "${trimmed}". Available: ${
704
+ profilesConfig.profiles.map((p) => p.name).join(", ") ||
705
+ "(none — /profile create <name>)"
706
+ }`,
707
+ "warning",
708
+ );
709
+ return;
710
+ }
711
+ activeProfileName = profile.name;
712
+ } else {
713
+ activeProfileName = null;
714
+ }
715
+ profileExplicit = opts?.explicit ?? true;
716
+ invalidateEffectiveRoles();
717
+ try {
718
+ persistState();
719
+ } catch (_e) {
720
+ void _e;
721
+ }
722
+ updateStatus(ctx as any);
723
+ if (workflowMode) {
724
+ try {
725
+ await applyModeModel(ctx as any, { quiet: opts?.quiet });
726
+ } catch (_e) {
727
+ void _e;
728
+ }
729
+ }
730
+ if (!opts?.quiet) {
731
+ ctx.ui.notify(
732
+ activeProfileName
733
+ ? profileActivationSummary(activeProfileName)
734
+ : "Profile deactivated — default roles flow active.",
735
+ "info",
736
+ );
737
+ }
738
+ }
739
+
476
740
  function activeRoleLabel(): string {
477
741
  if (roleConfig.activeRole)
478
742
  return `${roleIcon(roleConfig.activeRole)} ${roleConfig.activeRole}`;
@@ -623,8 +887,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
623
887
  }
624
888
 
625
889
  function updateStatus(ctx: ExtensionContext) {
890
+ // Profile prefix (⚡ name) when one is active, then the role label.
891
+ const profileLabel = activeProfileLabel();
626
892
  const roleLabel = activeRoleLabel();
627
- const suffix = roleLabel ? ` | ${roleLabel}` : "";
893
+ const statusParts = [profileLabel, roleLabel].filter(Boolean);
894
+ const suffix = statusParts.length ? ` | ${statusParts.join(" | ")}` : "";
628
895
  // Commit-policy surfacing (workflow.commitPolicy): always visible so the
629
896
  // user can see at a glance what the agent may do to git history. Read
630
897
  // live so /commit-policy changes show up without a reload.
@@ -719,7 +986,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
719
986
  /** Reviewer model label for the mode band (best-effort). */
720
987
  function reviewModelLabel(): string {
721
988
  ensureRoleConfig(reviewCwd());
722
- const r = getRole(roleConfig, REVIEW_ROLE_NAME);
989
+ ensureProfilesConfig(reviewCwd());
990
+ const r = getEffectiveRole(REVIEW_ROLE_NAME);
723
991
  return r?.model ? formatModelRef(r.model) : "unconfigured";
724
992
  }
725
993
 
@@ -887,9 +1155,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
887
1155
  ): Promise<string> {
888
1156
  return (async () => {
889
1157
  const cwd = reviewCwd();
1158
+ ensureRoleConfig(cwd);
1159
+ ensureProfilesConfig(cwd);
890
1160
  const agents = discoverAgents(cwd);
891
1161
  const base = agents.find((a) => a.name === "scout") ?? agents[0];
892
- const explorerRole = getRole(roleConfig, "explorer");
1162
+ const explorerRole = getEffectiveRole("explorer");
893
1163
  const m = explorerRole?.model;
894
1164
  const agent: AgentConfig = {
895
1165
  name: base?.name ?? "review-scout",
@@ -927,11 +1197,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
927
1197
  config: () => reviewConfigNow()?.config,
928
1198
  reviewerModel: () => {
929
1199
  ensureRoleConfig(cwd);
930
- const reviewer = getRole(roleConfig, REVIEW_ROLE_NAME);
1200
+ ensureProfilesConfig(cwd);
1201
+ const reviewer = getEffectiveRole(REVIEW_ROLE_NAME);
931
1202
  if (reviewer?.model) return reviewer.model;
932
1203
  // Graceful degradation: fall back to the planner model rather than
933
1204
  // silently running no review at all.
934
- const planner = getRole(roleConfig, "planner");
1205
+ const planner = getEffectiveRole("planner");
935
1206
  return planner?.model;
936
1207
  },
937
1208
  findModel: (provider: string, id: string) =>
@@ -1131,7 +1402,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
1131
1402
  if (!sub || sub === "status") {
1132
1403
  const resolved = readReviewModeConfig(cwd, agentDir);
1133
1404
  const c = resolved.config;
1134
- const model = getRole(roleConfig, REVIEW_ROLE_NAME)?.model;
1405
+ ensureProfilesConfig(cwd);
1406
+ const model = getEffectiveRole(REVIEW_ROLE_NAME)?.model;
1135
1407
  ctx.ui.notify(
1136
1408
  formatReviewStatusLines({
1137
1409
  enabled: c.enabled,
@@ -1507,13 +1779,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
1507
1779
  if (!workflowMode) return false;
1508
1780
  try {
1509
1781
  ensureRoleConfig((ctx as any)?.cwd ?? ctx.cwd);
1782
+ ensureProfilesConfig((ctx as any)?.cwd ?? ctx.cwd);
1510
1783
  } catch (_e) {
1511
1784
  void _e;
1512
1785
  }
1513
- const resolved = resolveModeModel(roleConfig, workflowMode);
1786
+ // Resolution reads the EFFECTIVE roles (base roles + active profile
1787
+ // overlay); with no profile active this is behaviourally identical to
1788
+ // the base config.
1789
+ const effectiveCfg: RoleConfig = { ...roleConfig, roles: effectiveRoles() };
1790
+ const resolved = resolveModeModel(effectiveCfg, workflowMode);
1514
1791
  if (!resolved) {
1515
- const targetName = roleConfig.activeRole ?? MODE_ROLE_MAP[workflowMode];
1516
- const role = targetName ? getRole(roleConfig, targetName) : undefined;
1792
+ const targetName = effectiveCfg.activeRole ?? MODE_ROLE_MAP[workflowMode];
1793
+ const role = targetName ? getRole(effectiveCfg, targetName) : undefined;
1517
1794
  if (targetName && !role) {
1518
1795
  ctx.ui.notify(
1519
1796
  `Workflow ${workflowMode}: role "${targetName}" not found — staying on current model.`,
@@ -1699,6 +1976,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
1699
1976
  awaitingDecisionAt: awaitingDecision ? lastHandoffAt : undefined,
1700
1977
  role: {
1701
1978
  activeRole: roleConfig.activeRole,
1979
+ // Session-scoped profile activation (see setActiveProfile): explicit
1980
+ // picks restore exactly; non-explicit entries re-derive the default.
1981
+ activeProfile: profileExplicit ? activeProfileName : null,
1982
+ profileExplicit,
1702
1983
  },
1703
1984
  defaultModel,
1704
1985
  lastModeSwitch,
@@ -2271,16 +2552,28 @@ export default function workflowExtension(pi: ExtensionAPI) {
2271
2552
 
2272
2553
  // Inject a user message → triggers an agent turn that proposes + confirms + commits
2273
2554
  const message = buildCommitPrompt(statSummary, stageAll);
2274
- // Arm the commit-policy bypass for exactly this agent turn: its `git
2275
- // commit` must run even under policy 'never'. Disarmed on turn_end /
2276
- // agent_settled, so the window is one turn wide.
2277
- commitBypassActive = true;
2278
- if ((pi as any).sendUserMessage) {
2279
- (pi as any).sendUserMessage(message);
2280
- } else {
2281
- (pi as any).sendMessage?.(
2282
- { customType: "workflow-commit", content: message, display: true },
2283
- { triggerTurn: true, deliverAs: "followUp" },
2555
+ // Start a commit flow. The exemption is NOT armed here: it is armed only
2556
+ // when the user confirms in THIS flow's questionnaire (see the
2557
+ // questionnaire tool), so an unconfirmed commit stays blocked under any
2558
+ // policy. A flow TTL bounds an abandoned confirmation.
2559
+ pendingCommitFlow = startCommitFlow(Date.now(), COMMIT_FLOW_TTL_MS);
2560
+ commitAuthorization = null;
2561
+ try {
2562
+ if ((pi as any).sendUserMessage) {
2563
+ await (pi as any).sendUserMessage(message);
2564
+ } else {
2565
+ await (pi as any).sendMessage?.(
2566
+ { customType: "workflow-commit", content: message, display: true },
2567
+ { triggerTurn: true, deliverAs: "followUp" },
2568
+ );
2569
+ }
2570
+ } catch (e: any) {
2571
+ // The turn never started → no questionnaire can arm; do not leave a
2572
+ // stale flow behind.
2573
+ pendingCommitFlow = null;
2574
+ ctx.ui.notify(
2575
+ `Failed to start the commit flow: ${String(e?.message ?? e ?? e)}`,
2576
+ "error",
2284
2577
  );
2285
2578
  }
2286
2579
  }
@@ -2782,19 +3075,19 @@ export default function workflowExtension(pi: ExtensionAPI) {
2782
3075
  await openRolePicker(ctx as any);
2783
3076
  return;
2784
3077
  }
2785
- ctx.ui.notify(formatRolesForDisplay(roleConfig), "info");
3078
+ ctx.ui.notify(formatRolesDisplayWithProfile(), "info");
2786
3079
  return;
2787
3080
  }
2788
3081
 
2789
3082
  if (sub === "list" || sub === "show") {
2790
- ctx.ui.notify(formatRolesForDisplay(roleConfig), "info");
3083
+ ctx.ui.notify(formatRolesDisplayWithProfile(), "info");
2791
3084
  return;
2792
3085
  }
2793
3086
 
2794
3087
  if (sub === "config") {
2795
3088
  if (!(ctx as any)?.hasUI) {
2796
3089
  ctx.ui.notify(
2797
- formatRolesForDisplay(roleConfig) +
3090
+ formatRolesDisplayWithProfile() +
2798
3091
  "\n\nPicker needs UI — use /role set instead.",
2799
3092
  "info",
2800
3093
  );
@@ -2974,107 +3267,1480 @@ export default function workflowExtension(pi: ExtensionAPI) {
2974
3267
  },
2975
3268
  });
2976
3269
 
2977
- // ── Role config screen (tabbed picker: one block per role) ─────────
3270
+ // ── Model profiles command + management UI ───────────────────────
2978
3271
  //
2979
- // One overlay with a tab per role (built-ins first, then customs) plus
2980
- // a Submit tab. Each role tab has two phases: pick model (from the
2981
- // connected providers — scoped models first, Ctrl+O for all) then pick
2982
- // thinking level. Selections stage locally; Submit saves to roles.json.
2983
-
2984
- interface StagedRoleModel {
2985
- provider: string;
2986
- id: string;
2987
- thinking: ThinkingLevel;
2988
- }
2989
-
2990
- interface RoleCatalogEntry {
2991
- provider: string;
2992
- id: string;
2993
- inScope: boolean;
2994
- }
2995
-
2996
- function collectRoleCatalog(ctx: ExtensionContext): {
2997
- scoped: RoleCatalogEntry[];
2998
- all: RoleCatalogEntry[];
2999
- truncated: boolean;
3000
- } {
3001
- const scoped: RoleCatalogEntry[] = [];
3002
- try {
3003
- const raw = ((ctx as any)?.scopedModels ?? []) as any[];
3004
- for (const s of raw) {
3005
- const m = s?.model ?? s;
3006
- if (m && typeof m.provider === "string" && typeof m.id === "string")
3007
- scoped.push({ provider: m.provider, id: m.id, inScope: true });
3008
- }
3009
- } catch (_e) {
3010
- void _e;
3011
- }
3012
- let available: any[] = [];
3272
+ // A profile is a named combination of role model settings (planner /
3273
+ // explorer / builder / reviewer). Profiles are DEFINED in profiles.json;
3274
+ // ACTIVATION is session-scoped (setActiveProfile) so two concurrent
3275
+ // sessions can run different profiles. /role keeps editing the base roles;
3276
+ // an active profile only overlays models on top (see effectiveRoles()).
3277
+
3278
+ const PROFILE_USAGE =
3279
+ "Usage: /profile (picker) | /profile list | /profile show <name> | " +
3280
+ "/profile use <name|none> | /profile create <name> | /profile edit <name> | " +
3281
+ "/profile rename <old> <new> | /profile delete <name> | " +
3282
+ "/profile default [name|none] [global|project]";
3283
+
3284
+ /** Effective workflow.defaultProfile for display (never throws). */
3285
+ function currentDefaultProfileName(cwd?: string): string | undefined {
3013
3286
  try {
3014
- const reg = (ctx as any)?.modelRegistry;
3015
- if (reg && typeof reg.getAvailable === "function")
3016
- available = [...reg.getAvailable()];
3017
- else if (reg && typeof reg.getAll === "function")
3018
- available = [...reg.getAll()];
3287
+ return readDefaultProfileSetting(cwd ?? process.cwd(), getAgentDir())
3288
+ .value;
3019
3289
  } catch (_e) {
3020
3290
  void _e;
3291
+ return undefined;
3021
3292
  }
3022
- const seen = new Set(scoped.map((s) => `${s.provider}/${s.id}`));
3023
- const rest: RoleCatalogEntry[] = [];
3024
- for (const m of available) {
3025
- if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
3026
- continue;
3027
- const key = `${m.provider}/${m.id}`;
3028
- if (seen.has(key)) continue;
3029
- seen.add(key);
3030
- rest.push({ provider: m.provider, id: m.id, inScope: false });
3293
+ }
3294
+
3295
+ /** Seed a new profile from the CURRENT effective roles (base + active profile). */
3296
+ function buildProfileSeed(name: string, description = ""): Profile {
3297
+ const seed: Profile = { name, description, roles: {} };
3298
+ for (const roleName of PROFILE_ROLES) {
3299
+ const m = getEffectiveRole(roleName)?.model;
3300
+ if (m) seed.roles[roleName] = { ...m };
3031
3301
  }
3032
- const CAP = 200;
3033
- const all = [...scoped, ...rest];
3034
- return {
3035
- scoped,
3036
- all: all.slice(0, CAP),
3037
- truncated: all.length > CAP,
3038
- };
3302
+ return seed;
3039
3303
  }
3040
3304
 
3041
- function orderedRoleTabs(): Role[] {
3042
- // Built-in roles first in their declared order, then custom roles.
3043
- // Driven by BUILT_IN_ROLES so adding a role never needs a second edit here.
3044
- const rank = (name: string) => {
3045
- const i = (BUILT_IN_ROLES as readonly string[]).indexOf(
3046
- name.toLowerCase(),
3047
- );
3048
- return i === -1 ? BUILT_IN_ROLES.length : i;
3049
- };
3050
- return [...roleConfig.roles].sort((a, b) => rank(a.name) - rank(b.name));
3305
+ const listThemeOf = (theme: any) => ({
3306
+ selectedPrefix: (t: string) => theme.fg("accent", t),
3307
+ selectedText: (t: string) => theme.fg("accent", t),
3308
+ description: (t: string) => theme.fg("muted", t),
3309
+ scrollInfo: (t: string) => theme.fg("dim", t),
3310
+ noMatch: (t: string) => theme.fg("warning", t),
3311
+ });
3312
+
3313
+ /** Small confirm overlay (used by /profile delete). True only on explicit Delete. */
3314
+ async function confirmProfileDelete(
3315
+ ctx: ExtensionContext,
3316
+ profile: Profile,
3317
+ ): Promise<boolean> {
3318
+ const result = await ctx.ui.custom<boolean | null>(
3319
+ (tui, theme, _kb, done) => {
3320
+ const list = new SelectList(
3321
+ [
3322
+ {
3323
+ value: "delete",
3324
+ label: `Delete ⚡ ${profile.name}`,
3325
+ description: "remove from profiles.json (cannot be undone)",
3326
+ },
3327
+ {
3328
+ value: "cancel",
3329
+ label: "Cancel",
3330
+ description: "keep the profile",
3331
+ },
3332
+ ],
3333
+ 2,
3334
+ listThemeOf(theme),
3335
+ );
3336
+ let cached: string[] | undefined;
3337
+ return {
3338
+ render: (width: number) => {
3339
+ if (cached) return cached;
3340
+ const W = Math.max(1, width);
3341
+ const lines: string[] = [];
3342
+ lines.push(theme.fg("accent", "─".repeat(W)));
3343
+ lines.push(
3344
+ ` ${theme.fg("warning", `Delete profile "${profile.name}"?`)}`,
3345
+ );
3346
+ lines.push("");
3347
+ for (const l of list.render(Math.max(1, W - 2))) lines.push(` ${l}`);
3348
+ lines.push("");
3349
+ lines.push(
3350
+ ` ${theme.fg("dim", "↑↓ navigate • Enter select • Esc cancel")}`,
3351
+ );
3352
+ lines.push(theme.fg("accent", "─".repeat(W)));
3353
+ cached = lines;
3354
+ return lines;
3355
+ },
3356
+ invalidate: () => {
3357
+ cached = undefined;
3358
+ },
3359
+ handleInput: (data: string) => {
3360
+ if (matchesKey(data, Key.escape)) {
3361
+ done(null);
3362
+ return;
3363
+ }
3364
+ if (matchesKey(data, Key.enter)) {
3365
+ done(list.getSelectedItem()?.value === "delete");
3366
+ return;
3367
+ }
3368
+ list.handleInput(data);
3369
+ cached = undefined;
3370
+ tui.requestRender();
3371
+ },
3372
+ };
3373
+ },
3374
+ );
3375
+ return result === true;
3051
3376
  }
3052
3377
 
3053
- async function openRolePicker(ctx: ExtensionContext): Promise<void> {
3054
- const tabs = orderedRoleTabs();
3055
- if (tabs.length === 0) {
3056
- ctx.ui.notify("No roles configured — /role reset to reseed.", "warning");
3057
- return;
3058
- }
3059
- const catalog = collectRoleCatalog(ctx);
3060
- if (catalog.all.length === 0) {
3061
- ctx.ui.notify(
3062
- "No models available from connected providers — connect a provider first, or use /role set <role> <provider/model> [thinking].",
3063
- "warning",
3064
- );
3065
- return;
3066
- }
3067
- // Stage current values so the picker opens on the active selection.
3068
- const staged = new Map<string, StagedRoleModel>();
3069
- for (const r of tabs) staged.set(r.name, { ...r.model });
3378
+ /**
3379
+ * Free-text profile name entry (Editor overlay, same input pattern as the
3380
+ * questionnaire). Resolves the validated name, or null on cancel/invalid-Esc.
3381
+ */
3382
+ async function inputProfileName(
3383
+ ctx: ExtensionContext,
3384
+ opts: {
3385
+ title: string;
3386
+ initial?: string;
3387
+ /** Names that would collide (case-insensitive), minus any being renamed. */
3388
+ existing?: readonly string[];
3389
+ },
3390
+ ): Promise<string | null> {
3391
+ return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
3392
+ const editorTheme: EditorTheme = {
3393
+ borderColor: (s) => theme.fg("accent", s),
3394
+ selectList: {
3395
+ selectedPrefix: (t: string) => theme.fg("accent", t),
3396
+ selectedText: (t: string) => theme.fg("accent", t),
3397
+ description: (t: string) => theme.fg("muted", t),
3398
+ scrollInfo: (t: string) => theme.fg("dim", t),
3399
+ noMatch: (t: string) => theme.fg("warning", t),
3400
+ },
3401
+ };
3402
+ const editor = new Editor(tui, editorTheme);
3403
+ if (opts.initial) editor.setText(opts.initial);
3404
+ let error = "";
3405
+ let cached: string[] | undefined;
3406
+ editor.onSubmit = (value: string) => {
3407
+ const check = validateProfileName(String(value ?? ""), opts.existing);
3408
+ if (!check.ok) {
3409
+ error = check.error;
3410
+ cached = undefined;
3411
+ tui.requestRender();
3412
+ return;
3413
+ }
3414
+ done(check.value);
3415
+ };
3416
+ return {
3417
+ render: (width: number) => {
3418
+ if (cached) return cached;
3419
+ const W = Math.max(1, width);
3420
+ const lines: string[] = [];
3421
+ lines.push(theme.fg("accent", "─".repeat(W)));
3422
+ lines.push(
3423
+ ...wrapTextWithAnsi(` ${theme.fg("text", opts.title)}`, W),
3424
+ );
3425
+ if (error)
3426
+ lines.push(
3427
+ ...wrapTextWithAnsi(` ${theme.fg("warning", error)}`, W),
3428
+ );
3429
+ lines.push("");
3430
+ for (const l of editor.render(Math.max(1, W))) lines.push(l);
3431
+ lines.push("");
3432
+ lines.push(` ${theme.fg("dim", "Enter confirm • Esc cancel")}`);
3433
+ lines.push(theme.fg("accent", "─".repeat(W)));
3434
+ cached = lines;
3435
+ return lines;
3436
+ },
3437
+ invalidate: () => {
3438
+ cached = undefined;
3439
+ },
3440
+ handleInput: (data: string) => {
3441
+ if (matchesKey(data, Key.escape)) {
3442
+ done(null);
3443
+ return;
3444
+ }
3445
+ editor.handleInput(data);
3446
+ cached = undefined;
3447
+ tui.requestRender();
3448
+ },
3449
+ };
3450
+ });
3451
+ }
3070
3452
 
3071
- overlayActive = true;
3072
- let saved: Map<string, StagedRoleModel> | null = null;
3073
- try {
3074
- saved = await ctx.ui.custom<Map<string, StagedRoleModel> | null>(
3453
+ /**
3454
+ * Profile management picker: activate / none / new / per-profile actions
3455
+ * (edit, rename, delete) and the default-profile entry. Loops so actions
3456
+ * return to the list; every action handles its own persistence.
3457
+ */
3458
+ async function openProfilePicker(ctx: ExtensionContext): Promise<void> {
3459
+ ensureProfilesConfig((ctx as any)?.cwd);
3460
+ ensureRoleConfig((ctx as any)?.cwd);
3461
+ while (true) {
3462
+ const profiles = profilesConfig.profiles;
3463
+ const def = currentDefaultProfileName((ctx as any)?.cwd);
3464
+ const entries: SelectItem[] = [];
3465
+ entries.push({
3466
+ value: "none",
3467
+ label: activeProfileName
3468
+ ? "— none (default roles) —"
3469
+ : "● — none (default roles) —",
3470
+ description: "deactivate any profile; the roles.json flow applies",
3471
+ });
3472
+ for (const p of profiles) {
3473
+ const isActive =
3474
+ activeProfileName?.toLowerCase() === p.name.toLowerCase();
3475
+ const isDefault = def?.toLowerCase() === p.name.toLowerCase();
3476
+ const markers = [
3477
+ isActive ? "● active" : "",
3478
+ isDefault ? "★ default" : "",
3479
+ ]
3480
+ .filter(Boolean)
3481
+ .join(", ");
3482
+ const modelSummary = PROFILE_ROLES.map((r) => {
3483
+ const m = p.roles[r];
3484
+ return m ? `${roleIcon(r)} ${formatModelRef(m)}` : null;
3485
+ })
3486
+ .filter(Boolean)
3487
+ .join(" · ");
3488
+ entries.push({
3489
+ value: p.name,
3490
+ label: `⚡ ${p.name}${markers ? ` (${markers})` : ""}`,
3491
+ description: p.description || modelSummary || undefined,
3492
+ });
3493
+ }
3494
+ entries.push({
3495
+ value: "__new",
3496
+ label: "+ New profile…",
3497
+ description: "name it, then pick a model per role (like /role)",
3498
+ });
3499
+ entries.push({
3500
+ value: "__default",
3501
+ label: "Default for new sessions…",
3502
+ description: `workflow.defaultProfile — currently: ${def ?? "(none)"}`,
3503
+ });
3504
+
3505
+ const selected = await ctx.ui.custom<string | null>(
3075
3506
  (tui, theme, _kb, done) => {
3076
- let currentTab = 0; // tabs.length = Submit
3077
- let phase: "model" | "thinking" = "model";
3507
+ const list = new SelectList(entries, 10, listThemeOf(theme));
3508
+ let cached: string[] | undefined;
3509
+ return {
3510
+ render: (width: number) => {
3511
+ if (cached) return cached;
3512
+ const W = Math.max(1, width);
3513
+ const lines: string[] = [];
3514
+ lines.push(theme.fg("accent", "─".repeat(W)));
3515
+ lines.push(
3516
+ ` ${theme.fg("text", "Model Profiles")} ${theme.fg(
3517
+ "muted",
3518
+ `— active: ${activeProfileName ?? "none (default roles)"}`,
3519
+ )}`,
3520
+ );
3521
+ lines.push("");
3522
+ for (const l of list.render(Math.max(1, W - 2)))
3523
+ lines.push(` ${l}`);
3524
+ lines.push("");
3525
+ lines.push(
3526
+ ` ${theme.fg("dim", "Enter: select/action • type to filter • Esc close")}`,
3527
+ );
3528
+ lines.push(theme.fg("accent", "─".repeat(W)));
3529
+ cached = lines;
3530
+ return lines;
3531
+ },
3532
+ invalidate: () => {
3533
+ cached = undefined;
3534
+ },
3535
+ handleInput: (data: string) => {
3536
+ if (matchesKey(data, Key.escape)) {
3537
+ done(null);
3538
+ return;
3539
+ }
3540
+ if (matchesKey(data, Key.enter)) {
3541
+ done(list.getSelectedItem()?.value ?? null);
3542
+ return;
3543
+ }
3544
+ list.handleInput(data);
3545
+ cached = undefined;
3546
+ tui.requestRender();
3547
+ },
3548
+ };
3549
+ },
3550
+ );
3551
+ if (!selected) return; // Esc / closed
3552
+ if (selected === "__new") {
3553
+ const name = await inputProfileName(ctx, {
3554
+ title: "New profile — name (dashes, no spaces):",
3555
+ existing: profilesConfig.profiles.map((p) => p.name),
3556
+ });
3557
+ if (!name) continue;
3558
+ await openProfileEditor(ctx, { createName: name });
3559
+ continue;
3560
+ }
3561
+ if (selected === "__default") {
3562
+ await pickDefaultProfile(ctx);
3563
+ continue;
3564
+ }
3565
+ if (selected === "none") {
3566
+ if (activeProfileName) {
3567
+ await setActiveProfile(ctx as any, null, { explicit: true });
3568
+ } else {
3569
+ ctx.ui.notify("No profile active — default roles flow.", "info");
3570
+ }
3571
+ continue;
3572
+ }
3573
+ // A concrete profile → action menu
3574
+ const profile = getProfile(profilesConfig, selected);
3575
+ if (!profile) continue;
3576
+ const action = await ctx.ui.custom<string | null>(
3577
+ (tui, theme, _kb, done) => {
3578
+ const isActive =
3579
+ activeProfileName?.toLowerCase() === profile.name.toLowerCase();
3580
+ const list = new SelectList(
3581
+ [
3582
+ isActive
3583
+ ? {
3584
+ value: "deactivate",
3585
+ label: "Deactivate",
3586
+ description: "back to default roles (this session)",
3587
+ }
3588
+ : {
3589
+ value: "activate",
3590
+ label: "Activate now",
3591
+ description:
3592
+ "session-scoped; plan/build re-apply immediately",
3593
+ },
3594
+ {
3595
+ value: "edit",
3596
+ label: "Edit models",
3597
+ description: "tabbed editor — model + thinking per role",
3598
+ },
3599
+ {
3600
+ value: "rename",
3601
+ label: "Rename",
3602
+ description: "change the profile name",
3603
+ },
3604
+ {
3605
+ value: "delete",
3606
+ label: "Delete",
3607
+ description: "remove from profiles.json",
3608
+ },
3609
+ { value: "back", label: "Back", description: "return to the profile list" },
3610
+ ],
3611
+ 5,
3612
+ listThemeOf(theme),
3613
+ );
3614
+ let cached: string[] | undefined;
3615
+ return {
3616
+ render: (width: number) => {
3617
+ if (cached) return cached;
3618
+ const W = Math.max(1, width);
3619
+ const lines: string[] = [];
3620
+ lines.push(theme.fg("accent", "─".repeat(W)));
3621
+ lines.push(
3622
+ ` ${theme.fg("text", `⚡ ${profile.name}`)}${
3623
+ profile.description
3624
+ ? theme.fg("muted", ` — ${profile.description}`)
3625
+ : ""
3626
+ }`,
3627
+ );
3628
+ lines.push("");
3629
+ for (const l of list.render(Math.max(1, W - 2)))
3630
+ lines.push(` ${l}`);
3631
+ lines.push("");
3632
+ lines.push(` ${theme.fg("dim", "Enter select • Esc back")}`);
3633
+ lines.push(theme.fg("accent", "─".repeat(W)));
3634
+ cached = lines;
3635
+ return lines;
3636
+ },
3637
+ invalidate: () => {
3638
+ cached = undefined;
3639
+ },
3640
+ handleInput: (data: string) => {
3641
+ if (matchesKey(data, Key.escape)) {
3642
+ done(null);
3643
+ return;
3644
+ }
3645
+ if (matchesKey(data, Key.enter)) {
3646
+ done(list.getSelectedItem()?.value ?? null);
3647
+ return;
3648
+ }
3649
+ list.handleInput(data);
3650
+ cached = undefined;
3651
+ tui.requestRender();
3652
+ },
3653
+ };
3654
+ },
3655
+ );
3656
+ if (!action || action === "back") continue;
3657
+ if (action === "activate") {
3658
+ await setActiveProfile(ctx as any, profile.name, { explicit: true });
3659
+ continue;
3660
+ }
3661
+ if (action === "deactivate") {
3662
+ await setActiveProfile(ctx as any, null, { explicit: true });
3663
+ continue;
3664
+ }
3665
+ if (action === "edit") {
3666
+ await openProfileEditor(ctx, { name: profile.name });
3667
+ continue;
3668
+ }
3669
+ if (action === "rename") {
3670
+ const newName = await inputProfileName(ctx, {
3671
+ title: `Rename profile "${profile.name}" to:`,
3672
+ initial: profile.name,
3673
+ existing: profilesConfig.profiles
3674
+ .filter((p) => p !== profile)
3675
+ .map((p) => p.name),
3676
+ });
3677
+ if (newName && newName !== profile.name) {
3678
+ const wasActive =
3679
+ activeProfileName?.toLowerCase() === profile.name.toLowerCase();
3680
+ profile.name = newName;
3681
+ if (wasActive) activeProfileName = profile.name;
3682
+ invalidateEffectiveRoles();
3683
+ try {
3684
+ saveProfiles(profilesConfig, "user");
3685
+ ctx.ui.notify(`Profile renamed → ⚡ ${profile.name}.`, "info");
3686
+ } catch (e: any) {
3687
+ ctx.ui.notify(
3688
+ `Renamed in memory but file save failed: ${String(e?.message || e)}`,
3689
+ "warning",
3690
+ );
3691
+ }
3692
+ updateStatus(ctx as any);
3693
+ }
3694
+ continue;
3695
+ }
3696
+ if (action === "delete") {
3697
+ const confirmed = await confirmProfileDelete(ctx, profile);
3698
+ if (!confirmed) continue;
3699
+ const wasActive =
3700
+ activeProfileName?.toLowerCase() === profile.name.toLowerCase();
3701
+ profilesConfig.profiles = profilesConfig.profiles.filter(
3702
+ (p) => p !== profile,
3703
+ );
3704
+ if (wasActive) {
3705
+ activeProfileName = null;
3706
+ invalidateEffectiveRoles();
3707
+ }
3708
+ try {
3709
+ saveProfiles(profilesConfig, "user");
3710
+ ctx.ui.notify(
3711
+ `Profile ⚡ ${profile.name} deleted.${
3712
+ wasActive
3713
+ ? " It was active — default roles flow restored."
3714
+ : ""
3715
+ }`,
3716
+ "info",
3717
+ );
3718
+ } catch (e: any) {
3719
+ ctx.ui.notify(
3720
+ `Deleted in memory but file save failed: ${String(e?.message || e)}`,
3721
+ "warning",
3722
+ );
3723
+ }
3724
+ if (wasActive && workflowMode) {
3725
+ try {
3726
+ await applyModeModel(ctx as any);
3727
+ } catch (_e) {
3728
+ void _e;
3729
+ }
3730
+ }
3731
+ updateStatus(ctx as any);
3732
+ continue;
3733
+ }
3734
+ }
3735
+ }
3736
+
3737
+ /** Picker for the default profile (workflow.defaultProfile) + its scope. */
3738
+ async function pickDefaultProfile(ctx: ExtensionContext): Promise<void> {
3739
+ const cwd = (ctx as any)?.cwd as string | undefined;
3740
+ const current = currentDefaultProfileName(cwd);
3741
+ const entries: SelectItem[] = [
3742
+ {
3743
+ value: "__none",
3744
+ label: "— none —",
3745
+ description: "new sessions start without a profile",
3746
+ },
3747
+ ...profilesConfig.profiles.map((p) => ({
3748
+ value: p.name,
3749
+ label: `⚡ ${p.name}`,
3750
+ description: p.description || undefined,
3751
+ })),
3752
+ ];
3753
+ const name = await ctx.ui.custom<string | null>(
3754
+ (tui, theme, _kb, done) => {
3755
+ const list = new SelectList(entries, 8, listThemeOf(theme));
3756
+ let cached: string[] | undefined;
3757
+ return {
3758
+ render: (width: number) => {
3759
+ if (cached) return cached;
3760
+ const W = Math.max(1, width);
3761
+ const lines: string[] = [];
3762
+ lines.push(theme.fg("accent", "─".repeat(W)));
3763
+ lines.push(
3764
+ ` ${theme.fg("text", "Default profile for new sessions")} ${theme.fg(
3765
+ "muted",
3766
+ `— currently: ${current ?? "(none)"}`,
3767
+ )}`,
3768
+ );
3769
+ lines.push("");
3770
+ for (const l of list.render(Math.max(1, W - 2)))
3771
+ lines.push(` ${l}`);
3772
+ lines.push("");
3773
+ lines.push(` ${theme.fg("dim", "Enter select • Esc cancel")}`);
3774
+ lines.push(theme.fg("accent", "─".repeat(W)));
3775
+ cached = lines;
3776
+ return lines;
3777
+ },
3778
+ invalidate: () => {
3779
+ cached = undefined;
3780
+ },
3781
+ handleInput: (data: string) => {
3782
+ if (matchesKey(data, Key.escape)) {
3783
+ done(null);
3784
+ return;
3785
+ }
3786
+ if (matchesKey(data, Key.enter)) {
3787
+ done(list.getSelectedItem()?.value ?? null);
3788
+ return;
3789
+ }
3790
+ list.handleInput(data);
3791
+ cached = undefined;
3792
+ tui.requestRender();
3793
+ },
3794
+ };
3795
+ },
3796
+ );
3797
+ if (name === null) return;
3798
+ // Keep the scope where the setting currently lives (else global).
3799
+ const resolved = readDefaultProfileSetting(
3800
+ cwd ?? process.cwd(),
3801
+ getAgentDir(),
3802
+ );
3803
+ const scope: "global" | "project" =
3804
+ resolved.sources.effective === "project" ? "project" : "global";
3805
+ const value = name === "__none" ? null : name;
3806
+ const res = writeDefaultProfileSetting(
3807
+ scope,
3808
+ cwd ?? process.cwd(),
3809
+ getAgentDir(),
3810
+ value,
3811
+ );
3812
+ if (!res.ok) {
3813
+ ctx.ui.notify(
3814
+ `Could not write default profile: ${res.error ?? "unknown error"}`,
3815
+ "error",
3816
+ );
3817
+ return;
3818
+ }
3819
+ ctx.ui.notify(
3820
+ value
3821
+ ? `Default profile → ⚡ ${value} (${scope} settings)\nNew sessions without an explicit /profile use start on it.`
3822
+ : `Default profile cleared (${scope} settings).`,
3823
+ "info",
3824
+ );
3825
+ updateStatus(ctx as any);
3826
+ }
3827
+
3828
+ interface StagedProfileModel {
3829
+ provider: string;
3830
+ id: string;
3831
+ thinking: ThinkingLevel;
3832
+ }
3833
+
3834
+ /**
3835
+ * Tabbed profile editor — one tab per built-in role (planner / explorer /
3836
+ * builder / reviewer, mirroring /role) plus two submit tabs: Save &
3837
+ * activate and Save only. Selections stage locally; saving writes
3838
+ * profiles.json (user scope). `createName` creates a new profile seeded
3839
+ * from the current effective roles; `name` edits an existing one.
3840
+ */
3841
+ async function openProfileEditor(
3842
+ ctx: ExtensionContext,
3843
+ opts: { createName?: string; name?: string },
3844
+ ): Promise<void> {
3845
+ ensureProfilesConfig((ctx as any)?.cwd);
3846
+ ensureRoleConfig((ctx as any)?.cwd);
3847
+ const catalog = collectRoleCatalog(ctx);
3848
+ if (catalog.all.length === 0) {
3849
+ ctx.ui.notify(
3850
+ "No models available from connected providers — connect a provider first.",
3851
+ "warning",
3852
+ );
3853
+ return;
3854
+ }
3855
+ const existing = opts.name
3856
+ ? getProfile(profilesConfig, opts.name)
3857
+ : undefined;
3858
+ if (opts.name && !existing) {
3859
+ ctx.ui.notify(`Profile "${opts.name}" no longer exists.`, "warning");
3860
+ return;
3861
+ }
3862
+ const isNew = !existing;
3863
+ const profileName = existing
3864
+ ? existing.name
3865
+ : (opts.createName ?? "").trim();
3866
+ if (!profileName) {
3867
+ ctx.ui.notify("Profile name required.", "warning");
3868
+ return;
3869
+ }
3870
+ const roleMeta = new Map<string, { name: string; description: string }>();
3871
+ for (const roleName of PROFILE_ROLES) {
3872
+ const base = getRole(roleConfig, roleName);
3873
+ roleMeta.set(roleName, {
3874
+ name: roleName,
3875
+ description: base?.description ?? `Role ${roleName}`,
3876
+ });
3877
+ }
3878
+ // Keep the narrow tuple type so profile.roles[roleName] typechecks.
3879
+ const tabIds = [...PROFILE_ROLES];
3880
+ // Stage from the profile (edit) or the current effective roles (create).
3881
+ const staged = new Map<string, StagedProfileModel>();
3882
+ for (const roleName of PROFILE_ROLES) {
3883
+ const src =
3884
+ existing?.roles[roleName] ?? getEffectiveRole(roleName)?.model;
3885
+ staged.set(roleName, {
3886
+ provider: src?.provider ?? "",
3887
+ id: src?.id ?? "",
3888
+ thinking: src?.thinking ?? "medium",
3889
+ });
3890
+ }
3891
+
3892
+ overlayActive = true;
3893
+ let saved: {
3894
+ activate: boolean;
3895
+ staged: Map<string, StagedProfileModel>;
3896
+ } | null = null;
3897
+ try {
3898
+ saved = await ctx.ui.custom<
3899
+ { activate: boolean; staged: Map<string, StagedProfileModel> } | null
3900
+ >((tui, theme, _kb, done) => {
3901
+ let currentTab = 0;
3902
+ let phase: "model" | "thinking" = "model";
3903
+ let showAll = catalog.scoped.length === 0;
3904
+ let cached: string[] | undefined;
3905
+ const themeList = listThemeOf(theme);
3906
+ const isSubmitTab = () => currentTab >= tabIds.length;
3907
+ const isSaveActivate = () => currentTab === tabIds.length;
3908
+ const buildModelItems = (roleName: string): SelectItem[] => {
3909
+ const st = staged.get(roleName)!;
3910
+ const cur = `${st.provider}/${st.id}`;
3911
+ const src = showAll ? catalog.all : catalog.scoped;
3912
+ return src.map((e) => {
3913
+ const ref = `${e.provider}/${e.id}`;
3914
+ const description: string | undefined =
3915
+ ref === cur
3916
+ ? "● selected"
3917
+ : showAll && !e.inScope
3918
+ ? "all"
3919
+ : undefined;
3920
+ return { value: ref, label: ref, description };
3921
+ });
3922
+ };
3923
+ const modelLists = new Map<string, SelectList>();
3924
+ const thinkingList = new SelectList(
3925
+ VALID_THINKING_LEVELS.map((t) => ({ value: t, label: t })),
3926
+ Math.min(VALID_THINKING_LEVELS.length, 8),
3927
+ themeList,
3928
+ );
3929
+ const getModelList = (roleName: string): SelectList => {
3930
+ let list = modelLists.get(roleName);
3931
+ if (!list) {
3932
+ list = new SelectList(buildModelItems(roleName), 10, themeList);
3933
+ const st = staged.get(roleName)!;
3934
+ const idx = (showAll ? catalog.all : catalog.scoped).findIndex(
3935
+ (e) => `${e.provider}/${e.id}` === `${st.provider}/${st.id}`,
3936
+ );
3937
+ if (idx > 0) list.setSelectedIndex(idx);
3938
+ modelLists.set(roleName, list);
3939
+ }
3940
+ return list;
3941
+ };
3942
+ const rebuildModelLists = () => modelLists.clear();
3943
+ const syncThinkingCursor = (roleName: string) => {
3944
+ const st = staged.get(roleName)!;
3945
+ const idx = VALID_THINKING_LEVELS.indexOf(st.thinking);
3946
+ if (idx >= 0) thinkingList.setSelectedIndex(idx);
3947
+ };
3948
+ const commitModelSelection = (roleName: string) => {
3949
+ const sel = getModelList(roleName).getSelectedItem();
3950
+ if (!sel) return false;
3951
+ const { provider, id } = parseModelRef(sel.value);
3952
+ if (!provider || !id) return false;
3953
+ const st = staged.get(roleName)!;
3954
+ staged.set(roleName, { ...st, provider, id });
3955
+ return true;
3956
+ };
3957
+ const commitThinkingSelection = (roleName: string) => {
3958
+ const sel = thinkingList.getSelectedItem();
3959
+ if (!sel || !isValidThinkingLevel(sel.value)) return false;
3960
+ const st = staged.get(roleName)!;
3961
+ staged.set(roleName, { ...st, thinking: sel.value });
3962
+ return true;
3963
+ };
3964
+ const advanceFromThinking = () => {
3965
+ if (currentTab < tabIds.length) currentTab++;
3966
+ phase = "model";
3967
+ cached = undefined;
3968
+ tui.requestRender();
3969
+ };
3970
+
3971
+ const handleInput = (data: string) => {
3972
+ if (matchesKey(data, Key.escape)) {
3973
+ if (phase === "thinking" && !isSubmitTab()) {
3974
+ phase = "model";
3975
+ cached = undefined;
3976
+ tui.requestRender();
3977
+ return;
3978
+ }
3979
+ done(null);
3980
+ return;
3981
+ }
3982
+ if (
3983
+ matchesKey(data, Key.tab) ||
3984
+ matchesKey(data, Key.right) ||
3985
+ matchesKey(data, Key.shift("tab")) ||
3986
+ matchesKey(data, Key.left)
3987
+ ) {
3988
+ const fwd =
3989
+ matchesKey(data, Key.tab) || matchesKey(data, Key.right);
3990
+ const n = tabIds.length + 2;
3991
+ currentTab = fwd
3992
+ ? (currentTab + 1) % n
3993
+ : (currentTab - 1 + n) % n;
3994
+ phase = "model";
3995
+ cached = undefined;
3996
+ tui.requestRender();
3997
+ return;
3998
+ }
3999
+ // Ctrl+O toggles scoped ↔ all models
4000
+ try {
4001
+ if (matchesKey(data, Key.ctrl("o"))) {
4002
+ showAll = !showAll;
4003
+ rebuildModelLists();
4004
+ cached = undefined;
4005
+ tui.requestRender();
4006
+ return;
4007
+ }
4008
+ } catch (_e) {
4009
+ void _e;
4010
+ }
4011
+ if (isSubmitTab()) {
4012
+ if (matchesKey(data, Key.enter)) {
4013
+ done({ activate: isSaveActivate(), staged: new Map(staged) });
4014
+ }
4015
+ return;
4016
+ }
4017
+ const roleName = tabIds[currentTab];
4018
+ if (matchesKey(data, Key.enter)) {
4019
+ if (phase === "model") {
4020
+ if (!commitModelSelection(roleName)) return;
4021
+ phase = "thinking";
4022
+ syncThinkingCursor(roleName);
4023
+ cached = undefined;
4024
+ tui.requestRender();
4025
+ return;
4026
+ }
4027
+ if (!commitThinkingSelection(roleName)) return;
4028
+ advanceFromThinking();
4029
+ return;
4030
+ }
4031
+ if (phase === "model") getModelList(roleName).handleInput(data);
4032
+ else thinkingList.handleInput(data);
4033
+ cached = undefined;
4034
+ tui.requestRender();
4035
+ };
4036
+
4037
+ const render = (width: number): string[] => {
4038
+ if (cached) return cached;
4039
+ const lines: string[] = [];
4040
+ const W = Math.max(1, width);
4041
+ const wrap = (s: string) => {
4042
+ const out: string[] = [];
4043
+ for (const line of s.split("\n"))
4044
+ out.push(...wrapTextWithAnsi(line, W));
4045
+ return out;
4046
+ };
4047
+ lines.push(theme.fg("accent", "─".repeat(W)));
4048
+ lines.push(
4049
+ ` ${theme.fg(
4050
+ "text",
4051
+ `${isNew ? "New profile" : "Edit profile"} ⚡ ${profileName}`,
4052
+ )}`,
4053
+ );
4054
+ lines.push("");
4055
+ // Tabs row: one block per role + two submit tabs
4056
+ const chips: string[] = [];
4057
+ for (let i = 0; i < tabIds.length; i++) {
4058
+ const roleName = tabIds[i];
4059
+ const st = staged.get(roleName)!;
4060
+ const base = existing?.roles[roleName];
4061
+ const dirty =
4062
+ !base ||
4063
+ `${st.provider}/${st.id}` !== `${base.provider}/${base.id}` ||
4064
+ st.thinking !== base.thinking;
4065
+ const box = dirty ? "■" : "□";
4066
+ const lbl = ` ${box} ${roleIcon(roleName)} ${roleName} `;
4067
+ chips.push(
4068
+ i === currentTab
4069
+ ? theme.bg("selectedBg", theme.fg("text", lbl))
4070
+ : theme.fg(dirty ? "success" : "muted", lbl),
4071
+ );
4072
+ }
4073
+ const saveActivateLbl = " ✓ Save & activate ";
4074
+ const saveOnlyLbl = " 💾 Save only ";
4075
+ chips.push(
4076
+ isSaveActivate()
4077
+ ? theme.bg("selectedBg", theme.fg("text", saveActivateLbl))
4078
+ : theme.fg("success", saveActivateLbl),
4079
+ );
4080
+ chips.push(
4081
+ currentTab === tabIds.length + 1
4082
+ ? theme.bg("selectedBg", theme.fg("text", saveOnlyLbl))
4083
+ : theme.fg("success", saveOnlyLbl),
4084
+ );
4085
+ lines.push(` ${chips.join("")}`);
4086
+ lines.push("");
4087
+ if (isSubmitTab()) {
4088
+ lines.push(
4089
+ ...wrap(
4090
+ ` ${theme.fg(
4091
+ "text",
4092
+ isSaveActivate()
4093
+ ? "Save & activate — Enter saves and switches this session to the profile; Esc cancels"
4094
+ : "Save only — Enter saves without activating; Esc cancels",
4095
+ )}`,
4096
+ ),
4097
+ );
4098
+ lines.push("");
4099
+ for (const roleName of tabIds) {
4100
+ const st = staged.get(roleName)!;
4101
+ lines.push(
4102
+ ...wrap(
4103
+ ` ${theme.fg("muted", `${roleIcon(roleName)} ${roleName}: `)}${theme.fg(
4104
+ "text",
4105
+ st.provider
4106
+ ? `${st.provider}/${st.id} (${st.thinking})`
4107
+ : "(unset)",
4108
+ )}`,
4109
+ ),
4110
+ );
4111
+ }
4112
+ } else {
4113
+ const roleName = tabIds[currentTab];
4114
+ const st = staged.get(roleName)!;
4115
+ const meta = roleMeta.get(roleName)!;
4116
+ lines.push(
4117
+ ...wrap(
4118
+ ` ${theme.fg("text", `${roleIcon(roleName)} ${roleName} — ${meta.description}`)}`,
4119
+ ),
4120
+ );
4121
+ lines.push(
4122
+ ...wrap(
4123
+ ` ${theme.fg(
4124
+ "muted",
4125
+ `staged: ${
4126
+ st.provider
4127
+ ? `${st.provider}/${st.id} (${st.thinking})`
4128
+ : "(unset)"
4129
+ }`,
4130
+ )}`,
4131
+ ),
4132
+ );
4133
+ lines.push("");
4134
+ if (phase === "model") {
4135
+ const scopeLabel = showAll
4136
+ ? `all ${String(catalog.all.length)}${catalog.truncated ? "+" : ""}`
4137
+ : `scoped ${String(catalog.scoped.length)}`;
4138
+ const toggleHint =
4139
+ catalog.scoped.length > 0 && !showAll
4140
+ ? " — Ctrl+O for all"
4141
+ : "";
4142
+ lines.push(
4143
+ ...wrap(
4144
+ ` ${theme.fg(
4145
+ "accent",
4146
+ `Model (${scopeLabel}${toggleHint}) — type to filter:`,
4147
+ )}`,
4148
+ ),
4149
+ );
4150
+ for (const l of getModelList(roleName).render(Math.max(1, W - 2)))
4151
+ lines.push(` ${l}`);
4152
+ } else {
4153
+ lines.push(
4154
+ ...wrap(` ${theme.fg("accent", "Thinking level:")}`),
4155
+ );
4156
+ for (const l of thinkingList.render(Math.max(1, W - 2)))
4157
+ lines.push(` ${l}`);
4158
+ }
4159
+ }
4160
+ lines.push("");
4161
+ lines.push(
4162
+ ...wrap(
4163
+ ` ${theme.fg(
4164
+ "dim",
4165
+ "Tab/←→ roles • ↑↓ navigate • type to filter • Enter select • Esc back/cancel • Ctrl+O scoped/all",
4166
+ )}`,
4167
+ ),
4168
+ );
4169
+ lines.push(theme.fg("accent", "─".repeat(W)));
4170
+ cached = lines;
4171
+ return lines;
4172
+ };
4173
+
4174
+ return {
4175
+ render,
4176
+ invalidate: () => {
4177
+ cached = undefined;
4178
+ },
4179
+ handleInput,
4180
+ };
4181
+ });
4182
+ } finally {
4183
+ overlayActive = false;
4184
+ }
4185
+ if (!saved) {
4186
+ ctx.ui.notify(
4187
+ isNew ? "Profile creation cancelled." : "Profile editor cancelled.",
4188
+ "info",
4189
+ );
4190
+ return;
4191
+ }
4192
+ // Validate against registry (warn-only, matching /role set).
4193
+ try {
4194
+ const reg = (ctx as any)?.modelRegistry;
4195
+ if (reg && typeof reg.find === "function") {
4196
+ for (const [roleName, st] of saved.staged) {
4197
+ if (
4198
+ st.provider &&
4199
+ st.id &&
4200
+ typeof reg.find === "function" &&
4201
+ !reg.find(st.provider, st.id)
4202
+ )
4203
+ ctx.ui.notify(
4204
+ `Warning: ${st.provider}/${st.id} (${roleName}) not in model registry — saved anyway.`,
4205
+ "warning",
4206
+ );
4207
+ }
4208
+ }
4209
+ } catch (_e) {
4210
+ void _e;
4211
+ }
4212
+ // Materialize: create or update the profile, then persist.
4213
+ let profile = existing;
4214
+ if (!profile) {
4215
+ profile = { name: profileName, description: "", roles: {} };
4216
+ profilesConfig.profiles.push(profile);
4217
+ }
4218
+ profile.roles = {};
4219
+ for (const roleName of PROFILE_ROLES) {
4220
+ const st = saved.staged.get(roleName);
4221
+ if (st && st.provider && st.id)
4222
+ profile.roles[roleName] = {
4223
+ provider: st.provider,
4224
+ id: st.id,
4225
+ thinking: st.thinking,
4226
+ };
4227
+ }
4228
+ try {
4229
+ saveProfiles(profilesConfig, "user");
4230
+ } catch (e: any) {
4231
+ ctx.ui.notify(
4232
+ `Profile updated but file save failed: ${String(e?.message || e)}`,
4233
+ "warning",
4234
+ );
4235
+ return;
4236
+ }
4237
+ invalidateEffectiveRoles();
4238
+ updateStatus(ctx as any);
4239
+ if (saved.activate) {
4240
+ await setActiveProfile(ctx as any, profile.name, { explicit: true });
4241
+ } else {
4242
+ ctx.ui.notify(
4243
+ `Profile ⚡ ${profile.name} saved. Activate with /profile use ${profile.name}.`,
4244
+ "info",
4245
+ );
4246
+ }
4247
+ }
4248
+
4249
+ // ── Model profiles command ───────────────────────────────────────
4250
+
4251
+ pi.registerCommand("profile", {
4252
+ description:
4253
+ "Switch or manage model profiles (named role-model combinations) — opens picker UI",
4254
+ handler: async (args, ctx) => {
4255
+ const cwd = (ctx as any)?.cwd as string | undefined;
4256
+ ensureRoleConfig(cwd);
4257
+ ensureProfilesConfig(cwd);
4258
+ const parts = String(args || "")
4259
+ .trim()
4260
+ .split(/\s+/)
4261
+ .filter(Boolean);
4262
+ const sub = (parts[0] || "").toLowerCase();
4263
+ const rest = parts.slice(1);
4264
+
4265
+ const persistAndNotify = (msg: string): boolean => {
4266
+ try {
4267
+ saveProfiles(profilesConfig, "user");
4268
+ } catch (e: any) {
4269
+ ctx.ui.notify(
4270
+ `Profile updated but file save failed: ${String(e?.message || e)}`,
4271
+ "warning",
4272
+ );
4273
+ return false;
4274
+ }
4275
+ invalidateEffectiveRoles();
4276
+ updateStatus(ctx as any);
4277
+ ctx.ui.notify(msg, "info");
4278
+ return true;
4279
+ };
4280
+
4281
+ // Bare /profile → picker UI (headless → text summary).
4282
+ if (!sub) {
4283
+ if ((ctx as any)?.hasUI) {
4284
+ await openProfilePicker(ctx as any);
4285
+ return;
4286
+ }
4287
+ ctx.ui.notify(
4288
+ formatProfilesForDisplay(
4289
+ profilesConfig,
4290
+ activeProfileName,
4291
+ currentDefaultProfileName(cwd),
4292
+ ),
4293
+ "info",
4294
+ );
4295
+ return;
4296
+ }
4297
+
4298
+ if (sub === "list" || (sub === "show" && !rest[0])) {
4299
+ ctx.ui.notify(
4300
+ formatProfilesForDisplay(
4301
+ profilesConfig,
4302
+ activeProfileName,
4303
+ currentDefaultProfileName(cwd),
4304
+ ),
4305
+ "info",
4306
+ );
4307
+ return;
4308
+ }
4309
+
4310
+ if (sub === "show") {
4311
+ const p = getProfile(profilesConfig, rest[0]);
4312
+ if (!p) {
4313
+ ctx.ui.notify(`Unknown profile "${rest[0]}".`, "warning");
4314
+ return;
4315
+ }
4316
+ ctx.ui.notify(describeProfile(p), "info");
4317
+ return;
4318
+ }
4319
+
4320
+ if (sub === "use" || sub === "activate") {
4321
+ const name = rest[0];
4322
+ if (
4323
+ !name ||
4324
+ (name.toLowerCase() !== "none" &&
4325
+ name.toLowerCase() !== "off" &&
4326
+ !getProfile(profilesConfig, name))
4327
+ ) {
4328
+ ctx.ui.notify(
4329
+ `Usage: /profile use <name|none>. Available: ${
4330
+ profilesConfig.profiles.map((p) => p.name).join(", ") ||
4331
+ "(none — /profile create <name>)"
4332
+ }`,
4333
+ "info",
4334
+ );
4335
+ return;
4336
+ }
4337
+ await setActiveProfile(
4338
+ ctx as any,
4339
+ name.toLowerCase() === "none" || name.toLowerCase() === "off"
4340
+ ? null
4341
+ : name,
4342
+ { explicit: true },
4343
+ );
4344
+ return;
4345
+ }
4346
+
4347
+ if (sub === "create" || sub === "new" || sub === "add") {
4348
+ const rawName = rest[0];
4349
+ if (!rawName) {
4350
+ ctx.ui.notify("Usage: /profile create <name>", "info");
4351
+ return;
4352
+ }
4353
+ const check = validateProfileName(
4354
+ rawName,
4355
+ profilesConfig.profiles.map((p) => p.name),
4356
+ );
4357
+ if (!check.ok) {
4358
+ ctx.ui.notify(`Cannot create profile: ${check.error}`, "warning");
4359
+ return;
4360
+ }
4361
+ if ((ctx as any)?.hasUI) {
4362
+ await openProfileEditor(ctx as any, { createName: check.value });
4363
+ return;
4364
+ }
4365
+ // Headless: create seeded from the current effective roles.
4366
+ const seed = buildProfileSeed(check.value);
4367
+ profilesConfig.profiles.push(seed);
4368
+ persistAndNotify(
4369
+ `Profile ⚡ ${seed.name} created (seeded from the current effective models). Activate with /profile use ${seed.name}.`,
4370
+ );
4371
+ return;
4372
+ }
4373
+
4374
+ if (sub === "edit" || sub === "config") {
4375
+ const name = rest[0];
4376
+ if (!name) {
4377
+ ctx.ui.notify("Usage: /profile edit <name>", "info");
4378
+ return;
4379
+ }
4380
+ const p = getProfile(profilesConfig, name);
4381
+ if (!p) {
4382
+ ctx.ui.notify(
4383
+ `Unknown profile "${name}". Available: ${
4384
+ profilesConfig.profiles.map((q) => q.name).join(", ") || "(none)"
4385
+ }`,
4386
+ "warning",
4387
+ );
4388
+ return;
4389
+ }
4390
+ if (!(ctx as any)?.hasUI) {
4391
+ ctx.ui.notify(describeProfile(p) + "\n\nEditor needs UI.", "info");
4392
+ return;
4393
+ }
4394
+ await openProfileEditor(ctx as any, { name: p.name });
4395
+ return;
4396
+ }
4397
+
4398
+ if (sub === "rename" || sub === "mv") {
4399
+ const [oldName, newName] = rest;
4400
+ if (!oldName || !newName) {
4401
+ ctx.ui.notify("Usage: /profile rename <old> <new>", "info");
4402
+ return;
4403
+ }
4404
+ const p = getProfile(profilesConfig, oldName);
4405
+ if (!p) {
4406
+ ctx.ui.notify(`Unknown profile "${oldName}".`, "warning");
4407
+ return;
4408
+ }
4409
+ const check = validateProfileName(
4410
+ newName,
4411
+ profilesConfig.profiles
4412
+ .filter((q) => q !== p)
4413
+ .map((q) => q.name),
4414
+ );
4415
+ if (!check.ok) {
4416
+ ctx.ui.notify(`Cannot rename: ${check.error}`, "warning");
4417
+ return;
4418
+ }
4419
+ const wasActive =
4420
+ activeProfileName?.toLowerCase() === p.name.toLowerCase();
4421
+ p.name = check.value;
4422
+ if (wasActive) activeProfileName = p.name;
4423
+ invalidateEffectiveRoles();
4424
+ persistAndNotify(
4425
+ `Profile renamed → ⚡ ${p.name}${
4426
+ wasActive ? " (still active in this session)" : ""
4427
+ }`,
4428
+ );
4429
+ const def = readDefaultProfileSetting(
4430
+ cwd ?? process.cwd(),
4431
+ getAgentDir(),
4432
+ );
4433
+ if (def.value && def.value.toLowerCase() === oldName.toLowerCase()) {
4434
+ ctx.ui.notify(
4435
+ `workflow.defaultProfile still points at "${def.value}" — update with /profile default ${p.name}${
4436
+ def.sources.effective === "project" ? " project" : ""
4437
+ }`,
4438
+ "warning",
4439
+ );
4440
+ }
4441
+ return;
4442
+ }
4443
+
4444
+ if (sub === "delete" || sub === "remove" || sub === "rm") {
4445
+ const name = rest[0];
4446
+ if (!name) {
4447
+ ctx.ui.notify("Usage: /profile delete <name>", "info");
4448
+ return;
4449
+ }
4450
+ const p = getProfile(profilesConfig, name);
4451
+ if (!p) {
4452
+ ctx.ui.notify(`Unknown profile "${name}".`, "warning");
4453
+ return;
4454
+ }
4455
+ if ((ctx as any)?.hasUI) {
4456
+ const confirmed = await confirmProfileDelete(ctx as any, p);
4457
+ if (!confirmed) {
4458
+ ctx.ui.notify("Deletion cancelled.", "info");
4459
+ return;
4460
+ }
4461
+ }
4462
+ const wasActive =
4463
+ activeProfileName?.toLowerCase() === p.name.toLowerCase();
4464
+ profilesConfig.profiles = profilesConfig.profiles.filter(
4465
+ (q) => q !== p,
4466
+ );
4467
+ if (wasActive) {
4468
+ activeProfileName = null;
4469
+ invalidateEffectiveRoles();
4470
+ }
4471
+ const savedOk = persistAndNotify(
4472
+ `Profile ⚡ ${p.name} deleted.${
4473
+ wasActive ? " It was active — default roles flow restored." : ""
4474
+ }`,
4475
+ );
4476
+ if (savedOk && wasActive && workflowMode) {
4477
+ try {
4478
+ await applyModeModel(ctx as any);
4479
+ updateStatus(ctx as any);
4480
+ } catch (_e) {
4481
+ void _e;
4482
+ }
4483
+ }
4484
+ return;
4485
+ }
4486
+
4487
+ if (sub === "default") {
4488
+ // /profile default [name|none] [global|project]
4489
+ const target = rest.find(
4490
+ (t) => t.toLowerCase() !== "global" && t.toLowerCase() !== "project",
4491
+ );
4492
+ const scope: "global" | "project" = rest
4493
+ .map((t) => t.toLowerCase())
4494
+ .includes("project")
4495
+ ? "project"
4496
+ : "global";
4497
+ const paths = defaultProfileSettingPaths(
4498
+ cwd ?? process.cwd(),
4499
+ getAgentDir(),
4500
+ );
4501
+ if (!target) {
4502
+ const resolved = readDefaultProfileSetting(
4503
+ cwd ?? process.cwd(),
4504
+ getAgentDir(),
4505
+ );
4506
+ ctx.ui.notify(
4507
+ [
4508
+ `Default profile (workflow.defaultProfile): ${
4509
+ resolved.value ?? "(none)"
4510
+ } (${resolved.sources.effective})`,
4511
+ `Global settings: ${paths.global}`,
4512
+ `Project settings: ${paths.project}`,
4513
+ "New sessions without an explicit /profile use start on the default profile.",
4514
+ "Usage: /profile default <name|none> [global|project]",
4515
+ ].join("\n"),
4516
+ "info",
4517
+ );
4518
+ return;
4519
+ }
4520
+ const value =
4521
+ target.toLowerCase() === "none" || target.toLowerCase() === "off"
4522
+ ? null
4523
+ : target;
4524
+ if (value && !getProfile(profilesConfig, value)) {
4525
+ ctx.ui.notify(
4526
+ `Unknown profile "${value}". Available: ${
4527
+ profilesConfig.profiles.map((q) => q.name).join(", ") || "(none)"
4528
+ }`,
4529
+ "warning",
4530
+ );
4531
+ return;
4532
+ }
4533
+ const res = writeDefaultProfileSetting(
4534
+ scope,
4535
+ cwd ?? process.cwd(),
4536
+ getAgentDir(),
4537
+ value,
4538
+ );
4539
+ if (!res.ok) {
4540
+ ctx.ui.notify(
4541
+ `Could not write default profile: ${res.error ?? "unknown error"}`,
4542
+ "error",
4543
+ );
4544
+ return;
4545
+ }
4546
+ ctx.ui.notify(
4547
+ value
4548
+ ? `Default profile → ⚡ ${value} (${scope} settings)\nNew sessions without an explicit /profile use start on it.`
4549
+ : `Default profile cleared (${scope} settings).`,
4550
+ "info",
4551
+ );
4552
+ updateStatus(ctx as any);
4553
+ return;
4554
+ }
4555
+
4556
+ // Bare profile name → show detail (mirrors /role <name>).
4557
+ const named = getProfile(profilesConfig, sub);
4558
+ if (named) {
4559
+ ctx.ui.notify(describeProfile(named), "info");
4560
+ return;
4561
+ }
4562
+ ctx.ui.notify(PROFILE_USAGE, "info");
4563
+ },
4564
+ getArgumentCompletions: (prefix: string) => {
4565
+ ensureProfilesConfig();
4566
+ const subs = [
4567
+ { value: "list", label: "list", description: "show all profiles" },
4568
+ {
4569
+ value: "use",
4570
+ label: "use <name|none>",
4571
+ description: "activate/deactivate for this session",
4572
+ },
4573
+ {
4574
+ value: "create",
4575
+ label: "create <name>",
4576
+ description: "create a profile (seeded from current models)",
4577
+ },
4578
+ {
4579
+ value: "edit",
4580
+ label: "edit <name>",
4581
+ description: "open the tabbed model editor",
4582
+ },
4583
+ {
4584
+ value: "rename",
4585
+ label: "rename <old> <new>",
4586
+ description: "rename a profile",
4587
+ },
4588
+ {
4589
+ value: "delete",
4590
+ label: "delete <name>",
4591
+ description: "delete a profile",
4592
+ },
4593
+ {
4594
+ value: "default",
4595
+ label: "default [name|none] [global|project]",
4596
+ description: "default profile for new sessions",
4597
+ },
4598
+ {
4599
+ value: "show",
4600
+ label: "show <name>",
4601
+ description: "show one profile's models",
4602
+ },
4603
+ ];
4604
+ const lower = String(prefix || "").toLowerCase();
4605
+ if (lower.includes(" ")) {
4606
+ // Completing an argument after a subcommand → profile names where relevant.
4607
+ const cmd = lower.trim().split(/\s+/)[0];
4608
+ const wantsNames =
4609
+ cmd === "use" ||
4610
+ cmd === "edit" ||
4611
+ cmd === "delete" ||
4612
+ cmd === "show" ||
4613
+ cmd === "default";
4614
+ if (!wantsNames) return null;
4615
+ const last = lower.trim().split(/\s+/).pop() ?? "";
4616
+ const names = profilesConfig.profiles
4617
+ .filter((p) => p.name.toLowerCase().startsWith(last))
4618
+ .map((p) => ({
4619
+ value: p.name,
4620
+ label: p.name,
4621
+ description: p.description || undefined,
4622
+ }));
4623
+ return cmd === "use" || cmd === "default"
4624
+ ? [
4625
+ ...names,
4626
+ { value: "none", label: "none", description: "no profile" },
4627
+ ]
4628
+ : names;
4629
+ }
4630
+ const matching = subs.filter((s) => s.value.startsWith(lower));
4631
+ if (matching.length > 0) return matching;
4632
+ const names = profilesConfig.profiles
4633
+ .filter((p) => p.name.toLowerCase().startsWith(lower))
4634
+ .map((p) => ({
4635
+ value: p.name,
4636
+ label: p.name,
4637
+ description: p.description || undefined,
4638
+ }));
4639
+ return names.length ? names : null;
4640
+ },
4641
+ });
4642
+
4643
+ // ── Role config screen (tabbed picker: one block per role) ─────────
4644
+ //
4645
+ // One overlay with a tab per role (built-ins first, then customs) plus
4646
+ // a Submit tab. Each role tab has two phases: pick model (from the
4647
+ // connected providers — scoped models first, Ctrl+O for all) then pick
4648
+ // thinking level. Selections stage locally; Submit saves to roles.json.
4649
+
4650
+ interface StagedRoleModel {
4651
+ provider: string;
4652
+ id: string;
4653
+ thinking: ThinkingLevel;
4654
+ }
4655
+
4656
+ interface RoleCatalogEntry {
4657
+ provider: string;
4658
+ id: string;
4659
+ inScope: boolean;
4660
+ }
4661
+
4662
+ function collectRoleCatalog(ctx: ExtensionContext): {
4663
+ scoped: RoleCatalogEntry[];
4664
+ all: RoleCatalogEntry[];
4665
+ truncated: boolean;
4666
+ } {
4667
+ const scoped: RoleCatalogEntry[] = [];
4668
+ try {
4669
+ const raw = ((ctx as any)?.scopedModels ?? []) as any[];
4670
+ for (const s of raw) {
4671
+ const m = s?.model ?? s;
4672
+ if (m && typeof m.provider === "string" && typeof m.id === "string")
4673
+ scoped.push({ provider: m.provider, id: m.id, inScope: true });
4674
+ }
4675
+ } catch (_e) {
4676
+ void _e;
4677
+ }
4678
+ let available: any[] = [];
4679
+ try {
4680
+ const reg = (ctx as any)?.modelRegistry;
4681
+ if (reg && typeof reg.getAvailable === "function")
4682
+ available = [...reg.getAvailable()];
4683
+ else if (reg && typeof reg.getAll === "function")
4684
+ available = [...reg.getAll()];
4685
+ } catch (_e) {
4686
+ void _e;
4687
+ }
4688
+ const seen = new Set(scoped.map((s) => `${s.provider}/${s.id}`));
4689
+ const rest: RoleCatalogEntry[] = [];
4690
+ for (const m of available) {
4691
+ if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
4692
+ continue;
4693
+ const key = `${m.provider}/${m.id}`;
4694
+ if (seen.has(key)) continue;
4695
+ seen.add(key);
4696
+ rest.push({ provider: m.provider, id: m.id, inScope: false });
4697
+ }
4698
+ const CAP = 200;
4699
+ const all = [...scoped, ...rest];
4700
+ return {
4701
+ scoped,
4702
+ all: all.slice(0, CAP),
4703
+ truncated: all.length > CAP,
4704
+ };
4705
+ }
4706
+
4707
+ function orderedRoleTabs(): Role[] {
4708
+ // Built-in roles first in their declared order, then custom roles.
4709
+ // Driven by BUILT_IN_ROLES so adding a role never needs a second edit here.
4710
+ const rank = (name: string) => {
4711
+ const i = (BUILT_IN_ROLES as readonly string[]).indexOf(
4712
+ name.toLowerCase(),
4713
+ );
4714
+ return i === -1 ? BUILT_IN_ROLES.length : i;
4715
+ };
4716
+ return [...roleConfig.roles].sort((a, b) => rank(a.name) - rank(b.name));
4717
+ }
4718
+
4719
+ async function openRolePicker(ctx: ExtensionContext): Promise<void> {
4720
+ const tabs = orderedRoleTabs();
4721
+ if (tabs.length === 0) {
4722
+ ctx.ui.notify("No roles configured — /role reset to reseed.", "warning");
4723
+ return;
4724
+ }
4725
+ const catalog = collectRoleCatalog(ctx);
4726
+ if (catalog.all.length === 0) {
4727
+ ctx.ui.notify(
4728
+ "No models available from connected providers — connect a provider first, or use /role set <role> <provider/model> [thinking].",
4729
+ "warning",
4730
+ );
4731
+ return;
4732
+ }
4733
+ // Stage current values so the picker opens on the active selection.
4734
+ const staged = new Map<string, StagedRoleModel>();
4735
+ for (const r of tabs) staged.set(r.name, { ...r.model });
4736
+
4737
+ overlayActive = true;
4738
+ let saved: Map<string, StagedRoleModel> | null = null;
4739
+ try {
4740
+ saved = await ctx.ui.custom<Map<string, StagedRoleModel> | null>(
4741
+ (tui, theme, _kb, done) => {
4742
+ let currentTab = 0; // tabs.length = Submit
4743
+ let phase: "model" | "thinking" = "model";
3078
4744
  let showAll = catalog.scoped.length === 0;
3079
4745
  let cached: string[] | undefined;
3080
4746
  // One SelectList per role tab for models + one shared for thinking,
@@ -3240,6 +4906,16 @@ export default function workflowExtension(pi: ExtensionAPI) {
3240
4906
  return out;
3241
4907
  };
3242
4908
  lines.push(theme.fg("accent", "─".repeat(W)));
4909
+ // Profile notice: /role edits the BASE roles; an active profile
4910
+ // overlays models on top (see effectiveRoles).
4911
+ if (activeProfileName) {
4912
+ lines.push(
4913
+ ...wrap(
4914
+ ` ${theme.fg("warning", `Profile ⚡ ${activeProfileName} active — editing BASE roles; profile overrides apply on top. /profile edit ${activeProfileName} to change the profile.`)}`,
4915
+ ),
4916
+ );
4917
+ lines.push("");
4918
+ }
3243
4919
  // Tabs row: one block per role + Submit
3244
4920
  const chips: string[] = [];
3245
4921
  for (let i = 0; i < tabs.length; i++) {
@@ -3475,7 +5151,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
3475
5151
  // Tasks whose agent has no explicit model use the explorer role's
3476
5152
  // single model. Telemetry lands in details.
3477
5153
  ensureRoleConfig(cwd);
3478
- const explorerRole = getRole(roleConfig, "explorer");
5154
+ ensureProfilesConfig(cwd);
5155
+ // Active profile's explorer model when a profile is active.
5156
+ const explorerRole = getEffectiveRole("explorer");
3479
5157
  const routedMeta: { role: string; model: string; reason: string }[] =
3480
5158
  new Array(jobs.length);
3481
5159
  const routedAgents: (AgentConfig | undefined)[] = new Array(jobs.length);
@@ -4155,6 +5833,34 @@ export default function workflowExtension(pi: ExtensionAPI) {
4155
5833
  overlayActive = false;
4156
5834
  }
4157
5835
 
5836
+ // /commit authorization: arm the commit-policy exemption ONLY when the
5837
+ // user confirms THIS flow's commit questionnaire. A cancel (or any
5838
+ // non-confirmation questionnaire) clears the pending flow and arms
5839
+ // nothing, so the policy keeps blocking. Headless never reaches here
5840
+ // (early return above), which is the intended fail-closed behaviour.
5841
+ if (pendingCommitFlow && isCommitConfirmation(questions)) {
5842
+ const cancelled = !result || result.cancelled === true;
5843
+ const answer =
5844
+ !result || result.cancelled ? undefined : result.answers?.[0];
5845
+ if (isPositiveCommitDecision({ cancelled, answer })) {
5846
+ commitAuthorization = armCommitAuthorization({
5847
+ flowId: pendingCommitFlow.id,
5848
+ now: Date.now(),
5849
+ ttlMs: COMMIT_AUTH_TTL_MS,
5850
+ maxCommits: COMMIT_AUTH_MAX_COMMITS,
5851
+ });
5852
+ try {
5853
+ ctx.ui.notify?.(
5854
+ "Commit authorized — this run may commit the staged changes.",
5855
+ "info",
5856
+ );
5857
+ } catch (_e) {
5858
+ void _e;
5859
+ }
5860
+ }
5861
+ pendingCommitFlow = null;
5862
+ }
5863
+
4158
5864
  if (!result || result.cancelled) {
4159
5865
  return {
4160
5866
  content: [{ type: "text", text: "User cancelled questionnaire" }],
@@ -4507,10 +6213,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
4507
6213
  pi.on("session_start", async (event, ctx) => {
4508
6214
  // Roles are file-backed; load them up-front so every read path (status,
4509
6215
  // band label, reviewer runtime) sees the real config even in a fresh
4510
- // session that has no persisted workflow entry yet.
6216
+ // session that has no persisted workflow entry yet. Profiles likewise.
4511
6217
  try {
4512
6218
  ensureRoleConfig((ctx as any)?.cwd);
4513
6219
  notifyRoleConfigFallback(ctx as ExtensionContext);
6220
+ ensureProfilesConfig((ctx as any)?.cwd);
6221
+ notifyProfilesFallback(ctx as ExtensionContext);
4514
6222
  } catch (_e) {
4515
6223
  void _e;
4516
6224
  }
@@ -4540,13 +6248,42 @@ export default function workflowExtension(pi: ExtensionAPI) {
4540
6248
  toolsBeforePlanMode = d.toolsBeforePlanMode;
4541
6249
  currentPlanFile = d.planFile;
4542
6250
  // restore role state (file config is source of truth for roles;
4543
- // session entry carries the latest activeRole)
6251
+ // session entry carries the latest activeRole) and the session's
6252
+ // own profile activation
4544
6253
  try {
4545
6254
  ensureRoleConfig((ctx as any)?.cwd);
6255
+ ensureProfilesConfig((ctx as any)?.cwd);
4546
6256
  const rd = (d as any).role;
4547
6257
  if (rd && typeof rd === "object") {
4548
6258
  if (typeof rd.activeRole === "string" || rd.activeRole === null)
4549
6259
  roleConfig.activeRole = rd.activeRole;
6260
+ // Profiles: an explicit pick restores exactly (name or none);
6261
+ // entries without an explicit choice (fresh / pre-profile /
6262
+ // default-seeded) re-derive workflow.defaultProfile below so
6263
+ // a changed default still applies on resume.
6264
+ profileExplicit = rd.profileExplicit === true;
6265
+ if (profileExplicit) {
6266
+ activeProfileName =
6267
+ typeof rd.activeProfile === "string"
6268
+ ? rd.activeProfile
6269
+ : null;
6270
+ if (
6271
+ activeProfileName &&
6272
+ !getProfile(profilesConfig, activeProfileName)
6273
+ ) {
6274
+ try {
6275
+ (ctx as any).ui?.notify?.(
6276
+ `Profiles: "${activeProfileName}" no longer exists — default roles flow active.`,
6277
+ "warning",
6278
+ );
6279
+ } catch (_e) {
6280
+ void _e;
6281
+ }
6282
+ activeProfileName = null;
6283
+ }
6284
+ } else {
6285
+ activeProfileName = null;
6286
+ }
4550
6287
  }
4551
6288
  } catch (_e) {
4552
6289
  void _e;
@@ -4628,6 +6365,39 @@ export default function workflowExtension(pi: ExtensionAPI) {
4628
6365
  } catch (_e) {
4629
6366
  void _e;
4630
6367
  }
6368
+ // Default-profile seeding: sessions without an explicit choice follow
6369
+ // workflow.defaultProfile, re-read at every start/resume so a changed
6370
+ // default applies. Sessions that picked explicitly (or explicitly chose
6371
+ // none) never see the default.
6372
+ if (!profileExplicit) {
6373
+ try {
6374
+ const def = readDefaultProfileSetting(
6375
+ (ctx as any).cwd ?? process.cwd(),
6376
+ getAgentDir(),
6377
+ ).value;
6378
+ if (def) {
6379
+ ensureProfilesConfig((ctx as any)?.cwd);
6380
+ const p = getProfile(profilesConfig, def);
6381
+ if (p) {
6382
+ if (activeProfileName !== p.name) {
6383
+ activeProfileName = p.name;
6384
+ invalidateEffectiveRoles();
6385
+ }
6386
+ } else {
6387
+ try {
6388
+ (ctx as any).ui?.notify?.(
6389
+ `Profiles: default profile "${def}" not found in profiles.json — skipping.`,
6390
+ "warning",
6391
+ );
6392
+ } catch (_e) {
6393
+ void _e;
6394
+ }
6395
+ }
6396
+ }
6397
+ } catch (_e) {
6398
+ void _e;
6399
+ }
6400
+ }
4631
6401
  // auto-enable plan mode if flag set at startup (support both --plan and --workflow-plan)
4632
6402
  const flagPlan =
4633
6403
  (pi as any).getFlagValue?.("plan") ??
@@ -4711,6 +6481,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
4711
6481
  }
4712
6482
  ctx.ui.setStatus("workflow", undefined);
4713
6483
  ctx.ui.setWidget("workflow-todos", undefined);
6484
+ // Drop any /commit authorization or flow: a reload/quit must never leave
6485
+ // an exemption armed for whatever runs next.
6486
+ commitAuthorization = null;
6487
+ pendingCommitFlow = null;
4714
6488
  // Release the reviewer session (and its custom tools) on shutdown so a
4715
6489
  // reload or quit never leaves an orphaned in-process session behind.
4716
6490
  await teardownReview();
@@ -4722,6 +6496,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
4722
6496
  todoItems = [];
4723
6497
  todoPlanPath = undefined;
4724
6498
  btwNotes = [];
6499
+ // A rewind must not preserve a /commit exemption across branches.
6500
+ commitAuthorization = null;
6501
+ pendingCommitFlow = null;
4725
6502
  try {
4726
6503
  const branch = ctx.sessionManager.getBranch();
4727
6504
  for (const e of branch as any[]) {
@@ -4758,32 +6535,52 @@ export default function workflowExtension(pi: ExtensionAPI) {
4758
6535
  * allow → automatic per-step commits while a plan todo list is active
4759
6536
  * (the extension owns commit timing — model-initiated git add/commit/push
4760
6537
  * are blocked); outside a plan the model may commit as before. The
4761
- * user-invoked /commit flow arms `commitBypassActive` so its own `git
4762
- * commit` runs (staging is extension-side; `push` and `add` are never
4763
- * bypassed).
6538
+ * user-invoked /commit flow arms `commitAuthorization` ONLY when the user
6539
+ * confirms its questionnaire; that exemption covers just `git commit` for
6540
+ * the rest of the run (staging is extension-side; `push` and `add` are
6541
+ * never exempted).
6542
+ *
6543
+ * The decision itself is the pure `decideCommitGate`; this wrapper supplies
6544
+ * the runtime inputs and renders the block reasons / ask overlay.
4764
6545
  */
4765
6546
  async function commitPolicyGate(
4766
6547
  event: any,
4767
6548
  ctx: any,
4768
6549
  cwd: string,
4769
6550
  ): Promise<{ block: true; reason: string } | undefined> {
4770
- const policy = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
4771
- // allow outside a plan: no per-step automation to own commit timing, so
4772
- // the model keeps its previous freedom.
4773
- if (policy === "allow" && todoItems.length === 0) return undefined;
4774
6551
  const cmd = String(event?.input?.command ?? "");
4775
6552
  const hit = gitCommitPolicyHit(cmd);
4776
6553
  if (!hit) return undefined;
4777
- // `add` is only gated in `never` mode; `ask` prompts on commit/push only
4778
- // (staging alone pollutes nothing).
4779
- if (policy === "ask" && hit === "add") return undefined;
4780
- // /commit bypass: only the flow's own `git commit` is exempt — `add` and
4781
- // `push` are not.
4782
- if (commitBypassActive && hit === "commit") {
6554
+ const policy = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
6555
+ const now = Date.now();
6556
+ const decision = decideCommitGate({
6557
+ policy,
6558
+ hit: hit as CommitGateHit,
6559
+ hasTodos: todoItems.length > 0,
6560
+ auth: commitAuthorization,
6561
+ now,
6562
+ });
6563
+ if (decision === "allow") {
6564
+ // Count a granted `git commit` against the authorization, but only when
6565
+ // the authorization was actually the deciding factor (never/ask, or
6566
+ // allow-with-an-active-plan). `allow` outside a plan is free regardless.
6567
+ const authWasDecisive =
6568
+ hit === "commit" &&
6569
+ commitAuthorization !== null &&
6570
+ !(policy === "allow" && todoItems.length === 0);
6571
+ if (authWasDecisive && commitAuthorization) {
6572
+ const auth = commitAuthorization;
6573
+ if (!isAuthorizationActive(auth, now)) {
6574
+ commitAuthorization = null;
6575
+ } else {
6576
+ const next = { ...auth, commits: auth.commits + 1 };
6577
+ commitAuthorization = next.commits >= next.maxCommits ? null : next;
6578
+ }
6579
+ }
4783
6580
  return undefined;
4784
6581
  }
4785
6582
 
4786
- if (policy === "never" || policy === "allow") {
6583
+ if (decision === "block") {
4787
6584
  return {
4788
6585
  block: true,
4789
6586
  reason:
@@ -4797,7 +6594,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
4797
6594
  cmd,
4798
6595
  };
4799
6596
  }
4800
- // policy === "ask" → confirmation overlay. Headless has no one to ask →
6597
+ // decision === "ask" → confirmation overlay. Headless has no one to ask →
4801
6598
  // fail closed rather than commit unbidden.
4802
6599
  const ui = ctx?.ui;
4803
6600
  if (!ui || !ctx.hasUI) {
@@ -5176,9 +6973,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
5176
6973
  });
5177
6974
 
5178
6975
  pi.on("agent_settled", async () => {
5179
- // Belt-and-braces: the run is over (completed, aborted or errored) — the
5180
- // /commit bypass must not leak into whatever runs next.
5181
- commitBypassActive = false;
6976
+ // The run is over (completed, aborted or errored). The /commit flow lives
6977
+ // within one run, so both the armed exemption and any unconfirmed flow are
6978
+ // dropped here — nothing that runs next inherits them.
6979
+ commitAuthorization = null;
6980
+ pendingCommitFlow = null;
5182
6981
  // keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
5183
6982
  });
5184
6983
 
@@ -5357,10 +7156,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
5357
7156
 
5358
7157
  // Track progress (all modes, whenever todos exist)
5359
7158
  pi.on("turn_end", async (event, ctx) => {
5360
- // Disarm the /commit bypass as soon as any turn ends: the /commit flow's
5361
- // `git commit` runs as a tool call WITHIN its turn, so this never disarms
5362
- // too early — and nothing after the turn inherits the exemption.
5363
- commitBypassActive = false;
7159
+ // NOTE: do NOT disarm the /commit authorization here. `turn_end` fires
7160
+ // after EVERY assistant response, but /commit is multi-turn (read diff →
7161
+ // confirm → commit), so disarming per turn kills the exemption before the
7162
+ // commit turn. Lifetime is the run: `agent_settled` clears it.
5364
7163
  if (todoItems.length === 0) return;
5365
7164
  if (!isAssistantMessage(event.message as AgentMessage)) return;
5366
7165
  const text = getTextContent(event.message as AssistantMessage);