@mnemom/mnemom 0.17.25 → 0.17.27

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 (45) 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 +25 -9
  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 +166 -9
  7. package/dist/commands/agent-setup.js +719 -54
  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 +133 -17
  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 +75 -8
  19. package/dist/lib/agent.js +83 -14
  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/model-tier.d.ts +75 -0
  28. package/dist/lib/model-tier.js +166 -0
  29. package/dist/lib/oauth.d.ts +7 -0
  30. package/dist/lib/oauth.js +92 -17
  31. package/dist/lib/served-agent-cache.d.ts +8 -1
  32. package/dist/lib/served-agent-cache.js +11 -2
  33. package/dist/lib/slack-channel.d.ts +1 -0
  34. package/dist/lib/slack-channel.js +30 -12
  35. package/dist/lib/slack-route.d.ts +72 -9
  36. package/dist/lib/slack-route.js +113 -24
  37. package/dist/lib/slack-router.d.ts +37 -2
  38. package/dist/lib/slack-router.js +98 -13
  39. package/dist/lib/slack-state.d.ts +105 -4
  40. package/dist/lib/slack-state.js +230 -11
  41. package/dist/lib/subscription-login.d.ts +12 -3
  42. package/dist/lib/subscription-login.js +19 -7
  43. package/dist/mcp/handoff-server.mjs +31 -3
  44. package/dist/rc-proxy.mjs +14 -0
  45. package/package.json +1 -1
@@ -14,6 +14,22 @@
14
14
  * 3. PLAIN HANDLE — `@pv2-lead …` typed as text routes to that session when it
15
15
  * is a known live session (plain text is not a Slack mention, so it is only
16
16
  * honoured for names we recognise, to avoid routing stray `@words`).
17
+ * 4. ALIAS — an operator-registered handle (`mnemom agent slack alias <handle>
18
+ * <session>`) routes to that session whether or not it is live right now:
19
+ * the inbox is a file and the session's listener resumes from its saved
20
+ * offset, so the message waits for it. The hit carries `for: <handle>`, so a
21
+ * session fronting for several handles can tell which one was addressed.
22
+ *
23
+ * STICKY THREADS: once a message is routed to a session by (2)-(4), the router
24
+ * records that routing like a post, so later replies in the same thread reach the
25
+ * same session under (1) without re-addressing it (bounded by a TTL in
26
+ * lib/slack-state.ts). The record keeps the ADDRESS that was used, and every read
27
+ * re-resolves it (reresolveRoutes), so a thread follows a restarted or replaced
28
+ * session instead of the one that happened to answer first.
29
+ *
30
+ * Aliases (4) and sticky threads are each OFF unless the operator turns them on
31
+ * (launcher `slack_aliases` / `slack_sticky_threads`); off, routing is exactly
32
+ * (1)-(3) as before.
17
33
  *
18
34
  * The app's own posts (bot_id set) are never routed — sessions do not hear
19
35
  * themselves or each other through the router.
@@ -33,15 +49,23 @@ export interface SlackMessageEvent {
33
49
  permalink?: string;
34
50
  }>;
35
51
  }
36
- /** One post a session made: `ts` in `channel`, plus the thread it was in (if any). */
52
+ /**
53
+ * One post a session made: `ts` in `channel`, plus the thread it was in (if any).
54
+ * A sticky-thread route record has the same shape, with `for` set when the message
55
+ * reached the session through an alias handle.
56
+ */
37
57
  export interface PostRecord {
38
58
  session: string;
39
59
  channel: string;
40
60
  ts: string;
41
61
  thread_ts?: string;
62
+ for?: string;
42
63
  }
43
- /** channel → thread root ts → sessions that posted in that thread. */
44
- export type ThreadIndex = Map<string, Map<string, Set<string>>>;
64
+ /**
65
+ * channel → thread root ts → sessions that posted (or were routed) in that thread,
66
+ * each with the alias handle it was reached as (undefined for a plain post).
67
+ */
68
+ export type ThreadIndex = Map<string, Map<string, Map<string, string | undefined>>>;
45
69
  export declare function buildThreadIndex(posts: Iterable<PostRecord>): ThreadIndex;
46
70
  /** Normalise a session name for comparison (same shape the launcher emits). */
47
71
  export declare function normSession(raw: string): string;
@@ -51,19 +75,58 @@ export declare function normSession(raw: string): string;
51
75
  * `agent-user-mnemom-design-helper`. Ambiguous or unknown → undefined.
52
76
  */
53
77
  export declare function resolveLiveSession(name: string, live: Map<string, string>): string | undefined;
78
+ /** A session a message is routed to, and the alias handle it was addressed by (if any). */
79
+ export interface RouteHit {
80
+ session: string;
81
+ for?: string;
82
+ /** The normalised name the message used to reach it (a handle or a session name). */
83
+ addr?: string;
84
+ }
85
+ /** Normalise an alias handle (the same shape as a session name; `@` and case dropped). */
86
+ export declare function normHandle(raw: string): string;
87
+ /**
88
+ * What `name` addresses: an exact live session, else a registered alias (live or
89
+ * not — see ALIAS above), else the ONE live session whose name ends in `-<name>`.
90
+ * An alias outranks a suffix match because an operator registered it on purpose.
91
+ */
92
+ export declare function resolveAddress(name: string, live: Map<string, string>, aliases?: Map<string, string>): RouteHit | undefined;
93
+ /** A sticky-thread route as stored: a post-shaped record plus the address it was made by. */
94
+ export interface StickyRoute extends PostRecord {
95
+ addr?: string;
96
+ }
54
97
  /**
55
- * The sessions `ev` is addressed to. `liveSessions` are the names with a running
56
- * listener; `botUserId` is the app's bot user (from auth.test). Returns [] for
57
- * events that address no one (or that the app itself posted).
98
+ * Re-resolve stored sticky routes against who is live and which aliases exist NOW,
99
+ * so a thread follows the session currently behind the address rather than the one
100
+ * it reached when the route was recorded:
101
+ * - the stored address resolves (live session, alias, unique live suffix) → route
102
+ * to that session, with the handle it resolved as;
103
+ * - it does not, and the route was made through an alias → drop it (the handle was
104
+ * removed: the operator stopped routing it);
105
+ * - it does not, and it was a plain session address → keep the recorded session
106
+ * (nothing newer answers to that name).
107
+ * A record with no stored address falls back to its alias handle.
58
108
  */
59
- export declare function routeMessage(ev: SlackMessageEvent, ctx: {
109
+ export declare function reresolveRoutes(routes: Iterable<StickyRoute>, live: Iterable<string>, aliases: Map<string, string>): PostRecord[];
110
+ export interface RouteContext {
60
111
  botUserId: string;
61
112
  liveSessions: Iterable<string>;
62
113
  threads: ThreadIndex;
63
- }): string[];
114
+ /** normalised handle → session (from `mnemom agent slack alias`). */
115
+ aliases?: Map<string, string>;
116
+ }
117
+ /**
118
+ * The sessions `ev` is addressed to, each with the alias handle it was reached
119
+ * as. `liveSessions` are the names with a running listener; `botUserId` is the
120
+ * app's bot user (from auth.test). Returns [] for events that address no one (or
121
+ * that the app itself posted). A session hit both as an alias and otherwise keeps
122
+ * the alias handle.
123
+ */
124
+ export declare function routeHits(ev: SlackMessageEvent, ctx: RouteContext): RouteHit[];
125
+ /** The session names `ev` is addressed to (routeHits without the handles). */
126
+ export declare function routeMessage(ev: SlackMessageEvent, ctx: RouteContext): string[];
64
127
  /**
65
128
  * The text a session receives: the app mention, and a live session name right
66
129
  * after it, are stripped so the model reads the instruction, not the routing.
67
130
  * A word after the mention that is NOT a live session is kept (it is content).
68
131
  */
69
- export declare function stripAddressing(text: string, botUserId: string, liveSessions: Iterable<string>): string;
132
+ export declare function stripAddressing(text: string, botUserId: string, liveSessions: Iterable<string>, aliases?: Map<string, string>): string;
@@ -14,6 +14,22 @@
14
14
  * 3. PLAIN HANDLE — `@pv2-lead …` typed as text routes to that session when it
15
15
  * is a known live session (plain text is not a Slack mention, so it is only
16
16
  * honoured for names we recognise, to avoid routing stray `@words`).
17
+ * 4. ALIAS — an operator-registered handle (`mnemom agent slack alias <handle>
18
+ * <session>`) routes to that session whether or not it is live right now:
19
+ * the inbox is a file and the session's listener resumes from its saved
20
+ * offset, so the message waits for it. The hit carries `for: <handle>`, so a
21
+ * session fronting for several handles can tell which one was addressed.
22
+ *
23
+ * STICKY THREADS: once a message is routed to a session by (2)-(4), the router
24
+ * records that routing like a post, so later replies in the same thread reach the
25
+ * same session under (1) without re-addressing it (bounded by a TTL in
26
+ * lib/slack-state.ts). The record keeps the ADDRESS that was used, and every read
27
+ * re-resolves it (reresolveRoutes), so a thread follows a restarted or replaced
28
+ * session instead of the one that happened to answer first.
29
+ *
30
+ * Aliases (4) and sticky threads are each OFF unless the operator turns them on
31
+ * (launcher `slack_aliases` / `slack_sticky_threads`); off, routing is exactly
32
+ * (1)-(3) as before.
17
33
  *
18
34
  * The app's own posts (bot_id set) are never routed — sessions do not hear
19
35
  * themselves or each other through the router.
@@ -30,8 +46,10 @@ export function buildThreadIndex(posts) {
30
46
  for (const root of new Set([p.ts, p.thread_ts].filter(Boolean))) {
31
47
  let sessions = byRoot.get(root);
32
48
  if (!sessions)
33
- byRoot.set(root, (sessions = new Set()));
34
- sessions.add(p.session);
49
+ byRoot.set(root, (sessions = new Map()));
50
+ // A plain post never erases the handle an earlier route recorded.
51
+ if (!sessions.has(p.session) || p.for)
52
+ sessions.set(p.session, p.for);
35
53
  }
36
54
  }
37
55
  return idx;
@@ -59,12 +77,71 @@ export function resolveLiveSession(name, live) {
59
77
  const hits = [...live.keys()].filter((k) => k.endsWith(`-${n}`));
60
78
  return hits.length === 1 ? live.get(hits[0]) : undefined;
61
79
  }
80
+ /** Normalise an alias handle (the same shape as a session name; `@` and case dropped). */
81
+ export function normHandle(raw) {
82
+ return normSession(raw.replace(/^@+/, ""));
83
+ }
62
84
  /**
63
- * The sessions `ev` is addressed to. `liveSessions` are the names with a running
64
- * listener; `botUserId` is the app's bot user (from auth.test). Returns [] for
65
- * events that address no one (or that the app itself posted).
85
+ * What `name` addresses: an exact live session, else a registered alias (live or
86
+ * not — see ALIAS above), else the ONE live session whose name ends in `-<name>`.
87
+ * An alias outranks a suffix match because an operator registered it on purpose.
66
88
  */
67
- export function routeMessage(ev, ctx) {
89
+ export function resolveAddress(name, live, aliases = new Map()) {
90
+ // An empty name matches nothing: live and alias keys are never empty, and
91
+ // resolveLiveSession refuses an empty suffix.
92
+ const n = normSession(name);
93
+ const exact = live.get(n);
94
+ if (exact)
95
+ return { session: exact, addr: n };
96
+ const alias = aliases.get(n);
97
+ if (alias)
98
+ return { session: alias, for: n, addr: n };
99
+ const hit = resolveLiveSession(n, live);
100
+ return hit ? { session: hit, addr: n } : undefined;
101
+ }
102
+ /**
103
+ * Re-resolve stored sticky routes against who is live and which aliases exist NOW,
104
+ * so a thread follows the session currently behind the address rather than the one
105
+ * it reached when the route was recorded:
106
+ * - the stored address resolves (live session, alias, unique live suffix) → route
107
+ * to that session, with the handle it resolved as;
108
+ * - it does not, and the route was made through an alias → drop it (the handle was
109
+ * removed: the operator stopped routing it);
110
+ * - it does not, and it was a plain session address → keep the recorded session
111
+ * (nothing newer answers to that name).
112
+ * A record with no stored address falls back to its alias handle.
113
+ */
114
+ export function reresolveRoutes(routes, live, aliases) {
115
+ const liveMap = new Map();
116
+ for (const s of live)
117
+ liveMap.set(normSession(s), s);
118
+ const out = [];
119
+ for (const r of routes) {
120
+ const base = {
121
+ session: r.session,
122
+ channel: r.channel,
123
+ ts: r.ts,
124
+ ...(r.thread_ts ? { thread_ts: r.thread_ts } : {}),
125
+ };
126
+ const addr = r.addr ?? r.for;
127
+ const now = addr ? resolveAddress(addr, liveMap, aliases) : undefined;
128
+ if (now)
129
+ out.push({ ...base, session: now.session, ...(now.for ? { for: now.for } : {}) });
130
+ else if (r.for)
131
+ continue;
132
+ else
133
+ out.push(base);
134
+ }
135
+ return out;
136
+ }
137
+ /**
138
+ * The sessions `ev` is addressed to, each with the alias handle it was reached
139
+ * as. `liveSessions` are the names with a running listener; `botUserId` is the
140
+ * app's bot user (from auth.test). Returns [] for events that address no one (or
141
+ * that the app itself posted). A session hit both as an alias and otherwise keeps
142
+ * the alias handle.
143
+ */
144
+ export function routeHits(ev, ctx) {
68
145
  if (ev.bot_id || !ev.user || !ev.channel || !ev.ts)
69
146
  return [];
70
147
  if (ev.subtype && ev.subtype !== "thread_broadcast" && ev.subtype !== "file_share")
@@ -72,43 +149,55 @@ export function routeMessage(ev, ctx) {
72
149
  const live = new Map();
73
150
  for (const s of ctx.liveSessions)
74
151
  live.set(normSession(s), s);
75
- const out = new Set();
152
+ const aliases = ctx.aliases ?? new Map();
153
+ const out = new Map();
154
+ const add = (h) => {
155
+ if (!h)
156
+ return;
157
+ const prev = out.get(h.session);
158
+ // An alias hit outranks a plain one for the same session; otherwise first wins.
159
+ if (!prev || (h.for && !prev.for))
160
+ out.set(h.session, h);
161
+ };
76
162
  const text = ev.text ?? "";
77
- // 1. thread reply
163
+ // 1. thread reply (posts + sticky routes)
78
164
  if (ev.thread_ts && ev.thread_ts !== ev.ts) {
79
- for (const s of ctx.threads.get(ev.channel)?.get(ev.thread_ts) ?? [])
80
- out.add(s);
165
+ for (const [s, f] of ctx.threads.get(ev.channel)?.get(ev.thread_ts) ?? []) {
166
+ add({ session: s, ...(f ? { for: f } : {}) });
167
+ }
81
168
  }
82
169
  // 2. <@BOT> name…
83
170
  const mention = `<@${ctx.botUserId}>`;
84
171
  let at = text.indexOf(mention);
85
172
  while (at !== -1) {
86
173
  const after = text.slice(at + mention.length).match(/^[\s,:]*@?([A-Za-z0-9][A-Za-z0-9-]*)/);
87
- if (after) {
88
- const hit = resolveLiveSession(after[1], live);
89
- if (hit)
90
- out.add(hit);
91
- }
174
+ if (after)
175
+ add(resolveAddress(after[1], live, aliases));
92
176
  at = text.indexOf(mention, at + mention.length);
93
177
  }
94
- // 3. plain @handle for a known live session
95
- for (const m of text.matchAll(HANDLE_RE)) {
96
- const hit = resolveLiveSession(m[1], live);
97
- if (hit)
98
- out.add(hit);
99
- }
100
- return [...out];
178
+ // 3./4. plain @handle for a known live session or a registered alias
179
+ for (const m of text.matchAll(HANDLE_RE))
180
+ add(resolveAddress(m[1], live, aliases));
181
+ return [...out.values()].map(({ session, for: f, addr }) => ({
182
+ session,
183
+ ...(f ? { for: f } : {}),
184
+ ...(addr ? { addr } : {}),
185
+ }));
186
+ }
187
+ /** The session names `ev` is addressed to (routeHits without the handles). */
188
+ export function routeMessage(ev, ctx) {
189
+ return routeHits(ev, ctx).map((h) => h.session);
101
190
  }
102
191
  /**
103
192
  * The text a session receives: the app mention, and a live session name right
104
193
  * after it, are stripped so the model reads the instruction, not the routing.
105
194
  * A word after the mention that is NOT a live session is kept (it is content).
106
195
  */
107
- export function stripAddressing(text, botUserId, liveSessions) {
196
+ export function stripAddressing(text, botUserId, liveSessions, aliases = new Map()) {
108
197
  const live = new Map([...liveSessions].map((s) => [normSession(s), s]));
109
198
  const re = new RegExp(`<@${botUserId}>[\\s,:]*(?:@?([A-Za-z0-9][A-Za-z0-9-]*)[\\s,:]*)?`, "g");
110
199
  return text
111
- .replace(re, (_whole, name) => name && !resolveLiveSession(name, live) ? `${name} ` : "")
200
+ .replace(re, (_whole, name) => name && !resolveAddress(name, live, aliases) ? `${name} ` : "")
112
201
  .replace(/[ \t]{2,}/g, " ")
113
202
  .trim();
114
203
  }
@@ -6,10 +6,29 @@
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 { type FetchLike } from "./slack.js";
15
+ /** How often the router settles held messages (delivered / expired). */
16
+ export declare const PENDING_SWEEP_MS = 30000;
17
+ /**
18
+ * Settle the held messages (see settlePending) and tell the sender of each expired
19
+ * one, in its thread, that it was not delivered. Best effort: a failed post is
20
+ * logged, never retried (the message is already dropped from the queue).
21
+ */
22
+ export declare function sweepPending(opts: {
23
+ botToken: string;
24
+ fetchImpl?: FetchLike;
25
+ log?: (line: string) => void;
26
+ now?: number;
27
+ }): Promise<{
28
+ delivered: number;
29
+ expired: number;
30
+ held: number;
31
+ }>;
13
32
  /** The running router's pid, or null. */
14
33
  export declare function routerPid(): number | null;
15
34
  /**
@@ -23,6 +42,22 @@ export declare function runRouter(opts: {
23
42
  appToken: string;
24
43
  /** Slack member ids allowed to reach a session; everyone else is dropped. */
25
44
  allow: string[];
45
+ /**
46
+ * Route `mnemom agent slack alias` handles, and hold a message for a session that
47
+ * is not listening (tell the sender it is waiting; report it undelivered after
48
+ * PENDING_TTL_MS). Off (the default): aliases are ignored and routing is unchanged.
49
+ */
50
+ aliases?: boolean;
51
+ /**
52
+ * A thread a session was addressed in keeps routing replies to it, re-resolved to
53
+ * whoever answers to that address now. Off (the default): only threads a session
54
+ * posted in route replies.
55
+ */
56
+ stickyThreads?: boolean;
57
+ /** How often to settle held messages (default PENDING_SWEEP_MS). */
58
+ sweepMs?: number;
59
+ /** Aborting stops the held-message sweep (tests; the CLI router runs until killed). */
60
+ signal?: AbortSignal;
26
61
  log?: (line: string) => void;
27
62
  fetchImpl?: FetchLike;
28
63
  }): Promise<never>;
@@ -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 (;;) {