@mnemom/mnemom 0.17.25 → 0.17.26

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 (43) hide show
  1. package/dist/commands/agent-config.js +9 -0
  2. package/dist/commands/agent-doctor.d.ts +3 -2
  3. package/dist/commands/agent-doctor.js +22 -7
  4. package/dist/commands/agent-goal.js +7 -2
  5. package/dist/commands/agent-sessions.js +2 -1
  6. package/dist/commands/agent-setup.d.ts +104 -4
  7. package/dist/commands/agent-setup.js +414 -18
  8. package/dist/commands/agent-slack.d.ts +4 -0
  9. package/dist/commands/agent-slack.js +67 -21
  10. package/dist/commands/agent.d.ts +29 -1
  11. package/dist/commands/agent.js +89 -12
  12. package/dist/commands/wrap.d.ts +2 -0
  13. package/dist/commands/wrap.js +13 -1
  14. package/dist/index.js +19 -5
  15. package/dist/lib/agent-identity-route.d.ts +21 -3
  16. package/dist/lib/agent-identity-route.js +42 -15
  17. package/dist/lib/agent-ledger.d.ts +5 -0
  18. package/dist/lib/agent.d.ts +62 -1
  19. package/dist/lib/agent.js +55 -0
  20. package/dist/lib/api.d.ts +33 -1
  21. package/dist/lib/api.js +10 -0
  22. package/dist/lib/experimental.js +14 -2
  23. package/dist/lib/goal-api.d.ts +3 -3
  24. package/dist/lib/goal-api.js +5 -2
  25. package/dist/lib/launcher-config.d.ts +18 -0
  26. package/dist/lib/launcher-config.js +47 -1
  27. package/dist/lib/oauth.d.ts +7 -0
  28. package/dist/lib/oauth.js +92 -17
  29. package/dist/lib/served-agent-cache.d.ts +8 -1
  30. package/dist/lib/served-agent-cache.js +11 -2
  31. package/dist/lib/slack-channel.d.ts +1 -0
  32. package/dist/lib/slack-channel.js +30 -12
  33. package/dist/lib/slack-route.d.ts +72 -9
  34. package/dist/lib/slack-route.js +113 -24
  35. package/dist/lib/slack-router.d.ts +37 -2
  36. package/dist/lib/slack-router.js +98 -13
  37. package/dist/lib/slack-state.d.ts +105 -4
  38. package/dist/lib/slack-state.js +230 -11
  39. package/dist/lib/subscription-login.d.ts +12 -3
  40. package/dist/lib/subscription-login.js +19 -7
  41. package/dist/mcp/handoff-server.mjs +31 -3
  42. package/dist/rc-proxy.mjs +14 -0
  43. package/package.json +1 -1
@@ -6,15 +6,37 @@
6
6
  * connection per session would silently drop messages). For every channel
7
7
  * message it asks lib/slack-route.ts who is addressed and appends the message to
8
8
  * each addressed session's inbox (lib/slack-state.ts); each session's listener
9
- * delivers from there. The router never writes to a session, never runs a tool,
10
- * and holds no state beyond an in-memory user-name cache.
9
+ * delivers from there. A routing made by address (mention, handle, alias) is
10
+ * recorded so later replies in that thread stick to the same session. The router
11
+ * never writes to a session, never runs a tool, and holds no state beyond an
12
+ * in-memory user-name cache.
11
13
  */
12
14
  import { spawn } from "node:child_process";
13
15
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
16
  import { join } from "node:path";
15
17
  import { fetchThread, formatThread, messageBody, slackCall, } from "./slack.js";
16
- import { buildThreadIndex, routeMessage, stripAddressing, } from "./slack-route.js";
17
- import { appendInbox, liveSessions, readPosts, slackDir } from "./slack-state.js";
18
+ import { buildThreadIndex, normSession, routeHits, stripAddressing, } from "./slack-route.js";
19
+ import { appendInbox, inboxSize, listAliases, liveSessions, PENDING_TTL_MS, readAliases, readPosts, readThreadRecords, recordPending, recordRoute, settlePending, slackDir, } from "./slack-state.js";
20
+ /** How often the router settles held messages (delivered / expired). */
21
+ export const PENDING_SWEEP_MS = 30_000;
22
+ const ttlText = () => `${Math.round(PENDING_TTL_MS / 60_000)} min`;
23
+ /**
24
+ * Settle the held messages (see settlePending) and tell the sender of each expired
25
+ * one, in its thread, that it was not delivered. Best effort: a failed post is
26
+ * logged, never retried (the message is already dropped from the queue).
27
+ */
28
+ export async function sweepPending(opts) {
29
+ const r = settlePending(opts.now ?? Date.now());
30
+ for (const p of r.expired) {
31
+ const res = await slackCall("chat.postMessage", opts.botToken, {
32
+ channel: p.channel,
33
+ thread_ts: p.thread_ts,
34
+ text: `<@${p.user}> not delivered: \`${p.session}\` did not pick this up within ${ttlText()}. Send it again once that session is running, or address another one.`,
35
+ }, opts.fetchImpl ?? fetch).catch((e) => ({ ok: false, error: e instanceof Error ? e.message : String(e) }));
36
+ opts.log?.(`undelivered ${p.channel}/${p.ts} → ${p.session}${res.ok ? "" : ` (notice failed: ${String(res.error)})`}`);
37
+ }
38
+ return { delivered: r.delivered.length, expired: r.expired.length, held: r.held.length };
39
+ }
18
40
  const routerPidFile = () => join(slackDir(), "router.pid");
19
41
  function pidAlive(pid) {
20
42
  try {
@@ -53,6 +75,8 @@ export function ensureRouter(argv = [process.execPath, process.argv[1] ?? "mnemo
53
75
  }
54
76
  /** Run the router until the process is killed. Reconnects on disconnect. */
55
77
  export async function runRouter(opts) {
78
+ const aliasesOn = opts.aliases === true;
79
+ const stickyOn = opts.stickyThreads === true;
56
80
  // Lines carry Slack-supplied values (channel, handles, disconnect reason);
57
81
  // strip CR/LF so a crafted message cannot forge extra log lines.
58
82
  const log = opts.log ?? ((l) => console.error(`[slack-router] ${l.replace(/\n|\r/g, "")}`));
@@ -69,6 +93,14 @@ export async function runRouter(opts) {
69
93
  throw new Error(`a router is already running (pid ${other}).`);
70
94
  mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
71
95
  writeFileSync(routerPidFile(), String(process.pid), { mode: 0o600 });
96
+ log(`aliases ${aliasesOn ? "on" : "off"}, sticky threads ${stickyOn ? "on" : "off"}`);
97
+ if (aliasesOn) {
98
+ const sweep = setInterval(() => {
99
+ sweepPending({ botToken: opts.botToken, fetchImpl, log }).catch((e) => log(`pending sweep failed: ${e instanceof Error ? e.message : e}`));
100
+ }, opts.sweepMs ?? PENDING_SWEEP_MS);
101
+ sweep.unref();
102
+ opts.signal?.addEventListener("abort", () => clearInterval(sweep));
103
+ }
72
104
  const names = new Map();
73
105
  const userName = async (id) => {
74
106
  if (names.has(id))
@@ -99,12 +131,14 @@ export async function runRouter(opts) {
99
131
  if (!ev.user || !opts.allow.includes(ev.user))
100
132
  return;
101
133
  const live = liveSessions();
102
- const threads = buildThreadIndex(readPosts());
103
- const targets = routeMessage(ev, { botUserId, liveSessions: live, threads });
104
- if (!targets.length) {
134
+ const aliases = aliasesOn ? readAliases() : new Map();
135
+ const threads = buildThreadIndex(stickyOn ? readThreadRecords(Date.now(), { live, aliases }) : readPosts());
136
+ const hits = routeHits(ev, { botUserId, liveSessions: live, threads, aliases });
137
+ if (!hits.length) {
105
138
  if (ev.text?.includes(`<@${botUserId}>`) && !ev.bot_id) {
106
- const hint = live.length
107
- ? `No live session matched. Address one as \`@<app> <session>: …\` — live now: ${live.map((s) => `\`${s}\``).join(", ")}.`
139
+ const handles = aliasesOn ? listAliases().map((a) => `\`@${a.handle}\``) : [];
140
+ const hint = live.length || handles.length
141
+ ? `No live session matched. Address one as \`@<app> <session>: …\` — live now: ${live.length ? live.map((s) => `\`${s}\``).join(", ") : "none"}${handles.length ? `; handles: ${handles.join(", ")}` : ""}.`
108
142
  : "No agent session is listening right now.";
109
143
  // Ephemeral: only the sender sees the hint, so a mistyped address never
110
144
  // leaves a permanent post in the thread for everyone else.
@@ -119,7 +153,7 @@ export async function runRouter(opts) {
119
153
  const user_name = await userName(user);
120
154
  const text = messageBody({
121
155
  ts,
122
- text: stripAddressing(ev.text ?? "", botUserId, live),
156
+ text: stripAddressing(ev.text ?? "", botUserId, live, aliases),
123
157
  files: ev.files,
124
158
  });
125
159
  // A thread reply carries the thread so far: "review this" means nothing
@@ -143,12 +177,24 @@ export async function runRouter(opts) {
143
177
  log(`thread context unavailable for ${channel}/${ev.thread_ts}: ${e instanceof Error ? e.message : e}`);
144
178
  }
145
179
  }
146
- for (const session of targets) {
180
+ const mentioned = (ev.text ?? "").includes(`<@${botUserId}>`);
181
+ // Which sessions THIS message addresses (as opposed to inherits from the thread).
182
+ const addressed = new Map(routeHits({ ...ev, thread_ts: undefined }, { botUserId, liveSessions: live, threads, aliases }).map((h) => [h.session, h]));
183
+ const liveNow = new Set(live.map(normSession));
184
+ const waitingFor = [];
185
+ for (const { session, for: handle } of hits) {
186
+ // Not listening: hold it (aliases on) so it is delivered when the session
187
+ // reads its inbox, or reported undelivered — never left unread in silence.
188
+ const held = aliasesOn && !liveNow.has(normSession(session));
189
+ const at = new Date().toISOString();
190
+ const start = held ? inboxSize(session) : 0;
147
191
  const via = [];
148
192
  if (inThread?.has(session))
149
193
  via.push("thread");
150
- if ((ev.text ?? "").includes(`<@${botUserId}>`))
194
+ if (mentioned)
151
195
  via.push("mention");
196
+ if (addressed.get(session)?.for)
197
+ via.push("alias");
152
198
  if (!via.length)
153
199
  via.push("handle");
154
200
  appendInbox(session, {
@@ -159,9 +205,48 @@ export async function runRouter(opts) {
159
205
  user_name,
160
206
  text,
161
207
  via,
208
+ ...(handle ? { for: handle } : {}),
162
209
  ...(context ? { context } : {}),
210
+ ...(held ? { queued_at: at } : {}),
163
211
  });
164
- log(`routed ${ev.channel}/${ev.ts} → ${session} (${via.join("+")})`);
212
+ if (held) {
213
+ recordPending({
214
+ session,
215
+ channel,
216
+ ts,
217
+ thread_ts: ev.thread_ts ?? ts,
218
+ user,
219
+ start,
220
+ end: inboxSize(session),
221
+ at,
222
+ });
223
+ waitingFor.push(session);
224
+ }
225
+ // Addressed here (not merely inherited from the thread) → the thread sticks.
226
+ // The address is kept so later replies re-resolve it (see reresolveRoutes).
227
+ const addr = addressed.get(session)?.addr;
228
+ if (stickyOn && addressed.has(session) && !inThread?.has(session)) {
229
+ recordRoute({
230
+ session,
231
+ channel,
232
+ ts,
233
+ thread_ts: ev.thread_ts,
234
+ ...(handle ? { for: handle } : {}),
235
+ ...(addr ? { addr } : {}),
236
+ at,
237
+ });
238
+ }
239
+ log(`routed ${ev.channel}/${ev.ts} → ${session} (${via.join("+")}${handle ? ` for @${handle}` : ""}${held ? ", held" : ""})`);
240
+ }
241
+ if (waitingFor.length) {
242
+ // Ephemeral: the sender is looking now; the durable answer is the
243
+ // not-delivered reply sweepPending posts if the session never reads it.
244
+ await slackCall("chat.postEphemeral", opts.botToken, {
245
+ channel,
246
+ user,
247
+ thread_ts: ev.thread_ts ?? ts,
248
+ text: `Waiting for ${waitingFor.map((s) => `\`${s}\``).join(", ")}: not listening right now. It gets this when it next reads Slack; if it has not within ${ttlText()}, I will reply here that it was not delivered.`,
249
+ }, fetchImpl).catch(() => undefined);
165
250
  }
166
251
  };
167
252
  for (;;) {
@@ -2,16 +2,47 @@
2
2
  * lib/slack-state.ts — the local, per-operator state behind `mnemom agent slack`.
3
3
  *
4
4
  * ~/.mnemom/slack/posts.jsonl every post a session made (routes thread replies)
5
+ * ~/.mnemom/slack/routes.jsonl every addressed routing the router made (sticky threads)
5
6
  * ~/.mnemom/slack/live/<session>.json a session currently listening (pid)
7
+ * ~/.mnemom/slack/aliases/<handle>.json an operator-registered handle → session
6
8
  * ~/.mnemom/slack/inbox/<session>.jsonl messages the router routed to a session
9
+ * ~/.mnemom/slack/offsets/<session>.off how far into its inbox a session has read
10
+ * ~/.mnemom/slack/pending.jsonl messages held for a session that was not
11
+ * listening (slack_aliases on): delivered when its
12
+ * offset passes them, else reported undelivered
7
13
  *
8
- * The router (one per operator machine) is the only writer of inboxes; each
9
- * session's listener is the only reader of its own. No secret is stored here.
14
+ * The router (one per operator machine) is the only writer of inboxes and routes;
15
+ * each session's listener is the only reader of its own inbox. No secret is
16
+ * stored here.
10
17
  */
11
- import { type PostRecord } from "./slack-route.js";
18
+ import { type PostRecord, type StickyRoute } from "./slack-route.js";
12
19
  export declare function slackDir(): string;
20
+ /** A sticky-thread route stops routing replies this long after it was made. */
21
+ export declare const STICKY_TTL_MS: number;
22
+ /**
23
+ * A message held for a session that was not listening is dropped (and its sender
24
+ * told it was not delivered) this long after it arrived.
25
+ */
26
+ export declare const PENDING_TTL_MS: number;
13
27
  export declare function recordPost(p: PostRecord): void;
14
28
  export declare function readPosts(): PostRecord[];
29
+ /** A routing the router made by address (mention/handle/alias), kept so the thread sticks. */
30
+ export interface RouteRecord extends StickyRoute {
31
+ /** ISO time the routing was made; the record expires STICKY_TTL_MS later. */
32
+ at: string;
33
+ }
34
+ export declare function recordRoute(r: RouteRecord): void;
35
+ /** Sticky routes still within their TTL at `now`, oldest first (capped like posts). */
36
+ export declare function readRoutes(now?: number): RouteRecord[];
37
+ /**
38
+ * The records that route thread replies: posts plus unexpired sticky routes. With
39
+ * `resolve`, each sticky route is re-resolved against who is live and which aliases
40
+ * exist now (reresolveRoutes), so a thread follows a restarted or replaced session.
41
+ */
42
+ export declare function readThreadRecords(now?: number, resolve?: {
43
+ live: Iterable<string>;
44
+ aliases: Map<string, string>;
45
+ }): PostRecord[];
15
46
  /**
16
47
  * The post `session` itself made at `ts` (optionally in `channel`), from the local
17
48
  * post index — the ownership check behind delete: a session may only remove its
@@ -26,13 +57,66 @@ export interface InboxMessage {
26
57
  user: string;
27
58
  user_name?: string;
28
59
  text: string;
29
- /** How it was addressed: thread | mention | handle. */
60
+ /** How it was addressed: thread | mention | handle | alias. */
30
61
  via: string[];
62
+ /** The alias handle it was addressed to (the session may front for several). */
63
+ for?: string;
31
64
  /** The thread so far (parent + recent replies), when the message is a thread reply. */
32
65
  context?: string;
66
+ /**
67
+ * Set (ISO time) when the router held the message because the session was not
68
+ * listening. A held message older than PENDING_TTL_MS is never delivered: its
69
+ * sender was already told it was not.
70
+ */
71
+ queued_at?: string;
33
72
  }
73
+ /** `messages` without the held ones that have expired (see InboxMessage.queued_at). */
74
+ export declare function freshInbox(messages: InboxMessage[], now?: number): InboxMessage[];
34
75
  export declare function appendInbox(session: string, msg: InboxMessage): void;
35
76
  export declare function inboxPath(session: string): string;
77
+ /** The inbox's size in bytes (0 when absent): the offset just past its last message. */
78
+ export declare function inboxSize(session: string): number;
79
+ /** How far into its inbox `session` has read (0 when it never saved an offset). */
80
+ export declare function readOffset(session: string): number;
81
+ /** `session` has read its inbox up to byte `offset`. */
82
+ export declare function writeOffset(session: string, offset: number): void;
83
+ /** True when `session` has saved an offset at all (it has read its inbox before). */
84
+ export declare function hasOffset(session: string): boolean;
85
+ /** A held message: where it came from, and the inbox offset just past it. */
86
+ export interface PendingRecord {
87
+ session: string;
88
+ channel: string;
89
+ ts: string;
90
+ /** The thread to answer in (the message's own ts when it was not in a thread). */
91
+ thread_ts: string;
92
+ user: string;
93
+ /** Inbox byte offset where the held message starts. */
94
+ start: number;
95
+ /** Inbox byte offset just past the held message: read once the offset reaches it. */
96
+ end: number;
97
+ /** ISO time it was held. */
98
+ at: string;
99
+ }
100
+ export declare function recordPending(p: PendingRecord): void;
101
+ export declare function readPending(): PendingRecord[];
102
+ /**
103
+ * Where `session`'s listener must start reading to deliver what is held for it:
104
+ * the start of its earliest held message it has not read, or undefined when
105
+ * nothing unread is held (the listener then starts at the end of its inbox and
106
+ * reads nothing). Cost: a read of pending.jsonl, which is absent with aliases off.
107
+ */
108
+ export declare function heldFrom(session: string): number | undefined;
109
+ /**
110
+ * Settle the held messages at `now`: one whose session has read past it is
111
+ * delivered; one older than PENDING_TTL_MS that is still unread is expired; the
112
+ * rest stay held. Rewrites pending.jsonl with the ones still held. The router is
113
+ * the only caller (and the only writer of pending.jsonl).
114
+ */
115
+ export declare function settlePending(now?: number): {
116
+ delivered: PendingRecord[];
117
+ expired: PendingRecord[];
118
+ held: PendingRecord[];
119
+ };
36
120
  /** Messages appended after byte `offset`, and the new offset. */
37
121
  export declare function readInboxFrom(session: string, offset: number): {
38
122
  messages: InboxMessage[];
@@ -40,5 +124,22 @@ export declare function readInboxFrom(session: string, offset: number): {
40
124
  };
41
125
  export declare function registerLive(session: string, pid?: number): void;
42
126
  export declare function unregisterLive(session: string): void;
127
+ export interface AliasRecord {
128
+ handle: string;
129
+ session: string;
130
+ since: string;
131
+ }
132
+ /**
133
+ * Register `handle` → `session` (idempotent; re-running with the same pair keeps the
134
+ * first `since`). The session need not be live: an alias is how a handle reaches a
135
+ * session that is launched later or restarts.
136
+ */
137
+ export declare function setAlias(handle: string, session: string): AliasRecord;
138
+ /** Remove `handle`; true when something was removed. */
139
+ export declare function removeAlias(handle: string): boolean;
140
+ /** Every registered alias, by handle (sorted). Corrupt files are ignored, not fatal. */
141
+ export declare function listAliases(): AliasRecord[];
142
+ /** normalised handle → session, the shape routeHits takes. */
143
+ export declare function readAliases(): Map<string, string>;
43
144
  /** Sessions with a running listener; stale entries (dead pid) are pruned. */
44
145
  export declare function liveSessions(): string[];
@@ -2,24 +2,42 @@
2
2
  * lib/slack-state.ts — the local, per-operator state behind `mnemom agent slack`.
3
3
  *
4
4
  * ~/.mnemom/slack/posts.jsonl every post a session made (routes thread replies)
5
+ * ~/.mnemom/slack/routes.jsonl every addressed routing the router made (sticky threads)
5
6
  * ~/.mnemom/slack/live/<session>.json a session currently listening (pid)
7
+ * ~/.mnemom/slack/aliases/<handle>.json an operator-registered handle → session
6
8
  * ~/.mnemom/slack/inbox/<session>.jsonl messages the router routed to a session
9
+ * ~/.mnemom/slack/offsets/<session>.off how far into its inbox a session has read
10
+ * ~/.mnemom/slack/pending.jsonl messages held for a session that was not
11
+ * listening (slack_aliases on): delivered when its
12
+ * offset passes them, else reported undelivered
7
13
  *
8
- * The router (one per operator machine) is the only writer of inboxes; each
9
- * session's listener is the only reader of its own. No secret is stored here.
14
+ * The router (one per operator machine) is the only writer of inboxes and routes;
15
+ * each session's listener is the only reader of its own inbox. No secret is
16
+ * stored here.
10
17
  */
11
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
18
+ import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
12
19
  import { join } from "node:path";
13
20
  import { MNEMOM_DIR } from "./config.js";
14
- import { normSession } from "./slack-route.js";
21
+ import { normHandle, normSession, reresolveRoutes, } from "./slack-route.js";
15
22
  export function slackDir() {
16
23
  return join(MNEMOM_DIR, "slack");
17
24
  }
18
25
  const postsFile = () => join(slackDir(), "posts.jsonl");
26
+ const routesFile = () => join(slackDir(), "routes.jsonl");
19
27
  const liveDir = () => join(slackDir(), "live");
28
+ const aliasDir = () => join(slackDir(), "aliases");
20
29
  const inboxDir = () => join(slackDir(), "inbox");
30
+ const offsetDir = () => join(slackDir(), "offsets");
31
+ const pendingFile = () => join(slackDir(), "pending.jsonl");
21
32
  /** Only the most recent posts matter for thread routing; cap what we read. */
22
33
  const MAX_POSTS = 5000;
34
+ /** A sticky-thread route stops routing replies this long after it was made. */
35
+ export const STICKY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
36
+ /**
37
+ * A message held for a session that was not listening is dropped (and its sender
38
+ * told it was not delivered) this long after it arrived.
39
+ */
40
+ export const PENDING_TTL_MS = 60 * 60 * 1000;
23
41
  function ensure(dir) {
24
42
  mkdirSync(dir, { recursive: true, mode: 0o700 });
25
43
  }
@@ -46,6 +64,31 @@ export function recordPost(p) {
46
64
  export function readPosts() {
47
65
  return readJsonl(postsFile()).slice(-MAX_POSTS);
48
66
  }
67
+ export function recordRoute(r) {
68
+ ensure(slackDir());
69
+ appendFileSync(routesFile(), JSON.stringify(r) + "\n", { mode: 0o600 });
70
+ }
71
+ /** Sticky routes still within their TTL at `now`, oldest first (capped like posts). */
72
+ export function readRoutes(now = Date.now()) {
73
+ return readJsonl(routesFile())
74
+ .filter((r) => {
75
+ const at = Date.parse(r.at);
76
+ return Number.isFinite(at) && now - at <= STICKY_TTL_MS && now >= at;
77
+ })
78
+ .slice(-MAX_POSTS);
79
+ }
80
+ /**
81
+ * The records that route thread replies: posts plus unexpired sticky routes. With
82
+ * `resolve`, each sticky route is re-resolved against who is live and which aliases
83
+ * exist now (reresolveRoutes), so a thread follows a restarted or replaced session.
84
+ */
85
+ export function readThreadRecords(now = Date.now(), resolve) {
86
+ const routes = readRoutes(now);
87
+ return [
88
+ ...readPosts(),
89
+ ...(resolve ? reresolveRoutes(routes, resolve.live, resolve.aliases) : routes),
90
+ ];
91
+ }
49
92
  /**
50
93
  * The post `session` itself made at `ts` (optionally in `channel`), from the local
51
94
  * post index — the ownership check behind delete: a session may only remove its
@@ -57,6 +100,15 @@ export function findOwnPost(session, ts, channel) {
57
100
  .reverse()
58
101
  .find((p) => p.ts === ts && normSession(p.session) === me && (!channel || p.channel === channel));
59
102
  }
103
+ /** `messages` without the held ones that have expired (see InboxMessage.queued_at). */
104
+ export function freshInbox(messages, now = Date.now()) {
105
+ return messages.filter((m) => {
106
+ if (!m.queued_at)
107
+ return true;
108
+ const at = Date.parse(m.queued_at);
109
+ return Number.isFinite(at) && now - at <= PENDING_TTL_MS;
110
+ });
111
+ }
60
112
  const fileFor = (session) => `${normSession(session)}`;
61
113
  export function appendInbox(session, msg) {
62
114
  ensure(inboxDir());
@@ -67,15 +119,117 @@ export function appendInbox(session, msg) {
67
119
  export function inboxPath(session) {
68
120
  return join(inboxDir(), `${fileFor(session)}.jsonl`);
69
121
  }
122
+ /** The inbox's size in bytes (0 when absent): the offset just past its last message. */
123
+ export function inboxSize(session) {
124
+ try {
125
+ return statSync(inboxPath(session)).size;
126
+ }
127
+ catch {
128
+ return 0;
129
+ }
130
+ }
131
+ // Normalised like the inbox file name, so a session name can never escape offsets/.
132
+ const offsetFile = (session) => join(offsetDir(), `${fileFor(session)}.off`);
133
+ /** How far into its inbox `session` has read (0 when it never saved an offset). */
134
+ export function readOffset(session) {
135
+ try {
136
+ return Number(readFileSync(offsetFile(session), "utf8")) || 0;
137
+ }
138
+ catch {
139
+ return 0;
140
+ }
141
+ }
142
+ /** `session` has read its inbox up to byte `offset`. */
143
+ export function writeOffset(session, offset) {
144
+ ensure(offsetDir());
145
+ writeFileSync(offsetFile(session), String(offset), { mode: 0o600 });
146
+ }
147
+ /** True when `session` has saved an offset at all (it has read its inbox before). */
148
+ export function hasOffset(session) {
149
+ return existsSync(offsetFile(session));
150
+ }
151
+ export function recordPending(p) {
152
+ ensure(slackDir());
153
+ appendFileSync(pendingFile(), JSON.stringify(p) + "\n", { mode: 0o600 });
154
+ }
155
+ export function readPending() {
156
+ return readJsonl(pendingFile());
157
+ }
158
+ /**
159
+ * Where `session`'s listener must start reading to deliver what is held for it:
160
+ * the start of its earliest held message it has not read, or undefined when
161
+ * nothing unread is held (the listener then starts at the end of its inbox and
162
+ * reads nothing). Cost: a read of pending.jsonl, which is absent with aliases off.
163
+ */
164
+ export function heldFrom(session) {
165
+ if (!existsSync(pendingFile()))
166
+ return undefined;
167
+ const me = normSession(session);
168
+ const read = readOffset(session);
169
+ const size = inboxSize(session);
170
+ let from;
171
+ for (const p of readPending()) {
172
+ if (normSession(p.session) !== me || !(p.end > read) || p.end > size)
173
+ continue;
174
+ const s = Number.isFinite(p.start) ? Math.max(p.start, read) : read;
175
+ from = from === undefined ? s : Math.min(from, s);
176
+ }
177
+ return from;
178
+ }
179
+ /**
180
+ * Settle the held messages at `now`: one whose session has read past it is
181
+ * delivered; one older than PENDING_TTL_MS that is still unread is expired; the
182
+ * rest stay held. Rewrites pending.jsonl with the ones still held. The router is
183
+ * the only caller (and the only writer of pending.jsonl).
184
+ */
185
+ export function settlePending(now = Date.now()) {
186
+ const delivered = [];
187
+ const expired = [];
188
+ const held = [];
189
+ const all = readPending();
190
+ if (!all.length)
191
+ return { delivered, expired, held };
192
+ for (const p of all) {
193
+ const at = Date.parse(p.at);
194
+ if (readOffset(p.session) >= p.end)
195
+ delivered.push(p);
196
+ else if (!Number.isFinite(at) || now - at > PENDING_TTL_MS)
197
+ expired.push(p);
198
+ else
199
+ held.push(p);
200
+ }
201
+ writeFileSync(pendingFile(), held.map((p) => JSON.stringify(p) + "\n").join(""), {
202
+ mode: 0o600,
203
+ });
204
+ return { delivered, expired, held };
205
+ }
70
206
  /** Messages appended after byte `offset`, and the new offset. */
71
207
  export function readInboxFrom(session, offset) {
72
- const path = inboxPath(session);
73
- if (!existsSync(path))
74
- return { messages: [], offset: 0 };
75
- const buf = readFileSync(path);
76
- if (buf.length < offset)
77
- offset = 0; // truncated/rotated
78
- const chunk = buf.subarray(offset).toString("utf8");
208
+ // Open, don't check-then-open: the router appends concurrently. Read only the
209
+ // bytes after `offset`, never the whole history.
210
+ let fd;
211
+ try {
212
+ fd = openSync(inboxPath(session), "r");
213
+ }
214
+ catch (e) {
215
+ if (e.code === "ENOENT")
216
+ return { messages: [], offset: 0 };
217
+ throw e;
218
+ }
219
+ let chunk;
220
+ try {
221
+ const size = fstatSync(fd).size;
222
+ if (size < offset)
223
+ offset = 0; // truncated/rotated
224
+ const buf = Buffer.alloc(size - offset);
225
+ // One positional read. A short read (the file shrank under us) is safe: the
226
+ // offset only advances past complete lines, so the rest is read next poll.
227
+ const got = buf.length ? readSync(fd, buf, 0, buf.length, offset) : 0;
228
+ chunk = buf.subarray(0, got).toString("utf8");
229
+ }
230
+ finally {
231
+ closeSync(fd);
232
+ }
79
233
  const end = chunk.lastIndexOf("\n") + 1; // leave a partial trailing line for next read
80
234
  const messages = [];
81
235
  for (const line of chunk.slice(0, end).split("\n")) {
@@ -106,6 +260,71 @@ function pidAlive(pid) {
106
260
  return err.code === "EPERM";
107
261
  }
108
262
  }
263
+ /**
264
+ * Register `handle` → `session` (idempotent; re-running with the same pair keeps the
265
+ * first `since`). The session need not be live: an alias is how a handle reaches a
266
+ * session that is launched later or restarts.
267
+ */
268
+ export function setAlias(handle, session) {
269
+ const h = normHandle(handle);
270
+ const s = session.trim();
271
+ if (!h)
272
+ throw new Error(`alias: '${handle}' is not a usable handle (letters, digits, dashes).`);
273
+ if (!normSession(s))
274
+ throw new Error(`alias: '${session}' is not a usable session name.`);
275
+ ensure(aliasDir());
276
+ const path = join(aliasDir(), `${h}.json`);
277
+ const prev = readAlias(path);
278
+ const rec = {
279
+ handle: h,
280
+ session: s,
281
+ since: prev && prev.session === s ? prev.since : new Date().toISOString(),
282
+ };
283
+ writeFileSync(path, JSON.stringify(rec), { mode: 0o600 });
284
+ return rec;
285
+ }
286
+ /** Remove `handle`; true when something was removed. */
287
+ export function removeAlias(handle) {
288
+ const h = normHandle(handle);
289
+ if (!h)
290
+ return false;
291
+ const path = join(aliasDir(), `${h}.json`);
292
+ if (!existsSync(path))
293
+ return false;
294
+ rmSync(path, { force: true });
295
+ return true;
296
+ }
297
+ function readAlias(path) {
298
+ try {
299
+ const rec = JSON.parse(readFileSync(path, "utf8"));
300
+ if (typeof rec.handle === "string" && typeof rec.session === "string" && rec.session) {
301
+ return { handle: rec.handle, session: rec.session, since: String(rec.since ?? "") };
302
+ }
303
+ }
304
+ catch {
305
+ /* corrupt → absent */
306
+ }
307
+ return undefined;
308
+ }
309
+ /** Every registered alias, by handle (sorted). Corrupt files are ignored, not fatal. */
310
+ export function listAliases() {
311
+ if (!existsSync(aliasDir()))
312
+ return [];
313
+ const out = [];
314
+ for (const f of readdirSync(aliasDir()).sort()) {
315
+ if (!f.endsWith(".json"))
316
+ continue;
317
+ const rec = readAlias(join(aliasDir(), f));
318
+ // The file name is the normalised handle; a record whose handle disagrees is ignored.
319
+ if (rec && `${normHandle(rec.handle)}.json` === f)
320
+ out.push(rec);
321
+ }
322
+ return out;
323
+ }
324
+ /** normalised handle → session, the shape routeHits takes. */
325
+ export function readAliases() {
326
+ return new Map(listAliases().map((a) => [normHandle(a.handle), a.session]));
327
+ }
109
328
  /** Sessions with a running listener; stale entries (dead pid) are pruned. */
110
329
  export function liveSessions() {
111
330
  if (!existsSync(liveDir()))
@@ -57,10 +57,19 @@ export interface MnemomKeyDeps {
57
57
  say(line: string): void;
58
58
  }
59
59
  /**
60
- * The Mnemom key a subscription launch sends: MNEMOM_AGENT_MNEMOM_KEY, else the stored
61
- * key, else mint one (gateway scope only) and store it. The key is never printed.
60
+ * The Mnemom key this machine already has — MNEMOM_AGENT_MNEMOM_KEY, else the stored key — or
61
+ * undefined. NEVER mints. An unpinned api-key launch sends it (`x-mnemom-api-key`) alongside its
62
+ * Anthropic key when there is one, so the gateway can serve the key owner's claimed agent of that
63
+ * name instead of a fresh one per rotated Anthropic key; with none it launches exactly as before.
62
64
  */
63
- export declare function resolveSubscriptionMnemomKey(deps: MnemomKeyDeps): Promise<string>;
65
+ export declare function readMnemomKey(deps: Pick<MnemomKeyDeps, "env" | "readStored">): string | undefined;
66
+ /**
67
+ * The Mnemom key a subscription launch (or a pinned launch, which the gateway honours only with
68
+ * a verified Mnemom key) sends: MNEMOM_AGENT_MNEMOM_KEY, else the stored key, else mint one
69
+ * (gateway scope only) and store it. The key is never printed. `purpose` names why in the
70
+ * one-line notes ("subscription login" by default).
71
+ */
72
+ export declare function resolveSubscriptionMnemomKey(deps: MnemomKeyDeps, purpose?: string): Promise<string>;
64
73
  /**
65
74
  * Remote Control on a subscription login is open to accounts entitled to Remote Control
66
75
  * (internal Mnemom accounts): the RC proxy then forwards the user's own OAuth bearer to the