claude-simple 0.2.23 → 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/README.md +20 -0
- package/client.js +20 -3
- package/dist/app.0.2.25.min.js +1164 -0
- package/dist/index.html +2 -2
- package/dist/style.0.2.25.min.css +1753 -0
- package/package.json +1 -1
- package/public/api.js +1 -0
- package/public/chat.js +213 -8
- package/public/router.js +5 -0
- package/public/settings.js +312 -63
- package/public/shell/notes.js +5 -2
- package/public/shell/session-list.js +50 -2
- package/public/shell/session-nav.js +45 -12
- package/public/state.js +3 -2
- package/public/style.css +126 -0
- package/public/ui.min.css +1 -1
- package/src/agent-env.js +211 -0
- package/src/agents/claude.js +10 -3
- package/src/agents/grok.js +77 -8
- package/src/agents/models.js +30 -1
- package/src/agents/sessions.js +27 -10
- package/src/app.js +450 -14
- package/src/collab.js +248 -0
- package/src/runtime/external-session-sync.js +106 -0
- package/src/runtime/protocol.js +3 -2
- package/dist/app.0.2.23.min.js +0 -1104
- package/dist/style.0.2.23.min.css +0 -1627
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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({
|
|
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({
|
|
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 = {
|
|
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}`);
|