claude-simple 0.2.24 → 0.2.25

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/src/app.js CHANGED
@@ -13,7 +13,7 @@ import zlib from 'zlib';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { runClaude } from './agents/claude.js';
15
15
  import { runCodex } from './agents/codex.js';
16
- import { runGrok } from './agents/grok.js';
16
+ import { runGrok, getGrokQueueState, cancelGrokQueue } from './agents/grok.js';
17
17
  import { runGemini } from './agents/gemini.js';
18
18
  import { runCursor } from './agents/cursor.js';
19
19
  import {
@@ -33,8 +33,21 @@ import { discoverAllAgentModels } from './agents/models.js';
33
33
  import { createTextBatcher } from './agents/stream-batch.js';
34
34
  import { normalizeCommand, mapAgentEmit } from './runtime/protocol.js';
35
35
  import { createOutbox } from './runtime/outbox.js';
36
+ import { createExternalSessionMirror } from './runtime/external-session-sync.js';
36
37
  import { resolveApproval, cancelApprovalsForSession } from './runtime/approvals.js';
37
38
  import { createMcpHttpHandler, createMcpService } from './mcp.js';
39
+ import { createCollab, roleAtLeast, proxyToLocalPort, machineId as localMachineId } from './collab.js';
40
+ import {
41
+ CLAUDE_ENV_KEYS,
42
+ loadAgentEnvDoc,
43
+ saveAgentEnvDoc,
44
+ patchEnv,
45
+ redactEnv,
46
+ applyAgentEnvToProcess,
47
+ pickAllowedEnv,
48
+ normalizeProfileName,
49
+ envForProfile,
50
+ } from './agent-env.js';
38
51
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
52
  const rootDir = path.join(__dirname, '..');
40
53
 
@@ -191,18 +204,27 @@ async function findUser(username) {
191
204
  return store.users.find(u => u.username === username) || null;
192
205
  }
193
206
 
194
- async function createUser(username, passwordHash) {
207
+ async function createUser(username, passwordHash, role = null) {
195
208
  const store = await loadCreds();
196
- const user = { id: Date.now(), username, passwordHash };
209
+ if (!role) role = store.users.length === 0 ? 'owner' : 'editor';
210
+ const user = { id: Date.now(), username, passwordHash, role };
197
211
  store.users.push(user);
198
212
  await saveCreds(store);
199
213
  return user;
200
214
  }
201
215
 
216
+ function userRole(user) {
217
+ return user?.role || 'owner';
218
+ }
219
+
202
220
  function jwtSecret() { return credStore?.jwtSecret; }
203
221
 
204
222
  function makeToken(user) {
205
- return jwt.sign({ userId: user.id, username: user.username }, jwtSecret(), { expiresIn: '7d' });
223
+ return jwt.sign(
224
+ { userId: user.id, username: user.username, role: userRole(user) },
225
+ jwtSecret(),
226
+ { expiresIn: '7d' },
227
+ );
206
228
  }
207
229
 
208
230
  function makeMcpToken(user, expiresInSeconds) {
@@ -238,6 +260,7 @@ function authMiddleware(req, res, next) {
238
260
  req.user = {
239
261
  userId: 0,
240
262
  username: req.headers['x-hub-user'] || 'hub',
263
+ role: req.headers['x-hub-role'] || 'editor',
241
264
  viaHub: true,
242
265
  };
243
266
  return next();
@@ -395,13 +418,20 @@ async function dispatchAgent(agent, command, options, send) {
395
418
  } else if (a === 'cursor') {
396
419
  await runCursor(command, options, send, { ...ctx, cliPath: CURSOR_AGENT_PATH, cursorHome: CURSOR_HOME });
397
420
  } else {
398
- await runClaude(command, options, send, { ...ctx, cliPath: CLAUDE_CLI_PATH });
421
+ const wss = await getWorkspaces();
422
+ const ws = wss.find(w => options.configDir && w.configDir === options.configDir)
423
+ || wss.find(w => options.workspaceName && w.name === options.workspaceName);
424
+ await runClaude(command, {
425
+ ...options,
426
+ configDir: options.configDir || ws?.configDir,
427
+ workspaceName: options.workspaceName || ws?.name,
428
+ }, send, { ...ctx, cliPath: CLAUDE_CLI_PATH });
399
429
  }
400
430
  }
401
431
 
402
432
  function abortSession(sessionId) {
403
433
  if (!sessionId) return false;
404
- let ok = false;
434
+ let ok = cancelGrokQueue(sessionId) > 0;
405
435
  cancelApprovalsForSession(sessionId);
406
436
  // Grok/Codex may register under temp key + real sessionId — abort every match.
407
437
  for (const [key, s] of activeSessions) {
@@ -475,6 +505,31 @@ function compactHistoryMessages(messages) {
475
505
  // ─── App factory ──────────────────────────────────────────────────────────────
476
506
  export function createApp() {
477
507
  const app = express();
508
+ const COLLAB_FILE = process.env.COLLAB_FILE || path.join(rootDir, '.collab.json');
509
+ const collab = createCollab({ filePath: COLLAB_FILE });
510
+ const allowRemoteProxy = process.env.PORT_PROXY_ALLOW_REMOTE === '1';
511
+ // External CLIs (Codex, Grok, Claude, …) write native session records while
512
+ // running. Mirror only sessions a browser is looking at; this is strictly
513
+ // read-only and does not turn the UI into the owner of that agent process.
514
+ const externalSessionMirror = createExternalSessionMirror({
515
+ readSession: readSessionBundle,
516
+ emit: (sessionId, event) => broadcastChat(sessionId, event),
517
+ // fs.watch below wakes the mirror immediately on native JSONL writes.
518
+ // This slower timer is only a safety net for missed/coalesced events.
519
+ intervalMs: 3_000,
520
+ });
521
+
522
+ function liveRole(req) {
523
+ return req.user?.role || 'viewer';
524
+ }
525
+ function requireRole(need) {
526
+ return (req, res, next) => {
527
+ if (!roleAtLeast(liveRole(req), need)) {
528
+ return res.status(403).json({ error: 'forbidden' });
529
+ }
530
+ next();
531
+ };
532
+ }
478
533
 
479
534
  // Gzip JSON/text API responses when client accepts it (mobile / direct browser).
480
535
  // Skip for hub-proxied edge traffic: Node fetch auto-decompresses gzip while
@@ -532,7 +587,7 @@ export function createApp() {
532
587
  })().catch(e => res.status(500).json({ error: e.message }));
533
588
  });
534
589
 
535
- app.post('/api/projects/:id/file', authMiddleware, (req, res) => {
590
+ app.post('/api/projects/:id/file', authMiddleware, requireRole('editor'), (req, res) => {
536
591
  (async () => {
537
592
  const projectPath = resolveProjectRoot(req);
538
593
  const rel = req.query.path;
@@ -636,7 +691,31 @@ export function createApp() {
636
691
  return res.status(400).json({ error: 'Username ≥3 chars, password ≥6 chars' });
637
692
  const hash = await bcrypt.hash(password, 12);
638
693
  const user = await createUser(username, hash);
639
- res.json({ token: makeToken(user), user: { id: user.id, username: user.username } });
694
+ res.json({
695
+ token: makeToken(user),
696
+ user: { id: user.id, username: user.username, role: userRole(user) },
697
+ });
698
+ } catch (e) {
699
+ res.status(500).json({ error: e.message });
700
+ }
701
+ });
702
+
703
+ app.post('/api/auth/password', authMiddleware, async (req, res) => {
704
+ try {
705
+ const current = req.body?.current;
706
+ const next = req.body?.next;
707
+ if (!current || !next) return res.status(400).json({ error: 'current and next required' });
708
+ if (String(next).length < 6) return res.status(400).json({ error: 'password ≥6 chars' });
709
+ const user = await findUser(req.user.username);
710
+ if (!user) return res.status(404).json({ error: 'not found' });
711
+ const ok = await bcrypt.compare(current, user.passwordHash);
712
+ if (!ok) return res.status(403).json({ error: 'current password incorrect' });
713
+ user.passwordHash = await bcrypt.hash(next, 12);
714
+ const store = await loadCreds();
715
+ const idx = store.users.findIndex(u => u.username === user.username);
716
+ if (idx >= 0) store.users[idx] = user;
717
+ await saveCreds(store);
718
+ res.json({ ok: true });
640
719
  } catch (e) {
641
720
  res.status(500).json({ error: e.message });
642
721
  }
@@ -650,12 +729,238 @@ export function createApp() {
650
729
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });
651
730
  const ok = await bcrypt.compare(password, user.passwordHash);
652
731
  if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
653
- res.json({ token: makeToken(user), user: { id: user.id, username: user.username } });
732
+ res.json({
733
+ token: makeToken(user),
734
+ user: { id: user.id, username: user.username, role: userRole(user) },
735
+ });
654
736
  } catch (e) {
655
737
  res.status(500).json({ error: e.message });
656
738
  }
657
739
  });
658
740
 
741
+ app.get('/api/agent-env', authMiddleware, async (req, res) => {
742
+ const doc = await loadAgentEnvDoc();
743
+ const name = normalizeProfileName(req.query.name);
744
+ const names = Object.keys(doc.profiles);
745
+ const env = name ? envForProfile(doc, name) : doc.env;
746
+ res.json({
747
+ keys: CLAUDE_ENV_KEYS,
748
+ name: name || '',
749
+ names,
750
+ env: redactEnv(env),
751
+ defaultEnv: redactEnv(doc.env),
752
+ profiles: Object.fromEntries(names.map(n => [n, redactEnv(doc.profiles[n])])),
753
+ process: redactEnv(pickAllowedEnv(process.env)),
754
+ });
755
+ });
756
+
757
+ app.put('/api/agent-env', authMiddleware, requireRole('editor'), async (req, res) => {
758
+ try {
759
+ const doc = await loadAgentEnvDoc();
760
+ const name = normalizeProfileName(req.body?.name || req.query.name);
761
+ const patch = req.body?.env || req.body;
762
+ if (name) {
763
+ doc.profiles[name] = patchEnv(doc.profiles[name] || {}, patch);
764
+ if (!Object.keys(doc.profiles[name]).length) delete doc.profiles[name];
765
+ } else {
766
+ doc.env = patchEnv(doc.env, patch);
767
+ }
768
+ await saveAgentEnvDoc(doc);
769
+ await applyAgentEnvToProcess(process.env);
770
+ const env = name ? envForProfile(doc, name) : doc.env;
771
+ res.json({
772
+ name: name || '',
773
+ names: Object.keys(doc.profiles),
774
+ env: redactEnv(env),
775
+ });
776
+ } catch (e) {
777
+ res.status(500).json({ error: e.message });
778
+ }
779
+ });
780
+
781
+ app.get('/api/collab/me', authMiddleware, (req, res) => {
782
+ res.json({
783
+ username: req.user.username,
784
+ role: liveRole(req),
785
+ userId: req.user.userId ?? req.user.id,
786
+ });
787
+ });
788
+
789
+ app.get('/api/collab/sync', authMiddleware, async (req, res) => {
790
+ const role = liveRole(req);
791
+ res.json({
792
+ serverTime: Date.now(),
793
+ locks: await collab.listLocks(),
794
+ routes: await collab.listRoutes(req.user.username, role),
795
+ machines: [{ id: localMachineId(), online: true }],
796
+ me: { username: req.user.username, role },
797
+ });
798
+ });
799
+
800
+ app.get('/api/collab/locks', authMiddleware, async (_req, res) => {
801
+ res.json(await collab.listLocks());
802
+ });
803
+
804
+ app.post('/api/collab/locks', authMiddleware, requireRole('editor'), async (req, res) => {
805
+ const r = await collab.acquire({
806
+ key: req.body.key,
807
+ kind: req.body.kind,
808
+ holder: req.user.username,
809
+ holderRole: liveRole(req),
810
+ lockToken: req.body.token,
811
+ ttlMs: (req.body.ttlSec || 60) * 1000,
812
+ exclusive: req.body.exclusive !== false,
813
+ });
814
+ if (r.code !== 200) return res.status(r.code).json({ error: r.error, lock: r.lock });
815
+ res.json(r.lock);
816
+ });
817
+
818
+ app.post('/api/collab/lock-action', authMiddleware, async (req, res) => {
819
+ const key = decodeURIComponent(req.body?.key || '');
820
+ const action = req.body?.action;
821
+ if (action === 'heartbeat') {
822
+ const r = await collab.heartbeat({
823
+ key, holder: req.user.username, lockToken: req.body?.token,
824
+ ttlMs: (req.body?.ttlSec || 60) * 1000,
825
+ });
826
+ if (r.code !== 200) return res.status(r.code).json({ error: r.error });
827
+ return res.json(r.lock);
828
+ }
829
+ if (action === 'release') {
830
+ const r = await collab.release({
831
+ key, holder: req.user.username, lockToken: req.body?.token, force: false,
832
+ });
833
+ if (r.code !== 200) return res.status(r.code).json({ error: r.error });
834
+ return res.json({ ok: true });
835
+ }
836
+ if (action === 'break') {
837
+ if (!roleAtLeast(liveRole(req), 'admin')) return res.status(403).json({ error: 'forbidden' });
838
+ await collab.release({ key, force: true });
839
+ return res.json({ ok: true });
840
+ }
841
+ res.status(400).json({ error: 'unknown action' });
842
+ });
843
+
844
+ app.get('/api/collab/routes', authMiddleware, async (req, res) => {
845
+ res.json(await collab.listRoutes(req.user.username, liveRole(req)));
846
+ });
847
+
848
+ app.post('/api/collab/routes', authMiddleware, requireRole('editor'), async (req, res) => {
849
+ const r = await collab.putRoute(req.user.username, liveRole(req), {
850
+ ...req.body,
851
+ machineId: req.body.machineId || localMachineId(),
852
+ });
853
+ if (r.code !== 200) return res.status(r.code).json({ error: r.error });
854
+ res.json(r.route);
855
+ });
856
+
857
+ app.get('/api/collab/routes/:slug', authMiddleware, async (req, res) => {
858
+ const rt = await collab.getRoute(req.params.slug);
859
+ if (!rt) return res.status(404).json({ error: 'not found' });
860
+ res.json(rt);
861
+ });
862
+
863
+ app.delete('/api/collab/routes/:slug', authMiddleware, requireRole('editor'), async (req, res) => {
864
+ const r = await collab.deleteRoute(req.user.username, liveRole(req), req.params.slug);
865
+ if (r.code !== 200) return res.status(r.code).json({ error: r.error });
866
+ res.json({ ok: true });
867
+ });
868
+
869
+ app.get('/api/collab/users', authMiddleware, requireRole('admin'), async (_req, res) => {
870
+ const store = await loadCreds();
871
+ res.json(store.users.map(u => ({
872
+ id: u.id, username: u.username, role: userRole(u), disabled: !!u.disabled,
873
+ })));
874
+ });
875
+
876
+ app.post('/api/collab/users', authMiddleware, requireRole('admin'), async (req, res) => {
877
+ try {
878
+ const { username, password, role } = req.body || {};
879
+ if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
880
+ if (await findUser(username)) return res.status(409).json({ error: 'user exists' });
881
+ const actor = liveRole(req);
882
+ let nextRole = role || 'editor';
883
+ if (nextRole === 'owner' && actor !== 'owner') return res.status(403).json({ error: 'only owner can create owner' });
884
+ if (nextRole === 'admin' && actor !== 'owner') return res.status(403).json({ error: 'only owner can create admin' });
885
+ const hash = await bcrypt.hash(password, 12);
886
+ const user = await createUser(username, hash, nextRole);
887
+ res.json({ id: user.id, username: user.username, role: userRole(user) });
888
+ } catch (e) {
889
+ res.status(500).json({ error: e.message });
890
+ }
891
+ });
892
+
893
+ app.patch('/api/collab/users/:username', authMiddleware, requireRole('admin'), async (req, res) => {
894
+ try {
895
+ const user = await findUser(req.params.username);
896
+ if (!user) return res.status(404).json({ error: 'not found' });
897
+ const actor = liveRole(req);
898
+ if (userRole(user) === 'owner' && actor !== 'owner') return res.status(403).json({ error: 'cannot modify owner' });
899
+ const store = await loadCreds();
900
+ const idx = store.users.findIndex(u => u.username === user.username);
901
+ if (req.body?.password) {
902
+ if (String(req.body.password).length < 6) return res.status(400).json({ error: 'password ≥6 chars' });
903
+ user.passwordHash = await bcrypt.hash(req.body.password, 12);
904
+ }
905
+ if (req.body?.role) {
906
+ if (req.body.role === 'owner' && actor !== 'owner') return res.status(403).json({ error: 'cannot promote to owner' });
907
+ user.role = req.body.role;
908
+ }
909
+ if (typeof req.body?.disabled === 'boolean') user.disabled = req.body.disabled;
910
+ if (idx >= 0) store.users[idx] = user;
911
+ await saveCreds(store);
912
+ res.json({ ok: true, username: user.username, role: userRole(user) });
913
+ } catch (e) {
914
+ res.status(500).json({ error: e.message });
915
+ }
916
+ });
917
+
918
+ app.all('/api/port-proxy', authMiddleware, requireRole('editor'), async (req, res) => {
919
+ try {
920
+ const host = req.query.host || '127.0.0.1';
921
+ const port = req.query.port;
922
+ let reqPath = req.query.path || '/';
923
+ if (req.query.q) reqPath += (reqPath.includes('?') ? '&' : '?') + req.query.q;
924
+ const chunks = [];
925
+ if (req.readable && !req.readableEnded && req.method !== 'GET' && req.method !== 'HEAD') {
926
+ await new Promise((resolve, reject) => {
927
+ req.on('data', c => chunks.push(c));
928
+ req.on('end', resolve);
929
+ req.on('error', reject);
930
+ });
931
+ }
932
+ const out = await proxyToLocalPort({
933
+ host, port, reqPath, method: req.method,
934
+ headers: req.headers, body: Buffer.concat(chunks), allowRemote: allowRemoteProxy,
935
+ });
936
+ for (const [k, v] of Object.entries(out.headers || {})) {
937
+ if (k.toLowerCase() === 'content-length' || k.toLowerCase() === 'transfer-encoding') continue;
938
+ res.setHeader(k, v);
939
+ }
940
+ res.status(out.status).end(out.body);
941
+ } catch (e) {
942
+ res.status(e.status || 502).json({ error: e.message });
943
+ }
944
+ });
945
+
946
+ app.all('/r/:slug/*', authMiddleware, async (req, res) => {
947
+ try {
948
+ const rt = await collab.getRoute(req.params.slug);
949
+ if (!rt || !rt.enabled) return res.status(404).json({ error: 'route not found' });
950
+ const suffix = '/' + (req.params[0] || '');
951
+ const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) : '';
952
+ let reqPath = suffix || '/';
953
+ if (q) reqPath += (reqPath.includes('?') ? '&' : '?') + q;
954
+ const out = await proxyToLocalPort({
955
+ host: rt.targetHost, port: rt.targetPort, reqPath, method: req.method,
956
+ headers: req.headers, body: null, allowRemote: allowRemoteProxy,
957
+ });
958
+ res.status(out.status).end(out.body);
959
+ } catch (e) {
960
+ res.status(e.status || 502).json({ error: e.message });
961
+ }
962
+ });
963
+
659
964
  app.get('/api/projects/:id/files', authMiddleware, async (req, res) => {
660
965
  try {
661
966
  const projectPath = resolveProjectRoot(req);
@@ -904,11 +1209,22 @@ export function createApp() {
904
1209
 
905
1210
  app.put('/api/sessions/meta', authMiddleware, async (req, res) => {
906
1211
  try {
907
- const { sessionId, agent, favorite, notes, title, hidden } = req.body || {};
1212
+ const { sessionId, agent, favorite, notes, title, hidden, branch } = req.body || {};
908
1213
  if (!sessionId) return res.status(400).json({ error: 'sessionId required' });
909
1214
  const key = sessionMetaKey(agent || 'claude', sessionId);
910
1215
  const db = await loadSessionMeta();
911
- const prev = db[key] || { favorite: false, notes: '', title: '', hidden: false, updatedAt: 0 };
1216
+ const prev = db[key] || { favorite: false, notes: '', title: '', hidden: false, branch: null, updatedAt: 0 };
1217
+ const nextBranch = branch === undefined
1218
+ ? (prev.branch || null)
1219
+ : (branch && typeof branch === 'object' && typeof branch.parentSessionId === 'string'
1220
+ ? {
1221
+ parentSessionId: branch.parentSessionId,
1222
+ parentAgent: String(branch.parentAgent || agent || 'claude'),
1223
+ parentMachineId: branch.parentMachineId ? String(branch.parentMachineId) : null,
1224
+ forkMessageIndex: Number.isInteger(branch.forkMessageIndex) ? branch.forkMessageIndex : null,
1225
+ forkedAt: Number.isFinite(Number(branch.forkedAt)) ? Number(branch.forkedAt) : Date.now(),
1226
+ }
1227
+ : null);
912
1228
  const next = {
913
1229
  favorite: favorite !== undefined ? Boolean(favorite) : Boolean(prev.favorite),
914
1230
  notes: notes !== undefined ? String(notes || '') : String(prev.notes || ''),
@@ -917,10 +1233,11 @@ export function createApp() {
917
1233
  ? String(title || '').trim().slice(0, 200)
918
1234
  : String(prev.title || '').trim(),
919
1235
  hidden: hidden !== undefined ? Boolean(hidden) : Boolean(prev.hidden),
1236
+ branch: nextBranch,
920
1237
  updatedAt: Date.now(),
921
1238
  };
922
1239
  // Drop empty entries to keep file small
923
- if (!next.favorite && !next.notes && !next.title && !next.hidden) {
1240
+ if (!next.favorite && !next.notes && !next.title && !next.hidden && !next.branch) {
924
1241
  delete db[key];
925
1242
  } else {
926
1243
  db[key] = next;
@@ -929,7 +1246,7 @@ export function createApp() {
929
1246
  res.json({
930
1247
  success: true,
931
1248
  key,
932
- ...(db[key] || { favorite: false, notes: '', title: '', hidden: false, updatedAt: Date.now() }),
1249
+ ...(db[key] || { favorite: false, notes: '', title: '', hidden: false, branch: null, updatedAt: Date.now() }),
933
1250
  });
934
1251
  } catch (e) { res.status(500).json({ error: e.message }); }
935
1252
  });
@@ -1120,6 +1437,61 @@ export function createApp() {
1120
1437
  }
1121
1438
  });
1122
1439
 
1440
+ // Fork a session at a completed assistant message. Native transcript cloning
1441
+ // lets the target agent continue with the same context while branch topology
1442
+ // itself is saved by the UI as session metadata (Hub-safe as well).
1443
+ app.post('/api/sessions/:sessionId/fork', authMiddleware, async (req, res) => {
1444
+ try {
1445
+ const { sessionId } = req.params;
1446
+ const { sourceAgent, upTo, assistantText } = req.body || {};
1447
+ if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return res.status(400).json({ error: 'Invalid session id' });
1448
+ const detectedSourceAgent = String(sourceAgent || '').toLowerCase();
1449
+ if (!ENABLED_AGENTS.includes(detectedSourceAgent)) {
1450
+ return res.status(400).json({ error: 'A valid sourceAgent is required' });
1451
+ }
1452
+ const opts = {
1453
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS,
1454
+ codexHome: CODEX_HOME,
1455
+ grokHome: GROK_HOME,
1456
+ geminiHome: GEMINI_HOME,
1457
+ cursorHome: CURSOR_HOME,
1458
+ };
1459
+ // A streamed assistant message may reach the screen a moment before the
1460
+ // CLI finishes its journal write. Retry briefly, then match the exact
1461
+ // output text; history index is preferred for already-loaded messages.
1462
+ let bundle = null;
1463
+ for (let attempt = 0; attempt < 3; attempt++) {
1464
+ bundle = await loadSessionMessages(sessionId, detectedSourceAgent, opts);
1465
+ const messages = bundle?.messages || [];
1466
+ if (Number.isInteger(upTo) && upTo >= 0 && upTo < messages.length) break;
1467
+ if (typeof assistantText === 'string' && assistantText.trim()
1468
+ && messages.some(m => m?.role === 'assistant' && m?.content === assistantText)) break;
1469
+ if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 180));
1470
+ }
1471
+ const messages = bundle?.messages || [];
1472
+ if (!messages.length) return res.status(404).json({ error: 'Source session history is not ready yet' });
1473
+ let forkIndex = Number.isInteger(upTo) && upTo >= 0 && upTo < messages.length ? upTo : -1;
1474
+ if (forkIndex < 0 && typeof assistantText === 'string' && assistantText.trim()) {
1475
+ for (let i = messages.length - 1; i >= 0; i--) {
1476
+ if (messages[i]?.role === 'assistant' && messages[i]?.content === assistantText) { forkIndex = i; break; }
1477
+ }
1478
+ }
1479
+ if (forkIndex < 0 || messages[forkIndex]?.role !== 'assistant') {
1480
+ return res.status(409).json({ error: 'The selected assistant message is not persisted yet. Please try again in a moment.' });
1481
+ }
1482
+ const { targetSessionId } = await convertSession(
1483
+ sessionId,
1484
+ detectedSourceAgent,
1485
+ detectedSourceAgent,
1486
+ opts,
1487
+ { upTo: forkIndex + 1 },
1488
+ );
1489
+ res.json({ success: true, targetSessionId, targetAgent: detectedSourceAgent, forkMessageIndex: forkIndex });
1490
+ } catch (e) {
1491
+ res.status(500).json({ error: e.message });
1492
+ }
1493
+ });
1494
+
1123
1495
  app.get('/api/sessions/:sessionId/memory', authMiddleware, async (req, res) => {
1124
1496
  try {
1125
1497
  const { sessionId } = req.params;
@@ -1476,6 +1848,40 @@ export function createApp() {
1476
1848
  const wssChat = new WebSocketServer(wssOpts);
1477
1849
  const wssShell = new WebSocketServer(wssOpts);
1478
1850
 
1851
+ // macOS supports recursive fs.watch; the non-recursive fallback still gives
1852
+ // us the periodic mirror safety net on platforms/filesystems that do not.
1853
+ // Watch only agent-owned record directories, never Terminal PTYs.
1854
+ const externalWatchers = [];
1855
+ let externalRefreshTimer = null;
1856
+ const requestExternalRefresh = () => {
1857
+ if (externalRefreshTimer) return;
1858
+ externalRefreshTimer = setTimeout(() => {
1859
+ externalRefreshTimer = null;
1860
+ externalSessionMirror.refresh().catch(() => {});
1861
+ // A CLI can create a brand-new native session without any browser being
1862
+ // subscribed yet. Let open UIs refresh their sidebar catalog as well.
1863
+ for (const client of chatClients) sendJson(client.ws, { type: 'external-sessions-changed' });
1864
+ }, 80);
1865
+ };
1866
+ for (const dir of [
1867
+ path.join(CODEX_HOME, 'sessions'),
1868
+ path.join(GROK_HOME, 'sessions'),
1869
+ ...CLAUDE_CONFIG_DIRS.map(d => path.join(d, 'projects')),
1870
+ GEMINI_HOME,
1871
+ CURSOR_HOME,
1872
+ ]) {
1873
+ try {
1874
+ externalWatchers.push(fsSync.watch(dir, { recursive: true }, requestExternalRefresh));
1875
+ } catch {
1876
+ try { externalWatchers.push(fsSync.watch(dir, requestExternalRefresh)); } catch { /* directory may not exist yet */ }
1877
+ }
1878
+ }
1879
+ server.once('close', () => {
1880
+ if (externalRefreshTimer) clearTimeout(externalRefreshTimer);
1881
+ for (const watcher of externalWatchers) watcher.close();
1882
+ externalSessionMirror.close();
1883
+ });
1884
+
1479
1885
  server.on('upgrade', (req, socket, head) => {
1480
1886
  const pathname = new URL(req.url, 'http://localhost').pathname;
1481
1887
  if (pathname === '/ws/chat') {
@@ -1503,7 +1909,12 @@ export function createApp() {
1503
1909
  return;
1504
1910
  }
1505
1911
 
1506
- const client = { ws, sessionId: url.searchParams.get('sessionId') || null };
1912
+ const client = {
1913
+ ws,
1914
+ sessionId: url.searchParams.get('sessionId') || null,
1915
+ agent: url.searchParams.get('agent') || null,
1916
+ externalMirrorKey: null,
1917
+ };
1507
1918
  chatClients.add(client);
1508
1919
 
1509
1920
  // Broadcast to every chat socket on the same session (mobile reconnect / multi-tab).
@@ -1546,11 +1957,33 @@ export function createApp() {
1546
1957
 
1547
1958
  if (cmd.cmd === 'session.subscribe') {
1548
1959
  client.sessionId = cmd.sessionId || client.sessionId || null;
1960
+ client.agent = cmd.agent || client.agent || null;
1549
1961
  wireSend({
1550
1962
  type: 'run-status',
1551
1963
  sessionId: client.sessionId,
1552
1964
  running: isSessionBusy(client.sessionId),
1553
1965
  });
1966
+ // UI-managed active runs already stream their events directly. For
1967
+ // everything else, tail the native persisted transcript so an agent
1968
+ // launched in an ordinary terminal appears live in this UI.
1969
+ if (!isSessionBusy(client.sessionId)) {
1970
+ externalSessionMirror.subscribe(client, {
1971
+ sessionId: client.sessionId,
1972
+ agent: client.agent,
1973
+ });
1974
+ } else {
1975
+ externalSessionMirror.unsubscribe(client);
1976
+ }
1977
+ const queued = getGrokQueueState(client.sessionId);
1978
+ if (queued.running || queued.items.length) {
1979
+ wireSend({
1980
+ type: 'grok-queue-status',
1981
+ agent: 'grok',
1982
+ sessionId: client.sessionId,
1983
+ running: queued.running,
1984
+ items: queued.items,
1985
+ });
1986
+ }
1554
1987
  outbox.flush();
1555
1988
  return;
1556
1989
  }
@@ -1572,6 +2005,7 @@ export function createApp() {
1572
2005
  const sid = cmd.sessionId;
1573
2006
  const ok = abortSession(sid);
1574
2007
  wireSend({ type: 'abort-result', success: ok, sessionId: sid });
2008
+ wireSend({ type: 'grok-queue-status', agent: 'grok', sessionId: sid, running: false, items: [] });
1575
2009
  outbox.flush();
1576
2010
  return;
1577
2011
  }
@@ -1666,6 +2100,7 @@ export function createApp() {
1666
2100
 
1667
2101
  ws.on('close', () => {
1668
2102
  chatClients.delete(client);
2103
+ externalSessionMirror.unsubscribe(client);
1669
2104
  clearInterval(heartbeat);
1670
2105
  outbox.flush();
1671
2106
  shellOut?.flush();
@@ -1788,6 +2223,7 @@ else:
1788
2223
  export async function init() {
1789
2224
  await loadCreds();
1790
2225
  await bootstrapEnvUser();
2226
+ await applyAgentEnvToProcess(process.env);
1791
2227
  await syncConfigDirs();
1792
2228
  console.log(`[agents] enabled: ${ENABLED_AGENTS.join(', ')}`);
1793
2229
  console.log(`[agents] claude=${CLAUDE_CLI_PATH} codex=${CODEX_CLI_PATH} grok=${GROK_CLI_PATH} gemini=${GEMINI_CLI_PATH}`);