@echomem/mcp 1.4.16 → 1.4.18
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/dist/hud/electron-main.js +7 -2
- package/dist/hud/monitor.js +164 -50
- package/dist/hud/server.js +79 -3
- package/dist/hud/session-launcher.js +395 -0
- package/dist/hud/web.js +336 -164
- package/dist/index.js +4 -1
- package/dist/migrate.js +19 -7
- package/dist/setup-page/client-extraction.js +16 -4
- package/dist/setup.js +83 -1
- package/dist/v1-contract.js +3 -2
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, screen, shell } from "electron";
|
|
7
7
|
import { disableAutostart, enableAutostart, isAutostartEnabled } from "./autostart.js";
|
|
8
8
|
import { createHudServer } from "./server.js";
|
|
9
|
+
import { launchWarmSession } from "./session-launcher.js";
|
|
9
10
|
const flags = parseFlags(process.argv.slice(2));
|
|
10
11
|
const mode = parseMode(flags.client);
|
|
11
12
|
const port = typeof flags.port === "string" ? Number(flags.port) || 17377 : 17377;
|
|
@@ -102,8 +103,12 @@ app.whenReady().then(async () => {
|
|
|
102
103
|
/* best-effort */
|
|
103
104
|
}
|
|
104
105
|
};
|
|
106
|
+
const onLaunchSession = (request) => launchWarmSession(request, {
|
|
107
|
+
openExternal: (externalUrl) => shell.openExternal(externalUrl),
|
|
108
|
+
openPath: (filePath) => shell.openPath(filePath),
|
|
109
|
+
});
|
|
105
110
|
try {
|
|
106
|
-
hudServer = await createHudServer({ mode, port, onReveal, onOpenExternal });
|
|
111
|
+
hudServer = await createHudServer({ mode, port, onReveal, onOpenExternal, onLaunchSession });
|
|
107
112
|
createWindow(hudServer.url);
|
|
108
113
|
}
|
|
109
114
|
catch (error) {
|
|
@@ -111,7 +116,7 @@ app.whenReady().then(async () => {
|
|
|
111
116
|
createWindow(preferredUrl);
|
|
112
117
|
return;
|
|
113
118
|
}
|
|
114
|
-
hudServer = await createHudServer({ mode, port: 0, onReveal, onOpenExternal });
|
|
119
|
+
hudServer = await createHudServer({ mode, port: 0, onReveal, onOpenExternal, onLaunchSession });
|
|
115
120
|
createWindow(hudServer.url);
|
|
116
121
|
}
|
|
117
122
|
}).catch((error) => {
|
package/dist/hud/monitor.js
CHANGED
|
@@ -30,9 +30,9 @@ export class HudMonitor extends EventEmitter {
|
|
|
30
30
|
claudeTitlesCheckedAt = 0;
|
|
31
31
|
claudeTitleCache = new Map();
|
|
32
32
|
lastPersistedRecent = "";
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
responseTimes = new Map();
|
|
34
|
+
expandedId = null;
|
|
35
|
+
expandedScore = null;
|
|
36
36
|
constructor(mode = "auto", pollMs = 750) {
|
|
37
37
|
super();
|
|
38
38
|
this.mode = mode;
|
|
@@ -53,37 +53,37 @@ export class HudMonitor extends EventEmitter {
|
|
|
53
53
|
snapshot() {
|
|
54
54
|
const focusedFamily = this.frontmostPreferredFamily();
|
|
55
55
|
this.refreshRecent();
|
|
56
|
-
this.releasePinAfterForegroundSwitch(focusedFamily);
|
|
57
56
|
const now = Date.now();
|
|
58
57
|
const sessions = [...this.scores.values()].map((score) => {
|
|
59
58
|
// Liveness uses file mtime (real last write), not score.updatedAt — the Claude cache stamps
|
|
60
59
|
// updatedAt = now on every read, which would make an idle session look permanently live.
|
|
61
60
|
const mtimeMs = this.mtimes.get(score.client) ?? (Date.parse(score.updatedAt) || now);
|
|
62
|
-
const
|
|
61
|
+
const responseAtMs = this.responseTimeFor(score.client, score.sourcePath, mtimeMs);
|
|
62
|
+
const lastActiveMs = Math.max(0, now - responseAtMs);
|
|
63
63
|
return {
|
|
64
64
|
...score,
|
|
65
|
-
live:
|
|
65
|
+
live: Math.max(0, now - mtimeMs) < LIVE_WINDOW_MS,
|
|
66
66
|
focused: focusedFamily === agentFamily(score.client),
|
|
67
67
|
label: this.labelFor(score),
|
|
68
68
|
lastActiveMs,
|
|
69
69
|
};
|
|
70
70
|
});
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
sessions.sort((a, b) =>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const pinnedId = pinned ? this.pinnedId : null;
|
|
71
|
+
// Only a newer assistant response can promote a conversation. Focus, clicks, and expansion are
|
|
72
|
+
// view state and must never rewrite chronology; sourcePath is only a deterministic exact-tie key.
|
|
73
|
+
sessions.sort((a, b) => a.lastActiveMs - b.lastActiveMs || a.sourcePath.localeCompare(b.sourcePath));
|
|
74
|
+
const expanded = this.expandedView(now);
|
|
75
|
+
const views = expanded
|
|
76
|
+
? [expanded, ...sessions.filter((s) => s.sourcePath !== expanded.sourcePath)]
|
|
77
|
+
: sessions;
|
|
79
78
|
return {
|
|
80
79
|
mode: this.mode,
|
|
81
|
-
active:
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
active: sessions[0] || null,
|
|
81
|
+
expanded,
|
|
82
|
+
expandedId: expanded ? this.expandedId : null,
|
|
83
|
+
scores: views,
|
|
84
|
+
sessions: views,
|
|
84
85
|
threadCounts: this.threadCounts,
|
|
85
86
|
recentSessions: this.recentSessions,
|
|
86
|
-
pinnedId,
|
|
87
87
|
missing: this.missing,
|
|
88
88
|
updatedAt: new Date().toISOString(),
|
|
89
89
|
};
|
|
@@ -96,29 +96,20 @@ export class HudMonitor extends EventEmitter {
|
|
|
96
96
|
this.labelCache.set(score.sourcePath, label);
|
|
97
97
|
return label;
|
|
98
98
|
}
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
// Expand one recent-session row in place (or null to collapse it). This deliberately does not
|
|
100
|
+
// participate in active-session selection, so inspecting history cannot move the timeline.
|
|
101
|
+
expand(id) {
|
|
102
|
+
if (id === this.expandedId)
|
|
102
103
|
return;
|
|
103
|
-
this.
|
|
104
|
-
this.
|
|
105
|
-
this.pinnedFromFamily = id ? this.frontmostPreferredFamily() : null;
|
|
104
|
+
this.expandedId = id;
|
|
105
|
+
this.expandedScore = null;
|
|
106
106
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
return;
|
|
112
|
-
this.pinnedId = null;
|
|
113
|
-
this.pinnedScore = null;
|
|
114
|
-
this.pinnedFromFamily = null;
|
|
115
|
-
}
|
|
116
|
-
// The user-selected tab, scored on demand (cached by file signature). null if nothing is pinned or
|
|
117
|
-
// the pinned session has aged out of the recent list.
|
|
118
|
-
pinnedView(now) {
|
|
119
|
-
if (!this.pinnedId)
|
|
107
|
+
// The expanded history row, scored on demand (cached by file signature). null if nothing is
|
|
108
|
+
// expanded or the session has aged out of the recent list.
|
|
109
|
+
expandedView(now) {
|
|
110
|
+
if (!this.expandedId)
|
|
120
111
|
return null;
|
|
121
|
-
const rec = this.recentSessions.find((r) => r.id === this.
|
|
112
|
+
const rec = this.recentSessions.find((r) => r.id === this.expandedId);
|
|
122
113
|
if (!rec)
|
|
123
114
|
return null;
|
|
124
115
|
let stat = null;
|
|
@@ -129,16 +120,23 @@ export class HudMonitor extends EventEmitter {
|
|
|
129
120
|
return null;
|
|
130
121
|
}
|
|
131
122
|
const sig = `${rec.sourcePath}:${stat.size}:${stat.mtimeMs}`;
|
|
132
|
-
if (!this.
|
|
123
|
+
if (!this.expandedScore || this.expandedScore.sig !== sig) {
|
|
133
124
|
try {
|
|
134
|
-
this.
|
|
125
|
+
this.expandedScore = { sig, score: adapters[rec.client].score(rec.sourcePath) };
|
|
135
126
|
}
|
|
136
127
|
catch {
|
|
137
128
|
return null;
|
|
138
129
|
}
|
|
139
130
|
}
|
|
140
|
-
const
|
|
141
|
-
|
|
131
|
+
const activityMtimeMs = liveMtime(rec.client, rec.sourcePath, stat.mtimeMs);
|
|
132
|
+
const lastActiveMs = Math.max(0, now - this.responseTimeFor(rec.client, rec.sourcePath, activityMtimeMs));
|
|
133
|
+
return {
|
|
134
|
+
...this.expandedScore.score,
|
|
135
|
+
live: Math.max(0, now - activityMtimeMs) < LIVE_WINDOW_MS,
|
|
136
|
+
focused: false,
|
|
137
|
+
label: rec.label || rec.title,
|
|
138
|
+
lastActiveMs,
|
|
139
|
+
};
|
|
142
140
|
}
|
|
143
141
|
frontmostPreferredFamily() {
|
|
144
142
|
if (this.mode !== "auto" && this.mode !== "both")
|
|
@@ -189,8 +187,10 @@ export class HudMonitor extends EventEmitter {
|
|
|
189
187
|
const newest = newestByClient.get(adapter.client);
|
|
190
188
|
if (!newest || mtimeMs > newest.mtimeMs)
|
|
191
189
|
newestByClient.set(adapter.client, { file, mtimeMs });
|
|
192
|
-
if (age < RECENT_WINDOW_MS)
|
|
193
|
-
|
|
190
|
+
if (age < RECENT_WINDOW_MS) {
|
|
191
|
+
const responseAtMs = this.responseTimeFor(adapter.client, file, mtimeMs);
|
|
192
|
+
recent.push(this.buildRecent(adapter.client, file, age, Math.max(0, now - responseAtMs)));
|
|
193
|
+
}
|
|
194
194
|
}
|
|
195
195
|
counts[adapter.client] = n;
|
|
196
196
|
}
|
|
@@ -199,24 +199,44 @@ export class HudMonitor extends EventEmitter {
|
|
|
199
199
|
// Always keep each agent's latest session reachable as a tab (so you can switch to "my last Codex"
|
|
200
200
|
// even if it's been idle longer than the window). Appended if the window didn't already include it.
|
|
201
201
|
for (const [client, info] of newestByClient) {
|
|
202
|
-
if (!list.some((r) => r.client === client))
|
|
203
|
-
|
|
202
|
+
if (!list.some((r) => r.client === client)) {
|
|
203
|
+
const activityAgeMs = now - info.mtimeMs;
|
|
204
|
+
const responseAgeMs = Math.max(0, now - this.responseTimeFor(client, info.file, info.mtimeMs));
|
|
205
|
+
list.push(this.buildRecent(client, info.file, activityAgeMs, responseAgeMs));
|
|
206
|
+
}
|
|
204
207
|
}
|
|
205
208
|
this.threadCounts = counts;
|
|
206
209
|
this.recentSessions = list;
|
|
207
210
|
this.persistRecent();
|
|
208
211
|
}
|
|
209
|
-
buildRecent(client, file,
|
|
212
|
+
buildRecent(client, file, activityAgeMs, responseAgeMs) {
|
|
210
213
|
return {
|
|
211
|
-
id:
|
|
214
|
+
id: recentSessionId(client, file),
|
|
212
215
|
client,
|
|
213
216
|
title: this.titleFor(client, file),
|
|
214
217
|
label: sessionLabel(file, client),
|
|
215
218
|
sourcePath: file,
|
|
216
|
-
lastActiveMs:
|
|
217
|
-
live:
|
|
219
|
+
lastActiveMs: responseAgeMs,
|
|
220
|
+
live: activityAgeMs < LIVE_WINDOW_MS,
|
|
218
221
|
};
|
|
219
222
|
}
|
|
223
|
+
responseTimeFor(client, file, fallbackMtimeMs) {
|
|
224
|
+
const transcript = client === "codex" ? file : resolveClaudeTranscript(file) ?? file;
|
|
225
|
+
let signature = "";
|
|
226
|
+
try {
|
|
227
|
+
const stat = fs.statSync(transcript);
|
|
228
|
+
signature = `${transcript}:${stat.size}:${stat.mtimeMs}`;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return fallbackMtimeMs;
|
|
232
|
+
}
|
|
233
|
+
const cached = this.responseTimes.get(transcript);
|
|
234
|
+
if (cached?.signature === signature)
|
|
235
|
+
return cached.timestampMs;
|
|
236
|
+
const timestampMs = latestAssistantResponseAt(transcript, client) || cached?.timestampMs || fallbackMtimeMs;
|
|
237
|
+
this.responseTimes.set(transcript, { signature, timestampMs });
|
|
238
|
+
return timestampMs;
|
|
239
|
+
}
|
|
220
240
|
titleFor(client, file) {
|
|
221
241
|
if (client === "codex") {
|
|
222
242
|
const uuid = codexUuidFromPath(file);
|
|
@@ -414,12 +434,106 @@ export function agentFamilyForApplication(name, bundleId = "") {
|
|
|
414
434
|
export function agentFamily(client) {
|
|
415
435
|
return client === "codex" ? "codex" : "claude";
|
|
416
436
|
}
|
|
437
|
+
// Return the timestamp of the newest assistant text response, ignoring user turns, tool calls,
|
|
438
|
+
// tool results, and other transcript writes. Scanning backwards keeps the common path cheap while
|
|
439
|
+
// making ordering depend on conversation responses instead of filesystem activity.
|
|
440
|
+
export function latestAssistantResponseAt(file, client) {
|
|
441
|
+
const chunkSize = 256 * 1024;
|
|
442
|
+
const maxScanBytes = 8 * 1024 * 1024;
|
|
443
|
+
let fd = null;
|
|
444
|
+
try {
|
|
445
|
+
fd = fs.openSync(file, "r");
|
|
446
|
+
const size = fs.fstatSync(fd).size;
|
|
447
|
+
let position = size;
|
|
448
|
+
let scanned = 0;
|
|
449
|
+
let carry = "";
|
|
450
|
+
while (position > 0 && scanned < maxScanBytes) {
|
|
451
|
+
const bytes = Math.min(chunkSize, position, maxScanBytes - scanned);
|
|
452
|
+
position -= bytes;
|
|
453
|
+
scanned += bytes;
|
|
454
|
+
const buffer = Buffer.allocUnsafe(bytes);
|
|
455
|
+
fs.readSync(fd, buffer, 0, bytes, position);
|
|
456
|
+
const lines = (buffer.toString("utf8") + carry).split("\n");
|
|
457
|
+
carry = lines.shift() || "";
|
|
458
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
459
|
+
const timestampMs = assistantResponseTimestamp(lines[i], client);
|
|
460
|
+
if (timestampMs !== null)
|
|
461
|
+
return timestampMs;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return assistantResponseTimestamp(carry, client);
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
if (fd !== null)
|
|
471
|
+
fs.closeSync(fd);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function assistantResponseTimestamp(line, client) {
|
|
475
|
+
if (!line.trim())
|
|
476
|
+
return null;
|
|
477
|
+
let row;
|
|
478
|
+
try {
|
|
479
|
+
row = JSON.parse(line);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
if (!isRecord(row))
|
|
485
|
+
return null;
|
|
486
|
+
const payload = isRecord(row.payload) ? row.payload : {};
|
|
487
|
+
const timestamp = typeof row.timestamp === "string"
|
|
488
|
+
? Date.parse(row.timestamp)
|
|
489
|
+
: typeof payload.timestamp === "string"
|
|
490
|
+
? Date.parse(payload.timestamp)
|
|
491
|
+
: Number.NaN;
|
|
492
|
+
if (!Number.isFinite(timestamp))
|
|
493
|
+
return null;
|
|
494
|
+
if (client === "codex") {
|
|
495
|
+
const type = typeof payload.type === "string" ? payload.type : "";
|
|
496
|
+
if ((type === "agent_message" || type === "assistant_message") && hasText(payload.message ?? payload.content)) {
|
|
497
|
+
return timestamp;
|
|
498
|
+
}
|
|
499
|
+
if (type === "message" && payload.role === "assistant" && hasText(payload.content ?? payload.message)) {
|
|
500
|
+
return timestamp;
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
if (row.type !== "assistant")
|
|
505
|
+
return null;
|
|
506
|
+
const message = isRecord(row.message) ? row.message : {};
|
|
507
|
+
if (message.role !== undefined && message.role !== "assistant")
|
|
508
|
+
return null;
|
|
509
|
+
return hasText(message.content) ? timestamp : null;
|
|
510
|
+
}
|
|
511
|
+
function hasText(value) {
|
|
512
|
+
if (typeof value === "string")
|
|
513
|
+
return Boolean(value.trim());
|
|
514
|
+
if (!Array.isArray(value))
|
|
515
|
+
return false;
|
|
516
|
+
return value.some((block) => {
|
|
517
|
+
if (typeof block === "string")
|
|
518
|
+
return Boolean(block.trim());
|
|
519
|
+
if (!isRecord(block))
|
|
520
|
+
return false;
|
|
521
|
+
const type = typeof block.type === "string" ? block.type : "";
|
|
522
|
+
return (type === "text" || type === "output_text") && typeof block.text === "string" && Boolean(block.text.trim());
|
|
523
|
+
});
|
|
524
|
+
}
|
|
417
525
|
function isRecord(value) {
|
|
418
526
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
419
527
|
}
|
|
420
528
|
function sessionIdFromPath(file) {
|
|
421
529
|
return codexUuidFromPath(file) || path.basename(file).replace(/\.jsonl$|\.json$/, "");
|
|
422
530
|
}
|
|
531
|
+
// Provider-qualified identity keeps one combined session feed safe when two clients reuse the same
|
|
532
|
+
// UUID (for example, a Claude Code session mirrored into Claude Desktop). The source id remains
|
|
533
|
+
// recoverable from sourcePath for provider-specific deep links and title metadata joins.
|
|
534
|
+
export function recentSessionId(client, file) {
|
|
535
|
+
return `${client}:${sessionIdFromPath(file)}`;
|
|
536
|
+
}
|
|
423
537
|
function codexUuidFromPath(file) {
|
|
424
538
|
const match = path.basename(file).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
|
|
425
539
|
return match ? match[0] : "";
|
package/dist/hud/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { homePath, newestFile, walkFiles } from "./fs.js";
|
|
|
11
11
|
import { KeyStore } from "../keystore.js";
|
|
12
12
|
import { assembleCodex, assembleClaude } from "../migrate.js";
|
|
13
13
|
import { readBillingAlert } from "../billing-alert.js";
|
|
14
|
+
import { readSessionWorkingDirectory, } from "./session-launcher.js";
|
|
14
15
|
const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
15
16
|
const PRICING_URL = (process.env.ECHO_PRICING_URL || "https://echoknows.com/pricing").replace(/\/$/, "");
|
|
16
17
|
export async function createHudServer(opts = {}) {
|
|
@@ -89,15 +90,17 @@ export async function createHudServer(opts = {}) {
|
|
|
89
90
|
});
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
|
-
if (url.pathname === "/select") {
|
|
93
|
+
if (url.pathname === "/expand" || url.pathname === "/select") {
|
|
93
94
|
const id = url.searchParams.get("id");
|
|
94
|
-
|
|
95
|
+
// /select remains as a compatibility alias, but selection no longer replaces the top card:
|
|
96
|
+
// it only expands that timeline row in place.
|
|
97
|
+
monitor.expand(id && id !== "auto" ? id : null);
|
|
95
98
|
latest = monitor.snapshot();
|
|
96
99
|
const payload = `data: ${JSON.stringify(latest)}\n\n`;
|
|
97
100
|
for (const client of clients)
|
|
98
101
|
client.write(payload);
|
|
99
102
|
res.writeHead(200, { "content-type": "application/json" });
|
|
100
|
-
res.end(JSON.stringify({ ok: true,
|
|
103
|
+
res.end(JSON.stringify({ ok: true, expanded: id && id !== "auto" ? id : null }));
|
|
101
104
|
return;
|
|
102
105
|
}
|
|
103
106
|
if (url.pathname === "/viewer") {
|
|
@@ -242,6 +245,15 @@ export async function createHudServer(opts = {}) {
|
|
|
242
245
|
});
|
|
243
246
|
return;
|
|
244
247
|
}
|
|
248
|
+
if (url.pathname === "/launch") {
|
|
249
|
+
handleLaunch(req, latest, opts.onLaunchSession, res).catch((error) => {
|
|
250
|
+
if (res.writableEnded)
|
|
251
|
+
return;
|
|
252
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
253
|
+
res.end(JSON.stringify({ ok: false, reason: error instanceof Error ? error.message : "launch_failed" }));
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
245
257
|
if (url.pathname === "/checkpoint-status") {
|
|
246
258
|
handleCheckpointStatus(latest, res).catch((error) => {
|
|
247
259
|
if (res.writableEnded)
|
|
@@ -355,6 +367,44 @@ function appendSource(url, source) {
|
|
|
355
367
|
return url;
|
|
356
368
|
}
|
|
357
369
|
}
|
|
370
|
+
async function handleLaunch(req, latest, launch, res) {
|
|
371
|
+
const respond = (obj) => {
|
|
372
|
+
if (res.writableEnded)
|
|
373
|
+
return;
|
|
374
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
375
|
+
res.end(JSON.stringify(obj));
|
|
376
|
+
};
|
|
377
|
+
if (req.method !== "POST")
|
|
378
|
+
return respond({ ok: false, reason: "method_not_allowed" });
|
|
379
|
+
const origin = req.headers.origin;
|
|
380
|
+
if (origin && !/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(origin)) {
|
|
381
|
+
return respond({ ok: false, reason: "origin_not_allowed" });
|
|
382
|
+
}
|
|
383
|
+
if (!launch)
|
|
384
|
+
return respond({ ok: false, reason: "no_desktop" });
|
|
385
|
+
const active = latest.active;
|
|
386
|
+
if (!active || !active.sourcePath)
|
|
387
|
+
return respond({ ok: false, reason: "no_active_session" });
|
|
388
|
+
const body = await readRequestJson(req, 600_000);
|
|
389
|
+
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
|
390
|
+
const expectedSource = typeof body.sourcePath === "string" ? body.sourcePath : "";
|
|
391
|
+
if (!prompt || prompt.length > 512_000)
|
|
392
|
+
return respond({ ok: false, reason: "invalid_context" });
|
|
393
|
+
if (!expectedSource || expectedSource !== active.sourcePath)
|
|
394
|
+
return respond({ ok: false, reason: "session_changed" });
|
|
395
|
+
const sessionFile = resolveSessionFile(active.client, active.sourcePath);
|
|
396
|
+
const assembled = active.client === "codex" ? assembleCodex(sessionFile) : assembleClaude(sessionFile);
|
|
397
|
+
const cwd = readSessionWorkingDirectory(sessionFile) || (assembled?.cwd && path.isAbsolute(assembled.cwd) ? assembled.cwd : "");
|
|
398
|
+
if (!assembled || !cwd)
|
|
399
|
+
return respond({ ok: false, reason: "workspace_missing" });
|
|
400
|
+
const result = await launch({
|
|
401
|
+
client: active.client,
|
|
402
|
+
cwd,
|
|
403
|
+
prompt,
|
|
404
|
+
title: assembled.title,
|
|
405
|
+
});
|
|
406
|
+
respond(result);
|
|
407
|
+
}
|
|
358
408
|
async function handleCheckpointStatus(latest, res) {
|
|
359
409
|
const respond = (obj) => {
|
|
360
410
|
if (res.writableEnded)
|
|
@@ -668,3 +718,29 @@ function serveHudAsset(pathname, res) {
|
|
|
668
718
|
fs.createReadStream(assetPath).pipe(res);
|
|
669
719
|
return true;
|
|
670
720
|
}
|
|
721
|
+
function readRequestJson(req, maxBytes) {
|
|
722
|
+
return new Promise((resolve, reject) => {
|
|
723
|
+
const chunks = [];
|
|
724
|
+
let size = 0;
|
|
725
|
+
req.on("data", (chunk) => {
|
|
726
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
727
|
+
size += buffer.length;
|
|
728
|
+
if (size > maxBytes) {
|
|
729
|
+
reject(new Error("request_too_large"));
|
|
730
|
+
req.destroy();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
chunks.push(buffer);
|
|
734
|
+
});
|
|
735
|
+
req.on("end", () => {
|
|
736
|
+
try {
|
|
737
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
738
|
+
resolve(typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {});
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
reject(new Error("invalid_json"));
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
req.on("error", reject);
|
|
745
|
+
});
|
|
746
|
+
}
|