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/src/collab.js ADDED
@@ -0,0 +1,267 @@
1
+ import { promises as fs } from 'fs';
2
+ import crypto from 'crypto';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import http from 'http';
6
+
7
+ const DEFAULT_TTL_MS = 60_000;
8
+ const AUTO_CLAIM_MS = 30_000;
9
+ const RANK = { viewer: 1, editor: 2, admin: 3, owner: 4 };
10
+
11
+ export function roleAtLeast(have, need) {
12
+ return (RANK[have] || 0) >= (RANK[need] || 0);
13
+ }
14
+
15
+ export function createCollab({ filePath }) {
16
+ let data = { users: [], locks: {}, routes: {} };
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
+ }
24
+
25
+ async function load() {
26
+ if (loaded) return data;
27
+ try {
28
+ data = JSON.parse(await fs.readFile(filePath, 'utf8'));
29
+ data.locks ||= {};
30
+ data.routes ||= {};
31
+ data.users ||= [];
32
+ } catch {
33
+ data = { users: [], locks: {}, routes: {} };
34
+ }
35
+ loaded = true;
36
+ return data;
37
+ }
38
+
39
+ async function save() {
40
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
41
+ const tmp = filePath + '.tmp';
42
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2));
43
+ await fs.rename(tmp, filePath);
44
+ }
45
+
46
+ function now() { return Date.now(); }
47
+
48
+ function purgeLocks() {
49
+ const t = now();
50
+ for (const [k, l] of Object.entries(data.locks)) {
51
+ if (l.expiresAt < t) delete data.locks[k];
52
+ }
53
+ }
54
+
55
+ function token() { return crypto.randomBytes(16).toString('hex'); }
56
+
57
+ async function acquire({ key, kind, holder, holderRole, lockToken, ttlMs, exclusive = true }) {
58
+ return withMutex(async () => {
59
+ await load();
60
+ purgeLocks();
61
+ if (!roleAtLeast(holderRole, 'editor')) {
62
+ return { code: 403, error: 'viewers cannot take exclusive locks' };
63
+ }
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
+ });
88
+ }
89
+
90
+ async function heartbeat({ key, holder, lockToken, ttlMs }) {
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
+ });
103
+ }
104
+
105
+ async function release({ key, holder, lockToken, force }) {
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
+ });
119
+ }
120
+
121
+ async function requireLock({ key, lockToken, writer, writerRole }) {
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
+ }
145
+ return { code: 423, error: 'locked', lock: { ...cur, token: undefined } };
146
+ });
147
+ }
148
+
149
+ async function listLocks() {
150
+ await load();
151
+ purgeLocks();
152
+ return Object.values(data.locks).map(l => ({ ...l, token: undefined }));
153
+ }
154
+
155
+ const slugRe = /^[a-z0-9][a-z0-9-]{1,47}$/;
156
+
157
+ async function putRoute(actor, actorRole, route) {
158
+ await load();
159
+ if (!roleAtLeast(actorRole, 'editor')) return { code: 403, error: 'forbidden' };
160
+ const slug = String(route.slug || '').toLowerCase().trim();
161
+ if (!slugRe.test(slug)) return { code: 400, error: 'invalid slug' };
162
+ const port = Number(route.targetPort);
163
+ if (!route.machineId || !Number.isInteger(port) || port < 1 || port > 65535) {
164
+ return { code: 400, error: 'machineId and targetPort required' };
165
+ }
166
+ const host = route.targetHost || '127.0.0.1';
167
+ const existing = data.routes[slug];
168
+ if (existing && existing.owner !== actor && !roleAtLeast(actorRole, 'admin')) {
169
+ return { code: 403, error: 'forbidden' };
170
+ }
171
+ const enabled = route.enabled !== false;
172
+ for (const [s, o] of Object.entries(data.routes)) {
173
+ if (s === slug || !o.enabled || !enabled) continue;
174
+ if (o.machineId === route.machineId && (o.targetHost || '127.0.0.1') === host && o.targetPort === port) {
175
+ return { code: 409, error: `port already mapped as ${s}` };
176
+ }
177
+ }
178
+ const nowMs = now();
179
+ const next = {
180
+ slug,
181
+ machineId: route.machineId,
182
+ targetHost: host,
183
+ targetPort: port,
184
+ owner: existing?.owner || actor,
185
+ public: !!route.public,
186
+ enabled,
187
+ createdAt: existing?.createdAt || nowMs,
188
+ updatedAt: nowMs,
189
+ };
190
+ data.routes[slug] = next;
191
+ await save();
192
+ return { code: 200, route: next };
193
+ }
194
+
195
+ async function deleteRoute(actor, actorRole, slug) {
196
+ await load();
197
+ const r = data.routes[slug];
198
+ if (!r) return { code: 404, error: 'not found' };
199
+ if (r.owner !== actor && !roleAtLeast(actorRole, 'admin')) return { code: 403, error: 'forbidden' };
200
+ delete data.routes[slug];
201
+ await save();
202
+ return { code: 200 };
203
+ }
204
+
205
+ async function listRoutes(viewer, role) {
206
+ await load();
207
+ return Object.values(data.routes).filter(r => r.public || r.owner === viewer || roleAtLeast(role, 'admin'));
208
+ }
209
+
210
+ async function getRoute(slug) {
211
+ await load();
212
+ return data.routes[slug] || null;
213
+ }
214
+
215
+ return {
216
+ acquire, heartbeat, release, requireLock, listLocks,
217
+ putRoute, deleteRoute, listRoutes, getRoute, load,
218
+ };
219
+ }
220
+
221
+ export function isLoopbackHost(host) {
222
+ const h = String(host || '').toLowerCase();
223
+ return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '0.0.0.0';
224
+ }
225
+
226
+ export function proxyToLocalPort({ host, port, reqPath, method, headers, body, allowRemote }) {
227
+ return new Promise((resolve, reject) => {
228
+ const h = host || '127.0.0.1';
229
+ if (!allowRemote && !isLoopbackHost(h)) {
230
+ reject(Object.assign(new Error('remote hosts not allowed'), { status: 403 }));
231
+ return;
232
+ }
233
+ const p = Number(port);
234
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
235
+ reject(Object.assign(new Error('invalid port'), { status: 400 }));
236
+ return;
237
+ }
238
+ const lib = http;
239
+ const hdrs = { ...headers };
240
+ delete hdrs.host;
241
+ delete hdrs.authorization;
242
+ delete hdrs['x-hub-token'];
243
+ const r = lib.request({
244
+ hostname: h,
245
+ port: p,
246
+ path: reqPath || '/',
247
+ method: method || 'GET',
248
+ headers: hdrs,
249
+ timeout: 30_000,
250
+ }, (res) => {
251
+ const chunks = [];
252
+ res.on('data', c => chunks.push(c));
253
+ res.on('end', () => resolve({
254
+ status: res.statusCode,
255
+ headers: res.headers,
256
+ body: Buffer.concat(chunks),
257
+ }));
258
+ });
259
+ r.on('error', reject);
260
+ if (body && body.length) r.write(body);
261
+ r.end();
262
+ });
263
+ }
264
+
265
+ export function machineId() {
266
+ return process.env.MACHINE_ID || os.hostname();
267
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Read-only mirror for agent sessions started outside this UI.
3
+ *
4
+ * Native CLIs persist their conversations as JSONL while they run. This
5
+ * service samples only sessions that a browser has explicitly subscribed to,
6
+ * usually when their native JSONL changes (with a low-frequency fallback),
7
+ * compares the normalized transcript, and asks the server to notify viewers
8
+ * when it changes. It never writes to, interrupts, or otherwise controls the
9
+ * external CLI process.
10
+ */
11
+
12
+ function transcriptFingerprint(bundle) {
13
+ const messages = Array.isArray(bundle?.messages) ? bundle.messages : [];
14
+ // Message parsing already normalizes the native formats. JSON preserves a
15
+ // change to an in-progress/rewritten final message as well as append-only
16
+ // turns, which a length-only cursor would miss.
17
+ return JSON.stringify({ agent: bundle?.agent || '', context: bundle?.context || null, messages });
18
+ }
19
+
20
+ export function createExternalSessionMirror({ readSession, emit, intervalMs = 750, clock = globalThis } = {}) {
21
+ if (typeof readSession !== 'function') throw new TypeError('readSession is required');
22
+ if (typeof emit !== 'function') throw new TypeError('emit is required');
23
+
24
+ const entries = new Map();
25
+ let timer = null;
26
+
27
+ const entryKey = (sessionId, agent) => `${agent || ''}:${sessionId}`;
28
+
29
+ function stopTimerWhenIdle() {
30
+ if (entries.size || !timer) return;
31
+ clock.clearInterval(timer);
32
+ timer = null;
33
+ }
34
+
35
+ function ensureTimer() {
36
+ if (timer || !entries.size) return;
37
+ timer = clock.setInterval(() => {
38
+ for (const entry of entries.values()) sync(entry).catch(() => {});
39
+ }, intervalMs);
40
+ }
41
+
42
+ async function sync(entry, { announce = false } = {}) {
43
+ if (entry.loading || !entries.has(entry.key)) return;
44
+ entry.loading = true;
45
+ try {
46
+ const bundle = await readSession(entry.sessionId, entry.agent || null);
47
+ if (!bundle || !entries.has(entry.key)) return;
48
+ const fingerprint = transcriptFingerprint(bundle);
49
+ const changed = fingerprint !== entry.fingerprint;
50
+ entry.fingerprint = fingerprint;
51
+ if (changed || announce) {
52
+ emit(entry.sessionId, {
53
+ type: 'external-session-updated',
54
+ sessionId: entry.sessionId,
55
+ agent: bundle.agent || entry.agent || null,
56
+ initial: !!announce,
57
+ updatedAt: Date.now(),
58
+ });
59
+ }
60
+ } finally {
61
+ entry.loading = false;
62
+ }
63
+ }
64
+
65
+ function unsubscribe(client) {
66
+ const key = client?.externalMirrorKey;
67
+ if (!key) return;
68
+ const entry = entries.get(key);
69
+ if (entry) {
70
+ entry.clients.delete(client);
71
+ if (!entry.clients.size) entries.delete(key);
72
+ }
73
+ client.externalMirrorKey = null;
74
+ stopTimerWhenIdle();
75
+ }
76
+
77
+ function subscribe(client, { sessionId, agent = null } = {}) {
78
+ if (!client || !sessionId) return;
79
+ const key = entryKey(sessionId, agent);
80
+ if (client.externalMirrorKey !== key) unsubscribe(client);
81
+
82
+ let entry = entries.get(key);
83
+ if (!entry) {
84
+ entry = { key, sessionId, agent, clients: new Set(), fingerprint: null, loading: false };
85
+ entries.set(key, entry);
86
+ }
87
+ entry.clients.add(client);
88
+ client.externalMirrorKey = key;
89
+ ensureTimer();
90
+ // Announce the initial snapshot too: the browser may have loaded history
91
+ // just before this subscription reached the server.
92
+ sync(entry, { announce: true }).catch(() => {});
93
+ }
94
+
95
+ function close() {
96
+ if (timer) clock.clearInterval(timer);
97
+ timer = null;
98
+ entries.clear();
99
+ }
100
+
101
+ async function refresh() {
102
+ await Promise.all([...entries.values()].map(entry => sync(entry)));
103
+ }
104
+
105
+ return { subscribe, unsubscribe, close, refresh, size: () => entries.size };
106
+ }
@@ -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
  }
@@ -95,13 +102,14 @@ export function normalizeCommand(msg) {
95
102
  return {
96
103
  cmd: 'session.subscribe',
97
104
  id: msg.id || null,
98
- agent: null,
105
+ agent: msg.agent || null,
99
106
  command: '',
100
107
  options: {},
101
108
  sessionId: msg.sessionId || null,
102
109
  requestId: null,
103
110
  allow: undefined,
104
111
  always: false,
112
+ preserveQueue: false,
105
113
  cwd: null,
106
114
  };
107
115
  }
@@ -129,6 +137,7 @@ export const UI_EVENT = {
129
137
  SHELL_OUTPUT: 'shell-output',
130
138
  SHELL_EXIT: 'shell-exit',
131
139
  RUN_STATUS: 'run-status',
140
+ GROK_QUEUE_STATUS: 'grok-queue-status',
132
141
  ABORT_RESULT: 'abort-result',
133
142
  BATCH: 'batch',
134
143
  PING: 'ping',
@@ -165,7 +174,7 @@ export function mapAgentEmit(data, { agent, compactTools = true } = {}) {
165
174
  'session-created', 'assistant', 'assistant_stream_start', 'assistant_delta',
166
175
  'assistant_stream_end', 'thought_stream_start', 'thought_delta', 'thought_stream_end',
167
176
  'result', 'complete', 'error', 'status', 'permission_request', 'permission_resolved',
168
- 'shell-start', 'shell-output', 'shell-exit', 'run-status', 'abort-result',
177
+ 'shell-start', 'shell-output', 'shell-exit', 'run-status', 'grok-queue-status', 'abort-result',
169
178
  ]);
170
179
  if (known.has(t)) {
171
180
  const out = { ...data };