@lelouchhe/webagent 0.2.6 → 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.
Files changed (57) hide show
  1. package/README.md +58 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +102 -3
  4. package/dist/index.html +64 -41
  5. package/dist/js/app.GSAIYHML.js +4 -0
  6. package/dist/js/chunk.AJZBJBMO.js +1 -0
  7. package/dist/js/chunk.CGWFHJI2.js +76 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.VZXGXFNN.js +5 -0
  10. package/dist/js/login.PYIK52HN.js +1 -0
  11. package/dist/js/viewer.6DT53STL.js +1 -0
  12. package/dist/login.html +49 -0
  13. package/dist/share-viewer.00gubshk.css +114 -0
  14. package/dist/share-viewer.html +53 -0
  15. package/dist/styles.012p32dz.css +1443 -0
  16. package/dist/sw.js +79 -27
  17. package/dist/theme-init.js +6 -0
  18. package/lib/agent-detect.js +110 -0
  19. package/lib/atomic-write.js +50 -0
  20. package/lib/attachment-dispatch.js +86 -0
  21. package/lib/attachment-interceptor.js +130 -0
  22. package/lib/attachment-labels.js +139 -0
  23. package/lib/attachments.js +154 -0
  24. package/lib/auth-middleware.js +102 -0
  25. package/lib/auth-store.js +269 -0
  26. package/lib/auth.js +89 -0
  27. package/lib/bootstrap.js +70 -0
  28. package/lib/bridge.js +244 -93
  29. package/lib/client-registry.js +60 -0
  30. package/lib/config.js +127 -9
  31. package/lib/daemon.js +185 -40
  32. package/lib/event-handler.js +209 -90
  33. package/lib/log-fmt.js +67 -0
  34. package/lib/log.js +83 -0
  35. package/lib/message-cleanup.js +48 -0
  36. package/lib/mode-bucket.js +62 -0
  37. package/lib/preflight.js +195 -0
  38. package/lib/push-service.js +338 -45
  39. package/lib/routes.js +1218 -144
  40. package/lib/server.js +159 -32
  41. package/lib/session-manager.js +164 -18
  42. package/lib/session-state.js +160 -0
  43. package/lib/sessions-anchor.js +28 -0
  44. package/lib/share/cleanup.js +45 -0
  45. package/lib/share/routes.js +972 -0
  46. package/lib/share/sanitize.js +179 -0
  47. package/lib/sse-manager.js +94 -8
  48. package/lib/sse-ticket.js +45 -0
  49. package/lib/startup-checks.js +94 -0
  50. package/lib/store.js +654 -24
  51. package/lib/title-service.js +42 -9
  52. package/lib/tokens.js +50 -0
  53. package/lib/types.js +23 -0
  54. package/package.json +38 -4
  55. package/dist/js/app.4FZ67UW4.js +0 -10
  56. package/dist/styles.008ve1hx.css +0 -669
  57. package/lib/shared/constants.js +0 -17
@@ -1,105 +1,224 @@
1
- export function handleAgentEvent(event, sessions, store, bridge, config, sseManager, pushService) {
2
- if ("sessionId" in event && event.sessionId && sessions.restoringSessions.has(event.sessionId))
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.cancelTimeout = config.cancelTimeout;
7
- if (event.agent)
8
- sessions.agentInfo = event.agent;
9
- break;
163
+ handleConnected(event, sessions, config);
164
+ return false;
10
165
  case "session_created":
11
- if (event.configOptions?.length)
12
- sessions.cachedConfigOptions = event.configOptions;
13
- for (const opt of event.configOptions ?? []) {
14
- store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
15
- }
16
- break;
17
166
  case "config_option_update":
18
- if (event.configOptions?.length)
19
- sessions.cachedConfigOptions = event.configOptions;
20
- for (const opt of event.configOptions ?? []) {
21
- store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
22
- }
23
- break;
167
+ handleConfigLikeEvent(event, sessions, store);
168
+ return false;
24
169
  case "message_chunk":
25
- sessions.flushThinkingBuffer(event.sessionId);
26
- sessions.appendAssistant(event.sessionId, event.text);
27
- break;
170
+ handleMessageChunk(event, sessions);
171
+ return false;
28
172
  case "thought_chunk":
29
- sessions.flushAssistantBuffer(event.sessionId);
30
- sessions.appendThinking(event.sessionId, event.text);
31
- break;
173
+ handleThoughtChunk(event, sessions);
174
+ return false;
32
175
  case "tool_call":
33
- sessions.flushBuffers(event.sessionId);
34
- store.saveEvent(event.sessionId, event.type, { id: event.id, title: event.title, kind: event.kind, rawInput: event.rawInput });
35
- break;
176
+ handleToolCall(event, sessions, store);
177
+ return false;
36
178
  case "tool_call_update":
37
- store.saveEvent(event.sessionId, event.type, { id: event.id, status: event.status, content: event.content });
38
- break;
179
+ store.saveEvent(event.sessionId, event.type, { id: event.id, status: event.status, content: event.content }, { from_ref: "agent" });
180
+ return false;
39
181
  case "plan":
40
- sessions.flushBuffers(event.sessionId);
41
- store.saveEvent(event.sessionId, event.type, { entries: event.entries });
42
- break;
43
- case "permission_request": {
44
- sessions.flushBuffers(event.sessionId);
45
- store.saveEvent(event.sessionId, event.type, {
46
- requestId: event.requestId, title: event.title, options: event.options,
47
- });
48
- sessions.pendingPermissions.set(event.requestId, {
49
- requestId: event.requestId,
50
- sessionId: event.sessionId,
51
- title: event.title,
52
- options: event.options.map((o) => ({ optionId: o.optionId, label: o.label ?? o.name ?? o.optionId })),
53
- });
54
- // Auto-approve permissions in autopilot mode (allow_once only to avoid persisting across mode switches)
55
- const mode = store.getSession(event.sessionId)?.mode ?? "";
56
- if (mode.includes("#autopilot")) {
57
- const opt = event.options.find((o) => o.kind === "allow_once");
58
- if (opt) {
59
- bridge.resolvePermission(event.requestId, opt.optionId);
60
- sessions.pendingPermissions.delete(event.requestId);
61
- const optionName = opt.label ?? opt.optionId;
62
- store.saveEvent(event.sessionId, "permission_response", {
63
- requestId: event.requestId, optionName, denied: false,
64
- });
65
- // Broadcast both so the frontend can render then collapse the permission card
66
- sseManager.broadcast(event);
67
- sseManager.broadcast({
68
- type: "permission_response",
69
- sessionId: event.sessionId,
70
- requestId: event.requestId,
71
- optionName,
72
- denied: false,
73
- });
74
- return;
75
- }
76
- }
77
- break;
78
- }
182
+ handlePlan(event, sessions, store);
183
+ return false;
184
+ case "permission_request":
185
+ return handlePermissionRequest(event, sessions, store, bridge, sseManager, config);
79
186
  case "prompt_done":
80
- sessions.activePrompts.delete(event.sessionId);
81
- sessions.flushBuffers(event.sessionId);
82
- store.saveEvent(event.sessionId, event.type, { stopReason: event.stopReason });
83
- break;
187
+ handlePromptDone(event, sessions, store);
188
+ return false;
84
189
  case "error":
85
- if (event.sessionId) {
86
- sessions.activePrompts.delete(event.sessionId);
87
- }
88
- break;
190
+ handleError(event, sessions);
191
+ return false;
89
192
  }
90
- sseManager.broadcast(event);
91
- // Push notification check (after broadcast so clients get the event first)
92
- if (pushService && "sessionId" in event && event.sessionId) {
93
- const session = store.getSession(event.sessionId);
94
- const eventData = {};
95
- if (event.type === "permission_request") {
96
- eventData.description = event.title;
97
- }
98
- if (pushService.maybeNotify(event.sessionId, session?.title ?? null, event.type, eventData)) {
99
- const notification = pushService.formatNotification(event.sessionId, session?.title ?? null, event.type, eventData);
100
- pushService.sendToAll(notification).catch((err) => {
101
- console.error("[push] failed to send:", err);
102
- });
103
- }
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);
104
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);
105
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
+ }