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