pi-jar 0.1.9 → 0.2.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 +15 -5
- package/extensions/index.ts +152 -14
- package/package.json +2 -2
- package/src/advisor.ts +226 -73
- package/src/composer.ts +21 -4
- package/src/context-diet.ts +94 -0
- package/src/delegate-worktree.ts +33 -13
- package/src/delegate.ts +11 -9
- package/src/model-roles.ts +29 -9
- package/src/profile-wizard.ts +61 -0
- package/src/profiles.ts +148 -0
- package/src/settings-ui.ts +46 -14
- package/src/settings.ts +4 -1
- package/src/shell-notifications.ts +33 -6
- package/src/shells.ts +4 -1
- package/src/side-model.ts +3 -1
- package/src/welcome.ts +6 -1
package/src/model-roles.ts
CHANGED
|
@@ -139,7 +139,12 @@ function writeConfig(file: string, config: RoleConfig): void {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
function profileRolePath(directory: string, profileId = "default"): string {
|
|
143
|
+
if (!/^[a-z0-9-]+$/.test(profileId)) throw new Error("Invalid profile ID");
|
|
144
|
+
return profileId === "default" ? join(directory, ROLE_FILE) : join(directory, "pi-jar-profiles", `${profileId}-roles.json`);
|
|
145
|
+
}
|
|
146
|
+
export const profileRoleFile = (directory: string, profileId?: string) => profileRolePath(directory, profileId);
|
|
147
|
+
export const projectRoleFile = (cwd: string, profileId?: string) => profileRolePath(join(cwd, ".pi"), profileId);
|
|
143
148
|
|
|
144
149
|
export function describeRole(row: RoleRow): string {
|
|
145
150
|
const target = row.error ? "⚠ " + row.error
|
|
@@ -152,6 +157,7 @@ export class ModelRoleManager {
|
|
|
152
157
|
private global: RoleConfig = readConfig(join(getAgentDir(), ROLE_FILE));
|
|
153
158
|
private project: RoleConfig = { version: 2, roles: {} };
|
|
154
159
|
private cwd: string | undefined;
|
|
160
|
+
private profileId = "default";
|
|
155
161
|
private active: string | undefined;
|
|
156
162
|
private readonly pi: ExtensionAPI;
|
|
157
163
|
/** True while pi-jar itself is switching models, so its own changes are not seen as manual. */
|
|
@@ -166,11 +172,21 @@ export class ModelRoleManager {
|
|
|
166
172
|
}
|
|
167
173
|
|
|
168
174
|
/** Reload global and project files; project assignments override global ones per role. */
|
|
169
|
-
load(cwd?: string): void {
|
|
170
|
-
|
|
175
|
+
load(cwd?: string, profileRoles?: RoleConfig, profileId = "default"): void {
|
|
176
|
+
const profileFile = profileRoleFile(getAgentDir(), profileId);
|
|
177
|
+
this.global = profileRoles && !existsSync(profileFile) ? parseRoleConfig(profileRoles) : readConfig(profileFile);
|
|
171
178
|
this.cwd = cwd;
|
|
172
|
-
this.
|
|
179
|
+
this.profileId = profileId;
|
|
180
|
+
this.project = cwd ? readConfig(projectRoleFile(cwd, profileId)) : { version: 2, roles: {} };
|
|
181
|
+
}
|
|
182
|
+
activeProfileId(): string { return this.profileId; }
|
|
183
|
+
/** Restore the label only, after a failed profile transaction restored model and effort. */
|
|
184
|
+
restoreActiveRole(role: string | undefined, ctx: ExtensionContext): void {
|
|
185
|
+
if (role !== undefined && !isRoleName(role)) throw new Error("Invalid role name");
|
|
186
|
+
this.active = role;
|
|
187
|
+
this.status(ctx);
|
|
173
188
|
}
|
|
189
|
+
profileRoleConfig(): RoleConfig { return structuredClone(this.global); }
|
|
174
190
|
|
|
175
191
|
private merged(): Record<string, string> { return { ...this.global.roles, ...this.project.roles }; }
|
|
176
192
|
activeRole(): string | undefined { return this.active; }
|
|
@@ -288,7 +304,7 @@ export class ModelRoleManager {
|
|
|
288
304
|
const epoch = this.manualEpoch;
|
|
289
305
|
return async () => {
|
|
290
306
|
// A model or effort the user picked during the workflow wins over the saved one.
|
|
291
|
-
if (this.manualEpoch !== epoch)
|
|
307
|
+
if (this.manualEpoch !== epoch) return;
|
|
292
308
|
this.applying = true;
|
|
293
309
|
try { if (previousModel) await this.pi.setModel(previousModel); this.pi.setThinkingLevel(previousThinking); }
|
|
294
310
|
finally { this.applying = false; }
|
|
@@ -310,6 +326,9 @@ export class ModelRoleManager {
|
|
|
310
326
|
return await this.activate(next, ctx) ? next : undefined;
|
|
311
327
|
}
|
|
312
328
|
|
|
329
|
+
/** Invalidate temporary role restorations after a profile change. */
|
|
330
|
+
invalidateTemporaryRestores(): void { this.manualEpoch++; this.active = undefined; }
|
|
331
|
+
|
|
313
332
|
/** Assign (or clear with undefined) a role in the chosen scope. */
|
|
314
333
|
update(role: string, spec: string | undefined, scope: RoleScope = this.scopeOf(role) ?? "global"): void {
|
|
315
334
|
if (!isRoleName(role)) throw new Error("Invalid role name: " + role);
|
|
@@ -320,7 +339,7 @@ export class ModelRoleManager {
|
|
|
320
339
|
const roles = { ...target.roles };
|
|
321
340
|
if (normalized) roles[role] = normalized; else delete roles[role];
|
|
322
341
|
const next: RoleConfig = { ...target, version: 2, roles };
|
|
323
|
-
writeConfig(scope === "project" ? projectRoleFile(this.cwd
|
|
342
|
+
writeConfig(scope === "project" ? projectRoleFile(this.cwd!, this.profileId) : profileRoleFile(getAgentDir(), this.profileId), next);
|
|
324
343
|
if (scope === "project") this.project = next; else this.global = next;
|
|
325
344
|
}
|
|
326
345
|
|
|
@@ -346,11 +365,11 @@ export class ModelRoleManager {
|
|
|
346
365
|
if (scope === "project" && !this.cwd) throw new Error("Project roles need a working directory");
|
|
347
366
|
const target = scope === "project" ? this.project : this.global;
|
|
348
367
|
const next: RoleConfig = { ...target, fallbacks: { ...target.fallbacks, [role]: [...new Set(normalized)] } };
|
|
349
|
-
writeConfig(scope === "project" ? projectRoleFile(this.cwd
|
|
368
|
+
writeConfig(scope === "project" ? projectRoleFile(this.cwd!, this.profileId) : profileRoleFile(getAgentDir(), this.profileId), next);
|
|
350
369
|
if (scope === "project") this.project = next; else this.global = next;
|
|
351
370
|
}
|
|
352
371
|
|
|
353
|
-
register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean } = {}): void {
|
|
372
|
+
register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean; beforeSessionStart?: (ctx: ExtensionContext) => { roles?: RoleConfig; profileId?: string } | undefined } = {}): void {
|
|
354
373
|
const manual = (_event: unknown, ctx: ExtensionContext) => {
|
|
355
374
|
if (this.applying) return;
|
|
356
375
|
this.manualEpoch++;
|
|
@@ -359,7 +378,8 @@ export class ModelRoleManager {
|
|
|
359
378
|
this.pi.on("model_select", manual);
|
|
360
379
|
this.pi.on("thinking_level_select", manual);
|
|
361
380
|
this.pi.on("session_start", async (_event, ctx) => {
|
|
362
|
-
|
|
381
|
+
const profile = options.beforeSessionStart?.(ctx);
|
|
382
|
+
this.load(ctx.cwd, profile?.roles, profile?.profileId);
|
|
363
383
|
this.active = undefined;
|
|
364
384
|
// Delegated children already received their role model/effort via CLI arguments.
|
|
365
385
|
if (options.activateDefault?.() !== false && this.resolveCandidates("default").length) await this.activate("default", ctx, true);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { JarVisualSettings } from "./settings.ts";
|
|
3
|
+
import type { JarProfile, ProfileStore } from "./profiles.ts";
|
|
4
|
+
import { isRoleName, normalizeSpec, type RoleConfig, type ModelRoleManager } from "./model-roles.ts";
|
|
5
|
+
|
|
6
|
+
export interface ProfileWizardOptions {
|
|
7
|
+
themes: readonly string[];
|
|
8
|
+
settings: JarVisualSettings;
|
|
9
|
+
roles: RoleConfig;
|
|
10
|
+
roleManager?: ModelRoleManager;
|
|
11
|
+
create(name: string, theme: string, settings: JarVisualSettings, roles: RoleConfig): JarProfile;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Every step is cancellable; creation happens only after the final confirmation. */
|
|
15
|
+
export async function createProfileWizard(ctx: ExtensionContext, store: ProfileStore, options: ProfileWizardOptions): Promise<JarProfile | undefined> {
|
|
16
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
|
|
17
|
+
const name = await ctx.ui.input("New profile name (1–32 characters)", "");
|
|
18
|
+
if (name === undefined) return undefined;
|
|
19
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) { ctx.ui.notify("Profile name cannot contain control characters", "warning"); return undefined; }
|
|
20
|
+
const normalized = name.trim().replace(/\s+/g, " ");
|
|
21
|
+
if (!normalized || normalized.length > 32 || /[\\/\0]/.test(normalized)) {
|
|
22
|
+
ctx.ui.notify("Profile name must be 1–32 characters and cannot contain slashes", "warning"); return undefined;
|
|
23
|
+
}
|
|
24
|
+
if (store.list().some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) {
|
|
25
|
+
ctx.ui.notify("A profile with that name already exists", "warning"); return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (!options.themes.length) { ctx.ui.notify("No themes are available for profiles", "warning"); return undefined; }
|
|
28
|
+
const selectedTheme = await ctx.ui.select("Choose a theme for " + normalized, ["Follow Pi", ...options.themes.filter((item) => item !== "follow")]);
|
|
29
|
+
if (!selectedTheme) return undefined;
|
|
30
|
+
const theme = selectedTheme === "Follow Pi" ? "follow" : selectedTheme;
|
|
31
|
+
const roles = structuredClone(options.roles);
|
|
32
|
+
const roleNames = options.roleManager?.list().map((role) => role.role) ?? [];
|
|
33
|
+
if (options.roleManager) {
|
|
34
|
+
for (const role of roleNames) {
|
|
35
|
+
const current = roles.roles[role];
|
|
36
|
+
const answer = await ctx.ui.input(`${role} role assignment (blank keeps current${current ? `: ${current}` : ""}; none clears)`, "");
|
|
37
|
+
if (answer === undefined) return undefined;
|
|
38
|
+
if (!answer.trim()) continue;
|
|
39
|
+
if (answer.trim().toLowerCase() === "none") { delete roles.roles[role]; continue; }
|
|
40
|
+
const model = normalizeSpec(answer.trim());
|
|
41
|
+
if (!model) { ctx.ui.notify("Enter a valid provider/model, @role, *, or none for " + role, "warning"); return undefined; }
|
|
42
|
+
roles.roles[role] = model;
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
const roleInput = await ctx.ui.input("Role assignments as role=provider/model, comma-separated (optional)", "");
|
|
46
|
+
if (roleInput === undefined) return undefined;
|
|
47
|
+
for (const assignment of roleInput.split(",").map((item) => item.trim()).filter(Boolean)) {
|
|
48
|
+
const split = assignment.indexOf("=");
|
|
49
|
+
if (split <= 0) { ctx.ui.notify("Use role=provider/model for each assignment", "warning"); return undefined; }
|
|
50
|
+
const role = assignment.slice(0, split).trim().toLowerCase();
|
|
51
|
+
const model = normalizeSpec(assignment.slice(split + 1).trim());
|
|
52
|
+
if (!isRoleName(role) || !model) { ctx.ui.notify("Invalid role assignment: " + assignment, "warning"); return undefined; }
|
|
53
|
+
roles.roles[role] = model;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const roleSummary = Object.entries(roles.roles).map(([role, target]) => `${role} → ${target}`).join("\n") || "No roles assigned";
|
|
57
|
+
const confirmed = await ctx.ui.confirm("Create profile", `${normalized}\nTheme: ${theme === "follow" ? "Follow Pi" : theme}\n${roleSummary}`);
|
|
58
|
+
if (!confirmed) return undefined;
|
|
59
|
+
try { return options.create(normalized, theme, options.settings, roles); }
|
|
60
|
+
catch (error) { ctx.ui.notify("Could not create profile: " + (error as Error).message, "error"); return undefined; }
|
|
61
|
+
}
|
package/src/profiles.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isRoleName, normalizeSpec, parseRoleConfig, type RoleConfig } from "./model-roles.ts";
|
|
4
|
+
import { isMaxSubagents, defaultVisualSettings, loadVisualSettings, type JarVisualSettings } from "./settings.ts";
|
|
5
|
+
import { ACCENT_NAMES } from "./accent.ts";
|
|
6
|
+
import { FOOTER_FIELDS } from "./footer-settings.ts";
|
|
7
|
+
import { ICON_SETS } from "./icons.ts";
|
|
8
|
+
|
|
9
|
+
export interface JarProfile { id: string; name: string; theme: string; settings: JarVisualSettings; roles: RoleConfig }
|
|
10
|
+
interface ProfileDocument { version: 1; active: string; profiles: JarProfile[] }
|
|
11
|
+
export const PROFILE_FILE = "pi-jar-profiles.json";
|
|
12
|
+
export const DEFAULT_PROFILE = "Default";
|
|
13
|
+
export const PROFILE_ENTRY = "pi-jar.profile";
|
|
14
|
+
export function conversationStarted(entries: readonly unknown[]): boolean {
|
|
15
|
+
return entries.some((entry) => isPlainObject(entry) && (entry.role === "user"
|
|
16
|
+
|| (entry.type === "message" && isPlainObject(entry.message) && entry.message.role === "user")));
|
|
17
|
+
}
|
|
18
|
+
export function pinnedProfileId(entries: readonly unknown[]): string | undefined {
|
|
19
|
+
for (const entry of [...entries].reverse()) {
|
|
20
|
+
if (isPlainObject(entry) && entry.type === "custom" && entry.customType === PROFILE_ENTRY
|
|
21
|
+
&& isPlainObject(entry.data) && entry.data.version === 1 && typeof entry.data.id === "string") return entry.data.id;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const safeName = (value: string) => value.trim().replace(/\s+/g, " ");
|
|
26
|
+
const profileId = () => `profile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
27
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
|
|
29
|
+
function validProfileName(value: unknown): value is string {
|
|
30
|
+
return typeof value === "string" && value.length > 0 && value.length <= 32 && !/[\\/\0\u0001-\u001f\u007f-\u009f]/.test(value) && value.trim() === value;
|
|
31
|
+
}
|
|
32
|
+
function normalizeProfile(value: unknown): JarProfile | undefined {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
34
|
+
const raw = value as Record<string, unknown>;
|
|
35
|
+
if (!validProfileName(raw.name) || typeof raw.theme !== "string" || !raw.theme || !isPlainObject(raw.settings)
|
|
36
|
+
|| raw.settings.version !== 1 || !isPlainObject(raw.roles) || raw.roles.version !== 2 || !isPlainObject(raw.roles.roles)) return undefined;
|
|
37
|
+
const settings = raw.settings;
|
|
38
|
+
const footer = settings.footer;
|
|
39
|
+
if (!["accent", "animations", "ui", "composer", "mascot", "suggestions", "goalRounds", "maxSubagents", "advisor", "advisorGates", "icons", "footer"].every((key) => key in settings)
|
|
40
|
+
|| !isPlainObject(settings.footer) || typeof settings.animations !== "boolean" || typeof settings.ui !== "boolean"
|
|
41
|
+
|| typeof settings.composer !== "boolean" || typeof settings.mascot !== "boolean" || typeof settings.suggestions !== "boolean"
|
|
42
|
+
|| typeof settings.advisor !== "boolean" || typeof settings.advisorGates !== "boolean"
|
|
43
|
+
|| (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean") || !Number.isInteger(settings.goalRounds)
|
|
44
|
+
|| (settings.goalRounds as number) < 1 || (settings.goalRounds as number) > 50
|
|
45
|
+
|| !isMaxSubagents(settings.maxSubagents) || typeof settings.accent !== "string"
|
|
46
|
+
|| !(settings.accent === "follow" || settings.accent === "default" || ACCENT_NAMES.includes(settings.accent as never))
|
|
47
|
+
|| !ICON_SETS.includes(settings.icons as never)
|
|
48
|
+
|| !isPlainObject(footer) || !FOOTER_FIELDS.every((field) => typeof footer[field] === "boolean")) return undefined;
|
|
49
|
+
if (raw.name === DEFAULT_PROFILE && raw.id !== "default") return undefined;
|
|
50
|
+
if (raw.name !== DEFAULT_PROFILE && (typeof raw.id !== "string" || !/^profile-[a-z0-9-]{8,64}$/.test(raw.id))) return undefined;
|
|
51
|
+
for (const [role, spec] of Object.entries(raw.roles.roles)) if (!isRoleName(role) || typeof spec !== "string" || !normalizeSpec(spec)) return undefined;
|
|
52
|
+
return { id: raw.id as string, name: raw.name, theme: raw.theme, settings: { ...raw.settings, contextDiet: settings.contextDiet ?? false } as unknown as JarVisualSettings, roles: parseRoleConfig(raw.roles) };
|
|
53
|
+
}
|
|
54
|
+
function atomicSave(file: string, document: ProfileDocument): string {
|
|
55
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
56
|
+
const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
57
|
+
const text = JSON.stringify(document, null, 2) + "\n";
|
|
58
|
+
try { writeFileSync(temporary, text, { flag: "wx" }); renameSync(temporary, file); }
|
|
59
|
+
catch (error) { try { unlinkSync(temporary); } catch {} throw error; }
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class ProfileStore {
|
|
64
|
+
private document: ProfileDocument;
|
|
65
|
+
private savedContents: string | undefined;
|
|
66
|
+
readonly directory: string;
|
|
67
|
+
constructor(directory: string, initialTheme = "pi-jar-dark", initialRoles: RoleConfig = { version: 2, roles: {} }) {
|
|
68
|
+
this.directory = directory;
|
|
69
|
+
const file = join(directory, PROFILE_FILE);
|
|
70
|
+
let loaded: ProfileDocument | undefined;
|
|
71
|
+
let missing = false;
|
|
72
|
+
try {
|
|
73
|
+
this.savedContents = readFileSync(file, "utf8");
|
|
74
|
+
const raw: unknown = JSON.parse(this.savedContents);
|
|
75
|
+
if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.profiles) || typeof raw.active !== "string") throw new Error("Unsupported or malformed pi-jar profiles file");
|
|
76
|
+
const profiles: JarProfile[] = [];
|
|
77
|
+
const names = new Set<string>(); const ids = new Set<string>();
|
|
78
|
+
for (const entry of raw.profiles) {
|
|
79
|
+
const profile = normalizeProfile(entry);
|
|
80
|
+
if (!profile) throw new Error("Malformed profile entry");
|
|
81
|
+
const name = profile.name.toLocaleLowerCase();
|
|
82
|
+
if (names.has(name) || ids.has(profile.id)) throw new Error("Duplicate profile name or id");
|
|
83
|
+
names.add(name); ids.add(profile.id); profiles.push(profile);
|
|
84
|
+
}
|
|
85
|
+
if (profiles.filter((profile) => profile.name === DEFAULT_PROFILE && profile.id === "default").length !== 1) throw new Error("Missing or invalid Default profile");
|
|
86
|
+
const active = profiles.find((profile) => profile.name === raw.active || profile.id === raw.active)?.name;
|
|
87
|
+
if (!active) throw new Error("Active profile does not exist");
|
|
88
|
+
loaded = { version: 1, active, profiles };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") missing = true;
|
|
91
|
+
else throw new Error(`Cannot load ${PROFILE_FILE}: ${(error as Error).message}`);
|
|
92
|
+
}
|
|
93
|
+
if (!loaded && missing) {
|
|
94
|
+
let legacy = defaultVisualSettings();
|
|
95
|
+
try { legacy = loadVisualSettings(directory); } catch { /* default settings */ }
|
|
96
|
+
loaded = { version: 1, active: DEFAULT_PROFILE, profiles: [{ id: "default", name: DEFAULT_PROFILE, theme: initialTheme, settings: legacy, roles: parseRoleConfig(initialRoles) }] };
|
|
97
|
+
}
|
|
98
|
+
this.document = loaded!;
|
|
99
|
+
if (missing) this.persist();
|
|
100
|
+
}
|
|
101
|
+
private persist(next = this.document): void {
|
|
102
|
+
const file = join(this.directory, PROFILE_FILE);
|
|
103
|
+
let current: string | undefined;
|
|
104
|
+
try { current = readFileSync(file, "utf8"); }
|
|
105
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
106
|
+
if (current !== this.savedContents) throw new Error("Profiles changed in another session or externally; reload before saving");
|
|
107
|
+
this.savedContents = atomicSave(file, next);
|
|
108
|
+
}
|
|
109
|
+
list(): JarProfile[] { return this.document.profiles.map((profile) => structuredClone(profile)); }
|
|
110
|
+
active(): JarProfile { return structuredClone(this.document.profiles.find((profile) => profile.name === this.document.active)!); }
|
|
111
|
+
get activeName(): string { return this.document.active; }
|
|
112
|
+
update(name: string, patch: Partial<Pick<JarProfile, "theme" | "settings">>): JarProfile {
|
|
113
|
+
const index = this.document.profiles.findIndex((profile) => profile.name === name);
|
|
114
|
+
if (index < 0) throw new Error("Unknown profile: " + name);
|
|
115
|
+
const profiles = [...this.document.profiles];
|
|
116
|
+
const profile = normalizeProfile(structuredClone({ ...profiles[index]!, ...patch }));
|
|
117
|
+
if (!profile) throw new Error("Invalid profile settings or theme");
|
|
118
|
+
profiles[index] = profile;
|
|
119
|
+
const next = { ...this.document, profiles };
|
|
120
|
+
this.persist(next);
|
|
121
|
+
this.document = next;
|
|
122
|
+
return structuredClone(profile);
|
|
123
|
+
}
|
|
124
|
+
add(name: string, theme: string, settings: JarVisualSettings, roles: RoleConfig = { version: 2, roles: {} }): JarProfile {
|
|
125
|
+
if (typeof name !== "string" || /[\u0000-\u001f\u007f-\u009f]/.test(name)) throw new Error("Profile name cannot contain control characters");
|
|
126
|
+
const normalized = safeName(name);
|
|
127
|
+
if (!validProfileName(normalized)) throw new Error("Profile name must be 1–32 characters and cannot contain slashes");
|
|
128
|
+
if (this.document.profiles.some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) throw new Error("A profile with that name already exists");
|
|
129
|
+
const id = profileId();
|
|
130
|
+
const profile = normalizeProfile({ id, name: normalized, theme, settings: structuredClone(settings), roles });
|
|
131
|
+
if (!profile) throw new Error("Invalid profile settings, theme, or roles");
|
|
132
|
+
const next = { ...this.document, profiles: [...this.document.profiles, profile] };
|
|
133
|
+
this.persist(next);
|
|
134
|
+
this.document = next;
|
|
135
|
+
return structuredClone(profile);
|
|
136
|
+
}
|
|
137
|
+
activate(name: string): JarProfile {
|
|
138
|
+
if (!this.document.profiles.some((profile) => profile.name === name)) throw new Error("Unknown profile: " + name);
|
|
139
|
+
const next = { ...this.document, active: name };
|
|
140
|
+
this.persist(next);
|
|
141
|
+
this.document = next;
|
|
142
|
+
return this.active();
|
|
143
|
+
}
|
|
144
|
+
cycle(): JarProfile {
|
|
145
|
+
const index = this.document.profiles.findIndex((profile) => profile.name === this.document.active);
|
|
146
|
+
return this.activate(this.document.profiles[(index + 1) % this.document.profiles.length]!.name);
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/settings-ui.ts
CHANGED
|
@@ -3,6 +3,9 @@ import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } fr
|
|
|
3
3
|
import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
|
|
4
4
|
import { ICON_SETS } from "./icons.ts";
|
|
5
5
|
import { GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
|
|
6
|
+
import { createProfileWizard } from "./profile-wizard.ts";
|
|
7
|
+
import type { JarProfile, ProfileStore } from "./profiles.ts";
|
|
8
|
+
import type { RoleConfig, ModelRoleManager } from "./model-roles.ts";
|
|
6
9
|
|
|
7
10
|
const LABELS: Record<FooterField, string> = {
|
|
8
11
|
model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory",
|
|
@@ -10,8 +13,7 @@ const LABELS: Record<FooterField, string> = {
|
|
|
10
13
|
extras: "Extension statuses", branch: "Git branch"
|
|
11
14
|
};
|
|
12
15
|
|
|
13
|
-
type Page = "appearance" | "footer" | "pi";
|
|
14
|
-
const PAGES: Page[] = ["appearance", "footer", "pi"];
|
|
16
|
+
type Page = "appearance" | "footer" | "pi" | "profiles";
|
|
15
17
|
|
|
16
18
|
/** Pi-level preferences pi-jar can toggle (written to Pi's own settings). */
|
|
17
19
|
export interface PiPreferences {
|
|
@@ -26,18 +28,22 @@ export async function openJarSettings(
|
|
|
26
28
|
current: () => JarVisualSettings,
|
|
27
29
|
update: (settings: JarVisualSettings) => void,
|
|
28
30
|
availableAccents: readonly string[],
|
|
29
|
-
pi?: PiPreferences
|
|
31
|
+
pi?: PiPreferences,
|
|
32
|
+
profiles?: { store: ProfileStore; themes: readonly string[]; roles: () => RoleConfig; roleManager?: ModelRoleManager; create: (name: string, theme: string, settings: JarVisualSettings, roles: RoleConfig) => JarProfile; select(name: string): Promise<void>; setTheme(name: string): void }
|
|
30
33
|
): Promise<void> {
|
|
31
34
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
const PAGES: Page[] = profiles ? ["appearance", "footer", "pi", "profiles"] : ["appearance", "footer", "pi"];
|
|
36
|
+
let lastPage: Page = "appearance";
|
|
37
|
+
while (true) {
|
|
38
|
+
const action = await ctx.ui.custom<"create" | "switch" | "theme" | undefined>((tui, theme, _keys, done) => {
|
|
39
|
+
let page: Page = lastPage;
|
|
34
40
|
let selected = 0;
|
|
35
41
|
let width = 64;
|
|
36
42
|
const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
|
|
37
43
|
name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
|
|
38
44
|
let choosing: "goalRounds" | "maxSubagents" | undefined;
|
|
39
45
|
const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : GOAL_ROUND_CHOICES;
|
|
40
|
-
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ?
|
|
46
|
+
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 7 : page === "profiles" ? 3 : 7;
|
|
41
47
|
const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : 2; choosing = undefined; };
|
|
42
48
|
const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
|
|
43
49
|
const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
|
|
@@ -50,6 +56,10 @@ export async function openJarSettings(
|
|
|
50
56
|
if (choosing === "maxSubagents") update({ ...state, maxSubagents: MAX_SUBAGENT_CHOICES[index]! });
|
|
51
57
|
else update({ ...state, goalRounds: GOAL_ROUND_CHOICES[index]! });
|
|
52
58
|
finishChoice();
|
|
59
|
+
} else if (page === "profiles") {
|
|
60
|
+
lastPage = page;
|
|
61
|
+
done(index === 0 ? "switch" : index === 1 ? "theme" : "create");
|
|
62
|
+
return;
|
|
53
63
|
} else if (page === "footer") {
|
|
54
64
|
const field = FOOTER_FIELDS[index];
|
|
55
65
|
if (field) update({ ...state, footer: { ...state.footer, [field]: !state.footer[field] } });
|
|
@@ -66,6 +76,7 @@ export async function openJarSettings(
|
|
|
66
76
|
}
|
|
67
77
|
else if (index === 4) update({ ...state, advisor: !state.advisor });
|
|
68
78
|
else if (index === 5) update({ ...state, advisorGates: !state.advisorGates });
|
|
79
|
+
else if (index === 6) update({ ...state, contextDiet: !state.contextDiet });
|
|
69
80
|
} else if (index === 0) {
|
|
70
81
|
const at = Math.max(0, accents.indexOf(state.accent));
|
|
71
82
|
update({ ...state, accent: accents[(at + 1) % accents.length]! });
|
|
@@ -90,7 +101,7 @@ export async function openJarSettings(
|
|
|
90
101
|
invalidate() {},
|
|
91
102
|
handleInput(data: string) {
|
|
92
103
|
if (matchesKey(data, Key.escape) || data === "q") {
|
|
93
|
-
if (choosing) { finishChoice(); tui.requestRender(); } else done();
|
|
104
|
+
if (choosing) { finishChoice(); tui.requestRender(); } else done(undefined);
|
|
94
105
|
return;
|
|
95
106
|
}
|
|
96
107
|
if (choosing && (matchesKey(data, Key.tab) || matchesKey(data, Key.right)
|
|
@@ -112,19 +123,20 @@ export async function openJarSettings(
|
|
|
112
123
|
tui.requestRender(); return { handled: true };
|
|
113
124
|
}
|
|
114
125
|
if (event.type !== "click" || event.button !== "left") return;
|
|
115
|
-
if (event.y === 0 && event.x >= width - 4) { done(); return { handled: true }; }
|
|
126
|
+
if (event.y === 0 && event.x >= width - 4) { done(undefined); return { handled: true }; }
|
|
116
127
|
if (event.y === 1) {
|
|
117
128
|
choosing = undefined;
|
|
118
129
|
if (event.x >= 2 && event.x < Math.min(width - 2, 19)) { page = "appearance"; selected = 0; }
|
|
119
130
|
else if (event.x >= 19 && event.x < Math.min(width - 2, 32)) { page = "footer"; selected = 0; }
|
|
120
|
-
else if (event.x >= 32 && event.x < width - 2) { page = "pi"; selected = 0; }
|
|
131
|
+
else if (event.x >= 32 && event.x < Math.min(width - 2, 41)) { page = "pi"; selected = 0; }
|
|
132
|
+
else if (profiles && event.x >= 41 && event.x < width - 2) { page = "profiles"; selected = 0; }
|
|
121
133
|
else return;
|
|
122
134
|
tui.requestRender(); return { handled: true, focus: true };
|
|
123
135
|
}
|
|
124
136
|
if (event.y >= 5 && event.y < 5 + visibleCount() && event.x >= 2 && event.x < width - 2) {
|
|
125
137
|
selected = firstVisible() + event.y - 5; apply(selected); return { handled: true, focus: true };
|
|
126
138
|
}
|
|
127
|
-
if (event.y === 5 + visibleCount() && event.x >= 2 && event.x < width - 2) { done(); return { handled: true }; }
|
|
139
|
+
if (event.y === 5 + visibleCount() && event.x >= 2 && event.x < width - 2) { done(undefined); return { handled: true }; }
|
|
128
140
|
},
|
|
129
141
|
render(available: number): string[] {
|
|
130
142
|
width = Math.max(8, available);
|
|
@@ -136,7 +148,8 @@ export async function openJarSettings(
|
|
|
136
148
|
+ "─".repeat(Math.max(0, width - 4 - visibleWidth(title))) + "×╮");
|
|
137
149
|
const tabs = line(theme.fg(page === "appearance" ? "accent" : "muted", "[ Appearance ]")
|
|
138
150
|
+ " " + theme.fg(page === "footer" ? "accent" : "muted", "[ Footer ]")
|
|
139
|
-
+ " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]")
|
|
151
|
+
+ " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]")
|
|
152
|
+
+ (profiles ? " " + theme.fg(page === "profiles" ? "accent" : "muted", "[ Profiles ]") : ""));
|
|
140
153
|
const prefs = pi?.get();
|
|
141
154
|
const divider = theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 2)) + "┤");
|
|
142
155
|
const allRows = choosing
|
|
@@ -149,7 +162,12 @@ export async function openJarSettings(
|
|
|
149
162
|
row(2, "Goal auto rounds", String(state.goalRounds), true),
|
|
150
163
|
row(3, "Max subagents (retained live pool)", String(state.maxSubagents), true),
|
|
151
164
|
row(4, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
|
|
152
|
-
row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates)
|
|
165
|
+
row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates),
|
|
166
|
+
row(6, "Context diet (prior-turn reasoning)", state.contextDiet ? "ON" : "OFF", state.contextDiet)
|
|
167
|
+
] : page === "profiles" ? [
|
|
168
|
+
row(0, "Active profile", profiles!.store.activeName, true),
|
|
169
|
+
row(1, "Theme", profiles!.store.active().theme, true),
|
|
170
|
+
row(2, "Create profile…", "OPEN", true)
|
|
153
171
|
] : [
|
|
154
172
|
row(0, "Accent", state.accent === "follow" ? "FOLLOW PI" : state.accent.toUpperCase(), true),
|
|
155
173
|
row(1, "Motion", state.animations ? "ON" : "OFF", state.animations),
|
|
@@ -163,11 +181,12 @@ export async function openJarSettings(
|
|
|
163
181
|
const rows = allRows.slice(start, start + pageSize());
|
|
164
182
|
return [top, tabs, divider,
|
|
165
183
|
line(theme.fg("accent", choosing === "maxSubagents" ? " MAX SUBAGENTS · RETAINED LIVE POOL"
|
|
166
|
-
: choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
|
|
184
|
+
: choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : page === "profiles" ? " PROFILES" : " APPEARANCE & MOTION")),
|
|
167
185
|
line(theme.fg("dim", explainPool() ? " Live pool includes idle/paused subagents"
|
|
168
186
|
: choosing ? " Select a value; Enter/click to save, Esc to cancel"
|
|
169
187
|
: page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
|
|
170
|
-
: page === "pi" ? " Fullscreen mode enables clicks and copy-on-select"
|
|
188
|
+
: page === "pi" ? " Fullscreen mode enables clicks and copy-on-select"
|
|
189
|
+
: page === "profiles" ? " Switch only in a fresh, idle session" : " Accent cycles through loaded themes")),
|
|
171
190
|
...rows,
|
|
172
191
|
line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
|
|
173
192
|
: " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
|
|
@@ -176,4 +195,17 @@ export async function openJarSettings(
|
|
|
176
195
|
}
|
|
177
196
|
};
|
|
178
197
|
});
|
|
198
|
+
if (!action || !profiles) return;
|
|
199
|
+
try {
|
|
200
|
+
if (action === "create") {
|
|
201
|
+
await createProfileWizard(ctx, profiles.store, { themes: profiles.themes, settings: { ...current(), accent: "follow" }, roles: profiles.roles(), roleManager: profiles.roleManager, create: profiles.create });
|
|
202
|
+
} else if (action === "switch") {
|
|
203
|
+
const name = await ctx.ui.select("Choose a profile", profiles.store.list().map((profile) => profile.name));
|
|
204
|
+
if (name) await profiles.select(name);
|
|
205
|
+
} else {
|
|
206
|
+
const name = await ctx.ui.select("Profile theme", ["Follow Pi", ...profiles.themes.filter((name) => name !== "follow")]);
|
|
207
|
+
if (name) profiles.setTheme(name === "Follow Pi" ? "follow" : name);
|
|
208
|
+
}
|
|
209
|
+
} catch (error) { ctx.ui.notify("Could not change profile: " + (error as Error).message, "error"); }
|
|
210
|
+
}
|
|
179
211
|
}
|
package/src/settings.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface JarVisualSettings {
|
|
|
23
23
|
advisor: boolean;
|
|
24
24
|
/** Consult the advisor automatically when the agent repeats a tool call or keeps failing. */
|
|
25
25
|
advisorGates: boolean;
|
|
26
|
+
/** Opt-in provider-context pruning of reasoning from completed earlier user turns. */
|
|
27
|
+
contextDiet: boolean;
|
|
26
28
|
/** Glyph set for footer, composer and views: unicode, Nerd Font icons, or plain ascii. */
|
|
27
29
|
icons: IconSet;
|
|
28
30
|
footer: FooterSettings;
|
|
@@ -37,7 +39,7 @@ export function isMaxSubagents(value: unknown): value is MaxSubagents {
|
|
|
37
39
|
|
|
38
40
|
export const SETTINGS_FILE = "pi-jar-settings.json";
|
|
39
41
|
export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
|
|
40
|
-
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 4, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
|
|
42
|
+
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 4, advisor: true, advisorGates: true, contextDiet: false, icons: "unicode", footer: { ...footer } };
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
|
|
@@ -70,6 +72,7 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
|
|
|
70
72
|
maxSubagents: isMaxSubagents(value.maxSubagents) ? value.maxSubagents : fallback.maxSubagents,
|
|
71
73
|
advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
|
|
72
74
|
advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
|
|
75
|
+
contextDiet: typeof value.contextDiet === "boolean" ? value.contextDiet : fallback.contextDiet,
|
|
73
76
|
icons: ICON_SETS.some((set) => set === value.icons) ? value.icons as IconSet : fallback.icons,
|
|
74
77
|
footer: Object.fromEntries(FOOTER_FIELDS.map((field) => [field,
|
|
75
78
|
typeof footer[field] === "boolean" ? footer[field] : fallback.footer[field]])) as FooterSettings
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI, CustomMessageEntryDraft } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, CustomMessageEntryDraft } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { cleanText } from "./status.ts";
|
|
4
4
|
import { describe, SHELL_MESSAGE, type ShellEvent, type ShellManager } from "./shells.ts";
|
|
@@ -34,14 +34,40 @@ export function registerShellNotifications(pi: ExtensionAPI, manager: () => Shel
|
|
|
34
34
|
let interrupted = false;
|
|
35
35
|
let deliveryFailures = 0;
|
|
36
36
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
37
|
+
let feedbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
38
|
+
let context: ExtensionContext | undefined;
|
|
39
|
+
const shown = new Set<string>();
|
|
40
|
+
const eventKey = (event: ShellEvent) => `${event.kind}:${event.job.id}`;
|
|
37
41
|
const warned = new Set<string>();
|
|
38
|
-
const cancelTimer = () => { clearTimeout(timer); timer = undefined; };
|
|
42
|
+
const cancelTimer = () => { clearTimeout(timer); timer = undefined; clearTimeout(feedbackTimer); feedbackTimer = undefined; };
|
|
39
43
|
const take = () => {
|
|
40
44
|
cancelTimer();
|
|
41
45
|
const events = manager()?.takeNotifications() ?? [];
|
|
46
|
+
shown.clear();
|
|
42
47
|
return events.length ? shellNotification(events) : undefined;
|
|
43
48
|
};
|
|
44
49
|
const notify = () => {
|
|
50
|
+
// UI feedback must not wait for the remote model to finish reasoning. Keep model transport
|
|
51
|
+
// boundary-safe and acknowledgeable: this preview does not consume or enqueue a follow-up.
|
|
52
|
+
if (active && !interrupted && context?.hasUI && !feedbackTimer && manager()?.pendingNotifications()) {
|
|
53
|
+
const owner = manager(), ctx = context;
|
|
54
|
+
feedbackTimer = setTimeout(() => {
|
|
55
|
+
feedbackTimer = undefined;
|
|
56
|
+
if (!active || interrupted || owner !== manager() || ctx !== context) return;
|
|
57
|
+
const snapshot = owner?.notificationSnapshot() ?? [];
|
|
58
|
+
const known = new Set(snapshot.map(eventKey));
|
|
59
|
+
for (const key of shown) if (!known.has(key)) shown.delete(key);
|
|
60
|
+
const events = snapshot.filter(event => !shown.has(eventKey(event)));
|
|
61
|
+
if (!events.length) return;
|
|
62
|
+
const summary = shellNotification(events).details as ShellNotificationDetails;
|
|
63
|
+
const text = `${summary.summary}: ${events.slice(0, 3).map(event => describe(event.job)).join("; ")}. Model delivery waits for a safe turn boundary.`;
|
|
64
|
+
try {
|
|
65
|
+
ctx.ui.notify(cleanText(text, 420), events.some(event => event.job.exitCode !== 0 && event.kind === "exit") ? "warning" : "info");
|
|
66
|
+
for (const event of events) shown.add(eventKey(event));
|
|
67
|
+
} catch (error) { console.error("pi-jar: could not show shell completion", error); }
|
|
68
|
+
}, 300);
|
|
69
|
+
feedbackTimer.unref?.();
|
|
70
|
+
}
|
|
45
71
|
if (active || interrupted || timer || !manager()?.pendingNotifications()) return;
|
|
46
72
|
const owner = manager();
|
|
47
73
|
timer = setTimeout(() => {
|
|
@@ -59,16 +85,17 @@ export function registerShellNotifications(pi: ExtensionAPI, manager: () => Shel
|
|
|
59
85
|
}, 300);
|
|
60
86
|
timer.unref?.();
|
|
61
87
|
};
|
|
62
|
-
const reset = () => { cancelTimer(); active = false; interrupted = false; deliveryFailures = 0; warned.clear(); };
|
|
88
|
+
const reset = () => { cancelTimer(); active = false; interrupted = false; context = undefined; deliveryFailures = 0; warned.clear(); shown.clear(); };
|
|
63
89
|
|
|
64
90
|
pi.registerMessageRenderer?.<ShellNotificationDetails>(SHELL_MESSAGE, (message, { expanded }, theme) => {
|
|
65
91
|
const text = typeof message.content === "string" ? message.content : "Background shells";
|
|
66
92
|
const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Background shells";
|
|
67
93
|
return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for status"), 0, 0);
|
|
68
94
|
});
|
|
69
|
-
pi.on?.("session_start", reset);
|
|
95
|
+
pi.on?.("session_start", (_event, ctx) => { reset(); context = ctx; });
|
|
70
96
|
pi.on?.("session_shutdown", reset);
|
|
71
|
-
pi.on?.("before_agent_start", () => {
|
|
97
|
+
pi.on?.("before_agent_start", (_event, ctx) => {
|
|
98
|
+
context = ctx;
|
|
72
99
|
active = true;
|
|
73
100
|
interrupted = false;
|
|
74
101
|
const entry = take();
|
|
@@ -76,7 +103,7 @@ export function registerShellNotifications(pi: ExtensionAPI, manager: () => Shel
|
|
|
76
103
|
const { type: _type, ...message } = entry;
|
|
77
104
|
return { message };
|
|
78
105
|
});
|
|
79
|
-
pi.on?.("agent_start", () => { active = true; cancelTimer(); });
|
|
106
|
+
pi.on?.("agent_start", (_event, ctx) => { context = ctx; active = true; cancelTimer(); });
|
|
80
107
|
pi.on?.("turn_end", event => {
|
|
81
108
|
if (event.outcome !== "completed" || event.context?.canContinue === false) return;
|
|
82
109
|
const entry = take();
|
package/src/shells.ts
CHANGED
|
@@ -125,6 +125,8 @@ export class ShellManager {
|
|
|
125
125
|
for (const event of events) if (this.jobs.has(event.job.id) && !this.pending.has(event.job.id)) this.pending.set(event.job.id, event);
|
|
126
126
|
}
|
|
127
127
|
pendingNotifications(): number { return this.pending.size; }
|
|
128
|
+
/** Non-consuming status snapshots for immediate UI feedback; never copy or expose log lines. */
|
|
129
|
+
notificationSnapshot(): ShellEvent[] { return [...this.pending.values()].map(event => ({ ...event, job: { ...event.job, lines: [] } })); }
|
|
128
130
|
verificationPending(): Array<Omit<ShellJob, "lines">> {
|
|
129
131
|
return [...this.jobs.values()].filter(entry => !entry.finished && entry.job.purpose !== "service").map(({ job }) => {
|
|
130
132
|
const { lines: _lines, ...summary } = job;
|
|
@@ -438,6 +440,7 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
|
|
|
438
440
|
promptGuidelines: [
|
|
439
441
|
"Use jar_shell start for servers, watchers and slow commands; set watch to a regex for the line you are waiting for (e.g. \"listening|ready|error\").",
|
|
440
442
|
"Do not poll: work while commands run, then use wait with explicit ids for relevant checks before the final summary. A bounded wait returns pending jobs honestly; never claim success without their exit codes. Do not wait for services/watchers. output, peek, list and wait acknowledge observed events, preventing stale replay.",
|
|
443
|
+
"Completion events cannot interrupt an in-flight model response. If no useful work remains, call wait for the relevant finite jobs immediately instead of thinking or waiting for an automatic wake-up.",
|
|
441
444
|
"Notifications contain coalesced status, not log dumps. Inspect output only for relevant diagnostics. Mark never-ending jobs purpose: service and kill shells you no longer need."
|
|
442
445
|
],
|
|
443
446
|
parameters: Parameters,
|
|
@@ -453,7 +456,7 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
|
|
|
453
456
|
case "start": {
|
|
454
457
|
const job = manager.start({ command: params.command ?? "", cwd: ctx.cwd, ...(params.name ? { name: params.name } : {}), ...(params.watch ? { watch: params.watch } : {}),
|
|
455
458
|
...(params.notify !== undefined ? { notify: params.notify } : {}), ...(params.purpose ? { purpose: params.purpose } : {}) });
|
|
456
|
-
return reply(`Started ${describe(job)} (pid ${job.pid ?? "?"}). ${job.notify ? "
|
|
459
|
+
return reply(`Started ${describe(job)} (pid ${job.pid ?? "?"}). ${job.notify ? "Completion reaches the model at the next safe turn boundary (or wakes it when idle). If no other useful work remains, use wait now." : "Notifications are off; check it with output."}`);
|
|
457
460
|
}
|
|
458
461
|
case "output": {
|
|
459
462
|
if (!params.id) return reply("output needs an id.");
|
package/src/side-model.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface SideAnswer { text: string; model: string }
|
|
|
30
30
|
* Ask the model assigned to `role` (or the current model) a one-shot question outside the
|
|
31
31
|
* conversation. Tries configured fallbacks in order on failure; cancellation never retries.
|
|
32
32
|
*/
|
|
33
|
-
export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, role: string,
|
|
33
|
+
export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, usage: Pick<SideUsage, "add"> | undefined, role: string,
|
|
34
34
|
systemPrompt: string, prompt: string, signal?: AbortSignal): Promise<SideAnswer> {
|
|
35
35
|
const fallbacks = roles.fallbackSpecs?.(role) ?? [];
|
|
36
36
|
const failures: string[] = [];
|
|
@@ -52,6 +52,8 @@ export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, us
|
|
|
52
52
|
const result = await ctx.modelRegistry.streamSimple(model,
|
|
53
53
|
{ systemPrompt, messages: [{ role: "user", content: prompt, timestamp: Date.now() }] },
|
|
54
54
|
{ ...(reasoning ? { reasoning } : {}), ...(signal ? { signal } : {}) }).result();
|
|
55
|
+
// Callers with detached work can supply a session-generation-safe usage sink.
|
|
56
|
+
// Returned attempts (including cancelled attempts) still account for actual usage.
|
|
55
57
|
const cost = result.usage?.cost?.total;
|
|
56
58
|
usage?.add({ role, model: name, input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cacheRead: result.usage?.cacheRead ?? 0,
|
|
57
59
|
cacheWrite: result.usage?.cacheWrite ?? 0, cost: Number.isFinite(cost) ? cost! : 0, at: Date.now() });
|