pikiloom 0.4.51 → 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.
- package/dashboard/dist/assets/AgentTab-DSpqOkYp.js +1 -0
- package/dashboard/dist/assets/{ConnectionModal-CwTYZYp3.js → ConnectionModal-BFcN8nJr.js} +1 -1
- package/dashboard/dist/assets/{DirBrowser-DbqFsndw.js → DirBrowser-Dk-TadnX.js} +1 -1
- package/dashboard/dist/assets/{ExtensionsTab-DjNMSGca.js → ExtensionsTab-wiaVcnfS.js} +1 -1
- package/dashboard/dist/assets/{IMAccessTab-0_qEQDIp.js → IMAccessTab-C4ltstAX.js} +1 -1
- package/dashboard/dist/assets/{Modal-Q7hHQ_0P.js → Modal-BeyxM41U.js} +1 -1
- package/dashboard/dist/assets/{Modals-CKH0xwVb.js → Modals-B2a8IIvO.js} +1 -1
- package/dashboard/dist/assets/{Select-DtQA2BGu.js → Select-DxYPQ-41.js} +1 -1
- package/dashboard/dist/assets/{SessionPanel-DXWaPkz4.js → SessionPanel-CYO6NGSt.js} +1 -1
- package/dashboard/dist/assets/{SystemTab-BM_f7FwS.js → SystemTab-1frRjK4R.js} +1 -1
- package/dashboard/dist/assets/index-BQ_R1F_G.css +1 -0
- package/dashboard/dist/assets/{index-CX0Ivr8b.js → index-C-9wwAiw.js} +3 -3
- package/dashboard/dist/assets/index-dR0w8M7K.js +3 -0
- package/dashboard/dist/assets/{shared-DDu6QCT8.js → shared-BbgVZfU_.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/agent/accounts.js +6 -3
- package/dist/agent/drivers/claude.js +154 -33
- package/dist/agent/drivers/codex.js +109 -26
- package/dist/agent/kernel-bridge.js +32 -12
- package/dist/agent/utils.js +5 -1
- package/dist/bot/bot.js +18 -1
- package/dist/bot/render-shared.js +26 -10
- package/dist/core/turn-audit.js +25 -0
- package/dist/dashboard/routes/accounts.js +5 -1
- package/package.json +1 -1
- package/packages/kernel/dist/drivers/claude.d.ts +3 -0
- package/packages/kernel/dist/drivers/claude.js +90 -6
- package/packages/kernel/dist/index.d.ts +1 -1
- package/packages/kernel/dist/index.js +1 -1
- package/dashboard/dist/assets/AgentTab-B8Cm50Mg.js +0 -1
- package/dashboard/dist/assets/index-BIUoh4Hy.js +0 -3
- package/dashboard/dist/assets/index-BcPBJtn2.css +0 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1838
|
-
|
|
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'
|
|
@@ -1889,17 +2002,22 @@ async function fetchClaudeUsageFromOAuth(tokenOverride) {
|
|
|
1889
2002
|
// token). The read costs no inference tokens but the endpoint RATE-LIMITS aggressive polling
|
|
1890
2003
|
// (observed: `rate_limit_error` payloads, which parse to null) — so `fresh` re-reads only after
|
|
1891
2004
|
// 15s, and a failed read backs off 60s serving the last good result. Concurrent callers share
|
|
1892
|
-
// one request.
|
|
2005
|
+
// one request. `force` is for explicit user-clicked refreshes only: it bypasses both the TTL and
|
|
2006
|
+
// the failure backoff (human-paced clicks cannot stampede the endpoint), with a short floor to
|
|
2007
|
+
// absorb double-clicks.
|
|
1893
2008
|
const NATIVE_USAGE_FRESH_TTL_MS = 15_000;
|
|
1894
2009
|
const NATIVE_USAGE_RETRY_TTL_MS = 60_000;
|
|
2010
|
+
const NATIVE_USAGE_FORCE_FLOOR_MS = 3_000;
|
|
1895
2011
|
const claudeNativeUsageLive = { at: 0, failedAt: 0, inflight: null };
|
|
1896
2012
|
export function claudeNativeUsage(opts) {
|
|
1897
|
-
const maxAge = opts?.
|
|
2013
|
+
const maxAge = opts?.force ? NATIVE_USAGE_FORCE_FLOOR_MS
|
|
2014
|
+
: opts?.fresh ? NATIVE_USAGE_FRESH_TTL_MS : CLAUDE_USAGE_QUERY_TTL_MS;
|
|
1898
2015
|
const now = Date.now();
|
|
1899
2016
|
if (claudeUsageCache.lastGood && now - claudeNativeUsageLive.at < maxAge) {
|
|
1900
2017
|
return Promise.resolve(claudeUsageCache.lastGood);
|
|
1901
2018
|
}
|
|
1902
|
-
|
|
2019
|
+
const failureFloor = opts?.force ? NATIVE_USAGE_FORCE_FLOOR_MS : NATIVE_USAGE_RETRY_TTL_MS;
|
|
2020
|
+
if (now - claudeNativeUsageLive.failedAt < failureFloor) {
|
|
1903
2021
|
return Promise.resolve(claudeUsageCache.lastGood);
|
|
1904
2022
|
}
|
|
1905
2023
|
if (claudeNativeUsageLive.inflight)
|
|
@@ -1933,12 +2051,14 @@ export function claudeNativeUsage(opts) {
|
|
|
1933
2051
|
// are tiered by how fresh the caller needs to be:
|
|
1934
2052
|
// default -> 5min TTL (background warmers, non-interactive surfaces)
|
|
1935
2053
|
// fresh -> 20s min re-probe interval (user is actively looking at the numbers)
|
|
1936
|
-
// force -> bypass
|
|
2054
|
+
// force -> bypass the TTL and the failure backoff (account switch, explicit refresh click),
|
|
2055
|
+
// modulo a short double-click floor
|
|
1937
2056
|
// Failures back off 60s on the default/fresh tiers (force still retries), and in-flight de-dup
|
|
1938
2057
|
// makes concurrent surfaces (cards + header + IM) share one probe.
|
|
1939
2058
|
const TOKEN_USAGE_OK_TTL_MS = CLAUDE_USAGE_QUERY_TTL_MS;
|
|
1940
2059
|
const TOKEN_USAGE_FRESH_TTL_MS = 20_000;
|
|
1941
2060
|
const TOKEN_USAGE_RETRY_TTL_MS = 60_000;
|
|
2061
|
+
const TOKEN_USAGE_FORCE_FLOOR_MS = 3_000;
|
|
1942
2062
|
const CLAUDE_USAGE_PROBE_MODEL = 'claude-haiku-4-5-20251001';
|
|
1943
2063
|
const claudeTokenUsageCache = new Map();
|
|
1944
2064
|
const claudeTokenUsageInflight = new Map();
|
|
@@ -2008,10 +2128,11 @@ export function claudeUsageForToken(token, opts) {
|
|
|
2008
2128
|
return Promise.resolve(null);
|
|
2009
2129
|
const now = Date.now();
|
|
2010
2130
|
const cached = claudeTokenUsageCache.get(t);
|
|
2011
|
-
if (cached
|
|
2012
|
-
const ttl =
|
|
2013
|
-
|
|
2014
|
-
|
|
2131
|
+
if (cached) {
|
|
2132
|
+
const ttl = opts?.force ? TOKEN_USAGE_FORCE_FLOOR_MS
|
|
2133
|
+
: cached.ok
|
|
2134
|
+
? (opts?.fresh ? TOKEN_USAGE_FRESH_TTL_MS : TOKEN_USAGE_OK_TTL_MS)
|
|
2135
|
+
: TOKEN_USAGE_RETRY_TTL_MS;
|
|
2015
2136
|
if (now - cached.at < ttl)
|
|
2016
2137
|
return Promise.resolve(cached.value);
|
|
2017
2138
|
}
|
|
@@ -1882,22 +1882,61 @@ function getCodexStateDbPath(home) {
|
|
|
1882
1882
|
return null;
|
|
1883
1883
|
}
|
|
1884
1884
|
}
|
|
1885
|
-
|
|
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
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
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
|
|
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
|
|
1997
|
-
const
|
|
1998
|
-
|
|
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
|
-
|
|
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:
|
|
303
|
+
incomplete: presentation.incomplete,
|
|
284
304
|
activity: snapshot.activity || null,
|
|
285
305
|
};
|
|
286
306
|
}
|
package/dist/agent/utils.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -148,13 +161,15 @@ export function formatUsageWindowsSummary(usage, now = Date.now()) {
|
|
|
148
161
|
}
|
|
149
162
|
return usage.status ? `status=${usage.status}` : 'no data';
|
|
150
163
|
}
|
|
151
|
-
//
|
|
164
|
+
// OLDEST capturedAt (ISO string) across the given agents' usage and their accounts' usage, or
|
|
152
165
|
// null when none carry a timestamp. Mirrors the dashboard usage popover: every usage here is
|
|
153
|
-
// probed in a single getUsageOverview pass, so one
|
|
154
|
-
//
|
|
155
|
-
|
|
166
|
+
// probed in a single getUsageOverview pass, so one stamp stands for the whole block — and it
|
|
167
|
+
// must be the oldest row's time, not the freshest, so a row pinned at last-good by a failed /
|
|
168
|
+
// rate-limited probe can't hide behind a sibling's fresh timestamp. ISO-8601 UTC sorts
|
|
169
|
+
// lexically → min = oldest.
|
|
170
|
+
export function oldestUsageCapturedAt(agents) {
|
|
156
171
|
let best = null;
|
|
157
|
-
const consider = (iso) => { if (iso && (!best || iso
|
|
172
|
+
const consider = (iso) => { if (iso && (!best || iso < best))
|
|
158
173
|
best = iso; };
|
|
159
174
|
for (const agent of agents) {
|
|
160
175
|
consider(agent.usage?.capturedAt);
|
|
@@ -175,9 +190,10 @@ export function buildUsageOverviewLines(overview, now = Date.now()) {
|
|
|
175
190
|
{ text: '', bold: false },
|
|
176
191
|
{ text: 'Provider Usage', bold: true },
|
|
177
192
|
];
|
|
178
|
-
// Data-freshness stamp (
|
|
179
|
-
//
|
|
180
|
-
|
|
193
|
+
// Data-freshness stamp (matches the dashboard usage popover): one "Updated: X ago" for the
|
|
194
|
+
// whole block, from the OLDEST capturedAt across everything shown — owning up to the most
|
|
195
|
+
// lagging row instead of advertising the freshest one.
|
|
196
|
+
const capturedMs = Date.parse(oldestUsageCapturedAt(shown) ?? '');
|
|
181
197
|
if (Number.isFinite(capturedMs)) {
|
|
182
198
|
lines.push({ text: ` Updated: ${fmtUptime(Math.max(0, now - capturedMs))} ago` });
|
|
183
199
|
}
|
|
@@ -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
|
+
}
|