pi-goal-list-loop-audit 0.27.9 → 0.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -156,6 +156,12 @@ export interface Goal {
|
|
|
156
156
|
stopReason?: string;
|
|
157
157
|
pauseReason?: string;
|
|
158
158
|
pauseSuggestedAction?: string;
|
|
159
|
+
/** v0.28.1 (S1/S2): stale-handle interrupt marker. Set INSTEAD of pausing
|
|
160
|
+
* when pi invalidates the extension handle mid-goal — the goal stays
|
|
161
|
+
* active so a fresh session auto-resumes it via the restore gate. Cleared
|
|
162
|
+
* on that auto-resume. */
|
|
163
|
+
interruptedAt?: string;
|
|
164
|
+
interruptedReason?: string;
|
|
159
165
|
/** v0.25.0 (contract item 22): auditor objections extracted as TODOs when
|
|
160
166
|
* aggressiveMode keeps the goal active past the disapproval cap. Rendered
|
|
161
167
|
* into every continuation prompt until the next audit clears them. */
|
|
@@ -137,6 +137,11 @@ export function buildStatusText(state: State, audit?: AuditDisplayProgress | nul
|
|
|
137
137
|
return `glla: ${paint(theme, pauseIsError(g) ? "error" : "warning", label)}`;
|
|
138
138
|
}
|
|
139
139
|
if (g.status === "active") {
|
|
140
|
+
// v0.28.1 (S1/S2): a stale-handle interrupt keeps the goal ACTIVE (the
|
|
141
|
+
// next fresh session auto-resumes it) — say so instead of looking healthy.
|
|
142
|
+
if (g.interruptedAt) {
|
|
143
|
+
return `glla: ${g.policy} ${paint(theme, "error", "⚠ interrupted — stale handle · auto-resumes on pi restart")}`;
|
|
144
|
+
}
|
|
140
145
|
// v0.24.7: list policy gets its own wording — a queue item is not a goal.
|
|
141
146
|
// Before: "glla: list ● 3m 19s · list 29" (policy label AND queue counter
|
|
142
147
|
// both said "list"). After: "glla: list ● 3m 19s · 29 queued". Goal
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -141,6 +141,11 @@ import {
|
|
|
141
141
|
syncSubagentModelOverrides,
|
|
142
142
|
type SubagentModelStrategy,
|
|
143
143
|
} from "../goal-loop-subagents.js";
|
|
144
|
+
import {
|
|
145
|
+
buildSettingsRows,
|
|
146
|
+
SettingsMenuComponent,
|
|
147
|
+
type SettingsRow,
|
|
148
|
+
} from "../settings-menu.js";
|
|
144
149
|
import {
|
|
145
150
|
applyMeasurement,
|
|
146
151
|
applyMetriclessTick,
|
|
@@ -193,24 +198,57 @@ let extensionApi: ExtensionAPI | null = null;
|
|
|
193
198
|
// failure shape. Detect the stale signature once and go terminally loud.
|
|
194
199
|
let extensionApiStale = false;
|
|
195
200
|
|
|
196
|
-
/** v0.26.7: a stale api is terminal for this process —
|
|
197
|
-
*
|
|
201
|
+
/** v0.26.7: a stale api is terminal for this process — go loudly with
|
|
202
|
+
* restart guidance instead of retrying sends that can never land.
|
|
203
|
+
* v0.28.1 (S1/S2): goals STAY ACTIVE with an interrupt marker instead of
|
|
204
|
+
* pausing — the restore gate only auto-resumes ACTIVE goals, so pausing
|
|
205
|
+
* here stranded goals until manual /goal resume (hegemon/sraaal shape).
|
|
206
|
+
* sendContinuation's extensionApiStale guard already stops further sends
|
|
207
|
+
* in this doomed process; the next fresh session auto-resumes. */
|
|
198
208
|
function goStaleTerminal(ctx: ExtensionContext, where: string): void {
|
|
199
209
|
if (extensionApiStale) return; // already terminal — don't re-spam
|
|
200
210
|
extensionApiStale = true;
|
|
201
211
|
appendLedger(ctx.cwd, "extension_api_stale", { where, kind: isLoopActive() ? "loop" : "goal" });
|
|
202
|
-
const guidance = "pi invalidated this session's extension handle (session replacement — compaction triggers it in pi 0.82.x). Sends can never land in this process. Restart pi (or reload extensions)
|
|
212
|
+
const guidance = "pi invalidated this session's extension handle (session replacement — compaction triggers it in pi 0.82.x). Sends can never land in this process. Restart pi (or reload extensions) — an active goal auto-resumes on the fresh session; loops need /loop start.";
|
|
203
213
|
if (isLoopActive()) {
|
|
204
214
|
clearLoopTimer();
|
|
205
215
|
state.loop = { ...state.loop!, active: false, stopReason: `extension api stale: ${guidance}` };
|
|
206
216
|
persistState(ctx);
|
|
207
217
|
} else if (state.goal && state.goal.status === "active") {
|
|
208
|
-
updateGoal({
|
|
218
|
+
updateGoal({ interruptedAt: nowIso(), interruptedReason: `extension api stale (${where})` }, ctx);
|
|
209
219
|
}
|
|
210
220
|
ctx.ui.notify(`glla: ${guidance}`, "warning");
|
|
211
221
|
notifyExternal(ctx, `glla: extension api stale — restart pi. (${where})`);
|
|
212
222
|
}
|
|
213
223
|
|
|
224
|
+
/** v0.28.1 (S3): side-effect-free staleness probe — getSessionName()
|
|
225
|
+
* routes through pi's assertActive() and throws the stale signature iff
|
|
226
|
+
* pi invalidated this factory handle (session replacement). A positive
|
|
227
|
+
* result is cached in extensionApiStale. */
|
|
228
|
+
function probeExtensionApiStale(): boolean {
|
|
229
|
+
if (extensionApiStale) return true;
|
|
230
|
+
if (!extensionApi) return false;
|
|
231
|
+
try {
|
|
232
|
+
extensionApi.getSessionName();
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if (isStaleApiError(err)) extensionApiStale = true;
|
|
235
|
+
}
|
|
236
|
+
return extensionApiStale;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** v0.28.1 (S3): command-entry staleness probe + honest warning. Returns
|
|
240
|
+
* true when the handle is stale — callers must skip send-dependent paths
|
|
241
|
+
* and must NOT claim work started (S3's "created — starting now" lie). */
|
|
242
|
+
function warnIfStaleAtEntry(ctx: ExtensionContext, what: string): boolean {
|
|
243
|
+
if (!probeExtensionApiStale()) return false;
|
|
244
|
+
appendLedger(ctx.cwd, "extension_api_stale", { where: `entry probe (${what})` });
|
|
245
|
+
ctx.ui.notify(
|
|
246
|
+
`glla: this session's extension handle is stale (pi session replacement) — ${what} can't send continuations in this process. State is safe in .pi-glla/ — restart pi and the active goal auto-resumes.`,
|
|
247
|
+
"warning",
|
|
248
|
+
);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
214
252
|
// The most recent ExtensionContext seen from any event or command handler.
|
|
215
253
|
// pi replaces sessions (newSession/fork/reload) and stale ctx throws on use,
|
|
216
254
|
// so timers must never capture a ctx — they read lastCtx at fire time.
|
|
@@ -837,8 +875,18 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
|
|
|
837
875
|
draftingUserReplies = 0;
|
|
838
876
|
draftingBlockedProposals = 0;
|
|
839
877
|
draftingSeedInFlight = true; // our injected prompt also arrives as a user message — don't count it
|
|
840
|
-
} catch {
|
|
878
|
+
} catch (err) {
|
|
841
879
|
draftingTarget = null;
|
|
880
|
+
// v0.28.1 (E6): the seed send used to fail SILENTLY — the user pressed
|
|
881
|
+
// Enter on /goal and nothing happened. Now: loud, and stale handles get
|
|
882
|
+
// the honest restart guidance.
|
|
883
|
+
if (isStaleApiError(err)) {
|
|
884
|
+
extensionApiStale = true;
|
|
885
|
+
appendLedger(ctx.cwd, "extension_api_stale", { where: "startDrafting seed" });
|
|
886
|
+
ctx.ui.notify("glla: can't start the drafting interview — this session's extension handle is stale (pi session replacement). Restart pi and re-run the command.", "warning");
|
|
887
|
+
} else {
|
|
888
|
+
ctx.ui.notify(`glla: couldn't start the drafting interview (${err instanceof Error ? err.message : String(err)}) — try again.`, "warning");
|
|
889
|
+
}
|
|
842
890
|
}
|
|
843
891
|
}
|
|
844
892
|
|
|
@@ -875,6 +923,10 @@ async function cmdGoal(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
875
923
|
// =================================================================
|
|
876
924
|
|
|
877
925
|
async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): Promise<void> {
|
|
926
|
+
// v0.28.1 (S3): probe at the creation entry — no "created — starting now"
|
|
927
|
+
// lie in a doomed process. (The draft path's seed send has its own loud
|
|
928
|
+
// stale handling — E6.)
|
|
929
|
+
const staleEntry = warnIfStaleAtEntry(ctx, "/goal");
|
|
878
930
|
let raw = args.trim();
|
|
879
931
|
// Users naturally quote the objective ("/goal \"do X\""); strip one layer of
|
|
880
932
|
// surrounding matching quotes so they don't leak into the goal text.
|
|
@@ -904,6 +956,13 @@ async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): P
|
|
|
904
956
|
iterationCounter = 0;
|
|
905
957
|
consecutiveErrorIterations = 0;
|
|
906
958
|
consecutiveNoToolIterations = 0;
|
|
959
|
+
if (staleEntry) {
|
|
960
|
+
// v0.28.1 (S3): the goal is persisted — mark the interrupt so the next
|
|
961
|
+
// fresh session auto-resumes, and tell the truth instead of "starting now".
|
|
962
|
+
updateGoal({ interruptedAt: nowIso(), interruptedReason: "created in a stale session" }, ctx);
|
|
963
|
+
ctx.ui.notify(`Goal ${goal.id} created and safe in .pi-glla/ — this stale process can't send continuations. Restart pi and it auto-resumes.`, "warning");
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
907
966
|
ctx.ui.notify(`Goal ${goal.id} created — starting now. Auditor will verify on completion.`, "info");
|
|
908
967
|
scheduleContinuation(ctx, true);
|
|
909
968
|
}
|
|
@@ -944,6 +1003,12 @@ async function cmdPause(ctx: ExtensionContext): Promise<void> {
|
|
|
944
1003
|
|
|
945
1004
|
async function cmdResume(ctx: ExtensionContext): Promise<void> {
|
|
946
1005
|
if (!state.goal || state.goal.status !== "paused") return;
|
|
1006
|
+
// v0.28.1 (S1/S3): resuming in a stale session used to flip status to
|
|
1007
|
+
// active, claim "Resumed goal", then re-pause on the stale send failure
|
|
1008
|
+
// (or zombie — S1). Now: persist the resume (the next fresh session
|
|
1009
|
+
// auto-resumes ACTIVE goals), mark the interrupt, tell the truth, and
|
|
1010
|
+
// skip the send that can never land.
|
|
1011
|
+
const staleEntry = warnIfStaleAtEntry(ctx, "/goal resume");
|
|
947
1012
|
// v0.12.0: refresh the token cap from CURRENT settings on resume — goals
|
|
948
1013
|
// snapshot the cap at creation, so a goal paused under an old default
|
|
949
1014
|
// (e.g. 10M) would re-pause instantly even after the default changed.
|
|
@@ -951,7 +1016,8 @@ async function cmdResume(ctx: ExtensionContext): Promise<void> {
|
|
|
951
1016
|
const usage = state.goal.usage
|
|
952
1017
|
? { tokensUsed: state.goal.usage.tokensUsed, tokensLimit: freshLimit }
|
|
953
1018
|
: undefined;
|
|
954
|
-
updateGoal({ status: "active", pauseReason: undefined, pauseSuggestedAction: undefined, ...(usage ? { usage } : {}) }, ctx);
|
|
1019
|
+
updateGoal({ status: "active", pauseReason: undefined, pauseSuggestedAction: undefined, ...(staleEntry ? { interruptedAt: nowIso(), interruptedReason: "resumed in a stale session" } : {}), ...(usage ? { usage } : {}) }, ctx);
|
|
1020
|
+
if (staleEntry) return;
|
|
955
1021
|
// v0.22.5: say what was resumed — with a non-empty list this also resumes
|
|
956
1022
|
// the queue (the active goal IS the list's head item).
|
|
957
1023
|
// v0.22.7: name WHAT was resumed — list items resume through /list.
|
|
@@ -1106,6 +1172,8 @@ async function bulkAddFromFile(ctx: ExtensionContext, abs: string): Promise<void
|
|
|
1106
1172
|
}
|
|
1107
1173
|
|
|
1108
1174
|
async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
|
|
1175
|
+
// v0.28.1 (S3): honest staleness warning; read-only subcommands still work.
|
|
1176
|
+
warnIfStaleAtEntry(ctx, "/list");
|
|
1109
1177
|
const parts = args.trim().split(/\s+/);
|
|
1110
1178
|
const sub = (parts[0] ?? "").toLowerCase();
|
|
1111
1179
|
const rest = args.trim().slice(sub.length).trim();
|
|
@@ -2402,6 +2470,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
|
2402
2470
|
};
|
|
2403
2471
|
}
|
|
2404
2472
|
const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
|
|
2473
|
+
// v0.28.1 (S3): honest staleness warning before any Confirm attempt.
|
|
2474
|
+
warnIfStaleAtEntry(liveCtx, "goal drafting");
|
|
2405
2475
|
// Multi-item list draft: one Confirm for the whole batch.
|
|
2406
2476
|
if (p.items && p.items.length > 0) {
|
|
2407
2477
|
// v0.23.7: show ALL items in full — the user approves the whole
|
|
@@ -2419,7 +2489,14 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
|
2419
2489
|
"Confirm list batch",
|
|
2420
2490
|
`${p.items.length} items:\n${preview}${batchActivates ? "\n\n(List is empty — confirming ACTIVATES item 1 immediately as the active goal.)" : ""}`,
|
|
2421
2491
|
);
|
|
2422
|
-
} catch {
|
|
2492
|
+
} catch (err) {
|
|
2493
|
+
// v0.28.1 (T1): a stale confirm is NOT a rejection — nothing was
|
|
2494
|
+
// refused; the dialog simply can't render in a doomed process.
|
|
2495
|
+
if (isStaleApiError(err)) {
|
|
2496
|
+
extensionApiStale = true;
|
|
2497
|
+
appendLedger(liveCtx.cwd, "extension_api_stale", { where: "batch confirm" });
|
|
2498
|
+
return { content: [{ type: "text", text: "The Confirm dialog could not render: pi invalidated this session's extension handle (session replacement). This is NOT a rejection — do NOT refine or re-propose. Tell the user to restart pi, then re-run the drafting flow." }], details: {} };
|
|
2499
|
+
}
|
|
2423
2500
|
batchConfirmed = false;
|
|
2424
2501
|
}
|
|
2425
2502
|
}
|
|
@@ -2460,7 +2537,13 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
|
2460
2537
|
} else {
|
|
2461
2538
|
try {
|
|
2462
2539
|
confirmed = await liveCtx.ui.confirm(isListDraft ? "Confirm list item" : "Confirm goal", `${p.objective.trim()}${contractBlock}${activationNote}`);
|
|
2463
|
-
} catch {
|
|
2540
|
+
} catch (err) {
|
|
2541
|
+
// v0.28.1 (T1): a stale confirm is NOT "Draft rejected by the user".
|
|
2542
|
+
if (isStaleApiError(err)) {
|
|
2543
|
+
extensionApiStale = true;
|
|
2544
|
+
appendLedger(liveCtx.cwd, "extension_api_stale", { where: "draft confirm" });
|
|
2545
|
+
return { content: [{ type: "text", text: "The Confirm dialog could not render: pi invalidated this session's extension handle (session replacement). This is NOT a rejection — do NOT refine or re-propose. Tell the user to restart pi, then re-run the drafting flow." }], details: {} };
|
|
2546
|
+
}
|
|
2464
2547
|
confirmed = false;
|
|
2465
2548
|
}
|
|
2466
2549
|
}
|
|
@@ -2893,173 +2976,241 @@ function resolveAuditorModel(ctx: ExtensionContext, ref?: string): { model: any;
|
|
|
2893
2976
|
* Done/Esc exits. Rarely opened by design; scriptable /glla key=value remains
|
|
2894
2977
|
* for tmux/headless.
|
|
2895
2978
|
*/
|
|
2979
|
+
/**
|
|
2980
|
+
* v0.28.0: open the /glla settings menu as a TUI table (top tabs row +
|
|
2981
|
+
* 4-column body: KEY | VALUE | SOURCE | DESCRIPTION). Loops until the user
|
|
2982
|
+
* exits (Esc / undefined from confirm or cancel) or until a handler returns.
|
|
2983
|
+
*
|
|
2984
|
+
* The dispatcher (handleSettingChoice, below) takes a stable id and calls the
|
|
2985
|
+
* per-key editor (input/select/confirm dialog) used by the pick. The prior
|
|
2986
|
+
* v0.27.0 dispatcher used `choice.startsWith(label)` strings; the new id-based
|
|
2987
|
+
* switch is contract-equal in behavior and unit-testable via
|
|
2988
|
+
* extensions/settings-menu.ts.
|
|
2989
|
+
*/
|
|
2896
2990
|
async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
|
|
2897
2991
|
for (;;) {
|
|
2898
|
-
const prov = settingsProvenance(ctx.cwd);
|
|
2899
|
-
const show = (k: keyof Settings, fallback: string) => {
|
|
2900
|
-
const p = prov[k];
|
|
2901
|
-
const v = p.value === undefined ? fallback : String(p.value);
|
|
2902
|
-
return `${v} [${p.source}]`;
|
|
2903
|
-
};
|
|
2904
2992
|
const settings = loadSettings(ctx.cwd);
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
"── Keep-going ──",
|
|
2910
|
-
`Auto-resume on load — ${show("autoResume", "default")} — on: resume on session load too · off: never · default: hold on load, resume on reload/fork`,
|
|
2911
|
-
`Auto-accept drafts — ${show("autoAcceptDrafts", "(off)")} — on: goal/loop drafts activate without the Confirm dialog (unattended rigs)`,
|
|
2912
|
-
`Aggressive mode — ${show("aggressiveMode", "(off)")} — flips DEFAULTS toward keep-going (autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs); explicit per-key settings still win`,
|
|
2913
|
-
"── Auditor ──",
|
|
2914
|
-
`Auditor model — ${show("auditorModel", "(pi session model)")} — provider/model override for the isolated auditor`,
|
|
2915
|
-
`Auditor thinking — ${show("auditorThinkingLevel", "(session, floor high)")} — thinking level for the auditor session`,
|
|
2916
|
-
`Audit cap — ${show("auditCap", "(5)")} — pause the goal after N consecutive disapprovals (0 = unlimited)`,
|
|
2917
|
-
`Audit feedback chars — ${show("auditFeedbackChars", "(full report)")} — cap the executor-visible disapproval report (0 = full report)`,
|
|
2918
|
-
`Quota retry minutes — ${show("quotaRetryMinutes", `(${DEFAULT_QUOTA_RETRY_MINUTES})`)} — auto-retry a quota-exhausted auditor after N minutes`,
|
|
2919
|
-
"── Stall brakes ──",
|
|
2920
|
-
`Wedge alert minutes — ${show("wedgeAlertMinutes", `(${WEDGE_ALERT_DEFAULT_MINUTES})`)} — hung-command alert while the session is busy (0 = off)`,
|
|
2921
|
-
`Stuck max interventions — ${show("stuckMaxInterventions", "(5)")} — consecutive stuck interventions before a loop stops`,
|
|
2922
|
-
`Stall escalation refires — ${show("stallEscalationRefires", "(5)")} — heartbeat refires with no turn before the goal pauses / loop stops (0 = never)`,
|
|
2923
|
-
`Stall short words — ${show("stallShortWords", `(${DEFAULT_STALL_SHORT_WORDS})`)} — turns with no tools AND fewer words than this count as a nudge`,
|
|
2924
|
-
`Stall similarity threshold — ${show("stallSimilarityThreshold", `(${DEFAULT_STALL_SIM_THRESHOLD})`)} — no-tool turns whose text is > this similar to the prior turn count as a nudge (0–1)`,
|
|
2925
|
-
"── Subagents ──",
|
|
2926
|
-
`Subagent model strategy — ${show("subagentModelStrategy", "(inherit-parent)")} — inherit-parent shares your session model+quota; agent-default pins haiku for Explore`,
|
|
2927
|
-
`Subagent Explore pin — ${settings.subagentModelOverrides?.Explore ?? "(follows strategy)"} — provider/model pin; always wins over strategy`,
|
|
2928
|
-
`Subagent Plan pin — ${settings.subagentModelOverrides?.Plan ?? "(follows strategy)"} — provider/model pin; always wins over strategy`,
|
|
2929
|
-
`Subagent general-purpose pin — ${settings.subagentModelOverrides?.["general-purpose"] ?? "(follows strategy)"} — provider/model pin; always wins over strategy`,
|
|
2930
|
-
"── Other ──",
|
|
2931
|
-
`Notify command — ${show("notifyCmd", "(off)")} — desktop push command; the event message is passed as $1`,
|
|
2932
|
-
`Token limit per goal — ${show("tokenLimit", "(off)")} — per-goal token budget; pause when exceeded (0 = off)`,
|
|
2933
|
-
`Reviewer config… — open the reviewer menu (post-completion follow-up enqueuer: mode, triggers, cascade, caps)`,
|
|
2934
|
-
"Done",
|
|
2935
|
-
];
|
|
2936
|
-
let choice: string | undefined;
|
|
2993
|
+
const prov = settingsProvenance(ctx.cwd);
|
|
2994
|
+
const rows = buildSettingsRows(settings, prov);
|
|
2995
|
+
const id = await promptSettingsMenu(ctx, rows);
|
|
2996
|
+
if (!id) return;
|
|
2937
2997
|
try {
|
|
2938
|
-
|
|
2939
|
-
`pi-goal-list-loop-audit settings — global: ${globalSettingsPath()}`,
|
|
2940
|
-
rows,
|
|
2941
|
-
);
|
|
2998
|
+
await handleSettingChoice(id, ctx);
|
|
2942
2999
|
} catch {
|
|
2943
3000
|
return;
|
|
2944
3001
|
}
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
}
|
|
3009
|
-
} else if (choice.startsWith("Stuck max interventions")) {
|
|
3010
|
-
const v = await ctx.ui.input("Consecutive stuck interventions before a loop stops", "positive integer; empty = default 5 (10 under aggressiveMode)");
|
|
3011
|
-
if (v !== undefined) {
|
|
3012
|
-
const n = Number.parseInt(v.trim(), 10);
|
|
3013
|
-
if (Number.isFinite(n) && n > 0) saveSettings("global", ctx.cwd, { stuckMaxInterventions: n });
|
|
3014
|
-
else if (!v.trim()) saveSettings("global", ctx.cwd, { stuckMaxInterventions: undefined });
|
|
3015
|
-
else ctx.ui.notify(`Not a positive integer: ${v}`, "warning");
|
|
3016
|
-
}
|
|
3017
|
-
} else if (choice.startsWith("Stall escalation")) {
|
|
3018
|
-
const v = await ctx.ui.input("Heartbeat refires without a turn before the goal pauses / loop stops", "non-negative integer; 0 = never escalate, empty = default 5");
|
|
3019
|
-
if (v !== undefined) {
|
|
3020
|
-
const n = Number.parseInt(v.trim(), 10);
|
|
3021
|
-
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { stallEscalationRefires: n });
|
|
3022
|
-
else if (!v.trim()) saveSettings("global", ctx.cwd, { stallEscalationRefires: undefined });
|
|
3023
|
-
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3024
|
-
}
|
|
3025
|
-
} else if (choice.startsWith("Subagent model strategy")) {
|
|
3026
|
-
const v = await ctx.ui.select("Subagent model (pi-subagents default agents)", [
|
|
3027
|
-
"inherit-parent — subagents share your session model + its quota pool (fixes separate-provider 403s; search agents may run on a pricier model)",
|
|
3028
|
-
"agent-default — upstream behavior: Explore pins claude-haiku-4-5 (cheap search, but a SEPARATE provider quota from your session)",
|
|
3029
|
-
]);
|
|
3030
|
-
if (v) {
|
|
3031
|
-
const strategy: SubagentModelStrategy = v.startsWith("agent-default") ? "agent-default" : "inherit-parent";
|
|
3032
|
-
saveSettings("global", ctx.cwd, { subagentModelStrategy: strategy });
|
|
3033
|
-
ctx.ui.notify("Subagent model strategy saved — applies to NEW pi sessions (pi-subagents registers agents at session start).", "info");
|
|
3034
|
-
}
|
|
3035
|
-
} else if (/^Subagent (Explore|Plan|general-purpose) pin/.test(choice)) {
|
|
3036
|
-
const agentType = choice.match(/^Subagent (Explore|Plan|general-purpose) pin/)![1]!;
|
|
3037
|
-
const v = await ctx.ui.input(`Model pin for ${agentType} subagents`, "provider/model-id e.g. minimax/MiniMax-M3 — always wins over strategy; empty = follow strategy");
|
|
3038
|
-
if (v !== undefined) {
|
|
3039
|
-
const current = loadSettings(ctx.cwd).subagentModelOverrides ?? {};
|
|
3040
|
-
const next = { ...current };
|
|
3041
|
-
if (v.trim()) next[agentType] = v.trim();
|
|
3042
|
-
else delete next[agentType];
|
|
3043
|
-
saveSettings("global", ctx.cwd, { subagentModelOverrides: Object.keys(next).length > 0 ? next : undefined });
|
|
3044
|
-
ctx.ui.notify(`${agentType} model pin saved — applies to NEW pi sessions.`, "info");
|
|
3045
|
-
}
|
|
3046
|
-
} else if (choice.startsWith("Notify command")) {
|
|
3047
|
-
const v = await ctx.ui.input("Notify command — the event message is passed as $1", "e.g. a desktop-notification or push command; empty = off");
|
|
3048
|
-
if (v !== undefined) saveSettings("global", ctx.cwd, { notifyCmd: v.trim() || undefined });
|
|
3049
|
-
} else if (choice.startsWith("Token limit")) {
|
|
3050
|
-
const v = await ctx.ui.input("Per-goal token budget", "non-negative integer; 0 or empty = off (no cap)");
|
|
3051
|
-
if (v !== undefined) {
|
|
3052
|
-
const n = Number.parseInt(v.trim(), 10);
|
|
3053
|
-
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { tokenLimit: n });
|
|
3054
|
-
else if (!v.trim()) saveSettings("global", ctx.cwd, { tokenLimit: undefined });
|
|
3055
|
-
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3056
|
-
}
|
|
3057
|
-
} else if (choice.startsWith("Reviewer config")) {
|
|
3058
|
-
await cmdReviewerSettings(ctx);
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
/**
|
|
3006
|
+
* Show the table-rendered settings menu and return the user's pick id (or
|
|
3007
|
+
* undefined for cancel). Wraps `ctx.ui.custom` so openSettingsUI stays a thin
|
|
3008
|
+
* loop. Falls back to a select-based legacy menu when the runtime has no
|
|
3009
|
+
* `ctx.ui.custom` (the `ctx.hasUI` guard already protects this path elsewhere;
|
|
3010
|
+
* this is a second-line defense for headless custom-only shards).
|
|
3011
|
+
*/
|
|
3012
|
+
async function promptSettingsMenu(
|
|
3013
|
+
ctx: ExtensionContext,
|
|
3014
|
+
rows: SettingsRow[],
|
|
3015
|
+
): Promise<string | undefined> {
|
|
3016
|
+
const title = `pi-goal-list-loop-audit settings — global: ${globalSettingsPath()}`;
|
|
3017
|
+
if (typeof (ctx.ui as { custom?: unknown }).custom !== "function") {
|
|
3018
|
+
// Headless / no custom shard — fall back to the legacy flat-row select
|
|
3019
|
+
// for any environment that lacks the new primitive. This is rare and
|
|
3020
|
+
// effectively an emergency hatch; the new UI is the supported path.
|
|
3021
|
+
const flat = rows.map((r) => `${r.label} — ${r.valueText} [${r.sourceText.replace(/^\[|\]$/g, "")}] — ${r.description}`);
|
|
3022
|
+
flat.push("Done");
|
|
3023
|
+
const v = await ctx.ui.select(title, flat);
|
|
3024
|
+
if (!v || v === "Done") return undefined;
|
|
3025
|
+
return rows.find((r) => v.startsWith(r.label))?.id;
|
|
3026
|
+
}
|
|
3027
|
+
return await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
3028
|
+
return new SettingsMenuComponent({ rows, title }, () => tui.requestRender(), theme, keybindings, done);
|
|
3029
|
+
});
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
/**
|
|
3033
|
+
* v0.28.0: per-key dispatch for the settings menu. The id comes from
|
|
3034
|
+
* `buildSettingsRows` (e.g. "autoResume", "auditorModel", "subagentModelOverrides.Explore").
|
|
3035
|
+
* Same handlers as v0.27.0's if/else chain — only the trigger changed from
|
|
3036
|
+
* `startsWith(label)` strings to stable ids.
|
|
3037
|
+
*/
|
|
3038
|
+
async function handleSettingChoice(id: string, ctx: ExtensionContext): Promise<void> {
|
|
3039
|
+
switch (id) {
|
|
3040
|
+
case "autoResume": {
|
|
3041
|
+
const v = await ctx.ui.select("Auto-resume goals/loops on session start", [
|
|
3042
|
+
"default — HOLD when a session is loaded (popup shows what waits); auto-resume on reload/fork so machinery never strands work",
|
|
3043
|
+
"on — auto-resume on EVERY session start (unattended rigs)",
|
|
3044
|
+
"off — never auto-resume; always wait for an explicit resume",
|
|
3045
|
+
]);
|
|
3046
|
+
if (v) saveSettings("global", ctx.cwd, { autoResume: v.startsWith("on") ? true : v.startsWith("off") ? false : undefined });
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
case "autoAcceptDrafts": {
|
|
3050
|
+
const v = await ctx.ui.select("Auto-accept goal/loop drafts", [
|
|
3051
|
+
"off — the Confirm dialog gates every draft",
|
|
3052
|
+
"on — drafts activate immediately, no Confirm (unattended rigs)",
|
|
3053
|
+
]);
|
|
3054
|
+
if (v) saveSettings("global", ctx.cwd, { autoAcceptDrafts: v.startsWith("on") ? true : undefined });
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
case "aggressiveMode": {
|
|
3058
|
+
const v = await ctx.ui.select("Aggressive mode (flips DEFAULTS toward keep-going — explicit per-key settings still win)", [
|
|
3059
|
+
"off — current behavior: pause at the audit cap, wedge alerts on, manual resume",
|
|
3060
|
+
"on — autoResume, audit cap 10, stuck max 10, wedge alerts off, quota auto-retry, cap disapprovals become a TODO list and the goal KEEPS GOING",
|
|
3061
|
+
]);
|
|
3062
|
+
if (v) {
|
|
3063
|
+
saveSettings("global", ctx.cwd, { aggressiveMode: v.startsWith("on") });
|
|
3064
|
+
ctx.ui.notify(`Aggressive mode ${v.startsWith("on") ? "ON — goals keep going past the audit cap; objections become TODOs" : "off"}.`, "info");
|
|
3059
3065
|
}
|
|
3060
|
-
} catch {
|
|
3061
3066
|
return;
|
|
3062
3067
|
}
|
|
3068
|
+
case "auditorModel": {
|
|
3069
|
+
const v = await ctx.ui.input("Auditor model override", "provider/model-id — empty keeps the pi session model");
|
|
3070
|
+
if (v !== undefined) saveSettings("global", ctx.cwd, { auditorModel: v.trim() || undefined });
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
case "auditorThinkingLevel": {
|
|
3074
|
+
const v = await ctx.ui.select("Auditor thinking level", ["off", "minimal", "low", "medium", "high", "xhigh"]);
|
|
3075
|
+
if (v) saveSettings("global", ctx.cwd, { auditorThinkingLevel: v as Settings["auditorThinkingLevel"] });
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
case "auditCap": {
|
|
3079
|
+
const v = await ctx.ui.input("Consecutive auditor disapprovals before the goal pauses", "non-negative integer; 0 = unlimited, empty = default 5");
|
|
3080
|
+
if (v !== undefined) {
|
|
3081
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3082
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { auditCap: n });
|
|
3083
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { auditCap: undefined });
|
|
3084
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3085
|
+
}
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
case "auditFeedbackChars": {
|
|
3089
|
+
const v = await ctx.ui.input("Auditor feedback returned to the executor (characters)", "non-negative integer cap; 0 or empty = full report (default)");
|
|
3090
|
+
if (v !== undefined) {
|
|
3091
|
+
const raw = v.trim();
|
|
3092
|
+
const n = Number(raw);
|
|
3093
|
+
if (/^\d+$/.test(raw) && Number.isSafeInteger(n)) saveSettings("global", ctx.cwd, { auditFeedbackChars: n });
|
|
3094
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { auditFeedbackChars: undefined });
|
|
3095
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3096
|
+
}
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
case "quotaRetryMinutes": {
|
|
3100
|
+
const v = await ctx.ui.input("Minutes before auto-retrying a quota-exhausted auditor", `positive integer; empty = default ${DEFAULT_QUOTA_RETRY_MINUTES}`);
|
|
3101
|
+
if (v !== undefined) {
|
|
3102
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3103
|
+
if (Number.isFinite(n) && n > 0) saveSettings("global", ctx.cwd, { quotaRetryMinutes: n });
|
|
3104
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { quotaRetryMinutes: undefined });
|
|
3105
|
+
else ctx.ui.notify(`Not a positive integer: ${v}`, "warning");
|
|
3106
|
+
}
|
|
3107
|
+
return;
|
|
3108
|
+
}
|
|
3109
|
+
case "wedgeAlertMinutes": {
|
|
3110
|
+
const v = await ctx.ui.input("Wedge alert threshold (minutes)", "non-negative integer; 0 = off, empty = default 30");
|
|
3111
|
+
if (v !== undefined) {
|
|
3112
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3113
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { wedgeAlertMinutes: n });
|
|
3114
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { wedgeAlertMinutes: undefined });
|
|
3115
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3116
|
+
}
|
|
3117
|
+
return;
|
|
3118
|
+
}
|
|
3119
|
+
case "stuckMaxInterventions": {
|
|
3120
|
+
const v = await ctx.ui.input("Consecutive stuck interventions before a loop stops", "positive integer; empty = default 5 (10 under aggressiveMode)");
|
|
3121
|
+
if (v !== undefined) {
|
|
3122
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3123
|
+
if (Number.isFinite(n) && n > 0) saveSettings("global", ctx.cwd, { stuckMaxInterventions: n });
|
|
3124
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { stuckMaxInterventions: undefined });
|
|
3125
|
+
else ctx.ui.notify(`Not a positive integer: ${v}`, "warning");
|
|
3126
|
+
}
|
|
3127
|
+
return;
|
|
3128
|
+
}
|
|
3129
|
+
case "stallEscalationRefires": {
|
|
3130
|
+
const v = await ctx.ui.input("Heartbeat refires without a turn before the goal pauses / loop stops", "non-negative integer; 0 = never escalate, empty = default 5");
|
|
3131
|
+
if (v !== undefined) {
|
|
3132
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3133
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { stallEscalationRefires: n });
|
|
3134
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { stallEscalationRefires: undefined });
|
|
3135
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3136
|
+
}
|
|
3137
|
+
return;
|
|
3138
|
+
}
|
|
3139
|
+
case "stallShortWords": {
|
|
3140
|
+
const v = await ctx.ui.input("Stall short words threshold", "non-negative integer; 0 = off, empty = default 15");
|
|
3141
|
+
if (v !== undefined) {
|
|
3142
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3143
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { stallShortWords: n });
|
|
3144
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { stallShortWords: undefined });
|
|
3145
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3146
|
+
}
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
case "stallSimilarityThreshold": {
|
|
3150
|
+
const v = await ctx.ui.input("Stall similarity threshold (0..1)", "decimal between 0 and 1; empty = default 0.6");
|
|
3151
|
+
if (v !== undefined) {
|
|
3152
|
+
const n = Number(v.trim());
|
|
3153
|
+
if (Number.isFinite(n) && n >= 0 && n <= 1) saveSettings("global", ctx.cwd, { stallSimilarityThreshold: n });
|
|
3154
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { stallSimilarityThreshold: undefined });
|
|
3155
|
+
else ctx.ui.notify(`Not a decimal between 0 and 1: ${v}`, "warning");
|
|
3156
|
+
}
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
case "subagentModelStrategy": {
|
|
3160
|
+
const v = await ctx.ui.select("Subagent model (pi-subagents default agents)", [
|
|
3161
|
+
"inherit-parent — share your session model + quota pool (recommended)",
|
|
3162
|
+
"agent-default — use the upstream pi-subagents default agents",
|
|
3163
|
+
]);
|
|
3164
|
+
if (v) {
|
|
3165
|
+
const strategy: SubagentModelStrategy = v.startsWith("agent-default") ? "agent-default" : "inherit-parent";
|
|
3166
|
+
saveSettings("global", ctx.cwd, { subagentModelStrategy: strategy });
|
|
3167
|
+
ctx.ui.notify("Subagent model strategy saved — applies to NEW pi sessions (pi-subagents registers agents at session start).", "info");
|
|
3168
|
+
}
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
3171
|
+
case "subagentModelOverrides.Explore":
|
|
3172
|
+
case "subagentModelOverrides.Plan":
|
|
3173
|
+
case "subagentModelOverrides.general-purpose": {
|
|
3174
|
+
const agentType = id.slice("subagentModelOverrides.".length);
|
|
3175
|
+
const v = await ctx.ui.input(`Model pin for ${agentType} subagents`, "provider/model-id e.g. minimax/MiniMax-M3 — always wins over strategy; empty = follow strategy");
|
|
3176
|
+
if (v !== undefined) {
|
|
3177
|
+
const current = loadSettings(ctx.cwd).subagentModelOverrides ?? {};
|
|
3178
|
+
const next = { ...current };
|
|
3179
|
+
if (v.trim()) next[agentType] = v.trim();
|
|
3180
|
+
else delete next[agentType];
|
|
3181
|
+
saveSettings("global", ctx.cwd, { subagentModelOverrides: Object.keys(next).length > 0 ? next : undefined });
|
|
3182
|
+
ctx.ui.notify(`${agentType} model pin saved — applies to NEW pi sessions.`, "info");
|
|
3183
|
+
}
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
case "subagentResolved":
|
|
3187
|
+
// Read-only (effective resolution row) — no editor; row just shows the
|
|
3188
|
+
// current effective subagent models. Treat as no-op.
|
|
3189
|
+
return;
|
|
3190
|
+
case "notifyCmd": {
|
|
3191
|
+
const v = await ctx.ui.input("Notify command — the event message is passed as $1", "e.g. a desktop-notification or push command; empty = off");
|
|
3192
|
+
if (v !== undefined) saveSettings("global", ctx.cwd, { notifyCmd: v.trim() || undefined });
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
case "tokenLimit": {
|
|
3196
|
+
const v = await ctx.ui.input("Per-goal token budget", "non-negative integer; 0 or empty = off (no cap)");
|
|
3197
|
+
if (v !== undefined) {
|
|
3198
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3199
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { tokenLimit: n });
|
|
3200
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { tokenLimit: undefined });
|
|
3201
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3202
|
+
}
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
case "postaudit":
|
|
3206
|
+
await cmdReviewerSettings(ctx);
|
|
3207
|
+
return;
|
|
3208
|
+
default:
|
|
3209
|
+
// Unknown id — keep the menu looping. Surface a soft warning so the
|
|
3210
|
+
// user knows a row existed but had no handler (better than silently
|
|
3211
|
+
// swallowing it).
|
|
3212
|
+
ctx.ui.notify(`/glla: unknown setting id "${id}" — please report this.`, "warning");
|
|
3213
|
+
return;
|
|
3063
3214
|
}
|
|
3064
3215
|
}
|
|
3065
3216
|
|
|
@@ -3959,8 +4110,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3959
4110
|
}
|
|
3960
4111
|
} else if (state.goal && state.goal.status === "active" && state.goal.autoContinue) {
|
|
3961
4112
|
if (autoResume) {
|
|
4113
|
+
// v0.28.1 (S2): clear the stale-handle interrupt marker — this IS
|
|
4114
|
+
// the auto-resume the marker promised.
|
|
4115
|
+
const wasInterrupted = !!state.goal.interruptedAt;
|
|
4116
|
+
if (wasInterrupted) updateGoal({ interruptedAt: undefined, interruptedReason: undefined }, ctx);
|
|
3962
4117
|
ctx.ui.notify(
|
|
3963
|
-
`Resuming ${state.goal.policy === "list" ? "list item" : "goal"} [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}`,
|
|
4118
|
+
`Resuming ${state.goal.policy === "list" ? "list item" : "goal"} [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}${wasInterrupted ? " — auto-resumed after the stale-handle interrupt" : ""}`,
|
|
3964
4119
|
"info",
|
|
3965
4120
|
);
|
|
3966
4121
|
scheduleContinuation(ctx, true);
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// pi-goal-list-loop-audit — v0.28.0
|
|
2
|
+
// extensions/settings-menu.ts
|
|
3
|
+
//
|
|
4
|
+
// The /glla settings menu as a real TUI table (v0.28.0).
|
|
5
|
+
//
|
|
6
|
+
// Pre-0.28.0 used `ctx.ui.select` with flat single-line rows; v0.28.0
|
|
7
|
+
// replaces it with a `ctx.ui.custom` Container/Text layout featuring:
|
|
8
|
+
// • a top TABS row listing all 5 sections (left/right to switch sections)
|
|
9
|
+
// • a 4-column table for the active section (KEY | VALUE | SOURCE | DESCRIPTION)
|
|
10
|
+
// • up/down navigation scoped to the active section's rows
|
|
11
|
+
// • Enter → emit the selected row's id (caller dispatches handler)
|
|
12
|
+
// • Esc / Ctrl+C → emit undefined (caller exits)
|
|
13
|
+
//
|
|
14
|
+
// Sections (5 total) map to the pre-0.28.0 menu groupings:
|
|
15
|
+
// keep-going | auditor | stall-brakes | subagents | other
|
|
16
|
+
//
|
|
17
|
+
// Extracted into its own module so tests can import `buildSettingsRows` directly
|
|
18
|
+
// (mirrors how `readState` lives in goal-loop-core.ts) and so the renderer is
|
|
19
|
+
// unit-testable via synthetic handleInput calls (no live TUI needed).
|
|
20
|
+
//
|
|
21
|
+
// The pre-v0.28.0 headless fallback (`/glla` with no args and no UI) keeps its
|
|
22
|
+
// existing text rendering — that's still the right shape for tmux/cron. Only
|
|
23
|
+
// the TUI menu becomes a table.
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
type Component,
|
|
27
|
+
truncateToWidth,
|
|
28
|
+
visibleWidth,
|
|
29
|
+
} from "@earendil-works/pi-tui";
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_AUDIT_FEEDBACK_CHARS,
|
|
33
|
+
DEFAULT_QUOTA_RETRY_MINUTES,
|
|
34
|
+
DEFAULT_STALL_ESCALATION_REFIRES,
|
|
35
|
+
} from "./goal-loop-core.ts";
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_STALL_SIM_THRESHOLD,
|
|
38
|
+
DEFAULT_STALL_SHORT_WORDS,
|
|
39
|
+
WEDGE_ALERT_DEFAULT_MINUTES,
|
|
40
|
+
} from "./goal-loop-backoff.ts";
|
|
41
|
+
import type { Settings } from "./goal-settings.ts";
|
|
42
|
+
import { resolveEffectiveSubagentModel } from "./goal-loop-subagents.ts";
|
|
43
|
+
|
|
44
|
+
// =================================================================
|
|
45
|
+
// Pure row builder (testable + reusable from the headless fallback)
|
|
46
|
+
// =================================================================
|
|
47
|
+
|
|
48
|
+
export type SettingsSectionId =
|
|
49
|
+
| "keep-going"
|
|
50
|
+
| "auditor"
|
|
51
|
+
| "stall-brakes"
|
|
52
|
+
| "subagents"
|
|
53
|
+
| "other";
|
|
54
|
+
|
|
55
|
+
export const SETTINGS_SECTIONS: readonly { id: SettingsSectionId; label: string }[] = [
|
|
56
|
+
{ id: "keep-going", label: "Keep-going" },
|
|
57
|
+
{ id: "auditor", label: "Auditor" },
|
|
58
|
+
{ id: "stall-brakes", label: "Stall brakes" },
|
|
59
|
+
{ id: "subagents", label: "Subagents" },
|
|
60
|
+
{ id: "other", label: "Other" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** One menu row. `id` is the stable dispatch key (caller switch(id) → handler). */
|
|
64
|
+
export interface SettingsRow {
|
|
65
|
+
/** Stable dispatch key — used both as the table id and as the switch(id) case. */
|
|
66
|
+
id: string;
|
|
67
|
+
/** Which section this row belongs to. */
|
|
68
|
+
section: SettingsSectionId;
|
|
69
|
+
/** KEY column — the setting name (left-aligned, padded to keyW). */
|
|
70
|
+
label: string;
|
|
71
|
+
/** VALUE column — current effective value, e.g. `true` / `(off)` / `60`. */
|
|
72
|
+
valueText: string;
|
|
73
|
+
/** SOURCE column — provenance tag, one of `[project]` / `[global]` / `[default]`. */
|
|
74
|
+
sourceText: string;
|
|
75
|
+
/** DESCRIPTION column — one-line explanation; truncated with ellipsis when narrow. */
|
|
76
|
+
description: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ProvenanceSource = "project" | "global" | "default";
|
|
80
|
+
|
|
81
|
+
export interface MenuProvenance {
|
|
82
|
+
value: unknown;
|
|
83
|
+
source: ProvenanceSource;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Defaults surfaced in the menu when the user has not set a value. */
|
|
87
|
+
export interface MenuDefaults {
|
|
88
|
+
auditCap: number;
|
|
89
|
+
stuckMaxInterventions: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const DEFAULT_MENU_DEFAULTS: MenuDefaults = {
|
|
93
|
+
auditCap: 5,
|
|
94
|
+
stuckMaxInterventions: 5,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Subagent model provenance context needed to render the subagent pins column. */
|
|
98
|
+
export interface MenuSubagentContext {
|
|
99
|
+
/** Active session model id (provider/model) — used by inherit-parent resolution. */
|
|
100
|
+
sessionModel?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the full ordered list of menu rows for every section.
|
|
105
|
+
* Pure: no I/O, no extension context; the renderer composes sections onto rows.
|
|
106
|
+
*/
|
|
107
|
+
export function buildSettingsRows(
|
|
108
|
+
settings: Settings,
|
|
109
|
+
prov: Partial<Record<keyof Settings, MenuProvenance>>,
|
|
110
|
+
subagent: MenuSubagentContext = {},
|
|
111
|
+
defaults: MenuDefaults = DEFAULT_MENU_DEFAULTS,
|
|
112
|
+
): SettingsRow[] {
|
|
113
|
+
const provFor = (k: keyof Settings): MenuProvenance =>
|
|
114
|
+
prov[k] ?? { value: undefined, source: "default" };
|
|
115
|
+
const show = (k: keyof Settings, fallback: string): string => {
|
|
116
|
+
const p = provFor(k);
|
|
117
|
+
return p.value === undefined ? fallback : String(p.value);
|
|
118
|
+
};
|
|
119
|
+
const src = (k: keyof Settings): string => `[${provFor(k).source}]`;
|
|
120
|
+
|
|
121
|
+
const rows: SettingsRow[] = [];
|
|
122
|
+
|
|
123
|
+
// ── Keep-going ──
|
|
124
|
+
rows.push(
|
|
125
|
+
{
|
|
126
|
+
id: "autoResume",
|
|
127
|
+
section: "keep-going",
|
|
128
|
+
label: "Auto-resume on load",
|
|
129
|
+
valueText: show("autoResume", "default"),
|
|
130
|
+
sourceText: src("autoResume"),
|
|
131
|
+
description:
|
|
132
|
+
"on: resume on session load too · off: never · default: hold on load, resume on reload/fork",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "autoAcceptDrafts",
|
|
136
|
+
section: "keep-going",
|
|
137
|
+
label: "Auto-accept drafts",
|
|
138
|
+
valueText: show("autoAcceptDrafts", "(off)"),
|
|
139
|
+
sourceText: src("autoAcceptDrafts"),
|
|
140
|
+
description: "on: goal/loop drafts activate without the Confirm dialog (unattended rigs)",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "aggressiveMode",
|
|
144
|
+
section: "keep-going",
|
|
145
|
+
label: "Aggressive mode",
|
|
146
|
+
valueText: show("aggressiveMode", "(off)"),
|
|
147
|
+
sourceText: src("aggressiveMode"),
|
|
148
|
+
description:
|
|
149
|
+
"flips DEFAULTS toward keep-going (autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs); explicit per-key settings still win",
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// ── Auditor ──
|
|
154
|
+
rows.push(
|
|
155
|
+
{
|
|
156
|
+
id: "auditorModel",
|
|
157
|
+
section: "auditor",
|
|
158
|
+
label: "Auditor model",
|
|
159
|
+
valueText: show("auditorModel", "(pi session model)"),
|
|
160
|
+
sourceText: src("auditorModel"),
|
|
161
|
+
description: "provider/model override for the isolated auditor",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "auditorThinkingLevel",
|
|
165
|
+
section: "auditor",
|
|
166
|
+
label: "Auditor thinking",
|
|
167
|
+
valueText: show("auditorThinkingLevel", "(session, floor high)"),
|
|
168
|
+
sourceText: src("auditorThinkingLevel"),
|
|
169
|
+
description: "thinking level for the auditor session",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: "auditCap",
|
|
173
|
+
section: "auditor",
|
|
174
|
+
label: "Audit cap",
|
|
175
|
+
valueText: show("auditCap", `(${defaults.auditCap})`),
|
|
176
|
+
sourceText: src("auditCap"),
|
|
177
|
+
description: "pause the goal after N consecutive disapprovals (0 = unlimited)",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: "auditFeedbackChars",
|
|
181
|
+
section: "auditor",
|
|
182
|
+
label: "Audit feedback chars",
|
|
183
|
+
valueText: show(
|
|
184
|
+
"auditFeedbackChars",
|
|
185
|
+
DEFAULT_AUDIT_FEEDBACK_CHARS === 0 ? "(full report)" : `(${DEFAULT_AUDIT_FEEDBACK_CHARS})`,
|
|
186
|
+
),
|
|
187
|
+
sourceText: src("auditFeedbackChars"),
|
|
188
|
+
description: "cap the executor-visible disapproval report (0 = full report)",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: "quotaRetryMinutes",
|
|
192
|
+
section: "auditor",
|
|
193
|
+
label: "Quota retry minutes",
|
|
194
|
+
valueText: show("quotaRetryMinutes", `(${DEFAULT_QUOTA_RETRY_MINUTES})`),
|
|
195
|
+
sourceText: src("quotaRetryMinutes"),
|
|
196
|
+
description: "auto-retry a quota-exhausted auditor after N minutes",
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// ── Stall brakes ──
|
|
201
|
+
rows.push(
|
|
202
|
+
{
|
|
203
|
+
id: "wedgeAlertMinutes",
|
|
204
|
+
section: "stall-brakes",
|
|
205
|
+
label: "Wedge alert minutes",
|
|
206
|
+
valueText: show("wedgeAlertMinutes", `(${WEDGE_ALERT_DEFAULT_MINUTES})`),
|
|
207
|
+
sourceText: src("wedgeAlertMinutes"),
|
|
208
|
+
description: "hung-command alert while the session is busy (0 = off)",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: "stuckMaxInterventions",
|
|
212
|
+
section: "stall-brakes",
|
|
213
|
+
label: "Stuck max interventions",
|
|
214
|
+
valueText: show("stuckMaxInterventions", `(${defaults.stuckMaxInterventions})`),
|
|
215
|
+
sourceText: src("stuckMaxInterventions"),
|
|
216
|
+
description: "consecutive stuck interventions before a loop stops",
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: "stallEscalationRefires",
|
|
220
|
+
section: "stall-brakes",
|
|
221
|
+
label: "Stall escalation refires",
|
|
222
|
+
valueText: show("stallEscalationRefires", `(${DEFAULT_STALL_ESCALATION_REFIRES})`),
|
|
223
|
+
sourceText: src("stallEscalationRefires"),
|
|
224
|
+
description:
|
|
225
|
+
"heartbeat refires with no turn before the goal pauses / loop stops (0 = never)",
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "stallShortWords",
|
|
229
|
+
section: "stall-brakes",
|
|
230
|
+
label: "Stall short words",
|
|
231
|
+
valueText: show("stallShortWords", `(${DEFAULT_STALL_SHORT_WORDS})`),
|
|
232
|
+
sourceText: src("stallShortWords"),
|
|
233
|
+
description: "turns with no tools AND fewer words than this count as a nudge",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: "stallSimilarityThreshold",
|
|
237
|
+
section: "stall-brakes",
|
|
238
|
+
label: "Stall similarity threshold",
|
|
239
|
+
valueText: show("stallSimilarityThreshold", `(${DEFAULT_STALL_SIM_THRESHOLD})`),
|
|
240
|
+
sourceText: src("stallSimilarityThreshold"),
|
|
241
|
+
description:
|
|
242
|
+
"no-tool turns whose text is > this similar to the prior turn count as a nudge (0–1)",
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// ── Subagents ──
|
|
247
|
+
rows.push(
|
|
248
|
+
{
|
|
249
|
+
id: "subagentModelStrategy",
|
|
250
|
+
section: "subagents",
|
|
251
|
+
label: "Subagent model strategy",
|
|
252
|
+
valueText: show("subagentModelStrategy", "(inherit-parent)"),
|
|
253
|
+
sourceText: src("subagentModelStrategy"),
|
|
254
|
+
description:
|
|
255
|
+
"inherit-parent shares your session model + quota pool; agent-default uses the upstream pi-subagents default agents",
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "subagentModelOverrides.Explore",
|
|
259
|
+
section: "subagents",
|
|
260
|
+
label: "Subagent Explore pin",
|
|
261
|
+
valueText: settings.subagentModelOverrides?.Explore ?? "(follows strategy)",
|
|
262
|
+
sourceText:
|
|
263
|
+
settings.subagentModelOverrides?.Explore !== undefined
|
|
264
|
+
? src("subagentModelOverrides")
|
|
265
|
+
: "[default]",
|
|
266
|
+
description: "provider/model pin; always wins over strategy",
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "subagentModelOverrides.Plan",
|
|
270
|
+
section: "subagents",
|
|
271
|
+
label: "Subagent Plan pin",
|
|
272
|
+
valueText: settings.subagentModelOverrides?.Plan ?? "(follows strategy)",
|
|
273
|
+
sourceText:
|
|
274
|
+
settings.subagentModelOverrides?.Plan !== undefined
|
|
275
|
+
? src("subagentModelOverrides")
|
|
276
|
+
: "[default]",
|
|
277
|
+
description: "provider/model pin; always wins over strategy",
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: "subagentModelOverrides.general-purpose",
|
|
281
|
+
section: "subagents",
|
|
282
|
+
label: "Subagent general-purpose pin",
|
|
283
|
+
valueText: settings.subagentModelOverrides?.["general-purpose"] ?? "(follows strategy)",
|
|
284
|
+
sourceText:
|
|
285
|
+
settings.subagentModelOverrides?.["general-purpose"] !== undefined
|
|
286
|
+
? src("subagentModelOverrides")
|
|
287
|
+
: "[default]",
|
|
288
|
+
description: "provider/model pin; always wins over strategy",
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
id: "subagentResolved",
|
|
292
|
+
section: "subagents",
|
|
293
|
+
label: "Effective resolution",
|
|
294
|
+
valueText: [
|
|
295
|
+
resolveEffectiveSubagentModel("Explore", settings, subagent.sessionModel),
|
|
296
|
+
resolveEffectiveSubagentModel("Plan", settings, subagent.sessionModel),
|
|
297
|
+
resolveEffectiveSubagentModel("general-purpose", settings, subagent.sessionModel),
|
|
298
|
+
].join(" · "),
|
|
299
|
+
sourceText: "[runtime]",
|
|
300
|
+
description: "effective Explore / Plan / general-purpose model given current settings",
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// ── Other ──
|
|
305
|
+
rows.push(
|
|
306
|
+
{
|
|
307
|
+
id: "notifyCmd",
|
|
308
|
+
section: "other",
|
|
309
|
+
label: "Notify command",
|
|
310
|
+
valueText: show("notifyCmd", "(off)"),
|
|
311
|
+
sourceText: src("notifyCmd"),
|
|
312
|
+
description: "desktop push command; the event message is passed as $1",
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
id: "tokenLimit",
|
|
316
|
+
section: "other",
|
|
317
|
+
label: "Token limit per goal",
|
|
318
|
+
valueText: show("tokenLimit", "(off)"),
|
|
319
|
+
sourceText: src("tokenLimit"),
|
|
320
|
+
description: "per-goal token budget; pause when exceeded (0 = off)",
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "postaudit",
|
|
324
|
+
section: "other",
|
|
325
|
+
label: "Postaudit config…",
|
|
326
|
+
valueText: "open sub-menu",
|
|
327
|
+
sourceText: "[—]",
|
|
328
|
+
description:
|
|
329
|
+
"post-completion follow-up enqueuer: mode, triggers, cascade, caps (postaudit / reviewer)",
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
return rows;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// =================================================================
|
|
337
|
+
// TUI table component
|
|
338
|
+
// =================================================================
|
|
339
|
+
|
|
340
|
+
/** Padding + gutter between columns. */
|
|
341
|
+
const COL_GUTTER = 2;
|
|
342
|
+
|
|
343
|
+
/** Maximum width for each fixed column before truncation kicks in. */
|
|
344
|
+
const MAX_KEY_W = 32;
|
|
345
|
+
const MAX_VALUE_W = 24;
|
|
346
|
+
const MAX_SOURCE_W = 10;
|
|
347
|
+
const MIN_DESC_W = 12;
|
|
348
|
+
|
|
349
|
+
/** A minimal subset of pi-tui's Theme interface used by the renderer. */
|
|
350
|
+
export interface SettingsMenuTheme {
|
|
351
|
+
fg(color: "accent" | "muted" | "dim" | "warning" | "success", text: string): string;
|
|
352
|
+
bold(text: string): string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Structural type for the KeybindingsManager — avoids pulling in the
|
|
356
|
+
* full class so callers can supply any compatible implementation.
|
|
357
|
+
* (Top-level and nested pi-tui ship separate KeybindingsManager classes
|
|
358
|
+
* with private fields; structural typing sidesteps the cross-package type
|
|
359
|
+
* incompatibility entirely.) */
|
|
360
|
+
export interface KeybindingsManagerLike {
|
|
361
|
+
matches(data: string, key: string): boolean;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export interface SettingsMenuFactoryDeps {
|
|
365
|
+
rows: SettingsRow[];
|
|
366
|
+
title: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* TUI Component for the /glla settings menu. Renders a top tabs row + a
|
|
371
|
+
* 4-column table for the active section. The host (extensions/loops/goal.ts)
|
|
372
|
+
* constructs it via `ctx.ui.custom(...)` and dispatches the returned id
|
|
373
|
+
* with `switch (id)` instead of the pre-v0.28.0 `startsWith` strings.
|
|
374
|
+
*/
|
|
375
|
+
export class SettingsMenuComponent implements Component {
|
|
376
|
+
private readonly rows: SettingsRow[];
|
|
377
|
+
private readonly title: string;
|
|
378
|
+
private readonly requestRender: () => void;
|
|
379
|
+
private readonly theme: SettingsMenuTheme;
|
|
380
|
+
private readonly keybindings: KeybindingsManagerLike;
|
|
381
|
+
private readonly done: (id: string | undefined) => void;
|
|
382
|
+
|
|
383
|
+
private activeSectionIdx: number;
|
|
384
|
+
private selectedIdx: number;
|
|
385
|
+
private cachedWidth?: number;
|
|
386
|
+
private cachedLines?: string[];
|
|
387
|
+
|
|
388
|
+
constructor(
|
|
389
|
+
deps: SettingsMenuFactoryDeps,
|
|
390
|
+
requestRender: () => void,
|
|
391
|
+
theme: SettingsMenuTheme,
|
|
392
|
+
keybindings: KeybindingsManagerLike,
|
|
393
|
+
done: (id: string | undefined) => void,
|
|
394
|
+
) {
|
|
395
|
+
this.rows = deps.rows;
|
|
396
|
+
this.title = deps.title;
|
|
397
|
+
this.requestRender = requestRender;
|
|
398
|
+
this.theme = theme;
|
|
399
|
+
this.keybindings = keybindings;
|
|
400
|
+
this.done = done;
|
|
401
|
+
this.activeSectionIdx = 0;
|
|
402
|
+
this.selectedIdx = 0;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Index into `SETTINGS_SECTIONS`. Exposed for tests. */
|
|
406
|
+
getActiveSectionIdx(): number {
|
|
407
|
+
return this.activeSectionIdx;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Index into the active-section's visible rows. Exposed for tests. */
|
|
411
|
+
getSelectedIdx(): number {
|
|
412
|
+
return this.selectedIdx;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Rows in the active section. Exposed for tests. */
|
|
416
|
+
visibleRows(): SettingsRow[] {
|
|
417
|
+
return this.rows.filter(
|
|
418
|
+
(r) => r.section === SETTINGS_SECTIONS[this.activeSectionIdx]!.id,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private refresh(): void {
|
|
423
|
+
this.cachedWidth = undefined;
|
|
424
|
+
this.cachedLines = undefined;
|
|
425
|
+
this.requestRender();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Move within the active section, wrapping at ends. Exposed for tests. */
|
|
429
|
+
move(delta: number): void {
|
|
430
|
+
const vs = this.visibleRows();
|
|
431
|
+
if (vs.length === 0) return;
|
|
432
|
+
const n = vs.length;
|
|
433
|
+
this.selectedIdx = ((this.selectedIdx + delta) % n + n) % n;
|
|
434
|
+
this.refresh();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Switch section. -1 = left, +1 = right; wraps at ends. Exposed for tests. */
|
|
438
|
+
switchSection(delta: number): void {
|
|
439
|
+
const n = SETTINGS_SECTIONS.length;
|
|
440
|
+
this.activeSectionIdx = ((this.activeSectionIdx + delta) % n + n) % n;
|
|
441
|
+
this.selectedIdx = 0;
|
|
442
|
+
this.refresh();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private resolveSelectedId(): string | undefined {
|
|
446
|
+
return this.visibleRows()[this.selectedIdx]?.id;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private widths(width: number) {
|
|
450
|
+
let keyW = visibleWidth(this.theme.bold("KEY"));
|
|
451
|
+
let valueW = visibleWidth(this.theme.bold("VALUE"));
|
|
452
|
+
let sourceW = visibleWidth(this.theme.bold("SOURCE"));
|
|
453
|
+
for (const r of this.visibleRows()) {
|
|
454
|
+
if (visibleWidth(r.label) > keyW) keyW = visibleWidth(r.label);
|
|
455
|
+
if (visibleWidth(r.valueText) > valueW) valueW = visibleWidth(r.valueText);
|
|
456
|
+
if (visibleWidth(r.sourceText) > sourceW) sourceW = visibleWidth(r.sourceText);
|
|
457
|
+
}
|
|
458
|
+
keyW = Math.min(keyW, MAX_KEY_W);
|
|
459
|
+
valueW = Math.min(valueW, MAX_VALUE_W);
|
|
460
|
+
sourceW = Math.min(sourceW, MAX_SOURCE_W);
|
|
461
|
+
const descW = Math.max(MIN_DESC_W, width - keyW - valueW - sourceW - 3 * COL_GUTTER);
|
|
462
|
+
return { keyW, valueW, sourceW, descW };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private padEnd(text: string, width: number): string {
|
|
466
|
+
const w = visibleWidth(text);
|
|
467
|
+
return w >= width ? text : text + " ".repeat(width - w);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private renderBody(width: number): string[] {
|
|
471
|
+
const { keyW, valueW, sourceW, descW } = this.widths(width);
|
|
472
|
+
const gutter = " ".repeat(COL_GUTTER);
|
|
473
|
+
|
|
474
|
+
const lines: string[] = [];
|
|
475
|
+
|
|
476
|
+
lines.push(this.theme.fg("accent", this.theme.bold(this.title)));
|
|
477
|
+
|
|
478
|
+
lines.push(
|
|
479
|
+
SETTINGS_SECTIONS.map((s, i) =>
|
|
480
|
+
i === this.activeSectionIdx
|
|
481
|
+
? this.theme.fg("accent", `[${s.label}]`)
|
|
482
|
+
: this.theme.fg("dim", s.label),
|
|
483
|
+
).join(" "),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
lines.push(
|
|
487
|
+
[
|
|
488
|
+
this.padEnd(this.theme.bold("KEY"), keyW),
|
|
489
|
+
this.padEnd(this.theme.bold("VALUE"), valueW),
|
|
490
|
+
this.padEnd(this.theme.bold("SOURCE"), sourceW),
|
|
491
|
+
this.theme.bold("DESCRIPTION"),
|
|
492
|
+
].join(gutter),
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const vs = this.visibleRows();
|
|
496
|
+
if (vs.length === 0) {
|
|
497
|
+
lines.push(this.theme.fg("muted", "(no settings in this section)"));
|
|
498
|
+
} else {
|
|
499
|
+
vs.forEach((r, i) => {
|
|
500
|
+
const selected = i === this.selectedIdx;
|
|
501
|
+
const prefix = selected ? "▶ " : " ";
|
|
502
|
+
const row = [
|
|
503
|
+
this.padEnd(prefix + r.label, keyW),
|
|
504
|
+
this.padEnd(r.valueText, valueW),
|
|
505
|
+
this.padEnd(r.sourceText, sourceW),
|
|
506
|
+
truncateToWidth(r.description, descW, "…"),
|
|
507
|
+
].join(gutter);
|
|
508
|
+
lines.push(selected ? this.theme.fg("accent", row) : row);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
lines.push(
|
|
513
|
+
this.theme.fg(
|
|
514
|
+
"dim",
|
|
515
|
+
"←/→ tab · ↑/↓ move · enter drill-in · esc exit",
|
|
516
|
+
),
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
return lines;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
render(width: number): string[] {
|
|
523
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
524
|
+
this.cachedWidth = width;
|
|
525
|
+
this.cachedLines = this.renderBody(width);
|
|
526
|
+
return this.cachedLines;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
handleInput(data: string): void {
|
|
530
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
531
|
+
this.done(this.resolveSelectedId());
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (this.keybindings.matches(data, "tui.select.cancel") || data === "\x1b") {
|
|
535
|
+
this.done(undefined);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (this.keybindings.matches(data, "tui.select.up")) {
|
|
539
|
+
this.move(-1);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (this.keybindings.matches(data, "tui.select.down")) {
|
|
543
|
+
this.move(+1);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// Left/right cycle sections. The Keybindings type only has up/down, so we
|
|
547
|
+
// match the raw CSI arrow-key sequences directly. Some terminals emit
|
|
548
|
+
// SS3 ("\x1bOD"/"\x1bOC") instead — fall back to those too.
|
|
549
|
+
if (data === "\x1b[D" || data === "\x1bOD") {
|
|
550
|
+
this.switchSection(-1);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (data === "\x1b[C" || data === "\x1bOC") {
|
|
554
|
+
this.switchSection(+1);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (data === "\t") {
|
|
558
|
+
this.switchSection(+1);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (data === "\x1b[Z") {
|
|
562
|
+
this.switchSection(-1);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
invalidate(): void {
|
|
568
|
+
this.cachedWidth = undefined;
|
|
569
|
+
this.cachedLines = undefined;
|
|
570
|
+
}
|
|
571
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.1",
|
|
4
4
|
"description": "Goal. Loop. Audit. Done. \u2014 a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor \u2014 only the read tools needed to verify your goal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dracon",
|
package/schemas/goal.schema.json
CHANGED
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
"stopReason": { "type": "string" },
|
|
47
47
|
"pauseReason": { "type": "string" },
|
|
48
48
|
"pauseSuggestedAction": { "type": "string" },
|
|
49
|
+
"interruptedAt": { "type": "string" },
|
|
50
|
+
"interruptedReason": { "type": "string" },
|
|
49
51
|
"activePath": { "type": "string" },
|
|
50
52
|
"archivedPath": { "type": "string" },
|
|
51
53
|
"usage": {
|