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/README.md +25 -2
- package/client.js +20 -3
- package/dist/app.0.2.26.min.js +1179 -0
- package/dist/index.html +2 -2
- package/dist/style.0.2.26.min.css +1851 -0
- package/package.json +1 -1
- package/public/api.js +10 -3
- package/public/app.js +1 -1
- package/public/chat.js +260 -12
- package/public/files.js +50 -0
- package/public/index.html +2 -2
- package/public/locks.js +139 -0
- package/public/privacy.html +111 -0
- package/public/router.js +5 -0
- package/public/settings.js +312 -63
- package/public/shell/boot.js +3 -0
- package/public/shell/notes.js +11 -3
- package/public/shell/session-list.js +50 -2
- package/public/shell/session-nav.js +49 -12
- package/public/shell/templates.js +5 -1
- package/public/shell.js +16 -4
- package/public/state.js +6 -2
- package/public/style.css +227 -3
- package/public/sw.js +3 -3
- 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 +24 -0
- package/src/agents/meta-agent.js +89 -17
- package/src/agents/models.js +30 -1
- package/src/agents/sessions.js +12 -2
- package/src/app.js +512 -25
- package/src/collab.js +267 -0
- package/src/runtime/external-session-sync.js +106 -0
- package/src/runtime/protocol.js +11 -2
- package/dist/app.0.2.24.min.js +0 -1104
- package/dist/style.0.2.24.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
|
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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 = {
|
|
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).
|
|
1618
|
-
// actually returned
|
|
1619
|
-
|
|
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}`);
|