pikiloom 0.4.41 → 0.4.43

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 (47) hide show
  1. package/dashboard/dist/assets/AgentTab-B8V2eV_D.js +1 -0
  2. package/dashboard/dist/assets/{ConnectionModal-CAlACYKM.js → ConnectionModal-BOVwXLJB.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-D3KGIof1.js → DirBrowser-DbeAWYiL.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-C_69Y7CR.js → ExtensionsTab-BMS3PW9N.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-B5_6IS4X.js → IMAccessTab-B1sRgJk3.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-BjZifpga.js → Modal-DCwGz46r.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-B_V24pNA.js → Modals-Bh_0en5P.js} +1 -1
  8. package/dashboard/dist/assets/{Select-D7xW38wq.js → Select-DC6zguGC.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-DNlLd-PL.js +1 -0
  10. package/dashboard/dist/assets/{SystemTab-i5nmYweA.js → SystemTab-uFqZinIZ.js} +1 -1
  11. package/dashboard/dist/assets/index-CL5H13Cl.js +3 -0
  12. package/dashboard/dist/assets/index-DooLxbPX.js +23 -0
  13. package/dashboard/dist/assets/index-DwmXPtDd.css +1 -0
  14. package/dashboard/dist/assets/{shared-BKwEgcmy.js → shared-Byy2BNLq.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/accounts.js +181 -0
  17. package/dist/agent/drivers/claude.js +187 -36
  18. package/dist/agent/drivers/codex.js +30 -2
  19. package/dist/agent/kernel-bridge.js +63 -3
  20. package/dist/agent/session.js +6 -2
  21. package/dist/agent/stream.js +26 -0
  22. package/dist/bot/bot.js +45 -10
  23. package/dist/bot/command-ui.js +69 -4
  24. package/dist/cli/kernel-app.js +4 -2
  25. package/dist/dashboard/routes/accounts.js +120 -0
  26. package/dist/dashboard/routes/agents.js +1 -1
  27. package/dist/dashboard/server.js +2 -0
  28. package/dist/model/responses-bridge.js +37 -0
  29. package/package.json +1 -1
  30. package/packages/kernel/dist/accounts.d.ts +6 -0
  31. package/packages/kernel/dist/accounts.js +29 -0
  32. package/packages/kernel/dist/drivers/acp.d.ts +24 -0
  33. package/packages/kernel/dist/drivers/acp.js +477 -0
  34. package/packages/kernel/dist/drivers/claude.d.ts +3 -1
  35. package/packages/kernel/dist/drivers/claude.js +126 -9
  36. package/packages/kernel/dist/drivers/codex.js +35 -15
  37. package/packages/kernel/dist/drivers/hermes.d.ts +3 -12
  38. package/packages/kernel/dist/drivers/hermes.js +8 -191
  39. package/packages/kernel/dist/drivers/index.d.ts +1 -0
  40. package/packages/kernel/dist/drivers/index.js +1 -0
  41. package/packages/kernel/dist/index.d.ts +2 -0
  42. package/packages/kernel/dist/index.js +3 -0
  43. package/dashboard/dist/assets/AgentTab-B4ZC9QFL.js +0 -1
  44. package/dashboard/dist/assets/SessionPanel-BbrfUDLg.js +0 -1
  45. package/dashboard/dist/assets/index-A_dL4aEz.js +0 -23
  46. package/dashboard/dist/assets/index-Bthwt6K_.css +0 -1
  47. package/dashboard/dist/assets/index-D18pCeqv.js +0 -3
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { execSync, spawn } from 'node:child_process';
4
4
  import { createInterface } from 'node:readline';
5
5
  import { registerDriver } from '../driver.js';
6
- import { Q, run, agentError, agentLog, agentWarn, buildStreamPreviewMeta, computeContext, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, joinErrorMessages, parseTodoWriteAsPlan, previewToolCallInput, previewToolCallResult, detectClaudeApiError, isRetryableClaudeApiError, detectClaudeModelError, claudeModelErrorMessage, emitSessionIdUpdate, IMAGE_EXTS, mimeForExt, listPikiloomSessions, mergeManagedAndNativeSessions, managedRecordToSessionInfo, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, attachAgentImage, applyTurnWindow, shortValue, roundPercent, modelFamily, normalizeClaudeModelId, emptyUsage, normalizeUsageStatus, collapseSkillPrompt, } from '../index.js';
6
+ import { Q, run, agentError, agentLog, agentWarn, buildStreamPreviewMeta, computeContext, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, joinErrorMessages, parseTodoWriteAsPlan, previewToolCallInput, previewToolCallResult, detectClaudeApiError, isRetryableClaudeApiError, detectClaudeModelError, claudeModelErrorMessage, emitSessionIdUpdate, IMAGE_EXTS, mimeForExt, listPikiloomSessions, mergeManagedAndNativeSessions, managedRecordToSessionInfo, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, attachAgentImage, applyTurnWindow, shortValue, roundPercent, toIsoFromEpochSeconds, modelFamily, normalizeClaudeModelId, emptyUsage, normalizeUsageStatus, collapseSkillPrompt, } from '../index.js';
7
7
  import { AGENT_STREAM_HARD_KILL_GRACE_MS, AGENT_GRACEFUL_ABORT_GRACE_MS, SESSION_RUNNING_THRESHOLD_MS } from '../../core/constants.js';
8
8
  import { terminateProcessTree } from '../../core/process-control.js';
9
9
  import { getHome, IS_MAC, encodePathAsDirName } from '../../core/platform.js';
@@ -1775,54 +1775,187 @@ function getClaudeOAuthToken() {
1775
1775
  return null;
1776
1776
  }
1777
1777
  }
1778
- function getClaudeUsageFromOAuth() {
1779
- const token = getClaudeOAuthToken();
1778
+ // Shape the `/api/oauth/usage` JSON into a UsageResult (shared by the sync curl probe and the
1779
+ // async fetch probe). Returns null on an API error payload or when no usable window is present.
1780
+ function buildClaudeOAuthUsage(data) {
1781
+ const apiError = data?.error;
1782
+ if (apiError && typeof apiError === 'object')
1783
+ return null;
1784
+ const makeWindow = (label, entry) => {
1785
+ if (!entry || typeof entry !== 'object')
1786
+ return null;
1787
+ const usedPercent = roundPercent(entry.utilization);
1788
+ if (usedPercent == null)
1789
+ return null;
1790
+ const remainingPercent = Math.max(0, Math.round((100 - usedPercent) * 10) / 10);
1791
+ const resetAt = typeof entry.resets_at === 'string' ? entry.resets_at : null;
1792
+ let resetAfterSeconds = null;
1793
+ if (resetAt) {
1794
+ const resetAtMs = Date.parse(resetAt);
1795
+ if (Number.isFinite(resetAtMs))
1796
+ resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
1797
+ }
1798
+ return {
1799
+ label, usedPercent, remainingPercent, resetAt, resetAfterSeconds,
1800
+ status: usedPercent >= 100 ? 'limit_reached' : usedPercent >= 80 ? 'warning' : 'allowed',
1801
+ };
1802
+ };
1803
+ const windows = [];
1804
+ for (const [label, key] of [['5h', 'five_hour'], ['7d', 'seven_day'], ['7d Opus', 'seven_day_opus'], ['7d Sonnet', 'seven_day_sonnet'], ['Extra', 'extra_usage']]) {
1805
+ const w = makeWindow(label, data[key]);
1806
+ if (w)
1807
+ windows.push(w);
1808
+ }
1809
+ if (!windows.length)
1810
+ return null;
1811
+ const overallStatus = windows.some(w => w.status === 'limit_reached') ? 'limit_reached'
1812
+ : windows.some(w => w.status === 'warning') ? 'warning' : 'allowed';
1813
+ return { ok: true, agent: 'claude', source: 'oauth-api', capturedAt: new Date().toISOString(), status: overallStatus, windows, error: null };
1814
+ }
1815
+ function getClaudeUsageFromOAuth(tokenOverride) {
1816
+ const token = tokenOverride || getClaudeOAuthToken();
1780
1817
  if (!token)
1781
1818
  return null;
1782
1819
  try {
1783
1820
  const raw = execSync(`curl -s --max-time 5 -H "Authorization: Bearer ${token}" -H "anthropic-beta: oauth-2025-04-20" -H "Content-Type: application/json" "https://api.anthropic.com/api/oauth/usage"`, { encoding: 'utf-8', timeout: 8000 }).trim();
1784
1821
  if (!raw || raw[0] !== '{')
1785
1822
  return null;
1786
- const data = JSON.parse(raw);
1787
- const capturedAt = new Date().toISOString();
1788
- const apiError = data?.error;
1789
- if (apiError && typeof apiError === 'object') {
1823
+ return buildClaudeOAuthUsage(JSON.parse(raw));
1824
+ }
1825
+ catch {
1826
+ return null;
1827
+ }
1828
+ }
1829
+ // Non-blocking variant for the live (getUsageLive) path — execSync would stall the event loop.
1830
+ async function fetchClaudeUsageFromOAuth(tokenOverride) {
1831
+ const token = tokenOverride || getClaudeOAuthToken();
1832
+ if (!token)
1833
+ return null;
1834
+ const controller = new AbortController();
1835
+ const timer = setTimeout(() => controller.abort(), 5_000);
1836
+ try {
1837
+ const res = await fetch('https://api.anthropic.com/api/oauth/usage', {
1838
+ headers: {
1839
+ authorization: `Bearer ${token}`,
1840
+ 'anthropic-beta': 'oauth-2025-04-20',
1841
+ 'content-type': 'application/json',
1842
+ },
1843
+ signal: controller.signal,
1844
+ });
1845
+ const data = await res.json().catch(() => null);
1846
+ return data ? buildClaudeOAuthUsage(data) : null;
1847
+ }
1848
+ catch {
1849
+ return null;
1850
+ }
1851
+ finally {
1852
+ clearTimeout(timer);
1853
+ }
1854
+ }
1855
+ // Per-account usage for a specific account's `claude setup-token`.
1856
+ //
1857
+ // IMPORTANT: setup-tokens are minted with the `user:inference` scope only — they do NOT carry
1858
+ // `user:profile`, so the read-only OAuth usage endpoint (`/api/oauth/usage`) rejects them with
1859
+ // `permission_error … scope requirement user:profile`. The native/default-login token works
1860
+ // there because it's a full login. So for account tokens we read the limit state from the
1861
+ // `anthropic-ratelimit-unified-*` *response headers* of a tiny inference call instead — those
1862
+ // headers come back on every `/v1/messages` response regardless of scope, and are exactly what
1863
+ // Claude Code itself uses to learn 5h / 7d limits. Cost is ~1 output token per probe, cached
1864
+ // for 5min per token (60s back-off on failure), with in-flight de-dup so concurrent surfaces
1865
+ // (cards + header + IM) share one probe.
1866
+ const TOKEN_USAGE_OK_TTL_MS = CLAUDE_USAGE_QUERY_TTL_MS;
1867
+ const TOKEN_USAGE_RETRY_TTL_MS = 60_000;
1868
+ const CLAUDE_USAGE_PROBE_MODEL = 'claude-haiku-4-5-20251001';
1869
+ const claudeTokenUsageCache = new Map();
1870
+ const claudeTokenUsageInflight = new Map();
1871
+ function usageFromRatelimitHeaders(h) {
1872
+ const makeWindow = (label, prefix) => {
1873
+ const raw = h.get(`anthropic-ratelimit-unified-${prefix}-utilization`);
1874
+ if (raw == null || raw === '')
1875
+ return null;
1876
+ const usedPercent = roundPercent(Number(raw) * 100);
1877
+ if (usedPercent == null)
1790
1878
  return null;
1879
+ const remainingPercent = Math.max(0, Math.round((100 - usedPercent) * 10) / 10);
1880
+ const resetAt = toIsoFromEpochSeconds(h.get(`anthropic-ratelimit-unified-${prefix}-reset`));
1881
+ let resetAfterSeconds = null;
1882
+ if (resetAt) {
1883
+ const resetAtMs = Date.parse(resetAt);
1884
+ if (Number.isFinite(resetAtMs))
1885
+ resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
1791
1886
  }
1792
- const makeWindow = (label, entry) => {
1793
- if (!entry || typeof entry !== 'object')
1794
- return null;
1795
- const usedPercent = roundPercent(entry.utilization);
1796
- if (usedPercent == null)
1797
- return null;
1798
- const remainingPercent = Math.max(0, Math.round((100 - usedPercent) * 10) / 10);
1799
- const resetAt = typeof entry.resets_at === 'string' ? entry.resets_at : null;
1800
- let resetAfterSeconds = null;
1801
- if (resetAt) {
1802
- const resetAtMs = Date.parse(resetAt);
1803
- if (Number.isFinite(resetAtMs))
1804
- resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
1805
- }
1806
- return {
1807
- label, usedPercent, remainingPercent, resetAt, resetAfterSeconds,
1808
- status: usedPercent >= 100 ? 'limit_reached' : usedPercent >= 80 ? 'warning' : 'allowed',
1809
- };
1887
+ return {
1888
+ label, usedPercent, remainingPercent, resetAt, resetAfterSeconds,
1889
+ status: usedPercent >= 100 ? 'limit_reached' : usedPercent >= 80 ? 'warning' : 'allowed',
1810
1890
  };
1811
- const windows = [];
1812
- for (const [label, key] of [['5h', 'five_hour'], ['7d', 'seven_day'], ['7d Opus', 'seven_day_opus'], ['7d Sonnet', 'seven_day_sonnet'], ['Extra', 'extra_usage']]) {
1813
- const w = makeWindow(label, data[key]);
1814
- if (w)
1815
- windows.push(w);
1816
- }
1817
- if (!windows.length)
1818
- return null;
1819
- const overallStatus = windows.some(w => w.status === 'limit_reached') ? 'limit_reached'
1820
- : windows.some(w => w.status === 'warning') ? 'warning' : 'allowed';
1821
- return { ok: true, agent: 'claude', source: 'oauth-api', capturedAt, status: overallStatus, windows, error: null };
1891
+ };
1892
+ // Only 5h + 7d are real here. The inference headers expose an overage (Extra) on/off STATUS
1893
+ // but never a utilization number, and `/api/oauth/usage` (which returns the actual extra_usage
1894
+ // figure) is scope-blocked for setup-tokens — so we deliberately do NOT synthesize an "Extra"
1895
+ // window for token accounts. Showing a fabricated 0% misrepresents real Extra spend.
1896
+ const windows = [makeWindow('5h', '5h'), makeWindow('7d', '7d')].filter((w) => w != null);
1897
+ if (!windows.length)
1898
+ return null;
1899
+ const overallStatus = windows.some(w => w.status === 'limit_reached') ? 'limit_reached'
1900
+ : windows.some(w => w.status === 'warning') ? 'warning' : 'allowed';
1901
+ return { ok: true, agent: 'claude', source: 'ratelimit-headers', capturedAt: new Date().toISOString(), status: overallStatus, windows, error: null };
1902
+ }
1903
+ async function claudeUsageFromInferenceHeaders(token) {
1904
+ const controller = new AbortController();
1905
+ const timer = setTimeout(() => controller.abort(), 10_000);
1906
+ try {
1907
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
1908
+ method: 'POST',
1909
+ headers: {
1910
+ authorization: `Bearer ${token}`,
1911
+ 'anthropic-beta': 'oauth-2025-04-20',
1912
+ 'anthropic-version': '2023-06-01',
1913
+ 'content-type': 'application/json',
1914
+ },
1915
+ body: JSON.stringify({ model: CLAUDE_USAGE_PROBE_MODEL, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
1916
+ signal: controller.signal,
1917
+ });
1918
+ // The unified rate-limit headers ride on every response (incl. 429), so parse them before
1919
+ // worrying about the status code. Drain the body so the socket can be reused/closed.
1920
+ const usage = usageFromRatelimitHeaders(res.headers);
1921
+ void res.text().catch(() => { });
1922
+ return usage;
1822
1923
  }
1823
1924
  catch {
1824
1925
  return null;
1825
1926
  }
1927
+ finally {
1928
+ clearTimeout(timer);
1929
+ }
1930
+ }
1931
+ export function claudeUsageForToken(token, opts) {
1932
+ const t = String(token || '');
1933
+ if (!t)
1934
+ return Promise.resolve(null);
1935
+ const now = Date.now();
1936
+ const cached = claudeTokenUsageCache.get(t);
1937
+ if (cached && !opts?.force) {
1938
+ const ttl = cached.ok ? TOKEN_USAGE_OK_TTL_MS : TOKEN_USAGE_RETRY_TTL_MS;
1939
+ if (now - cached.at < ttl)
1940
+ return Promise.resolve(cached.value);
1941
+ }
1942
+ const inflight = claudeTokenUsageInflight.get(t);
1943
+ if (inflight)
1944
+ return inflight;
1945
+ const p = (async () => {
1946
+ const fresh = await claudeUsageFromInferenceHeaders(t);
1947
+ const at = Date.now();
1948
+ if (fresh) {
1949
+ claudeTokenUsageCache.set(t, { value: fresh, at, ok: true });
1950
+ return fresh;
1951
+ }
1952
+ // Probe failed (network / transient): keep serving last-good, but back off retries.
1953
+ const value = claudeTokenUsageCache.get(t)?.value ?? null;
1954
+ claudeTokenUsageCache.set(t, { value, at, ok: false });
1955
+ return value;
1956
+ })().finally(() => claudeTokenUsageInflight.delete(t));
1957
+ claudeTokenUsageInflight.set(t, p);
1958
+ return p;
1826
1959
  }
1827
1960
  function getClaudeUsageFromTelemetry(home, model) {
1828
1961
  const telemetryRoot = path.join(home, '.claude', 'telemetry');
@@ -2093,6 +2226,24 @@ class ClaudeDriver {
2093
2226
  }
2094
2227
  return claudeUsageCache.lastGood ?? telemetry();
2095
2228
  }
2229
+ // Live usage for the agent-status surface. Unlike getUsage it ignores the 5-min query TTL and
2230
+ // always re-probes, so a freshly-switched login (the keychain default-login token changed) is
2231
+ // reflected promptly instead of serving the previous account's frozen `lastGood`. Bounded by
2232
+ // the caller's usage timeout, with the cached value as fallback.
2233
+ async getUsageLive(opts) {
2234
+ const home = getHome();
2235
+ if (!home)
2236
+ return emptyUsage('claude', 'HOME is not set.');
2237
+ const fresh = await fetchClaudeUsageFromOAuth();
2238
+ if (fresh) {
2239
+ claudeUsageCache.lastGood = fresh;
2240
+ claudeUsageCache.lastAttemptAt = Date.now();
2241
+ return fresh;
2242
+ }
2243
+ return claudeUsageCache.lastGood
2244
+ ?? getClaudeUsageFromTelemetry(home, opts.model)
2245
+ ?? emptyUsage('claude', 'No recent Claude usage data found.');
2246
+ }
2096
2247
  async deleteNativeSession(workdir, sessionId) {
2097
2248
  const file = claudeSessionTranscriptPath(workdir, sessionId);
2098
2249
  if (!file || !fs.existsSync(file))
@@ -1641,6 +1641,11 @@ function getCodexSessionMessagesFromRollout(opts) {
1641
1641
  const fallbackMsgs = [];
1642
1642
  let pendingAssistant = null;
1643
1643
  let sawAssistantResponseItems = false;
1644
+ // Codex records the user turn twice: a rich `response_item` (role=user, carries the image as an
1645
+ // input_image data URL or a localImage path) AND a text-only `event_msg`/user_message. The
1646
+ // user bubble is built from the latter, so stash any images from the former and attach them
1647
+ // when the user_message arrives — otherwise pasted images vanish from codex history on reparse.
1648
+ let pendingUserImages = [];
1644
1649
  const ensureAssistant = () => {
1645
1650
  if (!pendingAssistant)
1646
1651
  pendingAssistant = { blocks: [], toolNamesByCallId: new Map() };
@@ -1677,12 +1682,18 @@ function getCodexSessionMessagesFromRollout(opts) {
1677
1682
  if (ev.payload.type === 'user_message' && typeof ev.payload.message === 'string') {
1678
1683
  flushAssistant();
1679
1684
  const text = stripInjectedPrompts(ev.payload.message).trim();
1680
- if (!text)
1685
+ const imageBlocks = pendingUserImages;
1686
+ pendingUserImages = [];
1687
+ if (!text && !imageBlocks.length)
1681
1688
  continue;
1682
1689
  const userMessage = { role: 'user', text };
1683
1690
  fallbackMsgs.push(userMessage);
1684
1691
  allMsgs.push(userMessage);
1685
- richMsgs.push({ role: 'user', text, blocks: [{ type: 'text', content: text }] });
1692
+ const blocks = [];
1693
+ if (text)
1694
+ blocks.push({ type: 'text', content: text });
1695
+ blocks.push(...imageBlocks);
1696
+ richMsgs.push({ role: 'user', text, blocks });
1686
1697
  }
1687
1698
  else if (ev.payload.type === 'agent_message' && typeof ev.payload.message === 'string') {
1688
1699
  const text = ev.payload.message.trim();
@@ -1695,6 +1706,23 @@ function getCodexSessionMessagesFromRollout(opts) {
1695
1706
  continue;
1696
1707
  const payload = ev.payload;
1697
1708
  if (payload.type === 'message') {
1709
+ if (payload.role === 'user') {
1710
+ for (const c of (Array.isArray(payload.content) ? payload.content : [])) {
1711
+ if (c?.type === 'input_image' && typeof c.image_url === 'string' && c.image_url.startsWith('data:image/')) {
1712
+ pendingUserImages.push({ type: 'image', content: c.image_url });
1713
+ }
1714
+ else if (c?.type === 'localImage' && typeof c.path === 'string') {
1715
+ try {
1716
+ if (fs.existsSync(c.path) && fs.statSync(c.path).size <= 4 * 1024 * 1024) {
1717
+ const ext = path.extname(c.path).toLowerCase();
1718
+ pendingUserImages.push({ type: 'image', content: `data:${mimeForExt(ext)};base64,${fs.readFileSync(c.path).toString('base64')}` });
1719
+ }
1720
+ }
1721
+ catch { /* ignore */ }
1722
+ }
1723
+ }
1724
+ continue;
1725
+ }
1698
1726
  if (payload.role !== 'assistant')
1699
1727
  continue;
1700
1728
  const text = extractCodexMessageText(payload.content);
@@ -15,8 +15,44 @@ import { humanizeCodexError } from './drivers/codex.js';
15
15
  // Tests always run legacy (the unit suite asserts legacy driver behavior). The bridge
16
16
  // re-applies app-level parity the pure kernel must not own (claude jsonl entrypoint, codex humanize).
17
17
  const KERNEL_AGENTS = new Set(['claude', 'codex', 'gemini', 'hermes']);
18
+ // Generic ACP agents the kernel can drive via @pikiloom/kernel's AcpDriver. Hermes is its
19
+ // own built-in preset; this registry is for ANY other ACP CLI (OpenCode, Zed, …). Extend
20
+ // without a code edit via PIKILOOM_ACP_AGENTS='{"zed":{"command":"zed","args":["--acp"]}}'.
21
+ const DEFAULT_ACP_AGENTS = {
22
+ opencode: { command: 'opencode', args: ['acp'] },
23
+ };
24
+ export function acpAgentConfig(agent) {
25
+ if (DEFAULT_ACP_AGENTS[agent])
26
+ return DEFAULT_ACP_AGENTS[agent];
27
+ try {
28
+ const extra = process.env.PIKILOOM_ACP_AGENTS ? JSON.parse(process.env.PIKILOOM_ACP_AGENTS) : null;
29
+ const c = extra?.[agent];
30
+ if (c && (c.command || agent))
31
+ return { command: String(c.command || agent), args: Array.isArray(c.args) ? c.args.map(String) : ['acp'] };
32
+ }
33
+ catch { /* malformed env → ignore */ }
34
+ return null;
35
+ }
36
+ // pikiloom's mcpServers (Record) -> kernel McpServerSpec[] for ACP session/new forwarding.
37
+ function mcpRecordToKernelSpecs(servers) {
38
+ if (!servers)
39
+ return [];
40
+ const out = [];
41
+ for (const [name, cfg] of Object.entries(servers)) {
42
+ if (!cfg || typeof cfg !== 'object')
43
+ continue;
44
+ const type = String(cfg.type || '').toLowerCase();
45
+ if ((type === 'http' || type === 'sse') && cfg.url) {
46
+ out.push({ name, type: 'http', url: String(cfg.url), headers: cfg.headers || {} });
47
+ continue;
48
+ }
49
+ if (cfg.command)
50
+ out.push({ name, type: 'stdio', command: String(cfg.command), args: Array.isArray(cfg.args) ? cfg.args.map(String) : [], env: cfg.env || {} });
51
+ }
52
+ return out;
53
+ }
18
54
  export function shouldUseKernelPipeline(agent) {
19
- if (!KERNEL_AGENTS.has(agent))
55
+ if (!KERNEL_AGENTS.has(agent) && !acpAgentConfig(agent))
20
56
  return false;
21
57
  if (process.env.VITEST || process.env.NODE_ENV === 'test')
22
58
  return false; // tests assert legacy
@@ -41,7 +77,15 @@ function buildKernelDriver(kernel, opts) {
41
77
  effort: opts.thinkingEffort,
42
78
  env: opts.extraEnv,
43
79
  mcpConfigPath: opts.mcpConfigPath ?? null,
80
+ // Consumed by the ACP path (Hermes + generic AcpDriver) for session/new forwarding;
81
+ // ignored by claude/codex/gemini (they carry MCP via config file / native registration).
82
+ extraMcpServers: mcpRecordToKernelSpecs(opts.mcpServers),
44
83
  };
84
+ // Any registered generic ACP agent (OpenCode, …) -> the kernel's AcpDriver.
85
+ const acp = acpAgentConfig(opts.agent);
86
+ if (acp) {
87
+ return { driver: new kernel.AcpDriver({ id: opts.agent, command: acp.command, args: acp.args }), input: { ...common, model: opts.model ?? null } };
88
+ }
45
89
  switch (opts.agent) {
46
90
  case 'codex': {
47
91
  // codexExtraArgs is a flattened ['-c','k=v','-c','k=v',...]; extract the k=v values
@@ -65,6 +109,22 @@ function buildKernelDriver(kernel, opts) {
65
109
  };
66
110
  }
67
111
  }
112
+ // Kernel plan steps key their text as { text }; pikiloom's StreamPlan and the entire dashboard
113
+ // pipeline (pikichannel adapter -> ws.ts -> PlanProgressCard) key it as { step }. Translate at
114
+ // this seam — the same place we already remap usage fields — so codex/claude task lists render
115
+ // their text. Without it the progress count ("0/4") shows but every row is blank (the card reads
116
+ // step.step, which is undefined on the kernel shape).
117
+ export function toPikiloomPlan(plan) {
118
+ if (!plan || !Array.isArray(plan.steps))
119
+ return null;
120
+ const steps = plan.steps
121
+ .map((st) => ({
122
+ step: typeof st?.text === 'string' ? st.text : typeof st?.step === 'string' ? st.step : '',
123
+ status: (st?.status === 'completed' ? 'completed' : st?.status === 'inProgress' ? 'inProgress' : 'pending'),
124
+ }))
125
+ .filter((st) => st.step.trim());
126
+ return steps.length ? { explanation: typeof plan.explanation === 'string' ? plan.explanation : null, steps } : null;
127
+ }
68
128
  let _kernel = null;
69
129
  export async function loadKernel() {
70
130
  if (_kernel)
@@ -141,7 +201,7 @@ export async function kernelStream(opts) {
141
201
  providerName: opts.byokProviderName ?? null,
142
202
  };
143
203
  try {
144
- opts.onText(s.text || '', s.reasoning || '', s.activity || '', m, s.plan ?? null);
204
+ opts.onText(s.text || '', s.reasoning || '', s.activity || '', m, toPikiloomPlan(s.plan));
145
205
  }
146
206
  catch { /* isolate */ }
147
207
  };
@@ -185,7 +245,7 @@ export async function kernelStream(opts) {
185
245
  ok: !!result.ok,
186
246
  message: (result.text || snapshot.text || '').trim() || (finalError ?? '(no output)'),
187
247
  thinking: (result.reasoning || snapshot.reasoning || '').trim() || null,
188
- plan: snapshot.plan ?? null,
248
+ plan: toPikiloomPlan(snapshot.plan),
189
249
  sessionId: finalSessionId,
190
250
  workspacePath: null,
191
251
  model: input.model ?? null,
@@ -184,6 +184,7 @@ function normalizeSessionRecord(raw, workdir) {
184
184
  thinkingEffort: typeof raw?.thinkingEffort === 'string' && raw.thinkingEffort.trim() ? raw.thinkingEffort.trim() : null,
185
185
  workflowEnabled: typeof raw?.workflowEnabled === 'boolean' ? raw.workflowEnabled : null,
186
186
  profileId: typeof raw?.profileId === 'string' && raw.profileId.trim() ? raw.profileId.trim() : null,
187
+ accountId: typeof raw?.accountId === 'string' && raw.accountId.trim() ? raw.accountId.trim() : null,
187
188
  stagedFiles: Array.isArray(raw?.stagedFiles) ? dedupeStrings(raw.stagedFiles.filter((v) => typeof v === 'string')) : [],
188
189
  lastUserAttachments: Array.isArray(raw?.lastUserAttachments)
189
190
  ? dedupeStrings(raw.lastUserAttachments.filter((v) => typeof v === 'string'))
@@ -327,7 +328,7 @@ function writeSessionMeta(record) {
327
328
  workspacePath: record.workspacePath,
328
329
  threadId: record.threadId,
329
330
  createdAt: record.createdAt, updatedAt: record.updatedAt,
330
- title: record.title, model: record.model, thinkingEffort: record.thinkingEffort, workflowEnabled: record.workflowEnabled, profileId: record.profileId, stagedFiles: record.stagedFiles,
331
+ title: record.title, model: record.model, thinkingEffort: record.thinkingEffort, workflowEnabled: record.workflowEnabled, profileId: record.profileId, accountId: record.accountId ?? null, stagedFiles: record.stagedFiles,
331
332
  runState: record.runState, runDetail: record.runDetail, runUpdatedAt: record.runUpdatedAt,
332
333
  runPid: record.runPid,
333
334
  classification: record.classification,
@@ -563,7 +564,7 @@ export function ensureSessionWorkspace(opts) {
563
564
  workspacePath: sessionWorkspacePath(workdir, opts.agent, sessionId),
564
565
  threadId,
565
566
  createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
566
- title: summarizePromptTitle(opts.title) || null, model: null, thinkingEffort: null, workflowEnabled: null, profileId: null, stagedFiles: [], lastUserAttachments: [],
567
+ title: summarizePromptTitle(opts.title) || null, model: null, thinkingEffort: null, workflowEnabled: null, profileId: null, accountId: null, stagedFiles: [], lastUserAttachments: [],
567
568
  runState: 'completed', runDetail: null, runUpdatedAt: new Date().toISOString(),
568
569
  runPid: null,
569
570
  classification: null, userStatus: null, userNote: null,
@@ -597,6 +598,7 @@ export function managedRecordToSessionInfo(record) {
597
598
  thinkingEffort: record.thinkingEffort,
598
599
  workflowEnabled: record.workflowEnabled ?? null,
599
600
  profileId: record.profileId ?? null,
601
+ accountId: record.accountId ?? null,
600
602
  createdAt: record.createdAt,
601
603
  title,
602
604
  running: record.runState === 'running',
@@ -680,6 +682,7 @@ export function getSessionStoredConfig(workdir, agent, sessionId) {
680
682
  thinkingEffort: record?.thinkingEffort ?? null,
681
683
  workflowEnabled: record?.workflowEnabled ?? null,
682
684
  profileId: record?.profileId ?? null,
685
+ accountId: record?.accountId ?? null,
683
686
  };
684
687
  }
685
688
  export function ensureManagedSession(opts) {
@@ -774,6 +777,7 @@ export function mergeManagedAndNativeSessions(managedSessions, nativeSessions) {
774
777
  thinkingEffort: managed.thinkingEffort ?? native.thinkingEffort ?? null,
775
778
  workflowEnabled: managed.workflowEnabled ?? native.workflowEnabled ?? null,
776
779
  profileId: managed.profileId ?? native.profileId ?? null,
780
+ accountId: managed.accountId ?? native.accountId ?? null,
777
781
  createdAt: native.createdAt || managed.createdAt,
778
782
  classification: managed.classification ?? native.classification ?? null,
779
783
  userStatus: managed.userStatus ?? native.userStatus ?? null,
@@ -13,6 +13,7 @@ import { saveSessionRecord, setSessionRunState, applySessionRunResult, ensureSes
13
13
  import { clearAwaitResume } from './await-resume.js';
14
14
  import { collapseSkillPrompt } from './skills.js';
15
15
  import { shouldUseKernelPipeline, kernelStream } from './kernel-bridge.js';
16
+ import { accountAgentSupported, resolveAccountEnv, updateAccount } from './accounts.js';
16
17
  function trimSessionText(value, max = 24_000) {
17
18
  const text = typeof value === 'string' ? value.trim() : '';
18
19
  if (!text)
@@ -531,6 +532,29 @@ export async function doStream(opts) {
531
532
  catch (e) {
532
533
  agentWarn(`[byok] failed to apply Profile injection: ${e?.message || e}`);
533
534
  }
535
+ // Multi-account (native subscription): when NO BYOK profile is bound, route the turn
536
+ // through the agent's active local account by injecting its isolated config dir
537
+ // (CLAUDE_CONFIG_DIR / CODEX_HOME). A BYOK profile owns the credential, so it takes over
538
+ // and account injection is skipped — the two axes are mutually exclusive per turn.
539
+ try {
540
+ if (accountAgentSupported(prepared.agent)) {
541
+ const profileBound = prepared.profileId === undefined
542
+ ? !!getActiveProfile(prepared.agent)
543
+ : !!(prepared.profileId && getProfile(prepared.profileId));
544
+ if (!profileBound) {
545
+ const resolved = await resolveAccountEnv(prepared.agent, prepared.accountId);
546
+ if (resolved) {
547
+ prepared.extraEnv = { ...(prepared.extraEnv || {}), ...resolved.env };
548
+ prepared.accountId = resolved.accountId;
549
+ agentLog(`[account] ${prepared.agent} turn → account=${resolved.accountId} (${Object.keys(resolved.env).join('+')})`);
550
+ void updateAccount(prepared.agent, resolved.accountId, { lastUsedAt: new Date().toISOString() }).catch(() => { });
551
+ }
552
+ }
553
+ }
554
+ }
555
+ catch (e) {
556
+ agentWarn(`[account] injection failed: ${e?.message || e}`);
557
+ }
534
558
  try {
535
559
  if (prepared.thinkingEffort) {
536
560
  session.record.thinkingEffort = prepared.thinkingEffort.trim().toLowerCase() || session.record.thinkingEffort;
@@ -548,6 +572,8 @@ export async function doStream(opts) {
548
572
  session.record.model = turnModel;
549
573
  if (prepared.profileId !== undefined)
550
574
  session.record.profileId = prepared.profileId || null;
575
+ if (prepared.accountId !== undefined)
576
+ session.record.accountId = prepared.accountId || null;
551
577
  saveSessionRecord(opts.workdir, session.record);
552
578
  }
553
579
  catch (e) {
package/dist/bot/bot.js CHANGED
@@ -2,7 +2,7 @@ import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import { execSync, spawn } from 'node:child_process';
4
4
  import { getActiveUserConfig, loadWorkspaces, onUserConfigChange, resolveUserWorkdir, setUserWorkdir, updateUserConfig } from '../core/config/user-config.js';
5
- 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, deliverArtifact, attachmentUrl, isPendingSessionId, } from '../agent/index.js';
5
+ 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, deliverArtifact, attachmentUrl, attachAgentImage, rewriteAttachmentBlocksForTransport, isPendingSessionId, } from '../agent/index.js';
6
6
  import { compactForHandover, describeHandoverRef } from '../agent/handover.js';
7
7
  import { getActiveProfileId, getActiveProfile, setActiveProfile } from '../model/index.js';
8
8
  import { querySessions, querySessionTail, updateSession, } from './session-hub.js';
@@ -160,16 +160,48 @@ export class Bot {
160
160
  const snap = this.streamSnapshots.get(this.resolveSessionKey(sessionKey));
161
161
  return snap ? this.enrichSnapshot(snap) : null;
162
162
  }
163
+ taskAttachmentBlocks(task) {
164
+ if (!task?.attachments?.length)
165
+ return [];
166
+ const session = this.getSessionRuntimeByKey(task.sessionKey, { allowAnyWorkdir: true });
167
+ const sessionId = session?.sessionId || task.sessionKey.slice(task.agent.length + 1);
168
+ if (!sessionId)
169
+ return [];
170
+ const blocks = task.attachments
171
+ .map(imagePath => attachAgentImage({ imagePath, inlineThresholdBytes: 0 }))
172
+ .filter((block) => !!block);
173
+ return blocks.length
174
+ ? rewriteAttachmentBlocksForTransport(blocks, { agent: task.agent, sessionId })
175
+ : [];
176
+ }
177
+ taskPreview(taskId) {
178
+ const task = this.activeTasks.get(taskId) || null;
179
+ const raw = task?.prompt || '';
180
+ const attachments = this.taskAttachmentBlocks(task);
181
+ return {
182
+ taskId,
183
+ prompt: collapseSkillPrompt(raw) ?? raw,
184
+ ...(attachments.length ? { attachments } : {}),
185
+ };
186
+ }
163
187
  enrichSnapshot(snap) {
164
188
  let next = snap;
165
- const runningPrompt = next.taskId ? this.activeTasks.get(next.taskId)?.prompt : '';
166
- if (runningPrompt)
167
- next = { ...next, question: collapseSkillPrompt(runningPrompt) ?? runningPrompt };
168
- if (next.queuedTaskIds?.length) {
169
- const queuedTasks = next.queuedTaskIds.map(taskId => {
170
- const raw = this.activeTasks.get(taskId)?.prompt || '';
171
- return { taskId, prompt: collapseSkillPrompt(raw) ?? raw };
172
- });
189
+ const runningTask = next.taskId ? this.activeTasks.get(next.taskId) : null;
190
+ const runningPrompt = runningTask?.prompt || '';
191
+ if (runningPrompt) {
192
+ const questionBlocks = this.taskAttachmentBlocks(runningTask);
193
+ next = {
194
+ ...next,
195
+ question: collapseSkillPrompt(runningPrompt) ?? runningPrompt,
196
+ ...(questionBlocks.length ? { questionBlocks } : {}),
197
+ };
198
+ }
199
+ const queuedIds = [
200
+ ...(next.phase === 'queued' && next.taskId ? [next.taskId] : []),
201
+ ...(next.queuedTaskIds || []),
202
+ ].filter((taskId, index, all) => taskId && all.indexOf(taskId) === index);
203
+ if (queuedIds.length) {
204
+ const queuedTasks = queuedIds.map(taskId => this.taskPreview(taskId));
173
205
  next = { ...next, queuedTasks };
174
206
  }
175
207
  if (next.interactions?.length) {
@@ -234,13 +266,16 @@ export class Bot {
234
266
  switch (event.type) {
235
267
  case 'queued': {
236
268
  const existing = this.streamSnapshots.get(sessionKey);
237
- if (existing && (existing.phase === 'streaming' || existing.phase === 'done')) {
269
+ if (existing && existing.phase === 'streaming') {
238
270
  const list = existing.queuedTaskIds ? [...existing.queuedTaskIds] : [];
239
271
  if (existing.taskId !== event.taskId && !list.includes(event.taskId))
240
272
  list.push(event.taskId);
241
273
  existing.queuedTaskIds = list.length ? list : undefined;
242
274
  existing.updatedAt = now;
243
275
  }
276
+ else if (existing && existing.phase === 'done') {
277
+ this.streamSnapshots.set(sessionKey, { phase: 'queued', taskId: event.taskId, updatedAt: now });
278
+ }
244
279
  else if (existing && existing.phase === 'queued') {
245
280
  const list = existing.queuedTaskIds ? [...existing.queuedTaskIds] : [];
246
281
  if (existing.taskId !== event.taskId && !list.includes(event.taskId))