claude-simple 0.2.24 → 0.2.25
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 +20 -0
- package/client.js +20 -3
- package/dist/app.0.2.25.min.js +1164 -0
- package/dist/index.html +2 -2
- package/dist/style.0.2.25.min.css +1753 -0
- package/package.json +1 -1
- package/public/api.js +1 -0
- package/public/chat.js +213 -8
- package/public/router.js +5 -0
- package/public/settings.js +312 -63
- package/public/shell/notes.js +5 -2
- package/public/shell/session-list.js +50 -2
- package/public/shell/session-nav.js +45 -12
- package/public/state.js +3 -2
- package/public/style.css +126 -0
- package/public/ui.min.css +1 -1
- package/src/agent-env.js +211 -0
- package/src/agents/claude.js +10 -3
- package/src/agents/grok.js +24 -0
- package/src/agents/models.js +30 -1
- package/src/agents/sessions.js +12 -2
- package/src/app.js +450 -14
- package/src/collab.js +248 -0
- package/src/runtime/external-session-sync.js +106 -0
- package/src/runtime/protocol.js +3 -2
- package/dist/app.0.2.24.min.js +0 -1104
- package/dist/style.0.2.24.min.css +0 -1627
package/src/collab.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
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
|
+
|
|
19
|
+
async function load() {
|
|
20
|
+
if (loaded) return data;
|
|
21
|
+
try {
|
|
22
|
+
data = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
23
|
+
data.locks ||= {};
|
|
24
|
+
data.routes ||= {};
|
|
25
|
+
data.users ||= [];
|
|
26
|
+
} catch {
|
|
27
|
+
data = { users: [], locks: {}, routes: {} };
|
|
28
|
+
}
|
|
29
|
+
loaded = true;
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function save() {
|
|
34
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
35
|
+
const tmp = filePath + '.tmp';
|
|
36
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2));
|
|
37
|
+
await fs.rename(tmp, filePath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function now() { return Date.now(); }
|
|
41
|
+
|
|
42
|
+
function purgeLocks() {
|
|
43
|
+
const t = now();
|
|
44
|
+
for (const [k, l] of Object.entries(data.locks)) {
|
|
45
|
+
if (l.expiresAt < t) delete data.locks[k];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function token() { return crypto.randomBytes(16).toString('hex'); }
|
|
50
|
+
|
|
51
|
+
async function acquire({ key, kind, holder, holderRole, lockToken, ttlMs, exclusive = true }) {
|
|
52
|
+
await load();
|
|
53
|
+
purgeLocks();
|
|
54
|
+
if (!roleAtLeast(holderRole, 'editor')) {
|
|
55
|
+
return { code: 403, error: 'viewers cannot take exclusive locks' };
|
|
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;
|
|
75
|
+
}
|
|
76
|
+
await save();
|
|
77
|
+
return { code: 200, lock: { ...cur } };
|
|
78
|
+
}
|
|
79
|
+
const pub = { ...cur, token: undefined };
|
|
80
|
+
return { code: 423, error: 'locked', lock: pub };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function heartbeat({ key, holder, lockToken, ttlMs }) {
|
|
84
|
+
await load();
|
|
85
|
+
purgeLocks();
|
|
86
|
+
const cur = data.locks[key];
|
|
87
|
+
if (!cur) return { code: 404, error: 'no lock' };
|
|
88
|
+
if (cur.holder !== holder || cur.token !== lockToken) {
|
|
89
|
+
return { code: 409, error: 'token mismatch' };
|
|
90
|
+
}
|
|
91
|
+
cur.expiresAt = now() + (ttlMs || DEFAULT_TTL_MS);
|
|
92
|
+
await save();
|
|
93
|
+
return { code: 200, lock: { ...cur } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function release({ key, holder, lockToken, force }) {
|
|
97
|
+
await load();
|
|
98
|
+
const cur = data.locks[key];
|
|
99
|
+
if (!cur) return { code: 200 };
|
|
100
|
+
if (!force && (cur.holder !== holder || cur.token !== lockToken)) {
|
|
101
|
+
return { code: 409, error: 'token mismatch' };
|
|
102
|
+
}
|
|
103
|
+
delete data.locks[key];
|
|
104
|
+
await save();
|
|
105
|
+
return { code: 200 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function requireLock({ key, lockToken, writer, writerRole }) {
|
|
109
|
+
await load();
|
|
110
|
+
purgeLocks();
|
|
111
|
+
let cur = data.locks[key];
|
|
112
|
+
if (!cur) {
|
|
113
|
+
const l = {
|
|
114
|
+
key, kind: 'auto', holder: writer, holderRole: writerRole,
|
|
115
|
+
token: lockToken || token(), exclusive: true,
|
|
116
|
+
acquiredAt: now(), expiresAt: now() + AUTO_CLAIM_MS, version: 1,
|
|
117
|
+
};
|
|
118
|
+
data.locks[key] = l;
|
|
119
|
+
await save();
|
|
120
|
+
return { code: 200, lock: { ...l } };
|
|
121
|
+
}
|
|
122
|
+
if (cur.holder !== writer || (lockToken && lockToken !== cur.token)) {
|
|
123
|
+
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 } };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function listLocks() {
|
|
131
|
+
await load();
|
|
132
|
+
purgeLocks();
|
|
133
|
+
return Object.values(data.locks).map(l => ({ ...l, token: undefined }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const slugRe = /^[a-z0-9][a-z0-9-]{1,47}$/;
|
|
137
|
+
|
|
138
|
+
async function putRoute(actor, actorRole, route) {
|
|
139
|
+
await load();
|
|
140
|
+
if (!roleAtLeast(actorRole, 'editor')) return { code: 403, error: 'forbidden' };
|
|
141
|
+
const slug = String(route.slug || '').toLowerCase().trim();
|
|
142
|
+
if (!slugRe.test(slug)) return { code: 400, error: 'invalid slug' };
|
|
143
|
+
const port = Number(route.targetPort);
|
|
144
|
+
if (!route.machineId || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
145
|
+
return { code: 400, error: 'machineId and targetPort required' };
|
|
146
|
+
}
|
|
147
|
+
const host = route.targetHost || '127.0.0.1';
|
|
148
|
+
const existing = data.routes[slug];
|
|
149
|
+
if (existing && existing.owner !== actor && !roleAtLeast(actorRole, 'admin')) {
|
|
150
|
+
return { code: 403, error: 'forbidden' };
|
|
151
|
+
}
|
|
152
|
+
const enabled = route.enabled !== false;
|
|
153
|
+
for (const [s, o] of Object.entries(data.routes)) {
|
|
154
|
+
if (s === slug || !o.enabled || !enabled) continue;
|
|
155
|
+
if (o.machineId === route.machineId && (o.targetHost || '127.0.0.1') === host && o.targetPort === port) {
|
|
156
|
+
return { code: 409, error: `port already mapped as ${s}` };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const nowMs = now();
|
|
160
|
+
const next = {
|
|
161
|
+
slug,
|
|
162
|
+
machineId: route.machineId,
|
|
163
|
+
targetHost: host,
|
|
164
|
+
targetPort: port,
|
|
165
|
+
owner: existing?.owner || actor,
|
|
166
|
+
public: !!route.public,
|
|
167
|
+
enabled,
|
|
168
|
+
createdAt: existing?.createdAt || nowMs,
|
|
169
|
+
updatedAt: nowMs,
|
|
170
|
+
};
|
|
171
|
+
data.routes[slug] = next;
|
|
172
|
+
await save();
|
|
173
|
+
return { code: 200, route: next };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function deleteRoute(actor, actorRole, slug) {
|
|
177
|
+
await load();
|
|
178
|
+
const r = data.routes[slug];
|
|
179
|
+
if (!r) return { code: 404, error: 'not found' };
|
|
180
|
+
if (r.owner !== actor && !roleAtLeast(actorRole, 'admin')) return { code: 403, error: 'forbidden' };
|
|
181
|
+
delete data.routes[slug];
|
|
182
|
+
await save();
|
|
183
|
+
return { code: 200 };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function listRoutes(viewer, role) {
|
|
187
|
+
await load();
|
|
188
|
+
return Object.values(data.routes).filter(r => r.public || r.owner === viewer || roleAtLeast(role, 'admin'));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function getRoute(slug) {
|
|
192
|
+
await load();
|
|
193
|
+
return data.routes[slug] || null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
acquire, heartbeat, release, requireLock, listLocks,
|
|
198
|
+
putRoute, deleteRoute, listRoutes, getRoute, load,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function isLoopbackHost(host) {
|
|
203
|
+
const h = String(host || '').toLowerCase();
|
|
204
|
+
return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '0.0.0.0';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function proxyToLocalPort({ host, port, reqPath, method, headers, body, allowRemote }) {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const h = host || '127.0.0.1';
|
|
210
|
+
if (!allowRemote && !isLoopbackHost(h)) {
|
|
211
|
+
reject(Object.assign(new Error('remote hosts not allowed'), { status: 403 }));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const p = Number(port);
|
|
215
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) {
|
|
216
|
+
reject(Object.assign(new Error('invalid port'), { status: 400 }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const lib = http;
|
|
220
|
+
const hdrs = { ...headers };
|
|
221
|
+
delete hdrs.host;
|
|
222
|
+
delete hdrs.authorization;
|
|
223
|
+
delete hdrs['x-hub-token'];
|
|
224
|
+
const r = lib.request({
|
|
225
|
+
hostname: h,
|
|
226
|
+
port: p,
|
|
227
|
+
path: reqPath || '/',
|
|
228
|
+
method: method || 'GET',
|
|
229
|
+
headers: hdrs,
|
|
230
|
+
timeout: 30_000,
|
|
231
|
+
}, (res) => {
|
|
232
|
+
const chunks = [];
|
|
233
|
+
res.on('data', c => chunks.push(c));
|
|
234
|
+
res.on('end', () => resolve({
|
|
235
|
+
status: res.statusCode,
|
|
236
|
+
headers: res.headers,
|
|
237
|
+
body: Buffer.concat(chunks),
|
|
238
|
+
}));
|
|
239
|
+
});
|
|
240
|
+
r.on('error', reject);
|
|
241
|
+
if (body && body.length) r.write(body);
|
|
242
|
+
r.end();
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function machineId() {
|
|
247
|
+
return process.env.MACHINE_ID || os.hostname();
|
|
248
|
+
}
|
|
@@ -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
|
+
}
|
package/src/runtime/protocol.js
CHANGED
|
@@ -95,7 +95,7 @@ export function normalizeCommand(msg) {
|
|
|
95
95
|
return {
|
|
96
96
|
cmd: 'session.subscribe',
|
|
97
97
|
id: msg.id || null,
|
|
98
|
-
agent: null,
|
|
98
|
+
agent: msg.agent || null,
|
|
99
99
|
command: '',
|
|
100
100
|
options: {},
|
|
101
101
|
sessionId: msg.sessionId || null,
|
|
@@ -129,6 +129,7 @@ export const UI_EVENT = {
|
|
|
129
129
|
SHELL_OUTPUT: 'shell-output',
|
|
130
130
|
SHELL_EXIT: 'shell-exit',
|
|
131
131
|
RUN_STATUS: 'run-status',
|
|
132
|
+
GROK_QUEUE_STATUS: 'grok-queue-status',
|
|
132
133
|
ABORT_RESULT: 'abort-result',
|
|
133
134
|
BATCH: 'batch',
|
|
134
135
|
PING: 'ping',
|
|
@@ -165,7 +166,7 @@ export function mapAgentEmit(data, { agent, compactTools = true } = {}) {
|
|
|
165
166
|
'session-created', 'assistant', 'assistant_stream_start', 'assistant_delta',
|
|
166
167
|
'assistant_stream_end', 'thought_stream_start', 'thought_delta', 'thought_stream_end',
|
|
167
168
|
'result', 'complete', 'error', 'status', 'permission_request', 'permission_resolved',
|
|
168
|
-
'shell-start', 'shell-output', 'shell-exit', 'run-status', 'abort-result',
|
|
169
|
+
'shell-start', 'shell-output', 'shell-exit', 'run-status', 'grok-queue-status', 'abort-result',
|
|
169
170
|
]);
|
|
170
171
|
if (known.has(t)) {
|
|
171
172
|
const out = { ...data };
|