@mnemom/mnemom 0.17.34 → 0.17.35

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 (87) hide show
  1. package/dist/commands/agent-config.js +3 -0
  2. package/dist/commands/agent-goal.d.ts +8 -3
  3. package/dist/commands/agent-goal.js +22 -8
  4. package/dist/commands/agent-run.d.ts +10 -2
  5. package/dist/commands/agent-run.js +19 -6
  6. package/dist/commands/agent-slack.js +32 -0
  7. package/dist/commands/durable-inbox.d.ts +51 -0
  8. package/dist/commands/durable-inbox.js +134 -0
  9. package/dist/commands/durable-questions.d.ts +64 -0
  10. package/dist/commands/durable-questions.js +161 -0
  11. package/dist/durable/seat-proxy.d.ts +12 -1
  12. package/dist/durable/seat-proxy.js +55 -9
  13. package/dist/durable/seat-socket.d.ts +20 -2
  14. package/dist/durable/seat-socket.js +58 -0
  15. package/dist/durable/turn-chain.d.ts +8 -0
  16. package/dist/durable/turn-chain.js +25 -4
  17. package/dist/host/client.d.ts +41 -0
  18. package/dist/host/client.js +7 -0
  19. package/dist/host/control.d.ts +7 -0
  20. package/dist/host/control.js +15 -0
  21. package/dist/host/daemon.d.ts +20 -0
  22. package/dist/host/daemon.js +74 -1
  23. package/dist/host/inbox-delivery.d.ts +174 -0
  24. package/dist/host/inbox-delivery.js +453 -0
  25. package/dist/host/run.d.ts +18 -0
  26. package/dist/host/run.js +61 -0
  27. package/dist/host/s1-names.d.ts +2 -0
  28. package/dist/host/s1-names.js +2 -0
  29. package/dist/host/tmux.d.ts +7 -0
  30. package/dist/host/tmux.js +19 -0
  31. package/dist/index.js +59 -1
  32. package/dist/lib/api.d.ts +5 -0
  33. package/dist/lib/api.js +16 -0
  34. package/dist/lib/durable/api.d.ts +11 -0
  35. package/dist/lib/durable/api.js +28 -3
  36. package/dist/lib/durable/gate.d.ts +1 -1
  37. package/dist/lib/durable/gate.js +8 -1
  38. package/dist/lib/durable/live.d.ts +5 -0
  39. package/dist/lib/durable/live.js +19 -0
  40. package/dist/lib/experimental.js +3 -0
  41. package/dist/lib/goal-api.d.ts +11 -4
  42. package/dist/lib/goal-api.js +4 -3
  43. package/dist/lib/launcher-config.d.ts +5 -0
  44. package/dist/lib/launcher-config.js +17 -2
  45. package/dist/lib/prompt.d.ts +4 -2
  46. package/dist/lib/prompt.js +15 -5
  47. package/dist/lib/runner/job.d.ts +2 -2
  48. package/dist/lib/runner/job.js +16 -3
  49. package/dist/lib/runner/pr-body.d.ts +17 -0
  50. package/dist/lib/runner/pr-body.js +42 -0
  51. package/dist/lib/runner/proxy.d.ts +18 -3
  52. package/dist/lib/runner/proxy.js +24 -5
  53. package/dist/lib/scan/record.d.ts +60 -9
  54. package/dist/lib/scan/record.js +157 -39
  55. package/dist/lib/scan/report.d.ts +6 -8
  56. package/dist/lib/scan/report.js +21 -7
  57. package/dist/lib/scan/share.d.ts +5 -1
  58. package/dist/lib/scan/share.js +15 -5
  59. package/dist/lib/slack-durable.d.ts +123 -0
  60. package/dist/lib/slack-durable.js +237 -0
  61. package/dist/lib/slack-router.d.ts +7 -0
  62. package/dist/lib/slack-router.js +50 -4
  63. package/dist/vendor/durable-contract/index.js +992 -34
  64. package/dist/vendor/shared/durable-contract/admission.d.ts +81 -2
  65. package/dist/vendor/shared/durable-contract/admission.js +319 -2
  66. package/dist/vendor/shared/durable-contract/cli-surface.js +21 -0
  67. package/dist/vendor/shared/durable-contract/codes.d.ts +60 -0
  68. package/dist/vendor/shared/durable-contract/codes.js +22 -0
  69. package/dist/vendor/shared/durable-contract/events.d.ts +4 -2
  70. package/dist/vendor/shared/durable-contract/events.js +55 -1
  71. package/dist/vendor/shared/durable-contract/freeze-record.d.ts +14 -0
  72. package/dist/vendor/shared/durable-contract/inbox.d.ts +299 -0
  73. package/dist/vendor/shared/durable-contract/inbox.js +462 -0
  74. package/dist/vendor/shared/durable-contract/index.d.ts +2 -0
  75. package/dist/vendor/shared/durable-contract/index.js +2 -0
  76. package/dist/vendor/shared/durable-contract/question.d.ts +218 -0
  77. package/dist/vendor/shared/durable-contract/question.js +373 -0
  78. package/dist/vendor/shared/durable-contract/routes.d.ts +44 -2
  79. package/dist/vendor/shared/durable-contract/routes.js +43 -1
  80. package/dist/vendor/shared/durable-contract/turn.d.ts +15 -1
  81. package/dist/vendor/shared/durable-contract/turn.js +33 -1
  82. package/dist/vendor/shared/durable-contract/vectors.d.ts +45 -0
  83. package/dist/vendor/shared/durable-contract/vectors.js +49 -0
  84. package/package.json +1 -1
  85. package/plugins/integrity-manifest.json +6 -2
  86. package/plugins/mnemom/hooks/hooks.json +12 -0
  87. package/plugins/mnemom/hooks/seat-ask.mjs +221 -0
@@ -0,0 +1,453 @@
1
+ /**
2
+ * The homed inbox delivery (S1 amendment 60, signed off by Wassim 2026-11-02): the host daemon gives each
3
+ * seat it hosts the messages in its session's durable inbox, one at a time, as the seat's next
4
+ * prompt, and acks each once the turn answering it has ended. It replaces the Slack wake's pane
5
+ * typing (draft PR #3145's host/seat-wake.ts): Slack, the CLI and allowlisted agents all send
6
+ * through ROUTES.own_inbox_send, and the api, not a local file, holds what is waiting.
7
+ *
8
+ * Per seat, each pass:
9
+ * 1. Nothing in flight: when the seat is idle (its proxy said hello, the transcript's last turn
10
+ * finished, claude's input line is drawn and no turn is running) on two consecutive passes,
11
+ * read exec_inbox (content-free) and, when anything waits, exec_inbox_take one message. The api
12
+ * marks it delivered at the seat's epoch and journals inbox.delivered BEFORE the paste, so its
13
+ * sha is in the resolver's inbox_open by the time the turn reaches the gateway.
14
+ * 2. Paste the api's text (bracketed paste, then Enter), with every control character but TAB
15
+ * and LF removed first (`pasteSafe`), so no byte of a body can end the bracketed paste. Its
16
+ * first line is `[mnemom inbox <sha>] from ...`; the seat proxy stamps `inbox=<sha>` on that
17
+ * transcript entry (durable/turn-chain.ts), which A60 admits unattended while the sha is open.
18
+ * 3. Confirm the prompt landed: a transcript entry carrying the marker within CONFIRM_MS, else
19
+ * paste again while the seat is idle, waiting twice as long after each paste (at most
20
+ * PASTE_TRIES = INBOX_DELIVERY_LIMIT pastes). After that it is released (unconfirmed), so the
21
+ * api offers it again or, at its delivery limit, marks it dead. A paste whose Enter failed is
22
+ * submitted with Enter alone, never pasted again; a seat that is never idle again releases an
23
+ * unconfirmed message after INBOX_STALL_MS.
24
+ * 4. Once the turn after the marker has ended (idle again), exec_inbox_ack. A 409
25
+ * durable_inbox_not_open (already acked, or the epoch moved) also settles it.
26
+ *
27
+ * What is in flight is kept per session in `stateDir` (inbox id, sha, epoch, text), so a daemon
28
+ * restart neither pastes a message twice nor loses its ack. A record from an older epoch is dropped:
29
+ * the api re-offers that message to the new seat. When the dropped record's prompt had already
30
+ * reached the transcript, its sha is kept as handled first, and the re-offer is acked at the new
31
+ * epoch instead of pasted, so the conversation never sees one message twice.
32
+ *
33
+ * A take whose answer is lost or malformed may still have marked messages delivered at this epoch.
34
+ * Nothing is dropped on it: any message the answer does name is released (undeliverable), and the
35
+ * empty-take latch below lapses after INBOX_REOFFER_S, when the api re-offers the rest.
36
+ *
37
+ * Pure over injected deps; host/run.ts wires the executor API, tmux, the transcript and the log.
38
+ */
39
+ import * as fs from "node:fs";
40
+ import * as path from "node:path";
41
+ import { INBOX_DELIVERY_LIMIT, INBOX_REOFFER_S, inboxAckResponseOk, inboxMarkerOf, inboxReleaseResponseOk, inboxSummaryResponseOk, inboxTakeResponseOk, } from "../vendor/shared/durable-contract/inbox.js";
42
+ import { HEX64_RE, UUID_RE } from "../vendor/shared/durable-contract/canonical.js";
43
+ import { CODE } from "./s1-names.js";
44
+ /** How long a first paste has to show up in the transcript before it is pasted again (doubling). */
45
+ export const CONFIRM_MS = 15_000;
46
+ /** The longest wait between two pastes of one message. */
47
+ export const CONFIRM_MAX_MS = 300_000;
48
+ /** Pastes of one taken message at most (the api's own take limit, S1 INBOX_DELIVERY_LIMIT). */
49
+ export const PASTE_TRIES = INBOX_DELIVERY_LIMIT;
50
+ /** How long paste number `tries` (1-based) has to show up before the next one. */
51
+ export const confirmWindow = (tries) => Math.min(CONFIRM_MS * 2 ** Math.max(0, tries - 1), CONFIRM_MAX_MS);
52
+ /**
53
+ * The delivery text as it may be pasted into a terminal: CR LF and CR become LF, and every other
54
+ * control character but TAB and LF (C0 including ESC, DEL, C1) is removed. ESC[201~ inside a
55
+ * bracketed paste would end it and the bytes after it would reach claude as typed keys (a CR
56
+ * submits, `!` runs a shell command, Shift+Tab changes the permission mode). S1 inboxBodyOk refuses
57
+ * these at send; this holds whatever the api admitted.
58
+ */
59
+ export function pasteSafe(text) {
60
+ // eslint-disable-next-line no-control-regex
61
+ return text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
62
+ }
63
+ /** At most one exec_inbox read per seat in this period while idle. */
64
+ export const INBOX_POLL_MS = 5_000;
65
+ /**
66
+ * An unconfirmed paste on a seat that never shows idle again (a hung turn) is released after this
67
+ * long, so the api re-offers it and a later pass takes it again, instead of parking it forever.
68
+ */
69
+ export const INBOX_STALL_MS = 30 * 60_000;
70
+ /** The handled shas kept per session (a re-offer comes within one epoch change of its paste). */
71
+ export const HANDLED_MAX = 32;
72
+ /** claude's input line, and the footer it shows while a turn runs. */
73
+ const PROMPT_MARK = "❯";
74
+ const RUNNING_RE = /esc to interrupt/i;
75
+ const PASTE_OPEN_RE = /^<pasted_content id="[^"\n]{0,128}">\n?/;
76
+ function firstText(r) {
77
+ const c = r.message?.content;
78
+ if (typeof c === "string")
79
+ return c;
80
+ if (Array.isArray(c))
81
+ for (const b of c)
82
+ if (b && b.type === "text" && typeof b.text === "string")
83
+ return b.text;
84
+ return null;
85
+ }
86
+ /** The marker sha a user record's prompt carries, or null. */
87
+ export function recordInboxSha(r) {
88
+ if (r.type !== "user" || r.isMeta === true)
89
+ return null;
90
+ const t = firstText(r);
91
+ return t === null ? null : inboxMarkerOf(t.replace(PASTE_OPEN_RE, ""));
92
+ }
93
+ function records(transcript) {
94
+ const out = [];
95
+ for (const line of transcript.split("\n")) {
96
+ if (!line.trim())
97
+ continue;
98
+ try {
99
+ const r = JSON.parse(line);
100
+ if ((r.type === "user" || r.type === "assistant") && r.message && r.isMeta !== true)
101
+ out.push(r);
102
+ }
103
+ catch {
104
+ /* a torn tail line or not JSON */
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+ const finishedBy = (r) => {
110
+ if (!r || r.type !== "assistant")
111
+ return false;
112
+ const b = Array.isArray(r.message?.content)
113
+ ? r.message.content
114
+ : [];
115
+ return !b.some((x) => x?.type === "tool_use") && b.some((x) => x?.type === "text");
116
+ };
117
+ /**
118
+ * What a transcript says about a delivery: whether its last turn finished (the last conversation
119
+ * record is the assistant's own text with no tool call; an empty transcript is a fresh session,
120
+ * which is idle too), whether a prompt carrying `sha` is in it, and whether the turn that prompt
121
+ * started has ended.
122
+ */
123
+ export function transcriptView(transcript, sha) {
124
+ const recs = records(transcript);
125
+ const finished = recs.length === 0 || finishedBy(recs[recs.length - 1]);
126
+ if (sha === null)
127
+ return { finished, seen: false, answered: false };
128
+ let at = -1;
129
+ for (let i = recs.length - 1; i >= 0; i--)
130
+ if (recordInboxSha(recs[i]) === sha) {
131
+ at = i;
132
+ break;
133
+ }
134
+ const seen = at >= 0;
135
+ return { finished, seen, answered: seen && finished && recs.length - 1 > at };
136
+ }
137
+ /** claude's input line is drawn and no turn is running. */
138
+ export function paneIdle(screen) {
139
+ return screen !== null && screen.includes(PROMPT_MARK) && !RUNNING_RE.test(screen);
140
+ }
141
+ // ---- the in-flight record, one file per session ---------------------------------------------
142
+ /**
143
+ * A stored in-flight record, or null when any field is missing or out of range (a record from
144
+ * another daemon version, say): it is then treated as absent, so it can never re-paste unbounded.
145
+ * A record written before `submitted` existed was submitted (its paste and Enter were one step).
146
+ */
147
+ export function inFlightOf(v) {
148
+ if (!v || typeof v !== "object" || Array.isArray(v))
149
+ return null;
150
+ const r = v;
151
+ const ok = typeof r.inboxId === "string" &&
152
+ UUID_RE.test(r.inboxId) &&
153
+ typeof r.sha === "string" &&
154
+ HEX64_RE.test(r.sha) &&
155
+ Number.isSafeInteger(r.epoch) &&
156
+ r.epoch >= 0 &&
157
+ typeof r.text === "string" &&
158
+ typeof r.pastedAt === "number" &&
159
+ Number.isFinite(r.pastedAt) &&
160
+ Number.isSafeInteger(r.tries) &&
161
+ r.tries >= 1 &&
162
+ r.tries <= PASTE_TRIES &&
163
+ typeof r.confirmed === "boolean" &&
164
+ (r.submitted === undefined || typeof r.submitted === "boolean");
165
+ if (!ok)
166
+ return null;
167
+ return {
168
+ inboxId: r.inboxId,
169
+ sha: r.sha,
170
+ epoch: r.epoch,
171
+ text: r.text,
172
+ pastedAt: r.pastedAt,
173
+ tries: r.tries,
174
+ confirmed: r.confirmed,
175
+ submitted: r.submitted !== false,
176
+ };
177
+ }
178
+ export function fileInFlight(dir) {
179
+ const file = (id, kind = "") => path.join(dir, `${id.replace(/[^A-Za-z0-9-]/g, "_")}${kind}.json`);
180
+ const write = (f, v) => {
181
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
182
+ const tmp = `${f}.tmp`;
183
+ fs.writeFileSync(tmp, JSON.stringify(v), { mode: 0o600 });
184
+ fs.renameSync(tmp, f);
185
+ };
186
+ return {
187
+ load: (id) => {
188
+ try {
189
+ return inFlightOf(JSON.parse(fs.readFileSync(file(id), "utf8")));
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ },
195
+ save: (id, f) => {
196
+ if (f === null) {
197
+ fs.rmSync(file(id), { force: true });
198
+ return;
199
+ }
200
+ write(file(id), f);
201
+ },
202
+ loadHandled: (id) => {
203
+ try {
204
+ const v = JSON.parse(fs.readFileSync(file(id, ".handled"), "utf8"));
205
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
206
+ }
207
+ catch {
208
+ return [];
209
+ }
210
+ },
211
+ saveHandled: (id, shas) => {
212
+ if (shas.length === 0)
213
+ fs.rmSync(file(id, ".handled"), { force: true });
214
+ else
215
+ write(file(id, ".handled"), shas);
216
+ },
217
+ };
218
+ }
219
+ /** The messages a malformed take answer still names well enough to release. */
220
+ function namedInTake(body) {
221
+ const ms = body?.messages;
222
+ if (!Array.isArray(ms))
223
+ return [];
224
+ return ms.filter((m) => typeof m?.inbox_id === "string" &&
225
+ UUID_RE.test(m.inbox_id) &&
226
+ typeof m.sha === "string" &&
227
+ HEX64_RE.test(m.sha));
228
+ }
229
+ export class InboxDelivery {
230
+ d;
231
+ state = new Map();
232
+ constructor(d) {
233
+ this.d = d;
234
+ }
235
+ /** One pass over the daemon's seats. Returns what it did (for logs and tests). */
236
+ async sync(seats) {
237
+ const did = [];
238
+ const live = new Set(seats.map((s) => s.sessionId));
239
+ for (const id of this.state.keys())
240
+ if (!live.has(id))
241
+ this.state.delete(id);
242
+ for (const s of seats) {
243
+ if (!s.ready)
244
+ continue;
245
+ const st = this.state.get(s.sessionId) ?? {
246
+ idlePasses: 0,
247
+ lastPoll: -Infinity,
248
+ busy: false,
249
+ emptyDelivered: null,
250
+ emptyAt: -Infinity,
251
+ };
252
+ this.state.set(s.sessionId, st);
253
+ if (st.busy)
254
+ continue;
255
+ st.busy = true;
256
+ try {
257
+ const r = await this.seat(s, st);
258
+ if (r)
259
+ did.push(r);
260
+ }
261
+ catch (e) {
262
+ this.d.log(`inbox ${s.sessionId}: ${e.message}`);
263
+ }
264
+ finally {
265
+ st.busy = false;
266
+ }
267
+ }
268
+ return did;
269
+ }
270
+ async seat(s, st) {
271
+ let f = this.d.load(s.sessionId);
272
+ const text = this.d.transcript(s.sessionId);
273
+ if (f && f.epoch !== s.epoch) {
274
+ // A delivery to an earlier seat: the api re-offers it to this one (delivered_epoch < epoch).
275
+ // Its prompt may already be in the conversation (the restart fell between the turn and the
276
+ // ack): keep its sha as handled before the record goes, so the re-offer is acked, not pasted.
277
+ const seen = f.confirmed || (text !== null && transcriptView(text, f.sha).seen);
278
+ if (seen)
279
+ this.markHandled(s.sessionId, f.sha);
280
+ this.d.log(`inbox ${s.sessionId}: dropping ${f.inboxId} from epoch ${f.epoch} (now ${s.epoch})${seen ? ", handled" : ""}`);
281
+ this.d.save(s.sessionId, null);
282
+ f = null;
283
+ }
284
+ const view = transcriptView(text ?? "", f?.sha ?? null);
285
+ const idleNow = view.finished && paneIdle(await this.d.capture(s.tmuxSession));
286
+ st.idlePasses = idleNow ? st.idlePasses + 1 : 0;
287
+ const now = this.d.now();
288
+ if (f) {
289
+ // Without the transcript nothing can be confirmed or acked: wait for it (never re-paste blind).
290
+ if (text === null)
291
+ return null;
292
+ if (!f.confirmed && view.seen) {
293
+ f.confirmed = true;
294
+ this.d.save(s.sessionId, f);
295
+ }
296
+ if (!f.confirmed) {
297
+ if (!f.submitted) {
298
+ // The paste is in the input line but its Enter failed (or a restart came in between).
299
+ if (!idleNow)
300
+ return null;
301
+ await this.d.submit(s.tmuxSession);
302
+ f.submitted = true;
303
+ this.d.save(s.sessionId, f);
304
+ st.idlePasses = 0;
305
+ return `submit:${f.inboxId}`;
306
+ }
307
+ if (now - f.pastedAt < confirmWindow(f.tries))
308
+ return null;
309
+ if (f.tries >= PASTE_TRIES) {
310
+ this.d.log(`inbox ${s.sessionId}: ${f.inboxId} never reached the transcript after ${f.tries} pastes; releasing it`);
311
+ await this.release(s, f.inboxId, f.sha, "unconfirmed");
312
+ this.d.save(s.sessionId, null);
313
+ return `unconfirmed:${f.inboxId}`;
314
+ }
315
+ if (st.idlePasses < 2) {
316
+ if (now - f.pastedAt < INBOX_STALL_MS)
317
+ return null;
318
+ this.d.log(`inbox ${s.sessionId}: ${f.inboxId} unconfirmed and the seat not idle for ${Math.round((now - f.pastedAt) / 60_000)} min; releasing it`);
319
+ await this.release(s, f.inboxId, f.sha, "unconfirmed");
320
+ this.d.save(s.sessionId, null);
321
+ return `stalled:${f.inboxId}`;
322
+ }
323
+ // Counted before the paste, as a first delivery is recorded before its paste: a restart in
324
+ // between never pastes more than PASTE_TRIES times.
325
+ f.tries += 1;
326
+ f.pastedAt = now;
327
+ await this.put(s, st, f);
328
+ return `repaste:${f.inboxId}`;
329
+ }
330
+ if (!(view.answered && st.idlePasses >= 2))
331
+ return null;
332
+ const a = await this.d.api.inboxAck(s.sessionId, {
333
+ epoch: s.epoch,
334
+ nonce: this.d.nonce(),
335
+ inbox_id: f.inboxId,
336
+ sha: f.sha,
337
+ });
338
+ if ((a.status === 200 && inboxAckResponseOk(a.body)) ||
339
+ a.code === CODE.durable_inbox_not_open) {
340
+ this.d.save(s.sessionId, null);
341
+ this.d.log(`inbox ${s.sessionId}: acked ${f.inboxId}${a.status === 200 ? "" : ` (${a.code})`}`);
342
+ return `ack:${f.inboxId}`;
343
+ }
344
+ this.d.log(`inbox ${s.sessionId}: ack ${f.inboxId} failed: ${a.code ?? a.networkError ?? a.status}`);
345
+ return null;
346
+ }
347
+ // Nothing is taken blind: without the transcript a paste could never be confirmed or acked, and
348
+ // an empty-looking transcript would pass for an idle one. A seated session always has one (the
349
+ // daemon seats only a session with a cc_session_id and resumes its transcript).
350
+ if (text === null)
351
+ return null;
352
+ if (st.idlePasses < 2 || now - st.lastPoll < INBOX_POLL_MS)
353
+ return null;
354
+ st.lastPoll = now;
355
+ const sum = await this.d.api.inboxSummary(s.sessionId);
356
+ if (sum.status !== 200 || !inboxSummaryResponseOk(sum.body)) {
357
+ if (sum.status !== 404)
358
+ this.d.log(`inbox ${s.sessionId}: summary ${sum.code ?? sum.networkError ?? sum.status}`);
359
+ return null;
360
+ }
361
+ const b = sum.body;
362
+ // delivered counts a message delivered to an earlier seat too, which take re-offers.
363
+ if (b.admitted + b.delivered === 0)
364
+ return null;
365
+ if (b.admitted === 0 &&
366
+ b.delivered === st.emptyDelivered &&
367
+ now - st.emptyAt < INBOX_REOFFER_S * 1000)
368
+ return null;
369
+ const t = await this.d.api.inboxTake(s.sessionId, {
370
+ epoch: s.epoch,
371
+ nonce: this.d.nonce(),
372
+ limit: 1,
373
+ });
374
+ if (t.status !== 200 || !inboxTakeResponseOk(t.body)) {
375
+ // The api may have marked messages delivered all the same: hand back what the answer names,
376
+ // and never latch on it, so the next poll takes again (and gets the rest once re-offered).
377
+ this.d.log(`inbox ${s.sessionId}: take ${t.code ?? t.networkError ?? t.status}`);
378
+ st.emptyDelivered = null;
379
+ for (const n of namedInTake(t.body))
380
+ await this.release(s, n.inbox_id, n.sha, "undeliverable");
381
+ return null;
382
+ }
383
+ const m = t.body.messages[0];
384
+ if (!m) {
385
+ st.emptyDelivered = b.admitted === 0 ? b.delivered : null;
386
+ st.emptyAt = now;
387
+ return null;
388
+ }
389
+ st.emptyDelivered = null;
390
+ const handled = this.d.loadHandled(s.sessionId);
391
+ if (handled.includes(m.sha))
392
+ return this.ackHandled(s, m, handled);
393
+ const rec = {
394
+ inboxId: m.inbox_id,
395
+ sha: m.sha,
396
+ epoch: s.epoch,
397
+ text: m.text,
398
+ pastedAt: now,
399
+ tries: 1,
400
+ confirmed: false,
401
+ submitted: false,
402
+ };
403
+ // Recorded before the paste: a restart in between re-pastes at most once, never loses the ack.
404
+ await this.put(s, st, rec);
405
+ this.d.log(`inbox ${s.sessionId}: delivered ${m.inbox_id} (attempt ${m.attempt})`);
406
+ return `deliver:${m.inbox_id}`;
407
+ }
408
+ /** Save `f` as not yet submitted, paste it, press Enter, then save it as submitted. */
409
+ async put(s, st, f) {
410
+ f.submitted = false;
411
+ this.d.save(s.sessionId, f);
412
+ await this.d.paste(s.tmuxSession, pasteSafe(f.text));
413
+ st.idlePasses = 0;
414
+ await this.d.submit(s.tmuxSession);
415
+ f.submitted = true;
416
+ this.d.save(s.sessionId, f);
417
+ }
418
+ markHandled(sessionId, sha) {
419
+ const shas = this.d.loadHandled(sessionId).filter((x) => x !== sha);
420
+ shas.push(sha);
421
+ this.d.saveHandled(sessionId, shas.slice(-HANDLED_MAX));
422
+ }
423
+ /**
424
+ * A re-offer of a message whose prompt is already in the conversation: ack it at this epoch
425
+ * without pasting. A failed ack keeps the sha handled; the api re-offers it again later.
426
+ */
427
+ async ackHandled(s, m, handled) {
428
+ const a = await this.d.api.inboxAck(s.sessionId, {
429
+ epoch: s.epoch,
430
+ nonce: this.d.nonce(),
431
+ inbox_id: m.inbox_id,
432
+ sha: m.sha,
433
+ });
434
+ if (!(a.status === 200 && inboxAckResponseOk(a.body)) &&
435
+ a.code !== CODE.durable_inbox_not_open) {
436
+ this.d.log(`inbox ${s.sessionId}: ack of handled ${m.inbox_id} failed: ${a.code ?? a.networkError ?? a.status}`);
437
+ return null;
438
+ }
439
+ this.d.saveHandled(s.sessionId, handled.filter((x) => x !== m.sha));
440
+ this.d.log(`inbox ${s.sessionId}: acked handled ${m.inbox_id} without pasting it again`);
441
+ return `ack-handled:${m.inbox_id}`;
442
+ }
443
+ async release(s, inboxId, sha, reason) {
444
+ const r = await this.d.api.inboxRelease(s.sessionId, {
445
+ epoch: s.epoch,
446
+ nonce: this.d.nonce(),
447
+ inbox_id: inboxId,
448
+ sha,
449
+ reason,
450
+ });
451
+ this.d.log(`inbox ${s.sessionId}: release ${inboxId} (${reason}) ${r.status === 200 && inboxReleaseResponseOk(r.body) ? "ok" : `failed: ${r.code ?? r.networkError ?? r.status}`}`);
452
+ }
453
+ }
@@ -1,6 +1,8 @@
1
1
  import { type HostPaths, type HostInstance } from "./instance.js";
2
2
  import { type LoadedState } from "./state.js";
3
+ import { Tmux } from "./tmux.js";
3
4
  import { type AutoContinueDeps } from "./auto-continue.js";
5
+ import { type InboxDeliveryDeps } from "./inbox-delivery.js";
4
6
  import { resolveClaudeBin } from "../lib/claude-bin.js";
5
7
  export interface RunIo {
6
8
  readonly env: Readonly<Record<string, string | undefined>>;
@@ -38,6 +40,22 @@ export declare function publishIfLockHeld(held: () => boolean, write: () => void
38
40
  /** For tests and status: the OS boot id (macOS sysctl). */
39
41
  export declare function sysctlBootId(): string;
40
42
  export declare const defaultHome: () => string;
43
+ /** The inbox delivery's deps over the daemon's own: transcript tails, the seat's pane, a file store. */
44
+ export declare function inboxDeliveryDeps(o: {
45
+ api: InboxDeliveryDeps["api"];
46
+ nonce: () => string;
47
+ transcriptPath: (sessionId: string) => string | null;
48
+ tmux: Pick<Tmux, "capturePane" | "pasteText" | "pressEnter">;
49
+ sleep: (ms: number) => Promise<void>;
50
+ dir: string;
51
+ log: (line: string) => void;
52
+ }): InboxDeliveryDeps;
53
+ /** The pause between an inbox paste and the Enter that submits it. */
54
+ export declare const INBOX_PASTE_SETTLE_MS = 300;
55
+ /** How much of a transcript's end the inbox delivery reads (a turn and its prompt fit in it). */
56
+ export declare const INBOX_TRANSCRIPT_TAIL_BYTES: number;
57
+ /** The last `bytes` of a file as text, from a line start; null when unreadable. */
58
+ export declare function readTail(p: string, bytes: number): string | null;
41
59
  /** The interrupted turn of the transcript at `p` (auto-continue.ts), or null when it cannot be read. */
42
60
  export declare function interruptedAtFile(p: string, read: (p: string) => string): string | null;
43
61
  /** The fs calls the auto-continue markers make. */
package/dist/host/run.js CHANGED
@@ -24,6 +24,7 @@ import { buildSeatLaunch, seatDoor } from "./seat.js";
24
24
  import { socketDir, ensureSocketDir, controlSocketPath, releaseControlSocket, startControlServer, } from "./control.js";
25
25
  import { writeRuntime } from "./runtime.js";
26
26
  import { autoContinue, interruptedTurnId, turnContinuedJournal, } from "./auto-continue.js";
27
+ import { InboxDelivery, fileInFlight } from "./inbox-delivery.js";
27
28
  import { hostTestPauseAt } from "../durable/test-mode.mjs";
28
29
  import { resolvePluginDir } from "../lib/claude-plugin.js";
29
30
  import { liveCwdDeps, spawnCwdCause } from "../durable/cwd-rule.js";
@@ -130,6 +131,9 @@ export async function hostRun(io) {
130
131
  // One file per continued turn (<session>.<turn uuid>); kept across respawns and daemon restarts.
131
132
  const continuedDir = path.join(path.dirname(paths.stateFile), "continued");
132
133
  const pauseAt = hostTestPauseAt({ ...env });
134
+ // S1 amendment 60: the in-flight inbox delivery per session (host/inbox-delivery.ts), across restarts.
135
+ const inboxDir = path.join(path.dirname(paths.stateFile), "inbox");
136
+ let inboxDelivery = null;
133
137
  const deps = {
134
138
  api: executorApi({
135
139
  apiUrl,
@@ -202,6 +206,18 @@ export async function hostRun(io) {
202
206
  await sleep(3600_000);
203
207
  },
204
208
  interruptedAt: (p) => interruptedAtFile(p, (f) => fs.readFileSync(f, "utf8")),
209
+ inbox: (seats) => {
210
+ inboxDelivery ??= new InboxDelivery(inboxDeliveryDeps({
211
+ api: deps.api,
212
+ nonce: () => deps.nonce,
213
+ transcriptPath: (id) => lastKnown.get(id) ?? null,
214
+ tmux,
215
+ sleep,
216
+ dir: inboxDir,
217
+ log,
218
+ }));
219
+ return inboxDelivery.sync(seats);
220
+ },
205
221
  autoContinue: (seat, turnId) => autoContinue(seat, turnId, {
206
222
  ...fileContinuedMarkers(continuedDir, fs),
207
223
  journal: turnContinuedJournal(deps.api, () => deps.nonce),
@@ -256,6 +272,51 @@ export function sysctlBootId() {
256
272
  }).trim();
257
273
  }
258
274
  export const defaultHome = () => os.homedir();
275
+ /** The inbox delivery's deps over the daemon's own: transcript tails, the seat's pane, a file store. */
276
+ export function inboxDeliveryDeps(o) {
277
+ return {
278
+ api: o.api,
279
+ nonce: o.nonce,
280
+ transcript: (id) => {
281
+ const p = o.transcriptPath(id);
282
+ return p ? readTail(p, INBOX_TRANSCRIPT_TAIL_BYTES) : null;
283
+ },
284
+ capture: (t) => o.tmux.capturePane(t),
285
+ paste: async (t, text) => {
286
+ await o.tmux.pasteText(t, text);
287
+ // Let claude take the paste before the Enter that submits it.
288
+ await o.sleep(INBOX_PASTE_SETTLE_MS);
289
+ },
290
+ submit: (t) => o.tmux.pressEnter(t),
291
+ ...fileInFlight(o.dir),
292
+ now: () => Date.now(),
293
+ log: o.log,
294
+ };
295
+ }
296
+ /** The pause between an inbox paste and the Enter that submits it. */
297
+ export const INBOX_PASTE_SETTLE_MS = 300;
298
+ /** How much of a transcript's end the inbox delivery reads (a turn and its prompt fit in it). */
299
+ export const INBOX_TRANSCRIPT_TAIL_BYTES = 512 * 1024;
300
+ /** The last `bytes` of a file as text, from a line start; null when unreadable. */
301
+ export function readTail(p, bytes) {
302
+ let fd = null;
303
+ try {
304
+ fd = fs.openSync(p, "r");
305
+ const size = fs.fstatSync(fd).size;
306
+ const start = Math.max(0, size - bytes);
307
+ const buf = Buffer.alloc(size - start);
308
+ fs.readSync(fd, buf, 0, buf.length, start);
309
+ const text = buf.toString("utf8");
310
+ return start === 0 ? text : text.slice(text.indexOf("\n") + 1);
311
+ }
312
+ catch {
313
+ return null;
314
+ }
315
+ finally {
316
+ if (fd !== null)
317
+ fs.closeSync(fd);
318
+ }
319
+ }
259
320
  /** The interrupted turn of the transcript at `p` (auto-continue.ts), or null when it cannot be read. */
260
321
  export function interruptedAtFile(p, read) {
261
322
  try {
@@ -21,3 +21,5 @@ export { spawnIdempotencyKey, type ExecAppendBody } from "../lib/runner/exec-app
21
21
  * daemon and its seat proxy, never a wire code, so it is not a durable_* code (S1 owns those).
22
22
  */
23
23
  export declare const SEAT_UNKNOWN = "seat_unknown";
24
+ /** The control socket's answer when the api's answer to a relayed question op is malformed. Local too. */
25
+ export declare const SEAT_BAD_ANSWER = "seat_bad_answer";
@@ -20,3 +20,5 @@ export { spawnIdempotencyKey } from "../lib/runner/exec-append.js";
20
20
  * daemon and its seat proxy, never a wire code, so it is not a durable_* code (S1 owns those).
21
21
  */
22
22
  export const SEAT_UNKNOWN = "seat_unknown";
23
+ /** The control socket's answer when the api's answer to a relayed question op is malformed. Local too. */
24
+ export const SEAT_BAD_ANSWER = "seat_bad_answer";
@@ -52,5 +52,12 @@ export declare class Tmux {
52
52
  capturePane(name: string): Promise<string | null>;
53
53
  /** Type `text` literally into a session's pane, then press Enter. */
54
54
  sendLine(name: string, text: string): Promise<void>;
55
+ /**
56
+ * Paste `text` into a session's pane as bracketed paste (a multi-line prompt stays one prompt; a
57
+ * newline inside it never submits). The caller presses Enter (pressEnter) once claude took it.
58
+ * From draft PR #3145 (host/seat-wake.ts), now used by the durable inbox delivery.
59
+ */
60
+ pasteText(name: string, text: string): Promise<void>;
61
+ pressEnter(name: string): Promise<void>;
55
62
  killSession(name: string): Promise<void>;
56
63
  }
package/dist/host/tmux.js CHANGED
@@ -176,6 +176,25 @@ export class Tmux {
176
176
  if (e.code !== 0)
177
177
  throw new Error(`tmux send-keys failed: ${e.stderr.trim()}`);
178
178
  }
179
+ /**
180
+ * Paste `text` into a session's pane as bracketed paste (a multi-line prompt stays one prompt; a
181
+ * newline inside it never submits). The caller presses Enter (pressEnter) once claude took it.
182
+ * From draft PR #3145 (host/seat-wake.ts), now used by the durable inbox delivery.
183
+ */
184
+ async pasteText(name, text) {
185
+ const buf = `mnm-inbox-${process.pid}`;
186
+ const b = await this.run(["set-buffer", "-b", buf, "--", text]);
187
+ if (b.code !== 0)
188
+ throw new Error(`tmux set-buffer failed: ${b.stderr.trim()}`);
189
+ const p = await this.run(["paste-buffer", "-p", "-d", "-b", buf, "-t", `=${name}:`]);
190
+ if (p.code !== 0)
191
+ throw new Error(`tmux paste-buffer failed: ${p.stderr.trim()}`);
192
+ }
193
+ async pressEnter(name) {
194
+ const e = await this.run(["send-keys", "-t", `=${name}:`, "Enter"]);
195
+ if (e.code !== 0)
196
+ throw new Error(`tmux send-keys failed: ${e.stderr.trim()}`);
197
+ }
179
198
  async killSession(name) {
180
199
  await this.run(["kill-session", "-t", `=${name}`]);
181
200
  }