claude-simple 0.2.25 → 0.2.27
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.27.min.js +1182 -0
- package/dist/index.html +2 -2
- package/dist/style.0.2.27.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 +7 -1
- package/public/shell.js +18 -5
- 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/agents/sessions.js +125 -0
- 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/package.json
CHANGED
package/public/api.js
CHANGED
|
@@ -26,20 +26,26 @@ function isHubOnlyPath(path) {
|
|
|
26
26
|
|| p === '/api/notifications/read'
|
|
27
27
|
|| p === '/api/ai/notes'
|
|
28
28
|
|| p.startsWith('/api/ai/notes/')
|
|
29
|
-
|| p.startsWith('/api/auth/')
|
|
29
|
+
|| p.startsWith('/api/auth/')
|
|
30
|
+
|| p.startsWith('/api/collab/');
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export async function api(method, path, body, signal, opts = {}) {
|
|
33
|
-
const machineId = currentMachineId(opts);
|
|
34
|
+
const machineId = opts.hub ? null : currentMachineId(opts);
|
|
34
35
|
const headers = {
|
|
35
36
|
'Content-Type': 'application/json',
|
|
36
37
|
...(ctx.token ? { Authorization: `Bearer ${ctx.token}` } : {}),
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
// Hub mode: route data APIs to the selected edge (sessions/files/git/…)
|
|
40
|
-
if (hubMode.peek() && machineId && !isHubOnlyPath(path)) {
|
|
41
|
+
if (!opts.hub && hubMode.peek() && machineId && !isHubOnlyPath(path)) {
|
|
41
42
|
headers['X-Machine-Id'] = machineId;
|
|
42
43
|
}
|
|
44
|
+
const mutating = method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD';
|
|
45
|
+
if (mutating && ctx.lock?.token && ctx.lock?.key && !String(path).startsWith('/api/collab/')) {
|
|
46
|
+
headers['X-Lock-Token'] = ctx.lock.token;
|
|
47
|
+
headers['X-Lock-Key'] = ctx.lock.key;
|
|
48
|
+
}
|
|
43
49
|
|
|
44
50
|
const etagKey = opts.etag === true
|
|
45
51
|
? `${ctx.token || ''}:${machineId || ''}:${path}`
|
package/public/app.js
CHANGED
|
@@ -3,7 +3,7 @@ import { ctx } from './state.js';
|
|
|
3
3
|
import { probeHub } from './api.js';
|
|
4
4
|
import { initShell, showApp, initAuth, resumeSession, switchTab, shouldRestoreSessionOnBoot } from './shell.js';
|
|
5
5
|
import { initChat } from './chat.js';
|
|
6
|
-
import { initFilesTab } from './files.js';
|
|
6
|
+
import { initFilesTab } from './files.js?v=ui-22';
|
|
7
7
|
import { initGitTab } from './git.js';
|
|
8
8
|
import { initSettings } from './settings.js';
|
|
9
9
|
import { initTerminal } from './terminal.js';
|
package/public/chat.js
CHANGED
|
@@ -4,6 +4,7 @@ import { ctx, isProcessing, currentProject, currentTab, currentModel, currentEff
|
|
|
4
4
|
currentAgent, setAgent, AGENT_LABELS, getDefaultModel, getModelsForAgent, getEffortsForModel,
|
|
5
5
|
getDefaultEffort, addCustomModel, setCurrentModel, chatDensity, wsStatus, sessionMetaMap, metaKey } from './state.js';
|
|
6
6
|
import { sendWs, api, authHeaders, flushWsQueue, clearWsQueue } from './api.js';
|
|
7
|
+
import { acquireSessionLock, canWriteNow } from './locks.js';
|
|
7
8
|
import { initWakeLock, setWakeLockDesired } from './wake-lock.js';
|
|
8
9
|
import { pushNotification } from './notifications.js';
|
|
9
10
|
import { renderMermaid } from './mermaid.js';
|
|
@@ -252,20 +253,48 @@ function syncWakeLock() {
|
|
|
252
253
|
setWakeLockDesired(need);
|
|
253
254
|
}
|
|
254
255
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
256
|
+
function composerBlockReason() {
|
|
257
|
+
if ((ctx.role || 'editor') === 'viewer') return '只读账号,无法输入';
|
|
258
|
+
if (ctx.lock?.foreign) {
|
|
259
|
+
const who = ctx.lock.holder && ctx.lock.holder !== 'viewer' ? ctx.lock.holder : '其他用户';
|
|
260
|
+
return `会话由 ${who} 占用(锁)`;
|
|
261
|
+
}
|
|
262
|
+
return '当前无法输入';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Enable typing whenever this user is allowed to write (role + lease). */
|
|
266
|
+
export function applyComposerAccess() {
|
|
267
|
+
const writable = canWriteNow();
|
|
268
|
+
ctx.externalSessionReadOnly = !writable;
|
|
259
269
|
const input = $('chat-input');
|
|
260
270
|
const send = $('send-btn');
|
|
261
271
|
const attach = $('btn-attach');
|
|
272
|
+
const banner = $('session-lock-banner');
|
|
262
273
|
if (input) {
|
|
263
|
-
input.disabled =
|
|
264
|
-
|
|
265
|
-
|
|
274
|
+
input.disabled = !writable;
|
|
275
|
+
input.readOnly = !writable;
|
|
276
|
+
if (!writable) input.placeholder = composerBlockReason();
|
|
277
|
+
else if (isProcessing.peek()) input.placeholder = '回复中… 可先写好下一条,结束后自动发送 · 或点 ■ 停止';
|
|
278
|
+
else updateInputMode(input.value || '');
|
|
266
279
|
}
|
|
267
|
-
if (send) send.disabled =
|
|
268
|
-
if (attach) attach.disabled =
|
|
280
|
+
if (send) send.disabled = !writable;
|
|
281
|
+
if (attach) attach.disabled = !writable;
|
|
282
|
+
if (banner) {
|
|
283
|
+
if (!writable) {
|
|
284
|
+
banner.classList.remove('hidden');
|
|
285
|
+
banner.textContent = composerBlockReason();
|
|
286
|
+
} else {
|
|
287
|
+
banner.classList.add('hidden');
|
|
288
|
+
banner.textContent = '';
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Kept for session-nav resets. Never use this to lock the composer on
|
|
294
|
+
* native JSONL sync — UI-owned sessions write the same files, which left
|
|
295
|
+
* holders unable to type after a turn. */
|
|
296
|
+
export function setExternalSessionReadOnly(_readOnly) {
|
|
297
|
+
applyComposerAccess();
|
|
269
298
|
}
|
|
270
299
|
|
|
271
300
|
/** Manual reconnect (toolbar / banner). Resets backoff and forces a new socket. */
|
|
@@ -433,10 +462,8 @@ function handleWsMessage(msg) {
|
|
|
433
462
|
return;
|
|
434
463
|
}
|
|
435
464
|
if (msg.type === 'external-session-updated') {
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
// the browser, and keeps reconnects/idempotency straightforward.
|
|
439
|
-
setExternalSessionReadOnly(true);
|
|
465
|
+
// Native JSONL changed (our agent or a terminal CLI). Refresh history but
|
|
466
|
+
// do not disable the composer: that flag stuck after every UI turn.
|
|
440
467
|
document.dispatchEvent(new CustomEvent('sessions-changed'));
|
|
441
468
|
document.dispatchEvent(new CustomEvent('external-session-updated', { detail: msg }));
|
|
442
469
|
return;
|
|
@@ -488,6 +515,10 @@ function handleWsMessage(msg) {
|
|
|
488
515
|
if (msg.type === 'error') {
|
|
489
516
|
finalizeThoughtStream();
|
|
490
517
|
finalizeAssistantStream();
|
|
518
|
+
if (msg.code === 423 || /locked/i.test(String(msg.message || ''))) {
|
|
519
|
+
ctx.lock = { ...(ctx.lock || {}), held: false, foreign: true, holder: msg.lock?.holder || ctx.lock?.holder || 'another user' };
|
|
520
|
+
applyComposerAccess();
|
|
521
|
+
}
|
|
491
522
|
appendMsg('error', 'Error', msg.message);
|
|
492
523
|
markTurnDone();
|
|
493
524
|
return;
|
|
@@ -878,8 +909,20 @@ export function sendMessage() {
|
|
|
878
909
|
const input = $('chat-input');
|
|
879
910
|
const text = input?.value.trim();
|
|
880
911
|
if (!text || !currentProject.peek()) return;
|
|
881
|
-
if (
|
|
882
|
-
appendSystemMsg(
|
|
912
|
+
if (!canWriteNow()) {
|
|
913
|
+
appendSystemMsg(composerBlockReason());
|
|
914
|
+
acquireSessionLock().then(() => applyComposerAccess()).catch(() => {});
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (ctx.sessionId && !ctx.lock?.held && !ctx.lock?.unmanaged) {
|
|
918
|
+
acquireSessionLock().then(r => {
|
|
919
|
+
if (!r?.ok && !r?.skipped) {
|
|
920
|
+
applyComposerAccess();
|
|
921
|
+
appendSystemMsg(composerBlockReason());
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
sendMessage();
|
|
925
|
+
}).catch(() => sendMessage());
|
|
883
926
|
return;
|
|
884
927
|
}
|
|
885
928
|
|
|
@@ -892,9 +935,11 @@ export function sendMessage() {
|
|
|
892
935
|
const effort = currentEffort.peek() || getDefaultEffort(agent, currentModel.peek()) || 'low';
|
|
893
936
|
const ok = sendWs({
|
|
894
937
|
type: 'command', cmd: 'turn.start', agent, command: text, sessionId: ctx.sessionId,
|
|
938
|
+
lockToken: ctx.lock?.token || undefined,
|
|
895
939
|
options: {
|
|
896
940
|
agent, cwd: currentProject.peek().path, sessionId: ctx.sessionId,
|
|
897
941
|
configDir: ctx.configDir, model: currentModel.peek(),
|
|
942
|
+
lockToken: ctx.lock?.token || undefined,
|
|
898
943
|
effort,
|
|
899
944
|
...(currentPermission.peek() && { permissionMode: currentPermission.peek() }),
|
|
900
945
|
...(currentPermission.peek() === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
|
|
@@ -960,12 +1005,14 @@ export function sendMessage() {
|
|
|
960
1005
|
agent,
|
|
961
1006
|
command: text,
|
|
962
1007
|
sessionId: ctx.sessionId,
|
|
1008
|
+
lockToken: ctx.lock?.token || undefined,
|
|
963
1009
|
options: {
|
|
964
1010
|
agent,
|
|
965
1011
|
cwd: currentProject.peek().path,
|
|
966
1012
|
sessionId: ctx.sessionId,
|
|
967
1013
|
configDir: ctx.configDir,
|
|
968
1014
|
model: currentModel.peek(),
|
|
1015
|
+
lockToken: ctx.lock?.token || undefined,
|
|
969
1016
|
...(effort && { effort }),
|
|
970
1017
|
...(permission && { permissionMode: permission }),
|
|
971
1018
|
...(permission === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
|
|
@@ -1812,14 +1859,8 @@ export function initChat() {
|
|
|
1812
1859
|
watch(isProcessing, val => {
|
|
1813
1860
|
$('send-btn')?.classList.toggle('hidden', val);
|
|
1814
1861
|
$('stop-btn')?.classList.toggle('hidden', !val);
|
|
1815
|
-
|
|
1816
|
-
if (
|
|
1817
|
-
input.disabled = !!ctx.externalSessionReadOnly;
|
|
1818
|
-
if (ctx.externalSessionReadOnly) input.placeholder = '外部终端会话 · 实时同步中(只读)';
|
|
1819
|
-
else if (val) input.placeholder = '回复中… 可先写好下一条,结束后自动发送 · 或点 ■ 停止';
|
|
1820
|
-
else updateInputMode(input.value || '');
|
|
1821
|
-
}
|
|
1822
|
-
if (!val) $('send-btn')?.toggleAttribute('disabled', !!ctx.externalSessionReadOnly);
|
|
1862
|
+
applyComposerAccess();
|
|
1863
|
+
if (!val) $('send-btn')?.toggleAttribute('disabled', !canWriteNow());
|
|
1823
1864
|
const msgs = $('messages');
|
|
1824
1865
|
if (!msgs) return;
|
|
1825
1866
|
const existing = $('typing-indicator');
|
|
@@ -1835,6 +1876,8 @@ export function initChat() {
|
|
|
1835
1876
|
});
|
|
1836
1877
|
|
|
1837
1878
|
watch(chatDensity, () => applyChatDensity());
|
|
1879
|
+
document.addEventListener('session-lock-changed', () => applyComposerAccess());
|
|
1880
|
+
applyComposerAccess();
|
|
1838
1881
|
|
|
1839
1882
|
// Chat input: ⌘/Ctrl+Enter = send. On phones, Enter sends (Shift+Enter newline).
|
|
1840
1883
|
delegate.on('keydown', '#chat-input', e => {
|
package/public/files.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { effect, asyncEffect, delegate, esc, $ } from './lib.js';
|
|
3
3
|
import { currentProject, currentTab, filesRoot, filesPath, viewingFile, ctx } from './state.js';
|
|
4
4
|
import { api, authHeaders, authQuery } from './api.js';
|
|
5
|
+
import { renderMermaid } from './mermaid.js';
|
|
6
|
+
import { renderMath } from './math.js';
|
|
5
7
|
|
|
6
8
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
7
9
|
function fileIcon(name) {
|
|
@@ -24,6 +26,11 @@ function officeKind(path) {
|
|
|
24
26
|
return e === 'docx' || e === 'pptx' || e === 'xlsx' || e === 'pdf' ? e : null;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
function isMarkdownFile(path) {
|
|
30
|
+
const ext = extOf(path);
|
|
31
|
+
return ext === 'md' || ext === 'markdown' || ext === 'mdx';
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
function sizeLabel(size) {
|
|
28
35
|
if (size == null) return '';
|
|
29
36
|
if (size > 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
@@ -136,6 +143,7 @@ function renderDir(files) {
|
|
|
136
143
|
|
|
137
144
|
function renderTextFile(path, content, size) {
|
|
138
145
|
const isBinary = /[\x00-\x08\x0e-\x1f]/.test(content.slice(0, 512));
|
|
146
|
+
if (!isBinary && isMarkdownFile(path)) return renderMarkdownFile(path, content, size);
|
|
139
147
|
const lang = langFromPath(path);
|
|
140
148
|
const langClass = lang ? `language-${esc(lang)}` : '';
|
|
141
149
|
const body = isBinary
|
|
@@ -144,6 +152,37 @@ function renderTextFile(path, content, size) {
|
|
|
144
152
|
return fileChrome(path, size, isBinary ? 'binary' : (lang || 'text')) + body;
|
|
145
153
|
}
|
|
146
154
|
|
|
155
|
+
function markdownHtml(content) {
|
|
156
|
+
if (typeof marked !== 'undefined') {
|
|
157
|
+
try {
|
|
158
|
+
return marked.parse(content, { breaks: true, gfm: true });
|
|
159
|
+
} catch { /* Show the source below if parsing fails. */ }
|
|
160
|
+
}
|
|
161
|
+
return esc(content).replace(/\n/g, '<br>');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderMarkdownFile(path, content, size) {
|
|
165
|
+
return fileChrome(path, size, 'Markdown') + `
|
|
166
|
+
<div class="file-markdown-toolbar" role="group" aria-label="Markdown view">
|
|
167
|
+
<button type="button" class="file-markdown-mode active" data-file-render-mode="preview">Preview</button>
|
|
168
|
+
<button type="button" class="file-markdown-mode" data-file-render-mode="code">Code</button>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="file-markdown-preview md p-4 overflow-auto flex-1">${markdownHtml(content)}</div>
|
|
171
|
+
<pre class="file-markdown-code file-preview p-4 font-mono text-xs leading-relaxed overflow-auto flex-1 m-0 hidden"><code class="file-preview-code language-markdown">${esc(content)}</code></pre>`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function enhanceMarkdownPreview(root) {
|
|
175
|
+
const preview = root?.querySelector('.file-markdown-preview');
|
|
176
|
+
if (!preview) return;
|
|
177
|
+
if (typeof hljs !== 'undefined') {
|
|
178
|
+
preview.querySelectorAll('pre code:not(.hljs)').forEach(el => {
|
|
179
|
+
try { hljs.highlightElement(el); } catch { /* retain readable source */ }
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
renderMath(preview);
|
|
183
|
+
renderMermaid(preview);
|
|
184
|
+
}
|
|
185
|
+
|
|
147
186
|
// ── Office previews ───────────────────────────────────────────────────────────
|
|
148
187
|
async function renderDocx(arrayBuffer) {
|
|
149
188
|
await loadScript('/vendor/mammoth.browser.min.js');
|
|
@@ -426,6 +465,7 @@ async function showFile(area, file, data, signal) {
|
|
|
426
465
|
}
|
|
427
466
|
area.innerHTML = renderTextFile(file, data.content || '', size);
|
|
428
467
|
highlightFilePreview(area);
|
|
468
|
+
if (isMarkdownFile(file)) enhanceMarkdownPreview(area);
|
|
429
469
|
}
|
|
430
470
|
|
|
431
471
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
@@ -494,6 +534,16 @@ export function initFilesTab() {
|
|
|
494
534
|
if (!path) return;
|
|
495
535
|
deleteFile(currentProject.peek(), path);
|
|
496
536
|
});
|
|
537
|
+
delegate.on('click', '[data-file-render-mode]', (_, el) => {
|
|
538
|
+
const root = el.closest('#files-area');
|
|
539
|
+
if (!root) return;
|
|
540
|
+
const showCode = el.dataset.fileRenderMode === 'code';
|
|
541
|
+
root.querySelector('.file-markdown-preview')?.classList.toggle('hidden', showCode);
|
|
542
|
+
root.querySelector('.file-markdown-code')?.classList.toggle('hidden', !showCode);
|
|
543
|
+
root.querySelectorAll('[data-file-render-mode]').forEach(button => {
|
|
544
|
+
button.classList.toggle('active', button === el);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
497
547
|
// Excel sheet tabs
|
|
498
548
|
delegate.on('click', '.office-sheet-tab', (_, el) => {
|
|
499
549
|
const root = el.closest('.office-xlsx');
|
package/public/index.html
CHANGED
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
<script src="/vendor/mermaid.min.js"></script>
|
|
86
86
|
<link rel="stylesheet" href="/vendor/katex.min.css">
|
|
87
87
|
<script src="/vendor/katex.min.js"></script>
|
|
88
|
-
<link rel="stylesheet" href="style.css?v=ui-
|
|
88
|
+
<link rel="stylesheet" href="style.css?v=ui-22">
|
|
89
89
|
</head>
|
|
90
90
|
<body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
|
|
91
91
|
<div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
|
|
@@ -101,6 +101,6 @@
|
|
|
101
101
|
}
|
|
102
102
|
}, 10000);
|
|
103
103
|
</script>
|
|
104
|
-
<script type="module" src="app.js?v=ui-
|
|
104
|
+
<script type="module" src="app.js?v=ui-22"></script>
|
|
105
105
|
</body>
|
|
106
106
|
</html>
|
package/public/locks.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// locks.js — lease locks for the current session (hub or standalone)
|
|
2
|
+
import { ctx, currentAgent, selectedMachineId } from './state.js';
|
|
3
|
+
import { api } from './api.js';
|
|
4
|
+
|
|
5
|
+
const TTL_SEC = 60;
|
|
6
|
+
const HEARTBEAT_MS = 20_000; // design: ≤25s
|
|
7
|
+
|
|
8
|
+
let heartbeatTimer = null;
|
|
9
|
+
let acquireInFlight = null;
|
|
10
|
+
|
|
11
|
+
export function sessionLockKey({
|
|
12
|
+
sessionId = ctx.sessionId,
|
|
13
|
+
agent = ctx.agent || currentAgent.peek() || 'claude',
|
|
14
|
+
machineId = ctx.machineId || selectedMachineId.peek() || 'local',
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (!sessionId) return null;
|
|
17
|
+
return `session:${machineId || 'local'}:${agent || 'claude'}:${sessionId}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function canWriteNow() {
|
|
21
|
+
const role = ctx.role || 'editor';
|
|
22
|
+
if (role === 'viewer') return false;
|
|
23
|
+
if (ctx.lock?.foreign) return false;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emitLockChange() {
|
|
28
|
+
document.dispatchEvent(new CustomEvent('session-lock-changed', { detail: ctx.lock }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setLockState(next) {
|
|
32
|
+
ctx.lock = { key: null, token: null, holder: null, held: false, foreign: false, ...(next || {}) };
|
|
33
|
+
emitLockChange();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function acquireSessionLock(identity = {}) {
|
|
37
|
+
const key = sessionLockKey(identity);
|
|
38
|
+
if (!key) {
|
|
39
|
+
setLockState({ foreign: false, held: true, unmanaged: true });
|
|
40
|
+
return { ok: true, skipped: true };
|
|
41
|
+
}
|
|
42
|
+
const role = ctx.role || 'editor';
|
|
43
|
+
if (role === 'viewer') {
|
|
44
|
+
setLockState({ key, held: false, foreign: true, holder: 'viewer' });
|
|
45
|
+
return { ok: false, error: 'viewers cannot take exclusive locks', code: 403 };
|
|
46
|
+
}
|
|
47
|
+
if (ctx.lock?.key && ctx.lock.key !== key) {
|
|
48
|
+
await releaseSessionLock();
|
|
49
|
+
}
|
|
50
|
+
if (acquireInFlight) return acquireInFlight;
|
|
51
|
+
acquireInFlight = (async () => {
|
|
52
|
+
try {
|
|
53
|
+
const body = {
|
|
54
|
+
key,
|
|
55
|
+
kind: 'session',
|
|
56
|
+
ttlSec: TTL_SEC,
|
|
57
|
+
exclusive: true,
|
|
58
|
+
...(ctx.lock?.token && ctx.lock?.key === key ? { token: ctx.lock.token } : {}),
|
|
59
|
+
};
|
|
60
|
+
const lock = await api('POST', '/api/collab/locks', body, undefined, { hub: true });
|
|
61
|
+
setLockState({
|
|
62
|
+
key,
|
|
63
|
+
token: lock.token,
|
|
64
|
+
holder: lock.holder,
|
|
65
|
+
held: true,
|
|
66
|
+
foreign: false,
|
|
67
|
+
expiresAt: lock.expiresAt,
|
|
68
|
+
version: lock.version,
|
|
69
|
+
});
|
|
70
|
+
startLockHeartbeat();
|
|
71
|
+
return { ok: true, lock };
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const msg = String(err.message || err);
|
|
74
|
+
const locked = /locked|HTTP 423|423/.test(msg);
|
|
75
|
+
if (locked) {
|
|
76
|
+
setLockState({ key, held: false, foreign: true, holder: 'another user' });
|
|
77
|
+
return { ok: false, code: 423, error: msg };
|
|
78
|
+
}
|
|
79
|
+
// Collab API missing (older standalone): do not block typing.
|
|
80
|
+
setLockState({ key, held: true, foreign: false, unmanaged: true });
|
|
81
|
+
return { ok: true, skipped: true, error: msg };
|
|
82
|
+
} finally {
|
|
83
|
+
acquireInFlight = null;
|
|
84
|
+
}
|
|
85
|
+
})();
|
|
86
|
+
return acquireInFlight;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function heartbeatLock() {
|
|
90
|
+
const { key, token, held } = ctx.lock || {};
|
|
91
|
+
if (!key || !token || !held) return;
|
|
92
|
+
try {
|
|
93
|
+
const lock = await api('POST', '/api/collab/lock-action', {
|
|
94
|
+
key, action: 'heartbeat', token, ttlSec: TTL_SEC,
|
|
95
|
+
}, undefined, { hub: true });
|
|
96
|
+
if (lock?.token) ctx.lock.token = lock.token;
|
|
97
|
+
if (lock?.expiresAt) ctx.lock.expiresAt = lock.expiresAt;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const msg = String(err.message || err);
|
|
100
|
+
if (/token mismatch|HTTP 409|409|no lock|HTTP 404|404/.test(msg)) {
|
|
101
|
+
// Lost the lease — try to reclaim if we are still the holder.
|
|
102
|
+
const result = await acquireSessionLock();
|
|
103
|
+
if (!result.ok) stopLockHeartbeat();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function startLockHeartbeat() {
|
|
109
|
+
stopLockHeartbeat();
|
|
110
|
+
heartbeatTimer = setInterval(() => { heartbeatLock().catch(() => {}); }, HEARTBEAT_MS);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function stopLockHeartbeat() {
|
|
114
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function releaseSessionLock() {
|
|
118
|
+
stopLockHeartbeat();
|
|
119
|
+
const { key, token } = ctx.lock || {};
|
|
120
|
+
setLockState({ held: false, foreign: false });
|
|
121
|
+
if (!key) return;
|
|
122
|
+
try {
|
|
123
|
+
await api('POST', '/api/collab/lock-action', {
|
|
124
|
+
key, action: 'release', token, ttlSec: TTL_SEC,
|
|
125
|
+
}, undefined, { hub: true });
|
|
126
|
+
} catch { /* TTL will expire */ }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function loadCollabMe() {
|
|
130
|
+
try {
|
|
131
|
+
const me = await api('GET', '/api/collab/me', undefined, undefined, { hub: true });
|
|
132
|
+
ctx.role = me?.role || ctx.role || 'editor';
|
|
133
|
+
ctx.username = me?.username || ctx.username || null;
|
|
134
|
+
return me;
|
|
135
|
+
} catch {
|
|
136
|
+
if (!ctx.role) ctx.role = 'editor';
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<meta name="theme-color" content="#0f172a">
|
|
7
|
+
<meta name="robots" content="index,follow">
|
|
8
|
+
<title>AgentUI Privacy Policy</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: dark;
|
|
12
|
+
}
|
|
13
|
+
body {
|
|
14
|
+
margin: 0;
|
|
15
|
+
min-height: 100dvh;
|
|
16
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
17
|
+
background: #0f172a;
|
|
18
|
+
color: #e2e8f0;
|
|
19
|
+
line-height: 1.6;
|
|
20
|
+
}
|
|
21
|
+
main {
|
|
22
|
+
max-width: 42rem;
|
|
23
|
+
margin: 0 auto;
|
|
24
|
+
padding: 2.5rem 1.5rem 4rem;
|
|
25
|
+
}
|
|
26
|
+
h1 {
|
|
27
|
+
font-size: 1.6rem;
|
|
28
|
+
font-weight: 700;
|
|
29
|
+
margin: 0 0 0.35rem;
|
|
30
|
+
letter-spacing: -0.02em;
|
|
31
|
+
}
|
|
32
|
+
.updated {
|
|
33
|
+
color: #94a3b8;
|
|
34
|
+
font-size: 0.9rem;
|
|
35
|
+
margin: 0 0 1.75rem;
|
|
36
|
+
}
|
|
37
|
+
h2 {
|
|
38
|
+
font-size: 1.05rem;
|
|
39
|
+
margin: 1.75rem 0 0.6rem;
|
|
40
|
+
color: #cbd5e1;
|
|
41
|
+
}
|
|
42
|
+
p, li {
|
|
43
|
+
font-size: 0.95rem;
|
|
44
|
+
color: #cbd5e1;
|
|
45
|
+
}
|
|
46
|
+
p { margin: 0 0 0.85rem; }
|
|
47
|
+
ul {
|
|
48
|
+
margin: 0 0 0.85rem;
|
|
49
|
+
padding-left: 1.2rem;
|
|
50
|
+
}
|
|
51
|
+
li { margin: 0.25rem 0; }
|
|
52
|
+
a {
|
|
53
|
+
color: #818cf8;
|
|
54
|
+
text-decoration: none;
|
|
55
|
+
}
|
|
56
|
+
a:hover { text-decoration: underline; }
|
|
57
|
+
code {
|
|
58
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
59
|
+
font-size: 0.86em;
|
|
60
|
+
background: #1e293b;
|
|
61
|
+
border: 1px solid #334155;
|
|
62
|
+
border-radius: 0.3rem;
|
|
63
|
+
padding: 0.05rem 0.35rem;
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<main>
|
|
69
|
+
<h1>AgentUI Privacy Policy</h1>
|
|
70
|
+
<p class="updated">Last updated: 31 August 2026</p>
|
|
71
|
+
|
|
72
|
+
<p>
|
|
73
|
+
AgentUI (bundle <code>online.llmlife.agentui</code>) is a mobile client for a self-hosted AI coding hub.
|
|
74
|
+
The iOS app is published by llmlife.
|
|
75
|
+
</p>
|
|
76
|
+
|
|
77
|
+
<h2>What the app does</h2>
|
|
78
|
+
<ul>
|
|
79
|
+
<li>You type a hub URL and credentials. The app talks only to the hub you configure (default <code>https://agentui.antisense.fun</code>).</li>
|
|
80
|
+
<li>Session chat, files, git, terminal, and memory stay on that hub / your machines. We (the App Store publisher) do not operate a public user database for AgentUI.</li>
|
|
81
|
+
</ul>
|
|
82
|
+
|
|
83
|
+
<h2>Data the iOS app may access on device</h2>
|
|
84
|
+
<ul>
|
|
85
|
+
<li><strong>Camera and photo library:</strong> only if you attach a photo to a session. Images are uploaded to YOUR hub.</li>
|
|
86
|
+
<li><strong>Files you pick:</strong> uploaded to YOUR hub.</li>
|
|
87
|
+
<li><strong>Local notifications:</strong> session-complete banners on this device. No Apple Push token is required for that.</li>
|
|
88
|
+
<li><strong>Account token / hub URL:</strong> stored in app preferences on device.</li>
|
|
89
|
+
</ul>
|
|
90
|
+
|
|
91
|
+
<h2>What we do not do</h2>
|
|
92
|
+
<ul>
|
|
93
|
+
<li>No third-party analytics SDK, no ads, no tracking pixels in the iOS app.</li>
|
|
94
|
+
<li>No sale of personal data.</li>
|
|
95
|
+
<li>We do not require you to create an Apple-ID-linked social account.</li>
|
|
96
|
+
</ul>
|
|
97
|
+
|
|
98
|
+
<h2>Hub operators</h2>
|
|
99
|
+
<p>
|
|
100
|
+
Whoever runs the hub you sign into is the controller for chat logs, files, and account data on that server.
|
|
101
|
+
If you use <code>https://agentui.antisense.fun</code>, contact the operator via the GitHub repo
|
|
102
|
+
<a href="https://github.com/forechoandlook/claude-simple-ui">https://github.com/forechoandlook/claude-simple-ui</a>.
|
|
103
|
+
</p>
|
|
104
|
+
|
|
105
|
+
<h2>Contact</h2>
|
|
106
|
+
<p>
|
|
107
|
+
<a href="https://github.com/forechoandlook/claude-simple-ui/issues">https://github.com/forechoandlook/claude-simple-ui/issues</a>
|
|
108
|
+
</p>
|
|
109
|
+
</main>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|
package/public/shell/boot.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
hubMode, machinesList, hubMachineReady, setSelectedMachine, sessionsData, ctx,
|
|
5
5
|
} from '../state.js';
|
|
6
6
|
import { probeHub } from '../api.js';
|
|
7
|
+
import { loadCollabMe } from '../locks.js';
|
|
7
8
|
import { getCachedSessions } from '../cache.js';
|
|
8
9
|
import { getLastSessionContext } from './session-context.js';
|
|
9
10
|
import { loadAllSessions, loadWorkspaces, loadAgentsMeta } from './session-list.js';
|
|
@@ -23,6 +24,8 @@ export async function showApp(opts = {}) {
|
|
|
23
24
|
app.style.display = 'flex';
|
|
24
25
|
queueMicrotask(syncFocusScope);
|
|
25
26
|
|
|
27
|
+
loadCollabMe().catch(() => {});
|
|
28
|
+
|
|
26
29
|
// Use probe result from boot when provided (avoid second /api/hub round-trip)
|
|
27
30
|
let hub = opts.hub;
|
|
28
31
|
if (hub === undefined) {
|
package/public/shell/notes.js
CHANGED
|
@@ -118,11 +118,16 @@ export function renderSessionNotesBar() {
|
|
|
118
118
|
title="${esc(showTitle)}">${esc(showTitle)}</span>
|
|
119
119
|
<button type="button" class="session-rename-btn flex-shrink-0 opacity-70 hover:opacity-100"
|
|
120
120
|
data-rename-bar="1" title="Rename session">✎</button>
|
|
121
|
+
<div class="session-title-actions flex items-center gap-0.5 flex-shrink-0">
|
|
122
|
+
<button type="button" id="btn-new-session-current" class="session-bar-icon" title="新建会话" aria-label="新建会话"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 8v8M8 12h8"/></svg></button>
|
|
121
123
|
<button type="button" class="session-hide-btn flex-shrink-0 ${hid ? 'is-hidden' : ''}"
|
|
122
124
|
data-hide-toggle="1"
|
|
123
125
|
data-hide-session-id="${esc(sid)}"
|
|
124
126
|
data-hide-session-agent="${esc(agent)}"
|
|
125
|
-
title="${hid ? '
|
|
127
|
+
title="${hid ? '取消隐藏会话' : '隐藏会话'}" aria-label="${hid ? '取消隐藏会话' : '隐藏会话'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M3 3l18 18"/><path d="M10.6 10.7a2 2 0 0 0 2.7 2.7"/><path d="M9.9 5.1A10.5 10.5 0 0 1 12 4.9c5.4 0 9 5.1 9 7.1a10.9 10.9 0 0 1-3 4.2M6.2 6.3C4.1 7.8 3 10.2 3 12c0 2 3.6 7.1 9 7.1 1.2 0 2.3-.2 3.3-.6"/></svg></button>
|
|
128
|
+
<button type="button" id="btn-share-session-current" class="session-bar-icon" title="复制当前会话分享链接" aria-label="分享会话"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M12 15V3"/><path d="m8 7 4-4 4 4"/><path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7"/></svg></button>
|
|
129
|
+
<button type="button" id="btn-collapse-chat-chrome" class="session-bar-icon" title="折叠顶部内容" aria-label="折叠顶部内容"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M7 10l5 5 5-5"/><path d="M5 5h14"/></svg></button>
|
|
130
|
+
</div>
|
|
126
131
|
<span class="font-bold text-base-content/40 uppercase flex-shrink-0">Note:</span>
|
|
127
132
|
<span class="truncate text-base-content/70 ${note ? '' : 'italic text-base-content/40'}"
|
|
128
133
|
id="session-notes-text" title="${esc(note || 'Add a note for this session')}">${esc(note || 'No session note')}</span>
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
appendHistoryTokenBar, appendContextBar, flushToolBatch, coerceTs,
|
|
13
13
|
stashComposerDraft, restoreComposerDraft, discardComposerAttachments, parkQueuedMessages, detachProcessingForSessionSwitch, setExternalSessionReadOnly,
|
|
14
14
|
} from '../chat.js';
|
|
15
|
+
import { acquireSessionLock, releaseSessionLock } from '../locks.js';
|
|
15
16
|
import { setHash, syncHash } from '../router.js';
|
|
16
17
|
import { refreshModelSelect } from './session-list.js';
|
|
17
18
|
import { renderSessionNotesBar } from './notes.js';
|
|
@@ -54,6 +55,7 @@ function syncMachinePathHints(machineId) {
|
|
|
54
55
|
|
|
55
56
|
export function openProject(project) {
|
|
56
57
|
activeExternalHistory = null;
|
|
58
|
+
releaseSessionLock();
|
|
57
59
|
setExternalSessionReadOnly(false);
|
|
58
60
|
stashComposerDraft();
|
|
59
61
|
parkQueuedMessages();
|
|
@@ -88,6 +90,7 @@ export function openProject(project) {
|
|
|
88
90
|
|
|
89
91
|
export function goHome() {
|
|
90
92
|
activeExternalHistory = null;
|
|
93
|
+
releaseSessionLock();
|
|
91
94
|
setExternalSessionReadOnly(false);
|
|
92
95
|
stashComposerDraft();
|
|
93
96
|
parkQueuedMessages();
|
|
@@ -167,6 +170,7 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
|
|
|
167
170
|
|
|
168
171
|
ctx.sessionId = sid;
|
|
169
172
|
setExternalSessionReadOnly(false);
|
|
173
|
+
acquireSessionLock({ sessionId: sid, agent: resolvedAgent, machineId: resolvedMachine }).catch(() => {});
|
|
170
174
|
ctx.configDir = resolvedConfig;
|
|
171
175
|
ctx.agent = resolvedAgent;
|
|
172
176
|
setAgent(resolvedAgent);
|