@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/README.md +57 -4
- package/commit-policy.ts +170 -0
- package/index.ts +1940 -141
- package/package.json +3 -2
- package/profiles.ts +617 -0
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
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1516
|
-
const role = targetName ? getRole(
|
|
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
|
-
//
|
|
2275
|
-
//
|
|
2276
|
-
//
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
(pi as any).
|
|
2282
|
-
|
|
2283
|
-
|
|
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(
|
|
3078
|
+
ctx.ui.notify(formatRolesDisplayWithProfile(), "info");
|
|
2786
3079
|
return;
|
|
2787
3080
|
}
|
|
2788
3081
|
|
|
2789
3082
|
if (sub === "list" || sub === "show") {
|
|
2790
|
-
ctx.ui.notify(
|
|
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
|
-
|
|
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
|
-
// ──
|
|
3270
|
+
// ── Model profiles command + management UI ───────────────────────
|
|
2978
3271
|
//
|
|
2979
|
-
//
|
|
2980
|
-
//
|
|
2981
|
-
//
|
|
2982
|
-
//
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
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
|
-
|
|
3015
|
-
|
|
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
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
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
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
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
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
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
|
-
|
|
3077
|
-
let
|
|
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
|
-
|
|
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 `
|
|
4762
|
-
*
|
|
4763
|
-
*
|
|
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
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
5180
|
-
//
|
|
5181
|
-
|
|
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
|
-
//
|
|
5361
|
-
//
|
|
5362
|
-
//
|
|
5363
|
-
|
|
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);
|