@dieulc/workflow 0.3.3 → 0.5.0
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 +94 -2
- package/commit-policy.ts +358 -0
- package/index.ts +2258 -133
- package/package.json +4 -2
- package/permissions.ts +98 -0
- package/profiles.ts +617 -0
package/index.ts
CHANGED
|
@@ -82,11 +82,21 @@ import {
|
|
|
82
82
|
import {
|
|
83
83
|
WORKFLOW_ROLE_ENV,
|
|
84
84
|
explainCommandRefusal,
|
|
85
|
+
gitCommitPolicyHit,
|
|
85
86
|
policyForRole,
|
|
86
87
|
resolveSessionRole,
|
|
87
88
|
subagentEnv,
|
|
88
89
|
type WorkflowRole,
|
|
89
90
|
} from "./permissions.ts";
|
|
91
|
+
import {
|
|
92
|
+
commitConfigPaths,
|
|
93
|
+
describeCommitPolicyMode,
|
|
94
|
+
readCommitPolicyConfig,
|
|
95
|
+
stepCommitDecision,
|
|
96
|
+
writeCommitPolicyConfig,
|
|
97
|
+
type CommitPolicyMode,
|
|
98
|
+
type StepCommitCandidate,
|
|
99
|
+
} from "./commit-policy.ts";
|
|
90
100
|
import {
|
|
91
101
|
isGitRepo,
|
|
92
102
|
isGitRepoSync,
|
|
@@ -115,6 +125,24 @@ import {
|
|
|
115
125
|
type RoleModel,
|
|
116
126
|
type ThinkingLevel,
|
|
117
127
|
} from "./roles.ts";
|
|
128
|
+
import {
|
|
129
|
+
defaultProfileConfig,
|
|
130
|
+
loadProfilesDetailed,
|
|
131
|
+
profilesFingerprint,
|
|
132
|
+
saveProfiles,
|
|
133
|
+
getProfile,
|
|
134
|
+
formatProfilesForDisplay,
|
|
135
|
+
describeProfile,
|
|
136
|
+
validateProfileName,
|
|
137
|
+
applyProfileOverlay,
|
|
138
|
+
missingProfileRoles,
|
|
139
|
+
readDefaultProfileSetting,
|
|
140
|
+
writeDefaultProfileSetting,
|
|
141
|
+
defaultProfileSettingPaths,
|
|
142
|
+
PROFILE_ROLES,
|
|
143
|
+
type ProfileConfig,
|
|
144
|
+
type Profile,
|
|
145
|
+
} from "./profiles.ts";
|
|
118
146
|
import {
|
|
119
147
|
applySubmittedPlan,
|
|
120
148
|
formatReviewStatusLines,
|
|
@@ -372,6 +400,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
372
400
|
let todoItems: TodoItem[] = [];
|
|
373
401
|
// Plan file the current todo list was derived from (drives merge vs replace).
|
|
374
402
|
let todoPlanPath: string | undefined;
|
|
403
|
+
// Armed while the user-invoked /commit flow's agent turn is running: its
|
|
404
|
+
// `git commit` bypasses the commit-policy gate (staging already happened
|
|
405
|
+
// extension-side via piExecGit, outside the tool gate). Cleared on
|
|
406
|
+
// turn_end/agent_settled so the window stays one turn wide.
|
|
407
|
+
let commitBypassActive = false;
|
|
408
|
+
// ── Deterministic per-step commits (allow mode) ──
|
|
409
|
+
// In 'allow' mode the EXTENSION — not the model — owns commit timing:
|
|
410
|
+
// exactly one commit per batch of newly completed plan steps, made at the
|
|
411
|
+
// workflow_todo tool_result boundary (the runner awaits tool_result
|
|
412
|
+
// handlers, so the commit lands before the model starts the next step).
|
|
413
|
+
// committedStepRefs makes the guarantee idempotent; stepCommitsSeeded
|
|
414
|
+
// marks pre-existing done steps (session resume, re-executed plan) as
|
|
415
|
+
// already accounted for so they never trigger a spurious commit.
|
|
416
|
+
const committedStepRefs = new Set<string>();
|
|
417
|
+
let stepCommitsSeeded = false;
|
|
375
418
|
// ── Reminder throttle + staleness tracking (passive enforcement layer) ──
|
|
376
419
|
let turnsSinceLastTodoWrite = 0;
|
|
377
420
|
let turnsSinceLastReminder = 0;
|
|
@@ -448,6 +491,232 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
448
491
|
void _e;
|
|
449
492
|
}
|
|
450
493
|
}
|
|
494
|
+
|
|
495
|
+
// ── Model profiles state (session-scoped activation) ───────────
|
|
496
|
+
let profilesConfig: ProfileConfig = defaultProfileConfig();
|
|
497
|
+
let profilesLoadedFor: string | undefined;
|
|
498
|
+
let profilesLoadedFingerprint: string | undefined;
|
|
499
|
+
/** Reason the last load skipped the user file — only corrupt/legacy warns. */
|
|
500
|
+
let profilesFallbackReason: string | undefined;
|
|
501
|
+
let profilesFallbackSeverity: "info" | "warning" | undefined;
|
|
502
|
+
let profilesFallbackNotified = false;
|
|
503
|
+
/** Active profile for THIS session — never global (concurrent sessions each pick their own). */
|
|
504
|
+
let activeProfileName: string | null = null;
|
|
505
|
+
/** True once the user picked explicitly in this session; otherwise workflow.defaultProfile applies. */
|
|
506
|
+
let profileExplicit = false;
|
|
507
|
+
/** Cache for the effective (profile-overlaid) roles — see effectiveRoles(). */
|
|
508
|
+
let effectiveRolesCache: {
|
|
509
|
+
base: RoleConfig;
|
|
510
|
+
profile: string | null;
|
|
511
|
+
roles: Role[];
|
|
512
|
+
} | null = null;
|
|
513
|
+
|
|
514
|
+
function invalidateEffectiveRoles(): void {
|
|
515
|
+
effectiveRolesCache = null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The single profiles accessor, mirroring ensureRoleConfig: reloads when the
|
|
520
|
+
* working directory changes or either profiles.json changes on disk (mtime/
|
|
521
|
+
* size), so a `/profile` edit made in another window is picked up without a
|
|
522
|
+
* `/reload`. Non-destructive: a bad or missing file is never overwritten
|
|
523
|
+
* (see loadProfilesDetailed). A profile activated in this session that has
|
|
524
|
+
* vanished from disk is deactivated here (with the fallback warning).
|
|
525
|
+
*/
|
|
526
|
+
function ensureProfilesConfig(cwd?: string): ProfileConfig {
|
|
527
|
+
const key = cwd ?? "";
|
|
528
|
+
const fingerprint = profilesFingerprint(key);
|
|
529
|
+
if (
|
|
530
|
+
profilesLoadedFor === key &&
|
|
531
|
+
profilesLoadedFingerprint === fingerprint
|
|
532
|
+
) {
|
|
533
|
+
return profilesConfig;
|
|
534
|
+
}
|
|
535
|
+
const loaded = loadProfilesDetailed(key);
|
|
536
|
+
profilesConfig = loaded.config;
|
|
537
|
+
profilesLoadedFor = key;
|
|
538
|
+
profilesLoadedFingerprint = fingerprint;
|
|
539
|
+
profilesFallbackReason = loaded.usedDefaults ? loaded.reason : undefined;
|
|
540
|
+
profilesFallbackSeverity = loaded.usedDefaults
|
|
541
|
+
? loaded.severity
|
|
542
|
+
: undefined;
|
|
543
|
+
profilesFallbackNotified = false;
|
|
544
|
+
invalidateEffectiveRoles();
|
|
545
|
+
// A profile selected in this session may have been deleted/renamed in
|
|
546
|
+
// another window — fall back to the base roles flow.
|
|
547
|
+
if (activeProfileName && !getProfile(profilesConfig, activeProfileName)) {
|
|
548
|
+
activeProfileName = null;
|
|
549
|
+
}
|
|
550
|
+
return profilesConfig;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Surface a profiles load problem once per load. A MISSING file is the
|
|
555
|
+
* normal "feature unused" state (info — never notified); only corrupt or
|
|
556
|
+
* incompatible files warn.
|
|
557
|
+
*/
|
|
558
|
+
function notifyProfilesFallback(ctx?: ExtensionContext): void {
|
|
559
|
+
if (!profilesFallbackReason || profilesFallbackNotified) return;
|
|
560
|
+
profilesFallbackNotified = true;
|
|
561
|
+
if (profilesFallbackSeverity !== "warning") return;
|
|
562
|
+
try {
|
|
563
|
+
ctx?.ui.notify(`Profiles: ${profilesFallbackReason}`, "warning");
|
|
564
|
+
} catch (_e) {
|
|
565
|
+
void _e;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Resolve the session's active profile object (null when none/unknown). */
|
|
570
|
+
function resolveActiveProfile(): Profile | null {
|
|
571
|
+
if (!activeProfileName) return null;
|
|
572
|
+
return getProfile(profilesConfig, activeProfileName) ?? null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* The effective roles for THIS session: the file-backed base roles with the
|
|
577
|
+
* active profile's model overrides applied (see applyProfileOverlay). All
|
|
578
|
+
* model-RESOLUTION sites read through this; all EDITING sites (/role, role
|
|
579
|
+
* picker) keep using the base `roleConfig`. With no profile active the
|
|
580
|
+
* overlay is a shallow copy — behaviour is identical to the roles.json flow.
|
|
581
|
+
* Cache is keyed by the base config object identity (ensureRoleConfig
|
|
582
|
+
* reassigns it on file changes) + the active profile name, and invalidated
|
|
583
|
+
* by ensureProfilesConfig / setActiveProfile.
|
|
584
|
+
*/
|
|
585
|
+
function effectiveRoles(): Role[] {
|
|
586
|
+
const base = roleConfig;
|
|
587
|
+
const profileName = activeProfileName;
|
|
588
|
+
const cached = effectiveRolesCache;
|
|
589
|
+
if (cached && cached.base === base && cached.profile === profileName) {
|
|
590
|
+
return cached.roles;
|
|
591
|
+
}
|
|
592
|
+
const roles = applyProfileOverlay(base.roles, resolveActiveProfile());
|
|
593
|
+
effectiveRolesCache = { base, profile: profileName, roles };
|
|
594
|
+
return roles;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** getRole over the effective (profile-overlaid) roles. */
|
|
598
|
+
function getEffectiveRole(name: string): Role | undefined {
|
|
599
|
+
return getRole({ ...roleConfig, roles: effectiveRoles() }, name);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Footer label for the active profile ("" when none). */
|
|
603
|
+
function activeProfileLabel(): string {
|
|
604
|
+
return activeProfileName ? `⚡ ${activeProfileName}` : "";
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* /role display: the base roles (what /role edits) plus, when a profile is
|
|
609
|
+
* active, an explicit section naming the profile and its model overrides.
|
|
610
|
+
* Without a profile this is exactly the plain roles display.
|
|
611
|
+
*/
|
|
612
|
+
function formatRolesDisplayWithProfile(): string {
|
|
613
|
+
const base = formatRolesForDisplay(roleConfig);
|
|
614
|
+
const profile = resolveActiveProfile();
|
|
615
|
+
if (!profile) return base;
|
|
616
|
+
const lines: string[] = [
|
|
617
|
+
base,
|
|
618
|
+
"",
|
|
619
|
+
`Active profile: ⚡ ${profile.name}${
|
|
620
|
+
profile.description ? ` — ${profile.description}` : ""
|
|
621
|
+
}`,
|
|
622
|
+
];
|
|
623
|
+
for (const roleName of PROFILE_ROLES) {
|
|
624
|
+
const m = profile.roles[roleName];
|
|
625
|
+
if (m)
|
|
626
|
+
lines.push(
|
|
627
|
+
` ${roleIcon(roleName)} ${roleName} → ${formatModelRef(m)} (${m.thinking}) (profile override)`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
lines.push(
|
|
631
|
+
` /profile edit ${profile.name} to change the profile • /profile use none to deactivate`,
|
|
632
|
+
);
|
|
633
|
+
return lines.join("\n");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Multi-line notification describing what an activation resolves to. */
|
|
637
|
+
function profileActivationSummary(name: string): string {
|
|
638
|
+
const profile = getProfile(profilesConfig, name);
|
|
639
|
+
if (!profile) return `Profile "${name}" activated.`;
|
|
640
|
+
const lines = [`Profile ⚡ ${profile.name} activated:`];
|
|
641
|
+
for (const roleName of PROFILE_ROLES) {
|
|
642
|
+
const m = profile.roles[roleName];
|
|
643
|
+
if (m)
|
|
644
|
+
lines.push(
|
|
645
|
+
` ${roleIcon(roleName)} ${roleName} → ${formatModelRef(m)} (${m.thinking})`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const missing = missingProfileRoles(profile);
|
|
649
|
+
if (missing.length) {
|
|
650
|
+
lines.push(
|
|
651
|
+
` ${missing.join(", ")}: base role models (not overridden by this profile)`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (workflowMode) {
|
|
655
|
+
lines.push(` Applied to ${workflowMode} mode now; explore/review pick it up on next use.`);
|
|
656
|
+
} else {
|
|
657
|
+
lines.push(
|
|
658
|
+
" Applies on the next /plan, /build, explore subagent, or review round.",
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
return lines.join("\n");
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Activate/deactivate the session's profile. Session-scoped by design — the
|
|
666
|
+
* state rides the `workflow` session entry (persistState), never a global
|
|
667
|
+
* file, so two concurrent sessions can run different profiles. When the
|
|
668
|
+
* session is in plan/build mode the role model is re-applied immediately so
|
|
669
|
+
* the profile's planner/builder take effect right away; explorer/reviewer
|
|
670
|
+
* resolution picks the profile up on next subagent/review round.
|
|
671
|
+
*/
|
|
672
|
+
async function setActiveProfile(
|
|
673
|
+
ctx: ExtensionContext,
|
|
674
|
+
name: string | null,
|
|
675
|
+
opts?: { explicit?: boolean; quiet?: boolean },
|
|
676
|
+
): Promise<void> {
|
|
677
|
+
ensureProfilesConfig((ctx as any)?.cwd);
|
|
678
|
+
const trimmed = name ? String(name).trim() : null;
|
|
679
|
+
if (trimmed) {
|
|
680
|
+
const profile = getProfile(profilesConfig, trimmed);
|
|
681
|
+
if (!profile) {
|
|
682
|
+
ctx.ui.notify(
|
|
683
|
+
`Unknown profile "${trimmed}". Available: ${
|
|
684
|
+
profilesConfig.profiles.map((p) => p.name).join(", ") ||
|
|
685
|
+
"(none — /profile create <name>)"
|
|
686
|
+
}`,
|
|
687
|
+
"warning",
|
|
688
|
+
);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
activeProfileName = profile.name;
|
|
692
|
+
} else {
|
|
693
|
+
activeProfileName = null;
|
|
694
|
+
}
|
|
695
|
+
profileExplicit = opts?.explicit ?? true;
|
|
696
|
+
invalidateEffectiveRoles();
|
|
697
|
+
try {
|
|
698
|
+
persistState();
|
|
699
|
+
} catch (_e) {
|
|
700
|
+
void _e;
|
|
701
|
+
}
|
|
702
|
+
updateStatus(ctx as any);
|
|
703
|
+
if (workflowMode) {
|
|
704
|
+
try {
|
|
705
|
+
await applyModeModel(ctx as any, { quiet: opts?.quiet });
|
|
706
|
+
} catch (_e) {
|
|
707
|
+
void _e;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (!opts?.quiet) {
|
|
711
|
+
ctx.ui.notify(
|
|
712
|
+
activeProfileName
|
|
713
|
+
? profileActivationSummary(activeProfileName)
|
|
714
|
+
: "Profile deactivated — default roles flow active.",
|
|
715
|
+
"info",
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
451
720
|
function activeRoleLabel(): string {
|
|
452
721
|
if (roleConfig.activeRole)
|
|
453
722
|
return `${roleIcon(roleConfig.activeRole)} ${roleConfig.activeRole}`;
|
|
@@ -598,16 +867,32 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
598
867
|
}
|
|
599
868
|
|
|
600
869
|
function updateStatus(ctx: ExtensionContext) {
|
|
870
|
+
// Profile prefix (⚡ name) when one is active, then the role label.
|
|
871
|
+
const profileLabel = activeProfileLabel();
|
|
601
872
|
const roleLabel = activeRoleLabel();
|
|
602
|
-
const
|
|
873
|
+
const statusParts = [profileLabel, roleLabel].filter(Boolean);
|
|
874
|
+
const suffix = statusParts.length ? ` | ${statusParts.join(" | ")}` : "";
|
|
875
|
+
// Commit-policy surfacing (workflow.commitPolicy): always visible so the
|
|
876
|
+
// user can see at a glance what the agent may do to git history. Read
|
|
877
|
+
// live so /commit-policy changes show up without a reload.
|
|
878
|
+
let commitMode: CommitPolicyMode = "never";
|
|
879
|
+
try {
|
|
880
|
+
commitMode = readCommitPolicyConfig(
|
|
881
|
+
(ctx as any).cwd ?? process.cwd(),
|
|
882
|
+
getAgentDir(),
|
|
883
|
+
).config.mode;
|
|
884
|
+
} catch {
|
|
885
|
+
commitMode = "never";
|
|
886
|
+
}
|
|
887
|
+
const policy = ` · commits:${commitMode}`;
|
|
603
888
|
const staleMarker = todoStale ? " ⚠" : "";
|
|
604
889
|
const done = todoItems.filter((t) => isDone(t)).length;
|
|
605
890
|
const total = todoItems.length;
|
|
606
891
|
if (workflowMode === "build") {
|
|
607
892
|
const label =
|
|
608
893
|
total > 0
|
|
609
|
-
? `▶ build ${done}/${total}${staleMarker}${suffix}`
|
|
610
|
-
: `▶ build${suffix}`;
|
|
894
|
+
? `▶ build ${done}/${total}${staleMarker}${suffix}${policy}`
|
|
895
|
+
: `▶ build${suffix}${policy}`;
|
|
611
896
|
ctx.ui.setStatus("workflow", ctx.ui.theme.fg("accent", label));
|
|
612
897
|
} else if (workflowMode === "plan") {
|
|
613
898
|
const base = plannotatorActive
|
|
@@ -615,16 +900,22 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
615
900
|
: "⏸ plan";
|
|
616
901
|
const label =
|
|
617
902
|
total > 0
|
|
618
|
-
? `${base} ${done}/${total}${staleMarker}${suffix}`
|
|
619
|
-
: `${base}${suffix}`;
|
|
903
|
+
? `${base} ${done}/${total}${staleMarker}${suffix}${policy}`
|
|
904
|
+
: `${base}${suffix}${policy}`;
|
|
620
905
|
ctx.ui.setStatus("workflow", ctx.ui.theme.fg("warning", label));
|
|
621
906
|
} else if (total > 0) {
|
|
622
907
|
ctx.ui.setStatus(
|
|
623
908
|
"workflow",
|
|
624
|
-
ctx.ui.theme.fg(
|
|
909
|
+
ctx.ui.theme.fg(
|
|
910
|
+
"muted",
|
|
911
|
+
`◦ ${done}/${total}${staleMarker}${suffix}${policy}`,
|
|
912
|
+
),
|
|
625
913
|
);
|
|
626
914
|
} else {
|
|
627
|
-
ctx.ui.setStatus(
|
|
915
|
+
ctx.ui.setStatus(
|
|
916
|
+
"workflow",
|
|
917
|
+
ctx.ui.theme.fg("muted", `◦ commits:${commitMode}`),
|
|
918
|
+
);
|
|
628
919
|
}
|
|
629
920
|
if (todoItems.length > 0) {
|
|
630
921
|
ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
|
|
@@ -675,7 +966,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
675
966
|
/** Reviewer model label for the mode band (best-effort). */
|
|
676
967
|
function reviewModelLabel(): string {
|
|
677
968
|
ensureRoleConfig(reviewCwd());
|
|
678
|
-
|
|
969
|
+
ensureProfilesConfig(reviewCwd());
|
|
970
|
+
const r = getEffectiveRole(REVIEW_ROLE_NAME);
|
|
679
971
|
return r?.model ? formatModelRef(r.model) : "unconfigured";
|
|
680
972
|
}
|
|
681
973
|
|
|
@@ -843,9 +1135,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
843
1135
|
): Promise<string> {
|
|
844
1136
|
return (async () => {
|
|
845
1137
|
const cwd = reviewCwd();
|
|
1138
|
+
ensureRoleConfig(cwd);
|
|
1139
|
+
ensureProfilesConfig(cwd);
|
|
846
1140
|
const agents = discoverAgents(cwd);
|
|
847
1141
|
const base = agents.find((a) => a.name === "scout") ?? agents[0];
|
|
848
|
-
const explorerRole =
|
|
1142
|
+
const explorerRole = getEffectiveRole("explorer");
|
|
849
1143
|
const m = explorerRole?.model;
|
|
850
1144
|
const agent: AgentConfig = {
|
|
851
1145
|
name: base?.name ?? "review-scout",
|
|
@@ -883,11 +1177,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
883
1177
|
config: () => reviewConfigNow()?.config,
|
|
884
1178
|
reviewerModel: () => {
|
|
885
1179
|
ensureRoleConfig(cwd);
|
|
886
|
-
|
|
1180
|
+
ensureProfilesConfig(cwd);
|
|
1181
|
+
const reviewer = getEffectiveRole(REVIEW_ROLE_NAME);
|
|
887
1182
|
if (reviewer?.model) return reviewer.model;
|
|
888
1183
|
// Graceful degradation: fall back to the planner model rather than
|
|
889
1184
|
// silently running no review at all.
|
|
890
|
-
const planner =
|
|
1185
|
+
const planner = getEffectiveRole("planner");
|
|
891
1186
|
return planner?.model;
|
|
892
1187
|
},
|
|
893
1188
|
findModel: (provider: string, id: string) =>
|
|
@@ -1087,7 +1382,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1087
1382
|
if (!sub || sub === "status") {
|
|
1088
1383
|
const resolved = readReviewModeConfig(cwd, agentDir);
|
|
1089
1384
|
const c = resolved.config;
|
|
1090
|
-
|
|
1385
|
+
ensureProfilesConfig(cwd);
|
|
1386
|
+
const model = getEffectiveRole(REVIEW_ROLE_NAME)?.model;
|
|
1091
1387
|
ctx.ui.notify(
|
|
1092
1388
|
formatReviewStatusLines({
|
|
1093
1389
|
enabled: c.enabled,
|
|
@@ -1200,6 +1496,112 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1200
1496
|
},
|
|
1201
1497
|
});
|
|
1202
1498
|
|
|
1499
|
+
// ── /commit-policy: view/set the commit policy (workflow.commitPolicy) ──
|
|
1500
|
+
// Available in every mode: it only writes settings JSON; the gate itself
|
|
1501
|
+
// lives in tool_call and plan mode keeps its own stricter rules.
|
|
1502
|
+
pi.registerCommand("commit-policy", {
|
|
1503
|
+
description:
|
|
1504
|
+
"View or set the commit policy (what the agent may git add/commit/push): /commit-policy never|ask|allow [global|project]",
|
|
1505
|
+
handler: async (args, ctx) => {
|
|
1506
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
1507
|
+
const agentDir = getAgentDir();
|
|
1508
|
+
const tokens = String(args || "")
|
|
1509
|
+
.trim()
|
|
1510
|
+
.split(/\s+/)
|
|
1511
|
+
.filter(Boolean);
|
|
1512
|
+
const sub = (tokens[0] || "").toLowerCase();
|
|
1513
|
+
const scope: "global" | "project" =
|
|
1514
|
+
tokens.includes("project") && !tokens.includes("global")
|
|
1515
|
+
? "project"
|
|
1516
|
+
: "global";
|
|
1517
|
+
|
|
1518
|
+
if (!sub || sub === "status") {
|
|
1519
|
+
const resolved = readCommitPolicyConfig(cwd, agentDir);
|
|
1520
|
+
const paths = commitConfigPaths(cwd, agentDir);
|
|
1521
|
+
const where =
|
|
1522
|
+
resolved.sources.mode === "default"
|
|
1523
|
+
? "default — no settings file supplies it"
|
|
1524
|
+
: `${resolved.sources.mode} settings`;
|
|
1525
|
+
ctx.ui.notify(
|
|
1526
|
+
[
|
|
1527
|
+
`Commit policy: ${resolved.config.mode} (${where})`,
|
|
1528
|
+
` ${describeCommitPolicyMode(resolved.config.mode)}`,
|
|
1529
|
+
`Global settings: ${paths.global}`,
|
|
1530
|
+
`Project settings: ${paths.project}`,
|
|
1531
|
+
"Usage: /commit-policy never|ask|allow [global|project]",
|
|
1532
|
+
].join("\n"),
|
|
1533
|
+
"info",
|
|
1534
|
+
);
|
|
1535
|
+
updateStatus(ctx as any);
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
if (sub !== "never" && sub !== "ask" && sub !== "allow") {
|
|
1540
|
+
ctx.ui.notify(
|
|
1541
|
+
[
|
|
1542
|
+
"Usage: /commit-policy never|ask|allow [global|project]",
|
|
1543
|
+
" never agent cannot git add/commit/push — you commit (or /commit)",
|
|
1544
|
+
" ask agent may commit; each git commit/push asks first",
|
|
1545
|
+
" allow agent may commit freely (per-step during plan execution)",
|
|
1546
|
+
].join("\n"),
|
|
1547
|
+
"info",
|
|
1548
|
+
);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const res = writeCommitPolicyConfig(scope, cwd, agentDir, {
|
|
1553
|
+
mode: sub as CommitPolicyMode,
|
|
1554
|
+
});
|
|
1555
|
+
if (!res.ok) {
|
|
1556
|
+
ctx.ui.notify(
|
|
1557
|
+
`Could not write commit-policy settings: ${res.error ?? "unknown error"}`,
|
|
1558
|
+
"error",
|
|
1559
|
+
);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
ctx.ui.notify(
|
|
1563
|
+
`Commit policy → ${sub} (${scope})\n${describeCommitPolicyMode(
|
|
1564
|
+
sub as CommitPolicyMode,
|
|
1565
|
+
)}\nSettings: ${res.path}`,
|
|
1566
|
+
"info",
|
|
1567
|
+
);
|
|
1568
|
+
updateStatus(ctx as any);
|
|
1569
|
+
},
|
|
1570
|
+
getArgumentCompletions: (prefix: string) => {
|
|
1571
|
+
const flags = [
|
|
1572
|
+
{
|
|
1573
|
+
value: "never",
|
|
1574
|
+
label: "never",
|
|
1575
|
+
description:
|
|
1576
|
+
"agent cannot git add/commit/push — you commit (or /commit)",
|
|
1577
|
+
},
|
|
1578
|
+
{
|
|
1579
|
+
value: "ask",
|
|
1580
|
+
label: "ask",
|
|
1581
|
+
description: "agent may commit; each git commit/push asks first",
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
value: "allow",
|
|
1585
|
+
label: "allow",
|
|
1586
|
+
description:
|
|
1587
|
+
"agent may commit freely (per-step during plan execution)",
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
value: "global",
|
|
1591
|
+
label: "global",
|
|
1592
|
+
description: "write to ~/.pi/agent/settings.json",
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
value: "project",
|
|
1596
|
+
label: "project",
|
|
1597
|
+
description: "write to <cwd>/.pi/settings.json",
|
|
1598
|
+
},
|
|
1599
|
+
];
|
|
1600
|
+
const filtered = flags.filter((f) => f.value.startsWith(prefix));
|
|
1601
|
+
return filtered.length ? filtered : null;
|
|
1602
|
+
},
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1203
1605
|
pi.registerCommand("review-prompt", {
|
|
1204
1606
|
description:
|
|
1205
1607
|
"Edit the reviewer's guidance (framework alignment / best practice) used by Review Mode",
|
|
@@ -1357,13 +1759,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1357
1759
|
if (!workflowMode) return false;
|
|
1358
1760
|
try {
|
|
1359
1761
|
ensureRoleConfig((ctx as any)?.cwd ?? ctx.cwd);
|
|
1762
|
+
ensureProfilesConfig((ctx as any)?.cwd ?? ctx.cwd);
|
|
1360
1763
|
} catch (_e) {
|
|
1361
1764
|
void _e;
|
|
1362
1765
|
}
|
|
1363
|
-
|
|
1766
|
+
// Resolution reads the EFFECTIVE roles (base roles + active profile
|
|
1767
|
+
// overlay); with no profile active this is behaviourally identical to
|
|
1768
|
+
// the base config.
|
|
1769
|
+
const effectiveCfg: RoleConfig = { ...roleConfig, roles: effectiveRoles() };
|
|
1770
|
+
const resolved = resolveModeModel(effectiveCfg, workflowMode);
|
|
1364
1771
|
if (!resolved) {
|
|
1365
|
-
const targetName =
|
|
1366
|
-
const role = targetName ? getRole(
|
|
1772
|
+
const targetName = effectiveCfg.activeRole ?? MODE_ROLE_MAP[workflowMode];
|
|
1773
|
+
const role = targetName ? getRole(effectiveCfg, targetName) : undefined;
|
|
1367
1774
|
if (targetName && !role) {
|
|
1368
1775
|
ctx.ui.notify(
|
|
1369
1776
|
`Workflow ${workflowMode}: role "${targetName}" not found — staying on current model.`,
|
|
@@ -1549,6 +1956,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1549
1956
|
awaitingDecisionAt: awaitingDecision ? lastHandoffAt : undefined,
|
|
1550
1957
|
role: {
|
|
1551
1958
|
activeRole: roleConfig.activeRole,
|
|
1959
|
+
// Session-scoped profile activation (see setActiveProfile): explicit
|
|
1960
|
+
// picks restore exactly; non-explicit entries re-derive the default.
|
|
1961
|
+
activeProfile: profileExplicit ? activeProfileName : null,
|
|
1962
|
+
profileExplicit,
|
|
1552
1963
|
},
|
|
1553
1964
|
defaultModel,
|
|
1554
1965
|
lastModeSwitch,
|
|
@@ -1845,7 +2256,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1845
2256
|
};
|
|
1846
2257
|
const firstTodo = todoItems[0];
|
|
1847
2258
|
const feedbackBlock = feedback ? `\n\nFeedback: ${feedback}` : "";
|
|
1848
|
-
const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : (todoItems[0]?.text ?? "first step")}${feedbackBlock}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
|
|
2259
|
+
const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : (todoItems[0]?.text ?? "first step")}${feedbackBlock}${commitDirectiveLine(ctx)}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
|
|
1849
2260
|
(pi as any).sendMessage?.(planTodoListMessage, { deliverAs: "followUp" });
|
|
1850
2261
|
(pi as any).sendMessage?.(
|
|
1851
2262
|
{
|
|
@@ -2121,6 +2532,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
2121
2532
|
|
|
2122
2533
|
// Inject a user message → triggers an agent turn that proposes + confirms + commits
|
|
2123
2534
|
const message = buildCommitPrompt(statSummary, stageAll);
|
|
2535
|
+
// Arm the commit-policy bypass for exactly this agent turn: its `git
|
|
2536
|
+
// commit` must run even under policy 'never'. Disarmed on turn_end /
|
|
2537
|
+
// agent_settled, so the window is one turn wide.
|
|
2538
|
+
commitBypassActive = true;
|
|
2124
2539
|
if ((pi as any).sendUserMessage) {
|
|
2125
2540
|
(pi as any).sendUserMessage(message);
|
|
2126
2541
|
} else {
|
|
@@ -2628,19 +3043,19 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
2628
3043
|
await openRolePicker(ctx as any);
|
|
2629
3044
|
return;
|
|
2630
3045
|
}
|
|
2631
|
-
ctx.ui.notify(
|
|
3046
|
+
ctx.ui.notify(formatRolesDisplayWithProfile(), "info");
|
|
2632
3047
|
return;
|
|
2633
3048
|
}
|
|
2634
3049
|
|
|
2635
3050
|
if (sub === "list" || sub === "show") {
|
|
2636
|
-
ctx.ui.notify(
|
|
3051
|
+
ctx.ui.notify(formatRolesDisplayWithProfile(), "info");
|
|
2637
3052
|
return;
|
|
2638
3053
|
}
|
|
2639
3054
|
|
|
2640
3055
|
if (sub === "config") {
|
|
2641
3056
|
if (!(ctx as any)?.hasUI) {
|
|
2642
3057
|
ctx.ui.notify(
|
|
2643
|
-
|
|
3058
|
+
formatRolesDisplayWithProfile() +
|
|
2644
3059
|
"\n\nPicker needs UI — use /role set instead.",
|
|
2645
3060
|
"info",
|
|
2646
3061
|
);
|
|
@@ -2820,125 +3235,1498 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
2820
3235
|
},
|
|
2821
3236
|
});
|
|
2822
3237
|
|
|
2823
|
-
// ──
|
|
3238
|
+
// ── Model profiles command + management UI ───────────────────────
|
|
2824
3239
|
//
|
|
2825
|
-
//
|
|
2826
|
-
//
|
|
2827
|
-
//
|
|
2828
|
-
//
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
inScope: boolean;
|
|
2840
|
-
}
|
|
2841
|
-
|
|
2842
|
-
function collectRoleCatalog(ctx: ExtensionContext): {
|
|
2843
|
-
scoped: RoleCatalogEntry[];
|
|
2844
|
-
all: RoleCatalogEntry[];
|
|
2845
|
-
truncated: boolean;
|
|
2846
|
-
} {
|
|
2847
|
-
const scoped: RoleCatalogEntry[] = [];
|
|
2848
|
-
try {
|
|
2849
|
-
const raw = ((ctx as any)?.scopedModels ?? []) as any[];
|
|
2850
|
-
for (const s of raw) {
|
|
2851
|
-
const m = s?.model ?? s;
|
|
2852
|
-
if (m && typeof m.provider === "string" && typeof m.id === "string")
|
|
2853
|
-
scoped.push({ provider: m.provider, id: m.id, inScope: true });
|
|
2854
|
-
}
|
|
2855
|
-
} catch (_e) {
|
|
2856
|
-
void _e;
|
|
2857
|
-
}
|
|
2858
|
-
let available: any[] = [];
|
|
3240
|
+
// A profile is a named combination of role model settings (planner /
|
|
3241
|
+
// explorer / builder / reviewer). Profiles are DEFINED in profiles.json;
|
|
3242
|
+
// ACTIVATION is session-scoped (setActiveProfile) so two concurrent
|
|
3243
|
+
// sessions can run different profiles. /role keeps editing the base roles;
|
|
3244
|
+
// an active profile only overlays models on top (see effectiveRoles()).
|
|
3245
|
+
|
|
3246
|
+
const PROFILE_USAGE =
|
|
3247
|
+
"Usage: /profile (picker) | /profile list | /profile show <name> | " +
|
|
3248
|
+
"/profile use <name|none> | /profile create <name> | /profile edit <name> | " +
|
|
3249
|
+
"/profile rename <old> <new> | /profile delete <name> | " +
|
|
3250
|
+
"/profile default [name|none] [global|project]";
|
|
3251
|
+
|
|
3252
|
+
/** Effective workflow.defaultProfile for display (never throws). */
|
|
3253
|
+
function currentDefaultProfileName(cwd?: string): string | undefined {
|
|
2859
3254
|
try {
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
available = [...reg.getAvailable()];
|
|
2863
|
-
else if (reg && typeof reg.getAll === "function")
|
|
2864
|
-
available = [...reg.getAll()];
|
|
3255
|
+
return readDefaultProfileSetting(cwd ?? process.cwd(), getAgentDir())
|
|
3256
|
+
.value;
|
|
2865
3257
|
} catch (_e) {
|
|
2866
3258
|
void _e;
|
|
3259
|
+
return undefined;
|
|
2867
3260
|
}
|
|
2868
|
-
const seen = new Set(scoped.map((s) => `${s.provider}/${s.id}`));
|
|
2869
|
-
const rest: RoleCatalogEntry[] = [];
|
|
2870
|
-
for (const m of available) {
|
|
2871
|
-
if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
|
|
2872
|
-
continue;
|
|
2873
|
-
const key = `${m.provider}/${m.id}`;
|
|
2874
|
-
if (seen.has(key)) continue;
|
|
2875
|
-
seen.add(key);
|
|
2876
|
-
rest.push({ provider: m.provider, id: m.id, inScope: false });
|
|
2877
|
-
}
|
|
2878
|
-
const CAP = 200;
|
|
2879
|
-
const all = [...scoped, ...rest];
|
|
2880
|
-
return {
|
|
2881
|
-
scoped,
|
|
2882
|
-
all: all.slice(0, CAP),
|
|
2883
|
-
truncated: all.length > CAP,
|
|
2884
|
-
};
|
|
2885
3261
|
}
|
|
2886
3262
|
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
const
|
|
2891
|
-
const
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
};
|
|
2896
|
-
return [...roleConfig.roles].sort((a, b) => rank(a.name) - rank(b.name));
|
|
3263
|
+
/** Seed a new profile from the CURRENT effective roles (base + active profile). */
|
|
3264
|
+
function buildProfileSeed(name: string, description = ""): Profile {
|
|
3265
|
+
const seed: Profile = { name, description, roles: {} };
|
|
3266
|
+
for (const roleName of PROFILE_ROLES) {
|
|
3267
|
+
const m = getEffectiveRole(roleName)?.model;
|
|
3268
|
+
if (m) seed.roles[roleName] = { ...m };
|
|
3269
|
+
}
|
|
3270
|
+
return seed;
|
|
2897
3271
|
}
|
|
2898
3272
|
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
if (catalog.all.length === 0) {
|
|
2907
|
-
ctx.ui.notify(
|
|
2908
|
-
"No models available from connected providers — connect a provider first, or use /role set <role> <provider/model> [thinking].",
|
|
2909
|
-
"warning",
|
|
2910
|
-
);
|
|
2911
|
-
return;
|
|
2912
|
-
}
|
|
2913
|
-
// Stage current values so the picker opens on the active selection.
|
|
2914
|
-
const staged = new Map<string, StagedRoleModel>();
|
|
2915
|
-
for (const r of tabs) staged.set(r.name, { ...r.model });
|
|
3273
|
+
const listThemeOf = (theme: any) => ({
|
|
3274
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
3275
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
3276
|
+
description: (t: string) => theme.fg("muted", t),
|
|
3277
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
3278
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
3279
|
+
});
|
|
2916
3280
|
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
3281
|
+
/** Small confirm overlay (used by /profile delete). True only on explicit Delete. */
|
|
3282
|
+
async function confirmProfileDelete(
|
|
3283
|
+
ctx: ExtensionContext,
|
|
3284
|
+
profile: Profile,
|
|
3285
|
+
): Promise<boolean> {
|
|
3286
|
+
const result = await ctx.ui.custom<boolean | null>(
|
|
3287
|
+
(tui, theme, _kb, done) => {
|
|
3288
|
+
const list = new SelectList(
|
|
3289
|
+
[
|
|
3290
|
+
{
|
|
3291
|
+
value: "delete",
|
|
3292
|
+
label: `Delete ⚡ ${profile.name}`,
|
|
3293
|
+
description: "remove from profiles.json (cannot be undone)",
|
|
3294
|
+
},
|
|
3295
|
+
{
|
|
3296
|
+
value: "cancel",
|
|
3297
|
+
label: "Cancel",
|
|
3298
|
+
description: "keep the profile",
|
|
3299
|
+
},
|
|
3300
|
+
],
|
|
3301
|
+
2,
|
|
3302
|
+
listThemeOf(theme),
|
|
3303
|
+
);
|
|
3304
|
+
let cached: string[] | undefined;
|
|
3305
|
+
return {
|
|
3306
|
+
render: (width: number) => {
|
|
3307
|
+
if (cached) return cached;
|
|
3308
|
+
const W = Math.max(1, width);
|
|
3309
|
+
const lines: string[] = [];
|
|
3310
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
3311
|
+
lines.push(
|
|
3312
|
+
` ${theme.fg("warning", `Delete profile "${profile.name}"?`)}`,
|
|
3313
|
+
);
|
|
3314
|
+
lines.push("");
|
|
3315
|
+
for (const l of list.render(Math.max(1, W - 2))) lines.push(` ${l}`);
|
|
3316
|
+
lines.push("");
|
|
3317
|
+
lines.push(
|
|
3318
|
+
` ${theme.fg("dim", "↑↓ navigate • Enter select • Esc cancel")}`,
|
|
3319
|
+
);
|
|
3320
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
3321
|
+
cached = lines;
|
|
3322
|
+
return lines;
|
|
3323
|
+
},
|
|
3324
|
+
invalidate: () => {
|
|
3325
|
+
cached = undefined;
|
|
3326
|
+
},
|
|
3327
|
+
handleInput: (data: string) => {
|
|
3328
|
+
if (matchesKey(data, Key.escape)) {
|
|
3329
|
+
done(null);
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
if (matchesKey(data, Key.enter)) {
|
|
3333
|
+
done(list.getSelectedItem()?.value === "delete");
|
|
3334
|
+
return;
|
|
3335
|
+
}
|
|
3336
|
+
list.handleInput(data);
|
|
3337
|
+
cached = undefined;
|
|
3338
|
+
tui.requestRender();
|
|
3339
|
+
},
|
|
3340
|
+
};
|
|
3341
|
+
},
|
|
3342
|
+
);
|
|
3343
|
+
return result === true;
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
/**
|
|
3347
|
+
* Free-text profile name entry (Editor overlay, same input pattern as the
|
|
3348
|
+
* questionnaire). Resolves the validated name, or null on cancel/invalid-Esc.
|
|
3349
|
+
*/
|
|
3350
|
+
async function inputProfileName(
|
|
3351
|
+
ctx: ExtensionContext,
|
|
3352
|
+
opts: {
|
|
3353
|
+
title: string;
|
|
3354
|
+
initial?: string;
|
|
3355
|
+
/** Names that would collide (case-insensitive), minus any being renamed. */
|
|
3356
|
+
existing?: readonly string[];
|
|
3357
|
+
},
|
|
3358
|
+
): Promise<string | null> {
|
|
3359
|
+
return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
3360
|
+
const editorTheme: EditorTheme = {
|
|
3361
|
+
borderColor: (s) => theme.fg("accent", s),
|
|
3362
|
+
selectList: {
|
|
3363
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
3364
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
3365
|
+
description: (t: string) => theme.fg("muted", t),
|
|
3366
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
3367
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
3368
|
+
},
|
|
3369
|
+
};
|
|
3370
|
+
const editor = new Editor(tui, editorTheme);
|
|
3371
|
+
if (opts.initial) editor.setText(opts.initial);
|
|
3372
|
+
let error = "";
|
|
3373
|
+
let cached: string[] | undefined;
|
|
3374
|
+
editor.onSubmit = (value: string) => {
|
|
3375
|
+
const check = validateProfileName(String(value ?? ""), opts.existing);
|
|
3376
|
+
if (!check.ok) {
|
|
3377
|
+
error = check.error;
|
|
3378
|
+
cached = undefined;
|
|
3379
|
+
tui.requestRender();
|
|
3380
|
+
return;
|
|
3381
|
+
}
|
|
3382
|
+
done(check.value);
|
|
3383
|
+
};
|
|
3384
|
+
return {
|
|
3385
|
+
render: (width: number) => {
|
|
3386
|
+
if (cached) return cached;
|
|
3387
|
+
const W = Math.max(1, width);
|
|
3388
|
+
const lines: string[] = [];
|
|
3389
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
3390
|
+
lines.push(
|
|
3391
|
+
...wrapTextWithAnsi(` ${theme.fg("text", opts.title)}`, W),
|
|
3392
|
+
);
|
|
3393
|
+
if (error)
|
|
3394
|
+
lines.push(
|
|
3395
|
+
...wrapTextWithAnsi(` ${theme.fg("warning", error)}`, W),
|
|
3396
|
+
);
|
|
3397
|
+
lines.push("");
|
|
3398
|
+
for (const l of editor.render(Math.max(1, W))) lines.push(l);
|
|
3399
|
+
lines.push("");
|
|
3400
|
+
lines.push(` ${theme.fg("dim", "Enter confirm • Esc cancel")}`);
|
|
3401
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
3402
|
+
cached = lines;
|
|
3403
|
+
return lines;
|
|
3404
|
+
},
|
|
3405
|
+
invalidate: () => {
|
|
3406
|
+
cached = undefined;
|
|
3407
|
+
},
|
|
3408
|
+
handleInput: (data: string) => {
|
|
3409
|
+
if (matchesKey(data, Key.escape)) {
|
|
3410
|
+
done(null);
|
|
3411
|
+
return;
|
|
3412
|
+
}
|
|
3413
|
+
editor.handleInput(data);
|
|
3414
|
+
cached = undefined;
|
|
3415
|
+
tui.requestRender();
|
|
3416
|
+
},
|
|
3417
|
+
};
|
|
3418
|
+
});
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
/**
|
|
3422
|
+
* Profile management picker: activate / none / new / per-profile actions
|
|
3423
|
+
* (edit, rename, delete) and the default-profile entry. Loops so actions
|
|
3424
|
+
* return to the list; every action handles its own persistence.
|
|
3425
|
+
*/
|
|
3426
|
+
async function openProfilePicker(ctx: ExtensionContext): Promise<void> {
|
|
3427
|
+
ensureProfilesConfig((ctx as any)?.cwd);
|
|
3428
|
+
ensureRoleConfig((ctx as any)?.cwd);
|
|
3429
|
+
while (true) {
|
|
3430
|
+
const profiles = profilesConfig.profiles;
|
|
3431
|
+
const def = currentDefaultProfileName((ctx as any)?.cwd);
|
|
3432
|
+
const entries: SelectItem[] = [];
|
|
3433
|
+
entries.push({
|
|
3434
|
+
value: "none",
|
|
3435
|
+
label: activeProfileName
|
|
3436
|
+
? "— none (default roles) —"
|
|
3437
|
+
: "● — none (default roles) —",
|
|
3438
|
+
description: "deactivate any profile; the roles.json flow applies",
|
|
3439
|
+
});
|
|
3440
|
+
for (const p of profiles) {
|
|
3441
|
+
const isActive =
|
|
3442
|
+
activeProfileName?.toLowerCase() === p.name.toLowerCase();
|
|
3443
|
+
const isDefault = def?.toLowerCase() === p.name.toLowerCase();
|
|
3444
|
+
const markers = [
|
|
3445
|
+
isActive ? "● active" : "",
|
|
3446
|
+
isDefault ? "★ default" : "",
|
|
3447
|
+
]
|
|
3448
|
+
.filter(Boolean)
|
|
3449
|
+
.join(", ");
|
|
3450
|
+
const modelSummary = PROFILE_ROLES.map((r) => {
|
|
3451
|
+
const m = p.roles[r];
|
|
3452
|
+
return m ? `${roleIcon(r)} ${formatModelRef(m)}` : null;
|
|
3453
|
+
})
|
|
3454
|
+
.filter(Boolean)
|
|
3455
|
+
.join(" · ");
|
|
3456
|
+
entries.push({
|
|
3457
|
+
value: p.name,
|
|
3458
|
+
label: `⚡ ${p.name}${markers ? ` (${markers})` : ""}`,
|
|
3459
|
+
description: p.description || modelSummary || undefined,
|
|
3460
|
+
});
|
|
3461
|
+
}
|
|
3462
|
+
entries.push({
|
|
3463
|
+
value: "__new",
|
|
3464
|
+
label: "+ New profile…",
|
|
3465
|
+
description: "name it, then pick a model per role (like /role)",
|
|
3466
|
+
});
|
|
3467
|
+
entries.push({
|
|
3468
|
+
value: "__default",
|
|
3469
|
+
label: "Default for new sessions…",
|
|
3470
|
+
description: `workflow.defaultProfile — currently: ${def ?? "(none)"}`,
|
|
3471
|
+
});
|
|
3472
|
+
|
|
3473
|
+
const selected = await ctx.ui.custom<string | null>(
|
|
3474
|
+
(tui, theme, _kb, done) => {
|
|
3475
|
+
const list = new SelectList(entries, 10, listThemeOf(theme));
|
|
3476
|
+
let cached: string[] | undefined;
|
|
3477
|
+
return {
|
|
3478
|
+
render: (width: number) => {
|
|
3479
|
+
if (cached) return cached;
|
|
3480
|
+
const W = Math.max(1, width);
|
|
3481
|
+
const lines: string[] = [];
|
|
3482
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
3483
|
+
lines.push(
|
|
3484
|
+
` ${theme.fg("text", "Model Profiles")} ${theme.fg(
|
|
3485
|
+
"muted",
|
|
3486
|
+
`— active: ${activeProfileName ?? "none (default roles)"}`,
|
|
3487
|
+
)}`,
|
|
3488
|
+
);
|
|
3489
|
+
lines.push("");
|
|
3490
|
+
for (const l of list.render(Math.max(1, W - 2)))
|
|
3491
|
+
lines.push(` ${l}`);
|
|
3492
|
+
lines.push("");
|
|
3493
|
+
lines.push(
|
|
3494
|
+
` ${theme.fg("dim", "Enter: select/action • type to filter • Esc close")}`,
|
|
3495
|
+
);
|
|
3496
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
3497
|
+
cached = lines;
|
|
3498
|
+
return lines;
|
|
3499
|
+
},
|
|
3500
|
+
invalidate: () => {
|
|
3501
|
+
cached = undefined;
|
|
3502
|
+
},
|
|
3503
|
+
handleInput: (data: string) => {
|
|
3504
|
+
if (matchesKey(data, Key.escape)) {
|
|
3505
|
+
done(null);
|
|
3506
|
+
return;
|
|
3507
|
+
}
|
|
3508
|
+
if (matchesKey(data, Key.enter)) {
|
|
3509
|
+
done(list.getSelectedItem()?.value ?? null);
|
|
3510
|
+
return;
|
|
3511
|
+
}
|
|
3512
|
+
list.handleInput(data);
|
|
3513
|
+
cached = undefined;
|
|
3514
|
+
tui.requestRender();
|
|
3515
|
+
},
|
|
3516
|
+
};
|
|
3517
|
+
},
|
|
3518
|
+
);
|
|
3519
|
+
if (!selected) return; // Esc / closed
|
|
3520
|
+
if (selected === "__new") {
|
|
3521
|
+
const name = await inputProfileName(ctx, {
|
|
3522
|
+
title: "New profile — name (dashes, no spaces):",
|
|
3523
|
+
existing: profilesConfig.profiles.map((p) => p.name),
|
|
3524
|
+
});
|
|
3525
|
+
if (!name) continue;
|
|
3526
|
+
await openProfileEditor(ctx, { createName: name });
|
|
3527
|
+
continue;
|
|
3528
|
+
}
|
|
3529
|
+
if (selected === "__default") {
|
|
3530
|
+
await pickDefaultProfile(ctx);
|
|
3531
|
+
continue;
|
|
3532
|
+
}
|
|
3533
|
+
if (selected === "none") {
|
|
3534
|
+
if (activeProfileName) {
|
|
3535
|
+
await setActiveProfile(ctx as any, null, { explicit: true });
|
|
3536
|
+
} else {
|
|
3537
|
+
ctx.ui.notify("No profile active — default roles flow.", "info");
|
|
3538
|
+
}
|
|
3539
|
+
continue;
|
|
3540
|
+
}
|
|
3541
|
+
// A concrete profile → action menu
|
|
3542
|
+
const profile = getProfile(profilesConfig, selected);
|
|
3543
|
+
if (!profile) continue;
|
|
3544
|
+
const action = await ctx.ui.custom<string | null>(
|
|
3545
|
+
(tui, theme, _kb, done) => {
|
|
3546
|
+
const isActive =
|
|
3547
|
+
activeProfileName?.toLowerCase() === profile.name.toLowerCase();
|
|
3548
|
+
const list = new SelectList(
|
|
3549
|
+
[
|
|
3550
|
+
isActive
|
|
3551
|
+
? {
|
|
3552
|
+
value: "deactivate",
|
|
3553
|
+
label: "Deactivate",
|
|
3554
|
+
description: "back to default roles (this session)",
|
|
3555
|
+
}
|
|
3556
|
+
: {
|
|
3557
|
+
value: "activate",
|
|
3558
|
+
label: "Activate now",
|
|
3559
|
+
description:
|
|
3560
|
+
"session-scoped; plan/build re-apply immediately",
|
|
3561
|
+
},
|
|
3562
|
+
{
|
|
3563
|
+
value: "edit",
|
|
3564
|
+
label: "Edit models",
|
|
3565
|
+
description: "tabbed editor — model + thinking per role",
|
|
3566
|
+
},
|
|
3567
|
+
{
|
|
3568
|
+
value: "rename",
|
|
3569
|
+
label: "Rename",
|
|
3570
|
+
description: "change the profile name",
|
|
3571
|
+
},
|
|
3572
|
+
{
|
|
3573
|
+
value: "delete",
|
|
3574
|
+
label: "Delete",
|
|
3575
|
+
description: "remove from profiles.json",
|
|
3576
|
+
},
|
|
3577
|
+
{ value: "back", label: "Back", description: "return to the profile list" },
|
|
3578
|
+
],
|
|
3579
|
+
5,
|
|
3580
|
+
listThemeOf(theme),
|
|
3581
|
+
);
|
|
3582
|
+
let cached: string[] | undefined;
|
|
3583
|
+
return {
|
|
3584
|
+
render: (width: number) => {
|
|
3585
|
+
if (cached) return cached;
|
|
3586
|
+
const W = Math.max(1, width);
|
|
3587
|
+
const lines: string[] = [];
|
|
3588
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
3589
|
+
lines.push(
|
|
3590
|
+
` ${theme.fg("text", `⚡ ${profile.name}`)}${
|
|
3591
|
+
profile.description
|
|
3592
|
+
? theme.fg("muted", ` — ${profile.description}`)
|
|
3593
|
+
: ""
|
|
3594
|
+
}`,
|
|
3595
|
+
);
|
|
3596
|
+
lines.push("");
|
|
3597
|
+
for (const l of list.render(Math.max(1, W - 2)))
|
|
3598
|
+
lines.push(` ${l}`);
|
|
3599
|
+
lines.push("");
|
|
3600
|
+
lines.push(` ${theme.fg("dim", "Enter select • Esc back")}`);
|
|
3601
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
3602
|
+
cached = lines;
|
|
3603
|
+
return lines;
|
|
3604
|
+
},
|
|
3605
|
+
invalidate: () => {
|
|
3606
|
+
cached = undefined;
|
|
3607
|
+
},
|
|
3608
|
+
handleInput: (data: string) => {
|
|
3609
|
+
if (matchesKey(data, Key.escape)) {
|
|
3610
|
+
done(null);
|
|
3611
|
+
return;
|
|
3612
|
+
}
|
|
3613
|
+
if (matchesKey(data, Key.enter)) {
|
|
3614
|
+
done(list.getSelectedItem()?.value ?? null);
|
|
3615
|
+
return;
|
|
3616
|
+
}
|
|
3617
|
+
list.handleInput(data);
|
|
3618
|
+
cached = undefined;
|
|
3619
|
+
tui.requestRender();
|
|
3620
|
+
},
|
|
3621
|
+
};
|
|
3622
|
+
},
|
|
3623
|
+
);
|
|
3624
|
+
if (!action || action === "back") continue;
|
|
3625
|
+
if (action === "activate") {
|
|
3626
|
+
await setActiveProfile(ctx as any, profile.name, { explicit: true });
|
|
3627
|
+
continue;
|
|
3628
|
+
}
|
|
3629
|
+
if (action === "deactivate") {
|
|
3630
|
+
await setActiveProfile(ctx as any, null, { explicit: true });
|
|
3631
|
+
continue;
|
|
3632
|
+
}
|
|
3633
|
+
if (action === "edit") {
|
|
3634
|
+
await openProfileEditor(ctx, { name: profile.name });
|
|
3635
|
+
continue;
|
|
3636
|
+
}
|
|
3637
|
+
if (action === "rename") {
|
|
3638
|
+
const newName = await inputProfileName(ctx, {
|
|
3639
|
+
title: `Rename profile "${profile.name}" to:`,
|
|
3640
|
+
initial: profile.name,
|
|
3641
|
+
existing: profilesConfig.profiles
|
|
3642
|
+
.filter((p) => p !== profile)
|
|
3643
|
+
.map((p) => p.name),
|
|
3644
|
+
});
|
|
3645
|
+
if (newName && newName !== profile.name) {
|
|
3646
|
+
const wasActive =
|
|
3647
|
+
activeProfileName?.toLowerCase() === profile.name.toLowerCase();
|
|
3648
|
+
profile.name = newName;
|
|
3649
|
+
if (wasActive) activeProfileName = profile.name;
|
|
3650
|
+
invalidateEffectiveRoles();
|
|
3651
|
+
try {
|
|
3652
|
+
saveProfiles(profilesConfig, "user");
|
|
3653
|
+
ctx.ui.notify(`Profile renamed → ⚡ ${profile.name}.`, "info");
|
|
3654
|
+
} catch (e: any) {
|
|
3655
|
+
ctx.ui.notify(
|
|
3656
|
+
`Renamed in memory but file save failed: ${String(e?.message || e)}`,
|
|
3657
|
+
"warning",
|
|
3658
|
+
);
|
|
3659
|
+
}
|
|
3660
|
+
updateStatus(ctx as any);
|
|
3661
|
+
}
|
|
3662
|
+
continue;
|
|
3663
|
+
}
|
|
3664
|
+
if (action === "delete") {
|
|
3665
|
+
const confirmed = await confirmProfileDelete(ctx, profile);
|
|
3666
|
+
if (!confirmed) continue;
|
|
3667
|
+
const wasActive =
|
|
3668
|
+
activeProfileName?.toLowerCase() === profile.name.toLowerCase();
|
|
3669
|
+
profilesConfig.profiles = profilesConfig.profiles.filter(
|
|
3670
|
+
(p) => p !== profile,
|
|
3671
|
+
);
|
|
3672
|
+
if (wasActive) {
|
|
3673
|
+
activeProfileName = null;
|
|
3674
|
+
invalidateEffectiveRoles();
|
|
3675
|
+
}
|
|
3676
|
+
try {
|
|
3677
|
+
saveProfiles(profilesConfig, "user");
|
|
3678
|
+
ctx.ui.notify(
|
|
3679
|
+
`Profile ⚡ ${profile.name} deleted.${
|
|
3680
|
+
wasActive
|
|
3681
|
+
? " It was active — default roles flow restored."
|
|
3682
|
+
: ""
|
|
3683
|
+
}`,
|
|
3684
|
+
"info",
|
|
3685
|
+
);
|
|
3686
|
+
} catch (e: any) {
|
|
3687
|
+
ctx.ui.notify(
|
|
3688
|
+
`Deleted in memory but file save failed: ${String(e?.message || e)}`,
|
|
3689
|
+
"warning",
|
|
3690
|
+
);
|
|
3691
|
+
}
|
|
3692
|
+
if (wasActive && workflowMode) {
|
|
3693
|
+
try {
|
|
3694
|
+
await applyModeModel(ctx as any);
|
|
3695
|
+
} catch (_e) {
|
|
3696
|
+
void _e;
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
updateStatus(ctx as any);
|
|
3700
|
+
continue;
|
|
3701
|
+
}
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
/** Picker for the default profile (workflow.defaultProfile) + its scope. */
|
|
3706
|
+
async function pickDefaultProfile(ctx: ExtensionContext): Promise<void> {
|
|
3707
|
+
const cwd = (ctx as any)?.cwd as string | undefined;
|
|
3708
|
+
const current = currentDefaultProfileName(cwd);
|
|
3709
|
+
const entries: SelectItem[] = [
|
|
3710
|
+
{
|
|
3711
|
+
value: "__none",
|
|
3712
|
+
label: "— none —",
|
|
3713
|
+
description: "new sessions start without a profile",
|
|
3714
|
+
},
|
|
3715
|
+
...profilesConfig.profiles.map((p) => ({
|
|
3716
|
+
value: p.name,
|
|
3717
|
+
label: `⚡ ${p.name}`,
|
|
3718
|
+
description: p.description || undefined,
|
|
3719
|
+
})),
|
|
3720
|
+
];
|
|
3721
|
+
const name = await ctx.ui.custom<string | null>(
|
|
3722
|
+
(tui, theme, _kb, done) => {
|
|
3723
|
+
const list = new SelectList(entries, 8, listThemeOf(theme));
|
|
3724
|
+
let cached: string[] | undefined;
|
|
3725
|
+
return {
|
|
3726
|
+
render: (width: number) => {
|
|
3727
|
+
if (cached) return cached;
|
|
3728
|
+
const W = Math.max(1, width);
|
|
3729
|
+
const lines: string[] = [];
|
|
3730
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
3731
|
+
lines.push(
|
|
3732
|
+
` ${theme.fg("text", "Default profile for new sessions")} ${theme.fg(
|
|
3733
|
+
"muted",
|
|
3734
|
+
`— currently: ${current ?? "(none)"}`,
|
|
3735
|
+
)}`,
|
|
3736
|
+
);
|
|
3737
|
+
lines.push("");
|
|
3738
|
+
for (const l of list.render(Math.max(1, W - 2)))
|
|
3739
|
+
lines.push(` ${l}`);
|
|
3740
|
+
lines.push("");
|
|
3741
|
+
lines.push(` ${theme.fg("dim", "Enter select • Esc cancel")}`);
|
|
3742
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
3743
|
+
cached = lines;
|
|
3744
|
+
return lines;
|
|
3745
|
+
},
|
|
3746
|
+
invalidate: () => {
|
|
3747
|
+
cached = undefined;
|
|
3748
|
+
},
|
|
3749
|
+
handleInput: (data: string) => {
|
|
3750
|
+
if (matchesKey(data, Key.escape)) {
|
|
3751
|
+
done(null);
|
|
3752
|
+
return;
|
|
3753
|
+
}
|
|
3754
|
+
if (matchesKey(data, Key.enter)) {
|
|
3755
|
+
done(list.getSelectedItem()?.value ?? null);
|
|
3756
|
+
return;
|
|
3757
|
+
}
|
|
3758
|
+
list.handleInput(data);
|
|
3759
|
+
cached = undefined;
|
|
3760
|
+
tui.requestRender();
|
|
3761
|
+
},
|
|
3762
|
+
};
|
|
3763
|
+
},
|
|
3764
|
+
);
|
|
3765
|
+
if (name === null) return;
|
|
3766
|
+
// Keep the scope where the setting currently lives (else global).
|
|
3767
|
+
const resolved = readDefaultProfileSetting(
|
|
3768
|
+
cwd ?? process.cwd(),
|
|
3769
|
+
getAgentDir(),
|
|
3770
|
+
);
|
|
3771
|
+
const scope: "global" | "project" =
|
|
3772
|
+
resolved.sources.effective === "project" ? "project" : "global";
|
|
3773
|
+
const value = name === "__none" ? null : name;
|
|
3774
|
+
const res = writeDefaultProfileSetting(
|
|
3775
|
+
scope,
|
|
3776
|
+
cwd ?? process.cwd(),
|
|
3777
|
+
getAgentDir(),
|
|
3778
|
+
value,
|
|
3779
|
+
);
|
|
3780
|
+
if (!res.ok) {
|
|
3781
|
+
ctx.ui.notify(
|
|
3782
|
+
`Could not write default profile: ${res.error ?? "unknown error"}`,
|
|
3783
|
+
"error",
|
|
3784
|
+
);
|
|
3785
|
+
return;
|
|
3786
|
+
}
|
|
3787
|
+
ctx.ui.notify(
|
|
3788
|
+
value
|
|
3789
|
+
? `Default profile → ⚡ ${value} (${scope} settings)\nNew sessions without an explicit /profile use start on it.`
|
|
3790
|
+
: `Default profile cleared (${scope} settings).`,
|
|
3791
|
+
"info",
|
|
3792
|
+
);
|
|
3793
|
+
updateStatus(ctx as any);
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
interface StagedProfileModel {
|
|
3797
|
+
provider: string;
|
|
3798
|
+
id: string;
|
|
3799
|
+
thinking: ThinkingLevel;
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
/**
|
|
3803
|
+
* Tabbed profile editor — one tab per built-in role (planner / explorer /
|
|
3804
|
+
* builder / reviewer, mirroring /role) plus two submit tabs: Save &
|
|
3805
|
+
* activate and Save only. Selections stage locally; saving writes
|
|
3806
|
+
* profiles.json (user scope). `createName` creates a new profile seeded
|
|
3807
|
+
* from the current effective roles; `name` edits an existing one.
|
|
3808
|
+
*/
|
|
3809
|
+
async function openProfileEditor(
|
|
3810
|
+
ctx: ExtensionContext,
|
|
3811
|
+
opts: { createName?: string; name?: string },
|
|
3812
|
+
): Promise<void> {
|
|
3813
|
+
ensureProfilesConfig((ctx as any)?.cwd);
|
|
3814
|
+
ensureRoleConfig((ctx as any)?.cwd);
|
|
3815
|
+
const catalog = collectRoleCatalog(ctx);
|
|
3816
|
+
if (catalog.all.length === 0) {
|
|
3817
|
+
ctx.ui.notify(
|
|
3818
|
+
"No models available from connected providers — connect a provider first.",
|
|
3819
|
+
"warning",
|
|
3820
|
+
);
|
|
3821
|
+
return;
|
|
3822
|
+
}
|
|
3823
|
+
const existing = opts.name
|
|
3824
|
+
? getProfile(profilesConfig, opts.name)
|
|
3825
|
+
: undefined;
|
|
3826
|
+
if (opts.name && !existing) {
|
|
3827
|
+
ctx.ui.notify(`Profile "${opts.name}" no longer exists.`, "warning");
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3830
|
+
const isNew = !existing;
|
|
3831
|
+
const profileName = existing
|
|
3832
|
+
? existing.name
|
|
3833
|
+
: (opts.createName ?? "").trim();
|
|
3834
|
+
if (!profileName) {
|
|
3835
|
+
ctx.ui.notify("Profile name required.", "warning");
|
|
3836
|
+
return;
|
|
3837
|
+
}
|
|
3838
|
+
const roleMeta = new Map<string, { name: string; description: string }>();
|
|
3839
|
+
for (const roleName of PROFILE_ROLES) {
|
|
3840
|
+
const base = getRole(roleConfig, roleName);
|
|
3841
|
+
roleMeta.set(roleName, {
|
|
3842
|
+
name: roleName,
|
|
3843
|
+
description: base?.description ?? `Role ${roleName}`,
|
|
3844
|
+
});
|
|
3845
|
+
}
|
|
3846
|
+
// Keep the narrow tuple type so profile.roles[roleName] typechecks.
|
|
3847
|
+
const tabIds = [...PROFILE_ROLES];
|
|
3848
|
+
// Stage from the profile (edit) or the current effective roles (create).
|
|
3849
|
+
const staged = new Map<string, StagedProfileModel>();
|
|
3850
|
+
for (const roleName of PROFILE_ROLES) {
|
|
3851
|
+
const src =
|
|
3852
|
+
existing?.roles[roleName] ?? getEffectiveRole(roleName)?.model;
|
|
3853
|
+
staged.set(roleName, {
|
|
3854
|
+
provider: src?.provider ?? "",
|
|
3855
|
+
id: src?.id ?? "",
|
|
3856
|
+
thinking: src?.thinking ?? "medium",
|
|
3857
|
+
});
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3860
|
+
overlayActive = true;
|
|
3861
|
+
let saved: {
|
|
3862
|
+
activate: boolean;
|
|
3863
|
+
staged: Map<string, StagedProfileModel>;
|
|
3864
|
+
} | null = null;
|
|
3865
|
+
try {
|
|
3866
|
+
saved = await ctx.ui.custom<
|
|
3867
|
+
{ activate: boolean; staged: Map<string, StagedProfileModel> } | null
|
|
3868
|
+
>((tui, theme, _kb, done) => {
|
|
3869
|
+
let currentTab = 0;
|
|
3870
|
+
let phase: "model" | "thinking" = "model";
|
|
3871
|
+
let showAll = catalog.scoped.length === 0;
|
|
3872
|
+
let cached: string[] | undefined;
|
|
3873
|
+
const themeList = listThemeOf(theme);
|
|
3874
|
+
const isSubmitTab = () => currentTab >= tabIds.length;
|
|
3875
|
+
const isSaveActivate = () => currentTab === tabIds.length;
|
|
3876
|
+
const buildModelItems = (roleName: string): SelectItem[] => {
|
|
3877
|
+
const st = staged.get(roleName)!;
|
|
3878
|
+
const cur = `${st.provider}/${st.id}`;
|
|
3879
|
+
const src = showAll ? catalog.all : catalog.scoped;
|
|
3880
|
+
return src.map((e) => {
|
|
3881
|
+
const ref = `${e.provider}/${e.id}`;
|
|
3882
|
+
const description: string | undefined =
|
|
3883
|
+
ref === cur
|
|
3884
|
+
? "● selected"
|
|
3885
|
+
: showAll && !e.inScope
|
|
3886
|
+
? "all"
|
|
3887
|
+
: undefined;
|
|
3888
|
+
return { value: ref, label: ref, description };
|
|
3889
|
+
});
|
|
3890
|
+
};
|
|
3891
|
+
const modelLists = new Map<string, SelectList>();
|
|
3892
|
+
const thinkingList = new SelectList(
|
|
3893
|
+
VALID_THINKING_LEVELS.map((t) => ({ value: t, label: t })),
|
|
3894
|
+
Math.min(VALID_THINKING_LEVELS.length, 8),
|
|
3895
|
+
themeList,
|
|
3896
|
+
);
|
|
3897
|
+
const getModelList = (roleName: string): SelectList => {
|
|
3898
|
+
let list = modelLists.get(roleName);
|
|
3899
|
+
if (!list) {
|
|
3900
|
+
list = new SelectList(buildModelItems(roleName), 10, themeList);
|
|
3901
|
+
const st = staged.get(roleName)!;
|
|
3902
|
+
const idx = (showAll ? catalog.all : catalog.scoped).findIndex(
|
|
3903
|
+
(e) => `${e.provider}/${e.id}` === `${st.provider}/${st.id}`,
|
|
3904
|
+
);
|
|
3905
|
+
if (idx > 0) list.setSelectedIndex(idx);
|
|
3906
|
+
modelLists.set(roleName, list);
|
|
3907
|
+
}
|
|
3908
|
+
return list;
|
|
3909
|
+
};
|
|
3910
|
+
const rebuildModelLists = () => modelLists.clear();
|
|
3911
|
+
const syncThinkingCursor = (roleName: string) => {
|
|
3912
|
+
const st = staged.get(roleName)!;
|
|
3913
|
+
const idx = VALID_THINKING_LEVELS.indexOf(st.thinking);
|
|
3914
|
+
if (idx >= 0) thinkingList.setSelectedIndex(idx);
|
|
3915
|
+
};
|
|
3916
|
+
const commitModelSelection = (roleName: string) => {
|
|
3917
|
+
const sel = getModelList(roleName).getSelectedItem();
|
|
3918
|
+
if (!sel) return false;
|
|
3919
|
+
const { provider, id } = parseModelRef(sel.value);
|
|
3920
|
+
if (!provider || !id) return false;
|
|
3921
|
+
const st = staged.get(roleName)!;
|
|
3922
|
+
staged.set(roleName, { ...st, provider, id });
|
|
3923
|
+
return true;
|
|
3924
|
+
};
|
|
3925
|
+
const commitThinkingSelection = (roleName: string) => {
|
|
3926
|
+
const sel = thinkingList.getSelectedItem();
|
|
3927
|
+
if (!sel || !isValidThinkingLevel(sel.value)) return false;
|
|
3928
|
+
const st = staged.get(roleName)!;
|
|
3929
|
+
staged.set(roleName, { ...st, thinking: sel.value });
|
|
3930
|
+
return true;
|
|
3931
|
+
};
|
|
3932
|
+
const advanceFromThinking = () => {
|
|
3933
|
+
if (currentTab < tabIds.length) currentTab++;
|
|
3934
|
+
phase = "model";
|
|
3935
|
+
cached = undefined;
|
|
3936
|
+
tui.requestRender();
|
|
3937
|
+
};
|
|
3938
|
+
|
|
3939
|
+
const handleInput = (data: string) => {
|
|
3940
|
+
if (matchesKey(data, Key.escape)) {
|
|
3941
|
+
if (phase === "thinking" && !isSubmitTab()) {
|
|
3942
|
+
phase = "model";
|
|
3943
|
+
cached = undefined;
|
|
3944
|
+
tui.requestRender();
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3947
|
+
done(null);
|
|
3948
|
+
return;
|
|
3949
|
+
}
|
|
3950
|
+
if (
|
|
3951
|
+
matchesKey(data, Key.tab) ||
|
|
3952
|
+
matchesKey(data, Key.right) ||
|
|
3953
|
+
matchesKey(data, Key.shift("tab")) ||
|
|
3954
|
+
matchesKey(data, Key.left)
|
|
3955
|
+
) {
|
|
3956
|
+
const fwd =
|
|
3957
|
+
matchesKey(data, Key.tab) || matchesKey(data, Key.right);
|
|
3958
|
+
const n = tabIds.length + 2;
|
|
3959
|
+
currentTab = fwd
|
|
3960
|
+
? (currentTab + 1) % n
|
|
3961
|
+
: (currentTab - 1 + n) % n;
|
|
3962
|
+
phase = "model";
|
|
3963
|
+
cached = undefined;
|
|
3964
|
+
tui.requestRender();
|
|
3965
|
+
return;
|
|
3966
|
+
}
|
|
3967
|
+
// Ctrl+O toggles scoped ↔ all models
|
|
3968
|
+
try {
|
|
3969
|
+
if (matchesKey(data, Key.ctrl("o"))) {
|
|
3970
|
+
showAll = !showAll;
|
|
3971
|
+
rebuildModelLists();
|
|
3972
|
+
cached = undefined;
|
|
3973
|
+
tui.requestRender();
|
|
3974
|
+
return;
|
|
3975
|
+
}
|
|
3976
|
+
} catch (_e) {
|
|
3977
|
+
void _e;
|
|
3978
|
+
}
|
|
3979
|
+
if (isSubmitTab()) {
|
|
3980
|
+
if (matchesKey(data, Key.enter)) {
|
|
3981
|
+
done({ activate: isSaveActivate(), staged: new Map(staged) });
|
|
3982
|
+
}
|
|
3983
|
+
return;
|
|
3984
|
+
}
|
|
3985
|
+
const roleName = tabIds[currentTab];
|
|
3986
|
+
if (matchesKey(data, Key.enter)) {
|
|
3987
|
+
if (phase === "model") {
|
|
3988
|
+
if (!commitModelSelection(roleName)) return;
|
|
3989
|
+
phase = "thinking";
|
|
3990
|
+
syncThinkingCursor(roleName);
|
|
3991
|
+
cached = undefined;
|
|
3992
|
+
tui.requestRender();
|
|
3993
|
+
return;
|
|
3994
|
+
}
|
|
3995
|
+
if (!commitThinkingSelection(roleName)) return;
|
|
3996
|
+
advanceFromThinking();
|
|
3997
|
+
return;
|
|
3998
|
+
}
|
|
3999
|
+
if (phase === "model") getModelList(roleName).handleInput(data);
|
|
4000
|
+
else thinkingList.handleInput(data);
|
|
4001
|
+
cached = undefined;
|
|
4002
|
+
tui.requestRender();
|
|
4003
|
+
};
|
|
4004
|
+
|
|
4005
|
+
const render = (width: number): string[] => {
|
|
4006
|
+
if (cached) return cached;
|
|
4007
|
+
const lines: string[] = [];
|
|
4008
|
+
const W = Math.max(1, width);
|
|
4009
|
+
const wrap = (s: string) => {
|
|
4010
|
+
const out: string[] = [];
|
|
4011
|
+
for (const line of s.split("\n"))
|
|
4012
|
+
out.push(...wrapTextWithAnsi(line, W));
|
|
4013
|
+
return out;
|
|
4014
|
+
};
|
|
4015
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
4016
|
+
lines.push(
|
|
4017
|
+
` ${theme.fg(
|
|
4018
|
+
"text",
|
|
4019
|
+
`${isNew ? "New profile" : "Edit profile"} ⚡ ${profileName}`,
|
|
4020
|
+
)}`,
|
|
4021
|
+
);
|
|
4022
|
+
lines.push("");
|
|
4023
|
+
// Tabs row: one block per role + two submit tabs
|
|
4024
|
+
const chips: string[] = [];
|
|
4025
|
+
for (let i = 0; i < tabIds.length; i++) {
|
|
4026
|
+
const roleName = tabIds[i];
|
|
4027
|
+
const st = staged.get(roleName)!;
|
|
4028
|
+
const base = existing?.roles[roleName];
|
|
4029
|
+
const dirty =
|
|
4030
|
+
!base ||
|
|
4031
|
+
`${st.provider}/${st.id}` !== `${base.provider}/${base.id}` ||
|
|
4032
|
+
st.thinking !== base.thinking;
|
|
4033
|
+
const box = dirty ? "■" : "□";
|
|
4034
|
+
const lbl = ` ${box} ${roleIcon(roleName)} ${roleName} `;
|
|
4035
|
+
chips.push(
|
|
4036
|
+
i === currentTab
|
|
4037
|
+
? theme.bg("selectedBg", theme.fg("text", lbl))
|
|
4038
|
+
: theme.fg(dirty ? "success" : "muted", lbl),
|
|
4039
|
+
);
|
|
4040
|
+
}
|
|
4041
|
+
const saveActivateLbl = " ✓ Save & activate ";
|
|
4042
|
+
const saveOnlyLbl = " 💾 Save only ";
|
|
4043
|
+
chips.push(
|
|
4044
|
+
isSaveActivate()
|
|
4045
|
+
? theme.bg("selectedBg", theme.fg("text", saveActivateLbl))
|
|
4046
|
+
: theme.fg("success", saveActivateLbl),
|
|
4047
|
+
);
|
|
4048
|
+
chips.push(
|
|
4049
|
+
currentTab === tabIds.length + 1
|
|
4050
|
+
? theme.bg("selectedBg", theme.fg("text", saveOnlyLbl))
|
|
4051
|
+
: theme.fg("success", saveOnlyLbl),
|
|
4052
|
+
);
|
|
4053
|
+
lines.push(` ${chips.join("")}`);
|
|
4054
|
+
lines.push("");
|
|
4055
|
+
if (isSubmitTab()) {
|
|
4056
|
+
lines.push(
|
|
4057
|
+
...wrap(
|
|
4058
|
+
` ${theme.fg(
|
|
4059
|
+
"text",
|
|
4060
|
+
isSaveActivate()
|
|
4061
|
+
? "Save & activate — Enter saves and switches this session to the profile; Esc cancels"
|
|
4062
|
+
: "Save only — Enter saves without activating; Esc cancels",
|
|
4063
|
+
)}`,
|
|
4064
|
+
),
|
|
4065
|
+
);
|
|
4066
|
+
lines.push("");
|
|
4067
|
+
for (const roleName of tabIds) {
|
|
4068
|
+
const st = staged.get(roleName)!;
|
|
4069
|
+
lines.push(
|
|
4070
|
+
...wrap(
|
|
4071
|
+
` ${theme.fg("muted", `${roleIcon(roleName)} ${roleName}: `)}${theme.fg(
|
|
4072
|
+
"text",
|
|
4073
|
+
st.provider
|
|
4074
|
+
? `${st.provider}/${st.id} (${st.thinking})`
|
|
4075
|
+
: "(unset)",
|
|
4076
|
+
)}`,
|
|
4077
|
+
),
|
|
4078
|
+
);
|
|
4079
|
+
}
|
|
4080
|
+
} else {
|
|
4081
|
+
const roleName = tabIds[currentTab];
|
|
4082
|
+
const st = staged.get(roleName)!;
|
|
4083
|
+
const meta = roleMeta.get(roleName)!;
|
|
4084
|
+
lines.push(
|
|
4085
|
+
...wrap(
|
|
4086
|
+
` ${theme.fg("text", `${roleIcon(roleName)} ${roleName} — ${meta.description}`)}`,
|
|
4087
|
+
),
|
|
4088
|
+
);
|
|
4089
|
+
lines.push(
|
|
4090
|
+
...wrap(
|
|
4091
|
+
` ${theme.fg(
|
|
4092
|
+
"muted",
|
|
4093
|
+
`staged: ${
|
|
4094
|
+
st.provider
|
|
4095
|
+
? `${st.provider}/${st.id} (${st.thinking})`
|
|
4096
|
+
: "(unset)"
|
|
4097
|
+
}`,
|
|
4098
|
+
)}`,
|
|
4099
|
+
),
|
|
4100
|
+
);
|
|
4101
|
+
lines.push("");
|
|
4102
|
+
if (phase === "model") {
|
|
4103
|
+
const scopeLabel = showAll
|
|
4104
|
+
? `all ${String(catalog.all.length)}${catalog.truncated ? "+" : ""}`
|
|
4105
|
+
: `scoped ${String(catalog.scoped.length)}`;
|
|
4106
|
+
const toggleHint =
|
|
4107
|
+
catalog.scoped.length > 0 && !showAll
|
|
4108
|
+
? " — Ctrl+O for all"
|
|
4109
|
+
: "";
|
|
4110
|
+
lines.push(
|
|
4111
|
+
...wrap(
|
|
4112
|
+
` ${theme.fg(
|
|
4113
|
+
"accent",
|
|
4114
|
+
`Model (${scopeLabel}${toggleHint}) — type to filter:`,
|
|
4115
|
+
)}`,
|
|
4116
|
+
),
|
|
4117
|
+
);
|
|
4118
|
+
for (const l of getModelList(roleName).render(Math.max(1, W - 2)))
|
|
4119
|
+
lines.push(` ${l}`);
|
|
4120
|
+
} else {
|
|
4121
|
+
lines.push(
|
|
4122
|
+
...wrap(` ${theme.fg("accent", "Thinking level:")}`),
|
|
4123
|
+
);
|
|
4124
|
+
for (const l of thinkingList.render(Math.max(1, W - 2)))
|
|
4125
|
+
lines.push(` ${l}`);
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
lines.push("");
|
|
4129
|
+
lines.push(
|
|
4130
|
+
...wrap(
|
|
4131
|
+
` ${theme.fg(
|
|
4132
|
+
"dim",
|
|
4133
|
+
"Tab/←→ roles • ↑↓ navigate • type to filter • Enter select • Esc back/cancel • Ctrl+O scoped/all",
|
|
4134
|
+
)}`,
|
|
4135
|
+
),
|
|
4136
|
+
);
|
|
4137
|
+
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
4138
|
+
cached = lines;
|
|
4139
|
+
return lines;
|
|
4140
|
+
};
|
|
4141
|
+
|
|
4142
|
+
return {
|
|
4143
|
+
render,
|
|
4144
|
+
invalidate: () => {
|
|
4145
|
+
cached = undefined;
|
|
4146
|
+
},
|
|
4147
|
+
handleInput,
|
|
4148
|
+
};
|
|
4149
|
+
});
|
|
4150
|
+
} finally {
|
|
4151
|
+
overlayActive = false;
|
|
4152
|
+
}
|
|
4153
|
+
if (!saved) {
|
|
4154
|
+
ctx.ui.notify(
|
|
4155
|
+
isNew ? "Profile creation cancelled." : "Profile editor cancelled.",
|
|
4156
|
+
"info",
|
|
4157
|
+
);
|
|
4158
|
+
return;
|
|
4159
|
+
}
|
|
4160
|
+
// Validate against registry (warn-only, matching /role set).
|
|
4161
|
+
try {
|
|
4162
|
+
const reg = (ctx as any)?.modelRegistry;
|
|
4163
|
+
if (reg && typeof reg.find === "function") {
|
|
4164
|
+
for (const [roleName, st] of saved.staged) {
|
|
4165
|
+
if (
|
|
4166
|
+
st.provider &&
|
|
4167
|
+
st.id &&
|
|
4168
|
+
typeof reg.find === "function" &&
|
|
4169
|
+
!reg.find(st.provider, st.id)
|
|
4170
|
+
)
|
|
4171
|
+
ctx.ui.notify(
|
|
4172
|
+
`Warning: ${st.provider}/${st.id} (${roleName}) not in model registry — saved anyway.`,
|
|
4173
|
+
"warning",
|
|
4174
|
+
);
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
} catch (_e) {
|
|
4178
|
+
void _e;
|
|
4179
|
+
}
|
|
4180
|
+
// Materialize: create or update the profile, then persist.
|
|
4181
|
+
let profile = existing;
|
|
4182
|
+
if (!profile) {
|
|
4183
|
+
profile = { name: profileName, description: "", roles: {} };
|
|
4184
|
+
profilesConfig.profiles.push(profile);
|
|
4185
|
+
}
|
|
4186
|
+
profile.roles = {};
|
|
4187
|
+
for (const roleName of PROFILE_ROLES) {
|
|
4188
|
+
const st = saved.staged.get(roleName);
|
|
4189
|
+
if (st && st.provider && st.id)
|
|
4190
|
+
profile.roles[roleName] = {
|
|
4191
|
+
provider: st.provider,
|
|
4192
|
+
id: st.id,
|
|
4193
|
+
thinking: st.thinking,
|
|
4194
|
+
};
|
|
4195
|
+
}
|
|
4196
|
+
try {
|
|
4197
|
+
saveProfiles(profilesConfig, "user");
|
|
4198
|
+
} catch (e: any) {
|
|
4199
|
+
ctx.ui.notify(
|
|
4200
|
+
`Profile updated but file save failed: ${String(e?.message || e)}`,
|
|
4201
|
+
"warning",
|
|
4202
|
+
);
|
|
4203
|
+
return;
|
|
4204
|
+
}
|
|
4205
|
+
invalidateEffectiveRoles();
|
|
4206
|
+
updateStatus(ctx as any);
|
|
4207
|
+
if (saved.activate) {
|
|
4208
|
+
await setActiveProfile(ctx as any, profile.name, { explicit: true });
|
|
4209
|
+
} else {
|
|
4210
|
+
ctx.ui.notify(
|
|
4211
|
+
`Profile ⚡ ${profile.name} saved. Activate with /profile use ${profile.name}.`,
|
|
4212
|
+
"info",
|
|
4213
|
+
);
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
4216
|
+
|
|
4217
|
+
// ── Model profiles command ───────────────────────────────────────
|
|
4218
|
+
|
|
4219
|
+
pi.registerCommand("profile", {
|
|
4220
|
+
description:
|
|
4221
|
+
"Switch or manage model profiles (named role-model combinations) — opens picker UI",
|
|
4222
|
+
handler: async (args, ctx) => {
|
|
4223
|
+
const cwd = (ctx as any)?.cwd as string | undefined;
|
|
4224
|
+
ensureRoleConfig(cwd);
|
|
4225
|
+
ensureProfilesConfig(cwd);
|
|
4226
|
+
const parts = String(args || "")
|
|
4227
|
+
.trim()
|
|
4228
|
+
.split(/\s+/)
|
|
4229
|
+
.filter(Boolean);
|
|
4230
|
+
const sub = (parts[0] || "").toLowerCase();
|
|
4231
|
+
const rest = parts.slice(1);
|
|
4232
|
+
|
|
4233
|
+
const persistAndNotify = (msg: string): boolean => {
|
|
4234
|
+
try {
|
|
4235
|
+
saveProfiles(profilesConfig, "user");
|
|
4236
|
+
} catch (e: any) {
|
|
4237
|
+
ctx.ui.notify(
|
|
4238
|
+
`Profile updated but file save failed: ${String(e?.message || e)}`,
|
|
4239
|
+
"warning",
|
|
4240
|
+
);
|
|
4241
|
+
return false;
|
|
4242
|
+
}
|
|
4243
|
+
invalidateEffectiveRoles();
|
|
4244
|
+
updateStatus(ctx as any);
|
|
4245
|
+
ctx.ui.notify(msg, "info");
|
|
4246
|
+
return true;
|
|
4247
|
+
};
|
|
4248
|
+
|
|
4249
|
+
// Bare /profile → picker UI (headless → text summary).
|
|
4250
|
+
if (!sub) {
|
|
4251
|
+
if ((ctx as any)?.hasUI) {
|
|
4252
|
+
await openProfilePicker(ctx as any);
|
|
4253
|
+
return;
|
|
4254
|
+
}
|
|
4255
|
+
ctx.ui.notify(
|
|
4256
|
+
formatProfilesForDisplay(
|
|
4257
|
+
profilesConfig,
|
|
4258
|
+
activeProfileName,
|
|
4259
|
+
currentDefaultProfileName(cwd),
|
|
4260
|
+
),
|
|
4261
|
+
"info",
|
|
4262
|
+
);
|
|
4263
|
+
return;
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
if (sub === "list" || (sub === "show" && !rest[0])) {
|
|
4267
|
+
ctx.ui.notify(
|
|
4268
|
+
formatProfilesForDisplay(
|
|
4269
|
+
profilesConfig,
|
|
4270
|
+
activeProfileName,
|
|
4271
|
+
currentDefaultProfileName(cwd),
|
|
4272
|
+
),
|
|
4273
|
+
"info",
|
|
4274
|
+
);
|
|
4275
|
+
return;
|
|
4276
|
+
}
|
|
4277
|
+
|
|
4278
|
+
if (sub === "show") {
|
|
4279
|
+
const p = getProfile(profilesConfig, rest[0]);
|
|
4280
|
+
if (!p) {
|
|
4281
|
+
ctx.ui.notify(`Unknown profile "${rest[0]}".`, "warning");
|
|
4282
|
+
return;
|
|
4283
|
+
}
|
|
4284
|
+
ctx.ui.notify(describeProfile(p), "info");
|
|
4285
|
+
return;
|
|
4286
|
+
}
|
|
4287
|
+
|
|
4288
|
+
if (sub === "use" || sub === "activate") {
|
|
4289
|
+
const name = rest[0];
|
|
4290
|
+
if (
|
|
4291
|
+
!name ||
|
|
4292
|
+
(name.toLowerCase() !== "none" &&
|
|
4293
|
+
name.toLowerCase() !== "off" &&
|
|
4294
|
+
!getProfile(profilesConfig, name))
|
|
4295
|
+
) {
|
|
4296
|
+
ctx.ui.notify(
|
|
4297
|
+
`Usage: /profile use <name|none>. Available: ${
|
|
4298
|
+
profilesConfig.profiles.map((p) => p.name).join(", ") ||
|
|
4299
|
+
"(none — /profile create <name>)"
|
|
4300
|
+
}`,
|
|
4301
|
+
"info",
|
|
4302
|
+
);
|
|
4303
|
+
return;
|
|
4304
|
+
}
|
|
4305
|
+
await setActiveProfile(
|
|
4306
|
+
ctx as any,
|
|
4307
|
+
name.toLowerCase() === "none" || name.toLowerCase() === "off"
|
|
4308
|
+
? null
|
|
4309
|
+
: name,
|
|
4310
|
+
{ explicit: true },
|
|
4311
|
+
);
|
|
4312
|
+
return;
|
|
4313
|
+
}
|
|
4314
|
+
|
|
4315
|
+
if (sub === "create" || sub === "new" || sub === "add") {
|
|
4316
|
+
const rawName = rest[0];
|
|
4317
|
+
if (!rawName) {
|
|
4318
|
+
ctx.ui.notify("Usage: /profile create <name>", "info");
|
|
4319
|
+
return;
|
|
4320
|
+
}
|
|
4321
|
+
const check = validateProfileName(
|
|
4322
|
+
rawName,
|
|
4323
|
+
profilesConfig.profiles.map((p) => p.name),
|
|
4324
|
+
);
|
|
4325
|
+
if (!check.ok) {
|
|
4326
|
+
ctx.ui.notify(`Cannot create profile: ${check.error}`, "warning");
|
|
4327
|
+
return;
|
|
4328
|
+
}
|
|
4329
|
+
if ((ctx as any)?.hasUI) {
|
|
4330
|
+
await openProfileEditor(ctx as any, { createName: check.value });
|
|
4331
|
+
return;
|
|
4332
|
+
}
|
|
4333
|
+
// Headless: create seeded from the current effective roles.
|
|
4334
|
+
const seed = buildProfileSeed(check.value);
|
|
4335
|
+
profilesConfig.profiles.push(seed);
|
|
4336
|
+
persistAndNotify(
|
|
4337
|
+
`Profile ⚡ ${seed.name} created (seeded from the current effective models). Activate with /profile use ${seed.name}.`,
|
|
4338
|
+
);
|
|
4339
|
+
return;
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
if (sub === "edit" || sub === "config") {
|
|
4343
|
+
const name = rest[0];
|
|
4344
|
+
if (!name) {
|
|
4345
|
+
ctx.ui.notify("Usage: /profile edit <name>", "info");
|
|
4346
|
+
return;
|
|
4347
|
+
}
|
|
4348
|
+
const p = getProfile(profilesConfig, name);
|
|
4349
|
+
if (!p) {
|
|
4350
|
+
ctx.ui.notify(
|
|
4351
|
+
`Unknown profile "${name}". Available: ${
|
|
4352
|
+
profilesConfig.profiles.map((q) => q.name).join(", ") || "(none)"
|
|
4353
|
+
}`,
|
|
4354
|
+
"warning",
|
|
4355
|
+
);
|
|
4356
|
+
return;
|
|
4357
|
+
}
|
|
4358
|
+
if (!(ctx as any)?.hasUI) {
|
|
4359
|
+
ctx.ui.notify(describeProfile(p) + "\n\nEditor needs UI.", "info");
|
|
4360
|
+
return;
|
|
4361
|
+
}
|
|
4362
|
+
await openProfileEditor(ctx as any, { name: p.name });
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
|
|
4366
|
+
if (sub === "rename" || sub === "mv") {
|
|
4367
|
+
const [oldName, newName] = rest;
|
|
4368
|
+
if (!oldName || !newName) {
|
|
4369
|
+
ctx.ui.notify("Usage: /profile rename <old> <new>", "info");
|
|
4370
|
+
return;
|
|
4371
|
+
}
|
|
4372
|
+
const p = getProfile(profilesConfig, oldName);
|
|
4373
|
+
if (!p) {
|
|
4374
|
+
ctx.ui.notify(`Unknown profile "${oldName}".`, "warning");
|
|
4375
|
+
return;
|
|
4376
|
+
}
|
|
4377
|
+
const check = validateProfileName(
|
|
4378
|
+
newName,
|
|
4379
|
+
profilesConfig.profiles
|
|
4380
|
+
.filter((q) => q !== p)
|
|
4381
|
+
.map((q) => q.name),
|
|
4382
|
+
);
|
|
4383
|
+
if (!check.ok) {
|
|
4384
|
+
ctx.ui.notify(`Cannot rename: ${check.error}`, "warning");
|
|
4385
|
+
return;
|
|
4386
|
+
}
|
|
4387
|
+
const wasActive =
|
|
4388
|
+
activeProfileName?.toLowerCase() === p.name.toLowerCase();
|
|
4389
|
+
p.name = check.value;
|
|
4390
|
+
if (wasActive) activeProfileName = p.name;
|
|
4391
|
+
invalidateEffectiveRoles();
|
|
4392
|
+
persistAndNotify(
|
|
4393
|
+
`Profile renamed → ⚡ ${p.name}${
|
|
4394
|
+
wasActive ? " (still active in this session)" : ""
|
|
4395
|
+
}`,
|
|
4396
|
+
);
|
|
4397
|
+
const def = readDefaultProfileSetting(
|
|
4398
|
+
cwd ?? process.cwd(),
|
|
4399
|
+
getAgentDir(),
|
|
4400
|
+
);
|
|
4401
|
+
if (def.value && def.value.toLowerCase() === oldName.toLowerCase()) {
|
|
4402
|
+
ctx.ui.notify(
|
|
4403
|
+
`workflow.defaultProfile still points at "${def.value}" — update with /profile default ${p.name}${
|
|
4404
|
+
def.sources.effective === "project" ? " project" : ""
|
|
4405
|
+
}`,
|
|
4406
|
+
"warning",
|
|
4407
|
+
);
|
|
4408
|
+
}
|
|
4409
|
+
return;
|
|
4410
|
+
}
|
|
4411
|
+
|
|
4412
|
+
if (sub === "delete" || sub === "remove" || sub === "rm") {
|
|
4413
|
+
const name = rest[0];
|
|
4414
|
+
if (!name) {
|
|
4415
|
+
ctx.ui.notify("Usage: /profile delete <name>", "info");
|
|
4416
|
+
return;
|
|
4417
|
+
}
|
|
4418
|
+
const p = getProfile(profilesConfig, name);
|
|
4419
|
+
if (!p) {
|
|
4420
|
+
ctx.ui.notify(`Unknown profile "${name}".`, "warning");
|
|
4421
|
+
return;
|
|
4422
|
+
}
|
|
4423
|
+
if ((ctx as any)?.hasUI) {
|
|
4424
|
+
const confirmed = await confirmProfileDelete(ctx as any, p);
|
|
4425
|
+
if (!confirmed) {
|
|
4426
|
+
ctx.ui.notify("Deletion cancelled.", "info");
|
|
4427
|
+
return;
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
const wasActive =
|
|
4431
|
+
activeProfileName?.toLowerCase() === p.name.toLowerCase();
|
|
4432
|
+
profilesConfig.profiles = profilesConfig.profiles.filter(
|
|
4433
|
+
(q) => q !== p,
|
|
4434
|
+
);
|
|
4435
|
+
if (wasActive) {
|
|
4436
|
+
activeProfileName = null;
|
|
4437
|
+
invalidateEffectiveRoles();
|
|
4438
|
+
}
|
|
4439
|
+
const savedOk = persistAndNotify(
|
|
4440
|
+
`Profile ⚡ ${p.name} deleted.${
|
|
4441
|
+
wasActive ? " It was active — default roles flow restored." : ""
|
|
4442
|
+
}`,
|
|
4443
|
+
);
|
|
4444
|
+
if (savedOk && wasActive && workflowMode) {
|
|
4445
|
+
try {
|
|
4446
|
+
await applyModeModel(ctx as any);
|
|
4447
|
+
updateStatus(ctx as any);
|
|
4448
|
+
} catch (_e) {
|
|
4449
|
+
void _e;
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4452
|
+
return;
|
|
4453
|
+
}
|
|
4454
|
+
|
|
4455
|
+
if (sub === "default") {
|
|
4456
|
+
// /profile default [name|none] [global|project]
|
|
4457
|
+
const target = rest.find(
|
|
4458
|
+
(t) => t.toLowerCase() !== "global" && t.toLowerCase() !== "project",
|
|
4459
|
+
);
|
|
4460
|
+
const scope: "global" | "project" = rest
|
|
4461
|
+
.map((t) => t.toLowerCase())
|
|
4462
|
+
.includes("project")
|
|
4463
|
+
? "project"
|
|
4464
|
+
: "global";
|
|
4465
|
+
const paths = defaultProfileSettingPaths(
|
|
4466
|
+
cwd ?? process.cwd(),
|
|
4467
|
+
getAgentDir(),
|
|
4468
|
+
);
|
|
4469
|
+
if (!target) {
|
|
4470
|
+
const resolved = readDefaultProfileSetting(
|
|
4471
|
+
cwd ?? process.cwd(),
|
|
4472
|
+
getAgentDir(),
|
|
4473
|
+
);
|
|
4474
|
+
ctx.ui.notify(
|
|
4475
|
+
[
|
|
4476
|
+
`Default profile (workflow.defaultProfile): ${
|
|
4477
|
+
resolved.value ?? "(none)"
|
|
4478
|
+
} (${resolved.sources.effective})`,
|
|
4479
|
+
`Global settings: ${paths.global}`,
|
|
4480
|
+
`Project settings: ${paths.project}`,
|
|
4481
|
+
"New sessions without an explicit /profile use start on the default profile.",
|
|
4482
|
+
"Usage: /profile default <name|none> [global|project]",
|
|
4483
|
+
].join("\n"),
|
|
4484
|
+
"info",
|
|
4485
|
+
);
|
|
4486
|
+
return;
|
|
4487
|
+
}
|
|
4488
|
+
const value =
|
|
4489
|
+
target.toLowerCase() === "none" || target.toLowerCase() === "off"
|
|
4490
|
+
? null
|
|
4491
|
+
: target;
|
|
4492
|
+
if (value && !getProfile(profilesConfig, value)) {
|
|
4493
|
+
ctx.ui.notify(
|
|
4494
|
+
`Unknown profile "${value}". Available: ${
|
|
4495
|
+
profilesConfig.profiles.map((q) => q.name).join(", ") || "(none)"
|
|
4496
|
+
}`,
|
|
4497
|
+
"warning",
|
|
4498
|
+
);
|
|
4499
|
+
return;
|
|
4500
|
+
}
|
|
4501
|
+
const res = writeDefaultProfileSetting(
|
|
4502
|
+
scope,
|
|
4503
|
+
cwd ?? process.cwd(),
|
|
4504
|
+
getAgentDir(),
|
|
4505
|
+
value,
|
|
4506
|
+
);
|
|
4507
|
+
if (!res.ok) {
|
|
4508
|
+
ctx.ui.notify(
|
|
4509
|
+
`Could not write default profile: ${res.error ?? "unknown error"}`,
|
|
4510
|
+
"error",
|
|
4511
|
+
);
|
|
4512
|
+
return;
|
|
4513
|
+
}
|
|
4514
|
+
ctx.ui.notify(
|
|
4515
|
+
value
|
|
4516
|
+
? `Default profile → ⚡ ${value} (${scope} settings)\nNew sessions without an explicit /profile use start on it.`
|
|
4517
|
+
: `Default profile cleared (${scope} settings).`,
|
|
4518
|
+
"info",
|
|
4519
|
+
);
|
|
4520
|
+
updateStatus(ctx as any);
|
|
4521
|
+
return;
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
// Bare profile name → show detail (mirrors /role <name>).
|
|
4525
|
+
const named = getProfile(profilesConfig, sub);
|
|
4526
|
+
if (named) {
|
|
4527
|
+
ctx.ui.notify(describeProfile(named), "info");
|
|
4528
|
+
return;
|
|
4529
|
+
}
|
|
4530
|
+
ctx.ui.notify(PROFILE_USAGE, "info");
|
|
4531
|
+
},
|
|
4532
|
+
getArgumentCompletions: (prefix: string) => {
|
|
4533
|
+
ensureProfilesConfig();
|
|
4534
|
+
const subs = [
|
|
4535
|
+
{ value: "list", label: "list", description: "show all profiles" },
|
|
4536
|
+
{
|
|
4537
|
+
value: "use",
|
|
4538
|
+
label: "use <name|none>",
|
|
4539
|
+
description: "activate/deactivate for this session",
|
|
4540
|
+
},
|
|
4541
|
+
{
|
|
4542
|
+
value: "create",
|
|
4543
|
+
label: "create <name>",
|
|
4544
|
+
description: "create a profile (seeded from current models)",
|
|
4545
|
+
},
|
|
4546
|
+
{
|
|
4547
|
+
value: "edit",
|
|
4548
|
+
label: "edit <name>",
|
|
4549
|
+
description: "open the tabbed model editor",
|
|
4550
|
+
},
|
|
4551
|
+
{
|
|
4552
|
+
value: "rename",
|
|
4553
|
+
label: "rename <old> <new>",
|
|
4554
|
+
description: "rename a profile",
|
|
4555
|
+
},
|
|
4556
|
+
{
|
|
4557
|
+
value: "delete",
|
|
4558
|
+
label: "delete <name>",
|
|
4559
|
+
description: "delete a profile",
|
|
4560
|
+
},
|
|
4561
|
+
{
|
|
4562
|
+
value: "default",
|
|
4563
|
+
label: "default [name|none] [global|project]",
|
|
4564
|
+
description: "default profile for new sessions",
|
|
4565
|
+
},
|
|
4566
|
+
{
|
|
4567
|
+
value: "show",
|
|
4568
|
+
label: "show <name>",
|
|
4569
|
+
description: "show one profile's models",
|
|
4570
|
+
},
|
|
4571
|
+
];
|
|
4572
|
+
const lower = String(prefix || "").toLowerCase();
|
|
4573
|
+
if (lower.includes(" ")) {
|
|
4574
|
+
// Completing an argument after a subcommand → profile names where relevant.
|
|
4575
|
+
const cmd = lower.trim().split(/\s+/)[0];
|
|
4576
|
+
const wantsNames =
|
|
4577
|
+
cmd === "use" ||
|
|
4578
|
+
cmd === "edit" ||
|
|
4579
|
+
cmd === "delete" ||
|
|
4580
|
+
cmd === "show" ||
|
|
4581
|
+
cmd === "default";
|
|
4582
|
+
if (!wantsNames) return null;
|
|
4583
|
+
const last = lower.trim().split(/\s+/).pop() ?? "";
|
|
4584
|
+
const names = profilesConfig.profiles
|
|
4585
|
+
.filter((p) => p.name.toLowerCase().startsWith(last))
|
|
4586
|
+
.map((p) => ({
|
|
4587
|
+
value: p.name,
|
|
4588
|
+
label: p.name,
|
|
4589
|
+
description: p.description || undefined,
|
|
4590
|
+
}));
|
|
4591
|
+
return cmd === "use" || cmd === "default"
|
|
4592
|
+
? [
|
|
4593
|
+
...names,
|
|
4594
|
+
{ value: "none", label: "none", description: "no profile" },
|
|
4595
|
+
]
|
|
4596
|
+
: names;
|
|
4597
|
+
}
|
|
4598
|
+
const matching = subs.filter((s) => s.value.startsWith(lower));
|
|
4599
|
+
if (matching.length > 0) return matching;
|
|
4600
|
+
const names = profilesConfig.profiles
|
|
4601
|
+
.filter((p) => p.name.toLowerCase().startsWith(lower))
|
|
4602
|
+
.map((p) => ({
|
|
4603
|
+
value: p.name,
|
|
4604
|
+
label: p.name,
|
|
4605
|
+
description: p.description || undefined,
|
|
4606
|
+
}));
|
|
4607
|
+
return names.length ? names : null;
|
|
4608
|
+
},
|
|
4609
|
+
});
|
|
4610
|
+
|
|
4611
|
+
// ── Role config screen (tabbed picker: one block per role) ─────────
|
|
4612
|
+
//
|
|
4613
|
+
// One overlay with a tab per role (built-ins first, then customs) plus
|
|
4614
|
+
// a Submit tab. Each role tab has two phases: pick model (from the
|
|
4615
|
+
// connected providers — scoped models first, Ctrl+O for all) then pick
|
|
4616
|
+
// thinking level. Selections stage locally; Submit saves to roles.json.
|
|
4617
|
+
|
|
4618
|
+
interface StagedRoleModel {
|
|
4619
|
+
provider: string;
|
|
4620
|
+
id: string;
|
|
4621
|
+
thinking: ThinkingLevel;
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4624
|
+
interface RoleCatalogEntry {
|
|
4625
|
+
provider: string;
|
|
4626
|
+
id: string;
|
|
4627
|
+
inScope: boolean;
|
|
4628
|
+
}
|
|
4629
|
+
|
|
4630
|
+
function collectRoleCatalog(ctx: ExtensionContext): {
|
|
4631
|
+
scoped: RoleCatalogEntry[];
|
|
4632
|
+
all: RoleCatalogEntry[];
|
|
4633
|
+
truncated: boolean;
|
|
4634
|
+
} {
|
|
4635
|
+
const scoped: RoleCatalogEntry[] = [];
|
|
4636
|
+
try {
|
|
4637
|
+
const raw = ((ctx as any)?.scopedModels ?? []) as any[];
|
|
4638
|
+
for (const s of raw) {
|
|
4639
|
+
const m = s?.model ?? s;
|
|
4640
|
+
if (m && typeof m.provider === "string" && typeof m.id === "string")
|
|
4641
|
+
scoped.push({ provider: m.provider, id: m.id, inScope: true });
|
|
4642
|
+
}
|
|
4643
|
+
} catch (_e) {
|
|
4644
|
+
void _e;
|
|
4645
|
+
}
|
|
4646
|
+
let available: any[] = [];
|
|
4647
|
+
try {
|
|
4648
|
+
const reg = (ctx as any)?.modelRegistry;
|
|
4649
|
+
if (reg && typeof reg.getAvailable === "function")
|
|
4650
|
+
available = [...reg.getAvailable()];
|
|
4651
|
+
else if (reg && typeof reg.getAll === "function")
|
|
4652
|
+
available = [...reg.getAll()];
|
|
4653
|
+
} catch (_e) {
|
|
4654
|
+
void _e;
|
|
4655
|
+
}
|
|
4656
|
+
const seen = new Set(scoped.map((s) => `${s.provider}/${s.id}`));
|
|
4657
|
+
const rest: RoleCatalogEntry[] = [];
|
|
4658
|
+
for (const m of available) {
|
|
4659
|
+
if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
|
|
4660
|
+
continue;
|
|
4661
|
+
const key = `${m.provider}/${m.id}`;
|
|
4662
|
+
if (seen.has(key)) continue;
|
|
4663
|
+
seen.add(key);
|
|
4664
|
+
rest.push({ provider: m.provider, id: m.id, inScope: false });
|
|
4665
|
+
}
|
|
4666
|
+
const CAP = 200;
|
|
4667
|
+
const all = [...scoped, ...rest];
|
|
4668
|
+
return {
|
|
4669
|
+
scoped,
|
|
4670
|
+
all: all.slice(0, CAP),
|
|
4671
|
+
truncated: all.length > CAP,
|
|
4672
|
+
};
|
|
4673
|
+
}
|
|
4674
|
+
|
|
4675
|
+
function orderedRoleTabs(): Role[] {
|
|
4676
|
+
// Built-in roles first in their declared order, then custom roles.
|
|
4677
|
+
// Driven by BUILT_IN_ROLES so adding a role never needs a second edit here.
|
|
4678
|
+
const rank = (name: string) => {
|
|
4679
|
+
const i = (BUILT_IN_ROLES as readonly string[]).indexOf(
|
|
4680
|
+
name.toLowerCase(),
|
|
4681
|
+
);
|
|
4682
|
+
return i === -1 ? BUILT_IN_ROLES.length : i;
|
|
4683
|
+
};
|
|
4684
|
+
return [...roleConfig.roles].sort((a, b) => rank(a.name) - rank(b.name));
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4687
|
+
async function openRolePicker(ctx: ExtensionContext): Promise<void> {
|
|
4688
|
+
const tabs = orderedRoleTabs();
|
|
4689
|
+
if (tabs.length === 0) {
|
|
4690
|
+
ctx.ui.notify("No roles configured — /role reset to reseed.", "warning");
|
|
4691
|
+
return;
|
|
4692
|
+
}
|
|
4693
|
+
const catalog = collectRoleCatalog(ctx);
|
|
4694
|
+
if (catalog.all.length === 0) {
|
|
4695
|
+
ctx.ui.notify(
|
|
4696
|
+
"No models available from connected providers — connect a provider first, or use /role set <role> <provider/model> [thinking].",
|
|
4697
|
+
"warning",
|
|
4698
|
+
);
|
|
4699
|
+
return;
|
|
4700
|
+
}
|
|
4701
|
+
// Stage current values so the picker opens on the active selection.
|
|
4702
|
+
const staged = new Map<string, StagedRoleModel>();
|
|
4703
|
+
for (const r of tabs) staged.set(r.name, { ...r.model });
|
|
4704
|
+
|
|
4705
|
+
overlayActive = true;
|
|
4706
|
+
let saved: Map<string, StagedRoleModel> | null = null;
|
|
4707
|
+
try {
|
|
4708
|
+
saved = await ctx.ui.custom<Map<string, StagedRoleModel> | null>(
|
|
4709
|
+
(tui, theme, _kb, done) => {
|
|
4710
|
+
let currentTab = 0; // tabs.length = Submit
|
|
4711
|
+
let phase: "model" | "thinking" = "model";
|
|
4712
|
+
let showAll = catalog.scoped.length === 0;
|
|
4713
|
+
let cached: string[] | undefined;
|
|
4714
|
+
// One SelectList per role tab for models + one shared for thinking,
|
|
4715
|
+
// so filter text + cursor survive tab switches.
|
|
4716
|
+
const listTheme = {
|
|
4717
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
4718
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
4719
|
+
description: (t: string) => theme.fg("muted", t),
|
|
4720
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
4721
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
4722
|
+
};
|
|
4723
|
+
const currentRef = (role: Role) =>
|
|
4724
|
+
`${role.model.provider}/${role.model.id}`;
|
|
4725
|
+
const buildModelItems = (role: Role): SelectItem[] => {
|
|
4726
|
+
const st = staged.get(role.name)!;
|
|
4727
|
+
const cur = `${st.provider}/${st.id}`;
|
|
4728
|
+
const src = showAll ? catalog.all : catalog.scoped;
|
|
4729
|
+
return src.map((e) => {
|
|
2942
4730
|
const ref = `${e.provider}/${e.id}`;
|
|
2943
4731
|
let description: string | undefined;
|
|
2944
4732
|
if (ref === cur) {
|
|
@@ -3086,6 +4874,16 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3086
4874
|
return out;
|
|
3087
4875
|
};
|
|
3088
4876
|
lines.push(theme.fg("accent", "─".repeat(W)));
|
|
4877
|
+
// Profile notice: /role edits the BASE roles; an active profile
|
|
4878
|
+
// overlays models on top (see effectiveRoles).
|
|
4879
|
+
if (activeProfileName) {
|
|
4880
|
+
lines.push(
|
|
4881
|
+
...wrap(
|
|
4882
|
+
` ${theme.fg("warning", `Profile ⚡ ${activeProfileName} active — editing BASE roles; profile overrides apply on top. /profile edit ${activeProfileName} to change the profile.`)}`,
|
|
4883
|
+
),
|
|
4884
|
+
);
|
|
4885
|
+
lines.push("");
|
|
4886
|
+
}
|
|
3089
4887
|
// Tabs row: one block per role + Submit
|
|
3090
4888
|
const chips: string[] = [];
|
|
3091
4889
|
for (let i = 0; i < tabs.length; i++) {
|
|
@@ -3321,7 +5119,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3321
5119
|
// Tasks whose agent has no explicit model use the explorer role's
|
|
3322
5120
|
// single model. Telemetry lands in details.
|
|
3323
5121
|
ensureRoleConfig(cwd);
|
|
3324
|
-
|
|
5122
|
+
ensureProfilesConfig(cwd);
|
|
5123
|
+
// Active profile's explorer model when a profile is active.
|
|
5124
|
+
const explorerRole = getEffectiveRole("explorer");
|
|
3325
5125
|
const routedMeta: { role: string; model: string; reason: string }[] =
|
|
3326
5126
|
new Array(jobs.length);
|
|
3327
5127
|
const routedAgents: (AgentConfig | undefined)[] = new Array(jobs.length);
|
|
@@ -4353,10 +6153,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4353
6153
|
pi.on("session_start", async (event, ctx) => {
|
|
4354
6154
|
// Roles are file-backed; load them up-front so every read path (status,
|
|
4355
6155
|
// band label, reviewer runtime) sees the real config even in a fresh
|
|
4356
|
-
// session that has no persisted workflow entry yet.
|
|
6156
|
+
// session that has no persisted workflow entry yet. Profiles likewise.
|
|
4357
6157
|
try {
|
|
4358
6158
|
ensureRoleConfig((ctx as any)?.cwd);
|
|
4359
6159
|
notifyRoleConfigFallback(ctx as ExtensionContext);
|
|
6160
|
+
ensureProfilesConfig((ctx as any)?.cwd);
|
|
6161
|
+
notifyProfilesFallback(ctx as ExtensionContext);
|
|
4360
6162
|
} catch (_e) {
|
|
4361
6163
|
void _e;
|
|
4362
6164
|
}
|
|
@@ -4386,13 +6188,42 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4386
6188
|
toolsBeforePlanMode = d.toolsBeforePlanMode;
|
|
4387
6189
|
currentPlanFile = d.planFile;
|
|
4388
6190
|
// restore role state (file config is source of truth for roles;
|
|
4389
|
-
// session entry carries the latest activeRole)
|
|
6191
|
+
// session entry carries the latest activeRole) and the session's
|
|
6192
|
+
// own profile activation
|
|
4390
6193
|
try {
|
|
4391
6194
|
ensureRoleConfig((ctx as any)?.cwd);
|
|
6195
|
+
ensureProfilesConfig((ctx as any)?.cwd);
|
|
4392
6196
|
const rd = (d as any).role;
|
|
4393
6197
|
if (rd && typeof rd === "object") {
|
|
4394
6198
|
if (typeof rd.activeRole === "string" || rd.activeRole === null)
|
|
4395
6199
|
roleConfig.activeRole = rd.activeRole;
|
|
6200
|
+
// Profiles: an explicit pick restores exactly (name or none);
|
|
6201
|
+
// entries without an explicit choice (fresh / pre-profile /
|
|
6202
|
+
// default-seeded) re-derive workflow.defaultProfile below so
|
|
6203
|
+
// a changed default still applies on resume.
|
|
6204
|
+
profileExplicit = rd.profileExplicit === true;
|
|
6205
|
+
if (profileExplicit) {
|
|
6206
|
+
activeProfileName =
|
|
6207
|
+
typeof rd.activeProfile === "string"
|
|
6208
|
+
? rd.activeProfile
|
|
6209
|
+
: null;
|
|
6210
|
+
if (
|
|
6211
|
+
activeProfileName &&
|
|
6212
|
+
!getProfile(profilesConfig, activeProfileName)
|
|
6213
|
+
) {
|
|
6214
|
+
try {
|
|
6215
|
+
(ctx as any).ui?.notify?.(
|
|
6216
|
+
`Profiles: "${activeProfileName}" no longer exists — default roles flow active.`,
|
|
6217
|
+
"warning",
|
|
6218
|
+
);
|
|
6219
|
+
} catch (_e) {
|
|
6220
|
+
void _e;
|
|
6221
|
+
}
|
|
6222
|
+
activeProfileName = null;
|
|
6223
|
+
}
|
|
6224
|
+
} else {
|
|
6225
|
+
activeProfileName = null;
|
|
6226
|
+
}
|
|
4396
6227
|
}
|
|
4397
6228
|
} catch (_e) {
|
|
4398
6229
|
void _e;
|
|
@@ -4474,6 +6305,39 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4474
6305
|
} catch (_e) {
|
|
4475
6306
|
void _e;
|
|
4476
6307
|
}
|
|
6308
|
+
// Default-profile seeding: sessions without an explicit choice follow
|
|
6309
|
+
// workflow.defaultProfile, re-read at every start/resume so a changed
|
|
6310
|
+
// default applies. Sessions that picked explicitly (or explicitly chose
|
|
6311
|
+
// none) never see the default.
|
|
6312
|
+
if (!profileExplicit) {
|
|
6313
|
+
try {
|
|
6314
|
+
const def = readDefaultProfileSetting(
|
|
6315
|
+
(ctx as any).cwd ?? process.cwd(),
|
|
6316
|
+
getAgentDir(),
|
|
6317
|
+
).value;
|
|
6318
|
+
if (def) {
|
|
6319
|
+
ensureProfilesConfig((ctx as any)?.cwd);
|
|
6320
|
+
const p = getProfile(profilesConfig, def);
|
|
6321
|
+
if (p) {
|
|
6322
|
+
if (activeProfileName !== p.name) {
|
|
6323
|
+
activeProfileName = p.name;
|
|
6324
|
+
invalidateEffectiveRoles();
|
|
6325
|
+
}
|
|
6326
|
+
} else {
|
|
6327
|
+
try {
|
|
6328
|
+
(ctx as any).ui?.notify?.(
|
|
6329
|
+
`Profiles: default profile "${def}" not found in profiles.json — skipping.`,
|
|
6330
|
+
"warning",
|
|
6331
|
+
);
|
|
6332
|
+
} catch (_e) {
|
|
6333
|
+
void _e;
|
|
6334
|
+
}
|
|
6335
|
+
}
|
|
6336
|
+
}
|
|
6337
|
+
} catch (_e) {
|
|
6338
|
+
void _e;
|
|
6339
|
+
}
|
|
6340
|
+
}
|
|
4477
6341
|
// auto-enable plan mode if flag set at startup (support both --plan and --workflow-plan)
|
|
4478
6342
|
const flagPlan =
|
|
4479
6343
|
(pi as any).getFlagValue?.("plan") ??
|
|
@@ -4594,6 +6458,118 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4594
6458
|
updateStatus(ctx as any);
|
|
4595
6459
|
});
|
|
4596
6460
|
|
|
6461
|
+
// ── Commit policy gate (workflow.commitPolicy) ──────────────────
|
|
6462
|
+
|
|
6463
|
+
/**
|
|
6464
|
+
* Deterministic enforcement of the user's commit policy. Returns a block
|
|
6465
|
+
* result for `git add` / `git commit` / `git push` per the active mode, or
|
|
6466
|
+
* undefined when the command may proceed:
|
|
6467
|
+
* never → block; ask → confirmation overlay (headless: fail closed);
|
|
6468
|
+
* allow → automatic per-step commits while a plan todo list is active
|
|
6469
|
+
* (the extension owns commit timing — model-initiated git add/commit/push
|
|
6470
|
+
* are blocked); outside a plan the model may commit as before. The
|
|
6471
|
+
* user-invoked /commit flow arms `commitBypassActive` so its own `git
|
|
6472
|
+
* commit` runs (staging is extension-side; `push` and `add` are never
|
|
6473
|
+
* bypassed).
|
|
6474
|
+
*/
|
|
6475
|
+
async function commitPolicyGate(
|
|
6476
|
+
event: any,
|
|
6477
|
+
ctx: any,
|
|
6478
|
+
cwd: string,
|
|
6479
|
+
): Promise<{ block: true; reason: string } | undefined> {
|
|
6480
|
+
const policy = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
|
|
6481
|
+
// allow outside a plan: no per-step automation to own commit timing, so
|
|
6482
|
+
// the model keeps its previous freedom.
|
|
6483
|
+
if (policy === "allow" && todoItems.length === 0) return undefined;
|
|
6484
|
+
const cmd = String(event?.input?.command ?? "");
|
|
6485
|
+
const hit = gitCommitPolicyHit(cmd);
|
|
6486
|
+
if (!hit) return undefined;
|
|
6487
|
+
// `add` is only gated in `never` mode; `ask` prompts on commit/push only
|
|
6488
|
+
// (staging alone pollutes nothing).
|
|
6489
|
+
if (policy === "ask" && hit === "add") return undefined;
|
|
6490
|
+
// /commit bypass: only the flow's own `git commit` is exempt — `add` and
|
|
6491
|
+
// `push` are not.
|
|
6492
|
+
if (commitBypassActive && hit === "commit") {
|
|
6493
|
+
return undefined;
|
|
6494
|
+
}
|
|
6495
|
+
|
|
6496
|
+
if (policy === "never" || policy === "allow") {
|
|
6497
|
+
return {
|
|
6498
|
+
block: true,
|
|
6499
|
+
reason:
|
|
6500
|
+
policy === "never"
|
|
6501
|
+
? `Commit policy is 'never' (workflow.commitPolicy): \`git ${hit}\` is blocked. ` +
|
|
6502
|
+
"Finish your steps and leave changes uncommitted for user review — the user commits manually or runs /commit. " +
|
|
6503
|
+
`Change the policy with /commit-policy allow.\nCommand: ${cmd}`
|
|
6504
|
+
: `Commit policy is 'allow' with an active plan: a commit is made AUTOMATICALLY after each completed step — do not run \`git ${hit}\` yourself. ` +
|
|
6505
|
+
"Finish the step, mark it done via workflow_todo, and continue; the extension commits it (exactly one commit per step). " +
|
|
6506
|
+
"/commit always works for user-invoked commits.\nCommand: " +
|
|
6507
|
+
cmd,
|
|
6508
|
+
};
|
|
6509
|
+
}
|
|
6510
|
+
// policy === "ask" → confirmation overlay. Headless has no one to ask →
|
|
6511
|
+
// fail closed rather than commit unbidden.
|
|
6512
|
+
const ui = ctx?.ui;
|
|
6513
|
+
if (!ui || !ctx.hasUI) {
|
|
6514
|
+
return {
|
|
6515
|
+
block: true,
|
|
6516
|
+
reason:
|
|
6517
|
+
`Commit policy is 'ask' (workflow.commitPolicy): \`git ${hit}\` needs user approval, ` +
|
|
6518
|
+
"but no interactive UI is available. Leave the change uncommitted and let the user decide.",
|
|
6519
|
+
};
|
|
6520
|
+
}
|
|
6521
|
+
let sel: unknown;
|
|
6522
|
+
try {
|
|
6523
|
+
sel = await ui.select(
|
|
6524
|
+
`Commit policy 'ask': allow \`git ${hit}\`?`,
|
|
6525
|
+
["Allow once", "Deny"],
|
|
6526
|
+
);
|
|
6527
|
+
} catch {
|
|
6528
|
+
sel = undefined;
|
|
6529
|
+
}
|
|
6530
|
+
if (String(sel ?? "").startsWith("Allow")) return undefined;
|
|
6531
|
+
return {
|
|
6532
|
+
block: true,
|
|
6533
|
+
reason:
|
|
6534
|
+
`Commit policy 'ask': the user declined \`git ${hit}\`. Do not retry — ` +
|
|
6535
|
+
"leave the changes uncommitted for the user.",
|
|
6536
|
+
};
|
|
6537
|
+
}
|
|
6538
|
+
|
|
6539
|
+
/**
|
|
6540
|
+
* Policy-aware commit directive appended to the plan-execute handoff
|
|
6541
|
+
* message. Empty string when the policy cannot be read (a config hiccup
|
|
6542
|
+
* never blocks the handoff itself).
|
|
6543
|
+
*/
|
|
6544
|
+
function commitDirectiveLine(ctx: any): string {
|
|
6545
|
+
try {
|
|
6546
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
6547
|
+
const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
|
|
6548
|
+
switch (mode) {
|
|
6549
|
+
case "never":
|
|
6550
|
+
return (
|
|
6551
|
+
"\n\nCommit policy is 'never' (workflow.commitPolicy): do NOT run `git add`, `git commit`, or `git push`. " +
|
|
6552
|
+
"Complete all steps and leave every change uncommitted for user review — the user commits manually or via /commit."
|
|
6553
|
+
);
|
|
6554
|
+
case "ask":
|
|
6555
|
+
return (
|
|
6556
|
+
"\n\nCommit policy is 'ask' (workflow.commitPolicy): you may commit after each completed step " +
|
|
6557
|
+
"(`git add` + `git commit` with a concise conventional message); each `git commit`/`git push` will prompt the user for approval."
|
|
6558
|
+
);
|
|
6559
|
+
case "allow":
|
|
6560
|
+
return (
|
|
6561
|
+
"\n\nCommit policy is 'allow' (workflow.commitPolicy): do NOT run `git add`, `git commit`, or `git push` yourself — " +
|
|
6562
|
+
"after each completed step the extension automatically makes exactly ONE commit containing the changes so far. " +
|
|
6563
|
+
"Just finish a step, mark it done via workflow_todo, and move to the next step."
|
|
6564
|
+
);
|
|
6565
|
+
default:
|
|
6566
|
+
return "";
|
|
6567
|
+
}
|
|
6568
|
+
} catch {
|
|
6569
|
+
return "";
|
|
6570
|
+
}
|
|
6571
|
+
}
|
|
6572
|
+
|
|
4597
6573
|
// Block edits/writes + bash gating
|
|
4598
6574
|
pi.on("tool_call", async (event, ctx) => {
|
|
4599
6575
|
const cwd = (ctx as ExtensionContext).cwd;
|
|
@@ -4652,6 +6628,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4652
6628
|
} as any;
|
|
4653
6629
|
}
|
|
4654
6630
|
}
|
|
6631
|
+
// ── Commit policy gate ── Independent of the role gate: it also governs
|
|
6632
|
+
// the unrestricted builder role — that is the point (user-controlled
|
|
6633
|
+
// commit behavior). Plan mode is excluded: planner gating already blocks
|
|
6634
|
+
// every mutating git command. Covers bash and powershell so a powershell
|
|
6635
|
+
// wrapper cannot bypass the policy.
|
|
6636
|
+
if (
|
|
6637
|
+
(event.toolName === "bash" || event.toolName === "powershell") &&
|
|
6638
|
+
workflowMode !== "plan"
|
|
6639
|
+
) {
|
|
6640
|
+
const policyBlock = await commitPolicyGate(event, ctx, cwd);
|
|
6641
|
+
if (policyBlock) return policyBlock as any;
|
|
6642
|
+
}
|
|
4655
6643
|
if (
|
|
4656
6644
|
(event.toolName === "edit" || event.toolName === "write") &&
|
|
4657
6645
|
workflowMode === "plan"
|
|
@@ -4735,6 +6723,104 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4735
6723
|
});
|
|
4736
6724
|
|
|
4737
6725
|
// ── Checkpoint lifecycle: track leaf entryId and prompt header ─
|
|
6726
|
+
// ── Deterministic per-step commit executor (allow mode) ──────────
|
|
6727
|
+
|
|
6728
|
+
/** Mark pre-existing done steps (session resume, re-executed plan) as already accounted for. Called once per session. */
|
|
6729
|
+
function seedCommittedStepRefs(): void {
|
|
6730
|
+
if (stepCommitsSeeded) return;
|
|
6731
|
+
stepCommitsSeeded = true;
|
|
6732
|
+
for (const t of todoItems) {
|
|
6733
|
+
if (!isDone(t) || itemStatus(t) === "cancelled") continue;
|
|
6734
|
+
committedStepRefs.add(todoRef(t));
|
|
6735
|
+
}
|
|
6736
|
+
}
|
|
6737
|
+
|
|
6738
|
+
/** Done steps that have not been committed yet (in canonical order). */
|
|
6739
|
+
function uncommittedDoneSteps(): StepCommitCandidate[] {
|
|
6740
|
+
const out: StepCommitCandidate[] = [];
|
|
6741
|
+
for (const t of todoItems) {
|
|
6742
|
+
if (!isDone(t) || itemStatus(t) === "cancelled") continue;
|
|
6743
|
+
const ref = todoRef(t);
|
|
6744
|
+
if (committedStepRefs.has(ref)) continue;
|
|
6745
|
+
out.push({ ref, step: t.step, label: t.label, text: t.text });
|
|
6746
|
+
}
|
|
6747
|
+
return out;
|
|
6748
|
+
}
|
|
6749
|
+
|
|
6750
|
+
/**
|
|
6751
|
+
* Make the automatic per-step commit when the policy calls for it.
|
|
6752
|
+
* Deterministic: driven by the step-completion signal (workflow_todo
|
|
6753
|
+
* tool_result / [DONE:ref] tags), never by model whimsy; the committed
|
|
6754
|
+
* ref set makes it exactly-once per step. Never throws — a commit hiccup
|
|
6755
|
+
* must not break the run.
|
|
6756
|
+
*/
|
|
6757
|
+
async function autoCommitCompletedSteps(
|
|
6758
|
+
ctx: any,
|
|
6759
|
+
trigger: string,
|
|
6760
|
+
): Promise<void> {
|
|
6761
|
+
try {
|
|
6762
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
6763
|
+
if (!isGitRepoSync(cwd)) return;
|
|
6764
|
+
seedCommittedStepRefs();
|
|
6765
|
+
const completed = uncommittedDoneSteps();
|
|
6766
|
+
const dirty = await isDirty(pi as any, cwd);
|
|
6767
|
+
const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
|
|
6768
|
+
const decision = stepCommitDecision({
|
|
6769
|
+
mode,
|
|
6770
|
+
hasTodos: todoItems.length > 0,
|
|
6771
|
+
dirty,
|
|
6772
|
+
isInRepo: true,
|
|
6773
|
+
completed,
|
|
6774
|
+
});
|
|
6775
|
+
if (!decision.commit) return;
|
|
6776
|
+
const addRes = await piExecGit(["add", "-A"], cwd);
|
|
6777
|
+
if (addRes.exitCode !== 0) {
|
|
6778
|
+
try {
|
|
6779
|
+
ctx?.ui?.notify?.(
|
|
6780
|
+
`Per-step commit failed (git add): ${addRes.stderr.trim()}`,
|
|
6781
|
+
"error",
|
|
6782
|
+
);
|
|
6783
|
+
} catch (_e) {
|
|
6784
|
+
void _e;
|
|
6785
|
+
}
|
|
6786
|
+
return;
|
|
6787
|
+
}
|
|
6788
|
+
const commitRes = await piExecGit(["commit", "-m", decision.message], cwd);
|
|
6789
|
+
if (commitRes.exitCode !== 0) {
|
|
6790
|
+
try {
|
|
6791
|
+
ctx?.ui?.notify?.(
|
|
6792
|
+
`Per-step commit failed: ${commitRes.stderr.trim()}`,
|
|
6793
|
+
"error",
|
|
6794
|
+
);
|
|
6795
|
+
} catch (_e) {
|
|
6796
|
+
void _e;
|
|
6797
|
+
}
|
|
6798
|
+
return;
|
|
6799
|
+
}
|
|
6800
|
+
for (const ref of decision.refs) committedStepRefs.add(ref);
|
|
6801
|
+
try {
|
|
6802
|
+
ctx?.ui?.notify?.(
|
|
6803
|
+
`Committed ${decision.refs.join(", ")} — ${decision.message}`,
|
|
6804
|
+
"info",
|
|
6805
|
+
);
|
|
6806
|
+
} catch (_e) {
|
|
6807
|
+
void _e;
|
|
6808
|
+
}
|
|
6809
|
+
try {
|
|
6810
|
+
pi.appendEntry("workflow-step-commit", {
|
|
6811
|
+
refs: decision.refs,
|
|
6812
|
+
message: decision.message,
|
|
6813
|
+
trigger,
|
|
6814
|
+
at: Date.now(),
|
|
6815
|
+
});
|
|
6816
|
+
} catch (_e) {
|
|
6817
|
+
void _e;
|
|
6818
|
+
}
|
|
6819
|
+
} catch {
|
|
6820
|
+
// never break the agent run on a commit hiccup
|
|
6821
|
+
}
|
|
6822
|
+
}
|
|
6823
|
+
|
|
4738
6824
|
pi.on("tool_result", async (_event, ctx) => {
|
|
4739
6825
|
try {
|
|
4740
6826
|
const leaf =
|
|
@@ -4746,8 +6832,30 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4746
6832
|
}
|
|
4747
6833
|
});
|
|
4748
6834
|
|
|
6835
|
+
// Per-step commit trigger: the runner AWAITS tool_result handlers, so the
|
|
6836
|
+
// commit lands after the step is marked done and BEFORE the model starts
|
|
6837
|
+
// the next step — exactly one commit per step, at the moment of completion.
|
|
6838
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
6839
|
+
try {
|
|
6840
|
+
const ev = event as any;
|
|
6841
|
+
if (ev?.toolName !== "workflow_todo") return;
|
|
6842
|
+
const action = String(ev?.input?.action ?? ev?.details?.action ?? "");
|
|
6843
|
+
if (!action || action === "list" || action === "clear") return;
|
|
6844
|
+
await autoCommitCompletedSteps(ctx, `workflow_todo:${action}`);
|
|
6845
|
+
} catch (_e) {
|
|
6846
|
+
void _e;
|
|
6847
|
+
}
|
|
6848
|
+
});
|
|
6849
|
+
|
|
4749
6850
|
pi.on("turn_start", async (_event, ctx) => {
|
|
4750
6851
|
footerInjectedThisTurn = false;
|
|
6852
|
+
// Seed the per-step commit ledger once per session: steps already done
|
|
6853
|
+
// (restored sessions, re-executed plans) never trigger a spurious commit.
|
|
6854
|
+
try {
|
|
6855
|
+
seedCommittedStepRefs();
|
|
6856
|
+
} catch (_e) {
|
|
6857
|
+
void _e;
|
|
6858
|
+
}
|
|
4751
6859
|
if (!currentEntryId) return;
|
|
4752
6860
|
const cwd = (ctx as any).cwd as string;
|
|
4753
6861
|
try {
|
|
@@ -4778,6 +6886,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4778
6886
|
});
|
|
4779
6887
|
|
|
4780
6888
|
pi.on("agent_settled", async () => {
|
|
6889
|
+
// Belt-and-braces: the run is over (completed, aborted or errored) — the
|
|
6890
|
+
// /commit bypass must not leak into whatever runs next.
|
|
6891
|
+
commitBypassActive = false;
|
|
4781
6892
|
// keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
|
|
4782
6893
|
});
|
|
4783
6894
|
|
|
@@ -4956,10 +7067,24 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4956
7067
|
|
|
4957
7068
|
// Track progress (all modes, whenever todos exist)
|
|
4958
7069
|
pi.on("turn_end", async (event, ctx) => {
|
|
7070
|
+
// Disarm the /commit bypass as soon as any turn ends: the /commit flow's
|
|
7071
|
+
// `git commit` runs as a tool call WITHIN its turn, so this never disarms
|
|
7072
|
+
// too early — and nothing after the turn inherits the exemption.
|
|
7073
|
+
commitBypassActive = false;
|
|
4959
7074
|
if (todoItems.length === 0) return;
|
|
4960
7075
|
if (!isAssistantMessage(event.message as AgentMessage)) return;
|
|
4961
7076
|
const text = getTextContent(event.message as AssistantMessage);
|
|
4962
7077
|
const result = markCompletedRefs(text, todoItems);
|
|
7078
|
+
// Fallback per-step commit for steps completed via [DONE:ref] tags in the
|
|
7079
|
+
// response text (the workflow_todo path already committed at its
|
|
7080
|
+
// tool_result; the committed-ref set makes this a no-op then).
|
|
7081
|
+
if (result.marked > 0) {
|
|
7082
|
+
try {
|
|
7083
|
+
await autoCommitCompletedSteps(ctx, "turn_end:done-tags");
|
|
7084
|
+
} catch (_e) {
|
|
7085
|
+
void _e;
|
|
7086
|
+
}
|
|
7087
|
+
}
|
|
4963
7088
|
if (result.unknown.length > 0 || result.ambiguous.length > 0) {
|
|
4964
7089
|
lastRefMisses = { refs: [...result.unknown, ...result.ambiguous] };
|
|
4965
7090
|
try {
|
|
@@ -5516,7 +7641,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5516
7641
|
const list = formatTodoNumberedList(todoItems);
|
|
5517
7642
|
const content = plannotatorActive
|
|
5518
7643
|
? `**Plan ready** (${todoItems.length} steps)\\n\\nTo review in browser, ensure SSH port forwarding is active:\\n\`\`\`\\nLocalForward 9999 localhost:9999\\n\`\`\`\\nThen set \`PLANNOTATOR_REMOTE=1\` and \`PLANNOTATOR_PORT=9999\`.\\n\\nOr type "Execute the plan" to build directly.\\n\\nRemaining:\\n${list}`
|
|
5519
|
-
: `**Plan ready** (${todoItems.length} steps)\\n\\n${preview.slice(0, 500)}...\\n\\nRemaining:\\n${list}\\n\\nType "Execute the plan" to build, or tell me what to refine
|
|
7644
|
+
: `**Plan ready** (${todoItems.length} steps)\\n\\n${preview.slice(0, 500)}...\\n\\nRemaining:\\n${list}\\n\\nType "Execute the plan" to build, or tell me what to refine.${commitDirectiveLine(ctx)}`;
|
|
5520
7645
|
(pi as any).sendMessage?.(
|
|
5521
7646
|
{
|
|
5522
7647
|
customType: "workflow-handoff-fallback",
|
|
@@ -5932,7 +8057,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5932
8057
|
display: true,
|
|
5933
8058
|
};
|
|
5934
8059
|
const firstTodo = todoItems[0];
|
|
5935
|
-
const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : todoItems[0].text}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
|
|
8060
|
+
const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : todoItems[0].text}${commitDirectiveLine(ctx)}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
|
|
5936
8061
|
(pi as any).sendMessage?.(planTodoListMessage, {
|
|
5937
8062
|
deliverAs: "followUp",
|
|
5938
8063
|
});
|