@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
@@ -16,10 +16,11 @@
16
16
  import { randomUUID } from "node:crypto";
17
17
  import { EXECUTOR_POLL_S, HEARTBEAT_DIVISOR, RESPAWN_CONCURRENCY, CLAIM_BACKOFF_BASE_S, CLAIM_BACKOFF_MAX_S, CLAIM_BACKOFF_JITTER, LEASE_SECONDS_DEFAULT, } from "../vendor/shared/durable-contract/constants.js";
18
18
  import { TERMINAL_STATES } from "../vendor/shared/durable-contract/states.js";
19
+ import { questionAskMatches, questionAskResponseOk, } from "../vendor/shared/durable-contract/question.js";
19
20
  import { IDLE_LOG } from "./gate.js";
20
21
  import { classifyClaim, errorCode, } from "./client.js";
21
22
  import { seatTmuxSession } from "./tmux.js";
22
- import { CODE, SEAT_UNKNOWN, SPAWNED_KIND, spawnIdempotencyKey } from "./s1-names.js";
23
+ import { CODE, SEAT_BAD_ANSWER, SEAT_UNKNOWN, SPAWNED_KIND, spawnIdempotencyKey, } from "./s1-names.js";
23
24
  export const TICK_MS = 1000;
24
25
  export const ADOPT_RETRY_MS = 2000;
25
26
  export { SPAWNED_KIND, spawnIdempotencyKey } from "./s1-names.js";
@@ -179,6 +180,19 @@ export class HostDaemon {
179
180
  return did;
180
181
  }
181
182
  await this.checkSeats(did);
183
+ if (this.d.inbox) {
184
+ try {
185
+ did.push(...(await this.d.inbox([...this.seats.values()].map((x) => ({
186
+ sessionId: x.sessionId,
187
+ tmuxSession: x.tmuxSession,
188
+ epoch: x.epoch,
189
+ ready: x.helloAccepted,
190
+ })))));
191
+ }
192
+ catch (e) {
193
+ this.d.log(`inbox delivery: ${e.message}`);
194
+ }
195
+ }
182
196
  await this.heartbeat(did);
183
197
  if (polled && this.items.size > 0)
184
198
  await this.claimWave(did);
@@ -587,6 +601,65 @@ export class HostDaemon {
587
601
  }
588
602
  return { status: r.status, body: r.body };
589
603
  }
604
+ /**
605
+ * Relay a seat's question op (S1 amendment 61). Only for a registered, hello-accepted seat this
606
+ * daemon holds, with its own spawn id and epoch; the daemon adds the live lease's epoch and nonce
607
+ * (the executor key never leaves the daemon). The api checks the payload's closed shape.
608
+ */
609
+ async relayQuestion(op, sessionId, spawnId, epoch, payload) {
610
+ const s = this.seats.get(sessionId);
611
+ if (!s || s.spawnId !== spawnId || !s.registered || !s.helloAccepted || s.epoch !== epoch)
612
+ return {
613
+ status: 404,
614
+ body: {
615
+ error: { code: SEAT_UNKNOWN, message: "no live seat with that spawn id and epoch" },
616
+ },
617
+ };
618
+ const fence = { epoch: s.epoch, nonce: this.d.nonce };
619
+ if (op === "question_ask") {
620
+ const ask = {
621
+ session_id: sessionId,
622
+ tool_use_id: String(payload.tool_use_id),
623
+ questions: payload.questions,
624
+ };
625
+ const r = await this.d.api.questionAsk(sessionId, {
626
+ ...fence,
627
+ tool_use_id: ask.tool_use_id,
628
+ questions: ask.questions,
629
+ });
630
+ if (r.status !== 200)
631
+ return { status: r.status, body: r.body };
632
+ if (!questionAskResponseOk(r.body))
633
+ return {
634
+ status: 502,
635
+ body: { error: { code: SEAT_BAD_ANSWER, message: "not a question answer" } },
636
+ };
637
+ // The seat waits only on its own question (S1 question.ts questionAskMatches; the hook cannot
638
+ // import the contract, so the check is here): an answer about another is that question still
639
+ // being open, so the hook denies as for durable_question_busy and polls or releases nothing.
640
+ if (!(await questionAskMatches(r.body, ask)))
641
+ return {
642
+ status: 409,
643
+ body: {
644
+ error: {
645
+ code: CODE.durable_question_busy,
646
+ message: "another question of this session is still open",
647
+ },
648
+ },
649
+ };
650
+ return { status: r.status, body: r.body };
651
+ }
652
+ const r = op === "question_poll"
653
+ ? await this.d.api.questionPoll(sessionId, {
654
+ ...fence,
655
+ approval_id: String(payload.approval_id),
656
+ })
657
+ : await this.d.api.questionRelease(sessionId, {
658
+ ...fence,
659
+ approval_id: String(payload.approval_id),
660
+ });
661
+ return { status: r.status, body: r.body };
662
+ }
590
663
  // ---- status ------------------------------------------------------------------------------------
591
664
  publish(state) {
592
665
  this.d.publish({
@@ -0,0 +1,174 @@
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 { type InboxReleaseReason } from "../vendor/shared/durable-contract/inbox.js";
40
+ import type { HttpResult } from "./client.js";
41
+ /** How long a first paste has to show up in the transcript before it is pasted again (doubling). */
42
+ export declare const CONFIRM_MS = 15000;
43
+ /** The longest wait between two pastes of one message. */
44
+ export declare const CONFIRM_MAX_MS = 300000;
45
+ /** Pastes of one taken message at most (the api's own take limit, S1 INBOX_DELIVERY_LIMIT). */
46
+ export declare const PASTE_TRIES = 5;
47
+ /** How long paste number `tries` (1-based) has to show up before the next one. */
48
+ export declare const confirmWindow: (tries: number) => number;
49
+ /**
50
+ * The delivery text as it may be pasted into a terminal: CR LF and CR become LF, and every other
51
+ * control character but TAB and LF (C0 including ESC, DEL, C1) is removed. ESC[201~ inside a
52
+ * bracketed paste would end it and the bytes after it would reach claude as typed keys (a CR
53
+ * submits, `!` runs a shell command, Shift+Tab changes the permission mode). S1 inboxBodyOk refuses
54
+ * these at send; this holds whatever the api admitted.
55
+ */
56
+ export declare function pasteSafe(text: string): string;
57
+ /** At most one exec_inbox read per seat in this period while idle. */
58
+ export declare const INBOX_POLL_MS = 5000;
59
+ /**
60
+ * An unconfirmed paste on a seat that never shows idle again (a hung turn) is released after this
61
+ * long, so the api re-offers it and a later pass takes it again, instead of parking it forever.
62
+ */
63
+ export declare const INBOX_STALL_MS: number;
64
+ /** The handled shas kept per session (a re-offer comes within one epoch change of its paste). */
65
+ export declare const HANDLED_MAX = 32;
66
+ export interface InboxSeat {
67
+ readonly sessionId: string;
68
+ readonly tmuxSession: string;
69
+ readonly epoch: number;
70
+ /** The seat's proxy said hello: claude is up and owns the conversation. */
71
+ readonly ready: boolean;
72
+ }
73
+ export interface InFlight {
74
+ readonly inboxId: string;
75
+ readonly sha: string;
76
+ readonly epoch: number;
77
+ readonly text: string;
78
+ pastedAt: number;
79
+ tries: number;
80
+ confirmed: boolean;
81
+ /**
82
+ * The last paste was followed by its Enter. False between a paste and its Enter: if the Enter
83
+ * fails the text is still in claude's input line, so the next pass presses Enter again, never
84
+ * pastes it a second time.
85
+ */
86
+ submitted: boolean;
87
+ }
88
+ export interface InboxExecApi {
89
+ inboxSummary(sessionId: string): Promise<HttpResult>;
90
+ inboxTake(sessionId: string, body: {
91
+ epoch: number;
92
+ nonce: string;
93
+ limit: number;
94
+ }): Promise<HttpResult>;
95
+ inboxAck(sessionId: string, body: {
96
+ epoch: number;
97
+ nonce: string;
98
+ inbox_id: string;
99
+ sha: string;
100
+ }): Promise<HttpResult>;
101
+ inboxRelease(sessionId: string, body: {
102
+ epoch: number;
103
+ nonce: string;
104
+ inbox_id: string;
105
+ sha: string;
106
+ reason: InboxReleaseReason;
107
+ }): Promise<HttpResult>;
108
+ }
109
+ export interface InboxDeliveryDeps {
110
+ api: InboxExecApi;
111
+ nonce: () => string;
112
+ /** The session's transcript text (its tail is enough), or null when not on disk. */
113
+ transcript: (sessionId: string) => string | null;
114
+ capture: (tmuxSession: string) => Promise<string | null>;
115
+ /** Paste `text` into the pane's input line as one prompt (not submitted). */
116
+ paste: (tmuxSession: string, text: string) => Promise<void>;
117
+ /** Press Enter in the pane: submits what was pasted. */
118
+ submit: (tmuxSession: string) => Promise<void>;
119
+ load: (sessionId: string) => InFlight | null;
120
+ save: (sessionId: string, f: InFlight | null) => void;
121
+ /** The shas whose prompt reached the session's transcript but were never acked, oldest first. */
122
+ loadHandled: (sessionId: string) => string[];
123
+ saveHandled: (sessionId: string, shas: readonly string[]) => void;
124
+ now: () => number;
125
+ log: (line: string) => void;
126
+ }
127
+ interface Rec {
128
+ readonly type?: unknown;
129
+ readonly isMeta?: unknown;
130
+ readonly message?: {
131
+ readonly role?: unknown;
132
+ readonly content?: unknown;
133
+ };
134
+ }
135
+ /** The marker sha a user record's prompt carries, or null. */
136
+ export declare function recordInboxSha(r: Rec): string | null;
137
+ /**
138
+ * What a transcript says about a delivery: whether its last turn finished (the last conversation
139
+ * record is the assistant's own text with no tool call; an empty transcript is a fresh session,
140
+ * which is idle too), whether a prompt carrying `sha` is in it, and whether the turn that prompt
141
+ * started has ended.
142
+ */
143
+ export declare function transcriptView(transcript: string, sha: string | null): {
144
+ finished: boolean;
145
+ seen: boolean;
146
+ answered: boolean;
147
+ };
148
+ /** claude's input line is drawn and no turn is running. */
149
+ export declare function paneIdle(screen: string | null): boolean;
150
+ /**
151
+ * A stored in-flight record, or null when any field is missing or out of range (a record from
152
+ * another daemon version, say): it is then treated as absent, so it can never re-paste unbounded.
153
+ * A record written before `submitted` existed was submitted (its paste and Enter were one step).
154
+ */
155
+ export declare function inFlightOf(v: unknown): InFlight | null;
156
+ export declare function fileInFlight(dir: string): Pick<InboxDeliveryDeps, "load" | "save" | "loadHandled" | "saveHandled">;
157
+ export declare class InboxDelivery {
158
+ private readonly d;
159
+ private readonly state;
160
+ constructor(d: InboxDeliveryDeps);
161
+ /** One pass over the daemon's seats. Returns what it did (for logs and tests). */
162
+ sync(seats: readonly InboxSeat[]): Promise<string[]>;
163
+ private seat;
164
+ /** Save `f` as not yet submitted, paste it, press Enter, then save it as submitted. */
165
+ private put;
166
+ private markHandled;
167
+ /**
168
+ * A re-offer of a message whose prompt is already in the conversation: ack it at this epoch
169
+ * without pasting. A failed ack keeps the sha handled; the api re-offers it again later.
170
+ */
171
+ private ackHandled;
172
+ private release;
173
+ }
174
+ export {};