@a-t-h-i/bot-lobby 0.3.0 → 0.4.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 +128 -30
- package/package.json +1 -1
- package/prompts/backend.md +46 -1
- package/prompts/designer.md +94 -15
- package/prompts/master.md +58 -1
- package/prompts/qa.md +35 -2
- package/prompts/researcher.md +6 -0
- package/prompts/reviewer.md +16 -0
- package/prompts/scout.md +11 -2
- package/prompts/worker.md +35 -2
- package/src/desk/client-extension.ts +101 -0
- package/src/desk/desk.ts +249 -0
- package/src/desk/ipc.ts +178 -0
- package/src/desk/session.ts +214 -0
- package/src/execution/agent-runner.ts +160 -14
- package/src/execution/pi-runner.ts +351 -65
- package/src/index.ts +4 -0
- package/src/master/master.ts +21 -17
- package/src/master/research.ts +8 -7
- package/src/pi/activity.ts +45 -0
- package/src/pi/commands.ts +28 -3
- package/src/pi/expressions.ts +43 -12
- package/src/pi/kaomoji.ts +227 -0
- package/src/pi/mascot-art.ts +5 -15
- package/src/pi/model-support.ts +101 -0
- package/src/pi/run-summary.ts +170 -0
- package/src/pi/settings-ui.ts +158 -60
- package/src/pi/tools.ts +50 -7
- package/src/pi/ui.ts +48 -7
- package/src/pi/zen-large.ts +41 -4
- package/src/pi/zen-metrics.ts +47 -7
- package/src/pi/zen.ts +29 -8
- package/src/roles/reviewer.ts +24 -4
- package/src/roles/worker.ts +7 -1
- package/src/schemas/configuration.ts +112 -18
- package/src/schemas/findings.ts +20 -0
- package/src/schemas/task.ts +26 -1
- package/src/state/project.ts +9 -0
- package/src/text.ts +9 -0
- package/src/workflow/workflow.ts +127 -10
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
agentProfile,
|
|
4
|
+
isThinkingLevel,
|
|
5
|
+
THINKING_LEVELS,
|
|
6
|
+
type BotLobbyConfig,
|
|
7
|
+
type ProfileResolver,
|
|
8
|
+
type SubagentKind,
|
|
9
|
+
type ThinkingLevelName,
|
|
10
|
+
} from "../schemas/configuration.ts";
|
|
11
|
+
|
|
12
|
+
/** Find a model by `provider/id` (or bare id); undefined when the registry does not know it. */
|
|
13
|
+
export type ModelLookup = (ref: string) => Model<Api> | undefined;
|
|
14
|
+
|
|
15
|
+
export type { ProfileResolver };
|
|
16
|
+
|
|
17
|
+
/** `provider/id` for a model, the form settings store. */
|
|
18
|
+
export function modelRef(model: Pick<Model<Api>, "provider" | "id">): string {
|
|
19
|
+
return `${model.provider}/${model.id}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Thinking levels a model supports, in pi's order; every level when the model is unknown. */
|
|
23
|
+
export function supportedThinking(model: Model<Api> | undefined): ThinkingLevelName[] {
|
|
24
|
+
if (!model) return [...THINKING_LEVELS];
|
|
25
|
+
return getSupportedThinkingLevels(model).filter((level): level is ThinkingLevelName => isThinkingLevel(level));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ThinkingCheck {
|
|
29
|
+
/** The level to run at: the requested one, or pi's nearest supported level. */
|
|
30
|
+
level: string;
|
|
31
|
+
/** Set when the requested level had to change. */
|
|
32
|
+
warning?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Clamp `level` to what `model` supports, explaining any change; unknown models pass through. */
|
|
36
|
+
export function checkThinking(model: Model<Api> | undefined, level: string): ThinkingCheck {
|
|
37
|
+
if (!model || !isThinkingLevel(level)) return { level };
|
|
38
|
+
const clamped = clampThinkingLevel(model, level as ModelThinkingLevel);
|
|
39
|
+
if (clamped === level) return { level };
|
|
40
|
+
return { level: clamped, warning: `"${level}" thinking isn't supported by ${modelRef(model)} — using "${clamped}"` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const LABELS: Record<SubagentKind | "master", string> = {
|
|
44
|
+
master: "Master",
|
|
45
|
+
designer: "Designer",
|
|
46
|
+
backend: "Backend",
|
|
47
|
+
qa: "QA",
|
|
48
|
+
scout: "Scout",
|
|
49
|
+
researcher: "Researcher",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function kindLabel(kind: SubagentKind | "master"): string {
|
|
53
|
+
return LABELS[kind];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ResolverOptions {
|
|
57
|
+
lookup: ModelLookup;
|
|
58
|
+
/** The session's model as `provider/id`; used only where settings name no model yet. */
|
|
59
|
+
sessionModel?: string;
|
|
60
|
+
/** Receives each distinct clamp warning once per process. */
|
|
61
|
+
warn?: (message: string) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const warned = new Set<string>();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Profiles straight from settings, with the thinking level clamped to what
|
|
68
|
+
* each model supports. A subagent whose model is still unset runs on the
|
|
69
|
+
* session's model (named explicitly on the command line) until settings pin one.
|
|
70
|
+
*/
|
|
71
|
+
export function createProfileResolver(config: BotLobbyConfig, options: ResolverOptions): ProfileResolver {
|
|
72
|
+
return (domain, role) => {
|
|
73
|
+
const profile = agentProfile(config, domain, role);
|
|
74
|
+
const model = profile.model ?? options.sessionModel;
|
|
75
|
+
const check = checkThinking(model ? options.lookup(model) : undefined, profile.thinking);
|
|
76
|
+
if (check.warning) {
|
|
77
|
+
const message = `bot-lobby: ${kindLabel(profile.kind)} — ${check.warning}. Change it in /bot-lobby settings.`;
|
|
78
|
+
if (!warned.has(message)) {
|
|
79
|
+
warned.add(message);
|
|
80
|
+
options.warn?.(message);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { ...profile, model, thinking: check.level };
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every configured subagent whose thinking level its model does not support, for `/bot-lobby config`. */
|
|
88
|
+
export function thinkingMismatches(config: BotLobbyConfig, lookup: ModelLookup, sessionModel?: string): string[] {
|
|
89
|
+
const entries: Array<[SubagentKind | "master", string, string]> = [
|
|
90
|
+
["master", config.master.model, config.master.thinking],
|
|
91
|
+
["designer", config.agents.designer.model, config.agents.designer.thinking],
|
|
92
|
+
["backend", config.agents.backend.model, config.agents.backend.thinking],
|
|
93
|
+
["qa", config.agents.qa.model, config.agents.qa.thinking],
|
|
94
|
+
["researcher", config.researcher.model, config.researcher.thinking],
|
|
95
|
+
];
|
|
96
|
+
return entries.flatMap(([kind, ref, level]) => {
|
|
97
|
+
const model = ref === "inherit" ? sessionModel : ref;
|
|
98
|
+
const check = checkThinking(model ? lookup(model) : undefined, level);
|
|
99
|
+
return check.warning ? [`${kindLabel(kind)}: ${check.warning}`] : [];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-line descriptions of subagent runs, shared by the transcript entries,
|
|
3
|
+
* `/bot-lobby runs`, the Master's reports and the zen feed row. Pure: every
|
|
4
|
+
* clock read arrives as `now`.
|
|
5
|
+
*/
|
|
6
|
+
import type { AgentRun } from "../schemas/findings.ts";
|
|
7
|
+
import type { RunLogEntry } from "../schemas/task.ts";
|
|
8
|
+
import { shortDuration } from "../text.ts";
|
|
9
|
+
import { SLOT_LABELS, type SlotId } from "./mascot-art.ts";
|
|
10
|
+
|
|
11
|
+
/** The zen column a run belongs to; a researcher reports under RESEARCH from any domain. */
|
|
12
|
+
export function slotOf(run: Pick<AgentRun, "domain" | "role">): SlotId {
|
|
13
|
+
if (run.role === "researcher") return "research";
|
|
14
|
+
if (run.domain === "backend") return "dev";
|
|
15
|
+
return run.domain === "designer" ? "design" : "qa";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Upper-case agent name as the scene shows it (DEV, DESIGN, RESEARCH, QA). */
|
|
19
|
+
export function agentName(run: Pick<AgentRun, "domain" | "role">): string {
|
|
20
|
+
return SLOT_LABELS[slotOf(run)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Compact token count: 950, 41k, 1.2M. */
|
|
24
|
+
export function tokens(count: number): string {
|
|
25
|
+
if (!Number.isFinite(count) || count < 1000) return String(Math.max(0, Math.round(count || 0)));
|
|
26
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
27
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function durationOf(run: Pick<AgentRun, "startedAt" | "finishedAt">, now: number): number {
|
|
31
|
+
const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
|
|
32
|
+
const elapsed = end - Date.parse(run.startedAt);
|
|
33
|
+
return Number.isFinite(elapsed) && elapsed > 0 ? elapsed : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const STATUS_ICONS: Record<AgentRun["status"], string> = {
|
|
37
|
+
running: "◐",
|
|
38
|
+
success: "✓",
|
|
39
|
+
failed: "✗",
|
|
40
|
+
cancelled: "·",
|
|
41
|
+
timeout: "✗",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Why a run ended the way it did, in words, when that is not plain success. */
|
|
45
|
+
export function runFlags(run: Pick<AgentRun, "status" | "stalled" | "wrappedUp" | "attempts">): string[] {
|
|
46
|
+
const flags: string[] = [];
|
|
47
|
+
if (run.stalled) flags.push("stalled");
|
|
48
|
+
else if (run.status === "timeout") flags.push("hit its time limit");
|
|
49
|
+
else if (run.status === "failed") flags.push("failed");
|
|
50
|
+
else if (run.status === "cancelled") flags.push("cancelled");
|
|
51
|
+
if (run.wrappedUp) flags.push(run.status === "success" ? "wrapped up early — report may be partial" : "asked to wrap up");
|
|
52
|
+
if (run.attempts > 1) flags.push(`${run.attempts} attempts`);
|
|
53
|
+
return flags;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `✓ DEV worker · 3m 12s · 9 turns · 23 tools · 41k↑ 6k↓ · $0.12 · provider/model`,
|
|
58
|
+
* followed by any flags (stalled, time limit, wrapped up early, attempts).
|
|
59
|
+
*/
|
|
60
|
+
export function describeRun(run: AgentRun, now = Date.now()): string {
|
|
61
|
+
const parts = [`${STATUS_ICONS[run.status]} ${agentName(run)} ${run.role}`, shortDuration(durationOf(run, now))];
|
|
62
|
+
const turns = run.turns ?? run.usage?.turns;
|
|
63
|
+
if (turns) parts.push(`${turns} turn${turns === 1 ? "" : "s"}`);
|
|
64
|
+
if (run.tools) parts.push(`${run.tools} tool${run.tools === 1 ? "" : "s"}`);
|
|
65
|
+
if (run.usage && (run.usage.input || run.usage.output)) parts.push(`${tokens(run.usage.input)}↑ ${tokens(run.usage.output)}↓`);
|
|
66
|
+
if (run.usage?.cost) parts.push(`$${run.usage.cost.toFixed(2)}`);
|
|
67
|
+
if (run.model) parts.push(run.model);
|
|
68
|
+
parts.push(...runFlags(run));
|
|
69
|
+
const line = parts.join(" · ");
|
|
70
|
+
return run.status === "success" || !run.error ? line : `${line} — ${run.error.split("\n")[0]}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The persisted form of a finished run, for `task.runLog`. */
|
|
74
|
+
export function runLogEntry(run: AgentRun): RunLogEntry {
|
|
75
|
+
return {
|
|
76
|
+
runId: run.runId,
|
|
77
|
+
domain: run.domain,
|
|
78
|
+
role: run.role,
|
|
79
|
+
status: run.status,
|
|
80
|
+
startedAt: run.startedAt,
|
|
81
|
+
...(run.finishedAt ? { finishedAt: run.finishedAt } : {}),
|
|
82
|
+
...(run.model ? { model: run.model } : {}),
|
|
83
|
+
...(run.turns ?? run.usage?.turns ? { turns: run.turns ?? run.usage?.turns } : {}),
|
|
84
|
+
...(run.tools ? { tools: run.tools } : {}),
|
|
85
|
+
...(run.usage ? { input: run.usage.input, output: run.usage.output, cost: run.usage.cost } : {}),
|
|
86
|
+
attempts: run.attempts,
|
|
87
|
+
...(run.stalled ? { stalled: true } : {}),
|
|
88
|
+
...(run.wrappedUp ? { wrappedUp: true } : {}),
|
|
89
|
+
...(run.error ? { error: run.error.split("\n")[0]!.slice(0, 200) } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A persisted entry back as a run, so `describeRun` formats both the same way. */
|
|
94
|
+
export function runFromLog(entry: RunLogEntry, taskId: string): AgentRun {
|
|
95
|
+
return {
|
|
96
|
+
runId: entry.runId,
|
|
97
|
+
taskId,
|
|
98
|
+
domain: entry.domain,
|
|
99
|
+
role: entry.role,
|
|
100
|
+
status: entry.status,
|
|
101
|
+
output: "",
|
|
102
|
+
attempts: entry.attempts ?? 1,
|
|
103
|
+
startedAt: entry.startedAt,
|
|
104
|
+
...(entry.finishedAt ? { finishedAt: entry.finishedAt } : {}),
|
|
105
|
+
...(entry.model ? { model: entry.model } : {}),
|
|
106
|
+
...(entry.turns ? { turns: entry.turns } : {}),
|
|
107
|
+
...(entry.tools ? { tools: entry.tools } : {}),
|
|
108
|
+
...(entry.input !== undefined ? { usage: { input: entry.input, output: entry.output ?? 0, cost: entry.cost ?? 0, turns: entry.turns ?? 0 } } : {}),
|
|
109
|
+
...(entry.stalled ? { stalled: true } : {}),
|
|
110
|
+
...(entry.wrappedUp ? { wrappedUp: true } : {}),
|
|
111
|
+
...(entry.error ? { error: entry.error } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Silence after which a working agent reads as "quiet" in the scene. */
|
|
116
|
+
export const QUIET_MS = 45_000;
|
|
117
|
+
|
|
118
|
+
/** How long a running agent has produced nothing, or 0 when it is not running or has no clock yet. */
|
|
119
|
+
export function quietFor(run: Pick<AgentRun, "status" | "lastEventAt" | "startedAt">, now: number): number {
|
|
120
|
+
if (run.status !== "running") return 0;
|
|
121
|
+
const last = run.lastEventAt ?? Date.parse(run.startedAt);
|
|
122
|
+
const quiet = now - last;
|
|
123
|
+
return Number.isFinite(quiet) && quiet > 0 ? quiet : 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface FeedLine {
|
|
127
|
+
text: string;
|
|
128
|
+
kind: "info" | "warning";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Ticks one working agent holds the feed before it rotates to the next (~3 s at the live clock). */
|
|
132
|
+
export const FEED_ROTATE_TICKS = 12;
|
|
133
|
+
|
|
134
|
+
/** What one working agent is doing right now, with a warning when something needs attention. */
|
|
135
|
+
function liveFeed(run: AgentRun, now: number): FeedLine {
|
|
136
|
+
const name = agentName(run);
|
|
137
|
+
if (run.waitingFor) return { text: `⧗ ${name} waiting for ${run.waitingFor}${run.note ? ` · ${run.note}` : ""}`, kind: "warning" };
|
|
138
|
+
const quiet = quietFor(run, now);
|
|
139
|
+
if (quiet >= QUIET_MS) {
|
|
140
|
+
const doing = run.activity ? ` (last: ${run.activity}${run.detail ? ` ${run.detail}` : ""})` : "";
|
|
141
|
+
return { text: `! ${name} quiet for ${shortDuration(quiet)}${doing}`, kind: "warning" };
|
|
142
|
+
}
|
|
143
|
+
if (run.note && run.noteKind === "warning") return { text: `! ${name} ${run.note}`, kind: "warning" };
|
|
144
|
+
const parts = [`▸ ${name} ${run.activity ?? "working"}${run.detail ? ` ${run.detail}` : ""}`];
|
|
145
|
+
const turns = run.turns ?? run.usage?.turns;
|
|
146
|
+
if (turns) parts.push(`turn ${turns}`);
|
|
147
|
+
if (run.tools) parts.push(`${run.tools} tools`);
|
|
148
|
+
if (run.usage && run.usage.input + run.usage.output > 0) parts.push(`${tokens(run.usage.input + run.usage.output)} tok`);
|
|
149
|
+
if (run.note) parts.push(run.note);
|
|
150
|
+
return { text: parts.join(" · "), kind: "info" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The scene's feed row: a working agent that needs attention first, otherwise
|
|
155
|
+
* the working agents in rotation, otherwise the most recent finished run.
|
|
156
|
+
* Undefined before anything has run.
|
|
157
|
+
*/
|
|
158
|
+
export function feedLine(runs: readonly AgentRun[], now: number, tick: number): FeedLine | undefined {
|
|
159
|
+
const running = runs.filter((run) => run.status === "running");
|
|
160
|
+
if (running.length > 0) {
|
|
161
|
+
const lines = running.map((run) => liveFeed(run, now));
|
|
162
|
+
const warning = lines.find((line) => line.kind === "warning");
|
|
163
|
+
if (warning) return warning;
|
|
164
|
+
const index = Math.floor(Math.max(0, tick) / FEED_ROTATE_TICKS) % lines.length;
|
|
165
|
+
return lines[index];
|
|
166
|
+
}
|
|
167
|
+
const last = [...runs].filter((run) => run.finishedAt).sort((a, b) => Date.parse(a.finishedAt!) - Date.parse(b.finishedAt!)).at(-1);
|
|
168
|
+
if (!last) return undefined;
|
|
169
|
+
return { text: describeRun(last, now), kind: last.status === "success" && !last.wrappedUp ? "info" : "warning" };
|
|
170
|
+
}
|
package/src/pi/settings-ui.ts
CHANGED
|
@@ -6,47 +6,71 @@ import {
|
|
|
6
6
|
type Theme,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Container, type Component, type Focusable, fuzzyFilter, getKeybindings, Input, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
|
|
9
|
-
import { AGENT_KINDS, type AgentKind } from "../schemas/agent.ts";
|
|
10
9
|
import {
|
|
11
10
|
INHERIT_MODEL,
|
|
12
|
-
INHERIT_THINKING,
|
|
13
11
|
isThinkingLevel,
|
|
14
|
-
|
|
12
|
+
SCOUT_THINKING,
|
|
13
|
+
SUBAGENT_KINDS,
|
|
15
14
|
type AgentModelConfig,
|
|
16
15
|
type BotLobbyConfig,
|
|
16
|
+
type SubagentKind,
|
|
17
17
|
} from "../schemas/configuration.ts";
|
|
18
18
|
import { globalConfigPath, loadConfig, saveConfig } from "../state/project.ts";
|
|
19
|
+
import { checkThinking, kindLabel, modelRef, supportedThinking } from "./model-support.ts";
|
|
20
|
+
import { modelLookup } from "./tools.ts";
|
|
19
21
|
|
|
20
22
|
const CUSTOM_MODEL = "__custom__";
|
|
21
23
|
const MAX_VISIBLE = 12;
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
/** One settings entry: the master or a subagent profile. */
|
|
26
|
+
export type SettingsKind = "master" | SubagentKind;
|
|
27
|
+
export const SETTINGS_KINDS: readonly SettingsKind[] = ["master", ...SUBAGENT_KINDS];
|
|
28
|
+
|
|
29
|
+
/** The editable fields of one entry; scouts have no thinking and no instructions of their own. */
|
|
30
|
+
interface EntryView {
|
|
31
|
+
model: string;
|
|
32
|
+
thinking?: string;
|
|
33
|
+
instructions?: string;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function entryView(config: BotLobbyConfig, kind: SettingsKind): EntryView {
|
|
38
|
+
if (kind === "master") return config.master;
|
|
39
|
+
if (kind === "scout") return { model: config.scout.model, timeoutMs: config.scout.timeoutMs };
|
|
40
|
+
if (kind === "researcher") return config.researcher;
|
|
41
|
+
return config.agents[kind];
|
|
25
42
|
}
|
|
26
43
|
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
interface EntryPatch {
|
|
45
|
+
model?: string;
|
|
46
|
+
thinking?: string;
|
|
47
|
+
instructions?: string;
|
|
48
|
+
timeoutMs?: number;
|
|
29
49
|
}
|
|
30
50
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
/** Apply a patch to one entry in a config copy; scouts ignore thinking and instructions. */
|
|
52
|
+
export function patchEntry(config: BotLobbyConfig, kind: SettingsKind, patch: EntryPatch): BotLobbyConfig {
|
|
53
|
+
const next: BotLobbyConfig = { ...config, agents: { ...config.agents } };
|
|
54
|
+
if (kind === "master") next.master = { ...config.master, ...patch } as AgentModelConfig;
|
|
55
|
+
else if (kind === "scout") {
|
|
56
|
+
next.scout = {
|
|
57
|
+
model: patch.model ?? config.scout.model,
|
|
58
|
+
timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs,
|
|
59
|
+
};
|
|
60
|
+
} else if (kind === "researcher") next.researcher = { ...config.researcher, ...patch } as AgentModelConfig;
|
|
61
|
+
else next.agents[kind] = { ...config.agents[kind], ...patch } as AgentModelConfig;
|
|
62
|
+
return next;
|
|
36
63
|
}
|
|
37
64
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const slash = ref.indexOf("/");
|
|
41
|
-
if (slash > 0) return ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
|
|
42
|
-
return ctx.modelRegistry.getAvailable().find((model) => model.id === ref);
|
|
65
|
+
function updateEntry(kind: SettingsKind, patch: EntryPatch): void {
|
|
66
|
+
saveConfig(patchEntry(loadConfig(), kind, patch));
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
/** Apply the master model/thinking to the live session; "inherit" leaves it alone. */
|
|
46
70
|
export async function applyMasterModel(pi: ExtensionAPI, ctx: ExtensionContext, config: BotLobbyConfig): Promise<void> {
|
|
47
71
|
const { model, thinking } = config.master;
|
|
48
72
|
if (model !== INHERIT_MODEL) {
|
|
49
|
-
const found =
|
|
73
|
+
const found = modelLookup(ctx)(model);
|
|
50
74
|
if (!found) ctx.ui.notify(`bot-lobby: unknown master model "${model}".`, "warning");
|
|
51
75
|
else if (!(await pi.setModel(found))) ctx.ui.notify(`bot-lobby: no auth for ${model}.`, "warning");
|
|
52
76
|
}
|
|
@@ -173,9 +197,13 @@ async function pick(ctx: ExtensionContext, title: string, items: SelectItem[], o
|
|
|
173
197
|
return options.search ? pickSearch(ctx, title, items) : pickPlain(ctx, title, items);
|
|
174
198
|
}
|
|
175
199
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const items: SelectItem[] = [
|
|
200
|
+
/** Model choices; only the master offers `inherit`, since it is the live session itself. */
|
|
201
|
+
export function modelItems(ctx: ExtensionContext, current: string, includeInherit = true): SelectItem[] {
|
|
202
|
+
const items: SelectItem[] = [];
|
|
203
|
+
if (includeInherit) {
|
|
204
|
+
const inherit = current === INHERIT_MODEL ? `${INHERIT_MODEL} ✓` : INHERIT_MODEL;
|
|
205
|
+
items.push({ value: INHERIT_MODEL, label: inherit, description: "Use the session's current model" });
|
|
206
|
+
}
|
|
179
207
|
const usable = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : ctx.modelRegistry.getAvailable();
|
|
180
208
|
for (const model of usable) {
|
|
181
209
|
const value = `${model.provider}/${model.id}`;
|
|
@@ -187,54 +215,123 @@ export function modelItems(ctx: ExtensionContext, current: string): SelectItem[]
|
|
|
187
215
|
return items;
|
|
188
216
|
}
|
|
189
217
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
ctx.
|
|
218
|
+
/** The model an entry runs on: its own, or the session's while unset. */
|
|
219
|
+
function effectiveModel(ctx: ExtensionContext, model: string) {
|
|
220
|
+
if (model !== INHERIT_MODEL) return modelLookup(ctx)(model);
|
|
221
|
+
return ctx.model;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Thinking choices limited to what the entry's model supports. */
|
|
225
|
+
export function thinkingItems(levels: readonly string[], current: string | undefined): SelectItem[] {
|
|
226
|
+
return levels.map((level) => (level === current ? { value: level, label: `${level} ✓` } : { value: level, label: level }));
|
|
194
227
|
}
|
|
195
228
|
|
|
196
|
-
async function
|
|
197
|
-
|
|
198
|
-
|
|
229
|
+
async function commit(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind, patch: EntryPatch, detail: string): Promise<void> {
|
|
230
|
+
updateEntry(kind, patch);
|
|
231
|
+
if (kind === "master") await applyMasterModel(pi, ctx, loadConfig());
|
|
232
|
+
ctx.ui.notify(`bot-lobby: ${kindLabel(kind)} ${detail} — saved to ${globalConfigPath()}`, "info");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** After a model change, clamp a saved thinking level the new model cannot run, and say so. */
|
|
236
|
+
async function reconcileThinking(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
237
|
+
const view = entryView(loadConfig(), kind);
|
|
238
|
+
if (!view.thinking) return;
|
|
239
|
+
const check = checkThinking(effectiveModel(ctx, view.model), view.thinking);
|
|
240
|
+
if (!check.warning) return;
|
|
241
|
+
updateEntry(kind, { thinking: check.level });
|
|
242
|
+
if (kind === "master") await applyMasterModel(pi, ctx, loadConfig());
|
|
243
|
+
ctx.ui.notify(`bot-lobby: ${kindLabel(kind)} — ${check.warning}.`, "warning");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function editModel(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
247
|
+
const current = entryView(loadConfig(), kind).model;
|
|
248
|
+
const choice = await pick(ctx, `Model — ${kindLabel(kind)}`, modelItems(ctx, current, kind === "master"), { search: true });
|
|
199
249
|
if (choice === undefined) return;
|
|
200
250
|
const typed = choice === CUSTOM_MODEL ? (await ctx.ui.input("Model id", "provider/model"))?.trim() : choice;
|
|
201
251
|
if (!typed) return;
|
|
202
|
-
await commit(pi, ctx,
|
|
252
|
+
await commit(pi, ctx, kind, { model: typed }, `model → ${typed}`);
|
|
253
|
+
await reconcileThinking(pi, ctx, kind);
|
|
203
254
|
}
|
|
204
255
|
|
|
205
|
-
async function editThinking(pi: ExtensionAPI, ctx: ExtensionContext,
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
256
|
+
async function editThinking(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
257
|
+
const view = entryView(loadConfig(), kind);
|
|
258
|
+
const model = effectiveModel(ctx, view.model);
|
|
259
|
+
const levels = supportedThinking(model);
|
|
260
|
+
const title = model ? `Thinking — ${kindLabel(kind)} (${modelRef(model)})` : `Thinking — ${kindLabel(kind)}`;
|
|
261
|
+
const level = await pick(ctx, title, thinkingItems(levels, view.thinking));
|
|
262
|
+
if (!level || !isThinkingLevel(level)) return;
|
|
263
|
+
await commit(pi, ctx, kind, { thinking: level }, `thinking → ${level}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function editTimeout(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
267
|
+
const current = entryView(loadConfig(), kind).timeoutMs ?? loadConfig().workflow.agentTimeoutMs;
|
|
268
|
+
const typed = (await ctx.ui.input(`Time limit (minutes) — ${kindLabel(kind)}`, String(Math.round(current / 60_000))))?.trim();
|
|
269
|
+
if (!typed) return;
|
|
270
|
+
const minutes = Number(typed);
|
|
271
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
272
|
+
ctx.ui.notify(`bot-lobby: "${typed}" is not a positive number of minutes.`, "warning");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
await commit(pi, ctx, kind, { timeoutMs: Math.round(minutes * 60_000) }, `time limit → ${minutes}m`);
|
|
214
276
|
}
|
|
215
277
|
|
|
216
|
-
async function editInstructions(pi: ExtensionAPI, ctx: ExtensionContext,
|
|
217
|
-
const current =
|
|
218
|
-
const text = await ctx.ui.editor(`Instructions — ${
|
|
278
|
+
async function editInstructions(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
279
|
+
const current = entryView(loadConfig(), kind).instructions ?? "";
|
|
280
|
+
const text = await ctx.ui.editor(`Instructions — ${kindLabel(kind)}`, current);
|
|
219
281
|
if (text === undefined) return;
|
|
220
|
-
await commit(pi, ctx,
|
|
282
|
+
await commit(pi, ctx, kind, { instructions: text.trim() }, "instructions saved");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function minutes(ms: number | undefined): string {
|
|
286
|
+
return ms ? `${Math.round(ms / 60_000)}m` : "default";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Menu rows for one entry; scouts expose model and time limit only (their thinking is fixed). */
|
|
290
|
+
export function entryItems(kind: SettingsKind, view: EntryView): SelectItem[] {
|
|
291
|
+
const items: SelectItem[] = [{ value: "model", label: "Model", description: view.model }];
|
|
292
|
+
if (kind === "scout") items.push({ value: "fixed", label: "Thinking", description: `${SCOUT_THINKING} (fixed for scouts)` });
|
|
293
|
+
else items.push({ value: "thinking", label: "Thinking", description: view.thinking ?? "" });
|
|
294
|
+
if (kind !== "master") items.push({ value: "timeout", label: "Time limit", description: minutes(view.timeoutMs) });
|
|
295
|
+
if (kind !== "scout" && kind !== "researcher") {
|
|
296
|
+
items.push({ value: "instructions", label: "Instructions", description: view.instructions ? `${view.instructions.length} chars` : "(none)" });
|
|
297
|
+
}
|
|
298
|
+
items.push({ value: "back", label: "Back" });
|
|
299
|
+
return items;
|
|
221
300
|
}
|
|
222
301
|
|
|
223
|
-
async function
|
|
302
|
+
async function editEntry(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
224
303
|
for (;;) {
|
|
225
|
-
const
|
|
226
|
-
const items: SelectItem[] = [
|
|
227
|
-
{ value: "model", label: "Model", description: cfg.model },
|
|
228
|
-
{ value: "thinking", label: "Thinking", description: cfg.thinking },
|
|
229
|
-
{ value: "instructions", label: "Instructions", description: cfg.instructions ? `${cfg.instructions.length} chars` : "(none)" },
|
|
230
|
-
{ value: "back", label: "Back" },
|
|
231
|
-
];
|
|
232
|
-
const action = await pick(ctx, `${agentLabel(agent)} settings`, items);
|
|
304
|
+
const action = await pick(ctx, `${kindLabel(kind)} settings`, entryItems(kind, entryView(loadConfig(), kind)));
|
|
233
305
|
if (!action || action === "back") return;
|
|
234
|
-
if (action === "model") await editModel(pi, ctx,
|
|
235
|
-
else if (action === "thinking") await editThinking(pi, ctx,
|
|
236
|
-
else await
|
|
306
|
+
if (action === "model") await editModel(pi, ctx, kind);
|
|
307
|
+
else if (action === "thinking") await editThinking(pi, ctx, kind);
|
|
308
|
+
else if (action === "timeout") await editTimeout(pi, ctx, kind);
|
|
309
|
+
else if (action === "instructions") await editInstructions(pi, ctx, kind);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Subagents never inherit silently: any subagent whose model is still unset is
|
|
315
|
+
* pinned to the session's current model and saved, so settings always show
|
|
316
|
+
* what each agent runs on.
|
|
317
|
+
*/
|
|
318
|
+
export function prefillModels(config: BotLobbyConfig, sessionModel: string | undefined): { config: BotLobbyConfig; filled: SubagentKind[] } {
|
|
319
|
+
if (!sessionModel) return { config, filled: [] };
|
|
320
|
+
let next = config;
|
|
321
|
+
const filled: SubagentKind[] = [];
|
|
322
|
+
for (const kind of SUBAGENT_KINDS) {
|
|
323
|
+
if (entryView(next, kind).model !== INHERIT_MODEL) continue;
|
|
324
|
+
next = patchEntry(next, kind, { model: sessionModel });
|
|
325
|
+
filled.push(kind);
|
|
237
326
|
}
|
|
327
|
+
return { config: next, filled };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function entryDescription(kind: SettingsKind, view: EntryView): string {
|
|
331
|
+
const thinking = kind === "scout" ? SCOUT_THINKING : view.thinking;
|
|
332
|
+
const custom = view.instructions ? " · custom" : "";
|
|
333
|
+
const limit = kind === "master" ? "" : ` · ${minutes(view.timeoutMs)}`;
|
|
334
|
+
return `${view.model} · ${thinking}${limit}${custom}`;
|
|
238
335
|
}
|
|
239
336
|
|
|
240
337
|
/** Open the per-agent settings editor; every change is written to the global config. */
|
|
@@ -243,16 +340,17 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
|
|
|
243
340
|
ctx.ui.notify(`bot-lobby settings live in ${globalConfigPath()}; edit that file outside the TUI.`, "info");
|
|
244
341
|
return;
|
|
245
342
|
}
|
|
343
|
+
const prefilled = prefillModels(loadConfig(), ctx.model ? modelRef(ctx.model) : undefined);
|
|
344
|
+
if (prefilled.filled.length > 0) {
|
|
345
|
+
saveConfig(prefilled.config);
|
|
346
|
+
ctx.ui.notify(`bot-lobby: set ${prefilled.filled.map(kindLabel).join(", ")} to the session model; change them here any time.`, "info");
|
|
347
|
+
}
|
|
246
348
|
for (;;) {
|
|
247
349
|
const config = loadConfig();
|
|
248
|
-
const items: SelectItem[] =
|
|
249
|
-
const cfg = agentConfig(config, agent);
|
|
250
|
-
const custom = cfg.instructions ? " · custom" : "";
|
|
251
|
-
return { value: agent, label: agentLabel(agent), description: `${cfg.model} · ${cfg.thinking}${custom}` };
|
|
252
|
-
});
|
|
350
|
+
const items: SelectItem[] = SETTINGS_KINDS.map((kind) => ({ value: kind, label: kindLabel(kind), description: entryDescription(kind, entryView(config, kind)) }));
|
|
253
351
|
items.push({ value: "close", label: "Close" });
|
|
254
352
|
const choice = await pick(ctx, "bot-lobby settings", items);
|
|
255
353
|
if (!choice || choice === "close") return;
|
|
256
|
-
await
|
|
354
|
+
await editEntry(pi, ctx, choice as SettingsKind);
|
|
257
355
|
}
|
|
258
356
|
}
|