claude-simple 0.2.27 → 0.2.30

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 (79) hide show
  1. package/README.md +17 -1
  2. package/bin/edge-ctl.js +151 -4
  3. package/client.js +29 -4
  4. package/dist/addon-fit.min.js +0 -0
  5. package/dist/app.0.2.30.min.js +1308 -0
  6. package/dist/index.html +2 -2
  7. package/dist/style.0.2.30.min.css +2012 -0
  8. package/dist/xterm.min.css +0 -0
  9. package/dist/xterm.min.js +0 -0
  10. package/gateway.js +0 -0
  11. package/package.json +2 -2
  12. package/public/addon-fit.min.js +0 -0
  13. package/public/agent-panel.js +54 -22
  14. package/public/api.js +8 -5
  15. package/public/app.js +7 -5
  16. package/public/base.css +0 -0
  17. package/public/cache.js +19 -0
  18. package/public/chat.js +110 -19
  19. package/public/files.js +26 -10
  20. package/public/git.js +2 -2
  21. package/public/icons/icon-180.png +0 -0
  22. package/public/icons/icon-192.png +0 -0
  23. package/public/icons/icon-512.png +0 -0
  24. package/public/icons/icon-maskable-512.png +0 -0
  25. package/public/index.html +2 -2
  26. package/public/lib.js +0 -0
  27. package/public/manifest.webmanifest +0 -0
  28. package/public/markdown.js +16 -0
  29. package/public/math.js +145 -7
  30. package/public/mobile.js +0 -0
  31. package/public/notes.js +316 -0
  32. package/public/notifications.js +0 -0
  33. package/public/offline.html +0 -0
  34. package/public/pwa.js +1 -1
  35. package/public/router.js +4 -0
  36. package/public/scroll-follow.js +78 -0
  37. package/public/settings.js +41 -0
  38. package/public/shell/boot.js +0 -0
  39. package/public/shell/dashboard.js +0 -0
  40. package/public/shell/hub.js +0 -0
  41. package/public/shell/layout.js +0 -0
  42. package/public/shell/notes.js +0 -0
  43. package/public/shell/session-context.js +0 -0
  44. package/public/shell/session-list.js +79 -21
  45. package/public/shell/session-nav.js +70 -21
  46. package/public/shell/templates.js +3 -0
  47. package/public/shell/util.js +0 -0
  48. package/public/shell.js +15 -2
  49. package/public/state.js +7 -7
  50. package/public/style.css +161 -0
  51. package/public/sw.js +10 -5
  52. package/public/terminal.js +0 -0
  53. package/public/wake-lock.js +0 -0
  54. package/public/workspace-path.js +61 -0
  55. package/public/xterm.min.css +0 -0
  56. package/public/xterm.min.js +0 -0
  57. package/src/agent-quota.js +228 -0
  58. package/src/agents/claude.js +0 -0
  59. package/src/agents/codex.js +2 -0
  60. package/src/agents/cursor.js +2 -1
  61. package/src/agents/gemini.js +2 -1
  62. package/src/agents/grok.js +22 -1
  63. package/src/agents/meta-agent.js +7 -4
  64. package/src/agents/models.js +21 -12
  65. package/src/agents/sessions.js +270 -81
  66. package/src/agents/stream-batch.js +0 -0
  67. package/src/app.js +111 -51
  68. package/src/auto-update.js +2 -0
  69. package/src/collab.js +3 -0
  70. package/src/platform.js +133 -0
  71. package/src/project-api-path.js +14 -0
  72. package/src/runtime/approvals.js +0 -0
  73. package/src/runtime/chat-broadcast.js +12 -0
  74. package/src/runtime/outbox.js +0 -0
  75. package/src/runtime/port-websocket.js +80 -0
  76. package/src/runtime/protocol.js +0 -0
  77. package/src/session-history.js +31 -0
  78. package/dist/app.0.2.27.min.js +0 -1182
  79. package/dist/style.0.2.27.min.css +0 -1851
package/public/style.css CHANGED
@@ -1830,6 +1830,122 @@ html.settings-open #app { visibility: hidden; }
1830
1830
  .set-nav-item { white-space: nowrap; }
1831
1831
  }
1832
1832
 
1833
+ /* ── Personal notes (main pane, sidebar stays) ────────────────────────────── */
1834
+
1835
+ .notes-page {
1836
+ flex: 1;
1837
+ min-height: 0;
1838
+ display: flex;
1839
+ flex-direction: column;
1840
+ padding: 16px clamp(12px, 3vw, 32px) 24px;
1841
+ gap: 12px;
1842
+ overflow-y: auto;
1843
+ background: hsl(var(--b1));
1844
+ color: hsl(var(--bc));
1845
+ }
1846
+ .notes-page[hidden] { display: none !important; }
1847
+ .notes-count {
1848
+ margin: 4px 0 0;
1849
+ font-size: 12px;
1850
+ color: hsl(var(--bc) / .55);
1851
+ }
1852
+ .notes-header {
1853
+ display: flex;
1854
+ align-items: center;
1855
+ justify-content: space-between;
1856
+ gap: 16px;
1857
+ border-bottom: 1px solid hsl(var(--b3) / .35);
1858
+ padding-bottom: 12px;
1859
+ }
1860
+ .notes-header h2 {
1861
+ margin: 0;
1862
+ font-size: 1.4rem;
1863
+ letter-spacing: .02em;
1864
+ }
1865
+ .notes-header-actions { display: flex; gap: 8px; }
1866
+ .notes-add {
1867
+ display: flex;
1868
+ flex-direction: column;
1869
+ gap: 8px;
1870
+ padding: 12px;
1871
+ border-radius: 12px;
1872
+ background: hsl(var(--b2));
1873
+ border: 1px solid hsl(var(--b3) / .4);
1874
+ }
1875
+ .notes-add textarea,
1876
+ .notes-edit-text {
1877
+ width: 100%;
1878
+ font: inherit;
1879
+ background: hsl(var(--b1));
1880
+ color: inherit;
1881
+ border: 1px solid hsl(var(--b3) / .4);
1882
+ border-radius: 8px;
1883
+ padding: 10px 12px;
1884
+ resize: vertical;
1885
+ min-height: 80px;
1886
+ }
1887
+ .notes-form-actions {
1888
+ display: flex;
1889
+ gap: 8px;
1890
+ justify-content: flex-end;
1891
+ }
1892
+ .notes-list {
1893
+ list-style: none;
1894
+ margin: 0;
1895
+ padding: 0;
1896
+ display: flex;
1897
+ flex-direction: column;
1898
+ gap: 10px;
1899
+ }
1900
+ .notes-empty {
1901
+ padding: 28px;
1902
+ text-align: center;
1903
+ color: hsl(var(--bc) / .55);
1904
+ border: 1px dashed hsl(var(--b3) / .45);
1905
+ border-radius: 12px;
1906
+ }
1907
+ .notes-error { color: hsl(var(--er) / .85); border-color: hsl(var(--er) / .35); }
1908
+ .notes-item {
1909
+ background: hsl(var(--b2));
1910
+ border: 1px solid hsl(var(--b3) / .35);
1911
+ border-radius: 12px;
1912
+ padding: 14px 16px;
1913
+ display: flex;
1914
+ flex-direction: column;
1915
+ gap: 8px;
1916
+ }
1917
+ .notes-text {
1918
+ white-space: pre-wrap;
1919
+ word-break: break-word;
1920
+ font-size: .98rem;
1921
+ line-height: 1.55;
1922
+ }
1923
+ .notes-meta {
1924
+ font-size: .75rem;
1925
+ color: hsl(var(--bc) / .55);
1926
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1927
+ }
1928
+ .notes-item-foot {
1929
+ display: flex;
1930
+ align-items: center;
1931
+ gap: 8px;
1932
+ }
1933
+ .notes-item-actions {
1934
+ display: flex;
1935
+ gap: 4px;
1936
+ margin-left: auto;
1937
+ }
1938
+ .btn.danger {
1939
+ background: hsl(var(--er) / .15);
1940
+ color: hsl(var(--er));
1941
+ border: 1px solid hsl(var(--er) / .35);
1942
+ }
1943
+ .btn.primary {
1944
+ background: hsl(var(--p));
1945
+ color: hsl(var(--pc));
1946
+ border: 1px solid hsl(var(--p) / .85);
1947
+ }
1948
+
1833
1949
  /* Overlays must not eat keystrokes when hidden */
1834
1950
  #sidebar-overlay.hidden,
1835
1951
  .mermaid-zoom-overlay.hidden,
@@ -1847,3 +1963,48 @@ html.settings-open #app { visibility: hidden; }
1847
1963
  cursor: not-allowed;
1848
1964
  opacity: .78;
1849
1965
  }
1966
+
1967
+ .notes-add[hidden] { display: none; }
1968
+ .notes-error { color: #ef4444; }
1969
+
1970
+ .notes-item {
1971
+ background: rgba(255255255, .04);
1972
+ border: 1px solid rgba(255255255, .08);
1973
+ border-radius: 8px;
1974
+ padding: 12px;
1975
+ }
1976
+ .notes-text {
1977
+ white-space: pre-wrap;
1978
+ word-break: break-word;
1979
+ line-height: 1.55;
1980
+ }
1981
+ .notes-meta {
1982
+ margin-top: 8px;
1983
+ color: rgba(255255255, .4);
1984
+ font-size: 11px;
1985
+ display: flex;
1986
+ gap: 6px;
1987
+ }
1988
+ .notes-item-actions {
1989
+ margin-top: 8px;
1990
+ display: flex;
1991
+ gap: 6px;
1992
+ justify-content: flex-end;
1993
+ }
1994
+ .notes-edit-text {
1995
+ width: 100%;
1996
+ resize: vertical;
1997
+ min-height: 60px;
1998
+ background: transparent;
1999
+ color: inherit;
2000
+ border: 1px solid rgba(255255255, .08);
2001
+ border-radius: 6px;
2002
+ padding: 6px 8px;
2003
+ font-family: inherit;
2004
+ font-size: 14px;
2005
+ }
2006
+
2007
+ .ma-reasoning { border: 1px solid var(--color-base-300); border-radius: 8px; padding: 8px 10px; color: var(--color-base-content); font-size: 12px; }
2008
+ .ma-reasoning summary, .ma-tool summary { cursor: pointer; }
2009
+ .ma-reasoning pre { max-height: 260px; overflow: auto; overflow-wrap: anywhere; font: inherit; opacity: .7; }
2010
+ .ma-tool[open] .ma-tool-args, .ma-tool[open] .ma-tool-result { max-height: 320px; }
package/public/sw.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /* Agent UI service worker — cache shell assets only.
2
2
  * Never intercepts WebSocket or mutates API semantics beyond offline fallback.
3
3
  * Bump CACHE when shipping UI that must invalidate old shells. */
4
- const CACHE = 'agent-ui-shell-v27';
4
+ const CACHE = 'agent-ui-shell-v34';
5
5
  const PRECACHE = [
6
6
  '/',
7
7
  '/index.html',
8
8
  '/manifest.webmanifest',
9
- '/style.css?v=ui-22',
9
+ '/style.css?v=ui-25',
10
10
  '/style.css',
11
11
  '/ui.min.css',
12
12
  '/vendor/mini-react.min.js',
@@ -16,7 +16,7 @@ const PRECACHE = [
16
16
  '/vendor/mermaid.min.js',
17
17
  '/vendor/katex.min.js',
18
18
  '/vendor/katex.min.css',
19
- '/app.js?v=ui-22',
19
+ '/app.js?v=ui-25',
20
20
  '/icons/icon-192.png',
21
21
  '/icons/icon-512.png',
22
22
  '/icons/icon-maskable-512.png',
@@ -49,6 +49,8 @@ function isApiOrRealtime(url) {
49
49
  || p === '/machine-connect'
50
50
  || p.startsWith('/machine/')
51
51
  || p === '/healthz'
52
+ // Never cache the worker itself or an update can stall on the old shell.
53
+ || p === '/sw.js'
52
54
  );
53
55
  }
54
56
 
@@ -89,8 +91,10 @@ self.addEventListener('fetch', (event) => {
89
91
  return;
90
92
  }
91
93
 
92
- // Same-origin static modules: stale-while-revalidate.
94
+ // App JS/CSS must be network-first so a Hub public/ deploy is visible
95
+ // on the next reload. Vendor + icons stay stale-while-revalidate.
93
96
  if (isStaticAsset(url)) {
97
+ const vendor = url.pathname.startsWith('/vendor/') || url.pathname.startsWith('/icons/');
94
98
  event.respondWith(
95
99
  caches.open(CACHE).then(async (cache) => {
96
100
  const cached = await cache.match(req);
@@ -100,7 +104,8 @@ self.addEventListener('fetch', (event) => {
100
104
  return res;
101
105
  })
102
106
  .catch(() => cached);
103
- return cached || network;
107
+ if (vendor) return cached || network;
108
+ return network.then((res) => res || cached).catch(() => cached);
104
109
  }),
105
110
  );
106
111
  }
File without changes
File without changes
@@ -0,0 +1,61 @@
1
+ /** Resolve a markdown/chat href into a Files-tab target on the edge machine. */
2
+
3
+ function decodeHref(href) {
4
+ const raw = String(href ?? '').trim();
5
+ if (!raw) return '';
6
+ try {
7
+ return decodeURIComponent(raw);
8
+ } catch {
9
+ return raw;
10
+ }
11
+ }
12
+
13
+ function slash(p) {
14
+ return String(p || '').replace(/\\/g, '/');
15
+ }
16
+
17
+ function stripLineSuffix(p) {
18
+ return p.replace(/:[0-9]+(?::[0-9]+)?$/, '');
19
+ }
20
+
21
+ export function resolveWorkspaceFile(href, cwd) {
22
+ let raw = decodeHref(href);
23
+ if (!raw) return null;
24
+ if (/^(https?:|mailto:|tel:|javascript:|#)/i.test(raw)) return null;
25
+ raw = slash(raw).split(/[?#]/)[0];
26
+ raw = raw.replace(/^file:\/\//i, '');
27
+ if (/^\/[A-Za-z]:\//.test(raw)) raw = raw.slice(1);
28
+ if (!raw || raw === '.' || raw === '/') return null;
29
+ if (/^\/(api|ws|session)\b/.test(raw)) return null;
30
+
31
+ const isDir = raw.endsWith('/');
32
+ let path = stripLineSuffix(raw.replace(/\/+$/, ''));
33
+ const root = slash(cwd).replace(/\/+$/, '');
34
+ const isAbs = path.startsWith('/') || /^[A-Za-z]:\//.test(path);
35
+
36
+ if (isAbs && root) {
37
+ const p = path.toLowerCase();
38
+ const r = root.toLowerCase();
39
+ if (p === r) return { relative: '', isDir: true, root };
40
+ if (p.startsWith(`${r}/`)) {
41
+ return { relative: path.slice(root.length + 1), isDir, root };
42
+ }
43
+ const parts = path.split('/');
44
+ const name = parts.pop() || '';
45
+ const parent = parts.join('/') || (path.startsWith('/') ? '/' : '');
46
+ return { relative: name, isDir, root: parent };
47
+ }
48
+
49
+ path = path.replace(/^\.\//, '');
50
+ if (path.startsWith('../') && root) {
51
+ const base = root.split('/');
52
+ const segs = path.split('/');
53
+ for (const seg of segs) {
54
+ if (seg === '..') base.pop();
55
+ else if (seg && seg !== '.') base.push(seg);
56
+ }
57
+ const abs = base.join('/') || '/';
58
+ return resolveWorkspaceFile(abs, cwd);
59
+ }
60
+ return { relative: path, isDir, root: root || cwd };
61
+ }
File without changes
File without changes
@@ -0,0 +1,228 @@
1
+ // Read subscription meters without starting an inference turn. Credentials
2
+ // stay on the owning edge; only the normalized meters leave this module.
3
+ import { promises as fs } from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import crypto from 'node:crypto';
7
+ import { spawn, execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ const exec = promisify(execFile);
10
+
11
+ function percent(value) {
12
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : null;
13
+ }
14
+ function meter(label, used, reset, minutes) {
15
+ const usedPercent = percent(used);
16
+ if (usedPercent == null) return null;
17
+ const date = typeof reset === 'number' ? reset * 1000 : Date.parse(reset);
18
+ return { label, usedPercent, remainingPercent: 100 - usedPercent,
19
+ resetsAt: Number.isFinite(date) ? new Date(date).toISOString() : null,
20
+ windowMinutes: minutes || null };
21
+ }
22
+
23
+ export function normalizeClaudeQuota(data) {
24
+ const meters = [];
25
+ for (const [key, label, minutes] of [
26
+ ['five_hour', '5 小时', 300], ['seven_day', '周额度', 10080],
27
+ ['seven_day_opus', 'Opus 周额度', 10080], ['seven_day_sonnet', 'Sonnet 周额度', 10080],
28
+ ['seven_day_oauth_apps', 'OAuth 应用周额度', 10080],
29
+ ]) {
30
+ const row = data?.[key];
31
+ const m = meter(label, row?.utilization, row?.resets_at, minutes);
32
+ if (m) meters.push(m);
33
+ }
34
+ // Newer Claude usage responses expose named meters instead of fixed keys.
35
+ for (const row of (Array.isArray(data) ? data : data?.meters || [])) {
36
+ const m = meter(row.scope?.model?.display_name || row.kind || '额度', row.percent, row.resets_at,
37
+ row.group === 'weekly' ? 10080 : null);
38
+ if (m) meters.push(m);
39
+ }
40
+ return meters;
41
+ }
42
+
43
+ export function normalizeCodexQuota(data) {
44
+ const meters = [];
45
+ const buckets = data?.rateLimitsByLimitId || { codex: data?.rateLimits };
46
+ for (const [id, bucket] of Object.entries(buckets)) {
47
+ for (const key of ['primary', 'secondary']) {
48
+ const row = bucket?.[key];
49
+ const mins = row?.windowDurationMins;
50
+ const label = mins === 10080 ? '周额度' : mins === 300 ? '5 小时' : mins ? `${mins / 60} 小时` : key;
51
+ const m = meter(`${bucket?.limitName || id} · ${label}`, row?.usedPercent, row?.resetsAt, mins);
52
+ if (m) meters.push(m);
53
+ }
54
+ }
55
+ return meters;
56
+ }
57
+
58
+ export function normalizeGrokQuota(data) {
59
+ const config = data?.config;
60
+ if (!config) return [];
61
+ const period = config.currentPeriod;
62
+ const weekly = period?.type?.includes('WEEKLY');
63
+ const used = config.creditUsagePercent ?? (config.monthlyLimit?.val > 0 && typeof config.used?.val === 'number'
64
+ ? config.used.val / config.monthlyLimit.val * 100 : null);
65
+ const m = meter(weekly ? '周额度' : '账期额度', used, period?.end || config.billingPeriodEnd, weekly ? 10080 : null);
66
+ return m ? [m] : [];
67
+ }
68
+
69
+ async function getJSON(url, headers) {
70
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(15000), redirect: 'error' });
71
+ if (!response.ok) {
72
+ const err = new Error(response.status === 429 ? '额度接口限流,请稍后刷新' : response.status === 401 || response.status === 403 ? '登录已过期或缺少额度读取权限,请在机器上重新登录' : `额度服务返回 HTTP ${response.status}`);
73
+ err.status = response.status;
74
+ throw err;
75
+ }
76
+ return response.json();
77
+ }
78
+
79
+ export function readClaudeLimits(cliPath, configDir) {
80
+ return new Promise((resolve, reject) => {
81
+ const proc = spawn(cliPath, ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'], {
82
+ env: { ...process.env, CLAUDE_CONFIG_DIR: configDir }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
83
+ });
84
+ let buffer = '';
85
+ let settled = false;
86
+ const finish = (err, value) => {
87
+ if (settled) return;
88
+ settled = true; clearTimeout(timer); proc.stdin.end(); proc.kill();
89
+ if (err) reject(err); else resolve(value);
90
+ };
91
+ const timer = setTimeout(() => finish(new Error('Claude quota control timeout')), 20000);
92
+ const send = (id, request) => proc.stdin.write(`${JSON.stringify({ type: 'control_request', request_id: id, request })}\n`);
93
+ proc.on('error', () => finish(new Error('Claude CLI unavailable')));
94
+ proc.stdin.on('error', () => finish(new Error('Claude CLI closed')));
95
+ proc.stderr.on('data', () => {});
96
+ proc.on('exit', () => { if (!settled) finish(new Error('Claude CLI exited')); });
97
+ proc.stdout.on('data', chunk => {
98
+ buffer += chunk.toString();
99
+ if (buffer.length > 1024 * 1024) return finish(new Error('Claude quota response too large'));
100
+ let end;
101
+ while ((end = buffer.indexOf('\n')) >= 0) {
102
+ const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
103
+ let msg; try { msg = JSON.parse(line); } catch { continue; }
104
+ if (msg.type !== 'control_response') continue;
105
+ const response = msg.response;
106
+ if (response?.request_id === 'quota-init') {
107
+ if (response.subtype === 'error') return finish(new Error('Claude initialization failed'));
108
+ send('quota-read', { subtype: 'get_usage' });
109
+ } else if (response?.request_id === 'quota-read') {
110
+ if (response.subtype === 'error') return finish(new Error('Claude usage control unsupported'));
111
+ finish(null, response.response);
112
+ }
113
+ }
114
+ });
115
+ send('quota-init', { subtype: 'initialize', hooks: {} });
116
+ });
117
+ }
118
+
119
+ async function claudeQuota(configDir, cliPath) {
120
+ // Let Claude read its own Keychain entry through its native credential
121
+ // integration. No user prompt is submitted and no model turn is started.
122
+ try {
123
+ const usage = await readClaudeLimits(cliPath, configDir);
124
+ if (usage?.rate_limits_available) return { meters: normalizeClaudeQuota(usage.rate_limits), plan: usage.subscription_type };
125
+ if (usage?.rate_limits_available === false) return { status: 'unavailable', meters: [], message: '当前 Claude 登录没有可读取的订阅额度(API Key、第三方网关或缺少用量权限)' };
126
+ } catch {}
127
+ let credentials;
128
+ try { credentials = JSON.parse(await fs.readFile(path.join(configDir, '.credentials.json'), 'utf8')); } catch {}
129
+ if (!credentials && process.platform === 'darwin') {
130
+ const standard = path.join(os.homedir(), '.claude');
131
+ const suffix = `-${crypto.createHash('sha256').update(configDir).digest('hex').slice(0, 8)}`;
132
+ let locked = false;
133
+ for (const service of [`Claude Code-credentials${suffix}`, ...(configDir === standard ? ['Claude Code-credentials'] : [])]) {
134
+ try {
135
+ const result = await exec('/usr/bin/security', ['find-generic-password', '-s', service, '-w'], { timeout: 5000 });
136
+ credentials = JSON.parse(result.stdout);
137
+ if (credentials?.claudeAiOauth?.accessToken) break;
138
+ } catch (err) { if (err.code === 36 || err.killed) locked = true; }
139
+ }
140
+ if (!credentials && locked) return { status: 'unavailable', message: '这台机器的 macOS 钥匙串未解锁或不允许后台读取,请在机器上解锁并授权 security 读取 Claude 登录', meters: [] };
141
+ }
142
+ const oauth = credentials?.claudeAiOauth;
143
+ if (!oauth?.accessToken) return { status: 'unavailable', message: '未找到 Claude 订阅登录;API Key 或第三方网关没有订阅周额度', meters: [] };
144
+ const data = await getJSON('https://api.anthropic.com/api/oauth/usage', {
145
+ Authorization: `Bearer ${oauth.accessToken}`, 'anthropic-beta': 'oauth-2025-04-20', 'User-Agent': 'claude-simple-quota/1.0',
146
+ });
147
+ return { meters: normalizeClaudeQuota(data), plan: oauth.subscriptionType || null };
148
+ }
149
+
150
+ export function readCodexLimits(cliPath, codexHome) {
151
+ return new Promise((resolve, reject) => {
152
+ const proc = spawn(cliPath, ['app-server'], { env: { ...process.env, CODEX_HOME: codexHome }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
153
+ let buffer = '';
154
+ let settled = false;
155
+ const finish = (err, result) => {
156
+ if (settled) return;
157
+ settled = true;
158
+ clearTimeout(timer);
159
+ proc.stdin.end();
160
+ proc.kill();
161
+ if (err) reject(err); else resolve(result);
162
+ };
163
+ const timer = setTimeout(() => finish(new Error('Codex 额度读取超时')), 20000);
164
+ const send = msg => proc.stdin.write(`${JSON.stringify(msg)}\n`);
165
+ proc.on('error', () => finish(new Error('Codex CLI 不可用')));
166
+ proc.stdin.on('error', () => finish(new Error('Codex CLI 连接失败')));
167
+ proc.stderr.on('data', () => {}); // Never expose CLI diagnostics or credentials.
168
+ proc.on('exit', () => { if (!settled) finish(new Error('Codex CLI 未返回额度,请检查登录或升级 CLI')); });
169
+ proc.stdout.on('data', chunk => {
170
+ buffer += chunk.toString();
171
+ if (buffer.length > 1024 * 1024) return finish(new Error('Codex 额度响应过大'));
172
+ let index;
173
+ while ((index = buffer.indexOf('\n')) >= 0) {
174
+ const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
175
+ let msg; try { msg = JSON.parse(line); } catch { continue; }
176
+ if (msg.id === 1) {
177
+ if (msg.error) return finish(new Error('Codex CLI 初始化失败'));
178
+ send({ method: 'initialized' });
179
+ send({ id: 2, method: 'account/rateLimits/read' });
180
+ } else if (msg.id === 2) {
181
+ if (msg.error) return finish(new Error('Codex 未提供订阅额度,请检查 ChatGPT 登录;API Key 模式不支持此额度'));
182
+ finish(null, msg.result);
183
+ }
184
+ }
185
+ });
186
+ send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'claude_simple_quota', version: '1.0.0' } } });
187
+ });
188
+ }
189
+
190
+ async function grokQuota(grokHome) {
191
+ let auth;
192
+ try {
193
+ const store = JSON.parse(await fs.readFile(path.join(grokHome, 'auth.json'), 'utf8'));
194
+ auth = Object.values(store).find(row => row?.auth_mode === 'oidc' && typeof row.key === 'string');
195
+ } catch {}
196
+ if (!auth) return { status: 'unavailable', message: '未找到 Grok 订阅登录;API Key 模式没有个人周额度', meters: [] };
197
+ const data = await getJSON('https://cli-chat-proxy.grok.com/v1/billing?format=credits', {
198
+ Authorization: `Bearer ${auth.key}`, 'x-userid': auth.user_id, 'x-grok-client-version': '1.0.46', 'X-XAI-Token-Auth': 'xai-grok-cli',
199
+ });
200
+ return { meters: normalizeGrokQuota(data), plan: data.subscriptionTier || null };
201
+ }
202
+
203
+ export function createQuotaReader({ claudePath = 'claude', codexPath, codexHome, grokHome, defaultClaudeDir, allowedClaudeDirs }) {
204
+ const cache = new Map();
205
+ return async ({ agent, configDir } = {}) => {
206
+ const dir = configDir || defaultClaudeDir;
207
+ if (agent === 'claude' && !allowedClaudeDirs().includes(dir)) throw new Error('Unknown Claude config directory');
208
+ const key = `${agent}:${agent === 'claude' ? dir : ''}`;
209
+ const cached = cache.get(key);
210
+ if (cached && Date.now() < cached.expiresAt) return cached.promise;
211
+ const promise = (async () => {
212
+ let result;
213
+ try {
214
+ if (agent === 'claude') result = await claudeQuota(dir, claudePath);
215
+ else if (agent === 'codex') result = { meters: normalizeCodexQuota(await readCodexLimits(codexPath, codexHome)) };
216
+ else if (agent === 'grok') result = await grokQuota(grokHome);
217
+ else throw new Error('Unsupported agent');
218
+ return { agent, status: result.meters.length ? 'ok' : 'unavailable',
219
+ ...(result.meters.length ? {} : { message: '当前账号未返回额度数据' }), ...result, fetchedAt: Date.now() };
220
+ } catch (err) {
221
+ return { agent, status: 'error', message: err.status ? err.message : '无法读取额度,请检查机器上的 CLI 登录和网络', meters: [], fetchedAt: Date.now() };
222
+ }
223
+ })();
224
+ // Shared per account, including failures, to avoid vendor endpoint limits.
225
+ cache.set(key, { promise, expiresAt: Date.now() + 5 * 60 * 1000 });
226
+ return promise;
227
+ };
228
+ }
File without changes
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'child_process';
2
+ import { spawnAgentOpts } from '../platform.js';
2
3
  import readline from 'readline';
3
4
  import crypto from 'crypto';
4
5
 
@@ -94,6 +95,7 @@ export function runCodex(command, options, send, { cliPath, activeSessions }) {
94
95
  cwd: options.cwd || process.cwd(),
95
96
  env,
96
97
  stdio: ['pipe', 'pipe', 'pipe'],
98
+ ...spawnAgentOpts(),
97
99
  });
98
100
  } catch (e) {
99
101
  send({ type: 'error', message: `Failed to start codex: ${e.message}`, agent: 'codex' });
@@ -50,12 +50,13 @@ function toolBlocks(message) {
50
50
  export function runCursor(command, options, send, { cliPath, cursorHome, activeSessions }) {
51
51
  return new Promise(resolve => {
52
52
  const sessionKey = options.sessionId || crypto.randomUUID();
53
- const home = cursorHome || path.join(process.env.HOME || '', '.cursor');
53
+ const home = cursorHome || path.join(process.env.HOME || process.env.USERPROFILE || '', '.cursor');
54
54
  const args = buildArgs(command, options);
55
55
  let child;
56
56
  try {
57
57
  child = spawn(cliPath || 'cursor-agent', args, {
58
58
  cwd: options.cwd || process.cwd(), env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'],
59
+ windowsHide: process.platform === 'win32',
59
60
  });
60
61
  } catch (error) {
61
62
  send({ type: 'error', message: `Failed to start cursor-agent: ${error.message}`, agent: 'cursor' });
@@ -51,7 +51,7 @@ function toolInput(update) {
51
51
  export function runGemini(command, options, send, { cliPath, geminiHome, activeSessions }) {
52
52
  return new Promise(resolve => {
53
53
  const sessionKey = options.sessionId || crypto.randomUUID();
54
- const home = geminiHome || path.join(process.env.HOME || '', '.gemini');
54
+ const home = geminiHome || path.join(process.env.HOME || process.env.USERPROFILE || '', '.gemini');
55
55
  const args = buildArgs(command, options);
56
56
  // A resumed native conversation may predate the UI journal. Start recording
57
57
  // from this turn so its subsequent history/search/usage is still complete.
@@ -68,6 +68,7 @@ export function runGemini(command, options, send, { cliPath, geminiHome, activeS
68
68
  cwd: options.cwd || process.cwd(),
69
69
  env: { ...process.env },
70
70
  stdio: ['ignore', 'pipe', 'pipe'],
71
+ windowsHide: process.platform === 'win32',
71
72
  });
72
73
  } catch (error) {
73
74
  send({ type: 'error', message: `Failed to start agy: ${error.message}`, agent: 'gemini' });
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'child_process';
2
+ import { spawnAgentOpts } from '../platform.js';
2
3
  import fs from 'fs';
3
4
  import path from 'path';
4
5
  import readline from 'readline';
@@ -99,6 +100,7 @@ class TerminalHost {
99
100
  cwd,
100
101
  env,
101
102
  stdio: ['ignore', 'pipe', 'pipe'],
103
+ ...spawnAgentOpts(),
102
104
  shell: args.length === 0,
103
105
  });
104
106
  } catch (error) {
@@ -215,7 +217,10 @@ class GrokACPClient {
215
217
  if (model) args.push('--model', model);
216
218
  if (effort) args.push('--reasoning-effort', effort);
217
219
  args.push('stdio');
218
- this.child = spawn(cliPath || 'grok', args, { stdio: ['pipe', 'pipe', 'pipe'] });
220
+ this.child = spawn(cliPath || 'grok', args, {
221
+ stdio: ['pipe', 'pipe', 'pipe'],
222
+ ...spawnAgentOpts(),
223
+ });
219
224
  this.pending = new Map();
220
225
  this.listeners = new Map();
221
226
  this.sessionCwd = new Map();
@@ -388,8 +393,24 @@ function clientFor(options, cliPath) {
388
393
  return client;
389
394
  }
390
395
 
396
+ export function grokActivityStatus(update) {
397
+ const kind = update?.sessionUpdate;
398
+ if (kind === 'tool_call' || kind === 'tool_call_update') {
399
+ const name = update.title || update.kind || 'tool';
400
+ const st = update.status ? ` (${update.status})` : '';
401
+ return `Grok 工具: ${name}${st}`;
402
+ }
403
+ if (kind === 'agent_thought_chunk') return 'Grok 正在思考…';
404
+ if (kind === 'agent_message_chunk') return '';
405
+ return 'Grok ACP running…';
406
+ }
407
+
391
408
  function emitUpdate(update, send, state) {
392
409
  const kind = update?.sessionUpdate;
410
+ const activity = grokActivityStatus(update);
411
+ if (activity) {
412
+ send({ type: 'status', message: activity, agent: 'grok' });
413
+ }
393
414
  if (kind === 'agent_message_chunk' && update.content?.text) {
394
415
  if (state.thoughtOpen) {
395
416
  send({ type: 'thought_stream_end', agent: 'grok' });
@@ -26,6 +26,7 @@ import path from 'path';
26
26
  import os from 'os';
27
27
  import crypto from 'crypto';
28
28
  import { spawn } from 'child_process';
29
+ import { expandHome, shellExecSpec, spawnCommandOpts } from '../platform.js';
29
30
 
30
31
  export const AI_DEFAULTS = {
31
32
  url: 'https://api.xiaomimimo.com/v1/chat/completions',
@@ -443,7 +444,7 @@ const TOOLS = [
443
444
 
444
445
  function expandPath(p) {
445
446
  if (!p) return p;
446
- return String(p).replace(/^~(?=$|[/\\])/, os.homedir());
447
+ return expandHome(String(p));
447
448
  }
448
449
 
449
450
  function pad(n) {
@@ -1919,7 +1920,8 @@ export function createMetaAgent(deps) {
1919
1920
  const p = expandPath(args.path);
1920
1921
  if (!path.isAbsolute(p)) return { error: 'path must be absolute' };
1921
1922
  // block writing into sensitive system dirs
1922
- if (p === '/' || p.startsWith('/etc') || p.startsWith('/usr') || p.startsWith('/bin')) {
1923
+ if (p === '/' || p.startsWith('/etc') || p.startsWith('/usr') || p.startsWith('/bin')
1924
+ || /^[a-zA-Z]:\\(Windows|Program Files)/i.test(p)) {
1923
1925
  return { error: 'refusing to write to system path' };
1924
1926
  }
1925
1927
  await fs.mkdir(path.dirname(p), { recursive: true });
@@ -1933,10 +1935,11 @@ export function createMetaAgent(deps) {
1933
1935
  const cwd = expandPath(args.cwd || os.homedir());
1934
1936
  const timeoutMs = Math.min(Math.max(args.timeoutMs || 60000, 1000), 180000);
1935
1937
  return await new Promise((resolve) => {
1936
- const child = spawn(process.env.SHELL || 'bash', ['-c', args.command], {
1938
+ const spec = shellExecSpec(args.command);
1939
+ const child = spawn(spec.bin, spec.args, {
1937
1940
  cwd: fsSync.existsSync(cwd) ? cwd : os.homedir(),
1938
1941
  env: { ...process.env },
1939
- shell: false,
1942
+ ...spawnCommandOpts({ extra: { shell: false } }),
1940
1943
  });
1941
1944
  let stdout = '';
1942
1945
  let stderr = '';