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
package/public/sw.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/* Agent UI service worker — cache shell assets only.
|
|
2
|
+
* Never intercepts WebSocket or mutates API semantics beyond offline fallback.
|
|
3
|
+
* Bump CACHE when shipping UI that must invalidate old shells. */
|
|
4
|
+
const CACHE = 'agent-ui-shell-v3';
|
|
5
|
+
const PRECACHE = [
|
|
6
|
+
'/',
|
|
7
|
+
'/index.html',
|
|
8
|
+
'/manifest.webmanifest',
|
|
9
|
+
'/style.css?v=boot-2',
|
|
10
|
+
'/style.css',
|
|
11
|
+
'/app.js',
|
|
12
|
+
'/icons/icon-192.png',
|
|
13
|
+
'/icons/icon-512.png',
|
|
14
|
+
'/icons/icon-maskable-512.png',
|
|
15
|
+
'/icons/icon-180.png',
|
|
16
|
+
'/offline.html',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
self.addEventListener('install', (event) => {
|
|
20
|
+
event.waitUntil(
|
|
21
|
+
caches.open(CACHE)
|
|
22
|
+
.then((cache) => cache.addAll(PRECACHE.map((u) => new Request(u, { cache: 'reload' }))))
|
|
23
|
+
.then(() => self.skipWaiting())
|
|
24
|
+
.catch(() => self.skipWaiting()),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
self.addEventListener('activate', (event) => {
|
|
29
|
+
event.waitUntil(
|
|
30
|
+
caches.keys()
|
|
31
|
+
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
|
32
|
+
.then(() => self.clients.claim()),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function isApiOrRealtime(url) {
|
|
37
|
+
const p = url.pathname;
|
|
38
|
+
return (
|
|
39
|
+
p.startsWith('/api/')
|
|
40
|
+
|| p.startsWith('/ws/')
|
|
41
|
+
|| p === '/machine-connect'
|
|
42
|
+
|| p.startsWith('/machine/')
|
|
43
|
+
|| p === '/healthz'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isStaticAsset(url) {
|
|
48
|
+
if (url.origin !== self.location.origin) return false;
|
|
49
|
+
if (isApiOrRealtime(url)) return false;
|
|
50
|
+
return /\.(?:js|css|png|svg|webp|ico|woff2?|ttf|map|webmanifest)(?:$|\?)/i.test(url.pathname + url.search)
|
|
51
|
+
|| url.pathname.startsWith('/icons/');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
self.addEventListener('fetch', (event) => {
|
|
55
|
+
const req = event.request;
|
|
56
|
+
if (req.method !== 'GET') return;
|
|
57
|
+
|
|
58
|
+
const url = new URL(req.url);
|
|
59
|
+
|
|
60
|
+
// Let the browser handle API + anything that might upgrade to WebSocket.
|
|
61
|
+
if (isApiOrRealtime(url)) return;
|
|
62
|
+
|
|
63
|
+
// Cross-origin (CDN daisyui/tailwind/marked): network only — do not cache opaque forever.
|
|
64
|
+
if (url.origin !== self.location.origin) return;
|
|
65
|
+
|
|
66
|
+
// Navigations: network-first so deploys show up; offline → cached shell.
|
|
67
|
+
if (req.mode === 'navigate' || (req.headers.get('accept') || '').includes('text/html')) {
|
|
68
|
+
event.respondWith(
|
|
69
|
+
fetch(req)
|
|
70
|
+
.then((res) => {
|
|
71
|
+
const copy = res.clone();
|
|
72
|
+
if (res.ok) {
|
|
73
|
+
caches.open(CACHE).then((c) => c.put('/index.html', copy)).catch(() => {});
|
|
74
|
+
}
|
|
75
|
+
return res;
|
|
76
|
+
})
|
|
77
|
+
.catch(async () => {
|
|
78
|
+
const cached = await caches.match('/index.html') || await caches.match('/');
|
|
79
|
+
return cached || caches.match('/offline.html');
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Same-origin static modules: stale-while-revalidate.
|
|
86
|
+
if (isStaticAsset(url)) {
|
|
87
|
+
event.respondWith(
|
|
88
|
+
caches.open(CACHE).then(async (cache) => {
|
|
89
|
+
const cached = await cache.match(req);
|
|
90
|
+
const network = fetch(req)
|
|
91
|
+
.then((res) => {
|
|
92
|
+
if (res && res.ok) cache.put(req, res.clone()).catch(() => {});
|
|
93
|
+
return res;
|
|
94
|
+
})
|
|
95
|
+
.catch(() => cached);
|
|
96
|
+
return cached || network;
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
self.addEventListener('message', (event) => {
|
|
103
|
+
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
|
|
104
|
+
});
|
package/public/terminal.js
CHANGED
|
@@ -8,6 +8,9 @@ let fitAddon = null;
|
|
|
8
8
|
let connected = false;
|
|
9
9
|
let lastCwd = null;
|
|
10
10
|
let lastMachine = null;
|
|
11
|
+
let shellGen = 0;
|
|
12
|
+
let shellReconnectTimer = null;
|
|
13
|
+
let shellWanted = false;
|
|
11
14
|
|
|
12
15
|
function initXterm() {
|
|
13
16
|
if (term) return;
|
|
@@ -88,6 +91,9 @@ function activeMachine() {
|
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
function connect(cwd) {
|
|
94
|
+
if (shellReconnectTimer) { clearTimeout(shellReconnectTimer); shellReconnectTimer = null; }
|
|
95
|
+
shellWanted = true;
|
|
96
|
+
const gen = ++shellGen;
|
|
91
97
|
if (ws) { try { ws.close(); } catch {} ws = null; }
|
|
92
98
|
connected = false;
|
|
93
99
|
|
|
@@ -108,12 +114,14 @@ function connect(cwd) {
|
|
|
108
114
|
ws = new WebSocket(`${proto}://${location.host}/ws/shell?${q}`);
|
|
109
115
|
|
|
110
116
|
ws.addEventListener('open', () => {
|
|
117
|
+
if (gen !== shellGen) return;
|
|
111
118
|
connected = true;
|
|
112
119
|
term?.write('\x1b[32mConnected to interactive terminal\x1b[0m\r\n');
|
|
113
120
|
sendResize();
|
|
114
121
|
});
|
|
115
122
|
|
|
116
123
|
ws.addEventListener('message', e => {
|
|
124
|
+
if (gen !== shellGen) return;
|
|
117
125
|
let msg;
|
|
118
126
|
try { msg = JSON.parse(e.data); } catch { return; }
|
|
119
127
|
if (msg.type === 'output') {
|
|
@@ -126,11 +134,19 @@ function connect(cwd) {
|
|
|
126
134
|
});
|
|
127
135
|
|
|
128
136
|
ws.addEventListener('close', () => {
|
|
137
|
+
if (gen !== shellGen) return;
|
|
129
138
|
connected = false;
|
|
130
139
|
term?.write('\r\n\x1b[33m[disconnected]\x1b[0m\r\n');
|
|
140
|
+
if (!shellWanted || currentTab.peek() !== 'shell') return;
|
|
141
|
+
term?.write('\x1b[33m[reconnecting…]\x1b[0m\r\n');
|
|
142
|
+
shellReconnectTimer = setTimeout(() => {
|
|
143
|
+
shellReconnectTimer = null;
|
|
144
|
+
connect(lastCwd);
|
|
145
|
+
}, 1500);
|
|
131
146
|
});
|
|
132
147
|
|
|
133
148
|
ws.addEventListener('error', () => {
|
|
149
|
+
if (gen !== shellGen) return;
|
|
134
150
|
term?.write('\r\n\x1b[31m[WebSocket error]\x1b[0m\r\n');
|
|
135
151
|
});
|
|
136
152
|
}
|
|
@@ -161,6 +177,12 @@ function activateShellTab() {
|
|
|
161
177
|
}
|
|
162
178
|
|
|
163
179
|
export function initTerminal() {
|
|
180
|
+
document.addEventListener('visibilitychange', () => {
|
|
181
|
+
if (document.visibilityState !== 'visible') return;
|
|
182
|
+
if (currentTab.peek() !== 'shell' || !shellWanted) return;
|
|
183
|
+
if (!connected) connect(lastCwd);
|
|
184
|
+
});
|
|
185
|
+
|
|
164
186
|
// Defer xterm until first visit to shell (container must be measurable)
|
|
165
187
|
watch(currentTab, tab => {
|
|
166
188
|
if (tab === 'shell') activateShellTab();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// wake-lock.js — keep screen awake during active chat / agent runs (macOS sleep fix)
|
|
2
|
+
let wakeLock = null;
|
|
3
|
+
let wantLock = false;
|
|
4
|
+
|
|
5
|
+
async function acquire() {
|
|
6
|
+
if (!wantLock) return;
|
|
7
|
+
if (typeof navigator === 'undefined' || !navigator.wakeLock?.request) return;
|
|
8
|
+
if (document.visibilityState !== 'visible') return;
|
|
9
|
+
if (wakeLock) return;
|
|
10
|
+
try {
|
|
11
|
+
wakeLock = await navigator.wakeLock.request('screen');
|
|
12
|
+
wakeLock.addEventListener('release', () => {
|
|
13
|
+
wakeLock = null;
|
|
14
|
+
// System released (e.g. low battery) — try again if still wanted
|
|
15
|
+
if (wantLock && document.visibilityState === 'visible') {
|
|
16
|
+
setTimeout(() => { acquire().catch(() => {}); }, 500);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
} catch {
|
|
20
|
+
// Not allowed / unsupported — ignore
|
|
21
|
+
wakeLock = null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function release() {
|
|
26
|
+
const lock = wakeLock;
|
|
27
|
+
wakeLock = null;
|
|
28
|
+
if (!lock) return;
|
|
29
|
+
try { await lock.release(); } catch { /* ignore */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Enable/disable preferred wake-lock state (still requires visible document). */
|
|
33
|
+
export function setWakeLockDesired(on) {
|
|
34
|
+
wantLock = !!on;
|
|
35
|
+
if (wantLock) acquire().catch(() => {});
|
|
36
|
+
else release().catch(() => {});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function initWakeLock() {
|
|
40
|
+
if (typeof document === 'undefined') return;
|
|
41
|
+
document.addEventListener('visibilitychange', () => {
|
|
42
|
+
if (document.visibilityState === 'visible' && wantLock) acquire().catch(() => {});
|
|
43
|
+
else if (document.visibilityState === 'hidden') release().catch(() => {});
|
|
44
|
+
});
|
|
45
|
+
}
|
package/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createApp, init, PORT } from './src/app.js';
|
|
2
|
+
import { createApp, init, PORT, hasBusyAgent } from './src/app.js';
|
|
3
|
+
import { startAutoUpdate } from './src/auto-update.js';
|
|
3
4
|
|
|
4
5
|
await init();
|
|
5
6
|
|
|
@@ -10,3 +11,6 @@ server.listen(PORT, () => {
|
|
|
10
11
|
if (process.env.AUTH_USERNAME) console.log(` Auth: env user "${process.env.AUTH_USERNAME}"`);
|
|
11
12
|
console.log();
|
|
12
13
|
});
|
|
14
|
+
|
|
15
|
+
// Optional self-update (same as edge). AUTO_UPDATE=0 to disable.
|
|
16
|
+
startAutoUpdate({ role: 'standalone', isBusy: hasBusyAgent });
|
package/src/agents/claude.js
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import crypto from 'crypto';
|
|
3
|
+
import { mapAgentEmit, compactToolInput } from '../runtime/protocol.js';
|
|
4
|
+
import { waitForApproval, cancelApprovalsForSession } from '../runtime/approvals.js';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Run a Claude Code turn via the agent SDK.
|
|
6
|
-
* Emits
|
|
7
|
-
*
|
|
8
|
+
* Emits filtered UI events only (no raw SDK dump) to cut wire traffic.
|
|
9
|
+
* Supports canUseTool → permission_request / approval.respond round-trip.
|
|
8
10
|
*/
|
|
9
11
|
export async function runClaude(command, options, send, { cliPath, activeSessions }) {
|
|
10
12
|
const sessionKey = options.sessionId || crypto.randomUUID();
|
|
11
13
|
const controller = new AbortController();
|
|
12
|
-
activeSessions.set(sessionKey, {
|
|
14
|
+
activeSessions.set(sessionKey, {
|
|
15
|
+
controller,
|
|
16
|
+
kind: 'claude',
|
|
17
|
+
startTime: Date.now(),
|
|
18
|
+
busy: true,
|
|
19
|
+
key: sessionKey,
|
|
20
|
+
sessionId: options.sessionId || null,
|
|
21
|
+
});
|
|
13
22
|
|
|
14
23
|
const env = { ...process.env };
|
|
15
24
|
if (options.configDir) env.CLAUDE_CONFIG_DIR = options.configDir;
|
|
16
25
|
|
|
26
|
+
const permissionMode = options.permissionMode || 'default';
|
|
27
|
+
const autoApprove = permissionMode === 'bypassPermissions'
|
|
28
|
+
|| permissionMode === 'acceptEdits'
|
|
29
|
+
|| permissionMode === 'auto'
|
|
30
|
+
|| options.allowDangerouslySkipPermissions;
|
|
31
|
+
|
|
32
|
+
const emit = (raw) => {
|
|
33
|
+
const mapped = mapAgentEmit(raw, { agent: 'claude', compactTools: true });
|
|
34
|
+
if (mapped) send(mapped);
|
|
35
|
+
};
|
|
36
|
+
|
|
17
37
|
const sdkOptions = {
|
|
18
38
|
env,
|
|
19
39
|
pathToClaudeCodeExecutable: cliPath,
|
|
@@ -23,17 +43,54 @@ export async function runClaude(command, options, send, { cliPath, activeSession
|
|
|
23
43
|
model: options.model || 'claude-sonnet-4-5',
|
|
24
44
|
abortSignal: controller.signal,
|
|
25
45
|
};
|
|
26
|
-
if (options.cwd)
|
|
46
|
+
if (options.cwd) sdkOptions.cwd = options.cwd;
|
|
27
47
|
if (options.sessionId) sdkOptions.resume = options.sessionId;
|
|
28
|
-
if (options.effort)
|
|
29
|
-
if (
|
|
30
|
-
sdkOptions.permissionMode =
|
|
48
|
+
if (options.effort) sdkOptions.effort = options.effort;
|
|
49
|
+
if (permissionMode && permissionMode !== 'default') {
|
|
50
|
+
sdkOptions.permissionMode = permissionMode;
|
|
31
51
|
}
|
|
32
52
|
if (options.allowDangerouslySkipPermissions) {
|
|
33
53
|
sdkOptions.allowDangerouslySkipPermissions = true;
|
|
34
54
|
}
|
|
35
55
|
if (options.allowedTools?.length) sdkOptions.allowedTools = options.allowedTools;
|
|
36
56
|
|
|
57
|
+
// Interactive permission bridge when not in auto modes
|
|
58
|
+
if (!autoApprove && permissionMode !== 'bypassPermissions') {
|
|
59
|
+
sdkOptions.canUseTool = async (toolName, input, toolOpts) => {
|
|
60
|
+
if (controller.signal.aborted || toolOpts?.signal?.aborted) {
|
|
61
|
+
return { behavior: 'deny', message: 'aborted', interrupt: true };
|
|
62
|
+
}
|
|
63
|
+
const requestId = toolOpts?.toolUseID || crypto.randomUUID();
|
|
64
|
+
const sid = activeSessions.get(sessionKey)?.sessionId || options.sessionId || sessionKey;
|
|
65
|
+
send({
|
|
66
|
+
type: 'permission_request',
|
|
67
|
+
request_id: requestId,
|
|
68
|
+
requestId,
|
|
69
|
+
tool_name: toolName,
|
|
70
|
+
tool_input: compactToolInput(input, 400),
|
|
71
|
+
title: toolOpts?.title || null,
|
|
72
|
+
displayName: toolOpts?.displayName || null,
|
|
73
|
+
description: toolOpts?.description || null,
|
|
74
|
+
sessionId: sid,
|
|
75
|
+
agent: 'claude',
|
|
76
|
+
});
|
|
77
|
+
const decision = await waitForApproval(requestId, sid);
|
|
78
|
+
if (decision.cancelled || decision.timeout || controller.signal.aborted) {
|
|
79
|
+
return { behavior: 'deny', message: decision.timeout ? 'permission timeout' : 'denied', interrupt: !!decision.cancelled };
|
|
80
|
+
}
|
|
81
|
+
if (decision.allow) {
|
|
82
|
+
const result = { behavior: 'allow', updatedInput: input };
|
|
83
|
+
if (decision.always && toolOpts?.suggestions?.length) {
|
|
84
|
+
result.updatedPermissions = toolOpts.suggestions;
|
|
85
|
+
}
|
|
86
|
+
send({ type: 'permission_resolved', requestId, allow: true, agent: 'claude' });
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
send({ type: 'permission_resolved', requestId, allow: false, agent: 'claude' });
|
|
90
|
+
return { behavior: 'deny', message: 'User denied permission' };
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
37
94
|
let capturedSessionId = null;
|
|
38
95
|
|
|
39
96
|
try {
|
|
@@ -41,18 +98,59 @@ export async function runClaude(command, options, send, { cliPath, activeSession
|
|
|
41
98
|
for await (const msg of stream) {
|
|
42
99
|
if (msg.type === 'system' && msg.session_id && !capturedSessionId) {
|
|
43
100
|
capturedSessionId = msg.session_id;
|
|
101
|
+
const ent = activeSessions.get(sessionKey);
|
|
102
|
+
if (ent) {
|
|
103
|
+
ent.sessionId = capturedSessionId;
|
|
104
|
+
activeSessions.set(capturedSessionId, ent);
|
|
105
|
+
}
|
|
44
106
|
send({ type: 'session-created', sessionId: capturedSessionId, agent: 'claude' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Map SDK assistant content (text + tool_use)
|
|
111
|
+
if (msg.type === 'assistant' && msg.message?.content) {
|
|
112
|
+
emit({ type: 'assistant', message: msg.message, ts: Date.now() });
|
|
113
|
+
continue;
|
|
45
114
|
}
|
|
46
|
-
|
|
115
|
+
|
|
116
|
+
if (msg.type === 'result') {
|
|
117
|
+
emit({
|
|
118
|
+
type: 'result',
|
|
119
|
+
is_error: !!msg.is_error,
|
|
120
|
+
usage: msg.usage,
|
|
121
|
+
session_id: msg.session_id || capturedSessionId,
|
|
122
|
+
result: msg.result,
|
|
123
|
+
agent: 'claude',
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Drop stream_event / user / other noise via mapAgentEmit
|
|
129
|
+
emit(msg);
|
|
47
130
|
}
|
|
48
|
-
send({
|
|
131
|
+
send({
|
|
132
|
+
type: 'complete',
|
|
133
|
+
exitCode: 0,
|
|
134
|
+
sessionId: capturedSessionId || sessionKey,
|
|
135
|
+
agent: 'claude',
|
|
136
|
+
});
|
|
49
137
|
} catch (err) {
|
|
50
138
|
if (err.name === 'AbortError') {
|
|
51
|
-
|
|
139
|
+
cancelApprovalsForSession(capturedSessionId || sessionKey);
|
|
140
|
+
send({
|
|
141
|
+
type: 'complete',
|
|
142
|
+
exitCode: 130,
|
|
143
|
+
aborted: true,
|
|
144
|
+
sessionId: capturedSessionId || sessionKey,
|
|
145
|
+
agent: 'claude',
|
|
146
|
+
});
|
|
52
147
|
} else {
|
|
53
148
|
send({ type: 'error', message: err.message, agent: 'claude' });
|
|
54
149
|
}
|
|
55
150
|
} finally {
|
|
151
|
+
const ent = activeSessions.get(sessionKey);
|
|
152
|
+
if (ent) ent.busy = false;
|
|
56
153
|
activeSessions.delete(sessionKey);
|
|
154
|
+
if (capturedSessionId) activeSessions.delete(capturedSessionId);
|
|
57
155
|
}
|
|
58
156
|
}
|
package/src/agents/codex.js
CHANGED
|
@@ -100,7 +100,7 @@ export function runCodex(command, options, send, { cliPath, activeSessions }) {
|
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
const entry = { kind: 'codex', child, startTime: Date.now() };
|
|
103
|
+
const entry = { kind: 'codex', child, startTime: Date.now(), busy: true, key: sessionKey, sessionId: options.sessionId || null };
|
|
104
104
|
activeSessions.set(sessionKey, entry);
|
|
105
105
|
// Also register under thread id once known so abort-session works
|
|
106
106
|
let threadId = options.sessionId || null;
|
package/src/agents/grok.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import readline from 'readline';
|
|
3
3
|
import crypto from 'crypto';
|
|
4
|
+
import { createDeltaBatcher } from './stream-batch.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Run Grok CLI headlessly with streaming-json over WebSocket (not HTTP SSE).
|
|
@@ -89,7 +90,7 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
|
|
|
89
90
|
// Unbuffer stdout as much as possible
|
|
90
91
|
if (child.stdout?.setEncoding) child.stdout.setEncoding('utf8');
|
|
91
92
|
|
|
92
|
-
const entry = { kind: 'grok', child, startTime: Date.now() };
|
|
93
|
+
const entry = { kind: 'grok', child, startTime: Date.now(), busy: true, key: sessionKey, sessionId: options.sessionId || null };
|
|
93
94
|
activeSessions.set(sessionKey, entry);
|
|
94
95
|
|
|
95
96
|
let sessionId = options.sessionId || null;
|
|
@@ -101,9 +102,8 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
|
|
|
101
102
|
let textChars = 0;
|
|
102
103
|
let thoughtChars = 0;
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
let thoughtTimer = null;
|
|
105
|
+
const textBatch = createDeltaBatcher(send, 'assistant_delta', { agent: 'grok' }, { maxWait: 48, maxSize: 480 });
|
|
106
|
+
const thoughtBatch = createDeltaBatcher(send, 'thought_delta', { agent: 'grok' }, { maxWait: 140, maxSize: 600 });
|
|
107
107
|
|
|
108
108
|
child.stderr.on('data', d => { stderr += d.toString(); });
|
|
109
109
|
|
|
@@ -118,36 +118,27 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
function endStream() {
|
|
121
|
+
textBatch.flush();
|
|
121
122
|
if (streamOpen) {
|
|
122
123
|
send({ type: 'assistant_stream_end', agent: 'grok' });
|
|
123
124
|
streamOpen = false;
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
function flushThought(
|
|
128
|
-
if (!
|
|
129
|
-
if (!force && thoughtBuf.length < 24) return;
|
|
128
|
+
function flushThought() {
|
|
129
|
+
if (!thoughtBatch.pending()) return;
|
|
130
130
|
if (!thoughtOpen) {
|
|
131
131
|
send({ type: 'thought_stream_start', agent: 'grok' });
|
|
132
132
|
thoughtOpen = true;
|
|
133
133
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function scheduleThoughtFlush() {
|
|
140
|
-
if (thoughtTimer) return;
|
|
141
|
-
thoughtTimer = setTimeout(() => {
|
|
142
|
-
thoughtTimer = null;
|
|
143
|
-
flushThought(true);
|
|
144
|
-
}, 120);
|
|
134
|
+
const n = thoughtBatch.pending();
|
|
135
|
+
thoughtBatch.flush();
|
|
136
|
+
thoughtChars += n;
|
|
145
137
|
}
|
|
146
138
|
|
|
147
139
|
function emitTool(raw) {
|
|
148
140
|
// Finalize any open text/thought before tool card
|
|
149
|
-
|
|
150
|
-
flushThought(true);
|
|
141
|
+
flushThought();
|
|
151
142
|
if (thoughtOpen) {
|
|
152
143
|
send({ type: 'thought_stream_end', agent: 'grok' });
|
|
153
144
|
thoughtOpen = false;
|
|
@@ -179,10 +170,9 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
|
|
|
179
170
|
|
|
180
171
|
const t = String(raw.type || '').toLowerCase();
|
|
181
172
|
|
|
182
|
-
// ── Text tokens (
|
|
173
|
+
// ── Text tokens (batched ~50ms) ──────────────────────────────────────
|
|
183
174
|
if ((t === 'text' || t === 'message' || t === 'output_text') && raw.data != null) {
|
|
184
|
-
|
|
185
|
-
flushThought(true);
|
|
175
|
+
flushThought();
|
|
186
176
|
if (thoughtOpen) {
|
|
187
177
|
send({ type: 'thought_stream_end', agent: 'grok' });
|
|
188
178
|
thoughtOpen = false;
|
|
@@ -191,14 +181,17 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
|
|
|
191
181
|
if (!chunk) return;
|
|
192
182
|
ensureStream();
|
|
193
183
|
textChars += chunk.length;
|
|
194
|
-
|
|
184
|
+
textBatch.push(chunk);
|
|
195
185
|
return;
|
|
196
186
|
}
|
|
197
187
|
|
|
198
|
-
// ── Thinking tokens (
|
|
188
|
+
// ── Thinking tokens (batched stream) ────────────────────────────────
|
|
199
189
|
if ((t === 'thought' || t === 'thinking' || t === 'reasoning') && raw.data != null) {
|
|
200
|
-
|
|
201
|
-
|
|
190
|
+
if (!thoughtOpen) {
|
|
191
|
+
send({ type: 'thought_stream_start', agent: 'grok' });
|
|
192
|
+
thoughtOpen = true;
|
|
193
|
+
}
|
|
194
|
+
thoughtBatch.push(String(raw.data));
|
|
202
195
|
return;
|
|
203
196
|
}
|
|
204
197
|
|
|
@@ -219,6 +212,7 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
|
|
|
219
212
|
// ── Session id early (some builds emit before end) ───────────────────
|
|
220
213
|
if ((t === 'session' || t === 'session_started') && (raw.sessionId || raw.session_id)) {
|
|
221
214
|
sessionId = raw.sessionId || raw.session_id;
|
|
215
|
+
entry.sessionId = sessionId;
|
|
222
216
|
activeSessions.set(sessionId, entry);
|
|
223
217
|
if (!options.sessionId) {
|
|
224
218
|
send({ type: 'session-created', sessionId, agent: 'grok' });
|
|
@@ -229,8 +223,7 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
|
|
|
229
223
|
// ── End of turn ─────────────────────────────────────────────────────
|
|
230
224
|
if (t === 'end' || t === 'result' || t === 'done') {
|
|
231
225
|
gotEnd = true;
|
|
232
|
-
|
|
233
|
-
flushThought(true);
|
|
226
|
+
flushThought();
|
|
234
227
|
if (thoughtOpen) {
|
|
235
228
|
send({ type: 'thought_stream_end', agent: 'grok' });
|
|
236
229
|
thoughtOpen = false;
|
|
@@ -239,17 +232,22 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
|
|
|
239
232
|
|
|
240
233
|
if (raw.sessionId || raw.session_id) {
|
|
241
234
|
sessionId = raw.sessionId || raw.session_id;
|
|
235
|
+
entry.sessionId = sessionId;
|
|
242
236
|
activeSessions.set(sessionId, entry);
|
|
243
237
|
if (!options.sessionId) {
|
|
244
238
|
send({ type: 'session-created', sessionId, agent: 'grok' });
|
|
245
239
|
}
|
|
246
240
|
}
|
|
247
241
|
|
|
242
|
+
// Unlock UI immediately — process may linger after the turn ends.
|
|
243
|
+
entry.busy = false;
|
|
244
|
+
|
|
248
245
|
const u = raw.usage || {};
|
|
249
246
|
const costTicks = raw.total_cost_usd_ticks ?? u.cost_usd_ticks ?? u.costUsdTicks;
|
|
250
247
|
const costUSD = raw.total_cost_usd ?? u.cost_usd ?? u.costUSD
|
|
251
248
|
?? (typeof costTicks === 'number' ? costTicks / 1e10 : undefined);
|
|
252
249
|
|
|
250
|
+
const sid = sessionId || sessionKey;
|
|
253
251
|
send({
|
|
254
252
|
type: 'result',
|
|
255
253
|
is_error: false,
|
|
@@ -262,27 +260,36 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
|
|
|
262
260
|
reasoningTokens: u.reasoning_tokens || u.reasoningTokens || 0,
|
|
263
261
|
costUSD,
|
|
264
262
|
},
|
|
265
|
-
sessionId:
|
|
263
|
+
sessionId: sid,
|
|
266
264
|
agent: 'grok',
|
|
267
265
|
stats: { textChars, thoughtChars },
|
|
268
266
|
});
|
|
267
|
+
// Early complete so client can send next turn without waiting for process exit.
|
|
268
|
+
send({ type: 'complete', exitCode: 0, sessionId: sid, agent: 'grok' });
|
|
269
269
|
return;
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
if (t === 'error') {
|
|
273
|
+
entry.busy = false;
|
|
273
274
|
send({ type: 'error', message: raw.message || raw.data || 'grok error', agent: 'grok' });
|
|
274
275
|
}
|
|
275
276
|
});
|
|
276
277
|
|
|
277
278
|
child.on('close', (code) => {
|
|
278
|
-
|
|
279
|
-
flushThought(true);
|
|
279
|
+
flushThought();
|
|
280
280
|
if (thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
|
|
281
281
|
endStream();
|
|
282
282
|
rl.close();
|
|
283
|
+
entry.busy = false;
|
|
283
284
|
activeSessions.delete(sessionKey);
|
|
284
285
|
if (sessionId) activeSessions.delete(sessionId);
|
|
285
286
|
|
|
287
|
+
// Skip duplicate complete if turn already finished via streaming-json "end".
|
|
288
|
+
if (gotEnd && !aborted) {
|
|
289
|
+
resolve();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
286
293
|
if (aborted) {
|
|
287
294
|
send({ type: 'complete', exitCode: 130, aborted: true, sessionId: sessionId || sessionKey, agent: 'grok' });
|
|
288
295
|
} else if (code !== 0 && code != null && !gotEnd) {
|
|
@@ -300,6 +307,7 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
|
|
|
300
307
|
});
|
|
301
308
|
|
|
302
309
|
child.on('error', (e) => {
|
|
310
|
+
entry.busy = false;
|
|
303
311
|
activeSessions.delete(sessionKey);
|
|
304
312
|
if (sessionId) activeSessions.delete(sessionId);
|
|
305
313
|
send({ type: 'error', message: e.message, agent: 'grok' });
|
package/src/agents/meta-agent.js
CHANGED
|
@@ -427,6 +427,38 @@ export function createMetaAgent(deps) {
|
|
|
427
427
|
const VLM_THREAD_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d on disk
|
|
428
428
|
const ANON_SESSION_ID = '_anonymous';
|
|
429
429
|
|
|
430
|
+
/** run_command approval gate: id → { resolve } (see requestApproval/resolveApproval) */
|
|
431
|
+
const pendingApprovals = new Map();
|
|
432
|
+
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
433
|
+
|
|
434
|
+
/** Ask the caller (via onApproval) to confirm a sensitive tool call; auto-denies if unattended or timed out. */
|
|
435
|
+
function requestApproval(onApproval, name, args) {
|
|
436
|
+
if (typeof onApproval !== 'function') return Promise.resolve(false);
|
|
437
|
+
const id = crypto.randomUUID();
|
|
438
|
+
return new Promise((resolve) => {
|
|
439
|
+
const timer = setTimeout(() => {
|
|
440
|
+
pendingApprovals.delete(id);
|
|
441
|
+
resolve(false);
|
|
442
|
+
}, APPROVAL_TIMEOUT_MS);
|
|
443
|
+
pendingApprovals.set(id, {
|
|
444
|
+
resolve: (approved) => {
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
pendingApprovals.delete(id);
|
|
447
|
+
resolve(!!approved);
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
onApproval({ id, name, args });
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Called from the /api/ai/approve endpoint when the user responds. */
|
|
455
|
+
function resolveApproval(id, approved) {
|
|
456
|
+
const pending = pendingApprovals.get(id);
|
|
457
|
+
if (!pending) return false;
|
|
458
|
+
pending.resolve(approved);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
430
462
|
let configCache = null;
|
|
431
463
|
let reportsCache = null;
|
|
432
464
|
/** @type {{ version: number, sessions: Array<{id,title,createdAt,updatedAt,messageCount}> } | null} */
|
|
@@ -1716,7 +1748,7 @@ export function createMetaAgent(deps) {
|
|
|
1716
1748
|
/**
|
|
1717
1749
|
* Tool-enabled agent loop with streaming content callbacks.
|
|
1718
1750
|
*/
|
|
1719
|
-
async function runLoop(messages, { onContent, onTool, cfg, chatSessionId } = {}) {
|
|
1751
|
+
async function runLoop(messages, { onContent, onTool, onApproval, cfg, chatSessionId } = {}) {
|
|
1720
1752
|
const config = cfg || await loadConfig();
|
|
1721
1753
|
if (!config.key) throw new Error('AI API Key 未配置,请在 ⚙️ → Meta Agent 中填写');
|
|
1722
1754
|
|
|
@@ -1785,6 +1817,12 @@ export function createMetaAgent(deps) {
|
|
|
1785
1817
|
onTool?.(call.function.name, a, 'start', null, callId);
|
|
1786
1818
|
let result;
|
|
1787
1819
|
try {
|
|
1820
|
+
if (call.function.name === 'run_command') {
|
|
1821
|
+
const approved = await requestApproval(onApproval, call.function.name, a);
|
|
1822
|
+
if (!approved) {
|
|
1823
|
+
throw new Error('command rejected: user did not approve execution');
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1788
1826
|
result = await executeTool(call.function.name, a, toolCtx);
|
|
1789
1827
|
if (MUTATING.has(call.function.name)) needsMutate = true;
|
|
1790
1828
|
onTool?.(call.function.name, a, 'done', result, callId);
|
|
@@ -1939,6 +1977,7 @@ export function createMetaAgent(deps) {
|
|
|
1939
1977
|
buildDigest,
|
|
1940
1978
|
executeTool,
|
|
1941
1979
|
runLoop,
|
|
1980
|
+
resolveApproval,
|
|
1942
1981
|
generateReport,
|
|
1943
1982
|
listChatSessions,
|
|
1944
1983
|
getChatSession,
|