claude-simple 0.2.25 → 0.2.27

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.
@@ -28,8 +28,8 @@ import crypto from 'crypto';
28
28
  import { spawn } from 'child_process';
29
29
 
30
30
  export const AI_DEFAULTS = {
31
- url: 'https://api.deepseek.com/chat/completions',
32
- model: 'deepseek-chat',
31
+ url: 'https://api.xiaomimimo.com/v1/chat/completions',
32
+ model: 'mimo-v2.5',
33
33
  vlm_url: '',
34
34
  vlm_model: '',
35
35
  temperature: 0.3,
@@ -51,7 +51,8 @@ export const DEFAULT_SYSTEM = `你是 Claude Simple UI 的内置工作助手(M
51
51
  - 同一批图的追问:传入上次返回的 thread_id + 新 prompt,可复用图片与历史问答,不必重复传路径(也可继续传 paths 以补图)。
52
52
  - 不要假装已经看到图片;没调用工具就说「根据路径无法看见内容」。
53
53
  6. 需要验证时:write_file 写代码 → run_command 执行 → 根据输出迭代。
54
- 7. 回复简洁有结构,中文优先(用户用中文时)。`;
54
+ 7. 查找项目文件时优先用 find_files;先缩小范围再 read_file,不要用 run_command 代替只读检索。
55
+ 8. 回复简洁有结构,中文优先(用户用中文时)。`;
55
56
 
56
57
  export const REPORT_SYSTEM = `你是工作复盘助手。根据提供的 JSON 活动快照,写一份清晰的工作报告(Markdown)。
57
58
  结构建议:
@@ -292,6 +293,23 @@ const TOOLS = [
292
293
  },
293
294
  },
294
295
  },
296
+ {
297
+ type: 'function',
298
+ function: {
299
+ name: 'find_files',
300
+ description: '按文件名在目录树中查找文件;适合先定位代码、配置或文档,再用 read_file 读取内容',
301
+ parameters: {
302
+ type: 'object',
303
+ required: ['path', 'query'],
304
+ properties: {
305
+ path: { type: 'string', description: '要搜索的根目录(绝对路径)' },
306
+ query: { type: 'string', description: '文件名关键词,不区分大小写' },
307
+ max: { type: 'integer', description: '最多返回条数,默认 80,上限 200' },
308
+ maxDepth: { type: 'integer', description: '最大目录深度,默认 5,上限 10' },
309
+ },
310
+ },
311
+ },
312
+ },
295
313
  {
296
314
  type: 'function',
297
315
  function: {
@@ -504,12 +522,16 @@ export function createMetaAgent(deps) {
504
522
  cursorHome,
505
523
  } = deps;
506
524
 
507
- const configFile = path.join(rootDir, '.ai_config.json');
508
- const reportsFile = path.join(rootDir, '.ai_reports.json');
509
- const legacySessionsFile = path.join(rootDir, '.ai_sessions.json');
510
525
  const aiDir = process.env.AI_DATA_DIR
511
526
  ? expandPath(process.env.AI_DATA_DIR)
512
527
  : path.join(rootDir, '.ai');
528
+ // Keep credentials and reports outside an npm-installed package whenever
529
+ // AI_DATA_DIR is configured. Legacy files are read once for compatibility.
530
+ const configFile = path.join(aiDir, 'config.json');
531
+ const reportsFile = path.join(aiDir, 'reports.json');
532
+ const legacyConfigFile = path.join(rootDir, '.ai_config.json');
533
+ const legacyReportsFile = path.join(rootDir, '.ai_reports.json');
534
+ const legacySessionsFile = path.join(rootDir, '.ai_sessions.json');
513
535
  const notesFile = path.join(aiDir, 'meta-notes.json');
514
536
  const sessionBriefsFile = path.join(aiDir, 'session-briefs.json');
515
537
  const sessionsDir = path.join(aiDir, 'sessions');
@@ -1455,10 +1477,15 @@ export function createMetaAgent(deps) {
1455
1477
 
1456
1478
  async function loadConfig() {
1457
1479
  if (configCache) return configCache;
1480
+ await ensureAiDirs();
1458
1481
  try {
1459
1482
  configCache = { ...AI_DEFAULTS, ...JSON.parse(await fs.readFile(configFile, 'utf8')) };
1460
1483
  } catch {
1461
- configCache = { ...AI_DEFAULTS, key: '', vlm_key: '', system: DEFAULT_SYSTEM };
1484
+ try {
1485
+ configCache = { ...AI_DEFAULTS, ...JSON.parse(await fs.readFile(legacyConfigFile, 'utf8')) };
1486
+ } catch {
1487
+ configCache = { ...AI_DEFAULTS, key: '', vlm_key: '', system: DEFAULT_SYSTEM };
1488
+ }
1462
1489
  }
1463
1490
  if (!configCache.system) configCache.system = DEFAULT_SYSTEM;
1464
1491
  return configCache;
@@ -1492,10 +1519,15 @@ export function createMetaAgent(deps) {
1492
1519
 
1493
1520
  async function loadReports() {
1494
1521
  if (reportsCache) return reportsCache;
1522
+ await ensureAiDirs();
1495
1523
  try {
1496
1524
  reportsCache = JSON.parse(await fs.readFile(reportsFile, 'utf8'));
1497
1525
  } catch {
1498
- reportsCache = {};
1526
+ try {
1527
+ reportsCache = JSON.parse(await fs.readFile(legacyReportsFile, 'utf8'));
1528
+ } catch {
1529
+ reportsCache = {};
1530
+ }
1499
1531
  }
1500
1532
  return reportsCache;
1501
1533
  }
@@ -1622,11 +1654,26 @@ export function createMetaAgent(deps) {
1622
1654
  return res;
1623
1655
  }
1624
1656
 
1657
+ function isMimoModel(model) {
1658
+ return String(model || '').toLowerCase().includes('mimo');
1659
+ }
1660
+
1661
+ function providerOptions(body) {
1662
+ if (!isMimoModel(body?.model)) return body;
1663
+ // `extra_body` is an SDK-only Python convenience. This server uses fetch,
1664
+ // so MiMo's provider option must be sent at the JSON body top level.
1665
+ return {
1666
+ ...body,
1667
+ max_completion_tokens: body.max_completion_tokens || 2048,
1668
+ thinking: { type: 'disabled' },
1669
+ };
1670
+ }
1671
+
1625
1672
  async function chatSync(cfg, body) {
1626
1673
  const res = await chatCompletions({
1627
1674
  url: cfg.url || AI_DEFAULTS.url,
1628
1675
  apiKey: cfg.key || '',
1629
- body: { ...body, stream: false },
1676
+ body: providerOptions({ ...body, stream: false }),
1630
1677
  });
1631
1678
  return res.json();
1632
1679
  }
@@ -1635,7 +1682,7 @@ export function createMetaAgent(deps) {
1635
1682
  const res = await chatCompletions({
1636
1683
  url: cfg.url || AI_DEFAULTS.url,
1637
1684
  apiKey: cfg.key || '',
1638
- body: { ...body, stream: true },
1685
+ body: providerOptions({ ...body, stream: true }),
1639
1686
  });
1640
1687
  const reader = res.body.getReader();
1641
1688
  const dec = new TextDecoder();
@@ -1821,6 +1868,37 @@ export function createMetaAgent(deps) {
1821
1868
  path: path.join(dir, e.name),
1822
1869
  }));
1823
1870
  }
1871
+ case 'find_files': {
1872
+ const root = expandPath(args.path);
1873
+ if (!path.isAbsolute(root)) return { error: 'path must be absolute' };
1874
+ const query = String(args.query || '').trim().toLowerCase();
1875
+ if (!query) return { error: 'query required' };
1876
+ const max = Math.min(Math.max(args.max || 80, 1), 200);
1877
+ const maxDepth = Math.min(Math.max(args.maxDepth || 5, 0), 10);
1878
+ const ignored = new Set(['.git', 'node_modules', 'dist', 'build', '.next', '.dart_tool']);
1879
+ const matches = [];
1880
+ async function visit(dir, depth) {
1881
+ if (matches.length >= max || depth > maxDepth) return;
1882
+ let entries;
1883
+ try {
1884
+ entries = await fs.readdir(dir, { withFileTypes: true });
1885
+ } catch {
1886
+ return;
1887
+ }
1888
+ for (const entry of entries) {
1889
+ if (matches.length >= max) return;
1890
+ if (ignored.has(entry.name) || entry.isSymbolicLink()) continue;
1891
+ const full = path.join(dir, entry.name);
1892
+ if (entry.isDirectory()) {
1893
+ await visit(full, depth + 1);
1894
+ } else if (entry.isFile() && entry.name.toLowerCase().includes(query)) {
1895
+ matches.push(full);
1896
+ }
1897
+ }
1898
+ }
1899
+ await visit(root, 0);
1900
+ return { root, query, matches, truncated: matches.length >= max };
1901
+ }
1824
1902
  case 'read_file': {
1825
1903
  const p = expandPath(args.path);
1826
1904
  const maxBytes = Math.min(args.maxBytes || 120000, 500000);
@@ -1989,7 +2067,7 @@ export function createMetaAgent(deps) {
1989
2067
  const res = await chatCompletions({
1990
2068
  url: vlmUrl,
1991
2069
  apiKey: vlmKey,
1992
- body: { ...body, stream: false },
2070
+ body: providerOptions({ ...body, stream: false }),
1993
2071
  });
1994
2072
  const json = await res.json();
1995
2073
  const text = json.choices?.[0]?.message?.content || '';
@@ -2097,12 +2175,6 @@ export function createMetaAgent(deps) {
2097
2175
  stream: true,
2098
2176
  temperature: config.temperature ?? 0.3,
2099
2177
  };
2100
- // some providers
2101
- if (body.model && String(body.model).includes('mimo')) {
2102
- body.max_completion_tokens = 2048;
2103
- body.extra_body = { thinking: { type: 'disabled' } };
2104
- }
2105
-
2106
2178
  await streamChat(config, body, (chunk) => {
2107
2179
  const d = chunk.choices?.[0]?.delta;
2108
2180
  if (d?.content) {
@@ -217,6 +217,38 @@ export async function parseClaudeMessages(filePath) {
217
217
  return messages;
218
218
  }
219
219
 
220
+ async function forkClaudeNativeSession(sourceSessionId, targetSessionId, claudeConfigDirs, assistantText, occurrence) {
221
+ const sourceFile = await findClaudeSessionFile(sourceSessionId, claudeConfigDirs);
222
+ if (!sourceFile) throw new Error('Claude source transcript was not found');
223
+
224
+ const source = await fs.readFile(sourceFile, 'utf8');
225
+ const output = [];
226
+ let foundForkPoint = false;
227
+ let seenMatches = 0;
228
+ for (const line of source.split(/\r?\n/)) {
229
+ if (!line.trim()) continue;
230
+ let record;
231
+ try { record = JSON.parse(line); } catch { continue; }
232
+ output.push(JSON.stringify(replaceSessionId(record, sourceSessionId, targetSessionId)));
233
+ if (record.type !== 'assistant' || !Array.isArray(record.message?.content)) continue;
234
+ for (const part of record.message.content) {
235
+ if (part?.type === 'text' && part.text?.trim() === assistantText && ++seenMatches === occurrence) {
236
+ foundForkPoint = true;
237
+ break;
238
+ }
239
+ }
240
+ if (foundForkPoint) break;
241
+ }
242
+ if (!foundForkPoint) throw new Error('Selected Claude assistant message was not found in the native transcript');
243
+
244
+ const targetFile = path.join(path.dirname(sourceFile), `${targetSessionId}.jsonl`);
245
+ const tempFile = `${targetFile}.tmp-${crypto.randomUUID()}`;
246
+ await fs.writeFile(tempFile, `${output.join('\n')}\n`, 'utf8');
247
+ await fs.rename(tempFile, targetFile);
248
+ claudeFileCache.set(targetSessionId, targetFile);
249
+ return targetFile;
250
+ }
251
+
220
252
  // ─── Codex ────────────────────────────────────────────────────────────────────
221
253
 
222
254
  async function loadCodexIndex(codexHome) {
@@ -407,6 +439,63 @@ export async function parseCodexMessages(filePath) {
407
439
  return messages;
408
440
  }
409
441
 
442
+ function replaceSessionId(value, sourceSessionId, targetSessionId) {
443
+ if (value === sourceSessionId) return targetSessionId;
444
+ if (Array.isArray(value)) return value.map(item => replaceSessionId(item, sourceSessionId, targetSessionId));
445
+ if (value && typeof value === 'object') {
446
+ return Object.fromEntries(Object.entries(value)
447
+ .map(([key, item]) => [key, replaceSessionId(item, sourceSessionId, targetSessionId)]));
448
+ }
449
+ return value;
450
+ }
451
+
452
+ /**
453
+ * Codex can only resume a session from its native event log. Re-serializing a
454
+ * chat transcript loses turn context, tool results, and injected instructions,
455
+ * so a fork must clone the native rollout and cut it at the selected answer.
456
+ */
457
+ async function forkCodexNativeSession(sourceSessionId, targetSessionId, codexHome, assistantText, occurrence) {
458
+ const sourceFile = await findCodexSessionFile(sourceSessionId, codexHome);
459
+ if (!sourceFile) throw new Error('Codex source rollout was not found');
460
+
461
+ const source = await fs.readFile(sourceFile, 'utf8');
462
+ const output = [];
463
+ let foundForkPoint = false;
464
+ let seenMatches = 0;
465
+ for (const line of source.split(/\r?\n/)) {
466
+ if (!line.trim()) continue;
467
+ let record;
468
+ try { record = JSON.parse(line); } catch { continue; }
469
+
470
+ output.push(JSON.stringify(replaceSessionId(record, sourceSessionId, targetSessionId)));
471
+ const payload = record.type === 'response_item' ? record.payload : null;
472
+ if (payload?.type !== 'message' || payload.role !== 'assistant') continue;
473
+ const text = (payload.content || [])
474
+ .filter(part => part?.type === 'output_text' || part?.type === 'text')
475
+ .map(part => part.text || '')
476
+ .join('\n')
477
+ .trim();
478
+ if (text === assistantText && ++seenMatches === occurrence) {
479
+ foundForkPoint = true;
480
+ break;
481
+ }
482
+ }
483
+ if (!foundForkPoint) {
484
+ throw new Error('Selected Codex assistant message was not found in the native rollout');
485
+ }
486
+
487
+ const now = new Date();
488
+ const day = now.toISOString().slice(0, 10).split('-');
489
+ const targetDir = path.join(codexHome, 'sessions', day[0], day[1], day[2]);
490
+ const fileStamp = now.toISOString().replace(/[:.]/g, '-').replace('Z', '');
491
+ const targetFile = path.join(targetDir, `rollout-${fileStamp}-${targetSessionId}.jsonl`);
492
+ await fs.mkdir(targetDir, { recursive: true });
493
+ const tempFile = `${targetFile}.tmp-${crypto.randomUUID()}`;
494
+ await fs.writeFile(tempFile, `${output.join('\n')}\n`, 'utf8');
495
+ await fs.rename(tempFile, targetFile);
496
+ return targetFile;
497
+ }
498
+
410
499
  // ─── Grok ─────────────────────────────────────────────────────────────────────
411
500
 
412
501
  export async function scanGrokSessions(grokHome, filterCwd = null) {
@@ -1671,6 +1760,42 @@ export async function convertSession(sourceSessionId, sourceAgent, targetAgent,
1671
1760
  if (ta === 'gemini' || ta === 'cursor') {
1672
1761
  throw new Error(`${ta === 'cursor' ? 'Cursor' : 'Gemini'} sessions use native conversation storage and cannot be cloned safely. Start a new session instead.`);
1673
1762
  }
1763
+
1764
+ if (ta === 'claude' && sourceAgent.toLowerCase() === 'claude' && Number.isInteger(upTo)) {
1765
+ const selected = messages[messages.length - 1];
1766
+ if (selected?.role !== 'assistant' || !selected.content?.trim()) {
1767
+ throw new Error('A Claude fork must end at a persisted assistant message');
1768
+ }
1769
+ const occurrence = messages.filter(message => message.role === 'assistant'
1770
+ && message.content?.trim() === selected.content.trim()).length;
1771
+ await forkClaudeNativeSession(sourceSessionId, targetSessionId, claudeConfigDirs, selected.content.trim(), occurrence);
1772
+ try {
1773
+ const configDir = claudeConfigDirs?.[0] || path.join(os.homedir(), '.claude');
1774
+ await fs.appendFile(path.join(configDir, 'history.jsonl'), JSON.stringify({
1775
+ display, pastedContents: {}, timestamp: Date.now(), project: cwd, sessionId: targetSessionId,
1776
+ }) + '\n', 'utf8');
1777
+ } catch {}
1778
+ return { targetSessionId };
1779
+ }
1780
+
1781
+ // A same-agent Codex fork must remain a native rollout. The old synthetic
1782
+ // serializer produced a visible transcript, but `codex exec resume` did not
1783
+ // have the original turn context to continue it as an actual branch.
1784
+ if (ta === 'codex' && sourceAgent.toLowerCase() === 'codex' && Number.isInteger(upTo)) {
1785
+ const home = codexHome || path.join(os.homedir(), '.codex');
1786
+ const selected = messages[messages.length - 1];
1787
+ if (selected?.role !== 'assistant' || !selected.content?.trim()) {
1788
+ throw new Error('A Codex fork must end at a persisted assistant message');
1789
+ }
1790
+ const occurrence = messages.filter(message => message.role === 'assistant'
1791
+ && message.content?.trim() === selected.content.trim()).length;
1792
+ await forkCodexNativeSession(sourceSessionId, targetSessionId, home, selected.content.trim(), occurrence);
1793
+ try {
1794
+ const indexFile = path.join(home, 'session_index.jsonl');
1795
+ await fs.appendFile(indexFile, JSON.stringify({ id: targetSessionId, thread_name: display }) + '\n', 'utf8');
1796
+ } catch {}
1797
+ return { targetSessionId };
1798
+ }
1674
1799
 
1675
1800
  if (ta === 'claude') {
1676
1801
  const configDir = claudeConfigDirs?.[0] || path.join(os.homedir(), '.claude');
package/src/app.js CHANGED
@@ -142,15 +142,16 @@ let sessionMetaCache = null;
142
142
 
143
143
  /** Normalize project entry: old { notes } was the goal line. */
144
144
  function normalizeProjectEntry(entry) {
145
- if (!entry || typeof entry !== 'object') return { goal: '', notes: '', updatedAt: 0 };
145
+ if (!entry || typeof entry !== 'object') return { goal: '', notes: '', hidden: false, updatedAt: 0 };
146
146
  const hasGoalKey = Object.prototype.hasOwnProperty.call(entry, 'goal');
147
147
  if (!hasGoalKey && entry.notes != null) {
148
148
  // legacy: single notes field = Project Goal
149
- return { goal: String(entry.notes || ''), notes: '', updatedAt: entry.updatedAt || 0 };
149
+ return { goal: String(entry.notes || ''), notes: '', hidden: Boolean(entry.hidden), updatedAt: entry.updatedAt || 0 };
150
150
  }
151
151
  return {
152
152
  goal: String(entry.goal || ''),
153
153
  notes: String(entry.notes || ''),
154
+ hidden: Boolean(entry.hidden),
154
155
  updatedAt: entry.updatedAt || 0,
155
156
  };
156
157
  }
@@ -429,9 +430,9 @@ async function dispatchAgent(agent, command, options, send) {
429
430
  }
430
431
  }
431
432
 
432
- function abortSession(sessionId) {
433
+ function abortSession(sessionId, { preserveQueue = false } = {}) {
433
434
  if (!sessionId) return false;
434
- let ok = cancelGrokQueue(sessionId) > 0;
435
+ let ok = !preserveQueue && cancelGrokQueue(sessionId) > 0;
435
436
  cancelApprovalsForSession(sessionId);
436
437
  // Grok/Codex may register under temp key + real sessionId — abort every match.
437
438
  for (const [key, s] of activeSessions) {
@@ -522,6 +523,25 @@ export function createApp() {
522
523
  function liveRole(req) {
523
524
  return req.user?.role || 'viewer';
524
525
  }
526
+ function sessionResourceKey(sessionId, agent) {
527
+ return `session:${localMachineId()}:${agent || 'claude'}:${sessionId}`;
528
+ }
529
+ async function enforceSessionLock(req, res, sessionId, agent) {
530
+ if (!sessionId) return true;
531
+ const key = req.headers['x-lock-key'] || sessionResourceKey(sessionId, agent);
532
+ const r = await collab.requireLock({
533
+ key,
534
+ lockToken: req.headers['x-lock-token'],
535
+ writer: req.user.username,
536
+ writerRole: liveRole(req),
537
+ });
538
+ if (r.code !== 200) {
539
+ res.status(r.code).json({ error: r.error, lock: r.lock });
540
+ return false;
541
+ }
542
+ if (r.lock?.token) res.setHeader('X-Lock-Token', r.lock.token);
543
+ return true;
544
+ }
525
545
  function requireRole(need) {
526
546
  return (req, res, next) => {
527
547
  if (!roleAtLeast(liveRole(req), need)) {
@@ -815,20 +835,19 @@ export function createApp() {
815
835
  res.json(r.lock);
816
836
  });
817
837
 
818
- app.post('/api/collab/lock-action', authMiddleware, async (req, res) => {
819
- const key = decodeURIComponent(req.body?.key || '');
820
- const action = req.body?.action;
838
+ async function handleLockAction(req, res, key, action, token, ttlSec) {
839
+ key = decodeURIComponent(key || '');
821
840
  if (action === 'heartbeat') {
822
841
  const r = await collab.heartbeat({
823
- key, holder: req.user.username, lockToken: req.body?.token,
824
- ttlMs: (req.body?.ttlSec || 60) * 1000,
842
+ key, holder: req.user.username, lockToken: token,
843
+ ttlMs: (ttlSec || 60) * 1000,
825
844
  });
826
845
  if (r.code !== 200) return res.status(r.code).json({ error: r.error });
827
846
  return res.json(r.lock);
828
847
  }
829
848
  if (action === 'release') {
830
849
  const r = await collab.release({
831
- key, holder: req.user.username, lockToken: req.body?.token, force: false,
850
+ key, holder: req.user.username, lockToken: token, force: false,
832
851
  });
833
852
  if (r.code !== 200) return res.status(r.code).json({ error: r.error });
834
853
  return res.json({ ok: true });
@@ -839,6 +858,14 @@ export function createApp() {
839
858
  return res.json({ ok: true });
840
859
  }
841
860
  res.status(400).json({ error: 'unknown action' });
861
+ }
862
+
863
+ app.post('/api/collab/lock-action', authMiddleware, async (req, res) => {
864
+ await handleLockAction(req, res, req.body?.key, req.body?.action, req.body?.token, req.body?.ttlSec);
865
+ });
866
+
867
+ app.post('/api/collab/locks/:key/:action', authMiddleware, async (req, res) => {
868
+ await handleLockAction(req, res, req.params.key, req.params.action, req.body?.token, req.body?.ttlSec);
842
869
  });
843
870
 
844
871
  app.get('/api/collab/routes', authMiddleware, async (req, res) => {
@@ -1168,7 +1195,7 @@ export function createApp() {
1168
1195
 
1169
1196
  app.post('/api/projects/notes', authMiddleware, async (req, res) => {
1170
1197
  try {
1171
- const { root, goal, notes } = req.body || {};
1198
+ const { root, goal, notes, hidden } = req.body || {};
1172
1199
  if (!root) return res.status(400).json({ error: 'root required' });
1173
1200
  const resolved = path.resolve(expandPath(root));
1174
1201
  const db = await loadProjectNotes();
@@ -1177,6 +1204,7 @@ export function createApp() {
1177
1204
  const next = {
1178
1205
  goal: goal !== undefined ? String(goal || '') : prev.goal,
1179
1206
  notes: notes !== undefined ? String(notes || '') : prev.notes,
1207
+ hidden: hidden !== undefined ? Boolean(hidden) : prev.hidden,
1180
1208
  updatedAt: Date.now(),
1181
1209
  };
1182
1210
  db[resolved] = next;
@@ -1392,7 +1420,10 @@ export function createApp() {
1392
1420
  compact,
1393
1421
  };
1394
1422
  res.setHeader('X-Session-Agent', payload.agent);
1395
- res.setHeader('Cache-Control', 'private, max-age=30');
1423
+ // A session can be appended while a turn is streaming. Returning a
1424
+ // cached transcript here lets a client paint an old snapshot after a
1425
+ // newer WS update, so session history must always be read fresh.
1426
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
1396
1427
  res.json(payload);
1397
1428
  } catch (e) {
1398
1429
  res.status(500).json({ error: e.message });
@@ -1449,6 +1480,7 @@ export function createApp() {
1449
1480
  if (!ENABLED_AGENTS.includes(detectedSourceAgent)) {
1450
1481
  return res.status(400).json({ error: 'A valid sourceAgent is required' });
1451
1482
  }
1483
+ if (!await enforceSessionLock(req, res, sessionId, detectedSourceAgent)) return;
1452
1484
  const opts = {
1453
1485
  claudeConfigDirs: CLAUDE_CONFIG_DIRS,
1454
1486
  codexHome: CODEX_HOME,
@@ -1913,6 +1945,9 @@ export function createApp() {
1913
1945
  ws,
1914
1946
  sessionId: url.searchParams.get('sessionId') || null,
1915
1947
  agent: url.searchParams.get('agent') || null,
1948
+ username: decoded.username || null,
1949
+ role: decoded.role || (decoded.viaHub ? 'editor' : 'editor'),
1950
+ viaHub: !!decoded.viaHub,
1916
1951
  externalMirrorKey: null,
1917
1952
  };
1918
1953
  chatClients.add(client);
@@ -2003,7 +2038,7 @@ export function createApp() {
2003
2038
 
2004
2039
  if (cmd.cmd === 'turn.interrupt') {
2005
2040
  const sid = cmd.sessionId;
2006
- const ok = abortSession(sid);
2041
+ const ok = abortSession(sid, { preserveQueue: cmd.preserveQueue === true });
2007
2042
  wireSend({ type: 'abort-result', success: ok, sessionId: sid });
2008
2043
  wireSend({ type: 'grok-queue-status', agent: 'grok', sessionId: sid, running: false, items: [] });
2009
2044
  outbox.flush();
@@ -2016,6 +2051,24 @@ export function createApp() {
2016
2051
  if (cmd.sessionId) options.sessionId = cmd.sessionId;
2017
2052
  if (cmd.cwd) options.cwd = cmd.cwd;
2018
2053
  if (options.sessionId) client.sessionId = options.sessionId;
2054
+ // Hub already enforced the lease (X-Hub-User). Standalone checks here.
2055
+ if (!client.viaHub && options.sessionId && client.username) {
2056
+ const lockTok = cmd.lockToken || options.lockToken || msg.lockToken;
2057
+ const r = await collab.requireLock({
2058
+ key: sessionResourceKey(options.sessionId, agent),
2059
+ lockToken: lockTok,
2060
+ writer: client.username,
2061
+ writerRole: client.role || 'editor',
2062
+ });
2063
+ if (r.code !== 200) {
2064
+ wireSend({
2065
+ type: 'error', code: r.code, message: r.error || 'locked',
2066
+ lock: r.lock, sessionId: options.sessionId,
2067
+ });
2068
+ outbox.flush();
2069
+ return;
2070
+ }
2071
+ }
2019
2072
  // A socket may subscribe to another session while this agent is still
2020
2073
  // streaming. Keep this run's delivery target in its own closure rather
2021
2074
  // than consulting mutable client.sessionId in the shared socket outbox.
@@ -2048,11 +2101,9 @@ export function createApp() {
2048
2101
  } finally {
2049
2102
  // `complete` is a UI-facing agent event and is not a reliable
2050
2103
  // scheduling boundary (an adapter may emit it early or more than
2051
- // once). The hub advances its Grok FIFO only after dispatchAgent has
2052
- // actually returned from the ACP request.
2053
- if (agent === 'grok') {
2054
- runOutbox.send({ type: 'hub-turn-finished', agent, sessionId: runSessionId });
2055
- }
2104
+ // once). Clients may only advance a local FIFO after the adapter has
2105
+ // actually returned and released the session, for every agent.
2106
+ runOutbox.send({ type: 'hub-turn-finished', agent, sessionId: runSessionId });
2056
2107
  runOutbox.flush();
2057
2108
  }
2058
2109
  return;