@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,123 @@
1
+ /**
2
+ * lib/slack-durable.ts — the Slack router's door into durable sessions (S1 amendment 60, pending
3
+ * Wassim's sign-off).
4
+ *
5
+ * A Slack message routed to a session that is a LOCKED durable session (for example @alice) is not
6
+ * typed into a pane and not appended to a local inbox file: it is SENT to that session's durable
7
+ * inbox through ROUTES.own_inbox_send. The api admits it (or refuses it), the host daemon delivers it
8
+ * into the seat once the seat is idle, and the turn answering it is admitted unattended. Sessions
9
+ * that are not durable (or not locked) keep the router's file-inbox path unchanged.
10
+ *
11
+ * Who it is sent as:
12
+ * - the owner's own Slack member id (`ownerSlackIds`: launcher `slack_owner`, which `mnemom agent
13
+ * slack login` sets, plus MNEMOM_SLACK_OWNER_IDS): the owner's credential (the router runs as the
14
+ * owner), so the api records sender_kind "owner";
15
+ * - a Slack member mapped to an allowlisted sender agent (`agentSenders`, for example chief's bot
16
+ * user): that agent's own key, read from the environment variable the map names, so the api
17
+ * records it as from that agent on behalf of the owner, and refuses it unless the owner put
18
+ * that agent on the session's sender allowlist;
19
+ * - anyone else: not sent; the sender is told (ephemeral) that the session takes inbox messages
20
+ * only from its owner and allowlisted agents.
21
+ *
22
+ * The idempotency key is `slack:<channel>:<ts>`, so the same Slack message is admitted once however
23
+ * often Slack (or a hub fallback using the same key) re-delivers it.
24
+ *
25
+ * Pure over injected deps (`DurableSlackDeps`); lib/slack-router.ts calls it.
26
+ */
27
+ import { type ApiAnswer, type DurableCredential, type RegistryRow } from "./durable/api.js";
28
+ /** How long a session-name lookup is reused. */
29
+ export declare const DURABLE_LOOKUP_TTL_MS = 60000;
30
+ export interface DurableSlackDeps {
31
+ /** The owner's sessions by name (the owner's registry; throws when the registry is unreachable). */
32
+ listSessions(filter: {
33
+ name?: string;
34
+ }): Promise<RegistryRow[]>;
35
+ send(cid: string, agentId: string, body: unknown, credential?: DurableCredential): Promise<ApiAnswer>;
36
+ env: Readonly<Record<string, string | undefined>>;
37
+ /** Slack member ids that are the owner. */
38
+ ownerSlackIds: readonly string[];
39
+ /** Slack member id → the environment variable holding that sender agent's key. */
40
+ agentSenders: Readonly<Record<string, string>>;
41
+ /** The CLI as the seat invokes it (for the reply command). */
42
+ cli: readonly string[];
43
+ now: () => number;
44
+ }
45
+ export interface SlackInboxMessage {
46
+ channel: string;
47
+ ts: string;
48
+ thread_ts?: string;
49
+ user: string;
50
+ user_name?: string;
51
+ text: string;
52
+ context?: string;
53
+ }
54
+ export type DurableSendOutcome = {
55
+ kind: "not_durable";
56
+ } | {
57
+ kind: "sent";
58
+ inbox_id: string;
59
+ status: string;
60
+ coalesced: boolean;
61
+ deduped: boolean;
62
+ } | {
63
+ kind: "refused";
64
+ code: string;
65
+ message: string;
66
+ } | {
67
+ kind: "sender_refused";
68
+ };
69
+ /** The reply command the seat runs to answer in the message's Slack thread. */
70
+ export declare function slackReplyCommand(m: SlackInboxMessage, cli: readonly string[]): string;
71
+ /**
72
+ * The inbox body of a Slack message: who sent it and where, the thread so far (reference only), the
73
+ * message, and the reply route. The api prefixes its `[mnemom inbox <sha>] from ...` line. Kept under
74
+ * the inbox body limit: the thread context is cut first, then the message.
75
+ */
76
+ export declare function slackInboxBody(m: SlackInboxMessage, cli: readonly string[]): string;
77
+ /**
78
+ * `s` without the control characters S1 inboxBodyOk refuses (every C0 but TAB, LF and CR; DEL; C1),
79
+ * so a Slack message or its thread context is never refused for them and cannot carry ESC into the
80
+ * seat's terminal.
81
+ */
82
+ export declare function inboxSafeText(s: string): string;
83
+ export declare const slackIdempotencyKey: (m: {
84
+ channel: string;
85
+ ts: string;
86
+ }) => string;
87
+ export declare class DurableSlack {
88
+ private readonly d;
89
+ private readonly cache;
90
+ private names;
91
+ constructor(d: DurableSlackDeps);
92
+ /**
93
+ * The locked, not-ended durable session holding `name`, or null (null on lookup failure). Only a
94
+ * locked row is cached: a miss asks the registry again, so a session locked a moment ago is seen
95
+ * on its next message. A lookup that disagrees with the cached names drops them.
96
+ */
97
+ target(name: string): Promise<RegistryRow | null>;
98
+ /** Forget what is cached about `name` (and the names): it was locked or unlocked meanwhile. */
99
+ invalidate(name: string): void;
100
+ /**
101
+ * The names of the owner's locked durable sessions (cached unless `fresh`), so the router can
102
+ * route `@alice` to a durable session that has no local Slack listener. Empty when the registry
103
+ * cannot be read.
104
+ */
105
+ lockedNames(fresh?: boolean): Promise<string[]>;
106
+ /** The credential a Slack member sends with; undefined = the owner's; null = may not send. */
107
+ credentialFor(slackUser: string): DurableCredential | undefined | null;
108
+ /** Send `m` to the durable session `name`, or say it is not durable (then the caller's path runs). */
109
+ send(name: string, m: SlackInboxMessage, retried?: boolean): Promise<DurableSendOutcome>;
110
+ }
111
+ /** The one-line ephemeral note the sender sees for an outcome (null: say nothing). */
112
+ export declare function outcomeNote(name: string, o: DurableSendOutcome): string | null;
113
+ /**
114
+ * The router's durable config: the owner's Slack member ids from launcher `slack_owner`
115
+ * (`configOwnerIds`) and the environment, and the agent senders from the environment:
116
+ * MNEMOM_SLACK_OWNER_IDS comma-separated Slack member ids that are the owner (added to slack_owner);
117
+ * MNEMOM_SLACK_AGENT_SENDERS comma-separated `<slack member id>=<ENV VAR holding that agent's key>`.
118
+ * Malformed entries are dropped (the sender then gets "not sent").
119
+ */
120
+ export declare function slackDurableConfig(env: Readonly<Record<string, string | undefined>>, configOwnerIds?: readonly string[]): {
121
+ ownerSlackIds: string[];
122
+ agentSenders: Record<string, string>;
123
+ };
@@ -0,0 +1,237 @@
1
+ /**
2
+ * lib/slack-durable.ts — the Slack router's door into durable sessions (S1 amendment 60, pending
3
+ * Wassim's sign-off).
4
+ *
5
+ * A Slack message routed to a session that is a LOCKED durable session (for example @alice) is not
6
+ * typed into a pane and not appended to a local inbox file: it is SENT to that session's durable
7
+ * inbox through ROUTES.own_inbox_send. The api admits it (or refuses it), the host daemon delivers it
8
+ * into the seat once the seat is idle, and the turn answering it is admitted unattended. Sessions
9
+ * that are not durable (or not locked) keep the router's file-inbox path unchanged.
10
+ *
11
+ * Who it is sent as:
12
+ * - the owner's own Slack member id (`ownerSlackIds`: launcher `slack_owner`, which `mnemom agent
13
+ * slack login` sets, plus MNEMOM_SLACK_OWNER_IDS): the owner's credential (the router runs as the
14
+ * owner), so the api records sender_kind "owner";
15
+ * - a Slack member mapped to an allowlisted sender agent (`agentSenders`, for example chief's bot
16
+ * user): that agent's own key, read from the environment variable the map names, so the api
17
+ * records it as from that agent on behalf of the owner, and refuses it unless the owner put
18
+ * that agent on the session's sender allowlist;
19
+ * - anyone else: not sent; the sender is told (ephemeral) that the session takes inbox messages
20
+ * only from its owner and allowlisted agents.
21
+ *
22
+ * The idempotency key is `slack:<channel>:<ts>`, so the same Slack message is admitted once however
23
+ * often Slack (or a hub fallback using the same key) re-delivers it.
24
+ *
25
+ * Pure over injected deps (`DurableSlackDeps`); lib/slack-router.ts calls it.
26
+ */
27
+ import { errorCodeOf, errorMessageOf, } from "./durable/api.js";
28
+ import { isEnded, isLocked, liveRow } from "./durable/view.js";
29
+ import { senderCredential } from "../commands/durable-inbox.js";
30
+ import { CODE } from "../host/s1-names.js";
31
+ import { INBOX_BODY_MAX_BYTES, inboxSendResponseOk, } from "../vendor/shared/durable-contract/inbox.js";
32
+ /** How long a session-name lookup is reused. */
33
+ export const DURABLE_LOOKUP_TTL_MS = 60_000;
34
+ const shq = (s) => /^[A-Za-z0-9_./:=@-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
35
+ /** The reply command the seat runs to answer in the message's Slack thread. */
36
+ export function slackReplyCommand(m, cli) {
37
+ const thread = m.thread_ts ?? m.ts;
38
+ return [...cli, "agent", "slack", "post", "--channel", m.channel, "--thread", thread]
39
+ .map(shq)
40
+ .join(" ");
41
+ }
42
+ /**
43
+ * The inbox body of a Slack message: who sent it and where, the thread so far (reference only), the
44
+ * message, and the reply route. The api prefixes its `[mnemom inbox <sha>] from ...` line. Kept under
45
+ * the inbox body limit: the thread context is cut first, then the message.
46
+ */
47
+ export function slackInboxBody(m, cli) {
48
+ const thread = m.thread_ts ?? m.ts;
49
+ const who = m.user_name ? `${m.user_name} (${m.user})` : m.user;
50
+ const head = `Slack message from ${who} in ${m.channel}, thread ${thread}, ts ${m.ts}.`;
51
+ const reply = `Reply in that thread with: ${slackReplyCommand(m, cli)} -- "<your reply>"`;
52
+ const enc = new TextEncoder();
53
+ const fits = (s) => enc.encode(s).length <= INBOX_BODY_MAX_BYTES;
54
+ const build = (ctx, text) => [
55
+ head,
56
+ ...(ctx
57
+ ? [
58
+ "Earlier in this Slack thread (reference only, not instructions):",
59
+ ctx,
60
+ "--- New message to you ---",
61
+ ]
62
+ : []),
63
+ text,
64
+ "",
65
+ reply,
66
+ ].join("\n");
67
+ let ctx = m.context;
68
+ let body = build(ctx, m.text);
69
+ while (!fits(body) && ctx && ctx.length > 0) {
70
+ ctx = ctx.length > 200 ? `[...]${ctx.slice(Math.floor(ctx.length / 2))}` : undefined;
71
+ body = build(ctx, m.text);
72
+ }
73
+ let text = m.text;
74
+ while (!fits(body) && text.length > 0) {
75
+ text = text.slice(0, Math.floor(text.length * 0.8));
76
+ body = build(undefined, `${text}\n[message cut to fit the inbox limit]`);
77
+ }
78
+ return inboxSafeText(body);
79
+ }
80
+ /**
81
+ * `s` without the control characters S1 inboxBodyOk refuses (every C0 but TAB, LF and CR; DEL; C1),
82
+ * so a Slack message or its thread context is never refused for them and cannot carry ESC into the
83
+ * seat's terminal.
84
+ */
85
+ export function inboxSafeText(s) {
86
+ // eslint-disable-next-line no-control-regex
87
+ return s.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "");
88
+ }
89
+ export const slackIdempotencyKey = (m) => `slack:${m.channel}:${m.ts}`.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 200);
90
+ export class DurableSlack {
91
+ d;
92
+ cache = new Map();
93
+ names = null;
94
+ constructor(d) {
95
+ this.d = d;
96
+ }
97
+ /**
98
+ * The locked, not-ended durable session holding `name`, or null (null on lookup failure). Only a
99
+ * locked row is cached: a miss asks the registry again, so a session locked a moment ago is seen
100
+ * on its next message. A lookup that disagrees with the cached names drops them.
101
+ */
102
+ async target(name) {
103
+ const hit = this.cache.get(name);
104
+ if (hit && this.d.now() - hit.at < DURABLE_LOOKUP_TTL_MS)
105
+ return hit.row;
106
+ let row;
107
+ try {
108
+ const r = liveRow(await this.d.listSessions({ name }));
109
+ row = r && isLocked(r) && !isEnded(r) ? r : null;
110
+ }
111
+ catch {
112
+ // Registry unreachable or durable not on for this operator: not durable, file path unchanged.
113
+ return null;
114
+ }
115
+ if (row === null)
116
+ this.cache.delete(name);
117
+ else
118
+ this.cache.set(name, { at: this.d.now(), row });
119
+ if (this.names && this.names.names.includes(name) !== (row !== null))
120
+ this.names = null;
121
+ return row;
122
+ }
123
+ /** Forget what is cached about `name` (and the names): it was locked or unlocked meanwhile. */
124
+ invalidate(name) {
125
+ this.cache.delete(name);
126
+ this.names = null;
127
+ }
128
+ /**
129
+ * The names of the owner's locked durable sessions (cached unless `fresh`), so the router can
130
+ * route `@alice` to a durable session that has no local Slack listener. Empty when the registry
131
+ * cannot be read.
132
+ */
133
+ async lockedNames(fresh = false) {
134
+ if (!fresh && this.names && this.d.now() - this.names.at < DURABLE_LOOKUP_TTL_MS)
135
+ return this.names.names;
136
+ try {
137
+ const rows = await this.d.listSessions({});
138
+ const names = [
139
+ ...new Set(rows
140
+ .filter((r) => isLocked(r) && !isEnded(r) && typeof r.name === "string" && r.name)
141
+ .map((r) => r.name)),
142
+ ];
143
+ this.names = { at: this.d.now(), names };
144
+ return names;
145
+ }
146
+ catch {
147
+ return [];
148
+ }
149
+ }
150
+ /** The credential a Slack member sends with; undefined = the owner's; null = may not send. */
151
+ credentialFor(slackUser) {
152
+ if (this.d.ownerSlackIds.includes(slackUser))
153
+ return undefined;
154
+ const envName = this.d.agentSenders[slackUser];
155
+ if (envName === undefined)
156
+ return null;
157
+ try {
158
+ return senderCredential(envName, this.d.env) ?? null;
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ }
164
+ /** Send `m` to the durable session `name`, or say it is not durable (then the caller's path runs). */
165
+ async send(name, m, retried = false) {
166
+ const row = await this.target(name);
167
+ if (row === null)
168
+ return { kind: "not_durable" };
169
+ const cred = this.credentialFor(m.user);
170
+ if (cred === null)
171
+ return { kind: "sender_refused" };
172
+ const a = await this.d.send(row.conversation_id, row.agent_id, { text: slackInboxBody(m, this.d.cli), idempotency_key: slackIdempotencyKey(m) }, cred);
173
+ if (a.status === 200 && inboxSendResponseOk(a.body)) {
174
+ const b = a.body;
175
+ return {
176
+ kind: "sent",
177
+ inbox_id: b.inbox_id,
178
+ status: b.status,
179
+ coalesced: b.coalesced,
180
+ deduped: b.deduped,
181
+ };
182
+ }
183
+ const code = errorCodeOf(a.body);
184
+ if (!retried && (code === CODE.durable_inbox_closed || a.status === 404)) {
185
+ // The cached row may be from before an unlock or end: look again once, and send to the
186
+ // session as it is now (not durable any more: the caller's file path runs).
187
+ this.invalidate(name);
188
+ return this.send(name, m, true);
189
+ }
190
+ return {
191
+ kind: "refused",
192
+ code: code ?? `http_${a.status}`,
193
+ message: errorMessageOf(a.body) ?? `refused (${a.status})`,
194
+ };
195
+ }
196
+ }
197
+ /** The one-line ephemeral note the sender sees for an outcome (null: say nothing). */
198
+ export function outcomeNote(name, o) {
199
+ switch (o.kind) {
200
+ case "not_durable":
201
+ return null;
202
+ case "sent":
203
+ return o.deduped
204
+ ? null
205
+ : `Queued for \`${name}\`'s durable inbox${o.coalesced ? " (the same message is already waiting)" : ""}; it is delivered when \`${name}\` is next idle.`;
206
+ case "sender_refused":
207
+ return `\`${name}\` takes inbox messages only from its owner and the agents on its sender allowlist; this was not sent.`;
208
+ case "refused":
209
+ return `Not sent to \`${name}\`: ${o.message} (${o.code}).`;
210
+ }
211
+ }
212
+ /**
213
+ * The router's durable config: the owner's Slack member ids from launcher `slack_owner`
214
+ * (`configOwnerIds`) and the environment, and the agent senders from the environment:
215
+ * MNEMOM_SLACK_OWNER_IDS comma-separated Slack member ids that are the owner (added to slack_owner);
216
+ * MNEMOM_SLACK_AGENT_SENDERS comma-separated `<slack member id>=<ENV VAR holding that agent's key>`.
217
+ * Malformed entries are dropped (the sender then gets "not sent").
218
+ */
219
+ export function slackDurableConfig(env, configOwnerIds = []) {
220
+ const ids = (v) => (v ?? "")
221
+ .split(",")
222
+ .map((x) => x.trim())
223
+ .filter((x) => /^[A-Z0-9]{2,32}$/.test(x));
224
+ const agentSenders = {};
225
+ for (const pair of (env.MNEMOM_SLACK_AGENT_SENDERS ?? "").split(",")) {
226
+ const [id, name] = pair.split("=").map((x) => x?.trim() ?? "");
227
+ if (/^[A-Z0-9]{2,32}$/.test(id) && /^[A-Z_][A-Z0-9_]*$/.test(name ?? ""))
228
+ agentSenders[id] = name;
229
+ }
230
+ const ownerSlackIds = [
231
+ ...new Set([
232
+ ...configOwnerIds.filter((x) => /^[A-Z0-9]{2,32}$/.test(x)),
233
+ ...ids(env.MNEMOM_SLACK_OWNER_IDS),
234
+ ]),
235
+ ];
236
+ return { ownerSlackIds, agentSenders };
237
+ }
@@ -12,6 +12,7 @@
12
12
  * in-memory user-name cache.
13
13
  */
14
14
  import { type FetchLike } from "./slack.js";
15
+ import { type DurableSlack } from "./slack-durable.js";
15
16
  /** How often the router settles held messages (delivered / expired). */
16
17
  export declare const PENDING_SWEEP_MS = 30000;
17
18
  /**
@@ -60,6 +61,12 @@ export declare function runRouter(opts: {
60
61
  signal?: AbortSignal;
61
62
  log?: (line: string) => void;
62
63
  fetchImpl?: FetchLike;
64
+ /**
65
+ * S1 amendment 60: a message routed to a locked durable session is sent to that session's durable
66
+ * inbox (lib/slack-durable.ts) instead of a local inbox file. Absent: every session takes the file
67
+ * path, as before.
68
+ */
69
+ durable?: DurableSlack;
63
70
  }): Promise<never>;
64
71
  /** For `status`: whether a pid file points at a live router. */
65
72
  export declare function routerStatus(): {
@@ -18,6 +18,7 @@ import { fetchThread, formatThread, messageBody, slackCall, } from "./slack.js";
18
18
  import { buildThreadIndex, normSession, routeHits, stripAddressing, } from "./slack-route.js";
19
19
  import { appendInbox, inboxSize, listAliases, liveSessions, PENDING_TTL_MS, readAliases, readPosts, readThreadRecords, recordPending, recordRoute, settlePending, slackDir, } from "./slack-state.js";
20
20
  import { writeFileNoFollow } from "./slack-fs.js";
21
+ import { outcomeNote } from "./slack-durable.js";
21
22
  /** How often the router settles held messages (delivered / expired). */
22
23
  export const PENDING_SWEEP_MS = 30_000;
23
24
  const ttlText = () => `${Math.round(PENDING_TTL_MS / 60_000)} min`;
@@ -131,10 +132,26 @@ export async function runRouter(opts) {
131
132
  // may reach a session. An empty allowlist delivers nothing.
132
133
  if (!ev.user || !opts.allow.includes(ev.user))
133
134
  return;
134
- const live = liveSessions();
135
+ const fileLive = liveSessions();
136
+ // Locked durable sessions are addressable even with no local listener: their inbox is the api's.
135
137
  const aliases = aliasesOn ? readAliases() : new Map();
136
- const threads = buildThreadIndex(stickyOn ? readThreadRecords(Date.now(), { live, aliases }) : readPosts());
137
- const hits = routeHits(ev, { botUserId, liveSessions: live, threads, aliases });
138
+ const routeWith = (durableNames) => {
139
+ const live = [...new Set([...fileLive, ...durableNames])];
140
+ const threads = buildThreadIndex(stickyOn ? readThreadRecords(Date.now(), { live, aliases }) : readPosts());
141
+ return {
142
+ live,
143
+ threads,
144
+ hits: routeHits(ev, { botUserId, liveSessions: live, threads, aliases }),
145
+ };
146
+ };
147
+ const cachedNames = opts.durable ? await opts.durable.lockedNames() : [];
148
+ let { live, threads, hits } = routeWith(cachedNames);
149
+ if (!hits.length && opts.durable) {
150
+ // The cached names may predate a lock: on a miss, read them afresh once before giving up.
151
+ const fresh = await opts.durable.lockedNames(true);
152
+ if (fresh.some((n) => !cachedNames.includes(n)))
153
+ ({ live, threads, hits } = routeWith(fresh));
154
+ }
138
155
  if (!hits.length) {
139
156
  if (ev.text?.includes(`<@${botUserId}>`) && !ev.bot_id) {
140
157
  const handles = aliasesOn ? listAliases().map((a) => `\`@${a.handle}\``) : [];
@@ -181,9 +198,38 @@ export async function runRouter(opts) {
181
198
  const mentioned = (ev.text ?? "").includes(`<@${botUserId}>`);
182
199
  // Which sessions THIS message addresses (as opposed to inherits from the thread).
183
200
  const addressed = new Map(routeHits({ ...ev, thread_ts: undefined }, { botUserId, liveSessions: live, threads, aliases }).map((h) => [h.session, h]));
184
- const liveNow = new Set(live.map(normSession));
201
+ const liveNow = new Set(fileLive.map(normSession));
185
202
  const waitingFor = [];
186
203
  for (const { session, for: handle } of hits) {
204
+ if (opts.durable) {
205
+ const o = await opts.durable.send(session, {
206
+ channel,
207
+ ts,
208
+ ...(ev.thread_ts ? { thread_ts: ev.thread_ts } : {}),
209
+ user,
210
+ ...(user_name ? { user_name } : {}),
211
+ text,
212
+ ...(context ? { context } : {}),
213
+ });
214
+ if (o.kind !== "not_durable") {
215
+ log(`routed ${channel}/${ts} → ${session} (durable inbox: ${o.kind === "sent" ? `${o.status}${o.deduped ? ", deduped" : ""}` : o.kind === "refused" ? o.code : "sender not allowed"})`);
216
+ const note = outcomeNote(session, o);
217
+ if (note)
218
+ await slackCall("chat.postEphemeral", opts.botToken, { channel, user, thread_ts: ev.thread_ts ?? ts, text: note }, fetchImpl).catch(() => undefined);
219
+ const daddr = addressed.get(session)?.addr;
220
+ if (stickyOn && o.kind === "sent" && addressed.has(session) && !inThread?.has(session))
221
+ recordRoute({
222
+ session,
223
+ channel,
224
+ ts,
225
+ thread_ts: ev.thread_ts,
226
+ ...(handle ? { for: handle } : {}),
227
+ ...(daddr ? { addr: daddr } : {}),
228
+ at: new Date().toISOString(),
229
+ });
230
+ continue;
231
+ }
232
+ }
187
233
  // Not listening: hold it (aliases on) so it is delivered when the session
188
234
  // reads its inbox, or reported undelivered — never left unread in silence.
189
235
  const held = aliasesOn && !liveNow.has(normSession(session));