@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.
- package/dist/commands/agent-config.js +3 -0
- package/dist/commands/agent-goal.d.ts +8 -3
- package/dist/commands/agent-goal.js +22 -8
- package/dist/commands/agent-run.d.ts +14 -2
- package/dist/commands/agent-run.js +28 -7
- package/dist/commands/agent-share-render.js +1 -1
- package/dist/commands/agent-slack.js +32 -0
- package/dist/commands/durable-inbox.d.ts +51 -0
- package/dist/commands/durable-inbox.js +134 -0
- package/dist/commands/durable-questions.d.ts +64 -0
- package/dist/commands/durable-questions.js +161 -0
- package/dist/commands/scan.d.ts +13 -3
- package/dist/commands/scan.js +48 -10
- package/dist/durable/seat-proxy.d.ts +12 -1
- package/dist/durable/seat-proxy.js +55 -9
- package/dist/durable/seat-socket.d.ts +20 -2
- package/dist/durable/seat-socket.js +58 -0
- package/dist/durable/turn-chain.d.ts +8 -0
- package/dist/durable/turn-chain.js +25 -4
- package/dist/host/client.d.ts +41 -0
- package/dist/host/client.js +7 -0
- package/dist/host/control.d.ts +7 -0
- package/dist/host/control.js +15 -0
- package/dist/host/daemon.d.ts +20 -0
- package/dist/host/daemon.js +74 -1
- package/dist/host/inbox-delivery.d.ts +174 -0
- package/dist/host/inbox-delivery.js +453 -0
- package/dist/host/run.d.ts +18 -0
- package/dist/host/run.js +61 -0
- package/dist/host/s1-names.d.ts +2 -0
- package/dist/host/s1-names.js +2 -0
- package/dist/host/tmux.d.ts +7 -0
- package/dist/host/tmux.js +19 -0
- package/dist/index.js +62 -3
- package/dist/lib/api.d.ts +5 -0
- package/dist/lib/api.js +16 -0
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.js +21 -0
- package/dist/lib/durable/api.d.ts +11 -0
- package/dist/lib/durable/api.js +28 -3
- package/dist/lib/durable/gate.d.ts +1 -1
- package/dist/lib/durable/gate.js +8 -1
- package/dist/lib/durable/live.d.ts +5 -0
- package/dist/lib/durable/live.js +19 -0
- package/dist/lib/experimental.js +3 -0
- package/dist/lib/goal-api.d.ts +11 -4
- package/dist/lib/goal-api.js +4 -3
- package/dist/lib/launcher-config.d.ts +5 -0
- package/dist/lib/launcher-config.js +17 -2
- package/dist/lib/prompt.d.ts +4 -2
- package/dist/lib/prompt.js +15 -5
- package/dist/lib/runner/job.d.ts +2 -2
- package/dist/lib/runner/job.js +16 -3
- package/dist/lib/runner/pr-body.d.ts +17 -0
- package/dist/lib/runner/pr-body.js +42 -0
- package/dist/lib/runner/proxy.d.ts +18 -3
- package/dist/lib/runner/proxy.js +24 -5
- package/dist/lib/scan/pr-source.js +2 -0
- package/dist/lib/scan/record.d.ts +60 -9
- package/dist/lib/scan/record.js +157 -39
- package/dist/lib/scan/repo-picker.d.ts +32 -0
- package/dist/lib/scan/repo-picker.js +80 -0
- package/dist/lib/scan/report.d.ts +6 -8
- package/dist/lib/scan/report.js +21 -7
- package/dist/lib/scan/share.d.ts +5 -1
- package/dist/lib/scan/share.js +15 -5
- package/dist/lib/slack-durable.d.ts +123 -0
- package/dist/lib/slack-durable.js +237 -0
- package/dist/lib/slack-router.d.ts +7 -0
- package/dist/lib/slack-router.js +50 -4
- package/dist/vendor/durable-contract/index.js +992 -34
- package/dist/vendor/shared/durable-contract/admission.d.ts +81 -2
- package/dist/vendor/shared/durable-contract/admission.js +319 -2
- package/dist/vendor/shared/durable-contract/cli-surface.js +21 -0
- package/dist/vendor/shared/durable-contract/codes.d.ts +60 -0
- package/dist/vendor/shared/durable-contract/codes.js +22 -0
- package/dist/vendor/shared/durable-contract/events.d.ts +4 -2
- package/dist/vendor/shared/durable-contract/events.js +55 -1
- package/dist/vendor/shared/durable-contract/freeze-record.d.ts +14 -0
- package/dist/vendor/shared/durable-contract/inbox.d.ts +299 -0
- package/dist/vendor/shared/durable-contract/inbox.js +462 -0
- package/dist/vendor/shared/durable-contract/index.d.ts +2 -0
- package/dist/vendor/shared/durable-contract/index.js +2 -0
- package/dist/vendor/shared/durable-contract/question.d.ts +218 -0
- package/dist/vendor/shared/durable-contract/question.js +373 -0
- package/dist/vendor/shared/durable-contract/routes.d.ts +44 -2
- package/dist/vendor/shared/durable-contract/routes.js +43 -1
- package/dist/vendor/shared/durable-contract/turn.d.ts +15 -1
- package/dist/vendor/shared/durable-contract/turn.js +33 -1
- package/dist/vendor/shared/durable-contract/vectors.d.ts +45 -0
- package/dist/vendor/shared/durable-contract/vectors.js +49 -0
- package/node_modules/@mnemom/aip-verifier/LICENSE +190 -0
- package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.cts +478 -0
- package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.ts +478 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.cjs +971 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.cjs.map +1 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.d.cts +330 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.d.ts +330 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.js +933 -0
- package/node_modules/@mnemom/aip-verifier/dist/index.js.map +1 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs +879 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs.map +1 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.cts +96 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.ts +96 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.js +875 -0
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.js.map +1 -0
- package/node_modules/@mnemom/aip-verifier/package.json +86 -0
- package/node_modules/@noble/ed25519/LICENSE +21 -0
- package/node_modules/@noble/ed25519/README.md +371 -0
- package/node_modules/@noble/ed25519/index.d.ts +391 -0
- package/node_modules/@noble/ed25519/index.js +933 -0
- package/node_modules/@noble/ed25519/index.ts +1134 -0
- package/node_modules/@noble/ed25519/package.json +54 -0
- package/node_modules/@noble/hashes/LICENSE +21 -0
- package/node_modules/@noble/hashes/README.md +478 -0
- package/node_modules/@noble/hashes/_blake.d.ts +20 -0
- package/node_modules/@noble/hashes/_blake.js +59 -0
- package/node_modules/@noble/hashes/_md.d.ts +95 -0
- package/node_modules/@noble/hashes/_md.js +209 -0
- package/node_modules/@noble/hashes/_u64.d.ts +36 -0
- package/node_modules/@noble/hashes/_u64.js +77 -0
- package/node_modules/@noble/hashes/argon2.d.ts +148 -0
- package/node_modules/@noble/hashes/argon2.js +577 -0
- package/node_modules/@noble/hashes/blake1.d.ts +162 -0
- package/node_modules/@noble/hashes/blake1.js +630 -0
- package/node_modules/@noble/hashes/blake2.d.ts +203 -0
- package/node_modules/@noble/hashes/blake2.js +573 -0
- package/node_modules/@noble/hashes/blake3.d.ts +68 -0
- package/node_modules/@noble/hashes/blake3.js +310 -0
- package/node_modules/@noble/hashes/eskdf.d.ts +91 -0
- package/node_modules/@noble/hashes/eskdf.js +225 -0
- package/node_modules/@noble/hashes/hkdf.d.ts +70 -0
- package/node_modules/@noble/hashes/hkdf.js +152 -0
- package/node_modules/@noble/hashes/hmac.d.ts +50 -0
- package/node_modules/@noble/hashes/hmac.js +93 -0
- package/node_modules/@noble/hashes/index.d.ts +1 -0
- package/node_modules/@noble/hashes/index.js +34 -0
- package/node_modules/@noble/hashes/legacy.d.ts +107 -0
- package/node_modules/@noble/hashes/legacy.js +348 -0
- package/node_modules/@noble/hashes/package.json +91 -0
- package/node_modules/@noble/hashes/pbkdf2.d.ts +66 -0
- package/node_modules/@noble/hashes/pbkdf2.js +176 -0
- package/node_modules/@noble/hashes/scrypt.d.ts +98 -0
- package/node_modules/@noble/hashes/scrypt.js +304 -0
- package/node_modules/@noble/hashes/sha2.d.ts +176 -0
- package/node_modules/@noble/hashes/sha2.js +446 -0
- package/node_modules/@noble/hashes/sha3-addons.d.ts +533 -0
- package/node_modules/@noble/hashes/sha3-addons.js +973 -0
- package/node_modules/@noble/hashes/sha3.d.ts +208 -0
- package/node_modules/@noble/hashes/sha3.js +475 -0
- package/node_modules/@noble/hashes/src/_blake.ts +67 -0
- package/node_modules/@noble/hashes/src/_md.ts +227 -0
- package/node_modules/@noble/hashes/src/_u64.ts +111 -0
- package/node_modules/@noble/hashes/src/argon2.ts +693 -0
- package/node_modules/@noble/hashes/src/blake1.ts +677 -0
- package/node_modules/@noble/hashes/src/blake2.ts +648 -0
- package/node_modules/@noble/hashes/src/blake3.ts +344 -0
- package/node_modules/@noble/hashes/src/eskdf.ts +265 -0
- package/node_modules/@noble/hashes/src/hkdf.ts +161 -0
- package/node_modules/@noble/hashes/src/hmac.ts +130 -0
- package/node_modules/@noble/hashes/src/index.ts +33 -0
- package/node_modules/@noble/hashes/src/legacy.ts +361 -0
- package/node_modules/@noble/hashes/src/pbkdf2.ts +219 -0
- package/node_modules/@noble/hashes/src/scrypt.ts +375 -0
- package/node_modules/@noble/hashes/src/sha2.ts +486 -0
- package/node_modules/@noble/hashes/src/sha3-addons.ts +1157 -0
- package/node_modules/@noble/hashes/src/sha3.ts +525 -0
- package/node_modules/@noble/hashes/src/utils.ts +978 -0
- package/node_modules/@noble/hashes/src/webcrypto.ts +283 -0
- package/node_modules/@noble/hashes/utils.d.ts +560 -0
- package/node_modules/@noble/hashes/utils.js +687 -0
- package/node_modules/@noble/hashes/webcrypto.d.ts +121 -0
- package/node_modules/@noble/hashes/webcrypto.js +228 -0
- package/package.json +5 -2
- package/plugins/integrity-manifest.json +6 -2
- package/plugins/mnemom/hooks/hooks.json +12 -0
- 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
|
+
}
|
package/dist/commands/scan.d.ts
CHANGED
|
@@ -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.
|
|
97
|
-
*
|
|
98
|
-
* scan work, naming
|
|
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.";
|
package/dist/commands/scan.js
CHANGED
|
@@ -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.
|
|
130
|
-
*
|
|
131
|
-
* scan work, naming
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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 =
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
201
|
+
let code;
|
|
198
202
|
try {
|
|
199
203
|
const j = JSON.parse(body);
|
|
200
|
-
|
|
204
|
+
code =
|
|
201
205
|
j && typeof j === "object" && j.error && typeof j.error === "object"
|
|
202
|
-
? j.error.
|
|
206
|
+
? j.error.code
|
|
203
207
|
: undefined;
|
|
204
208
|
}
|
|
205
209
|
catch {
|
|
206
210
|
return null;
|
|
207
211
|
}
|
|
208
|
-
if (status === 409 &&
|
|
212
|
+
if (status === 409 && code === "durable_lease_fenced")
|
|
209
213
|
return "fenced_exit";
|
|
210
|
-
if (status === 401 &&
|
|
214
|
+
if (status === 401 && code === "durable_key_revoked")
|
|
211
215
|
return "revoked_exit";
|
|
212
|
-
if (status === 410 && (
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
99
|
-
|
|
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
|
}
|