pikiloop 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -0,0 +1,2499 @@
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
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { execSync, spawn } from 'node:child_process';
9
+ import { getActiveUserConfig, loadWorkspaces, onUserConfigChange, resolveUserWorkdir, setUserWorkdir, updateUserConfig } from '../core/config/user-config.js';
10
+ import { doStream, ensureManagedSession, findManagedThreadSession, getSessionStoredConfig, getUsage, initializeProjectSkills, listAgents, resolveAgentModels, resolveDefaultAgent, listSkills, stageSessionFiles, reconcileOrphanedRunningSessions, getAgentBoundModelId, setAgentBoundModelId, collapseSkillPrompt, readGoal, accountTurn, shouldContinueAfterTurn, renderContinuationPrompt, renderBudgetLimitPrompt, bumpContinuationCount, pauseGoal, resumeGoal, setGoal as setGoalState, clearGoal as clearGoalState, setCodexGoal, getCodexGoal, clearCodexGoal, pauseCodexGoal, resumeCodexGoal, getClaudeNativeGoal, buildClaudeSetGoalPrompt, buildClaudeClearGoalPrompt, isPendingSessionId, } from '../agent/index.js';
11
+ import { compactForHandover, describeHandoverRef } from '../agent/handover.js';
12
+ import { getActiveProfileId, setActiveProfile } from '../model/index.js';
13
+ import { querySessions, querySessionTail, updateSession, } from './session-hub.js';
14
+ import { getDriver, hasDriver, allDriverIds, getDriverCapabilities } from '../agent/driver.js';
15
+ import { resolveGuiIntegrationConfig } from '../agent/mcp/bridge.js';
16
+ import { terminateProcessTree } from '../core/process-control.js';
17
+ import { expandTilde } from '../core/platform.js';
18
+ import { VERSION } from '../core/version.js';
19
+ import { buildHumanLoopResponse, createEmptyHumanLoopAnswer, currentHumanLoopQuestion, isHumanLoopAwaitingText, setHumanLoopOption, setHumanLoopText, skipHumanLoopQuestion, summarizeResolvedHumanLoopAnswers, } from './human-loop.js';
20
+ import { writeScopedLog } from '../core/logging.js';
21
+ import { resolveAgentEffort, resolveAgentModel, resolveClaudeAccessMode, DEFAULT_CLAUDE_ACCESS_MODE, } from '../core/config/runtime-config.js';
22
+ import { envBool, envString, envInt, shellSplit, whichSync, fmtTokens, parseAllowedChatIds, ensureGitignore, } from '../core/utils.js';
23
+ import { getHostBatteryData, getHostCpuUsageData, getHostDisplayName, getHostMemoryUsageData, } from './host.js';
24
+ export { updateSession };
25
+ export { envBool, envString, envInt, shellSplit, whichSync, fmtTokens, fmtUptime, fmtBytes, parseAllowedChatIds, listSubdirs, extractThinkingTail, formatThinkingForDisplay, buildPrompt, ensureGitignore } from '../core/utils.js';
26
+ export { getHostBatteryData, getHostCpuUsageData, getHostDisplayName, getHostMemoryUsageData } from './host.js';
27
+ export { readGitStatus, formatGitStatusLine } from '../core/git.js';
28
+ import { BOT_TIMEOUTS } from '../core/constants.js';
29
+ export const DEFAULT_RUN_TIMEOUT_S = BOT_TIMEOUTS.defaultRunTimeoutS;
30
+ const MACOS_USER_ACTIVITY_PULSE_INTERVAL_MS = BOT_TIMEOUTS.macosUserActivityPulseInterval;
31
+ const MACOS_USER_ACTIVITY_PULSE_TIMEOUT_S = BOT_TIMEOUTS.macosUserActivityPulseTimeoutS;
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+ export function normalizeAgent(raw) {
36
+ const v = raw.trim().toLowerCase();
37
+ if (!hasDriver(v))
38
+ throw new Error(`Invalid agent: ${v}. Use: ${allDriverIds().join(', ')}`);
39
+ return v;
40
+ }
41
+ export function thinkLabel(agent) {
42
+ try {
43
+ return getDriver(agent).thinkLabel;
44
+ }
45
+ catch {
46
+ return 'Thinking';
47
+ }
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Prompt assembly helpers
51
+ // ---------------------------------------------------------------------------
52
+ function appendExtraPrompt(base, extra) {
53
+ const lhs = String(base || '').trim();
54
+ const rhs = String(extra || '').trim();
55
+ if (!lhs)
56
+ return rhs;
57
+ if (!rhs)
58
+ return lhs;
59
+ return `${lhs}\n\n${rhs}`;
60
+ }
61
+ function buildMcpDeliveryPrompt() {
62
+ return [
63
+ '[Artifact Return]',
64
+ 'This is an IM/chat conversation, so pay attention to the IM tools.',
65
+ ].join('\n');
66
+ }
67
+ function buildClaudeAskUserPrompt() {
68
+ // Claude is heavily trained on its built-in `AskUserQuestion` tool, so just
69
+ // registering `mcp__pikiloop__im_ask_user` alongside it isn't enough — the
70
+ // model still picks the native one, the CLI rejects it in -p mode with
71
+ // `is_error: true content: "Answer questions?"`, and the turn dies without
72
+ // ever firing the human-loop. This directive redirects calls *if* the model
73
+ // chooses to ask. It deliberately does not nudge the default ask-less
74
+ // behaviour — only the routing.
75
+ return [
76
+ '[Asking the user]',
77
+ 'The built-in `AskUserQuestion` tool is disabled here and will fail. If you would otherwise call it, call `mcp__pikiloop__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.',
78
+ ].join('\n');
79
+ }
80
+ function buildBrowserAutomationPrompt(browserEnabled) {
81
+ if (!browserEnabled) {
82
+ return [
83
+ '[Browser Automation]',
84
+ 'Managed browser automation is disabled by default for this session.',
85
+ process.platform === 'darwin'
86
+ ? 'On macOS, operate your main browser directly with native commands such as open, osascript, and screencapture when needed.'
87
+ : 'Use native OS or browser commands directly when browser automation is not enabled.',
88
+ ].join('\n');
89
+ }
90
+ return [
91
+ '[Browser Automation]',
92
+ 'A Playwright MCP browser server is already configured to use the local Chrome channel with a persistent profile.',
93
+ 'Do not call browser_install unless a browser tool explicitly reports that Chrome or the browser is missing.',
94
+ 'If you need a new tab, use browser_tabs with action="new".',
95
+ ].join('\n');
96
+ }
97
+ function buildWorkflowOptInPrompt() {
98
+ // Standing opt-in injected only when the user explicitly enabled workflow
99
+ // orchestration for this agent. The Workflow tool is left enabled (not
100
+ // disallowed) in this mode; this directive tells the model it may reach for
101
+ // it proactively on genuinely large work, while preserving the default
102
+ // single-agent behaviour for everything else (no baseline regression).
103
+ return [
104
+ '[Multi-agent Workflow]',
105
+ '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.',
106
+ 'Keep it proportional: do NOT orchestrate trivial or single-file tasks. When a workflow would not add value, just answer directly as a single agent. Workflows can spawn many sub-agents and consume significant tokens, so reserve them for work whose scale genuinely warrants the fan-out.',
107
+ ].join('\n');
108
+ }
109
+ function normalizeFromPikiloop(goal) {
110
+ return {
111
+ source: 'pikiloop',
112
+ objective: goal.objective,
113
+ status: goal.status,
114
+ tokenBudget: goal.tokenBudget,
115
+ tokensUsed: goal.tokensUsed,
116
+ timeUsedSeconds: goal.timeUsedSeconds,
117
+ continuationCount: goal.continuationCount,
118
+ };
119
+ }
120
+ function normalizeFromCodex(goal) {
121
+ return {
122
+ source: 'codex',
123
+ objective: goal.objective,
124
+ status: goal.status === 'budgetLimited' ? 'budget_limited' : goal.status,
125
+ tokenBudget: goal.tokenBudget,
126
+ tokensUsed: goal.tokensUsed,
127
+ timeUsedSeconds: goal.timeUsedSeconds,
128
+ continuationCount: null,
129
+ };
130
+ }
131
+ function normalizeFromClaudeNative(goal) {
132
+ return {
133
+ source: 'claude',
134
+ objective: goal.condition,
135
+ // Native /goal exposes no pause/budget — it's either active or absent.
136
+ status: 'active',
137
+ tokenBudget: null,
138
+ tokensUsed: 0,
139
+ timeUsedSeconds: 0,
140
+ continuationCount: null,
141
+ };
142
+ }
143
+ // ---------------------------------------------------------------------------
144
+ // Bot
145
+ // ---------------------------------------------------------------------------
146
+ export class Bot {
147
+ workdir;
148
+ defaultAgent;
149
+ runTimeout;
150
+ allowedChatIds;
151
+ // Per-agent config — keyed by agent id
152
+ agentConfigs = {};
153
+ // Convenience accessors (backward-compat)
154
+ get codexModel() { return this.agentConfigs.codex?.model || ''; }
155
+ set codexModel(v) { this.agentConfigs.codex.model = v; }
156
+ get codexReasoningEffort() { return this.agentConfigs.codex?.reasoningEffort || 'xhigh'; }
157
+ set codexReasoningEffort(v) { this.agentConfigs.codex.reasoningEffort = v; }
158
+ get codexFullAccess() { return this.agentConfigs.codex?.fullAccess ?? true; }
159
+ get codexExtraArgs() { return this.agentConfigs.codex?.extraArgs || []; }
160
+ get claudeModel() { return this.agentConfigs.claude?.model || ''; }
161
+ set claudeModel(v) { this.agentConfigs.claude.model = v; }
162
+ get claudePermissionMode() { return this.agentConfigs.claude?.permissionMode || 'bypassPermissions'; }
163
+ get claudeExtraArgs() { return this.agentConfigs.claude?.extraArgs || []; }
164
+ get claudeWorkflowEnabled() { return this.agentConfigs.claude?.workflowEnabled ?? false; }
165
+ get claudeAccessMode() { return this.agentConfigs.claude?.accessMode || DEFAULT_CLAUDE_ACCESS_MODE; }
166
+ get geminiApprovalMode() { return this.agentConfigs.gemini?.approvalMode || 'yolo'; }
167
+ get geminiSandbox() { return this.agentConfigs.gemini?.sandbox ?? false; }
168
+ get geminiExtraArgs() { return this.agentConfigs.gemini?.extraArgs || []; }
169
+ chats = new Map();
170
+ sessionStates = new Map();
171
+ activeTasks = new Map();
172
+ startedAt = Date.now();
173
+ connected = false;
174
+ stats = { totalTurns: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCachedTokens: 0 };
175
+ /* ── Dashboard stream state (polling-friendly snapshots) ── */
176
+ streamSnapshots = new Map();
177
+ snapshotCleanupTimers = new Map();
178
+ /** Maps promoted session keys (old → new) so poll endpoints can resolve pending IDs. */
179
+ promotedSessionKeys = new Map();
180
+ /** Reverse map (new → old[]) so pushSnapshotToSSE can broadcast on promoted-from aliases. */
181
+ promotedFromAliases = new Map();
182
+ /**
183
+ * Walk the promotion chain so callers passing a stale (pending or
184
+ * pre-rotation) key always resolve to the current canonical key for the same
185
+ * logical session. Multi-hop chains (pending → id_a → id_b after Claude
186
+ * `--resume` rotates twice) are followed end-to-end. Used by every
187
+ * sessionStates / streamSnapshots lookup so the rest of the codebase never
188
+ * has to special-case promotion.
189
+ */
190
+ resolveSessionKey(sessionKey) {
191
+ let key = sessionKey;
192
+ const seen = new Set();
193
+ while (!seen.has(key)) {
194
+ const next = this.promotedSessionKeys.get(key);
195
+ if (!next || next === key)
196
+ break;
197
+ seen.add(key);
198
+ key = next;
199
+ }
200
+ return key;
201
+ }
202
+ /**
203
+ * Drop all promotion bookkeeping that pointed at a now-retired canonical key.
204
+ * `promotedFromAliases.get(key)` is exactly the set of stale keys whose forward
205
+ * `promotedSessionKeys` entries resolve to `key`, so clearing them here keeps
206
+ * that map from growing for the whole process lifetime (otherwise one entry
207
+ * leaks per new session + per Claude `--resume` rotation, never reclaimed).
208
+ */
209
+ forgetPromotion(canonicalKey) {
210
+ const aliases = this.promotedFromAliases.get(canonicalKey);
211
+ if (aliases)
212
+ for (const alias of aliases)
213
+ this.promotedSessionKeys.delete(alias);
214
+ this.promotedFromAliases.delete(canonicalKey);
215
+ }
216
+ /** Get the current streaming snapshot for a session (used by polling endpoint).
217
+ * Follows the promotion chain so a pending or pre-rotation key still resolves. */
218
+ getStreamSnapshot(sessionKey) {
219
+ const snap = this.streamSnapshots.get(this.resolveSessionKey(sessionKey));
220
+ return snap ? this.enrichSnapshot(snap) : null;
221
+ }
222
+ /**
223
+ * Attach per-queued-task prompts and refresh interaction `currentIndex` from
224
+ * live prompt state — both are derived data the cached snapshot can't carry
225
+ * on its own (queued prompts come from RunningTask records; currentIndex
226
+ * advances asynchronously after select/skip/text without re-emitting the
227
+ * interaction event).
228
+ */
229
+ enrichSnapshot(snap) {
230
+ let next = snap;
231
+ if (next.queuedTaskIds?.length) {
232
+ const queuedTasks = next.queuedTaskIds.map(taskId => {
233
+ const raw = this.activeTasks.get(taskId)?.prompt || '';
234
+ // Show `/skillname` instead of the long expansion we synthesized for the
235
+ // agent — matches what the user actually typed in the queued row.
236
+ return { taskId, prompt: collapseSkillPrompt(raw) ?? raw };
237
+ });
238
+ next = { ...next, queuedTasks };
239
+ }
240
+ if (next.interactions?.length) {
241
+ const refreshed = next.interactions.map(snapshotEntry => {
242
+ const live = this.humanLoopPrompts.get(snapshotEntry.promptId);
243
+ if (!live)
244
+ return snapshotEntry;
245
+ return { ...snapshotEntry, currentIndex: live.currentIndex };
246
+ });
247
+ next = { ...next, interactions: refreshed };
248
+ }
249
+ return next;
250
+ }
251
+ /* ── Dashboard SSE push (injected by dashboard layer to avoid circular import) ── */
252
+ _onStreamSnapshot = null;
253
+ streamPushTimers = new Map();
254
+ streamPushPending = new Map();
255
+ /** Called by the dashboard layer to subscribe to stream snapshot changes. */
256
+ onStreamSnapshot(cb) {
257
+ this._onStreamSnapshot = cb;
258
+ }
259
+ pushSnapshotToSSE(sessionKey, immediate) {
260
+ if (!this._onStreamSnapshot)
261
+ return;
262
+ const snap = this.streamSnapshots.get(sessionKey) ?? null;
263
+ const cb = this._onStreamSnapshot;
264
+ const emitAll = () => {
265
+ const enriched = snap ? this.enrichSnapshot(snap) : null;
266
+ cb(sessionKey, enriched);
267
+ // Also broadcast on promoted-from aliases so clients still listening
268
+ // on the old (pending) key receive updates after session promotion.
269
+ const aliases = this.promotedFromAliases.get(sessionKey);
270
+ if (aliases)
271
+ for (const alias of aliases)
272
+ cb(alias, enriched ? { ...enriched } : null);
273
+ };
274
+ if (immediate) {
275
+ const timer = this.streamPushTimers.get(sessionKey);
276
+ if (timer) {
277
+ clearTimeout(timer);
278
+ this.streamPushTimers.delete(sessionKey);
279
+ }
280
+ this.streamPushPending.delete(sessionKey);
281
+ emitAll();
282
+ }
283
+ else {
284
+ // Coalesce: if a timer is pending, just mark dirty
285
+ this.streamPushPending.set(sessionKey, true);
286
+ if (this.streamPushTimers.has(sessionKey))
287
+ return;
288
+ this.streamPushTimers.set(sessionKey, setTimeout(() => {
289
+ this.streamPushTimers.delete(sessionKey);
290
+ if (this.streamPushPending.get(sessionKey)) {
291
+ this.streamPushPending.delete(sessionKey);
292
+ emitAll();
293
+ }
294
+ }, 80));
295
+ }
296
+ }
297
+ /** Emit a streaming event — updates the polling snapshot. */
298
+ emitStream(sessionKey, event) {
299
+ // Clear any pending cleanup timer
300
+ const pending = this.snapshotCleanupTimers.get(sessionKey);
301
+ if (pending) {
302
+ clearTimeout(pending);
303
+ this.snapshotCleanupTimers.delete(sessionKey);
304
+ }
305
+ const now = Date.now();
306
+ switch (event.type) {
307
+ case 'queued': {
308
+ const existing = this.streamSnapshots.get(sessionKey);
309
+ if (existing && (existing.phase === 'streaming' || existing.phase === 'done')) {
310
+ // Don't overwrite active stream — append to the queued list (deduped).
311
+ const list = existing.queuedTaskIds ? [...existing.queuedTaskIds] : [];
312
+ if (existing.taskId !== event.taskId && !list.includes(event.taskId))
313
+ list.push(event.taskId);
314
+ existing.queuedTaskIds = list.length ? list : undefined;
315
+ existing.updatedAt = now;
316
+ }
317
+ else if (existing && existing.phase === 'queued') {
318
+ // Already in queued phase with no active task — append additional queued IDs.
319
+ const list = existing.queuedTaskIds ? [...existing.queuedTaskIds] : [];
320
+ if (existing.taskId !== event.taskId && !list.includes(event.taskId))
321
+ list.push(event.taskId);
322
+ existing.queuedTaskIds = list.length ? list : undefined;
323
+ existing.updatedAt = now;
324
+ }
325
+ else {
326
+ this.streamSnapshots.set(sessionKey, { phase: 'queued', taskId: event.taskId, updatedAt: now });
327
+ }
328
+ break;
329
+ }
330
+ case 'start': {
331
+ // Preserve any tasks still queued behind the new active one. Drop the
332
+ // task that's now starting from that list since it has graduated.
333
+ const prev = this.streamSnapshots.get(sessionKey);
334
+ const remainingQueued = prev?.queuedTaskIds?.filter(id => id !== event.taskId);
335
+ this.streamSnapshots.set(sessionKey, {
336
+ phase: 'streaming', taskId: event.taskId,
337
+ text: '', thinking: '', activity: '', plan: null, sessionId: event.sessionId, updatedAt: now,
338
+ model: event.model, effort: event.effort, previewMeta: null,
339
+ startedAt: now,
340
+ queuedTaskIds: remainingQueued && remainingQueued.length ? remainingQueued : undefined,
341
+ });
342
+ break;
343
+ }
344
+ case 'text': {
345
+ const snap = this.streamSnapshots.get(sessionKey);
346
+ if (snap) {
347
+ snap.text = event.text;
348
+ snap.thinking = event.thinking;
349
+ snap.activity = event.activity;
350
+ snap.plan = event.plan?.steps?.length ? event.plan : null;
351
+ if (event.previewMeta)
352
+ snap.previewMeta = event.previewMeta;
353
+ snap.updatedAt = now;
354
+ }
355
+ break;
356
+ }
357
+ case 'done': {
358
+ const prev = this.streamSnapshots.get(sessionKey);
359
+ this.streamSnapshots.set(sessionKey, {
360
+ phase: 'done',
361
+ taskId: event.taskId,
362
+ sessionId: event.sessionId,
363
+ incomplete: !!event.incomplete,
364
+ text: prev?.text || '',
365
+ thinking: prev?.thinking || '',
366
+ activity: prev?.activity || '',
367
+ error: event.error,
368
+ plan: prev?.plan ?? null,
369
+ model: prev?.model ?? null,
370
+ effort: prev?.effort ?? null,
371
+ previewMeta: prev?.previewMeta ?? null,
372
+ startedAt: prev?.startedAt,
373
+ queuedTaskIds: prev?.queuedTaskIds,
374
+ updatedAt: now,
375
+ });
376
+ // Auto-clean 'done' snapshot after 30s so stale state doesn't linger.
377
+ // Extended from 10s to give clients time to pick up the final state
378
+ // after session promotion or WS reconnects.
379
+ this.snapshotCleanupTimers.set(sessionKey, setTimeout(() => {
380
+ this.streamSnapshots.delete(sessionKey);
381
+ this.snapshotCleanupTimers.delete(sessionKey);
382
+ this.forgetPromotion(sessionKey);
383
+ }, 30_000));
384
+ break;
385
+ }
386
+ case 'cancelled': {
387
+ const snap = this.streamSnapshots.get(sessionKey);
388
+ if (!snap)
389
+ break;
390
+ if (snap.queuedTaskIds?.includes(event.taskId)) {
391
+ // Cancelled one of the queued-behind tasks — keep the running/done
392
+ // snapshot, just remove this entry from the list.
393
+ const next = snap.queuedTaskIds.filter(id => id !== event.taskId);
394
+ snap.queuedTaskIds = next.length ? next : undefined;
395
+ snap.updatedAt = now;
396
+ }
397
+ else {
398
+ // Cancelled the currently displayed task — drop the whole snapshot.
399
+ this.streamSnapshots.delete(sessionKey);
400
+ this.forgetPromotion(sessionKey);
401
+ }
402
+ break;
403
+ }
404
+ case 'interaction': {
405
+ const snap = this.streamSnapshots.get(sessionKey);
406
+ if (snap) {
407
+ const list = snap.interactions || [];
408
+ list.push(event.interaction);
409
+ snap.interactions = list;
410
+ snap.updatedAt = now;
411
+ }
412
+ break;
413
+ }
414
+ case 'interaction-resolved': {
415
+ const snap = this.streamSnapshots.get(sessionKey);
416
+ if (snap?.interactions) {
417
+ snap.interactions = snap.interactions.filter(i => i.promptId !== event.promptId);
418
+ if (!snap.interactions.length)
419
+ delete snap.interactions;
420
+ snap.updatedAt = now;
421
+ }
422
+ break;
423
+ }
424
+ }
425
+ // Push to dashboard SSE — throttle text events, push everything else immediately
426
+ try {
427
+ this.pushSnapshotToSSE(sessionKey, event.type !== 'text');
428
+ }
429
+ catch { /* dashboard not loaded yet — ignore */ }
430
+ }
431
+ /**
432
+ * Stream-lifecycle helpers. The dashboard mirrors a running turn by reading
433
+ * `streamSnapshots`, which is built exclusively from `emitStream` calls.
434
+ * IM channels run `runStream` directly (not via `submitSessionTask`), so
435
+ * without these calls the dashboard never sees IM-initiated turns. Routing
436
+ * every IM handler through these helpers (and refactoring submitSessionTask
437
+ * to use them) keeps the two surfaces consistent: each side can observe
438
+ * whatever the other side started.
439
+ *
440
+ * Each helper resolves the live `task.sessionKey` so the event lands on the
441
+ * current snapshot after a pending→native session id promotion.
442
+ */
443
+ liveSessionKey(taskId, fallback) {
444
+ return this.activeTasks.get(taskId)?.sessionKey || fallback;
445
+ }
446
+ emitStreamQueued(sessionKey, taskId) {
447
+ this.emitStream(sessionKey, { type: 'queued', taskId, position: this.getQueuePosition(sessionKey, taskId) });
448
+ }
449
+ emitStreamStart(taskId, session) {
450
+ const cfg = this.resolveSessionStreamConfig(session);
451
+ const key = this.liveSessionKey(taskId, session.key);
452
+ this.debug(`[stream-lifecycle] start task=${taskId} key=${key} sessionId=${session.sessionId || '(pending)'} model=${cfg.model || '-'}`);
453
+ this.emitStream(key, {
454
+ type: 'start', taskId, agent: session.agent, sessionId: session.sessionId,
455
+ model: cfg.model, effort: cfg.effort,
456
+ });
457
+ }
458
+ emitStreamText(taskId, fallbackKey, text, thinking, activity = '', meta, plan) {
459
+ const key = this.liveSessionKey(taskId, fallbackKey);
460
+ const snap = this.streamSnapshots.get(key);
461
+ this.debug(`[stream-lifecycle] text task=${taskId} key=${key} bytes=${text.length}/${thinking.length} snap=${snap ? snap.phase : 'NONE'}`);
462
+ this.emitStream(key, {
463
+ type: 'text', text, thinking, activity, plan: plan ?? null, previewMeta: meta ?? null,
464
+ });
465
+ }
466
+ emitStreamDone(taskId, fallbackKey, opts) {
467
+ const key = this.liveSessionKey(taskId, fallbackKey);
468
+ this.debug(`[stream-lifecycle] done task=${taskId} key=${key} sessionId=${opts.sessionId || '(none)'} incomplete=${opts.incomplete}`);
469
+ this.emitStream(key, {
470
+ type: 'done', taskId,
471
+ sessionId: opts.sessionId,
472
+ incomplete: opts.incomplete,
473
+ ...(opts.error ? { error: opts.error } : {}),
474
+ });
475
+ }
476
+ emitStreamCancelled(taskId, fallbackKey) {
477
+ this.emitStream(this.liveSessionKey(taskId, fallbackKey), { type: 'cancelled', taskId });
478
+ }
479
+ keepAliveProc = null;
480
+ keepAlivePulseTimer = null;
481
+ sessionChains = new Map();
482
+ userConfigUnsubscribe = null;
483
+ taskKeysBySourceMessage = new Map();
484
+ taskKeysByActionId = new Map();
485
+ withdrawnSourceMessages = new Set();
486
+ nextTaskActionId = 1;
487
+ humanLoopPrompts = new Map();
488
+ humanLoopPromptIdsByChat = new Map();
489
+ nextHumanLoopPromptId = 1;
490
+ constructor() {
491
+ this.workdir = resolveUserWorkdir();
492
+ ensureGitignore(this.workdir);
493
+ initializeProjectSkills(this.workdir);
494
+ const config = getActiveUserConfig();
495
+ // Initialize per-agent configs
496
+ this.agentConfigs = {
497
+ codex: {
498
+ model: resolveAgentModel(config, 'codex'),
499
+ reasoningEffort: resolveAgentEffort(config, 'codex') || 'xhigh',
500
+ fullAccess: envBool('CODEX_FULL_ACCESS', true),
501
+ extraArgs: shellSplit(process.env.CODEX_EXTRA_ARGS || ''),
502
+ },
503
+ claude: {
504
+ model: resolveAgentModel(config, 'claude'),
505
+ reasoningEffort: resolveAgentEffort(config, 'claude') || 'high',
506
+ permissionMode: (process.env.CLAUDE_PERMISSION_MODE || 'bypassPermissions').trim(),
507
+ // Workflow orchestration is a per-session/per-turn choice (composer
508
+ // toggle / IM /mode), never a persisted default — always boot off.
509
+ workflowEnabled: false,
510
+ // Access mode (TUI subscription vs `claude -p` Agent SDK credits) IS a
511
+ // persisted preference — hydrate it from config so the boot value
512
+ // matches the dashboard toggle / env default.
513
+ accessMode: resolveClaudeAccessMode(config),
514
+ extraArgs: shellSplit(process.env.CLAUDE_EXTRA_ARGS || ''),
515
+ },
516
+ gemini: {
517
+ model: resolveAgentModel(config, 'gemini'),
518
+ approvalMode: envString('GEMINI_APPROVAL_MODE', 'yolo'),
519
+ sandbox: envBool('GEMINI_SANDBOX', false),
520
+ extraArgs: shellSplit(process.env.GEMINI_EXTRA_ARGS || ''),
521
+ },
522
+ // Hermes was missing from this map for a long time. Without an entry,
523
+ // `modelForAgent('hermes')` returned '' and `setModelForAgent('hermes',
524
+ // ...)` silently no-op'd because `if (config)` short-circuited — so any
525
+ // /models switch in IM looked successful in the log but never reached
526
+ // the hermes driver. Adding the entry lets the same machinery the other
527
+ // three agents already rely on apply to hermes too.
528
+ hermes: {
529
+ model: resolveAgentModel(config, 'hermes'),
530
+ reasoningEffort: resolveAgentEffort(config, 'hermes') || 'medium',
531
+ extraArgs: shellSplit(process.env.HERMES_EXTRA_ARGS || ''),
532
+ },
533
+ };
534
+ this.defaultAgent = normalizeAgent('codex');
535
+ this.runTimeout = envInt('PIKILOOP_TIMEOUT', DEFAULT_RUN_TIMEOUT_S);
536
+ this.allowedChatIds = parseAllowedChatIds(process.env.PIKILOOP_ALLOWED_IDS || '');
537
+ this.refreshManagedConfig(getActiveUserConfig(), { initial: true });
538
+ this.userConfigUnsubscribe = onUserConfigChange(config => this.refreshManagedConfig(config));
539
+ }
540
+ log(msg, level = 'info') {
541
+ writeScopedLog('pikiloop', msg, { level });
542
+ }
543
+ debug(msg) {
544
+ this.log(msg, 'debug');
545
+ }
546
+ warn(msg) {
547
+ this.log(msg, 'warn');
548
+ }
549
+ error(msg) {
550
+ this.log(msg, 'error');
551
+ }
552
+ chat(chatId) {
553
+ let s = this.chats.get(chatId);
554
+ if (!s) {
555
+ s = { agent: this.defaultAgent, sessionId: null, activeSessionKey: null, activeThreadId: null, modelId: null };
556
+ this.chats.set(chatId, s);
557
+ }
558
+ return s;
559
+ }
560
+ /** Effective workdir for a chat — per-chat override or global fallback. */
561
+ chatWorkdir(chatId) {
562
+ return this.chats.get(chatId)?.workdir || this.workdir;
563
+ }
564
+ sessionKey(agent, sessionId) {
565
+ return `${agent}:${sessionId}`;
566
+ }
567
+ getSessionRuntimeByKey(sessionKey, opts = {}) {
568
+ if (!sessionKey)
569
+ return null;
570
+ const runtime = this.sessionStates.get(this.resolveSessionKey(sessionKey)) || null;
571
+ if (!runtime)
572
+ return null;
573
+ if (!opts.allowAnyWorkdir && runtime.workdir !== this.workdir)
574
+ return null;
575
+ return runtime;
576
+ }
577
+ getSelectedSession(cs) {
578
+ return this.getSessionRuntimeByKey(cs.activeSessionKey, { allowAnyWorkdir: true });
579
+ }
580
+ hydrateSessionRuntime(session) {
581
+ if (!session.sessionId)
582
+ return null;
583
+ return this.upsertSessionRuntime({
584
+ agent: session.agent,
585
+ sessionId: session.sessionId,
586
+ workdir: session.workdir || this.workdir,
587
+ workspacePath: session.workspacePath ?? null,
588
+ threadId: session.threadId ?? null,
589
+ codexCumulative: session.codexCumulative,
590
+ modelId: session.modelId ?? null,
591
+ thinkingEffort: session.thinkingEffort ?? null,
592
+ handoverFrom: session.handoverFrom ?? null,
593
+ });
594
+ }
595
+ upsertSessionRuntime(session) {
596
+ const workdir = path.resolve(session.workdir || this.workdir);
597
+ const requestedKey = this.sessionKey(session.agent, session.sessionId);
598
+ // Follow the promotion chain. Without this, an insertion that races a
599
+ // pending→native promotion would `new` a phantom runtime under the stale
600
+ // pending key and the queued message would land in a session the dashboard
601
+ // can never reach again.
602
+ const resolvedKey = this.resolveSessionKey(requestedKey);
603
+ const existing = this.sessionStates.get(resolvedKey);
604
+ if (existing) {
605
+ existing.workdir = workdir;
606
+ // Do NOT overwrite agent/sessionId/key here — the existing record IS the
607
+ // canonical identity post-promotion. Letting upserts re-stamp the old
608
+ // pending id back over the native id would unwind the promotion.
609
+ if (session.workspacePath !== undefined)
610
+ existing.workspacePath = session.workspacePath ?? null;
611
+ if (session.threadId !== undefined)
612
+ existing.threadId = session.threadId ?? null;
613
+ if (session.codexCumulative !== undefined)
614
+ existing.codexCumulative = session.codexCumulative;
615
+ if (session.modelId !== undefined)
616
+ existing.modelId = session.modelId ?? null;
617
+ if (session.thinkingEffort !== undefined)
618
+ existing.thinkingEffort = session.thinkingEffort ?? null;
619
+ // handoverFrom is one-shot: only set if not already set (the first staging wins).
620
+ if (session.handoverFrom !== undefined && !existing.handoverFrom) {
621
+ existing.handoverFrom = session.handoverFrom;
622
+ }
623
+ return existing;
624
+ }
625
+ const runtime = {
626
+ key: requestedKey,
627
+ workdir,
628
+ agent: session.agent,
629
+ sessionId: session.sessionId,
630
+ workspacePath: session.workspacePath ?? null,
631
+ threadId: session.threadId ?? null,
632
+ codexCumulative: session.codexCumulative,
633
+ modelId: session.modelId ?? null,
634
+ thinkingEffort: session.thinkingEffort ?? null,
635
+ runningTaskIds: new Set(),
636
+ handoverFrom: session.handoverFrom ?? null,
637
+ };
638
+ this.sessionStates.set(requestedKey, runtime);
639
+ return runtime;
640
+ }
641
+ applySessionSelection(cs, session, opts = {}) {
642
+ const previousSessionKey = cs.activeSessionKey ?? null;
643
+ cs.activeSessionKey = session?.key ?? null;
644
+ if (session) {
645
+ cs.agent = session.agent;
646
+ cs.sessionId = session.sessionId;
647
+ cs.workspacePath = session.workspacePath;
648
+ cs.activeThreadId = session.threadId;
649
+ cs.codexCumulative = session.codexCumulative;
650
+ cs.modelId = session.modelId ?? null;
651
+ cs.workdir = session.workdir;
652
+ if (previousSessionKey && previousSessionKey !== session.key)
653
+ this.maybeEvictSessionRuntime(previousSessionKey);
654
+ return;
655
+ }
656
+ cs.sessionId = null;
657
+ cs.workspacePath = null;
658
+ if (!opts.preserveThread)
659
+ cs.activeThreadId = null;
660
+ cs.codexCumulative = undefined;
661
+ cs.modelId = null;
662
+ if (previousSessionKey)
663
+ this.maybeEvictSessionRuntime(previousSessionKey);
664
+ }
665
+ resetChatConversation(cs, opts) {
666
+ this.applySessionSelection(cs, null, { preserveThread: opts?.clearThread === false });
667
+ if (opts?.clearWorkdir)
668
+ cs.workdir = null;
669
+ }
670
+ adoptSession(cs, session) {
671
+ if (!session.sessionId) {
672
+ this.applySessionSelection(cs, null);
673
+ return;
674
+ }
675
+ const managed = ensureManagedSession({
676
+ agent: session.agent,
677
+ sessionId: session.sessionId,
678
+ workdir: 'workdir' in session && session.workdir ? session.workdir : this.workdir,
679
+ title: session.title ?? null,
680
+ model: session.model ?? null,
681
+ thinkingEffort: session.thinkingEffort ?? null,
682
+ profileId: session.profileId ?? null,
683
+ threadId: session.threadId ?? null,
684
+ });
685
+ const runtime = this.hydrateSessionRuntime({
686
+ agent: session.agent,
687
+ sessionId: session.sessionId,
688
+ workdir: 'workdir' in session ? session.workdir : null,
689
+ workspacePath: managed.workspacePath ?? session.workspacePath ?? null,
690
+ threadId: managed.threadId ?? session.threadId ?? null,
691
+ modelId: session.model ?? managed.model ?? null,
692
+ thinkingEffort: session.thinkingEffort ?? managed.thinkingEffort ?? null,
693
+ });
694
+ if (!runtime) {
695
+ this.applySessionSelection(cs, null);
696
+ return;
697
+ }
698
+ // Adopting an existing session is an explicit user pick — drop any
699
+ // queued handover from a prior agent toggle so we don't accidentally
700
+ // prepend the wrong context to the resumed session's next turn.
701
+ cs.pendingHandoverFrom = null;
702
+ this.applySessionSelection(cs, runtime);
703
+ }
704
+ syncSelectedChats(session) {
705
+ for (const [, cs] of this.chats) {
706
+ if (cs.activeSessionKey !== session.key)
707
+ continue;
708
+ this.applySessionSelection(cs, session);
709
+ }
710
+ }
711
+ moveSessionStreamSnapshot(previousKey, nextKey) {
712
+ if (!previousKey || !nextKey || previousKey === nextKey)
713
+ return;
714
+ const previousSnapshot = this.streamSnapshots.get(previousKey) || null;
715
+ const nextSnapshot = this.streamSnapshots.get(nextKey) || null;
716
+ const mergedSnapshot = previousSnapshot && (!nextSnapshot || previousSnapshot.updatedAt >= nextSnapshot.updatedAt)
717
+ ? previousSnapshot
718
+ : nextSnapshot;
719
+ this.streamSnapshots.delete(previousKey);
720
+ if (mergedSnapshot)
721
+ this.streamSnapshots.set(nextKey, mergedSnapshot);
722
+ const previousTimer = this.snapshotCleanupTimers.get(previousKey);
723
+ if (previousTimer) {
724
+ clearTimeout(previousTimer);
725
+ this.snapshotCleanupTimers.delete(previousKey);
726
+ if (mergedSnapshot?.phase === 'done') {
727
+ this.snapshotCleanupTimers.set(nextKey, setTimeout(() => {
728
+ this.streamSnapshots.delete(nextKey);
729
+ this.snapshotCleanupTimers.delete(nextKey);
730
+ }, 10_000));
731
+ }
732
+ }
733
+ }
734
+ promoteSessionRuntime(session, nextSessionId) {
735
+ const resolvedSessionId = nextSessionId.trim();
736
+ if (!resolvedSessionId || session.sessionId === resolvedSessionId)
737
+ return session;
738
+ const previousKey = session.key;
739
+ const previousSessionId = session.sessionId;
740
+ const nextKey = this.sessionKey(session.agent, resolvedSessionId);
741
+ const existing = this.sessionStates.get(nextKey);
742
+ if (existing && existing !== session) {
743
+ session.workspacePath = session.workspacePath ?? existing.workspacePath;
744
+ session.threadId = session.threadId ?? existing.threadId;
745
+ session.codexCumulative = session.codexCumulative ?? existing.codexCumulative;
746
+ session.modelId = session.modelId ?? existing.modelId ?? null;
747
+ session.thinkingEffort = session.thinkingEffort ?? existing.thinkingEffort ?? null;
748
+ for (const taskId of existing.runningTaskIds)
749
+ session.runningTaskIds.add(taskId);
750
+ }
751
+ this.sessionStates.delete(previousKey);
752
+ this.sessionStates.delete(nextKey);
753
+ session.sessionId = resolvedSessionId;
754
+ session.key = nextKey;
755
+ this.sessionStates.set(nextKey, session);
756
+ for (const [, task] of this.activeTasks) {
757
+ if (task.sessionKey === previousKey)
758
+ task.sessionKey = nextKey;
759
+ }
760
+ const previousChain = this.sessionChains.get(previousKey);
761
+ const nextChain = this.sessionChains.get(nextKey);
762
+ if (previousChain)
763
+ this.sessionChains.delete(previousKey);
764
+ if (previousChain || nextChain) {
765
+ const mergedChain = previousChain && nextChain && previousChain !== nextChain
766
+ ? Promise.allSettled([previousChain, nextChain]).then(() => { })
767
+ : (previousChain || nextChain);
768
+ this.sessionChains.set(nextKey, mergedChain);
769
+ }
770
+ this.moveSessionStreamSnapshot(previousKey, nextKey);
771
+ // Track promotion so poll endpoints + insertions can resolve pending →
772
+ // native. When the chain hops more than once (Claude `--resume` rotating
773
+ // session ids back-to-back), pull ancestor aliases forward AND re-point
774
+ // them at the latest key so a single lookup is O(1) and every WS listener
775
+ // that subscribed to any earlier key still receives updates.
776
+ this.promotedSessionKeys.set(previousKey, nextKey);
777
+ const aliases = new Set(this.promotedFromAliases.get(nextKey) || []);
778
+ aliases.add(previousKey);
779
+ const ancestorAliases = this.promotedFromAliases.get(previousKey);
780
+ if (ancestorAliases) {
781
+ for (const alias of ancestorAliases) {
782
+ aliases.add(alias);
783
+ this.promotedSessionKeys.set(alias, nextKey);
784
+ }
785
+ this.promotedFromAliases.delete(previousKey);
786
+ }
787
+ this.promotedFromAliases.set(nextKey, [...aliases]);
788
+ // Update the promoted snapshot's sessionId to reflect the native ID
789
+ const promotedSnap = this.streamSnapshots.get(nextKey);
790
+ if (promotedSnap)
791
+ promotedSnap.sessionId = resolvedSessionId;
792
+ // Notify dashboard clients still tracking the old (pending) key via SSE
793
+ // so they can detect the promotion and navigate to the correct session
794
+ if (this._onStreamSnapshot && promotedSnap) {
795
+ this._onStreamSnapshot(previousKey, this.enrichSnapshot(promotedSnap));
796
+ }
797
+ for (const [, cs] of this.chats) {
798
+ const matchesPreviousSelection = cs.activeSessionKey === previousKey;
799
+ const matchesNextSelection = cs.activeSessionKey === nextKey;
800
+ const matchesSessionId = cs.agent === session.agent && ((previousSessionId ? cs.sessionId === previousSessionId : false)
801
+ || cs.sessionId === resolvedSessionId);
802
+ if (!matchesPreviousSelection && !matchesNextSelection && !matchesSessionId)
803
+ continue;
804
+ this.applySessionSelection(cs, session);
805
+ }
806
+ return session;
807
+ }
808
+ isSessionSelected(sessionKey) {
809
+ if (!sessionKey)
810
+ return false;
811
+ for (const [, cs] of this.chats) {
812
+ if (cs.activeSessionKey === sessionKey)
813
+ return true;
814
+ }
815
+ return false;
816
+ }
817
+ maybeEvictSessionRuntime(sessionKey) {
818
+ const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
819
+ if (!session)
820
+ return;
821
+ if (session.runningTaskIds.size)
822
+ return;
823
+ if (session.workdir === this.workdir)
824
+ return;
825
+ if (this.isSessionSelected(session.key))
826
+ return;
827
+ this.sessionStates.delete(session.key);
828
+ }
829
+ findThreadSessionRuntime(chatId, threadId, agent) {
830
+ if (!threadId)
831
+ return null;
832
+ const managed = findManagedThreadSession(this.chatWorkdir(chatId), threadId, agent);
833
+ if (!managed?.sessionId)
834
+ return null;
835
+ return this.hydrateSessionRuntime({
836
+ agent: managed.agent,
837
+ sessionId: managed.sessionId,
838
+ workdir: managed.workdir || this.chatWorkdir(chatId),
839
+ workspacePath: managed.workspacePath ?? null,
840
+ threadId: managed.threadId ?? threadId,
841
+ modelId: managed.model ?? null,
842
+ });
843
+ }
844
+ ensureSessionForChat(chatId, title, files) {
845
+ const cs = this.chat(chatId);
846
+ const selected = this.getSelectedSession(cs);
847
+ if (selected)
848
+ return selected;
849
+ // Auto-resume an existing same-thread session of this agent (back-and-forth
850
+ // toggling). The handover queued on `cs.pendingHandoverFrom` is intentionally
851
+ // dropped here — the resumed session already has its own history; replaying
852
+ // an external handover on top would just be duplicate context.
853
+ const resumed = this.findThreadSessionRuntime(chatId, cs.activeThreadId, cs.agent);
854
+ if (resumed) {
855
+ cs.pendingHandoverFrom = null;
856
+ this.applySessionSelection(cs, resumed);
857
+ return resumed;
858
+ }
859
+ const wd = this.chatWorkdir(chatId);
860
+ const handoverFrom = cs.pendingHandoverFrom ?? null;
861
+ cs.pendingHandoverFrom = null;
862
+ const staged = stageSessionFiles({
863
+ agent: cs.agent,
864
+ workdir: wd,
865
+ files: [],
866
+ sessionId: null,
867
+ title: title || 'New session',
868
+ threadId: cs.activeThreadId ?? null,
869
+ handoverFrom,
870
+ });
871
+ const runtime = this.upsertSessionRuntime({
872
+ agent: cs.agent,
873
+ sessionId: staged.sessionId,
874
+ workspacePath: staged.workspacePath,
875
+ threadId: staged.threadId,
876
+ modelId: this.modelForAgent(cs.agent),
877
+ thinkingEffort: this.effortForAgent(cs.agent),
878
+ handoverFrom: staged.handoverFrom,
879
+ });
880
+ this.applySessionSelection(cs, runtime);
881
+ return runtime;
882
+ }
883
+ beginTask(task) {
884
+ const nextTask = {
885
+ ...task,
886
+ actionId: task.actionId || `t${(this.nextTaskActionId++).toString(36)}`,
887
+ status: 'queued',
888
+ cancelled: false,
889
+ abort: null,
890
+ placeholderMessageIds: [...(task.placeholderMessageIds || [])],
891
+ };
892
+ this.activeTasks.set(nextTask.taskId, nextTask);
893
+ this.taskKeysBySourceMessage.set(this.sourceMessageKey(task.chatId, task.sourceMessageId), nextTask.taskId);
894
+ this.taskKeysByActionId.set(String(nextTask.actionId), nextTask.taskId);
895
+ const session = this.getSessionRuntimeByKey(task.sessionKey, { allowAnyWorkdir: true });
896
+ session?.runningTaskIds.add(nextTask.taskId);
897
+ }
898
+ finishTask(taskId) {
899
+ for (const prompt of [...this.humanLoopPrompts.values()]) {
900
+ if (prompt.taskId !== taskId)
901
+ continue;
902
+ this.clearHumanLoopPrompt(prompt.promptId, new Error('Task finished before prompt was answered.'));
903
+ }
904
+ const task = this.activeTasks.get(taskId);
905
+ if (!task)
906
+ return;
907
+ this.activeTasks.delete(taskId);
908
+ this.taskKeysBySourceMessage.delete(this.sourceMessageKey(task.chatId, task.sourceMessageId));
909
+ if (task.actionId)
910
+ this.taskKeysByActionId.delete(String(task.actionId));
911
+ this.withdrawnSourceMessages.delete(this.sourceMessageKey(task.chatId, task.sourceMessageId));
912
+ const session = this.getSessionRuntimeByKey(task.sessionKey, { allowAnyWorkdir: true });
913
+ if (!session)
914
+ return;
915
+ session.runningTaskIds.delete(taskId);
916
+ this.maybeEvictSessionRuntime(session.key);
917
+ }
918
+ runningTaskForSession(sessionKey) {
919
+ const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
920
+ if (!session || !session.runningTaskIds.size)
921
+ return null;
922
+ let running = null;
923
+ for (const taskId of session.runningTaskIds) {
924
+ const task = this.activeTasks.get(taskId);
925
+ if (!task || task.status !== 'running')
926
+ continue;
927
+ if (!running || task.startedAt < running.startedAt)
928
+ running = task;
929
+ }
930
+ return running;
931
+ }
932
+ markTaskRunning(taskId, abort) {
933
+ const task = this.activeTasks.get(taskId);
934
+ if (!task)
935
+ return null;
936
+ if (task.cancelled)
937
+ return task;
938
+ task.status = 'running';
939
+ task.abort = abort || null;
940
+ task.steer = null;
941
+ task.freezePreviewOnAbort = false;
942
+ return task;
943
+ }
944
+ registerTaskPlaceholders(taskId, messageIds) {
945
+ const task = this.activeTasks.get(taskId);
946
+ if (!task)
947
+ return;
948
+ if (!task.placeholderMessageIds)
949
+ task.placeholderMessageIds = [];
950
+ for (const messageId of messageIds) {
951
+ if (messageId == null)
952
+ continue;
953
+ if (!task.placeholderMessageIds.includes(messageId))
954
+ task.placeholderMessageIds.push(messageId);
955
+ }
956
+ }
957
+ isSourceMessageWithdrawn(chatId, sourceMessageId) {
958
+ return this.withdrawnSourceMessages.has(this.sourceMessageKey(chatId, sourceMessageId));
959
+ }
960
+ actionIdForTask(taskId) {
961
+ return this.activeTasks.get(taskId)?.actionId || null;
962
+ }
963
+ withdrawQueuedTaskBySourceMessage(chatId, sourceMessageId) {
964
+ const sourceKey = this.sourceMessageKey(chatId, sourceMessageId);
965
+ this.withdrawnSourceMessages.add(sourceKey);
966
+ const taskId = this.taskKeysBySourceMessage.get(sourceKey);
967
+ if (!taskId)
968
+ return null;
969
+ const task = this.activeTasks.get(taskId);
970
+ if (!task || task.status !== 'queued')
971
+ return null;
972
+ task.cancelled = true;
973
+ return task;
974
+ }
975
+ stopTasksForSession(sessionKey) {
976
+ const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
977
+ if (!session)
978
+ return { interrupted: false, cancelledQueued: 0 };
979
+ let interrupted = false;
980
+ for (const taskId of session.runningTaskIds) {
981
+ const task = this.activeTasks.get(taskId);
982
+ if (!task)
983
+ continue;
984
+ if (!interrupted && task.status === 'running') {
985
+ interrupted = true;
986
+ task.cancelled = true;
987
+ try {
988
+ task.abort?.();
989
+ }
990
+ catch { }
991
+ }
992
+ }
993
+ return { interrupted, cancelledQueued: 0 };
994
+ }
995
+ stopTaskByActionId(actionId) {
996
+ const taskId = this.taskKeysByActionId.get(String(actionId));
997
+ if (!taskId)
998
+ return { task: null, interrupted: false, cancelled: false };
999
+ const task = this.activeTasks.get(taskId) || null;
1000
+ if (!task)
1001
+ return { task: null, interrupted: false, cancelled: false };
1002
+ if (task.status === 'queued') {
1003
+ task.cancelled = true;
1004
+ return { task, interrupted: false, cancelled: true };
1005
+ }
1006
+ if (task.status === 'running') {
1007
+ try {
1008
+ task.abort?.();
1009
+ }
1010
+ catch { }
1011
+ return { task, interrupted: true, cancelled: false };
1012
+ }
1013
+ return { task, interrupted: false, cancelled: false };
1014
+ }
1015
+ /**
1016
+ * Mark all queued tasks ahead of `targetTaskId` (in this session) so their
1017
+ * chain wrappers re-enqueue and yield to the steered task. Repeated steers
1018
+ * reset prior defer flags so only the latest target's predecessors defer.
1019
+ */
1020
+ markQueueDeferralsForSteer(targetTaskId) {
1021
+ const target = this.activeTasks.get(targetTaskId);
1022
+ if (!target)
1023
+ return;
1024
+ const snapshot = this.streamSnapshots.get(target.sessionKey);
1025
+ const queuedIds = snapshot?.queuedTaskIds || [];
1026
+ // Reset any previous defer flags for this session's queued tasks first so
1027
+ // a new steer call doesn't stack on top of an earlier (now-stale) decision.
1028
+ for (const id of queuedIds) {
1029
+ const t = this.activeTasks.get(id);
1030
+ if (t)
1031
+ t.deferForSteer = false;
1032
+ }
1033
+ const targetIdx = queuedIds.indexOf(targetTaskId);
1034
+ for (let i = 0; i < targetIdx; i++) {
1035
+ const t = this.activeTasks.get(queuedIds[i]);
1036
+ if (t && t.status === 'queued' && !t.cancelled)
1037
+ t.deferForSteer = true;
1038
+ }
1039
+ }
1040
+ /**
1041
+ * Steer hands off to the queued task's own placeholder card. Interrupt the
1042
+ * active task so the queued task can run next and the current preview can be
1043
+ * frozen in place instead of being rewritten as an error.
1044
+ */
1045
+ async steerTaskByActionId(actionId) {
1046
+ const taskId = this.taskKeysByActionId.get(String(actionId));
1047
+ if (!taskId)
1048
+ return { task: null, interrupted: false, steered: false };
1049
+ const task = this.activeTasks.get(taskId) || null;
1050
+ if (!task || task.status !== 'queued')
1051
+ return { task, interrupted: false, steered: false };
1052
+ this.markQueueDeferralsForSteer(taskId);
1053
+ const interrupted = this.interruptRunningTask(task.sessionKey, { freezePreview: true });
1054
+ return { task, interrupted, steered: false };
1055
+ }
1056
+ /**
1057
+ * Interrupt only the currently running task for a session, leaving queued tasks intact.
1058
+ * Used by the "Steer" action to let a queued task run next.
1059
+ */
1060
+ interruptRunningTask(sessionKey, opts = {}) {
1061
+ const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
1062
+ if (!session)
1063
+ return false;
1064
+ for (const taskId of session.runningTaskIds) {
1065
+ const task = this.activeTasks.get(taskId);
1066
+ if (!task || task.status !== 'running')
1067
+ continue;
1068
+ task.freezePreviewOnAbort = !!opts.freezePreview;
1069
+ try {
1070
+ task.abort?.();
1071
+ }
1072
+ catch { }
1073
+ return true;
1074
+ }
1075
+ return false;
1076
+ }
1077
+ /**
1078
+ * Return the number of tasks ahead of the given task in its session queue.
1079
+ * Counts running + queued (non-cancelled) tasks that were started before this one.
1080
+ */
1081
+ getQueuePosition(sessionKey, taskId) {
1082
+ const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
1083
+ if (!session)
1084
+ return 0;
1085
+ let ahead = 0;
1086
+ for (const otherId of session.runningTaskIds) {
1087
+ if (otherId === taskId)
1088
+ continue;
1089
+ const other = this.activeTasks.get(otherId);
1090
+ if (!other || other.cancelled)
1091
+ continue;
1092
+ if (other.status === 'running' || other.status === 'queued')
1093
+ ahead++;
1094
+ }
1095
+ return ahead;
1096
+ }
1097
+ sourceMessageKey(chatId, sourceMessageId) {
1098
+ return `${String(chatId)}:${String(sourceMessageId)}`;
1099
+ }
1100
+ queueSessionTask(session, task, taskId) {
1101
+ // Wrap the user task with a defer check. When steerTask() flags this task
1102
+ // to yield its chain slot to a steered task, the wrapper re-enqueues the
1103
+ // same fn at the tail and returns immediately so the next chain wrapper
1104
+ // (the steered task's) fires next. Tasks without a taskId (e.g. file
1105
+ // staging) skip the check.
1106
+ const runner = async () => {
1107
+ if (taskId) {
1108
+ const t = this.activeTasks.get(taskId);
1109
+ if (t?.deferForSteer && !t.cancelled) {
1110
+ t.deferForSteer = false;
1111
+ // Re-enqueue at the tail. Don't await — let the current slot finish
1112
+ // immediately so the chain advances to the steered task. The new
1113
+ // wrapper preserves the original fn so the deferred task still runs
1114
+ // (just after the steered one).
1115
+ void this.queueSessionTask(session, task, taskId);
1116
+ return undefined;
1117
+ }
1118
+ }
1119
+ return await task();
1120
+ };
1121
+ const prev = this.sessionChains.get(session.key) || Promise.resolve();
1122
+ const current = prev.catch(() => { }).then(runner);
1123
+ const settled = current.then(() => { }, () => { });
1124
+ const chained = settled.finally(() => {
1125
+ if (this.sessionChains.get(session.key) === chained)
1126
+ this.sessionChains.delete(session.key);
1127
+ });
1128
+ this.sessionChains.set(session.key, chained);
1129
+ return current;
1130
+ }
1131
+ sessionHasPendingWork(session) {
1132
+ return this.sessionChains.has(session.key);
1133
+ }
1134
+ beginHumanLoopPrompt(opts) {
1135
+ const promptId = `h${(this.nextHumanLoopPromptId++).toString(36)}`;
1136
+ let resolvePrompt;
1137
+ let rejectPrompt;
1138
+ const result = new Promise((resolve, reject) => {
1139
+ resolvePrompt = resolve;
1140
+ rejectPrompt = reject;
1141
+ });
1142
+ const answers = {};
1143
+ for (const question of opts.questions)
1144
+ answers[question.id] = createEmptyHumanLoopAnswer();
1145
+ const prompt = {
1146
+ promptId,
1147
+ taskId: opts.taskId,
1148
+ chatId: opts.chatId,
1149
+ title: opts.title,
1150
+ detail: opts.detail ?? null,
1151
+ hint: opts.hint ?? null,
1152
+ questions: opts.questions,
1153
+ currentIndex: 0,
1154
+ answers,
1155
+ resolveWith: opts.resolveWith,
1156
+ resolve: resolvePrompt,
1157
+ reject: rejectPrompt,
1158
+ messageIds: [],
1159
+ silent: opts.silent,
1160
+ };
1161
+ this.humanLoopPrompts.set(promptId, prompt);
1162
+ const chatKey = String(opts.chatId);
1163
+ const promptIds = this.humanLoopPromptIdsByChat.get(chatKey) || [];
1164
+ promptIds.push(promptId);
1165
+ this.humanLoopPromptIdsByChat.set(chatKey, promptIds);
1166
+ return { prompt, result };
1167
+ }
1168
+ pendingHumanLoopPrompt(chatId) {
1169
+ const promptIds = this.humanLoopPromptIdsByChat.get(String(chatId)) || [];
1170
+ for (let i = promptIds.length - 1; i >= 0; i--) {
1171
+ const prompt = this.humanLoopPrompts.get(promptIds[i]) || null;
1172
+ if (prompt && isHumanLoopAwaitingText(prompt))
1173
+ return prompt;
1174
+ }
1175
+ const promptId = promptIds[promptIds.length - 1];
1176
+ return promptId ? (this.humanLoopPrompts.get(promptId) || null) : null;
1177
+ }
1178
+ registerHumanLoopMessage(promptId, messageId) {
1179
+ if (messageId == null)
1180
+ return;
1181
+ const prompt = this.humanLoopPrompts.get(promptId);
1182
+ if (!prompt)
1183
+ return;
1184
+ if (!prompt.messageIds.includes(messageId))
1185
+ prompt.messageIds.push(messageId);
1186
+ }
1187
+ resolveHumanLoopPrompt(promptId) {
1188
+ const prompt = this.humanLoopPrompts.get(promptId) || null;
1189
+ if (!prompt)
1190
+ return null;
1191
+ this.humanLoopPrompts.delete(promptId);
1192
+ this.removeHumanLoopPromptFromChat(prompt.chatId, promptId);
1193
+ prompt.resolve(buildHumanLoopResponse(prompt));
1194
+ this.emitInteractionResolved(prompt.taskId, promptId);
1195
+ this.fireInteractionAnswered(prompt, 'answered');
1196
+ return prompt;
1197
+ }
1198
+ clearHumanLoopPrompt(promptId, error) {
1199
+ const prompt = this.humanLoopPrompts.get(promptId) || null;
1200
+ if (!prompt)
1201
+ return null;
1202
+ this.humanLoopPrompts.delete(promptId);
1203
+ this.removeHumanLoopPromptFromChat(prompt.chatId, promptId);
1204
+ if (error)
1205
+ prompt.reject(error);
1206
+ this.emitInteractionResolved(prompt.taskId, promptId);
1207
+ this.fireInteractionAnswered(prompt, 'cancelled');
1208
+ return prompt;
1209
+ }
1210
+ /**
1211
+ * Unified post-resolution hook for human-loop prompts. Each IM channel
1212
+ * overrides `onInteractionAnswered` to (1) collapse the original prompt card
1213
+ * to an answered/cancelled state and (2) echo the decision as a new chat
1214
+ * message so scrolling back shows what the user picked. Dashboard sessions
1215
+ * (chatId='dashboard') and channels that opt out remain silent.
1216
+ */
1217
+ fireInteractionAnswered(prompt, status) {
1218
+ if (prompt.silent)
1219
+ return;
1220
+ if (prompt.chatId === 'dashboard')
1221
+ return;
1222
+ const summary = summarizeResolvedHumanLoopAnswers(prompt, status);
1223
+ void Promise.resolve()
1224
+ .then(() => this.onInteractionAnswered(prompt, summary))
1225
+ .catch(err => this.warn(`onInteractionAnswered failed: ${err?.message || err}`));
1226
+ }
1227
+ /**
1228
+ * Channel hook fired after a human-loop prompt resolves (answered or
1229
+ * cancelled). Default: no-op. Override in channel subclasses to update the
1230
+ * original card and post a decision-echo message.
1231
+ */
1232
+ async onInteractionAnswered(_prompt, _summary) {
1233
+ // Default: no-op.
1234
+ }
1235
+ emitInteractionResolved(taskId, promptId) {
1236
+ const task = this.activeTasks.get(taskId);
1237
+ if (task)
1238
+ this.emitStream(task.sessionKey, { type: 'interaction-resolved', promptId });
1239
+ }
1240
+ humanLoopSelectOption(promptId, optionValue, opts = {}) {
1241
+ const prompt = this.humanLoopPrompts.get(promptId) || null;
1242
+ if (!prompt)
1243
+ return null;
1244
+ const result = setHumanLoopOption(prompt, optionValue, opts);
1245
+ if (result.completed)
1246
+ this.resolveHumanLoopPrompt(promptId);
1247
+ return { prompt, ...result };
1248
+ }
1249
+ humanLoopSkip(promptId) {
1250
+ const prompt = this.humanLoopPrompts.get(promptId) || null;
1251
+ if (!prompt)
1252
+ return null;
1253
+ const result = skipHumanLoopQuestion(prompt);
1254
+ if (result.completed)
1255
+ this.resolveHumanLoopPrompt(promptId);
1256
+ return { prompt, ...result };
1257
+ }
1258
+ humanLoopSubmitText(chatId, text) {
1259
+ const prompt = this.pendingHumanLoopPrompt(chatId);
1260
+ if (!prompt)
1261
+ return null;
1262
+ if (!isHumanLoopAwaitingText(prompt))
1263
+ return null;
1264
+ const result = setHumanLoopText(prompt, text);
1265
+ if (result.completed)
1266
+ this.resolveHumanLoopPrompt(prompt.promptId);
1267
+ return { prompt, ...result };
1268
+ }
1269
+ humanLoopCancel(promptId, reason = 'Prompt cancelled.') {
1270
+ return this.clearHumanLoopPrompt(promptId, new Error(reason));
1271
+ }
1272
+ humanLoopCurrentQuestion(promptId) {
1273
+ const prompt = this.humanLoopPrompts.get(promptId);
1274
+ return prompt ? currentHumanLoopQuestion(prompt) : null;
1275
+ }
1276
+ humanLoopPrompt(promptId) {
1277
+ return this.humanLoopPrompts.get(promptId) || null;
1278
+ }
1279
+ removeHumanLoopPromptFromChat(chatId, promptId) {
1280
+ const chatKey = String(chatId);
1281
+ const promptIds = this.humanLoopPromptIdsByChat.get(chatKey) || [];
1282
+ const next = promptIds.filter(id => id !== promptId);
1283
+ if (next.length)
1284
+ this.humanLoopPromptIdsByChat.set(chatKey, next);
1285
+ else
1286
+ this.humanLoopPromptIdsByChat.delete(chatKey);
1287
+ }
1288
+ /**
1289
+ * Create an interaction handler that bridges agent requests to the human-loop
1290
+ * state machine and pushes SSE events to the dashboard.
1291
+ *
1292
+ * IM channel subclasses override `renderInteractionPrompt()` to render
1293
+ * buttons/cards in their native UI. Dashboard clients receive the
1294
+ * `interaction` SSE event and respond via REST.
1295
+ */
1296
+ createInteractionHandler(chatId, taskId) {
1297
+ return async (request) => {
1298
+ const active = this.beginHumanLoopPrompt({
1299
+ taskId,
1300
+ chatId,
1301
+ title: request.title,
1302
+ hint: request.hint,
1303
+ questions: request.questions,
1304
+ resolveWith: request.resolveWith,
1305
+ });
1306
+ const interactionSnapshot = {
1307
+ promptId: active.prompt.promptId,
1308
+ kind: request.kind,
1309
+ title: request.title,
1310
+ hint: request.hint,
1311
+ questions: request.questions,
1312
+ currentIndex: active.prompt.currentIndex,
1313
+ };
1314
+ // Resolve sessionKey live at emit time — the task entry tracks promotion
1315
+ // (pending → native id), so a key captured at handler-creation time would
1316
+ // go stale on the very first turn of a fresh session and the dashboard
1317
+ // SSE event would land on an already-moved snapshot.
1318
+ const task = this.activeTasks.get(taskId);
1319
+ if (task)
1320
+ this.emitStream(task.sessionKey, { type: 'interaction', taskId, interaction: interactionSnapshot });
1321
+ // Dashboard sessions reply through SSE + REST (no IM render). When an IM
1322
+ // bot is also attached, its renderInteractionPrompt override would still
1323
+ // fire here with chatId='dashboard' — sending the prompt to the IM API
1324
+ // with an invalid receive_id, which surfaces as "Request failed with
1325
+ // status code 400" from the axios-based SDK. Skip the render for
1326
+ // dashboard chats; the SSE event is the canonical delivery.
1327
+ if (chatId !== 'dashboard') {
1328
+ try {
1329
+ await this.renderInteractionPrompt(active.prompt, chatId);
1330
+ }
1331
+ catch (error) {
1332
+ this.humanLoopCancel(active.prompt.promptId, error?.message || 'Failed to send prompt.');
1333
+ throw error;
1334
+ }
1335
+ }
1336
+ return active.result;
1337
+ };
1338
+ }
1339
+ /**
1340
+ * Render an interaction prompt in the IM channel.
1341
+ * Override in channel subclasses (Telegram, Feishu, etc.).
1342
+ * Dashboard-only sessions (chatId='dashboard') are a no-op by default.
1343
+ */
1344
+ async renderInteractionPrompt(_prompt, _chatId) {
1345
+ // Default: no-op (dashboard-only sessions use SSE events instead)
1346
+ }
1347
+ // ---- Public interaction API (used by dashboard routes) --------------------
1348
+ /** Respond to a pending interaction prompt with a selected option. */
1349
+ interactionSelectOption(promptId, optionValue, opts) {
1350
+ return this.humanLoopSelectOption(promptId, optionValue, opts);
1351
+ }
1352
+ /** Submit freeform text to a pending interaction prompt. */
1353
+ interactionSubmitText(promptId, text) {
1354
+ const prompt = this.humanLoopPrompt(promptId);
1355
+ if (!prompt)
1356
+ return null;
1357
+ // The dashboard modal submits a custom answer via an explicit Submit button,
1358
+ // so accept the text whenever the current question PERMITS freeform — not
1359
+ // only after an "Other" chip flipped `awaitingFreeform` (the IM-card flow,
1360
+ // which `isHumanLoopAwaitingText` gates on). An options question only allows
1361
+ // it when `allowFreeform` is set; an option-less question is freeform by
1362
+ // definition. (The IM passive-text path keeps the stricter check so a normal
1363
+ // chat message isn't silently captured as an answer.)
1364
+ const question = currentHumanLoopQuestion(prompt);
1365
+ if (!question)
1366
+ return null;
1367
+ const hasOptions = !!question.options?.length;
1368
+ const freeformAllowed = !hasOptions || question.allowFreeform !== false;
1369
+ if (!freeformAllowed && !isHumanLoopAwaitingText(prompt))
1370
+ return null;
1371
+ const result = setHumanLoopText(prompt, text);
1372
+ if (result.completed)
1373
+ this.resolveHumanLoopPrompt(prompt.promptId);
1374
+ return { prompt, ...result };
1375
+ }
1376
+ /** Skip the current question in a pending interaction prompt. */
1377
+ interactionSkip(promptId) {
1378
+ return this.humanLoopSkip(promptId);
1379
+ }
1380
+ /** Cancel a pending interaction prompt. */
1381
+ interactionCancel(promptId, reason = 'Cancelled from dashboard.') {
1382
+ return this.humanLoopCancel(promptId, reason);
1383
+ }
1384
+ /** Get a specific interaction prompt by ID. */
1385
+ interactionPrompt(promptId) {
1386
+ return this.humanLoopPrompt(promptId);
1387
+ }
1388
+ selectedSession(chatId) {
1389
+ return this.getSelectedSession(this.chat(chatId));
1390
+ }
1391
+ submitSessionTask(opts) {
1392
+ const session = this.upsertSessionRuntime({
1393
+ agent: opts.agent,
1394
+ sessionId: opts.sessionId,
1395
+ workdir: opts.workdir,
1396
+ workspacePath: null,
1397
+ // Only override when explicitly provided — undefined skips the overwrite in upsertSessionRuntime
1398
+ ...(opts.modelId !== undefined ? { modelId: opts.modelId } : {}),
1399
+ ...(opts.thinkingEffort !== undefined ? { thinkingEffort: opts.thinkingEffort } : {}),
1400
+ ...(opts.handoverFrom !== undefined ? { handoverFrom: opts.handoverFrom } : {}),
1401
+ });
1402
+ const taskId = `ext-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1403
+ const prompt = opts.prompt.trim();
1404
+ const attachments = opts.attachments || [];
1405
+ const chatId = opts.chatId ?? 'dashboard';
1406
+ this.beginTask({
1407
+ taskId,
1408
+ chatId,
1409
+ agent: session.agent,
1410
+ sessionKey: session.key,
1411
+ prompt,
1412
+ attachments,
1413
+ startedAt: Date.now(),
1414
+ sourceMessageId: opts.sourceMessageId ?? taskId,
1415
+ });
1416
+ this.emitStreamQueued(session.key, taskId);
1417
+ void this.queueSessionTask(session, async () => {
1418
+ const abortController = new AbortController();
1419
+ const task = this.markTaskRunning(taskId, () => abortController.abort());
1420
+ if (task?.cancelled) {
1421
+ this.emitStreamCancelled(taskId, session.key);
1422
+ this.finishTask(taskId);
1423
+ return;
1424
+ }
1425
+ this.emitStreamStart(taskId, session);
1426
+ // Wire up IM rendering for non-dashboard chats so /goal-driven tasks stream
1427
+ // to the same channel that submitted them, matching handleMessage's UX.
1428
+ const presenter = chatId !== 'dashboard'
1429
+ ? await this.createImTaskPresenter({
1430
+ chatId, taskId, session, agent: session.agent, prompt, attachments,
1431
+ }).catch(err => {
1432
+ this.warn(`[submitSessionTask] presenter setup failed task=${taskId}: ${err?.message || err}`);
1433
+ return null;
1434
+ })
1435
+ : null;
1436
+ try {
1437
+ const result = await this.runStream(prompt, session, attachments, (text, thinking, activity, meta, plan) => {
1438
+ opts.onText?.(text, thinking, activity, meta, plan);
1439
+ presenter?.onText(text, thinking, activity, meta, plan);
1440
+ this.emitStreamText(taskId, session.key, text, thinking, activity, meta, plan);
1441
+ }, undefined, undefined, abortController.signal, this.createInteractionHandler(chatId, taskId), undefined, undefined, (opts.forkOf || opts.workflowEnabled !== undefined)
1442
+ ? { ...(opts.forkOf ? { forkOf: opts.forkOf } : {}), ...(opts.workflowEnabled !== undefined ? { workflowEnabled: opts.workflowEnabled } : {}) }
1443
+ : undefined);
1444
+ this.emitStreamDone(taskId, session.key, {
1445
+ sessionId: result.sessionId || session.sessionId,
1446
+ incomplete: !!result.incomplete,
1447
+ ...(result.ok ? {} : { error: result.error || result.message }),
1448
+ });
1449
+ if (presenter) {
1450
+ try {
1451
+ await presenter.onSuccess(result);
1452
+ }
1453
+ catch (e) {
1454
+ this.warn(`[submitSessionTask] presenter onSuccess failed task=${taskId}: ${e?.message || e}`);
1455
+ }
1456
+ }
1457
+ try {
1458
+ this.maybeEnqueueGoalContinuation(session, opts, result);
1459
+ }
1460
+ catch (err) {
1461
+ this.debug(`[goal-continuation] enqueue failed: ${err?.message || err}`);
1462
+ }
1463
+ }
1464
+ catch (error) {
1465
+ const errMsg = error?.message || String(error);
1466
+ this.emitStreamDone(taskId, session.key, {
1467
+ sessionId: session.sessionId,
1468
+ incomplete: true,
1469
+ error: errMsg,
1470
+ });
1471
+ if (presenter) {
1472
+ try {
1473
+ await presenter.onFailure(errMsg);
1474
+ }
1475
+ catch (e) {
1476
+ this.warn(`[submitSessionTask] presenter onFailure failed task=${taskId}: ${e?.message || e}`);
1477
+ }
1478
+ }
1479
+ }
1480
+ finally {
1481
+ presenter?.dispose();
1482
+ this.finishTask(taskId);
1483
+ this.syncSelectedChats(session);
1484
+ }
1485
+ }, taskId).catch(error => {
1486
+ this.finishTask(taskId);
1487
+ this.error(`[submitSessionTask] queue failed task=${taskId} error=${error?.message || error}`);
1488
+ });
1489
+ return { ok: true, taskId, sessionKey: session.key, queued: true };
1490
+ }
1491
+ /**
1492
+ * Channel hook — returns a presenter that streams the task's runStream
1493
+ * output to the IM chat that submitted it. Default: null (dashboard-only
1494
+ * chats and channels that haven't opted in stay silent in IM).
1495
+ */
1496
+ async createImTaskPresenter(_opts) {
1497
+ return null;
1498
+ }
1499
+ /**
1500
+ * Goal continuation: after a turn ends, if a goal is still active for the
1501
+ * session, account token + wall-clock usage, then enqueue one more task with
1502
+ * the rendered continuation prompt. If the budget was just crossed, enqueue a
1503
+ * single wrap-up turn with the budget-limit prompt instead. Goal-continuation
1504
+ * tasks that get cancelled or errored auto-pause the goal so the loop does
1505
+ * not silently resume on the user's next message.
1506
+ *
1507
+ * Codex and Claude sessions short-circuit: each runs its own native `/goal`
1508
+ * lifecycle (codex's app-server state machine; claude's in-process Stop
1509
+ * hook), so pikiloop stays out to avoid a double loop. See setSessionGoal
1510
+ * et al — they bridge to codex's `thread/goal/*` RPC and to claude's
1511
+ * `/goal <condition>` slash command instead of writing pikiloop's goal.json.
1512
+ */
1513
+ maybeEnqueueGoalContinuation(session, opts, result) {
1514
+ if (session.agent === 'codex' || session.agent === 'claude')
1515
+ return;
1516
+ const sessionId = (result.sessionId || session.sessionId || '').trim();
1517
+ if (!sessionId || isPendingSessionId(sessionId))
1518
+ return;
1519
+ const workdir = session.workdir;
1520
+ const agent = session.agent;
1521
+ const goalBefore = readGoal(workdir, agent, sessionId);
1522
+ if (!goalBefore)
1523
+ return;
1524
+ if (!result.ok || result.incomplete) {
1525
+ if (opts.goalContinuation && goalBefore.status === 'active') {
1526
+ pauseGoal(workdir, agent, sessionId);
1527
+ this.debug(`[goal-continuation] paused goal=${goalBefore.goalId} after failed continuation`);
1528
+ }
1529
+ return;
1530
+ }
1531
+ if (goalBefore.status !== 'active')
1532
+ return;
1533
+ const usedTokens = Math.max(0, (result.inputTokens || 0) + (result.outputTokens || 0));
1534
+ const seconds = Math.max(0, Math.floor(result.elapsedS || 0));
1535
+ const { goal, budgetJustCrossed } = accountTurn(workdir, agent, sessionId, {
1536
+ tokens: usedTokens,
1537
+ seconds,
1538
+ });
1539
+ if (!goal)
1540
+ return;
1541
+ if (budgetJustCrossed) {
1542
+ const prompt = renderBudgetLimitPrompt(goal);
1543
+ this.debug(`[goal-continuation] budget exhausted goal=${goal.goalId} — enqueue wrap-up turn`);
1544
+ this.submitSessionTask({
1545
+ agent,
1546
+ sessionId,
1547
+ workdir,
1548
+ prompt,
1549
+ chatId: opts.chatId,
1550
+ modelId: opts.modelId,
1551
+ thinkingEffort: opts.thinkingEffort,
1552
+ goalContinuation: { kind: 'budget_wrapup', goalId: goal.goalId },
1553
+ });
1554
+ return;
1555
+ }
1556
+ const decision = shouldContinueAfterTurn(goal);
1557
+ if (!decision.shouldContinue) {
1558
+ this.debug(`[goal-continuation] stop goal=${goal.goalId} reason=${decision.reason}`);
1559
+ return;
1560
+ }
1561
+ const updated = bumpContinuationCount(workdir, agent, sessionId);
1562
+ if (!updated)
1563
+ return;
1564
+ const prompt = renderContinuationPrompt(updated);
1565
+ this.debug(`[goal-continuation] continue goal=${updated.goalId} count=${updated.continuationCount} tokens=${updated.tokensUsed}/${updated.tokenBudget ?? '∞'}`);
1566
+ this.submitSessionTask({
1567
+ agent,
1568
+ sessionId,
1569
+ workdir,
1570
+ prompt,
1571
+ chatId: opts.chatId,
1572
+ modelId: opts.modelId,
1573
+ thinkingEffort: opts.thinkingEffort,
1574
+ goalContinuation: { kind: 'continuation', goalId: updated.goalId },
1575
+ });
1576
+ }
1577
+ /**
1578
+ * Normalized goal view used by IM/dashboard renderers — same shape regardless
1579
+ * of whether the source is pikiloop's goal.json (claude / gemini / …) or
1580
+ * codex's native SQLite state machine.
1581
+ */
1582
+ // SessionGoalView is exported below the class.
1583
+ /**
1584
+ * Read the current goal for a session. For codex this hits codex's native
1585
+ * `thread/goal/get`; for other drivers, reads goal.json.
1586
+ */
1587
+ async getSessionGoal(workdir, agent, sessionId) {
1588
+ if (agent === 'codex') {
1589
+ if (!sessionId || isPendingSessionId(sessionId))
1590
+ return null;
1591
+ const goal = await getCodexGoal(sessionId);
1592
+ return goal ? normalizeFromCodex(goal) : null;
1593
+ }
1594
+ if (agent === 'claude') {
1595
+ if (!sessionId || isPendingSessionId(sessionId))
1596
+ return null;
1597
+ const goal = getClaudeNativeGoal(workdir, sessionId);
1598
+ return goal ? normalizeFromClaudeNative(goal) : null;
1599
+ }
1600
+ const goal = readGoal(workdir, agent, sessionId);
1601
+ return goal ? normalizeFromPikiloop(goal) : null;
1602
+ }
1603
+ /**
1604
+ * Set (or replace) the goal for a session. For codex this routes through
1605
+ * codex's native `thread/goal/set` and codex auto-starts a continuation turn
1606
+ * internally. For other drivers, pikiloop writes goal.json and enqueues the
1607
+ * first continuation turn so the agent starts working immediately.
1608
+ */
1609
+ async setSessionGoal(workdir, agent, sessionId, opts) {
1610
+ if (agent === 'codex') {
1611
+ if (!sessionId || isPendingSessionId(sessionId)) {
1612
+ throw new Error('codex session must exist before /goal — send a first message to create the thread');
1613
+ }
1614
+ const resp = await setCodexGoal({
1615
+ threadId: sessionId,
1616
+ objective: opts.objective,
1617
+ status: 'active',
1618
+ tokenBudget: opts.tokenBudget ?? null,
1619
+ });
1620
+ if (!resp.ok)
1621
+ throw new Error(resp.error);
1622
+ // codex returns a snapshot; if for some reason it's null, re-fetch.
1623
+ const goal = resp.goal ?? (await getCodexGoal(sessionId));
1624
+ if (!goal)
1625
+ throw new Error('codex did not return a goal snapshot');
1626
+ return normalizeFromCodex(goal);
1627
+ }
1628
+ if (agent === 'claude') {
1629
+ if (!sessionId || isPendingSessionId(sessionId)) {
1630
+ throw new Error('claude session must exist before /goal — send a first message to create the transcript');
1631
+ }
1632
+ // Native /goal owns its own continuation engine (Stop hook). pikiloop
1633
+ // just submits the slash command as the next task; claude internally
1634
+ // sets up the goal_status attachment, injects its meta directive, and
1635
+ // keeps looping until the Haiku completion check returns met. Token
1636
+ // budget is accepted in the API for shape parity with codex/portable
1637
+ // but ignored — claude native /goal has no budget concept.
1638
+ const objective = opts.objective.trim();
1639
+ if (!objective)
1640
+ throw new Error('objective must be non-empty');
1641
+ this.submitSessionTask({
1642
+ agent,
1643
+ sessionId,
1644
+ workdir,
1645
+ prompt: buildClaudeSetGoalPrompt(objective),
1646
+ chatId: opts.chatId,
1647
+ modelId: opts.modelId,
1648
+ thinkingEffort: opts.thinkingEffort,
1649
+ });
1650
+ // Return an optimistic snapshot — the actual goal_status attachment is
1651
+ // written by claude during the task; readers can poll getSessionGoal.
1652
+ return normalizeFromClaudeNative({
1653
+ condition: objective,
1654
+ status: 'active',
1655
+ met: false,
1656
+ updatedAtMs: Date.now(),
1657
+ });
1658
+ }
1659
+ const goal = setGoalState(workdir, agent, sessionId, {
1660
+ objective: opts.objective,
1661
+ tokenBudget: opts.tokenBudget ?? null,
1662
+ });
1663
+ if (!isPendingSessionId(sessionId)) {
1664
+ const prompt = renderContinuationPrompt(goal);
1665
+ this.submitSessionTask({
1666
+ agent,
1667
+ sessionId,
1668
+ workdir,
1669
+ prompt,
1670
+ chatId: opts.chatId,
1671
+ modelId: opts.modelId,
1672
+ thinkingEffort: opts.thinkingEffort,
1673
+ goalContinuation: { kind: 'continuation', goalId: goal.goalId },
1674
+ });
1675
+ }
1676
+ return normalizeFromPikiloop(goal);
1677
+ }
1678
+ async pauseSessionGoal(workdir, agent, sessionId) {
1679
+ if (agent === 'codex') {
1680
+ if (!sessionId || isPendingSessionId(sessionId))
1681
+ return null;
1682
+ const resp = await pauseCodexGoal(sessionId);
1683
+ if (!resp.ok)
1684
+ throw new Error(resp.error);
1685
+ const goal = resp.goal ?? (await getCodexGoal(sessionId));
1686
+ return goal ? normalizeFromCodex(goal) : null;
1687
+ }
1688
+ if (agent === 'claude') {
1689
+ // Claude's native /goal exposes no pause/resume — only set and clear.
1690
+ // Surface a clear error so the IM layer can render a friendly message.
1691
+ throw new Error('Claude native /goal does not support pause/resume — only `/goal clear`. Re-issue `/goal <objective>` to start fresh.');
1692
+ }
1693
+ const goal = pauseGoal(workdir, agent, sessionId);
1694
+ return goal ? normalizeFromPikiloop(goal) : null;
1695
+ }
1696
+ async resumeSessionGoal(workdir, agent, sessionId, opts = {}) {
1697
+ if (agent === 'codex') {
1698
+ if (!sessionId || isPendingSessionId(sessionId))
1699
+ return null;
1700
+ const resp = await resumeCodexGoal(sessionId);
1701
+ if (!resp.ok)
1702
+ throw new Error(resp.error);
1703
+ const goal = resp.goal ?? (await getCodexGoal(sessionId));
1704
+ return goal ? normalizeFromCodex(goal) : null;
1705
+ }
1706
+ if (agent === 'claude') {
1707
+ throw new Error('Claude native /goal does not support pause/resume — re-issue `/goal <objective>` to start fresh.');
1708
+ }
1709
+ const goal = resumeGoal(workdir, agent, sessionId);
1710
+ if (!goal || goal.status !== 'active')
1711
+ return goal ? normalizeFromPikiloop(goal) : null;
1712
+ if (!isPendingSessionId(sessionId)) {
1713
+ const prompt = renderContinuationPrompt(goal);
1714
+ this.submitSessionTask({
1715
+ agent,
1716
+ sessionId,
1717
+ workdir,
1718
+ prompt,
1719
+ chatId: opts.chatId,
1720
+ modelId: opts.modelId,
1721
+ thinkingEffort: opts.thinkingEffort,
1722
+ goalContinuation: { kind: 'continuation', goalId: goal.goalId },
1723
+ });
1724
+ }
1725
+ return normalizeFromPikiloop(goal);
1726
+ }
1727
+ async clearSessionGoal(workdir, agent, sessionId, opts = {}) {
1728
+ if (agent === 'codex') {
1729
+ if (!sessionId || isPendingSessionId(sessionId))
1730
+ return;
1731
+ const resp = await clearCodexGoal(sessionId);
1732
+ if (!resp.ok)
1733
+ throw new Error(resp.error);
1734
+ return;
1735
+ }
1736
+ if (agent === 'claude') {
1737
+ if (!sessionId || isPendingSessionId(sessionId))
1738
+ return;
1739
+ // Read goal-status first to avoid spawning a no-op turn when nothing is set.
1740
+ const existing = getClaudeNativeGoal(workdir, sessionId);
1741
+ if (!existing)
1742
+ return;
1743
+ this.submitSessionTask({
1744
+ agent,
1745
+ sessionId,
1746
+ workdir,
1747
+ prompt: buildClaudeClearGoalPrompt(),
1748
+ chatId: opts.chatId,
1749
+ modelId: opts.modelId,
1750
+ thinkingEffort: opts.thinkingEffort,
1751
+ });
1752
+ return;
1753
+ }
1754
+ clearGoalState(workdir, agent, sessionId);
1755
+ }
1756
+ cancelTask(taskId) {
1757
+ const task = this.activeTasks.get(taskId) || null;
1758
+ if (!task)
1759
+ return { task: null, interrupted: false, cancelled: false };
1760
+ if (task.status === 'queued') {
1761
+ task.cancelled = true;
1762
+ this.emitStream(task.sessionKey, { type: 'cancelled', taskId });
1763
+ return { task, interrupted: false, cancelled: true };
1764
+ }
1765
+ if (task.status === 'running') {
1766
+ task.cancelled = true;
1767
+ try {
1768
+ task.abort?.();
1769
+ }
1770
+ catch { }
1771
+ return { task, interrupted: true, cancelled: false };
1772
+ }
1773
+ return { task, interrupted: false, cancelled: false };
1774
+ }
1775
+ async steerTask(taskId) {
1776
+ const task = this.activeTasks.get(taskId) || null;
1777
+ if (!task || task.status !== 'queued')
1778
+ return { task, interrupted: false, steered: false };
1779
+ this.markQueueDeferralsForSteer(taskId);
1780
+ const interrupted = this.interruptRunningTask(task.sessionKey, { freezePreview: true });
1781
+ return { task, interrupted, steered: interrupted || !!task };
1782
+ }
1783
+ /**
1784
+ * Stop only the currently running task for a session. Queued tasks are
1785
+ * intentionally left intact and run normally once the chain advances — the
1786
+ * stop button means "abort what's running right now", not "throw away the
1787
+ * queue". To drop a specific queued entry, use the per-row × button which
1788
+ * routes through `cancelTask`.
1789
+ */
1790
+ stopAllSessionTasks(sessionKey) {
1791
+ return this.stopTasksForSession(sessionKey);
1792
+ }
1793
+ /**
1794
+ * Public "start a fresh session" entry point — wired to the "+ New" button
1795
+ * and the `/new` command. Only clears the chat's session selection so the
1796
+ * next user message lands in a fresh session; the previously selected
1797
+ * session keeps running independently (matching dashboard behaviour, where
1798
+ * each session is its own card and is never aborted by creating another).
1799
+ * Use `cancelTask` / `/stop` to actually interrupt a running task.
1800
+ */
1801
+ resetConversationForChat(chatId) {
1802
+ const cs = this.chat(chatId);
1803
+ this.resetChatConversation(cs);
1804
+ }
1805
+ adoptExistingSessionForChat(chatId, session) {
1806
+ const cs = this.chat(chatId);
1807
+ this.adoptSession(cs, session);
1808
+ return this.getSelectedSession(cs);
1809
+ }
1810
+ /**
1811
+ * Resume an existing session in a chat and restore the agent's persistent
1812
+ * model / effort / BYOK Profile binding so the next stream — and the IM
1813
+ * picker chips — match the session that was just adopted. This is the
1814
+ * shared "click a row from the workspace list" path used by both the
1815
+ * interactive selector and the text-command `/sessions <#>` flow.
1816
+ */
1817
+ resumeSessionForChat(chatId, session) {
1818
+ const runtime = this.adoptExistingSessionForChat(chatId, session);
1819
+ if (session.model) {
1820
+ this.switchModelForChat(chatId, session.model, session.profileId ?? null);
1821
+ }
1822
+ else if (session.profileId !== undefined) {
1823
+ this.switchModelForChat(chatId, this.modelForAgent(session.agent), null);
1824
+ }
1825
+ if (session.thinkingEffort) {
1826
+ this.switchEffortForChat(chatId, session.thinkingEffort);
1827
+ }
1828
+ return runtime;
1829
+ }
1830
+ switchAgentForChat(chatId, agent) {
1831
+ const cs = this.chat(chatId);
1832
+ if (cs.agent === agent)
1833
+ return false;
1834
+ // Capture the live session of the *outgoing* agent so the next message to
1835
+ // the new agent can replay it as a handover. We capture BEFORE flipping
1836
+ // cs.agent so the ref is honest about which agent it points at.
1837
+ const prevAgent = cs.agent;
1838
+ const prevSessionId = cs.sessionId && !isPendingSessionId(cs.sessionId) ? cs.sessionId : null;
1839
+ cs.agent = agent;
1840
+ // Pre-existing session of the new agent in this thread — back-and-forth
1841
+ // toggling resumes it without handover. The user's intent is "continue what
1842
+ // I had", not "translate cross-agent".
1843
+ const resumed = this.findThreadSessionRuntime(chatId, cs.activeThreadId, agent);
1844
+ if (resumed) {
1845
+ cs.pendingHandoverFrom = null;
1846
+ this.applySessionSelection(cs, resumed);
1847
+ this.log(`agent switched to ${agent} chat=${chatId} resumed=${resumed.sessionId}`);
1848
+ return true;
1849
+ }
1850
+ // No existing session of the new agent → next message will stage a fresh
1851
+ // one. Park the outgoing session as the handover source. If the outgoing
1852
+ // agent had no live session (e.g. the user is rapidly toggling agents
1853
+ // before sending anything), keep any already-pending handover so the
1854
+ // original source isn't lost across intermediate switches.
1855
+ if (prevSessionId) {
1856
+ cs.pendingHandoverFrom = { agent: prevAgent, sessionId: prevSessionId };
1857
+ }
1858
+ this.resetChatConversation(cs, { clearThread: false });
1859
+ this.log(`agent switched to ${agent} chat=${chatId} handoverFrom=${describeHandoverRef(cs.pendingHandoverFrom)}`);
1860
+ return true;
1861
+ }
1862
+ /**
1863
+ * Switch the active model for a chat. Supports both native (agent CLI's own
1864
+ * auth) and BYOK Profile selections:
1865
+ * - `profileId === undefined` (default) — set native model only; pre-union
1866
+ * callers (text-command channels) keep working unchanged.
1867
+ * - `profileId === null` — explicit clear: drop any active Profile, fall
1868
+ * back to native model.
1869
+ * - `profileId === '<uuid>'` — bind that Profile; `modelId` should match
1870
+ * the Profile's modelId so display surfaces stay in sync.
1871
+ *
1872
+ * The native model field (`agentConfigs[agent].model`) always tracks the
1873
+ * effective model id used by the agent CLI — when a Profile is bound, this
1874
+ * lets `modelForAgent()` return the right display string without an extra
1875
+ * lookup. When unbinding, we leave the field alone so the user's prior
1876
+ * native pick is preserved.
1877
+ */
1878
+ switchModelForChat(chatId, modelId, profileId) {
1879
+ const cs = this.chat(chatId);
1880
+ // Update activeProfileByAgent first — resolveSessionStreamConfig downstream
1881
+ // reads it via getActiveProfile() during spawn.
1882
+ if (profileId !== undefined) {
1883
+ setActiveProfile(cs.agent, profileId || null);
1884
+ }
1885
+ this.setModelForAgent(cs.agent, modelId);
1886
+ cs.modelId = modelId;
1887
+ const session = this.getSelectedSession(cs);
1888
+ if (session)
1889
+ session.modelId = modelId;
1890
+ this.persistAgentPreference(cs.agent, 'model', modelId);
1891
+ const profileTag = profileId === undefined
1892
+ ? ''
1893
+ : profileId
1894
+ ? ` profile=${profileId}`
1895
+ : ' profile=(cleared)';
1896
+ this.log(`model switched to ${modelId} for ${cs.agent} chat=${chatId} session=${cs.activeSessionKey || '(none)'}${profileTag}`);
1897
+ }
1898
+ /**
1899
+ * The Profile id currently bound to this agent, if any. Used by the IM
1900
+ * picker to flag "current selection" when the user has a Profile bound —
1901
+ * since multiple Profiles may share the same modelId, a model-id match
1902
+ * alone is ambiguous.
1903
+ */
1904
+ activeProfileIdForAgent(agent) {
1905
+ return getActiveProfileId(agent);
1906
+ }
1907
+ switchEffortForChat(chatId, effort) {
1908
+ const cs = this.chat(chatId);
1909
+ // "ultra" is a synthetic top rung in the effort picker, NOT a real --effort
1910
+ // value (the claude CLI rejects anything outside low|medium|high|xhigh|max).
1911
+ // It bundles "max reasoning depth + permit multi-agent Workflow
1912
+ // orchestration" — the same pairing as Claude's own `ultracode` mode. Decode
1913
+ // it here, the single apply choke point, so the rest of the pipeline only
1914
+ // ever sees a concrete effort value plus the orthogonal workflow flag.
1915
+ // Because the rungs are mutually exclusive, picking any concrete level also
1916
+ // clears the workflow opt-in (capability-gated — only claude advertises it).
1917
+ const ultra = effort === 'ultra';
1918
+ const realEffort = ultra ? 'max' : effort;
1919
+ this.setEffortForAgent(cs.agent, realEffort);
1920
+ const session = this.getSelectedSession(cs);
1921
+ if (session)
1922
+ session.thinkingEffort = realEffort;
1923
+ this.persistAgentPreference(cs.agent, 'effort', realEffort);
1924
+ if (getDriverCapabilities(cs.agent).workflow) {
1925
+ this.setWorkflowEnabledForAgent(cs.agent, ultra);
1926
+ this.persistAgentPreference(cs.agent, 'workflow', ultra ? '1' : '0');
1927
+ }
1928
+ this.log(`effort switched to ${effort} (effort=${realEffort}, workflow=${ultra}) for ${cs.agent} chat=${chatId}`);
1929
+ }
1930
+ /**
1931
+ * Effort value to *display* in the picker. Workflow is orthogonal under the
1932
+ * hood, but the UI folds "max depth + workflow on" into the single synthetic
1933
+ * `ultra` rung (see {@link switchEffortForChat}), so report it as current when
1934
+ * the agent has orchestration enabled. Mirrors the decomposition above.
1935
+ */
1936
+ effortSelectionForAgent(agent) {
1937
+ const effort = this.effortForAgent(agent);
1938
+ if (!effort)
1939
+ return null;
1940
+ if (getDriverCapabilities(agent).workflow && this.workflowEnabledForAgent(agent))
1941
+ return 'ultra';
1942
+ return effort;
1943
+ }
1944
+ switchPermissionModeForChat(chatId, mode) {
1945
+ const cs = this.chat(chatId);
1946
+ if (cs.agent === 'claude') {
1947
+ this.agentConfigs.claude.permissionMode = mode;
1948
+ this.resetChatConversation(cs);
1949
+ this.log(`permission mode switched to ${mode} for claude chat=${chatId}`);
1950
+ }
1951
+ }
1952
+ /**
1953
+ * Toggle multi-agent Workflow orchestration for the chat's current agent.
1954
+ * Unlike permission-mode it does NOT reset the conversation — the tool set is
1955
+ * resolved per-invocation, so the change cleanly takes effect on the next
1956
+ * turn without invalidating the session transcript. No-op for agents whose
1957
+ * driver doesn't advertise the capability.
1958
+ */
1959
+ switchWorkflowForChat(chatId, enabled) {
1960
+ const cs = this.chat(chatId);
1961
+ if (!getDriverCapabilities(cs.agent).workflow) {
1962
+ this.log(`workflow toggle ignored: ${cs.agent} does not support orchestration`);
1963
+ return;
1964
+ }
1965
+ this.setWorkflowEnabledForAgent(cs.agent, enabled);
1966
+ this.persistAgentPreference(cs.agent, 'workflow', enabled ? '1' : '0');
1967
+ this.log(`workflow ${enabled ? 'enabled' : 'disabled'} for ${cs.agent} chat=${chatId}`);
1968
+ }
1969
+ modelForAgent(agent) {
1970
+ // For agents whose CLIs cannot switch model via flags (Hermes uses ACP
1971
+ // session/set_model, which only fires when a BYOK Profile is bound), the
1972
+ // active Profile is the only meaningful source of truth — falling back to
1973
+ // `agentConfigs[agent].model` would surface a stale value the runtime
1974
+ // never actually uses. For agents with native model selectors
1975
+ // (Claude/Codex/Gemini), the user-config field is still authoritative.
1976
+ if (agent === 'hermes') {
1977
+ const bound = getAgentBoundModelId('hermes');
1978
+ if (bound)
1979
+ return bound;
1980
+ }
1981
+ return this.agentConfigs[agent]?.model || '';
1982
+ }
1983
+ /**
1984
+ * Resolve the effective model + thinking effort that a stream for `cs` will run with.
1985
+ * Mirrors the fallback chain used inside runStream() so callers (e.g. submitSessionTask
1986
+ * emitting a 'start' event) can label the active turn before runStream resolves it.
1987
+ */
1988
+ resolveSessionStreamConfig(cs) {
1989
+ const agentConfig = this.agentConfigs[cs.agent] || {};
1990
+ const sessionWorkdir = cs.workdir || this.workdir;
1991
+ const storedConfig = cs.sessionId && !isPendingSessionId(cs.sessionId)
1992
+ ? getSessionStoredConfig(sessionWorkdir, cs.agent, cs.sessionId)
1993
+ : null;
1994
+ const model = (cs.modelId && cs.modelId.trim())
1995
+ || (storedConfig?.model || '')
1996
+ || this.modelForAgent(cs.agent)
1997
+ || null;
1998
+ const effortRaw = (cs.thinkingEffort && cs.thinkingEffort.trim().toLowerCase())
1999
+ || (storedConfig?.thinkingEffort || '')
2000
+ || agentConfig.reasoningEffort
2001
+ || 'high';
2002
+ const effort = cs.agent === 'gemini' ? null : (effortRaw || null);
2003
+ return { model: model || null, effort };
2004
+ }
2005
+ fetchSessions(agent, workdir) {
2006
+ return querySessions({ agent, workdir: workdir || this.workdir });
2007
+ }
2008
+ fetchSessionTail(agent, sessionId, limit, workdir = this.workdir) {
2009
+ return querySessionTail({ agent, sessionId, workdir, limit });
2010
+ }
2011
+ fetchAgents(options = {}) {
2012
+ return listAgents(options);
2013
+ }
2014
+ fetchSkills(workdir) {
2015
+ const wd = workdir || this.workdir;
2016
+ initializeProjectSkills(wd);
2017
+ return listSkills(wd);
2018
+ }
2019
+ fetchModels(agent, workdir) {
2020
+ const wd = workdir || this.workdir;
2021
+ // Provider-aware: when the agent is bound to a BYOK Profile, the
2022
+ // returned model list is the provider's enumerable models. This keeps
2023
+ // IM /models consistent with the dashboard agent card.
2024
+ return resolveAgentModels(agent, { workdir: wd, currentModel: this.modelForAgent(agent) });
2025
+ }
2026
+ setDefaultAgent(agent) {
2027
+ const next = normalizeAgent(agent);
2028
+ const prev = this.defaultAgent;
2029
+ this.defaultAgent = next;
2030
+ for (const [, cs] of this.chats) {
2031
+ if (cs.activeSessionKey || cs.sessionId)
2032
+ continue;
2033
+ if (cs.agent === prev)
2034
+ cs.agent = next;
2035
+ }
2036
+ this.log(`default agent changed to ${next}`);
2037
+ }
2038
+ setModelForAgent(agent, modelId) {
2039
+ const config = this.agentConfigs[agent];
2040
+ if (config)
2041
+ config.model = modelId;
2042
+ this.log(`model for ${agent} changed to ${modelId}`);
2043
+ }
2044
+ effortForAgent(agent) {
2045
+ return this.agentConfigs[agent]?.reasoningEffort || 'high';
2046
+ }
2047
+ setEffortForAgent(agent, effort) {
2048
+ const config = this.agentConfigs[agent];
2049
+ if (config)
2050
+ config.reasoningEffort = effort;
2051
+ this.log(`effort for ${agent} changed to ${effort}`);
2052
+ }
2053
+ workflowEnabledForAgent(agent) {
2054
+ return this.agentConfigs[agent]?.workflowEnabled ?? false;
2055
+ }
2056
+ setWorkflowEnabledForAgent(agent, enabled) {
2057
+ const config = this.agentConfigs[agent];
2058
+ if (config)
2059
+ config.workflowEnabled = enabled;
2060
+ this.log(`workflow for ${agent} changed to ${enabled}`);
2061
+ }
2062
+ /**
2063
+ * Switch Claude's access mode (subscription TUI vs `claude -p` Agent SDK
2064
+ * credits). Persisted preference — takes effect on the NEXT spawned turn
2065
+ * (in-flight streams keep their own opts); does not reset any conversation
2066
+ * since both modes resume the same native session transcript.
2067
+ */
2068
+ setClaudeAccessMode(mode) {
2069
+ const config = this.agentConfigs.claude;
2070
+ if (config)
2071
+ config.accessMode = mode;
2072
+ this.log(`claude access mode changed to ${mode}`);
2073
+ }
2074
+ persistAgentPreference(agent, kind, value) {
2075
+ try {
2076
+ // Hermes model writes go to the active BYOK Profile (the runtime's only
2077
+ // model-switching surface). Falls through to the legacy `hermesModel`
2078
+ // user-config field when no Profile is bound.
2079
+ if (kind === 'model' && agent === 'hermes' && setAgentBoundModelId('hermes', value))
2080
+ return;
2081
+ // Workflow orchestration opt-in is a boolean field, and only claude
2082
+ // advertises the capability, so it bypasses the string patch below.
2083
+ if (kind === 'workflow') {
2084
+ if (agent === 'claude')
2085
+ updateUserConfig({ claudeWorkflowEnabled: value === '1' });
2086
+ return;
2087
+ }
2088
+ const patch = {};
2089
+ if (kind === 'model') {
2090
+ if (agent === 'claude')
2091
+ patch.claudeModel = value;
2092
+ else if (agent === 'codex')
2093
+ patch.codexModel = value;
2094
+ else if (agent === 'gemini')
2095
+ patch.geminiModel = value;
2096
+ else if (agent === 'hermes')
2097
+ patch.hermesModel = value;
2098
+ }
2099
+ else {
2100
+ if (agent === 'claude')
2101
+ patch.claudeReasoningEffort = value;
2102
+ else if (agent === 'codex')
2103
+ patch.codexReasoningEffort = value;
2104
+ else if (agent === 'gemini')
2105
+ patch.geminiReasoningEffort = value;
2106
+ else if (agent === 'hermes')
2107
+ patch.hermesReasoningEffort = value;
2108
+ }
2109
+ if (Object.keys(patch).length)
2110
+ updateUserConfig(patch);
2111
+ }
2112
+ catch (e) {
2113
+ this.warn(`persistAgentPreference failed: ${e?.message || e}`);
2114
+ }
2115
+ }
2116
+ getStatusData(chatId) {
2117
+ const cs = this.chat(chatId);
2118
+ const selectedSession = this.getSelectedSession(cs);
2119
+ const selectedTask = this.runningTaskForSession(selectedSession?.key ?? null);
2120
+ const fallbackTask = selectedTask || [...this.activeTasks.values()]
2121
+ .sort((a, b) => a.startedAt - b.startedAt)[0] || null;
2122
+ const model = selectedSession?.modelId || this.modelForAgent(cs.agent);
2123
+ const mem = process.memoryUsage();
2124
+ return {
2125
+ version: VERSION, uptime: Date.now() - this.startedAt,
2126
+ memRss: mem.rss, memHeap: mem.heapUsed, pid: process.pid,
2127
+ workdir: this.chatWorkdir(chatId), agent: cs.agent, model, sessionId: cs.sessionId,
2128
+ workspacePath: cs.workspacePath ?? null,
2129
+ running: fallbackTask, activeTasksCount: this.activeTasks.size, stats: this.stats,
2130
+ usage: getUsage({ agent: cs.agent, model }),
2131
+ };
2132
+ }
2133
+ getHostData() {
2134
+ const cpus = os.cpus();
2135
+ const totalMem = os.totalmem(), freeMem = os.freemem();
2136
+ const memory = getHostMemoryUsageData(totalMem, freeMem);
2137
+ const cpuUsage = getHostCpuUsageData();
2138
+ const [loadOne, loadFive, loadFifteen] = os.loadavg();
2139
+ let disk = null;
2140
+ const battery = getHostBatteryData();
2141
+ try {
2142
+ if (process.platform === 'win32') {
2143
+ const driveLetter = this.workdir.charAt(0).toUpperCase();
2144
+ const psOut = execSync(`powershell -NoProfile -Command "Get-PSDrive -Name ${driveLetter} | ForEach-Object { [PSCustomObject]@{Used=$_.Used;Free=$_.Free} } | ConvertTo-Json"`, { encoding: 'utf-8', timeout: 5000 }).trim();
2145
+ const info = JSON.parse(psOut);
2146
+ if (info.Used != null && info.Free != null) {
2147
+ const used = Number(info.Used), free = Number(info.Free), total = used + free;
2148
+ const fmt = (b) => b >= 1e12 ? `${(b / 1e12).toFixed(1)}T` : b >= 1e9 ? `${(b / 1e9).toFixed(1)}G` : b >= 1e6 ? `${(b / 1e6).toFixed(1)}M` : `${Math.round(b / 1e3)}K`;
2149
+ disk = { used: fmt(used), total: fmt(total), percent: `${Math.round(used / total * 100)}%` };
2150
+ }
2151
+ }
2152
+ else {
2153
+ const df = execSync(`df -h "${this.workdir}" | tail -1`, { encoding: 'utf-8', timeout: 3000 }).trim().split(/\s+/);
2154
+ if (df.length >= 5)
2155
+ disk = { used: df[2], total: df[1], percent: df[4] };
2156
+ }
2157
+ }
2158
+ catch { }
2159
+ let topProcs = [];
2160
+ try {
2161
+ if (process.platform === 'win32') {
2162
+ topProcs = execSync(`powershell -NoProfile -Command "Get-Process | Sort-Object -Property CPU -Descending | Select-Object -First 5 | ForEach-Object { \\"$($_.Id) $([math]::Round($_.CPU)) $([math]::Round($_.WorkingSet64/1MB)) $($_.ProcessName)\\"" }"`, { encoding: 'utf-8', timeout: 5000 }).trim().split('\n');
2163
+ }
2164
+ else {
2165
+ topProcs = execSync(`ps -eo pid,pcpu,pmem,comm --sort=-pcpu 2>/dev/null | head -6 || ps -eo pid,%cpu,%mem,comm -r 2>/dev/null | head -6`, { encoding: 'utf-8', timeout: 3000 }).trim().split('\n');
2166
+ }
2167
+ }
2168
+ catch { }
2169
+ const mem = process.memoryUsage();
2170
+ return {
2171
+ hostName: getHostDisplayName(),
2172
+ cpuModel: cpus[0]?.model || 'unknown', cpuCount: cpus.length,
2173
+ cpuUsage,
2174
+ loadAverage: { one: loadOne, five: loadFive, fifteen: loadFifteen },
2175
+ totalMem, freeMem, memoryUsed: memory.usedBytes, memoryAvailable: memory.availableBytes, memoryPercent: memory.percent, memorySource: memory.source,
2176
+ disk, battery, topProcs,
2177
+ selfPid: process.pid, selfRss: mem.rss, selfHeap: mem.heapUsed,
2178
+ };
2179
+ }
2180
+ switchWorkdir(newPath, opts = {}) {
2181
+ const old = this.workdir;
2182
+ const resolvedPath = path.resolve(expandTilde(newPath));
2183
+ if (opts.persist !== false) {
2184
+ setUserWorkdir(resolvedPath, { notify: false });
2185
+ }
2186
+ else {
2187
+ process.env.PIKILOOP_WORKDIR = resolvedPath;
2188
+ }
2189
+ this.workdir = resolvedPath;
2190
+ for (const [, cs] of this.chats) {
2191
+ this.resetChatConversation(cs, { clearWorkdir: true });
2192
+ }
2193
+ for (const [key, session] of this.sessionStates) {
2194
+ if (session.workdir === old && !session.runningTaskIds.size)
2195
+ this.sessionStates.delete(key);
2196
+ }
2197
+ ensureGitignore(resolvedPath);
2198
+ initializeProjectSkills(resolvedPath);
2199
+ this.log(`switch workdir: ${old} -> ${resolvedPath}`);
2200
+ this.afterSwitchWorkdir(old, resolvedPath);
2201
+ return old;
2202
+ }
2203
+ afterSwitchWorkdir(_oldPath, _newPath) { }
2204
+ onManagedConfigChange(_config, _opts = {}) { }
2205
+ /**
2206
+ * Subclass entry point — connect to the channel and block on its
2207
+ * listen loop. Each channel implementation overrides this; calling it
2208
+ * on the base class is a programming error.
2209
+ */
2210
+ run() {
2211
+ throw new Error('Bot.run() must be implemented by a channel subclass');
2212
+ }
2213
+ /**
2214
+ * Subclass hook: tear down the channel transport so `run()` can resolve.
2215
+ * Subclasses override to disconnect their specific channel — the base
2216
+ * implementation only cleans up the bot-level subscriptions that don't
2217
+ * belong to any one channel.
2218
+ *
2219
+ * Used by ChannelSupervisor when a channel must be stopped or replaced
2220
+ * in-process (channel removal, credential rotation) without restarting
2221
+ * the entire pikiloop runtime.
2222
+ */
2223
+ requestStop() {
2224
+ this.userConfigUnsubscribe?.();
2225
+ this.userConfigUnsubscribe = null;
2226
+ }
2227
+ /**
2228
+ * Scan registered workspaces + the active workdir for sessions stuck in
2229
+ * 'running' state after a crash/restart and downgrade them to 'incomplete'.
2230
+ * Safe to call at any time — only touches records whose owning process is
2231
+ * no longer alive (or that have gone stale past the age threshold).
2232
+ */
2233
+ reconcileStaleRunningSessions() {
2234
+ const seen = new Set();
2235
+ const candidates = [this.workdir];
2236
+ try {
2237
+ for (const ws of loadWorkspaces())
2238
+ candidates.push(ws.path);
2239
+ }
2240
+ catch { }
2241
+ for (const candidate of candidates) {
2242
+ const resolved = path.resolve(candidate);
2243
+ if (seen.has(resolved))
2244
+ continue;
2245
+ seen.add(resolved);
2246
+ try {
2247
+ reconcileOrphanedRunningSessions(resolved);
2248
+ }
2249
+ catch { }
2250
+ }
2251
+ }
2252
+ refreshManagedConfig(config, opts = {}) {
2253
+ const nextWorkdir = resolveUserWorkdir({ config });
2254
+ if (opts.initial) {
2255
+ this.workdir = nextWorkdir;
2256
+ ensureGitignore(this.workdir);
2257
+ initializeProjectSkills(this.workdir);
2258
+ this.reconcileStaleRunningSessions();
2259
+ }
2260
+ else if (nextWorkdir !== this.workdir) {
2261
+ this.switchWorkdir(nextWorkdir, { persist: false });
2262
+ }
2263
+ // The configured value is a *preference* (baseline 'codex' when unset);
2264
+ // clamp it to an installed agent so a fresh machine whose preferred CLI
2265
+ // isn't installed still routes new conversations to one that can actually
2266
+ // run, instead of surfacing an uninstalled default.
2267
+ const nextDefaultAgent = resolveDefaultAgent(config.defaultAgent || 'codex', listAgents().agents);
2268
+ if (opts.initial)
2269
+ this.defaultAgent = nextDefaultAgent;
2270
+ else if (nextDefaultAgent !== this.defaultAgent)
2271
+ this.setDefaultAgent(nextDefaultAgent);
2272
+ for (const agent of ['claude', 'codex', 'gemini', 'hermes']) {
2273
+ const nextModel = resolveAgentModel(config, agent);
2274
+ if (nextModel && this.modelForAgent(agent) !== nextModel) {
2275
+ if (opts.initial)
2276
+ this.agentConfigs[agent].model = nextModel;
2277
+ else
2278
+ this.setModelForAgent(agent, nextModel);
2279
+ }
2280
+ const nextEffort = resolveAgentEffort(config, agent);
2281
+ if (nextEffort && this.effortForAgent(agent) !== nextEffort) {
2282
+ if (opts.initial)
2283
+ this.agentConfigs[agent].reasoningEffort = nextEffort;
2284
+ else
2285
+ this.setEffortForAgent(agent, nextEffort);
2286
+ }
2287
+ // Access mode (claude only) IS reconciled — unlike workflow, it's a
2288
+ // persisted preference, so an external setting.json edit or a dashboard
2289
+ // save (which both flow through here via onUserConfigChange) must push
2290
+ // the new value onto the running bot so the next turn spawns accordingly.
2291
+ if (agent === 'claude') {
2292
+ const nextAccessMode = resolveClaudeAccessMode(config);
2293
+ if (this.claudeAccessMode !== nextAccessMode) {
2294
+ if (opts.initial)
2295
+ this.agentConfigs.claude.accessMode = nextAccessMode;
2296
+ else
2297
+ this.setClaudeAccessMode(nextAccessMode);
2298
+ }
2299
+ }
2300
+ // Workflow is intentionally NOT reconciled from config here: it's an
2301
+ // in-memory per-session toggle (composer / IM /mode), so a config-sync
2302
+ // tick must not clobber a deliberate in-session choice.
2303
+ }
2304
+ if (!opts.initial)
2305
+ this.onManagedConfigChange(config, opts);
2306
+ }
2307
+ async runStream(prompt, cs, attachments, onText, systemPrompt, mcpSendFile, abortSignal, onInteraction, onSteerReady, onCodexTurnReady, extras) {
2308
+ const agentConfig = this.agentConfigs[cs.agent] || {};
2309
+ // Session-level config stored on disk — used as fallback between explicit override and global defaults
2310
+ const sessionWorkdirForConfig = 'workdir' in cs && typeof cs.workdir === 'string' && cs.workdir ? cs.workdir : this.workdir;
2311
+ const storedConfig = cs.sessionId && !isPendingSessionId(cs.sessionId)
2312
+ ? getSessionStoredConfig(sessionWorkdirForConfig, cs.agent, cs.sessionId)
2313
+ : null;
2314
+ const resolvedModel = cs.modelId || storedConfig?.model || this.modelForAgent(cs.agent);
2315
+ const resolvedThinkingEffort = ('thinkingEffort' in cs && typeof cs.thinkingEffort === 'string' && cs.thinkingEffort.trim())
2316
+ ? cs.thinkingEffort.trim().toLowerCase()
2317
+ : (storedConfig?.thinkingEffort || agentConfig.reasoningEffort || 'high');
2318
+ const extraArgs = agentConfig.extraArgs || [];
2319
+ const browserEnabled = resolveGuiIntegrationConfig(getActiveUserConfig()).browserEnabled;
2320
+ const sessionWorkdir = 'workdir' in cs && typeof cs.workdir === 'string' && cs.workdir
2321
+ ? path.resolve(cs.workdir)
2322
+ : this.workdir;
2323
+ this.debug(`[runStream] agent=${cs.agent} session=${cs.sessionId || '(new)'} workdir=${sessionWorkdir} timeout=${this.runTimeout}s attachments=${attachments.length}`);
2324
+ this.debug(`[runStream] ${cs.agent} config: model=${resolvedModel} extraArgs=[${extraArgs.join(' ')}]`);
2325
+ const isFirstTurnOfSession = !cs.sessionId || isPendingSessionId(cs.sessionId);
2326
+ // ── Cross-agent handover ──
2327
+ // First turn of a session created by an agent switch: read the prior agent's
2328
+ // session, compact it, and prepend the seed to this turn's prompt. After this
2329
+ // single injection the new agent owns the canonical session file and `--resume`
2330
+ // takes over. See agent/handover.ts.
2331
+ const handoverFrom = ('handoverFrom' in cs && cs.handoverFrom) ? cs.handoverFrom : null;
2332
+ if (isFirstTurnOfSession && handoverFrom) {
2333
+ try {
2334
+ const result = await compactForHandover({
2335
+ fromAgent: handoverFrom.agent,
2336
+ fromSessionId: handoverFrom.sessionId,
2337
+ workdir: sessionWorkdir,
2338
+ toAgent: cs.agent,
2339
+ toModel: resolvedModel,
2340
+ });
2341
+ if (result.ok && result.seed) {
2342
+ prompt = result.seed + '\n\n' + prompt;
2343
+ this.debug(`[runStream] handover ${describeHandoverRef(handoverFrom)} → ${cs.agent} `
2344
+ + `mode=${result.mode} msgs=${result.messagesIncluded}/${result.messagesTotal} `
2345
+ + `turnsTotal=${result.turnsTotal} chars=${result.charsIncluded}/${result.budgetChars}`);
2346
+ }
2347
+ else {
2348
+ this.warn(`[runStream] handover ${describeHandoverRef(handoverFrom)} → ${cs.agent} `
2349
+ + `failed (${result.error || 'unknown'}); proceeding without prior context`);
2350
+ }
2351
+ }
2352
+ catch (e) {
2353
+ this.warn(`[runStream] handover threw: ${e?.message || e}; proceeding without prior context`);
2354
+ }
2355
+ }
2356
+ // Per-turn workflow opt-in (dashboard composer passes it explicitly);
2357
+ // falls back to the agent's in-memory flag (IM /mode) when unspecified.
2358
+ // Default off — never read from a persisted config default.
2359
+ const workflowEnabled = cs.agent === 'claude' && (extras?.workflowEnabled ?? this.claudeWorkflowEnabled);
2360
+ const mcpSystemPrompt = appendExtraPrompt(appendExtraPrompt(appendExtraPrompt(mcpSendFile ? buildMcpDeliveryPrompt() : '', onInteraction && cs.agent === 'claude' ? buildClaudeAskUserPrompt() : ''), buildBrowserAutomationPrompt(browserEnabled)), workflowEnabled ? buildWorkflowOptInPrompt() : '');
2361
+ // mcpSystemPrompt carries behaviour directives (use im_ask_user instead of
2362
+ // built-in AskUserQuestion, browser automation status, artifact delivery)
2363
+ // that must apply on every turn, not just the first — on resume the CLI
2364
+ // does not automatically re-inject the previous --append-system-prompt
2365
+ // contents, so Claude silently regresses to the built-in tools on turn 2+.
2366
+ // The caller-supplied `systemPrompt` (per-task scaffolding) remains
2367
+ // first-turn-only since later turns inherit it via the session transcript.
2368
+ const effectiveSystemPrompt = isFirstTurnOfSession
2369
+ ? appendExtraPrompt(systemPrompt, mcpSystemPrompt)
2370
+ : (mcpSystemPrompt || undefined);
2371
+ const syncNativeSessionId = (nativeSessionId) => {
2372
+ const resolvedSessionId = nativeSessionId.trim();
2373
+ if (!resolvedSessionId)
2374
+ return;
2375
+ if ('key' in cs && typeof cs.key === 'string') {
2376
+ const runtime = this.getSessionRuntimeByKey(cs.key, { allowAnyWorkdir: true });
2377
+ if (runtime) {
2378
+ this.promoteSessionRuntime(runtime, resolvedSessionId);
2379
+ return;
2380
+ }
2381
+ }
2382
+ cs.sessionId = resolvedSessionId;
2383
+ };
2384
+ const opts = {
2385
+ agent: cs.agent, prompt, workdir: sessionWorkdir, timeout: this.runTimeout,
2386
+ sessionId: cs.sessionId, model: null,
2387
+ thinkingEffort: resolvedThinkingEffort, onText,
2388
+ onSessionId: syncNativeSessionId,
2389
+ attachments: attachments.length ? attachments : undefined,
2390
+ // codex-specific
2391
+ codexModel: cs.agent === 'codex' ? resolvedModel : this.codexModel,
2392
+ codexFullAccess: this.codexFullAccess,
2393
+ codexDeveloperInstructions: effectiveSystemPrompt || undefined,
2394
+ codexExtraArgs: this.codexExtraArgs.length ? this.codexExtraArgs : undefined,
2395
+ codexPrevCumulative: cs.codexCumulative,
2396
+ // claude-specific
2397
+ claudeModel: cs.agent === 'claude' ? resolvedModel : this.claudeModel,
2398
+ claudePermissionMode: this.claudePermissionMode,
2399
+ claudeWorkflowEnabled: workflowEnabled,
2400
+ // Resolved per-stream so a live access-mode switch applies to new turns
2401
+ // while in-flight streams keep the mode they spawned with.
2402
+ claudeAccessMode: cs.agent === 'claude' ? this.claudeAccessMode : undefined,
2403
+ claudeAppendSystemPrompt: effectiveSystemPrompt || undefined,
2404
+ claudeExtraArgs: this.claudeExtraArgs.length ? this.claudeExtraArgs : undefined,
2405
+ // gemini-specific
2406
+ geminiModel: cs.agent === 'gemini' ? resolvedModel : (this.agentConfigs.gemini?.model || ''),
2407
+ geminiApprovalMode: this.geminiApprovalMode,
2408
+ geminiSandbox: this.geminiSandbox,
2409
+ geminiSystemInstruction: effectiveSystemPrompt || undefined,
2410
+ geminiExtraArgs: this.geminiExtraArgs.length ? this.geminiExtraArgs : undefined,
2411
+ // hermes-specific. Wire the chat's current model so /models switching in
2412
+ // IM takes effect even without a BYOK Profile (the BYOK injector in
2413
+ // stream.ts overrides this with the ACP-encoded `provider:model` when
2414
+ // a Profile is bound).
2415
+ hermesModel: cs.agent === 'hermes' && resolvedModel ? resolvedModel : undefined,
2416
+ // MCP bridge
2417
+ mcpSendFile,
2418
+ abortSignal,
2419
+ onInteraction,
2420
+ onSteerReady,
2421
+ onCodexTurnReady,
2422
+ // Fork lineage — when set, the driver branches off the parent session.
2423
+ forkOf: extras?.forkOf,
2424
+ };
2425
+ const result = await doStream(opts);
2426
+ this.stats.totalTurns++;
2427
+ if (result.inputTokens)
2428
+ this.stats.totalInputTokens += result.inputTokens;
2429
+ if (result.outputTokens)
2430
+ this.stats.totalOutputTokens += result.outputTokens;
2431
+ if (result.cachedInputTokens)
2432
+ this.stats.totalCachedTokens += result.cachedInputTokens;
2433
+ if (result.codexCumulative)
2434
+ cs.codexCumulative = result.codexCumulative;
2435
+ if (result.sessionId)
2436
+ syncNativeSessionId(result.sessionId);
2437
+ if (result.workspacePath)
2438
+ cs.workspacePath = result.workspacePath;
2439
+ if (result.model)
2440
+ cs.modelId = result.model;
2441
+ if ('key' in cs && typeof cs.key === 'string') {
2442
+ const runtime = this.getSessionRuntimeByKey(cs.key, { allowAnyWorkdir: true });
2443
+ if (runtime)
2444
+ this.syncSelectedChats(runtime);
2445
+ }
2446
+ this.debug(`[runStream] completed turn=${this.stats.totalTurns} cumulative: in=${fmtTokens(this.stats.totalInputTokens)} out=${fmtTokens(this.stats.totalOutputTokens)} cached=${fmtTokens(this.stats.totalCachedTokens)}`);
2447
+ return result;
2448
+ }
2449
+ startKeepAlive() {
2450
+ if (process.platform === 'darwin') {
2451
+ if (this.keepAliveProc || this.keepAlivePulseTimer)
2452
+ return;
2453
+ const bin = whichSync('caffeinate');
2454
+ if (bin) {
2455
+ // `-dis` = prevent display sleep + idle sleep + system sleep. The `-d`
2456
+ // (display) flag is intentional: the agent uses macOS `screencapture`
2457
+ // for desktop screenshots, which returns a black frame once the
2458
+ // display sleeps. Users who would rather let the screen turn off
2459
+ // should drop brightness or close the lid against an external display.
2460
+ this.keepAliveProc = spawn('caffeinate', ['-dis'], { stdio: 'ignore', detached: true });
2461
+ this.keepAliveProc.unref();
2462
+ this.log(`keep-alive: caffeinate (PID ${this.keepAliveProc.pid})`);
2463
+ const pulseUserActivity = () => {
2464
+ const pulse = spawn('caffeinate', ['-u', '-t', String(MACOS_USER_ACTIVITY_PULSE_TIMEOUT_S)], {
2465
+ stdio: 'ignore',
2466
+ detached: true,
2467
+ });
2468
+ pulse.unref();
2469
+ };
2470
+ pulseUserActivity();
2471
+ this.keepAlivePulseTimer = setInterval(pulseUserActivity, MACOS_USER_ACTIVITY_PULSE_INTERVAL_MS);
2472
+ this.keepAlivePulseTimer.unref?.();
2473
+ this.log(`keep-alive: macOS user activity pulse every ${MACOS_USER_ACTIVITY_PULSE_INTERVAL_MS / 1000}s`);
2474
+ }
2475
+ }
2476
+ else if (process.platform === 'linux') {
2477
+ if (this.keepAliveProc)
2478
+ return;
2479
+ const bin = whichSync('systemd-inhibit');
2480
+ if (bin) {
2481
+ this.keepAliveProc = spawn('systemd-inhibit', [
2482
+ '--what=idle', '--who=pikiloop', '--why=AI coding agent running', 'sleep', 'infinity',
2483
+ ], { stdio: 'ignore', detached: true });
2484
+ this.keepAliveProc.unref();
2485
+ this.log(`keep-alive: systemd-inhibit (PID ${this.keepAliveProc.pid})`);
2486
+ }
2487
+ }
2488
+ }
2489
+ stopKeepAlive() {
2490
+ if (this.keepAlivePulseTimer) {
2491
+ clearInterval(this.keepAlivePulseTimer);
2492
+ this.keepAlivePulseTimer = null;
2493
+ }
2494
+ if (this.keepAliveProc) {
2495
+ terminateProcessTree(this.keepAliveProc, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 2000 });
2496
+ this.keepAliveProc = null;
2497
+ }
2498
+ }
2499
+ }