@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
package/src/pi/tools.ts CHANGED
@@ -5,10 +5,11 @@ import { Type } from "typebox";
5
5
  import type { AgentRun } from "../schemas/findings.ts";
6
6
  import type { ProcessRunner } from "../execution/pi-runner.ts";
7
7
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
8
+ import { classifier, effortFor, hintsFor, triageFor } from "../classifier/instance.ts";
8
9
  import { truncate } from "../text.ts";
9
10
  import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
10
11
  import { isQuiet } from "./quiet.ts";
11
- import { createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
12
+ import { checkThinking, createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
12
13
  import { agentName, describeRun } from "./run-summary.ts";
13
14
  import {
14
15
  ORCHESTRATE_ACTIONS,
@@ -22,7 +23,7 @@ const OrchestrateSchema = Type.Object({
22
23
  action: StringEnum(ORCHESTRATE_ACTIONS, { description: "Workflow step to run" }),
23
24
  taskId: Type.Optional(Type.String({ description: "Task id; defaults to the active task" })),
24
25
  question: Type.Optional(Type.String({ description: "clarify: question for the user" })),
25
- options: Type.Optional(Type.Array(Type.String(), { description: "clarify: optional answer choices" })),
26
+ options: Type.Optional(Type.Array(Type.String(), { description: "clarify: optional answer choices; put your recommended one first and mark it (Recommended)" })),
26
27
  domains: Type.Optional(Type.Array(Type.String(), { description: "scout: any of designer, backend, qa" })),
27
28
  instruction: Type.Optional(Type.String({ description: "scout/research: what to investigate (for scout, also used to target-verify a claim)" })),
28
29
  proposal: Type.Optional(Type.String({ description: "propose: the user-facing proposal as a short `- ` bullet list, one line per change" })),
@@ -104,6 +105,10 @@ export function workflowDeps(
104
105
  signal,
105
106
  onUpdate,
106
107
  runProcess,
108
+ hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
109
+ classifier: classifier(),
110
+ triage: (request, triageSignal) => triageFor({ cwd: ctx.cwd, root, configDir }, request, triageSignal),
111
+ effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
107
112
  ask: async (question) => (hasUI ? ctx.ui.input(question) : undefined),
108
113
  choose: async (title, options) => (hasUI ? ctx.ui.select(title, options) : undefined),
109
114
  notify: (message, level = "info") => {
@@ -108,6 +108,59 @@ export interface LobbyConfig {
108
108
  keys: Record<string, string>;
109
109
  /** Clicks and the wheel work in the lobby (click a draft line to comment on it); shift+drag still selects text. */
110
110
  mouse: boolean;
111
+ /**
112
+ * Planning rounds before the oracle finalizes the plan on its own: the last
113
+ * round skips the seats and asks nothing; later replies only revise. 0 = unlimited.
114
+ */
115
+ maxPlanningRounds: number;
116
+ }
117
+
118
+ /** Decisions the classifier can make, each switched on or off on its own. */
119
+ export const CLASSIFIER_FEATURES = ["seats", "answers", "files", "triage", "effort"] as const;
120
+ export type ClassifierFeature = (typeof CLASSIFIER_FEATURES)[number];
121
+
122
+ /** Hosts that serve Jev behind the same System One API; `auto` takes OpenCode's free Jev when pi holds an OpenCode key, else TypeSafe. */
123
+ export const JEV_HOSTS = ["auto", "opencode", "typesafe", "openrouter", "vercel"] as const;
124
+ export type JevHostName = (typeof JEV_HOSTS)[number];
125
+
126
+ /** Probability and confidence cut-offs, 0 to 1; edited in the config file only. */
127
+ export interface ClassifierThresholds {
128
+ /** A planning seat runs when the idea or the latest answers touch its domain at least this likely. */
129
+ seatAt: number;
130
+ /** A seat that was READY comes back only at this probability. */
131
+ reseatReadyAt: number;
132
+ /** An obvious question is answered for you at this probability, when the pick is the recommended option… */
133
+ autoAnswerAt: number;
134
+ /** …and leads the runner-up by at least this much. */
135
+ autoAnswerMargin: number;
136
+ /** A file is a likely file at this relevance. */
137
+ fileRelevantAt: number;
138
+ /** A step scored simple at this confidence runs one thinking level lower. */
139
+ simpleAt: number;
140
+ /** A step scored trivial at this confidence runs on the cheaper model. */
141
+ trivialAt: number;
142
+ /** A quick fix scored large at this confidence is held instead of started. */
143
+ quickFixLargeAt: number;
144
+ }
145
+
146
+ /** The Jev classifier: a fast model for obvious decisions, so large models spend fewer tokens on them. */
147
+ export interface ClassifierConfig {
148
+ enabled: boolean;
149
+ /** Where Jev is called; the key comes from pi's own key store for that host. */
150
+ provider: JevHostName;
151
+ /** A pinned Jev model; empty uses the host's default. */
152
+ model: string;
153
+ /** A base URL override (a proxy); empty uses the host's. */
154
+ baseUrl: string;
155
+ /** Time limit per classifier call. */
156
+ timeoutMs: number;
157
+ features: Record<ClassifierFeature, boolean>;
158
+ thresholds: ClassifierThresholds;
159
+ fileHints: { topK: number; maxCandidates: number; budgetMs: number };
160
+ /** The cheaper model trivial steps run on; `inherit` keeps the configured model and only lowers thinking. */
161
+ effort: { cheapModel: ModelRef };
162
+ /** Path globs never sent to the classifier (file hints). */
163
+ exclude: string[];
111
164
  }
112
165
 
113
166
  export interface BotLobbyConfig {
@@ -122,6 +175,7 @@ export interface BotLobbyConfig {
122
175
  workflow: WorkflowConfig;
123
176
  knowledge: KnowledgeConfig;
124
177
  lobby: LobbyConfig;
178
+ classifier: ClassifierConfig;
125
179
  }
126
180
 
127
181
  export const DEFAULT_CONFIG: BotLobbyConfig = {
@@ -162,9 +216,34 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
162
216
  panels: { animations: false, conversation: true, activity: true, thinking: true },
163
217
  keys: {},
164
218
  mouse: true,
219
+ maxPlanningRounds: 5,
220
+ },
221
+ classifier: {
222
+ enabled: false,
223
+ provider: "auto",
224
+ model: "",
225
+ baseUrl: "",
226
+ timeoutMs: 4000,
227
+ features: { seats: true, answers: true, files: true, triage: true, effort: true },
228
+ thresholds: {
229
+ seatAt: 0.35,
230
+ reseatReadyAt: 0.6,
231
+ autoAnswerAt: 0.9,
232
+ autoAnswerMargin: 0.5,
233
+ fileRelevantAt: 0.5,
234
+ simpleAt: 0.7,
235
+ trivialAt: 0.8,
236
+ quickFixLargeAt: 0.8,
237
+ },
238
+ fileHints: { topK: 8, maxCandidates: 480, budgetMs: 1500 },
239
+ effort: { cheapModel: INHERIT_MODEL },
240
+ exclude: [],
165
241
  },
166
242
  };
167
243
 
244
+ /** Choices the settings menu cycles through for the planning round limit; 0 = unlimited. */
245
+ export const PLANNING_ROUND_CHOICES = [2, 3, 5, 8, 0] as const;
246
+
168
247
  function positive(value: unknown): number | undefined {
169
248
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
170
249
  }
@@ -191,7 +270,7 @@ function flag(value: unknown, fallback: boolean): boolean {
191
270
  }
192
271
 
193
272
  function normalizeLobby(value: unknown): LobbyConfig {
194
- const source = value as { autoOpen?: unknown; planningPanel?: unknown; autoAsk?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; mouse?: unknown } | undefined;
273
+ const source = value as { autoOpen?: unknown; planningPanel?: unknown; autoAsk?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; mouse?: unknown; maxPlanningRounds?: unknown } | undefined;
195
274
  const defaults = DEFAULT_CONFIG.lobby;
196
275
  const panel = Array.isArray(source?.planningPanel)
197
276
  ? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
@@ -206,6 +285,50 @@ function normalizeLobby(value: unknown): LobbyConfig {
206
285
  panels: Object.fromEntries(LOBBY_PANELS.map((name) => [name, flag(panels[name], defaults.panels[name])])) as Record<LobbyPanel, boolean>,
207
286
  keys: Object.fromEntries(Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0)),
208
287
  mouse: flag(source?.mouse, defaults.mouse),
288
+ maxPlanningRounds: roundLimit(source?.maxPlanningRounds, defaults.maxPlanningRounds),
289
+ };
290
+ }
291
+
292
+ /** A whole number of rounds, 0 for unlimited; anything else keeps the default. */
293
+ function roundLimit(value: unknown, fallback: number): number {
294
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback;
295
+ }
296
+
297
+ function probability(value: unknown, fallback: number): number {
298
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : fallback;
299
+ }
300
+
301
+ function count(value: unknown, fallback: number): number {
302
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
303
+ }
304
+
305
+ function text(value: unknown, fallback: string): string {
306
+ return typeof value === "string" ? value.trim() : fallback;
307
+ }
308
+
309
+ function normalizeClassifier(value: unknown): ClassifierConfig {
310
+ const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
311
+ const defaults = DEFAULT_CONFIG.classifier;
312
+ const features = (source.features ?? {}) as Partial<Record<ClassifierFeature, unknown>>;
313
+ const thresholds = (source.thresholds ?? {}) as Partial<Record<keyof ClassifierThresholds, unknown>>;
314
+ const hints = (source.fileHints ?? {}) as Record<string, unknown>;
315
+ const effort = (source.effort ?? {}) as Record<string, unknown>;
316
+ const provider = typeof source.provider === "string" && (JEV_HOSTS as readonly string[]).includes(source.provider) ? source.provider as JevHostName : defaults.provider;
317
+ return {
318
+ enabled: flag(source.enabled, defaults.enabled),
319
+ provider,
320
+ model: text(source.model, defaults.model),
321
+ baseUrl: text(source.baseUrl, defaults.baseUrl),
322
+ timeoutMs: count(source.timeoutMs, defaults.timeoutMs),
323
+ features: Object.fromEntries(CLASSIFIER_FEATURES.map((name) => [name, flag(features[name], defaults.features[name])])) as Record<ClassifierFeature, boolean>,
324
+ thresholds: Object.fromEntries(Object.entries(defaults.thresholds).map(([name, fallback]) => [name, probability(thresholds[name as keyof ClassifierThresholds], fallback)])) as unknown as ClassifierThresholds,
325
+ fileHints: {
326
+ topK: count(hints.topK, defaults.fileHints.topK),
327
+ maxCandidates: count(hints.maxCandidates, defaults.fileHints.maxCandidates),
328
+ budgetMs: count(hints.budgetMs, defaults.fileHints.budgetMs),
329
+ },
330
+ effort: { cheapModel: typeof effort.cheapModel === "string" && effort.cheapModel.trim() ? effort.cheapModel.trim() : defaults.effort.cheapModel },
331
+ exclude: Array.isArray(source.exclude) ? source.exclude.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) : [...defaults.exclude],
209
332
  };
210
333
  }
211
334
 
@@ -229,6 +352,7 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
229
352
  workflow,
230
353
  knowledge,
231
354
  lobby: normalizeLobby(src.lobby),
355
+ classifier: normalizeClassifier(src.classifier),
232
356
  };
233
357
  }
234
358
 
@@ -133,4 +133,8 @@ export interface AgentRun {
133
133
  wrappedUp?: boolean;
134
134
  /** File this worker is queued for at the file desk. */
135
135
  waitingFor?: string;
136
+ /** The classifier routed this run down: the configured profile it came from (`p/big · medium`). */
137
+ routedFrom?: string;
138
+ /** Why and where it was routed (`trivial 0.88: p/big · medium → p/cheap · low`). */
139
+ route?: string;
136
140
  }
@@ -89,11 +89,31 @@ export interface RunLogEntry {
89
89
  stalled?: boolean;
90
90
  wrappedUp?: boolean;
91
91
  error?: string;
92
+ /** The classifier routed this run down from this configured profile. */
93
+ routedFrom?: string;
92
94
  }
93
95
 
94
96
  /** Upper bound on persisted run-log entries. */
95
97
  export const MAX_RUN_LOG = 64;
96
98
 
99
+ export type TriageSize = "trivial" | "small" | "medium" | "large";
100
+
101
+ /** What the classifier made of the request when the task started: hints for the Master, never rules. */
102
+ export interface TaskTriage {
103
+ size: TriageSize;
104
+ sizeConfidence: number;
105
+ /** How likely each domain is touched, 0 to 1. */
106
+ domains: Partial<Record<Domain, number>>;
107
+ /** How likely building it needs outside facts. */
108
+ research: number;
109
+ /** How likely it is ambiguous as written. */
110
+ ambiguous: number;
111
+ kind?: string;
112
+ kindProbability?: number;
113
+ likelyFiles?: string[];
114
+ at: string;
115
+ }
116
+
97
117
  export interface Task {
98
118
  id: string;
99
119
  title: string;
@@ -121,6 +141,10 @@ export interface Task {
121
141
  ownerSessionId?: string;
122
142
  /** The planned task (PLAN-…) whose plan the user agreed in the planning panel: its proposal needs no approval. */
123
143
  approvedPlan?: string;
144
+ /** When the task was archived from the lobby (it then lives under archive/tasks, out of every list). */
145
+ archivedAt?: string;
146
+ /** The classifier's read of the request, when it was on as the task started. */
147
+ triage?: TaskTriage;
124
148
  }
125
149
 
126
150
  export function createTask(
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Archiving and deleting tasks from the lobby. An archived task moves, whole,
3
+ * out of the task tree to `archive/tasks/<id>` beside the knowledge archive:
4
+ * no list, widget or session sees it any more, and it can be restored as it
5
+ * was. Deleting removes a task's folder for good. A task still under way is
6
+ * abandoned before it is archived, so it never comes back half-driven.
7
+ */
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
11
+ import { taskDir, tasksRoot } from "../knowledge/paths.ts";
12
+ import { transition } from "./task-state.ts";
13
+ import { dataRoot, readDataRoots } from "./project.ts";
14
+
15
+ export function archiveRoot(root: string, configDir: string): string {
16
+ return join(dataRoot(root, configDir), "archive", "tasks");
17
+ }
18
+
19
+ /** Task ids become folder names: only ever one plain path segment. */
20
+ function checkId(taskId: string): string {
21
+ if (!/^[\w.-]+$/.test(taskId) || taskId === "." || taskId === "..") throw new Error(`not a task id: ${taskId}`);
22
+ return taskId;
23
+ }
24
+
25
+ function readAt(dir: string): Task | undefined {
26
+ try {
27
+ return JSON.parse(readFileSync(join(dir, "state.json"), "utf8")) as Task;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ function writeAt(dir: string, task: Task): void {
34
+ writeFileSync(join(dir, "state.json"), JSON.stringify(task, null, 2), "utf8");
35
+ }
36
+
37
+ /** Where a task on the list lives: the newest tree that has it (bot-lobby, then the pre-rename ones). */
38
+ function liveDir(root: string, configDir: string, taskId: string): string | undefined {
39
+ return readDataRoots(root, configDir).map((dr) => taskDir(dr, taskId)).find((dir) => existsSync(join(dir, "state.json")));
40
+ }
41
+
42
+ function archivedDir(root: string, configDir: string, taskId: string): string {
43
+ return join(archiveRoot(root, configDir), checkId(taskId));
44
+ }
45
+
46
+ /** Archived tasks, most recently archived first. */
47
+ export function listArchivedTasks(root: string, configDir: string): Task[] {
48
+ const dir = archiveRoot(root, configDir);
49
+ if (!existsSync(dir)) return [];
50
+ const tasks: Task[] = [];
51
+ for (const entry of readdirSync(dir)) {
52
+ const task = readAt(join(dir, entry));
53
+ if (task) tasks.push(task);
54
+ }
55
+ return tasks.sort((a, b) => (b.archivedAt ?? b.updatedAt).localeCompare(a.archivedAt ?? a.updatedAt));
56
+ }
57
+
58
+ /**
59
+ * Move a task out of every list into the archive; one still under way is
60
+ * abandoned first (and says so in its decisions). Returns the archived task.
61
+ */
62
+ export function archiveTask(root: string, configDir: string, taskId: string, now = new Date()): Task {
63
+ const from = liveDir(root, configDir, checkId(taskId));
64
+ const task = from ? readAt(from) : undefined;
65
+ if (!from || !task) throw new Error(`no task ${taskId}`);
66
+ const to = archivedDir(root, configDir, taskId);
67
+ if (existsSync(to)) throw new Error(`${taskId} is already in the archive`);
68
+ const stamp = now.toISOString();
69
+ if (!TERMINAL_STATES.includes(task.state)) {
70
+ transition(task, "abandoned", stamp);
71
+ task.decisions.push({ domain: "master", text: "Abandoned when it was archived from the lobby.", createdAt: stamp });
72
+ }
73
+ task.archivedAt = stamp;
74
+ writeAt(from, task);
75
+ mkdirSync(dirname(to), { recursive: true });
76
+ renameSync(from, to);
77
+ return task;
78
+ }
79
+
80
+ /** Bring an archived task back to the lists, as it was when archived. */
81
+ export function restoreTask(root: string, configDir: string, taskId: string): Task {
82
+ const from = archivedDir(root, configDir, taskId);
83
+ const task = readAt(from);
84
+ if (!task) throw new Error(`${taskId} is not in the archive`);
85
+ const to = taskDir(dataRoot(root, configDir), taskId);
86
+ if (existsSync(to)) throw new Error(`a task ${taskId} is already on the list`);
87
+ delete task.archivedAt;
88
+ writeAt(from, task);
89
+ mkdirSync(tasksRoot(dataRoot(root, configDir)), { recursive: true });
90
+ renameSync(from, to);
91
+ return task;
92
+ }
93
+
94
+ /** Remove a task for good: its folder, from the list or from the archive. */
95
+ export function deleteTask(root: string, configDir: string, taskId: string, where: "list" | "archive" = "list"): void {
96
+ const dir = where === "archive" ? archivedDir(root, configDir, taskId) : liveDir(root, configDir, checkId(taskId));
97
+ if (!dir || !existsSync(dir)) throw new Error(where === "archive" ? `${taskId} is not in the archive` : `no task ${taskId}`);
98
+ rmSync(dir, { recursive: true, force: true });
99
+ }
@@ -1,17 +1,21 @@
1
1
  /**
2
- * The task inbox: messages for a task's oracle written from any pi session
3
- * (the lobby of another window talking to a task it does not drive). Like plan
4
- * comments they live beside the task as an append-only JSON-lines log, never
5
- * inside state.json, and the session that owns the task delivers them to its
6
- * Master and records the delivery.
2
+ * Inboxes: messages for another session's oracle written from any pi session
3
+ * (the lobby of another window talking to a session it does not drive). A
4
+ * task's inbox lives beside the task, so a message waits there until some
5
+ * session drives the task; a session's inbox lives beside its heartbeat, for
6
+ * a running session with no task. Like plan comments they are append-only
7
+ * JSON-lines logs, never inside state.json, and the session they are for
8
+ * delivers them to its Master and records the delivery.
7
9
  */
8
10
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
9
11
  import { dirname, join } from "node:path";
10
12
  import { taskDirFor } from "./persistence.ts";
13
+ import { presenceDir } from "./presence.ts";
11
14
 
12
15
  export interface InboxMessage {
13
16
  id: string;
14
- taskId: string;
17
+ /** The task it was left for; absent for a session's own inbox. */
18
+ taskId?: string;
15
19
  text: string;
16
20
  createdAt: string;
17
21
  /** The pi session that wrote it. */
@@ -30,6 +34,10 @@ export function inboxPath(root: string, configDir: string, taskId: string): stri
30
34
  return join(taskDirFor(root, configDir, taskId), "inbox.jsonl");
31
35
  }
32
36
 
37
+ export function sessionInboxPath(root: string, configDir: string, sessionId: string): string {
38
+ return join(presenceDir(root, configDir), `${sessionId.replace(/[^\w.-]/g, "_")}.inbox.jsonl`);
39
+ }
40
+
33
41
  function append(path: string, event: InboxEvent): void {
34
42
  mkdirSync(dirname(path), { recursive: true });
35
43
  appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
@@ -56,12 +64,11 @@ function readEvents(path: string): InboxEvent[] {
56
64
  return events;
57
65
  }
58
66
 
59
- /** The task's messages, oldest first, with whether each reached the oracle. */
60
- export function readInbox(root: string, configDir: string, taskId: string): InboxMessage[] {
67
+ function readAt(path: string, taskId?: string): InboxMessage[] {
61
68
  const byId = new Map<string, InboxMessage>();
62
- for (const event of readEvents(inboxPath(root, configDir, taskId))) {
69
+ for (const event of readEvents(path)) {
63
70
  if (event.kind === "message" && !byId.has(event.id)) {
64
- byId.set(event.id, { id: event.id, taskId, text: event.text, createdAt: event.at, ...(event.by ? { by: event.by } : {}), delivered: false });
71
+ byId.set(event.id, { id: event.id, ...(taskId ? { taskId } : {}), text: event.text, createdAt: event.at, ...(event.by ? { by: event.by } : {}), delivered: false });
65
72
  } else if (event.kind === "delivered") {
66
73
  const message = byId.get(event.id);
67
74
  if (message) message.delivered = true;
@@ -70,17 +77,44 @@ export function readInbox(root: string, configDir: string, taskId: string): Inbo
70
77
  return [...byId.values()];
71
78
  }
72
79
 
73
- /** Leave a message for the task's oracle; returns it. */
74
- export function sendToInbox(root: string, configDir: string, taskId: string, text: string, by?: string, now = new Date()): InboxMessage {
80
+ function sendAt(path: string, text: string, by: string | undefined, now: Date, taskId?: string): InboxMessage {
75
81
  const body = text.trim().slice(0, MAX_INBOX_CHARS);
76
82
  if (!body) throw new Error("a message needs some text");
77
83
  const id = `${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
78
- append(inboxPath(root, configDir, taskId), { kind: "message", id, text: body, at: now.toISOString(), ...(by ? { by } : {}) });
79
- return { id, taskId, text: body, createdAt: now.toISOString(), ...(by ? { by } : {}), delivered: false };
84
+ append(path, { kind: "message", id, text: body, at: now.toISOString(), ...(by ? { by } : {}) });
85
+ return { id, ...(taskId ? { taskId } : {}), text: body, createdAt: now.toISOString(), ...(by ? { by } : {}), delivered: false };
86
+ }
87
+
88
+ function markAt(path: string, ids: readonly string[], now: Date): void {
89
+ for (const id of ids) append(path, { kind: "delivered", id, at: now.toISOString() });
90
+ }
91
+
92
+ /** The task's messages, oldest first, with whether each reached the oracle. */
93
+ export function readInbox(root: string, configDir: string, taskId: string): InboxMessage[] {
94
+ return readAt(inboxPath(root, configDir, taskId), taskId);
95
+ }
96
+
97
+ /** Leave a message for the task's oracle; returns it. */
98
+ export function sendToInbox(root: string, configDir: string, taskId: string, text: string, by?: string, now = new Date()): InboxMessage {
99
+ return sendAt(inboxPath(root, configDir, taskId), text, by, now, taskId);
80
100
  }
81
101
 
82
102
  export function markInboxDelivered(root: string, configDir: string, taskId: string, ids: readonly string[], now = new Date()): void {
83
- for (const id of ids) append(inboxPath(root, configDir, taskId), { kind: "delivered", id, at: now.toISOString() });
103
+ markAt(inboxPath(root, configDir, taskId), ids, now);
104
+ }
105
+
106
+ /** A running session's own messages (it has no task to hold them), oldest first. */
107
+ export function readSessionInbox(root: string, configDir: string, sessionId: string): InboxMessage[] {
108
+ return readAt(sessionInboxPath(root, configDir, sessionId));
109
+ }
110
+
111
+ /** Leave a message for a running session's oracle; returns it. */
112
+ export function sendToSession(root: string, configDir: string, sessionId: string, text: string, by?: string, now = new Date()): InboxMessage {
113
+ return sendAt(sessionInboxPath(root, configDir, sessionId), text, by, now);
114
+ }
115
+
116
+ export function markSessionInboxDelivered(root: string, configDir: string, sessionId: string, ids: readonly string[], now = new Date()): void {
117
+ markAt(sessionInboxPath(root, configDir, sessionId), ids, now);
84
118
  }
85
119
 
86
120
  /** The message the owning session sends its Master for undelivered inbox messages. */
@@ -11,7 +11,7 @@ import type { AgentRun } from "../schemas/findings.ts";
11
11
  import type { RunLogEntry, Task } from "../schemas/task.ts";
12
12
  import { dataRoot } from "./project.ts";
13
13
 
14
- export const METRIC_KINDS = ["master", "scout", "worker", "reviewer", "researcher", "quickfix", "planner", "panel"] as const;
14
+ export const METRIC_KINDS = ["master", "scout", "worker", "reviewer", "researcher", "quickfix", "planner", "panel", "classifier"] as const;
15
15
  export type MetricKind = (typeof METRIC_KINDS)[number];
16
16
 
17
17
  export type MetricStatus = "success" | "failed" | "cancelled" | "timeout";
@@ -33,6 +33,67 @@ export interface MetricRecord {
33
33
  cost?: number;
34
34
  taskId?: string;
35
35
  stalled?: boolean;
36
+ /** Classifier calls: what the call decided (seats, answers, files, triage, effort, test). */
37
+ purpose?: string;
38
+ /** A run the classifier routed down: the configured profile it came from. */
39
+ routedFrom?: string;
40
+ /** Classifier calls: what the decision spared (seat runs skipped, questions answered, quick fixes held). */
41
+ saved?: number;
42
+ }
43
+
44
+ /** What the classifier did and spared, for the Metrics tab. */
45
+ export interface ClassifierSummary {
46
+ calls: number;
47
+ ok: number;
48
+ p50Ms: number;
49
+ p90Ms: number;
50
+ /** Tokens Jev read across every call. */
51
+ input: number;
52
+ /** Calls per decision (seats, answers, files, triage, effort, test). */
53
+ byPurpose: Record<string, number>;
54
+ seatRunsSkipped: number;
55
+ questionsAnswered: number;
56
+ quickFixesHeld: number;
57
+ /** Agent runs the classifier routed down, and how many of them succeeded. */
58
+ routed: number;
59
+ routedOk: number;
60
+ }
61
+
62
+ function quantile(sorted: readonly number[], q: number): number {
63
+ if (sorted.length === 0) return 0;
64
+ return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
65
+ }
66
+
67
+ /** Classifier calls and the agent runs it routed, summarised; undefined when it never ran. */
68
+ export function summarizeClassifier(calls: readonly MetricRecord[], runs: readonly MetricRecord[]): ClassifierSummary | undefined {
69
+ const routed = runs.filter((record) => record.routedFrom);
70
+ if (calls.length === 0 && routed.length === 0) return undefined;
71
+ const times = calls.map((record) => record.durationMs).sort((a, b) => a - b);
72
+ const byPurpose: Record<string, number> = {};
73
+ let seatRunsSkipped = 0;
74
+ let questionsAnswered = 0;
75
+ let quickFixesHeld = 0;
76
+ for (const record of calls) {
77
+ const purpose = record.purpose ?? "other";
78
+ byPurpose[purpose] = (byPurpose[purpose] ?? 0) + 1;
79
+ const saved = record.saved ?? 0;
80
+ if (purpose === "seats") seatRunsSkipped += saved;
81
+ else if (purpose === "answers") questionsAnswered += saved;
82
+ else if (purpose === "triage") quickFixesHeld += saved;
83
+ }
84
+ return {
85
+ calls: calls.length,
86
+ ok: calls.filter((record) => record.status === "success").length,
87
+ p50Ms: quantile(times, 0.5),
88
+ p90Ms: quantile(times, 0.9),
89
+ input: calls.reduce((total, record) => total + (record.input ?? 0), 0),
90
+ byPurpose,
91
+ seatRunsSkipped,
92
+ questionsAnswered,
93
+ quickFixesHeld,
94
+ routed: routed.length,
95
+ routedOk: routed.filter((record) => record.status === "success").length,
96
+ };
36
97
  }
37
98
 
38
99
  /** Newest records kept in memory for aggregation. */
@@ -58,7 +119,17 @@ function isRecord(value: unknown): value is MetricRecord {
58
119
  return Boolean(record && typeof record.id === "string" && typeof record.kind === "string" && typeof record.durationMs === "number");
59
120
  }
60
121
 
122
+ /** Agent runs for the Metrics tab; classifier calls, a few hundred milliseconds each, are read apart. */
61
123
  export function readMetrics(root: string, configDir: string, limit = MAX_READ): MetricRecord[] {
124
+ return readRecords(root, configDir, limit).filter((record) => record.kind !== "classifier");
125
+ }
126
+
127
+ /** Classifier calls only. */
128
+ export function readClassifierMetrics(root: string, configDir: string, limit = MAX_READ): MetricRecord[] {
129
+ return readRecords(root, configDir, limit).filter((record) => record.kind === "classifier");
130
+ }
131
+
132
+ function readRecords(root: string, configDir: string, limit: number): MetricRecord[] {
62
133
  const path = metricsPath(root, configDir);
63
134
  if (!existsSync(path)) return [];
64
135
  let text: string;
@@ -110,6 +181,7 @@ export function metricFromRun(run: AgentRun): MetricRecord {
110
181
  ...(run.usage ? { input: run.usage.input, output: run.usage.output, cost: run.usage.cost } : {}),
111
182
  taskId: run.taskId,
112
183
  ...(run.stalled ? { stalled: true } : {}),
184
+ ...(run.routedFrom ? { routedFrom: run.routedFrom } : {}),
113
185
  };
114
186
  }
115
187
 
@@ -130,6 +202,7 @@ export function metricFromLog(entry: RunLogEntry, taskId: string): MetricRecord
130
202
  ...(entry.input !== undefined ? { input: entry.input, output: entry.output ?? 0, cost: entry.cost ?? 0 } : {}),
131
203
  taskId,
132
204
  ...(entry.stalled ? { stalled: true } : {}),
205
+ ...(entry.routedFrom ? { routedFrom: entry.routedFrom } : {}),
133
206
  };
134
207
  }
135
208
 
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Which pi sessions are running in this project right now. Every interactive
3
+ * or headless session with bot-lobby loaded (never a subagent) keeps a small
4
+ * heartbeat file beside the tasks, refreshed on its owner clock and removed
5
+ * when it ends; the lobby reads them to list the live sessions it can view,
6
+ * message or switch to. A heartbeat older than a few beats, or from a process
7
+ * that is gone, does not count.
8
+ */
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { dataRoot } from "./project.ts";
12
+
13
+ export interface Presence {
14
+ sessionId: string;
15
+ pid: number;
16
+ /** The pi session's name (a task's title once it starts one). */
17
+ name?: string;
18
+ /** The saved session file, so another window can switch to it once it ends. */
19
+ sessionFile?: string;
20
+ /** The task it drives, if any. */
21
+ taskId?: string;
22
+ /** `tui` for a terminal, `rpc` for a session another window started in the background. */
23
+ mode: string;
24
+ updatedAt: string;
25
+ }
26
+
27
+ /** A heartbeat older than this means the session is gone (its owner clock ticks every few seconds). */
28
+ export const PRESENCE_TTL_MS = 15_000;
29
+ /** A heartbeat this old is removed even if its process still runs (it switched sessions without saying so). */
30
+ const FORGET_MS = 10 * 60_000;
31
+
32
+ export function presenceDir(root: string, configDir: string): string {
33
+ return join(dataRoot(root, configDir), "sessions");
34
+ }
35
+
36
+ /** Session ids become file names: keep them to safe characters. */
37
+ function safeId(sessionId: string): string | undefined {
38
+ return /^[\w.-]+$/.test(sessionId) ? sessionId : undefined;
39
+ }
40
+
41
+ function presencePath(root: string, configDir: string, sessionId: string): string | undefined {
42
+ const id = safeId(sessionId);
43
+ return id ? join(presenceDir(root, configDir), `${id}.json`) : undefined;
44
+ }
45
+
46
+ /** Record (or refresh) this session's heartbeat; atomic, so readers never see half a file. */
47
+ export function writePresence(root: string, configDir: string, presence: Omit<Presence, "updatedAt">, now = new Date()): void {
48
+ const path = presencePath(root, configDir, presence.sessionId);
49
+ if (!path) return;
50
+ mkdirSync(presenceDir(root, configDir), { recursive: true });
51
+ const tmp = `${path}.${process.pid}.tmp`;
52
+ writeFileSync(tmp, JSON.stringify({ ...presence, updatedAt: now.toISOString() }), "utf8");
53
+ renameSync(tmp, path);
54
+ }
55
+
56
+ export function removePresence(root: string, configDir: string, sessionId: string): void {
57
+ const path = presencePath(root, configDir, sessionId);
58
+ if (path) rmSync(path, { force: true });
59
+ }
60
+
61
+ /** Whether a process is running: signal 0 checks without touching it (EPERM still means it exists). */
62
+ export function processAlive(pid: number): boolean {
63
+ if (!Number.isInteger(pid) || pid <= 0) return false;
64
+ try {
65
+ process.kill(pid, 0);
66
+ return true;
67
+ } catch (error) {
68
+ return (error as NodeJS.ErrnoException).code === "EPERM";
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The sessions running in this project, newest heartbeat first. Heartbeats
74
+ * of processes that are gone are removed; merely late ones are skipped.
75
+ */
76
+ export function livePresence(root: string, configDir: string, now = Date.now(), alive: (pid: number) => boolean = processAlive): Presence[] {
77
+ const dir = presenceDir(root, configDir);
78
+ if (!existsSync(dir)) return [];
79
+ const live: Presence[] = [];
80
+ for (const file of readdirSync(dir)) {
81
+ if (!file.endsWith(".json") || file.endsWith(".tmp")) continue;
82
+ let presence: Presence;
83
+ try {
84
+ presence = JSON.parse(readFileSync(join(dir, file), "utf8")) as Presence;
85
+ } catch {
86
+ continue;
87
+ }
88
+ if (typeof presence?.sessionId !== "string" || typeof presence.pid !== "number") continue;
89
+ const age = now - Date.parse(presence.updatedAt);
90
+ // Gone, or so long silent that its process has moved on to another session: clear it away.
91
+ if (!alive(presence.pid) || !(age < FORGET_MS)) {
92
+ rmSync(join(dir, file), { force: true });
93
+ continue;
94
+ }
95
+ if (age > PRESENCE_TTL_MS) continue;
96
+ live.push(presence);
97
+ }
98
+ return live.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
99
+ }