@a-t-h-i/bot-lobby 0.6.0 → 0.6.2

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.
Files changed (49) hide show
  1. package/README.md +177 -799
  2. package/package.json +1 -1
  3. package/prompts/master.md +17 -0
  4. package/prompts/panel.md +3 -1
  5. package/prompts/planner.md +14 -0
  6. package/prompts/quickfix.md +3 -1
  7. package/prompts/scout.md +3 -0
  8. package/prompts/worker.md +3 -0
  9. package/src/classifier/answers.ts +110 -0
  10. package/src/classifier/classifier.ts +171 -0
  11. package/src/classifier/client.ts +239 -0
  12. package/src/classifier/effort.ts +143 -0
  13. package/src/classifier/files.ts +427 -0
  14. package/src/classifier/hosts.ts +157 -0
  15. package/src/classifier/instance.ts +98 -0
  16. package/src/classifier/limits.ts +37 -0
  17. package/src/classifier/seats.ts +102 -0
  18. package/src/classifier/tools.ts +58 -0
  19. package/src/classifier/triage.ts +203 -0
  20. package/src/execution/agent-runner.ts +5 -0
  21. package/src/index.ts +6 -0
  22. package/src/lobby/keys.ts +1 -1
  23. package/src/lobby/planner.ts +305 -27
  24. package/src/lobby/quickfix.ts +120 -8
  25. package/src/lobby/runtime.ts +136 -5
  26. package/src/lobby/session-files.ts +12 -0
  27. package/src/lobby/sessions.ts +28 -2
  28. package/src/lobby/tabs/home.ts +54 -26
  29. package/src/lobby/tabs/metrics.ts +28 -1
  30. package/src/lobby/tabs/plan.ts +27 -4
  31. package/src/lobby/tabs/quickfix.ts +5 -1
  32. package/src/lobby/tabs/tasks.ts +30 -14
  33. package/src/lobby/view.ts +334 -41
  34. package/src/master/master.ts +95 -29
  35. package/src/pi/commands.ts +24 -4
  36. package/src/pi/events.ts +11 -2
  37. package/src/pi/owner.ts +57 -8
  38. package/src/pi/run-summary.ts +3 -1
  39. package/src/pi/settings-ui.ts +138 -2
  40. package/src/pi/start-task.ts +4 -0
  41. package/src/pi/tools.ts +7 -2
  42. package/src/schemas/configuration.ts +125 -1
  43. package/src/schemas/findings.ts +4 -0
  44. package/src/schemas/task.ts +24 -0
  45. package/src/state/archive.ts +99 -0
  46. package/src/state/inbox.ts +49 -15
  47. package/src/state/metrics.ts +74 -1
  48. package/src/state/presence.ts +99 -0
  49. package/src/workflow/workflow.ts +49 -1
@@ -13,6 +13,8 @@ import { parseWorkerResult, validateWorkerResult } from "../roles/worker.ts";
13
13
  import { parseReviewResult, validateReviewResult } from "../roles/reviewer.ts";
14
14
  import { truncate } from "../text.ts";
15
15
  import { isScoutResultUsable, parseScoutResult, validateScoutResult } from "../roles/scout.ts";
16
+ import type { FileHinter } from "../classifier/files.ts";
17
+ import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
16
18
 
17
19
  export interface ScoutOutcome {
18
20
  result: ScoutResult;
@@ -36,6 +38,10 @@ export interface ScoutRequest {
36
38
  profile?: ProfileResolver;
37
39
  signal?: AbortSignal;
38
40
  onUpdate?: (run: AgentRun) => void;
41
+ /** Likely files for each scout's context, and the lookup tool, while the classifier's file hints are on. */
42
+ hints?: FileHinter;
43
+ /** Moves a trivial scout to the cheaper model, while the classifier's effort routing is on. */
44
+ effort?: EffortRouter;
39
45
  }
40
46
 
41
47
  /** Model, thinking and time limit for one run, from settings. */
@@ -53,16 +59,41 @@ function scoutInstruction(request: ScoutRequest, domain: Domain): string {
53
59
  ].join("\n");
54
60
  }
55
61
 
56
- function scoutContext(request: ScoutRequest, domain: Domain): AgentRequest["context"] {
62
+ function scoutContext(request: ScoutRequest, domain: Domain, likely = ""): AgentRequest["context"] {
57
63
  const slices = readAgentKnowledge(request.dataRoots, domain);
58
64
  return {
59
65
  task: request.taskText,
60
66
  ...selectKnowledge(`${request.taskText} ${domainSpec(domain).scoutFocus}`, slices),
61
67
  instructions: request.config.agents[domain].instructions,
62
- workflowContext: `Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`,
68
+ workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, likely].filter(Boolean).join("\n\n"),
63
69
  };
64
70
  }
65
71
 
72
+ /** Where the classifier routes a run, or undefined (no router, no route, a failure). */
73
+ async function routeFor(effort: EffortRouter | undefined, instruction: string, profile: { model?: string; thinking: string }, options: { thinkingFixed?: boolean; context?: string; signal?: AbortSignal }): Promise<EffortRoute | undefined> {
74
+ if (!effort) return undefined;
75
+ try {
76
+ return await effort.route(instruction, profile, options);
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ /** The run fields a route changes: model, thinking, no in-place retry (the fallback is the retry), and its label. */
83
+ function routedFields(route: EffortRoute): Pick<AgentRequest, "model" | "thinking" | "retries" | "routedFrom" | "route"> {
84
+ return { model: route.model, thinking: route.thinking, retries: 0, routedFrom: profileLabel(route.from), route: routeLabel(route) };
85
+ }
86
+
87
+ /** The Likely files block for one agent's step, or "" without hints. */
88
+ async function likelyFor(hints: FileHinter | undefined, query: string, signal: AbortSignal | undefined, context?: string): Promise<string> {
89
+ if (!hints) return "";
90
+ try {
91
+ return await hints.block(query, signal, context);
92
+ } catch {
93
+ return "";
94
+ }
95
+ }
96
+
66
97
  function toOutcome(run: AgentRun, domain: Domain): ScoutOutcome {
67
98
  if (run.status !== "success") {
68
99
  return {
@@ -78,20 +109,36 @@ function toOutcome(run: AgentRun, domain: Domain): ScoutOutcome {
78
109
 
79
110
  /** Run the selected domain scouts concurrently and persist their findings. */
80
111
  export async function runScouts(request: ScoutRequest, run: ProcessRunner = spawnPiProcess): Promise<ScoutOutcome[]> {
81
- const requests: AgentRequest[] = request.domains.map((domain) => ({
112
+ // Each scout gets the files most likely to answer its own instruction and focus.
113
+ const likely = await Promise.all(request.domains.map((domain) => likelyFor(request.hints, `${request.instruction}\n${domainSpec(domain).scoutFocus}`, request.signal, request.taskText)));
114
+ const extraTools = request.hints?.tools() ?? [];
115
+ const requests: AgentRequest[] = request.domains.map((domain, index) => ({
82
116
  taskId: request.taskId,
83
117
  domain,
84
118
  role: "scout",
85
119
  instruction: scoutInstruction(request, domain),
86
- context: scoutContext(request, domain),
120
+ context: scoutContext(request, domain, likely[index]),
121
+ ...(extraTools.length > 0 ? { extraTools } : {}),
87
122
  ...profileFields(request.config, request.profile, domain, "scout"),
88
123
  cwd: request.cwd,
89
124
  signal: request.signal,
90
125
  onUpdate: request.onUpdate,
91
126
  ...watchdogOptions(request.config.workflow),
92
127
  }));
93
- const runs = await runParallel(requests, request.config.workflow.maxParallelScouts, run);
94
- const outcomes = runs.map((agentRun) => toOutcome(agentRun, agentRun.domain));
128
+ // Scouts think at a fixed level; a trivial one may move to the cheaper model.
129
+ const routes = await Promise.all(requests.map((entry) => routeFor(request.effort, entry.instruction, { ...(entry.model ? { model: entry.model } : {}), thinking: entry.thinking ?? "low" }, { thinkingFixed: true, context: request.taskText, ...(request.signal ? { signal: request.signal } : {}) })));
130
+ const first = requests.map((entry, index) => (routes[index] ? { ...entry, ...routedFields(routes[index]!) } : entry));
131
+ const runs = await runParallel(first, request.config.workflow.maxParallelScouts, run);
132
+ let outcomes = runs.map((agentRun) => toOutcome(agentRun, agentRun.domain));
133
+ // A routed scout that came back unusable runs again on its configured model.
134
+ const again = outcomes.map((outcome, index) => (routes[index] && !outcome.usable && outcome.run.status !== "cancelled" && !request.signal?.aborted ? index : -1)).filter((index) => index >= 0);
135
+ if (again.length > 0) {
136
+ const reruns = await runParallel(again.map((index) => requests[index]!), request.config.workflow.maxParallelScouts, run);
137
+ outcomes = outcomes.map((outcome, index) => {
138
+ const position = again.indexOf(index);
139
+ return position >= 0 ? toOutcome(reruns[position]!, reruns[position]!.domain) : outcome;
140
+ });
141
+ }
95
142
  saveScoutResults(request.taskDir, outcomes);
96
143
  return outcomes;
97
144
  }
@@ -152,9 +199,13 @@ export interface WorkerRequest {
152
199
  profile?: ProfileResolver;
153
200
  signal?: AbortSignal;
154
201
  onUpdate?: (run: AgentRun) => void;
202
+ /** Likely files for the worker's step, and the lookup tool, while the classifier's file hints are on. */
203
+ hints?: FileHinter;
204
+ /** Lowers thinking (or the model) for a step the classifier judges simple or trivial. */
205
+ effort?: EffortRouter;
155
206
  }
156
207
 
157
- function workerWorkflowContext(request: WorkerRequest): string {
208
+ function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
158
209
  const spec = domainSpec(request.domain);
159
210
  const own = request.scoutOutcomes.filter(
160
211
  (outcome) => outcome.result.domain === request.domain && outcome.usable,
@@ -166,7 +217,8 @@ function workerWorkflowContext(request: WorkerRequest): string {
166
217
  own.length > 0
167
218
  ? `Scout findings for your domain:\n${summarizeOutcomes(own, 1500)}`
168
219
  : "No scout findings were collected for your domain; verify the repository yourself.",
169
- ].join("\n\n");
220
+ likely,
221
+ ].filter(Boolean).join("\n\n");
170
222
  }
171
223
 
172
224
  /** Delegate one implementation step to a domain worker. */
@@ -176,28 +228,42 @@ export async function runWorker(
176
228
  ): Promise<WorkerOutcome> {
177
229
  const slices = readAgentKnowledge(request.dataRoots, request.domain);
178
230
  const selected = selectKnowledge(`${request.taskText} ${request.instruction}`, slices);
179
- const agentRun = await runAgent(
180
- {
181
- taskId: request.taskId,
182
- domain: request.domain,
183
- role: "worker",
184
- instruction: request.instruction,
185
- context: {
186
- task: request.taskText,
187
- ...selected,
188
- instructions: request.config.agents[request.domain].instructions,
189
- workflowContext: workerWorkflowContext(request),
190
- },
191
- ...profileFields(request.config, request.profile, request.domain, "worker"),
192
- cwd: request.cwd,
193
- signal: request.signal,
194
- onUpdate: request.onUpdate,
195
- ...watchdogOptions(request.config.workflow),
196
- ...request.agent,
231
+ const configured = profileFields(request.config, request.profile, request.domain, "worker");
232
+ const [likely, route] = await Promise.all([
233
+ likelyFor(request.hints, request.instruction, request.signal, request.taskText),
234
+ routeFor(request.effort, request.instruction, { ...(configured.model ? { model: configured.model } : {}), thinking: configured.thinking }, { context: request.taskText, ...(request.signal ? { signal: request.signal } : {}) }),
235
+ ]);
236
+ const hintTools = request.hints?.tools() ?? [];
237
+ const extraTools = [...(request.agent?.extraTools ?? []), ...hintTools];
238
+ const base: AgentRequest = {
239
+ taskId: request.taskId,
240
+ domain: request.domain,
241
+ role: "worker",
242
+ instruction: request.instruction,
243
+ context: {
244
+ task: request.taskText,
245
+ ...selected,
246
+ instructions: request.config.agents[request.domain].instructions,
247
+ workflowContext: workerWorkflowContext(request, likely),
197
248
  },
198
- run,
199
- );
200
- const result = parseWorkerResult(request.domain, agentRun.output);
249
+ ...configured,
250
+ cwd: request.cwd,
251
+ signal: request.signal,
252
+ onUpdate: request.onUpdate,
253
+ ...watchdogOptions(request.config.workflow),
254
+ ...request.agent,
255
+ ...(extraTools.length > 0 ? { extraTools } : {}),
256
+ };
257
+ let outcome = workerOutcome(request.domain, await runAgent(route ? { ...base, ...routedFields(route) } : base, run));
258
+ // A routed step that fell short runs again at the configured model and thinking.
259
+ if (route && fellShort(outcome.run, outcome.issues) && !request.signal?.aborted) {
260
+ outcome = workerOutcome(request.domain, await runAgent(base, run));
261
+ }
262
+ return outcome;
263
+ }
264
+
265
+ function workerOutcome(domain: Domain, agentRun: AgentRun): WorkerOutcome {
266
+ const result = parseWorkerResult(domain, agentRun.output);
201
267
  const issues =
202
268
  agentRun.status === "success"
203
269
  ? validateWorkerResult(result)
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { join } from "node:path";
2
3
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
4
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
@@ -18,7 +19,8 @@ import { readFirstExisting } from "../knowledge/store.ts";
18
19
  import { overThreshold } from "../knowledge/compactor.ts";
19
20
  import { applyApprovalChoice, describeTask, describeOversizedKnowledge, type ApprovalChoice } from "../workflow/workflow.ts";
20
21
  import { applyStatus, registerRevealShortcut, setMinimized } from "./ui.ts";
21
- import { openSettings } from "./settings-ui.ts";
22
+ import { classifierSummary, openSettings } from "./settings-ui.ts";
23
+ import { keyStatus } from "../classifier/instance.ts";
22
24
  import { kickoff, startPlannedTask, startTask } from "./start-task.ts";
23
25
  import { setAuto, toggleOwnAuto } from "./owner.ts";
24
26
  import { autoOpenLobby, showLobby } from "../lobby/runtime.ts";
@@ -43,11 +45,12 @@ const HELP = [
43
45
  "/bot-lobby claim <taskId> Take ownership of an orphaned task",
44
46
  "/bot-lobby auto [on|off] Auto mode: the oracle drives this session's task without asking (alt+g)",
45
47
  "/bot-lobby start-plan PLAN-… [auto] Start a planned task here; its agreed plan needs no approval",
48
+ "/bot-lobby switch <session.jsonl> Run a saved session in this window (the lobby's session browser uses it: alt+o, s)",
46
49
  "/bot-lobby help This help",
47
50
  ].join("\n");
48
51
 
49
52
  /** Subcommands only win when no free-form text follows (so tasks still start). */
50
- const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "knowledge", "config", "settings", "minimize", "restore", "claim", "auto", "start-plan"]);
53
+ const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "knowledge", "config", "settings", "minimize", "restore", "claim", "auto", "start-plan", "switch"]);
51
54
 
52
55
  function isTaskId(value: string | undefined): boolean {
53
56
  return Boolean(value && /^TASK-/.test(value));
@@ -62,7 +65,8 @@ export function parseCommand(args: string): { sub: string | undefined; rest: str
62
65
  if (!sub || !SUBCOMMANDS.has(sub)) return { sub: undefined, rest: [], restText: trimmed };
63
66
  const takesArgs = sub === "amend" || sub === "claim"
64
67
  || (sub === "auto" && rest.length === 1 && /^(on|off)$/i.test(rest[0]!))
65
- || (sub === "start-plan" && rest.length >= 1 && rest.length <= 2 && /^PLAN-/.test(rest[0]!) && (rest.length === 1 || rest[1] === "auto"));
68
+ || (sub === "start-plan" && rest.length >= 1 && rest.length <= 2 && /^PLAN-/.test(rest[0]!) && (rest.length === 1 || rest[1] === "auto"))
69
+ || (sub === "switch" && rest.length >= 1 && /\.jsonl$/.test(rest.join(" ")));
66
70
  if (!takesArgs && rest.length > 0 && !(rest.length === 1 && isTaskId(rest[0]))) {
67
71
  return { sub: undefined, rest: [], restText: trimmed };
68
72
  }
@@ -194,7 +198,21 @@ function showConfig(ctx: ExtensionCommandContext): void {
194
198
  const warnings = thinkingMismatches(config, modelLookup(ctx), ctx.model ? modelRef(ctx.model) : undefined);
195
199
  if (hasScoutThinking(readRawConfig())) warnings.push(`Scout: thinking is fixed at "${SCOUT_THINKING}"; the scout.thinking value in the file is ignored.`);
196
200
  const notes = warnings.length > 0 ? `\n\nWarnings:\n${warnings.map((line) => `- ${line}`).join("\n")}` : "";
197
- ctx.ui.notify(`${globalConfigPath()}\n${JSON.stringify(config, null, 2)}${notes}`, warnings.length > 0 ? "warning" : "info");
201
+ const jev = `Classifier: ${classifierSummary(config, keyStatus)}`;
202
+ ctx.ui.notify(`${globalConfigPath()}\n${jev}\n${JSON.stringify(config, null, 2)}${notes}`, warnings.length > 0 ? "warning" : "info");
203
+ }
204
+
205
+ /**
206
+ * `/bot-lobby switch <file>`: run a saved session in this window. Only a
207
+ * command context may replace the session, so the lobby's browser asks for a
208
+ * switch through this command. The context is stale once the switch starts.
209
+ */
210
+ async function switchCommand(ctx: ExtensionCommandContext, path: string): Promise<void> {
211
+ if (!existsSync(path)) return ctx.ui.notify(`bot-lobby: no session file at ${path}`, "warning");
212
+ if (ctx.sessionManager.getSessionFile() === path) return ctx.ui.notify("bot-lobby: this window already runs that session", "info");
213
+ if (!ctx.isIdle()) return ctx.ui.notify("bot-lobby: the oracle is working — esc stops it, then switch", "warning");
214
+ const result = await ctx.switchSession(path);
215
+ if (result.cancelled) ctx.ui.notify("bot-lobby: the switch was cancelled", "warning");
198
216
  }
199
217
 
200
218
  /** `/bot-lobby auto [on|off]`: switch (or set) auto mode for this session's task. */
@@ -265,6 +283,8 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
265
283
  return claimTaskCommand(ctx, configDir, rest[0]);
266
284
  case "auto":
267
285
  return autoCommand(ctx, configDir, rest[0]);
286
+ case "switch":
287
+ return switchCommand(ctx, restText);
268
288
  case "start-plan": {
269
289
  const started = await startPlannedTask(pi, ctx, configDir, rest[0]!, { auto: rest[1] === "auto" });
270
290
  if (typeof started === "string") return ctx.ui.notify(`bot-lobby: ${started}`, "warning");
package/src/pi/events.ts CHANGED
@@ -11,9 +11,18 @@ import { applyStatus, clearStatus, isMinimized, setMinimized, setOracleActivity
11
11
  import { ORACLE_THINKING, oracleActivityWord } from "./activity.ts";
12
12
  import { isSubagentProcess, visibleTools } from "./quiet.ts";
13
13
  import { registerQuietTools } from "./tool-renderers.ts";
14
- import { taskRequest, type Task } from "../schemas/task.ts";
14
+ import { taskRequest, type Task, type TaskState } from "../schemas/task.ts";
15
15
  import { pendingComments, readPlanComments, type PlanComment } from "../state/comments.ts";
16
16
  import { isAutoMode } from "../state/auto.ts";
17
+ import { triageContext } from "../classifier/triage.ts";
18
+
19
+ /** States in which the triage still helps the Master shape the task; once it is planned, the hints are noise. */
20
+ const SHAPING_STATES: ReadonlySet<TaskState> = new Set(["created", "clarifying", "scouting", "synthesizing", "awaiting_approval"]);
21
+
22
+ /** The Master's workflow context: the task's state, plus the classifier's triage while the task is being shaped. */
23
+ export function masterWorkflowContext(task: Task): string {
24
+ return [describeTask(task), SHAPING_STATES.has(task.state) ? triageContext(task.triage) : ""].filter(Boolean).join("\n\n");
25
+ }
17
26
 
18
27
  export function masterTaskContext(task: Task, comments: readonly PlanComment[] = [], auto = false): string {
19
28
  const open = pendingComments(comments);
@@ -96,7 +105,7 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
96
105
  standards: selected.standards,
97
106
  knowledge: selected.knowledge,
98
107
  decisions: selected.decisions,
99
- workflowContext: describeTask(task),
108
+ workflowContext: masterWorkflowContext(task),
100
109
  instructions: loadConfig().master.instructions,
101
110
  });
102
111
  });
package/src/pi/owner.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
- * What a session does for the task it drives, on its own clock. Every few
3
- * seconds a session that owns a task (in the terminal or headless, started
4
- * from another window's lobby) passes on what other sessions left for its
5
- * oracle, plan comments and inbox messages, and in auto mode keeps the oracle
6
- * going whenever its turn ends before the task is done. Subagents never own
7
- * tasks, so none of this runs in them.
2
+ * What a session does on its own clock. Every few seconds a session (in the
3
+ * terminal or headless, started from another window's lobby) refreshes its
4
+ * heartbeat so other windows see it running, passes on what other sessions
5
+ * left for its oracle — plan comments, messages for its task or for the
6
+ * session itself — and in auto mode keeps the oracle going whenever its turn
7
+ * ends before the task is done. Subagents never own tasks or show up as
8
+ * sessions, so none of this runs in them.
8
9
  */
9
10
  import type { KeyId } from "@earendil-works/pi-tui";
10
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -12,7 +13,8 @@ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
12
13
  import { activeTask, loadTask } from "../state/persistence.ts";
13
14
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
14
15
  import { commentMessage, markCommentsDelivered, readPlanComments, undeliveredComments } from "../state/comments.ts";
15
- import { inboxMessage, markInboxDelivered, readInbox } from "../state/inbox.ts";
16
+ import { inboxMessage, markInboxDelivered, markSessionInboxDelivered, readInbox, readSessionInbox } from "../state/inbox.ts";
17
+ import { removePresence, writePresence } from "../state/presence.ts";
16
18
  import { isAutoMode, setAutoMode } from "../state/auto.ts";
17
19
  import { isSubagentProcess } from "./quiet.ts";
18
20
  import { isMinimized } from "./ui.ts";
@@ -26,6 +28,7 @@ export const AUTO_KEY = "alt+g";
26
28
 
27
29
  export type OwnerEvent =
28
30
  | { kind: "comments" | "inbox"; taskId: string; count: number }
31
+ | { kind: "messages"; count: number }
29
32
  | { kind: "auto"; taskId: string; on: boolean }
30
33
  | { kind: "nudge"; taskId: string }
31
34
  | { kind: "stalled"; taskId: string };
@@ -33,6 +36,8 @@ export type OwnerEvent =
33
36
  interface Owner {
34
37
  pi: ExtensionAPI;
35
38
  ctx: ExtensionContext;
39
+ /** The session this clock runs for, kept so its heartbeat can be removed after its context goes stale. */
40
+ sessionId: string;
36
41
  root: string;
37
42
  configDir: string;
38
43
  timer?: ReturnType<typeof setInterval>;
@@ -84,6 +89,40 @@ export function deliverInbox(): number {
84
89
  return fresh.length;
85
90
  }
86
91
 
92
+ /** Pass messages other sessions left for this session itself (it may have no task) to its Master. */
93
+ export function deliverSessionInbox(): number {
94
+ const state = owner;
95
+ if (!state) return 0;
96
+ const sessionId = state.ctx.sessionManager.getSessionId();
97
+ const fresh = readSessionInbox(state.root, state.configDir, sessionId).filter((message) => !message.delivered);
98
+ if (fresh.length === 0) return 0;
99
+ state.pi.sendUserMessage(inboxMessage(fresh), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
100
+ markSessionInboxDelivered(state.root, state.configDir, sessionId, fresh.map((message) => message.id));
101
+ listener?.({ kind: "messages", count: fresh.length });
102
+ return fresh.length;
103
+ }
104
+
105
+ /** Tell other windows this session is running: its name, file, task and process. */
106
+ export function heartbeat(): void {
107
+ const state = owner;
108
+ if (!state) return;
109
+ const name = state.pi.getSessionName?.();
110
+ const sessionFile = state.ctx.sessionManager.getSessionFile?.();
111
+ const task = ownTask(state);
112
+ try {
113
+ writePresence(state.root, state.configDir, {
114
+ sessionId: state.ctx.sessionManager.getSessionId(),
115
+ pid: process.pid,
116
+ mode: state.ctx.mode ?? "tui",
117
+ ...(name ? { name } : {}),
118
+ ...(sessionFile ? { sessionFile } : {}),
119
+ ...(live(task) ? { taskId: task.id } : {}),
120
+ });
121
+ } catch {
122
+ // A read-only project only means other windows do not see this one.
123
+ }
124
+ }
125
+
87
126
  /** What auto mode remembers between nudges: the task as it was, and how many nudges changed nothing. */
88
127
  export interface AutoTrack {
89
128
  fingerprint: string;
@@ -172,13 +211,22 @@ export function toggleOwnAuto(): string {
172
211
 
173
212
  /** One tick of the owner's clock. */
174
213
  export function ownerTick(): void {
214
+ heartbeat();
175
215
  deliverComments();
176
216
  deliverInbox();
217
+ deliverSessionInbox();
177
218
  driveAuto();
178
219
  }
179
220
 
180
221
  function stop(): void {
181
222
  if (owner?.timer) clearInterval(owner.timer);
223
+ if (owner) {
224
+ try {
225
+ removePresence(owner.root, owner.configDir, owner.sessionId);
226
+ } catch {
227
+ // The heartbeat goes stale on its own.
228
+ }
229
+ }
182
230
  owner = undefined;
183
231
  }
184
232
 
@@ -186,7 +234,8 @@ function stop(): void {
186
234
  export function startOwner(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, pollMs = OWNER_POLL_MS): void {
187
235
  stop();
188
236
  if (isSubagentProcess()) return;
189
- owner = { pi, ctx, root: detectProjectRoot(ctx.cwd, configDir), configDir };
237
+ owner = { pi, ctx, sessionId: ctx.sessionManager.getSessionId(), root: detectProjectRoot(ctx.cwd, configDir), configDir };
238
+ heartbeat();
190
239
  if (pollMs > 0) {
191
240
  owner.timer = setInterval(() => ownerTick(), pollMs);
192
241
  owner.timer.unref?.();
@@ -42,8 +42,9 @@ const STATUS_ICONS: Record<AgentRun["status"], string> = {
42
42
  };
43
43
 
44
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[] {
45
+ export function runFlags(run: Pick<AgentRun, "status" | "stalled" | "wrappedUp" | "attempts" | "route">): string[] {
46
46
  const flags: string[] = [];
47
+ if (run.route) flags.push(`routed ${run.route}`);
47
48
  if (run.stalled) flags.push("stalled");
48
49
  else if (run.status === "timeout") flags.push("hit its time limit");
49
50
  else if (run.status === "failed") flags.push("failed");
@@ -88,6 +89,7 @@ export function runLogEntry(run: AgentRun): RunLogEntry {
88
89
  ...(run.stalled ? { stalled: true } : {}),
89
90
  ...(run.wrappedUp ? { wrappedUp: true } : {}),
90
91
  ...(run.error ? { error: run.error.split("\n")[0]!.slice(0, 200) } : {}),
92
+ ...(run.routedFrom ? { routedFrom: run.routedFrom } : {}),
91
93
  };
92
94
  }
93
95
 
@@ -7,19 +7,25 @@ import {
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
9
  import {
10
+ JEV_HOSTS,
10
11
  INHERIT_MODEL,
11
12
  isThinkingLevel,
12
13
  LOBBY_PANELS,
14
+ PLANNING_ROUND_CHOICES,
13
15
  SCOUT_THINKING,
14
16
  SUBAGENT_KINDS,
15
17
  type AgentModelConfig,
16
18
  type BotLobbyConfig,
19
+ type ClassifierFeature,
20
+ type JevHostName,
17
21
  type LobbyPanel,
18
22
  type SubagentKind,
19
23
  } from "../schemas/configuration.ts";
20
24
  import { globalConfigPath, loadConfig, saveConfig } from "../state/project.ts";
21
25
  import { checkThinking, kindLabel, modelRef, supportedThinking } from "./model-support.ts";
22
26
  import { modelLookup } from "./tools.ts";
27
+ import { classifier } from "../classifier/instance.ts";
28
+ import { describeKey, hostLabel, jevEndpoint, keyHint, maskKey, resolveKey, type StatusSource } from "../classifier/hosts.ts";
23
29
 
24
30
  const CUSTOM_MODEL = "__custom__";
25
31
  const MAX_VISIBLE = 12;
@@ -381,15 +387,29 @@ export function toggleLobbySwitch(config: BotLobbyConfig, id: LobbySwitch): BotL
381
387
  return { ...config, lobby: { ...config.lobby, [id]: value } };
382
388
  }
383
389
 
384
- /** On/off settings for the lobby; enter flips one and saves it. Key rebinding stays in the file (lobby.keys). */
390
+ /** `5 rounds`, or `unlimited` for 0. */
391
+ export function roundLimitLabel(limit: number): string {
392
+ return limit > 0 ? `${limit} round${limit === 1 ? "" : "s"}` : "unlimited";
393
+ }
394
+
395
+ /** The next planning round limit in the menu's cycle (2, 3, 5, 8, unlimited); a hand-edited value rejoins it. */
396
+ export function nextRoundLimit(current: number): number {
397
+ const index = PLANNING_ROUND_CHOICES.indexOf(current as (typeof PLANNING_ROUND_CHOICES)[number]);
398
+ if (index >= 0) return PLANNING_ROUND_CHOICES[(index + 1) % PLANNING_ROUND_CHOICES.length]!;
399
+ return PLANNING_ROUND_CHOICES.find((choice) => choice > current) ?? 0;
400
+ }
401
+
402
+ /** 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). */
385
403
  async function editLobby(ctx: ExtensionContext): Promise<void> {
386
404
  for (;;) {
387
405
  const config = loadConfig();
388
406
  const items: SelectItem[] = LOBBY_SWITCHES.map((entry) => ({ value: entry.id, label: entry.label, description: `${lobbySwitch(config, entry.id) ? "on" : "off"} · ${entry.help}` }));
407
+ 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` });
389
408
  items.push({ value: "back", label: "Back", description: `keys: lobby.keys in ${globalConfigPath()}` });
390
409
  const choice = await pick(ctx, "bot-lobby settings · Lobby", items);
391
410
  if (!choice || choice === "back") return;
392
- saveConfig(toggleLobbySwitch(config, choice as LobbySwitch));
411
+ if (choice === "rounds") saveConfig({ ...config, lobby: { ...config.lobby, maxPlanningRounds: nextRoundLimit(config.lobby.maxPlanningRounds) } });
412
+ else saveConfig(toggleLobbySwitch(config, choice as LobbySwitch));
393
413
  }
394
414
  }
395
415
 
@@ -399,11 +419,125 @@ function lobbySummary(config: BotLobbyConfig): string {
399
419
  config.lobby.autoOpen ? "opens with a task" : "opens on alt+l",
400
420
  config.lobby.autoAsk ? "asks at once" : "asks on enter",
401
421
  config.lobby.mouse ? "mouse" : "no mouse",
422
+ `planning: ${roundLimitLabel(config.lobby.maxPlanningRounds)}`,
402
423
  ...(config.lobby.issues ? ["issues tab"] : []),
403
424
  ...(hidden.length > 0 ? [`hidden: ${hidden.join(", ")}`] : []),
404
425
  ].join(" · ");
405
426
  }
406
427
 
428
+ /**
429
+ * Classifier decisions that exist so far, as the settings menu lists them.
430
+ * Each phase of the classifier work adds its own entry.
431
+ */
432
+ export const CLASSIFIER_FEATURE_ITEMS: ReadonlyArray<{ id: ClassifierFeature; label: string; help: string }> = [
433
+ { 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" },
434
+ { 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)" },
435
+ { 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" },
436
+ { 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" },
437
+ { 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" },
438
+ ];
439
+
440
+ /** The next Jev host in the menu's cycle. */
441
+ export function nextJevHost(current: JevHostName): JevHostName {
442
+ return JEV_HOSTS[(JEV_HOSTS.indexOf(current) + 1) % JEV_HOSTS.length]!;
443
+ }
444
+
445
+ /** The config with one classifier decision switched on or off. */
446
+ export function toggleClassifierFeature(config: BotLobbyConfig, feature: ClassifierFeature): BotLobbyConfig {
447
+ const features = { ...config.classifier.features, [feature]: !config.classifier.features[feature] };
448
+ return { ...config, classifier: { ...config.classifier, features } };
449
+ }
450
+
451
+ /** `on · OpenCode Zen · jev-1.13-free · key stored in pi`, for the settings menu and `/bot-lobby config`. */
452
+ export function classifierSummary(config: BotLobbyConfig, status: StatusSource | undefined): string {
453
+ const { host, model } = jevEndpoint(config.classifier, status);
454
+ const via = config.classifier.provider === "auto" ? `${host.label} (auto)` : host.label;
455
+ return [config.classifier.enabled ? "on" : "off", via, model, `key ${describeKey(host, status)}`].join(" · ");
456
+ }
457
+
458
+ /** pi's auth status per provider, never throwing. */
459
+ function authStatus(ctx: ExtensionContext): StatusSource {
460
+ return (piProvider) => {
461
+ try {
462
+ return ctx.modelRegistry.getProviderAuthStatus(piProvider);
463
+ } catch {
464
+ return undefined;
465
+ }
466
+ };
467
+ }
468
+
469
+ /** Where the key lives and how to set it, with the loaded key masked. */
470
+ async function keyHelp(ctx: ExtensionContext, config: BotLobbyConfig): Promise<string> {
471
+ const status = authStatus(ctx);
472
+ const { host } = jevEndpoint(config.classifier, status);
473
+ let masked = "";
474
+ try {
475
+ const key = await resolveKey(host, (provider) => ctx.modelRegistry.getApiKeyForProvider(provider));
476
+ if (key) masked = ` Loaded: ${maskKey(key)}.`;
477
+ } catch {
478
+ // No key to show.
479
+ }
480
+ const where = host.name === "opencode"
481
+ ? "it is the OpenCode key pi already uses for Zen and Go models"
482
+ : 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}`;
483
+ return `Jev via ${host.label}: key ${describeKey(host, status)}.${masked} To set it, ${keyHint(config.classifier)}; ${where}.`;
484
+ }
485
+
486
+ /** Type a Jev model id; empty uses the host's default (OpenCode: the free jev-1.13-free). */
487
+ async function editJevModel(ctx: ExtensionContext): Promise<void> {
488
+ const config = loadConfig();
489
+ const { host } = jevEndpoint(config.classifier, authStatus(ctx));
490
+ const typed = await ctx.ui.input(`Jev model (empty: ${host.label}'s default, ${host.model})`, config.classifier.model || host.model);
491
+ if (typed === undefined) return;
492
+ const model = typed.trim() === host.model ? "" : typed.trim();
493
+ saveConfig({ ...config, classifier: { ...config.classifier, model } });
494
+ ctx.ui.notify(`bot-lobby: Jev model → ${model || `${host.model} (${host.label}'s default)`} — saved to ${globalConfigPath()}`, "info");
495
+ }
496
+
497
+ /** The model trivial steps run on; `none` keeps each agent's model and only lowers thinking. */
498
+ async function editCheapModel(ctx: ExtensionContext): Promise<void> {
499
+ const config = loadConfig();
500
+ const current = config.classifier.effort.cheapModel;
501
+ const none: SelectItem = { value: INHERIT_MODEL, label: current === INHERIT_MODEL ? "none ✓" : "none", description: "Trivial steps keep their model and run one thinking level lower" };
502
+ const choice = await pick(ctx, "Cheaper model for trivial steps", [none, ...modelItems(ctx, current, false)], { search: true });
503
+ if (choice === undefined) return;
504
+ const typed = choice === CUSTOM_MODEL ? (await ctx.ui.input("Model id", "provider/model"))?.trim() : choice;
505
+ if (!typed) return;
506
+ saveConfig({ ...config, classifier: { ...config.classifier, effort: { cheapModel: typed } } });
507
+ ctx.ui.notify(`bot-lobby: trivial steps run on ${typed === INHERIT_MODEL ? "their own model, one thinking level lower" : typed} — saved to ${globalConfigPath()}`, "info");
508
+ }
509
+
510
+ /** The classifier: on/off, where Jev is called, its key, which decisions it makes, and a connection test. */
511
+ async function editClassifier(ctx: ExtensionContext): Promise<void> {
512
+ for (;;) {
513
+ const config = loadConfig();
514
+ const settings = config.classifier;
515
+ const status = authStatus(ctx);
516
+ const { host, model } = jevEndpoint(settings, status);
517
+ const items: SelectItem[] = [
518
+ { value: "enabled", label: "Classifier", description: `${settings.enabled ? "on" : "off"} · Jev makes the obvious decisions so large models spend fewer tokens on them` },
519
+ { value: "host", label: "Host", description: `${hostLabel(settings.provider)}${settings.provider === "auto" ? ` → ${host.label}` : ""} · enter cycles ${JEV_HOSTS.map(hostLabel).join(", ")}` },
520
+ { value: "key", label: "API key", description: describeKey(host, status) },
521
+ { 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` },
522
+ ...CLASSIFIER_FEATURE_ITEMS.map((entry) => ({ value: `feature:${entry.id}`, label: entry.label, description: `${settings.features[entry.id] ? "on" : "off"} · ${entry.help}` })),
523
+ { 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` },
524
+ { value: "test", label: "Test connection", description: "one tiny call: shows the model and how long it took" },
525
+ { value: "back", label: "Back", description: `thresholds and limits: classifier in ${globalConfigPath()}` },
526
+ ];
527
+ const choice = await pick(ctx, "bot-lobby settings · Classifier (Jev)", items);
528
+ if (!choice || choice === "back") return;
529
+ if (choice === "enabled") saveConfig({ ...config, classifier: { ...settings, enabled: !settings.enabled } });
530
+ else if (choice === "host") saveConfig({ ...config, classifier: { ...settings, provider: nextJevHost(settings.provider) } });
531
+ else if (choice === "key") ctx.ui.notify(await keyHelp(ctx, config), "info");
532
+ else if (choice === "model") await editJevModel(ctx);
533
+ else if (choice === "cheap") await editCheapModel(ctx);
534
+ else if (choice === "test") {
535
+ const result = await classifier().test();
536
+ ctx.ui.notify(result.ok ? `Jev answered in ${result.ms} ms (${result.model}).` : `Jev test failed: ${result.error}`, result.ok ? "info" : "error");
537
+ } else if (choice.startsWith("feature:")) saveConfig(toggleClassifierFeature(config, choice.slice("feature:".length) as ClassifierFeature));
538
+ }
539
+ }
540
+
407
541
  /** Open the per-agent settings editor; every change is written to the global config. */
408
542
  export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
409
543
  if (ctx.mode !== "tui") {
@@ -419,10 +553,12 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
419
553
  const config = loadConfig();
420
554
  const items: SelectItem[] = SETTINGS_KINDS.map((kind) => ({ value: kind, label: kindLabel(kind), description: entryDescription(kind, entryView(config, kind)) }));
421
555
  items.push({ value: "lobby", label: "Lobby", description: lobbySummary(config) });
556
+ items.push({ value: "classifier", label: "Classifier (Jev)", description: classifierSummary(config, authStatus(ctx)) });
422
557
  items.push({ value: "close", label: "Close" });
423
558
  const choice = await pick(ctx, "bot-lobby settings", items);
424
559
  if (!choice || choice === "close") return;
425
560
  if (choice === "lobby") await editLobby(ctx);
561
+ else if (choice === "classifier") await editClassifier(ctx);
426
562
  else await editEntry(pi, ctx, choice as SettingsKind);
427
563
  }
428
564
  }
@@ -14,6 +14,7 @@ import { applyStatus } from "./ui.ts";
14
14
  import { applyMasterModel } from "./settings-ui.ts";
15
15
  import { setAutoMode } from "../state/auto.ts";
16
16
  import { loadPlannedTask, markPlannedTaskStarted, plannedTaskRequest } from "../state/backlog.ts";
17
+ import { triageFor } from "../classifier/instance.ts";
17
18
 
18
19
  function uniqueTaskId(root: string, configDir: string, request: string): string {
19
20
  const base = nextTaskId(request);
@@ -65,6 +66,9 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
65
66
  if (options.approvedPlan) task.approvedPlan = options.approvedPlan;
66
67
  createTaskDir(root, configDir, task);
67
68
  transition(task, "clarifying");
69
+ // The classifier's read of the request (when it is on) reaches the Master's very first turn.
70
+ const triage = await triageFor({ cwd: ctx.cwd, root, configDir }, request);
71
+ if (triage) task.triage = triage;
68
72
  saveTask(root, configDir, task);
69
73
  if (options.auto) setAutoMode(root, configDir, task.id, true, sessionId);
70
74
  // A session that starts a task is named after it, so /resume and the lobby list it by name.