claude-simple 0.1.0 → 0.2.1
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/client.js +31 -2
- package/dist/app.0.2.1.min.js +868 -0
- package/dist/highlight.min.js +448 -430
- package/dist/index.html +2 -2
- package/dist/style.0.2.1.min.css +1182 -0
- package/package.json +1 -1
- package/public/agent-panel.js +44 -0
- package/public/api.js +61 -10
- package/public/app.js +4 -0
- package/public/chat.js +338 -31
- package/public/files.js +22 -30
- package/public/git.js +21 -17
- package/public/icons/icon-180.png +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-maskable-512.png +0 -0
- package/public/index.html +16 -3
- package/public/lib.js +7 -2
- package/public/manifest.webmanifest +36 -0
- package/public/offline.html +33 -0
- package/public/pwa.js +78 -0
- package/public/shell/boot.js +3 -0
- package/public/shell/hub.js +4 -2
- package/public/shell/notes.js +93 -31
- package/public/shell/session-list.js +29 -10
- package/public/shell/session-nav.js +7 -2
- package/public/shell/templates.js +11 -3
- package/public/shell.js +101 -8
- package/public/state.js +13 -2
- package/public/style.css +135 -3
- package/public/sw.js +104 -0
- package/public/terminal.js +22 -0
- package/public/wake-lock.js +45 -0
- package/server.js +5 -1
- package/src/agents/claude.js +108 -10
- package/src/agents/codex.js +1 -1
- package/src/agents/grok.js +40 -32
- package/src/agents/meta-agent.js +40 -1
- package/src/agents/stream-batch.js +62 -0
- package/src/app.js +312 -41
- package/src/auto-update.js +215 -0
- package/src/runtime/approvals.js +67 -0
- package/src/runtime/outbox.js +143 -0
- package/src/runtime/protocol.js +224 -0
- package/dist/app.0.1.0.min.js +0 -825
- package/dist/style.0.1.0.min.css +0 -1050
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional self-update against the public npm registry.
|
|
3
|
+
*
|
|
4
|
+
* Env:
|
|
5
|
+
* AUTO_UPDATE=0|false|off disable (default: enabled for edge client)
|
|
6
|
+
* AUTO_UPDATE_INTERVAL_HOURS check interval, default 12 (half day)
|
|
7
|
+
* AUTO_UPDATE_PACKAGE package name, default claude-simple
|
|
8
|
+
* AUTO_UPDATE_CHANNEL dist-tag, default latest
|
|
9
|
+
* AUTO_UPDATE_IDLE_WAIT_MS wait for agents to finish before applying, default 30min
|
|
10
|
+
* AUTO_UPDATE_APPLY=0 only log when newer; do not install/restart
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'child_process';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
19
|
+
|
|
20
|
+
function envBool(name, defaultOn) {
|
|
21
|
+
const v = process.env[name];
|
|
22
|
+
if (v == null || v === '') return defaultOn;
|
|
23
|
+
return !/^(0|false|off|no)$/i.test(String(v));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readLocalVersion() {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
29
|
+
return String(pkg.version || '0.0.0');
|
|
30
|
+
} catch {
|
|
31
|
+
return '0.0.0';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** semver-ish compare: a > b → 1, a < b → -1, eq → 0 */
|
|
36
|
+
export function cmpSemver(a, b) {
|
|
37
|
+
const pa = String(a).replace(/^v/, '').split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
|
|
38
|
+
const pb = String(b).replace(/^v/, '').split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
|
|
39
|
+
const n = Math.max(pa.length, pb.length);
|
|
40
|
+
for (let i = 0; i < n; i++) {
|
|
41
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
42
|
+
if (d !== 0) return d > 0 ? 1 : -1;
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchLatestVersion(pkgName, tag) {
|
|
48
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`;
|
|
49
|
+
const res = await fetch(url, {
|
|
50
|
+
headers: { Accept: 'application/json', 'User-Agent': 'claude-simple-auto-update' },
|
|
51
|
+
signal: AbortSignal.timeout(20_000),
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) throw new Error(`registry HTTP ${res.status}`);
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
const ver = data?.['dist-tags']?.[tag] || data?.['dist-tags']?.latest;
|
|
56
|
+
if (!ver) throw new Error('no dist-tag on registry');
|
|
57
|
+
return String(ver);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Detect install layout: global npm vs local/source tree. */
|
|
61
|
+
function detectInstallMode() {
|
|
62
|
+
const entry = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
63
|
+
const inGlobal = /[/\\]node_modules[/\\]claude-simple[/\\]/.test(entry)
|
|
64
|
+
|| /[/\\]lib[/\\]node_modules[/\\]claude-simple[/\\]/.test(PKG_ROOT);
|
|
65
|
+
// Prefer global update when package lives under a node_modules tree named claude-simple
|
|
66
|
+
if (inGlobal || /[/\\]node_modules[/\\]claude-simple$/.test(PKG_ROOT)) {
|
|
67
|
+
return { mode: 'global', cwd: process.cwd() };
|
|
68
|
+
}
|
|
69
|
+
return { mode: 'local', cwd: PKG_ROOT };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function runNpmInstall(mode, pkgName, version, cwd) {
|
|
73
|
+
const spec = `${pkgName}@${version}`;
|
|
74
|
+
const args = mode === 'global'
|
|
75
|
+
? ['install', '-g', spec, '--no-fund', '--no-audit']
|
|
76
|
+
: ['install', spec, '--no-fund', '--no-audit', '--prefix', cwd];
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
console.log(`[auto-update] running: npm ${args.join(' ')}`);
|
|
79
|
+
const child = spawn('npm', args, {
|
|
80
|
+
cwd: mode === 'global' ? osHomedir() : cwd,
|
|
81
|
+
env: process.env,
|
|
82
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
83
|
+
});
|
|
84
|
+
let out = '';
|
|
85
|
+
let err = '';
|
|
86
|
+
child.stdout.on('data', (d) => { out += d; process.stdout.write(d); });
|
|
87
|
+
child.stderr.on('data', (d) => { err += d; process.stderr.write(d); });
|
|
88
|
+
child.on('error', reject);
|
|
89
|
+
child.on('close', (code) => {
|
|
90
|
+
if (code === 0) resolve({ out, err });
|
|
91
|
+
else reject(new Error(`npm exited ${code}: ${(err || out).slice(-500)}`));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function osHomedir() {
|
|
97
|
+
return process.env.HOME || process.env.USERPROFILE || PKG_ROOT;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {object} opts
|
|
102
|
+
* @param {() => boolean} [opts.isBusy] return true while agent turn is running
|
|
103
|
+
* @param {boolean} [opts.enabled]
|
|
104
|
+
* @param {string} [opts.role] log label
|
|
105
|
+
*/
|
|
106
|
+
export function startAutoUpdate(opts = {}) {
|
|
107
|
+
const enabled = opts.enabled != null ? opts.enabled : envBool('AUTO_UPDATE', true);
|
|
108
|
+
if (!enabled) {
|
|
109
|
+
console.log('[auto-update] disabled (AUTO_UPDATE=0)');
|
|
110
|
+
return { stop() {} };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const intervalHours = Math.max(0.25, parseFloat(process.env.AUTO_UPDATE_INTERVAL_HOURS || '12') || 12);
|
|
114
|
+
const intervalMs = intervalHours * 3600 * 1000;
|
|
115
|
+
const pkgName = process.env.AUTO_UPDATE_PACKAGE || 'claude-simple';
|
|
116
|
+
const channel = process.env.AUTO_UPDATE_CHANNEL || 'latest';
|
|
117
|
+
const apply = envBool('AUTO_UPDATE_APPLY', true);
|
|
118
|
+
const idleWaitMs = Math.max(60_000, parseInt(process.env.AUTO_UPDATE_IDLE_WAIT_MS || String(30 * 60 * 1000), 10) || 30 * 60 * 1000);
|
|
119
|
+
const isBusy = typeof opts.isBusy === 'function' ? opts.isBusy : () => false;
|
|
120
|
+
const role = opts.role || 'process';
|
|
121
|
+
|
|
122
|
+
let stopped = false;
|
|
123
|
+
let timer = null;
|
|
124
|
+
let applying = false;
|
|
125
|
+
const localVersion = readLocalVersion();
|
|
126
|
+
|
|
127
|
+
console.log(
|
|
128
|
+
`[auto-update] enabled · local=${localVersion} · check every ${intervalHours}h · package=${pkgName}@${channel} · apply=${apply}`,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
async function waitUntilIdle() {
|
|
132
|
+
const deadline = Date.now() + idleWaitMs;
|
|
133
|
+
while (Date.now() < deadline) {
|
|
134
|
+
if (stopped) return false;
|
|
135
|
+
if (!isBusy()) return true;
|
|
136
|
+
console.log('[auto-update] agent busy — waiting before apply…');
|
|
137
|
+
await new Promise((r) => setTimeout(r, 15_000));
|
|
138
|
+
}
|
|
139
|
+
console.warn('[auto-update] idle wait timed out; applying anyway');
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function checkOnce() {
|
|
144
|
+
if (stopped || applying) return;
|
|
145
|
+
try {
|
|
146
|
+
const latest = await fetchLatestVersion(pkgName, channel);
|
|
147
|
+
const cmp = cmpSemver(latest, localVersion);
|
|
148
|
+
if (cmp <= 0) {
|
|
149
|
+
console.log(`[auto-update] up to date (${localVersion}, registry ${latest})`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
console.log(`[auto-update] new version available: ${localVersion} → ${latest}`);
|
|
153
|
+
if (!apply) {
|
|
154
|
+
console.log('[auto-update] AUTO_UPDATE_APPLY=0 — skip install');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
applying = true;
|
|
159
|
+
await waitUntilIdle();
|
|
160
|
+
if (stopped) return;
|
|
161
|
+
|
|
162
|
+
const { mode, cwd } = detectInstallMode();
|
|
163
|
+
console.log(`[auto-update] install mode=${mode} root=${PKG_ROOT}`);
|
|
164
|
+
|
|
165
|
+
// Global install when package is under node_modules; else npm install in package root
|
|
166
|
+
if (mode === 'global') {
|
|
167
|
+
await runNpmInstall('global', pkgName, latest, cwd);
|
|
168
|
+
} else {
|
|
169
|
+
// Source / linked tree: pull package.json version bump via npm update in place
|
|
170
|
+
await runNpmInstall('local', pkgName, latest, cwd);
|
|
171
|
+
// If we're running from a git checkout that *is* the package (not a dependency),
|
|
172
|
+
// local `npm install claude-simple@x` only updates node_modules — won't replace this tree.
|
|
173
|
+
// For git checkouts, pull via npm pack is wrong; log and skip restart unless global.
|
|
174
|
+
const pkgJsonName = (() => {
|
|
175
|
+
try { return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).name; }
|
|
176
|
+
catch { return null; }
|
|
177
|
+
})();
|
|
178
|
+
if (pkgJsonName === pkgName && !/node_modules/.test(PKG_ROOT)) {
|
|
179
|
+
console.warn(
|
|
180
|
+
'[auto-update] running from source checkout — npm cannot overwrite this tree. '
|
|
181
|
+
+ 'Use `npm install -g claude-simple` for auto-apply, or pull git + restart manually.',
|
|
182
|
+
);
|
|
183
|
+
applying = false;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log(`[auto-update] installed ${latest}; restarting ${role} (exit 0 — supervisor should relaunch)`);
|
|
189
|
+
// Give logs a moment to flush; systemd Restart= will bring us back on new code
|
|
190
|
+
setTimeout(() => process.exit(0), 500);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.warn(`[auto-update] check failed: ${e.message || e}`);
|
|
193
|
+
applying = false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// First check after a short delay (don't compete with boot / hub connect)
|
|
198
|
+
const firstDelay = Math.min(60_000, Math.max(15_000, intervalMs / 100));
|
|
199
|
+
timer = setTimeout(function tick() {
|
|
200
|
+
checkOnce().finally(() => {
|
|
201
|
+
if (stopped) return;
|
|
202
|
+
timer = setTimeout(tick, intervalMs);
|
|
203
|
+
});
|
|
204
|
+
}, firstDelay);
|
|
205
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
stop() {
|
|
209
|
+
stopped = true;
|
|
210
|
+
if (timer) clearTimeout(timer);
|
|
211
|
+
},
|
|
212
|
+
checkNow: checkOnce,
|
|
213
|
+
localVersion,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-flight tool permission requests (Claude canUseTool bridge).
|
|
3
|
+
* requestId → { resolve, reject, sessionId, createdAt }
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const pending = new Map();
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} requestId
|
|
12
|
+
* @param {string | null} sessionId
|
|
13
|
+
* @param {number} [ttlMs]
|
|
14
|
+
* @returns {Promise<{ allow: boolean, always?: boolean }>}
|
|
15
|
+
*/
|
|
16
|
+
export function waitForApproval(requestId, sessionId, ttlMs = DEFAULT_TTL_MS) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
pending.delete(requestId);
|
|
20
|
+
resolve({ allow: false, always: false, timeout: true });
|
|
21
|
+
}, ttlMs);
|
|
22
|
+
|
|
23
|
+
pending.set(requestId, {
|
|
24
|
+
resolve: (v) => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
resolve(v);
|
|
27
|
+
},
|
|
28
|
+
reject: (e) => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
reject(e);
|
|
31
|
+
},
|
|
32
|
+
sessionId: sessionId || null,
|
|
33
|
+
createdAt: Date.now(),
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} requestId
|
|
40
|
+
* @param {{ allow: boolean, always?: boolean }} decision
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function resolveApproval(requestId, decision) {
|
|
44
|
+
const entry = pending.get(requestId);
|
|
45
|
+
if (!entry) return false;
|
|
46
|
+
pending.delete(requestId);
|
|
47
|
+
entry.resolve({
|
|
48
|
+
allow: !!decision.allow,
|
|
49
|
+
always: !!decision.always,
|
|
50
|
+
});
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drop pending approvals for a session (on interrupt). */
|
|
55
|
+
export function cancelApprovalsForSession(sessionId) {
|
|
56
|
+
if (!sessionId) return;
|
|
57
|
+
for (const [id, entry] of pending) {
|
|
58
|
+
if (entry.sessionId === sessionId || entry.sessionId == null) {
|
|
59
|
+
pending.delete(id);
|
|
60
|
+
entry.resolve({ allow: false, always: false, cancelled: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function pendingApprovalCount() {
|
|
66
|
+
return pending.size;
|
|
67
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-pressure WebSocket outbox.
|
|
3
|
+
* - Coalesces assistant/thought deltas
|
|
4
|
+
* - Optionally packs several small events into one `batch` frame
|
|
5
|
+
* - Flushes on size, time, or non-mergeable event
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { UI_EVENT } from './protocol.js';
|
|
9
|
+
|
|
10
|
+
const DELTA_TYPES = new Set([
|
|
11
|
+
UI_EVENT.ASSISTANT_DELTA,
|
|
12
|
+
UI_EVENT.THOUGHT_DELTA,
|
|
13
|
+
'shell-output',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {(data: object) => void} rawSend — must JSON-send one frame
|
|
18
|
+
* @param {object} [opts]
|
|
19
|
+
*/
|
|
20
|
+
export function createOutbox(rawSend, opts = {}) {
|
|
21
|
+
const maxWait = opts.maxWait ?? 45;
|
|
22
|
+
const maxDeltaChars = opts.maxDeltaChars ?? 640;
|
|
23
|
+
const maxBatchItems = opts.maxBatchItems ?? 12;
|
|
24
|
+
const maxBatchBytes = opts.maxBatchBytes ?? 8_000;
|
|
25
|
+
const enableBatch = opts.enableBatch !== false;
|
|
26
|
+
|
|
27
|
+
/** @type {object | null} */
|
|
28
|
+
let pendingDelta = null;
|
|
29
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
30
|
+
let deltaTimer = null;
|
|
31
|
+
/** @type {object[]} */
|
|
32
|
+
let batch = [];
|
|
33
|
+
let batchBytes = 0;
|
|
34
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
35
|
+
let batchTimer = null;
|
|
36
|
+
|
|
37
|
+
function emitFrame(frame) {
|
|
38
|
+
if (!frame) return;
|
|
39
|
+
try {
|
|
40
|
+
rawSend(frame);
|
|
41
|
+
} catch { /* ignore closed socket */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function flushBatch() {
|
|
45
|
+
if (batchTimer) {
|
|
46
|
+
clearTimeout(batchTimer);
|
|
47
|
+
batchTimer = null;
|
|
48
|
+
}
|
|
49
|
+
if (!batch.length) return;
|
|
50
|
+
const items = batch;
|
|
51
|
+
batch = [];
|
|
52
|
+
batchBytes = 0;
|
|
53
|
+
if (items.length === 1) {
|
|
54
|
+
emitFrame(items[0]);
|
|
55
|
+
} else {
|
|
56
|
+
emitFrame({ type: UI_EVENT.BATCH, items });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function enqueue(frame) {
|
|
61
|
+
if (!enableBatch) {
|
|
62
|
+
emitFrame(frame);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Control / terminal events go out immediately (after draining batch)
|
|
66
|
+
const urgent = frame.type === 'error'
|
|
67
|
+
|| frame.type === 'complete'
|
|
68
|
+
|| frame.type === 'result'
|
|
69
|
+
|| frame.type === 'permission_request'
|
|
70
|
+
|| frame.type === 'session-created'
|
|
71
|
+
|| frame.type === 'run-status'
|
|
72
|
+
|| frame.type === 'abort-result'
|
|
73
|
+
|| frame.type === 'shell-exit';
|
|
74
|
+
if (urgent) {
|
|
75
|
+
flushBatch();
|
|
76
|
+
emitFrame(frame);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let size;
|
|
81
|
+
try {
|
|
82
|
+
size = JSON.stringify(frame).length;
|
|
83
|
+
} catch {
|
|
84
|
+
size = 256;
|
|
85
|
+
}
|
|
86
|
+
batch.push(frame);
|
|
87
|
+
batchBytes += size;
|
|
88
|
+
if (batch.length >= maxBatchItems || batchBytes >= maxBatchBytes) {
|
|
89
|
+
flushBatch();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!batchTimer) batchTimer = setTimeout(flushBatch, maxWait);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function flushDelta() {
|
|
96
|
+
if (deltaTimer) {
|
|
97
|
+
clearTimeout(deltaTimer);
|
|
98
|
+
deltaTimer = null;
|
|
99
|
+
}
|
|
100
|
+
if (!pendingDelta) return;
|
|
101
|
+
const d = pendingDelta;
|
|
102
|
+
pendingDelta = null;
|
|
103
|
+
enqueue(d);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Accept a UI event (or null to drop).
|
|
108
|
+
* @param {object | null | undefined} event
|
|
109
|
+
*/
|
|
110
|
+
function send(event) {
|
|
111
|
+
if (!event) return;
|
|
112
|
+
|
|
113
|
+
if (DELTA_TYPES.has(event.type)) {
|
|
114
|
+
// Merge consecutive deltas of same type (+ same agent/stream key)
|
|
115
|
+
const key = `${event.type}|${event.agent || ''}|${event.stream || ''}`;
|
|
116
|
+
if (pendingDelta) {
|
|
117
|
+
const pKey = `${pendingDelta.type}|${pendingDelta.agent || ''}|${pendingDelta.stream || ''}`;
|
|
118
|
+
if (pKey === key) {
|
|
119
|
+
pendingDelta.data = (pendingDelta.data || '') + (event.data || '');
|
|
120
|
+
if ((pendingDelta.data || '').length >= maxDeltaChars) flushDelta();
|
|
121
|
+
else if (!deltaTimer) deltaTimer = setTimeout(flushDelta, maxWait);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
flushDelta();
|
|
125
|
+
}
|
|
126
|
+
pendingDelta = { ...event, data: event.data || '' };
|
|
127
|
+
if ((pendingDelta.data || '').length >= maxDeltaChars) flushDelta();
|
|
128
|
+
else if (!deltaTimer) deltaTimer = setTimeout(flushDelta, maxWait);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Non-delta: flush coalesced text first so order stays correct
|
|
133
|
+
flushDelta();
|
|
134
|
+
enqueue(event);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function flush() {
|
|
138
|
+
flushDelta();
|
|
139
|
+
flushBatch();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { send, flush };
|
|
143
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed control-plane commands + canonical runtime events.
|
|
3
|
+
* Inspired by T3's orchestration/adapters split, kept intentionally thin.
|
|
4
|
+
*
|
|
5
|
+
* Client → server: Command
|
|
6
|
+
* Server → client: RuntimeEvent (optionally wrapped in `batch`)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @typedef {'turn.start'|'turn.interrupt'|'approval.respond'|'shell.exec'|'session.subscribe'} CommandType */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Normalize any inbound WS message into a command, or null if not a control command.
|
|
13
|
+
* Keeps legacy aliases working (agent-command, abort-session, …).
|
|
14
|
+
*/
|
|
15
|
+
export function normalizeCommand(msg) {
|
|
16
|
+
if (!msg || typeof msg !== 'object') return null;
|
|
17
|
+
|
|
18
|
+
// New shape: { type: 'command', cmd: 'turn.start', ... }
|
|
19
|
+
if (msg.type === 'command' && msg.cmd) {
|
|
20
|
+
return {
|
|
21
|
+
cmd: msg.cmd,
|
|
22
|
+
id: msg.id || null,
|
|
23
|
+
agent: msg.agent || msg.options?.agent || 'claude',
|
|
24
|
+
command: msg.command ?? msg.prompt ?? '',
|
|
25
|
+
options: msg.options || {},
|
|
26
|
+
sessionId: msg.sessionId || msg.options?.sessionId || null,
|
|
27
|
+
requestId: msg.requestId || null,
|
|
28
|
+
allow: msg.allow,
|
|
29
|
+
always: msg.always,
|
|
30
|
+
cwd: msg.cwd || msg.options?.cwd || null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (msg.type === 'claude-command' || msg.type === 'agent-command') {
|
|
35
|
+
return {
|
|
36
|
+
cmd: 'turn.start',
|
|
37
|
+
id: msg.id || null,
|
|
38
|
+
agent: msg.agent || msg.options?.agent || 'claude',
|
|
39
|
+
command: msg.command || '',
|
|
40
|
+
options: msg.options || {},
|
|
41
|
+
sessionId: msg.options?.sessionId || null,
|
|
42
|
+
requestId: null,
|
|
43
|
+
allow: undefined,
|
|
44
|
+
always: false,
|
|
45
|
+
cwd: msg.options?.cwd || null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (msg.type === 'abort-session') {
|
|
50
|
+
return {
|
|
51
|
+
cmd: 'turn.interrupt',
|
|
52
|
+
id: msg.id || null,
|
|
53
|
+
agent: null,
|
|
54
|
+
command: '',
|
|
55
|
+
options: {},
|
|
56
|
+
sessionId: msg.sessionId || null,
|
|
57
|
+
requestId: null,
|
|
58
|
+
allow: undefined,
|
|
59
|
+
always: false,
|
|
60
|
+
cwd: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (msg.type === 'claude-permission-response' || msg.type === 'approval.respond') {
|
|
65
|
+
return {
|
|
66
|
+
cmd: 'approval.respond',
|
|
67
|
+
id: msg.id || null,
|
|
68
|
+
agent: null,
|
|
69
|
+
command: '',
|
|
70
|
+
options: {},
|
|
71
|
+
sessionId: msg.sessionId || null,
|
|
72
|
+
requestId: msg.requestId || msg.request_id,
|
|
73
|
+
allow: msg.allow !== false && msg.allow !== 0 && msg.allow !== '0',
|
|
74
|
+
always: !!msg.always,
|
|
75
|
+
cwd: null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (msg.type === 'shell-command') {
|
|
80
|
+
return {
|
|
81
|
+
cmd: 'shell.exec',
|
|
82
|
+
id: msg.id || null,
|
|
83
|
+
agent: null,
|
|
84
|
+
command: msg.command || '',
|
|
85
|
+
options: {},
|
|
86
|
+
sessionId: null,
|
|
87
|
+
requestId: null,
|
|
88
|
+
allow: undefined,
|
|
89
|
+
always: false,
|
|
90
|
+
cwd: msg.cwd || null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (msg.type === 'subscribe') {
|
|
95
|
+
return {
|
|
96
|
+
cmd: 'session.subscribe',
|
|
97
|
+
id: msg.id || null,
|
|
98
|
+
agent: null,
|
|
99
|
+
command: '',
|
|
100
|
+
options: {},
|
|
101
|
+
sessionId: msg.sessionId || null,
|
|
102
|
+
requestId: null,
|
|
103
|
+
allow: undefined,
|
|
104
|
+
always: false,
|
|
105
|
+
cwd: null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Event types the UI understands today (kept stable for back-compat). */
|
|
113
|
+
export const UI_EVENT = {
|
|
114
|
+
SESSION_CREATED: 'session-created',
|
|
115
|
+
ASSISTANT: 'assistant',
|
|
116
|
+
ASSISTANT_STREAM_START: 'assistant_stream_start',
|
|
117
|
+
ASSISTANT_DELTA: 'assistant_delta',
|
|
118
|
+
ASSISTANT_STREAM_END: 'assistant_stream_end',
|
|
119
|
+
THOUGHT_STREAM_START: 'thought_stream_start',
|
|
120
|
+
THOUGHT_DELTA: 'thought_delta',
|
|
121
|
+
THOUGHT_STREAM_END: 'thought_stream_end',
|
|
122
|
+
RESULT: 'result',
|
|
123
|
+
COMPLETE: 'complete',
|
|
124
|
+
ERROR: 'error',
|
|
125
|
+
STATUS: 'status',
|
|
126
|
+
PERMISSION_REQUEST: 'permission_request',
|
|
127
|
+
PERMISSION_RESOLVED: 'permission_resolved',
|
|
128
|
+
SHELL_START: 'shell-start',
|
|
129
|
+
SHELL_OUTPUT: 'shell-output',
|
|
130
|
+
SHELL_EXIT: 'shell-exit',
|
|
131
|
+
RUN_STATUS: 'run-status',
|
|
132
|
+
ABORT_RESULT: 'abort-result',
|
|
133
|
+
BATCH: 'batch',
|
|
134
|
+
PING: 'ping',
|
|
135
|
+
PONG: 'pong',
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Compact tool_use inputs before they hit the wire (mobile tunnel cost).
|
|
140
|
+
* @param {unknown} input
|
|
141
|
+
* @param {number} [max=1200]
|
|
142
|
+
*/
|
|
143
|
+
export function compactToolInput(input, max = 1200) {
|
|
144
|
+
if (input == null) return input;
|
|
145
|
+
let raw;
|
|
146
|
+
try {
|
|
147
|
+
raw = typeof input === 'string' ? input : JSON.stringify(input);
|
|
148
|
+
} catch {
|
|
149
|
+
raw = String(input);
|
|
150
|
+
}
|
|
151
|
+
if (raw.length <= max) return input;
|
|
152
|
+
return { _truncated: true, preview: raw.slice(0, max), bytes: raw.length };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Map a loose agent emit into a UI event, dropping unknown SDK noise.
|
|
157
|
+
* Returns null to drop the frame entirely.
|
|
158
|
+
*/
|
|
159
|
+
export function mapAgentEmit(data, { agent, compactTools = true } = {}) {
|
|
160
|
+
if (!data || typeof data !== 'object') return null;
|
|
161
|
+
const t = data.type;
|
|
162
|
+
|
|
163
|
+
// Already a UI event we know
|
|
164
|
+
const known = new Set([
|
|
165
|
+
'session-created', 'assistant', 'assistant_stream_start', 'assistant_delta',
|
|
166
|
+
'assistant_stream_end', 'thought_stream_start', 'thought_delta', 'thought_stream_end',
|
|
167
|
+
'result', 'complete', 'error', 'status', 'permission_request', 'permission_resolved',
|
|
168
|
+
'shell-start', 'shell-output', 'shell-exit', 'run-status', 'abort-result',
|
|
169
|
+
]);
|
|
170
|
+
if (known.has(t)) {
|
|
171
|
+
const out = { ...data };
|
|
172
|
+
if (agent && !out.agent) out.agent = agent;
|
|
173
|
+
if (compactTools && out.type === 'assistant' && Array.isArray(out.message?.content)) {
|
|
174
|
+
out.message = {
|
|
175
|
+
...out.message,
|
|
176
|
+
content: out.message.content.map((b) => {
|
|
177
|
+
if (b?.type === 'tool_use' && b.input != null) {
|
|
178
|
+
return { ...b, input: compactToolInput(b.input) };
|
|
179
|
+
}
|
|
180
|
+
return b;
|
|
181
|
+
}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Claude Agent SDK message shapes → UI
|
|
188
|
+
if (t === 'system' && data.session_id) {
|
|
189
|
+
return { type: 'session-created', sessionId: data.session_id, agent: agent || 'claude' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (t === 'assistant' && data.message) {
|
|
193
|
+
// SDK assistant message — keep but compact tools
|
|
194
|
+
return mapAgentEmit({ type: 'assistant', message: data.message, ts: data.ts }, { agent, compactTools });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (t === 'result') {
|
|
198
|
+
return {
|
|
199
|
+
type: 'result',
|
|
200
|
+
is_error: !!data.is_error,
|
|
201
|
+
usage: data.usage || null,
|
|
202
|
+
sessionId: data.session_id || data.sessionId || null,
|
|
203
|
+
agent: agent || data.agent || 'claude',
|
|
204
|
+
result: typeof data.result === 'string' && data.result.length > 4000
|
|
205
|
+
? data.result.slice(0, 4000) + '…'
|
|
206
|
+
: data.result,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Drop high-chatter / unused SDK frames (stream_event, user, tool_progress, auth, …)
|
|
211
|
+
if (
|
|
212
|
+
t === 'stream_event'
|
|
213
|
+
|| t === 'user'
|
|
214
|
+
|| t === 'tool_progress'
|
|
215
|
+
|| t === 'auth_status'
|
|
216
|
+
|| t === 'rate_limit_event'
|
|
217
|
+
|| t === 'system' // non-session system notices
|
|
218
|
+
) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Unknown — drop to cut noise (was previously forwarded wholesale from Claude)
|
|
223
|
+
return null;
|
|
224
|
+
}
|