@mnemom/mnemom 0.17.34-next.4 → 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
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * Owner session verbs (ruling 33.22; design-v3 section 3.5, BACKBONE A2): scoped on the
14
14
  * conversation id under /v1/agent/sessions/; the `?agent_id=` query is not part of the match. Not
15
- * frozen: move, inbox, triggers and profiles (not in A2), the pre-existing GET /{cid} and POST
15
+ * frozen: move, triggers and profiles (not in A2), the pre-existing GET /{cid} and POST
16
16
  * /{cid}/contract, and create and list (their methods are not spelled in the sources).
17
17
  *
18
18
  * Doors amendment (ruling 34.03): exec_ready_list (the pool discovery read) and exec_oidc_exchange
@@ -23,10 +23,24 @@
23
23
  * Internal): int_charter is the api route the gateway's durable seal calls on a cold charter LRU.
24
24
  * The api serves it (A2: only the api holds the charter DEK); the gateway sends
25
25
  * CHARTER_BY_HASH_BODY_KEYS with INTERNAL_KEY_HEADER and reads CHARTER_BY_HASH_RESPONSE.
26
+ *
27
+ * Inbox (S1 amendment 60, signed off by Wassim 2026-11-02): the owner's send, list and allowlist routes
28
+ * under /v1/agent/sessions/{cid}/inbox, and the executor's summary, take and ack routes under
29
+ * /v1/executor/sessions/{id}/inbox. Bodies and answers are inbox.ts's closed shapes. A route takes
30
+ * one id, so the allowlist's agent_id and the acked inbox_id ride in the body.
31
+ *
32
+ * Questions (S1 amendment 61, signed off by Wassim 2026-11-02): the executor's ask, poll and release
33
+ * routes under /v1/executor/sessions/{id}/questions, the owner's list and answer routes under
34
+ * /v1/agent/sessions/{cid}/questions. Bodies and answers are question.ts's closed shapes; the
35
+ * approval_id rides in the body.
26
36
  */
27
37
  export type RouteMethod = "GET" | "POST" | "DELETE";
28
38
  /** github_oidc (ruling 34.03 (1)): a GitHub Actions workflow OIDC token, in the body, exchanged for a one-lease key. */
29
- export type RouteAuth = "owner_jwt" | "owner" | "poll_secret" | "executor_key" | "internal" | "github_oidc";
39
+ /**
40
+ * inbox_sender (S1 amendment 60): an `owner` credential, or an agent-bound credential (the agent's
41
+ * mnm_ds_ lease key or its session key) whose agent is on the recipient agent's inbox allowlist.
42
+ */
43
+ export type RouteAuth = "owner_jwt" | "owner" | "poll_secret" | "executor_key" | "internal" | "github_oidc" | "inbox_sender";
30
44
  export interface FixedRoute {
31
45
  readonly method: RouteMethod;
32
46
  readonly auth: RouteAuth;
@@ -61,6 +75,20 @@ export declare const ROUTES: Readonly<{
61
75
  exec_ready_list: FixedRoute;
62
76
  /** Body {oidc_token}; answers a one-lease org-scope executor key or an OIDC_EXCHANGE_REFUSALS refusal. */
63
77
  exec_oidc_exchange: FixedRoute;
78
+ /** Content-free summary {admitted, delivered, oldest_admitted_at} (inboxSummaryResponseOk); marks nothing. */
79
+ exec_inbox: ScopedRoute;
80
+ /** Body {epoch, nonce, limit?}: marks up to limit messages delivered at the live epoch, journals inbox.delivered, answers their delivery text. */
81
+ exec_inbox_take: ScopedRoute;
82
+ /** Body {epoch, nonce, inbox_id, sha}: acks a message delivered at this epoch, journals inbox.acked. */
83
+ exec_inbox_ack: ScopedRoute;
84
+ /** Body {epoch, nonce, inbox_id, sha, reason}: hands a delivered message back undone (admitted again, or dead at the limit), journals inbox.reverted. */
85
+ exec_inbox_release: ScopedRoute;
86
+ /** Body {epoch, nonce, tool_use_id, questions}: opens the session's question approval (or returns it when the open one has this question_sha), journals question.asked. */
87
+ exec_question: ScopedRoute;
88
+ /** Body {epoch, nonce, approval_id}: the question's state; an answer is handed over once (marked taken). */
89
+ exec_question_poll: ScopedRoute;
90
+ /** Body {epoch, nonce, approval_id}: the seat stopped waiting; the answer (now or later) goes to the inbox. */
91
+ exec_question_release: ScopedRoute;
64
92
  own_lock: ScopedRoute;
65
93
  own_unlock: ScopedRoute;
66
94
  own_pause: ScopedRoute;
@@ -70,6 +98,20 @@ export declare const ROUTES: Readonly<{
70
98
  own_amend: ScopedRoute;
71
99
  own_fail: ScopedRoute;
72
100
  own_events: ScopedRoute;
101
+ /** Body {text, idempotency_key} (inboxSendBodyOk); answer INBOX_SEND_RESPONSE or an INBOX_REJECT_CODES refusal. */
102
+ own_inbox_send: ScopedRoute;
103
+ /** {messages: [INBOX_LIST_ITEM, ...]}, newest first, content-free. */
104
+ own_inbox_list: ScopedRoute;
105
+ /** {recipient_agent_id, senders: [INBOX_SENDER_ITEM, ...]}: the session's agent's live allowlist. */
106
+ own_inbox_senders: ScopedRoute;
107
+ /** Body {agent_id, label?}: allowlists one of the owner's own agents in the same org. */
108
+ own_inbox_sender_add: ScopedRoute;
109
+ /** Body {agent_id}: revokes it (the row keeps revoked_at and revoked_by). */
110
+ own_inbox_sender_remove: ScopedRoute;
111
+ /** {questions: [QUESTION_LIST_ITEM, ...]}, newest first; the owner reads the question text. */
112
+ own_questions: ScopedRoute;
113
+ /** Body {approval_id, via, answers | dismiss: true}: answers or dismisses, journals question.answered. */
114
+ own_answer: ScopedRoute;
73
115
  int_events: FixedRoute;
74
116
  int_arm: FixedRoute;
75
117
  int_sweep: FixedRoute;
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Owner session verbs (ruling 33.22; design-v3 section 3.5, BACKBONE A2): scoped on the
15
15
  * conversation id under /v1/agent/sessions/; the `?agent_id=` query is not part of the match. Not
16
- * frozen: move, inbox, triggers and profiles (not in A2), the pre-existing GET /{cid} and POST
16
+ * frozen: move, triggers and profiles (not in A2), the pre-existing GET /{cid} and POST
17
17
  * /{cid}/contract, and create and list (their methods are not spelled in the sources).
18
18
  *
19
19
  * Doors amendment (ruling 34.03): exec_ready_list (the pool discovery read) and exec_oidc_exchange
@@ -24,6 +24,16 @@
24
24
  * Internal): int_charter is the api route the gateway's durable seal calls on a cold charter LRU.
25
25
  * The api serves it (A2: only the api holds the charter DEK); the gateway sends
26
26
  * CHARTER_BY_HASH_BODY_KEYS with INTERNAL_KEY_HEADER and reads CHARTER_BY_HASH_RESPONSE.
27
+ *
28
+ * Inbox (S1 amendment 60, signed off by Wassim 2026-11-02): the owner's send, list and allowlist routes
29
+ * under /v1/agent/sessions/{cid}/inbox, and the executor's summary, take and ack routes under
30
+ * /v1/executor/sessions/{id}/inbox. Bodies and answers are inbox.ts's closed shapes. A route takes
31
+ * one id, so the allowlist's agent_id and the acked inbox_id ride in the body.
32
+ *
33
+ * Questions (S1 amendment 61, signed off by Wassim 2026-11-02): the executor's ask, poll and release
34
+ * routes under /v1/executor/sessions/{id}/questions, the owner's list and answer routes under
35
+ * /v1/agent/sessions/{cid}/questions. Bodies and answers are question.ts's closed shapes; the
36
+ * approval_id rides in the body.
27
37
  */
28
38
  import { ARM_ROUTE } from "./arm.js";
29
39
  import { fieldOk } from "./admission.js";
@@ -64,6 +74,22 @@ export const ROUTES = Object.freeze({
64
74
  exec_ready_list: fixed("GET", "executor_key", "/v1/executor/ready"),
65
75
  /** Body {oidc_token}; answers a one-lease org-scope executor key or an OIDC_EXCHANGE_REFUSALS refusal. */
66
76
  exec_oidc_exchange: fixed("POST", "github_oidc", "/v1/executor/oidc/exchange"),
77
+ // ---- inbox, executor side (S1 amendment 60) ----
78
+ /** Content-free summary {admitted, delivered, oldest_admitted_at} (inboxSummaryResponseOk); marks nothing. */
79
+ exec_inbox: scoped("GET", "executor_key", EXEC_SESSION_PRE, "/inbox"),
80
+ /** Body {epoch, nonce, limit?}: marks up to limit messages delivered at the live epoch, journals inbox.delivered, answers their delivery text. */
81
+ exec_inbox_take: scoped("POST", "executor_key", EXEC_SESSION_PRE, "/inbox/take"),
82
+ /** Body {epoch, nonce, inbox_id, sha}: acks a message delivered at this epoch, journals inbox.acked. */
83
+ exec_inbox_ack: scoped("POST", "executor_key", EXEC_SESSION_PRE, "/inbox/ack"),
84
+ /** Body {epoch, nonce, inbox_id, sha, reason}: hands a delivered message back undone (admitted again, or dead at the limit), journals inbox.reverted. */
85
+ exec_inbox_release: scoped("POST", "executor_key", EXEC_SESSION_PRE, "/inbox/release"),
86
+ // ---- questions, executor side (S1 amendment 61) ----
87
+ /** Body {epoch, nonce, tool_use_id, questions}: opens the session's question approval (or returns it when the open one has this question_sha), journals question.asked. */
88
+ exec_question: scoped("POST", "executor_key", EXEC_SESSION_PRE, "/questions"),
89
+ /** Body {epoch, nonce, approval_id}: the question's state; an answer is handed over once (marked taken). */
90
+ exec_question_poll: scoped("POST", "executor_key", EXEC_SESSION_PRE, "/questions/poll"),
91
+ /** Body {epoch, nonce, approval_id}: the seat stopped waiting; the answer (now or later) goes to the inbox. */
92
+ exec_question_release: scoped("POST", "executor_key", EXEC_SESSION_PRE, "/questions/release"),
67
93
  // ---- owner session verbs (ruling 33.22) ----
68
94
  own_lock: scoped("POST", "owner", OWNER_SESSION_PRE, "/lock"),
69
95
  own_unlock: scoped("POST", "owner", OWNER_SESSION_PRE, "/unlock"),
@@ -74,6 +100,22 @@ export const ROUTES = Object.freeze({
74
100
  own_amend: scoped("POST", "owner", OWNER_SESSION_PRE, "/amend"),
75
101
  own_fail: scoped("POST", "owner", OWNER_SESSION_PRE, "/fail"),
76
102
  own_events: scoped("GET", "owner", OWNER_SESSION_PRE, "/events"),
103
+ // ---- inbox, owner side (S1 amendment 60) ----
104
+ /** Body {text, idempotency_key} (inboxSendBodyOk); answer INBOX_SEND_RESPONSE or an INBOX_REJECT_CODES refusal. */
105
+ own_inbox_send: scoped("POST", "inbox_sender", OWNER_SESSION_PRE, "/inbox"),
106
+ /** {messages: [INBOX_LIST_ITEM, ...]}, newest first, content-free. */
107
+ own_inbox_list: scoped("GET", "owner", OWNER_SESSION_PRE, "/inbox"),
108
+ /** {recipient_agent_id, senders: [INBOX_SENDER_ITEM, ...]}: the session's agent's live allowlist. */
109
+ own_inbox_senders: scoped("GET", "owner", OWNER_SESSION_PRE, "/inbox/senders"),
110
+ /** Body {agent_id, label?}: allowlists one of the owner's own agents in the same org. */
111
+ own_inbox_sender_add: scoped("POST", "owner", OWNER_SESSION_PRE, "/inbox/senders"),
112
+ /** Body {agent_id}: revokes it (the row keeps revoked_at and revoked_by). */
113
+ own_inbox_sender_remove: scoped("DELETE", "owner", OWNER_SESSION_PRE, "/inbox/senders"),
114
+ // ---- questions, owner side (S1 amendment 61) ----
115
+ /** {questions: [QUESTION_LIST_ITEM, ...]}, newest first; the owner reads the question text. */
116
+ own_questions: scoped("GET", "owner", OWNER_SESSION_PRE, "/questions"),
117
+ /** Body {approval_id, via, answers | dismiss: true}: answers or dismisses, journals question.answered. */
118
+ own_answer: scoped("POST", "owner", OWNER_SESSION_PRE, "/questions/answer"),
77
119
  // ---- gateway internal ----
78
120
  int_events: fixed("POST", "internal", GATEWAY_EVENTS_ROUTE),
79
121
  int_arm: fixed("GET", "internal", ARM_ROUTE),
@@ -13,7 +13,10 @@ export type TurnOrigin = (typeof TURN_ORIGINS)[number];
13
13
  export declare const TURN_SOURCES: readonly ["typed", "queued", "system", "sdk"];
14
14
  export type TurnSource = (typeof TURN_SOURCES)[number];
15
15
  export declare const CHAIN_KEY = "chain";
16
- /** Reserved entry key; no M1 emitter (P0a-B). Any entry carrying it is unattended. */
16
+ /**
17
+ * Entry key `inbox=<sha256 hex>`: the turn answers that inbox message. Any entry carrying it is
18
+ * unattended. Its emitter is the seat proxy (amendment 60); admission reads the newest entry's value.
19
+ */
17
20
  export declare const INBOX_KEY = "inbox";
18
21
  export declare const TURN_HASH_RE: RegExp;
19
22
  export interface TurnEntryFacts {
@@ -23,6 +26,8 @@ export interface TurnEntryFacts {
23
26
  producer?: string;
24
27
  /** True when the entry carried `inbox=<sha256 hex>`. */
25
28
  inbox: boolean;
29
+ /** The entry's `inbox=<sha256 hex>` value when it carried a well-formed one (amendment 60). */
30
+ inboxSha?: string;
26
31
  }
27
32
  /** Sources that keep a human entry attended: absent, typed, queued (queued attended is a recorded departure). */
28
33
  export declare const ATTENDED_SOURCES: readonly (TurnSource | undefined)[];
@@ -44,6 +49,15 @@ export declare const ATTENDED_TASK_PRODUCER = "session-task";
44
49
  * `schemaKnown=false` (schema=unknown with a well-formed chain, F07) is unattended.
45
50
  */
46
51
  export declare function candidatePosture(entries: readonly TurnEntryFacts[], schemaKnown?: boolean): "attended" | "unattended";
52
+ /**
53
+ * Amendment 60 (e): the inbox message the newest entry answers, or null. The newest entry's own
54
+ * `inbox=<sha>`; else, when the newest entry is a session-task notification (a background command or
55
+ * agent this session started, the amendment 56 continuation shape), the sha of the turn that started
56
+ * it: walking back over session-task entries, the first other entry's `inbox=<sha>`, if it has one.
57
+ * So a background task an inbox turn launched finishes inside that turn's admission, and nothing
58
+ * else does: a human, scheduled, peer or channel entry in between ends the walk with null.
59
+ */
60
+ export declare function inboxShaOf(entries: readonly TurnEntryFacts[]): string | null;
47
61
  /** chain = sha256 hex of the root promptId (UTF-8). */
48
62
  export declare function chainIdOf(rootPromptId: string): Promise<string>;
49
63
  /** Request classes (day-1 spike vocabulary) and whether each waits for its transcript entry. */
@@ -13,7 +13,10 @@ import { sha256Hex } from "./canonical.js";
13
13
  export const TURN_ORIGINS = ["human", "channel", "scheduled", "peer", "task", "meta"];
14
14
  export const TURN_SOURCES = ["typed", "queued", "system", "sdk"];
15
15
  export const CHAIN_KEY = "chain";
16
- /** Reserved entry key; no M1 emitter (P0a-B). Any entry carrying it is unattended. */
16
+ /**
17
+ * Entry key `inbox=<sha256 hex>`: the turn answers that inbox message. Any entry carrying it is
18
+ * unattended. Its emitter is the seat proxy (amendment 60); admission reads the newest entry's value.
19
+ */
17
20
  export const INBOX_KEY = "inbox";
18
21
  export const TURN_HASH_RE = /^[0-9a-f]{64}$/;
19
22
  /** Sources that keep a human entry attended: absent, typed, queued (queued attended is a recorded departure). */
@@ -51,6 +54,35 @@ export function candidatePosture(entries, schemaKnown = true) {
51
54
  entries.slice(0, -1).some(attendedHuman);
52
55
  return continuation ? "attended" : "unattended";
53
56
  }
57
+ const sessionTaskContinuation = (e) => e.origin === "task" &&
58
+ e.source === "system" &&
59
+ e.producer === ATTENDED_TASK_PRODUCER &&
60
+ !e.inbox;
61
+ /**
62
+ * Amendment 60 (e): the inbox message the newest entry answers, or null. The newest entry's own
63
+ * `inbox=<sha>`; else, when the newest entry is a session-task notification (a background command or
64
+ * agent this session started, the amendment 56 continuation shape), the sha of the turn that started
65
+ * it: walking back over session-task entries, the first other entry's `inbox=<sha>`, if it has one.
66
+ * So a background task an inbox turn launched finishes inside that turn's admission, and nothing
67
+ * else does: a human, scheduled, peer or channel entry in between ends the walk with null.
68
+ */
69
+ export function inboxShaOf(entries) {
70
+ const newest = entries[entries.length - 1];
71
+ if (newest === undefined)
72
+ return null;
73
+ const own = (e) => e.inbox && typeof e.inboxSha === "string" && TURN_HASH_RE.test(e.inboxSha) ? e.inboxSha : null;
74
+ if (newest.inbox)
75
+ return own(newest);
76
+ if (!sessionTaskContinuation(newest))
77
+ return null;
78
+ for (let i = entries.length - 2; i >= 0; i--) {
79
+ const e = entries[i];
80
+ if (sessionTaskContinuation(e))
81
+ continue;
82
+ return own(e);
83
+ }
84
+ return null;
85
+ }
54
86
  /** chain = sha256 hex of the root promptId (UTF-8). */
55
87
  export async function chainIdOf(rootPromptId) {
56
88
  if (typeof rootPromptId !== "string" || rootPromptId.length === 0)
@@ -189,3 +189,48 @@ export declare const SEAL_VECTORS: {
189
189
  readonly hash: "5a3bb041c155d67e50d18ee9afb0e6903f7a64988c881ab6373d3447fd5906f5";
190
190
  };
191
191
  };
192
+ /**
193
+ * S1 amendment 60: inboxSha golden vectors (sha256 of the canonical identity with `v: 1`), computed
194
+ * independently with node:crypto over a hand-sorted JSON preimage. `text` is the delivery text the
195
+ * api renders for the message (inboxDeliveryText), with the sender label shown.
196
+ */
197
+ export declare const INBOX_SHA_VECTORS: readonly [{
198
+ readonly identity: {
199
+ readonly session_id: "11111111-2222-4333-8444-555555555555";
200
+ readonly sender_kind: "owner";
201
+ readonly sender_agent_id: null;
202
+ readonly on_behalf_of: null;
203
+ readonly in_reply_to: null;
204
+ readonly body: "Please rebase PR 3220 and rerun the contract tests.";
205
+ };
206
+ readonly sender_label: null;
207
+ readonly preimage: "{\"body\":\"Please rebase PR 3220 and rerun the contract tests.\",\"in_reply_to\":null,\"on_behalf_of\":null,\"sender_agent_id\":null,\"sender_kind\":\"owner\",\"session_id\":\"11111111-2222-4333-8444-555555555555\",\"v\":1}";
208
+ readonly sha: "87fc04e971ee9fde7c3db3536772f4a7402c30a911111eff9fb37cbd3927c72f";
209
+ readonly text: "[mnemom inbox 87fc04e971ee9fde7c3db3536772f4a7402c30a911111eff9fb37cbd3927c72f] from the owner\n\nPlease rebase PR 3220 and rerun the contract tests.";
210
+ }, {
211
+ readonly identity: {
212
+ readonly session_id: "11111111-2222-4333-8444-555555555555";
213
+ readonly sender_kind: "agent";
214
+ readonly sender_agent_id: "mnm-chief01";
215
+ readonly on_behalf_of: "user_owner_1";
216
+ readonly in_reply_to: null;
217
+ readonly body: "Wassim asks: status on the inbox migration?";
218
+ };
219
+ readonly sender_label: "chief";
220
+ readonly preimage: "{\"body\":\"Wassim asks: status on the inbox migration?\",\"in_reply_to\":null,\"on_behalf_of\":\"user_owner_1\",\"sender_agent_id\":\"mnm-chief01\",\"sender_kind\":\"agent\",\"session_id\":\"11111111-2222-4333-8444-555555555555\",\"v\":1}";
221
+ readonly sha: "8a955bd0811770262fa0d400472472b9cd8bc1cff8a41abb2955a2c1582eea54";
222
+ readonly text: "[mnemom inbox 8a955bd0811770262fa0d400472472b9cd8bc1cff8a41abb2955a2c1582eea54] from agent chief on behalf of the owner\n\nWassim asks: status on the inbox migration?";
223
+ }, {
224
+ readonly identity: {
225
+ readonly session_id: "11111111-2222-4333-8444-555555555555";
226
+ readonly sender_kind: "owner";
227
+ readonly sender_agent_id: null;
228
+ readonly on_behalf_of: null;
229
+ readonly in_reply_to: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
230
+ readonly body: "Yes, ship it as a draft. — W ✓";
231
+ };
232
+ readonly sender_label: null;
233
+ readonly preimage: "{\"body\":\"Yes, ship it as a draft. — W ✓\",\"in_reply_to\":\"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee\",\"on_behalf_of\":null,\"sender_agent_id\":null,\"sender_kind\":\"owner\",\"session_id\":\"11111111-2222-4333-8444-555555555555\",\"v\":1}";
234
+ readonly sha: "f79c83ca6fd83b8b918fe6464479c79ba4abea1ec30f43fd62b9b6d5950cc218";
235
+ readonly text: "[mnemom inbox f79c83ca6fd83b8b918fe6464479c79ba4abea1ec30f43fd62b9b6d5950cc218] from the owner (answer to question aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee)\n\nYes, ship it as a draft. — W ✓";
236
+ }];
@@ -206,3 +206,52 @@ export const SEAL_VECTORS = {
206
206
  "hash": "5a3bb041c155d67e50d18ee9afb0e6903f7a64988c881ab6373d3447fd5906f5"
207
207
  }
208
208
  };
209
+ /**
210
+ * S1 amendment 60: inboxSha golden vectors (sha256 of the canonical identity with `v: 1`), computed
211
+ * independently with node:crypto over a hand-sorted JSON preimage. `text` is the delivery text the
212
+ * api renders for the message (inboxDeliveryText), with the sender label shown.
213
+ */
214
+ export const INBOX_SHA_VECTORS = [
215
+ {
216
+ identity: {
217
+ session_id: "11111111-2222-4333-8444-555555555555",
218
+ sender_kind: "owner",
219
+ sender_agent_id: null,
220
+ on_behalf_of: null,
221
+ in_reply_to: null,
222
+ body: "Please rebase PR 3220 and rerun the contract tests.",
223
+ },
224
+ sender_label: null,
225
+ preimage: '{"body":"Please rebase PR 3220 and rerun the contract tests.","in_reply_to":null,"on_behalf_of":null,"sender_agent_id":null,"sender_kind":"owner","session_id":"11111111-2222-4333-8444-555555555555","v":1}',
226
+ sha: "87fc04e971ee9fde7c3db3536772f4a7402c30a911111eff9fb37cbd3927c72f",
227
+ text: "[mnemom inbox 87fc04e971ee9fde7c3db3536772f4a7402c30a911111eff9fb37cbd3927c72f] from the owner\n\nPlease rebase PR 3220 and rerun the contract tests.",
228
+ },
229
+ {
230
+ identity: {
231
+ session_id: "11111111-2222-4333-8444-555555555555",
232
+ sender_kind: "agent",
233
+ sender_agent_id: "mnm-chief01",
234
+ on_behalf_of: "user_owner_1",
235
+ in_reply_to: null,
236
+ body: "Wassim asks: status on the inbox migration?",
237
+ },
238
+ sender_label: "chief",
239
+ preimage: '{"body":"Wassim asks: status on the inbox migration?","in_reply_to":null,"on_behalf_of":"user_owner_1","sender_agent_id":"mnm-chief01","sender_kind":"agent","session_id":"11111111-2222-4333-8444-555555555555","v":1}',
240
+ sha: "8a955bd0811770262fa0d400472472b9cd8bc1cff8a41abb2955a2c1582eea54",
241
+ text: "[mnemom inbox 8a955bd0811770262fa0d400472472b9cd8bc1cff8a41abb2955a2c1582eea54] from agent chief on behalf of the owner\n\nWassim asks: status on the inbox migration?",
242
+ },
243
+ {
244
+ identity: {
245
+ session_id: "11111111-2222-4333-8444-555555555555",
246
+ sender_kind: "owner",
247
+ sender_agent_id: null,
248
+ on_behalf_of: null,
249
+ in_reply_to: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
250
+ body: "Yes, ship it as a draft. — W ✓",
251
+ },
252
+ sender_label: null,
253
+ preimage: '{"body":"Yes, ship it as a draft. — W ✓","in_reply_to":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee","on_behalf_of":null,"sender_agent_id":null,"sender_kind":"owner","session_id":"11111111-2222-4333-8444-555555555555","v":1}',
254
+ sha: "f79c83ca6fd83b8b918fe6464479c79ba4abea1ec30f43fd62b9b6d5950cc218",
255
+ text: "[mnemom inbox f79c83ca6fd83b8b918fe6464479c79ba4abea1ec30f43fd62b9b6d5950cc218] from the owner (answer to question aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee)\n\nYes, ship it as a draft. — W ✓",
256
+ },
257
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.34-next.4",
3
+ "version": "0.17.35",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,12 +12,16 @@
12
12
  },
13
13
  {
14
14
  "path": "hooks/hooks.json",
15
- "sha256": "c9fe91672a14aa8fc74d8fe523e412b7fdbe16a281cdc1d3e51b162fef3a7ed3"
15
+ "sha256": "fe45556f57011f0f1805fc56d2c86d5e2462cd1ba998ef7b54778b26556cfc4e"
16
16
  },
17
17
  {
18
18
  "path": "hooks/register.tsx",
19
19
  "sha256": "98da58c198d4015a5d454110249b1bc5c9fe0a1e2271c310f46c1185935d4436"
20
20
  },
21
+ {
22
+ "path": "hooks/seat-ask.mjs",
23
+ "sha256": "dcd0b419fc2a6fb1275aff34abb3d107311cecb4c8d7eac3c49b0c8a600db4a3"
24
+ },
21
25
  {
22
26
  "path": "hooks/seat-hello.mjs",
23
27
  "sha256": "4d6cd4e2fed2dc701bc6990ed5c43b37fb4aaf202b25012c3a052845d624daee"
@@ -31,5 +35,5 @@
31
35
  "sha256": "b877ea4d4d6fe11ae2eb10e035b5f017b225905f7f9ec015b3c3010609cad27b"
32
36
  }
33
37
  ],
34
- "dir_hash": "ad16249ecbdd1e2fc6913529f9aefb357d516da7230f26a4e1c2c0ccf09735c5"
38
+ "dir_hash": "845a702134fb66daa9cbe8cc9a51f6a06ce08a08392ed5f0cf68ac4af7625398"
35
39
  }
@@ -12,6 +12,18 @@
12
12
  }
13
13
  ]
14
14
  }
15
+ ],
16
+ "PreToolUse": [
17
+ {
18
+ "matcher": "AskUserQuestion",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "[ -z \"$MNEMOM_SEAT_SOCKET\" ] || node \"${CLAUDE_PLUGIN_ROOT}/hooks/seat-ask.mjs\"",
23
+ "timeout": 600
24
+ }
25
+ ]
26
+ }
15
27
  ]
16
28
  }
17
29
  }
@@ -0,0 +1,221 @@
1
+ // The seat question hook (durable S1 amendment 61, signed off by Wassim 2026-11-02): a COMMAND hook on
2
+ // PreToolUse for AskUserQuestion. In a durable seat nobody watches the screen, so a drawn dialog
3
+ // would wait forever. This hook makes the question a journaled durable fact instead:
4
+ //
5
+ // 1. ask: {op:"question_ask", session_id, spawn_id, epoch, tool_use_id, questions} over the
6
+ // per-seat socket. The seat proxy relays it to the host daemon, which alone holds the
7
+ // executor key and calls exec_question; the api journals question.asked.
8
+ // 2. poll: {op:"question_poll", ..., approval_id} every POLL_INTERVAL_MS until WAIT_MS ends.
9
+ // answered -> stdout permissionDecision "allow" with updatedInput {questions, answers}:
10
+ // the dialog never draws and the tool result carries the owner's answer.
11
+ // dismissed -> "deny" with the dismissed reason (the session is not suspended).
12
+ // expired -> "deny" with the expired reason.
13
+ // created:false is this same call asked again (the daemon passes on only an answer whose
14
+ // question_sha is this ask's), so it is polled like a new one. A 409 durable_question_busy means
15
+ // an earlier question is still with the owner: this one was not recorded, the hook denies with
16
+ // PENDING_REASON at once and releases nothing (the earlier question belongs to its own wait).
17
+ // 3. release: when the window ends first, {op:"question_release", ..., approval_id}, then "deny"
18
+ // with the hand-off reason: the model ends its turn and the owner's answer arrives
19
+ // later as an amendment 60 inbox message, which the daemon delivers unattended.
20
+ //
21
+ // Without MNEMOM_SEAT_SOCKET (every launch that is not a durable seat) it does nothing. If the ask
22
+ // cannot be made (no socket, the daemon or api refuses), it writes nothing and exits 0, so Claude
23
+ // Code shows its own dialog as it always did: this hook only ever removes the screen dependency, it
24
+ // never blocks a question. It never sees a key: the ids are non-secret.
25
+ //
26
+ // The reason strings are S1 question.ts's QUESTION_HANDOFF_REASON and QUESTION_DISMISSED_REASON
27
+ // (a plugin file cannot import the contract; a CLI test pins them equal).
28
+ import net from "node:net";
29
+ import { createHash } from "node:crypto";
30
+ import { pathToFileURL } from "node:url";
31
+
32
+ export const SEAT_SOCKET_ENV = "MNEMOM_SEAT_SOCKET";
33
+ export const SEAT_SESSION_ENV = "MNEMOM_SEAT_SESSION_ID";
34
+ export const SEAT_SPAWN_ENV = "MNEMOM_SEAT_SPAWN_ID";
35
+ export const SEAT_EPOCH_ENV = "MNEMOM_SEAT_EPOCH";
36
+
37
+ /** hooks.json gives this hook 600 s; it stops waiting well before, to release and answer cleanly. */
38
+ export const WAIT_MS = 540_000;
39
+ export const POLL_INTERVAL_MS = 3_000;
40
+ /** One socket round trip (the seat proxy relays it to the daemon and the daemon to the api). */
41
+ export const OP_TIMEOUT_MS = 20_000;
42
+ export const MAX_ANSWER_BYTES = 256 * 1024;
43
+
44
+ export const HANDOFF_REASON =
45
+ "This question is now with your owner. End your turn now without guessing; the owner's answer will arrive as your next message.";
46
+ export const DISMISSED_REASON =
47
+ "Your owner dismissed this question without answering. Decide without it, or ask differently.";
48
+ /**
49
+ * The session already has a different question with the owner (one per session: asking another is
50
+ * 409 durable_question_busy). This call's question was not recorded; the model must not take the
51
+ * earlier question's answer for this one.
52
+ */
53
+ export const PENDING_REASON =
54
+ "This question was not sent: an earlier question of yours is still waiting for your owner. End your turn now without guessing; the owner's answer to that earlier question will arrive as your next message. Ask this one again after that if it still matters.";
55
+ export const EXPIRED_REASON =
56
+ "Your owner did not answer this question before it expired. Decide without it, or ask again later.";
57
+
58
+ /** S1 codes.ts durable_question_busy: another question of this session's is open. */
59
+ const BUSY_CODE = "durable_question_busy";
60
+ const TOOL_USE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
61
+
62
+ /** The seat ids from the env: `{ skip: true }` outside a seat or with an incomplete seat env. */
63
+ export function seatFromEnv(env) {
64
+ const socketPath = env[SEAT_SOCKET_ENV];
65
+ const sessionId = env[SEAT_SESSION_ENV];
66
+ const spawnId = env[SEAT_SPAWN_ENV];
67
+ const epoch = env[SEAT_EPOCH_ENV];
68
+ if (!socketPath || !sessionId || !spawnId || !/^\d{1,9}$/.test(epoch ?? ""))
69
+ return { skip: true };
70
+ return { socketPath, session_id: sessionId, spawn_id: spawnId, epoch: Number(epoch) };
71
+ }
72
+
73
+ /**
74
+ * The tool call from the hook's stdin JSON, or null when it is not an AskUserQuestion call with
75
+ * questions. tool_use_id comes from the input; when the engine gives none, a stable id is derived
76
+ * from the questions (asking again returns the session's open question either way).
77
+ */
78
+ export function toolCallOf(input) {
79
+ if (!input || typeof input !== "object") return null;
80
+ if (input.tool_name !== "AskUserQuestion") return null;
81
+ const ti = input.tool_input;
82
+ if (!ti || typeof ti !== "object" || !Array.isArray(ti.questions)) return null;
83
+ let id = typeof input.tool_use_id === "string" ? input.tool_use_id : "";
84
+ if (!TOOL_USE_ID_RE.test(id))
85
+ id = `h${createHash("sha256").update(JSON.stringify(ti.questions)).digest("hex").slice(0, 40)}`;
86
+ return { tool_use_id: id, tool_input: ti, questions: ti.questions };
87
+ }
88
+
89
+ /**
90
+ * One JSON line out, the first line back, parsed. Rejects on a socket error, close or timeout, and
91
+ * on an answer line over MAX_ANSWER_BYTES however the bytes were chunked.
92
+ */
93
+ export function sendFrame(socketPath, frame, timeoutMs = OP_TIMEOUT_MS) {
94
+ return new Promise((resolve, reject) => {
95
+ const sock = net.createConnection({ path: socketPath });
96
+ let buf = Buffer.alloc(0);
97
+ const done = (fn, v) => {
98
+ clearTimeout(timer);
99
+ sock.destroy();
100
+ fn(v);
101
+ };
102
+ const timer = setTimeout(() => done(reject, new Error("no answer in time")), timeoutMs);
103
+ sock.on("connect", () => sock.write(`${JSON.stringify(frame)}\n`));
104
+ sock.on("data", (d) => {
105
+ buf = Buffer.concat([buf, d]);
106
+ const i = buf.indexOf(0x0a);
107
+ // The line's bytes, or all the bytes so far while no newline has come.
108
+ if ((i < 0 ? buf.length : i) > MAX_ANSWER_BYTES)
109
+ return done(reject, new Error("answer too long"));
110
+ if (i < 0) return;
111
+ try {
112
+ done(resolve, JSON.parse(buf.subarray(0, i).toString("utf8")));
113
+ } catch {
114
+ done(reject, new Error("answer is not JSON"));
115
+ }
116
+ });
117
+ sock.on("error", (e) => done(reject, e));
118
+ sock.on("close", () => {
119
+ if (buf.indexOf(0x0a) < 0) done(reject, new Error("closed without an answer"));
120
+ });
121
+ });
122
+ }
123
+
124
+ /** The api body of a relayed answer `{ok, status, body}` when ok, else null. */
125
+ function okBody(reply) {
126
+ return reply &&
127
+ typeof reply === "object" &&
128
+ reply.ok === true &&
129
+ reply.body &&
130
+ typeof reply.body === "object"
131
+ ? reply.body
132
+ : null;
133
+ }
134
+
135
+ export function decision(permissionDecision, extra) {
136
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, ...extra } };
137
+ }
138
+
139
+ /**
140
+ * The hook. Resolves to the stdout object to print, or null to print nothing (Claude Code then
141
+ * draws its dialog as usual). Never throws.
142
+ */
143
+ export async function runSeatAsk(env, input, deps = {}) {
144
+ const send = deps.send ?? sendFrame;
145
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
146
+ const now = deps.now ?? (() => Date.now());
147
+ const warn = deps.warn ?? ((l) => process.stderr.write(`${l}\n`));
148
+ const waitMs = deps.waitMs ?? WAIT_MS;
149
+ const seat = seatFromEnv(env);
150
+ if (seat.skip) return null;
151
+ const call = toolCallOf(input);
152
+ if (!call) return null;
153
+ const { socketPath, ...ids } = seat;
154
+ let reply;
155
+ try {
156
+ reply = await send(socketPath, {
157
+ op: "question_ask",
158
+ ...ids,
159
+ tool_use_id: call.tool_use_id,
160
+ questions: call.questions,
161
+ });
162
+ } catch (e) {
163
+ warn(`mnemom: question not recorded: ${e instanceof Error ? e.message : String(e)}`);
164
+ return null;
165
+ }
166
+ if (reply?.status === 409 && reply.body?.error?.code === BUSY_CODE)
167
+ return decision("deny", { permissionDecisionReason: PENDING_REASON });
168
+ const asked = okBody(reply);
169
+ if (!asked || typeof asked.approval_id !== "string") {
170
+ warn("mnemom: question not recorded (refused); showing the dialog");
171
+ return null;
172
+ }
173
+ const approval_id = asked.approval_id;
174
+ const deadline = now() + waitMs;
175
+ while (now() < deadline) {
176
+ await sleep(POLL_INTERVAL_MS);
177
+ let st;
178
+ try {
179
+ st = okBody(await send(socketPath, { op: "question_poll", ...ids, approval_id }));
180
+ } catch {
181
+ continue; // a transient socket or daemon blip: keep the turn held and try again
182
+ }
183
+ if (!st) continue;
184
+ if (st.state === "answered" && st.answers && typeof st.answers === "object")
185
+ return decision("allow", {
186
+ permissionDecisionReason: `answered by the owner (question ${approval_id})`,
187
+ updatedInput: { ...call.tool_input, answers: st.answers },
188
+ });
189
+ if (st.state === "dismissed")
190
+ return decision("deny", { permissionDecisionReason: DISMISSED_REASON });
191
+ if (st.state === "expired")
192
+ return decision("deny", { permissionDecisionReason: EXPIRED_REASON });
193
+ if (st.state === "handed_off") break;
194
+ }
195
+ try {
196
+ await send(socketPath, { op: "question_release", ...ids, approval_id });
197
+ } catch (e) {
198
+ // The question stays open at the api and in own_questions; hand off all the same, the model
199
+ // must not sit on a dialog nobody can see.
200
+ warn(`mnemom: question release failed: ${e instanceof Error ? e.message : String(e)}`);
201
+ }
202
+ return decision("deny", { permissionDecisionReason: HANDOFF_REASON });
203
+ }
204
+
205
+ async function readStdin() {
206
+ let s = "";
207
+ for await (const c of process.stdin) s += c;
208
+ return s;
209
+ }
210
+
211
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
212
+ let input;
213
+ try {
214
+ input = JSON.parse(await readStdin());
215
+ } catch {
216
+ input = null;
217
+ }
218
+ const out = await runSeatAsk(process.env, input);
219
+ if (out) process.stdout.write(`${JSON.stringify(out)}\n`);
220
+ process.exitCode = 0;
221
+ }