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,743 @@
1
+ /**
2
+ * CLI spawn framework, stream orchestration, agent detection, and driver delegation.
3
+ */
4
+ import { execSync, spawn } from 'node:child_process';
5
+ import { createInterface } from 'node:readline';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { restartManagedBrowser } from '../browser-supervisor.js';
9
+ import { terminateProcessTree } from '../core/process-control.js';
10
+ import { AGENT_DETECT_TIMEOUTS, AGENT_STREAM_HARD_KILL_GRACE_MS } from '../core/constants.js';
11
+ import { getDriver, allDrivers, getAcceptedProviderKinds, hasDriver } from './driver.js';
12
+ import { resolveAgentInjection, getActiveProfile, getActiveProfileId, getProvider, updateProfile, listProfiles, } from '../model/index.js';
13
+ import { Q, agentLog, agentWarn, agentError, joinErrorMessages, normalizeErrorMessage, buildStreamPreviewMeta, computeContext, shortValue, isPendingSessionId, dedupeStrings, normalizeStreamPreviewPlan, } from './utils.js';
14
+ import { saveSessionRecord, setSessionRunState, applySessionRunResult, ensureSessionWorkspace, importFilesIntoWorkspace, syncManagedSessionIdentity, summarizePromptTitle, recordFork, } from './session.js';
15
+ import { clearAwaitResume } from './await-resume.js';
16
+ import { collapseSkillPrompt } from './skills.js';
17
+ // ---------------------------------------------------------------------------
18
+ // Private helpers
19
+ // ---------------------------------------------------------------------------
20
+ function trimSessionText(value, max = 24_000) {
21
+ const text = typeof value === 'string' ? value.trim() : '';
22
+ if (!text)
23
+ return null;
24
+ if (text.length <= max)
25
+ return text;
26
+ return `${text.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
27
+ }
28
+ /**
29
+ * Spot known browser-MCP failure signatures inside an agent stdout line so the
30
+ * supervisor can force-restart Chrome before the next turn. Both patterns are
31
+ * narrow on purpose: `Frame has been detached` is playwright-specific; the
32
+ * `Connection closed` MCP error only triggers when the same line names the
33
+ * `pikiloop-browser` server, so failures on other MCP servers do not nuke the
34
+ * managed browser. The supervisor itself debounces, so this can fire freely.
35
+ */
36
+ export function _detectBrowserMcpFailure(rawLine) {
37
+ if (!rawLine)
38
+ return null;
39
+ if (rawLine.includes('Frame has been detached'))
40
+ return 'playwright Frame detached';
41
+ if (rawLine.includes('pikiloop-browser') && rawLine.includes('Connection closed')) {
42
+ return 'pikiloop-browser MCP stdio closed';
43
+ }
44
+ return null;
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // Agent detection (private helpers and cache)
48
+ // ---------------------------------------------------------------------------
49
+ const AGENT_DETECT_TTL_MS = AGENT_DETECT_TIMEOUTS.detectTtl;
50
+ const AGENT_VERSION_TTL_MS = AGENT_DETECT_TIMEOUTS.versionTtl;
51
+ const AGENT_VERSION_TIMEOUT_MS = AGENT_DETECT_TIMEOUTS.versionCommand;
52
+ const agentDetectCache = new Map();
53
+ function isExecutableFile(filePath) {
54
+ try {
55
+ const stat = fs.statSync(filePath);
56
+ if (!stat.isFile())
57
+ return false;
58
+ if (process.platform === 'win32')
59
+ return true;
60
+ fs.accessSync(filePath, fs.constants.X_OK);
61
+ return true;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ function executableCandidates(cmd) {
68
+ if (process.platform !== 'win32')
69
+ return [cmd];
70
+ const ext = path.extname(cmd).toLowerCase();
71
+ if (ext)
72
+ return [cmd];
73
+ const pathExt = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
74
+ .split(';')
75
+ .map(value => value.trim())
76
+ .filter(Boolean);
77
+ return [cmd, ...pathExt.map(value => `${cmd}${value.toLowerCase()}`)];
78
+ }
79
+ function resolveAgentBinPath(cmd) {
80
+ const raw = String(cmd || '').trim();
81
+ if (!raw)
82
+ return null;
83
+ const hasPathSeparator = raw.includes('/') || raw.includes('\\');
84
+ if (hasPathSeparator) {
85
+ const absolutePath = path.resolve(raw);
86
+ for (const candidate of executableCandidates(absolutePath)) {
87
+ if (isExecutableFile(candidate))
88
+ return candidate;
89
+ }
90
+ return null;
91
+ }
92
+ const searchPaths = String(process.env.PATH || '')
93
+ .split(path.delimiter)
94
+ .map(entry => entry.trim())
95
+ .filter(Boolean);
96
+ for (const dir of searchPaths) {
97
+ for (const candidate of executableCandidates(path.join(dir, raw))) {
98
+ if (isExecutableFile(candidate))
99
+ return candidate;
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+ function readAgentVersion(binPath, timeoutMs) {
105
+ try {
106
+ const devnull = process.platform === 'win32' ? '2>nul' : '2>/dev/null';
107
+ return execSync(`${Q(binPath)} --version ${devnull}`, {
108
+ encoding: 'utf-8',
109
+ timeout: Math.max(250, timeoutMs),
110
+ }).trim().split('\n')[0] || null;
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ // Agent detection (used by all drivers)
117
+ export function detectAgentBin(cmd, agent, options = {}) {
118
+ const cacheKey = `${agent}:${cmd}`;
119
+ const now = Date.now();
120
+ const includeVersion = !!options.includeVersion;
121
+ const refresh = !!options.refresh;
122
+ const versionTimeoutMs = options.versionTimeoutMs ?? AGENT_VERSION_TIMEOUT_MS;
123
+ let entry = agentDetectCache.get(cacheKey) || null;
124
+ const shouldRefreshBase = refresh || !entry || now - entry.detectedAt > AGENT_DETECT_TTL_MS;
125
+ if (shouldRefreshBase) {
126
+ const binPath = resolveAgentBinPath(cmd);
127
+ const previousVersion = entry?.info.path === binPath ? entry.info.version ?? null : null;
128
+ const previousVersionAt = entry?.info.path === binPath ? entry.versionAt : 0;
129
+ entry = {
130
+ detectedAt: now,
131
+ versionAt: previousVersionAt,
132
+ info: {
133
+ agent,
134
+ installed: !!binPath,
135
+ path: binPath,
136
+ version: previousVersion,
137
+ },
138
+ };
139
+ agentDetectCache.set(cacheKey, entry);
140
+ }
141
+ if (!entry) {
142
+ return { agent, installed: false, path: null, version: null };
143
+ }
144
+ if (includeVersion
145
+ && entry.info.installed
146
+ && entry.info.path
147
+ && (refresh || !entry.versionAt || now - entry.versionAt > AGENT_VERSION_TTL_MS)) {
148
+ entry.info.version = readAgentVersion(entry.info.path, versionTimeoutMs);
149
+ entry.versionAt = now;
150
+ agentDetectCache.set(cacheKey, entry);
151
+ }
152
+ return { ...entry.info };
153
+ }
154
+ export function listAgents(options = {}) {
155
+ return { agents: allDrivers().map(d => detectAgentBin(d.cmd, d.id, options)) };
156
+ }
157
+ /**
158
+ * Resolve the *effective* default agent for new conversations.
159
+ *
160
+ * The stored value is only a *preference* — a new conversation can run only an
161
+ * agent whose CLI is actually installed. So when the preference's CLI isn't
162
+ * installed, we clamp to the first installed agent (in driver-registration
163
+ * order: claude → codex → gemini → hermes) instead of surfacing an uninstalled
164
+ * default the user can't run. When the preference *is* installed it always
165
+ * wins, so machines with the historical 'codex' default are unaffected. When
166
+ * nothing is installed we keep the prior behaviour (honour a valid preference,
167
+ * else 'codex') so the result is always defined.
168
+ *
169
+ * Resolution is derived, never persisted: if the user later installs their
170
+ * preferred agent, the original preference is honoured again automatically.
171
+ * `agents` is injected (defaults to live detection) so the resolution is a pure
172
+ * function of (preference, install-state) and trivially testable.
173
+ */
174
+ export function resolveDefaultAgent(preferred, agents = listAgents().agents) {
175
+ const want = typeof preferred === 'string' ? preferred.trim().toLowerCase() : '';
176
+ const wantValid = !!want && hasDriver(want);
177
+ const installed = agents.filter(a => a.installed).map(a => a.agent);
178
+ if (wantValid && installed.includes(want))
179
+ return want;
180
+ if (installed.length)
181
+ return installed[0];
182
+ return wantValid ? want : 'codex';
183
+ }
184
+ // ---------------------------------------------------------------------------
185
+ // Shared CLI spawn framework (used by driver-claude.ts, driver-gemini.ts)
186
+ // ---------------------------------------------------------------------------
187
+ export async function run(cmd, opts, parseLine, parseStderrLine) {
188
+ const start = Date.now();
189
+ const deadline = start + opts.timeout * 1000;
190
+ let stderr = '';
191
+ let lineCount = 0;
192
+ let timedOut = false;
193
+ let interrupted = false;
194
+ // BYOK: seed contextWindow from the provider-cached value so the live
195
+ // preview percent uses the real denominator (e.g. 1M for DeepSeek v4 Pro
196
+ // via OpenRouter) instead of whatever the CLI happens to report later.
197
+ // Parsers gate their cc/codex-advertised updates on `s.byokContextWindow`.
198
+ const byokWindow = opts.byokContextWindow && opts.byokContextWindow > 0
199
+ ? opts.byokContextWindow
200
+ : null;
201
+ const byokProvider = opts.byokProviderName || null;
202
+ const s = {
203
+ sessionId: opts.sessionId, text: '', thinking: '', msgs: [], thinkParts: [],
204
+ model: opts.model, thinkingEffort: opts.thinkingEffort, errors: null,
205
+ inputTokens: null, outputTokens: null, cachedInputTokens: null,
206
+ cacheCreationInputTokens: null,
207
+ // Output tokens from this turn's finished LLM calls — folded in by parsers
208
+ // when a new call resets the per-call counter (claude message_start).
209
+ turnOutputTokensBase: 0,
210
+ contextWindow: byokWindow,
211
+ byokContextWindow: byokWindow,
212
+ byokProviderName: byokProvider,
213
+ contextUsedTokens: null,
214
+ codexCumulative: null,
215
+ stopReason: null, activity: '',
216
+ recentActivity: [],
217
+ claudeToolsById: new Map(),
218
+ seenClaudeToolIds: new Set(),
219
+ geminiToolsById: new Map(),
220
+ // Claude-only: sub-agent invocations from the Task tool. Other drivers leave it empty.
221
+ subAgents: new Map(),
222
+ // Image blocks collected during the stream (assistant images, MCP tool
223
+ // results, …). Surfaced on the StreamResult so IM channels can dispatch
224
+ // them at end-of-turn without re-reading the session file.
225
+ imageBlocks: [],
226
+ // Wired to opts.onSessionId so parsers can broadcast id changes the instant
227
+ // the CLI surfaces them (see emitSessionIdUpdate in agent/utils.ts).
228
+ _emitSessionId: opts.onSessionId ?? null,
229
+ };
230
+ const shellCmd = cmd.map(Q).join(' ');
231
+ agentLog(`[spawn] full command: cd ${Q(opts.workdir)} && ${shellCmd}`);
232
+ agentLog(`[spawn] timeout: ${opts.timeout}s session: ${opts.sessionId || '(new)'}`);
233
+ agentLog(`[spawn] prompt (stdin): "${opts.prompt.slice(0, 300)}${opts.prompt.length > 300 ? '…' : ''}"`);
234
+ const spawnEnv = { ...process.env, ...(opts.extraEnv || {}) };
235
+ delete spawnEnv.CLAUDECODE;
236
+ const proc = spawn(shellCmd, {
237
+ cwd: opts.workdir,
238
+ env: spawnEnv,
239
+ stdio: ['pipe', 'pipe', 'pipe'],
240
+ shell: true,
241
+ detached: process.platform !== 'win32',
242
+ });
243
+ agentLog(`[spawn] pid=${proc.pid}`);
244
+ const abortStream = () => {
245
+ if (interrupted || proc.killed)
246
+ return;
247
+ interrupted = true;
248
+ s.stopReason = 'interrupted';
249
+ agentLog(`[abort] user interrupt, killing process tree pid=${proc.pid}`);
250
+ terminateProcessTree(proc, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 5000 });
251
+ };
252
+ if (opts.abortSignal?.aborted)
253
+ abortStream();
254
+ opts.abortSignal?.addEventListener('abort', abortStream, { once: true });
255
+ try {
256
+ proc.stdin.write(opts._stdinOverride ?? opts.prompt);
257
+ proc.stdin.end();
258
+ }
259
+ catch { }
260
+ let stderrLineBuf = '';
261
+ proc.stderr?.on('data', (c) => {
262
+ const chunk = c.toString();
263
+ stderr += chunk;
264
+ agentLog(`[stderr] ${chunk.trim().slice(0, 200)}`);
265
+ if (!parseStderrLine)
266
+ return;
267
+ stderrLineBuf += chunk;
268
+ const lines = stderrLineBuf.split(/\r?\n/);
269
+ stderrLineBuf = lines.pop() || '';
270
+ let touched = false;
271
+ for (const line of lines) {
272
+ const trimmed = line.trim();
273
+ if (!trimmed)
274
+ continue;
275
+ try {
276
+ parseStderrLine(trimmed, s);
277
+ touched = true;
278
+ }
279
+ catch { }
280
+ }
281
+ if (touched) {
282
+ try {
283
+ opts.onText(s.text, s.thinking, s.activity, buildStreamPreviewMeta(s), null);
284
+ }
285
+ catch { }
286
+ }
287
+ });
288
+ const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
289
+ rl.on('line', raw => {
290
+ if (Date.now() > deadline) {
291
+ timedOut = true;
292
+ s.stopReason = 'timeout';
293
+ agentWarn('[timeout] deadline exceeded, killing process tree');
294
+ terminateProcessTree(proc, { signal: 'SIGKILL' });
295
+ return;
296
+ }
297
+ const line = raw.trim();
298
+ if (!line || line[0] !== '{')
299
+ return;
300
+ lineCount++;
301
+ const browserFailure = _detectBrowserMcpFailure(line);
302
+ if (browserFailure) {
303
+ agentWarn(`[mcp-browser] failure observed (${browserFailure}); requesting browser restart`);
304
+ void restartManagedBrowser(browserFailure);
305
+ }
306
+ try {
307
+ const ev = JSON.parse(line);
308
+ const evType = ev.type || '?';
309
+ if (evType === 'system' || evType === 'result' || evType === 'assistant' || evType === 'thread.started' || evType === 'turn.completed' || evType === 'item.completed') {
310
+ agentLog(`[event] type=${evType} session=${ev.session_id || s.sessionId || '?'} model=${ev.model || s.model || '?'}`);
311
+ }
312
+ if (evType === 'stream_event') {
313
+ const inner = ev.event || {};
314
+ if (inner.type === 'message_start' || inner.type === 'message_delta')
315
+ agentLog(`[event] stream_event/${inner.type} session=${ev.session_id || '?'}`);
316
+ }
317
+ parseLine(ev, s);
318
+ opts.onText(s.text, s.thinking, s.activity, buildStreamPreviewMeta(s), null);
319
+ }
320
+ catch { }
321
+ });
322
+ const hardTimer = setTimeout(() => {
323
+ timedOut = true;
324
+ s.stopReason = 'timeout';
325
+ agentWarn(`[timeout] hard deadline reached (${opts.timeout}s), killing process tree pid=${proc.pid}`);
326
+ terminateProcessTree(proc, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 5000 });
327
+ }, opts.timeout * 1000 + AGENT_STREAM_HARD_KILL_GRACE_MS);
328
+ const [procOk, code] = await new Promise(resolve => {
329
+ proc.on('close', code => { clearTimeout(hardTimer); agentLog(`[exit] code=${code} lines_parsed=${lineCount}`); resolve([code === 0, code]); });
330
+ proc.on('error', e => { clearTimeout(hardTimer); agentError(`[error] ${e.message}`); stderr += e.message; resolve([false, -1]); });
331
+ });
332
+ opts.abortSignal?.removeEventListener('abort', abortStream);
333
+ if (!s.text.trim() && s.msgs.length)
334
+ s.text = s.msgs.join('\n\n');
335
+ if (!s.thinking.trim() && s.thinkParts.length)
336
+ s.thinking = s.thinkParts.join('\n\n');
337
+ const errorText = joinErrorMessages(s.errors);
338
+ const ok = procOk && !s.errors && !timedOut && !interrupted;
339
+ const error = errorText
340
+ || (interrupted ? 'Interrupted by user.' : null)
341
+ || (timedOut ? `Timed out after ${opts.timeout}s before the agent reported completion.` : null)
342
+ || (!procOk ? (stderr.trim() || `Failed (exit=${code}).`) : null);
343
+ const incomplete = !ok || s.stopReason === 'max_tokens' || s.stopReason === 'timeout';
344
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
345
+ agentLog(`[result] ok=${ok && !s.errors} elapsed=${elapsed}s text=${s.text.length}chars thinking=${s.thinking.length}chars session=${s.sessionId || '?'}`);
346
+ if (errorText)
347
+ agentWarn(`[result] errors: ${errorText}`);
348
+ if (s.stopReason)
349
+ agentLog(`[result] stop_reason=${s.stopReason}`);
350
+ if (stderr.trim() && !procOk)
351
+ agentWarn(`[result] stderr: ${stderr.trim().slice(0, 300)}`);
352
+ return {
353
+ ok, sessionId: s.sessionId, workspacePath: null,
354
+ model: s.model, thinkingEffort: s.thinkingEffort,
355
+ message: s.text.trim() || errorText || (procOk ? '(no textual response)' : `Failed (exit=${code}).\n\n${stderr.trim() || '(no output)'}`),
356
+ thinking: s.thinking.trim() || null,
357
+ elapsedS: (Date.now() - start) / 1000,
358
+ inputTokens: s.inputTokens, outputTokens: s.outputTokens, cachedInputTokens: s.cachedInputTokens,
359
+ cacheCreationInputTokens: s.cacheCreationInputTokens, contextWindow: s.contextWindow,
360
+ ...computeContext(s), codexCumulative: s.codexCumulative, error, stopReason: s.stopReason,
361
+ incomplete, activity: s.activity.trim() || null,
362
+ assistantBlocks: s.imageBlocks.length ? [...s.imageBlocks] : undefined,
363
+ };
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // Stream orchestration
367
+ // ---------------------------------------------------------------------------
368
+ function prepareStreamOpts(opts) {
369
+ // For display fields (title / lastQuestion / lastMessageText) prefer the
370
+ // `/skillname` shorthand the user typed over the long expansion we
371
+ // synthesized for the agent — the expanded form is what the CLI consumes,
372
+ // but it shouldn't leak into session list previews or sidebar tabs.
373
+ const displayPrompt = collapseSkillPrompt(opts.prompt) ?? opts.prompt;
374
+ const session = ensureSessionWorkspace({ agent: opts.agent, workdir: opts.workdir, sessionId: opts.sessionId, title: displayPrompt });
375
+ const importedFiles = importFilesIntoWorkspace(session.workspacePath, opts.attachments || []);
376
+ const attachmentRelPaths = dedupeStrings([...session.record.stagedFiles, ...importedFiles]);
377
+ // Capture staged files for MCP bridge before clearing
378
+ const stagedFiles = [...session.record.stagedFiles];
379
+ session.record.stagedFiles = [];
380
+ // Remember this turn's attachments so dashboard fallbacks (called while the
381
+ // agent CLI hasn't yet flushed the user event to its native session file)
382
+ // can still render the user's image bubble. Cleared/overwritten at the
383
+ // start of the NEXT turn — always reflects the turn currently in flight.
384
+ session.record.lastUserAttachments = [...attachmentRelPaths];
385
+ if (!session.record.title)
386
+ session.record.title = summarizePromptTitle(displayPrompt) || null;
387
+ session.record.lastQuestion = shortValue(displayPrompt, 500);
388
+ session.record.lastMessageText = shortValue(displayPrompt, 500);
389
+ setSessionRunState(session.record, 'running', null);
390
+ // A turn starting clears any "waiting on background work" marker the previous
391
+ // turn parked — the session is plainly running again, not waiting.
392
+ if (session.sessionId)
393
+ clearAwaitResume(opts.workdir, opts.agent, session.sessionId);
394
+ saveSessionRecord(opts.workdir, session.record);
395
+ const attachmentPaths = attachmentRelPaths.map(relPath => path.join(session.workspacePath, relPath));
396
+ // For pending sessions, pass null sessionId to the CLI so it creates a new session
397
+ const effectiveSessionId = isPendingSessionId(session.sessionId) ? null : session.sessionId;
398
+ return {
399
+ session,
400
+ attachments: attachmentPaths,
401
+ stagedFiles,
402
+ prepared: {
403
+ ...opts,
404
+ sessionId: effectiveSessionId,
405
+ attachments: attachmentPaths.length ? attachmentPaths : undefined,
406
+ onSessionId: (nativeSessionId) => {
407
+ if (syncManagedSessionIdentity(session, opts.workdir, nativeSessionId)) {
408
+ saveSessionRecord(opts.workdir, session.record);
409
+ }
410
+ try {
411
+ opts.onSessionId?.(nativeSessionId);
412
+ }
413
+ catch (error) {
414
+ agentWarn(`[session] onSessionId callback failed: ${error?.message || error}`);
415
+ }
416
+ },
417
+ },
418
+ };
419
+ }
420
+ function finalizeStreamResult(result, workdir, prompt, session) {
421
+ if (result.sessionId)
422
+ syncManagedSessionIdentity(session, workdir, result.sessionId);
423
+ session.record.model = result.model || session.record.model;
424
+ if (result.thinkingEffort)
425
+ session.record.thinkingEffort = result.thinkingEffort;
426
+ // Capture the BYOK Profile that was in effect for this run so a future
427
+ // `session.switch` can re-bind it (null = native CLI auth).
428
+ try {
429
+ session.record.profileId = getActiveProfileId(session.record.agent);
430
+ }
431
+ catch {
432
+ /* model layer not initialised in tests — leave profileId untouched */
433
+ }
434
+ const displayPrompt = collapseSkillPrompt(prompt) ?? prompt;
435
+ if (!session.record.title)
436
+ session.record.title = summarizePromptTitle(displayPrompt);
437
+ session.record.lastQuestion = shortValue(displayPrompt, 500);
438
+ session.record.lastAnswer = shortValue(result.message, 500);
439
+ session.record.lastMessageText = shortValue(result.message, 500) || shortValue(displayPrompt, 500);
440
+ session.record.lastThinking = trimSessionText(result.thinking);
441
+ session.record.lastPlan = normalizeStreamPreviewPlan(result.plan);
442
+ applySessionRunResult(session.record, result);
443
+ saveSessionRecord(workdir, session.record);
444
+ return { ...result, sessionId: session.sessionId, workspacePath: session.workspacePath };
445
+ }
446
+ export async function doStream(opts) {
447
+ let session;
448
+ let prepared;
449
+ let stagedFiles;
450
+ try {
451
+ const prep = prepareStreamOpts(opts);
452
+ session = prep.session;
453
+ prepared = prep.prepared;
454
+ stagedFiles = prep.stagedFiles;
455
+ }
456
+ catch (e) {
457
+ const message = e?.message || String(e);
458
+ return {
459
+ ok: false, message, thinking: null,
460
+ sessionId: opts.sessionId, workspacePath: null, model: opts.model, thinkingEffort: opts.thinkingEffort,
461
+ elapsedS: 0, inputTokens: null, outputTokens: null, cachedInputTokens: null,
462
+ cacheCreationInputTokens: null, contextWindow: null, contextUsedTokens: null, contextPercent: null,
463
+ codexCumulative: null, error: message, stopReason: null, incomplete: true, activity: null, plan: null,
464
+ };
465
+ }
466
+ // Start MCP bridge for IM tools (when sendFile is available) and/or supplemental servers (browser, etc.)
467
+ let bridge = null;
468
+ try {
469
+ const { startMcpBridge } = await import('./mcp/bridge.js');
470
+ const sessionDir = path.dirname(session.workspacePath);
471
+ bridge = await startMcpBridge({
472
+ sessionDir,
473
+ workspacePath: session.workspacePath,
474
+ workdir: opts.workdir,
475
+ stagedFiles,
476
+ sendFile: opts.mcpSendFile,
477
+ onInteraction: opts.onInteraction,
478
+ agent: opts.agent,
479
+ onLog: (message) => agentLog(`[mcp] ${message}`),
480
+ });
481
+ if (bridge) {
482
+ prepared.mcpConfigPath = bridge.configPath;
483
+ if (bridge.mcpServers)
484
+ prepared.mcpServers = bridge.mcpServers;
485
+ if (bridge.extraEnv)
486
+ prepared.extraEnv = { ...(prepared.extraEnv || {}), ...bridge.extraEnv };
487
+ if (bridge.configPath)
488
+ agentLog(`[mcp] bridge started on ${bridge.configPath}`);
489
+ else if (bridge.mcpServers)
490
+ agentLog(`[mcp] bridge registered with ${Object.keys(bridge.mcpServers).length} server(s)`);
491
+ else
492
+ agentLog('[mcp] bridge registered with codex');
493
+ try {
494
+ agentLog(`[mcp] config content:\n${fs.readFileSync(bridge.configPath, 'utf-8')}`);
495
+ }
496
+ catch { }
497
+ ;
498
+ }
499
+ }
500
+ catch (e) {
501
+ agentWarn(`[mcp] bridge start failed: ${e.message} — proceeding without MCP`);
502
+ }
503
+ // Apply BYOK injection (Provider/Profile from the model layer): merges env
504
+ // vars into prepared.extraEnv, overrides the per-agent model field, and
505
+ // hands argvAppend to drivers that consume it (Hermes via opts.extraEnv → its own argv builder).
506
+ try {
507
+ const injection = await resolveAgentInjection(prepared.agent);
508
+ if (injection) {
509
+ prepared.extraEnv = { ...(prepared.extraEnv || {}), ...injection.env };
510
+ if (injection.modelOverride) {
511
+ if (prepared.agent === 'claude')
512
+ prepared.claudeModel = injection.modelOverride;
513
+ else if (prepared.agent === 'codex')
514
+ prepared.codexModel = injection.modelOverride;
515
+ else if (prepared.agent === 'gemini')
516
+ prepared.geminiModel = injection.modelOverride;
517
+ else if (prepared.agent === 'hermes')
518
+ prepared.hermesModel = injection.modelOverride;
519
+ prepared.model = injection.modelOverride;
520
+ }
521
+ if (injection.argvAppend?.length) {
522
+ prepared.byokArgvAppend = injection.argvAppend;
523
+ }
524
+ if (injection.codexConfigOverrides?.length) {
525
+ const flags = injection.codexConfigOverrides.flatMap(o => ['-c', o]);
526
+ prepared.codexExtraArgs = [...(prepared.codexExtraArgs || []), ...flags];
527
+ }
528
+ if (injection.contextWindow && injection.contextWindow > 0) {
529
+ prepared.byokContextWindow = injection.contextWindow;
530
+ }
531
+ if (injection.providerName) {
532
+ prepared.byokProviderName = injection.providerName;
533
+ }
534
+ agentLog(`[byok] ${injection.detail}`);
535
+ }
536
+ // resolveAgentEffort (runtime-config) reads only the top-level hermesReasoningEffort
537
+ // field and cannot see the effort stored inside models.profiles[].effort. Override
538
+ // thinkingEffort here so the Profile's effort wins over the config default.
539
+ const activeProfile = getActiveProfile(prepared.agent);
540
+ if (activeProfile?.effort) {
541
+ prepared.thinkingEffort = activeProfile.effort;
542
+ }
543
+ }
544
+ catch (e) {
545
+ agentWarn(`[byok] failed to apply Profile injection: ${e?.message || e}`);
546
+ }
547
+ try {
548
+ const driver = getDriver(prepared.agent);
549
+ if (opts.forkOf && !driver.capabilities?.fork) {
550
+ throw new Error(`Agent ${prepared.agent} does not support fork`);
551
+ }
552
+ const result = await driver.doStream(prepared);
553
+ const finalized = finalizeStreamResult(result, opts.workdir, opts.prompt, session);
554
+ // Once the child has its real session ID, link the lineage. We do this
555
+ // after finalize so the child record is persisted with its native ID.
556
+ if (opts.forkOf && finalized.sessionId) {
557
+ try {
558
+ recordFork(opts.workdir, {
559
+ parent: { agent: opts.agent, sessionId: opts.forkOf.parentSessionId },
560
+ child: { agent: opts.agent, sessionId: finalized.sessionId },
561
+ atTurn: opts.forkOf.atTurn,
562
+ });
563
+ }
564
+ catch (e) {
565
+ agentWarn(`[fork] recordFork failed: ${e?.message || e}`);
566
+ }
567
+ }
568
+ return finalized;
569
+ }
570
+ catch (error) {
571
+ const failedResult = {
572
+ ok: false,
573
+ message: normalizeErrorMessage(error) || 'Agent stream failed.',
574
+ thinking: null,
575
+ sessionId: session.sessionId,
576
+ workspacePath: session.workspacePath,
577
+ model: session.record.model,
578
+ thinkingEffort: prepared.thinkingEffort,
579
+ elapsedS: 0,
580
+ inputTokens: null,
581
+ outputTokens: null,
582
+ cachedInputTokens: null,
583
+ cacheCreationInputTokens: null,
584
+ contextWindow: null,
585
+ contextUsedTokens: null,
586
+ contextPercent: null,
587
+ codexCumulative: null,
588
+ error: normalizeErrorMessage(error) || 'Agent stream failed.',
589
+ stopReason: null,
590
+ incomplete: true,
591
+ activity: null,
592
+ plan: null,
593
+ };
594
+ const failureDisplayPrompt = collapseSkillPrompt(opts.prompt) ?? opts.prompt;
595
+ session.record.lastQuestion = shortValue(failureDisplayPrompt, 500);
596
+ session.record.lastAnswer = shortValue(failedResult.message, 500);
597
+ session.record.lastMessageText = shortValue(failedResult.message, 500) || shortValue(failureDisplayPrompt, 500);
598
+ session.record.lastThinking = null;
599
+ session.record.lastPlan = null;
600
+ applySessionRunResult(session.record, failedResult);
601
+ saveSessionRecord(opts.workdir, session.record);
602
+ throw error;
603
+ }
604
+ finally {
605
+ if (bridge) {
606
+ await bridge.stop().catch(() => { });
607
+ if (bridge.hadActivity())
608
+ agentLog('[mcp] bridge stopped');
609
+ }
610
+ }
611
+ }
612
+ // ---------------------------------------------------------------------------
613
+ // Driver delegation
614
+ // ---------------------------------------------------------------------------
615
+ export function getSessions(opts) {
616
+ const workdir = path.resolve(opts.workdir);
617
+ agentLog(`[sessions] request agent=${opts.agent} workdir=${workdir} limit=${opts.limit ?? 'all'}`);
618
+ return getDriver(opts.agent).getSessions(workdir, opts.limit).then(result => {
619
+ agentLog(`[sessions] result agent=${opts.agent} ok=${result.ok} count=${result.sessions.length} error=${result.error || '(none)'}`);
620
+ return result;
621
+ });
622
+ }
623
+ export function getSessionTail(opts) {
624
+ return getDriver(opts.agent).getSessionTail(opts);
625
+ }
626
+ export function getSessionMessages(opts) {
627
+ return getDriver(opts.agent).getSessionMessages(opts);
628
+ }
629
+ export function listModels(agent, opts = {}) {
630
+ return getDriver(agent).listModels(opts);
631
+ }
632
+ /**
633
+ * Detect a Provider whose baseURL is on the local machine (Ollama / mlx-lm
634
+ * connected via `/api/local-models/connect`). Used only to bucket the entry
635
+ * into the `'local'` group in the unified picker — runtime behaviour is
636
+ * unchanged whether or not the baseURL is loopback.
637
+ */
638
+ function isLocalProviderBaseURL(baseURL) {
639
+ return /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::|\/|$)/i.test(baseURL);
640
+ }
641
+ /**
642
+ * Resolve the model list a UI surface should show for `agent`.
643
+ *
644
+ * Returns a *union* of:
645
+ * 1. The agent CLI's native model catalogue (no Profile required), tagged
646
+ * `group: 'native'`.
647
+ * 2. Every Profile whose provider kind appears in the driver's
648
+ * `acceptedProviderKinds`, tagged `group: 'cloud'` (remote BYOK) or
649
+ * `group: 'local'` (loopback baseURL — Ollama / mlx-lm).
650
+ *
651
+ * The previous behaviour — filter to the active Profile's provider — meant
652
+ * users could not switch *across* providers from the IM picker without first
653
+ * unbinding through the dashboard. The unified list removes that step so
654
+ * `/models` is a one-screen pick.
655
+ *
656
+ * Callers that need the strictly-native list (e.g. the dashboard agent card's
657
+ * "Native" branch) should call `driver.listModels()` directly — this function
658
+ * is for the unified picker.
659
+ */
660
+ export async function resolveAgentModels(agent, opts = {}) {
661
+ const driver = getDriver(agent);
662
+ // 1. Native — agent CLI's built-in catalogue.
663
+ let nativeResult;
664
+ try {
665
+ nativeResult = await driver.listModels(opts);
666
+ }
667
+ catch {
668
+ nativeResult = { agent, models: [], sources: [], note: null };
669
+ }
670
+ const native = nativeResult.models.map(m => ({
671
+ id: m.id,
672
+ alias: m.alias,
673
+ group: 'native',
674
+ }));
675
+ // 2. BYOK Profiles compatible with this driver — grouped into cloud vs local
676
+ // by baseURL. We never call the provider's /models endpoint here: that
677
+ // list can run into the hundreds for OpenRouter and would drown the
678
+ // picker. Profiles ARE the curated middle layer.
679
+ const acceptedKinds = new Set(getAcceptedProviderKinds(agent));
680
+ const cloud = [];
681
+ const local = [];
682
+ if (acceptedKinds.size > 0) {
683
+ for (const profile of listProfiles()) {
684
+ const provider = getProvider(profile.providerId);
685
+ if (!provider)
686
+ continue;
687
+ if (!acceptedKinds.has(provider.kind))
688
+ continue;
689
+ const isLocal = isLocalProviderBaseURL(provider.baseURL);
690
+ const entry = {
691
+ id: profile.modelId,
692
+ alias: profile.name,
693
+ group: isLocal ? 'local' : 'cloud',
694
+ profileId: profile.id,
695
+ providerName: provider.name,
696
+ };
697
+ (isLocal ? local : cloud).push(entry);
698
+ }
699
+ }
700
+ const sources = [...nativeResult.sources];
701
+ if (cloud.length)
702
+ sources.push(`${cloud.length} cloud profile${cloud.length === 1 ? '' : 's'}`);
703
+ if (local.length)
704
+ sources.push(`${local.length} local profile${local.length === 1 ? '' : 's'}`);
705
+ return {
706
+ agent,
707
+ models: [...native, ...cloud, ...local],
708
+ sources,
709
+ note: nativeResult.note ?? null,
710
+ };
711
+ }
712
+ export function getUsage(opts) {
713
+ return getDriver(opts.agent).getUsage(opts);
714
+ }
715
+ /**
716
+ * If the user has a BYOK Profile bound to `agent`, return its raw modelId
717
+ * (e.g. "deepseek/deepseek-v4-flash"). Returns null when no profile is bound.
718
+ * Used by display paths that need to show the profile's model rather than the
719
+ * pikiloop user-config model (which may be stale or unrelated to the active profile).
720
+ */
721
+ export function getAgentBoundModelId(agent) {
722
+ const profile = getActiveProfile(agent);
723
+ return profile?.modelId ?? null;
724
+ }
725
+ /**
726
+ * Persist a model id to the active BYOK Profile for `agent`. Returns true when
727
+ * the Profile was updated (caller should skip writing the legacy
728
+ * `<agent>Model` user-config field), false when no Profile is bound.
729
+ *
730
+ * Hermes uses this as the *primary* persistence path because `hermes acp` does
731
+ * not support runtime model switching via CLI flags — the only way to change
732
+ * the model is the Profile (which the driver passes to ACP `session/set_model`).
733
+ */
734
+ export function setAgentBoundModelId(agent, modelId) {
735
+ const profile = getActiveProfile(agent);
736
+ if (!profile)
737
+ return false;
738
+ const trimmed = modelId.trim();
739
+ if (!trimmed || trimmed === profile.modelId)
740
+ return true;
741
+ updateProfile(profile.id, { modelId: trimmed });
742
+ return true;
743
+ }