@lelouchhe/webagent 0.8.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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * File viewer size limits.
3
+ *
4
+ * Kept as plain exported constants for now so the routes have a single,
5
+ * testable knob; wiring these into `[limits]` config is a later milestone.
6
+ * Values stay in the same order of magnitude as attachment limits and keep
7
+ * rendered previews bounded. Files outside preview limits stream as downloads
8
+ * instead of being buffered in server or browser memory.
9
+ */
10
+ /** Directory listings cap — beyond this the response is truncated + flagged. */
11
+ export const MAX_LIST_ITEMS = 2000;
12
+ /** Text/Markdown/code preview + highlight cap; larger files download. */
13
+ export const MAX_TEXT_PREVIEW_BYTES = 1024 * 1024; // 1 MiB
14
+ /** Image render cap — larger images stream as downloads. */
15
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Path resolution and directory listing helpers for the file viewer.
3
+ *
4
+ * Contract (confirmed design): the viewer accepts *absolute* paths or `~`
5
+ * prefixes only — relative paths are rejected outright so there is no base
6
+ * ambiguity to exploit. `~` expands to HOME, then `realpath` canonicalizes
7
+ * symlinks and `..` segments once; every path on the system is legal by
8
+ * design (single-owner personal tool), so there is deliberately no escape
9
+ * rejection logic — the guards here prevent blocking on special files,
10
+ * unbounded directory scans, and whole-file buffering.
11
+ */
12
+ import { constants } from "node:fs";
13
+ import { open, opendir, realpath, stat, } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { isAbsolute, join, basename } from "node:path";
16
+ import { expandHomePath } from "../home-path.js";
17
+ import { MAX_LIST_ITEMS } from "./limits.js";
18
+ /** HTTP status-backed error; routes map it to a JSON error response. */
19
+ export class FilePathError extends Error {
20
+ status;
21
+ constructor(status, message) {
22
+ super(message);
23
+ this.status = status;
24
+ }
25
+ }
26
+ /**
27
+ * Expand a user-supplied path string into an absolute path.
28
+ * Accepts `/absolute/path` and `~/...` / `~`. Everything else (including
29
+ * `~user` and bare relative paths) is rejected with 400.
30
+ */
31
+ export function expandPath(raw, home = homedir()) {
32
+ if (raw.length === 0)
33
+ throw new FilePathError(400, "Missing path");
34
+ if (raw.includes("\0"))
35
+ throw new FilePathError(400, "Invalid path");
36
+ const expanded = expandHomePath(raw, home);
37
+ if (raw.startsWith("~") && expanded === raw) {
38
+ throw new FilePathError(400, "Unsupported ~user expansion");
39
+ }
40
+ if (!isAbsolute(expanded)) {
41
+ throw new FilePathError(400, "Path must be absolute or start with ~");
42
+ }
43
+ return expanded;
44
+ }
45
+ /** realpath canonicalization; missing paths map to 404. */
46
+ export async function canonicalize(target) {
47
+ try {
48
+ return await realpath(target);
49
+ }
50
+ catch (err) {
51
+ if (err.code === "ENOENT") {
52
+ throw new FilePathError(404, "Path does not exist");
53
+ }
54
+ throw err;
55
+ }
56
+ }
57
+ /** stat a canonical path; non-regular files (fifo/socket/device) → 400. */
58
+ export async function statMeta(path) {
59
+ const s = await stat(path);
60
+ let kind;
61
+ if (s.isFile())
62
+ kind = "file";
63
+ else if (s.isDirectory())
64
+ kind = "dir";
65
+ else
66
+ throw new FilePathError(400, "Not a regular file or directory");
67
+ return {
68
+ path,
69
+ name: basename(path),
70
+ kind,
71
+ size: s.size,
72
+ mtime: s.mtimeMs,
73
+ };
74
+ }
75
+ /**
76
+ * List one directory: scan at most MAX_LIST_ITEMS plus one sentinel raw entry,
77
+ * omit dotfiles/special nodes, then sort the bounded result dirs-first and
78
+ * lexicographically. Entries that vanish mid-list are skipped.
79
+ */
80
+ export async function listDirectory(target) {
81
+ const dir = await opendir(target);
82
+ const entries = [];
83
+ let scanned = 0;
84
+ let truncated = false;
85
+ // Dir's async iterator closes the descriptor both at EOF and on break.
86
+ for await (const entry of dir) {
87
+ scanned++;
88
+ // Read one sentinel beyond the cap so `truncated` is authoritative,
89
+ // but never materialize/sort an unbounded directory.
90
+ if (scanned > MAX_LIST_ITEMS) {
91
+ truncated = true;
92
+ break;
93
+ }
94
+ if (entry.name.startsWith("."))
95
+ continue;
96
+ try {
97
+ const s = await stat(join(target, entry.name));
98
+ // Only expose targets the viewer can actually open. Symlinks to
99
+ // regular files/dirs pass because stat follows them; fifos, sockets,
100
+ // and devices are omitted rather than mislabelled as files.
101
+ if (!s.isDirectory() && !s.isFile())
102
+ continue;
103
+ entries.push({
104
+ name: entry.name,
105
+ kind: s.isDirectory() ? "dir" : "file",
106
+ size: s.isFile() ? s.size : null,
107
+ mtime: s.mtimeMs,
108
+ });
109
+ }
110
+ catch {
111
+ // Raced deletion — skip rather than fail the whole listing.
112
+ }
113
+ }
114
+ entries.sort(compareEntries);
115
+ return { entries, truncated };
116
+ }
117
+ function compareEntries(a, b) {
118
+ if (a.kind !== b.kind)
119
+ return a.kind === "dir" ? -1 : 1;
120
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
121
+ }
122
+ /** Read up to `n` bytes from the head of a regular file (mime sniffing). */
123
+ export async function readHead(file, n = 4096) {
124
+ const h = await open(file, "r");
125
+ try {
126
+ const st = await h.stat();
127
+ const len = Math.min(st.size, n);
128
+ const buf = Buffer.alloc(len);
129
+ if (len > 0)
130
+ await h.read(buf, 0, len, 0);
131
+ return buf;
132
+ }
133
+ finally {
134
+ await h.close();
135
+ }
136
+ }
137
+ /**
138
+ * Open without following a final symlink and without blocking on a FIFO.
139
+ * Windows lacks equivalent POSIX flags; fstat below remains authoritative.
140
+ */
141
+ export function openForStreaming(file) {
142
+ const safeFlags = process.platform === "win32"
143
+ ? constants.O_RDONLY
144
+ : constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
145
+ return open(file, safeFlags);
146
+ }
147
+ /** Read mime-sniff bytes from an already validated descriptor. */
148
+ export async function readHandleHead(handle, size, n = 4096) {
149
+ const len = Math.min(size, n);
150
+ const buf = Buffer.alloc(len);
151
+ if (len === 0)
152
+ return buf;
153
+ const { bytesRead } = await handle.read(buf, 0, len, 0);
154
+ return buf.subarray(0, bytesRead);
155
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * File viewer HTTP routes — read-only access to arbitrary local files.
3
+ *
4
+ * URL space claimed: `/api/v1/files/{info,list,content}`.
5
+ * Sessionless by design (confirmed): the caller passes an absolute or
6
+ * `~`-prefixed path; the server own `~` expansion + realpath canonicalization.
7
+ * Relative paths are rejected. Bearer auth is enforced by the shared
8
+ * `/api/**` gate in routes.ts — these paths are deliberately NOT in the
9
+ * public whitelist, with one exception: `content` IS whitelisted so
10
+ * `<img>` / `<a download>` can fetch without an Authorization header, and
11
+ * it instead requires an HMAC sig+exp signed URL (issued by `info`),
12
+ * mirroring the attachment scheme.
13
+ *
14
+ * Guards (see paths.ts + limits.ts): only regular files/dirs are served
15
+ * (no fifos/sockets/devices), directory scans and inline previews are bounded,
16
+ * downloads stream with backpressure, and responses carry nosniff + a
17
+ * restrictive CSP. The viewer renders text/markdown/image only — nothing here
18
+ * executes content.
19
+ */
20
+ import { basename, dirname } from "node:path";
21
+ import { pipeline } from "node:stream/promises";
22
+ import { signAttachmentUrl, verifyAttachmentSig } from "../auth.js";
23
+ import { buildContentDisposition, sniffMime } from "../attachments.js";
24
+ import { HTTP_STATUS } from "../http-status.js";
25
+ import { abbreviateHomePath } from "../home-path.js";
26
+ import { log } from "../log.js";
27
+ import { MAX_IMAGE_BYTES, MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
28
+ import { canonicalize, expandPath, FilePathError, listDirectory, openForStreaming, readHandleHead, readHead, statMeta, } from "./paths.js";
29
+ const flog = log.scope("files");
30
+ const CONTENT_TTL_SECONDS = 3600; // signed URLs live 1h, like attachments
31
+ const SNIFF_BYTES = 4096;
32
+ function fileSystemError(err) {
33
+ const code = err?.code;
34
+ if (code === "EACCES" || code === "EPERM") {
35
+ return new FilePathError(HTTP_STATUS.FORBIDDEN, "Permission denied");
36
+ }
37
+ if (code === "ENOENT" || code === "ENOTDIR") {
38
+ return new FilePathError(HTTP_STATUS.NOT_FOUND, "Path does not exist");
39
+ }
40
+ if (code === "ELOOP" ||
41
+ code === "EINVAL" ||
42
+ code === "ENAMETOOLONG" ||
43
+ code === "ERR_INVALID_ARG_VALUE") {
44
+ return new FilePathError(HTTP_STATUS.BAD_REQUEST, "Invalid path");
45
+ }
46
+ return null;
47
+ }
48
+ function json(res, status, body) {
49
+ res.writeHead(status, {
50
+ "Content-Type": "application/json",
51
+ "Cache-Control": "no-store",
52
+ });
53
+ res.end(JSON.stringify(body));
54
+ }
55
+ function previewLimitFor(mime) {
56
+ const m = mime.toLowerCase();
57
+ if (m.startsWith("image/"))
58
+ return MAX_IMAGE_BYTES;
59
+ if (m.startsWith("text/"))
60
+ return MAX_TEXT_PREVIEW_BYTES;
61
+ return null;
62
+ }
63
+ /** Expand + canonicalize a caller-supplied path; 400 on empty/relative. */
64
+ async function resolvePath(raw) {
65
+ if (!raw)
66
+ throw new FilePathError(400, "Missing path");
67
+ return canonicalize(expandPath(raw));
68
+ }
69
+ function contentBasePath(pathRaw) {
70
+ return `/api/v1/files/content?path=${encodeURIComponent(pathRaw)}`;
71
+ }
72
+ async function streamHandle(res, handle, size) {
73
+ const stream = handle.createReadStream({
74
+ autoClose: false,
75
+ start: 0,
76
+ ...(size > 0 ? { end: size - 1 } : {}),
77
+ });
78
+ try {
79
+ await pipeline(stream, res);
80
+ }
81
+ catch (err) {
82
+ // Headers are already committed. A client disconnect or disk read error
83
+ // must terminate the stream, never fall through to a second JSON response.
84
+ if (!res.destroyed) {
85
+ res.destroy(err instanceof Error ? err : undefined);
86
+ }
87
+ }
88
+ }
89
+ export async function handleFileRoutes(req, res, deps) {
90
+ const url = req.url ?? "/";
91
+ if (!url.startsWith("/api/v1/files"))
92
+ return false;
93
+ const m = url.match(/^\/api\/v1\/files\/(info|list|content)(?:\?(.*))?$/);
94
+ if (!m)
95
+ return false;
96
+ const method = req.method ?? "GET";
97
+ if (method !== "GET") {
98
+ res.setHeader("Allow", "GET");
99
+ json(res, HTTP_STATUS.METHOD_NOT_ALLOWED, {
100
+ error: "Read-only: GET only",
101
+ });
102
+ return true;
103
+ }
104
+ try {
105
+ const params = new URLSearchParams(m[2]);
106
+ const pathRaw = params.get("path") ?? "";
107
+ switch (m[1]) {
108
+ case "info":
109
+ await handleInfo(res, deps, pathRaw);
110
+ return true;
111
+ case "list":
112
+ await handleList(res, pathRaw);
113
+ return true;
114
+ case "content":
115
+ await handleContent(res, deps, pathRaw, params);
116
+ return true;
117
+ }
118
+ }
119
+ catch (err) {
120
+ if (err instanceof FilePathError) {
121
+ json(res, err.status, { error: err.message });
122
+ return true;
123
+ }
124
+ const fsError = fileSystemError(err);
125
+ if (fsError) {
126
+ json(res, fsError.status, { error: fsError.message });
127
+ return true;
128
+ }
129
+ flog.error("file route failed", { url, error: String(err) });
130
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "internal_error" });
131
+ return true;
132
+ }
133
+ return false;
134
+ }
135
+ async function handleInfo(res, deps, pathRaw) {
136
+ const canonical = await resolvePath(pathRaw);
137
+ const meta = await statMeta(canonical);
138
+ const out = {
139
+ path: meta.path,
140
+ pathDisplay: abbreviateHomePath(meta.path),
141
+ name: meta.name,
142
+ kind: meta.kind,
143
+ size: meta.size,
144
+ mtime: meta.mtime,
145
+ };
146
+ if (meta.kind === "file") {
147
+ const mime = await sniffMime(await readHead(canonical, SNIFF_BYTES));
148
+ const previewLimit = previewLimitFor(mime);
149
+ out.mime = mime;
150
+ if (previewLimit !== null)
151
+ out.maxBytes = previewLimit;
152
+ if (deps.secret) {
153
+ const basePath = contentBasePath(canonical);
154
+ out.contentUrl = `${basePath}&${signAttachmentUrl(basePath, deps.secret, CONTENT_TTL_SECONDS)}`;
155
+ }
156
+ }
157
+ json(res, HTTP_STATUS.OK, out);
158
+ }
159
+ async function handleList(res, pathRaw) {
160
+ const canonical = await resolvePath(pathRaw);
161
+ const meta = await statMeta(canonical);
162
+ if (meta.kind !== "dir") {
163
+ throw new FilePathError(HTTP_STATUS.BAD_REQUEST, "Not a directory");
164
+ }
165
+ const { entries, truncated } = await listDirectory(canonical);
166
+ const parent = dirname(canonical);
167
+ json(res, HTTP_STATUS.OK, {
168
+ path: canonical,
169
+ pathDisplay: abbreviateHomePath(canonical),
170
+ parent,
171
+ parentDisplay: abbreviateHomePath(parent),
172
+ truncated,
173
+ entries,
174
+ });
175
+ }
176
+ async function handleContent(res, deps, pathRaw, params) {
177
+ const basePath = contentBasePath(pathRaw);
178
+ const sig = params.get("sig") ?? "";
179
+ const exp = params.get("exp") ?? "";
180
+ // content is whitelisted in auth-middleware.ts (media tags / downloads
181
+ // cannot send Authorization headers), so it must verify its own URL.
182
+ // Unlike the older attachment route, this new security-sensitive route
183
+ // fails closed when no signing secret is wired.
184
+ if (!deps.secret) {
185
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
186
+ error: "file_content_signing_unavailable",
187
+ });
188
+ return;
189
+ }
190
+ if (!sig || !exp || !verifyAttachmentSig(basePath, exp, sig, deps.secret)) {
191
+ res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
192
+ "Content-Type": "application/json",
193
+ });
194
+ res.end(JSON.stringify({ error: "Unauthorized" }));
195
+ return;
196
+ }
197
+ const canonical = await resolvePath(pathRaw);
198
+ // `info` signs the realpath-canonical string. If that path now resolves to
199
+ // another target (for example it was replaced by a symlink), the old
200
+ // capability must not silently acquire authority over the new target.
201
+ if (canonical !== pathRaw) {
202
+ json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
203
+ return;
204
+ }
205
+ const handle = await openForStreaming(canonical);
206
+ try {
207
+ const stats = await handle.stat();
208
+ if (!stats.isFile()) {
209
+ throw new FilePathError(HTTP_STATUS.BAD_REQUEST, "Not a regular file");
210
+ }
211
+ const mime = await sniffMime(await readHandleHead(handle, stats.size, SNIFF_BYTES));
212
+ const previewLimit = previewLimitFor(mime);
213
+ const disposition = previewLimit !== null && stats.size <= previewLimit
214
+ ? "inline"
215
+ : "attachment";
216
+ res.writeHead(HTTP_STATUS.OK, {
217
+ "Content-Type": mime,
218
+ "X-Content-Type-Options": "nosniff",
219
+ // Ordinary project files change in place; never let reopening show a
220
+ // cached pre-edit body under the same path-bound signed URL.
221
+ "Cache-Control": "no-store",
222
+ // Belt-and-braces: even if a mime mis-sniff ever lets a browser
223
+ // interpret this body as HTML, it can't load any subresource.
224
+ "Content-Security-Policy": "default-src 'none'",
225
+ "Content-Disposition": buildContentDisposition(disposition, basename(canonical)),
226
+ });
227
+ await streamHandle(res, handle, stats.size);
228
+ }
229
+ finally {
230
+ await handle.close().catch(() => { });
231
+ }
232
+ }
@@ -0,0 +1,35 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join, relative, sep } from "node:path";
3
+ const NATIVE_PATH = { isAbsolute, join, relative, sep };
4
+ function portableDisplayPath(input, path) {
5
+ return path.sep === "\\" ? input.replace(/\\/g, "/") : input;
6
+ }
7
+ /**
8
+ * Expand the current user's HOME shorthand with one authoritative grammar.
9
+ * Both `~/` and `~\` are accepted; named-user forms stay untouched.
10
+ */
11
+ export function expandHomePath(input, home = homedir(), path = NATIVE_PATH) {
12
+ if (input === "~")
13
+ return home;
14
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
15
+ const tail = input.slice(2).replace(/[\\/]/g, path.sep);
16
+ return path.join(home, tail);
17
+ }
18
+ return input;
19
+ }
20
+ /**
21
+ * Abbreviate HOME and normalize Windows display paths to portable `/`
22
+ * separators. Canonical filesystem paths remain native and are stored/signed
23
+ * separately; this function is exclusively for UI round-tripping.
24
+ */
25
+ export function abbreviateHomePath(input, home = homedir(), path = NATIVE_PATH) {
26
+ const relativePath = path.relative(home, input);
27
+ if (relativePath === "")
28
+ return "~";
29
+ if (relativePath === ".." ||
30
+ relativePath.startsWith(`..${path.sep}`) ||
31
+ path.isAbsolute(relativePath)) {
32
+ return portableDisplayPath(input, path);
33
+ }
34
+ return `~/${portableDisplayPath(relativePath, path)}`;
35
+ }
@@ -7,6 +7,7 @@ export const HTTP_STATUS = Object.freeze({
7
7
  UNAUTHORIZED: 401,
8
8
  FORBIDDEN: 403,
9
9
  NOT_FOUND: 404,
10
+ METHOD_NOT_ALLOWED: 405,
10
11
  CONFLICT: 409,
11
12
  GONE: 410,
12
13
  PAYLOAD_TOO_LARGE: 413,
package/lib/routes.js CHANGED
@@ -8,9 +8,11 @@ import { interruptBashProc, InvalidSessionDirectoryError, } from "./session-mana
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { createWriteStream } from "node:fs";
10
10
  import { handleShareRoutes } from "./share/routes.js";
11
+ import { handleFileRoutes } from "./files/routes.js";
11
12
  import { authenticate, isWhitelistedPath } from "./auth-middleware.js";
12
13
  import { enrichStoredEventsForDisplay } from "./attachment-labels.js";
13
14
  import { agentCommandToken, resolveAgentCommand } from "./agent-commands.js";
15
+ import { abbreviateHomePath } from "./home-path.js";
14
16
  import { log } from "./log.js";
15
17
  const rlog = log.scope("routes");
16
18
  const plog = rlog.scope("prompt");
@@ -403,6 +405,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
403
405
  }
404
406
  export function createRequestHandler(deps) {
405
407
  const { store, sessions, getBridge, sseManager, titleService } = deps;
408
+ let bootstrapSessionPromise = null;
406
409
  // eslint-disable-next-line complexity -- TODO: refactor main route handler into smaller handlers
407
410
  return async (req, res) => {
408
411
  const url = req.url ?? "/";
@@ -440,6 +443,14 @@ export function createRequestHandler(deps) {
440
443
  }))) {
441
444
  return;
442
445
  }
446
+ // File viewer — sessionless read-only access to arbitrary local paths.
447
+ // Claims /api/v1/files/{info,list,content} before the generic /api/v1
448
+ // branch. info/list use the Bearer gate above; content is whitelisted
449
+ // only because its handler requires an HMAC-signed URL for headerless
450
+ // media/download fetches (see src/files/routes.ts).
451
+ if (await handleFileRoutes(req, res, { secret: deps.attachmentSecret })) {
452
+ return;
453
+ }
443
454
  // --- API routes ---
444
455
  if (url === "/api/v1" || url.startsWith("/api/v1/")) {
445
456
  res.setHeader("Content-Type", "application/json");
@@ -450,6 +461,7 @@ export function createRequestHandler(deps) {
450
461
  endpoints: {
451
462
  sessions: "/api/v1/sessions",
452
463
  paths: "/api/v1/recent-paths",
464
+ files: "/api/v1/files",
453
465
  config: "/api/v1/config",
454
466
  events_stream: "/api/v1/events/stream",
455
467
  prompt: "/api/beta/prompt",
@@ -487,7 +499,10 @@ export function createRequestHandler(deps) {
487
499
  limit: isNaN(limit) ? 0 : limit,
488
500
  ttlDays,
489
501
  });
490
- json(res, HTTP_STATUS.OK, paths);
502
+ json(res, HTTP_STATUS.OK, paths.map((entry) => ({
503
+ ...entry,
504
+ cwdDisplay: abbreviateHomePath(entry.cwd),
505
+ })));
491
506
  return;
492
507
  }
493
508
  // GET /api/v1/version
@@ -888,6 +903,7 @@ export function createRequestHandler(deps) {
888
903
  id: session.id,
889
904
  title: session.title,
890
905
  cwd: session.cwd,
906
+ cwdDisplay: abbreviateHomePath(session.cwd),
891
907
  model: session.model,
892
908
  mode: session.mode,
893
909
  createdAt: session.created_at,
@@ -1149,6 +1165,11 @@ export function createRequestHandler(deps) {
1149
1165
  },
1150
1166
  ];
1151
1167
  });
1168
+ // A background task can trigger unsolicited Main-agent output after
1169
+ // the foreground ACP prompt has ended. Its chunks remain buffered
1170
+ // until a real protocol boundary arrives; seal them before this user
1171
+ // row so they cannot merge into the next turn's assistant response.
1172
+ sessions.flushBuffers(sessionId);
1152
1173
  const eventClientOpId = opId ?? randomUUID();
1153
1174
  store.saveEvent(sessionId, "user_message", {
1154
1175
  text: body.text,
@@ -1429,6 +1450,76 @@ export function createRequestHandler(deps) {
1429
1450
  json(res, HTTP_STATUS.OK, { title: body.value });
1430
1451
  return;
1431
1452
  }
1453
+ // POST /api/v1/sessions/bootstrap — atomically return the current
1454
+ // agent's latest session, creating one only when none exists.
1455
+ if (url === "/api/v1/sessions/bootstrap" && req.method === "POST") {
1456
+ const bridge = getBridge?.();
1457
+ if (!bridge) {
1458
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1459
+ error: "Agent not ready yet",
1460
+ });
1461
+ return;
1462
+ }
1463
+ if (!sessions) {
1464
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1465
+ error: "Session manager not available",
1466
+ });
1467
+ return;
1468
+ }
1469
+ const sessionManager = sessions;
1470
+ bootstrapSessionPromise ??= (async () => {
1471
+ const existing = store.listSessions().at(0);
1472
+ if (existing) {
1473
+ return {
1474
+ id: existing.id,
1475
+ cwd: existing.cwd,
1476
+ cwdDisplay: abbreviateHomePath(existing.cwd),
1477
+ title: existing.title,
1478
+ source: existing.source,
1479
+ configOptions: [],
1480
+ agentCommands: sessionManager.getAgentCommands(existing.id),
1481
+ created: false,
1482
+ };
1483
+ }
1484
+ const { sessionId, configOptions } = await sessionManager.createSession(bridge);
1485
+ const session = store.getSession(sessionId);
1486
+ const result = {
1487
+ id: sessionId,
1488
+ cwd: session?.cwd ?? deps.dataDir,
1489
+ cwdDisplay: abbreviateHomePath(session?.cwd ?? deps.dataDir),
1490
+ title: session?.title ?? null,
1491
+ source: session?.source ?? "auto",
1492
+ configOptions,
1493
+ agentCommands: sessionManager.getAgentCommands(sessionId),
1494
+ created: true,
1495
+ };
1496
+ sseManager.broadcast({
1497
+ type: "session_created",
1498
+ sessionId,
1499
+ cwd: result.cwd,
1500
+ cwdDisplay: result.cwdDisplay,
1501
+ title: result.title,
1502
+ configOptions,
1503
+ agentCommands: result.agentCommands,
1504
+ });
1505
+ return result;
1506
+ })().finally(() => {
1507
+ bootstrapSessionPromise = null;
1508
+ });
1509
+ try {
1510
+ const result = await bootstrapSessionPromise;
1511
+ json(res, HTTP_STATUS.OK, {
1512
+ ...result,
1513
+ clientOpId: getClientOpId(req) ?? undefined,
1514
+ });
1515
+ }
1516
+ catch (err) {
1517
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
1518
+ error: err instanceof Error ? err.message : String(err),
1519
+ });
1520
+ }
1521
+ return;
1522
+ }
1432
1523
  // --- Session CRUD: /api/v1/sessions/:id ---
1433
1524
  const sessionIdMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/?(\?.*)?$/);
1434
1525
  if (sessionIdMatch) {
@@ -1479,33 +1570,12 @@ export function createRequestHandler(deps) {
1479
1570
  });
1480
1571
  })
1481
1572
  .catch(() => { });
1482
- // Auto-retry if the last turn was interrupted (must wait for resume)
1483
- const hasInterrupted = store.hasInterruptedTurn(sessionId);
1484
- if (hasInterrupted) {
1485
- // Optimistically mark busy so concurrent POST sees the session as active
1486
- sessions.activePrompts.add(sessionId);
1487
- sessions.syncBusy(sessionId);
1488
- void resumePromise
1489
- .then(() => {
1490
- if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
1491
- // Retry not needed after all — release the optimistic lock
1492
- sessions.activePrompts.delete(sessionId);
1493
- sessions.syncBusy(sessionId);
1494
- }
1495
- })
1496
- .catch(() => {
1497
- sessions.activePrompts.delete(sessionId);
1498
- sessions.syncBusy(sessionId);
1499
- });
1500
- }
1501
- else {
1502
- resumePromise.catch((err) => {
1503
- slog.error("background resume failed", {
1504
- sessionId: sessionId.slice(0, 8) + "…",
1505
- error: err,
1506
- });
1573
+ resumePromise.catch((err) => {
1574
+ slog.error("background resume failed", {
1575
+ sessionId: sessionId.slice(0, 8) + "…",
1576
+ error: err,
1507
1577
  });
1508
- }
1578
+ });
1509
1579
  }
1510
1580
  }
1511
1581
  // If cache is cold and we kicked off a resume, wait briefly so the
@@ -1547,6 +1617,7 @@ export function createRequestHandler(deps) {
1547
1617
  json(res, HTTP_STATUS.OK, {
1548
1618
  id: freshSession.id,
1549
1619
  cwd: freshSession.cwd,
1620
+ cwdDisplay: abbreviateHomePath(freshSession.cwd),
1550
1621
  title: freshSession.title,
1551
1622
  source: freshSession.source,
1552
1623
  model: freshSession.model,
@@ -1612,6 +1683,9 @@ export function createRequestHandler(deps) {
1612
1683
  type: "session_created",
1613
1684
  sessionId,
1614
1685
  cwd: session?.cwd,
1686
+ cwdDisplay: session?.cwd
1687
+ ? abbreviateHomePath(session.cwd)
1688
+ : undefined,
1615
1689
  title: session?.title,
1616
1690
  configOptions,
1617
1691
  agentCommands: sessions.getAgentCommands(sessionId),
@@ -1630,6 +1704,9 @@ export function createRequestHandler(deps) {
1630
1704
  json(res, HTTP_STATUS.CREATED, {
1631
1705
  id: sessionId,
1632
1706
  cwd: session?.cwd ?? body.cwd,
1707
+ cwdDisplay: session?.cwd
1708
+ ? abbreviateHomePath(session.cwd)
1709
+ : undefined,
1633
1710
  title: session?.title ?? null,
1634
1711
  source: session?.source ?? source,
1635
1712
  configOptions,
@@ -2185,8 +2262,20 @@ export function createRequestHandler(deps) {
2185
2262
  return;
2186
2263
  }
2187
2264
  const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
2188
- const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
2265
+ const { sessionId, configOptions } = await sessions.createSession(bridge, cwd, undefined, "auto");
2189
2266
  const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
2267
+ const session = store.getSession(sessionId);
2268
+ sseManager.broadcast({
2269
+ type: "session_created",
2270
+ sessionId,
2271
+ cwd: session?.cwd,
2272
+ cwdDisplay: session?.cwd
2273
+ ? abbreviateHomePath(session.cwd)
2274
+ : undefined,
2275
+ title: session?.title,
2276
+ configOptions,
2277
+ agentCommands: sessions.getAgentCommands(sessionId),
2278
+ });
2190
2279
  json(res, HTTP_STATUS.ACCEPTED, { sessionId, streamUrl });
2191
2280
  // Fire-and-forget: send the prompt asynchronously, tracking busy state
2192
2281
  sessions.activePrompts.add(sessionId);