@a-t-h-i/bot-lobby 0.6.13 → 0.6.15
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 +70 -79
- package/package.json +4 -2
- package/prompts/master.md +3 -2
- package/prompts/qa.md +33 -11
- package/prompts/reviewer.md +5 -2
- package/prompts/worker.md +5 -2
- package/src/ask/relay.ts +1 -2
- package/src/ask/tool.ts +4 -6
- package/src/ask/types.ts +16 -0
- package/src/ask/web.ts +63 -0
- package/src/index.ts +7 -1
- package/src/lobby/ask.ts +4 -5
- package/src/lobby/blocks.ts +57 -0
- package/src/lobby/feed.ts +1 -1
- package/src/lobby/host.ts +178 -0
- package/src/lobby/keys.ts +10 -41
- package/src/lobby/prompt-hub.ts +30 -77
- package/src/lobby/prompts.ts +8 -12
- package/src/lobby/runtime.ts +49 -310
- package/src/lobby/service.ts +54 -134
- package/src/lobby/task-rows.ts +28 -0
- package/src/pi/commands.ts +14 -20
- package/src/pi/model-settings.ts +112 -0
- package/src/pi/start-task.ts +1 -1
- package/src/pi/tools.ts +6 -7
- package/src/pi/ui.ts +0 -9
- package/src/schemas/configuration.ts +9 -33
- package/src/state/attachments.ts +82 -0
- package/src/webui/api/index.ts +17 -15
- package/src/webui/api/lobby.ts +4 -3
- package/src/webui/api/planner.ts +6 -4
- package/src/webui/api/quickfix.ts +4 -3
- package/src/webui/api/sessions.ts +9 -7
- package/src/webui/api/settings.ts +20 -8
- package/src/webui/api/status.ts +4 -15
- package/src/webui/api/tasks.ts +11 -15
- package/src/webui/command.ts +35 -19
- package/src/webui/dev/fake-service.ts +1 -2
- package/src/webui/dev/fixtures.ts +2 -5
- package/src/webui/events.ts +1 -1
- package/src/webui/protocol.ts +22 -11
- package/src/webui/server.ts +42 -1
- package/src/webui/uploads.ts +68 -0
- package/webui/dist/assets/index-B1PnCyl2.css +2 -0
- package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
- package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/webui/dist/build.json +142 -110
- package/webui/dist/index.html +4 -3
- package/src/ask/dialog.ts +0 -193
- package/src/ask/image.ts +0 -202
- package/src/ask/png.ts +0 -179
- package/src/ask/state.ts +0 -175
- package/src/ask/view.ts +0 -163
- package/src/lobby/layout.ts +0 -496
- package/src/lobby/markdown.ts +0 -121
- package/src/lobby/mini.ts +0 -167
- package/src/lobby/tabs/excalidraw.ts +0 -95
- package/src/lobby/tabs/git.ts +0 -162
- package/src/lobby/tabs/home.ts +0 -544
- package/src/lobby/tabs/issues.ts +0 -73
- package/src/lobby/tabs/knowledge.ts +0 -135
- package/src/lobby/tabs/metrics.ts +0 -300
- package/src/lobby/tabs/plan.ts +0 -262
- package/src/lobby/tabs/quickfix.ts +0 -136
- package/src/lobby/tabs/tasks.ts +0 -412
- package/src/lobby/theme.ts +0 -30
- package/src/lobby/view.ts +0 -3018
- package/src/pi/settings-ui.ts +0 -708
- package/src/width.ts +0 -102
- package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
- package/webui/dist/assets/index-E6MPcbdC.js +0 -80
package/src/pi/settings-ui.ts
DELETED
|
@@ -1,708 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DynamicBorder,
|
|
3
|
-
getSelectListTheme,
|
|
4
|
-
type ExtensionAPI,
|
|
5
|
-
type ExtensionContext,
|
|
6
|
-
type Theme,
|
|
7
|
-
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { Container, type Component, type Focusable, fuzzyFilter, getKeybindings, Input, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
|
|
9
|
-
import {
|
|
10
|
-
GIT_ISOLATIONS,
|
|
11
|
-
JEV_HOSTS,
|
|
12
|
-
INHERIT_MODEL,
|
|
13
|
-
isThinkingLevel,
|
|
14
|
-
LOBBY_PANELS,
|
|
15
|
-
PLANNING_ROUND_CHOICES,
|
|
16
|
-
SCOUT_THINKING,
|
|
17
|
-
SPLIT_PLAN_CHOICES,
|
|
18
|
-
SUBAGENT_KINDS,
|
|
19
|
-
type AgentModelConfig,
|
|
20
|
-
type BotLobbyConfig,
|
|
21
|
-
type ClassifierFeature,
|
|
22
|
-
type GitIsolation,
|
|
23
|
-
type JevHostName,
|
|
24
|
-
type LobbyPanel,
|
|
25
|
-
type SubagentKind,
|
|
26
|
-
} from "../schemas/configuration.ts";
|
|
27
|
-
import { globalConfigPath, loadConfig, saveConfig } from "../state/project.ts";
|
|
28
|
-
import { checkThinking, kindLabel, modelRef, supportedThinking } from "./model-support.ts";
|
|
29
|
-
import { modelLookup } from "./tools.ts";
|
|
30
|
-
import { classifier } from "../classifier/instance.ts";
|
|
31
|
-
import { describeKey, hostLabel, jevEndpoint, keyHint, maskKey, resolveKey, type StatusSource } from "../classifier/hosts.ts";
|
|
32
|
-
|
|
33
|
-
const CUSTOM_MODEL = "__custom__";
|
|
34
|
-
const MAX_VISIBLE = 12;
|
|
35
|
-
|
|
36
|
-
/** One settings entry: the master or a subagent profile. */
|
|
37
|
-
export type SettingsKind = "master" | SubagentKind;
|
|
38
|
-
export const SETTINGS_KINDS: readonly SettingsKind[] = ["master", ...SUBAGENT_KINDS];
|
|
39
|
-
|
|
40
|
-
/** The editable fields of one entry; scouts have no thinking and no instructions of their own. */
|
|
41
|
-
interface EntryView {
|
|
42
|
-
model: string;
|
|
43
|
-
thinking?: string;
|
|
44
|
-
instructions?: string;
|
|
45
|
-
timeoutMs?: number;
|
|
46
|
-
/** The model this agent switches to when its own runs out of usage; unset = none. */
|
|
47
|
-
fallbackModel?: string;
|
|
48
|
-
fallbackThinking?: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function entryView(config: BotLobbyConfig, kind: SettingsKind): EntryView {
|
|
52
|
-
if (kind === "master") return config.master;
|
|
53
|
-
if (kind === "scout") return { model: config.scout.model, timeoutMs: config.scout.timeoutMs, ...(config.scout.fallbackModel ? { fallbackModel: config.scout.fallbackModel } : {}) };
|
|
54
|
-
if (kind === "researcher") return config.researcher;
|
|
55
|
-
if (kind === "quickfix") return config.quickFix;
|
|
56
|
-
if (kind === "planner") return config.planner;
|
|
57
|
-
return config.agents[kind];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface EntryPatch {
|
|
61
|
-
model?: string;
|
|
62
|
-
thinking?: string;
|
|
63
|
-
instructions?: string;
|
|
64
|
-
timeoutMs?: number;
|
|
65
|
-
/** `inherit` clears the fallback (its thinking level with it). */
|
|
66
|
-
fallbackModel?: string;
|
|
67
|
-
fallbackThinking?: string;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** A settings entry with the fallback patch applied: `inherit` removes the fallback model and its thinking. */
|
|
71
|
-
function withFallbackPatch<T extends { fallbackModel?: string; fallbackThinking?: string }>(entry: T): T {
|
|
72
|
-
if (entry.fallbackModel !== INHERIT_MODEL) return entry;
|
|
73
|
-
const { fallbackModel: _model, fallbackThinking: _thinking, ...rest } = entry;
|
|
74
|
-
return rest as T;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Apply a patch to one entry in a config copy; scouts ignore thinking and instructions. */
|
|
78
|
-
export function patchEntry(config: BotLobbyConfig, kind: SettingsKind, patch: EntryPatch): BotLobbyConfig {
|
|
79
|
-
const next: BotLobbyConfig = { ...config, agents: { ...config.agents } };
|
|
80
|
-
if (kind === "master") next.master = withFallbackPatch({ ...config.master, ...patch } as AgentModelConfig);
|
|
81
|
-
else if (kind === "scout") {
|
|
82
|
-
const fallbackModel = patch.fallbackModel ?? config.scout.fallbackModel;
|
|
83
|
-
next.scout = withFallbackPatch({
|
|
84
|
-
model: patch.model ?? config.scout.model,
|
|
85
|
-
timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs,
|
|
86
|
-
...(fallbackModel ? { fallbackModel } : {}),
|
|
87
|
-
});
|
|
88
|
-
} else if (kind === "researcher") next.researcher = withFallbackPatch({ ...config.researcher, ...patch } as AgentModelConfig);
|
|
89
|
-
else if (kind === "quickfix") next.quickFix = withFallbackPatch({ ...config.quickFix, ...patch } as AgentModelConfig);
|
|
90
|
-
else if (kind === "planner") next.planner = withFallbackPatch({ ...config.planner, ...patch } as AgentModelConfig);
|
|
91
|
-
else next.agents[kind] = withFallbackPatch({ ...config.agents[kind], ...patch } as AgentModelConfig);
|
|
92
|
-
return next;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function updateEntry(kind: SettingsKind, patch: EntryPatch): void {
|
|
96
|
-
saveConfig(patchEntry(loadConfig(), kind, patch));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Apply the master model/thinking to the live session; "inherit" leaves it alone. */
|
|
100
|
-
export async function applyMasterModel(pi: ExtensionAPI, ctx: ExtensionContext, config: BotLobbyConfig): Promise<void> {
|
|
101
|
-
const { model, thinking } = config.master;
|
|
102
|
-
if (model !== INHERIT_MODEL) {
|
|
103
|
-
const found = modelLookup(ctx)(model);
|
|
104
|
-
if (!found) ctx.ui.notify(`bot-lobby: unknown master model "${model}".`, "warning");
|
|
105
|
-
else if (!(await pi.setModel(found))) ctx.ui.notify(`bot-lobby: no auth for ${model}.`, "warning");
|
|
106
|
-
}
|
|
107
|
-
if (isThinkingLevel(thinking)) pi.setThinkingLevel(thinking);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function hintText(search: boolean): string {
|
|
111
|
-
const nav = "↑↓ navigate • enter select • esc back";
|
|
112
|
-
return search ? `type to search • ${nav}` : nav;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function frame(theme: Theme, title: string, body: Component, search = false): Component {
|
|
116
|
-
const container = new Container();
|
|
117
|
-
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
118
|
-
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
119
|
-
container.addChild(body);
|
|
120
|
-
container.addChild(new Text(theme.fg("dim", hintText(search)), 1, 0));
|
|
121
|
-
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
122
|
-
return container;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Fuzzy-filter picker items over label, value and description; blank queries pass through. */
|
|
126
|
-
export function filterItems(items: SelectItem[], query: string): SelectItem[] {
|
|
127
|
-
if (query.trim() === "") return items;
|
|
128
|
-
return fuzzyFilter(items, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function listFor(items: SelectItem[], done: (value: string | undefined) => void): SelectList {
|
|
132
|
-
const list = new SelectList(items, Math.min(items.length, MAX_VISIBLE), getSelectListTheme());
|
|
133
|
-
list.onSelect = (item) => done(item.value);
|
|
134
|
-
list.onCancel = () => done(undefined);
|
|
135
|
-
return list;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function isListKey(data: string): boolean {
|
|
139
|
-
const kb = getKeybindings();
|
|
140
|
-
return kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")
|
|
141
|
-
|| kb.matches(data, "tui.select.confirm") || kb.matches(data, "tui.select.cancel");
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Searchable picker: typing rebuilds the list, while arrows/enter/esc still drive it. */
|
|
145
|
-
export class SearchPicker implements Component, Focusable {
|
|
146
|
-
private readonly body = new Container();
|
|
147
|
-
private readonly input = new Input({ placeholder: "search" });
|
|
148
|
-
private readonly frame: Component;
|
|
149
|
-
private readonly items: SelectItem[];
|
|
150
|
-
private readonly done: (value: string | undefined) => void;
|
|
151
|
-
private readonly requestRender: () => void;
|
|
152
|
-
private list: SelectList;
|
|
153
|
-
private query = "";
|
|
154
|
-
|
|
155
|
-
constructor(title: string, items: SelectItem[], theme: Theme, done: (value: string | undefined) => void, requestRender: () => void) {
|
|
156
|
-
this.items = items;
|
|
157
|
-
this.done = done;
|
|
158
|
-
this.requestRender = requestRender;
|
|
159
|
-
this.list = listFor(items, done);
|
|
160
|
-
this.body.addChild(this.input);
|
|
161
|
-
this.body.addChild(this.list);
|
|
162
|
-
this.frame = frame(theme, title, this.body, true);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
get focused(): boolean {
|
|
166
|
-
return this.input.focused;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
set focused(value: boolean) {
|
|
170
|
-
this.input.focused = value;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
private refine(): void {
|
|
174
|
-
const query = this.input.getValue();
|
|
175
|
-
if (query === this.query) return;
|
|
176
|
-
this.query = query;
|
|
177
|
-
const next = filterItems(this.items, query);
|
|
178
|
-
this.body.removeChild(this.list);
|
|
179
|
-
this.list = listFor(next, this.done);
|
|
180
|
-
this.body.addChild(this.list);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
handleInput(data: string): void {
|
|
184
|
-
if (isListKey(data)) this.list.handleInput(data);
|
|
185
|
-
else {
|
|
186
|
-
this.input.handleInput(data);
|
|
187
|
-
this.refine();
|
|
188
|
-
}
|
|
189
|
-
this.requestRender();
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
render(width: number): string[] {
|
|
193
|
-
return this.frame.render(width);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
invalidate(): void {
|
|
197
|
-
this.frame.invalidate();
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function pickPlain(ctx: ExtensionContext, title: string, items: SelectItem[]): Promise<string | undefined> {
|
|
202
|
-
return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
|
|
203
|
-
const list = listFor(items, done);
|
|
204
|
-
const container = frame(theme, title, list);
|
|
205
|
-
return {
|
|
206
|
-
render: (width: number) => container.render(width),
|
|
207
|
-
invalidate: () => container.invalidate(),
|
|
208
|
-
handleInput: (data: string) => {
|
|
209
|
-
list.handleInput(data);
|
|
210
|
-
tui.requestRender();
|
|
211
|
-
},
|
|
212
|
-
};
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async function pickSearch(ctx: ExtensionContext, title: string, items: SelectItem[]): Promise<string | undefined> {
|
|
217
|
-
return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
|
|
218
|
-
return new SearchPicker(title, items, theme, done, () => tui.requestRender());
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
interface PickOptions {
|
|
223
|
-
search?: boolean;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function pick(ctx: ExtensionContext, title: string, items: SelectItem[], options: PickOptions = {}): Promise<string | undefined> {
|
|
227
|
-
return options.search ? pickSearch(ctx, title, items) : pickPlain(ctx, title, items);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Model choices; only the master offers `inherit`, since it is the live session itself. */
|
|
231
|
-
export function modelItems(ctx: ExtensionContext, current: string, includeInherit = true): SelectItem[] {
|
|
232
|
-
const items: SelectItem[] = [];
|
|
233
|
-
if (includeInherit) {
|
|
234
|
-
const inherit = current === INHERIT_MODEL ? `${INHERIT_MODEL} ✓` : INHERIT_MODEL;
|
|
235
|
-
items.push({ value: INHERIT_MODEL, label: inherit, description: "Use the session's current model" });
|
|
236
|
-
}
|
|
237
|
-
const usable = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : ctx.modelRegistry.getAvailable();
|
|
238
|
-
for (const model of usable) {
|
|
239
|
-
const value = `${model.provider}/${model.id}`;
|
|
240
|
-
const label = value === current ? `${value} ✓` : value;
|
|
241
|
-
const description = model.name && model.name !== value ? model.name : undefined;
|
|
242
|
-
items.push(description ? { value, label, description } : { value, label });
|
|
243
|
-
}
|
|
244
|
-
items.push({ value: CUSTOM_MODEL, label: "custom…", description: "Type a provider/model id" });
|
|
245
|
-
return items;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** The model an entry runs on: its own, or the session's while unset. */
|
|
249
|
-
function effectiveModel(ctx: ExtensionContext, model: string) {
|
|
250
|
-
if (model !== INHERIT_MODEL) return modelLookup(ctx)(model);
|
|
251
|
-
return ctx.model;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** Thinking choices limited to what the entry's model supports. */
|
|
255
|
-
export function thinkingItems(levels: readonly string[], current: string | undefined): SelectItem[] {
|
|
256
|
-
return levels.map((level) => (level === current ? { value: level, label: `${level} ✓` } : { value: level, label: level }));
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
async function commit(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind, patch: EntryPatch, detail: string): Promise<void> {
|
|
260
|
-
updateEntry(kind, patch);
|
|
261
|
-
if (kind === "master") await applyMasterModel(pi, ctx, loadConfig());
|
|
262
|
-
ctx.ui.notify(`bot-lobby: ${kindLabel(kind)} ${detail} — saved to ${globalConfigPath()}`, "info");
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** After a model change, clamp a saved thinking level the new model cannot run, and say so. */
|
|
266
|
-
async function reconcileThinking(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
267
|
-
const view = entryView(loadConfig(), kind);
|
|
268
|
-
if (!view.thinking) return;
|
|
269
|
-
const check = checkThinking(effectiveModel(ctx, view.model), view.thinking);
|
|
270
|
-
if (!check.warning) return;
|
|
271
|
-
updateEntry(kind, { thinking: check.level });
|
|
272
|
-
if (kind === "master") await applyMasterModel(pi, ctx, loadConfig());
|
|
273
|
-
ctx.ui.notify(`bot-lobby: ${kindLabel(kind)} — ${check.warning}.`, "warning");
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
async function editModel(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
277
|
-
const current = entryView(loadConfig(), kind).model;
|
|
278
|
-
const choice = await pick(ctx, `Model — ${kindLabel(kind)}`, modelItems(ctx, current, kind === "master"), { search: true });
|
|
279
|
-
if (choice === undefined) return;
|
|
280
|
-
const typed = choice === CUSTOM_MODEL ? (await ctx.ui.input("Model id", "provider/model"))?.trim() : choice;
|
|
281
|
-
if (!typed) return;
|
|
282
|
-
await commit(pi, ctx, kind, { model: typed }, `model → ${typed}`);
|
|
283
|
-
await reconcileThinking(pi, ctx, kind);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
async function editThinking(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
287
|
-
const view = entryView(loadConfig(), kind);
|
|
288
|
-
const model = effectiveModel(ctx, view.model);
|
|
289
|
-
const levels = supportedThinking(model);
|
|
290
|
-
const title = model ? `Thinking — ${kindLabel(kind)} (${modelRef(model)})` : `Thinking — ${kindLabel(kind)}`;
|
|
291
|
-
const level = await pick(ctx, title, thinkingItems(levels, view.thinking));
|
|
292
|
-
if (!level || !isThinkingLevel(level)) return;
|
|
293
|
-
await commit(pi, ctx, kind, { thinking: level }, `thinking → ${level}`);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/** The model an entry falls back to when its own runs out of usage; `none` removes it. */
|
|
297
|
-
async function editFallbackModel(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
298
|
-
const view = entryView(loadConfig(), kind);
|
|
299
|
-
const current = view.fallbackModel ?? INHERIT_MODEL;
|
|
300
|
-
const none: SelectItem = { value: INHERIT_MODEL, label: current === INHERIT_MODEL ? "none ✓" : "none", description: "No fallback: when its model runs out of usage the run fails" };
|
|
301
|
-
const choice = await pick(ctx, `Fallback model — ${kindLabel(kind)}`, [none, ...modelItems(ctx, current, false)], { search: true });
|
|
302
|
-
if (choice === undefined) return;
|
|
303
|
-
const typed = choice === CUSTOM_MODEL ? (await ctx.ui.input("Model id", "provider/model"))?.trim() : choice;
|
|
304
|
-
if (!typed) return;
|
|
305
|
-
await commit(pi, ctx, kind, { fallbackModel: typed }, typed === INHERIT_MODEL ? "fallback removed" : `fallback model → ${typed}`);
|
|
306
|
-
if (typed === INHERIT_MODEL || kind === "scout") return;
|
|
307
|
-
// Keep the fallback's thinking level one its model can run.
|
|
308
|
-
const level = entryView(loadConfig(), kind).fallbackThinking;
|
|
309
|
-
const check = level ? checkThinking(modelLookup(ctx)(typed), level) : undefined;
|
|
310
|
-
if (check?.warning) {
|
|
311
|
-
updateEntry(kind, { fallbackThinking: check.level });
|
|
312
|
-
ctx.ui.notify(`bot-lobby: ${kindLabel(kind)} fallback — ${check.warning}.`, "warning");
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/** The thinking level on the fallback model, limited to what that model supports. */
|
|
317
|
-
async function editFallbackThinking(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
318
|
-
const view = entryView(loadConfig(), kind);
|
|
319
|
-
if (!view.fallbackModel) return;
|
|
320
|
-
const model = modelLookup(ctx)(view.fallbackModel);
|
|
321
|
-
const title = `Fallback thinking — ${kindLabel(kind)} (${view.fallbackModel})`;
|
|
322
|
-
const level = await pick(ctx, title, thinkingItems(supportedThinking(model), view.fallbackThinking ?? view.thinking));
|
|
323
|
-
if (!level || !isThinkingLevel(level)) return;
|
|
324
|
-
await commit(pi, ctx, kind, { fallbackThinking: level }, `fallback thinking → ${level}`);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
async function editTimeout(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
328
|
-
const current = entryView(loadConfig(), kind).timeoutMs ?? loadConfig().workflow.agentTimeoutMs;
|
|
329
|
-
const typed = (await ctx.ui.input(`Time limit (minutes) — ${kindLabel(kind)}`, String(Math.round(current / 60_000))))?.trim();
|
|
330
|
-
if (!typed) return;
|
|
331
|
-
const minutes = Number(typed);
|
|
332
|
-
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
333
|
-
ctx.ui.notify(`bot-lobby: "${typed}" is not a positive number of minutes.`, "warning");
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
await commit(pi, ctx, kind, { timeoutMs: Math.round(minutes * 60_000) }, `time limit → ${minutes}m`);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
async function editInstructions(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
340
|
-
const current = entryView(loadConfig(), kind).instructions ?? "";
|
|
341
|
-
const text = await ctx.ui.editor(`Instructions — ${kindLabel(kind)}`, current);
|
|
342
|
-
if (text === undefined) return;
|
|
343
|
-
await commit(pi, ctx, kind, { instructions: text.trim() }, "instructions saved");
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function minutes(ms: number | undefined): string {
|
|
347
|
-
return ms ? `${Math.round(ms / 60_000)}m` : "default";
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** Menu rows for one entry; scouts expose model and time limit only (their thinking is fixed). */
|
|
351
|
-
export function entryItems(kind: SettingsKind, view: EntryView): SelectItem[] {
|
|
352
|
-
const items: SelectItem[] = [{ value: "model", label: "Model", description: view.model }];
|
|
353
|
-
if (kind === "scout") items.push({ value: "fixed", label: "Thinking", description: `${SCOUT_THINKING} (fixed for scouts)` });
|
|
354
|
-
else items.push({ value: "thinking", label: "Thinking", description: view.thinking ?? "" });
|
|
355
|
-
items.push({ value: "fallback", label: "Fallback model", description: view.fallbackModel ?? "none · used when this model runs out of usage" });
|
|
356
|
-
if (kind !== "scout" && view.fallbackModel) items.push({ value: "fallbackThinking", label: "Fallback thinking", description: view.fallbackThinking ?? view.thinking ?? "" });
|
|
357
|
-
if (kind !== "master") items.push({ value: "timeout", label: "Time limit", description: minutes(view.timeoutMs) });
|
|
358
|
-
if (kind !== "scout" && kind !== "researcher") {
|
|
359
|
-
items.push({ value: "instructions", label: "Instructions", description: view.instructions ? `${view.instructions.length} chars` : "(none)" });
|
|
360
|
-
}
|
|
361
|
-
items.push({ value: "back", label: "Back" });
|
|
362
|
-
return items;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
async function editEntry(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
366
|
-
for (;;) {
|
|
367
|
-
const action = await pick(ctx, `${kindLabel(kind)} settings`, entryItems(kind, entryView(loadConfig(), kind)));
|
|
368
|
-
if (!action || action === "back") return;
|
|
369
|
-
if (action === "model") await editModel(pi, ctx, kind);
|
|
370
|
-
else if (action === "thinking") await editThinking(pi, ctx, kind);
|
|
371
|
-
else if (action === "fallback") await editFallbackModel(pi, ctx, kind);
|
|
372
|
-
else if (action === "fallbackThinking") await editFallbackThinking(pi, ctx, kind);
|
|
373
|
-
else if (action === "timeout") await editTimeout(pi, ctx, kind);
|
|
374
|
-
else if (action === "instructions") await editInstructions(pi, ctx, kind);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** One agent's settings on their own: the lobby's Quick fix and Plan tabs open their agent's entry directly. */
|
|
379
|
-
export async function openEntrySettings(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
380
|
-
if (ctx.mode !== "tui") {
|
|
381
|
-
ctx.ui.notify(`bot-lobby settings live in ${globalConfigPath()}; edit that file outside the TUI.`, "info");
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
await editEntry(pi, ctx, kind);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Subagents never inherit silently: any subagent whose model is still unset is
|
|
389
|
-
* pinned to the session's current model and saved, so settings always show
|
|
390
|
-
* what each agent runs on.
|
|
391
|
-
*/
|
|
392
|
-
export function prefillModels(config: BotLobbyConfig, sessionModel: string | undefined): { config: BotLobbyConfig; filled: SubagentKind[] } {
|
|
393
|
-
if (!sessionModel) return { config, filled: [] };
|
|
394
|
-
let next = config;
|
|
395
|
-
const filled: SubagentKind[] = [];
|
|
396
|
-
for (const kind of SUBAGENT_KINDS) {
|
|
397
|
-
if (entryView(next, kind).model !== INHERIT_MODEL) continue;
|
|
398
|
-
next = patchEntry(next, kind, { model: sessionModel });
|
|
399
|
-
filled.push(kind);
|
|
400
|
-
}
|
|
401
|
-
return { config: next, filled };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function entryDescription(kind: SettingsKind, view: EntryView): string {
|
|
405
|
-
const thinking = kind === "scout" ? SCOUT_THINKING : view.thinking;
|
|
406
|
-
const custom = view.instructions ? " · custom" : "";
|
|
407
|
-
const limit = kind === "master" ? "" : ` · ${minutes(view.timeoutMs)}`;
|
|
408
|
-
const fallback = view.fallbackModel ? ` · fallback ${view.fallbackModel}` : "";
|
|
409
|
-
return `${view.model} · ${thinking}${limit}${custom}${fallback}`;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/** The lobby's on/off settings as the settings menu lists them; `panel:*` are the Lobby tab's panes. */
|
|
413
|
-
export type LobbySwitch = "autoOpen" | "autoAsk" | "mouse" | "miniLine" | "issues" | `panel:${LobbyPanel}`;
|
|
414
|
-
|
|
415
|
-
const PANEL_SWITCH_LABELS: Record<LobbyPanel, string> = { conversation: "Conversation pane", activity: "Activity log pane", thinking: "Thinking pane" };
|
|
416
|
-
|
|
417
|
-
export const LOBBY_SWITCHES: ReadonlyArray<{ id: LobbySwitch; label: string; help: string }> = [
|
|
418
|
-
{ id: "autoOpen", label: "Open with a task", help: "open the lobby when this session starts or resumes a task" },
|
|
419
|
-
{ id: "autoAsk", label: "Ask at once", help: "put the panel's questions to you as soon as a round ends, while the Plan tab is open" },
|
|
420
|
-
{ id: "mouse", label: "Mouse", help: "click tabs and draft lines, scroll with the wheel (shift+drag still selects text)" },
|
|
421
|
-
{ id: "miniLine", label: "Status line when hidden", help: "one line under the editor while the lobby is hidden: task steps, the planning round, a quick fix, or idle" },
|
|
422
|
-
{ id: "issues", label: "Issues tab", help: "the GitHub Issues tab" },
|
|
423
|
-
...LOBBY_PANELS.map((panel) => ({
|
|
424
|
-
id: `panel:${panel}` as const,
|
|
425
|
-
label: PANEL_SWITCH_LABELS[panel],
|
|
426
|
-
help: "shown on the Lobby tab; its key in the lobby toggles it too",
|
|
427
|
-
})),
|
|
428
|
-
];
|
|
429
|
-
|
|
430
|
-
export function lobbySwitch(config: BotLobbyConfig, id: LobbySwitch): boolean {
|
|
431
|
-
if (id.startsWith("panel:")) return config.lobby.panels[id.slice("panel:".length) as LobbyPanel];
|
|
432
|
-
return config.lobby[id as Exclude<LobbySwitch, `panel:${string}`>];
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** The config with one lobby setting flipped. */
|
|
436
|
-
export function toggleLobbySwitch(config: BotLobbyConfig, id: LobbySwitch): BotLobbyConfig {
|
|
437
|
-
const value = !lobbySwitch(config, id);
|
|
438
|
-
if (id.startsWith("panel:")) {
|
|
439
|
-
const panel = id.slice("panel:".length) as LobbyPanel;
|
|
440
|
-
return { ...config, lobby: { ...config.lobby, panels: { ...config.lobby.panels, [panel]: value } } };
|
|
441
|
-
}
|
|
442
|
-
return { ...config, lobby: { ...config.lobby, [id]: value } };
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/** `5 rounds`, or `unlimited` for 0. */
|
|
446
|
-
export function roundLimitLabel(limit: number): string {
|
|
447
|
-
return limit > 0 ? `${limit} round${limit === 1 ? "" : "s"}` : "unlimited";
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/** The next planning round limit in the menu's cycle (2, 3, 5, 8, unlimited); a hand-edited value rejoins it. */
|
|
451
|
-
export function nextRoundLimit(current: number): number {
|
|
452
|
-
const index = PLANNING_ROUND_CHOICES.indexOf(current as (typeof PLANNING_ROUND_CHOICES)[number]);
|
|
453
|
-
if (index >= 0) return PLANNING_ROUND_CHOICES[(index + 1) % PLANNING_ROUND_CHOICES.length]!;
|
|
454
|
-
return PLANNING_ROUND_CHOICES.find((choice) => choice > current) ?? 0;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** `over 8 steps`, or `never` for 0. */
|
|
458
|
-
export function splitLimitLabel(limit: number): string {
|
|
459
|
-
return limit > 0 ? `over ${limit} steps` : "never";
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/** The next step count in the menu's cycle (6, 8, 10, 12, never); a hand-edited value rejoins it. */
|
|
463
|
-
export function nextSplitLimit(current: number): number {
|
|
464
|
-
const index = SPLIT_PLAN_CHOICES.indexOf(current as (typeof SPLIT_PLAN_CHOICES)[number]);
|
|
465
|
-
if (index >= 0) return SPLIT_PLAN_CHOICES[(index + 1) % SPLIT_PLAN_CHOICES.length]!;
|
|
466
|
-
return SPLIT_PLAN_CHOICES.find((choice) => choice > current) ?? 0;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/** `on · port 7347 · opens the browser · questions both`, for the settings menu. */
|
|
470
|
-
export function webSummary(config: BotLobbyConfig): string {
|
|
471
|
-
const web = config.lobby.web;
|
|
472
|
-
return `${web.enabled ? "on" : "off"} · port ${web.port === 0 ? "any" : web.port} · ${web.openBrowser ? "opens the browser" : "link only"} · questions ${web.questions}`;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/** The next place the web UI's questions are answered (`both`, in the browser or the terminal). */
|
|
476
|
-
export function nextWebQuestions(current: string): "both" | "terminal" {
|
|
477
|
-
return current === "both" ? "terminal" : "both";
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/** A port typed in the settings menu: 0 (any free port) or a real port; undefined when it is not a port. */
|
|
481
|
-
export function parseWebPort(typed: string): number | undefined {
|
|
482
|
-
if (!/^\d+$/.test(typed.trim())) return undefined;
|
|
483
|
-
const port = Number(typed.trim());
|
|
484
|
-
return port === 0 || (port > 0 && port <= 65535) ? port : undefined;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/** The loopback web UI's own settings: on/off, its port, the browser and where its questions are answered. */
|
|
488
|
-
async function editWeb(ctx: ExtensionContext): Promise<void> {
|
|
489
|
-
const typed = await pick(ctx, "bot-lobby settings · Web UI", [
|
|
490
|
-
{ value: "enabled", label: "Web UI", description: `${loadConfig().lobby.web.enabled ? "on" : "off"} · start the loopback browser UI with /bot-lobby web` },
|
|
491
|
-
{ value: "port", label: "Port", description: `${loadConfig().lobby.web.port === 0 ? "any free port" : loadConfig().lobby.web.port} · the base port (then the next free up to +20); 0 means any free port` },
|
|
492
|
-
{ value: "browser", label: "Open browser", description: `${loadConfig().lobby.web.openBrowser ? "on" : "off"} · open the link in the browser on /bot-lobby web` },
|
|
493
|
-
{ value: "questions", label: "Questions", description: `${loadConfig().lobby.web.questions} · where the web UI's questions are answered: both, or the terminal only` },
|
|
494
|
-
{ value: "back", label: "Back" },
|
|
495
|
-
]);
|
|
496
|
-
if (!typed || typed === "back") return;
|
|
497
|
-
const config = loadConfig();
|
|
498
|
-
if (typed === "enabled") saveConfig({ ...config, lobby: { ...config.lobby, web: { ...config.lobby.web, enabled: !config.lobby.web.enabled } } });
|
|
499
|
-
else if (typed === "browser") saveConfig({ ...config, lobby: { ...config.lobby, web: { ...config.lobby.web, openBrowser: !config.lobby.web.openBrowser } } });
|
|
500
|
-
else if (typed === "questions") saveConfig({ ...config, lobby: { ...config.lobby, web: { ...config.lobby.web, questions: nextWebQuestions(config.lobby.web.questions) } } });
|
|
501
|
-
else await editWebPort(ctx);
|
|
502
|
-
await editWeb(ctx);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/** Type the web UI's port; 0 means any free port. */
|
|
506
|
-
async function editWebPort(ctx: ExtensionContext): Promise<void> {
|
|
507
|
-
const typed = (await ctx.ui.input("Web UI port (0: any free port)", String(loadConfig().lobby.web.port)))?.trim();
|
|
508
|
-
if (!typed) return;
|
|
509
|
-
const port = parseWebPort(typed);
|
|
510
|
-
if (port === undefined) {
|
|
511
|
-
ctx.ui.notify(`bot-lobby: "${typed}" is not a port (0, or 1-65535).`, "warning");
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
const config = loadConfig();
|
|
515
|
-
saveConfig({ ...config, lobby: { ...config.lobby, web: { ...config.lobby.web, port } } });
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/** On/off settings for the lobby, and the planning round limit; enter flips or cycles one and saves it. Key rebinding stays in the file (lobby.keys). */
|
|
519
|
-
async function editLobby(ctx: ExtensionContext): Promise<void> {
|
|
520
|
-
for (;;) {
|
|
521
|
-
const config = loadConfig();
|
|
522
|
-
const items: SelectItem[] = LOBBY_SWITCHES.map((entry) => ({ value: entry.id, label: entry.label, description: `${lobbySwitch(config, entry.id) ? "on" : "off"} · ${entry.help}` }));
|
|
523
|
-
items.push({ value: "rounds", label: "Planning rounds", description: `${roundLimitLabel(config.lobby.maxPlanningRounds)} · enter cycles 2, 3, 5, 8, unlimited; the last round the oracle settles alone` });
|
|
524
|
-
items.push({ value: "split", label: "Split long plans", description: `${splitLimitLabel(config.lobby.splitPlanAbove)} · saving a plan with more steps offers to split it into up to 5 tasks · enter cycles 6, 8, 10, 12, never` });
|
|
525
|
-
items.push({ value: "web", label: "Web UI", description: webSummary(config) });
|
|
526
|
-
items.push({ value: "back", label: "Back", description: `keys: lobby.keys in ${globalConfigPath()}` });
|
|
527
|
-
const choice = await pick(ctx, "bot-lobby settings · Lobby", items);
|
|
528
|
-
if (!choice || choice === "back") return;
|
|
529
|
-
if (choice === "web") await editWeb(ctx);
|
|
530
|
-
else if (choice === "rounds") saveConfig({ ...config, lobby: { ...config.lobby, maxPlanningRounds: nextRoundLimit(config.lobby.maxPlanningRounds) } });
|
|
531
|
-
else if (choice === "split") saveConfig({ ...config, lobby: { ...config.lobby, splitPlanAbove: nextSplitLimit(config.lobby.splitPlanAbove) } });
|
|
532
|
-
else saveConfig(toggleLobbySwitch(config, choice as LobbySwitch));
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
function lobbySummary(config: BotLobbyConfig): string {
|
|
537
|
-
const hidden = LOBBY_PANELS.filter((panel) => !config.lobby.panels[panel]);
|
|
538
|
-
return [
|
|
539
|
-
config.lobby.autoOpen ? "opens with a task" : "opens on alt+l",
|
|
540
|
-
config.lobby.autoAsk ? "asks at once" : "asks on enter",
|
|
541
|
-
config.lobby.mouse ? "mouse" : "no mouse",
|
|
542
|
-
...(config.lobby.miniLine ? [] : ["no status line"]),
|
|
543
|
-
`planning: ${roundLimitLabel(config.lobby.maxPlanningRounds)}`,
|
|
544
|
-
`split plans ${splitLimitLabel(config.lobby.splitPlanAbove)}`,
|
|
545
|
-
...(config.lobby.issues ? ["issues tab"] : []),
|
|
546
|
-
...(hidden.length > 0 ? [`hidden: ${hidden.join(", ")}`] : []),
|
|
547
|
-
].join(" · ");
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Classifier decisions that exist so far, as the settings menu lists them.
|
|
552
|
-
* Each phase of the classifier work adds its own entry.
|
|
553
|
-
*/
|
|
554
|
-
export const CLASSIFIER_FEATURE_ITEMS: ReadonlyArray<{ id: ClassifierFeature; label: string; help: string }> = [
|
|
555
|
-
{ id: "seats", label: "Planning seats", help: "each round, only the seats your idea or latest answers touch sit; 1-4 in the Plan tab pins one" },
|
|
556
|
-
{ id: "answers", label: "Obvious answers", help: "a panel question whose recommended option the conversation already makes clearly right is answered for you (listed under Assumptions)" },
|
|
557
|
-
{ id: "triage", label: "Task triage", help: "a new task's size, domains and research need reach the Master as hints; a quick fix that is really a task is held for you" },
|
|
558
|
-
{ id: "effort", label: "Effort routing", help: "a simple step runs one thinking level lower, a trivial one on the cheaper model below; a routed run that falls short runs again on your settings" },
|
|
559
|
-
{ id: "review", label: "Pull request read", help: "the Git tab's quick read of a pull request: its size, and how likely it is risky, security-relevant, breaking or untested, before an agent reviews it" },
|
|
560
|
-
{ id: "knowledge", label: "Relevant knowledge", help: "when an agent's knowledge, standards or decisions file is too long for its prompt, Jev picks the sections that bear on the step, and the agent is told where the rest is" },
|
|
561
|
-
{ id: "files", label: "File hints", help: "scouts, workers, quick fixes and the planning panel start with the files most likely needed, and can look more up with find_relevant_files" },
|
|
562
|
-
];
|
|
563
|
-
|
|
564
|
-
/** What each git isolation means, for the settings menu. */
|
|
565
|
-
const GIT_ISOLATION_HELP: Record<GitIsolation, string> = {
|
|
566
|
-
off: "tasks work in the folder you started them in",
|
|
567
|
-
branch: "each new task gets a git branch named after it, checked out in the working folder",
|
|
568
|
-
worktree: "each new task gets its own worktree and branch, named after it: every agent runs there, apart from your checkout",
|
|
569
|
-
};
|
|
570
|
-
|
|
571
|
-
/** The next git isolation in the menu's cycle (off, branch, worktree). */
|
|
572
|
-
export function nextGitIsolation(current: GitIsolation): GitIsolation {
|
|
573
|
-
return GIT_ISOLATIONS[(GIT_ISOLATIONS.indexOf(current) + 1) % GIT_ISOLATIONS.length]!;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/** `branch · each new task gets…`, for the settings menu; `--branch`, `--worktree` and `--no-branch` decide for one task. */
|
|
577
|
-
export function gitSummary(config: BotLobbyConfig): string {
|
|
578
|
-
const isolation = config.workflow.gitIsolation;
|
|
579
|
-
return `${isolation} · ${GIT_ISOLATION_HELP[isolation]} · enter cycles ${GIT_ISOLATIONS.join(", ")}`;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/** The next Jev host in the menu's cycle. */
|
|
583
|
-
export function nextJevHost(current: JevHostName): JevHostName {
|
|
584
|
-
return JEV_HOSTS[(JEV_HOSTS.indexOf(current) + 1) % JEV_HOSTS.length]!;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/** The config with one classifier decision switched on or off. */
|
|
588
|
-
export function toggleClassifierFeature(config: BotLobbyConfig, feature: ClassifierFeature): BotLobbyConfig {
|
|
589
|
-
const features = { ...config.classifier.features, [feature]: !config.classifier.features[feature] };
|
|
590
|
-
return { ...config, classifier: { ...config.classifier, features } };
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/** `on · OpenCode Zen · jev-1.13-free · key stored in pi`, for the settings menu and `/bot-lobby config`. */
|
|
594
|
-
export function classifierSummary(config: BotLobbyConfig, status: StatusSource | undefined): string {
|
|
595
|
-
const { host, model } = jevEndpoint(config.classifier, status);
|
|
596
|
-
const via = config.classifier.provider === "auto" ? `${host.label} (auto)` : host.label;
|
|
597
|
-
return [config.classifier.enabled ? "on" : "off", via, model, `key ${describeKey(host, status)}`].join(" · ");
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/** pi's auth status per provider, never throwing. */
|
|
601
|
-
function authStatus(ctx: ExtensionContext): StatusSource {
|
|
602
|
-
return (piProvider) => {
|
|
603
|
-
try {
|
|
604
|
-
return ctx.modelRegistry.getProviderAuthStatus(piProvider);
|
|
605
|
-
} catch {
|
|
606
|
-
return undefined;
|
|
607
|
-
}
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/** Where the key lives and how to set it, with the loaded key masked. */
|
|
612
|
-
async function keyHelp(ctx: ExtensionContext, config: BotLobbyConfig): Promise<string> {
|
|
613
|
-
const status = authStatus(ctx);
|
|
614
|
-
const { host } = jevEndpoint(config.classifier, status);
|
|
615
|
-
let masked = "";
|
|
616
|
-
try {
|
|
617
|
-
const key = await resolveKey(host, (provider) => ctx.modelRegistry.getApiKeyForProvider(provider));
|
|
618
|
-
if (key) masked = ` Loaded: ${maskKey(key)}.`;
|
|
619
|
-
} catch {
|
|
620
|
-
// No key to show.
|
|
621
|
-
}
|
|
622
|
-
const where = host.name === "opencode"
|
|
623
|
-
? "it is the OpenCode key pi already uses for Zen and Go models"
|
|
624
|
-
: host.name === "typesafe" ? "it is saved in pi's auth.json with your other keys" : `it is the key pi already holds for ${host.label}`;
|
|
625
|
-
return `Jev via ${host.label}: key ${describeKey(host, status)}.${masked} To set it, ${keyHint(config.classifier)}; ${where}.`;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/** Type a Jev model id; empty uses the host's default (OpenCode: the free jev-1.13-free). */
|
|
629
|
-
async function editJevModel(ctx: ExtensionContext): Promise<void> {
|
|
630
|
-
const config = loadConfig();
|
|
631
|
-
const { host } = jevEndpoint(config.classifier, authStatus(ctx));
|
|
632
|
-
const typed = await ctx.ui.input(`Jev model (empty: ${host.label}'s default, ${host.model})`, config.classifier.model || host.model);
|
|
633
|
-
if (typed === undefined) return;
|
|
634
|
-
const model = typed.trim() === host.model ? "" : typed.trim();
|
|
635
|
-
saveConfig({ ...config, classifier: { ...config.classifier, model } });
|
|
636
|
-
ctx.ui.notify(`bot-lobby: Jev model → ${model || `${host.model} (${host.label}'s default)`} — saved to ${globalConfigPath()}`, "info");
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/** The model trivial steps run on; `none` keeps each agent's model and only lowers thinking. */
|
|
640
|
-
async function editCheapModel(ctx: ExtensionContext): Promise<void> {
|
|
641
|
-
const config = loadConfig();
|
|
642
|
-
const current = config.classifier.effort.cheapModel;
|
|
643
|
-
const none: SelectItem = { value: INHERIT_MODEL, label: current === INHERIT_MODEL ? "none ✓" : "none", description: "Trivial steps keep their model and run one thinking level lower" };
|
|
644
|
-
const choice = await pick(ctx, "Cheaper model for trivial steps", [none, ...modelItems(ctx, current, false)], { search: true });
|
|
645
|
-
if (choice === undefined) return;
|
|
646
|
-
const typed = choice === CUSTOM_MODEL ? (await ctx.ui.input("Model id", "provider/model"))?.trim() : choice;
|
|
647
|
-
if (!typed) return;
|
|
648
|
-
saveConfig({ ...config, classifier: { ...config.classifier, effort: { cheapModel: typed } } });
|
|
649
|
-
ctx.ui.notify(`bot-lobby: trivial steps run on ${typed === INHERIT_MODEL ? "their own model, one thinking level lower" : typed} — saved to ${globalConfigPath()}`, "info");
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/** The classifier: on/off, where Jev is called, its key, which decisions it makes, and a connection test. */
|
|
653
|
-
async function editClassifier(ctx: ExtensionContext): Promise<void> {
|
|
654
|
-
for (;;) {
|
|
655
|
-
const config = loadConfig();
|
|
656
|
-
const settings = config.classifier;
|
|
657
|
-
const status = authStatus(ctx);
|
|
658
|
-
const { host, model } = jevEndpoint(settings, status);
|
|
659
|
-
const items: SelectItem[] = [
|
|
660
|
-
{ value: "enabled", label: "Classifier", description: `${settings.enabled ? "on" : "off"} · Jev makes the obvious decisions so large models spend fewer tokens on them` },
|
|
661
|
-
{ value: "host", label: "Host", description: `${hostLabel(settings.provider)}${settings.provider === "auto" ? ` → ${host.label}` : ""} · enter cycles ${JEV_HOSTS.map(hostLabel).join(", ")}` },
|
|
662
|
-
{ value: "key", label: "API key", description: describeKey(host, status) },
|
|
663
|
-
{ value: "model", label: "Model", description: `${model}${settings.model ? "" : " (the host's default)"} · e.g. jev-1.13 once OpenCode's free jev-1.13-free ends` },
|
|
664
|
-
...CLASSIFIER_FEATURE_ITEMS.map((entry) => ({ value: `feature:${entry.id}`, label: entry.label, description: `${settings.features[entry.id] ? "on" : "off"} · ${entry.help}` })),
|
|
665
|
-
{ value: "cheap", label: "Cheaper model", description: `${settings.effort.cheapModel === INHERIT_MODEL ? "none: trivial steps keep their model and drop a thinking level" : settings.effort.cheapModel} · what effort routing runs trivial steps on` },
|
|
666
|
-
{ value: "test", label: "Test connection", description: "one tiny call: shows the model and how long it took" },
|
|
667
|
-
{ value: "back", label: "Back", description: `thresholds and limits: classifier in ${globalConfigPath()}` },
|
|
668
|
-
];
|
|
669
|
-
const choice = await pick(ctx, "bot-lobby settings · Classifier (Jev)", items);
|
|
670
|
-
if (!choice || choice === "back") return;
|
|
671
|
-
if (choice === "enabled") saveConfig({ ...config, classifier: { ...settings, enabled: !settings.enabled } });
|
|
672
|
-
else if (choice === "host") saveConfig({ ...config, classifier: { ...settings, provider: nextJevHost(settings.provider) } });
|
|
673
|
-
else if (choice === "key") ctx.ui.notify(await keyHelp(ctx, config), "info");
|
|
674
|
-
else if (choice === "model") await editJevModel(ctx);
|
|
675
|
-
else if (choice === "cheap") await editCheapModel(ctx);
|
|
676
|
-
else if (choice === "test") {
|
|
677
|
-
const result = await classifier().test();
|
|
678
|
-
ctx.ui.notify(result.ok ? `Jev answered in ${result.ms} ms (${result.model}).` : `Jev test failed: ${result.error}`, result.ok ? "info" : "error");
|
|
679
|
-
} else if (choice.startsWith("feature:")) saveConfig(toggleClassifierFeature(config, choice.slice("feature:".length) as ClassifierFeature));
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
/** Open the per-agent settings editor; every change is written to the global config. */
|
|
684
|
-
export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
685
|
-
if (ctx.mode !== "tui") {
|
|
686
|
-
ctx.ui.notify(`bot-lobby settings live in ${globalConfigPath()}; edit that file outside the TUI.`, "info");
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
const prefilled = prefillModels(loadConfig(), ctx.model ? modelRef(ctx.model) : undefined);
|
|
690
|
-
if (prefilled.filled.length > 0) {
|
|
691
|
-
saveConfig(prefilled.config);
|
|
692
|
-
ctx.ui.notify(`bot-lobby: set ${prefilled.filled.map(kindLabel).join(", ")} to the session model; change them here any time.`, "info");
|
|
693
|
-
}
|
|
694
|
-
for (;;) {
|
|
695
|
-
const config = loadConfig();
|
|
696
|
-
const items: SelectItem[] = SETTINGS_KINDS.map((kind) => ({ value: kind, label: kindLabel(kind), description: entryDescription(kind, entryView(config, kind)) }));
|
|
697
|
-
items.push({ value: "git", label: "Git isolation", description: gitSummary(config) });
|
|
698
|
-
items.push({ value: "lobby", label: "Lobby", description: lobbySummary(config) });
|
|
699
|
-
items.push({ value: "classifier", label: "Classifier (Jev)", description: classifierSummary(config, authStatus(ctx)) });
|
|
700
|
-
items.push({ value: "close", label: "Close" });
|
|
701
|
-
const choice = await pick(ctx, "bot-lobby settings", items);
|
|
702
|
-
if (!choice || choice === "close") return;
|
|
703
|
-
if (choice === "git") saveConfig({ ...config, workflow: { ...config.workflow, gitIsolation: nextGitIsolation(config.workflow.gitIsolation) } });
|
|
704
|
-
else if (choice === "lobby") await editLobby(ctx);
|
|
705
|
-
else if (choice === "classifier") await editClassifier(ctx);
|
|
706
|
-
else await editEntry(pi, ctx, choice as SettingsKind);
|
|
707
|
-
}
|
|
708
|
-
}
|