@roamcode.ai/server 1.0.0 → 1.0.1

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 (99) hide show
  1. package/dist/index.d.ts +2406 -82
  2. package/dist/index.js +8314 -44
  3. package/dist/managed-update-helper.js +621 -26
  4. package/dist/mcp-send.js +76 -90
  5. package/dist/start.d.ts +591 -9
  6. package/dist/start.js +7629 -353
  7. package/package.json +3 -3
  8. package/dist/auth.d.ts +0 -63
  9. package/dist/auth.js +0 -133
  10. package/dist/claude-auth-service.d.ts +0 -76
  11. package/dist/claude-auth-service.js +0 -217
  12. package/dist/claude-latest-service.d.ts +0 -34
  13. package/dist/claude-latest-service.js +0 -61
  14. package/dist/config.d.ts +0 -78
  15. package/dist/config.js +0 -59
  16. package/dist/data-dir.d.ts +0 -42
  17. package/dist/data-dir.js +0 -70
  18. package/dist/diag.d.ts +0 -43
  19. package/dist/diag.js +0 -83
  20. package/dist/fs-service.d.ts +0 -90
  21. package/dist/fs-service.js +0 -290
  22. package/dist/managed-runtime.d.ts +0 -51
  23. package/dist/managed-runtime.js +0 -411
  24. package/dist/managed-update-helper.d.ts +0 -2
  25. package/dist/mcp-send.d.ts +0 -33
  26. package/dist/origin-check.d.ts +0 -37
  27. package/dist/origin-check.js +0 -101
  28. package/dist/pane-status.d.ts +0 -61
  29. package/dist/pane-status.js +0 -145
  30. package/dist/providers/claude-metadata-service.d.ts +0 -58
  31. package/dist/providers/claude-metadata-service.js +0 -352
  32. package/dist/providers/claude-provider.d.ts +0 -11
  33. package/dist/providers/claude-provider.js +0 -166
  34. package/dist/providers/codex-activity.d.ts +0 -21
  35. package/dist/providers/codex-activity.js +0 -122
  36. package/dist/providers/codex-app-server-client.d.ts +0 -90
  37. package/dist/providers/codex-app-server-client.js +0 -485
  38. package/dist/providers/codex-latest-service.d.ts +0 -50
  39. package/dist/providers/codex-latest-service.js +0 -174
  40. package/dist/providers/codex-metadata-service.d.ts +0 -161
  41. package/dist/providers/codex-metadata-service.js +0 -686
  42. package/dist/providers/codex-profile-client.d.ts +0 -16
  43. package/dist/providers/codex-profile-client.js +0 -52
  44. package/dist/providers/codex-profile-security.d.ts +0 -23
  45. package/dist/providers/codex-profile-security.js +0 -161
  46. package/dist/providers/codex-provider.d.ts +0 -15
  47. package/dist/providers/codex-provider.js +0 -174
  48. package/dist/providers/codex-thread-coordinator.d.ts +0 -18
  49. package/dist/providers/codex-thread-coordinator.js +0 -93
  50. package/dist/providers/codex-thread-persistence.d.ts +0 -9
  51. package/dist/providers/codex-thread-persistence.js +0 -45
  52. package/dist/providers/codex-thread-resolver.d.ts +0 -59
  53. package/dist/providers/codex-thread-resolver.js +0 -322
  54. package/dist/providers/options.d.ts +0 -7
  55. package/dist/providers/options.js +0 -155
  56. package/dist/providers/provider-artifacts.d.ts +0 -3
  57. package/dist/providers/provider-artifacts.js +0 -30
  58. package/dist/providers/registry.d.ts +0 -7
  59. package/dist/providers/registry.js +0 -23
  60. package/dist/providers/types.d.ts +0 -95
  61. package/dist/providers/types.js +0 -8
  62. package/dist/push-dispatch.d.ts +0 -81
  63. package/dist/push-dispatch.js +0 -100
  64. package/dist/push-store.d.ts +0 -25
  65. package/dist/push-store.js +0 -79
  66. package/dist/rate-limit.d.ts +0 -52
  67. package/dist/rate-limit.js +0 -72
  68. package/dist/server-config.d.ts +0 -60
  69. package/dist/server-config.js +0 -77
  70. package/dist/service-install.d.ts +0 -60
  71. package/dist/service-install.js +0 -221
  72. package/dist/session-defaults.d.ts +0 -26
  73. package/dist/session-defaults.js +0 -60
  74. package/dist/session-store.d.ts +0 -81
  75. package/dist/session-store.js +0 -654
  76. package/dist/static-routes.d.ts +0 -101
  77. package/dist/static-routes.js +0 -188
  78. package/dist/terminal-capability.d.ts +0 -5
  79. package/dist/terminal-capability.js +0 -27
  80. package/dist/terminal-manager.d.ts +0 -224
  81. package/dist/terminal-manager.js +0 -917
  82. package/dist/terminal-process.d.ts +0 -85
  83. package/dist/terminal-process.js +0 -238
  84. package/dist/terminal-shared.d.ts +0 -36
  85. package/dist/terminal-shared.js +0 -43
  86. package/dist/tmux-list.d.ts +0 -11
  87. package/dist/tmux-list.js +0 -39
  88. package/dist/transport.d.ts +0 -123
  89. package/dist/transport.js +0 -1559
  90. package/dist/updater.d.ts +0 -161
  91. package/dist/updater.js +0 -451
  92. package/dist/usage-service.d.ts +0 -118
  93. package/dist/usage-service.js +0 -173
  94. package/dist/vapid.d.ts +0 -17
  95. package/dist/vapid.js +0 -31
  96. package/dist/web-push-send.d.ts +0 -20
  97. package/dist/web-push-send.js +0 -21
  98. package/dist/ws-ticket.d.ts +0 -47
  99. package/dist/ws-ticket.js +0 -62
@@ -1,917 +0,0 @@
1
- // packages/server/src/terminal-manager.ts
2
- import { unlinkSync, readdirSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { TerminalProcess, tmuxSessionName, TMUX_SOCKET } from "./terminal-process.js";
5
- import { capturePane } from "./pane-status.js";
6
- import { CODEX_MCP_TOKEN_PREFIX } from "./config.js";
7
- import { parseLegacyClaudeArgs } from "./providers/options.js";
8
- import { ProviderRegistry } from "./providers/registry.js";
9
- import { ProviderError, } from "./providers/types.js";
10
- import { createCodexThreadPersistence } from "./providers/codex-thread-persistence.js";
11
- import { codexThreadResolutionCoordinator } from "./providers/codex-thread-coordinator.js";
12
- /** Cap the per-session replay buffer of attachment frames so a long-lived session can't grow unbounded. */
13
- const MAX_ATTACHMENT_BUFFER = 50;
14
- const clampDim = (n, fallback) => Math.max(1, Math.trunc(n ?? fallback) || fallback);
15
- function claudeArgsOf(options) {
16
- const args = [];
17
- if (options.model)
18
- args.push("--model", options.model);
19
- if (options.effort)
20
- args.push("--effort", options.effort);
21
- if (options.permissionMode)
22
- args.push("--permission-mode", options.permissionMode);
23
- if (options.dangerouslySkip)
24
- args.push("--dangerously-skip-permissions");
25
- for (const dir of options.addDirs ?? [])
26
- args.push("--add-dir", dir);
27
- args.push(...(options.legacyArgs ?? []));
28
- return args;
29
- }
30
- export class TerminalManager {
31
- deps;
32
- records = new Map();
33
- providers;
34
- attachConfig;
35
- constructor(deps) {
36
- this.deps = deps;
37
- this.providers = deps.providers;
38
- }
39
- /** Late-bound (after listen(), which resolves the loopback port) — same config the chat SessionManager
40
- * gets. When set, each terminal's claude is spawned with `--mcp-config` so send_image/send_file work. */
41
- setAttachConfig(attach) {
42
- this.attachConfig = attach;
43
- }
44
- create(explicit) {
45
- if (explicit.options.provider !== explicit.provider) {
46
- throw new ProviderError("INVALID_PROVIDER_OPTIONS", "provider and options provider must match");
47
- }
48
- this.providers.get(explicit.provider);
49
- if (this.records.has(explicit.id)) {
50
- throw new Error(`Session id ${explicit.id} already exists`);
51
- }
52
- const now = this.deps.now();
53
- const options = explicit.options;
54
- const dangerouslySkip = options.provider === "claude"
55
- ? options.dangerouslySkip === true
56
- : options.dangerouslyBypassApprovalsAndSandbox === true;
57
- const meta = {
58
- id: explicit.id,
59
- provider: explicit.provider,
60
- cwd: explicit.cwd,
61
- mode: "terminal",
62
- status: "running",
63
- createdAt: now,
64
- lastActivityAt: now,
65
- activity: "idle", // the ~2.5s monitor flips it to "working" as soon as claude starts generating
66
- awaiting: false,
67
- dangerouslySkip,
68
- model: options.model,
69
- effort: options.provider === "claude" ? options.effort : options.reasoningEffort,
70
- ...(options.provider === "claude"
71
- ? { permissionMode: options.permissionMode }
72
- : { sandbox: options.sandbox, approvalPolicy: options.approvalPolicy }),
73
- ...(options.provider === "codex" ? { identityState: "pending" } : {}),
74
- };
75
- const common = {
76
- meta,
77
- cols: clampDim(explicit.cols, 80),
78
- rows: clampDim(explicit.rows, 24),
79
- subs: new Set(),
80
- cleanupPaths: new Set(),
81
- attachments: [],
82
- adoptedLive: false,
83
- };
84
- if (options.provider === "claude") {
85
- const spawnArgs = claudeArgsOf(options);
86
- this.deps.store.claimNew({
87
- provider: "claude",
88
- id: explicit.id,
89
- cwd: explicit.cwd,
90
- mode: "terminal",
91
- dangerouslySkip,
92
- status: "running",
93
- createdAt: now,
94
- lastActivityAt: now,
95
- ...(spawnArgs.length > 0 ? { spawnArgs } : {}),
96
- });
97
- const record = { ...common, provider: "claude", options };
98
- this.records.set(explicit.id, record);
99
- }
100
- else {
101
- this.deps.store.claimNew({
102
- provider: "codex",
103
- id: explicit.id,
104
- cwd: explicit.cwd,
105
- mode: "terminal",
106
- launchOptions: options,
107
- status: "running",
108
- createdAt: now,
109
- lastActivityAt: now,
110
- });
111
- const record = { ...common, provider: "codex", options, identityAmbiguous: false };
112
- this.records.set(explicit.id, record);
113
- }
114
- return meta;
115
- }
116
- /** Temporary explicit-Claude seam for the pre-provider transport. */
117
- createLegacyClaude(opts) {
118
- return this.create({
119
- id: opts.id,
120
- cwd: opts.cwd,
121
- provider: "claude",
122
- options: parseLegacyClaudeArgs(opts.claudeArgs ?? []),
123
- ...(opts.cols === undefined ? {} : { cols: opts.cols }),
124
- ...(opts.rows === undefined ? {} : { rows: opts.rows }),
125
- });
126
- }
127
- /**
128
- * Delete stale per-session 0600 files that hold the access token — `mcp-config-<id>.json`, `hooks-<id>.json`,
129
- * `hook-auth-<id>`, and `codex-mcp-token-<id>`. A file is stale when no live session owns its id: leaked by a
130
- * crash, an orphan-reap, a rehydrated record (which carries no such paths, so stop() never unlinks its files),
131
- * or a token rotation. Call at boot AFTER rehydrate + setAttachConfig so `records` reflects the surviving
132
- * sessions. No-op without an attach config.
133
- */
134
- sweepStaleMcpConfigs() {
135
- if (!this.attachConfig)
136
- return 0;
137
- const dir = this.attachConfig.dataDir;
138
- let names;
139
- try {
140
- names = readdirSync(dir);
141
- }
142
- catch {
143
- return 0;
144
- }
145
- const liveIds = new Set(this.records.keys());
146
- let removed = 0;
147
- for (const name of names) {
148
- const m = /^(?:mcp-config-|hooks-)(.+)\.json$/.exec(name) ?? /^(?:hook-auth-)(.+)$/.exec(name);
149
- const sessionId = m?.[1] ?? (name.startsWith(CODEX_MCP_TOKEN_PREFIX) ? name.slice(CODEX_MCP_TOKEN_PREFIX.length) : undefined);
150
- if (!sessionId || liveIds.has(sessionId))
151
- continue;
152
- try {
153
- unlinkSync(join(dir, name));
154
- removed += 1;
155
- }
156
- catch {
157
- /* already gone / race — ignore */
158
- }
159
- }
160
- return removed;
161
- }
162
- /**
163
- * Kill running terminal sessions with NO attached client that have been idle longer than `ttlMs`. OFF by
164
- * default (ttlMs <= 0) and opt-in via env (SESSION_IDLE_TTL_MS): sessions intentionally survive a
165
- * disconnect for later reattach, so reaping them must never be the default — it's only for hosts that
166
- * choose to bound the accumulation of detached claude+tmux processes. Returns the number reaped.
167
- */
168
- reapIdle(ttlMs) {
169
- if (!(ttlMs > 0))
170
- return 0;
171
- const cutoff = this.deps.now() - ttlMs;
172
- const stale = [];
173
- for (const [id, rec] of this.records) {
174
- if (rec.subs.size > 0 || rec.meta.status !== "running")
175
- continue; // attached or already ended → keep
176
- if (rec.meta.lastActivityAt <= cutoff)
177
- stale.push(id);
178
- }
179
- for (const id of stale)
180
- this.stop(id); // stop() kills tmux+claude, unlinks the mcp config, prunes the row
181
- return stale.length;
182
- }
183
- /**
184
- * Push a JSON control message to every attached client of a terminal session. An `attach` (file/image)
185
- * frame is ALSO buffered (bounded, oldest-dropped) so a client that (re)connects later still sees files
186
- * that arrived while it was away — see {@link attach}'s replay. Other control frames (e.g. `ask`, which
187
- * has its own answer/replay flow in the transport) are delivered live only.
188
- */
189
- pushControl(id, msg) {
190
- const rec = this.records.get(id);
191
- if (!rec)
192
- return false;
193
- if (msg.t === "attach") {
194
- rec.attachments.push(msg);
195
- if (rec.attachments.length > MAX_ATTACHMENT_BUFFER)
196
- rec.attachments.shift();
197
- }
198
- const json = JSON.stringify(msg);
199
- for (const s of [...rec.subs]) {
200
- try {
201
- s.onControl?.(json);
202
- }
203
- catch {
204
- /* ignore a bad sink */
205
- }
206
- }
207
- return true;
208
- }
209
- /**
210
- * Set a session's `awaiting` flag ("claude is blocked on YOU"). The capture-pane monitor
211
- * ({@link refreshActivity}) is the authority for this flag in production; this explicit setter exists for
212
- * direct overrides and is exercised by the manager's tests. Deterministic — no timers, no terminal scraping.
213
- * A missing session is a no-op. Does NOT fire the away-from-desk push — the monitor owns that so it can gate
214
- * on {@link isAttached}.
215
- */
216
- setAwaiting(id, value) {
217
- const rec = this.records.get(id);
218
- if (!rec)
219
- return;
220
- rec.meta.awaiting = value;
221
- // Keep `activity` consistent for the instant case (the ask flow calls this the moment claude blocks on a
222
- // question, before the monitor's next sweep): true → "blocked" so the rail shows "needs you" immediately.
223
- // On false we don't know the real state, so leave `activity` for the monitor to re-derive from the pane.
224
- if (value)
225
- rec.meta.activity = "blocked";
226
- }
227
- /** Clear the awaiting flag (user is active / session ended). */
228
- clearAwaiting(rec) {
229
- rec.meta.awaiting = false;
230
- }
231
- /** Whether a session currently has ≥1 attached client (a live browser WS). The hook route uses this so the
232
- * away-from-desk push fires ONLY when nobody is watching. */
233
- isAttached(id) {
234
- return (this.records.get(id)?.subs.size ?? 0) > 0;
235
- }
236
- /** How many sessions are currently `awaiting` you. Threaded into each away-from-desk push as `badgeCount`
237
- * so the home-screen app badge tracks "how many sessions need you" (Android/desktop badge; iOS can't). */
238
- awaitingCount() {
239
- let n = 0;
240
- for (const rec of this.records.values())
241
- if (rec.meta.awaiting)
242
- n += 1;
243
- return n;
244
- }
245
- /**
246
- * Set/clear a session's display name (PATCH /sessions/:id). `undefined` clears back to unnamed. Written
247
- * through to the store so the name survives restarts + rehydrate. Returns false for an unknown id so the
248
- * route can 404 without a separate lookup.
249
- */
250
- setName(id, name) {
251
- const rec = this.records.get(id);
252
- if (!rec)
253
- return false;
254
- rec.meta.name = name;
255
- this.deps.store.setName(id, name);
256
- return true;
257
- }
258
- /**
259
- * Re-derive every RUNNING session's `awaiting` flag from its LIVE rendered tmux pane (`capture-pane`) — the
260
- * single, UNIVERSAL source of truth for working / blocked / idle. Works for EVERY session (no per-session
261
- * hooks needed → correct for sessions created before hooks existed) and works while the browser is DETACHED
262
- * (it reads tmux directly). Called on a ~2.5s timer from start.ts. Read-only: it can never disturb a live
263
- * session.
264
- *
265
- * {@link classifyPaneStatus} decides. A capture that returns "" (tmux hiccup) leaves the last value
266
- * untouched so the status never flaps on a transient miss.
267
- *
268
- * `awaiting` (the loud "needs you" flag) tracks activity==="blocked" ONLY — a finished-but-idle session, or
269
- * one whose background agents are still developing, is NOT "needs you". Fires
270
- * {@link TerminalManagerDeps.onAwaiting} (the away push) on a not-blocked→blocked transition when no client is
271
- * attached, so the nudge is universal + fires only for a genuine block.
272
- */
273
- async refreshActivity() {
274
- const capture = this.deps.capturePane ??
275
- ((name) => capturePane({ socket: this.deps.tmuxSocket ?? TMUX_SOCKET, sessionName: name }));
276
- await Promise.all([...this.records.entries()].map(async ([id, rec]) => {
277
- if (rec.meta.status !== "running")
278
- return;
279
- const pane = await capture(tmuxSessionName(id));
280
- if (!pane)
281
- return; // capture failed/empty → keep the last known value (don't flap on a transient miss)
282
- const provider = this.providers.get(rec.provider);
283
- const activity = provider.classifyPane(pane);
284
- const runtimeMetadata = provider.runtimeMetadata?.(pane);
285
- if (runtimeMetadata?.model)
286
- rec.meta.model = runtimeMetadata.model;
287
- if (runtimeMetadata?.effort)
288
- rec.meta.effort = runtimeMetadata.effort;
289
- const nowBlocked = activity === "blocked";
290
- const wasBlocked = rec.meta.awaiting;
291
- rec.meta.activity = activity;
292
- rec.meta.awaiting = nowBlocked;
293
- if (nowBlocked && !wasBlocked && rec.subs.size === 0) {
294
- try {
295
- this.deps.onAwaiting?.(id);
296
- }
297
- catch {
298
- /* a push failure must never break the monitor */
299
- }
300
- }
301
- }));
302
- }
303
- /**
304
- * Nudge tmux to repaint the WHOLE screen for a session whose pty is already running — used when a client
305
- * REATTACHES (a fresh xterm) to a still-live tmux client that drew its screen earlier and won't redraw on
306
- * its own, so the new client would otherwise show only a blinking cursor until something changes. A brief
307
- * pty size wiggle (+1 row, then back) sends SIGWINCH, which makes tmux redraw — exactly what the manual
308
- * window-resize the user found does, minus the manual part. Deferred so the new client's own initial resize
309
- * lands first (rec.cols/rec.rows are then the final size we wiggle around). Best-effort + unref'd timers.
310
- */
311
- forceRedraw(rec) {
312
- const proc = rec.proc;
313
- if (!proc)
314
- return;
315
- const t = setTimeout(() => {
316
- if (rec.proc !== proc)
317
- return; // detached / respawned in the meantime
318
- proc.resize(rec.cols, Math.max(1, rec.rows + 1)); // wiggle up → SIGWINCH → tmux redraws
319
- const back = setTimeout(() => {
320
- if (rec.proc === proc)
321
- proc.resize(rec.cols, rec.rows); // ...then restore the real viewport size
322
- }, 60);
323
- if (typeof back.unref === "function")
324
- back.unref();
325
- }, 180);
326
- if (typeof t.unref === "function")
327
- t.unref();
328
- }
329
- /** Subscribe to provider output, spawning lazily through the owning provider on the first attach. */
330
- async attach(id, handlers, size, opts) {
331
- const rec = this.records.get(id);
332
- if (!rec || opts?.signal?.aborted)
333
- return undefined;
334
- const resumeConversation = opts?.respawn === "continue" && rec.meta.status === "ended" && !rec.proc;
335
- const provider = this.providers.get(rec.provider);
336
- if (resumeConversation &&
337
- provider.resumeIdentity === "required" &&
338
- (rec.provider !== "codex" || rec.identityAmbiguous || !rec.providerSessionId)) {
339
- throw new ProviderError("RESUME_IDENTITY_UNAVAILABLE", `exact resume identity unavailable for ${rec.provider} session ${id}`);
340
- }
341
- if (size && !rec.proc) {
342
- rec.cols = clampDim(size.cols, rec.cols);
343
- rec.rows = clampDim(size.rows, rec.rows);
344
- }
345
- let abortListenerAttached = false;
346
- let detached = false;
347
- const releaseAbortListener = () => {
348
- if (!abortListenerAttached)
349
- return;
350
- abortListenerAttached = false;
351
- opts?.signal?.removeEventListener("abort", abortPendingAttach);
352
- };
353
- const sub = {
354
- onData: handlers.onData,
355
- onExit: handlers.onExit,
356
- onControl: handlers.onControl,
357
- releaseAbortListener,
358
- };
359
- const detach = () => {
360
- if (detached)
361
- return;
362
- detached = true;
363
- releaseAbortListener();
364
- rec.subs.delete(sub);
365
- // Keep the owning PTY client while detached: its runtime/exit listeners are the lifecycle monitor for
366
- // the provider and tmux session. Data fanout is already a no-op with no subscribers, and a later attach
367
- // reuses this process instead of double-spawning or losing provider cleanup ownership.
368
- if (rec.subs.size === 0 && rec.meta.awaiting && rec.meta.status === "running") {
369
- try {
370
- this.deps.onAwaiting?.(id);
371
- }
372
- catch {
373
- /* a push must NEVER break the terminal */
374
- }
375
- }
376
- };
377
- const abortPendingAttach = () => detach();
378
- opts?.signal?.addEventListener("abort", abortPendingAttach, { once: true });
379
- abortListenerAttached = opts?.signal !== undefined;
380
- rec.subs.add(sub);
381
- // Replay any file/image attachments that arrived while this client was away, so the Files panel is
382
- // correct on (re)connect. Only to the newly-attached sub. Each attach frame carries a unique `id`, so
383
- // the web can dedupe a replayed frame it already rendered. Wrapped so a bad sink can't break attach.
384
- if (sub.onControl && rec.attachments.length > 0) {
385
- for (const msg of rec.attachments) {
386
- try {
387
- sub.onControl(JSON.stringify(msg));
388
- }
389
- catch {
390
- /* ignore a bad sink */
391
- }
392
- }
393
- }
394
- const joinedLiveProcess = rec.proc !== undefined;
395
- if (!rec.proc) {
396
- try {
397
- const intent = resumeConversation ? "resume" : "fresh";
398
- if (rec.spawnPromise && rec.spawnIntent !== intent) {
399
- throw new ProviderError("RESUME_IDENTITY_UNAVAILABLE", `conflicting concurrent attach intent for ${rec.provider} session ${id}`);
400
- }
401
- if (!rec.spawnPromise) {
402
- const promise = this.spawnForRecord(id, rec, intent);
403
- rec.spawnPromise = promise;
404
- rec.spawnIntent = intent;
405
- void promise.then(() => {
406
- if (rec.spawnPromise === promise) {
407
- rec.spawnPromise = undefined;
408
- rec.spawnIntent = undefined;
409
- }
410
- }, () => {
411
- if (rec.spawnPromise === promise) {
412
- rec.spawnPromise = undefined;
413
- rec.spawnIntent = undefined;
414
- }
415
- });
416
- }
417
- await rec.spawnPromise;
418
- }
419
- catch (error) {
420
- detach();
421
- throw error;
422
- }
423
- if (!rec.proc) {
424
- detach();
425
- return undefined;
426
- }
427
- }
428
- if (joinedLiveProcess) {
429
- // Reattaching to a STILL-RUNNING session: its pty/tmux client is alive from an earlier connection whose
430
- // WS never cleanly closed (e.g. the app was backgrounded a long time, so the old sub lingered and the pty
431
- // wasn't torn down + respawned). tmux drew its screen to that pty long ago, so THIS fresh xterm receives
432
- // no redraw and shows only a blinking cursor until something changes — the reported "open an old chat →
433
- // blank until I resize the window" bug. Nudge tmux to repaint the whole screen. See forceRedraw.
434
- //
435
- // ALT-SCREEN HANDOFF: a tmux client sits on the ALTERNATE screen for its whole attached life, but it
436
- // sent that enter sequence (`smcup`, \x1b[?1049h) only ONCE — down the pty when it first attached, to a
437
- // subscriber that's long gone. A fresh xterm joining this LIVE pty therefore renders the coming redraw
438
- // into its NORMAL buffer: every repaint stacks into local scrollback (a phantom right-hand scrollbar),
439
- // and the web client's two-finger gesture — which picks claude's pager vs local scrollback by the
440
- // active buffer type — silently degrades to scrolling that junk buffer (user report: "sağda scrollbar
441
- // çıkıyor, arkaya çok az kaydırabiliyorum"). Flip the newcomer onto the alt screen BEFORE the redraw.
442
- try {
443
- sub.onData("\x1b[?1049h");
444
- }
445
- catch {
446
- /* ignore a bad sink */
447
- }
448
- this.forceRedraw(rec);
449
- }
450
- return {
451
- unsubscribe: () => {
452
- detach();
453
- },
454
- };
455
- }
456
- async spawnForRecord(id, rec, intent) {
457
- const provider = this.providers.get(rec.provider);
458
- const buildingCleanupPaths = new Set();
459
- try {
460
- const adoptingLive = rec.adoptedLive && intent === "fresh";
461
- const spec = adoptingLive
462
- ? { executable: "/usr/bin/true", args: [], env: process.env, cleanupPaths: [] }
463
- : await provider.buildProcess({
464
- roamSessionId: id,
465
- cwd: rec.meta.cwd,
466
- intent,
467
- options: rec.options,
468
- ...(this.attachConfig ? { attach: this.attachConfig } : {}),
469
- ...(intent === "resume" && rec.provider === "codex" && rec.providerSessionId
470
- ? { providerSessionId: rec.providerSessionId }
471
- : {}),
472
- registerCleanupPaths: (paths) => {
473
- for (const path of paths)
474
- buildingCleanupPaths.add(path);
475
- },
476
- });
477
- for (const path of spec.cleanupPaths)
478
- buildingCleanupPaths.add(path);
479
- if (this.records.get(id) !== rec || rec.subs.size === 0) {
480
- this.cleanupProviderPaths(provider, [...buildingCleanupPaths]);
481
- buildingCleanupPaths.clear();
482
- return undefined;
483
- }
484
- for (const path of buildingCleanupPaths)
485
- rec.cleanupPaths.add(path);
486
- buildingCleanupPaths.clear();
487
- const startProcess = () => {
488
- const candidate = new TerminalProcess({
489
- sessionId: id,
490
- cwd: rec.meta.cwd,
491
- executable: spec.executable,
492
- args: spec.args,
493
- env: spec.env,
494
- cols: rec.cols,
495
- rows: rec.rows,
496
- enableMouseHistory: rec.provider === "codex",
497
- ...(this.deps.ptySpawn ? { ptySpawn: this.deps.ptySpawn } : {}),
498
- ...(this.deps.runTmux ? { runTmux: this.deps.runTmux } : {}),
499
- ...(this.deps.tmuxSocket ? { tmuxSocket: this.deps.tmuxSocket } : {}),
500
- ...(adoptingLive ? { attachOnly: true } : {}),
501
- });
502
- const runtimeSignalParser = provider.createRuntimeSignalParser?.();
503
- candidate.on("data", (chunk) => {
504
- try {
505
- const signals = runtimeSignalParser ? runtimeSignalParser.push(chunk) : provider.runtimeSignals(chunk);
506
- for (const signal of signals)
507
- this.applyRuntimeSignal(id, rec, signal);
508
- }
509
- catch {
510
- /* malformed provider output must not interrupt PTY fanout */
511
- }
512
- for (const sink of [...rec.subs]) {
513
- try {
514
- sink.onData(chunk);
515
- }
516
- catch {
517
- /* ignore a bad sink */
518
- }
519
- }
520
- });
521
- candidate.on("exit", () => {
522
- if (rec.proc !== candidate)
523
- return;
524
- rec.meta.status = "ended";
525
- rec.meta.activity = "idle";
526
- this.clearAwaiting(rec);
527
- rec.proc = undefined;
528
- this.cleanupRecordPaths(rec, provider);
529
- const dying = [...rec.subs];
530
- const wasAttached = dying.length > 0;
531
- for (const sink of dying)
532
- sink.releaseAbortListener();
533
- rec.subs.clear();
534
- for (const sink of dying) {
535
- try {
536
- sink.onExit?.();
537
- }
538
- catch {
539
- /* ignore */
540
- }
541
- }
542
- try {
543
- this.deps.onFinished?.(id, wasAttached);
544
- }
545
- catch {
546
- /* ignore */
547
- }
548
- });
549
- try {
550
- candidate.start();
551
- }
552
- catch (error) {
553
- candidate.removeAllListeners();
554
- try {
555
- candidate.stop({ kill: true });
556
- }
557
- catch {
558
- /* a partially-started terminal is already fail-closed */
559
- }
560
- throw error;
561
- }
562
- // Publish only after the actual PTY/tmux client started successfully. A failed start must never leave
563
- // a broken object that makes attach believe a terminal exists.
564
- rec.meta.status = "running";
565
- rec.proc = candidate;
566
- rec.adoptedLive = false;
567
- return candidate;
568
- };
569
- if (adoptingLive)
570
- return startProcess();
571
- if (rec.provider === "codex" && intent === "fresh") {
572
- const storedIdentity = this.deps.store.get(id)?.providerSessionId;
573
- if (rec.providerSessionId !== undefined || storedIdentity !== undefined) {
574
- // A deliberate fresh restart creates a new conversation. Retire the old authoritative id before
575
- // the resolver snapshot/spawn so provisional ownership can never collide with, resume, or expose
576
- // the previous thread. Persist first: a crash after this point remains fail-closed on reopen.
577
- this.deps.store.setProviderSessionId(id, undefined);
578
- rec.providerSessionId = undefined;
579
- rec.meta.providerSessionId = undefined;
580
- }
581
- rec.identityAmbiguous = false;
582
- rec.meta.identityState = "pending";
583
- }
584
- if (rec.provider === "codex" && intent === "fresh" && this.deps.codexThreadResolver) {
585
- let proc;
586
- let terminalSpawnAttempted = false;
587
- let terminalSpawnError;
588
- let preSpawnError;
589
- try {
590
- const exactId = await this.deps.codexThreadResolver(rec.meta.cwd).resolveAfterSpawn({
591
- cwd: rec.meta.cwd,
592
- persistence: createCodexThreadPersistence(this.deps.store, id),
593
- spawn: (signal) => {
594
- const started = (async () => {
595
- try {
596
- await spec.preSpawnCheck?.();
597
- }
598
- catch (error) {
599
- preSpawnError = error;
600
- throw error;
601
- }
602
- if (signal.aborted || this.records.get(id) !== rec || rec.subs.size === 0) {
603
- throw new ProviderError("RESUME_IDENTITY_UNAVAILABLE", "Codex launch was canceled");
604
- }
605
- terminalSpawnAttempted = true;
606
- try {
607
- proc = startProcess();
608
- }
609
- catch (error) {
610
- terminalSpawnError = error;
611
- throw error;
612
- }
613
- })();
614
- return {
615
- started,
616
- cancel: async () => {
617
- try {
618
- await started;
619
- }
620
- catch {
621
- return; // no process exists, so cancellation is fully acknowledged
622
- }
623
- // A live terminal is deliberately not killed for auxiliary identity failure. An unresolved
624
- // acknowledgement makes the Task 7 coordinator poison later discovery before releasing.
625
- await new Promise(() => { });
626
- },
627
- };
628
- },
629
- });
630
- rec.providerSessionId = exactId;
631
- rec.identityAmbiguous = false;
632
- rec.meta.providerSessionId = exactId;
633
- rec.meta.identityState = "exact";
634
- return proc;
635
- }
636
- catch {
637
- if (terminalSpawnError !== undefined)
638
- throw terminalSpawnError;
639
- if (preSpawnError !== undefined)
640
- throw preSpawnError;
641
- rec.providerSessionId = undefined;
642
- rec.identityAmbiguous = true;
643
- rec.meta.providerSessionId = undefined;
644
- rec.meta.identityState = "ambiguous";
645
- try {
646
- this.deps.store.setProviderSessionId(id, undefined);
647
- }
648
- catch {
649
- /* identity remains fail-closed in memory */
650
- }
651
- if (!proc && !terminalSpawnAttempted && this.records.get(id) === rec && rec.subs.size > 0) {
652
- // Discovery failed before a terminal was actually attempted (including a resolver-owned deadline
653
- // between proof and spawn). Starting now is intentionally untracked, so poison process-wide
654
- // discovery, recheck any selected-profile proof immediately before the fallback process
655
- // construction, then allow this degraded-but-usable terminal to continue.
656
- codexThreadResolutionCoordinator.poisonUnknownSpawnOutcome();
657
- await spec.preSpawnCheck?.();
658
- if (this.records.get(id) !== rec)
659
- return undefined;
660
- if (rec.subs.size > 0) {
661
- terminalSpawnAttempted = true;
662
- proc = startProcess();
663
- }
664
- }
665
- if (!proc) {
666
- rec.meta.status = "ended";
667
- rec.meta.activity = "idle";
668
- this.clearAwaiting(rec);
669
- try {
670
- this.deps.store.setStatus(id, "dormant");
671
- }
672
- catch {
673
- /* in-memory state is still truthfully ended */
674
- }
675
- this.cleanupRecordPaths(rec, provider);
676
- }
677
- return proc;
678
- }
679
- }
680
- await spec.preSpawnCheck?.();
681
- if (this.records.get(id) !== rec || rec.subs.size === 0) {
682
- this.cleanupRecordPaths(rec, provider);
683
- return undefined;
684
- }
685
- return startProcess();
686
- }
687
- catch (error) {
688
- rec.proc = undefined;
689
- rec.meta.status = "ended";
690
- rec.meta.activity = "idle";
691
- this.clearAwaiting(rec);
692
- try {
693
- this.deps.store.setStatus(id, "errored");
694
- }
695
- catch {
696
- /* preserve the original provider/terminal error */
697
- }
698
- this.cleanupProviderPaths(provider, [...buildingCleanupPaths]);
699
- buildingCleanupPaths.clear();
700
- this.cleanupRecordPaths(rec, provider);
701
- throw error;
702
- }
703
- }
704
- cleanupProviderPaths(provider, paths) {
705
- if (paths.length === 0)
706
- return;
707
- try {
708
- provider.cleanup(paths);
709
- }
710
- catch {
711
- /* provider cleanup is best-effort and must not break teardown */
712
- }
713
- }
714
- cleanupRecordPaths(rec, provider) {
715
- if (rec.cleanupPaths.size === 0)
716
- return;
717
- const paths = [...rec.cleanupPaths];
718
- rec.cleanupPaths.clear();
719
- this.cleanupProviderPaths(provider, paths);
720
- }
721
- applyRuntimeSignal(id, rec, signal) {
722
- if (signal.type === "provider-session-id") {
723
- if (rec.provider !== "codex")
724
- return;
725
- // Production exact identity is resolver-owned. OSC ids remain a compatibility signal only when no
726
- // resolver was configured (principally isolated adapter/manager tests).
727
- if (this.deps.codexThreadResolver)
728
- return;
729
- if (rec.identityAmbiguous || (rec.providerSessionId && rec.providerSessionId !== signal.id)) {
730
- rec.identityAmbiguous = true;
731
- rec.providerSessionId = undefined;
732
- rec.meta.identityState = "ambiguous";
733
- rec.meta.providerSessionId = undefined;
734
- this.deps.store.setProviderSessionId(id, undefined);
735
- return;
736
- }
737
- try {
738
- this.deps.store.setProviderSessionId(id, signal.id);
739
- rec.providerSessionId = signal.id;
740
- rec.meta.identityState = "exact";
741
- rec.meta.providerSessionId = signal.id;
742
- }
743
- catch {
744
- rec.identityAmbiguous = true;
745
- rec.providerSessionId = undefined;
746
- rec.meta.identityState = "ambiguous";
747
- rec.meta.providerSessionId = undefined;
748
- try {
749
- this.deps.store.setProviderSessionId(id, undefined);
750
- }
751
- catch {
752
- /* already fail-closed in memory */
753
- }
754
- }
755
- return;
756
- }
757
- const wasBlocked = rec.meta.awaiting;
758
- rec.meta.activity = signal.type;
759
- rec.meta.awaiting = signal.type === "blocked";
760
- if (rec.meta.awaiting && !wasBlocked && rec.subs.size === 0) {
761
- try {
762
- this.deps.onAwaiting?.(id);
763
- }
764
- catch {
765
- /* a push failure must not break terminal output */
766
- }
767
- }
768
- }
769
- write(id, data) {
770
- const rec = this.records.get(id);
771
- rec?.proc?.write(data);
772
- if (rec) {
773
- rec.meta.activity = "working";
774
- this.clearAwaiting(rec);
775
- rec.meta.lastActivityAt = this.deps.now();
776
- this.deps.store.touch(id, rec.meta.lastActivityAt);
777
- }
778
- }
779
- resize(id, cols, rows) {
780
- const rec = this.records.get(id);
781
- if (!rec)
782
- return;
783
- rec.cols = clampDim(cols, rec.cols);
784
- rec.rows = clampDim(rows, rec.rows);
785
- rec.proc?.resize(rec.cols, rec.rows);
786
- }
787
- stop(id) {
788
- const rec = this.records.get(id);
789
- this.records.delete(id);
790
- if (rec?.proc) {
791
- const proc = rec.proc;
792
- rec.proc = undefined;
793
- proc.removeAllListeners();
794
- proc.stop({ kill: true });
795
- }
796
- else
797
- this.killTmux(id);
798
- if (rec) {
799
- this.cleanupRecordPaths(rec, this.providers.get(rec.provider));
800
- }
801
- this.deps.store.delete(id);
802
- }
803
- get(id) {
804
- return this.records.get(id)?.meta;
805
- }
806
- list() {
807
- return [...this.records.values()].map((r) => r.meta);
808
- }
809
- /**
810
- * After a server/OTA restart: adopt stored terminal sessions whose tmux session is still alive (so they
811
- * reappear, resumable), prune store rows whose tmux session is gone, AND kill ORPHAN tmux sessions — live
812
- * `rc-*` sessions with no store row (leaked by a crash or an interrupted cleanup) — so they don't pile up.
813
- */
814
- rehydrate(opts) {
815
- const live = new Set(opts.liveTmuxNames);
816
- const storedTerminalIds = new Set(this.deps.store
817
- .list()
818
- .filter((s) => s.mode === "terminal")
819
- .map((s) => s.id));
820
- for (const s of this.deps.store.list()) {
821
- if (s.mode !== "terminal")
822
- continue;
823
- if (!live.has(tmuxSessionName(s.id))) {
824
- this.deps.store.delete(s.id); // tmux session gone → prune the stale row
825
- continue;
826
- }
827
- if (this.records.has(s.id))
828
- continue;
829
- try {
830
- this.providers.get(s.provider);
831
- }
832
- catch {
833
- continue; // retain the durable row and live tmux session; never launch under another provider
834
- }
835
- let parsedOptions;
836
- try {
837
- parsedOptions = s.provider === "claude" ? parseLegacyClaudeArgs(s.spawnArgs ?? []) : s.launchOptions;
838
- }
839
- catch {
840
- continue; // isolate malformed historical options to this row; retain it for diagnostics
841
- }
842
- const options = s.provider === "claude" && parsedOptions.provider === "claude" && s.dangerouslySkip
843
- ? { ...parsedOptions, dangerouslySkip: true }
844
- : parsedOptions;
845
- const common = {
846
- meta: {
847
- id: s.id,
848
- provider: s.provider,
849
- cwd: s.cwd,
850
- mode: "terminal",
851
- status: "running",
852
- createdAt: s.createdAt,
853
- lastActivityAt: s.lastActivityAt,
854
- activity: "idle", // the monitor re-derives real activity from the pane on its next ~2.5s sweep
855
- awaiting: false,
856
- // Preserve the persisted RCE-skip flag so a rehydrated session is still badged correctly.
857
- dangerouslySkip: options.provider === "claude"
858
- ? options.dangerouslySkip === true
859
- : options.dangerouslyBypassApprovalsAndSandbox === true,
860
- model: options.model,
861
- effort: options.provider === "claude" ? options.effort : options.reasoningEffort,
862
- ...(options.provider === "claude"
863
- ? { permissionMode: options.permissionMode }
864
- : { sandbox: options.sandbox, approvalPolicy: options.approvalPolicy }),
865
- // The user's rename survives a server restart the same way.
866
- name: s.name,
867
- ...(s.provider === "codex"
868
- ? s.providerSessionId
869
- ? { identityState: "exact", providerSessionId: s.providerSessionId }
870
- : { identityState: "ambiguous" }
871
- : {}),
872
- },
873
- cols: 80,
874
- rows: 24,
875
- subs: new Set(),
876
- cleanupPaths: new Set(),
877
- attachments: [],
878
- adoptedLive: true,
879
- };
880
- if (s.provider === "claude" && options.provider === "claude") {
881
- this.records.set(s.id, { ...common, provider: "claude", options });
882
- }
883
- else if (s.provider === "codex" && options.provider === "codex") {
884
- this.records.set(s.id, {
885
- ...common,
886
- provider: "codex",
887
- options,
888
- ...(s.providerSessionId ? { providerSessionId: s.providerSessionId } : {}),
889
- identityAmbiguous: false,
890
- });
891
- }
892
- }
893
- // Orphan-reap ONLY with a durable store. In "memory-fallback" (better-sqlite3 didn't load) the store is
894
- // EMPTY after a restart, so EVERY live rc-* session looks like an orphan — reaping would then destroy
895
- // ALL running terminals on any restart (incl. OTA), e.g. after a native-module ABI break. Leaking a
896
- // genuinely-orphaned tmux session is far better than killing every live one, so skip reaping here.
897
- if (this.deps.store.mode !== "memory-fallback") {
898
- for (const name of opts.liveTmuxNames) {
899
- if (!name.startsWith("rc-"))
900
- continue;
901
- const id = name.slice(3);
902
- if (!storedTerminalIds.has(id))
903
- this.killTmux(id); // orphan → reap
904
- }
905
- }
906
- }
907
- /** Kill a tmux session for an id without needing a live proc (reuses TerminalProcess's socketed kill). */
908
- killTmux(id) {
909
- new TerminalProcess({
910
- sessionId: id,
911
- cwd: "/",
912
- executable: "/usr/bin/true",
913
- ...(this.deps.runTmux ? { runTmux: this.deps.runTmux } : {}),
914
- ...(this.deps.tmuxSocket ? { tmuxSocket: this.deps.tmuxSocket } : {}),
915
- }).stop({ kill: true });
916
- }
917
- }