baychat 0.11.2 → 0.11.4
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/README.md +18 -3
- package/dist/index.js +11 -4
- package/dist/relay/adapters.js +56 -1
- package/dist/relay/commands.js +55 -2
- package/dist/relay/daemon.js +83 -10
- package/dist/relay/resume.js +615 -0
- package/dist/relay/updates.js +28 -11
- package/dist/relay/watermarks.js +113 -0
- package/dist/runtime-install.js +1 -1
- package/dist/runtimes.js +76 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ per session, never one that another integration already uses.
|
|
|
69
69
|
| `baychat relay start [--foreground]` | Run the **relay**: one long-poll for this whole machine that wakes local sessions the moment a message arrives. Installs a systemd user unit so it returns after a reboot (see [Relay](#relay)) |
|
|
70
70
|
| `baychat relay status` | Sessions, cursor, and any **delivery pending** — messages that reached this box and that nothing answered (exit 2 if any) |
|
|
71
71
|
| `baychat relay stop` | Stop the relay and disable it at boot |
|
|
72
|
-
| `baychat relay attach --session <name> [--runtime claude\|codex\|hermes] [--resume-id <id>] [--conversation <id>] [--timeout <sec>]` | Register this session with the relay and block until it is woken (exit 0) or the wait lapses (exit 2) |
|
|
72
|
+
| `baychat relay attach --session <name> [--runtime claude\|codex\|cursor\|hermes] [--resume-id <id>] [--conversation <id>] [--timeout <sec>]` | Register this session with the relay and block until it is woken (exit 0) or the wait lapses (exit 2) |
|
|
73
73
|
| `baychat mcp` | Run a local **stdio MCP server** so MCP-aware clients (Claude Desktop, Claude Code, Cursor) get BayChat as native tools (see below) |
|
|
74
74
|
| `baychat mcp-config [--client codex\|cursor\|desktop]` | Print a paste-ready config that points another MCP client at the **remote** BayChat server. No `--client` lists what's supported (see [Other MCP clients](#other-mcp-clients)) |
|
|
75
75
|
|
|
@@ -102,8 +102,23 @@ with its full context. Run it as a background process from your session and you
|
|
|
102
102
|
get near-instant delivery.
|
|
103
103
|
|
|
104
104
|
When no session is attached, the relay falls back to a **headless resume**
|
|
105
|
-
(`claude -p --resume`, `codex exec resume`)
|
|
106
|
-
session.
|
|
105
|
+
(`claude -p --resume`, `codex exec resume`) — but only once it knows *which*
|
|
106
|
+
runtime session that BayChat session is. It finds out three ways, and refuses
|
|
107
|
+
rather than guess:
|
|
108
|
+
|
|
109
|
+
1. **You tell it**: `--resume-id <id>` on attach.
|
|
110
|
+
2. **The session tells it**: `attach` reads the id out of the environment its own
|
|
111
|
+
runtime gave it (`CLAUDE_CODE_SESSION_ID` for Claude Code), so no flag is
|
|
112
|
+
needed. The installed skill passes it explicitly as well.
|
|
113
|
+
3. **The relay finds it**: it searches the runtime's own state —
|
|
114
|
+
`~/.claude/projects`, `~/.codex/sessions` — for the session whose transcript
|
|
115
|
+
records *this* attach. Sub-agent threads are skipped, and if two sessions
|
|
116
|
+
claimed the name it names both and resumes neither.
|
|
117
|
+
|
|
118
|
+
Nothing here ranks by recency: `claude --continue` and `codex exec resume --last`
|
|
119
|
+
mean "whoever ran last in this directory", which on a working machine is
|
|
120
|
+
routinely a different session. `relay status` prints where each id came from so
|
|
121
|
+
you can see which of the three you got.
|
|
107
122
|
|
|
108
123
|
Three things it will not do:
|
|
109
124
|
|
package/dist/index.js
CHANGED
|
@@ -60,15 +60,22 @@ Usage:
|
|
|
60
60
|
process instead
|
|
61
61
|
baychat relay status Transport, sessions, cursor, and any DELIVERY
|
|
62
62
|
PENDING — messages that reached this box and
|
|
63
|
-
that nothing answered (exit 2 if any pending)
|
|
63
|
+
that nothing answered (exit 2 if any pending).
|
|
64
|
+
Each session also prints WHERE its resume id
|
|
65
|
+
came from, so "we can wake this headlessly"
|
|
66
|
+
is a claim you can check
|
|
64
67
|
baychat relay stop Stop the relay and disable it at boot
|
|
65
68
|
baychat relay attach --session <name> [--runtime claude|codex|hermes]
|
|
66
69
|
[--resume-id <id>] [--timeout <sec>]
|
|
67
70
|
Register this session with the relay and block
|
|
68
71
|
until it is woken (exit 0), or the wait lapses
|
|
69
|
-
(exit 2).
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
(exit 2). Without --resume-id the session is
|
|
73
|
+
asked to identify itself from its runtime's
|
|
74
|
+
environment ($CLAUDE_CODE_SESSION_ID for Claude
|
|
75
|
+
Code); failing that the relay searches the
|
|
76
|
+
runtime's own session state when a message
|
|
77
|
+
arrives. A session it cannot identify is
|
|
78
|
+
reported pending, never guessed at
|
|
72
79
|
|
|
73
80
|
Connect flow: in BayChat, open the agent -> Connect -> copy the pairing code,
|
|
74
81
|
then run \`baychat pair <code>\`. Pairing rotates the agent token; use a
|
package/dist/relay/adapters.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KNOWN_RUNTIMES = void 0;
|
|
3
4
|
exports.buildWakePrompt = buildWakePrompt;
|
|
4
5
|
exports.adapterFor = adapterFor;
|
|
5
6
|
exports.isKnownRuntime = isKnownRuntime;
|
|
6
7
|
exports.runHeadless = runHeadless;
|
|
7
8
|
const child_process_1 = require("child_process");
|
|
9
|
+
const resume_1 = require("./resume");
|
|
8
10
|
/** How long a headless turn may run before the relay gives up on it. */
|
|
9
11
|
const HEADLESS_TIMEOUT_MS = 10 * 60_000;
|
|
10
12
|
/**
|
|
@@ -40,6 +42,9 @@ const claudeAdapter = {
|
|
|
40
42
|
}
|
|
41
43
|
return { ok: true };
|
|
42
44
|
},
|
|
45
|
+
discoverResume(target, opts) {
|
|
46
|
+
return (0, resume_1.discoverClaudeResume)(target.name, opts);
|
|
47
|
+
},
|
|
43
48
|
headlessCommand(target, prompt) {
|
|
44
49
|
return { file: "claude", args: ["-p", prompt, "--resume", target.resumeId] };
|
|
45
50
|
},
|
|
@@ -55,10 +60,47 @@ const codexAdapter = {
|
|
|
55
60
|
}
|
|
56
61
|
return { ok: true };
|
|
57
62
|
},
|
|
63
|
+
discoverResume(target, opts) {
|
|
64
|
+
return (0, resume_1.discoverCodexResume)(target.name, opts);
|
|
65
|
+
},
|
|
58
66
|
headlessCommand(target, prompt) {
|
|
59
67
|
return { file: "codex", args: ["exec", "resume", target.resumeId, prompt] };
|
|
60
68
|
},
|
|
61
69
|
};
|
|
70
|
+
/**
|
|
71
|
+
* Cursor can be woken, and cannot be resumed. Both halves matter.
|
|
72
|
+
*
|
|
73
|
+
* A Cursor session that launched `baychat relay attach` in the background is
|
|
74
|
+
* woken exactly like Claude Code or Codex: the attach socket does not care which
|
|
75
|
+
* runtime wrote to it, and the exit of the attach process is what re-invokes the
|
|
76
|
+
* session. That covers the ordinary case — a developer with Cursor open — which
|
|
77
|
+
* is why refusing to let Cursor attach at all was the wrong reading of "Cursor
|
|
78
|
+
* cannot be resumed headlessly".
|
|
79
|
+
*
|
|
80
|
+
* What Cursor has no equivalent of is `claude -p --resume <id>`: no documented
|
|
81
|
+
* entry point continues one specific Cursor conversation from outside it. So a
|
|
82
|
+
* wake that finds it detached is `pending`, with the reason — never a fresh
|
|
83
|
+
* process answering a room it has no memory of.
|
|
84
|
+
*/
|
|
85
|
+
const cursorAdapter = {
|
|
86
|
+
runtime: "cursor",
|
|
87
|
+
canResume() {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: "Cursor has no headless resume — it is reachable only while `baychat relay attach` is running, so attach must be re-armed after every wake",
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
async discoverResume() {
|
|
94
|
+
// Nothing to discover: Cursor writes no on-disk session state this package
|
|
95
|
+
// can identify a conversation from, and a search that cannot succeed is
|
|
96
|
+
// only a way to produce a confident-looking wrong answer.
|
|
97
|
+
return { ok: false, reason: "Cursor records no resumable session id on this machine" };
|
|
98
|
+
},
|
|
99
|
+
headlessCommand() {
|
|
100
|
+
// Unreachable: the daemon consults canResume first and reports pending.
|
|
101
|
+
throw new Error("cursor has no headless command");
|
|
102
|
+
},
|
|
103
|
+
};
|
|
62
104
|
/**
|
|
63
105
|
* Hermes is self-hosted and already receives messages through its own agent
|
|
64
106
|
* webhook — there is no local process for the relay to resume. When Hermes is
|
|
@@ -74,6 +116,11 @@ const hermesAdapter = {
|
|
|
74
116
|
reason: "Hermes is self-hosted and has no local headless resume — it must attach to the relay, or receive the message over its own agent webhook",
|
|
75
117
|
};
|
|
76
118
|
},
|
|
119
|
+
async discoverResume() {
|
|
120
|
+
// Nothing to discover: Hermes runs somewhere else and leaves no local
|
|
121
|
+
// transcript. Searching would only produce a confident-looking wrong answer.
|
|
122
|
+
return { ok: false, reason: "Hermes keeps no local session state on this machine" };
|
|
123
|
+
},
|
|
77
124
|
headlessCommand() {
|
|
78
125
|
// Unreachable: the daemon consults canResume first and reports pending.
|
|
79
126
|
throw new Error("hermes has no headless command");
|
|
@@ -82,13 +129,21 @@ const hermesAdapter = {
|
|
|
82
129
|
const ADAPTERS = {
|
|
83
130
|
claude: claudeAdapter,
|
|
84
131
|
codex: codexAdapter,
|
|
132
|
+
cursor: cursorAdapter,
|
|
85
133
|
hermes: hermesAdapter,
|
|
86
134
|
};
|
|
87
135
|
function adapterFor(runtime) {
|
|
88
136
|
return ADAPTERS[runtime];
|
|
89
137
|
}
|
|
138
|
+
/** Every value `relay attach --runtime` accepts, for the error that lists them. */
|
|
139
|
+
exports.KNOWN_RUNTIMES = Object.keys(ADAPTERS);
|
|
140
|
+
/**
|
|
141
|
+
* Derived from the adapter table rather than restated, because a second copy of
|
|
142
|
+
* this list is exactly how Cursor came to be told to attach with a value the
|
|
143
|
+
* relay rejected. Having an adapter IS being attachable.
|
|
144
|
+
*/
|
|
90
145
|
function isKnownRuntime(value) {
|
|
91
|
-
return
|
|
146
|
+
return Object.prototype.hasOwnProperty.call(ADAPTERS, value);
|
|
92
147
|
}
|
|
93
148
|
/**
|
|
94
149
|
* Run a headless turn to completion.
|
package/dist/relay/commands.js
CHANGED
|
@@ -46,6 +46,7 @@ const path = __importStar(require("path"));
|
|
|
46
46
|
const util_1 = require("util");
|
|
47
47
|
const adapters_1 = require("./adapters");
|
|
48
48
|
const daemon_1 = require("./daemon");
|
|
49
|
+
const resume_1 = require("./resume");
|
|
49
50
|
const socket_1 = require("./socket");
|
|
50
51
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
51
52
|
const UNIT_NAME = "baychat-relay.service";
|
|
@@ -202,6 +203,7 @@ async function cmdRelayStatus() {
|
|
|
202
203
|
for (const s of status.sessions) {
|
|
203
204
|
const state = s.attached ? "attached" : s.resumeId ? "detached (headless resume ready)" : "detached (no resume id)";
|
|
204
205
|
console.log(` ${s.name} [${s.runtime}] ${state}`);
|
|
206
|
+
console.log(` ${resumeLabel(s)}`);
|
|
205
207
|
}
|
|
206
208
|
// Pending is the point of the whole command: these are messages that reached
|
|
207
209
|
// this machine and that nobody answered.
|
|
@@ -215,6 +217,23 @@ async function cmdRelayStatus() {
|
|
|
215
217
|
}
|
|
216
218
|
return 0;
|
|
217
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Where this session's resume id came from, in one line.
|
|
222
|
+
*
|
|
223
|
+
* "We can wake this headlessly" is a claim about identity, and a human has to
|
|
224
|
+
* be able to judge it: a session that named itself out of its own environment
|
|
225
|
+
* is a different level of confidence from one the relay matched to a transcript
|
|
226
|
+
* on disk. Printing only the id would hide that difference, and printing
|
|
227
|
+
* nothing would hide the whole question — which is how two production sessions
|
|
228
|
+
* sat detached and unwakeable without it being anyone's obvious problem.
|
|
229
|
+
*/
|
|
230
|
+
function resumeLabel(s) {
|
|
231
|
+
if (!s.resumeId) {
|
|
232
|
+
return "resume: none — a wake while detached is reported DELIVERY PENDING, not delivered";
|
|
233
|
+
}
|
|
234
|
+
const provenance = s.resumeEvidence ?? s.resumeSource ?? "origin not recorded (registered before provenance existed)";
|
|
235
|
+
return `resume: ${s.resumeId} — ${provenance}`;
|
|
236
|
+
}
|
|
218
237
|
/**
|
|
219
238
|
* Which event source is live, in one line.
|
|
220
239
|
*
|
|
@@ -260,10 +279,11 @@ async function cmdRelayStop() {
|
|
|
260
279
|
*/
|
|
261
280
|
async function cmdRelayAttach(opts) {
|
|
262
281
|
if (!(0, adapters_1.isKnownRuntime)(opts.runtime)) {
|
|
263
|
-
console.log(`unknown runtime "${opts.runtime}" — expected
|
|
282
|
+
console.log(`unknown runtime "${opts.runtime}" — expected one of: ${adapters_1.KNOWN_RUNTIMES.join(", ")}`);
|
|
264
283
|
return 1;
|
|
265
284
|
}
|
|
266
285
|
const runtime = opts.runtime;
|
|
286
|
+
const resume = await resolveAttachResumeId(runtime, opts.resumeId, opts.discovery);
|
|
267
287
|
let sock;
|
|
268
288
|
try {
|
|
269
289
|
sock = await connectOrFail();
|
|
@@ -320,8 +340,41 @@ async function cmdRelayAttach(opts) {
|
|
|
320
340
|
type: "attach",
|
|
321
341
|
session: opts.session,
|
|
322
342
|
runtime,
|
|
323
|
-
resumeId:
|
|
343
|
+
resumeId: resume.ok ? resume.resumeId : undefined,
|
|
344
|
+
resumeSource: resume.ok ? resume.source : undefined,
|
|
345
|
+
resumeEvidence: resume.ok ? resume.evidence : undefined,
|
|
346
|
+
resumeCwd: resume.ok ? resume.cwd : undefined,
|
|
324
347
|
cwd: process.cwd(),
|
|
325
348
|
});
|
|
326
349
|
});
|
|
327
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* Decide what resume id this attach registers, and say so out loud.
|
|
353
|
+
*
|
|
354
|
+
* An explicit `--resume-id` always wins: a human (or a skill that knows the
|
|
355
|
+
* runtime's own variable) said this is the session, and second-guessing them
|
|
356
|
+
* would break the escape hatch. Otherwise the session is asked to identify
|
|
357
|
+
* itself out of its runtime's environment — the only method that cannot name
|
|
358
|
+
* somebody else's session, because it runs inside this one.
|
|
359
|
+
*
|
|
360
|
+
* Both outcomes print. A silent "no resume id" is how a session ends up
|
|
361
|
+
* unwakeable without anyone noticing until a message goes unanswered.
|
|
362
|
+
*/
|
|
363
|
+
async function resolveAttachResumeId(runtime, explicit, discovery) {
|
|
364
|
+
const given = explicit?.trim();
|
|
365
|
+
if (given) {
|
|
366
|
+
// Not validated: `--resume-id` is the escape hatch, and a runtime whose ids
|
|
367
|
+
// are not uuids must still be able to use it.
|
|
368
|
+
console.log(`Resume id: ${given} (passed with --resume-id)`);
|
|
369
|
+
return { ok: true, resumeId: given, source: "flag", evidence: "passed with --resume-id" };
|
|
370
|
+
}
|
|
371
|
+
const found = await (0, resume_1.resumeIdFromSessionEnv)(runtime, discovery);
|
|
372
|
+
if (found.ok) {
|
|
373
|
+
console.log(`Resume id: ${found.resumeId} (${found.evidence})`);
|
|
374
|
+
return found;
|
|
375
|
+
}
|
|
376
|
+
console.log(`No resume id: ${found.reason}.`);
|
|
377
|
+
console.log(" The relay will search this runtime's own session state when a message arrives while you are detached,");
|
|
378
|
+
console.log(" and report DELIVERY PENDING rather than resume a session it cannot identify.");
|
|
379
|
+
return found;
|
|
380
|
+
}
|
package/dist/relay/daemon.js
CHANGED
|
@@ -42,14 +42,23 @@ const queue_1 = require("./queue");
|
|
|
42
42
|
const registry_1 = require("./registry");
|
|
43
43
|
const socket_1 = require("./socket");
|
|
44
44
|
const transport_1 = require("./transport");
|
|
45
|
+
const watermarks_1 = require("./watermarks");
|
|
46
|
+
/**
|
|
47
|
+
* How long a failed resume-id discovery is believed before it is retried.
|
|
48
|
+
*
|
|
49
|
+
* A session whose runtime left no identifying trace stays undiscoverable, so
|
|
50
|
+
* re-scanning every transcript on the box per message would make an
|
|
51
|
+
* unanswerable delivery expensive as well as pending.
|
|
52
|
+
*/
|
|
53
|
+
const DISCOVERY_INTERVAL_MS = 10 * 60_000;
|
|
45
54
|
class RelayDaemon {
|
|
46
55
|
registry = new registry_1.SessionRegistry();
|
|
47
56
|
queue;
|
|
48
57
|
abort = new AbortController();
|
|
49
58
|
/** Live attach sockets by session name. */
|
|
50
59
|
attached = new Map();
|
|
51
|
-
/** Per
|
|
52
|
-
watermarks = new
|
|
60
|
+
/** Per (session, conversation) delivery positions — the 409 catch-up baseline. */
|
|
61
|
+
watermarks = new watermarks_1.Watermarks();
|
|
53
62
|
pending = [];
|
|
54
63
|
server;
|
|
55
64
|
startedAt = new Date().toISOString();
|
|
@@ -62,9 +71,15 @@ class RelayDaemon {
|
|
|
62
71
|
transportDetail = "starting";
|
|
63
72
|
/** Live session names from the last poll, for pruning and for `relay status`. */
|
|
64
73
|
liveSessions = [];
|
|
74
|
+
/** Why discovery last refused, per session, and when — throttles the rescan. */
|
|
75
|
+
discoveryReasons = new Map();
|
|
76
|
+
discovery;
|
|
77
|
+
spawnHeadless;
|
|
65
78
|
log;
|
|
66
79
|
constructor(opts = {}) {
|
|
67
80
|
this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
|
|
81
|
+
this.discovery = opts.discovery ?? {};
|
|
82
|
+
this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
|
|
68
83
|
this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
|
|
69
84
|
this.lastError = `delivery failed for ${session}: ${errText(err)}`;
|
|
70
85
|
this.log(this.lastError);
|
|
@@ -119,6 +134,9 @@ class RelayDaemon {
|
|
|
119
134
|
},
|
|
120
135
|
onSessions: (names) => {
|
|
121
136
|
this.liveSessions = names;
|
|
137
|
+
// A recovery must cover every live session, including one this relay has
|
|
138
|
+
// never had to route an event to — skipping it re-baselines it at "now".
|
|
139
|
+
this.watermarks.noteLiveSessions(names);
|
|
122
140
|
const dropped = this.registry.pruneToLive(names);
|
|
123
141
|
for (const name of dropped)
|
|
124
142
|
this.log(`session ended server-side, dropped: ${name}`);
|
|
@@ -140,7 +158,7 @@ class RelayDaemon {
|
|
|
140
158
|
});
|
|
141
159
|
}
|
|
142
160
|
/**
|
|
143
|
-
* Route one event and record
|
|
161
|
+
* Route one event and record this session's position in that conversation.
|
|
144
162
|
*
|
|
145
163
|
* Routing is the SERVER's answer (`sessionName`), not a local guess: the
|
|
146
164
|
* device poll already knows which session agent each event was queued for.
|
|
@@ -150,9 +168,10 @@ class RelayDaemon {
|
|
|
150
168
|
*/
|
|
151
169
|
onEvent(event) {
|
|
152
170
|
const { conversationId, sessionName, message } = event;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
171
|
+
// Per SESSION, not per conversation: a sibling in the same room that was
|
|
172
|
+
// behind must not have its recovery window truncated by a session that was
|
|
173
|
+
// ahead. An event with no session still registers the room — see Watermarks.
|
|
174
|
+
this.watermarks.record(sessionName, conversationId, message.createdAt);
|
|
156
175
|
if (!sessionName) {
|
|
157
176
|
this.log(`event ${message.id} carries no session (renamed or ended mid-poll) — ignoring`);
|
|
158
177
|
return;
|
|
@@ -183,15 +202,25 @@ class RelayDaemon {
|
|
|
183
202
|
return;
|
|
184
203
|
}
|
|
185
204
|
const adapter = (0, adapters_1.adapterFor)(target.runtime);
|
|
186
|
-
|
|
205
|
+
// A target with no resume id is the unbounded failure this whole path
|
|
206
|
+
// exists to close: without one, `canResume` says no and the message waits
|
|
207
|
+
// for a human. Ask the runtime's own on-disk state who this session is
|
|
208
|
+
// before accepting that answer.
|
|
209
|
+
const resolved = await this.resolveResume(target);
|
|
210
|
+
const check = adapter.canResume(resolved);
|
|
187
211
|
if (!check.ok) {
|
|
188
212
|
// The honest outcome: it reached this box, and nothing answered it.
|
|
189
|
-
|
|
213
|
+
const why = this.discoveryReasons.get(session)?.reason;
|
|
214
|
+
const reason = why ? `${check.reason ?? "cannot resume"}; discovery: ${why}` : (check.reason ?? "cannot resume");
|
|
215
|
+
this.record({ kind: "pending", session, reason }, session, batch);
|
|
190
216
|
return;
|
|
191
217
|
}
|
|
192
218
|
const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch);
|
|
193
|
-
const { file, args } = adapter.headlessCommand(
|
|
194
|
-
|
|
219
|
+
const { file, args } = adapter.headlessCommand(resolved, prompt);
|
|
220
|
+
// The session's own directory when we know it: `cwd` is only where the
|
|
221
|
+
// attach process ran, and `codex exec` refuses to start outside a trusted
|
|
222
|
+
// directory at all.
|
|
223
|
+
const { exitCode, stderr } = await this.spawnHeadless(file, args, { cwd: resolved.resumeCwd ?? resolved.cwd });
|
|
195
224
|
if (exitCode !== 0) {
|
|
196
225
|
// A non-zero headless turn did not necessarily reply. Recording it as
|
|
197
226
|
// delivered would claim an answer we cannot evidence.
|
|
@@ -200,6 +229,43 @@ class RelayDaemon {
|
|
|
200
229
|
}
|
|
201
230
|
this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
|
|
202
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Fill in a missing resume id from the runtime's on-disk state, at most once
|
|
234
|
+
* every `DISCOVERY_INTERVAL_MS` per session.
|
|
235
|
+
*
|
|
236
|
+
* Throttled because a refusal is the common steady state — a session whose
|
|
237
|
+
* runtime left no trace will never become discoverable — and re-reading every
|
|
238
|
+
* transcript on the box for each arriving message would make an unanswerable
|
|
239
|
+
* delivery expensive as well as pending.
|
|
240
|
+
*
|
|
241
|
+
* A discovered id is persisted with its provenance, so the search happens
|
|
242
|
+
* once per session rather than once per wake, and `relay status` can show a
|
|
243
|
+
* human where the id came from.
|
|
244
|
+
*/
|
|
245
|
+
async resolveResume(target) {
|
|
246
|
+
if (target.resumeId)
|
|
247
|
+
return target;
|
|
248
|
+
const last = this.discoveryReasons.get(target.name);
|
|
249
|
+
if (last && Date.now() - last.at < DISCOVERY_INTERVAL_MS)
|
|
250
|
+
return target;
|
|
251
|
+
const result = await (0, adapters_1.adapterFor)(target.runtime).discoverResume(target, this.discovery);
|
|
252
|
+
if (!result.ok) {
|
|
253
|
+
this.discoveryReasons.set(target.name, { at: Date.now(), reason: result.reason });
|
|
254
|
+
this.log(`no resume id for ${target.name}: ${result.reason}`);
|
|
255
|
+
return target;
|
|
256
|
+
}
|
|
257
|
+
this.discoveryReasons.delete(target.name);
|
|
258
|
+
const updated = this.registry.upsert({
|
|
259
|
+
name: target.name,
|
|
260
|
+
runtime: target.runtime,
|
|
261
|
+
resumeId: result.resumeId,
|
|
262
|
+
resumeSource: result.source,
|
|
263
|
+
resumeEvidence: result.evidence,
|
|
264
|
+
resumeCwd: result.cwd,
|
|
265
|
+
});
|
|
266
|
+
this.log(`resume id for ${target.name}: ${result.resumeId} (${result.evidence})`);
|
|
267
|
+
return updated;
|
|
268
|
+
}
|
|
203
269
|
record(outcome, session, batch) {
|
|
204
270
|
if (outcome.kind === "woken") {
|
|
205
271
|
this.delivered += batch.length;
|
|
@@ -233,8 +299,15 @@ class RelayDaemon {
|
|
|
233
299
|
name: frame.session,
|
|
234
300
|
runtime: frame.runtime,
|
|
235
301
|
resumeId: frame.resumeId,
|
|
302
|
+
resumeSource: frame.resumeSource,
|
|
303
|
+
resumeEvidence: frame.resumeEvidence,
|
|
304
|
+
resumeCwd: frame.resumeCwd,
|
|
236
305
|
cwd: frame.cwd,
|
|
237
306
|
});
|
|
307
|
+
// A session that names itself supersedes anything discovery guessed
|
|
308
|
+
// for it, so drop the throttle and let the next wake use the new id.
|
|
309
|
+
if (frame.resumeId)
|
|
310
|
+
this.discoveryReasons.delete(frame.session);
|
|
238
311
|
this.attached.set(frame.session, sock);
|
|
239
312
|
this.registry.setAttached(frame.session, true);
|
|
240
313
|
(0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
|