tokenmaxxing 0.19.0 → 0.21.0
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/DESIGN.md +34 -23
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/cli/add.ts +1 -0
- package/src/cli/auth.ts +25 -14
- package/src/cli/check.ts +3 -2
- package/src/cli/codexadd.ts +44 -40
- package/src/cli/codexinit.ts +59 -12
- package/src/cli/codexswitch.ts +15 -1
- package/src/cli/config.ts +10 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/init.ts +54 -32
- package/src/cli/onboard.ts +62 -45
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +650 -78
- package/src/cli/status.ts +69 -23
- package/src/cli/switch.ts +54 -19
- package/src/entries/codexstophook.ts +123 -4
- package/src/entries/codexsupervisor.ts +87 -13
- package/src/entries/sessionstart.ts +1 -1
- package/src/entries/statusline.ts +56 -20
- package/src/entries/stophook.ts +23 -9
- package/src/entries/supervisor.ts +134 -18
- package/src/lib/atomic.ts +28 -6
- package/src/lib/claudebin.ts +2 -2
- package/src/lib/claudejson.ts +5 -5
- package/src/lib/claudelock.ts +112 -37
- package/src/lib/codexauth.ts +10 -2
- package/src/lib/codexbin.ts +1 -1
- package/src/lib/codexdecide.ts +149 -19
- package/src/lib/codexpick.ts +17 -6
- package/src/lib/codexpresence.ts +59 -21
- package/src/lib/codexsample.ts +17 -8
- package/src/lib/codexswap.ts +10 -1
- package/src/lib/credstore.ts +6 -2
- package/src/lib/decide.ts +114 -42
- package/src/lib/install.ts +125 -17
- package/src/lib/keychain.ts +41 -15
- package/src/lib/lock.ts +57 -35
- package/src/lib/log.ts +36 -7
- package/src/lib/oauth.ts +18 -11
- package/src/lib/paths.ts +17 -11
- package/src/lib/picker.ts +11 -3
- package/src/lib/proc.ts +37 -0
- package/src/lib/sample.ts +91 -31
- package/src/lib/sessions.ts +23 -1
- package/src/lib/settings.ts +59 -18
- package/src/lib/slackbridge.ts +583 -76
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +127 -21
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +79 -37
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +61 -7
- package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
- package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/cli/serve.ts
CHANGED
|
@@ -1,41 +1,54 @@
|
|
|
1
1
|
// `tokenmaxxing serve` - the Slack bridge daemon. Socket Mode (no public URL):
|
|
2
|
-
// a mention in a linked channel opens a claude session for that thread
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// Claude
|
|
2
|
+
// a mention in a linked channel opens a claude session for that thread IN the
|
|
3
|
+
// linked repo checkout (normal mode, user decision 2026-07-18 superseding the
|
|
4
|
+
// same-day worktree-per-thread default: a thread's agent cuts its own worktree
|
|
5
|
+
// only when a task needs isolation, guidance in `.memory`), and every further
|
|
6
|
+
// thread message becomes one claude turn whose streamed output posts back into
|
|
7
|
+
// the thread. Stack chosen by the user 2026-07-18: Vercel Chat SDK (`chat` +
|
|
8
|
+
// `@chat-adapter/slack`) for Slack, the Claude Agent SDK driven through
|
|
9
|
+
// src/sdk.ts for claude (EVE was researched and dropped: it owns its own model
|
|
10
|
+
// loop instead of driving Claude Code).
|
|
9
11
|
//
|
|
10
12
|
// serve setup print the app manifest + prompt for the two tokens
|
|
11
|
-
// serve link <ch> <repo> [--
|
|
13
|
+
// serve link <ch> <repo> [--dangerous] [--model <m>]
|
|
12
14
|
// serve unlink <ch> remove a link
|
|
13
15
|
// serve links list links
|
|
14
16
|
// serve run the daemon
|
|
15
17
|
|
|
16
18
|
import { existsSync, realpathSync } from "node:fs";
|
|
17
|
-
import {
|
|
19
|
+
import { delay, omit, uniq } from "es-toolkit";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { Chat, ThreadImpl, type StreamChunk } from "chat";
|
|
18
22
|
import { createSlackAdapter } from "@chat-adapter/slack";
|
|
19
23
|
import { createMemoryState } from "@chat-adapter/state-memory";
|
|
20
24
|
import {
|
|
21
25
|
bareChannelId,
|
|
22
26
|
isChannelId,
|
|
27
|
+
isOutsideAuthor,
|
|
23
28
|
linkForChannel,
|
|
29
|
+
listSlackThreads,
|
|
24
30
|
loadSlackConfig,
|
|
25
31
|
loadSlackThread,
|
|
26
32
|
removeLink,
|
|
33
|
+
resumeDecision,
|
|
27
34
|
saveSlackConfig,
|
|
28
35
|
saveSlackThread,
|
|
29
36
|
stripLeadingMention,
|
|
30
37
|
upsertLink,
|
|
31
38
|
SlackLinkSchema,
|
|
39
|
+
type ActiveTurn,
|
|
32
40
|
type SlackConfig,
|
|
41
|
+
type SlackLink,
|
|
42
|
+
type SlackThread,
|
|
33
43
|
} from "../lib/slackstate.ts";
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
44
|
+
import { cleanupThread, fetchWorkspaceTeamId, killGroup, relayThread, type CleanupOutcome, type TurnOutcome } from "../lib/slackbridge.ts";
|
|
45
|
+
import { pidStartTime } from "../lib/proc.ts";
|
|
46
|
+
import { acquireLock } from "../lib/lock.ts";
|
|
47
|
+
import { paths } from "../lib/paths.ts";
|
|
48
|
+
import { log, setLogEcho } from "../lib/log.ts";
|
|
36
49
|
import { c, count } from "./render.ts";
|
|
37
50
|
|
|
38
|
-
const SERVE_USAGE = "usage: tokenmaxxing serve [setup | link <channel-id> <repo> [--
|
|
51
|
+
const SERVE_USAGE = "usage: tokenmaxxing serve [setup | link <channel-id> <repo> [--yolo | --dangerous] [--model <m>] | unlink <channel-id> | links]";
|
|
39
52
|
|
|
40
53
|
/** The manifest the user pastes at api.slack.com/apps > From an app manifest.
|
|
41
54
|
* Scopes/events verified against docs.slack.dev 2026-07-18: a channel-thread
|
|
@@ -62,6 +75,7 @@ oauth_config:
|
|
|
62
75
|
- groups:history
|
|
63
76
|
- chat:write
|
|
64
77
|
- files:write
|
|
78
|
+
- im:history
|
|
65
79
|
- users:read
|
|
66
80
|
|
|
67
81
|
settings:
|
|
@@ -89,7 +103,7 @@ function printSetupInstructions(): void {
|
|
|
89
103
|
console.log(`${c.dim("Existing app? Paste the manifest over App Manifest in its settings, then reinstall to the workspace (scope changes need it). Tokens stay valid unless you rotate them.")}`);
|
|
90
104
|
}
|
|
91
105
|
|
|
92
|
-
function cmdServeSetup(): number {
|
|
106
|
+
async function cmdServeSetup(): Promise<number> {
|
|
93
107
|
printSetupInstructions();
|
|
94
108
|
console.log();
|
|
95
109
|
const botToken = prompt("bot token (xoxb-...):")?.trim();
|
|
@@ -107,12 +121,23 @@ function cmdServeSetup(): number {
|
|
|
107
121
|
console.error(c.red("tokens rejected: the bot token must start with xoxb- and the app token with xapp-"));
|
|
108
122
|
return 1;
|
|
109
123
|
}
|
|
110
|
-
|
|
124
|
+
// the external-author guard needs the home workspace id; capture it from the
|
|
125
|
+
// token itself so the reference can never drift from the workspace the bot
|
|
126
|
+
// actually lives in (re-captured on every setup: new tokens may belong to a
|
|
127
|
+
// different workspace).
|
|
128
|
+
try {
|
|
129
|
+
cfg = { ...cfg, workspaceTeamId: await fetchWorkspaceTeamId({ botToken }) };
|
|
130
|
+
saveSlackConfig(cfg);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
133
|
+
console.error(c.red(`tokens saved, but ${detail} - check the bot token; the daemon re-tries the capture at start`));
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
console.log(`${c.green("✓")} saved to slack.json (0600) for workspace ${cfg.workspaceTeamId} with ${count({ n: cfg.links.length, noun: "link" })}`);
|
|
111
137
|
return 0;
|
|
112
138
|
}
|
|
113
139
|
|
|
114
140
|
function cmdServeLink(argv: string[]): number {
|
|
115
|
-
const worktree = !argv.includes("--no-worktree");
|
|
116
141
|
// yolo mode = the SDK's bypassPermissions; --dangerous is the same switch.
|
|
117
142
|
const dangerous = argv.includes("--yolo") || argv.includes("--dangerous");
|
|
118
143
|
const modelIdx = argv.indexOf("--model");
|
|
@@ -133,7 +158,7 @@ function cmdServeLink(argv: string[]): number {
|
|
|
133
158
|
}
|
|
134
159
|
const repoReal = realpathSync(repo);
|
|
135
160
|
if (!existsSync(`${repoReal}/.git`)) {
|
|
136
|
-
console.error(c.red(`${repoReal} is not a git repository
|
|
161
|
+
console.error(c.red(`${repoReal} is not a git repository`));
|
|
137
162
|
return 1;
|
|
138
163
|
}
|
|
139
164
|
const cfg = loadSlackConfig();
|
|
@@ -144,12 +169,11 @@ function cmdServeLink(argv: string[]): number {
|
|
|
144
169
|
const link = SlackLinkSchema.parse({
|
|
145
170
|
channel,
|
|
146
171
|
repo: repoReal,
|
|
147
|
-
worktree,
|
|
148
172
|
permissionMode: dangerous ? "bypassPermissions" : "acceptEdits",
|
|
149
173
|
...(model ? { model } : {}),
|
|
150
174
|
});
|
|
151
175
|
saveSlackConfig(upsertLink(cfg, link));
|
|
152
|
-
const flags = [
|
|
176
|
+
const flags = [link.permissionMode, ...(model ? [model] : [])].join(", ");
|
|
153
177
|
console.log(`${c.green("✓")} linked ${c.bold(channel)} → ${repoReal} (${flags})`);
|
|
154
178
|
return 0;
|
|
155
179
|
}
|
|
@@ -177,14 +201,472 @@ function cmdServeLinks(): number {
|
|
|
177
201
|
return 0;
|
|
178
202
|
}
|
|
179
203
|
for (const l of cfg.links) {
|
|
180
|
-
const flags = [l.
|
|
204
|
+
const flags = [l.permissionMode, ...(l.model ? [l.model] : [])].join(", ");
|
|
181
205
|
console.log(`${c.bold(l.channel)} → ${l.repo} ${c.dim(`(${flags})`)}`);
|
|
182
206
|
}
|
|
183
207
|
return 0;
|
|
184
208
|
}
|
|
185
209
|
|
|
210
|
+
/** Event-name endings that pick the terminal paint: red for failures, yellow
|
|
211
|
+
* for degraded-but-continuing conditions, cyan otherwise. Structural endsWith
|
|
212
|
+
* checks so new events inherit sensible colors from their naming. */
|
|
213
|
+
const RED_EVENT_ENDINGS = ["error", "failed", "invalid_grant"];
|
|
214
|
+
const YELLOW_EVENT_ENDINGS = ["_dropped", "_drift", "_unparsed", "_gave_up", "_abort", "forced_exit", "contested", "draining"];
|
|
215
|
+
|
|
216
|
+
function eventPaint(event: string): (s: string) => string {
|
|
217
|
+
if (RED_EVENT_ENDINGS.some((ending) => event.endsWith(ending))) return c.red;
|
|
218
|
+
if (YELLOW_EVENT_ENDINGS.some((ending) => event.endsWith(ending))) return c.yellow;
|
|
219
|
+
return c.cyan;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** One terminal line per log() event while the daemon runs: the file log stays
|
|
223
|
+
* canonical; this makes `xx serve` observable without tailing tokenmaxxing.log.
|
|
224
|
+
* Field values can carry newlines (e.g. usage.probe_failed's stderr excerpt),
|
|
225
|
+
* so they are escaped to keep the one-line-per-event contract. Exported for
|
|
226
|
+
* tests. */
|
|
227
|
+
export function formatLogLine(input: { event: string; parts: string }): string {
|
|
228
|
+
const time = new Date().toLocaleTimeString("en-GB");
|
|
229
|
+
const parts = input.parts.replaceAll("\r", "\\r").replaceAll("\n", "\\n");
|
|
230
|
+
return `${c.dim(time)} ${eventPaint(input.event)(input.event)}${parts ? ` ${parts}` : ""}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The slice of a Chat SDK thread the runtime touches. z.custom because it
|
|
234
|
+
* carries functions: the zod-native way to name the structural shape once for
|
|
235
|
+
* the daemon and test fakes alike. */
|
|
236
|
+
const ServeThreadSchema = z.custom<{
|
|
237
|
+
id: string;
|
|
238
|
+
channelId: string;
|
|
239
|
+
post: (m: string | AsyncIterable<string | StreamChunk>) => Promise<unknown>;
|
|
240
|
+
subscribe: () => Promise<void>;
|
|
241
|
+
unsubscribe: () => Promise<void>;
|
|
242
|
+
startTyping: () => Promise<void>;
|
|
243
|
+
}>();
|
|
244
|
+
type ServeThread = z.infer<typeof ServeThreadSchema>;
|
|
245
|
+
|
|
246
|
+
/** The slice of a Chat SDK message the author guard + folding read. */
|
|
247
|
+
const ServeMessageSchema = z.custom<{
|
|
248
|
+
text: string;
|
|
249
|
+
author: { userId: string; isMe: boolean; isBot?: boolean | "unknown" };
|
|
250
|
+
raw?: unknown;
|
|
251
|
+
}>();
|
|
252
|
+
type ServeMessage = z.infer<typeof ServeMessageSchema>;
|
|
253
|
+
|
|
254
|
+
/** Reap a previous generation's detached claude child that survived an
|
|
255
|
+
* uncatchable daemon death (SIGKILL, crash: the "exit" event never fires
|
|
256
|
+
* on those, so the hook that kills the group never ran) - resuming beside
|
|
257
|
+
* a live orphan would put two claude processes on one cwd and session
|
|
258
|
+
* (adversarial-review catch). Signals fire ONLY on a verified pid+lstart
|
|
259
|
+
* identity match (cubic review catch: this machine runs the user's real
|
|
260
|
+
* claude sessions; a recycled pid must never get the kill). SIGTERM the
|
|
261
|
+
* group, escalate to SIGKILL if it lingers past the grace. */
|
|
262
|
+
async function reapOrphan(turn: ActiveTurn): Promise<void> {
|
|
263
|
+
if (turn.pid === undefined || turn.pidStartedAt === undefined) return;
|
|
264
|
+
if (pidStartTime(turn.pid) !== turn.pidStartedAt) return; // gone, or a recycled pid
|
|
265
|
+
log("serve.orphan_reaped", { pid: turn.pid });
|
|
266
|
+
killGroup(turn.pid);
|
|
267
|
+
for (let i = 0; i < 10; i++) {
|
|
268
|
+
await delay(500);
|
|
269
|
+
if (pidStartTime(turn.pid) !== turn.pidStartedAt) return;
|
|
270
|
+
}
|
|
271
|
+
killGroup(turn.pid, "SIGKILL");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The daemon's message-handling runtime, extracted from runDaemon as an
|
|
276
|
+
* injectable seam so tests can drive the REAL handler wiring (author guard,
|
|
277
|
+
* skipped-message folding, per-thread serialization, activeTurn markers,
|
|
278
|
+
* drain drops, finish close-out) against fake threads and a fake relay.
|
|
279
|
+
* runDaemon passes the production deps; behavior is identical. The startup
|
|
280
|
+
* interrupted-turn recovery stays inline in runDaemon (it needs the live bot
|
|
281
|
+
* and adapter for streamable thread handles) and reuses the chain/turn pieces
|
|
282
|
+
* exposed here.
|
|
283
|
+
*/
|
|
284
|
+
export function buildServeRuntime(seam: {
|
|
285
|
+
cfg: SlackConfig;
|
|
286
|
+
workspaceTeamId: string;
|
|
287
|
+
/** read per message: the adapter only learns its bot user id on connect. */
|
|
288
|
+
botUserId: () => string | null;
|
|
289
|
+
relay: (input: {
|
|
290
|
+
cwd: string;
|
|
291
|
+
sessionId: string | null;
|
|
292
|
+
prompt: string;
|
|
293
|
+
requesterIds: string[];
|
|
294
|
+
link: SlackLink;
|
|
295
|
+
post: (m: AsyncIterable<string | StreamChunk>) => Promise<unknown>;
|
|
296
|
+
/** fires when init assigns a session id the caller has not persisted yet
|
|
297
|
+
* (see relayThread). */
|
|
298
|
+
onSessionId?: (sessionId: string) => void;
|
|
299
|
+
/** fires with the detached claude child's pid at each spawn (see
|
|
300
|
+
* relayThread). */
|
|
301
|
+
onSpawn?: (pid: number) => void;
|
|
302
|
+
drainSignal?: AbortSignal;
|
|
303
|
+
}) => Promise<TurnOutcome>;
|
|
304
|
+
cleanup: (input: { threadId: string }) => CleanupOutcome;
|
|
305
|
+
}) {
|
|
306
|
+
const { cfg, workspaceTeamId } = seam;
|
|
307
|
+
// in-flight turns, tracked so a shutdown signal can drain them instead of
|
|
308
|
+
// killing a half-streamed answer (live incident 2026-07-18: a deploy
|
|
309
|
+
// restart cut a turn mid-sentence and the answer never reached Slack).
|
|
310
|
+
const activeTurns = new Set<Promise<void>>();
|
|
311
|
+
// aborts depleted-pool park/retry sleeps on drain, so a countdown never
|
|
312
|
+
// holds the restart hostage.
|
|
313
|
+
const drainAbort = new AbortController();
|
|
314
|
+
let draining = false;
|
|
315
|
+
|
|
316
|
+
/** One relayed turn with the durable activeTurn marker around it: written
|
|
317
|
+
* before the spawn, cleared when the turn returns, so a marker surviving
|
|
318
|
+
* into the next daemon start identifies a turn a restart killed mid-run.
|
|
319
|
+
* The session id persists the moment init assigns it - a first-turn kill
|
|
320
|
+
* must stay resumable. */
|
|
321
|
+
const runTurn = async (input: {
|
|
322
|
+
thread: { id: string; post: (m: AsyncIterable<string | StreamChunk>) => Promise<unknown> };
|
|
323
|
+
record: SlackThread;
|
|
324
|
+
prompt: string;
|
|
325
|
+
requesterIds: string[];
|
|
326
|
+
sessionId: string | null;
|
|
327
|
+
marker: ActiveTurn;
|
|
328
|
+
link: SlackLink;
|
|
329
|
+
}): Promise<TurnOutcome> => {
|
|
330
|
+
let record: SlackThread = { ...input.record, activeTurn: input.marker };
|
|
331
|
+
saveSlackThread(record);
|
|
332
|
+
let outcome: TurnOutcome | null = null;
|
|
333
|
+
try {
|
|
334
|
+
outcome = await seam.relay({
|
|
335
|
+
cwd: record.cwd,
|
|
336
|
+
sessionId: input.sessionId,
|
|
337
|
+
prompt: input.prompt,
|
|
338
|
+
requesterIds: input.requesterIds,
|
|
339
|
+
link: input.link,
|
|
340
|
+
post: (m) => input.thread.post(m),
|
|
341
|
+
onSpawn: (pid) => {
|
|
342
|
+
// the lstart token makes the pid a verifiable identity for the
|
|
343
|
+
// orphan reaper; a child dead before ps sees it persists without
|
|
344
|
+
// one, and an identity-less pid is never signaled.
|
|
345
|
+
const startedAt = pidStartTime(pid);
|
|
346
|
+
record = { ...record, activeTurn: { ...input.marker, pid, ...(startedAt === null ? {} : { pidStartedAt: startedAt }) } };
|
|
347
|
+
saveSlackThread(record);
|
|
348
|
+
},
|
|
349
|
+
onSessionId: (sessionId) => {
|
|
350
|
+
record = { ...record, sessionId };
|
|
351
|
+
saveSlackThread(record);
|
|
352
|
+
},
|
|
353
|
+
drainSignal: drainAbort.signal,
|
|
354
|
+
});
|
|
355
|
+
record = { ...record, sessionId: outcome.sessionId };
|
|
356
|
+
return outcome;
|
|
357
|
+
} finally {
|
|
358
|
+
// a failure DURING a drain is presumed to be the shutdown signal killing
|
|
359
|
+
// the claude child (terminal Ctrl-C and group signals hit the whole
|
|
360
|
+
// process group, so the child dies and relayThread returns failed while
|
|
361
|
+
// the daemon is still draining - codex review catch): keep the marker so
|
|
362
|
+
// the next generation auto-resumes, exactly the killed-turn state it
|
|
363
|
+
// exists to detect. Outside a drain, or on success, clear it.
|
|
364
|
+
// An announced drop is TERMINAL: relayThread told the user to resend, so
|
|
365
|
+
// retaining the marker would replay work the drop notice disclaimed
|
|
366
|
+
// (duplicate turns, quota, side effects). null outcome = relay threw =
|
|
367
|
+
// still presumed killed.
|
|
368
|
+
const presumedKilled = draining && (outcome === null || (outcome.failed && !outcome.announcedDrop));
|
|
369
|
+
// An unannounced drop OUTSIDE a drain still clears the marker on
|
|
370
|
+
// purpose (retention would re-execute the turn at the next restart; see
|
|
371
|
+
// notifyDelivered's doc) - but the loss must be operator-visible.
|
|
372
|
+
if (!draining && outcome !== null && outcome.failed && outcome.rateLimited && !outcome.announcedDrop) {
|
|
373
|
+
log("serve.drop_unannounced", { thread: input.thread.id });
|
|
374
|
+
}
|
|
375
|
+
saveSlackThread(presumedKilled ? record : omit(record, ["activeTurn"]));
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const handleTurn = async (input: {
|
|
380
|
+
thread: ServeThread;
|
|
381
|
+
/** every relayed message this turn (queue-skipped + triggering), text
|
|
382
|
+
* paired with its author id: a decision may be owed to an earlier
|
|
383
|
+
* folded sender, and a sender whose whole message was the bot mention
|
|
384
|
+
* contributes no prompt text, so text and author filter together
|
|
385
|
+
* (review catches 2026-07-18). */
|
|
386
|
+
relayed: { text: string; authorId: string }[];
|
|
387
|
+
isMention: boolean;
|
|
388
|
+
}) => {
|
|
389
|
+
const { thread, isMention } = input;
|
|
390
|
+
const link = linkForChannel(cfg, bareChannelId(thread.channelId));
|
|
391
|
+
if (!link) {
|
|
392
|
+
// checked BEFORE the draining branch: unlinked channels are
|
|
393
|
+
// contractually log-only silent, and a drain-window drop notice posted
|
|
394
|
+
// into one would tell a user to resend a message that will never be
|
|
395
|
+
// served (closing-review catch).
|
|
396
|
+
log("serve.unlinked_channel", { channel: thread.channelId });
|
|
397
|
+
return; // not a linked channel - stay silent in Slack
|
|
398
|
+
}
|
|
399
|
+
if (draining) {
|
|
400
|
+
// the socket stays connected until the drain finishes; anything landing
|
|
401
|
+
// in that window is dropped loudly rather than spawning an unwaitable
|
|
402
|
+
// turn - and the THREAD is told, not just the log (a silent drop reads
|
|
403
|
+
// as the bot thinking; slaude's recorded drop-notice rule). Tracked so
|
|
404
|
+
// the drain wait flushes it before exit; errors swallowed so the notice
|
|
405
|
+
// can never fail the drain.
|
|
406
|
+
log("serve.drain_dropped", { thread: thread.id });
|
|
407
|
+
void tracked(
|
|
408
|
+
(async () => {
|
|
409
|
+
try {
|
|
410
|
+
await thread.post(
|
|
411
|
+
(async function* () {
|
|
412
|
+
yield "tokenmaxxing is restarting - this message was dropped; please re-send it in a moment.";
|
|
413
|
+
})(),
|
|
414
|
+
);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
// caught (never rethrown - the notice must not fail the drain)
|
|
417
|
+
// but logged: an unposted notice means the user saw nothing.
|
|
418
|
+
log("serve.drain_notice_failed", { thread: thread.id, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
419
|
+
}
|
|
420
|
+
})(),
|
|
421
|
+
);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
log("serve.message", { thread: thread.id, isMention, texts: input.relayed.length });
|
|
425
|
+
// relayed carries queue-skipped messages plus the triggering one: the
|
|
426
|
+
// queue strategy hands a turn only the LATEST message and the rest via
|
|
427
|
+
// context.skipped, so they are folded into one prompt here. A message
|
|
428
|
+
// that is empty once its bot mention is stripped contributes neither
|
|
429
|
+
// prompt text nor a requester id (cursor review catch 2026-07-18).
|
|
430
|
+
const stripped = input.relayed
|
|
431
|
+
.map((m) => ({ text: stripLeadingMention({ text: m.text, botUserId: seam.botUserId() }), authorId: m.authorId }))
|
|
432
|
+
.filter((m) => m.text !== "");
|
|
433
|
+
const prompt = stripped.map((m) => m.text).join("\n\n");
|
|
434
|
+
const requesterIds = uniq(stripped.map((m) => m.authorId));
|
|
435
|
+
if (!prompt) return;
|
|
436
|
+
// this whole handler runs inside the per-thread `serialized` chain (call
|
|
437
|
+
// sites below), which startup resumes share too - so this load already
|
|
438
|
+
// sees any session id a resume persisted, and no second claude process
|
|
439
|
+
// can ever share this thread's cwd.
|
|
440
|
+
let record = loadSlackThread(thread.id);
|
|
441
|
+
if (!record) {
|
|
442
|
+
if (!isMention) return; // only a mention opens a session
|
|
443
|
+
record = { threadId: thread.id, repo: link.repo, cwd: link.repo, sessionId: null, createdAt: new Date().toISOString() };
|
|
444
|
+
saveSlackThread(record);
|
|
445
|
+
log("serve.thread_opened", { thread: thread.id, cwd: link.repo });
|
|
446
|
+
}
|
|
447
|
+
// Inside the serialized chain a marker can only be a PREVIOUS
|
|
448
|
+
// generation's killed turn (this generation's turns clear theirs before
|
|
449
|
+
// releasing the chain, and the serve-lock keeps generations exclusive):
|
|
450
|
+
// an inbound message can win the chain ahead of startup recovery (e.g.
|
|
451
|
+
// Slack redelivering the killed turn's unacked mention), and runTurn's
|
|
452
|
+
// fresh marker would silently discard the orphan's pid identity - reap it
|
|
453
|
+
// here so two claude processes never share the thread's cwd and session
|
|
454
|
+
// (closing-review catch, the recovery-path reap alone loses this race).
|
|
455
|
+
if (record.activeTurn) await reapOrphan(record.activeTurn);
|
|
456
|
+
// subscriptions live in the memory state, so a daemon restart forgets
|
|
457
|
+
// them; every mention re-subscribes to keep follow-up replies flowing.
|
|
458
|
+
if (isMention) await thread.subscribe();
|
|
459
|
+
// "is working..." assistant status; a no-op until the Slack app has the
|
|
460
|
+
// agent feature + assistant:write (the adapter warns instead of throwing).
|
|
461
|
+
await thread.startTyping();
|
|
462
|
+
const startedAt = Date.now();
|
|
463
|
+
const outcome = await runTurn({
|
|
464
|
+
thread,
|
|
465
|
+
record,
|
|
466
|
+
prompt,
|
|
467
|
+
requesterIds,
|
|
468
|
+
sessionId: record.sessionId,
|
|
469
|
+
marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0 },
|
|
470
|
+
link,
|
|
471
|
+
});
|
|
472
|
+
await settleTurn({ thread, outcome, startedAt });
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
|
|
476
|
+
* log line, and the finish_thread garbage collection when the model called
|
|
477
|
+
* it. Never throws into the caller - the daemon must keep serving. */
|
|
478
|
+
const settleTurn = async (input: {
|
|
479
|
+
thread: { id: string; post: (m: string | AsyncIterable<string | StreamChunk>) => Promise<unknown>; unsubscribe: () => Promise<void> };
|
|
480
|
+
outcome: TurnOutcome;
|
|
481
|
+
startedAt: number;
|
|
482
|
+
}) => {
|
|
483
|
+
const { thread, outcome, startedAt } = input;
|
|
484
|
+
log(outcome.failed ? "serve.turn_failed" : "serve.turn_done", {
|
|
485
|
+
thread: thread.id,
|
|
486
|
+
seconds: Math.round((Date.now() - startedAt) / 1000),
|
|
487
|
+
});
|
|
488
|
+
// the user declared the work finished: close the thread now that the
|
|
489
|
+
// turn (and its claude subprocess) is over. Never throw into the caller -
|
|
490
|
+
// the daemon must keep serving other threads.
|
|
491
|
+
if (!outcome.finish) return;
|
|
492
|
+
let result: CleanupOutcome;
|
|
493
|
+
try {
|
|
494
|
+
result = seam.cleanup({ threadId: thread.id });
|
|
495
|
+
} catch (e) {
|
|
496
|
+
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
497
|
+
log("serve.cleanup_error", { thread: thread.id, err: detail });
|
|
498
|
+
try {
|
|
499
|
+
await thread.post(`tokenmaxxing: cleanup failed: ${detail}`);
|
|
500
|
+
} catch (postErr) {
|
|
501
|
+
// the diagnostic is best-effort, but its failure is never silent.
|
|
502
|
+
log("serve.finish_notify_error", { thread: thread.id, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
|
|
503
|
+
}
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
// the Slack calls are guarded too: this whole path must never throw into
|
|
507
|
+
// the caller (review catch, PR #18) - the record is already gone, so a
|
|
508
|
+
// failed confirmation only gets logged.
|
|
509
|
+
try {
|
|
510
|
+
// a refusal keeps the subscription so the thread stays live for a retry.
|
|
511
|
+
if (result.removed) await thread.unsubscribe();
|
|
512
|
+
await thread.post(result.message);
|
|
513
|
+
} catch (e) {
|
|
514
|
+
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
515
|
+
log("serve.finish_notify_error", { thread: thread.id, err: detail });
|
|
516
|
+
}
|
|
517
|
+
log("serve.thread_finished", { thread: thread.id, removed: result.removed });
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const relayable = (m: ServeMessage) => {
|
|
521
|
+
if (m.author.isMe || m.author.isBot === true) return false;
|
|
522
|
+
// outsiders must not drive sessions (owner rule 2026-07-16, ported from
|
|
523
|
+
// slaude): Slack Connect externals and cross-workspace guests are
|
|
524
|
+
// rejected fail-closed - silent in Slack, loud in the log.
|
|
525
|
+
if (isOutsideAuthor({ raw: m.raw, workspaceTeamId })) {
|
|
526
|
+
log("serve.outside_author", {});
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
return true;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const tracked = async (turn: Promise<void>) => {
|
|
533
|
+
activeTurns.add(turn);
|
|
534
|
+
try {
|
|
535
|
+
await turn;
|
|
536
|
+
} finally {
|
|
537
|
+
activeTurns.delete(turn);
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
// Per-thread serialization owned HERE, not only by the SDK's queue lock:
|
|
542
|
+
// chat 4.34.0 acquires the dispatch lock with a 30s TTL and extends it only
|
|
543
|
+
// BETWEEN dispatches, so any handler outliving 30s (every claude turn, any
|
|
544
|
+
// depleted-pool park) lets a later message take the expired lock and start
|
|
545
|
+
// a second concurrent handler in the same thread and cwd (review catch,
|
|
546
|
+
// PR #18). Escaped messages run as sequential turns in arrival order.
|
|
547
|
+
const threadTurns = new Map<string, Promise<void>>();
|
|
548
|
+
const serialized = (threadId: string, run: () => Promise<void>) => {
|
|
549
|
+
const prev = threadTurns.get(threadId) ?? Promise.resolve();
|
|
550
|
+
const next = (async () => {
|
|
551
|
+
try {
|
|
552
|
+
await prev;
|
|
553
|
+
} catch { /* the previous turn's rejection was already surfaced to its own handler */ }
|
|
554
|
+
await run();
|
|
555
|
+
})();
|
|
556
|
+
threadTurns.set(threadId, next);
|
|
557
|
+
// GC observer: swallow next's rejection HERE only (the handler awaiting
|
|
558
|
+
// `next` still sees it), else the observer chain is an unhandled rejection
|
|
559
|
+
// (cubic review catch, PR #18).
|
|
560
|
+
void (async () => {
|
|
561
|
+
try {
|
|
562
|
+
await next;
|
|
563
|
+
} catch { /* surfaced to the awaiting handler */ }
|
|
564
|
+
if (threadTurns.get(threadId) === next) threadTurns.delete(threadId);
|
|
565
|
+
})();
|
|
566
|
+
return next;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
// both Chat SDK callbacks funnel here. Filter EVERY message, trigger
|
|
570
|
+
// included: an outsider (or our own post) arriving last must not discard
|
|
571
|
+
// relayable home-workspace messages queued behind the turn - the queue hands
|
|
572
|
+
// the handler only the latest message and the rest ride context.skipped
|
|
573
|
+
// (review catch, PR #18).
|
|
574
|
+
const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
|
|
575
|
+
const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId }));
|
|
576
|
+
if (relayed.length === 0) return; // outsider mentions never open a session
|
|
577
|
+
await tracked(serialized(input.thread.id, () => handleTurn({ thread: input.thread, relayed, isMention: input.isMention })));
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
/** Recover one thread whose activeTurn marker survived the previous daemon:
|
|
581
|
+
* a restart killed that turn mid-run. Notify the thread, then resume the
|
|
582
|
+
* session (or replay the original prompt when the kill landed before init
|
|
583
|
+
* assigned a session id); past the retry cap, give up loudly. EVERY branch
|
|
584
|
+
* runs inside the shared per-thread `serialized` chain and recomputes the
|
|
585
|
+
* decision from a fresh reload there: an inbound turn (or Slack
|
|
586
|
+
* redelivering the killed turn's unacked mention) can win the chain first,
|
|
587
|
+
* handle the thread, and clear the marker - acting on the startup snapshot
|
|
588
|
+
* would then re-run superseded work and write stale record fields over the
|
|
589
|
+
* session id that turn persisted (adversarial-review catch). Lives in the
|
|
590
|
+
* seam with `streamable` INJECTED (the daemon passes its bot-backed handle
|
|
591
|
+
* builder, tests a fake) so that superseded-recovery race is pinnable
|
|
592
|
+
* (closing-review catch: the invariant had no test while inline). */
|
|
593
|
+
const recoverInterrupted = async (
|
|
594
|
+
record: SlackThread,
|
|
595
|
+
streamable: (threadId: string) => Promise<{ thread: ServeThread; requesterIds: string[] }>,
|
|
596
|
+
) => {
|
|
597
|
+
try {
|
|
598
|
+
const { thread, requesterIds } = await streamable(record.threadId);
|
|
599
|
+
const link = linkForChannel(cfg, bareChannelId(thread.channelId));
|
|
600
|
+
await serialized(record.threadId, async () => {
|
|
601
|
+
// a drain signal can land between the scan and this turn; leave the
|
|
602
|
+
// marker at its previous count so the next start retries.
|
|
603
|
+
if (draining) return;
|
|
604
|
+
const fresh = loadSlackThread(record.threadId);
|
|
605
|
+
const turn = fresh?.activeTurn;
|
|
606
|
+
const decision = fresh ? resumeDecision(fresh) : null;
|
|
607
|
+
if (!fresh || !turn || !decision) return; // superseded: an earlier turn already cleared the marker
|
|
608
|
+
await reapOrphan(turn);
|
|
609
|
+
if (!link) {
|
|
610
|
+
// unlinked since the turn started: nothing can run here; drop the
|
|
611
|
+
// marker and stay silent, like every unlinked-channel path.
|
|
612
|
+
saveSlackThread(omit(fresh, ["activeTurn"]));
|
|
613
|
+
log("serve.resume_unlinked", { thread: record.threadId });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (decision.kind === "give-up") {
|
|
617
|
+
log("serve.resume_gave_up", { thread: record.threadId });
|
|
618
|
+
// post BEFORE clearing, mirroring the resume branch's ordering: a
|
|
619
|
+
// kill or post failure here leaves the marker for the next restart
|
|
620
|
+
// to retry the notice (at-least-once; worst case a duplicate
|
|
621
|
+
// give-up notice), instead of the thread going permanently dark
|
|
622
|
+
// with the user never told the daemon gave up.
|
|
623
|
+
await thread.post(decision.notice);
|
|
624
|
+
saveSlackThread(omit(fresh, ["activeTurn"]));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
log("serve.resume_interrupted", { thread: record.threadId, attempt: decision.marker.resumeCount });
|
|
628
|
+
// the notice posts BEFORE runTurn persists the incremented marker,
|
|
629
|
+
// on purpose: the cap bounds quota-SPENDING attempts (the spawn),
|
|
630
|
+
// and a failed notice spends nothing - the marker survives at its
|
|
631
|
+
// old count for the next restart to retry, at most once per
|
|
632
|
+
// operator-triggered restart, each logged as serve.resume_error.
|
|
633
|
+
// A permanently unpostable channel (bot kicked, archived) therefore
|
|
634
|
+
// retries on every restart; unlinking it clears the marker.
|
|
635
|
+
await thread.post(decision.notice);
|
|
636
|
+
const startedAt = Date.now();
|
|
637
|
+
const outcome = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
|
|
638
|
+
await settleTurn({ thread, outcome, startedAt });
|
|
639
|
+
});
|
|
640
|
+
} catch (e) {
|
|
641
|
+
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
642
|
+
log("serve.resume_error", { thread: record.threadId, err: detail });
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
/** in-flight turn promises; shutdown drains them. */
|
|
648
|
+
activeTurns,
|
|
649
|
+
isDraining: () => draining,
|
|
650
|
+
/** stop taking new turns and wake parked/retrying ones. */
|
|
651
|
+
beginDrain: () => {
|
|
652
|
+
draining = true;
|
|
653
|
+
drainAbort.abort();
|
|
654
|
+
},
|
|
655
|
+
relayable,
|
|
656
|
+
onMessage,
|
|
657
|
+
/** the pieces runDaemon's startup interrupted-turn recovery reuses, so a
|
|
658
|
+
* resumed turn shares the exact chain and marker machinery of an inbound
|
|
659
|
+
* one. */
|
|
660
|
+
serialized,
|
|
661
|
+
tracked,
|
|
662
|
+
runTurn,
|
|
663
|
+
settleTurn,
|
|
664
|
+
recoverInterrupted,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
186
668
|
async function runDaemon(): Promise<number> {
|
|
187
|
-
|
|
669
|
+
let cfg = loadSlackConfig();
|
|
188
670
|
if (!cfg) {
|
|
189
671
|
printSetupInstructions();
|
|
190
672
|
return 1;
|
|
@@ -193,6 +675,30 @@ async function runDaemon(): Promise<number> {
|
|
|
193
675
|
console.error(c.red("no channel links - run `tokenmaxxing serve link <channel-id> <repo>` first"));
|
|
194
676
|
return 1;
|
|
195
677
|
}
|
|
678
|
+
// the external-author guard compares every message's origin against the home
|
|
679
|
+
// workspace. The reference is re-captured from the live token at EVERY start
|
|
680
|
+
// (a stale persisted id would fail-closed reject the owner's own messages);
|
|
681
|
+
// a failed capture fails the daemon fast - the guard never runs
|
|
682
|
+
// reference-less, and the daemon is useless without Slack reachable anyway.
|
|
683
|
+
const workspaceTeamId = await fetchWorkspaceTeamId({ botToken: cfg.botToken });
|
|
684
|
+
if (cfg.workspaceTeamId !== workspaceTeamId) {
|
|
685
|
+
cfg = { ...cfg, workspaceTeamId };
|
|
686
|
+
saveSlackConfig(cfg);
|
|
687
|
+
log("serve.team_captured", { team: workspaceTeamId });
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// every log() event from here on (serve.* plus the in-process swap/decision
|
|
691
|
+
// events fired by ensureBestAccount/stopHookCheck) also prints to the
|
|
692
|
+
// terminal, so a foreground `xx serve` shows what it is doing live.
|
|
693
|
+
setLogEcho({ printer: (entry) => console.log(formatLogLine(entry)) });
|
|
694
|
+
|
|
695
|
+
// single-instance guard, held for the process lifetime (the fd releases on
|
|
696
|
+
// exit): without it a replacement daemon can start inside the previous
|
|
697
|
+
// generation's drain window, read a still-RUNNING turn's activeTurn marker,
|
|
698
|
+
// and resume it - two claude processes in one cwd (adversarial-review
|
|
699
|
+
// catch). Blocking here makes a rolling restart wait out the drain instead.
|
|
700
|
+
console.log(c.dim("acquiring the serve singleton lock (waits for a draining daemon to exit)"));
|
|
701
|
+
await acquireLock(paths.serveLockFile);
|
|
196
702
|
|
|
197
703
|
const slack = createSlackAdapter({
|
|
198
704
|
mode: "socket",
|
|
@@ -211,78 +717,124 @@ async function runDaemon(): Promise<number> {
|
|
|
211
717
|
// so the values are inlined).
|
|
212
718
|
webClientOptions: { retryConfig: { retries: 5, factor: 3.86 }, timeout: 15_000 },
|
|
213
719
|
});
|
|
720
|
+
// held directly (not only via Chat) so startup can re-subscribe recorded
|
|
721
|
+
// threads: subscriptions live in this in-memory state and die with the
|
|
722
|
+
// process, and only a fresh mention would otherwise revive a thread.
|
|
723
|
+
const state = createMemoryState();
|
|
214
724
|
const bot = new Chat({
|
|
215
725
|
userName: "tokenmaxxing",
|
|
216
726
|
adapters: { slack },
|
|
217
|
-
state
|
|
727
|
+
state,
|
|
218
728
|
// per-thread lock with queueing: a message landing mid-turn waits its turn
|
|
219
|
-
// instead of racing a second claude spawn on the same cwd.
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
729
|
+
// instead of racing a second claude spawn on the same cwd. Queue-entry TTL
|
|
730
|
+
// expiry is SILENT (chat 4.34.0 has no app callback for it), so the TTL
|
|
731
|
+
// must outlast the longest legitimate hold: a depleted-pool park
|
|
732
|
+
// (PARK_MAX_MS 14min) plus a long claude turn. Expired-and-folded beats
|
|
733
|
+
// silently-vanished, hence a full hour.
|
|
734
|
+
concurrency: { strategy: "queue", queueEntryTtlMs: 3_600_000 },
|
|
223
735
|
// without this a cards-only segment in post-and-edit fallback would
|
|
224
736
|
// strand a bare "..." placeholder message.
|
|
225
737
|
fallbackStreamingPlaceholderText: null,
|
|
226
738
|
logger: "warn",
|
|
227
739
|
});
|
|
228
740
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
});
|
|
270
|
-
if (outcome.sessionId !== record.sessionId) {
|
|
271
|
-
saveSlackThread({ ...record, sessionId: outcome.sessionId });
|
|
741
|
+
// the message-handling runtime (author guard, folding, per-thread
|
|
742
|
+
// serialization, activeTurn markers, drain drops, finish close-out) lives
|
|
743
|
+
// in buildServeRuntime so tests can drive the real wiring; production deps
|
|
744
|
+
// go in here.
|
|
745
|
+
const runtime = buildServeRuntime({
|
|
746
|
+
cfg,
|
|
747
|
+
workspaceTeamId,
|
|
748
|
+
botUserId: () => slack.botUserId ?? null,
|
|
749
|
+
relay: relayThread,
|
|
750
|
+
cleanup: cleanupThread,
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
/** A proactive thread handle that can still stream natively. bot.thread()
|
|
754
|
+
* carries no currentMessage, and without one handleStream has no
|
|
755
|
+
* recipientUserId/recipientTeamId, so the Slack adapter's stream() gate
|
|
756
|
+
* falls back to card-less post-and-edit that also strands a blank message
|
|
757
|
+
* per card-only segment (verified in chat 4.34.0 + @chat-adapter/slack).
|
|
758
|
+
* Reusing the newest human message in the thread as currentMessage
|
|
759
|
+
* restores the exact context an inbound turn would have, and its author is
|
|
760
|
+
* the natural requester to tag on a resumed turn. The explicit ThreadImpl
|
|
761
|
+
* constructor (published typed API) is deliberate over the prose-documented
|
|
762
|
+
* ThreadImpl.fromJSON/reviver restore path: fromJSON takes the lazy config
|
|
763
|
+
* branch, which silently reverts fallbackStreamingPlaceholderText to "..."
|
|
764
|
+
* (re-stranding the placeholder this daemon suppresses) and needs
|
|
765
|
+
* registerSingleton for state. */
|
|
766
|
+
const streamableThread = async (threadId: string) => {
|
|
767
|
+
const handle = bot.thread(threadId);
|
|
768
|
+
for await (const message of handle.messages) {
|
|
769
|
+
if (runtime.relayable(message)) {
|
|
770
|
+
const thread = new ThreadImpl({
|
|
771
|
+
adapter: slack,
|
|
772
|
+
stateAdapter: state,
|
|
773
|
+
channelId: handle.channelId,
|
|
774
|
+
id: threadId,
|
|
775
|
+
isDM: false,
|
|
776
|
+
currentMessage: message,
|
|
777
|
+
fallbackStreamingPlaceholderText: null,
|
|
778
|
+
});
|
|
779
|
+
return { thread, requesterIds: [message.author.userId] };
|
|
780
|
+
}
|
|
272
781
|
}
|
|
782
|
+
// no human message on record: card-less is all there is
|
|
783
|
+
return { thread: handle, requesterIds: [] };
|
|
273
784
|
};
|
|
274
785
|
|
|
275
|
-
|
|
786
|
+
bot.onNewMention(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: true }));
|
|
787
|
+
bot.onSubscribedMessage(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: false }));
|
|
276
788
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
789
|
+
// drain instead of dying mid-answer: stop taking new turns, let in-flight
|
|
790
|
+
// ones finish (bounded - a hung claude turn must not block a restart
|
|
791
|
+
// forever), then disconnect. A second signal forces an immediate exit.
|
|
792
|
+
// Registered BEFORE initialize() (post-0.19.1 review catch): the socket
|
|
793
|
+
// goes live inside initialize, so a turn could start while runDaemon was
|
|
794
|
+
// still suspended there and a signal in that window hit default
|
|
795
|
+
// disposition - an instant kill with no drain.
|
|
796
|
+
const DRAIN_MS = 300_000;
|
|
797
|
+
const shutdown = async (signal: string) => {
|
|
798
|
+
if (runtime.isDraining()) {
|
|
799
|
+
log("serve.forced_exit", { signal });
|
|
800
|
+
process.exit(1);
|
|
801
|
+
}
|
|
802
|
+
runtime.beginDrain(); // parked/retrying turns wake, post their drop notice, and finish
|
|
803
|
+
log("serve.draining", { signal, turns: runtime.activeTurns.size });
|
|
804
|
+
console.log(`${c.yellow("●")} ${signal}: draining ${count({ n: runtime.activeTurns.size, noun: "in-flight turn" })} (again to force)`);
|
|
805
|
+
// re-snapshot until stable inside the deadline: drain-window drop notices
|
|
806
|
+
// join activeTurns after the first snapshot and must still flush.
|
|
807
|
+
const deadline = Date.now() + DRAIN_MS;
|
|
808
|
+
while (runtime.activeTurns.size > 0 && Date.now() < deadline) {
|
|
809
|
+
await Promise.race([Promise.allSettled([...runtime.activeTurns]), delay(deadline - Date.now())]);
|
|
810
|
+
}
|
|
811
|
+
try {
|
|
812
|
+
await bot.shutdown();
|
|
813
|
+
} catch (e) {
|
|
814
|
+
// exit must be reached even when the socket teardown rejects; the
|
|
815
|
+
// second-signal force path must not be the only escape.
|
|
816
|
+
log("serve.shutdown_error", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
817
|
+
}
|
|
818
|
+
log("serve.stopped", { dropped: runtime.activeTurns.size });
|
|
819
|
+
process.exit(0);
|
|
820
|
+
};
|
|
821
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
822
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
823
|
+
// closing the foreground terminal sends SIGHUP, whose default disposition
|
|
824
|
+
// kills the daemon WITHOUT the "exit" event - so the process-exit hook that
|
|
825
|
+
// kill-groups the DETACHED claude child never runs, the child survives as
|
|
826
|
+
// an orphan still mutating the cwd, and the freed serve-lock lets the next
|
|
827
|
+
// generation resume the same session beside it (adversarial-review catch,
|
|
828
|
+
// exit-skip verified empirically on Bun; a mid-turn orphan does NOT die on
|
|
829
|
+
// its dead stdout pipe - also verified - which is why the reaper exists).
|
|
830
|
+
// Draining instead keeps the child owned; its Slack streaming needs no
|
|
831
|
+
// tty, so the turn can even finish. A SIGKILL/crash orphan is covered by
|
|
832
|
+
// reapOrphan via the marker's pid identity; the only unmarked window is
|
|
833
|
+
// spawn-to-persist, both inside the spawn hook BEFORE the SDK writes the
|
|
834
|
+
// prompt, and a prompt-less orphan exits on its dead stdin's EOF (verified
|
|
835
|
+
// against the real claude binary in the SDK's exact stdio shape: dead in
|
|
836
|
+
// 2s, zero API calls).
|
|
837
|
+
process.on("SIGHUP", () => void shutdown("SIGHUP"));
|
|
286
838
|
|
|
287
839
|
// initialize() starts the PERSISTENT Socket Mode client (auto-reconnecting)
|
|
288
840
|
// wired straight into event routing; the daemon only has to stay alive.
|
|
@@ -291,6 +843,26 @@ async function runDaemon(): Promise<number> {
|
|
|
291
843
|
// and awaiting it in a loop starved the event loop so hard the WebSocket
|
|
292
844
|
// never delivered a single event (live incident 2026-07-18).
|
|
293
845
|
await bot.initialize();
|
|
846
|
+
|
|
847
|
+
// subscriptions live in the memory state and died with the previous daemon;
|
|
848
|
+
// the durable slack-threads/ records say which threads are ours, so restore
|
|
849
|
+
// routing for them (message routing checks stateAdapter.isSubscribed,
|
|
850
|
+
// verified in chat 4.34.0). Without this a restart leaves every open thread
|
|
851
|
+
// deaf to non-mention follow-ups.
|
|
852
|
+
const records = listSlackThreads();
|
|
853
|
+
for (const record of records) await state.subscribe(record.threadId);
|
|
854
|
+
log("serve.resubscribed", { threads: records.length });
|
|
855
|
+
|
|
856
|
+
// threads whose activeTurn marker survived the previous daemon were killed
|
|
857
|
+
// mid-turn by a restart (live incident 2026-07-18: a redeploy silently
|
|
858
|
+
// killed a ship turn 8 minutes in and the thread just went dark). Each gets
|
|
859
|
+
// a notice and an auto-resumed turn, tracked so a drain waits for them too;
|
|
860
|
+
// the actionable decision is recomputed under the per-thread lock inside.
|
|
861
|
+
for (const record of records) {
|
|
862
|
+
if (!record.activeTurn) continue;
|
|
863
|
+
void runtime.tracked(runtime.recoverInterrupted(record, streamableThread));
|
|
864
|
+
}
|
|
865
|
+
|
|
294
866
|
console.log(`${c.green("●")} serving ${count({ n: cfg.links.length, noun: "linked channel" })} over Slack Socket Mode - mention the bot in a linked channel to open a session (Ctrl-C to stop)`);
|
|
295
867
|
log("serve.started", { links: cfg.links.length });
|
|
296
868
|
await new Promise<never>(() => {});
|