pikiloom 0.4.26 → 0.4.27

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 (155) hide show
  1. package/dashboard/dist/assets/{AgentTab-BNJ73P0m.js → AgentTab-DIP-y4dl.js} +1 -1
  2. package/dashboard/dist/assets/ConnectionModal-BCfvhkiI.js +1 -0
  3. package/dashboard/dist/assets/{DirBrowser-D-GT-G-w.js → DirBrowser-Dot7ua9-.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-BrJMulJR.js → ExtensionsTab-GioMx4S6.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-auPnx468.js → IMAccessTab-C9_r8jJP.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-lYfO9m-I.js → Modal-M1AP1sgR.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-PRdWOIY9.js → Modals-CCm3UUfJ.js} +1 -1
  8. package/dashboard/dist/assets/{Select-BhbU-rll.js → Select-YubDM16P.js} +1 -1
  9. package/dashboard/dist/assets/{SessionPanel-_0dLWvEb.js → SessionPanel-Cnv9o96n.js} +1 -1
  10. package/dashboard/dist/assets/SystemTab-VgNivIe3.js +1 -0
  11. package/dashboard/dist/assets/index-BY7l20bi.js +23 -0
  12. package/dashboard/dist/assets/index-BlqvzDUL.js +3 -0
  13. package/dashboard/dist/assets/index-CXIN3nTr.css +1 -0
  14. package/dashboard/dist/assets/{shared-C5uXvhZJ.js → shared-C0SAInnT.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/acp-client.js +0 -59
  17. package/dist/agent/artifacts.js +1 -38
  18. package/dist/agent/auto-update.js +2 -78
  19. package/dist/agent/await-resume.js +0 -20
  20. package/dist/agent/cli/auth.js +3 -51
  21. package/dist/agent/cli/catalog.js +0 -3
  22. package/dist/agent/cli/detector.js +1 -19
  23. package/dist/agent/cli/index.js +0 -3
  24. package/dist/agent/cli/registry.js +0 -25
  25. package/dist/agent/driver.js +0 -14
  26. package/dist/agent/drivers/claude-tui.js +2 -769
  27. package/dist/agent/drivers/claude.js +12 -631
  28. package/dist/agent/drivers/codex.js +11 -162
  29. package/dist/agent/drivers/gemini.js +6 -114
  30. package/dist/agent/drivers/hermes.js +0 -128
  31. package/dist/agent/goal.js +0 -45
  32. package/dist/agent/handover.js +1 -46
  33. package/dist/agent/images.js +7 -133
  34. package/dist/agent/index.js +1 -29
  35. package/dist/agent/mcp/bridge.js +1 -197
  36. package/dist/agent/mcp/extensions.js +5 -84
  37. package/dist/agent/mcp/oauth.js +2 -69
  38. package/dist/agent/mcp/registry.js +2 -33
  39. package/dist/agent/mcp/session-server.js +2 -45
  40. package/dist/agent/mcp/tools/ask-user.js +0 -6
  41. package/dist/agent/mcp/tools/await-resume.js +0 -15
  42. package/dist/agent/mcp/tools/goal.js +0 -25
  43. package/dist/agent/mcp/tools/types.js +0 -5
  44. package/dist/agent/mcp/tools/workspace.js +0 -23
  45. package/dist/agent/npm.js +0 -14
  46. package/dist/agent/session.js +86 -196
  47. package/dist/agent/skill-installer.js +3 -70
  48. package/dist/agent/skills.js +0 -43
  49. package/dist/agent/stream.js +5 -148
  50. package/dist/agent/types.js +0 -12
  51. package/dist/agent/utils.js +2 -120
  52. package/dist/bot/bot.js +3 -468
  53. package/dist/bot/command-ui.js +0 -45
  54. package/dist/bot/commands.js +0 -51
  55. package/dist/bot/headless-bot.js +0 -21
  56. package/dist/bot/host.js +1 -5
  57. package/dist/bot/human-loop.js +0 -8
  58. package/dist/bot/menu.js +0 -3
  59. package/dist/bot/orchestration.js +0 -3
  60. package/dist/bot/render-shared.js +0 -63
  61. package/dist/bot/session-hub.js +14 -92
  62. package/dist/bot/session-status.js +1 -5
  63. package/dist/bot/streaming.js +0 -53
  64. package/dist/browser-profile.js +0 -51
  65. package/dist/browser-supervisor.js +0 -75
  66. package/dist/catalog/cli-tools.js +0 -53
  67. package/dist/catalog/index.js +0 -18
  68. package/dist/catalog/local-models.js +0 -18
  69. package/dist/catalog/mcp-servers.js +0 -35
  70. package/dist/catalog/skill-repos.js +0 -32
  71. package/dist/channels/base.js +0 -19
  72. package/dist/channels/dingtalk/bot.js +0 -3
  73. package/dist/channels/dingtalk/channel.js +0 -33
  74. package/dist/channels/discord/bot.js +0 -7
  75. package/dist/channels/discord/channel.js +0 -22
  76. package/dist/channels/feishu/bot.js +0 -71
  77. package/dist/channels/feishu/channel.js +0 -78
  78. package/dist/channels/feishu/markdown.js +0 -5
  79. package/dist/channels/feishu/render.js +0 -45
  80. package/dist/channels/health.js +0 -46
  81. package/dist/channels/slack/bot.js +0 -7
  82. package/dist/channels/slack/channel.js +1 -27
  83. package/dist/channels/states.js +0 -3
  84. package/dist/channels/telegram/bot.js +0 -60
  85. package/dist/channels/telegram/channel.js +2 -99
  86. package/dist/channels/telegram/directory.js +0 -3
  87. package/dist/channels/telegram/live-preview.js +0 -8
  88. package/dist/channels/telegram/render.js +0 -30
  89. package/dist/channels/wecom/bot.js +0 -3
  90. package/dist/channels/wecom/channel.js +0 -58
  91. package/dist/channels/weixin/api.js +0 -9
  92. package/dist/channels/weixin/bot.js +0 -88
  93. package/dist/channels/weixin/channel.js +0 -6
  94. package/dist/cli/autostart.js +1 -44
  95. package/dist/cli/channel-supervisor.js +1 -59
  96. package/dist/cli/channels.js +0 -6
  97. package/dist/cli/main.js +1 -145
  98. package/dist/cli/onboarding.js +0 -6
  99. package/dist/cli/run.js +0 -11
  100. package/dist/cli/setup-wizard.js +0 -3
  101. package/dist/core/config/runtime-config.js +0 -38
  102. package/dist/core/config/user-config.js +0 -63
  103. package/dist/core/config/validation.js +0 -9
  104. package/dist/core/constants.js +0 -268
  105. package/dist/core/git.js +0 -31
  106. package/dist/core/legacy-compat.js +1 -28
  107. package/dist/core/logging.js +0 -3
  108. package/dist/core/platform.js +0 -28
  109. package/dist/core/process-control.js +0 -8
  110. package/dist/core/secrets/index.js +0 -17
  111. package/dist/core/secrets/inline-seal.js +0 -7
  112. package/dist/core/secrets/ref.js +0 -19
  113. package/dist/core/secrets/resolver.js +0 -8
  114. package/dist/core/secrets/store.js +1 -12
  115. package/dist/core/utils.js +1 -37
  116. package/dist/core/version.js +0 -3
  117. package/dist/dashboard/platform.js +1 -35
  118. package/dist/dashboard/routes/agents.js +2 -71
  119. package/dist/dashboard/routes/cli.js +0 -27
  120. package/dist/dashboard/routes/config.js +0 -54
  121. package/dist/dashboard/routes/extensions.js +0 -132
  122. package/dist/dashboard/routes/local-models.js +0 -68
  123. package/dist/dashboard/routes/models.js +0 -46
  124. package/dist/dashboard/routes/sessions.js +0 -100
  125. package/dist/dashboard/runtime.js +1 -58
  126. package/dist/dashboard/server.js +0 -49
  127. package/dist/dashboard/session-control.js +2 -69
  128. package/dist/model/catalog.js +1 -17
  129. package/dist/model/index.js +0 -15
  130. package/dist/model/injector.js +1 -181
  131. package/dist/model/provider-models.js +1 -48
  132. package/dist/model/responses-bridge.js +3 -45
  133. package/dist/model/store.js +0 -17
  134. package/dist/model/types.js +0 -12
  135. package/dist/model/validation.js +1 -18
  136. package/dist/pikichannel/adapter-pikiloom.js +0 -31
  137. package/dist/pikichannel/code.js +0 -12
  138. package/dist/pikichannel/codec.js +0 -14
  139. package/dist/pikichannel/host.js +4 -33
  140. package/dist/pikichannel/protocol.js +0 -52
  141. package/dist/pikichannel/rendezvous-broker.js +5 -26
  142. package/dist/pikichannel/rendezvous-host.js +3 -16
  143. package/dist/pikichannel/server.js +6 -55
  144. package/dist/pikichannel/transport.js +0 -25
  145. package/dist/pikichannel/transports/webrtc-host.js +0 -18
  146. package/dist/pikichannel/transports/webrtc-shared.js +4 -34
  147. package/dist/pikichannel/transports/websocket-host.js +1 -14
  148. package/dist/pikichannel/turn.js +2 -69
  149. package/dist/pikichannel/web/sdk.js +14 -71
  150. package/package.json +1 -1
  151. package/dashboard/dist/assets/ConnectionModal-Dt05H2f9.js +0 -1
  152. package/dashboard/dist/assets/SystemTab-CN_QrRKt.js +0 -1
  153. package/dashboard/dist/assets/index-CP3LYJR2.js +0 -23
  154. package/dashboard/dist/assets/index-C_neLnaO.js +0 -3
  155. package/dashboard/dist/assets/index-CyCspgtc.css +0 -1
package/dist/bot/bot.js CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Shared Bot base class: chat state, session lifecycle, task queue, streaming bridge.
3
- *
4
- * Channel-agnostic. Subclassed per IM channel (see channels/telegram/bot.ts, etc.).
5
- */
6
1
  import os from 'node:os';
7
2
  import path from 'node:path';
8
3
  import { execSync, spawn } from 'node:child_process';
@@ -29,9 +24,6 @@ import { BOT_TIMEOUTS } from '../core/constants.js';
29
24
  export const DEFAULT_RUN_TIMEOUT_S = BOT_TIMEOUTS.defaultRunTimeoutS;
30
25
  const MACOS_USER_ACTIVITY_PULSE_INTERVAL_MS = BOT_TIMEOUTS.macosUserActivityPulseInterval;
31
26
  const MACOS_USER_ACTIVITY_PULSE_TIMEOUT_S = BOT_TIMEOUTS.macosUserActivityPulseTimeoutS;
32
- // ---------------------------------------------------------------------------
33
- // Helpers
34
- // ---------------------------------------------------------------------------
35
27
  export function normalizeAgent(raw) {
36
28
  const v = raw.trim().toLowerCase();
37
29
  if (!hasDriver(v))
@@ -46,9 +38,6 @@ export function thinkLabel(agent) {
46
38
  return 'Thinking';
47
39
  }
48
40
  }
49
- // ---------------------------------------------------------------------------
50
- // Prompt assembly helpers
51
- // ---------------------------------------------------------------------------
52
41
  function appendExtraPrompt(base, extra) {
53
42
  const lhs = String(base || '').trim();
54
43
  const rhs = String(extra || '').trim();
@@ -58,10 +47,6 @@ function appendExtraPrompt(base, extra) {
58
47
  return lhs;
59
48
  return `${lhs}\n\n${rhs}`;
60
49
  }
61
- // NOTE: the `[Artifact Return]` header is a load-bearing marker — both
62
- // `stripInjectedPrompts` (bot/streaming.ts) and Gemini's
63
- // `GEMINI_SYSTEM_BLOCK_SENTINELS` key on it to strip this injected block from
64
- // displayed user messages. Keep the token in sync if you ever change it.
65
50
  function buildMcpDeliveryPrompt() {
66
51
  return [
67
52
  '[Artifact Return]',
@@ -69,13 +54,6 @@ function buildMcpDeliveryPrompt() {
69
54
  ].join('\n');
70
55
  }
71
56
  function buildClaudeAskUserPrompt() {
72
- // Claude is heavily trained on its built-in `AskUserQuestion` tool, so just
73
- // registering `mcp__pikiloom__im_ask_user` alongside it isn't enough — the
74
- // model still picks the native one, the CLI rejects it in -p mode with
75
- // `is_error: true content: "Answer questions?"`, and the turn dies without
76
- // ever firing the human-loop. This directive redirects calls *if* the model
77
- // chooses to ask. It deliberately does not nudge the default ask-less
78
- // behaviour — only the routing.
79
57
  return [
80
58
  '[Asking the user]',
81
59
  'The built-in `AskUserQuestion` tool is disabled here and will fail. If you would otherwise call it, call `mcp__pikiloom__im_ask_user` instead — same intent (a question plus optional choices), it blocks until the user replies via the IM/dashboard channel. Default behaviour is unchanged: infer obvious decisions yourself and only ask when you genuinely cannot proceed.',
@@ -99,11 +77,6 @@ function buildBrowserAutomationPrompt(browserEnabled) {
99
77
  ].join('\n');
100
78
  }
101
79
  function buildWorkflowOptInPrompt() {
102
- // Standing opt-in injected only when the user explicitly enabled workflow
103
- // orchestration for this agent. The Workflow tool is left enabled (not
104
- // disallowed) in this mode; this directive tells the model it may reach for
105
- // it proactively on genuinely large work, while preserving the default
106
- // single-agent behaviour for everything else (no baseline regression).
107
80
  return [
108
81
  '[Multi-agent Workflow]',
109
82
  'Workflow orchestration is enabled for this session. For substantial multi-step work — broad research, large refactors or audits, fan-out reviews across many files — you may proactively author and run a Workflow to decompose and parallelise it.',
@@ -136,7 +109,6 @@ function normalizeFromClaudeNative(goal) {
136
109
  return {
137
110
  source: 'claude',
138
111
  objective: goal.condition,
139
- // Native /goal exposes no pause/budget — it's either active or absent.
140
112
  status: 'active',
141
113
  tokenBudget: null,
142
114
  tokensUsed: 0,
@@ -144,17 +116,12 @@ function normalizeFromClaudeNative(goal) {
144
116
  continuationCount: null,
145
117
  };
146
118
  }
147
- // ---------------------------------------------------------------------------
148
- // Bot
149
- // ---------------------------------------------------------------------------
150
119
  export class Bot {
151
120
  workdir;
152
121
  defaultAgent;
153
122
  runTimeout;
154
123
  allowedChatIds;
155
- // Per-agent config — keyed by agent id
156
124
  agentConfigs = {};
157
- // Convenience accessors (backward-compat)
158
125
  get codexModel() { return this.agentConfigs.codex?.model || ''; }
159
126
  set codexModel(v) { this.agentConfigs.codex.model = v; }
160
127
  get codexReasoningEffort() { return this.agentConfigs.codex?.reasoningEffort || 'xhigh'; }
@@ -176,21 +143,10 @@ export class Bot {
176
143
  startedAt = Date.now();
177
144
  connected = false;
178
145
  stats = { totalTurns: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCachedTokens: 0 };
179
- /* ── Dashboard stream state (polling-friendly snapshots) ── */
180
146
  streamSnapshots = new Map();
181
147
  snapshotCleanupTimers = new Map();
182
- /** Maps promoted session keys (old → new) so poll endpoints can resolve pending IDs. */
183
148
  promotedSessionKeys = new Map();
184
- /** Reverse map (new → old[]) so pushSnapshotToSSE can broadcast on promoted-from aliases. */
185
149
  promotedFromAliases = new Map();
186
- /**
187
- * Walk the promotion chain so callers passing a stale (pending or
188
- * pre-rotation) key always resolve to the current canonical key for the same
189
- * logical session. Multi-hop chains (pending → id_a → id_b after Claude
190
- * `--resume` rotates twice) are followed end-to-end. Used by every
191
- * sessionStates / streamSnapshots lookup so the rest of the codebase never
192
- * has to special-case promotion.
193
- */
194
150
  resolveSessionKey(sessionKey) {
195
151
  let key = sessionKey;
196
152
  const seen = new Set();
@@ -203,13 +159,6 @@ export class Bot {
203
159
  }
204
160
  return key;
205
161
  }
206
- /**
207
- * Drop all promotion bookkeeping that pointed at a now-retired canonical key.
208
- * `promotedFromAliases.get(key)` is exactly the set of stale keys whose forward
209
- * `promotedSessionKeys` entries resolve to `key`, so clearing them here keeps
210
- * that map from growing for the whole process lifetime (otherwise one entry
211
- * leaks per new session + per Claude `--resume` rotation, never reclaimed).
212
- */
213
162
  forgetPromotion(canonicalKey) {
214
163
  const aliases = this.promotedFromAliases.get(canonicalKey);
215
164
  if (aliases)
@@ -217,32 +166,18 @@ export class Bot {
217
166
  this.promotedSessionKeys.delete(alias);
218
167
  this.promotedFromAliases.delete(canonicalKey);
219
168
  }
220
- /** Get the current streaming snapshot for a session (used by polling endpoint).
221
- * Follows the promotion chain so a pending or pre-rotation key still resolves. */
222
169
  getStreamSnapshot(sessionKey) {
223
170
  const snap = this.streamSnapshots.get(this.resolveSessionKey(sessionKey));
224
171
  return snap ? this.enrichSnapshot(snap) : null;
225
172
  }
226
- /**
227
- * Attach per-queued-task prompts and refresh interaction `currentIndex` from
228
- * live prompt state — both are derived data the cached snapshot can't carry
229
- * on its own (queued prompts come from RunningTask records; currentIndex
230
- * advances asynchronously after select/skip/text without re-emitting the
231
- * interaction event).
232
- */
233
173
  enrichSnapshot(snap) {
234
174
  let next = snap;
235
- // Attach the running turn's prompt so a watching terminal can render the
236
- // user message for a follow-up it didn't originate (no local optimistic
237
- // bubble). The RunningTask record is the source of truth while it's live.
238
175
  const runningPrompt = next.taskId ? this.activeTasks.get(next.taskId)?.prompt : '';
239
176
  if (runningPrompt)
240
177
  next = { ...next, question: collapseSkillPrompt(runningPrompt) ?? runningPrompt };
241
178
  if (next.queuedTaskIds?.length) {
242
179
  const queuedTasks = next.queuedTaskIds.map(taskId => {
243
180
  const raw = this.activeTasks.get(taskId)?.prompt || '';
244
- // Show `/skillname` instead of the long expansion we synthesized for the
245
- // agent — matches what the user actually typed in the queued row.
246
181
  return { taskId, prompt: collapseSkillPrompt(raw) ?? raw };
247
182
  });
248
183
  next = { ...next, queuedTasks };
@@ -258,11 +193,9 @@ export class Bot {
258
193
  }
259
194
  return next;
260
195
  }
261
- /* ── Dashboard SSE push (injected by dashboard layer to avoid circular import) ── */
262
196
  _onStreamSnapshot = null;
263
197
  streamPushTimers = new Map();
264
198
  streamPushPending = new Map();
265
- /** Called by the dashboard layer to subscribe to stream snapshot changes. */
266
199
  onStreamSnapshot(cb) {
267
200
  this._onStreamSnapshot = cb;
268
201
  }
@@ -274,8 +207,6 @@ export class Bot {
274
207
  const emitAll = () => {
275
208
  const enriched = snap ? this.enrichSnapshot(snap) : null;
276
209
  cb(sessionKey, enriched);
277
- // Also broadcast on promoted-from aliases so clients still listening
278
- // on the old (pending) key receive updates after session promotion.
279
210
  const aliases = this.promotedFromAliases.get(sessionKey);
280
211
  if (aliases)
281
212
  for (const alias of aliases)
@@ -291,7 +222,6 @@ export class Bot {
291
222
  emitAll();
292
223
  }
293
224
  else {
294
- // Coalesce: if a timer is pending, just mark dirty
295
225
  this.streamPushPending.set(sessionKey, true);
296
226
  if (this.streamPushTimers.has(sessionKey))
297
227
  return;
@@ -304,9 +234,7 @@ export class Bot {
304
234
  }, 80));
305
235
  }
306
236
  }
307
- /** Emit a streaming event — updates the polling snapshot. */
308
237
  emitStream(sessionKey, event) {
309
- // Clear any pending cleanup timer
310
238
  const pending = this.snapshotCleanupTimers.get(sessionKey);
311
239
  if (pending) {
312
240
  clearTimeout(pending);
@@ -317,7 +245,6 @@ export class Bot {
317
245
  case 'queued': {
318
246
  const existing = this.streamSnapshots.get(sessionKey);
319
247
  if (existing && (existing.phase === 'streaming' || existing.phase === 'done')) {
320
- // Don't overwrite active stream — append to the queued list (deduped).
321
248
  const list = existing.queuedTaskIds ? [...existing.queuedTaskIds] : [];
322
249
  if (existing.taskId !== event.taskId && !list.includes(event.taskId))
323
250
  list.push(event.taskId);
@@ -325,7 +252,6 @@ export class Bot {
325
252
  existing.updatedAt = now;
326
253
  }
327
254
  else if (existing && existing.phase === 'queued') {
328
- // Already in queued phase with no active task — append additional queued IDs.
329
255
  const list = existing.queuedTaskIds ? [...existing.queuedTaskIds] : [];
330
256
  if (existing.taskId !== event.taskId && !list.includes(event.taskId))
331
257
  list.push(event.taskId);
@@ -338,8 +264,6 @@ export class Bot {
338
264
  break;
339
265
  }
340
266
  case 'start': {
341
- // Preserve any tasks still queued behind the new active one. Drop the
342
- // task that's now starting from that list since it has graduated.
343
267
  const prev = this.streamSnapshots.get(sessionKey);
344
268
  const remainingQueued = prev?.queuedTaskIds?.filter(id => id !== event.taskId);
345
269
  this.streamSnapshots.set(sessionKey, {
@@ -392,9 +316,6 @@ export class Bot {
392
316
  queuedTaskIds: prev?.queuedTaskIds,
393
317
  updatedAt: now,
394
318
  });
395
- // Auto-clean 'done' snapshot after 30s so stale state doesn't linger.
396
- // Extended from 10s to give clients time to pick up the final state
397
- // after session promotion or WS reconnects.
398
319
  this.snapshotCleanupTimers.set(sessionKey, setTimeout(() => {
399
320
  this.streamSnapshots.delete(sessionKey);
400
321
  this.snapshotCleanupTimers.delete(sessionKey);
@@ -407,14 +328,11 @@ export class Bot {
407
328
  if (!snap)
408
329
  break;
409
330
  if (snap.queuedTaskIds?.includes(event.taskId)) {
410
- // Cancelled one of the queued-behind tasks — keep the running/done
411
- // snapshot, just remove this entry from the list.
412
331
  const next = snap.queuedTaskIds.filter(id => id !== event.taskId);
413
332
  snap.queuedTaskIds = next.length ? next : undefined;
414
333
  snap.updatedAt = now;
415
334
  }
416
335
  else {
417
- // Cancelled the currently displayed task — drop the whole snapshot.
418
336
  this.streamSnapshots.delete(sessionKey);
419
337
  this.forgetPromotion(sessionKey);
420
338
  }
@@ -441,24 +359,11 @@ export class Bot {
441
359
  break;
442
360
  }
443
361
  }
444
- // Push to dashboard SSE — throttle text events, push everything else immediately
445
362
  try {
446
363
  this.pushSnapshotToSSE(sessionKey, event.type !== 'text');
447
364
  }
448
- catch { /* dashboard not loaded yet — ignore */ }
449
- }
450
- /**
451
- * Stream-lifecycle helpers. The dashboard mirrors a running turn by reading
452
- * `streamSnapshots`, which is built exclusively from `emitStream` calls.
453
- * IM channels run `runStream` directly (not via `submitSessionTask`), so
454
- * without these calls the dashboard never sees IM-initiated turns. Routing
455
- * every IM handler through these helpers (and refactoring submitSessionTask
456
- * to use them) keeps the two surfaces consistent: each side can observe
457
- * whatever the other side started.
458
- *
459
- * Each helper resolves the live `task.sessionKey` so the event lands on the
460
- * current snapshot after a pending→native session id promotion.
461
- */
365
+ catch { }
366
+ }
462
367
  liveSessionKey(taskId, fallback) {
463
368
  return this.activeTasks.get(taskId)?.sessionKey || fallback;
464
369
  }
@@ -495,26 +400,12 @@ export class Bot {
495
400
  emitStreamCancelled(taskId, fallbackKey) {
496
401
  this.emitStream(this.liveSessionKey(taskId, fallbackKey), { type: 'cancelled', taskId });
497
402
  }
498
- /**
499
- * Wrap a per-turn `im_send_file` callback so every artifact delivery is
500
- * recorded to the session's durable manifest (agent/artifacts.ts) and
501
- * mirrored live into the running turn's snapshot — on top of whatever
502
- * terminal-specific push the caller supplied (`inner`, e.g. an IM chat
503
- * upload). The returned callback is always safe to register, so the tool is
504
- * available for dashboard / headless turns that have no `inner` (the
505
- * dashboard serves the recorded copy over HTTP). Recording is best-effort and
506
- * never propagates an error back into the delivery result.
507
- */
508
403
  buildArtifactSendFile(agent, sessionKey, cs, inner) {
509
404
  return async (filePath, sendOpts) => {
510
- // Terminal-specific push first (IM chat). Dashboard / headless has no inner.
511
405
  const result = inner ? await inner(filePath, sendOpts) : { ok: true };
512
406
  if (!result.ok)
513
407
  return result;
514
408
  try {
515
- // Resolve the freshest session id at delivery time — send_file fires
516
- // late in a turn, after a pending→native promotion has normally already
517
- // happened, so the manifest lands under the canonical id.
518
409
  let sid = '';
519
410
  if (sessionKey) {
520
411
  const rt = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
@@ -563,7 +454,6 @@ export class Bot {
563
454
  ensureGitignore(this.workdir);
564
455
  initializeProjectSkills(this.workdir);
565
456
  const config = getActiveUserConfig();
566
- // Initialize per-agent configs
567
457
  this.agentConfigs = {
568
458
  codex: {
569
459
  model: resolveAgentModel(config, 'codex'),
@@ -575,12 +465,7 @@ export class Bot {
575
465
  model: resolveAgentModel(config, 'claude'),
576
466
  reasoningEffort: resolveAgentEffort(config, 'claude') || 'high',
577
467
  permissionMode: (process.env.CLAUDE_PERMISSION_MODE || 'bypassPermissions').trim(),
578
- // Workflow orchestration is a per-session/per-turn choice (composer
579
- // toggle / IM /mode), never a persisted default — always boot off.
580
468
  workflowEnabled: false,
581
- // Access mode (TUI subscription vs `claude -p` Agent SDK credits) IS a
582
- // persisted preference — hydrate it from config so the boot value
583
- // matches the dashboard toggle / env default.
584
469
  accessMode: resolveClaudeAccessMode(config),
585
470
  extraArgs: shellSplit(process.env.CLAUDE_EXTRA_ARGS || ''),
586
471
  },
@@ -590,12 +475,6 @@ export class Bot {
590
475
  sandbox: envBool('GEMINI_SANDBOX', false),
591
476
  extraArgs: shellSplit(process.env.GEMINI_EXTRA_ARGS || ''),
592
477
  },
593
- // Hermes was missing from this map for a long time. Without an entry,
594
- // `modelForAgent('hermes')` returned '' and `setModelForAgent('hermes',
595
- // ...)` silently no-op'd because `if (config)` short-circuited — so any
596
- // /models switch in IM looked successful in the log but never reached
597
- // the hermes driver. Adding the entry lets the same machinery the other
598
- // three agents already rely on apply to hermes too.
599
478
  hermes: {
600
479
  model: resolveAgentModel(config, 'hermes'),
601
480
  reasoningEffort: resolveAgentEffort(config, 'hermes') || 'medium',
@@ -628,7 +507,6 @@ export class Bot {
628
507
  }
629
508
  return s;
630
509
  }
631
- /** Effective workdir for a chat — per-chat override or global fallback. */
632
510
  chatWorkdir(chatId) {
633
511
  return this.chats.get(chatId)?.workdir || this.workdir;
634
512
  }
@@ -666,17 +544,10 @@ export class Bot {
666
544
  upsertSessionRuntime(session) {
667
545
  const workdir = path.resolve(session.workdir || this.workdir);
668
546
  const requestedKey = this.sessionKey(session.agent, session.sessionId);
669
- // Follow the promotion chain. Without this, an insertion that races a
670
- // pending→native promotion would `new` a phantom runtime under the stale
671
- // pending key and the queued message would land in a session the dashboard
672
- // can never reach again.
673
547
  const resolvedKey = this.resolveSessionKey(requestedKey);
674
548
  const existing = this.sessionStates.get(resolvedKey);
675
549
  if (existing) {
676
550
  existing.workdir = workdir;
677
- // Do NOT overwrite agent/sessionId/key here — the existing record IS the
678
- // canonical identity post-promotion. Letting upserts re-stamp the old
679
- // pending id back over the native id would unwind the promotion.
680
551
  if (session.workspacePath !== undefined)
681
552
  existing.workspacePath = session.workspacePath ?? null;
682
553
  if (session.threadId !== undefined)
@@ -687,7 +558,6 @@ export class Bot {
687
558
  existing.modelId = session.modelId ?? null;
688
559
  if (session.thinkingEffort !== undefined)
689
560
  existing.thinkingEffort = session.thinkingEffort ?? null;
690
- // handoverFrom is one-shot: only set if not already set (the first staging wins).
691
561
  if (session.handoverFrom !== undefined && !existing.handoverFrom) {
692
562
  existing.handoverFrom = session.handoverFrom;
693
563
  }
@@ -766,9 +636,6 @@ export class Bot {
766
636
  this.applySessionSelection(cs, null);
767
637
  return;
768
638
  }
769
- // Adopting an existing session is an explicit user pick — drop any
770
- // queued handover from a prior agent toggle so we don't accidentally
771
- // prepend the wrong context to the resumed session's next turn.
772
639
  cs.pendingHandoverFrom = null;
773
640
  this.applySessionSelection(cs, runtime);
774
641
  }
@@ -839,11 +706,6 @@ export class Bot {
839
706
  this.sessionChains.set(nextKey, mergedChain);
840
707
  }
841
708
  this.moveSessionStreamSnapshot(previousKey, nextKey);
842
- // Track promotion so poll endpoints + insertions can resolve pending →
843
- // native. When the chain hops more than once (Claude `--resume` rotating
844
- // session ids back-to-back), pull ancestor aliases forward AND re-point
845
- // them at the latest key so a single lookup is O(1) and every WS listener
846
- // that subscribed to any earlier key still receives updates.
847
709
  this.promotedSessionKeys.set(previousKey, nextKey);
848
710
  const aliases = new Set(this.promotedFromAliases.get(nextKey) || []);
849
711
  aliases.add(previousKey);
@@ -856,12 +718,9 @@ export class Bot {
856
718
  this.promotedFromAliases.delete(previousKey);
857
719
  }
858
720
  this.promotedFromAliases.set(nextKey, [...aliases]);
859
- // Update the promoted snapshot's sessionId to reflect the native ID
860
721
  const promotedSnap = this.streamSnapshots.get(nextKey);
861
722
  if (promotedSnap)
862
723
  promotedSnap.sessionId = resolvedSessionId;
863
- // Notify dashboard clients still tracking the old (pending) key via SSE
864
- // so they can detect the promotion and navigate to the correct session
865
724
  if (this._onStreamSnapshot && promotedSnap) {
866
725
  this._onStreamSnapshot(previousKey, this.enrichSnapshot(promotedSnap));
867
726
  }
@@ -917,10 +776,6 @@ export class Bot {
917
776
  const selected = this.getSelectedSession(cs);
918
777
  if (selected)
919
778
  return selected;
920
- // Auto-resume an existing same-thread session of this agent (back-and-forth
921
- // toggling). The handover queued on `cs.pendingHandoverFrom` is intentionally
922
- // dropped here — the resumed session already has its own history; replaying
923
- // an external handover on top would just be duplicate context.
924
779
  const resumed = this.findThreadSessionRuntime(chatId, cs.activeThreadId, cs.agent);
925
780
  if (resumed) {
926
781
  cs.pendingHandoverFrom = null;
@@ -1083,19 +938,12 @@ export class Bot {
1083
938
  }
1084
939
  return { task, interrupted: false, cancelled: false };
1085
940
  }
1086
- /**
1087
- * Mark all queued tasks ahead of `targetTaskId` (in this session) so their
1088
- * chain wrappers re-enqueue and yield to the steered task. Repeated steers
1089
- * reset prior defer flags so only the latest target's predecessors defer.
1090
- */
1091
941
  markQueueDeferralsForSteer(targetTaskId) {
1092
942
  const target = this.activeTasks.get(targetTaskId);
1093
943
  if (!target)
1094
944
  return;
1095
945
  const snapshot = this.streamSnapshots.get(target.sessionKey);
1096
946
  const queuedIds = snapshot?.queuedTaskIds || [];
1097
- // Reset any previous defer flags for this session's queued tasks first so
1098
- // a new steer call doesn't stack on top of an earlier (now-stale) decision.
1099
947
  for (const id of queuedIds) {
1100
948
  const t = this.activeTasks.get(id);
1101
949
  if (t)
@@ -1108,11 +956,6 @@ export class Bot {
1108
956
  t.deferForSteer = true;
1109
957
  }
1110
958
  }
1111
- /**
1112
- * Steer hands off to the queued task's own placeholder card. Interrupt the
1113
- * active task so the queued task can run next and the current preview can be
1114
- * frozen in place instead of being rewritten as an error.
1115
- */
1116
959
  async steerTaskByActionId(actionId) {
1117
960
  const taskId = this.taskKeysByActionId.get(String(actionId));
1118
961
  if (!taskId)
@@ -1124,10 +967,6 @@ export class Bot {
1124
967
  const interrupted = this.interruptRunningTask(task.sessionKey, { freezePreview: true });
1125
968
  return { task, interrupted, steered: false };
1126
969
  }
1127
- /**
1128
- * Interrupt only the currently running task for a session, leaving queued tasks intact.
1129
- * Used by the "Steer" action to let a queued task run next.
1130
- */
1131
970
  interruptRunningTask(sessionKey, opts = {}) {
1132
971
  const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
1133
972
  if (!session)
@@ -1145,10 +984,6 @@ export class Bot {
1145
984
  }
1146
985
  return false;
1147
986
  }
1148
- /**
1149
- * Return the number of tasks ahead of the given task in its session queue.
1150
- * Counts running + queued (non-cancelled) tasks that were started before this one.
1151
- */
1152
987
  getQueuePosition(sessionKey, taskId) {
1153
988
  const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
1154
989
  if (!session)
@@ -1169,20 +1004,11 @@ export class Bot {
1169
1004
  return `${String(chatId)}:${String(sourceMessageId)}`;
1170
1005
  }
1171
1006
  queueSessionTask(session, task, taskId) {
1172
- // Wrap the user task with a defer check. When steerTask() flags this task
1173
- // to yield its chain slot to a steered task, the wrapper re-enqueues the
1174
- // same fn at the tail and returns immediately so the next chain wrapper
1175
- // (the steered task's) fires next. Tasks without a taskId (e.g. file
1176
- // staging) skip the check.
1177
1007
  const runner = async () => {
1178
1008
  if (taskId) {
1179
1009
  const t = this.activeTasks.get(taskId);
1180
1010
  if (t?.deferForSteer && !t.cancelled) {
1181
1011
  t.deferForSteer = false;
1182
- // Re-enqueue at the tail. Don't await — let the current slot finish
1183
- // immediately so the chain advances to the steered task. The new
1184
- // wrapper preserves the original fn so the deferred task still runs
1185
- // (just after the steered one).
1186
1012
  void this.queueSessionTask(session, task, taskId);
1187
1013
  return undefined;
1188
1014
  }
@@ -1278,13 +1104,6 @@ export class Bot {
1278
1104
  this.fireInteractionAnswered(prompt, 'cancelled');
1279
1105
  return prompt;
1280
1106
  }
1281
- /**
1282
- * Unified post-resolution hook for human-loop prompts. Each IM channel
1283
- * overrides `onInteractionAnswered` to (1) collapse the original prompt card
1284
- * to an answered/cancelled state and (2) echo the decision as a new chat
1285
- * message so scrolling back shows what the user picked. Dashboard sessions
1286
- * (chatId='dashboard') and channels that opt out remain silent.
1287
- */
1288
1107
  fireInteractionAnswered(prompt, status) {
1289
1108
  if (prompt.silent)
1290
1109
  return;
@@ -1295,13 +1114,7 @@ export class Bot {
1295
1114
  .then(() => this.onInteractionAnswered(prompt, summary))
1296
1115
  .catch(err => this.warn(`onInteractionAnswered failed: ${err?.message || err}`));
1297
1116
  }
1298
- /**
1299
- * Channel hook fired after a human-loop prompt resolves (answered or
1300
- * cancelled). Default: no-op. Override in channel subclasses to update the
1301
- * original card and post a decision-echo message.
1302
- */
1303
1117
  async onInteractionAnswered(_prompt, _summary) {
1304
- // Default: no-op.
1305
1118
  }
1306
1119
  emitInteractionResolved(taskId, promptId) {
1307
1120
  const task = this.activeTasks.get(taskId);
@@ -1356,14 +1169,6 @@ export class Bot {
1356
1169
  else
1357
1170
  this.humanLoopPromptIdsByChat.delete(chatKey);
1358
1171
  }
1359
- /**
1360
- * Create an interaction handler that bridges agent requests to the human-loop
1361
- * state machine and pushes SSE events to the dashboard.
1362
- *
1363
- * IM channel subclasses override `renderInteractionPrompt()` to render
1364
- * buttons/cards in their native UI. Dashboard clients receive the
1365
- * `interaction` SSE event and respond via REST.
1366
- */
1367
1172
  createInteractionHandler(chatId, taskId) {
1368
1173
  return async (request) => {
1369
1174
  const active = this.beginHumanLoopPrompt({
@@ -1382,19 +1187,9 @@ export class Bot {
1382
1187
  questions: request.questions,
1383
1188
  currentIndex: active.prompt.currentIndex,
1384
1189
  };
1385
- // Resolve sessionKey live at emit time — the task entry tracks promotion
1386
- // (pending → native id), so a key captured at handler-creation time would
1387
- // go stale on the very first turn of a fresh session and the dashboard
1388
- // SSE event would land on an already-moved snapshot.
1389
1190
  const task = this.activeTasks.get(taskId);
1390
1191
  if (task)
1391
1192
  this.emitStream(task.sessionKey, { type: 'interaction', taskId, interaction: interactionSnapshot });
1392
- // Dashboard sessions reply through SSE + REST (no IM render). When an IM
1393
- // bot is also attached, its renderInteractionPrompt override would still
1394
- // fire here with chatId='dashboard' — sending the prompt to the IM API
1395
- // with an invalid receive_id, which surfaces as "Request failed with
1396
- // status code 400" from the axios-based SDK. Skip the render for
1397
- // dashboard chats; the SSE event is the canonical delivery.
1398
1193
  if (chatId !== 'dashboard') {
1399
1194
  try {
1400
1195
  await this.renderInteractionPrompt(active.prompt, chatId);
@@ -1407,31 +1202,15 @@ export class Bot {
1407
1202
  return active.result;
1408
1203
  };
1409
1204
  }
1410
- /**
1411
- * Render an interaction prompt in the IM channel.
1412
- * Override in channel subclasses (Telegram, Feishu, etc.).
1413
- * Dashboard-only sessions (chatId='dashboard') are a no-op by default.
1414
- */
1415
1205
  async renderInteractionPrompt(_prompt, _chatId) {
1416
- // Default: no-op (dashboard-only sessions use SSE events instead)
1417
1206
  }
1418
- // ---- Public interaction API (used by dashboard routes) --------------------
1419
- /** Respond to a pending interaction prompt with a selected option. */
1420
1207
  interactionSelectOption(promptId, optionValue, opts) {
1421
1208
  return this.humanLoopSelectOption(promptId, optionValue, opts);
1422
1209
  }
1423
- /** Submit freeform text to a pending interaction prompt. */
1424
1210
  interactionSubmitText(promptId, text) {
1425
1211
  const prompt = this.humanLoopPrompt(promptId);
1426
1212
  if (!prompt)
1427
1213
  return null;
1428
- // The dashboard modal submits a custom answer via an explicit Submit button,
1429
- // so accept the text whenever the current question PERMITS freeform — not
1430
- // only after an "Other" chip flipped `awaitingFreeform` (the IM-card flow,
1431
- // which `isHumanLoopAwaitingText` gates on). An options question only allows
1432
- // it when `allowFreeform` is set; an option-less question is freeform by
1433
- // definition. (The IM passive-text path keeps the stricter check so a normal
1434
- // chat message isn't silently captured as an answer.)
1435
1214
  const question = currentHumanLoopQuestion(prompt);
1436
1215
  if (!question)
1437
1216
  return null;
@@ -1444,15 +1223,12 @@ export class Bot {
1444
1223
  this.resolveHumanLoopPrompt(prompt.promptId);
1445
1224
  return { prompt, ...result };
1446
1225
  }
1447
- /** Skip the current question in a pending interaction prompt. */
1448
1226
  interactionSkip(promptId) {
1449
1227
  return this.humanLoopSkip(promptId);
1450
1228
  }
1451
- /** Cancel a pending interaction prompt. */
1452
1229
  interactionCancel(promptId, reason = 'Cancelled from dashboard.') {
1453
1230
  return this.humanLoopCancel(promptId, reason);
1454
1231
  }
1455
- /** Get a specific interaction prompt by ID. */
1456
1232
  interactionPrompt(promptId) {
1457
1233
  return this.humanLoopPrompt(promptId);
1458
1234
  }
@@ -1465,7 +1241,6 @@ export class Bot {
1465
1241
  sessionId: opts.sessionId,
1466
1242
  workdir: opts.workdir,
1467
1243
  workspacePath: null,
1468
- // Only override when explicitly provided — undefined skips the overwrite in upsertSessionRuntime
1469
1244
  ...(opts.modelId !== undefined ? { modelId: opts.modelId } : {}),
1470
1245
  ...(opts.thinkingEffort !== undefined ? { thinkingEffort: opts.thinkingEffort } : {}),
1471
1246
  ...(opts.handoverFrom !== undefined ? { handoverFrom: opts.handoverFrom } : {}),
@@ -1493,12 +1268,7 @@ export class Bot {
1493
1268
  this.finishTask(taskId);
1494
1269
  return;
1495
1270
  }
1496
- // Thread the per-send Workflow choice so the live divider folds to `ultra`
1497
- // immediately (the dashboard composer picks ultra per-send without flipping
1498
- // the agent-global flag resolveSessionStreamConfig would otherwise read).
1499
1271
  this.emitStreamStart(taskId, session, { workflowEnabled: opts.workflowEnabled });
1500
- // Wire up IM rendering for non-dashboard chats so /goal-driven tasks stream
1501
- // to the same channel that submitted them, matching handleMessage's UX.
1502
1272
  const presenter = chatId !== 'dashboard'
1503
1273
  ? await this.createImTaskPresenter({
1504
1274
  chatId, taskId, session, agent: session.agent, prompt, attachments,
@@ -1562,28 +1332,9 @@ export class Bot {
1562
1332
  });
1563
1333
  return { ok: true, taskId, sessionKey: session.key, queued: true };
1564
1334
  }
1565
- /**
1566
- * Channel hook — returns a presenter that streams the task's runStream
1567
- * output to the IM chat that submitted it. Default: null (dashboard-only
1568
- * chats and channels that haven't opted in stay silent in IM).
1569
- */
1570
1335
  async createImTaskPresenter(_opts) {
1571
1336
  return null;
1572
1337
  }
1573
- /**
1574
- * Goal continuation: after a turn ends, if a goal is still active for the
1575
- * session, account token + wall-clock usage, then enqueue one more task with
1576
- * the rendered continuation prompt. If the budget was just crossed, enqueue a
1577
- * single wrap-up turn with the budget-limit prompt instead. Goal-continuation
1578
- * tasks that get cancelled or errored auto-pause the goal so the loop does
1579
- * not silently resume on the user's next message.
1580
- *
1581
- * Codex and Claude sessions short-circuit: each runs its own native `/goal`
1582
- * lifecycle (codex's app-server state machine; claude's in-process Stop
1583
- * hook), so pikiloom stays out to avoid a double loop. See setSessionGoal
1584
- * et al — they bridge to codex's `thread/goal/*` RPC and to claude's
1585
- * `/goal <condition>` slash command instead of writing pikiloom's goal.json.
1586
- */
1587
1338
  maybeEnqueueGoalContinuation(session, opts, result) {
1588
1339
  if (session.agent === 'codex' || session.agent === 'claude')
1589
1340
  return;
@@ -1648,16 +1399,6 @@ export class Bot {
1648
1399
  goalContinuation: { kind: 'continuation', goalId: updated.goalId },
1649
1400
  });
1650
1401
  }
1651
- /**
1652
- * Normalized goal view used by IM/dashboard renderers — same shape regardless
1653
- * of whether the source is pikiloom's goal.json (claude / gemini / …) or
1654
- * codex's native SQLite state machine.
1655
- */
1656
- // SessionGoalView is exported below the class.
1657
- /**
1658
- * Read the current goal for a session. For codex this hits codex's native
1659
- * `thread/goal/get`; for other drivers, reads goal.json.
1660
- */
1661
1402
  async getSessionGoal(workdir, agent, sessionId) {
1662
1403
  if (agent === 'codex') {
1663
1404
  if (!sessionId || isPendingSessionId(sessionId))
@@ -1674,12 +1415,6 @@ export class Bot {
1674
1415
  const goal = readGoal(workdir, agent, sessionId);
1675
1416
  return goal ? normalizeFromPikiloom(goal) : null;
1676
1417
  }
1677
- /**
1678
- * Set (or replace) the goal for a session. For codex this routes through
1679
- * codex's native `thread/goal/set` and codex auto-starts a continuation turn
1680
- * internally. For other drivers, pikiloom writes goal.json and enqueues the
1681
- * first continuation turn so the agent starts working immediately.
1682
- */
1683
1418
  async setSessionGoal(workdir, agent, sessionId, opts) {
1684
1419
  if (agent === 'codex') {
1685
1420
  if (!sessionId || isPendingSessionId(sessionId)) {
@@ -1693,7 +1428,6 @@ export class Bot {
1693
1428
  });
1694
1429
  if (!resp.ok)
1695
1430
  throw new Error(resp.error);
1696
- // codex returns a snapshot; if for some reason it's null, re-fetch.
1697
1431
  const goal = resp.goal ?? (await getCodexGoal(sessionId));
1698
1432
  if (!goal)
1699
1433
  throw new Error('codex did not return a goal snapshot');
@@ -1703,12 +1437,6 @@ export class Bot {
1703
1437
  if (!sessionId || isPendingSessionId(sessionId)) {
1704
1438
  throw new Error('claude session must exist before /goal — send a first message to create the transcript');
1705
1439
  }
1706
- // Native /goal owns its own continuation engine (Stop hook). pikiloom
1707
- // just submits the slash command as the next task; claude internally
1708
- // sets up the goal_status attachment, injects its meta directive, and
1709
- // keeps looping until the Haiku completion check returns met. Token
1710
- // budget is accepted in the API for shape parity with codex/portable
1711
- // but ignored — claude native /goal has no budget concept.
1712
1440
  const objective = opts.objective.trim();
1713
1441
  if (!objective)
1714
1442
  throw new Error('objective must be non-empty');
@@ -1721,8 +1449,6 @@ export class Bot {
1721
1449
  modelId: opts.modelId,
1722
1450
  thinkingEffort: opts.thinkingEffort,
1723
1451
  });
1724
- // Return an optimistic snapshot — the actual goal_status attachment is
1725
- // written by claude during the task; readers can poll getSessionGoal.
1726
1452
  return normalizeFromClaudeNative({
1727
1453
  condition: objective,
1728
1454
  status: 'active',
@@ -1760,8 +1486,6 @@ export class Bot {
1760
1486
  return goal ? normalizeFromCodex(goal) : null;
1761
1487
  }
1762
1488
  if (agent === 'claude') {
1763
- // Claude's native /goal exposes no pause/resume — only set and clear.
1764
- // Surface a clear error so the IM layer can render a friendly message.
1765
1489
  throw new Error('Claude native /goal does not support pause/resume — only `/goal clear`. Re-issue `/goal <objective>` to start fresh.');
1766
1490
  }
1767
1491
  const goal = pauseGoal(workdir, agent, sessionId);
@@ -1810,7 +1534,6 @@ export class Bot {
1810
1534
  if (agent === 'claude') {
1811
1535
  if (!sessionId || isPendingSessionId(sessionId))
1812
1536
  return;
1813
- // Read goal-status first to avoid spawning a no-op turn when nothing is set.
1814
1537
  const existing = getClaudeNativeGoal(workdir, sessionId);
1815
1538
  if (!existing)
1816
1539
  return;
@@ -1854,24 +1577,9 @@ export class Bot {
1854
1577
  const interrupted = this.interruptRunningTask(task.sessionKey, { freezePreview: true });
1855
1578
  return { task, interrupted, steered: interrupted || !!task };
1856
1579
  }
1857
- /**
1858
- * Stop only the currently running task for a session. Queued tasks are
1859
- * intentionally left intact and run normally once the chain advances — the
1860
- * stop button means "abort what's running right now", not "throw away the
1861
- * queue". To drop a specific queued entry, use the per-row × button which
1862
- * routes through `cancelTask`.
1863
- */
1864
1580
  stopAllSessionTasks(sessionKey) {
1865
1581
  return this.stopTasksForSession(sessionKey);
1866
1582
  }
1867
- /**
1868
- * Public "start a fresh session" entry point — wired to the "+ New" button
1869
- * and the `/new` command. Only clears the chat's session selection so the
1870
- * next user message lands in a fresh session; the previously selected
1871
- * session keeps running independently (matching dashboard behaviour, where
1872
- * each session is its own card and is never aborted by creating another).
1873
- * Use `cancelTask` / `/stop` to actually interrupt a running task.
1874
- */
1875
1583
  resetConversationForChat(chatId) {
1876
1584
  const cs = this.chat(chatId);
1877
1585
  this.resetChatConversation(cs);
@@ -1881,13 +1589,6 @@ export class Bot {
1881
1589
  this.adoptSession(cs, session);
1882
1590
  return this.getSelectedSession(cs);
1883
1591
  }
1884
- /**
1885
- * Resume an existing session in a chat and restore the agent's persistent
1886
- * model / effort / BYOK Profile binding so the next stream — and the IM
1887
- * picker chips — match the session that was just adopted. This is the
1888
- * shared "click a row from the workspace list" path used by both the
1889
- * interactive selector and the text-command `/sessions <#>` flow.
1890
- */
1891
1592
  resumeSessionForChat(chatId, session) {
1892
1593
  const runtime = this.adoptExistingSessionForChat(chatId, session);
1893
1594
  if (session.model) {
@@ -1905,15 +1606,9 @@ export class Bot {
1905
1606
  const cs = this.chat(chatId);
1906
1607
  if (cs.agent === agent)
1907
1608
  return false;
1908
- // Capture the live session of the *outgoing* agent so the next message to
1909
- // the new agent can replay it as a handover. We capture BEFORE flipping
1910
- // cs.agent so the ref is honest about which agent it points at.
1911
1609
  const prevAgent = cs.agent;
1912
1610
  const prevSessionId = cs.sessionId && !isPendingSessionId(cs.sessionId) ? cs.sessionId : null;
1913
1611
  cs.agent = agent;
1914
- // Pre-existing session of the new agent in this thread — back-and-forth
1915
- // toggling resumes it without handover. The user's intent is "continue what
1916
- // I had", not "translate cross-agent".
1917
1612
  const resumed = this.findThreadSessionRuntime(chatId, cs.activeThreadId, agent);
1918
1613
  if (resumed) {
1919
1614
  cs.pendingHandoverFrom = null;
@@ -1921,11 +1616,6 @@ export class Bot {
1921
1616
  this.log(`agent switched to ${agent} chat=${chatId} resumed=${resumed.sessionId}`);
1922
1617
  return true;
1923
1618
  }
1924
- // No existing session of the new agent → next message will stage a fresh
1925
- // one. Park the outgoing session as the handover source. If the outgoing
1926
- // agent had no live session (e.g. the user is rapidly toggling agents
1927
- // before sending anything), keep any already-pending handover so the
1928
- // original source isn't lost across intermediate switches.
1929
1619
  if (prevSessionId) {
1930
1620
  cs.pendingHandoverFrom = { agent: prevAgent, sessionId: prevSessionId };
1931
1621
  }
@@ -1933,26 +1623,8 @@ export class Bot {
1933
1623
  this.log(`agent switched to ${agent} chat=${chatId} handoverFrom=${describeHandoverRef(cs.pendingHandoverFrom)}`);
1934
1624
  return true;
1935
1625
  }
1936
- /**
1937
- * Switch the active model for a chat. Supports both native (agent CLI's own
1938
- * auth) and BYOK Profile selections:
1939
- * - `profileId === undefined` (default) — set native model only; pre-union
1940
- * callers (text-command channels) keep working unchanged.
1941
- * - `profileId === null` — explicit clear: drop any active Profile, fall
1942
- * back to native model.
1943
- * - `profileId === '<uuid>'` — bind that Profile; `modelId` should match
1944
- * the Profile's modelId so display surfaces stay in sync.
1945
- *
1946
- * The native model field (`agentConfigs[agent].model`) always tracks the
1947
- * effective model id used by the agent CLI — when a Profile is bound, this
1948
- * lets `modelForAgent()` return the right display string without an extra
1949
- * lookup. When unbinding, we leave the field alone so the user's prior
1950
- * native pick is preserved.
1951
- */
1952
1626
  switchModelForChat(chatId, modelId, profileId) {
1953
1627
  const cs = this.chat(chatId);
1954
- // Update activeProfileByAgent first — resolveSessionStreamConfig downstream
1955
- // reads it via getActiveProfile() during spawn.
1956
1628
  if (profileId !== undefined) {
1957
1629
  setActiveProfile(cs.agent, profileId || null);
1958
1630
  }
@@ -1969,25 +1641,11 @@ export class Bot {
1969
1641
  : ' profile=(cleared)';
1970
1642
  this.log(`model switched to ${modelId} for ${cs.agent} chat=${chatId} session=${cs.activeSessionKey || '(none)'}${profileTag}`);
1971
1643
  }
1972
- /**
1973
- * The Profile id currently bound to this agent, if any. Used by the IM
1974
- * picker to flag "current selection" when the user has a Profile bound —
1975
- * since multiple Profiles may share the same modelId, a model-id match
1976
- * alone is ambiguous.
1977
- */
1978
1644
  activeProfileIdForAgent(agent) {
1979
1645
  return getActiveProfileId(agent);
1980
1646
  }
1981
1647
  switchEffortForChat(chatId, effort) {
1982
1648
  const cs = this.chat(chatId);
1983
- // "ultra" is a synthetic top rung in the effort picker, NOT a real --effort
1984
- // value (the claude CLI rejects anything outside low|medium|high|xhigh|max).
1985
- // It bundles "max reasoning depth + permit multi-agent Workflow
1986
- // orchestration" — the same pairing as Claude's own `ultracode` mode. Decode
1987
- // it here, the single apply choke point, so the rest of the pipeline only
1988
- // ever sees a concrete effort value plus the orthogonal workflow flag.
1989
- // Because the rungs are mutually exclusive, picking any concrete level also
1990
- // clears the workflow opt-in (capability-gated — only claude advertises it).
1991
1649
  const ultra = effort === 'ultra';
1992
1650
  const realEffort = ultra ? 'max' : effort;
1993
1651
  this.setEffortForAgent(cs.agent, realEffort);
@@ -2001,12 +1659,6 @@ export class Bot {
2001
1659
  }
2002
1660
  this.log(`effort switched to ${effort} (effort=${realEffort}, workflow=${ultra}) for ${cs.agent} chat=${chatId}`);
2003
1661
  }
2004
- /**
2005
- * Effort value to *display* in the picker. Workflow is orthogonal under the
2006
- * hood, but the UI folds "max depth + workflow on" into the single synthetic
2007
- * `ultra` rung (see {@link switchEffortForChat}), so report it as current when
2008
- * the agent has orchestration enabled. Mirrors the decomposition above.
2009
- */
2010
1662
  effortSelectionForAgent(agent) {
2011
1663
  const effort = this.effortForAgent(agent);
2012
1664
  if (!effort)
@@ -2023,13 +1675,6 @@ export class Bot {
2023
1675
  this.log(`permission mode switched to ${mode} for claude chat=${chatId}`);
2024
1676
  }
2025
1677
  }
2026
- /**
2027
- * Toggle multi-agent Workflow orchestration for the chat's current agent.
2028
- * Unlike permission-mode it does NOT reset the conversation — the tool set is
2029
- * resolved per-invocation, so the change cleanly takes effect on the next
2030
- * turn without invalidating the session transcript. No-op for agents whose
2031
- * driver doesn't advertise the capability.
2032
- */
2033
1678
  switchWorkflowForChat(chatId, enabled) {
2034
1679
  const cs = this.chat(chatId);
2035
1680
  if (!getDriverCapabilities(cs.agent).workflow) {
@@ -2041,12 +1686,6 @@ export class Bot {
2041
1686
  this.log(`workflow ${enabled ? 'enabled' : 'disabled'} for ${cs.agent} chat=${chatId}`);
2042
1687
  }
2043
1688
  modelForAgent(agent) {
2044
- // For agents whose CLIs cannot switch model via flags (Hermes uses ACP
2045
- // session/set_model, which only fires when a BYOK Profile is bound), the
2046
- // active Profile is the only meaningful source of truth — falling back to
2047
- // `agentConfigs[agent].model` would surface a stale value the runtime
2048
- // never actually uses. For agents with native model selectors
2049
- // (Claude/Codex/Gemini), the user-config field is still authoritative.
2050
1689
  if (agent === 'hermes') {
2051
1690
  const bound = getAgentBoundModelId('hermes');
2052
1691
  if (bound)
@@ -2054,11 +1693,6 @@ export class Bot {
2054
1693
  }
2055
1694
  return this.agentConfigs[agent]?.model || '';
2056
1695
  }
2057
- /**
2058
- * Resolve the effective model + thinking effort that a stream for `cs` will run with.
2059
- * Mirrors the fallback chain used inside runStream() so callers (e.g. submitSessionTask
2060
- * emitting a 'start' event) can label the active turn before runStream resolves it.
2061
- */
2062
1696
  resolveSessionStreamConfig(cs, opts) {
2063
1697
  const agentConfig = this.agentConfigs[cs.agent] || {};
2064
1698
  const sessionWorkdir = cs.workdir || this.workdir;
@@ -2074,12 +1708,6 @@ export class Bot {
2074
1708
  || agentConfig.reasoningEffort
2075
1709
  || 'high';
2076
1710
  const effort = cs.agent === 'gemini' ? null : (effortRaw || null);
2077
- // Fold to the synthetic 'ultra' rung for display when Workflow is on (mirrors
2078
- // effortSelectionForAgent / the dashboard's foldUltraEffort), so the live reply
2079
- // badge and IM running footer label the turn 'ultra' instead of a bare 'max'.
2080
- // Prefer the per-turn workflow choice when the caller threads one (dashboard
2081
- // composer sends ultra per-send without flipping the agent-global flag);
2082
- // fall back to the agent-global flag (IM /mode, agent card).
2083
1711
  const workflowOn = opts?.workflowEnabled ?? this.workflowEnabledForAgent(cs.agent);
2084
1712
  const displayEffort = effort && getDriverCapabilities(cs.agent).workflow && workflowOn
2085
1713
  ? 'ultra'
@@ -2102,9 +1730,6 @@ export class Bot {
2102
1730
  }
2103
1731
  fetchModels(agent, workdir) {
2104
1732
  const wd = workdir || this.workdir;
2105
- // Provider-aware: when the agent is bound to a BYOK Profile, the
2106
- // returned model list is the provider's enumerable models. This keeps
2107
- // IM /models consistent with the dashboard agent card.
2108
1733
  return resolveAgentModels(agent, { workdir: wd, currentModel: this.modelForAgent(agent) });
2109
1734
  }
2110
1735
  setDefaultAgent(agent) {
@@ -2143,12 +1768,6 @@ export class Bot {
2143
1768
  config.workflowEnabled = enabled;
2144
1769
  this.log(`workflow for ${agent} changed to ${enabled}`);
2145
1770
  }
2146
- /**
2147
- * Switch Claude's access mode (subscription TUI vs `claude -p` Agent SDK
2148
- * credits). Persisted preference — takes effect on the NEXT spawned turn
2149
- * (in-flight streams keep their own opts); does not reset any conversation
2150
- * since both modes resume the same native session transcript.
2151
- */
2152
1771
  setClaudeAccessMode(mode) {
2153
1772
  const config = this.agentConfigs.claude;
2154
1773
  if (config)
@@ -2157,13 +1776,8 @@ export class Bot {
2157
1776
  }
2158
1777
  persistAgentPreference(agent, kind, value) {
2159
1778
  try {
2160
- // Hermes model writes go to the active BYOK Profile (the runtime's only
2161
- // model-switching surface). Falls through to the legacy `hermesModel`
2162
- // user-config field when no Profile is bound.
2163
1779
  if (kind === 'model' && agent === 'hermes' && setAgentBoundModelId('hermes', value))
2164
1780
  return;
2165
- // Workflow orchestration opt-in is a boolean field, and only claude
2166
- // advertises the capability, so it bypasses the string patch below.
2167
1781
  if (kind === 'workflow') {
2168
1782
  if (agent === 'claude')
2169
1783
  updateUserConfig({ claudeWorkflowEnabled: value === '1' });
@@ -2286,34 +1900,13 @@ export class Bot {
2286
1900
  }
2287
1901
  afterSwitchWorkdir(_oldPath, _newPath) { }
2288
1902
  onManagedConfigChange(_config, _opts = {}) { }
2289
- /**
2290
- * Subclass entry point — connect to the channel and block on its
2291
- * listen loop. Each channel implementation overrides this; calling it
2292
- * on the base class is a programming error.
2293
- */
2294
1903
  run() {
2295
1904
  throw new Error('Bot.run() must be implemented by a channel subclass');
2296
1905
  }
2297
- /**
2298
- * Subclass hook: tear down the channel transport so `run()` can resolve.
2299
- * Subclasses override to disconnect their specific channel — the base
2300
- * implementation only cleans up the bot-level subscriptions that don't
2301
- * belong to any one channel.
2302
- *
2303
- * Used by ChannelSupervisor when a channel must be stopped or replaced
2304
- * in-process (channel removal, credential rotation) without restarting
2305
- * the entire pikiloom runtime.
2306
- */
2307
1906
  requestStop() {
2308
1907
  this.userConfigUnsubscribe?.();
2309
1908
  this.userConfigUnsubscribe = null;
2310
1909
  }
2311
- /**
2312
- * Scan registered workspaces + the active workdir for sessions stuck in
2313
- * 'running' state after a crash/restart and downgrade them to 'incomplete'.
2314
- * Safe to call at any time — only touches records whose owning process is
2315
- * no longer alive (or that have gone stale past the age threshold).
2316
- */
2317
1910
  reconcileStaleRunningSessions() {
2318
1911
  const seen = new Set();
2319
1912
  const candidates = [this.workdir];
@@ -2344,10 +1937,6 @@ export class Bot {
2344
1937
  else if (nextWorkdir !== this.workdir) {
2345
1938
  this.switchWorkdir(nextWorkdir, { persist: false });
2346
1939
  }
2347
- // The configured value is a *preference* (baseline 'codex' when unset);
2348
- // clamp it to an installed agent so a fresh machine whose preferred CLI
2349
- // isn't installed still routes new conversations to one that can actually
2350
- // run, instead of surfacing an uninstalled default.
2351
1940
  const nextDefaultAgent = resolveDefaultAgent(config.defaultAgent || 'codex', listAgents().agents);
2352
1941
  if (opts.initial)
2353
1942
  this.defaultAgent = nextDefaultAgent;
@@ -2368,10 +1957,6 @@ export class Bot {
2368
1957
  else
2369
1958
  this.setEffortForAgent(agent, nextEffort);
2370
1959
  }
2371
- // Access mode (claude only) IS reconciled — unlike workflow, it's a
2372
- // persisted preference, so an external setting.json edit or a dashboard
2373
- // save (which both flow through here via onUserConfigChange) must push
2374
- // the new value onto the running bot so the next turn spawns accordingly.
2375
1960
  if (agent === 'claude') {
2376
1961
  const nextAccessMode = resolveClaudeAccessMode(config);
2377
1962
  if (this.claudeAccessMode !== nextAccessMode) {
@@ -2381,16 +1966,12 @@ export class Bot {
2381
1966
  this.setClaudeAccessMode(nextAccessMode);
2382
1967
  }
2383
1968
  }
2384
- // Workflow is intentionally NOT reconciled from config here: it's an
2385
- // in-memory per-session toggle (composer / IM /mode), so a config-sync
2386
- // tick must not clobber a deliberate in-session choice.
2387
1969
  }
2388
1970
  if (!opts.initial)
2389
1971
  this.onManagedConfigChange(config, opts);
2390
1972
  }
2391
1973
  async runStream(prompt, cs, attachments, onText, systemPrompt, mcpSendFile, abortSignal, onInteraction, onSteerReady, onCodexTurnReady, extras) {
2392
1974
  const agentConfig = this.agentConfigs[cs.agent] || {};
2393
- // Session-level config stored on disk — used as fallback between explicit override and global defaults
2394
1975
  const sessionWorkdirForConfig = 'workdir' in cs && typeof cs.workdir === 'string' && cs.workdir ? cs.workdir : this.workdir;
2395
1976
  const storedConfig = cs.sessionId && !isPendingSessionId(cs.sessionId)
2396
1977
  ? getSessionStoredConfig(sessionWorkdirForConfig, cs.agent, cs.sessionId)
@@ -2407,11 +1988,6 @@ export class Bot {
2407
1988
  this.debug(`[runStream] agent=${cs.agent} session=${cs.sessionId || '(new)'} workdir=${sessionWorkdir} timeout=${this.runTimeout}s attachments=${attachments.length}`);
2408
1989
  this.debug(`[runStream] ${cs.agent} config: model=${resolvedModel} extraArgs=[${extraArgs.join(' ')}]`);
2409
1990
  const isFirstTurnOfSession = !cs.sessionId || isPendingSessionId(cs.sessionId);
2410
- // ── Cross-agent handover ──
2411
- // First turn of a session created by an agent switch: read the prior agent's
2412
- // session, compact it, and prepend the seed to this turn's prompt. After this
2413
- // single injection the new agent owns the canonical session file and `--resume`
2414
- // takes over. See agent/handover.ts.
2415
1991
  const handoverFrom = ('handoverFrom' in cs && cs.handoverFrom) ? cs.handoverFrom : null;
2416
1992
  if (isFirstTurnOfSession && handoverFrom) {
2417
1993
  try {
@@ -2437,32 +2013,10 @@ export class Bot {
2437
2013
  this.warn(`[runStream] handover threw: ${e?.message || e}; proceeding without prior context`);
2438
2014
  }
2439
2015
  }
2440
- // Per-turn workflow opt-in (dashboard composer passes it explicitly);
2441
- // falls back to the agent's in-memory flag (IM /mode) when unspecified.
2442
- // Default off — never read from a persisted config default.
2443
2016
  const workflowEnabled = cs.agent === 'claude' && (extras?.workflowEnabled ?? this.claudeWorkflowEnabled);
2444
- // ── Artifact delivery (terminal-agnostic) ──
2445
- // `im_send_file` is the single "hand a file to the user" verb. Whatever the
2446
- // caller wired as `mcpSendFile` (an IM channel push, or nothing for a
2447
- // dashboard / headless turn) is wrapped so every delivery ALSO records the
2448
- // file into the session's delivered-artifact manifest — the durable SSOT
2449
- // that survives reload + workspace cleanup and is servable over HTTP — and
2450
- // surfaces it live in the running turn's snapshot for any watching
2451
- // dashboard. Always provided, so the tool + delivery prompt light up even
2452
- // when there is no IM channel (the dashboard is the delivery surface then).
2453
2017
  const deliverySessionKey = ('key' in cs && typeof cs.key === 'string') ? cs.key : null;
2454
2018
  const wrappedSendFile = this.buildArtifactSendFile(cs.agent, deliverySessionKey, cs, mcpSendFile);
2455
- const mcpSystemPrompt = appendExtraPrompt(appendExtraPrompt(appendExtraPrompt(
2456
- // Always-on: `wrappedSendFile` is provided for every turn (see above),
2457
- // so artifact delivery is available regardless of terminal.
2458
- buildMcpDeliveryPrompt(), onInteraction && cs.agent === 'claude' ? buildClaudeAskUserPrompt() : ''), buildBrowserAutomationPrompt(browserEnabled)), workflowEnabled ? buildWorkflowOptInPrompt() : '');
2459
- // mcpSystemPrompt carries behaviour directives (use im_ask_user instead of
2460
- // built-in AskUserQuestion, browser automation status, artifact delivery)
2461
- // that must apply on every turn, not just the first — on resume the CLI
2462
- // does not automatically re-inject the previous --append-system-prompt
2463
- // contents, so Claude silently regresses to the built-in tools on turn 2+.
2464
- // The caller-supplied `systemPrompt` (per-task scaffolding) remains
2465
- // first-turn-only since later turns inherit it via the session transcript.
2019
+ const mcpSystemPrompt = appendExtraPrompt(appendExtraPrompt(appendExtraPrompt(buildMcpDeliveryPrompt(), onInteraction && cs.agent === 'claude' ? buildClaudeAskUserPrompt() : ''), buildBrowserAutomationPrompt(browserEnabled)), workflowEnabled ? buildWorkflowOptInPrompt() : '');
2466
2020
  const effectiveSystemPrompt = isFirstTurnOfSession
2467
2021
  ? appendExtraPrompt(systemPrompt, mcpSystemPrompt)
2468
2022
  : (mcpSystemPrompt || undefined);
@@ -2485,45 +2039,31 @@ export class Bot {
2485
2039
  thinkingEffort: resolvedThinkingEffort, onText,
2486
2040
  onSessionId: syncNativeSessionId,
2487
2041
  attachments: attachments.length ? attachments : undefined,
2488
- // codex-specific
2489
2042
  codexModel: cs.agent === 'codex' ? resolvedModel : this.codexModel,
2490
2043
  codexFullAccess: this.codexFullAccess,
2491
2044
  codexDeveloperInstructions: effectiveSystemPrompt || undefined,
2492
2045
  codexExtraArgs: this.codexExtraArgs.length ? this.codexExtraArgs : undefined,
2493
2046
  codexPrevCumulative: cs.codexCumulative,
2494
- // claude-specific
2495
2047
  claudeModel: cs.agent === 'claude' ? resolvedModel : this.claudeModel,
2496
2048
  claudePermissionMode: this.claudePermissionMode,
2497
2049
  claudeWorkflowEnabled: workflowEnabled,
2498
- // Resolved per-stream so a live access-mode switch applies to new turns
2499
- // while in-flight streams keep the mode they spawned with.
2500
2050
  claudeAccessMode: cs.agent === 'claude' ? this.claudeAccessMode : undefined,
2501
2051
  claudeAppendSystemPrompt: effectiveSystemPrompt || undefined,
2502
2052
  claudeExtraArgs: this.claudeExtraArgs.length ? this.claudeExtraArgs : undefined,
2503
- // gemini-specific
2504
2053
  geminiModel: cs.agent === 'gemini' ? resolvedModel : (this.agentConfigs.gemini?.model || ''),
2505
2054
  geminiApprovalMode: this.geminiApprovalMode,
2506
2055
  geminiSandbox: this.geminiSandbox,
2507
2056
  geminiSystemInstruction: effectiveSystemPrompt || undefined,
2508
2057
  geminiExtraArgs: this.geminiExtraArgs.length ? this.geminiExtraArgs : undefined,
2509
- // hermes-specific. Wire the chat's current model so /models switching in
2510
- // IM takes effect even without a BYOK Profile (the BYOK injector in
2511
- // stream.ts overrides this with the ACP-encoded `provider:model` when
2512
- // a Profile is bound).
2513
2058
  hermesModel: cs.agent === 'hermes' && resolvedModel ? resolvedModel : undefined,
2514
- // MCP bridge
2515
2059
  mcpSendFile: wrappedSendFile,
2516
2060
  abortSignal,
2517
2061
  onInteraction,
2518
2062
  onSteerReady,
2519
2063
  onCodexTurnReady,
2520
- // Fork lineage — when set, the driver branches off the parent session.
2521
2064
  forkOf: extras?.forkOf,
2522
2065
  };
2523
2066
  const result = await doStream(opts);
2524
- // 'ultra' is a display-only rung (max reasoning + Workflow). The command ran on
2525
- // concrete max+workflow; relabel the result effort so the reply footer (IM
2526
- // final card, etc.) matches the picker. Display-only — never re-fed into a run.
2527
2067
  if (cs.agent === 'claude' && workflowEnabled && result.thinkingEffort) {
2528
2068
  result.thinkingEffort = 'ultra';
2529
2069
  }
@@ -2556,11 +2096,6 @@ export class Bot {
2556
2096
  return;
2557
2097
  const bin = whichSync('caffeinate');
2558
2098
  if (bin) {
2559
- // `-dis` = prevent display sleep + idle sleep + system sleep. The `-d`
2560
- // (display) flag is intentional: the agent uses macOS `screencapture`
2561
- // for desktop screenshots, which returns a black frame once the
2562
- // display sleeps. Users who would rather let the screen turn off
2563
- // should drop brightness or close the lid against an external display.
2564
2099
  this.keepAliveProc = spawn('caffeinate', ['-dis'], { stdio: 'ignore', detached: true });
2565
2100
  this.keepAliveProc.unref();
2566
2101
  this.log(`keep-alive: caffeinate (PID ${this.keepAliveProc.pid})`);