@lelouchhe/webagent 0.7.0 → 0.9.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/lib/server.js CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { loadConfig } from "./config.js";
6
6
  import { setLogLevel, log } from "./log.js";
7
7
  import { AgentBridge } from "./bridge.js";
8
+ import { agentKeyFromCommand } from "./agent-key.js";
8
9
  import { Store } from "./store.js";
9
10
  import { SessionManager } from "./session-manager.js";
10
11
  import { TitleService } from "./title-service.js";
@@ -53,7 +54,7 @@ const PKG_VERSION = (() => {
53
54
  }
54
55
  })();
55
56
  // --- Core dependencies ---
56
- const store = new Store(config.data_dir);
57
+ const store = new Store(config.data_dir, agentKeyFromCommand(preflight.agentCmd));
57
58
  console.log(`[store] using ${config.data_dir}/`);
58
59
  // Pin <data_dir>/sessions realpath at boot so all later anchor checks
59
60
  // (file:// URI construction, permission interceptor) compare against the
@@ -104,31 +105,34 @@ sessions.state.onPatch((event) => {
104
105
  sseManager.broadcast(event);
105
106
  });
106
107
  let bridge = null;
107
- let messageCleanup = null;
108
+ const messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days, (pendingCount) => {
109
+ sseManager.broadcastGlobal({ type: "inbox_count_changed", pendingCount });
110
+ });
108
111
  let sharePreviewCleanup = null;
109
112
  // --- HTTP server ---
113
+ const requestHandler = createRequestHandler({
114
+ store,
115
+ sessions,
116
+ sseManager,
117
+ clientRegistry,
118
+ titleService,
119
+ getBridge: () => bridge,
120
+ publicDir: PUBLIC_DIR,
121
+ dataDir: config.data_dir,
122
+ limits: config.limits,
123
+ pushService,
124
+ serverVersion: PKG_VERSION,
125
+ debugLevel: config.debug.level,
126
+ authStore,
127
+ ticketStore,
128
+ attachmentSecret,
129
+ shareConfig: config.share,
130
+ });
110
131
  const server = createServer((req, res) => {
111
- void createRequestHandler({
112
- store,
113
- sessions,
114
- sseManager,
115
- clientRegistry,
116
- titleService,
117
- getBridge: () => bridge,
118
- publicDir: PUBLIC_DIR,
119
- dataDir: config.data_dir,
120
- limits: config.limits,
121
- pushService,
122
- serverVersion: PKG_VERSION,
123
- debugLevel: config.debug.level,
124
- authStore,
125
- ticketStore,
126
- attachmentSecret,
127
- shareConfig: config.share,
128
- })(req, res);
132
+ void requestHandler(req, res);
129
133
  });
130
134
  async function initBridge(agentCmd) {
131
- const b = new AgentBridge(agentCmd);
135
+ const b = new AgentBridge(agentCmd, store);
132
136
  b.setAttachmentDispatcher(attachmentDispatcher);
133
137
  const eventHandlerConfig = _buildBridgeEventHandlerConfig({
134
138
  cancelTimeout: config.limits.cancel_timeout,
@@ -153,7 +157,7 @@ async function initBridge(agentCmd) {
153
157
  async function shutdown() {
154
158
  console.log("\n[server] shutting down...");
155
159
  sseManager.stopHeartbeat();
156
- messageCleanup?.stop();
160
+ messageCleanup.stop();
157
161
  sharePreviewCleanup?.stop();
158
162
  sessions.killAllBashProcs();
159
163
  await bridge?.shutdown();
@@ -185,7 +189,6 @@ server.listen(config.port, config.host, () => {
185
189
  // will use. If the gate ran, auth.json exists and has ≥ 1 token.
186
190
  await authStore.load();
187
191
  console.log(`[server] listening on http://localhost:${config.port}`);
188
- messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days);
189
192
  if (config.share.enabled) {
190
193
  sharePreviewCleanup = startSharePreviewCleanup(store);
191
194
  console.log(`[share] preview gc armed (24h interval)`);
@@ -3,12 +3,14 @@ import { randomUUID } from "node:crypto";
3
3
  import { rm } from "node:fs/promises";
4
4
  import { stat } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
+ import { MessageNotFoundError } from "./store.js";
6
7
  import { SessionStateManager } from "./session-state.js";
7
8
  import { buildLabelMap } from "./attachment-labels.js";
9
+ import { abbreviateHomePath, expandHomePath } from "./home-path.js";
8
10
  import { log } from "./log.js";
9
11
  const slog = log.scope("session");
10
12
  const IS_WIN = process.platform === "win32";
11
- export function interruptBashProc(proc) {
13
+ export function interruptBashProc(proc, force = false) {
12
14
  if (!proc)
13
15
  return;
14
16
  if (IS_WIN && typeof proc.pid === "number") {
@@ -18,19 +20,23 @@ export function interruptBashProc(proc) {
18
20
  }
19
21
  if (typeof proc.pid === "number") {
20
22
  try {
21
- process.kill(-proc.pid, "SIGINT");
23
+ process.kill(-proc.pid, force ? "SIGKILL" : "SIGINT");
22
24
  return;
23
25
  }
24
26
  catch {
25
27
  // Fall through to direct child kill when the process is not a group leader.
26
28
  }
27
29
  }
28
- proc.kill("SIGINT");
30
+ proc.kill(force ? "SIGKILL" : "SIGINT");
29
31
  }
30
- /** Known config option IDs that we persist per-session. */
31
- const _PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"];
32
32
  /** Minimum age (seconds) before an empty session is eligible for cleanup. */
33
33
  const EMPTY_SESSION_MIN_AGE_S = 60;
34
+ export class InvalidSessionDirectoryError extends Error {
35
+ constructor(cwd) {
36
+ super(`Directory does not exist: ${cwd}`);
37
+ this.name = "InvalidSessionDirectoryError";
38
+ }
39
+ }
34
40
  /**
35
41
  * Centralizes all session-related state that was previously scattered
36
42
  * across module-level variables in server.ts.
@@ -42,13 +48,20 @@ export class SessionManager {
42
48
  assistantBuffers = new Map();
43
49
  thinkingBuffers = new Map();
44
50
  activePrompts = new Set();
51
+ pendingPromptSubmissions = new Map();
52
+ cancelledPromptSubmissions = new Set();
45
53
  runningBashProcs = new Map();
54
+ interruptedBashProcs = new WeakSet();
46
55
  /** Pending permission requests keyed by requestId. */
47
56
  pendingPermissions = new Map();
48
57
  /** Per-session runtime state (busy/streaming/permissions snapshots + patches). */
49
58
  state = new SessionStateManager();
50
59
  /** Deduplicates concurrent resume calls for the same session. */
51
60
  pendingResumes = new Map();
61
+ nextPromptNumber = 0;
62
+ nextPromptSubmissionNumber = 0;
63
+ /** Deduplicates concurrent attempts to materialize one inbox message. */
64
+ pendingMessageConsumes = new Map();
52
65
  /**
53
66
  * Per-session attachment label map (CLAUDE.md "Attachment label
54
67
  * egress rewrite"). Built lazily from the `attachments` table on
@@ -76,15 +89,15 @@ export class SessionManager {
76
89
  }
77
90
  }
78
91
  /** Create a new session in both bridge and store, inheriting the source session's config. */
79
- async createSession(bridge, cwd, inheritFromSessionId, source = "auto") {
80
- const sessionCwd = cwd ?? this.defaultCwd;
92
+ async createSession(bridge, cwd, inheritFromSessionId, source = "auto", opts) {
93
+ const sessionCwd = expandHomePath(cwd ?? this.defaultCwd);
81
94
  try {
82
95
  const info = await stat(sessionCwd);
83
96
  if (!info.isDirectory())
84
97
  throw new Error("not a directory");
85
98
  }
86
99
  catch {
87
- throw new Error(`Directory does not exist: ${sessionCwd}`);
100
+ throw new InvalidSessionDirectoryError(sessionCwd);
88
101
  }
89
102
  // Clean up empty sessions (no events) older than the threshold
90
103
  const cleaned = this.store.deleteEmptySessions(EMPTY_SESSION_MIN_AGE_S);
@@ -97,33 +110,124 @@ export class SessionManager {
97
110
  const sourceSession = inheritFromSessionId
98
111
  ? this.store.getSession(inheritFromSessionId)
99
112
  : null;
100
- const { sessionId } = await bridge.newSession(sessionCwd);
101
- this.liveSessions.add(sessionId);
102
- this.store.createSession(sessionId, sessionCwd, source);
113
+ const webSessionId = randomUUID();
114
+ const { sessionId: agentSessionId, configOptions: createdConfigOptions } = await bridge.newSession(sessionCwd, { silent: opts?.silent });
115
+ let configOptions = createdConfigOptions;
116
+ try {
117
+ this.store.createSession(webSessionId, sessionCwd, source, agentSessionId);
118
+ }
119
+ catch (err) {
120
+ slog.warn("ACP session created but local persistence failed", {
121
+ agentSessionId,
122
+ error: err,
123
+ });
124
+ bridge.discardUnboundSession?.(agentSessionId);
125
+ throw err;
126
+ }
127
+ this.liveSessions.add(webSessionId);
128
+ this.recordConfigOptions(webSessionId, createdConfigOptions);
129
+ bridge.sessionMapped?.(agentSessionId);
103
130
  // Inherit config options from source session
104
131
  if (sourceSession) {
132
+ const thinkingOption = createdConfigOptions.find((option) => "options" in option &&
133
+ (option.id === "reasoning_effort" ||
134
+ option.id === "thought_level" ||
135
+ option.category === "thought_level"));
105
136
  const inherited = [
106
137
  { configId: "model", value: sourceSession.model },
107
- { configId: "reasoning_effort", value: sourceSession.reasoning_effort },
138
+ {
139
+ configId: thinkingOption?.id ?? "reasoning_effort",
140
+ value: sourceSession.reasoning_effort,
141
+ },
108
142
  ];
109
143
  for (const { configId, value } of inherited) {
110
144
  if (!value)
111
145
  continue;
112
146
  try {
113
- await bridge.setConfigOption(sessionId, configId, value);
114
- this.store.updateSessionConfig(sessionId, configId, value);
147
+ const updatedConfigOptions = await bridge.setConfigOption(webSessionId, configId, value);
148
+ if (updatedConfigOptions.length > 0) {
149
+ configOptions = updatedConfigOptions;
150
+ this.recordConfigOptions(webSessionId, updatedConfigOptions);
151
+ }
152
+ else {
153
+ this.store.updateSessionConfig(webSessionId, configId, value);
154
+ }
115
155
  }
116
156
  catch {
117
157
  // Option may no longer be available; ignore
118
158
  }
119
159
  }
120
160
  }
121
- const session = this.store.getSession(sessionId);
161
+ const session = this.store.getSession(webSessionId);
122
162
  return {
123
- sessionId,
124
- configOptions: session ? this.buildConfigOptions(session) : [],
163
+ sessionId: webSessionId,
164
+ configOptions: session
165
+ ? this.applyStoredConfig(configOptions, session)
166
+ : [],
125
167
  };
126
168
  }
169
+ /** Cache the ACP config schema and persist this session's current values. */
170
+ recordConfigOptions(sessionId, configOptions) {
171
+ if (configOptions.length === 0)
172
+ return;
173
+ this.cachedConfigOptions = configOptions;
174
+ for (const option of configOptions) {
175
+ if (typeof option.currentValue === "string") {
176
+ this.store.updateSessionConfig(sessionId, option.id, option.currentValue);
177
+ }
178
+ }
179
+ }
180
+ /**
181
+ * Materialize a pending inbox message as a real ACP-backed session.
182
+ * Returns null when the message is neither pending nor previously consumed.
183
+ */
184
+ consumeMessage(bridge, messageId, inheritFromSessionId) {
185
+ const pending = this.pendingMessageConsumes.get(messageId);
186
+ if (pending)
187
+ return pending;
188
+ const existing = this.store.findConsumedMessageSession(messageId);
189
+ if (existing) {
190
+ return Promise.resolve({
191
+ sessionId: existing,
192
+ alreadyConsumed: true,
193
+ });
194
+ }
195
+ const operation = this.consumePendingMessage(bridge, messageId, inheritFromSessionId).finally(() => {
196
+ if (this.pendingMessageConsumes.get(messageId) === operation) {
197
+ this.pendingMessageConsumes.delete(messageId);
198
+ }
199
+ });
200
+ this.pendingMessageConsumes.set(messageId, operation);
201
+ return operation;
202
+ }
203
+ async consumePendingMessage(bridge, messageId, inheritFromSessionId) {
204
+ const message = this.store.getMessage(messageId);
205
+ if (!message)
206
+ return null;
207
+ const { sessionId } = await this.createSession(bridge, message.cwd ?? undefined, inheritFromSessionId, "message", { silent: true });
208
+ try {
209
+ const result = this.store.consumeMessageTx(messageId, sessionId);
210
+ if (result.alreadyConsumed) {
211
+ this.deleteSession(sessionId);
212
+ }
213
+ return result;
214
+ }
215
+ catch (err) {
216
+ this.deleteSession(sessionId);
217
+ if (err instanceof MessageNotFoundError) {
218
+ const consumedSessionId = this.store.findConsumedMessageSession(messageId);
219
+ return consumedSessionId
220
+ ? { sessionId: consumedSessionId, alreadyConsumed: true }
221
+ : null;
222
+ }
223
+ slog.warn("message consume left an unreachable ACP session", {
224
+ messageId,
225
+ sessionId,
226
+ error: err,
227
+ });
228
+ throw err;
229
+ }
230
+ }
127
231
  /** Resume a session — returns event to send to the requesting client. */
128
232
  async resumeSession(bridge, sessionId) {
129
233
  const session = this.store.getSession(sessionId);
@@ -136,6 +240,7 @@ export class SessionManager {
136
240
  type: "session_created",
137
241
  sessionId,
138
242
  cwd: session.cwd,
243
+ cwdDisplay: abbreviateHomePath(session.cwd),
139
244
  title: session.title,
140
245
  configOptions,
141
246
  };
@@ -159,6 +264,7 @@ export class SessionManager {
159
264
  type: "session_created",
160
265
  sessionId,
161
266
  cwd: session.cwd,
267
+ cwdDisplay: abbreviateHomePath(session.cwd),
162
268
  title: session.title,
163
269
  configOptions,
164
270
  };
@@ -179,33 +285,42 @@ export class SessionManager {
179
285
  * DB row — setConfigOption's currentValue for unrelated keys is the
180
286
  * agent's in-memory default, not the user's preference.
181
287
  *
182
- * Key priority mode > reasoning_effort > model:
288
+ * Key priority mode > thinking aliases > model:
183
289
  * - mode is a small stable enum, rewriting current value is idempotent.
290
+ * - ACP agents use both reasoning_effort and thought_level for thinking.
184
291
  * - model has the highest schema-drift risk (agent upgrades drop values).
185
292
  */
186
293
  async tryWarmCache(bridge, sessionId, session) {
187
294
  if (this.cachedConfigOptions.length > 0)
188
295
  return;
189
- const pick = session.mode
190
- ? { id: "mode", value: session.mode }
191
- : session.reasoning_effort
192
- ? { id: "reasoning_effort", value: session.reasoning_effort }
193
- : session.model
194
- ? { id: "model", value: session.model }
195
- : null;
196
- if (!pick)
296
+ const candidates = [];
297
+ if (session.mode)
298
+ candidates.push({ id: "mode", value: session.mode });
299
+ if (session.reasoning_effort) {
300
+ candidates.push({ id: "reasoning_effort", value: session.reasoning_effort }, { id: "thought_level", value: session.reasoning_effort });
301
+ }
302
+ if (session.model)
303
+ candidates.push({ id: "model", value: session.model });
304
+ if (candidates.length === 0)
197
305
  return;
198
- try {
199
- const opts = await bridge.setConfigOption(sessionId, pick.id, pick.value);
200
- if (opts.length > 0) {
201
- this.cachedConfigOptions = opts;
202
- slog.info("warmed cache on resume", { options: opts.length });
306
+ let lastError = null;
307
+ for (const candidate of candidates) {
308
+ try {
309
+ const opts = await bridge.setConfigOption(sessionId, candidate.id, candidate.value);
310
+ if (opts.length > 0) {
311
+ this.cachedConfigOptions = opts;
312
+ slog.info("warmed cache on resume", { options: opts.length });
313
+ return;
314
+ }
315
+ }
316
+ catch (err) {
317
+ lastError = err;
203
318
  }
204
319
  }
205
- catch (err) {
320
+ if (lastError) {
206
321
  slog.warn("cache warming failed", {
207
322
  sessionId: sessionId.slice(0, 8) + "…",
208
- error: err,
323
+ error: lastError,
209
324
  });
210
325
  }
211
326
  }
@@ -233,14 +348,17 @@ export class SessionManager {
233
348
  /** Override currentValue in configOptions with stored session values. */
234
349
  applyStoredConfig(configOptions, session) {
235
350
  if (!configOptions.length)
236
- return this.cachedConfigOptions;
351
+ return configOptions;
237
352
  const stored = {
238
353
  model: session.model,
239
354
  mode: session.mode,
240
355
  reasoning_effort: session.reasoning_effort,
356
+ thought_level: session.reasoning_effort,
241
357
  };
242
358
  return configOptions.map((opt) => {
243
- const override = stored[opt.id];
359
+ const override = opt.category === "thought_level"
360
+ ? session.reasoning_effort
361
+ : stored[opt.id];
244
362
  if (override && "options" in opt)
245
363
  return { ...opt, currentValue: override };
246
364
  return opt;
@@ -311,6 +429,11 @@ export class SessionManager {
311
429
  this.assistantBuffers.delete(sessionId);
312
430
  this.thinkingBuffers.delete(sessionId);
313
431
  this.activePrompts.delete(sessionId);
432
+ const pendingSubmission = this.pendingPromptSubmissions.get(sessionId);
433
+ this.pendingPromptSubmissions.delete(sessionId);
434
+ if (pendingSubmission !== undefined) {
435
+ this.cancelledPromptSubmissions.delete(pendingSubmission);
436
+ }
314
437
  this.runningBashProcs.delete(sessionId);
315
438
  this.attachmentLabelCache.delete(sessionId);
316
439
  this.agentCommandSnapshots.delete(sessionId);
@@ -338,17 +461,17 @@ export class SessionManager {
338
461
  /** Flush only the assistant message buffer to store. */
339
462
  flushAssistantBuffer(sessionId) {
340
463
  const assistant = this.assistantBuffers.get(sessionId);
341
- if (assistant) {
464
+ this.assistantBuffers.delete(sessionId);
465
+ if (assistant && this.store.getSession(sessionId)) {
342
466
  this.store.saveEvent(sessionId, "assistant_message", { text: assistant }, { from_ref: "agent" });
343
- this.assistantBuffers.delete(sessionId);
344
467
  }
345
468
  }
346
469
  /** Flush only the thinking buffer to store. */
347
470
  flushThinkingBuffer(sessionId) {
348
471
  const thinking = this.thinkingBuffers.get(sessionId);
349
- if (thinking) {
472
+ this.thinkingBuffers.delete(sessionId);
473
+ if (thinking && this.store.getSession(sessionId)) {
350
474
  this.store.saveEvent(sessionId, "thinking", { text: thinking }, { from_ref: "agent" });
351
- this.thinkingBuffers.delete(sessionId);
352
475
  }
353
476
  }
354
477
  /** Append to assistant message buffer. */
@@ -366,12 +489,52 @@ export class SessionManager {
366
489
  return this.store.getSession(sessionId)?.cwd ?? this.defaultCwd;
367
490
  }
368
491
  getBusyKind(sessionId) {
369
- if (this.runningBashProcs.has(sessionId))
370
- return "bash";
492
+ if (this.pendingPromptSubmissions.has(sessionId))
493
+ return "agent";
371
494
  if (this.activePrompts.has(sessionId))
372
495
  return "agent";
496
+ if (this.runningBashProcs.has(sessionId))
497
+ return "bash";
373
498
  return null;
374
499
  }
500
+ /**
501
+ * True when `promptId` still names the session's live turn. A turn can
502
+ * outlive its own supersession — cancelling one and immediately starting
503
+ * another interleaves them — and its terminal work must not clear state
504
+ * that now belongs to the replacement. An absent id is treated as current
505
+ * so callers predating turn identity keep their old behaviour.
506
+ */
507
+ isCurrentPrompt(sessionId, promptId) {
508
+ if (!promptId)
509
+ return true;
510
+ const current = this.state.getState(sessionId).runtime.busy?.promptId;
511
+ return current == null || current === promptId;
512
+ }
513
+ reservePromptSubmission(sessionId) {
514
+ if (this.getBusyKind(sessionId) !== null)
515
+ return null;
516
+ const submissionId = ++this.nextPromptSubmissionNumber;
517
+ this.pendingPromptSubmissions.set(sessionId, submissionId);
518
+ return submissionId;
519
+ }
520
+ cancelPendingPromptSubmission(sessionId) {
521
+ const submissionId = this.pendingPromptSubmissions.get(sessionId);
522
+ if (submissionId === undefined)
523
+ return false;
524
+ this.cancelledPromptSubmissions.add(submissionId);
525
+ return true;
526
+ }
527
+ isPromptSubmissionCancelled(submissionId) {
528
+ return this.cancelledPromptSubmissions.has(submissionId);
529
+ }
530
+ releasePromptSubmission(sessionId, submissionId, sync = true) {
531
+ if (this.pendingPromptSubmissions.get(sessionId) === submissionId) {
532
+ this.pendingPromptSubmissions.delete(sessionId);
533
+ }
534
+ this.cancelledPromptSubmissions.delete(submissionId);
535
+ if (sync)
536
+ this.syncBusy(sessionId);
537
+ }
375
538
  /**
376
539
  * Recompute busy from active prompts/bash procs and patch the state manager.
377
540
  * Call this immediately after mutating activePrompts / runningBashProcs so
@@ -390,8 +553,14 @@ export class SessionManager {
390
553
  this.state.clearCancelSafety(sessionId);
391
554
  return;
392
555
  }
393
- const nextPromptId = kind === "agent" ? (promptId ?? current?.promptId ?? null) : null;
394
- if (current?.kind === kind && current.promptId === nextPromptId)
556
+ const nextPromptId = kind === "agent"
557
+ ? (promptId ??
558
+ (current?.kind === "agent"
559
+ ? current.promptId
560
+ : `prompt-${++this.nextPromptNumber}`))
561
+ : null;
562
+ const sameWork = current?.kind === kind && current.promptId === nextPromptId;
563
+ if (sameWork)
395
564
  return;
396
565
  this.state.patch(sessionId, {
397
566
  runtime: {
@@ -399,6 +568,7 @@ export class SessionManager {
399
568
  kind: kind,
400
569
  since: current?.kind === kind ? current.since : new Date().toISOString(),
401
570
  promptId: nextPromptId,
571
+ cancelStatus: null,
402
572
  },
403
573
  },
404
574
  });
@@ -417,13 +587,16 @@ export class SessionManager {
417
587
  });
418
588
  this.activePrompts.add(sessionId);
419
589
  this.syncBusy(sessionId);
590
+ const promptId = this.state.getState(sessionId).runtime.busy?.promptId ?? undefined;
420
591
  bridge
421
- .prompt(sessionId, "Continue your previous response — it was interrupted mid-way.")
592
+ .prompt(sessionId, "Continue your previous response — it was interrupted mid-way.", undefined, promptId)
422
593
  .catch((err) => {
423
594
  slog.error("auto-retry failed", {
424
595
  sessionId: sessionId.slice(0, 8) + "…",
425
596
  error: err,
426
597
  });
598
+ if (!this.isCurrentPrompt(sessionId, promptId))
599
+ return;
427
600
  this.activePrompts.delete(sessionId);
428
601
  this.syncBusy(sessionId);
429
602
  });