@a-t-h-i/bot-lobby 0.6.57 → 0.6.59

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 (118) hide show
  1. package/README.md +17 -11
  2. package/THIRD_PARTY_NOTICES.md +17 -3
  3. package/package.json +6 -1
  4. package/prompts/lobby-chat.md +39 -0
  5. package/prompts/master.md +7 -7
  6. package/prompts/reviewer.md +6 -6
  7. package/src/classifier/instance.ts +1 -1
  8. package/src/classifier/lobby.ts +37 -0
  9. package/src/classifier/triage.ts +2 -24
  10. package/src/execution/delegation.ts +2 -2
  11. package/src/index.ts +0 -3
  12. package/src/lobby/chat-agents.ts +293 -0
  13. package/src/lobby/feed.ts +73 -17
  14. package/src/lobby/host.ts +8 -4
  15. package/src/lobby/keys.ts +1 -0
  16. package/src/lobby/planner.ts +5 -5
  17. package/src/lobby/pr-review.ts +2 -2
  18. package/src/lobby/profile.ts +16 -0
  19. package/src/lobby/prompts.ts +56 -4
  20. package/src/lobby/recovery.ts +10 -42
  21. package/src/lobby/runtime.ts +27 -45
  22. package/src/lobby/service.ts +142 -13
  23. package/src/lobby/session-files.ts +4 -1
  24. package/src/lobby/topics.ts +0 -1
  25. package/src/master/master.ts +1 -1
  26. package/src/pi/commands.ts +6 -8
  27. package/src/pi/events.ts +11 -3
  28. package/src/pi/fresh-context.ts +1 -1
  29. package/src/pi/model-settings.ts +0 -2
  30. package/src/pi/model-support.ts +11 -2
  31. package/src/pi/owner.ts +35 -4
  32. package/src/pi/start-task.ts +3 -9
  33. package/src/pi/switch.ts +1 -1
  34. package/src/schemas/configuration.ts +14 -24
  35. package/src/state/changes.ts +25 -23
  36. package/src/state/metrics.ts +5 -8
  37. package/src/state/recovery.ts +3 -3
  38. package/src/webui/api/index.ts +12 -18
  39. package/src/webui/api/lobby.ts +9 -6
  40. package/src/webui/code.ts +202 -0
  41. package/src/webui/dev/fake-service.ts +28 -62
  42. package/src/webui/dev/fixtures.ts +1 -4
  43. package/src/webui/protocol.ts +15 -9
  44. package/src/webui/static.ts +5 -2
  45. package/src/workflow/track.ts +1 -52
  46. package/src/workflow/workflow.ts +2 -2
  47. package/webui/dist/assets/CodeSurface-BtzJOVyH.js +448 -0
  48. package/webui/dist/assets/{TerminalView-C8VC7KXy.js → TerminalView-Cz-KNWsq.js} +2 -2
  49. package/webui/dist/assets/astro-p42oEArs.js +1 -0
  50. package/webui/dist/assets/c-DaO8xguW.js +1 -0
  51. package/webui/dist/assets/ccount-DQxSH8e6.js +1 -0
  52. package/webui/dist/assets/csharp-dmMxLVLW.js +1 -0
  53. package/webui/dist/assets/css-C8fQr_0Q.js +1 -0
  54. package/webui/dist/assets/diff-woXpYk--.js +1 -0
  55. package/webui/dist/assets/dockerfile-IyjqRm3v.js +1 -0
  56. package/webui/dist/assets/dotenv-CuH5my32.js +1 -0
  57. package/webui/dist/assets/fish-BJitypiv.js +1 -0
  58. package/webui/dist/assets/go-rLFTqkRN.js +1 -0
  59. package/webui/dist/assets/graphql-AVu4aKQX.js +1 -0
  60. package/webui/dist/assets/html-wSbaAAK3.js +1 -0
  61. package/webui/dist/assets/index-D5fvY8f8.js +112 -0
  62. package/webui/dist/assets/index-DFvk8ADr.css +2 -0
  63. package/webui/dist/assets/ini-B5eOa1yu.js +1 -0
  64. package/webui/dist/assets/java-DdHNQ9hk.js +1 -0
  65. package/webui/dist/assets/javascript-BgS3c2Ky.js +1 -0
  66. package/webui/dist/assets/json-qhed-kSA.js +1 -0
  67. package/webui/dist/assets/json5-BR5RXkoi.js +1 -0
  68. package/webui/dist/assets/jsonc-CYpm1nAK.js +1 -0
  69. package/webui/dist/assets/jsonl-CmCQp5Yx.js +1 -0
  70. package/webui/dist/assets/jsx-gTgmArGx.js +1 -0
  71. package/webui/dist/assets/kotlin-DhhofPvG.js +1 -0
  72. package/webui/dist/assets/kts-DFAlwc6n.js +1 -0
  73. package/webui/dist/assets/less-DVTAwKKz.js +1 -0
  74. package/webui/dist/assets/lua-xZcbXUE9.js +1 -0
  75. package/webui/dist/assets/makefile-Dixweg8N.js +1 -0
  76. package/webui/dist/assets/markdown-BYOwaDjH.js +1 -0
  77. package/webui/dist/assets/php-C6PDnbTe.js +1 -0
  78. package/webui/dist/assets/pierre-dark-CpLgRqie.js +1 -0
  79. package/webui/dist/assets/pierre-dark-protanopia-deuteranopia-B35FxJx-.js +1 -0
  80. package/webui/dist/assets/pierre-dark-soft-kZQmAZld.js +1 -0
  81. package/webui/dist/assets/pierre-dark-tritanopia-CpjhbsIL.js +1 -0
  82. package/webui/dist/assets/pierre-dark-vibrant-CpQYzh95.js +1 -0
  83. package/webui/dist/assets/pierre-light-CoaEpmwp.js +1 -0
  84. package/webui/dist/assets/pierre-light-protanopia-deuteranopia-0fSaH845.js +1 -0
  85. package/webui/dist/assets/pierre-light-soft-lWLdNTOI.js +1 -0
  86. package/webui/dist/assets/pierre-light-tritanopia-CEbqgOJL.js +1 -0
  87. package/webui/dist/assets/pierre-light-vibrant-D80Fkn33.js +1 -0
  88. package/webui/dist/assets/postcss-D_Wz94rJ.js +1 -0
  89. package/webui/dist/assets/powershell-QtSnmXcu.js +1 -0
  90. package/webui/dist/assets/prisma-DMgNhnYS.js +1 -0
  91. package/webui/dist/assets/properties-CxAuzHS9.js +1 -0
  92. package/webui/dist/assets/python-gzcpVVnB.js +1 -0
  93. package/webui/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
  94. package/webui/dist/assets/ruby-CPL7DL0w.js +1 -0
  95. package/webui/dist/assets/rust-DpiIf5JO.js +1 -0
  96. package/webui/dist/assets/sass-DXrisJhu.js +1 -0
  97. package/webui/dist/assets/scss-CHVQqdPx.js +1 -0
  98. package/webui/dist/assets/shellscript-CLZ0U2zV.js +1 -0
  99. package/webui/dist/assets/sql-CEbI0hMM.js +1 -0
  100. package/webui/dist/assets/svelte-DKhxH1PN.js +1 -0
  101. package/webui/dist/assets/swift-CyEgAFGc.js +1 -0
  102. package/webui/dist/assets/toml-CcmNWLt0.js +1 -0
  103. package/webui/dist/assets/tsx-CUOciP0b.js +1 -0
  104. package/webui/dist/assets/typescript-BydbNFcO.js +1 -0
  105. package/webui/dist/assets/vue-kBwcnTZb.js +1 -0
  106. package/webui/dist/assets/wasm-BnjxR4X6.js +1 -0
  107. package/webui/dist/assets/xml-CeXJrFlG.js +1 -0
  108. package/webui/dist/assets/yaml-rwi0_p6S.js +1 -0
  109. package/webui/dist/assets/yml-s9iVJXCw.js +1 -0
  110. package/webui/dist/assets/zsh-B7CDPTg5.js +1 -0
  111. package/webui/dist/build.json +61 -45
  112. package/webui/dist/index.html +4 -2
  113. package/prompts/quickfix.md +0 -67
  114. package/src/lobby/quickfix.ts +0 -546
  115. package/src/pi/route.ts +0 -180
  116. package/src/webui/api/quickfix.ts +0 -49
  117. package/webui/dist/assets/index-Bs1_77_1.js +0 -114
  118. package/webui/dist/assets/index-DOLQ4FDd.css +0 -2
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The lobby inside pi: one runtime per master session. It owns the lobby's
3
- * state (the quick-fix queue, planner, issues, pulls, knowledge), narrates the
3
+ * state (the chat agents, planner, issues, pulls, knowledge), narrates the
4
4
  * Master's own turn into the feed, passes plan comments to the Master that
5
5
  * owns a task and records each Master turn in the metrics log. The web server
6
6
  * (src/webui) is its only surface.
@@ -15,19 +15,17 @@ import { basename } from "node:path";
15
15
  import { isSubagentProcess } from "../pi/quiet.ts";
16
16
  import { modelRef } from "../pi/model-support.ts";
17
17
  import { modelLookup } from "../pi/tools.ts";
18
- import { setQuickFixHandoff } from "../pi/route.ts";
19
- import type { Domain } from "../schemas/agent.ts";
20
18
  import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
21
19
  import { classifier, effortFor, hintsFor, scriptsPickerFor } from "../classifier/instance.ts";
22
20
  import { checkThinking } from "../pi/model-support.ts";
23
- import { QuickFixQueue } from "./quickfix.ts";
24
21
  import type { Asker } from "../ask/types.ts";
22
+ import { ChatAgents } from "./chat-agents.ts";
25
23
  import { execCommand, IssuesState } from "./issues.ts";
26
24
  import { PullsState } from "./pulls.ts";
27
25
  import { PullReviews } from "./pr-review.ts";
28
26
  import { KnowledgeBook } from "./knowledge.ts";
29
27
  import type { LobbyService } from "./host.ts";
30
- import { backgroundSessions, carryHandOff, createLobbyService, lobbyNotify, lobbyProfile, plannerDeps, resumeSession, reviewProfile, seatProfile, setServiceState, savePlan as serviceSavePlan, answerPanel as serviceAnswerPanel } from "./service.ts";
28
+ import { backgroundSessions, carryHandOff, chatProfile, chatReplied, chatHistoryLines, createLobbyService, lobbyNotify, lobbyProfile, plannerDeps, resumeSession, reviewProfile, seatProfile, setServiceState, savePlan as serviceSavePlan, answerPanel as serviceAnswerPanel } from "./service.ts";
31
29
  import { lobbyTopics } from "./topics.ts";
32
30
  export { backgroundSessions, setSessionLauncher } from "./service.ts";
33
31
  import { deliverComments, onOwnerEvent } from "../pi/owner.ts";
@@ -42,7 +40,8 @@ export interface Runtime {
42
40
  configDir: string;
43
41
  ctx: ExtensionContext;
44
42
  root: string;
45
- quickfix: QuickFixQueue;
43
+ /** The lobby chat's agents other than the oracle, answering one at a time. */
44
+ chat: ChatAgents;
46
45
  planner?: PlanningSession;
47
46
  issues: IssuesState;
48
47
  pulls: PullsState;
@@ -80,11 +79,11 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
80
79
  return serviceAnswerPanel(state);
81
80
  }
82
81
 
83
- /** What the lobby is running now (a quick fix, a review, a planning round), in words, or undefined. */
82
+ /** What the lobby is running now (a chat agent's reply, a review, a planning round), in words, or undefined. */
84
83
  export function lobbyWorkRunning(): string | undefined {
85
84
  const state = runtime;
86
85
  if (!state) return undefined;
87
- if (state.quickfix.running) return "A quick fix";
86
+ if (state.chat.running) return "A chat reply";
88
87
  if (state.planner?.busy) return "A planning round";
89
88
  if (state.reviews.busy) return "A pull request review";
90
89
  return undefined;
@@ -107,32 +106,13 @@ function shutdown(carryOn: boolean): void {
107
106
  runtime = undefined;
108
107
  lobbyService = undefined;
109
108
  setServiceState(undefined);
110
- setQuickFixHandoff(undefined);
111
109
  state.unsubscribeFeed?.();
112
- state.quickfix.cancelAll();
110
+ state.chat.cancelAll();
113
111
  state.reviews.cancelAll();
114
112
  state.planner?.cancel();
115
113
  onRunUpdates(undefined);
116
114
  }
117
115
 
118
- /**
119
- * The oracle routed a request to the quick-fix agent: queue it (no "looks like
120
- * a task" hold, the oracle already decided), on its builder's settings when it
121
- * is a quick feature, and tell the page where it went.
122
- */
123
- function handToQuickFix(state: Runtime, request: string, builder: Domain | undefined, reason: string): string {
124
- const quick = lobbyProfile(state, "quickfix");
125
- const profile = builder ? { ...seatProfile(state, builder), instructions: quick.instructions } : undefined;
126
- const job = state.quickfix.submit(request, Date.now(), {
127
- force: true,
128
- ...(profile ? { profile } : {}),
129
- note: `The oracle sent your request here: ${reason.replace(/[.\s]+$/, "")}.${builder ? ` A quick feature: it runs on ${builder === "designer" ? "DESIGN" : "DEV"}'s model, thinking and grants.` : ""}`,
130
- });
131
- lobbyTopics.bump("quickfix");
132
- pushNotice("The oracle sent your request to the quick-fix agent.", "info");
133
- return job.id;
134
- }
135
-
136
116
  /**
137
117
  * Start the lobby's backend: state, service and background wiring. Runs for
138
118
  * every interactive master session, not a subagent.
@@ -150,10 +130,10 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
150
130
  asking: false,
151
131
  workspace: { name: basename(ctx.cwd) || ctx.cwd },
152
132
  readingWorkspace: false,
153
- quickfix: undefined as unknown as QuickFixQueue,
154
133
  issues: new IssuesState(execCommand, ctx.cwd, () => lobbyTopics.bump("issues")),
155
134
  pulls: new PullsState(execCommand, ctx.cwd, () => lobbyTopics.bump("git")),
156
135
  reviews: undefined as unknown as PullReviews,
136
+ chat: undefined as unknown as ChatAgents,
157
137
  knowledge: new KnowledgeBook({
158
138
  root,
159
139
  configDir,
@@ -175,34 +155,30 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
175
155
  notify: (message, level) => lobbyNotify(state, message, level),
176
156
  classifier: classifier(),
177
157
  });
178
- state.quickfix = new QuickFixQueue({
158
+ state.chat = new ChatAgents({
179
159
  cwd: ctx.cwd,
180
160
  root,
181
161
  configDir,
182
- profile: () => lobbyProfile(state, "quickfix"),
183
- stallTimeoutMs: workflow.stallTimeoutMs,
184
- toolStallTimeoutMs: workflow.toolStallTimeoutMs,
185
- feed: lobbyFeed,
162
+ profile: (agent) => chatProfile(state, agent),
163
+ history: chatHistoryLines,
164
+ onReply: (job) => chatReplied(state, job),
186
165
  onChange: () => {
187
- lobbyTopics.bump("quickfix");
188
- state.keeper?.quickfix.schedule();
166
+ lobbyTopics.bump("lobby");
167
+ lobbyTopics.bump("status");
189
168
  },
190
- notify: (message, level) => lobbyNotify(state, message, level),
169
+ feed: lobbyFeed,
191
170
  hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
192
- scripts: scriptsPickerFor(),
193
- classifier: classifier(),
194
- effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
195
- lint: () => loadConfig().lint,
171
+ stallTimeoutMs: workflow.stallTimeoutMs,
172
+ toolStallTimeoutMs: workflow.toolStallTimeoutMs,
196
173
  });
197
174
  runtime = state;
198
175
  lobbyService = createLobbyService(state);
199
176
  setServiceState(state);
200
177
  // Topic routing (src/lobby/topics.ts): sources bump a topic when their data
201
178
  // changes and the web page rereads it. lobbyFeed (lobby), run updates (tasks),
202
- // quick fix (quickfix), planner (planner), background sessions (sessions),
179
+ // chat agents (lobby), planner (planner), background sessions (sessions),
203
180
  // metrics (metrics), pulls/reviews (git), issues (issues), the prompt hub
204
181
  // (prompts), owner/notify events (notices), busy/workspace (status).
205
- setQuickFixHandoff((request, builder, reason) => (runtime === state ? handToQuickFix(state, request, builder, reason) : undefined));
206
182
  lobbyFeed.clear();
207
183
  // Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
208
184
  lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY));
@@ -219,6 +195,7 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
219
195
  if (event.kind === "comments") lobbyFeed.log("LOBBY", `passed ${event.count} plan comment${event.count === 1 ? "" : "s"} on ${event.taskId} to the oracle`, "info");
220
196
  else if (event.kind === "inbox" || event.kind === "messages") lobbyFeed.log("LOBBY", `passed ${event.count} message${event.count === 1 ? "" : "s"} from another session to the oracle`, "info");
221
197
  else if (event.kind === "auto") lobbyFeed.log("LOBBY", `auto mode ${event.on ? "on" : "off"} for ${event.taskId}`, event.on ? "success" : "info");
198
+ else if (event.kind === "held") lobbyFeed.log("LOBBY", `auto mode: you stopped the oracle on ${event.taskId}; it waits for your next message`, "info");
222
199
  else if (event.kind === "nudge") lobbyFeed.log("LOBBY", `auto mode: keeping the oracle going on ${event.taskId}`, "info");
223
200
  else lobbyFeed.log("LOBBY", `auto mode: no progress on ${event.taskId} — it needs you`, "warning");
224
201
  });
@@ -239,7 +216,6 @@ function carryOn(state: Runtime): void {
239
216
  sessionFile: () => state.ctx.sessionManager.getSessionFile?.(),
240
217
  working: () => !state.ctx.isIdle(),
241
218
  planner: () => state.planner,
242
- quickfix: () => state.quickfix.snapshot(),
243
219
  background: () => backgroundSessions(),
244
220
  });
245
221
  let summary: string | undefined;
@@ -250,7 +226,6 @@ function carryOn(state: Runtime): void {
250
226
  sessionId: ctx.sessionManager.getSessionId(),
251
227
  relaunch: (start) => resumeSession(state, start),
252
228
  restorePlanner: (snapshot) => restorePlanner(state, snapshot),
253
- restoreQuickFixes: (jobs) => state.quickfix.restore(jobs).length,
254
229
  nudge: (message) => nudgeWhenIdle(state, message),
255
230
  });
256
231
  } catch (error) {
@@ -359,6 +334,9 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
359
334
  const finished = turn;
360
335
  turn = undefined;
361
336
  lobbyFeed.replyEnd();
337
+ // A tool call cut off by a stop or a steer never ends on its own; the turn's end closes it.
338
+ lobbyFeed.endAll("MASTER");
339
+ lobbyTopics.bump("status");
362
340
  const taskId = finished?.taskId ?? currentZenTask()?.id;
363
341
  if (!finished || !taskId || !runtime) return;
364
342
  appendMetrics(runtime.root, configDir, [{
@@ -378,6 +356,10 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
378
356
  taskId,
379
357
  }]);
380
358
  lobbyTopics.bump("metrics");
359
+ });
360
+ // pi is idle only once the run has settled (after agent_end, retries and continuations): the page's busy flag rereads then.
361
+ pi.on("agent_settled", () => {
362
+ lobbyFeed.endAll("MASTER");
381
363
  lobbyTopics.bump("status");
382
364
  });
383
365
  }
@@ -29,12 +29,14 @@ import { applyStatus, currentZenTask, taskSnapshot } from "../pi/ui.ts";
29
29
  import { taskName } from "../text.ts";
30
30
  import { describeWorkspace } from "../execution/workspace.ts";
31
31
  import { stripStartFlags } from "../pi/start-flags.ts";
32
- import { checkThinking, modelRef, resolveLobbyProfile, resolvePanelProfile, resolveReviewProfile, supportedThinking } from "../pi/model-support.ts";
32
+ import { checkThinking, modelRef, resolveChatProfile, resolveLobbyProfile, resolvePanelProfile, resolveReviewProfile, supportedThinking } from "../pi/model-support.ts";
33
33
  import { modelLookup } from "../pi/tools.ts";
34
- import { startPlannedTask } from "../pi/start-task.ts";
35
- import { pendingRequest, startRequest } from "../pi/route.ts";
34
+ import { startPlannedTask, startTask } from "../pi/start-task.ts";
36
35
  import { deliverComments, setAuto } from "../pi/owner.ts";
37
- import { chatFromEntries, lobbyFeed, type ChatEntry } from "./feed.ts";
36
+ import { CHAT_MARK, chatFromEntries, chatMarkContent, CLEARED_NOTE, lobbyFeed, type ChatEntry, type ChatMarkDetails } from "./feed.ts";
37
+ import { CHAT_AGENT_SOURCES, parseLobbyCommand, parseMention, type LobbyCommand } from "./prompts.ts";
38
+ import type { ChatJob, ChatWorker } from "./chat-agents.ts";
39
+ import { routeChatMessage } from "../classifier/lobby.ts";
38
40
  import { availableClassifiers, classifier, effortFor, hintsFor } from "../classifier/instance.ts";
39
41
  import { cleanSessionName, launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
40
42
  import { askForSession } from "./session-questions.ts";
@@ -147,23 +149,144 @@ function failed(state: Runtime, what: string, error: Error): void {
147
149
  lobbyNotify(state, `bot-lobby: ${what} — ${error.message}`, "error");
148
150
  }
149
151
 
150
- function toOracle(state: Runtime, text: string): string | undefined {
152
+ /** What sending to the lobby chat did: nothing to say, a notice, or a notice and the tab to open. */
153
+ export type LobbySendResult = string | { notice: string; open: "plan" } | undefined;
154
+
155
+ function toOracle(state: Runtime, text: string): LobbySendResult {
151
156
  const busy = !state.ctx.isIdle();
157
+ const command = parseLobbyCommand(text);
158
+ if (command) return lobbyCommand(state, command.command, command.rest);
152
159
  if (text.startsWith("/")) {
153
160
  state.pi.sendUserMessage(text, { expandPromptTemplates: true, ...(busy ? { deliverAs: "followUp" as const } : {}) });
154
161
  return `sent ${text.split(/\s+/)[0]} to pi`;
155
162
  }
156
- // While the oracle decides where a request goes, what the user types joins that conversation.
157
- if (!currentZenTask() && !pendingRequest()) {
158
- startRequest(state.pi, state.ctx, state.configDir, text).catch((error: Error) => failed(state, "could not start the task", error));
159
- return "reading your request — a quick fix or a task, the oracle takes it from here";
163
+ // A task in this session: the oracle drives it, and what you say goes to it.
164
+ if (currentZenTask()) return toMaster(state, text);
165
+ const mention = parseMention(text);
166
+ if (mention?.agent === "master") return toMaster(state, mention.rest.trim() || text);
167
+ if (mention) {
168
+ const body = mention.rest.trim();
169
+ if (!body) return `say what you want ${CHAT_AGENT_SOURCES[mention.agent]} to do after @${mention.agent === "designer" ? "design" : mention.agent === "backend" ? "dev" : mention.agent === "researcher" ? "research" : mention.agent}`;
170
+ toAgent(state, mention.agent, body, `you named ${CHAT_AGENT_SOURCES[mention.agent]}`);
171
+ return undefined;
160
172
  }
173
+ // While the oracle is mid-turn, what you say steers it, as it always has.
174
+ if (busy) return toMaster(state, text);
175
+ lobbyFeed.sayQueued(text);
176
+ void routeChat(state, text).catch((error: Error) => failed(state, "could not pass your message on", error));
177
+ return undefined;
178
+ }
179
+
180
+ /** The oracle answers: a pi turn, or a steer while it is mid-turn. */
181
+ function toMaster(state: Runtime, text: string): undefined {
182
+ const busy = !state.ctx.isIdle();
161
183
  state.pi.sendUserMessage(text, busy ? { deliverAs: "steer" } : undefined);
162
184
  // A steer reaches the oracle only when its turn lets it in; the conversation shows it now.
163
185
  if (busy) lobbyFeed.sayQueued(text);
164
186
  return undefined;
165
187
  }
166
188
 
189
+ /** One line of the conversation as an agent reads it. */
190
+ function chatLine(entry: ChatEntry): string {
191
+ if (entry.role === "you") return `You${entry.to ? ` (to ${entry.to})` : ""}: ${entry.text}`;
192
+ if (entry.role === "note") return `(${entry.text})`;
193
+ return `${entry.agent ?? "MASTER"}: ${entry.text}`;
194
+ }
195
+
196
+ /** The conversation since the oracle's context was last cleared, as lines. */
197
+ export function chatHistoryLines(): string[] {
198
+ const chat = lobbyFeed.chat;
199
+ const cleared = chat.map((entry) => entry.text).lastIndexOf(CLEARED_NOTE);
200
+ return chat.slice(cleared + 1).map(chatLine);
201
+ }
202
+
203
+ /** Who answers a message nobody was named in: the classifier's pick when it is sure, else the Master. */
204
+ async function routeChat(state: Runtime, text: string): Promise<void> {
205
+ const jev = classifier();
206
+ const lines = chatHistoryLines();
207
+ const picked = jev ? await routeChatMessage(jev, text, lines.slice(-7, -1), { at: loadConfig().classifier.thresholds.lobbyAgentAt }).catch(() => undefined) : undefined;
208
+ if (state !== serviceState) return;
209
+ if (!picked || picked.route === "master") {
210
+ toMaster(state, text);
211
+ return;
212
+ }
213
+ if (picked.route === "knowledge") {
214
+ lobbyFeed.log("LOBBY", `a knowledge question (${picked.probability.toFixed(2)}): the oracle reads the project's knowledge`, "info");
215
+ state.pi.sendUserMessage(knowledgeQuestion(text, readDataRoots(state.root, state.configDir)), state.ctx.isIdle() ? undefined : { deliverAs: "followUp" });
216
+ return;
217
+ }
218
+ toAgent(state, picked.route, text, `the classifier picked ${CHAT_AGENT_SOURCES[picked.route]} (${picked.probability.toFixed(2)})`, true);
219
+ }
220
+
221
+ /** An agent other than the oracle answers: the message joins the conversation (and the session) under its name, and its reply follows. */
222
+ function toAgent(state: Runtime, agent: ChatWorker, text: string, why: string, shown = false): void {
223
+ const source = CHAT_AGENT_SOURCES[agent];
224
+ if (shown) {
225
+ lobbyFeed.takeQueued(text);
226
+ lobbyFeed.addressed(text, source);
227
+ } else lobbyFeed.say("you", text, Date.now(), { to: source });
228
+ keepChatLine(state, { role: "you", agent: source, text });
229
+ lobbyFeed.log("LOBBY", `${source} answers: ${why}`, "info");
230
+ state.chat.submit(agent, text, why);
231
+ }
232
+
233
+ /** Keep a line the oracle did not take part in, in its session: it reads it as context, and the conversation shows it again later. */
234
+ function keepChatLine(state: Runtime, details: ChatMarkDetails): void {
235
+ try {
236
+ state.pi.sendMessage({ customType: CHAT_MARK, content: chatMarkContent(details), display: true, details }, { triggerTurn: false });
237
+ } catch {
238
+ // A session that cannot take it keeps the conversation in the page alone.
239
+ }
240
+ }
241
+
242
+ /** A chat agent finished: its reply joins the conversation and the session. */
243
+ export function chatReplied(state: Runtime, job: ChatJob): void {
244
+ const source = CHAT_AGENT_SOURCES[job.agent];
245
+ const text = job.reply ?? (job.status === "success" ? "(no reply)" : "");
246
+ if (text) {
247
+ lobbyFeed.say("oracle", text, job.finishedAt ?? Date.now(), { agent: source });
248
+ keepChatLine(state, { role: "agent", agent: source, text });
249
+ }
250
+ if (job.status !== "success" && job.status !== "cancelled") lobbyFeed.say("note", `✗ ${source}'s reply ${job.status}${job.error ? `: ${job.error.split("\n")[0]}` : ""}`);
251
+ }
252
+
253
+ /** The model, thinking and grants a chat agent answers with. */
254
+ export function chatProfile(state: Runtime, agent: ChatWorker) {
255
+ return resolveChatProfile(loadConfig(), agent, {
256
+ lookup: modelLookup(state.ctx),
257
+ sessionModel: sessionModel(state.ctx),
258
+ warn: (message) => lobbyNotify(state, message, "warning"),
259
+ });
260
+ }
261
+
262
+ /** The work a bare `/plan` or `/task` is about: the conversation so far. */
263
+ function fromConversation(): string | undefined {
264
+ const lines = chatHistoryLines().filter((line) => !line.startsWith("("));
265
+ if (!lines.some((line) => line.startsWith("You"))) return undefined;
266
+ return `From the lobby chat:\n\n${lines.slice(-12).join("\n\n")}`;
267
+ }
268
+
269
+ /** `/plan` and `/task`: plan the work with the panel on the Plan tab, or start it as a task. */
270
+ function lobbyCommand(state: Runtime, command: LobbyCommand, rest: string): LobbySendResult {
271
+ const request = rest.trim() || fromConversation();
272
+ if (!request) return `type what to ${command === "plan" ? "plan" : "do"} after /${command}`;
273
+ if (command === "task") {
274
+ const current = currentZenTask();
275
+ if (current) return `this session already drives ${current.id}; finish or cancel it first, or start the task from another session`;
276
+ startTask(state.pi, state.ctx, state.configDir, request)
277
+ .then(() => lobbyTopics.bump("tasks"))
278
+ .catch((error: Error) => failed(state, "could not start the task", error));
279
+ return "starting a task — the oracle takes it from here";
280
+ }
281
+ if (state.planner?.busy) return "the panel is still thinking — wait for the round to finish";
282
+ // A plan under way stays among the project's previous plans; this one starts afresh.
283
+ const fresh = state.planner && state.planner.messages.length > 0;
284
+ const session = fresh || !state.planner ? newPlanner(state, undefined, loadConfig().lobby.planningPanel) : state.planner;
285
+ session.send(request).catch((error: Error) => failed(state, "could not start planning", error));
286
+ lobbyTopics.bump("planner");
287
+ return { notice: fresh ? "planning on the Plan tab — your earlier plan is under Previous plans" : "planning on the Plan tab", open: "plan" };
288
+ }
289
+
167
290
  function startPlanned(state: Runtime, plan: PlannedTask): string {
168
291
  const current = currentZenTask();
169
292
  if (current) return `this session already drives ${current.id}; finish or cancel it first, or start ${plan.id} from another session`;
@@ -580,7 +703,7 @@ function liveSessions(state: Runtime): LiveSession[] {
580
703
  */
581
704
  function chatHistory(state: Runtime, sessionId?: string): ChatEntry[] {
582
705
  if (sessionId) return chats.history(sessionId);
583
- return chatFromEntries(state.ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY).map((line, index) => ({ id: -(index + 1), at: line.at ?? 0, role: line.role, text: line.text }));
706
+ return chatFromEntries(state.ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY).map((line, index) => ({ ...line, id: -(index + 1), at: line.at ?? 0 }));
584
707
  }
585
708
 
586
709
  /** Leave a message for a session running in another terminal. */
@@ -914,7 +1037,11 @@ export function createLobbyService(state: Runtime): LobbyService {
914
1037
  return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
915
1038
  },
916
1039
  feed: lobbyFeed,
917
- masterBusy: () => !state.ctx.isIdle(),
1040
+ masterBusy: () => !state.ctx.isIdle() || state.chat.busy,
1041
+ answering: () => {
1042
+ const job = state.chat.running;
1043
+ return job ? CHAT_AGENT_SOURCES[job.agent] : undefined;
1044
+ },
918
1045
  tasks: () => peekTasks(state.root, state.configDir),
919
1046
  work: (taskId) => taskWork(state.root, state.configDir, taskId),
920
1047
  deliveryDeliver: (taskId, request: DeliveryRequest) => deliver(request, { cwd: state.root, exec: execCommand }, deliveryStore(state.root, state.configDir, taskId)),
@@ -952,8 +1079,10 @@ export function createLobbyService(state: Runtime): LobbyService {
952
1079
  discardPlannedTask(state.root, state.configDir, id);
953
1080
  lobbyTopics.bump("plans");
954
1081
  },
955
- abortMaster: () => state.ctx.abort(),
956
- quickfix: state.quickfix,
1082
+ abortMaster: () => {
1083
+ state.chat.cancelAll();
1084
+ if (!state.ctx.isIdle()) state.ctx.abort();
1085
+ },
957
1086
  planner: () => state.planner,
958
1087
  newPlanner: (seed, seats) => newPlanner(state, seed, seats),
959
1088
  previousPlans: (archived) => plansBefore(state, archived),
@@ -7,7 +7,7 @@
7
7
  * parent, and the text of user and assistant messages (never tool output).
8
8
  */
9
9
  import { closeSync, openSync, readSync, statSync } from "node:fs";
10
- import { chatText, CLEARED_NOTE, isClearedMark, MAX_CHAT, textOf, type ChatEntry } from "./feed.ts";
10
+ import { chatMarkLine, chatText, CLEARED_NOTE, isClearedMark, MAX_CHAT, textOf, type ChatEntry } from "./feed.ts";
11
11
 
12
12
  /** What a session log keeps of one entry: its parent, and its conversation lines if it is a message. */
13
13
  interface LogNode {
@@ -96,6 +96,9 @@ export class SessionLog {
96
96
  if (chat.length > 0) node.chat = chat;
97
97
  } else if (isClearedMark(entry)) {
98
98
  node.chat = [{ id: ++logIds, at, role: "note", text: CLEARED_NOTE }];
99
+ } else {
100
+ const marked = chatMarkLine(entry);
101
+ if (marked) node.chat = [{ ...marked, id: ++logIds, at }];
99
102
  }
100
103
  this.nodes.set(entry.id, node);
101
104
  this.leaf = entry.id;
@@ -10,7 +10,6 @@ export const LOBBY_TOPICS = [
10
10
  "tasks",
11
11
  "plans",
12
12
  "planner",
13
- "quickfix",
14
13
  "sessions",
15
14
  "metrics",
16
15
  "git",
@@ -423,7 +423,7 @@ export interface ReviewerRequest {
423
423
  workerSummary: string;
424
424
  scoutOutcomes: ScoutOutcome[];
425
425
  diff: string;
426
- /** Who changed each changed file (planned, quick fix, pre-existing, …), one line each. */
426
+ /** Who changed each changed file (planned, lobby chat, pre-existing, …), one line each. */
427
427
  provenance?: string;
428
428
  /** What the previous QA round asked for, when there was one: this round verifies it. */
429
429
  previousRound?: string;
@@ -23,8 +23,7 @@ import { applyStatus, registerRevealShortcut } from "./ui.ts";
23
23
  import { isOn, registerSwitch, turnOff, turnOn } from "./switch.ts";
24
24
  import { classifierSummary } from "./model-settings.ts";
25
25
  import { keyStatus } from "../classifier/instance.ts";
26
- import { kickoff, startPlannedTask } from "./start-task.ts";
27
- import { startRequest } from "./route.ts";
26
+ import { kickoff, startPlannedTask, startTask } from "./start-task.ts";
28
27
  import { setAuto, toggleOwnAuto } from "./owner.ts";
29
28
  import { registerWebServer, webCommand } from "../webui/command.ts";
30
29
  import { modelRef, thinkingMismatches } from "./model-support.ts";
@@ -37,8 +36,8 @@ import { livePresence } from "../state/presence.ts";
37
36
  import { carryOnMessage } from "../lobby/resume.ts";
38
37
 
39
38
  const HELP = [
40
- "/bot-lobby Open the web lobby in your browser: tasks, plan, quick fix, metrics",
41
- "/bot-lobby <request> Start a request: a quick fix when one agent can do it alone (the oracle confirms), else a task",
39
+ "/bot-lobby Open the web lobby in your browser: chat, tasks, plan, metrics",
40
+ "/bot-lobby <request> Start a task (in the lobby, /task <request> does the same)",
42
41
  "/bot-lobby --task [--auto] <request> Always a task (also when the request begins with a subcommand word)",
43
42
  "/bot-lobby --fast|--full <request> Start a task on the fast track (straight to the agents it needs) or the full workflow, whatever it reads as",
44
43
  "/bot-lobby --branch|--worktree|--no-branch <request> Give the task its own git branch, or its own worktree, named after it (or neither), whatever workflow.gitIsolation says",
@@ -80,7 +79,7 @@ export interface ParsedCommand {
80
79
  auto?: boolean;
81
80
  /** `--fast` or `--full`: the task's path, whatever its request reads as. */
82
81
  track?: "fast" | "full";
83
- /** `--task`: a task, never routed to the quick-fix agent. */
82
+ /** `--task`: the rest is a task's request, even when it begins with a subcommand word. */
84
83
  task?: boolean;
85
84
  /** `--branch`, `--worktree` or `--no-branch`: the task's git isolation, whatever the config says. */
86
85
  isolation?: GitIsolation;
@@ -336,13 +335,12 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
336
335
  return filtered.length > 0 ? filtered : null;
337
336
  },
338
337
  handler: async (args, ctx) => {
339
- const { sub, rest, restText, auto, track, task, isolation } = parseCommand(args ?? "");
338
+ const { sub, rest, restText, auto, track, isolation } = parseCommand(args ?? "");
340
339
  // A request, the bare command or anything that drives a task needs bot-lobby on (`web stop` does not).
341
340
  if (!sub || (TURNS_ON.has(sub) && !(sub === "web" && rest[0] === "stop"))) turnOn(pi, ctx, configDir);
342
341
  if (!sub) {
343
342
  if (!restText) return webCommand(ctx, undefined);
344
- // A request one agent can do alone may go to the quick-fix agent once the oracle confirms; the rest start as tasks.
345
- await startRequest(pi, ctx, configDir, restText, { ...(auto ? { auto } : {}), ...(track ? { track } : {}), ...(task ? { task } : {}), ...(isolation ? { isolation } : {}) });
343
+ await startTask(pi, ctx, configDir, restText, { ...(auto ? { auto } : {}), ...(track ? { track } : {}), ...(isolation ? { isolation } : {}) });
346
344
  return;
347
345
  }
348
346
  switch (sub) {
package/src/pi/events.ts CHANGED
@@ -55,6 +55,14 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
55
55
  * operating prompt is patched into the system prompt as one cache-stable
56
56
  * section rather than replacing the whole prompt.
57
57
  */
58
+ /** What the oracle is told while no task runs: the lobby is a chat, and the workflow waits for /plan or /task. */
59
+ export const LOBBY_CHAT_NOTE = [
60
+ "## Bot Lobby chat",
61
+ "You are the Master in Bot Lobby's chat: the user talks to the team here, and everyday work (questions, code changes, updates, minor fixes) happens in the chat itself, so do it directly.",
62
+ "DESIGN, DEV, QA, RESEARCH and the scout answer in this chat too when the user names one with @ or the classifier gives a message to one; those lines reach you as \"The user asked …\" and \"… answered\" messages, for context: you need not answer them.",
63
+ "Bot Lobby's workflow (scouts, a plan, the QA gate) runs only when the user types /plan (plan it with the panel) or /task (start it as a task). When a request is large, risky or spans several areas, suggest one of them in a line; never start one yourself.",
64
+ ].join("\n");
65
+
58
66
  export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
59
67
  // Web tools the oracle put away for the task in hand, given back when it ends.
60
68
  let hiddenWeb: string[] = [];
@@ -121,10 +129,10 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
121
129
  }
122
130
  }
123
131
  if (!task) {
124
- // After a task ended and cleared the context, a line on where its record is, in case the user refers back to it.
132
+ // The lobby chat, and after a task ended and cleared the context, a line on where its record is, in case the user refers back to it.
125
133
  const previous = hidden ? undefined : previousTaskNote(ctx, configDir);
126
- if (previous) event.systemPromptOptions.sections["bot-lobby"] = previous;
127
- else delete event.systemPromptOptions.sections["bot-lobby"];
134
+ if (isSubagentProcess() || hidden) delete event.systemPromptOptions.sections["bot-lobby"];
135
+ else event.systemPromptOptions.sections["bot-lobby"] = [LOBBY_CHAT_NOTE, previous ?? ""].filter(Boolean).join("\n\n");
128
136
  return;
129
137
  }
130
138
  const dataRoots = readDataRoots(root, configDir);
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * A boundary is a custom entry in the session file, which is never sent to
11
11
  * the model: it survives /resume and belongs to the branch it was made on.
12
- * Subagents (scouts, workers, reviewers, the planning panel, quick fixes)
12
+ * Subagents (scouts, workers, reviewers, the planning panel, lobby chat agents)
13
13
  * already run as pi processes of their own, each with its own context, and
14
14
  * are not touched here.
15
15
  */
@@ -30,7 +30,6 @@ export function entryView(config: BotLobbyConfig, kind: SettingsKind): EntryView
30
30
  if (kind === "master") return config.master;
31
31
  if (kind === "scout") return { model: config.scout.model };
32
32
  if (kind === "researcher") return config.researcher;
33
- if (kind === "quickfix") return config.quickFix;
34
33
  if (kind === "planner") return config.planner;
35
34
  return config.agents[kind];
36
35
  }
@@ -47,7 +46,6 @@ export function patchEntry(config: BotLobbyConfig, kind: SettingsKind, patch: En
47
46
  if (kind === "master") next.master = { ...config.master, ...patch } as AgentModelConfig;
48
47
  else if (kind === "scout") next.scout = { ...config.scout, model: patch.model ?? config.scout.model };
49
48
  else if (kind === "researcher") next.researcher = { ...config.researcher, ...patch } as AgentModelConfig;
50
- else if (kind === "quickfix") next.quickFix = { ...config.quickFix, ...patch } as AgentModelConfig;
51
49
  else if (kind === "planner") next.planner = { ...config.planner, ...patch } as AgentModelConfig;
52
50
  else next.agents[kind] = { ...config.agents[kind], ...patch } as AgentModelConfig;
53
51
  return next;
@@ -53,7 +53,6 @@ const LABELS: Record<SubagentKind | "master", string> = {
53
53
  qa: "QA",
54
54
  scout: "Scout",
55
55
  researcher: "Researcher",
56
- quickfix: "Quick fix",
57
56
  planner: "Planner",
58
57
  };
59
58
 
@@ -127,6 +126,17 @@ export function resolvePanelProfile(config: BotLobbyConfig, member: PanelMember,
127
126
  return resolveRunProfile(panelMemberProfile(config, member), kindLabel(member), options);
128
127
  }
129
128
 
129
+ /**
130
+ * A lobby chat agent's model, thinking and grants: the domain's (or the
131
+ * researcher's) settings, as on the planning panel; the scout's own model at
132
+ * its fixed thinking.
133
+ */
134
+ export function resolveChatProfile(config: BotLobbyConfig, agent: "designer" | "backend" | "qa" | "researcher" | "scout", options: ResolverOptions): RunProfile {
135
+ if (agent !== "scout") return resolvePanelProfile(config, agent, options);
136
+ const profile = agentProfile(config, "backend", "scout");
137
+ return resolveRunProfile({ model: profile.model, thinking: profile.thinking, ...(profile.mcpTools ? { mcpTools: profile.mcpTools } : {}), ...(profile.skills ? { skills: profile.skills } : {}), ...(profile.autoCompact === false ? { autoCompact: false } : {}), ...(profile.compactAt ? { compactAt: profile.compactAt } : {}), ...(profile.harness ? { harness: profile.harness } : {}) }, kindLabel("scout"), options);
138
+ }
139
+
130
140
  /**
131
141
  * The model, thinking and grants a pull request review runs on: QA's, the
132
142
  * agent whose job is judging work, with its custom instructions.
@@ -144,7 +154,6 @@ export function thinkingMismatches(config: BotLobbyConfig, lookup: ModelLookup,
144
154
  ["backend", config.agents.backend.model, config.agents.backend.thinking],
145
155
  ["qa", config.agents.qa.model, config.agents.qa.thinking],
146
156
  ["researcher", config.researcher.model, config.researcher.thinking],
147
- ["quickfix", config.quickFix.model, config.quickFix.thinking],
148
157
  ["planner", config.planner.model, config.planner.thinking],
149
158
  ];
150
159
  return entries.flatMap(([kind, ref, level]) => {
package/src/pi/owner.ts CHANGED
@@ -32,7 +32,8 @@ export type OwnerEvent =
32
32
  | { kind: "messages"; count: number }
33
33
  | { kind: "auto"; taskId: string; on: boolean }
34
34
  | { kind: "nudge"; taskId: string }
35
- | { kind: "stalled"; taskId: string };
35
+ | { kind: "stalled"; taskId: string }
36
+ | { kind: "held"; taskId: string };
36
37
 
37
38
  interface Owner {
38
39
  pi: ExtensionAPI;
@@ -43,6 +44,8 @@ interface Owner {
43
44
  configDir: string;
44
45
  timer?: ReturnType<typeof setInterval>;
45
46
  auto?: AutoTrack;
47
+ /** The user stopped the oracle: auto mode waits until they start a turn again or switch it. */
48
+ held?: boolean;
46
49
  /** The session's task as this tick read it: one read serves the whole tick. */
47
50
  tick?: { task: Task | undefined };
48
51
  }
@@ -177,7 +180,7 @@ export function driveAuto(): boolean {
177
180
  state.auto = undefined;
178
181
  return false;
179
182
  }
180
- if (!state.ctx.isIdle()) return false;
183
+ if (state.held || !state.ctx.isIdle()) return false;
181
184
  const step = autoStep(state.auto, taskFingerprint(task));
182
185
  state.auto = step.track;
183
186
  if (step.stalledNow) {
@@ -199,6 +202,7 @@ export function setAuto(root: string, configDir: string, taskId: string, on: boo
199
202
  const task = ownTask(state);
200
203
  if (task?.id !== taskId) return;
201
204
  state.auto = undefined;
205
+ state.held = false;
202
206
  if (on) driveAuto();
203
207
  }
204
208
 
@@ -213,6 +217,27 @@ export function toggleOwnAuto(): string {
213
217
  return on ? `auto mode on for ${task.id}: the oracle drives it to completion without asking you` : `auto mode off for ${task.id}: the oracle asks you again`;
214
218
  }
215
219
 
220
+ /** Whether a finished turn was stopped by the user (its last reply was aborted). */
221
+ export function stoppedTurn(messages: readonly unknown[] | undefined): boolean {
222
+ const last = [...(messages ?? [])].reverse().find((message) => (message as { role?: string })?.role === "assistant") as { stopReason?: string } | undefined;
223
+ return last?.stopReason === "aborted";
224
+ }
225
+
226
+ /**
227
+ * A turn ended. One the user stopped holds auto mode, so the next tick does
228
+ * not start the task again; any turn that starts after it (the user's next
229
+ * message) lets auto mode carry on.
230
+ */
231
+ export function turnEnded(messages: readonly unknown[] | undefined): void {
232
+ const state = owner;
233
+ if (!state || !stoppedTurn(messages)) return;
234
+ const task = ownTask(state);
235
+ if (state.held || !live(task) || !isAutoMode(state.root, state.configDir, task.id)) return;
236
+ state.held = true;
237
+ state.auto = undefined;
238
+ listener?.({ kind: "held", taskId: task.id });
239
+ }
240
+
216
241
  /** One tick of the owner's clock. */
217
242
  export function ownerTick(): void {
218
243
  const state = owner;
@@ -260,8 +285,14 @@ export function registerOwner(pi: ExtensionAPI, configDir: string): void {
260
285
  };
261
286
  pi.on("session_start", (_event, ctx) => startOwner(pi, ctx, configDir));
262
287
  pi.on("session_shutdown", () => stop());
263
- pi.on("agent_start", (_event, ctx) => track(ctx));
264
- pi.on("agent_end", (_event, ctx) => track(ctx));
288
+ pi.on("agent_start", (_event, ctx) => {
289
+ track(ctx);
290
+ if (owner) owner.held = false;
291
+ });
292
+ pi.on("agent_end", (event, ctx) => {
293
+ track(ctx);
294
+ turnEnded((event as { messages?: readonly unknown[] }).messages);
295
+ });
265
296
  // Auto mode has nobody to answer a question: tell the oracle to decide instead.
266
297
  pi.on("tool_call", (event, ctx) => {
267
298
  if (event.toolName !== "ask_user_question" || !owner) return undefined;