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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
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
- /** External CLI transcripts are observable, but this UI must not race their
256
- * terminal owner by submitting another turn into the same native session. */
257
- export function setExternalSessionReadOnly(readOnly) {
258
- ctx.externalSessionReadOnly = !!readOnly;
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 = !!readOnly;
264
- if (readOnly) input.placeholder = '外部终端会话 · 实时同步中(只读)';
265
- else if (!isProcessing.peek()) updateInputMode(input.value || '');
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 = !!readOnly;
268
- if (attach) attach.disabled = !!readOnly;
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
- // The external CLI owns this conversation. Reloading the normalized native
437
- // transcript is safer than trying to synthesize its varied JSONL events in
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 (ctx.externalSessionReadOnly) {
882
- appendSystemMsg('此会话由外部终端运行,agentui 仅实时同步,不会发送输入。');
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
- const input = $('chat-input');
1816
- if (input) {
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-20">
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-20"></script>
104
+ <script type="module" src="app.js?v=ui-22"></script>
105
105
  </body>
106
106
  </html>
@@ -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>
@@ -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) {
@@ -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 ? 'Unhide session' : 'Hide session'}">${hid ? '👁' : '🙈'}</button>
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);