pikiloom 0.4.52 → 0.4.53

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 (29) hide show
  1. package/dashboard/dist/assets/{AgentTab-TSZ8xlgI.js → AgentTab-DSpqOkYp.js} +1 -1
  2. package/dashboard/dist/assets/{ConnectionModal-B9oO45Ef.js → ConnectionModal-BFcN8nJr.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-C_WsxNQt.js → DirBrowser-Dk-TadnX.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-BnjedgD_.js → ExtensionsTab-wiaVcnfS.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-BSd7MmZS.js → IMAccessTab-C4ltstAX.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-DqFyZZqT.js → Modal-BeyxM41U.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-BJYW-XQN.js → Modals-B2a8IIvO.js} +1 -1
  8. package/dashboard/dist/assets/{Select-CSadvVMI.js → Select-DxYPQ-41.js} +1 -1
  9. package/dashboard/dist/assets/{SessionPanel-xQtNHsyz.js → SessionPanel-CYO6NGSt.js} +1 -1
  10. package/dashboard/dist/assets/{SystemTab-q3hvXi8s.js → SystemTab-1frRjK4R.js} +1 -1
  11. package/dashboard/dist/assets/index-BQ_R1F_G.css +1 -0
  12. package/dashboard/dist/assets/{index-DWfL0h2E.js → index-C-9wwAiw.js} +3 -3
  13. package/dashboard/dist/assets/index-dR0w8M7K.js +3 -0
  14. package/dashboard/dist/assets/{shared-CCX1F8yo.js → shared-BbgVZfU_.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/drivers/claude.js +138 -25
  17. package/dist/agent/drivers/codex.js +109 -26
  18. package/dist/agent/kernel-bridge.js +32 -12
  19. package/dist/agent/utils.js +5 -1
  20. package/dist/bot/bot.js +18 -1
  21. package/dist/bot/render-shared.js +15 -2
  22. package/dist/core/turn-audit.js +25 -0
  23. package/package.json +1 -1
  24. package/packages/kernel/dist/drivers/claude.d.ts +3 -0
  25. package/packages/kernel/dist/drivers/claude.js +90 -6
  26. package/packages/kernel/dist/index.d.ts +1 -1
  27. package/packages/kernel/dist/index.js +1 -1
  28. package/dashboard/dist/assets/index-BcPBJtn2.css +0 -1
  29. package/dashboard/dist/assets/index-FePQwr1a.js +0 -3
@@ -1440,13 +1440,21 @@ const SYSTEM_INJECTED_USER_TAGS = new Set([
1440
1440
  'case_id',
1441
1441
  'tool-use-id',
1442
1442
  'output-file',
1443
+ // Kernel claude driver's in-process truncated-turn recovery prompt (see packages/kernel
1444
+ // drivers/claude.ts): injected on the CLI's stdin, so it lands in the jsonl as a real user
1445
+ // message — hide it from the transcript like other system-injected turns.
1446
+ 'pikiloom-recover',
1443
1447
  ]);
1448
+ // The CLI's resume-time repair placeholder for a turn that never concluded (paired with an
1449
+ // isMeta "Continue from where you left off." user record). It is not model output — but it IS
1450
+ // the only durable marker that the previous reply was cut off before its closing message.
1444
1451
  function isClaudeSyntheticResumeNoise(text) {
1445
1452
  const t = (text || '').trim().toLowerCase();
1446
1453
  if (!t)
1447
1454
  return true;
1448
1455
  return t === 'no response requested.' || t === 'no response requested';
1449
1456
  }
1457
+ const CLAUDE_INCOMPLETE_TURN_NOTICE = '⚠️ This reply ended before a closing message was delivered (interrupted, or the model returned an empty final response).';
1450
1458
  function isSystemInjectedUserEvent(text) {
1451
1459
  const trimmed = (text || '').trim();
1452
1460
  if (!trimmed)
@@ -1629,13 +1637,18 @@ function getClaudeSessionMessages(opts) {
1629
1637
  else if (ev.type === 'assistant') {
1630
1638
  if (ev.message?.model === '<synthetic>') {
1631
1639
  const noticeText = extractClaudeText(ev.message?.content, true).trim();
1632
- if (isClaudeSyntheticResumeNoise(noticeText))
1640
+ // "No response requested." is the CLI's resume repair for a turn that never
1641
+ // concluded. Dropping it hid the cut-off entirely — the dangling turn read as a
1642
+ // normal answer that stops mid-sentence. Show a notice on that turn instead.
1643
+ const displayText = !noticeText
1644
+ ? ''
1645
+ : isClaudeSyntheticResumeNoise(noticeText) ? CLAUDE_INCOMPLETE_TURN_NOTICE : noticeText;
1646
+ if (!displayText)
1633
1647
  continue;
1634
1648
  if (pendingRole === 'user')
1635
1649
  flush();
1636
1650
  pendingRole = 'assistant';
1637
- if (noticeText)
1638
- pendingBlocks.push({ type: 'system_notice', content: noticeText });
1651
+ pendingBlocks.push({ type: 'system_notice', content: displayText });
1639
1652
  continue;
1640
1653
  }
1641
1654
  if (pendingRole === 'user')
@@ -1808,37 +1821,137 @@ function getClaudeOAuthToken() {
1808
1821
  return null;
1809
1822
  }
1810
1823
  }
1824
+ function claudeResetInfo(resetsAt) {
1825
+ const resetAt = typeof resetsAt === 'string' ? resetsAt : null;
1826
+ let resetAfterSeconds = null;
1827
+ if (resetAt) {
1828
+ const resetAtMs = Date.parse(resetAt);
1829
+ if (Number.isFinite(resetAtMs))
1830
+ resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
1831
+ }
1832
+ return { resetAt, resetAfterSeconds };
1833
+ }
1834
+ // Window status from the upstream severity when it maps to one of ours, else from the percent.
1835
+ function claudeWindowStatus(usedPercent, severity) {
1836
+ const fromSeverity = normalizeUsageStatus(severity);
1837
+ if (fromSeverity === 'limit_reached' || fromSeverity === 'warning' || fromSeverity === 'allowed')
1838
+ return fromSeverity;
1839
+ return usedPercent >= 100 ? 'limit_reached' : usedPercent >= 80 ? 'warning' : 'allowed';
1840
+ }
1841
+ // "$101.61" from a minor-unit amount (10161 with exponent 2). Non-USD keeps the currency code.
1842
+ function claudeCreditAmount(amountMinor, exponent, currency) {
1843
+ const minor = Number(amountMinor);
1844
+ if (!Number.isFinite(minor))
1845
+ return null;
1846
+ const expRaw = Number(exponent);
1847
+ const exp = Number.isFinite(expRaw) ? Math.max(0, Math.min(4, Math.round(expRaw))) : 2;
1848
+ const value = (minor / Math.pow(10, exp)).toFixed(exp);
1849
+ const cur = typeof currency === 'string' && currency ? currency.toUpperCase() : 'USD';
1850
+ return cur === 'USD' ? `$${value}` : `${value} ${cur}`;
1851
+ }
1852
+ const CLAUDE_LIMIT_KIND_LABELS = {
1853
+ session: '5h',
1854
+ weekly_all: '7d',
1855
+ };
1856
+ // One window per `limits[]` entry. `weekly_scoped` is the per-model weekly quota (the successor
1857
+ // of the legacy seven_day_opus / seven_day_sonnet keys) — label it by the scoped model name.
1858
+ function claudeWindowFromLimitEntry(entry) {
1859
+ if (!entry || typeof entry !== 'object')
1860
+ return null;
1861
+ const usedPercent = roundPercent(entry.percent);
1862
+ if (usedPercent == null)
1863
+ return null;
1864
+ const kind = typeof entry.kind === 'string' ? entry.kind : '';
1865
+ let label = CLAUDE_LIMIT_KIND_LABELS[kind] ?? null;
1866
+ if (!label && kind === 'weekly_scoped') {
1867
+ const scopeName = entry.scope?.model?.display_name || entry.scope?.model?.id || entry.scope?.surface;
1868
+ label = typeof scopeName === 'string' && scopeName ? `7d ${scopeName}` : '7d scoped';
1869
+ }
1870
+ if (!label)
1871
+ label = kind ? kind.replace(/_/g, ' ') : 'limit';
1872
+ const { resetAt, resetAfterSeconds } = claudeResetInfo(entry.resets_at);
1873
+ return {
1874
+ label, usedPercent,
1875
+ remainingPercent: Math.max(0, Math.round((100 - usedPercent) * 10) / 10),
1876
+ resetAt, resetAfterSeconds,
1877
+ status: claudeWindowStatus(usedPercent, entry.severity),
1878
+ };
1879
+ }
1880
+ // The Extra (credit-metered overage) window. Percent comes from extra_usage.utilization (finer
1881
+ // grained) or spend.percent; the actual spend money rides along as `detail` so surfaces can show
1882
+ // "$101.61 / $500.00" instead of a bare percent. Skipped when the account has Extra disabled.
1883
+ function claudeExtraUsageWindow(data) {
1884
+ const extra = data?.extra_usage && typeof data.extra_usage === 'object' ? data.extra_usage : null;
1885
+ const spend = data?.spend && typeof data.spend === 'object' ? data.spend : null;
1886
+ if (extra?.is_enabled === false || (!extra && spend?.enabled === false))
1887
+ return null;
1888
+ let usedPercent = extra ? roundPercent(extra.utilization) : null;
1889
+ if (usedPercent == null && spend)
1890
+ usedPercent = roundPercent(spend.percent);
1891
+ if (usedPercent == null)
1892
+ return null;
1893
+ let detail = null;
1894
+ const used = claudeCreditAmount(spend?.used?.amount_minor, spend?.used?.exponent, spend?.used?.currency)
1895
+ ?? claudeCreditAmount(extra?.used_credits, extra?.decimal_places, extra?.currency);
1896
+ const limit = claudeCreditAmount(spend?.limit?.amount_minor, spend?.limit?.exponent, spend?.limit?.currency)
1897
+ ?? claudeCreditAmount(extra?.monthly_limit, extra?.decimal_places, extra?.currency);
1898
+ if (used)
1899
+ detail = limit ? `${used} / ${limit}` : used;
1900
+ const { resetAt, resetAfterSeconds } = claudeResetInfo(extra?.resets_at);
1901
+ return {
1902
+ label: 'Extra', usedPercent,
1903
+ remainingPercent: Math.max(0, Math.round((100 - usedPercent) * 10) / 10),
1904
+ resetAt, resetAfterSeconds,
1905
+ status: claudeWindowStatus(usedPercent, spend?.severity),
1906
+ detail,
1907
+ };
1908
+ }
1811
1909
  // Shape the `/api/oauth/usage` JSON into a UsageResult (shared by the sync curl probe and the
1812
1910
  // async fetch probe). Returns null on an API error payload or when no usable window is present.
1813
- function buildClaudeOAuthUsage(data) {
1911
+ //
1912
+ // The payload carries two generations of quota shapes and we prefer the newer one:
1913
+ // - `limits[]` (2026+): one entry per window — kind "session" (5h), "weekly_all" (7d) and
1914
+ // "weekly_scoped" (per-model weekly, scope.model.display_name) — each with its own percent /
1915
+ // severity / resets_at. On these payloads the legacy `seven_day_opus` / `seven_day_sonnet`
1916
+ // top-level keys are null, so parsing only those silently drops the scoped model window.
1917
+ // - legacy fixed top-level keys: five_hour / seven_day / seven_day_opus / seven_day_sonnet.
1918
+ // `extra_usage` + `spend` (credit-metered overage) ride on both shapes, handled separately so
1919
+ // the actual dollar spend is preserved.
1920
+ export function buildClaudeOAuthUsage(data) {
1814
1921
  const apiError = data?.error;
1815
1922
  if (apiError && typeof apiError === 'object')
1816
1923
  return null;
1817
- const makeWindow = (label, entry) => {
1818
- if (!entry || typeof entry !== 'object')
1819
- return null;
1820
- const usedPercent = roundPercent(entry.utilization);
1821
- if (usedPercent == null)
1822
- return null;
1823
- const remainingPercent = Math.max(0, Math.round((100 - usedPercent) * 10) / 10);
1824
- const resetAt = typeof entry.resets_at === 'string' ? entry.resets_at : null;
1825
- let resetAfterSeconds = null;
1826
- if (resetAt) {
1827
- const resetAtMs = Date.parse(resetAt);
1828
- if (Number.isFinite(resetAtMs))
1829
- resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
1830
- }
1831
- return {
1832
- label, usedPercent, remainingPercent, resetAt, resetAfterSeconds,
1833
- status: usedPercent >= 100 ? 'limit_reached' : usedPercent >= 80 ? 'warning' : 'allowed',
1834
- };
1835
- };
1836
1924
  const windows = [];
1837
- for (const [label, key] of [['5h', 'five_hour'], ['7d', 'seven_day'], ['7d Opus', 'seven_day_opus'], ['7d Sonnet', 'seven_day_sonnet'], ['Extra', 'extra_usage']]) {
1838
- const w = makeWindow(label, data[key]);
1925
+ const limitEntries = Array.isArray(data?.limits) ? data.limits : [];
1926
+ for (const entry of limitEntries) {
1927
+ const w = claudeWindowFromLimitEntry(entry);
1839
1928
  if (w)
1840
1929
  windows.push(w);
1841
1930
  }
1931
+ if (!windows.length) {
1932
+ const makeWindow = (label, entry) => {
1933
+ if (!entry || typeof entry !== 'object')
1934
+ return null;
1935
+ const usedPercent = roundPercent(entry.utilization);
1936
+ if (usedPercent == null)
1937
+ return null;
1938
+ const { resetAt, resetAfterSeconds } = claudeResetInfo(entry.resets_at);
1939
+ return {
1940
+ label, usedPercent,
1941
+ remainingPercent: Math.max(0, Math.round((100 - usedPercent) * 10) / 10),
1942
+ resetAt, resetAfterSeconds,
1943
+ status: claudeWindowStatus(usedPercent),
1944
+ };
1945
+ };
1946
+ for (const [label, key] of [['5h', 'five_hour'], ['7d', 'seven_day'], ['7d Opus', 'seven_day_opus'], ['7d Sonnet', 'seven_day_sonnet']]) {
1947
+ const w = makeWindow(label, data[key]);
1948
+ if (w)
1949
+ windows.push(w);
1950
+ }
1951
+ }
1952
+ const extra = claudeExtraUsageWindow(data);
1953
+ if (extra)
1954
+ windows.push(extra);
1842
1955
  if (!windows.length)
1843
1956
  return null;
1844
1957
  const overallStatus = windows.some(w => w.status === 'limit_reached') ? 'limit_reached'
@@ -1882,22 +1882,61 @@ function getCodexStateDbPath(home) {
1882
1882
  return null;
1883
1883
  }
1884
1884
  }
1885
- function codexUsageFromRateLimits(rateLimits, capturedAt, source) {
1885
+ // Account status across both codex rate-limit generations: newer payloads report
1886
+ // `rate_limit_reached_type` (a string names the tripped window, null means not limited); older
1887
+ // ones used `limit_reached` / `allowed` booleans, which no longer exist on current codex.
1888
+ function codexRateLimitStatus(rateLimits) {
1889
+ const reachedType = rateLimits.rate_limit_reached_type ?? rateLimits.rateLimitReachedType;
1890
+ if (typeof reachedType === 'string' && reachedType)
1891
+ return 'limit_reached';
1892
+ if (rateLimits.limit_reached === true)
1893
+ return 'limit_reached';
1894
+ if (rateLimits.allowed === true)
1895
+ return 'allowed';
1896
+ if ('rate_limit_reached_type' in rateLimits || 'rateLimitReachedType' in rateLimits)
1897
+ return 'allowed';
1898
+ return null;
1899
+ }
1900
+ // Compact credits description ("unlimited" or a balance figure); null when the credits block
1901
+ // carries no user-meaningful number (hasCredits alone says nothing about remaining spend).
1902
+ function codexCreditsSummary(credits) {
1903
+ if (!credits || typeof credits !== 'object')
1904
+ return null;
1905
+ if (credits.unlimited === true)
1906
+ return 'unlimited';
1907
+ const balance = credits.balance;
1908
+ if (typeof balance === 'number' && Number.isFinite(balance))
1909
+ return String(balance);
1910
+ if (typeof balance === 'string' && balance)
1911
+ return balance;
1912
+ return null;
1913
+ }
1914
+ export function codexUsageFromRateLimits(rateLimits, capturedAt, source) {
1886
1915
  if (!rateLimits || typeof rateLimits !== 'object')
1887
1916
  return null;
1888
1917
  const windows = [
1889
1918
  usageWindowFromRateLimit('Primary', rateLimits.primary),
1890
1919
  usageWindowFromRateLimit('Secondary', rateLimits.secondary),
1891
1920
  ].filter((v) => !!v);
1921
+ // Team plans may carry a per-member limit alongside the shared one.
1922
+ const individual = rateLimits.individual_limit;
1923
+ if (individual && typeof individual === 'object') {
1924
+ for (const [fallback, entry] of [['Primary', individual.primary], ['Secondary', individual.secondary]]) {
1925
+ const w = usageWindowFromRateLimit(fallback, entry);
1926
+ if (w)
1927
+ windows.push({ ...w, label: `${w.label} (individual)` });
1928
+ }
1929
+ }
1892
1930
  if (!windows.length)
1893
1931
  return null;
1894
- let status = null;
1895
- if (rateLimits.limit_reached === true)
1896
- status = 'limit_reached';
1897
- else if (rateLimits.allowed === true)
1898
- status = 'allowed';
1899
- return { ok: true, agent: 'codex', source, capturedAt, status, windows, error: null };
1932
+ return {
1933
+ ok: true, agent: 'codex', source, capturedAt, status: codexRateLimitStatus(rateLimits), windows, error: null,
1934
+ planType: typeof rateLimits.plan_type === 'string' && rateLimits.plan_type ? rateLimits.plan_type : null,
1935
+ creditsSummary: codexCreditsSummary(rateLimits.credits),
1936
+ };
1900
1937
  }
1938
+ // Legacy source: codex >= 0.142 dropped the `logs` table from the state db entirely, so this
1939
+ // returns null there (sqlite3 errors out) and callers fall through to the session-history scan.
1901
1940
  function getCodexUsageFromStateDb(home) {
1902
1941
  const dbPath = getCodexStateDbPath(home);
1903
1942
  if (!dbPath)
@@ -1985,30 +2024,74 @@ function parseRateLimitWindow(label, rl) {
1985
2024
  status: null,
1986
2025
  };
1987
2026
  }
2027
+ // Windows for one camelCase rate-limit block from the app-server: primary / secondary plus the
2028
+ // per-member individualLimit team plans may carry. `prefix` disambiguates window labels when an
2029
+ // account reports multiple limit ids.
2030
+ function codexLiveWindows(rl, prefix) {
2031
+ const windows = [];
2032
+ const push = (w, suffix = '') => {
2033
+ if (w)
2034
+ windows.push({ ...w, label: `${prefix}${w.label}${suffix}` });
2035
+ };
2036
+ push(parseRateLimitWindow('Primary', rl.primary));
2037
+ push(parseRateLimitWindow('Secondary', rl.secondary));
2038
+ const individual = rl.individualLimit;
2039
+ if (individual && typeof individual === 'object') {
2040
+ push(parseRateLimitWindow('Primary', individual.primary), ' (individual)');
2041
+ push(parseRateLimitWindow('Secondary', individual.secondary), ' (individual)');
2042
+ }
2043
+ return windows;
2044
+ }
2045
+ // Pure parse of an `account/rateLimits/read` result. Prefers `rateLimitsByLimitId` (accounts can
2046
+ // carry several limits) over the flat `rateLimits`; also lifts planType, credits and
2047
+ // `rateLimitResetCredits` (limit-reset coupons) which the previous parse dropped entirely.
2048
+ export function codexUsageFromLiveRateLimitsResult(result, capturedAt) {
2049
+ const byId = result?.rateLimitsByLimitId && typeof result.rateLimitsByLimitId === 'object'
2050
+ ? Object.entries(result.rateLimitsByLimitId).filter(([, v]) => v && typeof v === 'object')
2051
+ : [];
2052
+ const blocks = byId.length ? byId
2053
+ : result?.rateLimits && typeof result.rateLimits === 'object' ? [['', result.rateLimits]] : [];
2054
+ if (!blocks.length)
2055
+ return null;
2056
+ const windows = [];
2057
+ let status = null;
2058
+ let planType = null;
2059
+ let creditsSummary = null;
2060
+ const multi = blocks.length > 1;
2061
+ for (const [limitId, rl] of blocks) {
2062
+ const name = typeof rl.limitName === 'string' && rl.limitName ? rl.limitName : limitId;
2063
+ windows.push(...codexLiveWindows(rl, multi && name ? `${name} ` : ''));
2064
+ const blockStatus = codexRateLimitStatus(rl);
2065
+ if (blockStatus === 'limit_reached')
2066
+ status = 'limit_reached';
2067
+ else if (blockStatus && status == null)
2068
+ status = blockStatus;
2069
+ if (!planType && typeof rl.planType === 'string' && rl.planType)
2070
+ planType = rl.planType;
2071
+ if (!creditsSummary)
2072
+ creditsSummary = codexCreditsSummary(rl.credits);
2073
+ }
2074
+ const resetCreditsRaw = Number(result?.rateLimitResetCredits?.availableCount);
2075
+ return {
2076
+ ok: windows.length > 0, agent: 'codex', source: 'app-server-live', capturedAt, status,
2077
+ windows, error: windows.length > 0 ? null : 'No rate limit windows.',
2078
+ planType, creditsSummary,
2079
+ resetCreditsAvailable: Number.isFinite(resetCreditsRaw) ? resetCreditsRaw : null,
2080
+ };
2081
+ }
1988
2082
  export async function getCodexUsageLive() {
1989
2083
  const home = getHome();
2084
+ // The state-db `logs` table no longer exists on codex >= 0.142, so the offline fallback must
2085
+ // also try the session-history scan.
2086
+ const fallback = (reason) => getCodexUsageFromStateDb(home) || getCodexUsageFromSessions(home) || emptyUsage('codex', reason);
1990
2087
  const srv = getSharedServer();
1991
- if (!(await srv.ensureRunning())) {
1992
- return getCodexUsageFromStateDb(home) || emptyUsage('codex', 'Failed to start codex app-server.');
1993
- }
2088
+ if (!(await srv.ensureRunning()))
2089
+ return fallback('Failed to start codex app-server.');
1994
2090
  const resp = await srv.call('account/rateLimits/read');
1995
2091
  if (resp.error)
1996
- return getCodexUsageFromStateDb(home) || emptyUsage('codex', resp.error.message || 'account/rateLimits/read failed');
1997
- const rl = resp.result?.rateLimits;
1998
- if (!rl)
1999
- return getCodexUsageFromStateDb(home) || emptyUsage('codex', 'No rate limits in response.');
2000
- const capturedAt = new Date().toISOString();
2001
- const windows = [];
2002
- const w1 = parseRateLimitWindow('Primary', rl.primary);
2003
- if (w1)
2004
- windows.push(w1);
2005
- const w2 = parseRateLimitWindow('Secondary', rl.secondary);
2006
- if (w2)
2007
- windows.push(w2);
2008
- return {
2009
- ok: windows.length > 0, agent: 'codex', source: 'app-server-live', capturedAt, status: null,
2010
- windows, error: windows.length > 0 ? null : 'No rate limit windows.',
2011
- };
2092
+ return fallback(resp.error.message || 'account/rateLimits/read failed');
2093
+ const parsed = codexUsageFromLiveRateLimitsResult(resp.result, new Date().toISOString());
2094
+ return parsed ?? fallback('No rate limits in response.');
2012
2095
  }
2013
2096
  class CodexDriver {
2014
2097
  id = 'codex';
@@ -140,6 +140,30 @@ export function kernelUsageToResultFields(u) {
140
140
  contextPercent: u?.contextPercent ?? null,
141
141
  };
142
142
  }
143
+ // How a kernel DriverResult presents as the finished message + incomplete flag. Pure +
144
+ // exported for regression testing. The load-bearing rule: a turn whose closing reply never
145
+ // arrived (stopReason 'stalled'/'truncated') must SAY so even when mid-turn narration exists —
146
+ // substituting a note only for empty text is exactly what let those endings read as normal
147
+ // answers that stop mid-sentence, with nothing marking the swallow.
148
+ export function composeKernelFinalPresentation(input) {
149
+ const endNote = input.stopReason === 'stalled'
150
+ ? 'The turn stopped responding after tool use without producing a reply (the model or provider may have stalled). Send any message to continue.'
151
+ : input.stopReason === 'truncated'
152
+ ? 'The reply ended after the last tool call without a closing message (the model returned an empty final response). Send any message to continue.'
153
+ : null;
154
+ // Empty-text fallback (mirrors the legacy driver): a clean turn with no prose reads
155
+ // "(no textual response)", not "(no output)" (which the kernel path used to show for every
156
+ // textless turn — including a background-launch turn that intentionally ends without prose).
157
+ // A turn that settled while work keeps running in the background gets an explicit note.
158
+ const message = input.bodyText
159
+ ? (endNote ? `${input.bodyText}\n\n⚠️ ${endNote}` : input.bodyText)
160
+ : input.finalError
161
+ || endNote
162
+ || (input.stopReason === 'background'
163
+ ? 'Work is now running in the background — I’ll report back here once it finishes. (Send any message to check on it.)'
164
+ : input.ok ? '(no textual response)' : '(no output)');
165
+ return { message, incomplete: !input.ok || !!endNote };
166
+ }
143
167
  let _kernel = null;
144
168
  export async function loadKernel() {
145
169
  if (_kernel)
@@ -256,19 +280,15 @@ export async function kernelStream(opts) {
256
280
  ? (humanizeCodexError(result.error) ?? result.error)
257
281
  : (result.error ?? null);
258
282
  const usageFields = kernelUsageToResultFields(result.usage || snapshot.usage || {});
283
+ const presentation = composeKernelFinalPresentation({
284
+ bodyText: (result.text || snapshot.text || '').trim(),
285
+ finalError,
286
+ ok: !!result.ok,
287
+ stopReason: result.stopReason ?? null,
288
+ });
259
289
  return {
260
290
  ok: !!result.ok,
261
- // Empty-text fallback (mirrors the legacy driver): a clean turn with no prose reads
262
- // "(no textual response)", not "(no output)" (which the kernel path used to show for every
263
- // textless turn — including a background-launch turn that intentionally ends without prose).
264
- // A turn that settled while work keeps running in the background gets an explicit note.
265
- message: (result.text || snapshot.text || '').trim()
266
- || finalError
267
- || (result.stopReason === 'background'
268
- ? 'Work is now running in the background — I’ll report back here once it finishes. (Send any message to check on it.)'
269
- : result.stopReason === 'stalled'
270
- ? 'The turn stopped responding after tool use without producing a reply (the model or provider may have stalled). Send any message to continue.'
271
- : result.ok ? '(no textual response)' : '(no output)'),
291
+ message: presentation.message,
272
292
  thinking: (result.reasoning || snapshot.reasoning || '').trim() || null,
273
293
  plan: toPikiloomPlan(snapshot.plan),
274
294
  sessionId: finalSessionId,
@@ -280,7 +300,7 @@ export async function kernelStream(opts) {
280
300
  codexCumulative: null,
281
301
  error: finalError,
282
302
  stopReason: result.stopReason ?? null,
283
- incomplete: !result.ok,
303
+ incomplete: presentation.incomplete,
284
304
  activity: snapshot.activity || null,
285
305
  };
286
306
  }
@@ -414,7 +414,7 @@ export function normalizeUsageStatus(value) {
414
414
  return 'limit_reached';
415
415
  if (normalized.includes('warning') || normalized.includes('warn'))
416
416
  return 'warning';
417
- if (normalized.includes('allowed') || normalized === 'ok' || normalized === 'healthy' || normalized === 'ready')
417
+ if (normalized.includes('allowed') || normalized === 'ok' || normalized === 'healthy' || normalized === 'ready' || normalized === 'normal')
418
418
  return 'allowed';
419
419
  return normalized;
420
420
  }
@@ -427,6 +427,10 @@ export function labelFromWindowMinutes(value, fallback) {
427
427
  return '5h';
428
428
  if (Math.abs(roundedMinutes - 10080) <= 5)
429
429
  return '7d';
430
+ // Codex team/enterprise plans meter on a monthly window reported as the average month
431
+ // length (365d/12 = 43800min = 730h) — without this branch it renders as "730h".
432
+ if (Math.abs(roundedMinutes - 43800) <= 60)
433
+ return '1mo';
430
434
  const roundedDays = Math.round(roundedMinutes / 1440);
431
435
  if (roundedDays >= 1 && Math.abs(roundedMinutes - roundedDays * 1440) <= 5)
432
436
  return `${roundedDays}d`;
package/dist/bot/bot.js CHANGED
@@ -10,6 +10,7 @@ import { getDriver, hasDriver, allDriverIds, getDriverCapabilities } from '../ag
10
10
  import { resolveGuiIntegrationConfig } from '../agent/mcp/bridge.js';
11
11
  import { composeSessionToolPrompt } from '../agent/mcp/capabilities.js';
12
12
  import { terminateProcessTree } from '../core/process-control.js';
13
+ import { appendTurnAudit } from '../core/turn-audit.js';
13
14
  import { expandTilde } from '../core/platform.js';
14
15
  import { VERSION } from '../core/version.js';
15
16
  import { buildHumanLoopResponse, createEmptyHumanLoopAnswer, currentHumanLoopQuestion, isHumanLoopAwaitingText, setHumanLoopOption, setHumanLoopText, skipHumanLoopQuestion, summarizeResolvedHumanLoopAnswers, } from './human-loop.js';
@@ -2114,7 +2115,23 @@ export class Bot {
2114
2115
  onCodexTurnReady,
2115
2116
  forkOf: extras?.forkOf,
2116
2117
  };
2117
- const result = await doStream(opts);
2118
+ let result;
2119
+ try {
2120
+ result = await doStream(opts);
2121
+ }
2122
+ catch (e) {
2123
+ appendTurnAudit({
2124
+ agent: cs.agent, sessionId: cs.sessionId || null, ok: false, stopReason: 'exception',
2125
+ incomplete: true, error: String(e?.message || e).slice(0, 500), promptPreview: prompt.slice(0, 120),
2126
+ });
2127
+ throw e;
2128
+ }
2129
+ appendTurnAudit({
2130
+ agent: cs.agent, sessionId: result.sessionId || cs.sessionId || null,
2131
+ ok: result.ok, stopReason: result.stopReason ?? null, incomplete: !!result.incomplete,
2132
+ error: result.error ? String(result.error).slice(0, 500) : null,
2133
+ elapsedS: result.elapsedS, model: result.model ?? resolvedModel, promptPreview: prompt.slice(0, 120),
2134
+ });
2118
2135
  if (cs.agent === 'claude' && workflowEnabled && result.thinkingEffort) {
2119
2136
  result.thinkingEffort = 'ultra';
2120
2137
  }
@@ -127,9 +127,22 @@ function formatUsageResetDuration(seconds) {
127
127
  function formatUsageWindowSummary(window, now) {
128
128
  const percent = window.usedPercent != null ? `${Math.round(window.usedPercent)}%` : null;
129
129
  const reset = formatUsageWindowReset(window, now);
130
- const main = percent ? `${window.label} ${percent}` : window.label;
130
+ let main = percent ? `${window.label} ${percent}` : window.label;
131
+ if (window.detail)
132
+ main += ` (${window.detail})`;
131
133
  return reset ? `${main} (${reset})` : main;
132
134
  }
135
+ // "plan team · limit resets ×1" — account-level metadata riding on the usage snapshot.
136
+ function usageResultMetaParts(usage) {
137
+ const parts = [];
138
+ if (usage.planType)
139
+ parts.push(`plan ${usage.planType}`);
140
+ if (usage.creditsSummary)
141
+ parts.push(`credits ${usage.creditsSummary}`);
142
+ if (usage.resetCreditsAvailable)
143
+ parts.push(`limit resets ×${usage.resetCreditsAvailable}`);
144
+ return parts;
145
+ }
133
146
  // Compact "5h 42% (reset 3h12m) · 7d 18% (reset 4d6h)" summary of a usage
134
147
  // snapshot, or a short reason when no quota numbers are available.
135
148
  export function formatUsageWindowsSummary(usage, now = Date.now()) {
@@ -139,7 +152,7 @@ export function formatUsageWindowsSummary(usage, now = Date.now()) {
139
152
  .filter(w => w.usedPercent != null)
140
153
  .map(w => formatUsageWindowSummary(w, now));
141
154
  if (parts.length)
142
- return parts.join(' · ');
155
+ return [...parts, ...usageResultMetaParts(usage)].join(' · ');
143
156
  const resetWindow = usage.windows.find(w => usageWindowResetSeconds(w, now) != null);
144
157
  if (resetWindow) {
145
158
  const reset = formatUsageWindowReset(resetWindow, now);
@@ -0,0 +1,25 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getUserConfigPath } from './config/user-config.js';
4
+ // Per-turn终态 audit trail. Prod runs with no disk logs at all, which turns every
5
+ // swallowed-reply report into jsonl archaeology (reconstructing process lifetimes from MCP log
6
+ // filenames). One JSON line per finished turn — how it ended, not what it said — is enough to
7
+ // answer "who ended this turn and why" after the fact. Appends must never break a turn.
8
+ const MAX_AUDIT_BYTES = 2 * 1024 * 1024;
9
+ export function turnAuditPath() {
10
+ return path.join(path.dirname(getUserConfigPath()), 'logs', 'turn-audit.jsonl');
11
+ }
12
+ export function appendTurnAudit(entry) {
13
+ try {
14
+ const file = turnAuditPath();
15
+ fs.mkdirSync(path.dirname(file), { recursive: true });
16
+ try {
17
+ // Single-slot rotation: cap the live file, keep exactly one predecessor.
18
+ if (fs.statSync(file).size > MAX_AUDIT_BYTES)
19
+ fs.renameSync(file, `${file}.1`);
20
+ }
21
+ catch { /* no file yet */ }
22
+ fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
23
+ }
24
+ catch { /* auditing is best-effort by design */ }
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiloom",
3
- "version": "0.4.52",
3
+ "version": "0.4.53",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,11 +35,14 @@ export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent
35
35
  export declare function claudeBgHoldCapMs(): number;
36
36
  export declare function claudeBgSettleQuietMs(): number;
37
37
  export declare function claudeModelStallMs(): number;
38
+ export declare const CLAUDE_TRUNCATED_RECOVERY_PROMPT: string;
39
+ export declare function claudeTruncatedRecoveryEnabled(): boolean;
38
40
  export declare function claudeUserEventHasToolResult(ev: any): boolean;
39
41
  export declare function isTerminalTaskStatus(status: unknown): boolean;
40
42
  export declare function trackClaudeBackgroundTask(ev: any, s: any): void;
41
43
  export declare function markClaudeTaskNotificationTerminal(content: any, s: any): void;
42
44
  export declare function pendingClaudeBackgroundTasks(s: any): number;
45
+ export declare function claudeTurnEndedDangling(s: any): boolean;
43
46
  export type ClaudeResultSettleDecision = 'settle' | 'hold' | 'quiet-settle';
44
47
  export declare function decideClaudeResultSettle(input: {
45
48
  hasError: boolean;