@lelouchhe/webagent 0.3.0 → 0.4.0
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 +58 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +96 -3
- package/dist/index.html +64 -41
- package/dist/js/app.GSAIYHML.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.CGWFHJI2.js +76 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.6DT53STL.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +53 -0
- package/dist/styles.012p32dz.css +1443 -0
- package/dist/sw.js +79 -27
- package/dist/theme-init.js +6 -0
- package/lib/agent-detect.js +110 -0
- package/lib/atomic-write.js +50 -0
- package/lib/attachment-dispatch.js +86 -0
- package/lib/attachment-interceptor.js +130 -0
- package/lib/attachment-labels.js +139 -0
- package/lib/attachments.js +154 -0
- package/lib/auth-middleware.js +102 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +60 -0
- package/lib/config.js +123 -9
- package/lib/daemon.js +175 -41
- package/lib/event-handler.js +209 -91
- package/lib/log-fmt.js +67 -0
- package/lib/log.js +83 -0
- package/lib/message-cleanup.js +48 -0
- package/lib/mode-bucket.js +62 -0
- package/lib/preflight.js +195 -0
- package/lib/push-service.js +338 -45
- package/lib/routes.js +1202 -144
- package/lib/server.js +149 -33
- package/lib/session-manager.js +164 -18
- package/lib/session-state.js +160 -0
- package/lib/sessions-anchor.js +28 -0
- package/lib/share/cleanup.js +45 -0
- package/lib/share/routes.js +972 -0
- package/lib/share/sanitize.js +179 -0
- package/lib/sse-manager.js +94 -8
- package/lib/sse-ticket.js +45 -0
- package/lib/startup-checks.js +94 -0
- package/lib/store.js +624 -30
- package/lib/title-service.js +42 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +38 -4
- package/dist/js/app.2562YGRO.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- package/lib/shared/constants.js +0 -17
package/lib/event-handler.js
CHANGED
|
@@ -1,106 +1,224 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { shouldAutoApproveAttachmentRead, } from "./attachment-interceptor.js";
|
|
2
|
+
import { log } from "./log.js";
|
|
3
|
+
import { isAutopilotMode } from "./mode-bucket.js";
|
|
4
|
+
const ailog = log.scope("attachment-interceptor");
|
|
5
|
+
const plog = log.scope("push");
|
|
6
|
+
function handleConnected(event, sessions, config) {
|
|
7
|
+
event.cancelTimeout = config.cancelTimeout;
|
|
8
|
+
event.recentPathsLimit = config.recentPathsLimit;
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive check
|
|
10
|
+
if (event.agent)
|
|
11
|
+
sessions.agentInfo = event.agent;
|
|
12
|
+
}
|
|
13
|
+
function handleConfigLikeEvent(event, sessions, store) {
|
|
14
|
+
if (event.configOptions.length)
|
|
15
|
+
sessions.cachedConfigOptions = event.configOptions;
|
|
16
|
+
for (const opt of event.configOptions) {
|
|
17
|
+
store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function handleMessageChunk(event, sessions) {
|
|
21
|
+
sessions.flushThinkingBuffer(event.sessionId);
|
|
22
|
+
sessions.appendAssistant(event.sessionId, event.text);
|
|
23
|
+
sessions.state.patch(event.sessionId, {
|
|
24
|
+
runtime: { streaming: { assistant: true, thinking: false } },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function handleThoughtChunk(event, sessions) {
|
|
28
|
+
sessions.flushAssistantBuffer(event.sessionId);
|
|
29
|
+
sessions.appendThinking(event.sessionId, event.text);
|
|
30
|
+
sessions.state.patch(event.sessionId, {
|
|
31
|
+
runtime: { streaming: { assistant: false, thinking: true } },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function handleToolCall(event, sessions, store) {
|
|
35
|
+
sessions.flushBuffers(event.sessionId);
|
|
36
|
+
sessions.state.patch(event.sessionId, {
|
|
37
|
+
runtime: { streaming: { assistant: false, thinking: false } },
|
|
38
|
+
});
|
|
39
|
+
store.saveEvent(event.sessionId, event.type, {
|
|
40
|
+
id: event.id,
|
|
41
|
+
title: event.title,
|
|
42
|
+
kind: event.kind,
|
|
43
|
+
rawInput: event.rawInput,
|
|
44
|
+
}, { from_ref: "agent" });
|
|
45
|
+
}
|
|
46
|
+
function handlePlan(event, sessions, store) {
|
|
47
|
+
sessions.flushBuffers(event.sessionId);
|
|
48
|
+
sessions.state.patch(event.sessionId, {
|
|
49
|
+
runtime: { streaming: { assistant: false, thinking: false } },
|
|
50
|
+
});
|
|
51
|
+
store.saveEvent(event.sessionId, event.type, { entries: event.entries }, { from_ref: "agent" });
|
|
52
|
+
}
|
|
53
|
+
function performAutoApprove(event, opt, sessions, store, bridge, sseManager, broadcastRequest) {
|
|
54
|
+
bridge.resolvePermission(event.requestId, opt.optionId);
|
|
55
|
+
sessions.pendingPermissions.delete(event.requestId);
|
|
56
|
+
sessions.syncPendingPermissions(event.sessionId);
|
|
57
|
+
const optionName = opt.label ?? opt.optionId;
|
|
58
|
+
store.saveEvent(event.sessionId, "permission_response", {
|
|
59
|
+
requestId: event.requestId,
|
|
60
|
+
optionName,
|
|
61
|
+
denied: false,
|
|
62
|
+
}, { from_ref: "system" });
|
|
63
|
+
if (broadcastRequest)
|
|
64
|
+
sseManager.broadcast(event);
|
|
65
|
+
sseManager.broadcast({
|
|
66
|
+
type: "permission_response",
|
|
67
|
+
sessionId: event.sessionId,
|
|
68
|
+
requestId: event.requestId,
|
|
69
|
+
optionName,
|
|
70
|
+
denied: false,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function maybeAutoApprovePermission(event, sessions, store, bridge, sseManager) {
|
|
74
|
+
const mode = store.getSession(event.sessionId)?.mode ?? "";
|
|
75
|
+
if (!isAutopilotMode(mode))
|
|
76
|
+
return false;
|
|
77
|
+
const opt = event.options.find((o) => o.kind === "allow_once");
|
|
78
|
+
if (!opt)
|
|
79
|
+
return false;
|
|
80
|
+
performAutoApprove(event, opt, sessions, store, bridge, sseManager, true);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
function maybeAutoApproveAttachmentRead(event, sessions, store, bridge, sseManager, config) {
|
|
84
|
+
// Plan §1.4 — async attachment-read auto-approve runs *after* the
|
|
85
|
+
// permission_request has already been broadcast (so the UI shows it
|
|
86
|
+
// briefly), then if the request matches we follow up with a
|
|
87
|
+
// permission_response, identical to autopilot's collapse behavior.
|
|
88
|
+
const interceptor = config.attachmentInterceptor;
|
|
89
|
+
if (!interceptor)
|
|
3
90
|
return;
|
|
91
|
+
const opt = event.options.find((o) => o.kind === "allow_once");
|
|
92
|
+
if (!opt)
|
|
93
|
+
return;
|
|
94
|
+
void shouldAutoApproveAttachmentRead({
|
|
95
|
+
sessionId: event.sessionId,
|
|
96
|
+
toolKind: event.toolKind,
|
|
97
|
+
toolName: event.toolName,
|
|
98
|
+
locations: event.locations,
|
|
99
|
+
rawInput: event.rawInput,
|
|
100
|
+
}, {
|
|
101
|
+
listAttachmentRealpaths: (sid) => store.listAttachmentRealpaths(sid),
|
|
102
|
+
counters: interceptor.counters,
|
|
103
|
+
logger: interceptor.logger,
|
|
104
|
+
onSchemaDrift: interceptor.onSchemaDrift,
|
|
105
|
+
}).then((approved) => {
|
|
106
|
+
if (!approved)
|
|
107
|
+
return;
|
|
108
|
+
// Race guard: the user (or another client) may have already
|
|
109
|
+
// resolved the permission while we were realpath-ing.
|
|
110
|
+
if (!sessions.pendingPermissions.has(event.requestId))
|
|
111
|
+
return;
|
|
112
|
+
performAutoApprove(event, opt, sessions, store, bridge, sseManager, false);
|
|
113
|
+
}, (err) => {
|
|
114
|
+
ailog.warn("unexpected error", { error: err.message });
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function handlePermissionRequest(event, sessions, store, bridge, sseManager, config) {
|
|
118
|
+
sessions.flushBuffers(event.sessionId);
|
|
119
|
+
sessions.state.patch(event.sessionId, {
|
|
120
|
+
runtime: { streaming: { assistant: false, thinking: false } },
|
|
121
|
+
});
|
|
122
|
+
store.saveEvent(event.sessionId, event.type, {
|
|
123
|
+
requestId: event.requestId,
|
|
124
|
+
title: event.title,
|
|
125
|
+
options: event.options,
|
|
126
|
+
}, { from_ref: "agent" });
|
|
127
|
+
sessions.pendingPermissions.set(event.requestId, {
|
|
128
|
+
requestId: event.requestId,
|
|
129
|
+
sessionId: event.sessionId,
|
|
130
|
+
title: event.title,
|
|
131
|
+
options: event.options.map((o) => ({
|
|
132
|
+
optionId: o.optionId,
|
|
133
|
+
label: o.label ?? o.name ?? o.optionId,
|
|
134
|
+
})),
|
|
135
|
+
});
|
|
136
|
+
sessions.syncPendingPermissions(event.sessionId);
|
|
137
|
+
const autopiloted = maybeAutoApprovePermission(event, sessions, store, bridge, sseManager);
|
|
138
|
+
if (autopiloted)
|
|
139
|
+
return true;
|
|
140
|
+
// Async attachment-read auto-approve runs after the request broadcasts.
|
|
141
|
+
maybeAutoApproveAttachmentRead(event, sessions, store, bridge, sseManager, config);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
function handlePromptDone(event, sessions, store) {
|
|
145
|
+
sessions.activePrompts.delete(event.sessionId);
|
|
146
|
+
sessions.syncBusy(event.sessionId);
|
|
147
|
+
sessions.flushBuffers(event.sessionId);
|
|
148
|
+
sessions.state.patch(event.sessionId, {
|
|
149
|
+
runtime: { streaming: { assistant: false, thinking: false } },
|
|
150
|
+
});
|
|
151
|
+
store.saveEvent(event.sessionId, event.type, { stopReason: event.stopReason }, { from_ref: "agent" });
|
|
152
|
+
}
|
|
153
|
+
function handleError(event, sessions) {
|
|
154
|
+
if (event.sessionId) {
|
|
155
|
+
sessions.activePrompts.delete(event.sessionId);
|
|
156
|
+
sessions.syncBusy(event.sessionId);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager) {
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- only handles events with side effects
|
|
4
161
|
switch (event.type) {
|
|
5
162
|
case "connected":
|
|
6
|
-
event
|
|
7
|
-
|
|
8
|
-
if (event.agent)
|
|
9
|
-
sessions.agentInfo = event.agent;
|
|
10
|
-
break;
|
|
163
|
+
handleConnected(event, sessions, config);
|
|
164
|
+
return false;
|
|
11
165
|
case "session_created":
|
|
12
|
-
if (event.configOptions?.length)
|
|
13
|
-
sessions.cachedConfigOptions = event.configOptions;
|
|
14
|
-
for (const opt of event.configOptions ?? []) {
|
|
15
|
-
store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
|
|
16
|
-
}
|
|
17
|
-
break;
|
|
18
166
|
case "config_option_update":
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
for (const opt of event.configOptions ?? []) {
|
|
22
|
-
store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
|
|
23
|
-
}
|
|
24
|
-
break;
|
|
167
|
+
handleConfigLikeEvent(event, sessions, store);
|
|
168
|
+
return false;
|
|
25
169
|
case "message_chunk":
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
break;
|
|
170
|
+
handleMessageChunk(event, sessions);
|
|
171
|
+
return false;
|
|
29
172
|
case "thought_chunk":
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
break;
|
|
173
|
+
handleThoughtChunk(event, sessions);
|
|
174
|
+
return false;
|
|
33
175
|
case "tool_call":
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
break;
|
|
176
|
+
handleToolCall(event, sessions, store);
|
|
177
|
+
return false;
|
|
37
178
|
case "tool_call_update":
|
|
38
|
-
store.saveEvent(event.sessionId, event.type, { id: event.id, status: event.status, content: event.content });
|
|
39
|
-
|
|
179
|
+
store.saveEvent(event.sessionId, event.type, { id: event.id, status: event.status, content: event.content }, { from_ref: "agent" });
|
|
180
|
+
return false;
|
|
40
181
|
case "plan":
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
sessions.flushBuffers(event.sessionId);
|
|
46
|
-
store.saveEvent(event.sessionId, event.type, {
|
|
47
|
-
requestId: event.requestId, title: event.title, options: event.options,
|
|
48
|
-
});
|
|
49
|
-
sessions.pendingPermissions.set(event.requestId, {
|
|
50
|
-
requestId: event.requestId,
|
|
51
|
-
sessionId: event.sessionId,
|
|
52
|
-
title: event.title,
|
|
53
|
-
options: event.options.map((o) => ({ optionId: o.optionId, label: o.label ?? o.name ?? o.optionId })),
|
|
54
|
-
});
|
|
55
|
-
// Auto-approve permissions in autopilot mode (allow_once only to avoid persisting across mode switches)
|
|
56
|
-
const mode = store.getSession(event.sessionId)?.mode ?? "";
|
|
57
|
-
if (mode.includes("#autopilot")) {
|
|
58
|
-
const opt = event.options.find((o) => o.kind === "allow_once");
|
|
59
|
-
if (opt) {
|
|
60
|
-
bridge.resolvePermission(event.requestId, opt.optionId);
|
|
61
|
-
sessions.pendingPermissions.delete(event.requestId);
|
|
62
|
-
const optionName = opt.label ?? opt.optionId;
|
|
63
|
-
store.saveEvent(event.sessionId, "permission_response", {
|
|
64
|
-
requestId: event.requestId, optionName, denied: false,
|
|
65
|
-
});
|
|
66
|
-
// Broadcast both so the frontend can render then collapse the permission card
|
|
67
|
-
sseManager.broadcast(event);
|
|
68
|
-
sseManager.broadcast({
|
|
69
|
-
type: "permission_response",
|
|
70
|
-
sessionId: event.sessionId,
|
|
71
|
-
requestId: event.requestId,
|
|
72
|
-
optionName,
|
|
73
|
-
denied: false,
|
|
74
|
-
});
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
182
|
+
handlePlan(event, sessions, store);
|
|
183
|
+
return false;
|
|
184
|
+
case "permission_request":
|
|
185
|
+
return handlePermissionRequest(event, sessions, store, bridge, sseManager, config);
|
|
80
186
|
case "prompt_done":
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
store.saveEvent(event.sessionId, event.type, { stopReason: event.stopReason });
|
|
84
|
-
break;
|
|
187
|
+
handlePromptDone(event, sessions, store);
|
|
188
|
+
return false;
|
|
85
189
|
case "error":
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
break;
|
|
190
|
+
handleError(event, sessions);
|
|
191
|
+
return false;
|
|
90
192
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
console.error("[push] failed to send:", err);
|
|
103
|
-
});
|
|
104
|
-
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
function maybePushNotify(event, pushService) {
|
|
196
|
+
if (!("sessionId" in event) || !event.sessionId)
|
|
197
|
+
return;
|
|
198
|
+
const pushEvent = {
|
|
199
|
+
type: event.type,
|
|
200
|
+
};
|
|
201
|
+
if (event.type === "permission_request") {
|
|
202
|
+
pushEvent.title = event.title;
|
|
203
|
+
pushEvent.eventId = String(event.requestId);
|
|
105
204
|
}
|
|
205
|
+
else if (event.type === "bash_done") {
|
|
206
|
+
// bash_done has `code` not `exitCode`; command not stored in the event
|
|
207
|
+
pushEvent.exitCode = event.code ?? undefined;
|
|
208
|
+
}
|
|
209
|
+
pushService.sendForEvent(event.sessionId, pushEvent).catch((err) => {
|
|
210
|
+
plog.error("failed to send", { error: err });
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
export function handleAgentEvent(event, sessions, store, bridge, config, sseManager, pushService, _clientRegistry) {
|
|
214
|
+
if ("sessionId" in event &&
|
|
215
|
+
event.sessionId &&
|
|
216
|
+
sessions.restoringSessions.has(event.sessionId))
|
|
217
|
+
return;
|
|
218
|
+
const suppress = dispatchAgentEvent(event, sessions, store, bridge, config, sseManager);
|
|
219
|
+
if (suppress)
|
|
220
|
+
return;
|
|
221
|
+
sseManager.broadcast(event);
|
|
222
|
+
if (pushService)
|
|
223
|
+
maybePushNotify(event, pushService);
|
|
106
224
|
}
|
package/lib/log-fmt.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Shared log formatting utilities used by both the frontend (`public/js/log.ts`)
|
|
2
|
+
// and the backend (`src/log.ts`). Keeping a single source of truth here avoids
|
|
3
|
+
// drift between the two emit paths (level rank, timestamp format, structured
|
|
4
|
+
// field stringification).
|
|
5
|
+
export const LEVEL_RANK = {
|
|
6
|
+
debug: 0,
|
|
7
|
+
info: 1,
|
|
8
|
+
warn: 2,
|
|
9
|
+
error: 3,
|
|
10
|
+
off: 99,
|
|
11
|
+
};
|
|
12
|
+
export const VALID_LEVELS = new Set([
|
|
13
|
+
"off",
|
|
14
|
+
"debug",
|
|
15
|
+
"info",
|
|
16
|
+
"warn",
|
|
17
|
+
"error",
|
|
18
|
+
]);
|
|
19
|
+
const FIELD_CAP_BYTES = 4096;
|
|
20
|
+
export function safeStringify(value) {
|
|
21
|
+
try {
|
|
22
|
+
const seen = new WeakSet();
|
|
23
|
+
const str = JSON.stringify(value, (_key, v) => {
|
|
24
|
+
if (v instanceof Error) {
|
|
25
|
+
return { name: v.name, message: v.message, stack: v.stack };
|
|
26
|
+
}
|
|
27
|
+
if (typeof v === "function") {
|
|
28
|
+
const fn = v;
|
|
29
|
+
return `[Function ${fn.name ?? "anonymous"}]`;
|
|
30
|
+
}
|
|
31
|
+
if (typeof v === "bigint")
|
|
32
|
+
return v.toString() + "n";
|
|
33
|
+
if (typeof v === "symbol")
|
|
34
|
+
return v.toString();
|
|
35
|
+
if (typeof v === "object" && v !== null) {
|
|
36
|
+
if (seen.has(v))
|
|
37
|
+
return "[Circular]";
|
|
38
|
+
seen.add(v);
|
|
39
|
+
}
|
|
40
|
+
return v;
|
|
41
|
+
});
|
|
42
|
+
if (typeof str !== "string")
|
|
43
|
+
return String(value);
|
|
44
|
+
if (str.length > FIELD_CAP_BYTES) {
|
|
45
|
+
return (str.slice(0, FIELD_CAP_BYTES) + `…(truncated, ${str.length} bytes)`);
|
|
46
|
+
}
|
|
47
|
+
return str;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
try {
|
|
51
|
+
return String(value);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return "[unserializable]";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function pad2(n) {
|
|
59
|
+
return n < 10 ? "0" + n : String(n);
|
|
60
|
+
}
|
|
61
|
+
function pad3(n) {
|
|
62
|
+
return n < 10 ? "00" + n : n < 100 ? "0" + n : String(n);
|
|
63
|
+
}
|
|
64
|
+
export function formatTs(ts) {
|
|
65
|
+
const d = new Date(ts);
|
|
66
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${pad3(d.getMilliseconds())}`;
|
|
67
|
+
}
|
package/lib/log.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Backend logger — mirror of `public/js/log.ts`, single-knob level-gated.
|
|
2
|
+
//
|
|
3
|
+
// Single axis:
|
|
4
|
+
// level: off | debug | info | warn | error
|
|
5
|
+
//
|
|
6
|
+
// `off` short-circuits with a single boolean read (zero cost when disabled).
|
|
7
|
+
// For any other level, records at or above the threshold are written to
|
|
8
|
+
// stdout (debug/info) or stderr (warn/error), formatted as:
|
|
9
|
+
// HH:MM:SS.mmm LEVEL [scope] msg {fields-json}\n
|
|
10
|
+
//
|
|
11
|
+
// Format and `safeStringify` are shared with the frontend via `./log-fmt.ts`,
|
|
12
|
+
// keeping the two emit paths in sync.
|
|
13
|
+
//
|
|
14
|
+
// Tests can hook the output via `setLogSink((stream, line) => ...)`. In
|
|
15
|
+
// production this is unset and writes go straight to `process.stdout` /
|
|
16
|
+
// `process.stderr`.
|
|
17
|
+
import { LEVEL_RANK, VALID_LEVELS, safeStringify, formatTs, } from "./log-fmt.js";
|
|
18
|
+
// ============================================================
|
|
19
|
+
// Module state
|
|
20
|
+
// ============================================================
|
|
21
|
+
let currentLevel = "off";
|
|
22
|
+
let sink = null;
|
|
23
|
+
// ============================================================
|
|
24
|
+
// Public API
|
|
25
|
+
// ============================================================
|
|
26
|
+
export function setLogLevel(level) {
|
|
27
|
+
if (!VALID_LEVELS.has(level))
|
|
28
|
+
return;
|
|
29
|
+
currentLevel = level;
|
|
30
|
+
}
|
|
31
|
+
export function getLogLevel() {
|
|
32
|
+
return currentLevel;
|
|
33
|
+
}
|
|
34
|
+
/** Install a custom sink (test hook). Pass `null` to restore stdout/stderr. */
|
|
35
|
+
export function setLogSink(fn) {
|
|
36
|
+
sink = fn;
|
|
37
|
+
}
|
|
38
|
+
// ============================================================
|
|
39
|
+
// Logger implementation
|
|
40
|
+
// ============================================================
|
|
41
|
+
function make(parentScope) {
|
|
42
|
+
const emit = (level, msg, fields) => {
|
|
43
|
+
// Zero-overhead gate: must be the first statement.
|
|
44
|
+
if (LEVEL_RANK[level] < LEVEL_RANK[currentLevel]) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const ts = formatTs(Date.now());
|
|
48
|
+
const scopePart = parentScope ? ` [${parentScope}]` : "";
|
|
49
|
+
const fieldsPart = fields !== undefined ? " " + safeStringify(fields) : "";
|
|
50
|
+
const line = `${ts}${scopePart} ${level.toUpperCase()} ${msg}${fieldsPart}\n`;
|
|
51
|
+
const stream = level === "warn" || level === "error" ? "err" : "out";
|
|
52
|
+
try {
|
|
53
|
+
if (sink) {
|
|
54
|
+
sink(stream, line);
|
|
55
|
+
}
|
|
56
|
+
else if (stream === "err") {
|
|
57
|
+
process.stderr.write(line);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
process.stdout.write(line);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// never let logger internals throw to caller
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
debug: (m, f) => {
|
|
69
|
+
emit("debug", m, f);
|
|
70
|
+
},
|
|
71
|
+
info: (m, f) => {
|
|
72
|
+
emit("info", m, f);
|
|
73
|
+
},
|
|
74
|
+
warn: (m, f) => {
|
|
75
|
+
emit("warn", m, f);
|
|
76
|
+
},
|
|
77
|
+
error: (m, f) => {
|
|
78
|
+
emit("error", m, f);
|
|
79
|
+
},
|
|
80
|
+
scope: (n) => make(parentScope ? `${parentScope}.${n}` : n),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export const log = make();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { log } from "./log.js";
|
|
2
|
+
const mlog = log.scope("msg");
|
|
3
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Sweep unprocessed messages whose created_at is older than ttlDays.
|
|
6
|
+
* Returns the number of rows removed. `now` is injectable for tests.
|
|
7
|
+
* ttlDays=0 means "keep forever" — returns 0 without touching the DB.
|
|
8
|
+
*/
|
|
9
|
+
export function sweepOnce(store, ttlDays, now = Date.now()) {
|
|
10
|
+
if (ttlDays <= 0)
|
|
11
|
+
return 0;
|
|
12
|
+
const threshold = now - ttlDays * DAY_MS;
|
|
13
|
+
const removed = store.deleteOlderThan(threshold);
|
|
14
|
+
if (removed > 0) {
|
|
15
|
+
mlog.info("ttl sweep", { removed, ttl_days: ttlDays });
|
|
16
|
+
}
|
|
17
|
+
return removed;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Start the unprocessed-message TTL cleanup job:
|
|
21
|
+
* - sweep once immediately (synchronous),
|
|
22
|
+
* - then every 24h via setInterval.
|
|
23
|
+
*
|
|
24
|
+
* ttlDays=0 disables the scheduler entirely (handle.armed=false).
|
|
25
|
+
*/
|
|
26
|
+
export function startMessageCleanup(store, ttlDays) {
|
|
27
|
+
sweepOnce(store, ttlDays);
|
|
28
|
+
if (ttlDays <= 0) {
|
|
29
|
+
return { armed: false, stop: () => { } };
|
|
30
|
+
}
|
|
31
|
+
const timer = setInterval(() => {
|
|
32
|
+
try {
|
|
33
|
+
sweepOnce(store, ttlDays);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
mlog.error("ttl sweep failed", { error: err });
|
|
37
|
+
}
|
|
38
|
+
}, DAY_MS);
|
|
39
|
+
// Don't keep the event loop alive for this interval alone (let server.ts own lifecycle).
|
|
40
|
+
if (typeof timer.unref === "function")
|
|
41
|
+
timer.unref();
|
|
42
|
+
return {
|
|
43
|
+
armed: true,
|
|
44
|
+
stop: () => {
|
|
45
|
+
clearInterval(timer);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Cross-agent ACP mode classification.
|
|
2
|
+
//
|
|
3
|
+
// Different agents emit `currentModeId` in different forms:
|
|
4
|
+
// - Copilot CLI: "https://agentclientprotocol.com/protocol/session-modes#autopilot"
|
|
5
|
+
// - Claude Code: "bypassPermissions" (bare camelCase string)
|
|
6
|
+
// - Codex: "read-only" / "auto" / "full-access" (bare hyphenated)
|
|
7
|
+
// - Gemini CLI: "default" / "autoEdit" / "yolo" / "plan" (bare; enum-based)
|
|
8
|
+
// - OpenCode: "build" / "plan" / "general" + user-defined agent names
|
|
9
|
+
//
|
|
10
|
+
// `extractModeId` normalizes URL forms into a short id. All bucket / display
|
|
11
|
+
// logic flows through `extractModeId`, so adding a new mode means adding one
|
|
12
|
+
// entry to one of the small constant sets below.
|
|
13
|
+
//
|
|
14
|
+
// Buckets webagent cares about:
|
|
15
|
+
// - plan → read-only; visual hint only, no permission interception
|
|
16
|
+
// - autopilot → all permission_requests auto-approved with `allow_once`
|
|
17
|
+
// - default → forwarded as-is to the user (anything that's neither plan nor autopilot)
|
|
18
|
+
//
|
|
19
|
+
// The agent's own internal modes (Claude acceptEdits/dontAsk/auto, Gemini
|
|
20
|
+
// autoEdit, OpenCode user-defined agents) all fall into the default bucket
|
|
21
|
+
// from webagent's perspective: the agent decides internally whether to emit
|
|
22
|
+
// a permission_request, and we just respond to what arrives.
|
|
23
|
+
const PLAN_IDS = new Set(["plan", "read-only"]);
|
|
24
|
+
const AUTOPILOT_IDS = new Set([
|
|
25
|
+
"autopilot",
|
|
26
|
+
"bypassPermissions",
|
|
27
|
+
"full-access",
|
|
28
|
+
"yolo",
|
|
29
|
+
]);
|
|
30
|
+
// IDs that should hide the pill entirely (the canonical "default" of each
|
|
31
|
+
// agent — showing it adds noise because it's the resting state).
|
|
32
|
+
// - "agent" → Copilot default
|
|
33
|
+
// - "default" → Claude default + Gemini default
|
|
34
|
+
// - "build" → OpenCode default
|
|
35
|
+
const HIDDEN_DEFAULT_IDS = new Set(["agent", "default", "build"]);
|
|
36
|
+
export function extractModeId(raw) {
|
|
37
|
+
if (!raw)
|
|
38
|
+
return "";
|
|
39
|
+
const m = raw.match(/[#/]([^#/]+)$/);
|
|
40
|
+
return m ? m[1] : raw;
|
|
41
|
+
}
|
|
42
|
+
export function isPlanMode(raw) {
|
|
43
|
+
return PLAN_IDS.has(extractModeId(raw));
|
|
44
|
+
}
|
|
45
|
+
export function isAutopilotMode(raw) {
|
|
46
|
+
return AUTOPILOT_IDS.has(extractModeId(raw));
|
|
47
|
+
}
|
|
48
|
+
export function shouldShowModePill(raw) {
|
|
49
|
+
const id = extractModeId(raw);
|
|
50
|
+
if (!id)
|
|
51
|
+
return false;
|
|
52
|
+
return !HIDDEN_DEFAULT_IDS.has(id);
|
|
53
|
+
}
|
|
54
|
+
// camelCase → "camel Case" (CSS `text-transform: uppercase` finishes the job).
|
|
55
|
+
// `bypassPermissions` → "bypass Permissions" → "BYPASS PERMISSIONS"
|
|
56
|
+
// `acceptEdits` → "accept Edits" → "ACCEPT EDITS"
|
|
57
|
+
// `plan` → "plan" → "PLAN"
|
|
58
|
+
export function formatModeLabel(raw) {
|
|
59
|
+
return extractModeId(raw)
|
|
60
|
+
.replace(/([A-Z])/g, " $1")
|
|
61
|
+
.trim();
|
|
62
|
+
}
|