claude-simple 0.1.0 → 0.2.1

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 (46) hide show
  1. package/client.js +31 -2
  2. package/dist/app.0.2.1.min.js +868 -0
  3. package/dist/highlight.min.js +448 -430
  4. package/dist/index.html +2 -2
  5. package/dist/style.0.2.1.min.css +1182 -0
  6. package/package.json +1 -1
  7. package/public/agent-panel.js +44 -0
  8. package/public/api.js +61 -10
  9. package/public/app.js +4 -0
  10. package/public/chat.js +338 -31
  11. package/public/files.js +22 -30
  12. package/public/git.js +21 -17
  13. package/public/icons/icon-180.png +0 -0
  14. package/public/icons/icon-192.png +0 -0
  15. package/public/icons/icon-512.png +0 -0
  16. package/public/icons/icon-maskable-512.png +0 -0
  17. package/public/index.html +16 -3
  18. package/public/lib.js +7 -2
  19. package/public/manifest.webmanifest +36 -0
  20. package/public/offline.html +33 -0
  21. package/public/pwa.js +78 -0
  22. package/public/shell/boot.js +3 -0
  23. package/public/shell/hub.js +4 -2
  24. package/public/shell/notes.js +93 -31
  25. package/public/shell/session-list.js +29 -10
  26. package/public/shell/session-nav.js +7 -2
  27. package/public/shell/templates.js +11 -3
  28. package/public/shell.js +101 -8
  29. package/public/state.js +13 -2
  30. package/public/style.css +135 -3
  31. package/public/sw.js +104 -0
  32. package/public/terminal.js +22 -0
  33. package/public/wake-lock.js +45 -0
  34. package/server.js +5 -1
  35. package/src/agents/claude.js +108 -10
  36. package/src/agents/codex.js +1 -1
  37. package/src/agents/grok.js +40 -32
  38. package/src/agents/meta-agent.js +40 -1
  39. package/src/agents/stream-batch.js +62 -0
  40. package/src/app.js +312 -41
  41. package/src/auto-update.js +215 -0
  42. package/src/runtime/approvals.js +67 -0
  43. package/src/runtime/outbox.js +143 -0
  44. package/src/runtime/protocol.js +224 -0
  45. package/dist/app.0.1.0.min.js +0 -825
  46. package/dist/style.0.1.0.min.css +0 -1050
package/public/sw.js ADDED
@@ -0,0 +1,104 @@
1
+ /* Agent UI service worker — cache shell assets only.
2
+ * Never intercepts WebSocket or mutates API semantics beyond offline fallback.
3
+ * Bump CACHE when shipping UI that must invalidate old shells. */
4
+ const CACHE = 'agent-ui-shell-v3';
5
+ const PRECACHE = [
6
+ '/',
7
+ '/index.html',
8
+ '/manifest.webmanifest',
9
+ '/style.css?v=boot-2',
10
+ '/style.css',
11
+ '/app.js',
12
+ '/icons/icon-192.png',
13
+ '/icons/icon-512.png',
14
+ '/icons/icon-maskable-512.png',
15
+ '/icons/icon-180.png',
16
+ '/offline.html',
17
+ ];
18
+
19
+ self.addEventListener('install', (event) => {
20
+ event.waitUntil(
21
+ caches.open(CACHE)
22
+ .then((cache) => cache.addAll(PRECACHE.map((u) => new Request(u, { cache: 'reload' }))))
23
+ .then(() => self.skipWaiting())
24
+ .catch(() => self.skipWaiting()),
25
+ );
26
+ });
27
+
28
+ self.addEventListener('activate', (event) => {
29
+ event.waitUntil(
30
+ caches.keys()
31
+ .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
32
+ .then(() => self.clients.claim()),
33
+ );
34
+ });
35
+
36
+ function isApiOrRealtime(url) {
37
+ const p = url.pathname;
38
+ return (
39
+ p.startsWith('/api/')
40
+ || p.startsWith('/ws/')
41
+ || p === '/machine-connect'
42
+ || p.startsWith('/machine/')
43
+ || p === '/healthz'
44
+ );
45
+ }
46
+
47
+ function isStaticAsset(url) {
48
+ if (url.origin !== self.location.origin) return false;
49
+ if (isApiOrRealtime(url)) return false;
50
+ return /\.(?:js|css|png|svg|webp|ico|woff2?|ttf|map|webmanifest)(?:$|\?)/i.test(url.pathname + url.search)
51
+ || url.pathname.startsWith('/icons/');
52
+ }
53
+
54
+ self.addEventListener('fetch', (event) => {
55
+ const req = event.request;
56
+ if (req.method !== 'GET') return;
57
+
58
+ const url = new URL(req.url);
59
+
60
+ // Let the browser handle API + anything that might upgrade to WebSocket.
61
+ if (isApiOrRealtime(url)) return;
62
+
63
+ // Cross-origin (CDN daisyui/tailwind/marked): network only — do not cache opaque forever.
64
+ if (url.origin !== self.location.origin) return;
65
+
66
+ // Navigations: network-first so deploys show up; offline → cached shell.
67
+ if (req.mode === 'navigate' || (req.headers.get('accept') || '').includes('text/html')) {
68
+ event.respondWith(
69
+ fetch(req)
70
+ .then((res) => {
71
+ const copy = res.clone();
72
+ if (res.ok) {
73
+ caches.open(CACHE).then((c) => c.put('/index.html', copy)).catch(() => {});
74
+ }
75
+ return res;
76
+ })
77
+ .catch(async () => {
78
+ const cached = await caches.match('/index.html') || await caches.match('/');
79
+ return cached || caches.match('/offline.html');
80
+ }),
81
+ );
82
+ return;
83
+ }
84
+
85
+ // Same-origin static modules: stale-while-revalidate.
86
+ if (isStaticAsset(url)) {
87
+ event.respondWith(
88
+ caches.open(CACHE).then(async (cache) => {
89
+ const cached = await cache.match(req);
90
+ const network = fetch(req)
91
+ .then((res) => {
92
+ if (res && res.ok) cache.put(req, res.clone()).catch(() => {});
93
+ return res;
94
+ })
95
+ .catch(() => cached);
96
+ return cached || network;
97
+ }),
98
+ );
99
+ }
100
+ });
101
+
102
+ self.addEventListener('message', (event) => {
103
+ if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
104
+ });
@@ -8,6 +8,9 @@ let fitAddon = null;
8
8
  let connected = false;
9
9
  let lastCwd = null;
10
10
  let lastMachine = null;
11
+ let shellGen = 0;
12
+ let shellReconnectTimer = null;
13
+ let shellWanted = false;
11
14
 
12
15
  function initXterm() {
13
16
  if (term) return;
@@ -88,6 +91,9 @@ function activeMachine() {
88
91
  }
89
92
 
90
93
  function connect(cwd) {
94
+ if (shellReconnectTimer) { clearTimeout(shellReconnectTimer); shellReconnectTimer = null; }
95
+ shellWanted = true;
96
+ const gen = ++shellGen;
91
97
  if (ws) { try { ws.close(); } catch {} ws = null; }
92
98
  connected = false;
93
99
 
@@ -108,12 +114,14 @@ function connect(cwd) {
108
114
  ws = new WebSocket(`${proto}://${location.host}/ws/shell?${q}`);
109
115
 
110
116
  ws.addEventListener('open', () => {
117
+ if (gen !== shellGen) return;
111
118
  connected = true;
112
119
  term?.write('\x1b[32mConnected to interactive terminal\x1b[0m\r\n');
113
120
  sendResize();
114
121
  });
115
122
 
116
123
  ws.addEventListener('message', e => {
124
+ if (gen !== shellGen) return;
117
125
  let msg;
118
126
  try { msg = JSON.parse(e.data); } catch { return; }
119
127
  if (msg.type === 'output') {
@@ -126,11 +134,19 @@ function connect(cwd) {
126
134
  });
127
135
 
128
136
  ws.addEventListener('close', () => {
137
+ if (gen !== shellGen) return;
129
138
  connected = false;
130
139
  term?.write('\r\n\x1b[33m[disconnected]\x1b[0m\r\n');
140
+ if (!shellWanted || currentTab.peek() !== 'shell') return;
141
+ term?.write('\x1b[33m[reconnecting…]\x1b[0m\r\n');
142
+ shellReconnectTimer = setTimeout(() => {
143
+ shellReconnectTimer = null;
144
+ connect(lastCwd);
145
+ }, 1500);
131
146
  });
132
147
 
133
148
  ws.addEventListener('error', () => {
149
+ if (gen !== shellGen) return;
134
150
  term?.write('\r\n\x1b[31m[WebSocket error]\x1b[0m\r\n');
135
151
  });
136
152
  }
@@ -161,6 +177,12 @@ function activateShellTab() {
161
177
  }
162
178
 
163
179
  export function initTerminal() {
180
+ document.addEventListener('visibilitychange', () => {
181
+ if (document.visibilityState !== 'visible') return;
182
+ if (currentTab.peek() !== 'shell' || !shellWanted) return;
183
+ if (!connected) connect(lastCwd);
184
+ });
185
+
164
186
  // Defer xterm until first visit to shell (container must be measurable)
165
187
  watch(currentTab, tab => {
166
188
  if (tab === 'shell') activateShellTab();
@@ -0,0 +1,45 @@
1
+ // wake-lock.js — keep screen awake during active chat / agent runs (macOS sleep fix)
2
+ let wakeLock = null;
3
+ let wantLock = false;
4
+
5
+ async function acquire() {
6
+ if (!wantLock) return;
7
+ if (typeof navigator === 'undefined' || !navigator.wakeLock?.request) return;
8
+ if (document.visibilityState !== 'visible') return;
9
+ if (wakeLock) return;
10
+ try {
11
+ wakeLock = await navigator.wakeLock.request('screen');
12
+ wakeLock.addEventListener('release', () => {
13
+ wakeLock = null;
14
+ // System released (e.g. low battery) — try again if still wanted
15
+ if (wantLock && document.visibilityState === 'visible') {
16
+ setTimeout(() => { acquire().catch(() => {}); }, 500);
17
+ }
18
+ });
19
+ } catch {
20
+ // Not allowed / unsupported — ignore
21
+ wakeLock = null;
22
+ }
23
+ }
24
+
25
+ async function release() {
26
+ const lock = wakeLock;
27
+ wakeLock = null;
28
+ if (!lock) return;
29
+ try { await lock.release(); } catch { /* ignore */ }
30
+ }
31
+
32
+ /** Enable/disable preferred wake-lock state (still requires visible document). */
33
+ export function setWakeLockDesired(on) {
34
+ wantLock = !!on;
35
+ if (wantLock) acquire().catch(() => {});
36
+ else release().catch(() => {});
37
+ }
38
+
39
+ export function initWakeLock() {
40
+ if (typeof document === 'undefined') return;
41
+ document.addEventListener('visibilitychange', () => {
42
+ if (document.visibilityState === 'visible' && wantLock) acquire().catch(() => {});
43
+ else if (document.visibilityState === 'hidden') release().catch(() => {});
44
+ });
45
+ }
package/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { createApp, init, PORT } from './src/app.js';
2
+ import { createApp, init, PORT, hasBusyAgent } from './src/app.js';
3
+ import { startAutoUpdate } from './src/auto-update.js';
3
4
 
4
5
  await init();
5
6
 
@@ -10,3 +11,6 @@ server.listen(PORT, () => {
10
11
  if (process.env.AUTH_USERNAME) console.log(` Auth: env user "${process.env.AUTH_USERNAME}"`);
11
12
  console.log();
12
13
  });
14
+
15
+ // Optional self-update (same as edge). AUTO_UPDATE=0 to disable.
16
+ startAutoUpdate({ role: 'standalone', isBusy: hasBusyAgent });
@@ -1,19 +1,39 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
2
  import crypto from 'crypto';
3
+ import { mapAgentEmit, compactToolInput } from '../runtime/protocol.js';
4
+ import { waitForApproval, cancelApprovalsForSession } from '../runtime/approvals.js';
3
5
 
4
6
  /**
5
7
  * Run a Claude Code turn via the agent SDK.
6
- * Emits the same WS-facing messages as the original runClaude:
7
- * session-created, assistant, result, complete, error (+ raw SDK msgs).
8
+ * Emits filtered UI events only (no raw SDK dump) to cut wire traffic.
9
+ * Supports canUseTool → permission_request / approval.respond round-trip.
8
10
  */
9
11
  export async function runClaude(command, options, send, { cliPath, activeSessions }) {
10
12
  const sessionKey = options.sessionId || crypto.randomUUID();
11
13
  const controller = new AbortController();
12
- activeSessions.set(sessionKey, { controller, kind: 'claude', startTime: Date.now() });
14
+ activeSessions.set(sessionKey, {
15
+ controller,
16
+ kind: 'claude',
17
+ startTime: Date.now(),
18
+ busy: true,
19
+ key: sessionKey,
20
+ sessionId: options.sessionId || null,
21
+ });
13
22
 
14
23
  const env = { ...process.env };
15
24
  if (options.configDir) env.CLAUDE_CONFIG_DIR = options.configDir;
16
25
 
26
+ const permissionMode = options.permissionMode || 'default';
27
+ const autoApprove = permissionMode === 'bypassPermissions'
28
+ || permissionMode === 'acceptEdits'
29
+ || permissionMode === 'auto'
30
+ || options.allowDangerouslySkipPermissions;
31
+
32
+ const emit = (raw) => {
33
+ const mapped = mapAgentEmit(raw, { agent: 'claude', compactTools: true });
34
+ if (mapped) send(mapped);
35
+ };
36
+
17
37
  const sdkOptions = {
18
38
  env,
19
39
  pathToClaudeCodeExecutable: cliPath,
@@ -23,17 +43,54 @@ export async function runClaude(command, options, send, { cliPath, activeSession
23
43
  model: options.model || 'claude-sonnet-4-5',
24
44
  abortSignal: controller.signal,
25
45
  };
26
- if (options.cwd) sdkOptions.cwd = options.cwd;
46
+ if (options.cwd) sdkOptions.cwd = options.cwd;
27
47
  if (options.sessionId) sdkOptions.resume = options.sessionId;
28
- if (options.effort) sdkOptions.effort = options.effort;
29
- if (options.permissionMode && options.permissionMode !== 'default') {
30
- sdkOptions.permissionMode = options.permissionMode;
48
+ if (options.effort) sdkOptions.effort = options.effort;
49
+ if (permissionMode && permissionMode !== 'default') {
50
+ sdkOptions.permissionMode = permissionMode;
31
51
  }
32
52
  if (options.allowDangerouslySkipPermissions) {
33
53
  sdkOptions.allowDangerouslySkipPermissions = true;
34
54
  }
35
55
  if (options.allowedTools?.length) sdkOptions.allowedTools = options.allowedTools;
36
56
 
57
+ // Interactive permission bridge when not in auto modes
58
+ if (!autoApprove && permissionMode !== 'bypassPermissions') {
59
+ sdkOptions.canUseTool = async (toolName, input, toolOpts) => {
60
+ if (controller.signal.aborted || toolOpts?.signal?.aborted) {
61
+ return { behavior: 'deny', message: 'aborted', interrupt: true };
62
+ }
63
+ const requestId = toolOpts?.toolUseID || crypto.randomUUID();
64
+ const sid = activeSessions.get(sessionKey)?.sessionId || options.sessionId || sessionKey;
65
+ send({
66
+ type: 'permission_request',
67
+ request_id: requestId,
68
+ requestId,
69
+ tool_name: toolName,
70
+ tool_input: compactToolInput(input, 400),
71
+ title: toolOpts?.title || null,
72
+ displayName: toolOpts?.displayName || null,
73
+ description: toolOpts?.description || null,
74
+ sessionId: sid,
75
+ agent: 'claude',
76
+ });
77
+ const decision = await waitForApproval(requestId, sid);
78
+ if (decision.cancelled || decision.timeout || controller.signal.aborted) {
79
+ return { behavior: 'deny', message: decision.timeout ? 'permission timeout' : 'denied', interrupt: !!decision.cancelled };
80
+ }
81
+ if (decision.allow) {
82
+ const result = { behavior: 'allow', updatedInput: input };
83
+ if (decision.always && toolOpts?.suggestions?.length) {
84
+ result.updatedPermissions = toolOpts.suggestions;
85
+ }
86
+ send({ type: 'permission_resolved', requestId, allow: true, agent: 'claude' });
87
+ return result;
88
+ }
89
+ send({ type: 'permission_resolved', requestId, allow: false, agent: 'claude' });
90
+ return { behavior: 'deny', message: 'User denied permission' };
91
+ };
92
+ }
93
+
37
94
  let capturedSessionId = null;
38
95
 
39
96
  try {
@@ -41,18 +98,59 @@ export async function runClaude(command, options, send, { cliPath, activeSession
41
98
  for await (const msg of stream) {
42
99
  if (msg.type === 'system' && msg.session_id && !capturedSessionId) {
43
100
  capturedSessionId = msg.session_id;
101
+ const ent = activeSessions.get(sessionKey);
102
+ if (ent) {
103
+ ent.sessionId = capturedSessionId;
104
+ activeSessions.set(capturedSessionId, ent);
105
+ }
44
106
  send({ type: 'session-created', sessionId: capturedSessionId, agent: 'claude' });
107
+ continue;
108
+ }
109
+
110
+ // Map SDK assistant content (text + tool_use)
111
+ if (msg.type === 'assistant' && msg.message?.content) {
112
+ emit({ type: 'assistant', message: msg.message, ts: Date.now() });
113
+ continue;
45
114
  }
46
- send(msg);
115
+
116
+ if (msg.type === 'result') {
117
+ emit({
118
+ type: 'result',
119
+ is_error: !!msg.is_error,
120
+ usage: msg.usage,
121
+ session_id: msg.session_id || capturedSessionId,
122
+ result: msg.result,
123
+ agent: 'claude',
124
+ });
125
+ continue;
126
+ }
127
+
128
+ // Drop stream_event / user / other noise via mapAgentEmit
129
+ emit(msg);
47
130
  }
48
- send({ type: 'complete', exitCode: 0, sessionId: capturedSessionId || sessionKey, agent: 'claude' });
131
+ send({
132
+ type: 'complete',
133
+ exitCode: 0,
134
+ sessionId: capturedSessionId || sessionKey,
135
+ agent: 'claude',
136
+ });
49
137
  } catch (err) {
50
138
  if (err.name === 'AbortError') {
51
- send({ type: 'complete', exitCode: 130, aborted: true, sessionId: capturedSessionId || sessionKey, agent: 'claude' });
139
+ cancelApprovalsForSession(capturedSessionId || sessionKey);
140
+ send({
141
+ type: 'complete',
142
+ exitCode: 130,
143
+ aborted: true,
144
+ sessionId: capturedSessionId || sessionKey,
145
+ agent: 'claude',
146
+ });
52
147
  } else {
53
148
  send({ type: 'error', message: err.message, agent: 'claude' });
54
149
  }
55
150
  } finally {
151
+ const ent = activeSessions.get(sessionKey);
152
+ if (ent) ent.busy = false;
56
153
  activeSessions.delete(sessionKey);
154
+ if (capturedSessionId) activeSessions.delete(capturedSessionId);
57
155
  }
58
156
  }
@@ -100,7 +100,7 @@ export function runCodex(command, options, send, { cliPath, activeSessions }) {
100
100
  return;
101
101
  }
102
102
 
103
- const entry = { kind: 'codex', child, startTime: Date.now() };
103
+ const entry = { kind: 'codex', child, startTime: Date.now(), busy: true, key: sessionKey, sessionId: options.sessionId || null };
104
104
  activeSessions.set(sessionKey, entry);
105
105
  // Also register under thread id once known so abort-session works
106
106
  let threadId = options.sessionId || null;
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'child_process';
2
2
  import readline from 'readline';
3
3
  import crypto from 'crypto';
4
+ import { createDeltaBatcher } from './stream-batch.js';
4
5
 
5
6
  /**
6
7
  * Run Grok CLI headlessly with streaming-json over WebSocket (not HTTP SSE).
@@ -89,7 +90,7 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
89
90
  // Unbuffer stdout as much as possible
90
91
  if (child.stdout?.setEncoding) child.stdout.setEncoding('utf8');
91
92
 
92
- const entry = { kind: 'grok', child, startTime: Date.now() };
93
+ const entry = { kind: 'grok', child, startTime: Date.now(), busy: true, key: sessionKey, sessionId: options.sessionId || null };
93
94
  activeSessions.set(sessionKey, entry);
94
95
 
95
96
  let sessionId = options.sessionId || null;
@@ -101,9 +102,8 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
101
102
  let textChars = 0;
102
103
  let thoughtChars = 0;
103
104
 
104
- // Throttle thought deltas a bit (they're very chatty) but keep text live
105
- let thoughtBuf = '';
106
- let thoughtTimer = null;
105
+ const textBatch = createDeltaBatcher(send, 'assistant_delta', { agent: 'grok' }, { maxWait: 48, maxSize: 480 });
106
+ const thoughtBatch = createDeltaBatcher(send, 'thought_delta', { agent: 'grok' }, { maxWait: 140, maxSize: 600 });
107
107
 
108
108
  child.stderr.on('data', d => { stderr += d.toString(); });
109
109
 
@@ -118,36 +118,27 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
118
118
  }
119
119
 
120
120
  function endStream() {
121
+ textBatch.flush();
121
122
  if (streamOpen) {
122
123
  send({ type: 'assistant_stream_end', agent: 'grok' });
123
124
  streamOpen = false;
124
125
  }
125
126
  }
126
127
 
127
- function flushThought(force = false) {
128
- if (!thoughtBuf) return;
129
- if (!force && thoughtBuf.length < 24) return;
128
+ function flushThought() {
129
+ if (!thoughtBatch.pending()) return;
130
130
  if (!thoughtOpen) {
131
131
  send({ type: 'thought_stream_start', agent: 'grok' });
132
132
  thoughtOpen = true;
133
133
  }
134
- send({ type: 'thought_delta', data: thoughtBuf, agent: 'grok' });
135
- thoughtChars += thoughtBuf.length;
136
- thoughtBuf = '';
137
- }
138
-
139
- function scheduleThoughtFlush() {
140
- if (thoughtTimer) return;
141
- thoughtTimer = setTimeout(() => {
142
- thoughtTimer = null;
143
- flushThought(true);
144
- }, 120);
134
+ const n = thoughtBatch.pending();
135
+ thoughtBatch.flush();
136
+ thoughtChars += n;
145
137
  }
146
138
 
147
139
  function emitTool(raw) {
148
140
  // Finalize any open text/thought before tool card
149
- if (thoughtTimer) { clearTimeout(thoughtTimer); thoughtTimer = null; }
150
- flushThought(true);
141
+ flushThought();
151
142
  if (thoughtOpen) {
152
143
  send({ type: 'thought_stream_end', agent: 'grok' });
153
144
  thoughtOpen = false;
@@ -179,10 +170,9 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
179
170
 
180
171
  const t = String(raw.type || '').toLowerCase();
181
172
 
182
- // ── Text tokens (stream immediately) ─────────────────────────────────
173
+ // ── Text tokens (batched ~50ms) ──────────────────────────────────────
183
174
  if ((t === 'text' || t === 'message' || t === 'output_text') && raw.data != null) {
184
- if (thoughtTimer) { clearTimeout(thoughtTimer); thoughtTimer = null; }
185
- flushThought(true);
175
+ flushThought();
186
176
  if (thoughtOpen) {
187
177
  send({ type: 'thought_stream_end', agent: 'grok' });
188
178
  thoughtOpen = false;
@@ -191,14 +181,17 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
191
181
  if (!chunk) return;
192
182
  ensureStream();
193
183
  textChars += chunk.length;
194
- send({ type: 'assistant_delta', data: chunk, agent: 'grok' });
184
+ textBatch.push(chunk);
195
185
  return;
196
186
  }
197
187
 
198
- // ── Thinking tokens (throttled stream) ───────────────────────────────
188
+ // ── Thinking tokens (batched stream) ────────────────────────────────
199
189
  if ((t === 'thought' || t === 'thinking' || t === 'reasoning') && raw.data != null) {
200
- thoughtBuf += String(raw.data);
201
- scheduleThoughtFlush();
190
+ if (!thoughtOpen) {
191
+ send({ type: 'thought_stream_start', agent: 'grok' });
192
+ thoughtOpen = true;
193
+ }
194
+ thoughtBatch.push(String(raw.data));
202
195
  return;
203
196
  }
204
197
 
@@ -219,6 +212,7 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
219
212
  // ── Session id early (some builds emit before end) ───────────────────
220
213
  if ((t === 'session' || t === 'session_started') && (raw.sessionId || raw.session_id)) {
221
214
  sessionId = raw.sessionId || raw.session_id;
215
+ entry.sessionId = sessionId;
222
216
  activeSessions.set(sessionId, entry);
223
217
  if (!options.sessionId) {
224
218
  send({ type: 'session-created', sessionId, agent: 'grok' });
@@ -229,8 +223,7 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
229
223
  // ── End of turn ─────────────────────────────────────────────────────
230
224
  if (t === 'end' || t === 'result' || t === 'done') {
231
225
  gotEnd = true;
232
- if (thoughtTimer) { clearTimeout(thoughtTimer); thoughtTimer = null; }
233
- flushThought(true);
226
+ flushThought();
234
227
  if (thoughtOpen) {
235
228
  send({ type: 'thought_stream_end', agent: 'grok' });
236
229
  thoughtOpen = false;
@@ -239,17 +232,22 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
239
232
 
240
233
  if (raw.sessionId || raw.session_id) {
241
234
  sessionId = raw.sessionId || raw.session_id;
235
+ entry.sessionId = sessionId;
242
236
  activeSessions.set(sessionId, entry);
243
237
  if (!options.sessionId) {
244
238
  send({ type: 'session-created', sessionId, agent: 'grok' });
245
239
  }
246
240
  }
247
241
 
242
+ // Unlock UI immediately — process may linger after the turn ends.
243
+ entry.busy = false;
244
+
248
245
  const u = raw.usage || {};
249
246
  const costTicks = raw.total_cost_usd_ticks ?? u.cost_usd_ticks ?? u.costUsdTicks;
250
247
  const costUSD = raw.total_cost_usd ?? u.cost_usd ?? u.costUSD
251
248
  ?? (typeof costTicks === 'number' ? costTicks / 1e10 : undefined);
252
249
 
250
+ const sid = sessionId || sessionKey;
253
251
  send({
254
252
  type: 'result',
255
253
  is_error: false,
@@ -262,27 +260,36 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
262
260
  reasoningTokens: u.reasoning_tokens || u.reasoningTokens || 0,
263
261
  costUSD,
264
262
  },
265
- sessionId: sessionId || sessionKey,
263
+ sessionId: sid,
266
264
  agent: 'grok',
267
265
  stats: { textChars, thoughtChars },
268
266
  });
267
+ // Early complete so client can send next turn without waiting for process exit.
268
+ send({ type: 'complete', exitCode: 0, sessionId: sid, agent: 'grok' });
269
269
  return;
270
270
  }
271
271
 
272
272
  if (t === 'error') {
273
+ entry.busy = false;
273
274
  send({ type: 'error', message: raw.message || raw.data || 'grok error', agent: 'grok' });
274
275
  }
275
276
  });
276
277
 
277
278
  child.on('close', (code) => {
278
- if (thoughtTimer) { clearTimeout(thoughtTimer); thoughtTimer = null; }
279
- flushThought(true);
279
+ flushThought();
280
280
  if (thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
281
281
  endStream();
282
282
  rl.close();
283
+ entry.busy = false;
283
284
  activeSessions.delete(sessionKey);
284
285
  if (sessionId) activeSessions.delete(sessionId);
285
286
 
287
+ // Skip duplicate complete if turn already finished via streaming-json "end".
288
+ if (gotEnd && !aborted) {
289
+ resolve();
290
+ return;
291
+ }
292
+
286
293
  if (aborted) {
287
294
  send({ type: 'complete', exitCode: 130, aborted: true, sessionId: sessionId || sessionKey, agent: 'grok' });
288
295
  } else if (code !== 0 && code != null && !gotEnd) {
@@ -300,6 +307,7 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
300
307
  });
301
308
 
302
309
  child.on('error', (e) => {
310
+ entry.busy = false;
303
311
  activeSessions.delete(sessionKey);
304
312
  if (sessionId) activeSessions.delete(sessionId);
305
313
  send({ type: 'error', message: e.message, agent: 'grok' });
@@ -427,6 +427,38 @@ export function createMetaAgent(deps) {
427
427
  const VLM_THREAD_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d on disk
428
428
  const ANON_SESSION_ID = '_anonymous';
429
429
 
430
+ /** run_command approval gate: id → { resolve } (see requestApproval/resolveApproval) */
431
+ const pendingApprovals = new Map();
432
+ const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
433
+
434
+ /** Ask the caller (via onApproval) to confirm a sensitive tool call; auto-denies if unattended or timed out. */
435
+ function requestApproval(onApproval, name, args) {
436
+ if (typeof onApproval !== 'function') return Promise.resolve(false);
437
+ const id = crypto.randomUUID();
438
+ return new Promise((resolve) => {
439
+ const timer = setTimeout(() => {
440
+ pendingApprovals.delete(id);
441
+ resolve(false);
442
+ }, APPROVAL_TIMEOUT_MS);
443
+ pendingApprovals.set(id, {
444
+ resolve: (approved) => {
445
+ clearTimeout(timer);
446
+ pendingApprovals.delete(id);
447
+ resolve(!!approved);
448
+ },
449
+ });
450
+ onApproval({ id, name, args });
451
+ });
452
+ }
453
+
454
+ /** Called from the /api/ai/approve endpoint when the user responds. */
455
+ function resolveApproval(id, approved) {
456
+ const pending = pendingApprovals.get(id);
457
+ if (!pending) return false;
458
+ pending.resolve(approved);
459
+ return true;
460
+ }
461
+
430
462
  let configCache = null;
431
463
  let reportsCache = null;
432
464
  /** @type {{ version: number, sessions: Array<{id,title,createdAt,updatedAt,messageCount}> } | null} */
@@ -1716,7 +1748,7 @@ export function createMetaAgent(deps) {
1716
1748
  /**
1717
1749
  * Tool-enabled agent loop with streaming content callbacks.
1718
1750
  */
1719
- async function runLoop(messages, { onContent, onTool, cfg, chatSessionId } = {}) {
1751
+ async function runLoop(messages, { onContent, onTool, onApproval, cfg, chatSessionId } = {}) {
1720
1752
  const config = cfg || await loadConfig();
1721
1753
  if (!config.key) throw new Error('AI API Key 未配置,请在 ⚙️ → Meta Agent 中填写');
1722
1754
 
@@ -1785,6 +1817,12 @@ export function createMetaAgent(deps) {
1785
1817
  onTool?.(call.function.name, a, 'start', null, callId);
1786
1818
  let result;
1787
1819
  try {
1820
+ if (call.function.name === 'run_command') {
1821
+ const approved = await requestApproval(onApproval, call.function.name, a);
1822
+ if (!approved) {
1823
+ throw new Error('command rejected: user did not approve execution');
1824
+ }
1825
+ }
1788
1826
  result = await executeTool(call.function.name, a, toolCtx);
1789
1827
  if (MUTATING.has(call.function.name)) needsMutate = true;
1790
1828
  onTool?.(call.function.name, a, 'done', result, callId);
@@ -1939,6 +1977,7 @@ export function createMetaAgent(deps) {
1939
1977
  buildDigest,
1940
1978
  executeTool,
1941
1979
  runLoop,
1980
+ resolveApproval,
1942
1981
  generateReport,
1943
1982
  listChatSessions,
1944
1983
  getChatSession,