claude-simple 0.2.8 → 0.2.9

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/dist/index.html CHANGED
@@ -4,13 +4,13 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
6
6
  <title>Claude Code</title>
7
- <link rel="stylesheet" href="/style.0.2.8.min.css">
7
+ <link rel="stylesheet" href="/style.0.2.9.min.css">
8
8
  <link rel="stylesheet" href="/highlight.min.css">
9
9
  <script src="/marked.min.js"></script>
10
10
  <script src="/highlight.min.js"></script>
11
11
  </head>
12
12
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
13
13
  <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
14
- <script type="module" src="/app.0.2.8.min.js"></script>
14
+ <script type="module" src="/app.0.2.9.min.js"></script>
15
15
  </body>
16
16
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
package/public/chat.js CHANGED
@@ -2,7 +2,7 @@
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, setCurrentModel, chatDensity, wsStatus } from './state.js';
5
+ getDefaultEffort, addCustomModel, setCurrentModel, chatDensity, wsStatus } from './state.js';
6
6
  import { sendWs, api, authHeaders, flushWsQueue, clearWsQueue } from './api.js';
7
7
  import { initWakeLock, setWakeLockDesired } from './wake-lock.js';
8
8
  import { pushNotification } from './notifications.js';
@@ -773,16 +773,17 @@ export function sendMessage() {
773
773
 
774
774
  // While a turn is running, queue the next message (common Grok complaint).
775
775
  if (isProcessing.peek()) {
776
- // Grok owns its queue on the edge through a long-lived ACP process. Send
777
- // immediately so a page reload cannot discard this follow-up in the tab.
776
+ // Send immediately so a page reload cannot discard this follow-up in the
777
+ // tab. In hub mode the hub serializes it; standalone uses the edge queue.
778
778
  if (currentAgent.peek() === 'grok' && ctx.sessionId) {
779
779
  const agent = 'grok';
780
+ const effort = currentEffort.peek() || getDefaultEffort(agent, currentModel.peek()) || 'low';
780
781
  const ok = sendWs({
781
782
  type: 'command', cmd: 'turn.start', agent, command: text, sessionId: ctx.sessionId,
782
783
  options: {
783
784
  agent, cwd: currentProject.peek().path, sessionId: ctx.sessionId,
784
785
  configDir: ctx.configDir, model: currentModel.peek(),
785
- ...(currentEffort.peek() && { effort: currentEffort.peek() }),
786
+ effort,
786
787
  ...(currentPermission.peek() && { permissionMode: currentPermission.peek() }),
787
788
  ...(currentPermission.peek() === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
788
789
  },
@@ -831,9 +832,11 @@ export function sendMessage() {
831
832
  } else {
832
833
  flushToolBatch();
833
834
  appendMsg('user', 'You', text, { ts: now });
834
- const effort = currentEffort.peek();
835
- const permission = currentPermission.peek();
836
835
  const agent = currentAgent.peek() || 'claude';
836
+ const effort = currentEffort.peek()
837
+ || getDefaultEffort(agent, currentModel.peek())
838
+ || (agent === 'grok' ? 'low' : '');
839
+ const permission = currentPermission.peek();
837
840
  // Existing sessions retain their own picker value. New sessions are bound
838
841
  // on the session-created event above.
839
842
  if (ctx.sessionId) setCurrentModel(currentModel.peek(), agent, ctx.sessionId);
@@ -7,7 +7,7 @@ import {
7
7
  sidebarView, agentFilter, timeRange, activityHits, activityLoading,
8
8
  sessionMetaMap, LOW_TURN_THRESHOLD, metaKey, sessionDisplayTitle,
9
9
  hubMode, selectedMachineId, agentsMeta, currentEffort,
10
- getModelsForAgent, getDefaultModel, getEffortsForModel,
10
+ getModelsForAgent, getDefaultModel, getEffortsForModel, getDefaultEffort,
11
11
  addCustomModel, removeCustomModel, getCustomModels, setCurrentModel, ctx,
12
12
  } from '../state.js';
13
13
  import { api, NOT_MODIFIED } from '../api.js';
@@ -174,15 +174,28 @@ export function refreshEffortSelect() {
174
174
  const agent = currentAgent.peek() || 'claude';
175
175
  const model = currentModel.peek();
176
176
  const efforts = getEffortsForModel(agent, model);
177
- const cur = currentEffort.peek() || '';
177
+ let cur = currentEffort.peek() || '';
178
+ if (!cur) {
179
+ const def = getDefaultEffort(agent, model);
180
+ if (def && efforts.includes(def)) {
181
+ cur = def;
182
+ currentEffort.value = def;
183
+ }
184
+ }
178
185
  const opts = [`<option value="">effort: off</option>`]
179
186
  .concat(efforts.map(e =>
180
187
  `<option value="${esc(e)}" ${e === cur ? 'selected' : ''}>${esc(e)}</option>`
181
188
  ));
182
189
  sel.innerHTML = opts.join('');
183
190
  if (cur && !efforts.includes(cur)) {
184
- currentEffort.value = '';
185
- sel.value = '';
191
+ const def = getDefaultEffort(agent, model);
192
+ if (def && efforts.includes(def)) {
193
+ currentEffort.value = def;
194
+ sel.value = def;
195
+ } else {
196
+ currentEffort.value = '';
197
+ sel.value = '';
198
+ }
186
199
  } else {
187
200
  sel.value = cur;
188
201
  }
package/public/state.js CHANGED
@@ -285,6 +285,18 @@ export function getEffortsForModel(agent, modelId) {
285
285
  return ['low', 'medium', 'high', 'xhigh', 'max'];
286
286
  }
287
287
 
288
+ /** Default reasoning effort for agent/model (Grok → low). */
289
+ export function getDefaultEffort(agent, modelId) {
290
+ const a = (agent || 'claude').toLowerCase();
291
+ const meta = agentsMeta.peek()?.defaults?.[a];
292
+ if (meta?.effortDefault) return String(meta.effortDefault);
293
+ const models = getModelsForAgent(a);
294
+ const m = models.find(x => x.value === modelId);
295
+ if (m?.defaultEffort) return String(m.defaultEffort);
296
+ if (a === 'grok') return 'low';
297
+ return '';
298
+ }
299
+
288
300
  export function setAgent(agent) {
289
301
  const a = (agent || 'claude').toLowerCase();
290
302
  currentAgent.value = a;
@@ -300,6 +312,9 @@ export function setAgent(agent) {
300
312
  } else {
301
313
  setCurrentModel(getDefaultModel(a), a);
302
314
  }
315
+ const defEffort = getDefaultEffort(a, currentModel.peek());
316
+ if (defEffort) currentEffort.value = defEffort;
317
+ else if (a !== 'grok') currentEffort.value = '';
303
318
  }
304
319
 
305
320
  function matchesAgentFilter(s, filter) {
@@ -1,10 +1,11 @@
1
1
  import { spawn } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
2
4
  import readline from 'readline';
3
5
  import crypto from 'crypto';
4
6
 
5
- // Grok's ACP transport is a long-lived JSON-RPC process. Keeping it alive is
6
- // important: it owns the real Grok session and lets the edge, rather than an
7
- // individual browser tab, serialize follow-up prompts.
7
+ // Persistent ACP (JSON-RPC over stdio). Advertise terminal+fs so Grok routes
8
+ // shell/file tools through client RPCs instead of its own broken terminal path.
8
9
  const clients = new Map();
9
10
  const queues = new Map();
10
11
  const queueAliases = new Map();
@@ -13,6 +14,147 @@ function profileKey(options) {
13
14
  return `${options.model || ''}\u001f${options.effort || ''}`;
14
15
  }
15
16
 
17
+ function envFromParams(envList) {
18
+ const env = { ...process.env };
19
+ if (!Array.isArray(envList)) return env;
20
+ for (const item of envList) {
21
+ if (!item || typeof item.name !== 'string') continue;
22
+ env[item.name] = item.value == null ? '' : String(item.value);
23
+ }
24
+ return env;
25
+ }
26
+
27
+ class TerminalHost {
28
+ constructor() {
29
+ this.terminals = new Map();
30
+ }
31
+
32
+ create(params = {}) {
33
+ const terminalId = `term_${crypto.randomUUID()}`;
34
+ const command = String(params.command || '');
35
+ const args = Array.isArray(params.args) ? params.args.map(String) : [];
36
+ const cwd = params.cwd || process.cwd();
37
+ const limit = Number.isFinite(params.outputByteLimit) && params.outputByteLimit > 0
38
+ ? params.outputByteLimit
39
+ : 1024 * 1024;
40
+ const env = envFromParams(params.env);
41
+
42
+ let child;
43
+ try {
44
+ child = spawn(command, args, {
45
+ cwd,
46
+ env,
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ shell: args.length === 0,
49
+ });
50
+ } catch (error) {
51
+ const err = new Error(error.message || 'Failed to start terminal');
52
+ err.code = -32000;
53
+ throw err;
54
+ }
55
+
56
+ const entry = {
57
+ id: terminalId,
58
+ child,
59
+ output: '',
60
+ truncated: false,
61
+ limit,
62
+ exitCode: null,
63
+ signal: null,
64
+ exited: false,
65
+ waiters: [],
66
+ };
67
+
68
+ const append = chunk => {
69
+ entry.output += chunk;
70
+ if (Buffer.byteLength(entry.output, 'utf8') > entry.limit) {
71
+ // Truncate from the start at a UTF-8-safe boundary.
72
+ let buf = Buffer.from(entry.output, 'utf8');
73
+ buf = buf.subarray(buf.length - entry.limit);
74
+ while (buf.length && (buf[0] & 0xc0) === 0x80) buf = buf.subarray(1);
75
+ entry.output = buf.toString('utf8');
76
+ entry.truncated = true;
77
+ }
78
+ };
79
+
80
+ child.stdout?.setEncoding('utf8');
81
+ child.stderr?.setEncoding('utf8');
82
+ child.stdout?.on('data', append);
83
+ child.stderr?.on('data', append);
84
+ child.on('error', err => {
85
+ append(`\n[terminal error] ${err.message}\n`);
86
+ if (!entry.exited) {
87
+ entry.exited = true;
88
+ entry.exitCode = 1;
89
+ entry.signal = null;
90
+ for (const resolve of entry.waiters) resolve({ exitCode: 1, signal: null });
91
+ entry.waiters = [];
92
+ }
93
+ });
94
+ child.on('close', (code, signal) => {
95
+ entry.exited = true;
96
+ entry.exitCode = code;
97
+ entry.signal = signal || null;
98
+ for (const resolve of entry.waiters) resolve({ exitCode: code, signal: signal || null });
99
+ entry.waiters = [];
100
+ });
101
+
102
+ this.terminals.set(terminalId, entry);
103
+ return { terminalId };
104
+ }
105
+
106
+ get(terminalId) {
107
+ const entry = this.terminals.get(terminalId);
108
+ if (!entry) {
109
+ const err = new Error(`Unknown terminal: ${terminalId}`);
110
+ err.code = -32000;
111
+ throw err;
112
+ }
113
+ return entry;
114
+ }
115
+
116
+ output(terminalId) {
117
+ const entry = this.get(terminalId);
118
+ const result = {
119
+ output: entry.output,
120
+ truncated: entry.truncated,
121
+ };
122
+ if (entry.exited) {
123
+ result.exitStatus = { exitCode: entry.exitCode, signal: entry.signal };
124
+ }
125
+ return result;
126
+ }
127
+
128
+ waitForExit(terminalId) {
129
+ const entry = this.get(terminalId);
130
+ if (entry.exited) return Promise.resolve({ exitCode: entry.exitCode, signal: entry.signal });
131
+ return new Promise(resolve => entry.waiters.push(resolve));
132
+ }
133
+
134
+ kill(terminalId) {
135
+ const entry = this.get(terminalId);
136
+ if (!entry.exited && entry.child?.pid) {
137
+ try { entry.child.kill('SIGTERM'); } catch { /* already gone */ }
138
+ }
139
+ return {};
140
+ }
141
+
142
+ release(terminalId) {
143
+ const entry = this.terminals.get(terminalId);
144
+ if (!entry) return {};
145
+ if (!entry.exited && entry.child?.pid) {
146
+ try { entry.child.kill('SIGTERM'); } catch { /* already gone */ }
147
+ setTimeout(() => {
148
+ if (!entry.exited && entry.child?.pid) {
149
+ try { entry.child.kill('SIGKILL'); } catch { /* already gone */ }
150
+ }
151
+ }, 800).unref?.();
152
+ }
153
+ this.terminals.delete(terminalId);
154
+ return {};
155
+ }
156
+ }
157
+
16
158
  class GrokACPClient {
17
159
  constructor({ cliPath, model, effort }) {
18
160
  const args = ['agent', '--always-approve'];
@@ -22,13 +164,17 @@ class GrokACPClient {
22
164
  this.child = spawn(cliPath || 'grok', args, { stdio: ['pipe', 'pipe', 'pipe'] });
23
165
  this.pending = new Map();
24
166
  this.listeners = new Map();
25
- this.nextID = 1;
167
+ // High ids avoid colliding with agent-originated request ids (often 0..N).
168
+ this.nextID = 100000;
169
+ this.terminals = new TerminalHost();
26
170
  this.ready = this.initialize();
27
171
  this.child.stderr.on('data', d => console.warn(`[grok acp] ${String(d).trim()}`));
28
172
  this.child.on('close', () => {
29
173
  for (const { reject } of this.pending.values()) reject(new Error('Grok ACP process stopped'));
30
174
  this.pending.clear();
31
- for (const fn of this.listeners.values()) fn({ type: 'error', message: 'Grok ACP process stopped', agent: 'grok' });
175
+ for (const fn of this.listeners.values()) {
176
+ fn({ type: 'error', message: 'Grok ACP process stopped', agent: 'grok' });
177
+ }
32
178
  this.listeners.clear();
33
179
  });
34
180
  const rl = readline.createInterface({ input: this.child.stdout, crlfDelay: Infinity });
@@ -38,19 +184,82 @@ class GrokACPClient {
38
184
  async initialize() {
39
185
  const init = await this.request('initialize', {
40
186
  protocolVersion: 1,
41
- clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true },
42
- clientInfo: { name: 'claude-simple-ui', version: '0.2.7' },
187
+ clientCapabilities: {
188
+ fs: { readTextFile: true, writeTextFile: true },
189
+ terminal: true,
190
+ },
191
+ clientInfo: { name: 'claude-simple-ui', version: '0.2.9' },
43
192
  });
44
193
  const methods = new Set((init.authMethods || []).map(x => x?.id));
45
194
  const methodId = process.env.XAI_API_KEY && methods.has('xai.api_key')
46
195
  ? 'xai.api_key'
47
196
  : (methods.has('cached_token') ? 'cached_token' : null);
48
197
  if (methodId) await this.request('authenticate', { methodId, _meta: { headless: true } });
198
+ return init;
199
+ }
200
+
201
+ async handleClientRequest(msg) {
202
+ const method = msg.method;
203
+ const params = msg.params || {};
204
+ try {
205
+ let result = {};
206
+ if (method === 'fs/read_text_file') {
207
+ const filePath = String(params.path || '');
208
+ const content = await fs.promises.readFile(filePath, 'utf8');
209
+ const limit = Number.isFinite(params.limit) ? params.limit : null;
210
+ const line = Number.isFinite(params.line) ? params.line : null;
211
+ if (line != null || limit != null) {
212
+ const lines = content.split('\n');
213
+ const start = Math.max(0, (line || 1) - 1);
214
+ const end = limit != null ? start + limit : lines.length;
215
+ result = { content: lines.slice(start, end).join('\n') };
216
+ } else {
217
+ result = { content };
218
+ }
219
+ } else if (method === 'fs/write_text_file') {
220
+ const filePath = String(params.path || '');
221
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
222
+ await fs.promises.writeFile(filePath, params.content ?? '', 'utf8');
223
+ result = {};
224
+ } else if (method === 'terminal/create') {
225
+ result = this.terminals.create(params);
226
+ } else if (method === 'terminal/output') {
227
+ result = this.terminals.output(params.terminalId);
228
+ } else if (method === 'terminal/wait_for_exit') {
229
+ result = await this.terminals.waitForExit(params.terminalId);
230
+ } else if (method === 'terminal/kill') {
231
+ result = this.terminals.kill(params.terminalId);
232
+ } else if (method === 'terminal/release') {
233
+ result = this.terminals.release(params.terminalId);
234
+ } else {
235
+ this.write({
236
+ jsonrpc: '2.0',
237
+ id: msg.id,
238
+ error: { code: -32601, message: `Unsupported client method: ${method}` },
239
+ });
240
+ return;
241
+ }
242
+ this.write({ jsonrpc: '2.0', id: msg.id, result });
243
+ } catch (error) {
244
+ this.write({
245
+ jsonrpc: '2.0',
246
+ id: msg.id,
247
+ error: { code: error.code || -32000, message: error.message || 'Client request failed' },
248
+ });
249
+ }
49
250
  }
50
251
 
51
252
  receive(line) {
52
253
  let msg;
53
254
  try { msg = JSON.parse(line); } catch { return; }
255
+
256
+ // Agent→client requests always carry a method. Check before pending so
257
+ // overlapping numeric ids cannot steal a client RPC as our response.
258
+ if (msg.id != null && msg.method) {
259
+ void this.handleClientRequest(msg);
260
+ return;
261
+ }
262
+
54
263
  if (msg.id != null && this.pending.has(msg.id)) {
55
264
  const pending = this.pending.get(msg.id);
56
265
  this.pending.delete(msg.id);
@@ -58,13 +267,7 @@ class GrokACPClient {
58
267
  else pending.resolve(msg.result || {});
59
268
  return;
60
269
  }
61
- // Grok's documented ACP client sample does not need client-side tool RPC.
62
- // Return a structured error for optional extension calls instead of leaving
63
- // an agent request hanging forever.
64
- if (msg.id != null && msg.method) {
65
- this.write({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Unsupported client method: ${msg.method}` } });
66
- return;
67
- }
270
+
68
271
  if (msg.method === 'session/update') {
69
272
  const sid = msg.params?.sessionId;
70
273
  const fn = sid && this.listeners.get(sid);
@@ -109,37 +312,68 @@ class GrokACPClient {
109
312
  function clientFor(options, cliPath) {
110
313
  const key = profileKey(options);
111
314
  let client = clients.get(key);
112
- if (!client || client.child.exitCode != null) {
315
+ if (!client || client.child.exitCode != null || client.child.signalCode != null) {
113
316
  client = new GrokACPClient({ cliPath, model: options.model, effort: options.effort });
114
317
  clients.set(key, client);
115
318
  }
116
319
  return client;
117
320
  }
118
321
 
119
- function emitUpdate(update, send, sessionId, state) {
322
+ function emitUpdate(update, send, state) {
120
323
  const kind = update?.sessionUpdate;
121
324
  if (kind === 'agent_message_chunk' && update.content?.text) {
122
- if (!state.textOpen) { send({ type: 'assistant_stream_start', agent: 'grok', ts: Date.now() }); state.textOpen = true; }
325
+ if (state.thoughtOpen) {
326
+ send({ type: 'thought_stream_end', agent: 'grok' });
327
+ state.thoughtOpen = false;
328
+ }
329
+ if (!state.textOpen) {
330
+ send({ type: 'assistant_stream_start', agent: 'grok', ts: Date.now() });
331
+ state.textOpen = true;
332
+ }
123
333
  send({ type: 'assistant_delta', data: update.content.text, agent: 'grok' });
124
334
  } else if (kind === 'agent_thought_chunk' && update.content?.text) {
125
- if (!state.thoughtOpen) { send({ type: 'thought_stream_start', agent: 'grok' }); state.thoughtOpen = true; }
335
+ if (!state.thoughtOpen) {
336
+ send({ type: 'thought_stream_start', agent: 'grok' });
337
+ state.thoughtOpen = true;
338
+ }
126
339
  send({ type: 'thought_delta', data: update.content.text, agent: 'grok' });
127
340
  } else if (kind === 'tool_call' || kind === 'tool_call_update') {
128
- if (state.textOpen) { send({ type: 'assistant_stream_end', agent: 'grok' }); state.textOpen = false; }
129
- if (state.thoughtOpen) { send({ type: 'thought_stream_end', agent: 'grok' }); state.thoughtOpen = false; }
341
+ if (state.textOpen) {
342
+ send({ type: 'assistant_stream_end', agent: 'grok' });
343
+ state.textOpen = false;
344
+ }
345
+ if (state.thoughtOpen) {
346
+ send({ type: 'thought_stream_end', agent: 'grok' });
347
+ state.thoughtOpen = false;
348
+ }
130
349
  if (kind === 'tool_call') {
131
- send({ type: 'assistant', agent: 'grok', message: { content: [{
132
- type: 'tool_use', name: update.title || update.kind || 'tool', input: update.rawInput || update.input || {},
133
- }] } });
350
+ send({
351
+ type: 'assistant',
352
+ agent: 'grok',
353
+ message: {
354
+ content: [{
355
+ type: 'tool_use',
356
+ name: update.title || update.kind || 'tool',
357
+ input: update.rawInput || update.input || {},
358
+ }],
359
+ },
360
+ });
134
361
  }
135
362
  }
136
363
  }
137
364
 
138
365
  async function runOne(item, { cliPath, activeSessions, queueKey }) {
139
366
  const { command, options, send } = item;
140
- const client = clientFor(options, cliPath);
367
+ const effort = options.effort || 'low';
368
+ const client = clientFor({ ...options, effort }, cliPath);
141
369
  const localKey = options.sessionId || crypto.randomUUID();
142
- const entry = { kind: 'grok-acp', startTime: Date.now(), busy: true, key: localKey, sessionId: options.sessionId || null };
370
+ const entry = {
371
+ kind: 'grok-acp',
372
+ startTime: Date.now(),
373
+ busy: true,
374
+ key: localKey,
375
+ sessionId: options.sessionId || null,
376
+ };
143
377
  activeSessions.set(localKey, entry);
144
378
  const state = { textOpen: false, thoughtOpen: false };
145
379
  let sessionId = options.sessionId;
@@ -148,9 +382,14 @@ async function runOne(item, { cliPath, activeSessions, queueKey }) {
148
382
  queueAliases.set(sessionId, queueKey);
149
383
  entry.sessionId = sessionId;
150
384
  activeSessions.set(sessionId, entry);
151
- entry.abort = () => { client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } }); };
385
+ entry.abort = () => {
386
+ try {
387
+ client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } });
388
+ } catch { /* process may already be gone */ }
389
+ };
152
390
  if (!options.sessionId) send({ type: 'session-created', sessionId, agent: 'grok' });
153
- client.listeners.set(sessionId, update => emitUpdate(update, send, sessionId, state));
391
+ client.listeners.set(sessionId, update => emitUpdate(update, send, state));
392
+ send({ type: 'status', message: 'Grok ACP running…', agent: 'grok', sessionId });
154
393
  const result = await client.request('session/prompt', {
155
394
  sessionId,
156
395
  prompt: [{ type: 'text', text: command }],
@@ -158,10 +397,24 @@ async function runOne(item, { cliPath, activeSessions, queueKey }) {
158
397
  if (state.textOpen) send({ type: 'assistant_stream_end', agent: 'grok' });
159
398
  if (state.thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
160
399
  entry.busy = false;
161
- send({ type: 'result', is_error: result.stopReason === 'error', usage: result._meta?.usage || null, sessionId, agent: 'grok' });
162
- send({ type: 'complete', exitCode: result.stopReason === 'cancelled' ? 130 : 0, aborted: result.stopReason === 'cancelled', sessionId, agent: 'grok' });
400
+ send({
401
+ type: 'result',
402
+ is_error: result.stopReason === 'error',
403
+ usage: result._meta?.usage || null,
404
+ sessionId,
405
+ agent: 'grok',
406
+ });
407
+ send({
408
+ type: 'complete',
409
+ exitCode: result.stopReason === 'cancelled' ? 130 : 0,
410
+ aborted: result.stopReason === 'cancelled',
411
+ sessionId,
412
+ agent: 'grok',
413
+ });
163
414
  } catch (e) {
164
415
  entry.busy = false;
416
+ if (state.textOpen) send({ type: 'assistant_stream_end', agent: 'grok' });
417
+ if (state.thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
165
418
  send({ type: 'error', message: e.message, sessionId, agent: 'grok' });
166
419
  send({ type: 'complete', exitCode: 1, sessionId, agent: 'grok' });
167
420
  } finally {
@@ -171,19 +424,26 @@ async function runOne(item, { cliPath, activeSessions, queueKey }) {
171
424
  }
172
425
  }
173
426
 
174
- /** Queue all Grok follow-ups at the edge. The promise resolves after this item runs. */
427
+ /** Queue all Grok follow-ups at the edge. Promise resolves after this item runs. */
175
428
  export function runGrok(command, options, send, { cliPath, activeSessions }) {
176
- const key = (options.sessionId && queueAliases.get(options.sessionId)) || options.sessionId || `new:${options.cwd || ''}`;
429
+ const key = (options.sessionId && queueAliases.get(options.sessionId))
430
+ || options.sessionId
431
+ || `new:${options.cwd || ''}`;
177
432
  const queue = queues.get(key) || { items: [], running: false };
178
433
  queues.set(key, queue);
179
434
  return new Promise(resolve => {
180
435
  queue.items.push({ command, options, send, resolve });
181
436
  if (queue.running) {
182
- send({ type: 'status', message: `Grok follow-up queued (${queue.items.length - 1} ahead)`, sessionId: options.sessionId, agent: 'grok' });
437
+ send({
438
+ type: 'status',
439
+ message: `Grok follow-up queued (${queue.items.length - 1} ahead)`,
440
+ sessionId: options.sessionId,
441
+ agent: 'grok',
442
+ });
183
443
  return;
184
444
  }
185
445
  queue.running = true;
186
- const drain = async () => {
446
+ void (async () => {
187
447
  while (queue.items.length) {
188
448
  const item = queue.items.shift();
189
449
  await runOne(item, { cliPath, activeSessions, queueKey: key });
@@ -192,16 +452,19 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
192
452
  queue.running = false;
193
453
  queues.delete(key);
194
454
  for (const [sid, alias] of queueAliases) if (alias === key) queueAliases.delete(sid);
195
- };
196
- void drain();
455
+ })();
197
456
  });
198
457
  }
199
458
 
200
459
  export async function interruptGrok(sessionId) {
201
460
  for (const client of clients.values()) {
202
461
  if (client.listeners.has(sessionId)) {
203
- client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } });
204
- return true;
462
+ try {
463
+ client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } });
464
+ return true;
465
+ } catch {
466
+ return false;
467
+ }
205
468
  }
206
469
  }
207
470
  return false;
@@ -304,7 +304,8 @@ async function discoverGrok(grokHome) {
304
304
 
305
305
  const active = models.find(m => m.value === defaultModel);
306
306
  const efforts = active?.efforts || DEFAULT_EFFORTS;
307
- const effortDefault = active?.defaultEffort || tomlSectionString(cfgText, 'models', 'default_reasoning_effort') || null;
307
+ // This UI defaults Grok reasoning effort to low.
308
+ const effortDefault = 'low';
308
309
 
309
310
  return {
310
311
  model: defaultModel,
package/src/app.js CHANGED
@@ -1485,8 +1485,18 @@ export function createApp() {
1485
1485
  }
1486
1486
  agentSend(data);
1487
1487
  };
1488
- await dispatchAgent(agent, cmd.command || '', options, runSend);
1489
- runOutbox.flush();
1488
+ try {
1489
+ await dispatchAgent(agent, cmd.command || '', options, runSend);
1490
+ } finally {
1491
+ // `complete` is a UI-facing agent event and is not a reliable
1492
+ // scheduling boundary (an adapter may emit it early or more than
1493
+ // once). The hub advances its Grok FIFO only after dispatchAgent has
1494
+ // actually returned from the ACP request.
1495
+ if (agent === 'grok') {
1496
+ runOutbox.send({ type: 'hub-turn-finished', agent, sessionId: runSessionId });
1497
+ }
1498
+ runOutbox.flush();
1499
+ }
1490
1500
  return;
1491
1501
  }
1492
1502