claude-simple 0.2.24 → 0.2.26

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
 
@@ -129,15 +142,16 @@ let sessionMetaCache = null;
129
142
 
130
143
  /** Normalize project entry: old { notes } was the goal line. */
131
144
  function normalizeProjectEntry(entry) {
132
- if (!entry || typeof entry !== 'object') return { goal: '', notes: '', updatedAt: 0 };
145
+ if (!entry || typeof entry !== 'object') return { goal: '', notes: '', hidden: false, updatedAt: 0 };
133
146
  const hasGoalKey = Object.prototype.hasOwnProperty.call(entry, 'goal');
134
147
  if (!hasGoalKey && entry.notes != null) {
135
148
  // legacy: single notes field = Project Goal
136
- 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 };
137
150
  }
138
151
  return {
139
152
  goal: String(entry.goal || ''),
140
153
  notes: String(entry.notes || ''),
154
+ hidden: Boolean(entry.hidden),
141
155
  updatedAt: entry.updatedAt || 0,
142
156
  };
143
157
  }
@@ -191,18 +205,27 @@ async function findUser(username) {
191
205
  return store.users.find(u => u.username === username) || null;
192
206
  }
193
207
 
194
- async function createUser(username, passwordHash) {
208
+ async function createUser(username, passwordHash, role = null) {
195
209
  const store = await loadCreds();
196
- const user = { id: Date.now(), username, passwordHash };
210
+ if (!role) role = store.users.length === 0 ? 'owner' : 'editor';
211
+ const user = { id: Date.now(), username, passwordHash, role };
197
212
  store.users.push(user);
198
213
  await saveCreds(store);
199
214
  return user;
200
215
  }
201
216
 
217
+ function userRole(user) {
218
+ return user?.role || 'owner';
219
+ }
220
+
202
221
  function jwtSecret() { return credStore?.jwtSecret; }
203
222
 
204
223
  function makeToken(user) {
205
- return jwt.sign({ userId: user.id, username: user.username }, jwtSecret(), { expiresIn: '7d' });
224
+ return jwt.sign(
225
+ { userId: user.id, username: user.username, role: userRole(user) },
226
+ jwtSecret(),
227
+ { expiresIn: '7d' },
228
+ );
206
229
  }
207
230
 
208
231
  function makeMcpToken(user, expiresInSeconds) {
@@ -238,6 +261,7 @@ function authMiddleware(req, res, next) {
238
261
  req.user = {
239
262
  userId: 0,
240
263
  username: req.headers['x-hub-user'] || 'hub',
264
+ role: req.headers['x-hub-role'] || 'editor',
241
265
  viaHub: true,
242
266
  };
243
267
  return next();
@@ -395,13 +419,20 @@ async function dispatchAgent(agent, command, options, send) {
395
419
  } else if (a === 'cursor') {
396
420
  await runCursor(command, options, send, { ...ctx, cliPath: CURSOR_AGENT_PATH, cursorHome: CURSOR_HOME });
397
421
  } else {
398
- await runClaude(command, options, send, { ...ctx, cliPath: CLAUDE_CLI_PATH });
422
+ const wss = await getWorkspaces();
423
+ const ws = wss.find(w => options.configDir && w.configDir === options.configDir)
424
+ || wss.find(w => options.workspaceName && w.name === options.workspaceName);
425
+ await runClaude(command, {
426
+ ...options,
427
+ configDir: options.configDir || ws?.configDir,
428
+ workspaceName: options.workspaceName || ws?.name,
429
+ }, send, { ...ctx, cliPath: CLAUDE_CLI_PATH });
399
430
  }
400
431
  }
401
432
 
402
- function abortSession(sessionId) {
433
+ function abortSession(sessionId, { preserveQueue = false } = {}) {
403
434
  if (!sessionId) return false;
404
- let ok = false;
435
+ let ok = !preserveQueue && cancelGrokQueue(sessionId) > 0;
405
436
  cancelApprovalsForSession(sessionId);
406
437
  // Grok/Codex may register under temp key + real sessionId — abort every match.
407
438
  for (const [key, s] of activeSessions) {
@@ -475,6 +506,50 @@ function compactHistoryMessages(messages) {
475
506
  // ─── App factory ──────────────────────────────────────────────────────────────
476
507
  export function createApp() {
477
508
  const app = express();
509
+ const COLLAB_FILE = process.env.COLLAB_FILE || path.join(rootDir, '.collab.json');
510
+ const collab = createCollab({ filePath: COLLAB_FILE });
511
+ const allowRemoteProxy = process.env.PORT_PROXY_ALLOW_REMOTE === '1';
512
+ // External CLIs (Codex, Grok, Claude, …) write native session records while
513
+ // running. Mirror only sessions a browser is looking at; this is strictly
514
+ // read-only and does not turn the UI into the owner of that agent process.
515
+ const externalSessionMirror = createExternalSessionMirror({
516
+ readSession: readSessionBundle,
517
+ emit: (sessionId, event) => broadcastChat(sessionId, event),
518
+ // fs.watch below wakes the mirror immediately on native JSONL writes.
519
+ // This slower timer is only a safety net for missed/coalesced events.
520
+ intervalMs: 3_000,
521
+ });
522
+
523
+ function liveRole(req) {
524
+ return req.user?.role || 'viewer';
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
+ }
545
+ function requireRole(need) {
546
+ return (req, res, next) => {
547
+ if (!roleAtLeast(liveRole(req), need)) {
548
+ return res.status(403).json({ error: 'forbidden' });
549
+ }
550
+ next();
551
+ };
552
+ }
478
553
 
479
554
  // Gzip JSON/text API responses when client accepts it (mobile / direct browser).
480
555
  // Skip for hub-proxied edge traffic: Node fetch auto-decompresses gzip while
@@ -532,7 +607,7 @@ export function createApp() {
532
607
  })().catch(e => res.status(500).json({ error: e.message }));
533
608
  });
534
609
 
535
- app.post('/api/projects/:id/file', authMiddleware, (req, res) => {
610
+ app.post('/api/projects/:id/file', authMiddleware, requireRole('editor'), (req, res) => {
536
611
  (async () => {
537
612
  const projectPath = resolveProjectRoot(req);
538
613
  const rel = req.query.path;
@@ -636,7 +711,31 @@ export function createApp() {
636
711
  return res.status(400).json({ error: 'Username ≥3 chars, password ≥6 chars' });
637
712
  const hash = await bcrypt.hash(password, 12);
638
713
  const user = await createUser(username, hash);
639
- res.json({ token: makeToken(user), user: { id: user.id, username: user.username } });
714
+ res.json({
715
+ token: makeToken(user),
716
+ user: { id: user.id, username: user.username, role: userRole(user) },
717
+ });
718
+ } catch (e) {
719
+ res.status(500).json({ error: e.message });
720
+ }
721
+ });
722
+
723
+ app.post('/api/auth/password', authMiddleware, async (req, res) => {
724
+ try {
725
+ const current = req.body?.current;
726
+ const next = req.body?.next;
727
+ if (!current || !next) return res.status(400).json({ error: 'current and next required' });
728
+ if (String(next).length < 6) return res.status(400).json({ error: 'password ≥6 chars' });
729
+ const user = await findUser(req.user.username);
730
+ if (!user) return res.status(404).json({ error: 'not found' });
731
+ const ok = await bcrypt.compare(current, user.passwordHash);
732
+ if (!ok) return res.status(403).json({ error: 'current password incorrect' });
733
+ user.passwordHash = await bcrypt.hash(next, 12);
734
+ const store = await loadCreds();
735
+ const idx = store.users.findIndex(u => u.username === user.username);
736
+ if (idx >= 0) store.users[idx] = user;
737
+ await saveCreds(store);
738
+ res.json({ ok: true });
640
739
  } catch (e) {
641
740
  res.status(500).json({ error: e.message });
642
741
  }
@@ -650,12 +749,245 @@ export function createApp() {
650
749
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });
651
750
  const ok = await bcrypt.compare(password, user.passwordHash);
652
751
  if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
653
- res.json({ token: makeToken(user), user: { id: user.id, username: user.username } });
752
+ res.json({
753
+ token: makeToken(user),
754
+ user: { id: user.id, username: user.username, role: userRole(user) },
755
+ });
756
+ } catch (e) {
757
+ res.status(500).json({ error: e.message });
758
+ }
759
+ });
760
+
761
+ app.get('/api/agent-env', authMiddleware, async (req, res) => {
762
+ const doc = await loadAgentEnvDoc();
763
+ const name = normalizeProfileName(req.query.name);
764
+ const names = Object.keys(doc.profiles);
765
+ const env = name ? envForProfile(doc, name) : doc.env;
766
+ res.json({
767
+ keys: CLAUDE_ENV_KEYS,
768
+ name: name || '',
769
+ names,
770
+ env: redactEnv(env),
771
+ defaultEnv: redactEnv(doc.env),
772
+ profiles: Object.fromEntries(names.map(n => [n, redactEnv(doc.profiles[n])])),
773
+ process: redactEnv(pickAllowedEnv(process.env)),
774
+ });
775
+ });
776
+
777
+ app.put('/api/agent-env', authMiddleware, requireRole('editor'), async (req, res) => {
778
+ try {
779
+ const doc = await loadAgentEnvDoc();
780
+ const name = normalizeProfileName(req.body?.name || req.query.name);
781
+ const patch = req.body?.env || req.body;
782
+ if (name) {
783
+ doc.profiles[name] = patchEnv(doc.profiles[name] || {}, patch);
784
+ if (!Object.keys(doc.profiles[name]).length) delete doc.profiles[name];
785
+ } else {
786
+ doc.env = patchEnv(doc.env, patch);
787
+ }
788
+ await saveAgentEnvDoc(doc);
789
+ await applyAgentEnvToProcess(process.env);
790
+ const env = name ? envForProfile(doc, name) : doc.env;
791
+ res.json({
792
+ name: name || '',
793
+ names: Object.keys(doc.profiles),
794
+ env: redactEnv(env),
795
+ });
654
796
  } catch (e) {
655
797
  res.status(500).json({ error: e.message });
656
798
  }
657
799
  });
658
800
 
801
+ app.get('/api/collab/me', authMiddleware, (req, res) => {
802
+ res.json({
803
+ username: req.user.username,
804
+ role: liveRole(req),
805
+ userId: req.user.userId ?? req.user.id,
806
+ });
807
+ });
808
+
809
+ app.get('/api/collab/sync', authMiddleware, async (req, res) => {
810
+ const role = liveRole(req);
811
+ res.json({
812
+ serverTime: Date.now(),
813
+ locks: await collab.listLocks(),
814
+ routes: await collab.listRoutes(req.user.username, role),
815
+ machines: [{ id: localMachineId(), online: true }],
816
+ me: { username: req.user.username, role },
817
+ });
818
+ });
819
+
820
+ app.get('/api/collab/locks', authMiddleware, async (_req, res) => {
821
+ res.json(await collab.listLocks());
822
+ });
823
+
824
+ app.post('/api/collab/locks', authMiddleware, requireRole('editor'), async (req, res) => {
825
+ const r = await collab.acquire({
826
+ key: req.body.key,
827
+ kind: req.body.kind,
828
+ holder: req.user.username,
829
+ holderRole: liveRole(req),
830
+ lockToken: req.body.token,
831
+ ttlMs: (req.body.ttlSec || 60) * 1000,
832
+ exclusive: req.body.exclusive !== false,
833
+ });
834
+ if (r.code !== 200) return res.status(r.code).json({ error: r.error, lock: r.lock });
835
+ res.json(r.lock);
836
+ });
837
+
838
+ async function handleLockAction(req, res, key, action, token, ttlSec) {
839
+ key = decodeURIComponent(key || '');
840
+ if (action === 'heartbeat') {
841
+ const r = await collab.heartbeat({
842
+ key, holder: req.user.username, lockToken: token,
843
+ ttlMs: (ttlSec || 60) * 1000,
844
+ });
845
+ if (r.code !== 200) return res.status(r.code).json({ error: r.error });
846
+ return res.json(r.lock);
847
+ }
848
+ if (action === 'release') {
849
+ const r = await collab.release({
850
+ key, holder: req.user.username, lockToken: token, force: false,
851
+ });
852
+ if (r.code !== 200) return res.status(r.code).json({ error: r.error });
853
+ return res.json({ ok: true });
854
+ }
855
+ if (action === 'break') {
856
+ if (!roleAtLeast(liveRole(req), 'admin')) return res.status(403).json({ error: 'forbidden' });
857
+ await collab.release({ key, force: true });
858
+ return res.json({ ok: true });
859
+ }
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);
869
+ });
870
+
871
+ app.get('/api/collab/routes', authMiddleware, async (req, res) => {
872
+ res.json(await collab.listRoutes(req.user.username, liveRole(req)));
873
+ });
874
+
875
+ app.post('/api/collab/routes', authMiddleware, requireRole('editor'), async (req, res) => {
876
+ const r = await collab.putRoute(req.user.username, liveRole(req), {
877
+ ...req.body,
878
+ machineId: req.body.machineId || localMachineId(),
879
+ });
880
+ if (r.code !== 200) return res.status(r.code).json({ error: r.error });
881
+ res.json(r.route);
882
+ });
883
+
884
+ app.get('/api/collab/routes/:slug', authMiddleware, async (req, res) => {
885
+ const rt = await collab.getRoute(req.params.slug);
886
+ if (!rt) return res.status(404).json({ error: 'not found' });
887
+ res.json(rt);
888
+ });
889
+
890
+ app.delete('/api/collab/routes/:slug', authMiddleware, requireRole('editor'), async (req, res) => {
891
+ const r = await collab.deleteRoute(req.user.username, liveRole(req), req.params.slug);
892
+ if (r.code !== 200) return res.status(r.code).json({ error: r.error });
893
+ res.json({ ok: true });
894
+ });
895
+
896
+ app.get('/api/collab/users', authMiddleware, requireRole('admin'), async (_req, res) => {
897
+ const store = await loadCreds();
898
+ res.json(store.users.map(u => ({
899
+ id: u.id, username: u.username, role: userRole(u), disabled: !!u.disabled,
900
+ })));
901
+ });
902
+
903
+ app.post('/api/collab/users', authMiddleware, requireRole('admin'), async (req, res) => {
904
+ try {
905
+ const { username, password, role } = req.body || {};
906
+ if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
907
+ if (await findUser(username)) return res.status(409).json({ error: 'user exists' });
908
+ const actor = liveRole(req);
909
+ let nextRole = role || 'editor';
910
+ if (nextRole === 'owner' && actor !== 'owner') return res.status(403).json({ error: 'only owner can create owner' });
911
+ if (nextRole === 'admin' && actor !== 'owner') return res.status(403).json({ error: 'only owner can create admin' });
912
+ const hash = await bcrypt.hash(password, 12);
913
+ const user = await createUser(username, hash, nextRole);
914
+ res.json({ id: user.id, username: user.username, role: userRole(user) });
915
+ } catch (e) {
916
+ res.status(500).json({ error: e.message });
917
+ }
918
+ });
919
+
920
+ app.patch('/api/collab/users/:username', authMiddleware, requireRole('admin'), async (req, res) => {
921
+ try {
922
+ const user = await findUser(req.params.username);
923
+ if (!user) return res.status(404).json({ error: 'not found' });
924
+ const actor = liveRole(req);
925
+ if (userRole(user) === 'owner' && actor !== 'owner') return res.status(403).json({ error: 'cannot modify owner' });
926
+ const store = await loadCreds();
927
+ const idx = store.users.findIndex(u => u.username === user.username);
928
+ if (req.body?.password) {
929
+ if (String(req.body.password).length < 6) return res.status(400).json({ error: 'password ≥6 chars' });
930
+ user.passwordHash = await bcrypt.hash(req.body.password, 12);
931
+ }
932
+ if (req.body?.role) {
933
+ if (req.body.role === 'owner' && actor !== 'owner') return res.status(403).json({ error: 'cannot promote to owner' });
934
+ user.role = req.body.role;
935
+ }
936
+ if (typeof req.body?.disabled === 'boolean') user.disabled = req.body.disabled;
937
+ if (idx >= 0) store.users[idx] = user;
938
+ await saveCreds(store);
939
+ res.json({ ok: true, username: user.username, role: userRole(user) });
940
+ } catch (e) {
941
+ res.status(500).json({ error: e.message });
942
+ }
943
+ });
944
+
945
+ app.all('/api/port-proxy', authMiddleware, requireRole('editor'), async (req, res) => {
946
+ try {
947
+ const host = req.query.host || '127.0.0.1';
948
+ const port = req.query.port;
949
+ let reqPath = req.query.path || '/';
950
+ if (req.query.q) reqPath += (reqPath.includes('?') ? '&' : '?') + req.query.q;
951
+ const chunks = [];
952
+ if (req.readable && !req.readableEnded && req.method !== 'GET' && req.method !== 'HEAD') {
953
+ await new Promise((resolve, reject) => {
954
+ req.on('data', c => chunks.push(c));
955
+ req.on('end', resolve);
956
+ req.on('error', reject);
957
+ });
958
+ }
959
+ const out = await proxyToLocalPort({
960
+ host, port, reqPath, method: req.method,
961
+ headers: req.headers, body: Buffer.concat(chunks), allowRemote: allowRemoteProxy,
962
+ });
963
+ for (const [k, v] of Object.entries(out.headers || {})) {
964
+ if (k.toLowerCase() === 'content-length' || k.toLowerCase() === 'transfer-encoding') continue;
965
+ res.setHeader(k, v);
966
+ }
967
+ res.status(out.status).end(out.body);
968
+ } catch (e) {
969
+ res.status(e.status || 502).json({ error: e.message });
970
+ }
971
+ });
972
+
973
+ app.all('/r/:slug/*', authMiddleware, async (req, res) => {
974
+ try {
975
+ const rt = await collab.getRoute(req.params.slug);
976
+ if (!rt || !rt.enabled) return res.status(404).json({ error: 'route not found' });
977
+ const suffix = '/' + (req.params[0] || '');
978
+ const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) : '';
979
+ let reqPath = suffix || '/';
980
+ if (q) reqPath += (reqPath.includes('?') ? '&' : '?') + q;
981
+ const out = await proxyToLocalPort({
982
+ host: rt.targetHost, port: rt.targetPort, reqPath, method: req.method,
983
+ headers: req.headers, body: null, allowRemote: allowRemoteProxy,
984
+ });
985
+ res.status(out.status).end(out.body);
986
+ } catch (e) {
987
+ res.status(e.status || 502).json({ error: e.message });
988
+ }
989
+ });
990
+
659
991
  app.get('/api/projects/:id/files', authMiddleware, async (req, res) => {
660
992
  try {
661
993
  const projectPath = resolveProjectRoot(req);
@@ -863,7 +1195,7 @@ export function createApp() {
863
1195
 
864
1196
  app.post('/api/projects/notes', authMiddleware, async (req, res) => {
865
1197
  try {
866
- const { root, goal, notes } = req.body || {};
1198
+ const { root, goal, notes, hidden } = req.body || {};
867
1199
  if (!root) return res.status(400).json({ error: 'root required' });
868
1200
  const resolved = path.resolve(expandPath(root));
869
1201
  const db = await loadProjectNotes();
@@ -872,6 +1204,7 @@ export function createApp() {
872
1204
  const next = {
873
1205
  goal: goal !== undefined ? String(goal || '') : prev.goal,
874
1206
  notes: notes !== undefined ? String(notes || '') : prev.notes,
1207
+ hidden: hidden !== undefined ? Boolean(hidden) : prev.hidden,
875
1208
  updatedAt: Date.now(),
876
1209
  };
877
1210
  db[resolved] = next;
@@ -904,11 +1237,22 @@ export function createApp() {
904
1237
 
905
1238
  app.put('/api/sessions/meta', authMiddleware, async (req, res) => {
906
1239
  try {
907
- const { sessionId, agent, favorite, notes, title, hidden } = req.body || {};
1240
+ const { sessionId, agent, favorite, notes, title, hidden, branch } = req.body || {};
908
1241
  if (!sessionId) return res.status(400).json({ error: 'sessionId required' });
909
1242
  const key = sessionMetaKey(agent || 'claude', sessionId);
910
1243
  const db = await loadSessionMeta();
911
- const prev = db[key] || { favorite: false, notes: '', title: '', hidden: false, updatedAt: 0 };
1244
+ const prev = db[key] || { favorite: false, notes: '', title: '', hidden: false, branch: null, updatedAt: 0 };
1245
+ const nextBranch = branch === undefined
1246
+ ? (prev.branch || null)
1247
+ : (branch && typeof branch === 'object' && typeof branch.parentSessionId === 'string'
1248
+ ? {
1249
+ parentSessionId: branch.parentSessionId,
1250
+ parentAgent: String(branch.parentAgent || agent || 'claude'),
1251
+ parentMachineId: branch.parentMachineId ? String(branch.parentMachineId) : null,
1252
+ forkMessageIndex: Number.isInteger(branch.forkMessageIndex) ? branch.forkMessageIndex : null,
1253
+ forkedAt: Number.isFinite(Number(branch.forkedAt)) ? Number(branch.forkedAt) : Date.now(),
1254
+ }
1255
+ : null);
912
1256
  const next = {
913
1257
  favorite: favorite !== undefined ? Boolean(favorite) : Boolean(prev.favorite),
914
1258
  notes: notes !== undefined ? String(notes || '') : String(prev.notes || ''),
@@ -917,10 +1261,11 @@ export function createApp() {
917
1261
  ? String(title || '').trim().slice(0, 200)
918
1262
  : String(prev.title || '').trim(),
919
1263
  hidden: hidden !== undefined ? Boolean(hidden) : Boolean(prev.hidden),
1264
+ branch: nextBranch,
920
1265
  updatedAt: Date.now(),
921
1266
  };
922
1267
  // Drop empty entries to keep file small
923
- if (!next.favorite && !next.notes && !next.title && !next.hidden) {
1268
+ if (!next.favorite && !next.notes && !next.title && !next.hidden && !next.branch) {
924
1269
  delete db[key];
925
1270
  } else {
926
1271
  db[key] = next;
@@ -929,7 +1274,7 @@ export function createApp() {
929
1274
  res.json({
930
1275
  success: true,
931
1276
  key,
932
- ...(db[key] || { favorite: false, notes: '', title: '', hidden: false, updatedAt: Date.now() }),
1277
+ ...(db[key] || { favorite: false, notes: '', title: '', hidden: false, branch: null, updatedAt: Date.now() }),
933
1278
  });
934
1279
  } catch (e) { res.status(500).json({ error: e.message }); }
935
1280
  });
@@ -1075,7 +1420,10 @@ export function createApp() {
1075
1420
  compact,
1076
1421
  };
1077
1422
  res.setHeader('X-Session-Agent', payload.agent);
1078
- 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');
1079
1427
  res.json(payload);
1080
1428
  } catch (e) {
1081
1429
  res.status(500).json({ error: e.message });
@@ -1120,6 +1468,62 @@ export function createApp() {
1120
1468
  }
1121
1469
  });
1122
1470
 
1471
+ // Fork a session at a completed assistant message. Native transcript cloning
1472
+ // lets the target agent continue with the same context while branch topology
1473
+ // itself is saved by the UI as session metadata (Hub-safe as well).
1474
+ app.post('/api/sessions/:sessionId/fork', authMiddleware, async (req, res) => {
1475
+ try {
1476
+ const { sessionId } = req.params;
1477
+ const { sourceAgent, upTo, assistantText } = req.body || {};
1478
+ if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return res.status(400).json({ error: 'Invalid session id' });
1479
+ const detectedSourceAgent = String(sourceAgent || '').toLowerCase();
1480
+ if (!ENABLED_AGENTS.includes(detectedSourceAgent)) {
1481
+ return res.status(400).json({ error: 'A valid sourceAgent is required' });
1482
+ }
1483
+ if (!await enforceSessionLock(req, res, sessionId, detectedSourceAgent)) return;
1484
+ const opts = {
1485
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS,
1486
+ codexHome: CODEX_HOME,
1487
+ grokHome: GROK_HOME,
1488
+ geminiHome: GEMINI_HOME,
1489
+ cursorHome: CURSOR_HOME,
1490
+ };
1491
+ // A streamed assistant message may reach the screen a moment before the
1492
+ // CLI finishes its journal write. Retry briefly, then match the exact
1493
+ // output text; history index is preferred for already-loaded messages.
1494
+ let bundle = null;
1495
+ for (let attempt = 0; attempt < 3; attempt++) {
1496
+ bundle = await loadSessionMessages(sessionId, detectedSourceAgent, opts);
1497
+ const messages = bundle?.messages || [];
1498
+ if (Number.isInteger(upTo) && upTo >= 0 && upTo < messages.length) break;
1499
+ if (typeof assistantText === 'string' && assistantText.trim()
1500
+ && messages.some(m => m?.role === 'assistant' && m?.content === assistantText)) break;
1501
+ if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 180));
1502
+ }
1503
+ const messages = bundle?.messages || [];
1504
+ if (!messages.length) return res.status(404).json({ error: 'Source session history is not ready yet' });
1505
+ let forkIndex = Number.isInteger(upTo) && upTo >= 0 && upTo < messages.length ? upTo : -1;
1506
+ if (forkIndex < 0 && typeof assistantText === 'string' && assistantText.trim()) {
1507
+ for (let i = messages.length - 1; i >= 0; i--) {
1508
+ if (messages[i]?.role === 'assistant' && messages[i]?.content === assistantText) { forkIndex = i; break; }
1509
+ }
1510
+ }
1511
+ if (forkIndex < 0 || messages[forkIndex]?.role !== 'assistant') {
1512
+ return res.status(409).json({ error: 'The selected assistant message is not persisted yet. Please try again in a moment.' });
1513
+ }
1514
+ const { targetSessionId } = await convertSession(
1515
+ sessionId,
1516
+ detectedSourceAgent,
1517
+ detectedSourceAgent,
1518
+ opts,
1519
+ { upTo: forkIndex + 1 },
1520
+ );
1521
+ res.json({ success: true, targetSessionId, targetAgent: detectedSourceAgent, forkMessageIndex: forkIndex });
1522
+ } catch (e) {
1523
+ res.status(500).json({ error: e.message });
1524
+ }
1525
+ });
1526
+
1123
1527
  app.get('/api/sessions/:sessionId/memory', authMiddleware, async (req, res) => {
1124
1528
  try {
1125
1529
  const { sessionId } = req.params;
@@ -1476,6 +1880,40 @@ export function createApp() {
1476
1880
  const wssChat = new WebSocketServer(wssOpts);
1477
1881
  const wssShell = new WebSocketServer(wssOpts);
1478
1882
 
1883
+ // macOS supports recursive fs.watch; the non-recursive fallback still gives
1884
+ // us the periodic mirror safety net on platforms/filesystems that do not.
1885
+ // Watch only agent-owned record directories, never Terminal PTYs.
1886
+ const externalWatchers = [];
1887
+ let externalRefreshTimer = null;
1888
+ const requestExternalRefresh = () => {
1889
+ if (externalRefreshTimer) return;
1890
+ externalRefreshTimer = setTimeout(() => {
1891
+ externalRefreshTimer = null;
1892
+ externalSessionMirror.refresh().catch(() => {});
1893
+ // A CLI can create a brand-new native session without any browser being
1894
+ // subscribed yet. Let open UIs refresh their sidebar catalog as well.
1895
+ for (const client of chatClients) sendJson(client.ws, { type: 'external-sessions-changed' });
1896
+ }, 80);
1897
+ };
1898
+ for (const dir of [
1899
+ path.join(CODEX_HOME, 'sessions'),
1900
+ path.join(GROK_HOME, 'sessions'),
1901
+ ...CLAUDE_CONFIG_DIRS.map(d => path.join(d, 'projects')),
1902
+ GEMINI_HOME,
1903
+ CURSOR_HOME,
1904
+ ]) {
1905
+ try {
1906
+ externalWatchers.push(fsSync.watch(dir, { recursive: true }, requestExternalRefresh));
1907
+ } catch {
1908
+ try { externalWatchers.push(fsSync.watch(dir, requestExternalRefresh)); } catch { /* directory may not exist yet */ }
1909
+ }
1910
+ }
1911
+ server.once('close', () => {
1912
+ if (externalRefreshTimer) clearTimeout(externalRefreshTimer);
1913
+ for (const watcher of externalWatchers) watcher.close();
1914
+ externalSessionMirror.close();
1915
+ });
1916
+
1479
1917
  server.on('upgrade', (req, socket, head) => {
1480
1918
  const pathname = new URL(req.url, 'http://localhost').pathname;
1481
1919
  if (pathname === '/ws/chat') {
@@ -1503,7 +1941,15 @@ export function createApp() {
1503
1941
  return;
1504
1942
  }
1505
1943
 
1506
- const client = { ws, sessionId: url.searchParams.get('sessionId') || null };
1944
+ const client = {
1945
+ ws,
1946
+ sessionId: url.searchParams.get('sessionId') || null,
1947
+ agent: url.searchParams.get('agent') || null,
1948
+ username: decoded.username || null,
1949
+ role: decoded.role || (decoded.viaHub ? 'editor' : 'editor'),
1950
+ viaHub: !!decoded.viaHub,
1951
+ externalMirrorKey: null,
1952
+ };
1507
1953
  chatClients.add(client);
1508
1954
 
1509
1955
  // Broadcast to every chat socket on the same session (mobile reconnect / multi-tab).
@@ -1546,11 +1992,33 @@ export function createApp() {
1546
1992
 
1547
1993
  if (cmd.cmd === 'session.subscribe') {
1548
1994
  client.sessionId = cmd.sessionId || client.sessionId || null;
1995
+ client.agent = cmd.agent || client.agent || null;
1549
1996
  wireSend({
1550
1997
  type: 'run-status',
1551
1998
  sessionId: client.sessionId,
1552
1999
  running: isSessionBusy(client.sessionId),
1553
2000
  });
2001
+ // UI-managed active runs already stream their events directly. For
2002
+ // everything else, tail the native persisted transcript so an agent
2003
+ // launched in an ordinary terminal appears live in this UI.
2004
+ if (!isSessionBusy(client.sessionId)) {
2005
+ externalSessionMirror.subscribe(client, {
2006
+ sessionId: client.sessionId,
2007
+ agent: client.agent,
2008
+ });
2009
+ } else {
2010
+ externalSessionMirror.unsubscribe(client);
2011
+ }
2012
+ const queued = getGrokQueueState(client.sessionId);
2013
+ if (queued.running || queued.items.length) {
2014
+ wireSend({
2015
+ type: 'grok-queue-status',
2016
+ agent: 'grok',
2017
+ sessionId: client.sessionId,
2018
+ running: queued.running,
2019
+ items: queued.items,
2020
+ });
2021
+ }
1554
2022
  outbox.flush();
1555
2023
  return;
1556
2024
  }
@@ -1570,8 +2038,9 @@ export function createApp() {
1570
2038
 
1571
2039
  if (cmd.cmd === 'turn.interrupt') {
1572
2040
  const sid = cmd.sessionId;
1573
- const ok = abortSession(sid);
2041
+ const ok = abortSession(sid, { preserveQueue: cmd.preserveQueue === true });
1574
2042
  wireSend({ type: 'abort-result', success: ok, sessionId: sid });
2043
+ wireSend({ type: 'grok-queue-status', agent: 'grok', sessionId: sid, running: false, items: [] });
1575
2044
  outbox.flush();
1576
2045
  return;
1577
2046
  }
@@ -1582,6 +2051,24 @@ export function createApp() {
1582
2051
  if (cmd.sessionId) options.sessionId = cmd.sessionId;
1583
2052
  if (cmd.cwd) options.cwd = cmd.cwd;
1584
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
+ }
1585
2072
  // A socket may subscribe to another session while this agent is still
1586
2073
  // streaming. Keep this run's delivery target in its own closure rather
1587
2074
  // than consulting mutable client.sessionId in the shared socket outbox.
@@ -1614,11 +2101,9 @@ export function createApp() {
1614
2101
  } finally {
1615
2102
  // `complete` is a UI-facing agent event and is not a reliable
1616
2103
  // scheduling boundary (an adapter may emit it early or more than
1617
- // once). The hub advances its Grok FIFO only after dispatchAgent has
1618
- // actually returned from the ACP request.
1619
- if (agent === 'grok') {
1620
- runOutbox.send({ type: 'hub-turn-finished', agent, sessionId: runSessionId });
1621
- }
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 });
1622
2107
  runOutbox.flush();
1623
2108
  }
1624
2109
  return;
@@ -1666,6 +2151,7 @@ export function createApp() {
1666
2151
 
1667
2152
  ws.on('close', () => {
1668
2153
  chatClients.delete(client);
2154
+ externalSessionMirror.unsubscribe(client);
1669
2155
  clearInterval(heartbeat);
1670
2156
  outbox.flush();
1671
2157
  shellOut?.flush();
@@ -1788,6 +2274,7 @@ else:
1788
2274
  export async function init() {
1789
2275
  await loadCreds();
1790
2276
  await bootstrapEnvUser();
2277
+ await applyAgentEnvToProcess(process.env);
1791
2278
  await syncConfigDirs();
1792
2279
  console.log(`[agents] enabled: ${ENABLED_AGENTS.join(', ')}`);
1793
2280
  console.log(`[agents] claude=${CLAUDE_CLI_PATH} codex=${CODEX_CLI_PATH} grok=${GROK_CLI_PATH} gemini=${GEMINI_CLI_PATH}`);