pikiloom 0.4.14 → 0.4.16

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 (56) hide show
  1. package/dashboard/dist/assets/AgentTab-B5tmLxa7.js +1 -0
  2. package/dashboard/dist/assets/{DirBrowser-B5hxg2zn.js → DirBrowser-CBp5nyfS.js} +1 -1
  3. package/dashboard/dist/assets/{ExtensionsTab-C2FAUsui.js → ExtensionsTab-w4pkrNas.js} +1 -1
  4. package/dashboard/dist/assets/{IMAccessTab-CS-2-ENn.js → IMAccessTab-37Po5LP1.js} +1 -1
  5. package/dashboard/dist/assets/{Modal-BF2CycPZ.js → Modal-CBMO5UcS.js} +1 -1
  6. package/dashboard/dist/assets/{Modals-BHYtxTUE.js → Modals-DMlEjJUG.js} +1 -1
  7. package/dashboard/dist/assets/Select-BiSTkS_t.js +1 -0
  8. package/dashboard/dist/assets/SessionPanel-BVC7kwlX.js +1 -0
  9. package/dashboard/dist/assets/{SystemTab-B_hq7KIo.js → SystemTab-Brzt5wTT.js} +1 -1
  10. package/dashboard/dist/assets/codex-C6EwIzap.png +0 -0
  11. package/dashboard/dist/assets/deepseek-DOQzDJ-4.ico +0 -0
  12. package/dashboard/dist/assets/hermes-ClPe1RPI.png +0 -0
  13. package/dashboard/dist/assets/index-5Q-Q7ByM.js +3 -0
  14. package/dashboard/dist/assets/index-Dw3ty4QY.js +23 -0
  15. package/dashboard/dist/assets/logo-wordmark-B0Z6VgSZ.png +0 -0
  16. package/dashboard/dist/assets/logo-wordmark-light-D9FCWeOH.png +0 -0
  17. package/dashboard/dist/assets/playwright-GP3HuCap.ico +0 -0
  18. package/dashboard/dist/assets/qwen-DKVAROae.png +0 -0
  19. package/dashboard/dist/assets/shared-P-W1OYQ6.js +1 -0
  20. package/dashboard/dist/index.html +1 -1
  21. package/dashboard/dist/logo.png +0 -0
  22. package/dist/agent/auto-update.js +99 -4
  23. package/dist/agent/drivers/claude.js +6 -26
  24. package/dist/agent/drivers/codex.js +4 -26
  25. package/dist/agent/drivers/gemini.js +4 -26
  26. package/dist/agent/drivers/hermes.js +4 -26
  27. package/dist/agent/index.js +1 -1
  28. package/dist/agent/mcp/bridge.js +201 -7
  29. package/dist/agent/mcp/extensions.js +20 -9
  30. package/dist/agent/session.js +16 -3
  31. package/dist/agent/stream.js +40 -5
  32. package/dist/bot/bot.js +18 -5
  33. package/dist/channels/telegram/bot.js +2 -2
  34. package/dist/channels/telegram/render.js +47 -1
  35. package/dist/core/constants.js +8 -0
  36. package/dist/dashboard/routes/config.js +134 -12
  37. package/dist/dashboard/routes/models.js +9 -1
  38. package/dist/dashboard/routes/sessions.js +25 -0
  39. package/dist/dashboard/server.js +8 -0
  40. package/dist/model/index.js +1 -1
  41. package/dist/model/injector.js +42 -0
  42. package/dist/model/responses-bridge.js +129 -88
  43. package/package.json +1 -1
  44. package/dashboard/dist/assets/AgentTab-Ce9nOgKB.js +0 -1
  45. package/dashboard/dist/assets/Select--CwQ1vbY.js +0 -1
  46. package/dashboard/dist/assets/SessionPanel-D0h4d0Nw.js +0 -1
  47. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  48. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  49. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  50. package/dashboard/dist/assets/index-Dws-2k-J.js +0 -3
  51. package/dashboard/dist/assets/index-jCpvbF9B.js +0 -23
  52. package/dashboard/dist/assets/logo-wordmark-FzeBAUsd.png +0 -0
  53. package/dashboard/dist/assets/logo-wordmark-light-snSpARTN.png +0 -0
  54. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  55. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  56. package/dashboard/dist/assets/shared-D1ruCzXL.js +0 -1
@@ -16,7 +16,7 @@ import { join, extname } from 'node:path';
16
16
  import { resolve as resolvePath } from 'node:path';
17
17
  import { registerDriver } from '../driver.js';
18
18
  import { AcpClient, toAcpMcpServers } from '../acp-client.js';
19
- import { agentLog, agentWarn, emptyUsage, normalizeErrorMessage, listPikiloomSessions, findPikiloomSession, buildStreamPreviewMeta, applyTurnWindow, pushRecentActivity, IMAGE_EXTS, mimeForExt, } from '../index.js';
19
+ import { agentLog, agentWarn, emptyUsage, normalizeErrorMessage, listPikiloomSessions, managedRecordToSessionInfo, findPikiloomSession, buildStreamPreviewMeta, applyTurnWindow, pushRecentActivity, IMAGE_EXTS, mimeForExt, } from '../index.js';
20
20
  // Build the ACP `prompt` content array from the user's text + staged
21
21
  // attachments. Images become ImageContentBlocks (base64 + mimeType — the
22
22
  // shape Hermes' acp_adapter accepts and converts to OpenAI multimodal
@@ -370,32 +370,10 @@ async function getHermesSessions(workdir, limit) {
370
370
  // for the `hermes sessions` CLI but irrelevant to pikiloom, which always
371
371
  // creates its own ACP session per turn and records it under .pikiloom.
372
372
  const resolvedWorkdir = resolvePath(workdir);
373
+ // Canonical record→SessionInfo mapper (single source of truth) — see claude.ts.
374
+ // Hand-rolling dropped thinkingEffort/workflowEnabled/profileId.
373
375
  const records = listPikiloomSessions(resolvedWorkdir, 'hermes');
374
- const sessions = records.map(record => ({
375
- sessionId: record.sessionId,
376
- agent: 'hermes',
377
- workdir: record.workdir,
378
- workspacePath: record.workspacePath,
379
- threadId: record.threadId,
380
- model: record.model,
381
- createdAt: record.createdAt,
382
- title: record.title,
383
- running: record.runState === 'running',
384
- runState: record.runState,
385
- runDetail: record.runDetail,
386
- runUpdatedAt: record.runUpdatedAt,
387
- runPid: record.runPid,
388
- classification: record.classification,
389
- userStatus: record.userStatus,
390
- userNote: record.userNote,
391
- lastQuestion: record.lastQuestion,
392
- lastAnswer: record.lastAnswer,
393
- lastMessageText: record.lastMessageText,
394
- migratedFrom: record.migratedFrom,
395
- migratedTo: record.migratedTo,
396
- linkedSessions: record.linkedSessions,
397
- numTurns: record.numTurns ?? null,
398
- }));
376
+ const sessions = records.map(managedRecordToSessionInfo);
399
377
  sessions.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''));
400
378
  const sliced = typeof limit === 'number' ? sessions.slice(0, limit) : sessions;
401
379
  agentLog(`[sessions:hermes] workdir=${resolvedWorkdir} pikiloom=${records.length} returned=${sliced.length}`);
@@ -23,7 +23,7 @@ export { attachAgentImage, attachInlineImage, materializeImage, rewriteImageBloc
23
23
  // ── Re-export: utilities ────────────────────────────────────────────────────
24
24
  export { Q, agentLog, agentWarn, agentError, dedupeStrings, numberOrNull, normalizeStreamPreviewPlan, parseTodoWriteAsPlan, normalizeActivityLine, pushRecentActivity, detectClaudeApiError, isRetryableClaudeApiError, detectClaudeModelError, claudeModelErrorMessage, firstNonEmptyLine, shortValue, normalizeErrorMessage, joinErrorMessages, appendSystemPrompt, mimeForExt, computeContext, buildStreamPreviewMeta, summarizeClaudeToolUse, summarizeClaudeToolResult, previewToolCallInput, previewToolCallResult, roundPercent, toIsoFromEpochSeconds, normalizeUsageStatus, labelFromWindowMinutes, usageWindowFromRateLimit, parseJsonTail, modelFamily, normalizeClaudeModelId, emptyUsage, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, stripClaudeAtMentionImages, isPendingSessionId, emitSessionIdUpdate, sessionListDisplayTitle, } from './utils.js';
25
25
  // ── Re-export: session management ───────────────────────────────────────────
26
- export { updateSessionMeta, promoteSessionId, recordFork, listPikiloomSessions, findPikiloomSession, getSessionStoredConfig, ensureManagedSession, findManagedThreadSession, stageSessionFiles, mergeManagedAndNativeSessions, getSessions, getSessionTail, getSessionMessages, applyTurnWindow, applyTurnFilter, classifySession, deriveUserStatus, exportSession, importSession, deleteAgentSession, isProcessAlive, isRunningSessionStale, reconcileOrphanedRunningSessions, } from './session.js';
26
+ export { updateSessionMeta, promoteSessionId, recordFork, listPikiloomSessions, findPikiloomSession, getSessionStoredConfig, ensureManagedSession, findManagedThreadSession, stageSessionFiles, mergeManagedAndNativeSessions, managedRecordToSessionInfo, getSessions, getSessionTail, getSessionMessages, applyTurnWindow, applyTurnFilter, classifySession, deriveUserStatus, exportSession, importSession, deleteAgentSession, isProcessAlive, isRunningSessionStale, reconcileOrphanedRunningSessions, } from './session.js';
27
27
  // ── Re-export: stream & detection ───────────────────────────────────────────
28
28
  export { detectAgentBin, listAgents, resolveDefaultAgent, run, doStream, listModels, resolveAgentModels, getUsage, getAgentBoundModelId, setAgentBoundModelId, } from './stream.js';
29
29
  // ── Re-export: driver registry ──────────────────────────────────────────────
@@ -110,6 +110,58 @@ export const PEEKABOO_MCP_ARGV = ['-y', '-p', PEEKABOO_NPX_PACKAGE, 'peekaboo-mc
110
110
  */
111
111
  export const PEEKABOO_WARM_ARGV = ['-y', '-p', PEEKABOO_NPX_PACKAGE, 'peekaboo', '--version'];
112
112
  let peekabooWarmStarted = false;
113
+ const PEEKABOO_ENV_ALLOWLIST = [
114
+ 'HOME',
115
+ 'PATH',
116
+ 'USER',
117
+ 'LOGNAME',
118
+ 'SHELL',
119
+ 'TMPDIR',
120
+ 'TEMP',
121
+ 'TMP',
122
+ 'LANG',
123
+ 'LC_ALL',
124
+ 'LC_CTYPE',
125
+ ];
126
+ const PEEKABOO_DEFAULT_PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';
127
+ function cleanEnvString(value) {
128
+ if (typeof value !== 'string')
129
+ return null;
130
+ const trimmed = value.trim();
131
+ if (!trimmed || trimmed.includes('\0'))
132
+ return null;
133
+ return trimmed;
134
+ }
135
+ /**
136
+ * Peekaboo only needs a normal user shell environment so npx can resolve/cache
137
+ * the package. Do not inherit the full pikiloom/agent environment here: it may
138
+ * contain provider API keys, channel tokens, OAuth bearer values, or database
139
+ * URLs that a GUI MCP server has no reason to see.
140
+ */
141
+ export function buildPeekabooChildEnv(env = process.env) {
142
+ const safe = {};
143
+ for (const key of PEEKABOO_ENV_ALLOWLIST) {
144
+ const value = cleanEnvString(env[key]);
145
+ if (value)
146
+ safe[key] = value;
147
+ }
148
+ safe.PATH ||= PEEKABOO_DEFAULT_PATH;
149
+ safe.HOME ||= os.homedir();
150
+ safe.PIKILOOM_MCP_SERVER = 'peekaboo';
151
+ safe.npm_config_yes = 'true';
152
+ return safe;
153
+ }
154
+ function peekabooEnvArgs(env) {
155
+ return Object.entries(env).map(([key, value]) => `${key}=${value}`);
156
+ }
157
+ function buildPeekabooMcpServer() {
158
+ const safeEnv = buildPeekabooChildEnv();
159
+ return {
160
+ name: 'peekaboo',
161
+ command: '/usr/bin/env',
162
+ args: ['-i', ...peekabooEnvArgs(safeEnv), 'npx', ...PEEKABOO_MCP_ARGV],
163
+ };
164
+ }
113
165
  /**
114
166
  * Pre-warm the Peekaboo npx package so the agent's MCP server connects instantly.
115
167
  *
@@ -132,7 +184,11 @@ export function ensurePeekabooWarm() {
132
184
  return;
133
185
  peekabooWarmStarted = true;
134
186
  try {
135
- const child = spawn('npx', PEEKABOO_WARM_ARGV, { stdio: 'ignore', detached: true });
187
+ const child = spawn('npx', PEEKABOO_WARM_ARGV, {
188
+ stdio: 'ignore',
189
+ detached: true,
190
+ env: buildPeekabooChildEnv(),
191
+ });
136
192
  // npx missing / spawn failure: clear the latch so a later call can retry.
137
193
  child.on('error', () => { peekabooWarmStarted = false; });
138
194
  child.unref();
@@ -159,11 +215,7 @@ export function buildSupplementalMcpServers(gui = resolveGuiIntegrationConfig(),
159
215
  if (gui.peekabooEnabled && process.platform === 'darwin') {
160
216
  // Peekaboo — native macOS GUI automation via Accessibility + ScreenCaptureKit.
161
217
  // Run the dedicated MCP bin from the multi-bin @steipete/peekaboo package.
162
- servers.push({
163
- name: 'peekaboo',
164
- command: 'npx',
165
- args: [...PEEKABOO_MCP_ARGV],
166
- });
218
+ servers.push(buildPeekabooMcpServer());
167
219
  }
168
220
  return servers;
169
221
  }
@@ -235,6 +287,33 @@ function codexBearerEnvName(serverName) {
235
287
  const safe = serverName.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
236
288
  return `PIKILOOM_MCP_BEARER_${safe || 'UNNAMED'}`;
237
289
  }
290
+ const REDACTED = '[REDACTED]';
291
+ const SENSITIVE_CONFIG_KEY_RE = /(authorization|bearer|token|secret|password|passwd|api[_-]?key|credential|cookie|session|connection[_-]?string|dsn)/i;
292
+ const URL_PASSWORD_RE = /([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^@\s/]+)@/gi;
293
+ const QUERY_SECRET_RE = /([?&](?:access_token|api[_-]?key|key|token|secret|password|passwd)=)[^&\s]+/gi;
294
+ function redactStringForLog(key, value) {
295
+ if (SENSITIVE_CONFIG_KEY_RE.test(key)) {
296
+ const bearer = /^\s*Bearer\s+/i.test(value);
297
+ return bearer ? `Bearer ${REDACTED}` : REDACTED;
298
+ }
299
+ return value
300
+ .replace(URL_PASSWORD_RE, `$1$2:${REDACTED}@`)
301
+ .replace(QUERY_SECRET_RE, `$1${REDACTED}`);
302
+ }
303
+ function redactForLog(value, key = '') {
304
+ if (typeof value === 'string')
305
+ return redactStringForLog(key, value);
306
+ if (Array.isArray(value))
307
+ return value.map(item => redactForLog(item, key));
308
+ if (!value || typeof value !== 'object')
309
+ return value;
310
+ return Object.fromEntries(Object.entries(value)
311
+ .map(([childKey, childValue]) => [childKey, redactForLog(childValue, childKey)]));
312
+ }
313
+ export function redactMcpConfigForLog(configPath) {
314
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
315
+ return JSON.stringify(redactForLog(parsed), null, 2);
316
+ }
238
317
  export function buildGeminiMcpConfig(servers) {
239
318
  return {
240
319
  // Session attachments live under .pikiloom/... and should remain readable to
@@ -383,6 +462,116 @@ function reapStalePlaywrightMcpProcesses(cdpEndpoint) {
383
462
  }
384
463
  return { reaped, spared };
385
464
  }
465
+ // ---------------------------------------------------------------------------
466
+ // Stale peekaboo-mcp reaper
467
+ // ---------------------------------------------------------------------------
468
+ function commandTokenBase(token) {
469
+ return path.basename(token.replace(/^"+|"+$/g, '')).replace(/\.(?:cmd|exe)$/i, '').toLowerCase();
470
+ }
471
+ /**
472
+ * Pure matcher for stale-process hygiene. It intentionally targets the
473
+ * long-running MCP server only, not the quick cache warm (`peekaboo --version`).
474
+ */
475
+ export function _matchPeekabooMcpProcessCommand(command) {
476
+ if (!command || !command.includes('peekaboo-mcp'))
477
+ return false;
478
+ const tokens = command.split(/\s+/).filter(Boolean);
479
+ if (!tokens.length)
480
+ return false;
481
+ if (commandTokenBase(tokens[0]) === 'node' && (tokens[1] === '-e' || tokens[1] === '--eval'))
482
+ return false;
483
+ const hasMcpBin = tokens.some(token => token === 'peekaboo-mcp'
484
+ || /(?:^|[\\/])peekaboo-mcp(?:$|\s)/.test(token)
485
+ || /(?:^|[\\/])peekaboo-mcp$/.test(token));
486
+ if (!hasMcpBin)
487
+ return false;
488
+ const hasPackage = command.includes('@steipete/peekaboo')
489
+ || command.includes('@steipete\\peekaboo')
490
+ || command.includes('/@steipete/peekaboo/');
491
+ const launcher = commandTokenBase(tokens[0]);
492
+ const knownLauncher = launcher === 'env' || launcher === 'npx' || launcher === 'npm' || launcher === 'node' || launcher === 'peekaboo-mcp';
493
+ return hasPackage || knownLauncher;
494
+ }
495
+ function commandLooksLikeLiveMcpController(command) {
496
+ if (!command)
497
+ return false;
498
+ const text = command.toLowerCase();
499
+ if (/\bpikiloom\b/.test(text) || text.includes('pikiloom@'))
500
+ return true;
501
+ const first = commandTokenBase(command.split(/\s+/)[0] || '');
502
+ return first === 'claude' || first === 'codex' || first === 'gemini' || first === 'hermes';
503
+ }
504
+ const PEEKABOO_REAP_THROTTLE_MS = 30_000;
505
+ const PEEKABOO_REAP_FORCE_AFTER_MS = 2_000;
506
+ let lastPeekabooReapAt = 0;
507
+ function reapStalePeekabooMcpProcesses() {
508
+ const reaped = [];
509
+ const spared = [];
510
+ if (process.platform !== 'darwin')
511
+ return { reaped, spared };
512
+ if (Date.now() - lastPeekabooReapAt < PEEKABOO_REAP_THROTTLE_MS)
513
+ return { reaped, spared };
514
+ lastPeekabooReapAt = Date.now();
515
+ const result = spawnSync('ps', ['-axo', 'pid=,ppid=,command='], { encoding: 'utf8' });
516
+ if (result.status !== 0)
517
+ return { reaped, spared };
518
+ const ppidByPid = new Map();
519
+ const commandByPid = new Map();
520
+ const candidates = [];
521
+ const lines = String(result.stdout || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
522
+ for (const line of lines) {
523
+ const m = line.match(/^(\d+)\s+(\d+)\s+(.*)$/);
524
+ if (!m)
525
+ continue;
526
+ const pid = Number(m[1]);
527
+ const ppid = Number(m[2]);
528
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid))
529
+ continue;
530
+ const command = m[3] || '';
531
+ ppidByPid.set(pid, ppid);
532
+ commandByPid.set(pid, command);
533
+ if (pid === process.pid)
534
+ continue;
535
+ if (_matchPeekabooMcpProcessCommand(command))
536
+ candidates.push(pid);
537
+ }
538
+ const hasProtectedAncestor = (pid) => {
539
+ let cur = pid;
540
+ for (let depth = 0; depth < 40 && cur != null && cur > 1; depth++) {
541
+ if (cur === process.pid)
542
+ return true;
543
+ const command = commandByPid.get(cur) || '';
544
+ if (cur !== pid && commandLooksLikeLiveMcpController(command))
545
+ return true;
546
+ cur = ppidByPid.get(cur);
547
+ }
548
+ return false;
549
+ };
550
+ for (const pid of candidates) {
551
+ if (hasProtectedAncestor(pid)) {
552
+ spared.push(pid);
553
+ continue;
554
+ }
555
+ try {
556
+ process.kill(pid, 'SIGTERM');
557
+ const forceTimer = setTimeout(() => {
558
+ try {
559
+ process.kill(pid, 0);
560
+ process.kill(pid, 'SIGKILL');
561
+ }
562
+ catch {
563
+ // Process already exited or cannot be signalled — no-op.
564
+ }
565
+ }, PEEKABOO_REAP_FORCE_AFTER_MS);
566
+ forceTimer.unref?.();
567
+ reaped.push(pid);
568
+ }
569
+ catch {
570
+ // Already dead — no-op.
571
+ }
572
+ }
573
+ return { reaped, spared };
574
+ }
386
575
  /**
387
576
  * Decide which CDP endpoint the per-session playwright/mcp should attach to.
388
577
  *
@@ -514,8 +703,13 @@ export async function startMcpBridge(opts) {
514
703
  // Peekaboo ships a native binary behind npx; warm its cache out-of-band so the
515
704
  // agent's MCP server connects instantly instead of hanging at "Still
516
705
  // connecting" on a cold first-run download (see ensurePeekabooWarm).
517
- if (gui.peekabooEnabled)
706
+ if (gui.peekabooEnabled) {
518
707
  ensurePeekabooWarm();
708
+ const { reaped, spared } = reapStalePeekabooMcpProcesses();
709
+ if (reaped.length) {
710
+ opts.onLog?.(`reaped ${reaped.length} stale peekaboo-mcp process(es): pid=${reaped.join(',')}${spared.length ? ` (spared active: ${spared.join(',')})` : ''}`);
711
+ }
712
+ }
519
713
  // Lazy browser lifecycle: probe an already-running managed Chrome via
520
714
  // <profileDir>/DevToolsActivePort and attach if reachable; otherwise leave
521
715
  // Chrome unlaunched and let playwright/mcp launch it with `--user-data-dir`
@@ -17,6 +17,7 @@ import os from 'node:os';
17
17
  import path from 'node:path';
18
18
  import { spawn } from 'node:child_process';
19
19
  import { loadUserConfig, saveUserConfig } from '../../core/config/user-config.js';
20
+ import { terminateProcessTree } from '../../core/process-control.js';
20
21
  import { getRecommendedMcpServers, } from './registry.js';
21
22
  import { hasValidMcpToken, injectOAuthHeaders } from './oauth.js';
22
23
  // ---------------------------------------------------------------------------
@@ -548,6 +549,7 @@ export async function checkMcpHealth(config, timeoutMs = 10_000) {
548
549
  return new Promise((resolve) => {
549
550
  const start = Date.now();
550
551
  let checkInterval = null;
552
+ let settled = false;
551
553
  const cleanup = () => {
552
554
  if (checkInterval) {
553
555
  clearInterval(checkInterval);
@@ -555,20 +557,29 @@ export async function checkMcpHealth(config, timeoutMs = 10_000) {
555
557
  }
556
558
  clearTimeout(timer);
557
559
  };
560
+ const stopChildTree = () => {
561
+ terminateProcessTree(child, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 1500 });
562
+ };
563
+ const finish = (result) => {
564
+ if (settled)
565
+ return;
566
+ settled = true;
567
+ cleanup();
568
+ resolve(result);
569
+ };
558
570
  const child = spawn(config.command, config.args || [], {
559
571
  stdio: ['pipe', 'pipe', 'pipe'],
560
572
  env: { ...process.env, ...config.env },
573
+ detached: process.platform !== 'win32',
561
574
  });
562
575
  const timer = setTimeout(() => {
563
- cleanup();
564
- child.kill();
565
- resolve({ ok: false, error: `timeout after ${timeoutMs}ms`, elapsedMs: Date.now() - start });
576
+ stopChildTree();
577
+ finish({ ok: false, error: `timeout after ${timeoutMs}ms`, elapsedMs: Date.now() - start });
566
578
  }, timeoutMs);
567
579
  let stdout = '';
568
580
  child.stdout?.on('data', (data) => { stdout += data.toString(); });
569
581
  child.on('error', (err) => {
570
- cleanup();
571
- resolve({ ok: false, error: err.message, elapsedMs: Date.now() - start });
582
+ finish({ ok: false, error: err.message, elapsedMs: Date.now() - start });
572
583
  });
573
584
  const initRequest = JSON.stringify({
574
585
  jsonrpc: '2.0',
@@ -585,8 +596,8 @@ export async function checkMcpHealth(config, timeoutMs = 10_000) {
585
596
  child.stdin?.write(header + initRequest);
586
597
  }
587
598
  catch {
588
- cleanup();
589
- resolve({ ok: false, error: 'failed to write to stdin', elapsedMs: Date.now() - start });
599
+ stopChildTree();
600
+ finish({ ok: false, error: 'failed to write to stdin', elapsedMs: Date.now() - start });
590
601
  return;
591
602
  }
592
603
  checkInterval = setInterval(() => {
@@ -606,7 +617,7 @@ export async function checkMcpHealth(config, timeoutMs = 10_000) {
606
617
  }
607
618
  catch { /* best-effort */ }
608
619
  setTimeout(() => {
609
- child.kill();
620
+ stopChildTree();
610
621
  const tools = [];
611
622
  try {
612
623
  const jsonMatches = stdout.match(/\{[^{}]*"tools"\s*:\s*\[[\s\S]*?\]\s*[^{}]*\}/g);
@@ -630,7 +641,7 @@ export async function checkMcpHealth(config, timeoutMs = 10_000) {
630
641
  }
631
642
  }
632
643
  catch { /* best effort */ }
633
- resolve({ ok: true, tools: tools.length ? tools : undefined, elapsedMs: Date.now() - start });
644
+ finish({ ok: true, tools: tools.length ? tools : undefined, elapsedMs: Date.now() - start });
634
645
  }, 1500);
635
646
  }, 100);
636
647
  });
@@ -222,6 +222,7 @@ function normalizeSessionRecord(raw, workdir) {
222
222
  title: typeof raw?.title === 'string' && raw.title.trim() ? raw.title.trim() : null,
223
223
  model: typeof raw?.model === 'string' && raw.model.trim() ? raw.model.trim() : null,
224
224
  thinkingEffort: typeof raw?.thinkingEffort === 'string' && raw.thinkingEffort.trim() ? raw.thinkingEffort.trim() : null,
225
+ workflowEnabled: typeof raw?.workflowEnabled === 'boolean' ? raw.workflowEnabled : null,
225
226
  profileId: typeof raw?.profileId === 'string' && raw.profileId.trim() ? raw.profileId.trim() : null,
226
227
  stagedFiles: Array.isArray(raw?.stagedFiles) ? dedupeStrings(raw.stagedFiles.filter((v) => typeof v === 'string')) : [],
227
228
  lastUserAttachments: Array.isArray(raw?.lastUserAttachments)
@@ -299,7 +300,7 @@ function writeSessionMeta(record) {
299
300
  workspacePath: record.workspacePath,
300
301
  threadId: record.threadId,
301
302
  createdAt: record.createdAt, updatedAt: record.updatedAt,
302
- title: record.title, model: record.model, thinkingEffort: record.thinkingEffort, stagedFiles: record.stagedFiles,
303
+ title: record.title, model: record.model, thinkingEffort: record.thinkingEffort, workflowEnabled: record.workflowEnabled, stagedFiles: record.stagedFiles,
303
304
  runState: record.runState, runDetail: record.runDetail, runUpdatedAt: record.runUpdatedAt,
304
305
  runPid: record.runPid,
305
306
  classification: record.classification,
@@ -583,7 +584,7 @@ export function ensureSessionWorkspace(opts) {
583
584
  workspacePath: sessionWorkspacePath(workdir, opts.agent, sessionId),
584
585
  threadId,
585
586
  createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
586
- title: summarizePromptTitle(opts.title) || null, model: null, thinkingEffort: null, profileId: null, stagedFiles: [], lastUserAttachments: [],
587
+ title: summarizePromptTitle(opts.title) || null, model: null, thinkingEffort: null, workflowEnabled: null, profileId: null, stagedFiles: [], lastUserAttachments: [],
587
588
  runState: 'completed', runDetail: null, runUpdatedAt: new Date().toISOString(),
588
589
  runPid: null,
589
590
  classification: null, userStatus: null, userNote: null,
@@ -607,7 +608,7 @@ export function ensureSessionWorkspace(opts) {
607
608
  // ---------------------------------------------------------------------------
608
609
  // Record to SessionInfo
609
610
  // ---------------------------------------------------------------------------
610
- function managedRecordToSessionInfo(record) {
611
+ export function managedRecordToSessionInfo(record) {
611
612
  // Collapse pre-fix records that stored the canonical skill expansion as the
612
613
  // title / lastQuestion / lastMessageText. New records get collapsed at write
613
614
  // time in `prepareStreamOpts`; this read-time pass keeps existing sessions
@@ -623,6 +624,7 @@ function managedRecordToSessionInfo(record) {
623
624
  threadId: record.threadId,
624
625
  model: record.model,
625
626
  thinkingEffort: record.thinkingEffort,
627
+ workflowEnabled: record.workflowEnabled ?? null,
626
628
  profileId: record.profileId ?? null,
627
629
  createdAt: record.createdAt,
628
630
  title,
@@ -729,6 +731,7 @@ export function getSessionStoredConfig(workdir, agent, sessionId) {
729
731
  return {
730
732
  model: record?.model ?? null,
731
733
  thinkingEffort: record?.thinkingEffort ?? null,
734
+ workflowEnabled: record?.workflowEnabled ?? null,
732
735
  profileId: record?.profileId ?? null,
733
736
  };
734
737
  }
@@ -825,6 +828,16 @@ export function mergeManagedAndNativeSessions(managedSessions, nativeSessions) {
825
828
  runUpdatedAt: useNativeTimeline ? (native.runUpdatedAt ?? managed.runUpdatedAt) : (managed.runUpdatedAt ?? native.runUpdatedAt),
826
829
  title: native.title || managed.title,
827
830
  model: native.model || managed.model,
831
+ // Pikiloom-owned metadata: the native session file (Claude JSONL etc.)
832
+ // carries none of these, so the `...native` spread would clobber them with
833
+ // `undefined`/`null`. The managed record (our centralized index) is the
834
+ // source of truth — recover each like `model` above. Without this the list
835
+ // silently drops the user's per-session choices: effort/Workflow fold back
836
+ // to the global default (per-send `ultra` → `max` after the turn) and the
837
+ // BYOK Profile binding is lost on resume.
838
+ thinkingEffort: managed.thinkingEffort ?? native.thinkingEffort ?? null,
839
+ workflowEnabled: managed.workflowEnabled ?? native.workflowEnabled ?? null,
840
+ profileId: managed.profileId ?? native.profileId ?? null,
828
841
  createdAt: native.createdAt || managed.createdAt,
829
842
  classification: managed.classification ?? native.classification ?? null,
830
843
  userStatus: managed.userStatus ?? native.userStatus ?? null,
@@ -7,7 +7,8 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { restartManagedBrowser } from '../browser-supervisor.js';
9
9
  import { terminateProcessTree } from '../core/process-control.js';
10
- import { AGENT_DETECT_TIMEOUTS, AGENT_STREAM_HARD_KILL_GRACE_MS } from '../core/constants.js';
10
+ import { AGENT_DETECT_TIMEOUTS, AGENT_STREAM_HARD_KILL_GRACE_MS, AGENT_UPDATE_TIMEOUTS } from '../core/constants.js';
11
+ import { awaitAgentUpdateIdle } from './auto-update.js';
11
12
  import { getDriver, allDrivers, getAcceptedProviderKinds, hasDriver } from './driver.js';
12
13
  import { resolveAgentInjection, getActiveProfile, getActiveProfileId, getProvider, updateProfile, listProfiles, } from '../model/index.js';
13
14
  import { Q, agentLog, agentWarn, agentError, joinErrorMessages, normalizeErrorMessage, buildStreamPreviewMeta, computeContext, shortValue, isPendingSessionId, dedupeStrings, normalizeStreamPreviewPlan, } from './utils.js';
@@ -417,12 +418,18 @@ function prepareStreamOpts(opts) {
417
418
  },
418
419
  };
419
420
  }
420
- function finalizeStreamResult(result, workdir, prompt, session) {
421
+ function finalizeStreamResult(result, workdir, prompt, session, workflowEnabled) {
421
422
  if (result.sessionId)
422
423
  syncManagedSessionIdentity(session, workdir, result.sessionId);
423
424
  session.record.model = result.model || session.record.model;
424
425
  if (result.thinkingEffort)
425
426
  session.record.thinkingEffort = result.thinkingEffort;
427
+ // Remember whether this turn ran with Workflow on so the synthetic `ultra`
428
+ // rung re-folds for display after the live stream ends and on resume — the
429
+ // stored `thinkingEffort` stays the concrete rung (e.g. `max`). `undefined`
430
+ // (driver invoked outside the bot) leaves the prior value untouched.
431
+ if (workflowEnabled !== undefined)
432
+ session.record.workflowEnabled = workflowEnabled;
426
433
  // Capture the BYOK Profile that was in effect for this run so a future
427
434
  // `session.switch` can re-bind it (null = native CLI auth).
428
435
  try {
@@ -466,7 +473,7 @@ export async function doStream(opts) {
466
473
  // Start MCP bridge for IM tools (when sendFile is available) and/or supplemental servers (browser, etc.)
467
474
  let bridge = null;
468
475
  try {
469
- const { startMcpBridge } = await import('./mcp/bridge.js');
476
+ const { startMcpBridge, redactMcpConfigForLog } = await import('./mcp/bridge.js');
470
477
  const sessionDir = path.dirname(session.workspacePath);
471
478
  bridge = await startMcpBridge({
472
479
  sessionDir,
@@ -491,7 +498,8 @@ export async function doStream(opts) {
491
498
  else
492
499
  agentLog('[mcp] bridge registered with codex');
493
500
  try {
494
- agentLog(`[mcp] config content:\n${fs.readFileSync(bridge.configPath, 'utf-8')}`);
501
+ if (bridge.configPath)
502
+ agentLog(`[mcp] config content:\n${redactMcpConfigForLog(bridge.configPath)}`);
495
503
  }
496
504
  catch { }
497
505
  ;
@@ -544,13 +552,40 @@ export async function doStream(opts) {
544
552
  catch (e) {
545
553
  agentWarn(`[byok] failed to apply Profile injection: ${e?.message || e}`);
546
554
  }
555
+ // In-memory-first: stamp the turn's resolved reasoning rung + Workflow opt-in
556
+ // onto the centralized index NOW — before the agent CLI has flushed its own
557
+ // session file — so the session list/composer reflect the user's pick during
558
+ // the very first turn instead of only after finalizeStreamResult. The managed
559
+ // record is the single source of truth for this metadata and links to the
560
+ // native agent-session by id on promotion; finalize re-stamps it (plus the
561
+ // actual model) authoritatively at turn end.
562
+ try {
563
+ if (prepared.thinkingEffort) {
564
+ session.record.thinkingEffort = prepared.thinkingEffort.trim().toLowerCase() || session.record.thinkingEffort;
565
+ }
566
+ if (opts.claudeWorkflowEnabled !== undefined) {
567
+ session.record.workflowEnabled = opts.claudeWorkflowEnabled;
568
+ }
569
+ saveSessionRecord(opts.workdir, session.record);
570
+ }
571
+ catch (e) {
572
+ agentWarn(`[session] turn-start metadata stamp failed: ${e?.message || e}`);
573
+ }
547
574
  try {
548
575
  const driver = getDriver(prepared.agent);
549
576
  if (opts.forkOf && !driver.capabilities?.fork) {
550
577
  throw new Error(`Agent ${prepared.agent} does not support fork`);
551
578
  }
579
+ // A background agent-CLI auto-update (`npm install -g` / `brew upgrade`, by
580
+ // this process OR the `npx pikiloom@latest` self-bootstrap) briefly removes
581
+ // the bin while it relinks; exec'ing into that window fails with exit 127
582
+ // "command not found". Wait out any in-flight reinstall of THIS agent before
583
+ // dispatching to the driver — this is the one chokepoint every agent turn
584
+ // (claude -p, claude TUI, codex app-server, gemini) passes through. No-op
585
+ // when nothing is updating.
586
+ await awaitAgentUpdateIdle(prepared.agent, AGENT_UPDATE_TIMEOUTS.spawnWait);
552
587
  const result = await driver.doStream(prepared);
553
- const finalized = finalizeStreamResult(result, opts.workdir, opts.prompt, session);
588
+ const finalized = finalizeStreamResult(result, opts.workdir, opts.prompt, session, opts.claudeWorkflowEnabled);
554
589
  // Once the child has its real session ID, link the lineage. We do this
555
590
  // after finalize so the child record is persisted with its native ID.
556
591
  if (opts.forkOf && finalized.sessionId) {
package/dist/bot/bot.js CHANGED
@@ -228,6 +228,12 @@ export class Bot {
228
228
  */
229
229
  enrichSnapshot(snap) {
230
230
  let next = snap;
231
+ // Attach the running turn's prompt so a watching terminal can render the
232
+ // user message for a follow-up it didn't originate (no local optimistic
233
+ // bubble). The RunningTask record is the source of truth while it's live.
234
+ const runningPrompt = next.taskId ? this.activeTasks.get(next.taskId)?.prompt : '';
235
+ if (runningPrompt)
236
+ next = { ...next, question: collapseSkillPrompt(runningPrompt) ?? runningPrompt };
231
237
  if (next.queuedTaskIds?.length) {
232
238
  const queuedTasks = next.queuedTaskIds.map(taskId => {
233
239
  const raw = this.activeTasks.get(taskId)?.prompt || '';
@@ -446,8 +452,8 @@ export class Bot {
446
452
  emitStreamQueued(sessionKey, taskId) {
447
453
  this.emitStream(sessionKey, { type: 'queued', taskId, position: this.getQueuePosition(sessionKey, taskId) });
448
454
  }
449
- emitStreamStart(taskId, session) {
450
- const cfg = this.resolveSessionStreamConfig(session);
455
+ emitStreamStart(taskId, session, opts) {
456
+ const cfg = this.resolveSessionStreamConfig(session, opts);
451
457
  const key = this.liveSessionKey(taskId, session.key);
452
458
  this.debug(`[stream-lifecycle] start task=${taskId} key=${key} sessionId=${session.sessionId || '(pending)'} model=${cfg.model || '-'}`);
453
459
  this.emitStream(key, {
@@ -1422,7 +1428,10 @@ export class Bot {
1422
1428
  this.finishTask(taskId);
1423
1429
  return;
1424
1430
  }
1425
- this.emitStreamStart(taskId, session);
1431
+ // Thread the per-send Workflow choice so the live divider folds to `ultra`
1432
+ // immediately (the dashboard composer picks ultra per-send without flipping
1433
+ // the agent-global flag resolveSessionStreamConfig would otherwise read).
1434
+ this.emitStreamStart(taskId, session, { workflowEnabled: opts.workflowEnabled });
1426
1435
  // Wire up IM rendering for non-dashboard chats so /goal-driven tasks stream
1427
1436
  // to the same channel that submitted them, matching handleMessage's UX.
1428
1437
  const presenter = chatId !== 'dashboard'
@@ -1985,7 +1994,7 @@ export class Bot {
1985
1994
  * Mirrors the fallback chain used inside runStream() so callers (e.g. submitSessionTask
1986
1995
  * emitting a 'start' event) can label the active turn before runStream resolves it.
1987
1996
  */
1988
- resolveSessionStreamConfig(cs) {
1997
+ resolveSessionStreamConfig(cs, opts) {
1989
1998
  const agentConfig = this.agentConfigs[cs.agent] || {};
1990
1999
  const sessionWorkdir = cs.workdir || this.workdir;
1991
2000
  const storedConfig = cs.sessionId && !isPendingSessionId(cs.sessionId)
@@ -2003,7 +2012,11 @@ export class Bot {
2003
2012
  // Fold to the synthetic 'ultra' rung for display when Workflow is on (mirrors
2004
2013
  // effortSelectionForAgent / the dashboard's foldUltraEffort), so the live reply
2005
2014
  // badge and IM running footer label the turn 'ultra' instead of a bare 'max'.
2006
- const displayEffort = effort && getDriverCapabilities(cs.agent).workflow && this.workflowEnabledForAgent(cs.agent)
2015
+ // Prefer the per-turn workflow choice when the caller threads one (dashboard
2016
+ // composer sends ultra per-send without flipping the agent-global flag);
2017
+ // fall back to the agent-global flag (IM /mode, agent card).
2018
+ const workflowOn = opts?.workflowEnabled ?? this.workflowEnabledForAgent(cs.agent);
2019
+ const displayEffort = effort && getDriverCapabilities(cs.agent).workflow && workflowOn
2007
2020
  ? 'ultra'
2008
2021
  : effort;
2009
2022
  return { model: model || null, effort: displayEffort };
@@ -19,7 +19,7 @@ import { buildAgentsCommandView, buildModelsCommandView, buildModeCommandView, b
19
19
  import { buildSwitchWorkdirView, buildWorkspacesView, resolveRegisteredPath } from './directory.js';
20
20
  import { LivePreview } from './live-preview.js';
21
21
  import { registerProcessRuntime, buildRestartCommand, requestProcessRestart, } from '../../core/process-control.js';
22
- import { buildInitialPreviewHtml, buildHumanLoopPromptHtml, buildAnsweredHumanLoopPromptHtml, buildStreamPreviewHtml, buildFinalReplyRender, dispatchImageBlocks, escapeHtml, formatMenuLines, formatProviderUsageLines, renderCommandNoticeHtml, renderCommandSelectionHtml, renderCommandSelectionKeyboard, renderSessionTurnHtml, truncateMiddle, } from './render.js';
22
+ import { buildInitialPreviewHtml, buildHumanLoopPromptHtml, buildAnsweredHumanLoopPromptHtml, buildStreamPreviewHtml, buildFinalReplyRender, dispatchImageBlocks, escapeHtml, formatMenuLines, formatProviderUsageLines, renderCommandNoticeHtml, renderCommandSelectionHtml, renderCommandSelectionKeyboard, renderSessionTurnHtml, truncateMiddle, unpackCallbackData, } from './render.js';
23
23
  import { currentHumanLoopQuestion, humanLoopOptionSelected } from '../../bot/human-loop.js';
24
24
  import { TelegramChannel } from './channel.js';
25
25
  import { splitText, supportsChannelCapability } from '../base.js';
@@ -1016,7 +1016,7 @@ export class TelegramBot extends Bot {
1016
1016
  return false;
1017
1017
  }
1018
1018
  async handleSessionsPageCallback(data, ctx) {
1019
- const action = decodeCommandAction(data);
1019
+ const action = decodeCommandAction(unpackCallbackData(data));
1020
1020
  if (!action)
1021
1021
  return false;
1022
1022
  const result = await executeCommandAction(this, ctx.chatId, action, {