@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.
- package/README.md +58 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +102 -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 +127 -9
- package/lib/daemon.js +185 -40
- package/lib/event-handler.js +209 -90
- 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 +1218 -144
- package/lib/server.js +159 -32
- 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 +654 -24
- 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.4FZ67UW4.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- package/lib/shared/constants.js +0 -17
package/lib/server.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { loadConfig } from "./config.js";
|
|
6
|
+
import { setLogLevel, log } from "./log.js";
|
|
6
7
|
import { AgentBridge } from "./bridge.js";
|
|
7
8
|
import { Store } from "./store.js";
|
|
8
9
|
import { SessionManager } from "./session-manager.js";
|
|
@@ -11,12 +12,40 @@ import { createRequestHandler } from "./routes.js";
|
|
|
11
12
|
import { handleAgentEvent } from "./event-handler.js";
|
|
12
13
|
import { PushService } from "./push-service.js";
|
|
13
14
|
import { SseManager } from "./sse-manager.js";
|
|
15
|
+
import { TicketStore } from "./sse-ticket.js";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { ClientRegistry } from "./client-registry.js";
|
|
18
|
+
import { startMessageCleanup } from "./message-cleanup.js";
|
|
19
|
+
import { startSharePreviewCleanup, } from "./share/cleanup.js";
|
|
20
|
+
import { AuthStore } from "./auth-store.js";
|
|
21
|
+
import { join as pathJoin } from "node:path";
|
|
22
|
+
import { resolveSessionsAnchor } from "./sessions-anchor.js";
|
|
23
|
+
import { runStartupChecks } from "./startup-checks.js";
|
|
24
|
+
import { AttachmentDispatcher } from "./attachment-dispatch.js";
|
|
25
|
+
import { createCounters as createAttachmentInterceptorCounters, } from "./attachment-interceptor.js";
|
|
26
|
+
// Prefix all console output with ISO-ish timestamps (YYYY-MM-DD HH:MM:SS)
|
|
27
|
+
for (const method of ["log", "error", "warn"]) {
|
|
28
|
+
const orig = console[method].bind(console);
|
|
29
|
+
console[method] = (...args) => {
|
|
30
|
+
const ts = new Date()
|
|
31
|
+
.toLocaleString("sv-SE", { hour12: false })
|
|
32
|
+
.replace(",", "");
|
|
33
|
+
orig(ts, ...args);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
14
36
|
const config = loadConfig();
|
|
37
|
+
setLogLevel(config.debug.level);
|
|
38
|
+
// Unified startup gate: preflight (node, data_dir, agent, port) + auth
|
|
39
|
+
// bootstrap (mint or refuse). Skipped via WEBAGENT_STARTUP_CHECKED=1
|
|
40
|
+
// when a parent process (daemon supervisor) already ran the gate in
|
|
41
|
+
// the operator's foreground TTY before forking.
|
|
42
|
+
const preflight = await runStartupChecks(config);
|
|
15
43
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
16
44
|
const PUBLIC_DIR = join(__dirname, "..", config.public_dir);
|
|
17
45
|
const PKG_VERSION = (() => {
|
|
18
46
|
try {
|
|
19
|
-
|
|
47
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
48
|
+
return pkg.version ?? "unknown";
|
|
20
49
|
}
|
|
21
50
|
catch {
|
|
22
51
|
return "unknown";
|
|
@@ -25,31 +54,100 @@ const PKG_VERSION = (() => {
|
|
|
25
54
|
// --- Core dependencies ---
|
|
26
55
|
const store = new Store(config.data_dir);
|
|
27
56
|
console.log(`[store] using ${config.data_dir}/`);
|
|
57
|
+
// Pin <data_dir>/sessions realpath at boot so all later anchor checks
|
|
58
|
+
// (file:// URI construction, permission interceptor) compare against the
|
|
59
|
+
// same canonical path. Defends against macOS /var → /private/var.
|
|
60
|
+
const sessionsAnchor = resolveSessionsAnchor(config.data_dir);
|
|
61
|
+
const attachmentDispatcher = new AttachmentDispatcher(store, sessionsAnchor, {
|
|
62
|
+
warn: (msg) => {
|
|
63
|
+
console.warn(msg);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
// Counters + once-per-process schemaDrift signal for the permission
|
|
67
|
+
// auto-approve interceptor (uploads-plan v2.6 §1.4 F7). Dumped hourly.
|
|
68
|
+
const attachmentInterceptorCounters = createAttachmentInterceptorCounters();
|
|
69
|
+
let lastSchemaDriftAt = 0;
|
|
70
|
+
const SCHEMA_DRIFT_THROTTLE_MS = 24 * 60 * 60 * 1000;
|
|
71
|
+
const ATTACHMENT_INTERCEPTOR_DUMP_MS = 60 * 60 * 1000;
|
|
72
|
+
setInterval(() => {
|
|
73
|
+
log
|
|
74
|
+
.scope("attachment-interceptor")
|
|
75
|
+
.info("counters", { ...attachmentInterceptorCounters });
|
|
76
|
+
}, ATTACHMENT_INTERCEPTOR_DUMP_MS).unref();
|
|
28
77
|
const sessions = new SessionManager(store, config.default_cwd, config.data_dir);
|
|
29
|
-
const titleService = new TitleService(store, sessions, config.default_cwd);
|
|
30
|
-
const pushService = new PushService(store, config.data_dir, config.push.vapid_subject
|
|
78
|
+
const titleService = new TitleService(store, sessions, config.default_cwd, config.title.model);
|
|
79
|
+
const pushService = new PushService(store, config.data_dir, config.push.vapid_subject, {
|
|
80
|
+
globalVisibilitySuppression: config.push.global_visibility_suppression,
|
|
81
|
+
});
|
|
31
82
|
console.log(`[push] VAPID public key ready`);
|
|
32
83
|
const sseManager = new SseManager();
|
|
33
|
-
|
|
84
|
+
const clientRegistry = new ClientRegistry();
|
|
85
|
+
sseManager.onRemove((clientId) => {
|
|
86
|
+
pushService.removeClient(clientId);
|
|
87
|
+
clientRegistry.remove(clientId);
|
|
88
|
+
});
|
|
34
89
|
sseManager.startHeartbeat();
|
|
90
|
+
const authStore = new AuthStore(pathJoin(config.data_dir, "auth.json"));
|
|
91
|
+
const ticketStore = new TicketStore();
|
|
92
|
+
// In-memory image signing secret; regenerated on every restart so previously
|
|
93
|
+
// leaked URLs become invalid the moment the server is bounced.
|
|
94
|
+
const attachmentSecret = randomBytes(32);
|
|
95
|
+
// SSE heartbeat re-checks token revocation; revoked → connection closed
|
|
96
|
+
// within one heartbeat interval (≤15s).
|
|
97
|
+
sseManager.setRevocationCheck((tokenName) => !authStore.hasTokenName(tokenName));
|
|
98
|
+
sseManager.setAttachmentSecret(attachmentSecret);
|
|
99
|
+
sseManager.setLabelMapProvider((sessionId) => sessions.getLabelMap(sessionId));
|
|
100
|
+
// Broadcast runtime state patches to all SSE clients interested in the session.
|
|
101
|
+
sessions.state.onPatch((event) => {
|
|
102
|
+
sseManager.broadcast(event);
|
|
103
|
+
});
|
|
35
104
|
let bridge = null;
|
|
105
|
+
let messageCleanup = null;
|
|
106
|
+
let sharePreviewCleanup = null;
|
|
36
107
|
// --- HTTP server ---
|
|
37
|
-
const server = createServer(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
108
|
+
const server = createServer((req, res) => {
|
|
109
|
+
void createRequestHandler({
|
|
110
|
+
store,
|
|
111
|
+
sessions,
|
|
112
|
+
sseManager,
|
|
113
|
+
clientRegistry,
|
|
114
|
+
titleService,
|
|
115
|
+
getBridge: () => bridge,
|
|
116
|
+
publicDir: PUBLIC_DIR,
|
|
117
|
+
dataDir: config.data_dir,
|
|
118
|
+
limits: config.limits,
|
|
119
|
+
pushService,
|
|
120
|
+
serverVersion: PKG_VERSION,
|
|
121
|
+
debugLevel: config.debug.level,
|
|
122
|
+
authStore,
|
|
123
|
+
ticketStore,
|
|
124
|
+
attachmentSecret,
|
|
125
|
+
shareConfig: config.share,
|
|
126
|
+
})(req, res);
|
|
127
|
+
});
|
|
128
|
+
async function initBridge(agentCmd) {
|
|
129
|
+
const b = new AgentBridge(agentCmd);
|
|
130
|
+
b.setAttachmentDispatcher(attachmentDispatcher);
|
|
51
131
|
b.on("event", (event) => {
|
|
52
|
-
handleAgentEvent(event, sessions, store, b, {
|
|
132
|
+
handleAgentEvent(event, sessions, store, b, {
|
|
133
|
+
cancelTimeout: config.limits.cancel_timeout,
|
|
134
|
+
recentPathsLimit: config.limits.recent_paths,
|
|
135
|
+
attachmentInterceptor: {
|
|
136
|
+
counters: attachmentInterceptorCounters,
|
|
137
|
+
logger: log.scope("attachment-interceptor"),
|
|
138
|
+
onSchemaDrift: (ctx) => {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
if (now - lastSchemaDriftAt < SCHEMA_DRIFT_THROTTLE_MS)
|
|
141
|
+
return;
|
|
142
|
+
lastSchemaDriftAt = now;
|
|
143
|
+
log
|
|
144
|
+
.scope("attachment-interceptor")
|
|
145
|
+
.error("schema drift detected — rawInput has no known path key", {
|
|
146
|
+
ctx,
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
}, sseManager, pushService, clientRegistry);
|
|
53
151
|
});
|
|
54
152
|
await b.start();
|
|
55
153
|
bridge = b;
|
|
@@ -59,24 +157,53 @@ async function initBridge() {
|
|
|
59
157
|
async function shutdown() {
|
|
60
158
|
console.log("\n[server] shutting down...");
|
|
61
159
|
sseManager.stopHeartbeat();
|
|
160
|
+
messageCleanup?.stop();
|
|
161
|
+
sharePreviewCleanup?.stop();
|
|
62
162
|
sessions.killAllBashProcs();
|
|
63
163
|
await bridge?.shutdown();
|
|
164
|
+
await authStore.close();
|
|
64
165
|
store.close();
|
|
65
166
|
server.close();
|
|
66
167
|
process.exit(0);
|
|
67
168
|
}
|
|
68
|
-
process.on("SIGINT",
|
|
69
|
-
|
|
169
|
+
process.on("SIGINT", () => {
|
|
170
|
+
void shutdown();
|
|
171
|
+
});
|
|
172
|
+
process.on("SIGTERM", () => {
|
|
173
|
+
void shutdown();
|
|
174
|
+
});
|
|
175
|
+
// SIGHUP: reload auth.json without restarting (e.g. after CLI revoked a token)
|
|
176
|
+
process.on("SIGHUP", () => {
|
|
177
|
+
authStore.reload().then(() => {
|
|
178
|
+
console.log("[auth] reloaded auth.json");
|
|
179
|
+
}, (err) => {
|
|
180
|
+
console.error("[auth] reload failed:", err);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
70
183
|
// --- Start ---
|
|
71
|
-
server.listen(config.port, "0.0.0.0",
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
184
|
+
server.listen(config.port, "0.0.0.0", () => {
|
|
185
|
+
void (async () => {
|
|
186
|
+
// The auth gate already ran in runStartupChecks (above) — either in
|
|
187
|
+
// this process or in a parent that handed off via WEBAGENT_STARTUP_
|
|
188
|
+
// CHECKED. Just open the AuthStore handle the rest of the server
|
|
189
|
+
// will use. If the gate ran, auth.json exists and has ≥ 1 token.
|
|
190
|
+
await authStore.load();
|
|
191
|
+
console.log(`[server] listening on http://localhost:${config.port}`);
|
|
192
|
+
messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days);
|
|
193
|
+
if (config.share.enabled) {
|
|
194
|
+
sharePreviewCleanup = startSharePreviewCleanup(store);
|
|
195
|
+
console.log(`[share] preview gc armed (24h interval)`);
|
|
196
|
+
}
|
|
197
|
+
// agent_cmd resolved by preflight (handles the "auto" sentinel).
|
|
198
|
+
const agentCmd = preflight.agentCmd;
|
|
199
|
+
console.log(`[bridge] starting: ${agentCmd}...`);
|
|
200
|
+
try {
|
|
201
|
+
await initBridge(agentCmd);
|
|
202
|
+
console.log(`[bridge] ready`);
|
|
203
|
+
sessions.hydrate();
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.error(`[bridge] failed to start:`, err);
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
82
209
|
});
|
package/lib/session-manager.js
CHANGED
|
@@ -2,6 +2,10 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { rm } from "node:fs/promises";
|
|
3
3
|
import { stat } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { SessionStateManager } from "./session-state.js";
|
|
6
|
+
import { buildLabelMap } from "./attachment-labels.js";
|
|
7
|
+
import { log } from "./log.js";
|
|
8
|
+
const slog = log.scope("session");
|
|
5
9
|
const IS_WIN = process.platform === "win32";
|
|
6
10
|
export function interruptBashProc(proc) {
|
|
7
11
|
if (!proc)
|
|
@@ -23,7 +27,7 @@ export function interruptBashProc(proc) {
|
|
|
23
27
|
proc.kill("SIGINT");
|
|
24
28
|
}
|
|
25
29
|
/** Known config option IDs that we persist per-session. */
|
|
26
|
-
const
|
|
30
|
+
const _PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"];
|
|
27
31
|
/** Minimum age (seconds) before an empty session is eligible for cleanup. */
|
|
28
32
|
const EMPTY_SESSION_MIN_AGE_S = 60;
|
|
29
33
|
/**
|
|
@@ -40,8 +44,17 @@ export class SessionManager {
|
|
|
40
44
|
runningBashProcs = new Map();
|
|
41
45
|
/** Pending permission requests keyed by requestId. */
|
|
42
46
|
pendingPermissions = new Map();
|
|
47
|
+
/** Per-session runtime state (busy/streaming/permissions snapshots + patches). */
|
|
48
|
+
state = new SessionStateManager();
|
|
43
49
|
/** Deduplicates concurrent resume calls for the same session. */
|
|
44
50
|
pendingResumes = new Map();
|
|
51
|
+
/**
|
|
52
|
+
* Per-session attachment label map (CLAUDE.md "Attachment label
|
|
53
|
+
* egress rewrite"). Built lazily from the `attachments` table on
|
|
54
|
+
* first read; invalidated on attachment INSERT and session
|
|
55
|
+
* DELETE. Lookup is cheap (Map.get); rebuild is one SQLite query.
|
|
56
|
+
*/
|
|
57
|
+
attachmentLabelCache = new Map();
|
|
45
58
|
cachedConfigOptions = [];
|
|
46
59
|
agentInfo = null;
|
|
47
60
|
store;
|
|
@@ -75,11 +88,11 @@ export class SessionManager {
|
|
|
75
88
|
for (const id of cleaned)
|
|
76
89
|
this.liveSessions.delete(id);
|
|
77
90
|
if (cleaned.length > 0)
|
|
78
|
-
|
|
91
|
+
slog.info("cleaned empty session(s)", { count: cleaned.length });
|
|
79
92
|
const sourceSession = inheritFromSessionId
|
|
80
93
|
? this.store.getSession(inheritFromSessionId)
|
|
81
94
|
: null;
|
|
82
|
-
const sessionId = await bridge.newSession(sessionCwd);
|
|
95
|
+
const { sessionId } = await bridge.newSession(sessionCwd);
|
|
83
96
|
this.liveSessions.add(sessionId);
|
|
84
97
|
this.store.createSession(sessionId, sessionCwd, source);
|
|
85
98
|
// Inherit config options from source session
|
|
@@ -120,35 +133,77 @@ export class SessionManager {
|
|
|
120
133
|
cwd: session.cwd,
|
|
121
134
|
title: session.title,
|
|
122
135
|
configOptions,
|
|
123
|
-
busyKind: this.getBusyKind(sessionId) ?? undefined,
|
|
124
136
|
};
|
|
125
137
|
}
|
|
126
138
|
// Restore via ACP
|
|
127
139
|
this.restoringSessions.add(sessionId);
|
|
128
140
|
try {
|
|
129
|
-
|
|
141
|
+
await bridge.loadSession(sessionId, session.cwd);
|
|
130
142
|
this.liveSessions.add(sessionId);
|
|
131
143
|
if (session.title)
|
|
132
144
|
this.sessionHasTitle.add(sessionId);
|
|
133
|
-
|
|
134
|
-
|
|
145
|
+
// Piggyback a cache-warming setConfigOption on the user's own resume
|
|
146
|
+
// when the global cache is empty (typical after bridge.restart). Uses
|
|
147
|
+
// the session's own stored value — idempotent, no side effect. Failure
|
|
148
|
+
// is swallowed: the resume still succeeds and the frontend falls back
|
|
149
|
+
// to snapshot-based mode/model display (see public/js/state.ts).
|
|
150
|
+
await this.tryWarmCache(bridge, sessionId, session);
|
|
151
|
+
const configOptions = this.applyStoredConfig(this.cachedConfigOptions, session);
|
|
152
|
+
slog.info("restored", { sessionId: sessionId.slice(0, 8) + "…" });
|
|
135
153
|
return {
|
|
136
154
|
type: "session_created",
|
|
137
155
|
sessionId,
|
|
138
156
|
cwd: session.cwd,
|
|
139
157
|
title: session.title,
|
|
140
158
|
configOptions,
|
|
141
|
-
busyKind: this.getBusyKind(sessionId) ?? undefined,
|
|
142
159
|
};
|
|
143
160
|
}
|
|
144
161
|
catch (err) {
|
|
145
|
-
|
|
162
|
+
slog.error("restore failed", { error: err });
|
|
146
163
|
throw err;
|
|
147
164
|
}
|
|
148
165
|
finally {
|
|
149
166
|
this.restoringSessions.delete(sessionId);
|
|
150
167
|
}
|
|
151
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* When cachedConfigOptions is empty, use the session's own stored config
|
|
171
|
+
* value to trigger a setConfigOption. The agent's response carries the
|
|
172
|
+
* full ConfigOption[] schema (options lists + in-memory currentValues),
|
|
173
|
+
* which we cache. Writes **only** the global cache, never the session's
|
|
174
|
+
* DB row — setConfigOption's currentValue for unrelated keys is the
|
|
175
|
+
* agent's in-memory default, not the user's preference.
|
|
176
|
+
*
|
|
177
|
+
* Key priority mode > reasoning_effort > model:
|
|
178
|
+
* - mode is a small stable enum, rewriting current value is idempotent.
|
|
179
|
+
* - model has the highest schema-drift risk (agent upgrades drop values).
|
|
180
|
+
*/
|
|
181
|
+
async tryWarmCache(bridge, sessionId, session) {
|
|
182
|
+
if (this.cachedConfigOptions.length > 0)
|
|
183
|
+
return;
|
|
184
|
+
const pick = session.mode
|
|
185
|
+
? { id: "mode", value: session.mode }
|
|
186
|
+
: session.reasoning_effort
|
|
187
|
+
? { id: "reasoning_effort", value: session.reasoning_effort }
|
|
188
|
+
: session.model
|
|
189
|
+
? { id: "model", value: session.model }
|
|
190
|
+
: null;
|
|
191
|
+
if (!pick)
|
|
192
|
+
return;
|
|
193
|
+
try {
|
|
194
|
+
const opts = await bridge.setConfigOption(sessionId, pick.id, pick.value);
|
|
195
|
+
if (opts.length > 0) {
|
|
196
|
+
this.cachedConfigOptions = opts;
|
|
197
|
+
slog.info("warmed cache on resume", { options: opts.length });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
slog.warn("cache warming failed", {
|
|
202
|
+
sessionId: sessionId.slice(0, 8) + "…",
|
|
203
|
+
error: err,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
152
207
|
/**
|
|
153
208
|
* Ensure a session is resumed (live in ACP). Deduplicates concurrent calls.
|
|
154
209
|
* Unlike resumeSession(), this is fire-and-forget safe — callers that only
|
|
@@ -186,22 +241,52 @@ export class SessionManager {
|
|
|
186
241
|
return opt;
|
|
187
242
|
});
|
|
188
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Lazy-build and return the attachment label map for a session.
|
|
246
|
+
* Used by both egress chokepoints (SSE broadcast + replay helper)
|
|
247
|
+
* to translate uuid paths into `<name> [#<id4>]` labels.
|
|
248
|
+
*
|
|
249
|
+
* Cached; call `invalidateLabelCache(sid)` after any attachments
|
|
250
|
+
* INSERT/DELETE to force rebuild. Restart-safe: empty on cold
|
|
251
|
+
* start, rebuilt on first egress per session.
|
|
252
|
+
*/
|
|
253
|
+
getLabelMap(sessionId) {
|
|
254
|
+
const hit = this.attachmentLabelCache.get(sessionId);
|
|
255
|
+
if (hit)
|
|
256
|
+
return hit;
|
|
257
|
+
const map = buildLabelMap(this.store.listAttachmentLabels(sessionId));
|
|
258
|
+
this.attachmentLabelCache.set(sessionId, map);
|
|
259
|
+
return map;
|
|
260
|
+
}
|
|
261
|
+
/** Invalidate label cache for a session (call after attachment write). */
|
|
262
|
+
invalidateLabelCache(sessionId) {
|
|
263
|
+
this.attachmentLabelCache.delete(sessionId);
|
|
264
|
+
}
|
|
189
265
|
/** Delete a session from store and clean up all state (including images). */
|
|
190
266
|
deleteSession(sessionId) {
|
|
191
|
-
this.store.deleteSession(sessionId);
|
|
267
|
+
const mode = this.store.deleteSession(sessionId);
|
|
192
268
|
this.liveSessions.delete(sessionId);
|
|
193
269
|
this.sessionHasTitle.delete(sessionId);
|
|
194
270
|
this.assistantBuffers.delete(sessionId);
|
|
195
271
|
this.thinkingBuffers.delete(sessionId);
|
|
196
272
|
this.activePrompts.delete(sessionId);
|
|
197
273
|
this.runningBashProcs.delete(sessionId);
|
|
274
|
+
this.attachmentLabelCache.delete(sessionId);
|
|
198
275
|
// Clean pending permissions for this session
|
|
199
276
|
for (const [reqId, perm] of this.pendingPermissions) {
|
|
200
277
|
if (perm.sessionId === sessionId)
|
|
201
278
|
this.pendingPermissions.delete(reqId);
|
|
202
279
|
}
|
|
203
|
-
|
|
204
|
-
|
|
280
|
+
this.state.delete(sessionId);
|
|
281
|
+
if (mode === "hard") {
|
|
282
|
+
// Tombstoned sessions keep their attachments alive for the share viewer
|
|
283
|
+
// (shared files still resolve via /s/:token/attachments/...). The reap
|
|
284
|
+
// path in share/routes.ts removes them once the last share is gone.
|
|
285
|
+
rm(join(this.dataDir, "sessions", sessionId), {
|
|
286
|
+
recursive: true,
|
|
287
|
+
force: true,
|
|
288
|
+
}).catch(() => { });
|
|
289
|
+
}
|
|
205
290
|
}
|
|
206
291
|
/** Flush assistant/thinking buffers to store. */
|
|
207
292
|
flushBuffers(sessionId) {
|
|
@@ -212,7 +297,7 @@ export class SessionManager {
|
|
|
212
297
|
flushAssistantBuffer(sessionId) {
|
|
213
298
|
const assistant = this.assistantBuffers.get(sessionId);
|
|
214
299
|
if (assistant) {
|
|
215
|
-
this.store.saveEvent(sessionId, "assistant_message", { text: assistant });
|
|
300
|
+
this.store.saveEvent(sessionId, "assistant_message", { text: assistant }, { from_ref: "agent" });
|
|
216
301
|
this.assistantBuffers.delete(sessionId);
|
|
217
302
|
}
|
|
218
303
|
}
|
|
@@ -220,7 +305,7 @@ export class SessionManager {
|
|
|
220
305
|
flushThinkingBuffer(sessionId) {
|
|
221
306
|
const thinking = this.thinkingBuffers.get(sessionId);
|
|
222
307
|
if (thinking) {
|
|
223
|
-
this.store.saveEvent(sessionId, "thinking", { text: thinking });
|
|
308
|
+
this.store.saveEvent(sessionId, "thinking", { text: thinking }, { from_ref: "agent" });
|
|
224
309
|
this.thinkingBuffers.delete(sessionId);
|
|
225
310
|
}
|
|
226
311
|
}
|
|
@@ -245,6 +330,37 @@ export class SessionManager {
|
|
|
245
330
|
return "agent";
|
|
246
331
|
return null;
|
|
247
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Recompute busy from active prompts/bash procs and patch the state manager.
|
|
335
|
+
* Call this immediately after mutating activePrompts / runningBashProcs so
|
|
336
|
+
* the frontend snapshot stays in sync via `state_patch` broadcast.
|
|
337
|
+
*
|
|
338
|
+
* `promptId` attaches to an agent busy transition (ignored otherwise). If
|
|
339
|
+
* omitted when staying agent-busy, the existing promptId is preserved.
|
|
340
|
+
*/
|
|
341
|
+
syncBusy(sessionId, promptId) {
|
|
342
|
+
const kind = this.getBusyKind(sessionId);
|
|
343
|
+
const current = this.state.getState(sessionId).runtime.busy;
|
|
344
|
+
if (kind === null) {
|
|
345
|
+
if (current !== null)
|
|
346
|
+
this.state.patch(sessionId, { runtime: { busy: null } });
|
|
347
|
+
// Also clear any pending cancel safety net now that we are idle.
|
|
348
|
+
this.state.clearCancelSafety(sessionId);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const nextPromptId = kind === "agent" ? (promptId ?? current?.promptId ?? null) : null;
|
|
352
|
+
if (current?.kind === kind && current.promptId === nextPromptId)
|
|
353
|
+
return;
|
|
354
|
+
this.state.patch(sessionId, {
|
|
355
|
+
runtime: {
|
|
356
|
+
busy: {
|
|
357
|
+
kind: kind,
|
|
358
|
+
since: current?.kind === kind ? current.since : new Date().toISOString(),
|
|
359
|
+
promptId: nextPromptId,
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
}
|
|
248
364
|
/**
|
|
249
365
|
* If the session's last turn was interrupted (user_message without prompt_done),
|
|
250
366
|
* auto-retry by prompting the agent to continue. Returns true if retrying.
|
|
@@ -254,18 +370,48 @@ export class SessionManager {
|
|
|
254
370
|
return false;
|
|
255
371
|
if (!this.store.hasInterruptedTurn(sessionId))
|
|
256
372
|
return false;
|
|
257
|
-
|
|
373
|
+
slog.info("auto-retrying interrupted turn", {
|
|
374
|
+
sessionId: sessionId.slice(0, 8) + "…",
|
|
375
|
+
});
|
|
258
376
|
this.activePrompts.add(sessionId);
|
|
259
|
-
|
|
260
|
-
|
|
377
|
+
this.syncBusy(sessionId);
|
|
378
|
+
bridge
|
|
379
|
+
.prompt(sessionId, "Continue your previous response — it was interrupted mid-way.")
|
|
380
|
+
.catch((err) => {
|
|
381
|
+
slog.error("auto-retry failed", {
|
|
382
|
+
sessionId: sessionId.slice(0, 8) + "…",
|
|
383
|
+
error: err,
|
|
384
|
+
});
|
|
261
385
|
this.activePrompts.delete(sessionId);
|
|
386
|
+
this.syncBusy(sessionId);
|
|
262
387
|
});
|
|
263
388
|
return true;
|
|
264
389
|
}
|
|
265
390
|
/** Get pending permission requests for a session (or all sessions if no id). */
|
|
266
391
|
getPendingPermissions(sessionId) {
|
|
267
392
|
const perms = [...this.pendingPermissions.values()];
|
|
268
|
-
return sessionId ? perms.filter(p => p.sessionId === sessionId) : perms;
|
|
393
|
+
return sessionId ? perms.filter((p) => p.sessionId === sessionId) : perms;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Re-derive runtime.pendingPermissions from the Map and push via state_patch.
|
|
397
|
+
* Call this after every mutation of `pendingPermissions` so the frontend
|
|
398
|
+
* snapshot stays authoritative.
|
|
399
|
+
*/
|
|
400
|
+
syncPendingPermissions(sessionId) {
|
|
401
|
+
const forSession = [...this.pendingPermissions.values()]
|
|
402
|
+
.filter((p) => p.sessionId === sessionId)
|
|
403
|
+
.map((p) => ({
|
|
404
|
+
requestId: p.requestId,
|
|
405
|
+
toolName: "",
|
|
406
|
+
title: p.title,
|
|
407
|
+
options: p.options.map((o) => ({
|
|
408
|
+
optionId: o.optionId,
|
|
409
|
+
label: o.label,
|
|
410
|
+
})),
|
|
411
|
+
}));
|
|
412
|
+
this.state.patch(sessionId, {
|
|
413
|
+
runtime: { pendingPermissions: forSession },
|
|
414
|
+
});
|
|
269
415
|
}
|
|
270
416
|
/** Kill all running bash processes (for shutdown). */
|
|
271
417
|
killAllBashProcs() {
|