@mnemom/mnemom 0.17.34 → 0.17.36

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 (177) 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 +14 -2
  5. package/dist/commands/agent-run.js +28 -7
  6. package/dist/commands/agent-share-render.js +1 -1
  7. package/dist/commands/agent-slack.js +32 -0
  8. package/dist/commands/durable-inbox.d.ts +51 -0
  9. package/dist/commands/durable-inbox.js +134 -0
  10. package/dist/commands/durable-questions.d.ts +64 -0
  11. package/dist/commands/durable-questions.js +161 -0
  12. package/dist/commands/scan.d.ts +13 -3
  13. package/dist/commands/scan.js +48 -10
  14. package/dist/durable/seat-proxy.d.ts +12 -1
  15. package/dist/durable/seat-proxy.js +55 -9
  16. package/dist/durable/seat-socket.d.ts +20 -2
  17. package/dist/durable/seat-socket.js +58 -0
  18. package/dist/durable/turn-chain.d.ts +8 -0
  19. package/dist/durable/turn-chain.js +25 -4
  20. package/dist/host/client.d.ts +41 -0
  21. package/dist/host/client.js +7 -0
  22. package/dist/host/control.d.ts +7 -0
  23. package/dist/host/control.js +15 -0
  24. package/dist/host/daemon.d.ts +20 -0
  25. package/dist/host/daemon.js +74 -1
  26. package/dist/host/inbox-delivery.d.ts +174 -0
  27. package/dist/host/inbox-delivery.js +453 -0
  28. package/dist/host/run.d.ts +18 -0
  29. package/dist/host/run.js +61 -0
  30. package/dist/host/s1-names.d.ts +2 -0
  31. package/dist/host/s1-names.js +2 -0
  32. package/dist/host/tmux.d.ts +7 -0
  33. package/dist/host/tmux.js +19 -0
  34. package/dist/index.js +62 -3
  35. package/dist/lib/api.d.ts +5 -0
  36. package/dist/lib/api.js +16 -0
  37. package/dist/lib/config.d.ts +7 -0
  38. package/dist/lib/config.js +21 -0
  39. package/dist/lib/durable/api.d.ts +11 -0
  40. package/dist/lib/durable/api.js +28 -3
  41. package/dist/lib/durable/gate.d.ts +1 -1
  42. package/dist/lib/durable/gate.js +8 -1
  43. package/dist/lib/durable/live.d.ts +5 -0
  44. package/dist/lib/durable/live.js +19 -0
  45. package/dist/lib/experimental.js +3 -0
  46. package/dist/lib/goal-api.d.ts +11 -4
  47. package/dist/lib/goal-api.js +4 -3
  48. package/dist/lib/launcher-config.d.ts +5 -0
  49. package/dist/lib/launcher-config.js +17 -2
  50. package/dist/lib/prompt.d.ts +4 -2
  51. package/dist/lib/prompt.js +15 -5
  52. package/dist/lib/runner/job.d.ts +2 -2
  53. package/dist/lib/runner/job.js +16 -3
  54. package/dist/lib/runner/pr-body.d.ts +17 -0
  55. package/dist/lib/runner/pr-body.js +42 -0
  56. package/dist/lib/runner/proxy.d.ts +18 -3
  57. package/dist/lib/runner/proxy.js +24 -5
  58. package/dist/lib/scan/pr-source.js +2 -0
  59. package/dist/lib/scan/record.d.ts +60 -9
  60. package/dist/lib/scan/record.js +157 -39
  61. package/dist/lib/scan/repo-picker.d.ts +32 -0
  62. package/dist/lib/scan/repo-picker.js +80 -0
  63. package/dist/lib/scan/report.d.ts +6 -8
  64. package/dist/lib/scan/report.js +21 -7
  65. package/dist/lib/scan/share.d.ts +5 -1
  66. package/dist/lib/scan/share.js +15 -5
  67. package/dist/lib/slack-durable.d.ts +123 -0
  68. package/dist/lib/slack-durable.js +237 -0
  69. package/dist/lib/slack-router.d.ts +7 -0
  70. package/dist/lib/slack-router.js +50 -4
  71. package/dist/vendor/durable-contract/index.js +992 -34
  72. package/dist/vendor/shared/durable-contract/admission.d.ts +81 -2
  73. package/dist/vendor/shared/durable-contract/admission.js +319 -2
  74. package/dist/vendor/shared/durable-contract/cli-surface.js +21 -0
  75. package/dist/vendor/shared/durable-contract/codes.d.ts +60 -0
  76. package/dist/vendor/shared/durable-contract/codes.js +22 -0
  77. package/dist/vendor/shared/durable-contract/events.d.ts +4 -2
  78. package/dist/vendor/shared/durable-contract/events.js +55 -1
  79. package/dist/vendor/shared/durable-contract/freeze-record.d.ts +14 -0
  80. package/dist/vendor/shared/durable-contract/inbox.d.ts +299 -0
  81. package/dist/vendor/shared/durable-contract/inbox.js +462 -0
  82. package/dist/vendor/shared/durable-contract/index.d.ts +2 -0
  83. package/dist/vendor/shared/durable-contract/index.js +2 -0
  84. package/dist/vendor/shared/durable-contract/question.d.ts +218 -0
  85. package/dist/vendor/shared/durable-contract/question.js +373 -0
  86. package/dist/vendor/shared/durable-contract/routes.d.ts +44 -2
  87. package/dist/vendor/shared/durable-contract/routes.js +43 -1
  88. package/dist/vendor/shared/durable-contract/turn.d.ts +15 -1
  89. package/dist/vendor/shared/durable-contract/turn.js +33 -1
  90. package/dist/vendor/shared/durable-contract/vectors.d.ts +45 -0
  91. package/dist/vendor/shared/durable-contract/vectors.js +49 -0
  92. package/node_modules/@mnemom/aip-verifier/LICENSE +190 -0
  93. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.cts +478 -0
  94. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.ts +478 -0
  95. package/node_modules/@mnemom/aip-verifier/dist/index.cjs +971 -0
  96. package/node_modules/@mnemom/aip-verifier/dist/index.cjs.map +1 -0
  97. package/node_modules/@mnemom/aip-verifier/dist/index.d.cts +330 -0
  98. package/node_modules/@mnemom/aip-verifier/dist/index.d.ts +330 -0
  99. package/node_modules/@mnemom/aip-verifier/dist/index.js +933 -0
  100. package/node_modules/@mnemom/aip-verifier/dist/index.js.map +1 -0
  101. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs +879 -0
  102. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs.map +1 -0
  103. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.cts +96 -0
  104. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.ts +96 -0
  105. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js +875 -0
  106. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js.map +1 -0
  107. package/node_modules/@mnemom/aip-verifier/package.json +86 -0
  108. package/node_modules/@noble/ed25519/LICENSE +21 -0
  109. package/node_modules/@noble/ed25519/README.md +371 -0
  110. package/node_modules/@noble/ed25519/index.d.ts +391 -0
  111. package/node_modules/@noble/ed25519/index.js +933 -0
  112. package/node_modules/@noble/ed25519/index.ts +1134 -0
  113. package/node_modules/@noble/ed25519/package.json +54 -0
  114. package/node_modules/@noble/hashes/LICENSE +21 -0
  115. package/node_modules/@noble/hashes/README.md +478 -0
  116. package/node_modules/@noble/hashes/_blake.d.ts +20 -0
  117. package/node_modules/@noble/hashes/_blake.js +59 -0
  118. package/node_modules/@noble/hashes/_md.d.ts +95 -0
  119. package/node_modules/@noble/hashes/_md.js +209 -0
  120. package/node_modules/@noble/hashes/_u64.d.ts +36 -0
  121. package/node_modules/@noble/hashes/_u64.js +77 -0
  122. package/node_modules/@noble/hashes/argon2.d.ts +148 -0
  123. package/node_modules/@noble/hashes/argon2.js +577 -0
  124. package/node_modules/@noble/hashes/blake1.d.ts +162 -0
  125. package/node_modules/@noble/hashes/blake1.js +630 -0
  126. package/node_modules/@noble/hashes/blake2.d.ts +203 -0
  127. package/node_modules/@noble/hashes/blake2.js +573 -0
  128. package/node_modules/@noble/hashes/blake3.d.ts +68 -0
  129. package/node_modules/@noble/hashes/blake3.js +310 -0
  130. package/node_modules/@noble/hashes/eskdf.d.ts +91 -0
  131. package/node_modules/@noble/hashes/eskdf.js +225 -0
  132. package/node_modules/@noble/hashes/hkdf.d.ts +70 -0
  133. package/node_modules/@noble/hashes/hkdf.js +152 -0
  134. package/node_modules/@noble/hashes/hmac.d.ts +50 -0
  135. package/node_modules/@noble/hashes/hmac.js +93 -0
  136. package/node_modules/@noble/hashes/index.d.ts +1 -0
  137. package/node_modules/@noble/hashes/index.js +34 -0
  138. package/node_modules/@noble/hashes/legacy.d.ts +107 -0
  139. package/node_modules/@noble/hashes/legacy.js +348 -0
  140. package/node_modules/@noble/hashes/package.json +91 -0
  141. package/node_modules/@noble/hashes/pbkdf2.d.ts +66 -0
  142. package/node_modules/@noble/hashes/pbkdf2.js +176 -0
  143. package/node_modules/@noble/hashes/scrypt.d.ts +98 -0
  144. package/node_modules/@noble/hashes/scrypt.js +304 -0
  145. package/node_modules/@noble/hashes/sha2.d.ts +176 -0
  146. package/node_modules/@noble/hashes/sha2.js +446 -0
  147. package/node_modules/@noble/hashes/sha3-addons.d.ts +533 -0
  148. package/node_modules/@noble/hashes/sha3-addons.js +973 -0
  149. package/node_modules/@noble/hashes/sha3.d.ts +208 -0
  150. package/node_modules/@noble/hashes/sha3.js +475 -0
  151. package/node_modules/@noble/hashes/src/_blake.ts +67 -0
  152. package/node_modules/@noble/hashes/src/_md.ts +227 -0
  153. package/node_modules/@noble/hashes/src/_u64.ts +111 -0
  154. package/node_modules/@noble/hashes/src/argon2.ts +693 -0
  155. package/node_modules/@noble/hashes/src/blake1.ts +677 -0
  156. package/node_modules/@noble/hashes/src/blake2.ts +648 -0
  157. package/node_modules/@noble/hashes/src/blake3.ts +344 -0
  158. package/node_modules/@noble/hashes/src/eskdf.ts +265 -0
  159. package/node_modules/@noble/hashes/src/hkdf.ts +161 -0
  160. package/node_modules/@noble/hashes/src/hmac.ts +130 -0
  161. package/node_modules/@noble/hashes/src/index.ts +33 -0
  162. package/node_modules/@noble/hashes/src/legacy.ts +361 -0
  163. package/node_modules/@noble/hashes/src/pbkdf2.ts +219 -0
  164. package/node_modules/@noble/hashes/src/scrypt.ts +375 -0
  165. package/node_modules/@noble/hashes/src/sha2.ts +486 -0
  166. package/node_modules/@noble/hashes/src/sha3-addons.ts +1157 -0
  167. package/node_modules/@noble/hashes/src/sha3.ts +525 -0
  168. package/node_modules/@noble/hashes/src/utils.ts +978 -0
  169. package/node_modules/@noble/hashes/src/webcrypto.ts +283 -0
  170. package/node_modules/@noble/hashes/utils.d.ts +560 -0
  171. package/node_modules/@noble/hashes/utils.js +687 -0
  172. package/node_modules/@noble/hashes/webcrypto.d.ts +121 -0
  173. package/node_modules/@noble/hashes/webcrypto.js +228 -0
  174. package/package.json +5 -2
  175. package/plugins/integrity-manifest.json +6 -2
  176. package/plugins/mnemom/hooks/hooks.json +12 -0
  177. package/plugins/mnemom/hooks/seat-ask.mjs +221 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The durable question verbs (S1 amendment 61, signed off by Wassim 2026-11-02; ROUTES.own_questions and
3
+ * ROUTES.own_answer):
4
+ *
5
+ * mnemom agent sessions questions <name> [--all] [--json]
6
+ * mnemom agent sessions answer <name> <qid|latest> [choice...] [--dismiss] [--json]
7
+ *
8
+ * A locked seat that calls AskUserQuestion records the question at the api (question.asked) instead
9
+ * of drawing a dialog nobody sees. `questions` lists them (the open ones by default), with the option
10
+ * the seat recommends marked; `answer` answers one (question.answered, via coding_agent) or dismisses
11
+ * it. Each choice is a 1-based option number (comma separated for a multi-select question), an
12
+ * option label, or free text; with several questions, one choice per question in order. The seat
13
+ * gets the answer either live (its hook is still waiting) or as its next inbox message.
14
+ *
15
+ * Every dependency is injected (`QuestionVerbDeps`), so each branch is unit-tested.
16
+ */
17
+ import { answerFromChoice, QUESTION_ID_RE, questionAnswerResponseOk, questionListResponseOk, } from "../vendor/shared/durable-contract/question.js";
18
+ import { DurableRefusal } from "../lib/durable/refusal.js";
19
+ import { handleVerbError, refusalFromAnswer, resolveTarget, } from "./durable-verbs.js";
20
+ const usage = (message) => new DurableRefusal({ code: "invalid_argument", message, fix: null, exit: 2 });
21
+ async function listQuestions(deps, name) {
22
+ const row = await resolveTarget(deps.api, name);
23
+ const a = await deps.api.questions("own_questions", row.conversation_id, row.agent_id);
24
+ if (a.status !== 200 || !questionListResponseOk(a.body))
25
+ throw refusalFromAnswer(a, name, deps.launcherKey(), row);
26
+ return {
27
+ rows: a.body.questions,
28
+ cid: row.conversation_id,
29
+ agentId: row.agent_id,
30
+ raw: a.body,
31
+ };
32
+ }
33
+ /**
34
+ * Whether the owner can still answer `q`. The api reports a question the seat released as
35
+ * `handed_off` whether or not it was answered since (S1 question.ts: answered and handed_off can
36
+ * both hold), so a handed-off question is still waiting for the owner while it has no answered_at
37
+ * and has not expired. Its answer then reaches the seat as an inbox message (S1 §61 (c)/(e)).
38
+ */
39
+ export function awaitingAnswer(q, now) {
40
+ if (q.state === "open")
41
+ return true;
42
+ return q.state === "handed_off" && q.answered_at === null && Date.parse(q.expires_at) > now;
43
+ }
44
+ /** The state the owner reads: a handed-off question still waiting for them says so. */
45
+ function stateLabel(q, now) {
46
+ if (q.state !== "handed_off")
47
+ return q.state;
48
+ if (q.answered_at !== null)
49
+ return q.answers !== null ? "answered (handed off)" : "dismissed (handed off)";
50
+ return awaitingAnswer(q, now) ? "open (handed off: the answer goes to its inbox)" : "expired";
51
+ }
52
+ /**
53
+ * Text the seat's model wrote, made safe for the owner's terminal: every C0/C1 control character
54
+ * (ESC, CSI, OSC, BEL, CR, ...) and DEL is shown escaped (`\x1b`), so a question cannot move the
55
+ * cursor, rewrite or hide a line, or write the clipboard. S1 question.ts only refuses NUL.
56
+ */
57
+ export function terminalSafe(s) {
58
+ // eslint-disable-next-line no-control-regex
59
+ return s.replace(/[\u0000-\u001f\u007f-\u009f]/g, (c) => {
60
+ const h = c.charCodeAt(0).toString(16).padStart(2, "0");
61
+ return `\\x${h}`;
62
+ });
63
+ }
64
+ /** The human lines for one question row. */
65
+ export function renderQuestion(q, now = Date.now()) {
66
+ const t = terminalSafe;
67
+ const lines = [`question ${q.approval_id} ${stateLabel(q, now)} asked ${t(q.created_at)}`];
68
+ q.questions.forEach((item, i) => {
69
+ lines.push(` ${item.header ? `[${t(item.header)}] ` : ""}${t(item.question)}${item.multiSelect ? " (choose one or more)" : ""}`);
70
+ item.options.forEach((o, j) => {
71
+ const rec = q.recommended[i] === j ? " <- recommended" : "";
72
+ lines.push(` ${j + 1}. ${t(o.label)}${o.description ? ` - ${t(o.description)}` : ""}${rec}`);
73
+ });
74
+ const a = q.answers?.[item.question];
75
+ if (a !== undefined)
76
+ lines.push(` answer: ${t(a)}`);
77
+ });
78
+ if (q.answered_via)
79
+ lines.push(` answered via ${q.answered_via} at ${q.answered_at ?? "?"}`);
80
+ return lines;
81
+ }
82
+ export async function questionsCommand(name, opts, deps) {
83
+ const json = opts.json === true;
84
+ try {
85
+ const { rows, raw } = await listQuestions(deps, name);
86
+ const now = deps.now();
87
+ const shown = opts.all === true ? rows : rows.filter((r) => awaitingAnswer(r, now));
88
+ if (json) {
89
+ deps.out(JSON.stringify(opts.all === true ? raw : { questions: shown }));
90
+ return 0;
91
+ }
92
+ if (shown.length === 0) {
93
+ deps.out(`${name}: no ${opts.all === true ? "" : "open "}questions`);
94
+ return 0;
95
+ }
96
+ for (const q of shown)
97
+ for (const l of renderQuestion(q, now))
98
+ deps.out(l);
99
+ deps.out(`answer with: mnemom agent sessions answer ${name} <question id|latest> <choice>...`);
100
+ return 0;
101
+ }
102
+ catch (e) {
103
+ return handleVerbError(e, json, deps);
104
+ }
105
+ }
106
+ /** The answers map for one question row from the owner's choices (one per question, in order). */
107
+ export function answersFromChoices(q, choices) {
108
+ if (choices.length !== q.questions.length)
109
+ throw usage(`question ${q.approval_id} has ${q.questions.length} question(s): give one choice for each, in order`);
110
+ const out = {};
111
+ q.questions.forEach((item, i) => {
112
+ const c = choices[i] ?? "";
113
+ if (c.trim() === "")
114
+ throw usage("an empty choice is not an answer");
115
+ out[item.question] = answerFromChoice(item, c);
116
+ });
117
+ return out;
118
+ }
119
+ export async function answerCommand(name, qid, choices, opts, deps) {
120
+ const json = opts.json === true;
121
+ const say = json ? deps.err : deps.out;
122
+ try {
123
+ const dismiss = opts.dismiss === true;
124
+ if (dismiss && choices.length > 0)
125
+ throw usage("--dismiss takes no choices");
126
+ if (!dismiss && choices.length === 0)
127
+ throw usage(`mnemom agent sessions answer ${name} <question id|latest> <choice>... (or --dismiss)`);
128
+ if (qid !== "latest" && !QUESTION_ID_RE.test(qid))
129
+ throw usage(`'${qid}' is not a question id (see mnemom agent sessions questions ${name})`);
130
+ const { rows, cid, agentId } = await listQuestions(deps, name);
131
+ const now = deps.now();
132
+ const open = rows.filter((r) => awaitingAnswer(r, now));
133
+ const q = qid === "latest" ? open[0] : rows.find((r) => r.approval_id === qid);
134
+ if (!q)
135
+ throw usage(qid === "latest" ? `${name} has no open question` : `${name} has no question ${qid}`);
136
+ if (!awaitingAnswer(q, now))
137
+ throw usage(`question ${q.approval_id} is already ${stateLabel(q, now)}`);
138
+ const body = dismiss
139
+ ? { approval_id: q.approval_id, via: "coding_agent", dismiss: true }
140
+ : {
141
+ approval_id: q.approval_id,
142
+ via: "coding_agent",
143
+ answers: answersFromChoices(q, choices),
144
+ };
145
+ const a = await deps.api.questions("own_answer", cid, agentId, body);
146
+ if (a.status !== 200 || !questionAnswerResponseOk(a.body))
147
+ throw refusalFromAnswer(a, name, deps.launcherKey());
148
+ const r = a.body;
149
+ say(r.outcome === "dismissed"
150
+ ? `${name}: question ${q.approval_id} dismissed`
151
+ : r.delivery === "live"
152
+ ? `${name}: answered; the waiting seat takes it now`
153
+ : `${name}: answered; it reaches the session as its next message (inbox ${r.inbox_id})`);
154
+ if (json)
155
+ deps.out(JSON.stringify(a.body));
156
+ return 0;
157
+ }
158
+ catch (e) {
159
+ return handleVerbError(e, json, deps);
160
+ }
161
+ }
@@ -11,6 +11,7 @@ import { type AnthropicUsageDeps } from "../lib/scan/spend/anthropic-usage.js";
11
11
  import { type RecordDeps, type RecordOutcome } from "../lib/scan/record.js";
12
12
  import { type CostSpend } from "../lib/scan/cost-per-pr.js";
13
13
  import { type ReportEmailDeps } from "../lib/scan/report-email.js";
14
+ import { type RepoListDeps } from "../lib/scan/repo-picker.js";
14
15
  import { type ReportRecipients } from "../lib/scan/share.js";
15
16
  export interface ScanCommandOptions {
16
17
  repos: string[];
@@ -42,12 +43,20 @@ export interface ScanCommandOptions {
42
43
  recordDeps?: RecordDeps;
43
44
  /** Test seam: the report-email POST. */
44
45
  emailDeps?: ReportEmailDeps;
46
+ /** Test seam: the org this login was authorized for (none of the stored tokens carry one today). */
47
+ authorizedOrg?: () => string | undefined;
48
+ /** Test seam: the `mnemom org use` default. */
49
+ activeOrg?: () => {
50
+ org_id: string;
51
+ } | undefined;
45
52
  /** Test seam: the signed-in user's orgs, used to infer --org when it is omitted. */
46
53
  listOrgs?: () => Promise<Array<{
47
54
  org_id: string;
48
55
  name?: string;
49
56
  is_personal?: boolean;
50
57
  }>>;
58
+ /** Test seam: the fetch behind the repo picker's list read. */
59
+ repoListDeps?: RepoListDeps;
51
60
  /** Test seams. */
52
61
  isTty?: boolean;
53
62
  env?: NodeJS.ProcessEnv;
@@ -93,9 +102,10 @@ export declare function resolveMeasuredUsage(o: ScanCommandOptions, period: {
93
102
  } | null>;
94
103
  /**
95
104
  * --org is optional only because it can be inferred: the report is emailed on every scan, so the scan
96
- * needs an org. Inference uses the signed-in user's own memberships (GET /v1/orgs, personal org
97
- * excluded): exactly one org is used; none, several, or a failed read stops the scan loudly BEFORE any
98
- * scan work, naming the fix. It never guesses between orgs and never skips the email.
105
+ * needs an org. Order: --org; the org the login was authorized for; the `mnemom org use` default; the
106
+ * signed-in user's single non-personal membership (GET /v1/orgs); a terminal picker over several; else it
107
+ * stops loudly BEFORE any scan work, naming each org beside its id. It never guesses between orgs and
108
+ * never skips the email.
99
109
  */
100
110
  export declare function resolveScanOrg(o: ScanCommandOptions): Promise<string>;
101
111
  export declare const INVALID_ORG_MSG = "--org must be an org id like org-1a2b3c4d.";
@@ -12,10 +12,12 @@ import { isSpendTiersEnabled, NOT_ENABLED_MSG, parseMonthlySpendUsd, selectSpend
12
12
  import { ADMIN_KEY_ENV, AnthropicUsageError, fetchMeasuredUsage, isAdminKeyShape, usageFailureText, } from "../lib/scan/spend/anthropic-usage.js";
13
13
  import { getAuthInfo } from "../lib/auth.js";
14
14
  import { listMyOrgs } from "../lib/api.js";
15
+ import { getActiveOrg } from "../lib/cli-config.js";
15
16
  import { fetchRecordTotals, isOrgId, } from "../lib/scan/record.js";
16
17
  import { modelledSpend } from "../lib/scan/cost-per-pr.js";
17
18
  import { RECORD_NO_ORG_NOTE, renderRecordSection } from "../lib/scan/report.js";
18
19
  import { buildEmailBody, buildEmailReport, DRY_RUN_MSG, UNAVAILABLE_MSG, failureMessage, ReportEmailError, sendReportEmail, } from "../lib/scan/report-email.js";
20
+ import { pickRepos, REPO_PICKER_NO_TERMINAL_HINT, } from "../lib/scan/repo-picker.js";
19
21
  import { buildSharePayload, parseShareLevel, promptShareLevel, } from "../lib/scan/share.js";
20
22
  /** Parses repeated/comma-separated `--repo owner/name`. Returns null on any invalid slug. */
21
23
  export function parseRepoFlags(raw) {
@@ -126,9 +128,10 @@ export async function resolveMeasuredUsage(o, period) {
126
128
  }
127
129
  /**
128
130
  * --org is optional only because it can be inferred: the report is emailed on every scan, so the scan
129
- * needs an org. Inference uses the signed-in user's own memberships (GET /v1/orgs, personal org
130
- * excluded): exactly one org is used; none, several, or a failed read stops the scan loudly BEFORE any
131
- * scan work, naming the fix. It never guesses between orgs and never skips the email.
131
+ * needs an org. Order: --org; the org the login was authorized for; the `mnemom org use` default; the
132
+ * signed-in user's single non-personal membership (GET /v1/orgs); a terminal picker over several; else it
133
+ * stops loudly BEFORE any scan work, naming each org beside its id. It never guesses between orgs and
134
+ * never skips the email.
132
135
  */
133
136
  export async function resolveScanOrg(o) {
134
137
  if (o.org !== undefined) {
@@ -136,6 +139,18 @@ export async function resolveScanOrg(o) {
136
139
  throw new Error(INVALID_ORG_MSG);
137
140
  return o.org;
138
141
  }
142
+ // 2. The org this login was authorized for, when the token or session carries one.
143
+ const authorized = o.authorizedOrg?.();
144
+ if (authorized !== undefined) {
145
+ if (!isOrgId(authorized))
146
+ throw new Error(INVALID_ORG_MSG);
147
+ return authorized;
148
+ }
149
+ // 3. The default set with `mnemom org use <slug>` (a malformed stored id is ignored, not trusted).
150
+ const active = (o.activeOrg ?? getActiveOrg)()?.org_id;
151
+ if (active !== undefined && isOrgId(active))
152
+ return active;
153
+ // 4-6. Memberships: a single one, else a terminal picker, else fail closed.
139
154
  let orgs;
140
155
  try {
141
156
  orgs = (await (o.listOrgs ?? listMyOrgs)()).filter((x) => !x.is_personal);
@@ -147,8 +162,20 @@ export async function resolveScanOrg(o) {
147
162
  return orgs[0].org_id;
148
163
  if (orgs.length === 0)
149
164
  throw new Error("You belong to no organization, so the report has nowhere to be emailed from. Pass --org <org_id>.");
150
- throw new Error(`You belong to ${orgs.length} organizations; pass --org <org_id> (${orgs.map((x) => x.org_id).join(", ")}).`);
165
+ // Several orgs: ids alone mean nothing, so show each name beside its id. A terminal picks one; anything
166
+ // else fails closed (never a guess) with the same labels in the message.
167
+ const labels = orgs.map(orgLabel);
168
+ if (o.isTty ?? process.stdin.isTTY) {
169
+ const chosen = await askSelect("Which organization should the report be emailed for?", labels);
170
+ const hit = orgs.find((x, i) => labels[i] === chosen);
171
+ if (hit && isOrgId(hit.org_id))
172
+ return hit.org_id;
173
+ throw new Error("No organization selected. Pass --org <org_id>.");
174
+ }
175
+ throw new Error(`You belong to ${orgs.length} organizations; pass --org <org_id> (${labels.join(", ")}).`);
151
176
  }
177
+ /** "Name (org-id)", or just the id when the org has no usable name. */
178
+ const orgLabel = (x) => typeof x.name === "string" && x.name.trim() !== "" ? `${x.name.trim()} (${x.org_id})` : x.org_id;
152
179
  export const INVALID_ORG_MSG = "--org must be an org id like org-1a2b3c4d.";
153
180
  /**
154
181
  * The Record read for the preview: null when the flag gate is off (no read, no output, as before).
@@ -168,23 +195,34 @@ export async function scanCommand(o) {
168
195
  if (o.share !== undefined && parseShareLevel(o.share) === null) {
169
196
  throw new Error("--share must be one of: summary, full, none");
170
197
  }
171
- if (o.repos.length === 0)
172
- throw new Error("--repo owner/name is required (repeatable)");
198
+ const noRepoFlag = o.repos.length === 0;
199
+ // No --repo: on a terminal pick from the repos this GitHub login can read; otherwise the error stays, with a hint.
200
+ if (noRepoFlag && !(o.isTty ?? process.stdin.isTTY))
201
+ throw new Error(`--repo owner/name is required (repeatable). ${REPO_PICKER_NO_TERMINAL_HINT}`);
173
202
  // The report is emailed on every scan, so resolve the org before any scan work; failure is loud.
174
203
  const emailOrg = await resolveScanOrg(o);
175
204
  if (o.ctoEmail !== undefined && !isEmail(o.ctoEmail))
176
205
  throw new Error("--cto-email must be a valid email address");
206
+ const getCredential = async () => {
207
+ const c = o.credential ?? (await resolveCredential().catch(() => null));
208
+ if (!c)
209
+ throw new Error(failureText("credential"));
210
+ return c;
211
+ };
212
+ // Asked before the spend prompts so the repo comes first; the credential is reused for the scan.
213
+ const pickedCredential = noRepoFlag ? await getCredential() : null;
214
+ const repos = pickedCredential
215
+ ? await pickRepos(pickedCredential, { isTty: true, deps: o.repoListDeps })
216
+ : o.repos;
177
217
  const selfReported = await resolveSelfReportedSpend(o);
178
218
  const now = o.now ?? Date.now;
179
219
  const end = new Date(now()).toISOString().slice(0, 10);
180
220
  const period = o.period ?? { start: end, end };
181
221
  const measured = await resolveMeasuredUsage(o, period);
182
- const credential = o.credential ?? (await resolveCredential().catch(() => null));
183
- if (!credential)
184
- throw new Error(failureText("credential"));
222
+ const credential = pickedCredential ?? (await getCredential());
185
223
  // Before the local scan: a refused credential fails closed without spending the GitHub reads.
186
224
  const record = await resolveRecordOutcome(o);
187
- const outcome = await runLocalScan({ credential, now, ...o.deps }, { repos: o.repos, period });
225
+ const outcome = await runLocalScan({ credential, now, ...o.deps }, { repos, period });
188
226
  if (outcome.status === "withheld")
189
227
  throw new Error(`Scan withheld: ${failureText(outcome.reason)}`);
190
228
  const { result } = outcome;
@@ -51,7 +51,10 @@ export declare function armVerdict(pathname: string, rawHeaders: readonly string
51
51
  forward: false;
52
52
  reason: DropReason;
53
53
  };
54
- /** The dead-key signal in a door response (C13-R7), read from the JSON body's error.type. */
54
+ /**
55
+ * The dead-key signal in a door response (C13-R7), read from the JSON body's error.code: the
56
+ * gateway's durable refusals are durableErrorBody, `{error:{code, message}}`, with no error.type.
57
+ */
55
58
  export declare function deadKeyReason(status: number, body: string): ExitReason | null;
56
59
  /** Whether a request path is origin-form and free of dot segments (raw or percent-encoded). */
57
60
  export declare function isCleanOriginForm(url: string): boolean;
@@ -71,6 +74,14 @@ export interface SeatProxyDeps {
71
74
  export interface SeatProxyState {
72
75
  dead: ExitReason | null;
73
76
  }
77
+ /**
78
+ * The dead-candidate body as text, decoded by its content-encoding (gzip, deflate, br; identity or
79
+ * absent passes through; a comma list is undone last-applied first). The decoded size is capped too
80
+ * (zlib maxOutputLength), so a decompression bomb cannot blow up memory. Returns null when the body
81
+ * cannot be decoded: corrupt bytes, an unknown encoding, or over the cap once decoded. For the dead-key
82
+ * check only; the forwarded response keeps the original bytes.
83
+ */
84
+ export declare function decodeDeadBody(buf: Buffer, contentEncoding: string | undefined, cap: number): string | null;
74
85
  /** The request handler of a seat proxy for `cfg` with lease key `key`. */
75
86
  export declare function createSeatProxyHandler(cfg: SeatProxyArgs, key: string, deps: SeatProxyDeps, state?: SeatProxyState): (req: http.IncomingMessage, res: http.ServerResponse) => void;
76
87
  export interface SeatProxyMainDeps {
@@ -20,6 +20,7 @@ import fs from "node:fs";
20
20
  import http from "node:http";
21
21
  import https from "node:https";
22
22
  import os from "node:os";
23
+ import zlib from "node:zlib";
23
24
  import { randomBytes } from "node:crypto";
24
25
  import { TurnOrigins, claudeConfigDir, TURN_HEADER } from "../turn-origin.mjs";
25
26
  import { httpDoorAllowed } from "./test-mode.mjs";
@@ -190,26 +191,29 @@ export function armVerdict(pathname, rawHeaders) {
190
191
  reason: a.hold !== "on" ? "unattended_hold_off" : "unattended_fence_off",
191
192
  };
192
193
  }
193
- /** The dead-key signal in a door response (C13-R7), read from the JSON body's error.type. */
194
+ /**
195
+ * The dead-key signal in a door response (C13-R7), read from the JSON body's error.code: the
196
+ * gateway's durable refusals are durableErrorBody, `{error:{code, message}}`, with no error.type.
197
+ */
194
198
  export function deadKeyReason(status, body) {
195
199
  if (status !== 401 && status !== 409 && status !== 410)
196
200
  return null;
197
- let type;
201
+ let code;
198
202
  try {
199
203
  const j = JSON.parse(body);
200
- type =
204
+ code =
201
205
  j && typeof j === "object" && j.error && typeof j.error === "object"
202
- ? j.error.type
206
+ ? j.error.code
203
207
  : undefined;
204
208
  }
205
209
  catch {
206
210
  return null;
207
211
  }
208
- if (status === 409 && type === "durable_lease_fenced")
212
+ if (status === 409 && code === "durable_lease_fenced")
209
213
  return "fenced_exit";
210
- if (status === 401 && type === "durable_key_revoked")
214
+ if (status === 401 && code === "durable_key_revoked")
211
215
  return "revoked_exit";
212
- if (status === 410 && (type === "durable_session_ended" || type === "durable_unlocked"))
216
+ if (status === 410 && (code === "durable_session_ended" || code === "durable_unlocked"))
213
217
  return "ended_exit";
214
218
  return null;
215
219
  }
@@ -252,6 +256,45 @@ function sendLocal(res, status, type, message) {
252
256
  res.writeHead(status, { "content-type": "application/json" });
253
257
  res.end(JSON.stringify({ type: "error", error: { type, message } }));
254
258
  }
259
+ /**
260
+ * The dead-candidate body as text, decoded by its content-encoding (gzip, deflate, br; identity or
261
+ * absent passes through; a comma list is undone last-applied first). The decoded size is capped too
262
+ * (zlib maxOutputLength), so a decompression bomb cannot blow up memory. Returns null when the body
263
+ * cannot be decoded: corrupt bytes, an unknown encoding, or over the cap once decoded. For the dead-key
264
+ * check only; the forwarded response keeps the original bytes.
265
+ */
266
+ export function decodeDeadBody(buf, contentEncoding, cap) {
267
+ const encs = (contentEncoding ?? "")
268
+ .split(",")
269
+ .map((e) => e.trim().toLowerCase())
270
+ .filter((e) => e !== "" && e !== "identity");
271
+ const opts = { maxOutputLength: Math.max(cap, 1) };
272
+ try {
273
+ let out = buf;
274
+ for (const enc of encs.reverse()) {
275
+ if (enc === "gzip" || enc === "x-gzip")
276
+ out = zlib.gunzipSync(out, opts);
277
+ else if (enc === "br")
278
+ out = zlib.brotliDecompressSync(out, opts);
279
+ else if (enc === "deflate") {
280
+ try {
281
+ out = zlib.inflateSync(out, opts);
282
+ }
283
+ catch (e) {
284
+ if (e instanceof RangeError)
285
+ throw e;
286
+ out = zlib.inflateRawSync(out, opts); // some servers send raw deflate
287
+ }
288
+ }
289
+ else
290
+ return null;
291
+ }
292
+ return out.toString("utf8");
293
+ }
294
+ catch {
295
+ return null;
296
+ }
297
+ }
255
298
  /** Thrown by readAll when a body passes its cap. */
256
299
  class OverCap extends Error {
257
300
  }
@@ -393,15 +436,18 @@ export function createSeatProxyHandler(cfg, key, deps, state = { dead: null }) {
393
436
  const deadCandidate = status === 401 || status === 409 || status === 410;
394
437
  if (deadCandidate) {
395
438
  void readAll(ur, deadBodyCap).then((buf) => {
396
- const dead = deadKeyReason(status, buf.toString("utf8"));
439
+ const text = decodeDeadBody(buf, ur.headers["content-encoding"], deadBodyCap);
440
+ const dead = text === null ? null : deadKeyReason(status, text);
397
441
  if (dead)
398
442
  state.dead = dead;
399
443
  if (verdict.forward) {
444
+ if (text === null)
445
+ deps.log(`${pathname} ${status} undecodable_body`);
400
446
  res.writeHead(status, responseHeaders(ur.rawHeaders));
401
447
  res.end(buf);
402
448
  }
403
449
  else {
404
- deps.log(`${pathname} ${status} drop ${verdict.reason}`);
450
+ deps.log(`${pathname} ${status} drop ${text === null ? "undecodable_body" : verdict.reason}`);
405
451
  sendDrop(res, reqId());
406
452
  }
407
453
  if (dead) {
@@ -3,6 +3,8 @@ import net from "node:net";
3
3
  export declare const SEAT_SOCKET_REFUSAL = "{\"ok\":false,\"error\":\"seat_op_refused\"}";
4
4
  /** Our own hello cannot reach the daemon (no relay configured, or the relay failed). */
5
5
  export declare const SEAT_SOCKET_UNAVAILABLE = "{\"ok\":false,\"error\":\"seat_hello_unavailable\"}";
6
+ /** A question op that cannot reach the daemon (no relay configured, or the relay failed). */
7
+ export declare const SEAT_SOCKET_QUESTION_UNAVAILABLE = "{\"ok\":false,\"error\":\"seat_question_unavailable\"}";
6
8
  /** A frame (the bytes before the newline) longer than this many bytes is refused without parsing. */
7
9
  export declare const SEAT_SOCKET_MAX_FRAME: number;
8
10
  /** macOS sun_path is 104 bytes including the NUL. */
@@ -13,12 +15,28 @@ export interface SeatIdentity {
13
15
  epoch: number;
14
16
  spawnId: string;
15
17
  }
16
- export type HelloRelay = (frame: {
18
+ /** S1 amendment 61: the seat's question ops (seat-ask.mjs). */
19
+ export declare const SEAT_QUESTION_OPS: readonly ["question_ask", "question_poll", "question_release"];
20
+ export type SeatQuestionOp = (typeof SEAT_QUESTION_OPS)[number];
21
+ export type SeatRelayFrame = {
17
22
  op: "hello";
18
23
  session_id: string;
19
24
  epoch: number;
20
25
  spawn_id: string;
21
- }) => Promise<string>;
26
+ } | {
27
+ op: SeatQuestionOp;
28
+ session_id: string;
29
+ epoch: number;
30
+ spawn_id: string;
31
+ [k: string]: unknown;
32
+ };
33
+ /** One frame to the daemon's control socket, its one-line answer back. */
34
+ export type HelloRelay = (frame: SeatRelayFrame) => Promise<string>;
35
+ /**
36
+ * The relay frame for the seat's own question op, or null (refused). Session, spawn id and epoch
37
+ * must all be the seat's own (epoch required); the payload must be the op's exact shape.
38
+ */
39
+ export declare function ownQuestionFrame(frame: unknown, seat: SeatIdentity | null): SeatRelayFrame | null;
22
40
  /** Whether a parsed frame is the seat's own hello. Everything else is refused. */
23
41
  export declare function isOwnHello(frame: unknown, seat: SeatIdentity | null): boolean;
24
42
  /** The reply line (without newline) for one request line. Never throws. */
@@ -10,19 +10,63 @@
10
10
  // the hook unchanged; a repeat hello is relayed like the first. Every other op, and any op naming
11
11
  // another session id or spawn id, gets the one fixed refusal line below, with no relay and no API
12
12
  // call. The refusal echoes nothing from the request.
13
+ //
14
+ // S1 amendment 61 (signed off by Wassim 2026-11-02) adds three question ops, relayed the same way and
15
+ // only for the seat's own session, spawn id AND epoch (required here, not optional as for hello):
16
+ // {op:"question_ask", tool_use_id, questions} the plugin's AskUserQuestion PreToolUse hook
17
+ // {op:"question_poll", approval_id} while that hook waits for the owner
18
+ // {op:"question_release", approval_id} the hook stopped waiting; the answer goes to the inbox
19
+ // The payload is checked against S1 question.ts's shapes before any relay; the daemon adds the
20
+ // epoch and nonce of the live lease and calls the executor question routes.
13
21
  import fs from "node:fs";
14
22
  import net from "node:net";
15
23
  import os from "node:os";
16
24
  import path from "node:path";
17
25
  import { createHash } from "node:crypto";
26
+ import { normalizeQuestions, QUESTION_TOOL_USE_ID_RE } from "../vendor/durable-contract/index.js";
18
27
  /** The one fixed refusal line (bytes before the newline). A local literal: S1 freezes no seat-socket refusal line yet (gap c3-seat-proxy-enums-not-in-s1). */
19
28
  export const SEAT_SOCKET_REFUSAL = '{"ok":false,"error":"seat_op_refused"}';
20
29
  /** Our own hello cannot reach the daemon (no relay configured, or the relay failed). */
21
30
  export const SEAT_SOCKET_UNAVAILABLE = '{"ok":false,"error":"seat_hello_unavailable"}';
31
+ /** A question op that cannot reach the daemon (no relay configured, or the relay failed). */
32
+ export const SEAT_SOCKET_QUESTION_UNAVAILABLE = '{"ok":false,"error":"seat_question_unavailable"}';
22
33
  /** A frame (the bytes before the newline) longer than this many bytes is refused without parsing. */
23
34
  export const SEAT_SOCKET_MAX_FRAME = 64 * 1024;
24
35
  /** macOS sun_path is 104 bytes including the NUL. */
25
36
  export const SOCKET_PATH_MAX = 103;
37
+ /** S1 amendment 61: the seat's question ops (seat-ask.mjs). */
38
+ export const SEAT_QUESTION_OPS = ["question_ask", "question_poll", "question_release"];
39
+ const UUID_LC = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
40
+ /**
41
+ * The relay frame for the seat's own question op, or null (refused). Session, spawn id and epoch
42
+ * must all be the seat's own (epoch required); the payload must be the op's exact shape.
43
+ */
44
+ export function ownQuestionFrame(frame, seat) {
45
+ if (!seat || !frame || typeof frame !== "object" || Array.isArray(frame))
46
+ return null;
47
+ const f = frame;
48
+ if (!SEAT_QUESTION_OPS.includes(f.op))
49
+ return null;
50
+ if (f.session_id !== seat.sessionId || f.spawn_id !== seat.spawnId || f.epoch !== seat.epoch)
51
+ return null;
52
+ const base = { session_id: seat.sessionId, epoch: seat.epoch, spawn_id: seat.spawnId };
53
+ const keys = Object.keys(f).sort().join(",");
54
+ if (f.op === "question_ask") {
55
+ if (keys !== "epoch,op,questions,session_id,spawn_id,tool_use_id")
56
+ return null;
57
+ if (typeof f.tool_use_id !== "string" || !QUESTION_TOOL_USE_ID_RE.test(f.tool_use_id))
58
+ return null;
59
+ const questions = normalizeQuestions(f.questions);
60
+ if (!questions)
61
+ return null;
62
+ return { op: "question_ask", ...base, tool_use_id: f.tool_use_id, questions };
63
+ }
64
+ if (keys !== "approval_id,epoch,op,session_id,spawn_id")
65
+ return null;
66
+ if (typeof f.approval_id !== "string" || !UUID_LC.test(f.approval_id))
67
+ return null;
68
+ return { op: f.op, ...base, approval_id: f.approval_id };
69
+ }
26
70
  /** Whether a parsed frame is the seat's own hello. Everything else is refused. */
27
71
  export function isOwnHello(frame, seat) {
28
72
  if (!seat || !frame || typeof frame !== "object" || Array.isArray(frame))
@@ -48,6 +92,20 @@ export async function answerFrame(line, seat, relay) {
48
92
  catch {
49
93
  return { reply: SEAT_SOCKET_REFUSAL, outcome: "refused" };
50
94
  }
95
+ const question = ownQuestionFrame(frame, seat);
96
+ if (question) {
97
+ if (!relay)
98
+ return { reply: SEAT_SOCKET_QUESTION_UNAVAILABLE, outcome: "unavailable" };
99
+ try {
100
+ const first = String(await relay(question)).split("\n")[0];
101
+ return first
102
+ ? { reply: first, outcome: "relayed" }
103
+ : { reply: SEAT_SOCKET_QUESTION_UNAVAILABLE, outcome: "unavailable" };
104
+ }
105
+ catch {
106
+ return { reply: SEAT_SOCKET_QUESTION_UNAVAILABLE, outcome: "unavailable" };
107
+ }
108
+ }
51
109
  if (!isOwnHello(frame, seat) || !seat)
52
110
  return { reply: SEAT_SOCKET_REFUSAL, outcome: "refused" };
53
111
  if (!relay)
@@ -36,6 +36,14 @@ export declare function statedEntry(e: TranscriptEntry): TranscriptEntry;
36
36
  * origin=task entries only (S1 amendment 56); undefined otherwise or when not a TURN_PRODUCER_RE token.
37
37
  */
38
38
  export declare function entryProducer(e: TranscriptEntry): string | undefined;
39
+ /**
40
+ * The inbox sha an entry answers (S1 amendment 60): the `[mnemom inbox <sha>]` marker on the first
41
+ * line of the entry's first text (after Claude Code's paste open tag, as the host daemon delivers by
42
+ * bracketed paste), else undefined. Only a prompt entry stated as human can carry one: a task or
43
+ * system entry that quotes a marker never does. Restrict-only: the stamp only makes a turn
44
+ * unattended, and the gateway admits it only while the sha is open (delivered, not acked).
45
+ */
46
+ export declare function entryInboxSha(e: TranscriptEntry): string | undefined;
39
47
  /** The header value: the leading chain element, then the stated entries up to and including the root. */
40
48
  export declare function durableHeaderValue(entries: readonly TranscriptEntry[], chain: string): string;
41
49
  /** Entries up to and including the last one whose promptId is `promptId`; none when it is absent. */
@@ -24,9 +24,10 @@
24
24
  // origin.producer, e.g. session-task), here only. turn-origin.mjs is main's, so the plain proxies'
25
25
  // header bytes never carry it.
26
26
  import { createHash } from "node:crypto";
27
- import { buildTurnHeader, entryOrigin, entrySource, TURN_PARTS_MAX, } from "../turn-origin.mjs";
27
+ import { buildTurnHeader, entryOrigin, entrySource, entryTexts, TURN_PARTS_MAX, } from "../turn-origin.mjs";
28
28
  import { TURN_WAIT_MS, isCountTokensPath, } from "../vendor/durable-contract/index.js";
29
29
  import { TURN_PRODUCER_RE } from "../vendor/shared/durable-contract/turn.js";
30
+ import { inboxMarkerOf } from "../vendor/shared/durable-contract/inbox.js";
30
31
  const ID_RE = /^[A-Za-z0-9-]{8,64}$/;
31
32
  const POLL_MS = 20;
32
33
  const one = (v) => String((Array.isArray(v) ? v[0] : v) ?? "").trim();
@@ -84,9 +85,26 @@ export function entryProducer(e) {
84
85
  const p = e.origin?.producer;
85
86
  return typeof p === "string" && TURN_PRODUCER_RE.test(p) ? p : undefined;
86
87
  }
88
+ /** Claude Code's open tag for a bracketed paste, which may lead a pasted prompt's text. */
89
+ const PASTE_OPEN_RE = /^<pasted_content id="[^"\n]{0,128}">\n?/;
90
+ /**
91
+ * The inbox sha an entry answers (S1 amendment 60): the `[mnemom inbox <sha>]` marker on the first
92
+ * line of the entry's first text (after Claude Code's paste open tag, as the host daemon delivers by
93
+ * bracketed paste), else undefined. Only a prompt entry stated as human can carry one: a task or
94
+ * system entry that quotes a marker never does. Restrict-only: the stamp only makes a turn
95
+ * unattended, and the gateway admits it only while the sha is open (delivered, not acked).
96
+ */
97
+ export function entryInboxSha(e) {
98
+ if (entryOrigin(e) !== "human" || entrySource(e) === "system")
99
+ return undefined;
100
+ const first = entryTexts(e)[0];
101
+ if (typeof first !== "string")
102
+ return undefined;
103
+ return inboxMarkerOf(first.replace(PASTE_OPEN_RE, "")) ?? undefined;
104
+ }
87
105
  /**
88
106
  * main's buildTurnHeader, entry by entry, with `producer=<p>; ` before `pastes=` on an entry that
89
- * states one; the same newest TURN_PARTS_MAX parts.
107
+ * states one and `; inbox=<sha>` after `h=` on an inbox delivery; the same newest TURN_PARTS_MAX parts.
90
108
  */
91
109
  function statedParts(stated) {
92
110
  const parts = [];
@@ -95,8 +113,11 @@ function statedParts(stated) {
95
113
  if (!own)
96
114
  continue;
97
115
  const producer = entryProducer(e);
98
- for (const part of own.split(", "))
99
- parts.push(producer ? part.replace("; pastes=", `; producer=${producer}; pastes=`) : part);
116
+ const inbox = entryInboxSha(e);
117
+ for (const part of own.split(", ")) {
118
+ const p = producer ? part.replace("; pastes=", `; producer=${producer}; pastes=`) : part;
119
+ parts.push(inbox ? `${p}; inbox=${inbox}` : p);
120
+ }
100
121
  }
101
122
  return parts.slice(-TURN_PARTS_MAX).join(", ");
102
123
  }