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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
@@ -346,6 +346,37 @@ function updateToolChip(id, phase, result) {
346
346
  }
347
347
  }
348
348
 
349
+ /** run_command wants to execute — show an inline approve/deny card and POST the answer. */
350
+ function appendApprovalPrompt(d) {
351
+ const box = $('ma-messages');
352
+ if (!box) return;
353
+ const el = document.createElement('div');
354
+ el.className = 'ma-approval';
355
+ el.dataset.approvalId = d.id;
356
+ const cmd = d.args?.command ?? '';
357
+ el.innerHTML = `
358
+ <div class="ma-approval-label">⚠ Agent 想要执行命令,是否允许?</div>
359
+ <pre class="ma-approval-cmd">${esc(cmd)}</pre>
360
+ <div class="ma-approval-actions">
361
+ <button type="button" class="btn btn-xs btn-error" data-approve="0">拒绝</button>
362
+ <button type="button" class="btn btn-xs btn-success" data-approve="1">允许执行</button>
363
+ </div>`;
364
+ el.querySelectorAll('[data-approve]').forEach((btn) => {
365
+ btn.addEventListener('click', async () => {
366
+ const approved = btn.dataset.approve === '1';
367
+ el.querySelectorAll('button').forEach((b) => { b.disabled = true; });
368
+ el.querySelector('.ma-approval-label').textContent = approved ? '✓ 已允许,正在执行…' : '✗ 已拒绝';
369
+ try {
370
+ await api('POST', '/api/ai/approve', { id: d.id, approved });
371
+ } catch (e) {
372
+ el.querySelector('.ma-approval-label').textContent = `确认失败:${e.message}`;
373
+ }
374
+ });
375
+ });
376
+ box.appendChild(el);
377
+ box.scrollTop = box.scrollHeight;
378
+ }
379
+
349
380
  function nextImgLabel() {
350
381
  imgSeq += 1;
351
382
  return `img${imgSeq}`;
@@ -713,6 +744,7 @@ async function sendChat() {
713
744
  updateToolChip(d.id, d.phase, d.result);
714
745
  }
715
746
  },
747
+ approval: (d) => appendApprovalPrompt(d),
716
748
  done: (d) => {
717
749
  if (d.sessionId) {
718
750
  chatSessionId = d.sessionId;
@@ -1069,7 +1101,19 @@ export function initMetaAgent() {
1069
1101
  });
1070
1102
 
1071
1103
  // bootstrap: empty chat (last session restored on openMetaAgent)
1104
+ // History/config fetch is deferred to loadMetaAgentBoot() — called once hub
1105
+ // status + selected machine are known (boot.js / hub.js), not here: at this
1106
+ // point probeHub() hasn't resolved yet, so hubMode always reads as false.
1072
1107
  loadSession(null, { silent: true });
1108
+ }
1109
+
1110
+ /**
1111
+ * Meta-agent history + AI config are machine-scoped in hub mode — skip until
1112
+ * a machine is selected (called again from enterMachine() once one is).
1113
+ */
1114
+ export function loadMetaAgentBoot() {
1115
+ if (!ctx.token) return;
1116
+ if (hubMode.peek() && !currentMachineId()) return;
1073
1117
  refreshHistoryCache();
1074
1118
  api('GET', '/api/ai/config').then(scheduleAutoReport).catch(() => {});
1075
1119
  }
package/public/api.js CHANGED
@@ -1,5 +1,15 @@
1
1
  // api.js — HTTP + WebSocket helpers
2
2
  import { ctx, hubMode, selectedMachineId } from './state.js';
3
+ import { createFetch } from './lib.js';
4
+
5
+ // Only coalesce identical in-flight reads. Data is deliberately not retained
6
+ // here: session and workspace freshness remain controlled by their existing
7
+ // IndexedDB cache, while other API reads keep their previous always-fresh
8
+ // behavior.
9
+ const pendingGets = createFetch({ cache: false, dedupe: true, retry: 0 });
10
+ /** Path → ETag for conditional GETs (304 = free on mobile radio). */
11
+ const etagStore = new Map();
12
+ export const NOT_MODIFIED = Symbol('not-modified');
3
13
 
4
14
  /** Active edge machine id (hub). */
5
15
  export function currentMachineId(opts = {}) {
@@ -27,19 +37,60 @@ export async function api(method, path, body, signal, opts = {}) {
27
37
  headers['X-Machine-Id'] = machineId;
28
38
  }
29
39
 
30
- const res = await fetch(path, {
31
- method,
32
- signal,
33
- headers,
34
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
35
- });
36
- const data = await res.json().catch(() => ({}));
37
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
38
- return data;
40
+ const etagKey = opts.etag === true
41
+ ? `${ctx.token || ''}:${machineId || ''}:${path}`
42
+ : (opts.etag || null);
43
+ if (method.toUpperCase() === 'GET' && etagKey) {
44
+ const prev = etagStore.get(etagKey);
45
+ if (prev) headers['If-None-Match'] = prev;
46
+ }
47
+
48
+ const request = async () => {
49
+ const res = await fetch(path, {
50
+ method,
51
+ signal,
52
+ headers,
53
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
54
+ });
55
+ if (res.status === 304) return NOT_MODIFIED;
56
+ const data = await res.json().catch(() => ({}));
57
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
58
+ if (etagKey) {
59
+ const etag = res.headers.get('ETag');
60
+ if (etag) etagStore.set(etagKey, etag);
61
+ }
62
+ return data;
63
+ };
64
+
65
+ // A caller-provided AbortSignal must retain exclusive ownership of its
66
+ // request; sharing it could let one panel cancel another panel's read.
67
+ if (method.toUpperCase() !== 'GET' || signal || opts.dedupe === false) {
68
+ return request();
69
+ }
70
+ const key = `${ctx.token || ''}:${machineId || ''}:${path}`;
71
+ return pendingGets.get(key, request);
39
72
  }
40
73
 
74
+ const outboundWs = [];
75
+
41
76
  export function sendWs(data) {
42
- if (ctx.ws?.readyState === WebSocket.OPEN) ctx.ws.send(JSON.stringify(data));
77
+ if (ctx.ws?.readyState === WebSocket.OPEN) {
78
+ ctx.ws.send(JSON.stringify(data));
79
+ return true;
80
+ }
81
+ if (data?.type === 'ping' || data?.type === 'pong' || data?.type === 'subscribe') return false;
82
+ outboundWs.push(data);
83
+ return false;
84
+ }
85
+
86
+ export function flushWsQueue() {
87
+ while (outboundWs.length && ctx.ws?.readyState === WebSocket.OPEN) {
88
+ ctx.ws.send(JSON.stringify(outboundWs.shift()));
89
+ }
90
+ }
91
+
92
+ export function clearWsQueue() {
93
+ outboundWs.length = 0;
43
94
  }
44
95
 
45
96
  /** Query string for download links (token + machine in hub mode). */
package/public/app.js CHANGED
@@ -10,6 +10,7 @@ import { initTerminal } from './terminal.js';
10
10
  import { initImagePaste } from './chat.js';
11
11
  import { initMetaAgent } from './agent-panel.js';
12
12
  import { initRouter, applyInitialRoute, parseHashPath } from './router.js';
13
+ import { initPwa } from './pwa.js';
13
14
 
14
15
  function markBootReady() {
15
16
  const root = document.documentElement;
@@ -93,3 +94,6 @@ initRouter(resumeSession, switchTab);
93
94
  document.addEventListener('router:home', () => {
94
95
  clearBootRestore();
95
96
  });
97
+
98
+ // PWA: register SW (static cache only; never touches chat WS)
99
+ initPwa();
package/public/chat.js CHANGED
@@ -2,8 +2,9 @@
2
2
  import { watch, delegate, esc, $ } from './lib.js';
3
3
  import { ctx, isProcessing, currentProject, currentTab, currentModel, currentEffort, currentPermission,
4
4
  currentAgent, setAgent, AGENT_LABELS, getDefaultModel, getModelsForAgent, getEffortsForModel,
5
- addCustomModel, chatDensity } from './state.js';
6
- import { sendWs, api } from './api.js';
5
+ addCustomModel, chatDensity, wsStatus } from './state.js';
6
+ import { sendWs, api, flushWsQueue, clearWsQueue } from './api.js';
7
+ import { initWakeLock, setWakeLockDesired } from './wake-lock.js';
7
8
 
8
9
  function assistantLabel() {
9
10
  return AGENT_LABELS[currentAgent.peek()] || 'Assistant';
@@ -21,23 +22,246 @@ function stickBottom(el, force) {
21
22
  if (force || atBottom(el)) el.scrollTop = el.scrollHeight;
22
23
  }
23
24
 
24
- // ── WebSocket ─────────────────────────────────────────────────────────────────
25
- export function connectWS() {
26
- ctx.ws?.close();
25
+ // ── WebSocket (auto-reconnect for mobile radio / background) ─────────────────
26
+ let wsGen = 0;
27
+ let reconnectTimer = null;
28
+ let reconnectDelay = 1000;
29
+ let lastPongAt = 0;
30
+ let heartbeatTimer = null;
31
+ let lifecycleBound = false;
32
+ let sawDisconnect = false;
33
+ let lastWsMachine = null;
34
+ let reconnectQuiet = false;
35
+ /** Queued user text to send after current turn finishes (Grok used to block forever). */
36
+ let pendingUserText = null;
37
+
38
+ function bindSocketLifecycle() {
39
+ if (lifecycleBound) return;
40
+ lifecycleBound = true;
41
+ document.addEventListener('visibilitychange', () => {
42
+ if (document.visibilityState === 'visible') {
43
+ reconnectDelay = 400;
44
+ ensureChatSocket({ probe: true });
45
+ // Re-subscribe so server can re-attach stream / clear stuck busy state
46
+ if (ctx.ws?.readyState === WebSocket.OPEN && ctx.sessionId) {
47
+ try { ctx.ws.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId })); } catch { /* ignore */ }
48
+ }
49
+ }
50
+ });
51
+ window.addEventListener('online', () => {
52
+ reconnectDelay = 400;
53
+ ensureChatSocket({ force: true });
54
+ });
55
+ window.addEventListener('pageshow', (e) => {
56
+ if (e.persisted) ensureChatSocket({ force: true });
57
+ else ensureChatSocket({ probe: true });
58
+ });
59
+ }
60
+
61
+ function updateConnDot() {
62
+ const dot = $('conn-dot');
63
+ if (!dot) return;
64
+ const st = wsStatus.peek();
65
+ dot.classList.remove('bg-success', 'bg-warning', 'bg-error', 'bg-base-content/30', 'animate-pulse');
66
+ if (st === 'open') {
67
+ dot.classList.add('bg-success');
68
+ dot.title = '已连接';
69
+ } else if (st === 'connecting' || st === 'reconnecting') {
70
+ dot.classList.add('bg-warning', 'animate-pulse');
71
+ dot.title = st === 'connecting' ? '连接中…' : '重连中…';
72
+ } else if (st === 'offline') {
73
+ dot.classList.add('bg-error');
74
+ dot.title = '离线';
75
+ } else {
76
+ dot.classList.add('bg-base-content/30');
77
+ dot.title = '未连接';
78
+ }
79
+ }
80
+
81
+ function ensureChatSocket(opts = {}) {
82
+ const st = ctx.ws?.readyState;
83
+ if (opts.force) {
84
+ reconnectDelay = 400;
85
+ connectWS({ quiet: true });
86
+ return;
87
+ }
88
+ if (st === WebSocket.OPEN) {
89
+ if (opts.probe && lastPongAt && Date.now() - lastPongAt > 45_000) {
90
+ try { ctx.ws.close(); } catch { /* reconnect via close handler */ }
91
+ } else if (opts.probe && ctx.ws.readyState === WebSocket.OPEN) {
92
+ try { ctx.ws.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
93
+ }
94
+ return;
95
+ }
96
+ if (st === WebSocket.CONNECTING) return;
97
+ reconnectDelay = Math.min(reconnectDelay, 800);
98
+ connectWS({ quiet: true });
99
+ }
100
+
101
+ function scheduleReconnect() {
102
+ if (reconnectTimer) return;
103
+ wsStatus.value = (typeof navigator !== 'undefined' && navigator.onLine === false) ? 'offline' : 'reconnecting';
104
+ updateConnDot();
105
+ reconnectTimer = setTimeout(() => {
106
+ reconnectTimer = null;
107
+ connectWS({ quiet: true });
108
+ }, reconnectDelay);
109
+ reconnectDelay = Math.min(Math.round(reconnectDelay * 1.8), 15_000);
110
+ }
111
+
112
+ function stopHeartbeat() {
113
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
114
+ }
115
+
116
+ function startHeartbeat(sock, gen) {
117
+ stopHeartbeat();
118
+ // Server already pings ~35s. Client only watches for silence and pings
119
+ // when the tab is visible — avoids double traffic + radio wake on mobile.
120
+ heartbeatTimer = setInterval(() => {
121
+ if (gen !== wsGen || sock.readyState !== WebSocket.OPEN) return;
122
+ if (lastPongAt && Date.now() - lastPongAt > 70_000) {
123
+ try { sock.close(); } catch { /* close handler reconnects */ }
124
+ return;
125
+ }
126
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
127
+ // Light client probe only if server ping seems late (tunnel stall).
128
+ if (lastPongAt && Date.now() - lastPongAt > 40_000) {
129
+ try { sock.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
130
+ }
131
+ }, 30_000);
132
+ }
133
+
134
+ function syncWakeLock() {
135
+ // Keep screen on while a turn is running OR while a project chat is open and connected.
136
+ const need = isProcessing.peek()
137
+ || (!!currentProject.peek() && wsStatus.peek() === 'open');
138
+ setWakeLockDesired(need);
139
+ }
140
+
141
+ /** Manual reconnect (toolbar / banner). Resets backoff and forces a new socket. */
142
+ export function refreshConnection() {
143
+ reconnectDelay = 400;
144
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
145
+ reconnectQuiet = false;
146
+ const st = ctx.ws?.readyState;
147
+ if (st === WebSocket.OPEN || st === WebSocket.CONNECTING) {
148
+ try { ctx.ws?.close(); } catch { /* ignore */ }
149
+ }
150
+ connectWS({ quiet: false });
151
+ appendSystemMsg('正在重新连接并同步…');
152
+ }
153
+
154
+ export function connectWS(opts = {}) {
155
+ bindSocketLifecycle();
156
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
157
+ if (opts.quiet) reconnectQuiet = true;
158
+
159
+ const mid = ctx.machineId || (typeof localStorage !== 'undefined' ? localStorage.getItem('machineId') : null);
160
+ if (lastWsMachine !== mid) {
161
+ clearWsQueue();
162
+ lastWsMachine = mid;
163
+ }
164
+
165
+ const gen = ++wsGen;
166
+ try { ctx.ws?.close(); } catch { /* ignore */ }
167
+
168
+ wsStatus.value = 'connecting';
169
+ updateConnDot();
27
170
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
28
171
  const q = new URLSearchParams();
29
172
  if (ctx.token) q.set('token', ctx.token);
30
- // Hub mode: route chat WS to the selected edge
31
- const mid = ctx.machineId || (typeof localStorage !== 'undefined' ? localStorage.getItem('machineId') : null);
32
173
  if (mid) q.set('machine', mid);
33
- ctx.ws = new WebSocket(`${proto}://${location.host}/ws/chat?${q}`);
34
- ctx.ws.addEventListener('open', () => appendSystemMsg(mid ? `Connected · ${mid}` : 'Connected'));
35
- ctx.ws.addEventListener('message', e => handleWsMessage(JSON.parse(e.data)));
36
- ctx.ws.addEventListener('close', () => { if (isProcessing.peek()) { appendSystemMsg('Connection closed'); isProcessing.value = false; } });
37
- ctx.ws.addEventListener('error', () => { appendSystemMsg('WebSocket error'); isProcessing.value = false; });
174
+ if (ctx.sessionId) q.set('sessionId', ctx.sessionId);
175
+
176
+ const sock = new WebSocket(`${proto}://${location.host}/ws/chat?${q}`);
177
+ ctx.ws = sock;
178
+
179
+ sock.addEventListener('open', () => {
180
+ if (gen !== wsGen) return;
181
+ reconnectDelay = 1000;
182
+ lastPongAt = Date.now();
183
+ wsStatus.value = 'open';
184
+ updateConnDot();
185
+ if (ctx.sessionId) {
186
+ try { sock.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId })); } catch { /* ignore */ }
187
+ }
188
+ flushWsQueue();
189
+ startHeartbeat(sock, gen);
190
+ syncWakeLock();
191
+ if (sawDisconnect && !reconnectQuiet) {
192
+ appendSystemMsg(mid ? `已重连 · ${mid}` : '已重连');
193
+ }
194
+ sawDisconnect = false;
195
+ reconnectQuiet = false;
196
+ });
197
+
198
+ sock.addEventListener('message', e => {
199
+ if (gen !== wsGen) return;
200
+ let msg;
201
+ try { msg = JSON.parse(e.data); } catch { return; }
202
+ if (msg.type === 'pong') { lastPongAt = Date.now(); return; }
203
+ if (msg.type === 'ping') {
204
+ lastPongAt = Date.now();
205
+ if (sock.readyState === WebSocket.OPEN) {
206
+ try { sock.send(JSON.stringify({ type: 'pong' })); } catch { /* ignore */ }
207
+ }
208
+ return;
209
+ }
210
+ if (msg.type === 'run-status') {
211
+ // Only adopt "running" from server; never re-lock after we already finished a turn
212
+ // unless the server still has a busy run (reconnect mid-stream).
213
+ if (msg.running) {
214
+ isProcessing.value = true;
215
+ } else if (isProcessing.peek()) {
216
+ // Server says idle — unlock (fixes Grok lingering process / missed complete)
217
+ isProcessing.value = false;
218
+ flushPendingUserText();
219
+ }
220
+ syncWakeLock();
221
+ return;
222
+ }
223
+ handleWsMessage(msg);
224
+ });
225
+
226
+ sock.addEventListener('close', () => {
227
+ if (gen !== wsGen) return;
228
+ stopHeartbeat();
229
+ sawDisconnect = true;
230
+ wsStatus.value = (typeof navigator !== 'undefined' && navigator.onLine === false) ? 'offline' : 'reconnecting';
231
+ updateConnDot();
232
+ scheduleReconnect();
233
+ });
234
+
235
+ sock.addEventListener('error', () => {
236
+ // close follows; avoid flipping isProcessing so an in-flight run can resume
237
+ });
238
+ }
239
+
240
+ function flushPendingUserText() {
241
+ if (!pendingUserText || isProcessing.peek()) return;
242
+ const text = pendingUserText;
243
+ pendingUserText = null;
244
+ const input = $('chat-input');
245
+ if (input) {
246
+ input.value = text;
247
+ autoResize(input);
248
+ }
249
+ // Defer so isProcessing watch can re-enable send button first
250
+ setTimeout(() => sendMessage(), 0);
251
+ }
252
+
253
+ function markTurnDone() {
254
+ isProcessing.value = false;
255
+ syncWakeLock();
256
+ flushPendingUserText();
38
257
  }
39
258
 
40
259
  function handleWsMessage(msg) {
260
+ // Server outbox may pack multiple events into one frame
261
+ if (msg.type === 'batch' && Array.isArray(msg.items)) {
262
+ for (const item of msg.items) handleWsMessage(item);
263
+ return;
264
+ }
41
265
  if (msg.type === 'session-created') {
42
266
  ctx.sessionId = msg.sessionId;
43
267
  if (msg.agent) setAgent(msg.agent);
@@ -49,7 +273,6 @@ function handleWsMessage(msg) {
49
273
  finalizeThoughtStream();
50
274
  finalizeAssistantStream();
51
275
  flushToolBatch();
52
- isProcessing.value = false;
53
276
  const u = msg.usage;
54
277
  if (u) {
55
278
  const norm = {
@@ -66,24 +289,27 @@ function handleWsMessage(msg) {
66
289
  appendHistoryTokenBar(norm);
67
290
  }
68
291
  if (!msg.is_error) appendSystemMsg('✓ Done');
292
+ // Unlock immediately so the next Grok turn can be typed/sent without waiting process exit.
293
+ markTurnDone();
69
294
  return;
70
295
  }
71
296
  if (msg.type === 'complete') {
72
297
  finalizeThoughtStream();
73
298
  finalizeAssistantStream();
74
299
  flushToolBatch();
75
- isProcessing.value = false;
76
300
  // result already printed "Done" for codex/grok; avoid double for those
77
301
  if (!msg.aborted && msg.agent === 'claude') appendSystemMsg('✓ Done');
78
302
  else if (!msg.aborted && msg.agent && msg.agent !== 'claude' && !msg.error) { /* result handled it */ }
79
303
  else if (!msg.aborted && !msg.agent) appendSystemMsg('✓ Done');
304
+ else if (msg.aborted) appendSystemMsg('已停止');
305
+ markTurnDone();
80
306
  return;
81
307
  }
82
308
  if (msg.type === 'error') {
83
309
  finalizeThoughtStream();
84
310
  finalizeAssistantStream();
85
311
  appendMsg('error', 'Error', msg.message);
86
- isProcessing.value = false;
312
+ markTurnDone();
87
313
  return;
88
314
  }
89
315
  if (msg.type === 'shell-start') { ctx.shellBubble = null; return; }
@@ -95,7 +321,7 @@ function handleWsMessage(msg) {
95
321
  if (msg.type === 'shell-exit') {
96
322
  ctx.shellBubble = null;
97
323
  appendSystemMsg(msg.code === 0 ? '✓ Exit 0' : `✗ Exit ${msg.code}${msg.error ? ': ' + msg.error : ''}`);
98
- isProcessing.value = false;
324
+ markTurnDone();
99
325
  return;
100
326
  }
101
327
  if (msg.type === 'assistant') {
@@ -152,6 +378,11 @@ function handleWsMessage(msg) {
152
378
  return;
153
379
  }
154
380
 
381
+ if (msg.type === 'permission_resolved') {
382
+ const id = msg.requestId || msg.request_id;
383
+ if (id) $(`perm-${id}`)?.remove();
384
+ return;
385
+ }
155
386
  if (msg.type === 'permission_request' || (msg.request_id && msg.tool_name))
156
387
  renderPermissionRequest(msg);
157
388
  }
@@ -467,7 +698,18 @@ async function showUsageCommand() {
467
698
  export function sendMessage() {
468
699
  const input = $('chat-input');
469
700
  const text = input?.value.trim();
470
- if (!text || isProcessing.peek() || !currentProject.peek()) return;
701
+ if (!text || !currentProject.peek()) return;
702
+
703
+ // While a turn is running, queue the next message (common Grok complaint).
704
+ if (isProcessing.peek()) {
705
+ pendingUserText = text;
706
+ input.value = '';
707
+ autoResize(input);
708
+ updateInputMode('');
709
+ appendSystemMsg(`已排队下一条,当前回复结束后发送:${text.length > 80 ? text.slice(0, 80) + '…' : text}`);
710
+ return;
711
+ }
712
+
471
713
  input.value = '';
472
714
  autoResize(input);
473
715
  updateInputMode('');
@@ -479,32 +721,51 @@ export function sendMessage() {
479
721
  }
480
722
 
481
723
  isProcessing.value = true;
724
+ syncWakeLock();
482
725
  const now = Date.now();
483
726
  if (text.startsWith('!')) {
484
727
  appendMsg('shell', '$ Shell', text.slice(1).trim(), { ts: now });
485
- sendWs({ type: 'shell-command', command: text.slice(1).trim(), cwd: currentProject.peek().path });
728
+ // Typed control plane (legacy shell-command still accepted server-side)
729
+ const ok = sendWs({
730
+ type: 'command',
731
+ cmd: 'shell.exec',
732
+ command: text.slice(1).trim(),
733
+ cwd: currentProject.peek().path,
734
+ });
735
+ if (!ok) appendSystemMsg('连接未就绪,命令已入队,重连后发送');
486
736
  } else {
487
737
  flushToolBatch();
488
738
  appendMsg('user', 'You', text, { ts: now });
489
739
  const effort = currentEffort.peek();
490
740
  const permission = currentPermission.peek();
491
741
  const agent = currentAgent.peek() || 'claude';
492
- sendWs({ type: 'agent-command', agent, command: text, options: {
742
+ const ok = sendWs({
743
+ type: 'command',
744
+ cmd: 'turn.start',
493
745
  agent,
494
- cwd: currentProject.peek().path,
746
+ command: text,
495
747
  sessionId: ctx.sessionId,
496
- configDir: ctx.configDir,
497
- model: currentModel.peek(),
498
- ...(effort && { effort }),
499
- ...(permission && permission !== 'default' && { permissionMode: permission }),
500
- ...(permission === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
501
- }});
748
+ options: {
749
+ agent,
750
+ cwd: currentProject.peek().path,
751
+ sessionId: ctx.sessionId,
752
+ configDir: ctx.configDir,
753
+ model: currentModel.peek(),
754
+ ...(effort && { effort }),
755
+ ...(permission && permission !== 'default' && { permissionMode: permission }),
756
+ ...(permission === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
757
+ },
758
+ });
759
+ if (!ok) appendSystemMsg('连接未就绪,消息已入队,重连后发送');
502
760
  }
503
761
  }
504
762
 
505
763
  export function stopProcessing() {
506
- if (ctx.sessionId) sendWs({ type: 'abort-session', sessionId: ctx.sessionId });
507
- isProcessing.value = false;
764
+ if (ctx.sessionId) {
765
+ sendWs({ type: 'command', cmd: 'turn.interrupt', sessionId: ctx.sessionId });
766
+ }
767
+ pendingUserText = null;
768
+ markTurnDone();
508
769
  }
509
770
 
510
771
  export function newSession() {
@@ -977,13 +1238,52 @@ function highlightCode(root) {
977
1238
  // ── Init (called after HTML is in DOM) ────────────────────────────────────────
978
1239
  export function initChat() {
979
1240
  applyChatDensity();
1241
+ initWakeLock();
1242
+
1243
+ watch(wsStatus, st => {
1244
+ updateConnDot();
1245
+ syncWakeLock();
1246
+ const el = $('ws-banner');
1247
+ const text = $('ws-banner-text');
1248
+ if (!el) return;
1249
+ if (st === 'open' || st === 'idle') {
1250
+ el.classList.add('hidden');
1251
+ if (text) text.textContent = '';
1252
+ return;
1253
+ }
1254
+ // Show banner for reconnecting/offline; brief connecting after a drop
1255
+ if (st === 'connecting' && !sawDisconnect) {
1256
+ el.classList.add('hidden');
1257
+ return;
1258
+ }
1259
+ el.classList.remove('hidden');
1260
+ const msg = st === 'offline'
1261
+ ? '网络已断开,恢复后会自动重连'
1262
+ : st === 'connecting'
1263
+ ? '正在连接…'
1264
+ : '连接中断,正在重连…(熄屏或切网时常见)';
1265
+ if (text) text.textContent = msg;
1266
+ else el.textContent = msg;
1267
+ });
1268
+
1269
+ watch(currentProject, () => syncWakeLock());
1270
+
1271
+ delegate.on('click', '#btn-refresh-conn, #ws-banner-retry', e => {
1272
+ e.preventDefault();
1273
+ refreshConnection();
1274
+ });
980
1275
 
981
1276
  // Processing state → send/stop buttons + typing indicator
1277
+ // Keep the input enabled so the next message can be composed (and queued).
982
1278
  watch(isProcessing, val => {
983
1279
  $('send-btn')?.classList.toggle('hidden', val);
984
1280
  $('stop-btn')?.classList.toggle('hidden', !val);
985
1281
  const input = $('chat-input');
986
- if (input) input.disabled = val;
1282
+ if (input) {
1283
+ input.disabled = false;
1284
+ if (val) input.placeholder = '回复中… 可先写好下一条,结束后自动发送 · 或点 ■ 停止';
1285
+ else updateInputMode(input.value || '');
1286
+ }
987
1287
  const msgs = $('messages');
988
1288
  if (!msgs) return;
989
1289
  const existing = $('typing-indicator');
@@ -995,6 +1295,7 @@ export function initChat() {
995
1295
  existing?.remove();
996
1296
  flushToolBatch();
997
1297
  }
1298
+ syncWakeLock();
998
1299
  });
999
1300
 
1000
1301
  watch(chatDensity, () => applyChatDensity());
@@ -1006,9 +1307,15 @@ export function initChat() {
1006
1307
  });
1007
1308
  delegate.on('input', '#chat-input', (e, el) => { autoResize(el); updateInputMode(el.value); });
1008
1309
 
1009
- // Permission buttons
1310
+ // Permission buttons → approval.respond (typed control plane)
1010
1311
  delegate.on('click', '[data-perm-id]', (e, el) => {
1011
- sendWs({ type: 'claude-permission-response', requestId: el.dataset.permId, allow: el.dataset.permAllow === '1' });
1312
+ sendWs({
1313
+ type: 'command',
1314
+ cmd: 'approval.respond',
1315
+ requestId: el.dataset.permId,
1316
+ allow: el.dataset.permAllow === '1',
1317
+ sessionId: ctx.sessionId,
1318
+ });
1012
1319
  el.closest('.perm-banner')?.remove();
1013
1320
  });
1014
1321