@roamcode.ai/server 1.0.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/LICENSE +21 -0
- package/dist/auth.d.ts +63 -0
- package/dist/auth.js +133 -0
- package/dist/claude-auth-service.d.ts +76 -0
- package/dist/claude-auth-service.js +217 -0
- package/dist/claude-latest-service.d.ts +34 -0
- package/dist/claude-latest-service.js +61 -0
- package/dist/config.d.ts +78 -0
- package/dist/config.js +59 -0
- package/dist/data-dir.d.ts +42 -0
- package/dist/data-dir.js +70 -0
- package/dist/diag.d.ts +43 -0
- package/dist/diag.js +83 -0
- package/dist/fs-service.d.ts +90 -0
- package/dist/fs-service.js +290 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +44 -0
- package/dist/managed-runtime.d.ts +51 -0
- package/dist/managed-runtime.js +411 -0
- package/dist/managed-update-helper.d.ts +2 -0
- package/dist/managed-update-helper.js +34 -0
- package/dist/mcp-send.d.ts +33 -0
- package/dist/mcp-send.js +107 -0
- package/dist/origin-check.d.ts +37 -0
- package/dist/origin-check.js +101 -0
- package/dist/pane-status.d.ts +61 -0
- package/dist/pane-status.js +145 -0
- package/dist/providers/claude-metadata-service.d.ts +58 -0
- package/dist/providers/claude-metadata-service.js +352 -0
- package/dist/providers/claude-provider.d.ts +11 -0
- package/dist/providers/claude-provider.js +166 -0
- package/dist/providers/codex-activity.d.ts +21 -0
- package/dist/providers/codex-activity.js +122 -0
- package/dist/providers/codex-app-server-client.d.ts +90 -0
- package/dist/providers/codex-app-server-client.js +485 -0
- package/dist/providers/codex-latest-service.d.ts +50 -0
- package/dist/providers/codex-latest-service.js +174 -0
- package/dist/providers/codex-metadata-service.d.ts +161 -0
- package/dist/providers/codex-metadata-service.js +686 -0
- package/dist/providers/codex-profile-client.d.ts +16 -0
- package/dist/providers/codex-profile-client.js +52 -0
- package/dist/providers/codex-profile-security.d.ts +23 -0
- package/dist/providers/codex-profile-security.js +161 -0
- package/dist/providers/codex-provider.d.ts +15 -0
- package/dist/providers/codex-provider.js +174 -0
- package/dist/providers/codex-thread-coordinator.d.ts +18 -0
- package/dist/providers/codex-thread-coordinator.js +93 -0
- package/dist/providers/codex-thread-persistence.d.ts +9 -0
- package/dist/providers/codex-thread-persistence.js +45 -0
- package/dist/providers/codex-thread-resolver.d.ts +59 -0
- package/dist/providers/codex-thread-resolver.js +322 -0
- package/dist/providers/options.d.ts +7 -0
- package/dist/providers/options.js +155 -0
- package/dist/providers/provider-artifacts.d.ts +3 -0
- package/dist/providers/provider-artifacts.js +30 -0
- package/dist/providers/registry.d.ts +7 -0
- package/dist/providers/registry.js +23 -0
- package/dist/providers/types.d.ts +95 -0
- package/dist/providers/types.js +8 -0
- package/dist/push-dispatch.d.ts +81 -0
- package/dist/push-dispatch.js +100 -0
- package/dist/push-store.d.ts +25 -0
- package/dist/push-store.js +79 -0
- package/dist/rate-limit.d.ts +52 -0
- package/dist/rate-limit.js +72 -0
- package/dist/server-config.d.ts +60 -0
- package/dist/server-config.js +77 -0
- package/dist/service-install.d.ts +60 -0
- package/dist/service-install.js +221 -0
- package/dist/session-defaults.d.ts +26 -0
- package/dist/session-defaults.js +60 -0
- package/dist/session-store.d.ts +81 -0
- package/dist/session-store.js +654 -0
- package/dist/start.d.ts +31 -0
- package/dist/start.js +372 -0
- package/dist/static-routes.d.ts +101 -0
- package/dist/static-routes.js +188 -0
- package/dist/terminal-capability.d.ts +5 -0
- package/dist/terminal-capability.js +27 -0
- package/dist/terminal-manager.d.ts +224 -0
- package/dist/terminal-manager.js +917 -0
- package/dist/terminal-process.d.ts +85 -0
- package/dist/terminal-process.js +238 -0
- package/dist/terminal-shared.d.ts +36 -0
- package/dist/terminal-shared.js +43 -0
- package/dist/tmux-list.d.ts +11 -0
- package/dist/tmux-list.js +39 -0
- package/dist/transport.d.ts +123 -0
- package/dist/transport.js +1559 -0
- package/dist/updater.d.ts +161 -0
- package/dist/updater.js +451 -0
- package/dist/usage-service.d.ts +118 -0
- package/dist/usage-service.js +173 -0
- package/dist/vapid.d.ts +17 -0
- package/dist/vapid.js +31 -0
- package/dist/web-push-send.d.ts +20 -0
- package/dist/web-push-send.js +21 -0
- package/dist/ws-ticket.d.ts +47 -0
- package/dist/ws-ticket.js +62 -0
- package/package.json +55 -0
|
@@ -0,0 +1,917 @@
|
|
|
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
|
+
}
|