@mnemom/mnemom 0.17.34 → 0.17.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/commands/agent-config.js +3 -0
  2. package/dist/commands/agent-goal.d.ts +8 -3
  3. package/dist/commands/agent-goal.js +22 -8
  4. package/dist/commands/agent-run.d.ts +10 -2
  5. package/dist/commands/agent-run.js +19 -6
  6. package/dist/commands/agent-slack.js +32 -0
  7. package/dist/commands/durable-inbox.d.ts +51 -0
  8. package/dist/commands/durable-inbox.js +134 -0
  9. package/dist/commands/durable-questions.d.ts +64 -0
  10. package/dist/commands/durable-questions.js +161 -0
  11. package/dist/durable/seat-proxy.d.ts +12 -1
  12. package/dist/durable/seat-proxy.js +55 -9
  13. package/dist/durable/seat-socket.d.ts +20 -2
  14. package/dist/durable/seat-socket.js +58 -0
  15. package/dist/durable/turn-chain.d.ts +8 -0
  16. package/dist/durable/turn-chain.js +25 -4
  17. package/dist/host/client.d.ts +41 -0
  18. package/dist/host/client.js +7 -0
  19. package/dist/host/control.d.ts +7 -0
  20. package/dist/host/control.js +15 -0
  21. package/dist/host/daemon.d.ts +20 -0
  22. package/dist/host/daemon.js +74 -1
  23. package/dist/host/inbox-delivery.d.ts +174 -0
  24. package/dist/host/inbox-delivery.js +453 -0
  25. package/dist/host/run.d.ts +18 -0
  26. package/dist/host/run.js +61 -0
  27. package/dist/host/s1-names.d.ts +2 -0
  28. package/dist/host/s1-names.js +2 -0
  29. package/dist/host/tmux.d.ts +7 -0
  30. package/dist/host/tmux.js +19 -0
  31. package/dist/index.js +59 -1
  32. package/dist/lib/api.d.ts +5 -0
  33. package/dist/lib/api.js +16 -0
  34. package/dist/lib/durable/api.d.ts +11 -0
  35. package/dist/lib/durable/api.js +28 -3
  36. package/dist/lib/durable/gate.d.ts +1 -1
  37. package/dist/lib/durable/gate.js +8 -1
  38. package/dist/lib/durable/live.d.ts +5 -0
  39. package/dist/lib/durable/live.js +19 -0
  40. package/dist/lib/experimental.js +3 -0
  41. package/dist/lib/goal-api.d.ts +11 -4
  42. package/dist/lib/goal-api.js +4 -3
  43. package/dist/lib/launcher-config.d.ts +5 -0
  44. package/dist/lib/launcher-config.js +17 -2
  45. package/dist/lib/prompt.d.ts +4 -2
  46. package/dist/lib/prompt.js +15 -5
  47. package/dist/lib/runner/job.d.ts +2 -2
  48. package/dist/lib/runner/job.js +16 -3
  49. package/dist/lib/runner/pr-body.d.ts +17 -0
  50. package/dist/lib/runner/pr-body.js +42 -0
  51. package/dist/lib/runner/proxy.d.ts +18 -3
  52. package/dist/lib/runner/proxy.js +24 -5
  53. package/dist/lib/scan/record.d.ts +60 -9
  54. package/dist/lib/scan/record.js +157 -39
  55. package/dist/lib/scan/report.d.ts +6 -8
  56. package/dist/lib/scan/report.js +21 -7
  57. package/dist/lib/scan/share.d.ts +5 -1
  58. package/dist/lib/scan/share.js +15 -5
  59. package/dist/lib/slack-durable.d.ts +123 -0
  60. package/dist/lib/slack-durable.js +237 -0
  61. package/dist/lib/slack-router.d.ts +7 -0
  62. package/dist/lib/slack-router.js +50 -4
  63. package/dist/vendor/durable-contract/index.js +992 -34
  64. package/dist/vendor/shared/durable-contract/admission.d.ts +81 -2
  65. package/dist/vendor/shared/durable-contract/admission.js +319 -2
  66. package/dist/vendor/shared/durable-contract/cli-surface.js +21 -0
  67. package/dist/vendor/shared/durable-contract/codes.d.ts +60 -0
  68. package/dist/vendor/shared/durable-contract/codes.js +22 -0
  69. package/dist/vendor/shared/durable-contract/events.d.ts +4 -2
  70. package/dist/vendor/shared/durable-contract/events.js +55 -1
  71. package/dist/vendor/shared/durable-contract/freeze-record.d.ts +14 -0
  72. package/dist/vendor/shared/durable-contract/inbox.d.ts +299 -0
  73. package/dist/vendor/shared/durable-contract/inbox.js +462 -0
  74. package/dist/vendor/shared/durable-contract/index.d.ts +2 -0
  75. package/dist/vendor/shared/durable-contract/index.js +2 -0
  76. package/dist/vendor/shared/durable-contract/question.d.ts +218 -0
  77. package/dist/vendor/shared/durable-contract/question.js +373 -0
  78. package/dist/vendor/shared/durable-contract/routes.d.ts +44 -2
  79. package/dist/vendor/shared/durable-contract/routes.js +43 -1
  80. package/dist/vendor/shared/durable-contract/turn.d.ts +15 -1
  81. package/dist/vendor/shared/durable-contract/turn.js +33 -1
  82. package/dist/vendor/shared/durable-contract/vectors.d.ts +45 -0
  83. package/dist/vendor/shared/durable-contract/vectors.js +49 -0
  84. package/package.json +1 -1
  85. package/plugins/integrity-manifest.json +6 -2
  86. package/plugins/mnemom/hooks/hooks.json +12 -0
  87. package/plugins/mnemom/hooks/seat-ask.mjs +221 -0
@@ -0,0 +1,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
+ }
@@ -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
  }
@@ -89,6 +89,47 @@ export interface ExecutorApi {
89
89
  nonce: string;
90
90
  entries: readonly HeartbeatEntry[];
91
91
  }): Promise<HttpResult>;
92
+ /** S1 amendment 60: the content-free inbox summary of a session this executor holds. */
93
+ inboxSummary(sessionId: string): Promise<HttpResult>;
94
+ /** Marks up to `limit` messages delivered at the live epoch and answers their delivery text. */
95
+ inboxTake(sessionId: string, body: {
96
+ epoch: number;
97
+ nonce: string;
98
+ limit: number;
99
+ }): Promise<HttpResult>;
100
+ inboxAck(sessionId: string, body: {
101
+ epoch: number;
102
+ nonce: string;
103
+ inbox_id: string;
104
+ sha: string;
105
+ }): Promise<HttpResult>;
106
+ /** Hands a delivered message back undone (admitted again, or dead at its delivery limit). */
107
+ inboxRelease(sessionId: string, body: {
108
+ epoch: number;
109
+ nonce: string;
110
+ inbox_id: string;
111
+ sha: string;
112
+ reason: "unconfirmed" | "refused" | "undeliverable";
113
+ }): Promise<HttpResult>;
114
+ /** S1 amendment 61: opens (or returns) the session's question approval; journals question.asked. */
115
+ questionAsk(sessionId: string, body: {
116
+ epoch: number;
117
+ nonce: string;
118
+ tool_use_id: string;
119
+ questions: unknown;
120
+ }): Promise<HttpResult>;
121
+ /** The question's state; an answer is handed over once (the api marks it taken). */
122
+ questionPoll(sessionId: string, body: {
123
+ epoch: number;
124
+ nonce: string;
125
+ approval_id: string;
126
+ }): Promise<HttpResult>;
127
+ /** The seat stopped waiting: the answer, now or later, goes to the inbox. */
128
+ questionRelease(sessionId: string, body: {
129
+ epoch: number;
130
+ nonce: string;
131
+ approval_id: string;
132
+ }): Promise<HttpResult>;
92
133
  }
93
134
  export declare function executorApi(opts: {
94
135
  apiUrl: string;
@@ -131,6 +131,13 @@ export function executorApi(opts) {
131
131
  seatExit: (id, body) => call("exec_seat_exit", id, "POST", body),
132
132
  fail: (id, body) => call("exec_fail", id, "POST", body),
133
133
  heartbeat: (body) => call("exec_heartbeat", undefined, "POST", body),
134
+ inboxSummary: (id) => call("exec_inbox", id, "GET"),
135
+ inboxTake: (id, body) => call("exec_inbox_take", id, "POST", body),
136
+ inboxAck: (id, body) => call("exec_inbox_ack", id, "POST", body),
137
+ inboxRelease: (id, body) => call("exec_inbox_release", id, "POST", body),
138
+ questionAsk: (id, body) => call("exec_question", id, "POST", body),
139
+ questionPoll: (id, body) => call("exec_question_poll", id, "POST", body),
140
+ questionRelease: (id, body) => call("exec_question_release", id, "POST", body),
134
141
  };
135
142
  }
136
143
  /** Map a claim response to what the daemon does with it (C12-R14, R31). */
@@ -16,7 +16,14 @@ export interface ControlHandlers {
16
16
  status: number;
17
17
  body: unknown;
18
18
  }>;
19
+ /** S1 amendment 61; absent: the question ops are unknown_op. */
20
+ relayQuestion?(op: ControlQuestionOp, sessionId: string, spawnId: string, epoch: number, payload: Record<string, unknown>): Promise<{
21
+ status: number;
22
+ body: unknown;
23
+ }>;
19
24
  }
25
+ export declare const CONTROL_QUESTION_OPS: readonly ["question_ask", "question_poll", "question_release"];
26
+ export type ControlQuestionOp = (typeof CONTROL_QUESTION_OPS)[number];
20
27
  export declare function handleControl(h: ControlHandlers, line: string): Promise<Record<string, unknown>>;
21
28
  export declare function startControlServer(socket: string, h: ControlHandlers, log: (s: string) => void): net.Server;
22
29
  /** dev and inode of the socket file at `socket`, or null when there is none. */
@@ -5,6 +5,10 @@
5
5
  * {op:"seat_key", session_id, spawn_id} -> {ok:true, lease_key} once per spawn, then {ok:false}
6
6
  * {op:"hello", session_id, spawn_id} -> {ok, status, body}: the daemon POSTs the executor hello
7
7
  * (the executor key never leaves the daemon, C12-R27)
8
+ * {op:"question_ask"|"question_poll"|"question_release", session_id, spawn_id, epoch, ...}
9
+ * -> {ok, status, body}: S1 amendment 61; the daemon adds the
10
+ * live lease's nonce and calls exec_question[_poll|_release]
11
+ * for a registered seat of that spawn id AND epoch
8
12
  * Anything else is refused locally.
9
13
  */
10
14
  import * as fs from "node:fs";
@@ -47,6 +51,7 @@ export function ensureSocketDir(dir, uid = os.userInfo().uid) {
47
51
  checkOwnSocketDir(dir, uid);
48
52
  }
49
53
  export const controlSocketPath = (dir) => path.join(dir, "daemon.sock");
54
+ export const CONTROL_QUESTION_OPS = ["question_ask", "question_poll", "question_release"];
50
55
  const ID = /^[0-9a-f-]{36}$/i;
51
56
  export async function handleControl(h, line) {
52
57
  let req;
@@ -70,6 +75,16 @@ export async function handleControl(h, line) {
70
75
  const r = await h.relayHello(sid, spawn);
71
76
  return { ok: r.status === 200, status: r.status, body: r.body };
72
77
  }
78
+ if (h.relayQuestion && CONTROL_QUESTION_OPS.includes(req.op)) {
79
+ const op = req.op;
80
+ if (!Number.isSafeInteger(req.epoch) || req.epoch < 0)
81
+ return { ok: false, error: "bad_request" };
82
+ const payload = op === "question_ask"
83
+ ? { tool_use_id: req.tool_use_id, questions: req.questions }
84
+ : { approval_id: req.approval_id };
85
+ const r = await h.relayQuestion(op, sid, spawn, req.epoch, payload);
86
+ return { ok: r.status === 200, status: r.status, body: r.body };
87
+ }
73
88
  return { ok: false, error: "unknown_op" };
74
89
  }
75
90
  export function startControlServer(socket, h, log) {
@@ -1,6 +1,7 @@
1
1
  import type { ClaimUnavailableCause, CwdCheckCause } from "../vendor/shared/durable-contract/codes.js";
2
2
  import { type ExecutorApi, type SessionItem } from "./client.js";
3
3
  import { type SeatPane } from "./tmux.js";
4
+ import type { ControlQuestionOp } from "./control.js";
4
5
  export declare const TICK_MS = 1000;
5
6
  export declare const ADOPT_RETRY_MS = 2000;
6
7
  export { SPAWNED_KIND, spawnIdempotencyKey } from "./s1-names.js";
@@ -99,6 +100,16 @@ export interface DaemonDeps {
99
100
  epoch: number;
100
101
  spawnId: string;
101
102
  }, turnId: string | null) => Promise<unknown>;
103
+ /**
104
+ * S1 amendment 60: deliver each seat's durable inbox into it (host/inbox-delivery.ts), once per
105
+ * pass after the seats are checked. Absent: no inbox delivery.
106
+ */
107
+ readonly inbox?: (seats: readonly {
108
+ sessionId: string;
109
+ tmuxSession: string;
110
+ epoch: number;
111
+ ready: boolean;
112
+ }[]) => Promise<readonly string[]>;
102
113
  }
103
114
  export interface UnavailableInfo {
104
115
  cause: ClaimUnavailableCause | null;
@@ -217,5 +228,14 @@ export declare class HostDaemon {
217
228
  status: number;
218
229
  body: unknown;
219
230
  }>;
231
+ /**
232
+ * Relay a seat's question op (S1 amendment 61). Only for a registered, hello-accepted seat this
233
+ * daemon holds, with its own spawn id and epoch; the daemon adds the live lease's epoch and nonce
234
+ * (the executor key never leaves the daemon). The api checks the payload's closed shape.
235
+ */
236
+ relayQuestion(op: ControlQuestionOp, sessionId: string, spawnId: string, epoch: number, payload: Record<string, unknown>): Promise<{
237
+ status: number;
238
+ body: unknown;
239
+ }>;
220
240
  private publish;
221
241
  }