claude-simple 0.2.25 → 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 +6 -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 +9 -3
- package/public/app.js +1 -1
- package/public/chat.js +66 -23
- 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/shell/boot.js +3 -0
- package/public/shell/notes.js +6 -1
- package/public/shell/session-nav.js +4 -0
- package/public/shell/templates.js +5 -1
- package/public/shell.js +16 -4
- package/public/state.js +3 -0
- package/public/style.css +101 -3
- package/public/sw.js +3 -3
- package/public/ui.min.css +1 -1
- package/src/agents/meta-agent.js +89 -17
- package/src/app.js +69 -18
- package/src/collab.js +84 -65
- package/src/runtime/protocol.js +8 -0
- package/dist/app.0.2.25.min.js +0 -1164
- package/dist/style.0.2.25.min.css +0 -1753
package/src/agents/meta-agent.js
CHANGED
|
@@ -28,8 +28,8 @@ import crypto from 'crypto';
|
|
|
28
28
|
import { spawn } from 'child_process';
|
|
29
29
|
|
|
30
30
|
export const AI_DEFAULTS = {
|
|
31
|
-
url: 'https://api.
|
|
32
|
-
model: '
|
|
31
|
+
url: 'https://api.xiaomimimo.com/v1/chat/completions',
|
|
32
|
+
model: 'mimo-v2.5',
|
|
33
33
|
vlm_url: '',
|
|
34
34
|
vlm_model: '',
|
|
35
35
|
temperature: 0.3,
|
|
@@ -51,7 +51,8 @@ export const DEFAULT_SYSTEM = `你是 Claude Simple UI 的内置工作助手(M
|
|
|
51
51
|
- 同一批图的追问:传入上次返回的 thread_id + 新 prompt,可复用图片与历史问答,不必重复传路径(也可继续传 paths 以补图)。
|
|
52
52
|
- 不要假装已经看到图片;没调用工具就说「根据路径无法看见内容」。
|
|
53
53
|
6. 需要验证时:write_file 写代码 → run_command 执行 → 根据输出迭代。
|
|
54
|
-
7.
|
|
54
|
+
7. 查找项目文件时优先用 find_files;先缩小范围再 read_file,不要用 run_command 代替只读检索。
|
|
55
|
+
8. 回复简洁有结构,中文优先(用户用中文时)。`;
|
|
55
56
|
|
|
56
57
|
export const REPORT_SYSTEM = `你是工作复盘助手。根据提供的 JSON 活动快照,写一份清晰的工作报告(Markdown)。
|
|
57
58
|
结构建议:
|
|
@@ -292,6 +293,23 @@ const TOOLS = [
|
|
|
292
293
|
},
|
|
293
294
|
},
|
|
294
295
|
},
|
|
296
|
+
{
|
|
297
|
+
type: 'function',
|
|
298
|
+
function: {
|
|
299
|
+
name: 'find_files',
|
|
300
|
+
description: '按文件名在目录树中查找文件;适合先定位代码、配置或文档,再用 read_file 读取内容',
|
|
301
|
+
parameters: {
|
|
302
|
+
type: 'object',
|
|
303
|
+
required: ['path', 'query'],
|
|
304
|
+
properties: {
|
|
305
|
+
path: { type: 'string', description: '要搜索的根目录(绝对路径)' },
|
|
306
|
+
query: { type: 'string', description: '文件名关键词,不区分大小写' },
|
|
307
|
+
max: { type: 'integer', description: '最多返回条数,默认 80,上限 200' },
|
|
308
|
+
maxDepth: { type: 'integer', description: '最大目录深度,默认 5,上限 10' },
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
},
|
|
295
313
|
{
|
|
296
314
|
type: 'function',
|
|
297
315
|
function: {
|
|
@@ -504,12 +522,16 @@ export function createMetaAgent(deps) {
|
|
|
504
522
|
cursorHome,
|
|
505
523
|
} = deps;
|
|
506
524
|
|
|
507
|
-
const configFile = path.join(rootDir, '.ai_config.json');
|
|
508
|
-
const reportsFile = path.join(rootDir, '.ai_reports.json');
|
|
509
|
-
const legacySessionsFile = path.join(rootDir, '.ai_sessions.json');
|
|
510
525
|
const aiDir = process.env.AI_DATA_DIR
|
|
511
526
|
? expandPath(process.env.AI_DATA_DIR)
|
|
512
527
|
: path.join(rootDir, '.ai');
|
|
528
|
+
// Keep credentials and reports outside an npm-installed package whenever
|
|
529
|
+
// AI_DATA_DIR is configured. Legacy files are read once for compatibility.
|
|
530
|
+
const configFile = path.join(aiDir, 'config.json');
|
|
531
|
+
const reportsFile = path.join(aiDir, 'reports.json');
|
|
532
|
+
const legacyConfigFile = path.join(rootDir, '.ai_config.json');
|
|
533
|
+
const legacyReportsFile = path.join(rootDir, '.ai_reports.json');
|
|
534
|
+
const legacySessionsFile = path.join(rootDir, '.ai_sessions.json');
|
|
513
535
|
const notesFile = path.join(aiDir, 'meta-notes.json');
|
|
514
536
|
const sessionBriefsFile = path.join(aiDir, 'session-briefs.json');
|
|
515
537
|
const sessionsDir = path.join(aiDir, 'sessions');
|
|
@@ -1455,10 +1477,15 @@ export function createMetaAgent(deps) {
|
|
|
1455
1477
|
|
|
1456
1478
|
async function loadConfig() {
|
|
1457
1479
|
if (configCache) return configCache;
|
|
1480
|
+
await ensureAiDirs();
|
|
1458
1481
|
try {
|
|
1459
1482
|
configCache = { ...AI_DEFAULTS, ...JSON.parse(await fs.readFile(configFile, 'utf8')) };
|
|
1460
1483
|
} catch {
|
|
1461
|
-
|
|
1484
|
+
try {
|
|
1485
|
+
configCache = { ...AI_DEFAULTS, ...JSON.parse(await fs.readFile(legacyConfigFile, 'utf8')) };
|
|
1486
|
+
} catch {
|
|
1487
|
+
configCache = { ...AI_DEFAULTS, key: '', vlm_key: '', system: DEFAULT_SYSTEM };
|
|
1488
|
+
}
|
|
1462
1489
|
}
|
|
1463
1490
|
if (!configCache.system) configCache.system = DEFAULT_SYSTEM;
|
|
1464
1491
|
return configCache;
|
|
@@ -1492,10 +1519,15 @@ export function createMetaAgent(deps) {
|
|
|
1492
1519
|
|
|
1493
1520
|
async function loadReports() {
|
|
1494
1521
|
if (reportsCache) return reportsCache;
|
|
1522
|
+
await ensureAiDirs();
|
|
1495
1523
|
try {
|
|
1496
1524
|
reportsCache = JSON.parse(await fs.readFile(reportsFile, 'utf8'));
|
|
1497
1525
|
} catch {
|
|
1498
|
-
|
|
1526
|
+
try {
|
|
1527
|
+
reportsCache = JSON.parse(await fs.readFile(legacyReportsFile, 'utf8'));
|
|
1528
|
+
} catch {
|
|
1529
|
+
reportsCache = {};
|
|
1530
|
+
}
|
|
1499
1531
|
}
|
|
1500
1532
|
return reportsCache;
|
|
1501
1533
|
}
|
|
@@ -1622,11 +1654,26 @@ export function createMetaAgent(deps) {
|
|
|
1622
1654
|
return res;
|
|
1623
1655
|
}
|
|
1624
1656
|
|
|
1657
|
+
function isMimoModel(model) {
|
|
1658
|
+
return String(model || '').toLowerCase().includes('mimo');
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function providerOptions(body) {
|
|
1662
|
+
if (!isMimoModel(body?.model)) return body;
|
|
1663
|
+
// `extra_body` is an SDK-only Python convenience. This server uses fetch,
|
|
1664
|
+
// so MiMo's provider option must be sent at the JSON body top level.
|
|
1665
|
+
return {
|
|
1666
|
+
...body,
|
|
1667
|
+
max_completion_tokens: body.max_completion_tokens || 2048,
|
|
1668
|
+
thinking: { type: 'disabled' },
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1625
1672
|
async function chatSync(cfg, body) {
|
|
1626
1673
|
const res = await chatCompletions({
|
|
1627
1674
|
url: cfg.url || AI_DEFAULTS.url,
|
|
1628
1675
|
apiKey: cfg.key || '',
|
|
1629
|
-
body: { ...body, stream: false },
|
|
1676
|
+
body: providerOptions({ ...body, stream: false }),
|
|
1630
1677
|
});
|
|
1631
1678
|
return res.json();
|
|
1632
1679
|
}
|
|
@@ -1635,7 +1682,7 @@ export function createMetaAgent(deps) {
|
|
|
1635
1682
|
const res = await chatCompletions({
|
|
1636
1683
|
url: cfg.url || AI_DEFAULTS.url,
|
|
1637
1684
|
apiKey: cfg.key || '',
|
|
1638
|
-
body: { ...body, stream: true },
|
|
1685
|
+
body: providerOptions({ ...body, stream: true }),
|
|
1639
1686
|
});
|
|
1640
1687
|
const reader = res.body.getReader();
|
|
1641
1688
|
const dec = new TextDecoder();
|
|
@@ -1821,6 +1868,37 @@ export function createMetaAgent(deps) {
|
|
|
1821
1868
|
path: path.join(dir, e.name),
|
|
1822
1869
|
}));
|
|
1823
1870
|
}
|
|
1871
|
+
case 'find_files': {
|
|
1872
|
+
const root = expandPath(args.path);
|
|
1873
|
+
if (!path.isAbsolute(root)) return { error: 'path must be absolute' };
|
|
1874
|
+
const query = String(args.query || '').trim().toLowerCase();
|
|
1875
|
+
if (!query) return { error: 'query required' };
|
|
1876
|
+
const max = Math.min(Math.max(args.max || 80, 1), 200);
|
|
1877
|
+
const maxDepth = Math.min(Math.max(args.maxDepth || 5, 0), 10);
|
|
1878
|
+
const ignored = new Set(['.git', 'node_modules', 'dist', 'build', '.next', '.dart_tool']);
|
|
1879
|
+
const matches = [];
|
|
1880
|
+
async function visit(dir, depth) {
|
|
1881
|
+
if (matches.length >= max || depth > maxDepth) return;
|
|
1882
|
+
let entries;
|
|
1883
|
+
try {
|
|
1884
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1885
|
+
} catch {
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
for (const entry of entries) {
|
|
1889
|
+
if (matches.length >= max) return;
|
|
1890
|
+
if (ignored.has(entry.name) || entry.isSymbolicLink()) continue;
|
|
1891
|
+
const full = path.join(dir, entry.name);
|
|
1892
|
+
if (entry.isDirectory()) {
|
|
1893
|
+
await visit(full, depth + 1);
|
|
1894
|
+
} else if (entry.isFile() && entry.name.toLowerCase().includes(query)) {
|
|
1895
|
+
matches.push(full);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
await visit(root, 0);
|
|
1900
|
+
return { root, query, matches, truncated: matches.length >= max };
|
|
1901
|
+
}
|
|
1824
1902
|
case 'read_file': {
|
|
1825
1903
|
const p = expandPath(args.path);
|
|
1826
1904
|
const maxBytes = Math.min(args.maxBytes || 120000, 500000);
|
|
@@ -1989,7 +2067,7 @@ export function createMetaAgent(deps) {
|
|
|
1989
2067
|
const res = await chatCompletions({
|
|
1990
2068
|
url: vlmUrl,
|
|
1991
2069
|
apiKey: vlmKey,
|
|
1992
|
-
body: { ...body, stream: false },
|
|
2070
|
+
body: providerOptions({ ...body, stream: false }),
|
|
1993
2071
|
});
|
|
1994
2072
|
const json = await res.json();
|
|
1995
2073
|
const text = json.choices?.[0]?.message?.content || '';
|
|
@@ -2097,12 +2175,6 @@ export function createMetaAgent(deps) {
|
|
|
2097
2175
|
stream: true,
|
|
2098
2176
|
temperature: config.temperature ?? 0.3,
|
|
2099
2177
|
};
|
|
2100
|
-
// some providers
|
|
2101
|
-
if (body.model && String(body.model).includes('mimo')) {
|
|
2102
|
-
body.max_completion_tokens = 2048;
|
|
2103
|
-
body.extra_body = { thinking: { type: 'disabled' } };
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
2178
|
await streamChat(config, body, (chunk) => {
|
|
2107
2179
|
const d = chunk.choices?.[0]?.delta;
|
|
2108
2180
|
if (d?.content) {
|
package/src/app.js
CHANGED
|
@@ -142,15 +142,16 @@ let sessionMetaCache = null;
|
|
|
142
142
|
|
|
143
143
|
/** Normalize project entry: old { notes } was the goal line. */
|
|
144
144
|
function normalizeProjectEntry(entry) {
|
|
145
|
-
if (!entry || typeof entry !== 'object') return { goal: '', notes: '', updatedAt: 0 };
|
|
145
|
+
if (!entry || typeof entry !== 'object') return { goal: '', notes: '', hidden: false, updatedAt: 0 };
|
|
146
146
|
const hasGoalKey = Object.prototype.hasOwnProperty.call(entry, 'goal');
|
|
147
147
|
if (!hasGoalKey && entry.notes != null) {
|
|
148
148
|
// legacy: single notes field = Project Goal
|
|
149
|
-
return { goal: String(entry.notes || ''), notes: '', updatedAt: entry.updatedAt || 0 };
|
|
149
|
+
return { goal: String(entry.notes || ''), notes: '', hidden: Boolean(entry.hidden), updatedAt: entry.updatedAt || 0 };
|
|
150
150
|
}
|
|
151
151
|
return {
|
|
152
152
|
goal: String(entry.goal || ''),
|
|
153
153
|
notes: String(entry.notes || ''),
|
|
154
|
+
hidden: Boolean(entry.hidden),
|
|
154
155
|
updatedAt: entry.updatedAt || 0,
|
|
155
156
|
};
|
|
156
157
|
}
|
|
@@ -429,9 +430,9 @@ async function dispatchAgent(agent, command, options, send) {
|
|
|
429
430
|
}
|
|
430
431
|
}
|
|
431
432
|
|
|
432
|
-
function abortSession(sessionId) {
|
|
433
|
+
function abortSession(sessionId, { preserveQueue = false } = {}) {
|
|
433
434
|
if (!sessionId) return false;
|
|
434
|
-
let ok = cancelGrokQueue(sessionId) > 0;
|
|
435
|
+
let ok = !preserveQueue && cancelGrokQueue(sessionId) > 0;
|
|
435
436
|
cancelApprovalsForSession(sessionId);
|
|
436
437
|
// Grok/Codex may register under temp key + real sessionId — abort every match.
|
|
437
438
|
for (const [key, s] of activeSessions) {
|
|
@@ -522,6 +523,25 @@ export function createApp() {
|
|
|
522
523
|
function liveRole(req) {
|
|
523
524
|
return req.user?.role || 'viewer';
|
|
524
525
|
}
|
|
526
|
+
function sessionResourceKey(sessionId, agent) {
|
|
527
|
+
return `session:${localMachineId()}:${agent || 'claude'}:${sessionId}`;
|
|
528
|
+
}
|
|
529
|
+
async function enforceSessionLock(req, res, sessionId, agent) {
|
|
530
|
+
if (!sessionId) return true;
|
|
531
|
+
const key = req.headers['x-lock-key'] || sessionResourceKey(sessionId, agent);
|
|
532
|
+
const r = await collab.requireLock({
|
|
533
|
+
key,
|
|
534
|
+
lockToken: req.headers['x-lock-token'],
|
|
535
|
+
writer: req.user.username,
|
|
536
|
+
writerRole: liveRole(req),
|
|
537
|
+
});
|
|
538
|
+
if (r.code !== 200) {
|
|
539
|
+
res.status(r.code).json({ error: r.error, lock: r.lock });
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
if (r.lock?.token) res.setHeader('X-Lock-Token', r.lock.token);
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
525
545
|
function requireRole(need) {
|
|
526
546
|
return (req, res, next) => {
|
|
527
547
|
if (!roleAtLeast(liveRole(req), need)) {
|
|
@@ -815,20 +835,19 @@ export function createApp() {
|
|
|
815
835
|
res.json(r.lock);
|
|
816
836
|
});
|
|
817
837
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const action = req.body?.action;
|
|
838
|
+
async function handleLockAction(req, res, key, action, token, ttlSec) {
|
|
839
|
+
key = decodeURIComponent(key || '');
|
|
821
840
|
if (action === 'heartbeat') {
|
|
822
841
|
const r = await collab.heartbeat({
|
|
823
|
-
key, holder: req.user.username, lockToken:
|
|
824
|
-
ttlMs: (
|
|
842
|
+
key, holder: req.user.username, lockToken: token,
|
|
843
|
+
ttlMs: (ttlSec || 60) * 1000,
|
|
825
844
|
});
|
|
826
845
|
if (r.code !== 200) return res.status(r.code).json({ error: r.error });
|
|
827
846
|
return res.json(r.lock);
|
|
828
847
|
}
|
|
829
848
|
if (action === 'release') {
|
|
830
849
|
const r = await collab.release({
|
|
831
|
-
key, holder: req.user.username, lockToken:
|
|
850
|
+
key, holder: req.user.username, lockToken: token, force: false,
|
|
832
851
|
});
|
|
833
852
|
if (r.code !== 200) return res.status(r.code).json({ error: r.error });
|
|
834
853
|
return res.json({ ok: true });
|
|
@@ -839,6 +858,14 @@ export function createApp() {
|
|
|
839
858
|
return res.json({ ok: true });
|
|
840
859
|
}
|
|
841
860
|
res.status(400).json({ error: 'unknown action' });
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
app.post('/api/collab/lock-action', authMiddleware, async (req, res) => {
|
|
864
|
+
await handleLockAction(req, res, req.body?.key, req.body?.action, req.body?.token, req.body?.ttlSec);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
app.post('/api/collab/locks/:key/:action', authMiddleware, async (req, res) => {
|
|
868
|
+
await handleLockAction(req, res, req.params.key, req.params.action, req.body?.token, req.body?.ttlSec);
|
|
842
869
|
});
|
|
843
870
|
|
|
844
871
|
app.get('/api/collab/routes', authMiddleware, async (req, res) => {
|
|
@@ -1168,7 +1195,7 @@ export function createApp() {
|
|
|
1168
1195
|
|
|
1169
1196
|
app.post('/api/projects/notes', authMiddleware, async (req, res) => {
|
|
1170
1197
|
try {
|
|
1171
|
-
const { root, goal, notes } = req.body || {};
|
|
1198
|
+
const { root, goal, notes, hidden } = req.body || {};
|
|
1172
1199
|
if (!root) return res.status(400).json({ error: 'root required' });
|
|
1173
1200
|
const resolved = path.resolve(expandPath(root));
|
|
1174
1201
|
const db = await loadProjectNotes();
|
|
@@ -1177,6 +1204,7 @@ export function createApp() {
|
|
|
1177
1204
|
const next = {
|
|
1178
1205
|
goal: goal !== undefined ? String(goal || '') : prev.goal,
|
|
1179
1206
|
notes: notes !== undefined ? String(notes || '') : prev.notes,
|
|
1207
|
+
hidden: hidden !== undefined ? Boolean(hidden) : prev.hidden,
|
|
1180
1208
|
updatedAt: Date.now(),
|
|
1181
1209
|
};
|
|
1182
1210
|
db[resolved] = next;
|
|
@@ -1392,7 +1420,10 @@ export function createApp() {
|
|
|
1392
1420
|
compact,
|
|
1393
1421
|
};
|
|
1394
1422
|
res.setHeader('X-Session-Agent', payload.agent);
|
|
1395
|
-
|
|
1423
|
+
// A session can be appended while a turn is streaming. Returning a
|
|
1424
|
+
// cached transcript here lets a client paint an old snapshot after a
|
|
1425
|
+
// newer WS update, so session history must always be read fresh.
|
|
1426
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
1396
1427
|
res.json(payload);
|
|
1397
1428
|
} catch (e) {
|
|
1398
1429
|
res.status(500).json({ error: e.message });
|
|
@@ -1449,6 +1480,7 @@ export function createApp() {
|
|
|
1449
1480
|
if (!ENABLED_AGENTS.includes(detectedSourceAgent)) {
|
|
1450
1481
|
return res.status(400).json({ error: 'A valid sourceAgent is required' });
|
|
1451
1482
|
}
|
|
1483
|
+
if (!await enforceSessionLock(req, res, sessionId, detectedSourceAgent)) return;
|
|
1452
1484
|
const opts = {
|
|
1453
1485
|
claudeConfigDirs: CLAUDE_CONFIG_DIRS,
|
|
1454
1486
|
codexHome: CODEX_HOME,
|
|
@@ -1913,6 +1945,9 @@ export function createApp() {
|
|
|
1913
1945
|
ws,
|
|
1914
1946
|
sessionId: url.searchParams.get('sessionId') || null,
|
|
1915
1947
|
agent: url.searchParams.get('agent') || null,
|
|
1948
|
+
username: decoded.username || null,
|
|
1949
|
+
role: decoded.role || (decoded.viaHub ? 'editor' : 'editor'),
|
|
1950
|
+
viaHub: !!decoded.viaHub,
|
|
1916
1951
|
externalMirrorKey: null,
|
|
1917
1952
|
};
|
|
1918
1953
|
chatClients.add(client);
|
|
@@ -2003,7 +2038,7 @@ export function createApp() {
|
|
|
2003
2038
|
|
|
2004
2039
|
if (cmd.cmd === 'turn.interrupt') {
|
|
2005
2040
|
const sid = cmd.sessionId;
|
|
2006
|
-
const ok = abortSession(sid);
|
|
2041
|
+
const ok = abortSession(sid, { preserveQueue: cmd.preserveQueue === true });
|
|
2007
2042
|
wireSend({ type: 'abort-result', success: ok, sessionId: sid });
|
|
2008
2043
|
wireSend({ type: 'grok-queue-status', agent: 'grok', sessionId: sid, running: false, items: [] });
|
|
2009
2044
|
outbox.flush();
|
|
@@ -2016,6 +2051,24 @@ export function createApp() {
|
|
|
2016
2051
|
if (cmd.sessionId) options.sessionId = cmd.sessionId;
|
|
2017
2052
|
if (cmd.cwd) options.cwd = cmd.cwd;
|
|
2018
2053
|
if (options.sessionId) client.sessionId = options.sessionId;
|
|
2054
|
+
// Hub already enforced the lease (X-Hub-User). Standalone checks here.
|
|
2055
|
+
if (!client.viaHub && options.sessionId && client.username) {
|
|
2056
|
+
const lockTok = cmd.lockToken || options.lockToken || msg.lockToken;
|
|
2057
|
+
const r = await collab.requireLock({
|
|
2058
|
+
key: sessionResourceKey(options.sessionId, agent),
|
|
2059
|
+
lockToken: lockTok,
|
|
2060
|
+
writer: client.username,
|
|
2061
|
+
writerRole: client.role || 'editor',
|
|
2062
|
+
});
|
|
2063
|
+
if (r.code !== 200) {
|
|
2064
|
+
wireSend({
|
|
2065
|
+
type: 'error', code: r.code, message: r.error || 'locked',
|
|
2066
|
+
lock: r.lock, sessionId: options.sessionId,
|
|
2067
|
+
});
|
|
2068
|
+
outbox.flush();
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2019
2072
|
// A socket may subscribe to another session while this agent is still
|
|
2020
2073
|
// streaming. Keep this run's delivery target in its own closure rather
|
|
2021
2074
|
// than consulting mutable client.sessionId in the shared socket outbox.
|
|
@@ -2048,11 +2101,9 @@ export function createApp() {
|
|
|
2048
2101
|
} finally {
|
|
2049
2102
|
// `complete` is a UI-facing agent event and is not a reliable
|
|
2050
2103
|
// scheduling boundary (an adapter may emit it early or more than
|
|
2051
|
-
// once).
|
|
2052
|
-
// actually returned
|
|
2053
|
-
|
|
2054
|
-
runOutbox.send({ type: 'hub-turn-finished', agent, sessionId: runSessionId });
|
|
2055
|
-
}
|
|
2104
|
+
// once). Clients may only advance a local FIFO after the adapter has
|
|
2105
|
+
// actually returned and released the session, for every agent.
|
|
2106
|
+
runOutbox.send({ type: 'hub-turn-finished', agent, sessionId: runSessionId });
|
|
2056
2107
|
runOutbox.flush();
|
|
2057
2108
|
}
|
|
2058
2109
|
return;
|
package/src/collab.js
CHANGED
|
@@ -15,6 +15,12 @@ export function roleAtLeast(have, need) {
|
|
|
15
15
|
export function createCollab({ filePath }) {
|
|
16
16
|
let data = { users: [], locks: {}, routes: {} };
|
|
17
17
|
let loaded = false;
|
|
18
|
+
let chain = Promise.resolve();
|
|
19
|
+
function withMutex(fn) {
|
|
20
|
+
const run = chain.then(fn, fn);
|
|
21
|
+
chain = run.then(() => undefined, () => undefined);
|
|
22
|
+
return run;
|
|
23
|
+
}
|
|
18
24
|
|
|
19
25
|
async function load() {
|
|
20
26
|
if (loaded) return data;
|
|
@@ -49,82 +55,95 @@ export function createCollab({ filePath }) {
|
|
|
49
55
|
function token() { return crypto.randomBytes(16).toString('hex'); }
|
|
50
56
|
|
|
51
57
|
async function acquire({ key, kind, holder, holderRole, lockToken, ttlMs, exclusive = true }) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!key) return { code: 400, error: 'key required' };
|
|
58
|
-
ttlMs = Math.min(Math.max(ttlMs || DEFAULT_TTL_MS, 5_000), 5 * 60_000);
|
|
59
|
-
const cur = data.locks[key];
|
|
60
|
-
if (!cur) {
|
|
61
|
-
const l = {
|
|
62
|
-
key, kind, holder, holderRole,
|
|
63
|
-
token: lockToken || token(),
|
|
64
|
-
exclusive, acquiredAt: now(), expiresAt: now() + ttlMs, version: 1,
|
|
65
|
-
};
|
|
66
|
-
data.locks[key] = l;
|
|
67
|
-
await save();
|
|
68
|
-
return { code: 200, lock: { ...l } };
|
|
69
|
-
}
|
|
70
|
-
if (cur.holder === holder && (!lockToken || lockToken === cur.token)) {
|
|
71
|
-
cur.expiresAt = now() + ttlMs;
|
|
72
|
-
if (!lockToken) {
|
|
73
|
-
cur.token = token();
|
|
74
|
-
cur.version += 1;
|
|
58
|
+
return withMutex(async () => {
|
|
59
|
+
await load();
|
|
60
|
+
purgeLocks();
|
|
61
|
+
if (!roleAtLeast(holderRole, 'editor')) {
|
|
62
|
+
return { code: 403, error: 'viewers cannot take exclusive locks' };
|
|
75
63
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
64
|
+
if (!key) return { code: 400, error: 'key required' };
|
|
65
|
+
ttlMs = Math.min(Math.max(ttlMs || DEFAULT_TTL_MS, 5_000), 5 * 60_000);
|
|
66
|
+
const cur = data.locks[key];
|
|
67
|
+
if (!cur) {
|
|
68
|
+
const l = {
|
|
69
|
+
key, kind, holder, holderRole,
|
|
70
|
+
token: lockToken || token(),
|
|
71
|
+
exclusive, acquiredAt: now(), expiresAt: now() + ttlMs, version: 1,
|
|
72
|
+
};
|
|
73
|
+
data.locks[key] = l;
|
|
74
|
+
await save();
|
|
75
|
+
return { code: 200, lock: { ...l } };
|
|
76
|
+
}
|
|
77
|
+
// Same principal always reclaims. Never rotate the token on a token-less
|
|
78
|
+
// refresh: rotating locked the holder's other tabs out of their own session.
|
|
79
|
+
if (cur.holder === holder) {
|
|
80
|
+
cur.expiresAt = now() + ttlMs;
|
|
81
|
+
cur.holderRole = holderRole || cur.holderRole;
|
|
82
|
+
await save();
|
|
83
|
+
return { code: 200, lock: { ...cur } };
|
|
84
|
+
}
|
|
85
|
+
const pub = { ...cur, token: undefined };
|
|
86
|
+
return { code: 423, error: 'locked', lock: pub };
|
|
87
|
+
});
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
async function heartbeat({ key, holder, lockToken, ttlMs }) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
return withMutex(async () => {
|
|
92
|
+
await load();
|
|
93
|
+
purgeLocks();
|
|
94
|
+
const cur = data.locks[key];
|
|
95
|
+
if (!cur) return { code: 404, error: 'no lock' };
|
|
96
|
+
if (cur.holder !== holder || cur.token !== lockToken) {
|
|
97
|
+
return { code: 409, error: 'token mismatch' };
|
|
98
|
+
}
|
|
99
|
+
cur.expiresAt = now() + (ttlMs || DEFAULT_TTL_MS);
|
|
100
|
+
await save();
|
|
101
|
+
return { code: 200, lock: { ...cur } };
|
|
102
|
+
});
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
async function release({ key, holder, lockToken, force }) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
return withMutex(async () => {
|
|
107
|
+
await load();
|
|
108
|
+
const cur = data.locks[key];
|
|
109
|
+
if (!cur) return { code: 200 };
|
|
110
|
+
// Holder can drop their own lease without presenting the token (Flutter
|
|
111
|
+
// lock list has no stored token). Anyone else needs force-break.
|
|
112
|
+
if (!force && cur.holder !== holder) {
|
|
113
|
+
return { code: 409, error: 'token mismatch' };
|
|
114
|
+
}
|
|
115
|
+
delete data.locks[key];
|
|
116
|
+
await save();
|
|
117
|
+
return { code: 200 };
|
|
118
|
+
});
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
async function requireLock({ key, lockToken, writer, writerRole }) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
return withMutex(async () => {
|
|
123
|
+
await load();
|
|
124
|
+
purgeLocks();
|
|
125
|
+
if (!roleAtLeast(writerRole, 'editor')) {
|
|
126
|
+
return { code: 403, error: 'forbidden' };
|
|
127
|
+
}
|
|
128
|
+
let cur = data.locks[key];
|
|
129
|
+
if (!cur) {
|
|
130
|
+
const l = {
|
|
131
|
+
key, kind: 'auto', holder: writer, holderRole: writerRole,
|
|
132
|
+
token: lockToken || token(), exclusive: true,
|
|
133
|
+
acquiredAt: now(), expiresAt: now() + AUTO_CLAIM_MS, version: 1,
|
|
134
|
+
};
|
|
135
|
+
data.locks[key] = l;
|
|
136
|
+
await save();
|
|
137
|
+
return { code: 200, lock: { ...l } };
|
|
138
|
+
}
|
|
139
|
+
// Same holder may omit or present a stale token; they already own the lease.
|
|
140
|
+
if (cur.holder === writer) {
|
|
141
|
+
cur.expiresAt = now() + DEFAULT_TTL_MS;
|
|
142
|
+
await save();
|
|
143
|
+
return { code: 200, lock: { ...cur } };
|
|
144
|
+
}
|
|
123
145
|
return { code: 423, error: 'locked', lock: { ...cur, token: undefined } };
|
|
124
|
-
}
|
|
125
|
-
cur.expiresAt = now() + DEFAULT_TTL_MS;
|
|
126
|
-
await save();
|
|
127
|
-
return { code: 200, lock: { ...cur } };
|
|
146
|
+
});
|
|
128
147
|
}
|
|
129
148
|
|
|
130
149
|
async function listLocks() {
|
package/src/runtime/protocol.js
CHANGED
|
@@ -27,6 +27,9 @@ export function normalizeCommand(msg) {
|
|
|
27
27
|
requestId: msg.requestId || null,
|
|
28
28
|
allow: msg.allow,
|
|
29
29
|
always: msg.always,
|
|
30
|
+
// An immediate replacement turn keeps any server-side follow-ups. This
|
|
31
|
+
// is opt-in; the legacy Stop action continues to discard them.
|
|
32
|
+
preserveQueue: msg.preserveQueue === true,
|
|
30
33
|
cwd: msg.cwd || msg.options?.cwd || null,
|
|
31
34
|
};
|
|
32
35
|
}
|
|
@@ -42,6 +45,7 @@ export function normalizeCommand(msg) {
|
|
|
42
45
|
requestId: null,
|
|
43
46
|
allow: undefined,
|
|
44
47
|
always: false,
|
|
48
|
+
preserveQueue: false,
|
|
45
49
|
cwd: msg.options?.cwd || null,
|
|
46
50
|
};
|
|
47
51
|
}
|
|
@@ -57,6 +61,7 @@ export function normalizeCommand(msg) {
|
|
|
57
61
|
requestId: null,
|
|
58
62
|
allow: undefined,
|
|
59
63
|
always: false,
|
|
64
|
+
preserveQueue: false,
|
|
60
65
|
cwd: null,
|
|
61
66
|
};
|
|
62
67
|
}
|
|
@@ -72,6 +77,7 @@ export function normalizeCommand(msg) {
|
|
|
72
77
|
requestId: msg.requestId || msg.request_id,
|
|
73
78
|
allow: msg.allow !== false && msg.allow !== 0 && msg.allow !== '0',
|
|
74
79
|
always: !!msg.always,
|
|
80
|
+
preserveQueue: false,
|
|
75
81
|
cwd: null,
|
|
76
82
|
};
|
|
77
83
|
}
|
|
@@ -87,6 +93,7 @@ export function normalizeCommand(msg) {
|
|
|
87
93
|
requestId: null,
|
|
88
94
|
allow: undefined,
|
|
89
95
|
always: false,
|
|
96
|
+
preserveQueue: false,
|
|
90
97
|
cwd: msg.cwd || null,
|
|
91
98
|
};
|
|
92
99
|
}
|
|
@@ -102,6 +109,7 @@ export function normalizeCommand(msg) {
|
|
|
102
109
|
requestId: null,
|
|
103
110
|
allow: undefined,
|
|
104
111
|
always: false,
|
|
112
|
+
preserveQueue: false,
|
|
105
113
|
cwd: null,
|
|
106
114
|
};
|
|
107
115
|
}
|