tokenmaxxing 0.19.1 → 1.0.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 -25
- package/README.md +6 -5
- 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/codexrm.ts +49 -0
- package/src/cli/codexswitch.ts +20 -2
- package/src/cli/config.ts +25 -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/rename.ts +20 -0
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +638 -115
- 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 +184 -20
- 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 +18 -3
- 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 +136 -49
- 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 +581 -81
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +123 -20
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +92 -38
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +70 -9
- 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,43 +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 { delay } from "es-toolkit";
|
|
18
|
-
import {
|
|
19
|
+
import { delay, omit, uniq } from "es-toolkit";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { Chat, ThreadImpl, type StreamChunk } from "chat";
|
|
19
22
|
import { createSlackAdapter } from "@chat-adapter/slack";
|
|
20
23
|
import { createMemoryState } from "@chat-adapter/state-memory";
|
|
21
24
|
import {
|
|
22
25
|
bareChannelId,
|
|
23
26
|
isChannelId,
|
|
27
|
+
isOutsideAuthor,
|
|
24
28
|
linkForChannel,
|
|
25
29
|
listSlackThreads,
|
|
26
30
|
loadSlackConfig,
|
|
27
31
|
loadSlackThread,
|
|
28
32
|
removeLink,
|
|
33
|
+
resumeDecision,
|
|
29
34
|
saveSlackConfig,
|
|
30
35
|
saveSlackThread,
|
|
31
36
|
stripLeadingMention,
|
|
32
37
|
upsertLink,
|
|
33
38
|
SlackLinkSchema,
|
|
39
|
+
type ActiveTurn,
|
|
34
40
|
type SlackConfig,
|
|
41
|
+
type SlackLink,
|
|
42
|
+
type SlackThread,
|
|
35
43
|
} from "../lib/slackstate.ts";
|
|
36
|
-
import {
|
|
37
|
-
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";
|
|
38
49
|
import { c, count } from "./render.ts";
|
|
39
50
|
|
|
40
|
-
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]";
|
|
41
52
|
|
|
42
53
|
/** The manifest the user pastes at api.slack.com/apps > From an app manifest.
|
|
43
54
|
* Scopes/events verified against docs.slack.dev 2026-07-18: a channel-thread
|
|
@@ -64,6 +75,7 @@ oauth_config:
|
|
|
64
75
|
- groups:history
|
|
65
76
|
- chat:write
|
|
66
77
|
- files:write
|
|
78
|
+
- im:history
|
|
67
79
|
- users:read
|
|
68
80
|
|
|
69
81
|
settings:
|
|
@@ -91,7 +103,7 @@ function printSetupInstructions(): void {
|
|
|
91
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.")}`);
|
|
92
104
|
}
|
|
93
105
|
|
|
94
|
-
function cmdServeSetup(): number {
|
|
106
|
+
async function cmdServeSetup(): Promise<number> {
|
|
95
107
|
printSetupInstructions();
|
|
96
108
|
console.log();
|
|
97
109
|
const botToken = prompt("bot token (xoxb-...):")?.trim();
|
|
@@ -109,12 +121,23 @@ function cmdServeSetup(): number {
|
|
|
109
121
|
console.error(c.red("tokens rejected: the bot token must start with xoxb- and the app token with xapp-"));
|
|
110
122
|
return 1;
|
|
111
123
|
}
|
|
112
|
-
|
|
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" })}`);
|
|
113
137
|
return 0;
|
|
114
138
|
}
|
|
115
139
|
|
|
116
140
|
function cmdServeLink(argv: string[]): number {
|
|
117
|
-
const worktree = !argv.includes("--no-worktree");
|
|
118
141
|
// yolo mode = the SDK's bypassPermissions; --dangerous is the same switch.
|
|
119
142
|
const dangerous = argv.includes("--yolo") || argv.includes("--dangerous");
|
|
120
143
|
const modelIdx = argv.indexOf("--model");
|
|
@@ -135,7 +158,7 @@ function cmdServeLink(argv: string[]): number {
|
|
|
135
158
|
}
|
|
136
159
|
const repoReal = realpathSync(repo);
|
|
137
160
|
if (!existsSync(`${repoReal}/.git`)) {
|
|
138
|
-
console.error(c.red(`${repoReal} is not a git repository
|
|
161
|
+
console.error(c.red(`${repoReal} is not a git repository`));
|
|
139
162
|
return 1;
|
|
140
163
|
}
|
|
141
164
|
const cfg = loadSlackConfig();
|
|
@@ -146,12 +169,11 @@ function cmdServeLink(argv: string[]): number {
|
|
|
146
169
|
const link = SlackLinkSchema.parse({
|
|
147
170
|
channel,
|
|
148
171
|
repo: repoReal,
|
|
149
|
-
worktree,
|
|
150
172
|
permissionMode: dangerous ? "bypassPermissions" : "acceptEdits",
|
|
151
173
|
...(model ? { model } : {}),
|
|
152
174
|
});
|
|
153
175
|
saveSlackConfig(upsertLink(cfg, link));
|
|
154
|
-
const flags = [
|
|
176
|
+
const flags = [link.permissionMode, ...(model ? [model] : [])].join(", ");
|
|
155
177
|
console.log(`${c.green("✓")} linked ${c.bold(channel)} → ${repoReal} (${flags})`);
|
|
156
178
|
return 0;
|
|
157
179
|
}
|
|
@@ -179,14 +201,475 @@ function cmdServeLinks(): number {
|
|
|
179
201
|
return 0;
|
|
180
202
|
}
|
|
181
203
|
for (const l of cfg.links) {
|
|
182
|
-
const flags = [l.
|
|
204
|
+
const flags = [l.permissionMode, ...(l.model ? [l.model] : [])].join(", ");
|
|
183
205
|
console.log(`${c.bold(l.channel)} → ${l.repo} ${c.dim(`(${flags})`)}`);
|
|
184
206
|
}
|
|
185
207
|
return 0;
|
|
186
208
|
}
|
|
187
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). A turn whose child reached a
|
|
367
|
+
// SUCCESSFUL result is also terminal even when failed (that failure is
|
|
368
|
+
// Slack delivery, not a killed child - resuming would re-run completed
|
|
369
|
+
// work; adversarial-review catch). null outcome = relay threw =
|
|
370
|
+
// still presumed killed.
|
|
371
|
+
const presumedKilled = draining && (outcome === null || (outcome.failed && !outcome.announcedDrop && !outcome.resultReceived));
|
|
372
|
+
// An unannounced drop OUTSIDE a drain still clears the marker on
|
|
373
|
+
// purpose (retention would re-execute the turn at the next restart; see
|
|
374
|
+
// notifyDelivered's doc) - but the loss must be operator-visible.
|
|
375
|
+
if (!draining && outcome !== null && outcome.failed && outcome.rateLimited && !outcome.announcedDrop) {
|
|
376
|
+
log("serve.drop_unannounced", { thread: input.thread.id });
|
|
377
|
+
}
|
|
378
|
+
saveSlackThread(presumedKilled ? record : omit(record, ["activeTurn"]));
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const handleTurn = async (input: {
|
|
383
|
+
thread: ServeThread;
|
|
384
|
+
/** every relayed message this turn (queue-skipped + triggering), text
|
|
385
|
+
* paired with its author id: a decision may be owed to an earlier
|
|
386
|
+
* folded sender, and a sender whose whole message was the bot mention
|
|
387
|
+
* contributes no prompt text, so text and author filter together
|
|
388
|
+
* (review catches 2026-07-18). */
|
|
389
|
+
relayed: { text: string; authorId: string }[];
|
|
390
|
+
isMention: boolean;
|
|
391
|
+
}) => {
|
|
392
|
+
const { thread, isMention } = input;
|
|
393
|
+
const link = linkForChannel(cfg, bareChannelId(thread.channelId));
|
|
394
|
+
if (!link) {
|
|
395
|
+
// checked BEFORE the draining branch: unlinked channels are
|
|
396
|
+
// contractually log-only silent, and a drain-window drop notice posted
|
|
397
|
+
// into one would tell a user to resend a message that will never be
|
|
398
|
+
// served (closing-review catch).
|
|
399
|
+
log("serve.unlinked_channel", { channel: thread.channelId });
|
|
400
|
+
return; // not a linked channel - stay silent in Slack
|
|
401
|
+
}
|
|
402
|
+
if (draining) {
|
|
403
|
+
// the socket stays connected until the drain finishes; anything landing
|
|
404
|
+
// in that window is dropped loudly rather than spawning an unwaitable
|
|
405
|
+
// turn - and the THREAD is told, not just the log (a silent drop reads
|
|
406
|
+
// as the bot thinking; slaude's recorded drop-notice rule). Tracked so
|
|
407
|
+
// the drain wait flushes it before exit; errors swallowed so the notice
|
|
408
|
+
// can never fail the drain.
|
|
409
|
+
log("serve.drain_dropped", { thread: thread.id });
|
|
410
|
+
void tracked(
|
|
411
|
+
(async () => {
|
|
412
|
+
try {
|
|
413
|
+
await thread.post(
|
|
414
|
+
(async function* () {
|
|
415
|
+
yield "tokenmaxxing is restarting - this message was dropped; please re-send it in a moment.";
|
|
416
|
+
})(),
|
|
417
|
+
);
|
|
418
|
+
} catch (e) {
|
|
419
|
+
// caught (never rethrown - the notice must not fail the drain)
|
|
420
|
+
// but logged: an unposted notice means the user saw nothing.
|
|
421
|
+
log("serve.drain_notice_failed", { thread: thread.id, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
422
|
+
}
|
|
423
|
+
})(),
|
|
424
|
+
);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
log("serve.message", { thread: thread.id, isMention, texts: input.relayed.length });
|
|
428
|
+
// relayed carries queue-skipped messages plus the triggering one: the
|
|
429
|
+
// queue strategy hands a turn only the LATEST message and the rest via
|
|
430
|
+
// context.skipped, so they are folded into one prompt here. A message
|
|
431
|
+
// that is empty once its bot mention is stripped contributes neither
|
|
432
|
+
// prompt text nor a requester id (cursor review catch 2026-07-18).
|
|
433
|
+
const stripped = input.relayed
|
|
434
|
+
.map((m) => ({ text: stripLeadingMention({ text: m.text, botUserId: seam.botUserId() }), authorId: m.authorId }))
|
|
435
|
+
.filter((m) => m.text !== "");
|
|
436
|
+
const prompt = stripped.map((m) => m.text).join("\n\n");
|
|
437
|
+
const requesterIds = uniq(stripped.map((m) => m.authorId));
|
|
438
|
+
if (!prompt) return;
|
|
439
|
+
// this whole handler runs inside the per-thread `serialized` chain (call
|
|
440
|
+
// sites below), which startup resumes share too - so this load already
|
|
441
|
+
// sees any session id a resume persisted, and no second claude process
|
|
442
|
+
// can ever share this thread's cwd.
|
|
443
|
+
let record = loadSlackThread(thread.id);
|
|
444
|
+
if (!record) {
|
|
445
|
+
if (!isMention) return; // only a mention opens a session
|
|
446
|
+
record = { threadId: thread.id, repo: link.repo, cwd: link.repo, sessionId: null, createdAt: new Date().toISOString() };
|
|
447
|
+
saveSlackThread(record);
|
|
448
|
+
log("serve.thread_opened", { thread: thread.id, cwd: link.repo });
|
|
449
|
+
}
|
|
450
|
+
// Inside the serialized chain a marker can only be a PREVIOUS
|
|
451
|
+
// generation's killed turn (this generation's turns clear theirs before
|
|
452
|
+
// releasing the chain, and the serve-lock keeps generations exclusive):
|
|
453
|
+
// an inbound message can win the chain ahead of startup recovery (e.g.
|
|
454
|
+
// Slack redelivering the killed turn's unacked mention), and runTurn's
|
|
455
|
+
// fresh marker would silently discard the orphan's pid identity - reap it
|
|
456
|
+
// here so two claude processes never share the thread's cwd and session
|
|
457
|
+
// (closing-review catch, the recovery-path reap alone loses this race).
|
|
458
|
+
if (record.activeTurn) await reapOrphan(record.activeTurn);
|
|
459
|
+
// subscriptions live in the memory state, so a daemon restart forgets
|
|
460
|
+
// them; every mention re-subscribes to keep follow-up replies flowing.
|
|
461
|
+
if (isMention) await thread.subscribe();
|
|
462
|
+
// "is working..." assistant status; a no-op until the Slack app has the
|
|
463
|
+
// agent feature + assistant:write (the adapter warns instead of throwing).
|
|
464
|
+
await thread.startTyping();
|
|
465
|
+
const startedAt = Date.now();
|
|
466
|
+
const outcome = await runTurn({
|
|
467
|
+
thread,
|
|
468
|
+
record,
|
|
469
|
+
prompt,
|
|
470
|
+
requesterIds,
|
|
471
|
+
sessionId: record.sessionId,
|
|
472
|
+
marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0 },
|
|
473
|
+
link,
|
|
474
|
+
});
|
|
475
|
+
await settleTurn({ thread, outcome, startedAt });
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
/** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
|
|
479
|
+
* log line, and the finish_thread garbage collection when the model called
|
|
480
|
+
* it. Never throws into the caller - the daemon must keep serving. */
|
|
481
|
+
const settleTurn = async (input: {
|
|
482
|
+
thread: { id: string; post: (m: string | AsyncIterable<string | StreamChunk>) => Promise<unknown>; unsubscribe: () => Promise<void> };
|
|
483
|
+
outcome: TurnOutcome;
|
|
484
|
+
startedAt: number;
|
|
485
|
+
}) => {
|
|
486
|
+
const { thread, outcome, startedAt } = input;
|
|
487
|
+
log(outcome.failed ? "serve.turn_failed" : "serve.turn_done", {
|
|
488
|
+
thread: thread.id,
|
|
489
|
+
seconds: Math.round((Date.now() - startedAt) / 1000),
|
|
490
|
+
});
|
|
491
|
+
// the user declared the work finished: close the thread now that the
|
|
492
|
+
// turn (and its claude subprocess) is over. Never throw into the caller -
|
|
493
|
+
// the daemon must keep serving other threads.
|
|
494
|
+
if (!outcome.finish) return;
|
|
495
|
+
let result: CleanupOutcome;
|
|
496
|
+
try {
|
|
497
|
+
result = seam.cleanup({ threadId: thread.id });
|
|
498
|
+
} catch (e) {
|
|
499
|
+
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
500
|
+
log("serve.cleanup_error", { thread: thread.id, err: detail });
|
|
501
|
+
try {
|
|
502
|
+
await thread.post(`tokenmaxxing: cleanup failed: ${detail}`);
|
|
503
|
+
} catch (postErr) {
|
|
504
|
+
// the diagnostic is best-effort, but its failure is never silent.
|
|
505
|
+
log("serve.finish_notify_error", { thread: thread.id, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
|
|
506
|
+
}
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
// the Slack calls are guarded too: this whole path must never throw into
|
|
510
|
+
// the caller (review catch, PR #18) - the record is already gone, so a
|
|
511
|
+
// failed confirmation only gets logged.
|
|
512
|
+
try {
|
|
513
|
+
// a refusal keeps the subscription so the thread stays live for a retry.
|
|
514
|
+
if (result.removed) await thread.unsubscribe();
|
|
515
|
+
await thread.post(result.message);
|
|
516
|
+
} catch (e) {
|
|
517
|
+
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
518
|
+
log("serve.finish_notify_error", { thread: thread.id, err: detail });
|
|
519
|
+
}
|
|
520
|
+
log("serve.thread_finished", { thread: thread.id, removed: result.removed });
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const relayable = (m: ServeMessage) => {
|
|
524
|
+
if (m.author.isMe || m.author.isBot === true) return false;
|
|
525
|
+
// outsiders must not drive sessions (owner rule 2026-07-16, ported from
|
|
526
|
+
// slaude): Slack Connect externals and cross-workspace guests are
|
|
527
|
+
// rejected fail-closed - silent in Slack, loud in the log.
|
|
528
|
+
if (isOutsideAuthor({ raw: m.raw, workspaceTeamId })) {
|
|
529
|
+
log("serve.outside_author", {});
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
return true;
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const tracked = async (turn: Promise<void>) => {
|
|
536
|
+
activeTurns.add(turn);
|
|
537
|
+
try {
|
|
538
|
+
await turn;
|
|
539
|
+
} finally {
|
|
540
|
+
activeTurns.delete(turn);
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
// Per-thread serialization owned HERE, not only by the SDK's queue lock:
|
|
545
|
+
// chat 4.34.0 acquires the dispatch lock with a 30s TTL and extends it only
|
|
546
|
+
// BETWEEN dispatches, so any handler outliving 30s (every claude turn, any
|
|
547
|
+
// depleted-pool park) lets a later message take the expired lock and start
|
|
548
|
+
// a second concurrent handler in the same thread and cwd (review catch,
|
|
549
|
+
// PR #18). Escaped messages run as sequential turns in arrival order.
|
|
550
|
+
const threadTurns = new Map<string, Promise<void>>();
|
|
551
|
+
const serialized = (threadId: string, run: () => Promise<void>) => {
|
|
552
|
+
const prev = threadTurns.get(threadId) ?? Promise.resolve();
|
|
553
|
+
const next = (async () => {
|
|
554
|
+
try {
|
|
555
|
+
await prev;
|
|
556
|
+
} catch { /* the previous turn's rejection was already surfaced to its own handler */ }
|
|
557
|
+
await run();
|
|
558
|
+
})();
|
|
559
|
+
threadTurns.set(threadId, next);
|
|
560
|
+
// GC observer: swallow next's rejection HERE only (the handler awaiting
|
|
561
|
+
// `next` still sees it), else the observer chain is an unhandled rejection
|
|
562
|
+
// (cubic review catch, PR #18).
|
|
563
|
+
void (async () => {
|
|
564
|
+
try {
|
|
565
|
+
await next;
|
|
566
|
+
} catch { /* surfaced to the awaiting handler */ }
|
|
567
|
+
if (threadTurns.get(threadId) === next) threadTurns.delete(threadId);
|
|
568
|
+
})();
|
|
569
|
+
return next;
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// both Chat SDK callbacks funnel here. Filter EVERY message, trigger
|
|
573
|
+
// included: an outsider (or our own post) arriving last must not discard
|
|
574
|
+
// relayable home-workspace messages queued behind the turn - the queue hands
|
|
575
|
+
// the handler only the latest message and the rest ride context.skipped
|
|
576
|
+
// (review catch, PR #18).
|
|
577
|
+
const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
|
|
578
|
+
const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId }));
|
|
579
|
+
if (relayed.length === 0) return; // outsider mentions never open a session
|
|
580
|
+
await tracked(serialized(input.thread.id, () => handleTurn({ thread: input.thread, relayed, isMention: input.isMention })));
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/** Recover one thread whose activeTurn marker survived the previous daemon:
|
|
584
|
+
* a restart killed that turn mid-run. Notify the thread, then resume the
|
|
585
|
+
* session (or replay the original prompt when the kill landed before init
|
|
586
|
+
* assigned a session id); past the retry cap, give up loudly. EVERY branch
|
|
587
|
+
* runs inside the shared per-thread `serialized` chain and recomputes the
|
|
588
|
+
* decision from a fresh reload there: an inbound turn (or Slack
|
|
589
|
+
* redelivering the killed turn's unacked mention) can win the chain first,
|
|
590
|
+
* handle the thread, and clear the marker - acting on the startup snapshot
|
|
591
|
+
* would then re-run superseded work and write stale record fields over the
|
|
592
|
+
* session id that turn persisted (adversarial-review catch). Lives in the
|
|
593
|
+
* seam with `streamable` INJECTED (the daemon passes its bot-backed handle
|
|
594
|
+
* builder, tests a fake) so that superseded-recovery race is pinnable
|
|
595
|
+
* (closing-review catch: the invariant had no test while inline). */
|
|
596
|
+
const recoverInterrupted = async (
|
|
597
|
+
record: SlackThread,
|
|
598
|
+
streamable: (threadId: string) => Promise<{ thread: ServeThread; requesterIds: string[] }>,
|
|
599
|
+
) => {
|
|
600
|
+
try {
|
|
601
|
+
const { thread, requesterIds } = await streamable(record.threadId);
|
|
602
|
+
const link = linkForChannel(cfg, bareChannelId(thread.channelId));
|
|
603
|
+
await serialized(record.threadId, async () => {
|
|
604
|
+
// a drain signal can land between the scan and this turn; leave the
|
|
605
|
+
// marker at its previous count so the next start retries.
|
|
606
|
+
if (draining) return;
|
|
607
|
+
const fresh = loadSlackThread(record.threadId);
|
|
608
|
+
const turn = fresh?.activeTurn;
|
|
609
|
+
const decision = fresh ? resumeDecision(fresh) : null;
|
|
610
|
+
if (!fresh || !turn || !decision) return; // superseded: an earlier turn already cleared the marker
|
|
611
|
+
await reapOrphan(turn);
|
|
612
|
+
if (!link) {
|
|
613
|
+
// unlinked since the turn started: nothing can run here; drop the
|
|
614
|
+
// marker and stay silent, like every unlinked-channel path.
|
|
615
|
+
saveSlackThread(omit(fresh, ["activeTurn"]));
|
|
616
|
+
log("serve.resume_unlinked", { thread: record.threadId });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (decision.kind === "give-up") {
|
|
620
|
+
log("serve.resume_gave_up", { thread: record.threadId });
|
|
621
|
+
// post BEFORE clearing, mirroring the resume branch's ordering: a
|
|
622
|
+
// kill or post failure here leaves the marker for the next restart
|
|
623
|
+
// to retry the notice (at-least-once; worst case a duplicate
|
|
624
|
+
// give-up notice), instead of the thread going permanently dark
|
|
625
|
+
// with the user never told the daemon gave up.
|
|
626
|
+
await thread.post(decision.notice);
|
|
627
|
+
saveSlackThread(omit(fresh, ["activeTurn"]));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
log("serve.resume_interrupted", { thread: record.threadId, attempt: decision.marker.resumeCount });
|
|
631
|
+
// the notice posts BEFORE runTurn persists the incremented marker,
|
|
632
|
+
// on purpose: the cap bounds quota-SPENDING attempts (the spawn),
|
|
633
|
+
// and a failed notice spends nothing - the marker survives at its
|
|
634
|
+
// old count for the next restart to retry, at most once per
|
|
635
|
+
// operator-triggered restart, each logged as serve.resume_error.
|
|
636
|
+
// A permanently unpostable channel (bot kicked, archived) therefore
|
|
637
|
+
// retries on every restart; unlinking it clears the marker.
|
|
638
|
+
await thread.post(decision.notice);
|
|
639
|
+
const startedAt = Date.now();
|
|
640
|
+
const outcome = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
|
|
641
|
+
await settleTurn({ thread, outcome, startedAt });
|
|
642
|
+
});
|
|
643
|
+
} catch (e) {
|
|
644
|
+
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
645
|
+
log("serve.resume_error", { thread: record.threadId, err: detail });
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
/** in-flight turn promises; shutdown drains them. */
|
|
651
|
+
activeTurns,
|
|
652
|
+
isDraining: () => draining,
|
|
653
|
+
/** stop taking new turns and wake parked/retrying ones. */
|
|
654
|
+
beginDrain: () => {
|
|
655
|
+
draining = true;
|
|
656
|
+
drainAbort.abort();
|
|
657
|
+
},
|
|
658
|
+
relayable,
|
|
659
|
+
onMessage,
|
|
660
|
+
/** the pieces runDaemon's startup interrupted-turn recovery reuses, so a
|
|
661
|
+
* resumed turn shares the exact chain and marker machinery of an inbound
|
|
662
|
+
* one. */
|
|
663
|
+
serialized,
|
|
664
|
+
tracked,
|
|
665
|
+
runTurn,
|
|
666
|
+
settleTurn,
|
|
667
|
+
recoverInterrupted,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
188
671
|
async function runDaemon(): Promise<number> {
|
|
189
|
-
|
|
672
|
+
let cfg = loadSlackConfig();
|
|
190
673
|
if (!cfg) {
|
|
191
674
|
printSetupInstructions();
|
|
192
675
|
return 1;
|
|
@@ -195,6 +678,30 @@ async function runDaemon(): Promise<number> {
|
|
|
195
678
|
console.error(c.red("no channel links - run `tokenmaxxing serve link <channel-id> <repo>` first"));
|
|
196
679
|
return 1;
|
|
197
680
|
}
|
|
681
|
+
// the external-author guard compares every message's origin against the home
|
|
682
|
+
// workspace. The reference is re-captured from the live token at EVERY start
|
|
683
|
+
// (a stale persisted id would fail-closed reject the owner's own messages);
|
|
684
|
+
// a failed capture fails the daemon fast - the guard never runs
|
|
685
|
+
// reference-less, and the daemon is useless without Slack reachable anyway.
|
|
686
|
+
const workspaceTeamId = await fetchWorkspaceTeamId({ botToken: cfg.botToken });
|
|
687
|
+
if (cfg.workspaceTeamId !== workspaceTeamId) {
|
|
688
|
+
cfg = { ...cfg, workspaceTeamId };
|
|
689
|
+
saveSlackConfig(cfg);
|
|
690
|
+
log("serve.team_captured", { team: workspaceTeamId });
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// every log() event from here on (serve.* plus the in-process swap/decision
|
|
694
|
+
// events fired by ensureBestAccount/stopHookCheck) also prints to the
|
|
695
|
+
// terminal, so a foreground `xx serve` shows what it is doing live.
|
|
696
|
+
setLogEcho({ printer: (entry) => console.log(formatLogLine(entry)) });
|
|
697
|
+
|
|
698
|
+
// single-instance guard, held for the process lifetime (the fd releases on
|
|
699
|
+
// exit): without it a replacement daemon can start inside the previous
|
|
700
|
+
// generation's drain window, read a still-RUNNING turn's activeTurn marker,
|
|
701
|
+
// and resume it - two claude processes in one cwd (adversarial-review
|
|
702
|
+
// catch). Blocking here makes a rolling restart wait out the drain instead.
|
|
703
|
+
console.log(c.dim("acquiring the serve singleton lock (waits for a draining daemon to exit)"));
|
|
704
|
+
await acquireLock(paths.serveLockFile);
|
|
198
705
|
|
|
199
706
|
const slack = createSlackAdapter({
|
|
200
707
|
mode: "socket",
|
|
@@ -222,95 +729,121 @@ async function runDaemon(): Promise<number> {
|
|
|
222
729
|
adapters: { slack },
|
|
223
730
|
state,
|
|
224
731
|
// per-thread lock with queueing: a message landing mid-turn waits its turn
|
|
225
|
-
// instead of racing a second claude spawn on the same cwd.
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
732
|
+
// instead of racing a second claude spawn on the same cwd. Queue-entry TTL
|
|
733
|
+
// expiry is SILENT (chat 4.34.0 has no app callback for it), so the TTL
|
|
734
|
+
// must outlast the longest legitimate hold: a depleted-pool park
|
|
735
|
+
// (PARK_MAX_MS 14min) plus a long claude turn. Expired-and-folded beats
|
|
736
|
+
// silently-vanished, hence a full hour. maxQueueSize matters for the same
|
|
737
|
+
// reason: the SDK default is 10 with a LOG-LESS drop-oldest trim
|
|
738
|
+
// (@chat-adapter/state-memory enqueue splices the front, and the SDK's
|
|
739
|
+
// message-dropped log only fires for drop-newest), so a >10-message burst
|
|
740
|
+
// behind one long turn silently ate the oldest instructions
|
|
741
|
+
// (adversarial-review catch). 100 outlasts any legitimate burst; the TTL
|
|
742
|
+
// stays the real bound.
|
|
743
|
+
concurrency: { strategy: "queue", queueEntryTtlMs: 3_600_000, maxQueueSize: 100 },
|
|
229
744
|
// without this a cards-only segment in post-and-edit fallback would
|
|
230
745
|
// strand a bare "..." placeholder message.
|
|
231
746
|
fallbackStreamingPlaceholderText: null,
|
|
232
747
|
logger: "warn",
|
|
233
748
|
});
|
|
234
749
|
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
750
|
+
// the message-handling runtime (author guard, folding, per-thread
|
|
751
|
+
// serialization, activeTurn markers, drain drops, finish close-out) lives
|
|
752
|
+
// in buildServeRuntime so tests can drive the real wiring; production deps
|
|
753
|
+
// go in here.
|
|
754
|
+
const runtime = buildServeRuntime({
|
|
755
|
+
cfg,
|
|
756
|
+
workspaceTeamId,
|
|
757
|
+
botUserId: () => slack.botUserId ?? null,
|
|
758
|
+
relay: relayThread,
|
|
759
|
+
cleanup: cleanupThread,
|
|
760
|
+
});
|
|
240
761
|
|
|
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
|
-
if (!record) {
|
|
270
|
-
if (!isMention) return; // only a mention opens a session
|
|
271
|
-
const cwd = ensureThreadCwd({ link, threadId: thread.id });
|
|
272
|
-
record = { threadId: thread.id, repo: link.repo, cwd, sessionId: null, createdAt: new Date().toISOString() };
|
|
273
|
-
saveSlackThread(record);
|
|
274
|
-
log("serve.thread_opened", { thread: thread.id, cwd });
|
|
275
|
-
}
|
|
276
|
-
// subscriptions live in the memory state, so a daemon restart forgets
|
|
277
|
-
// them; every mention re-subscribes to keep follow-up replies flowing.
|
|
278
|
-
if (isMention) await thread.subscribe();
|
|
279
|
-
// "is working..." assistant status; a no-op until the Slack app has the
|
|
280
|
-
// agent feature + assistant:write (the adapter warns instead of throwing).
|
|
281
|
-
await thread.startTyping();
|
|
282
|
-
const outcome = await relayThread({
|
|
283
|
-
cwd: record.cwd,
|
|
284
|
-
sessionId: record.sessionId,
|
|
285
|
-
prompt,
|
|
286
|
-
link,
|
|
287
|
-
post: (m) => thread.post(m),
|
|
288
|
-
});
|
|
289
|
-
if (outcome.sessionId !== record.sessionId) {
|
|
290
|
-
saveSlackThread({ ...record, sessionId: outcome.sessionId });
|
|
762
|
+
/** A proactive thread handle that can still stream natively. bot.thread()
|
|
763
|
+
* carries no currentMessage, and without one handleStream has no
|
|
764
|
+
* recipientUserId/recipientTeamId, so the Slack adapter's stream() gate
|
|
765
|
+
* falls back to card-less post-and-edit that also strands a blank message
|
|
766
|
+
* per card-only segment (verified in chat 4.34.0 + @chat-adapter/slack).
|
|
767
|
+
* Reusing the newest human message in the thread as currentMessage
|
|
768
|
+
* restores the exact context an inbound turn would have, and its author is
|
|
769
|
+
* the natural requester to tag on a resumed turn. The explicit ThreadImpl
|
|
770
|
+
* constructor (published typed API) is deliberate over the prose-documented
|
|
771
|
+
* ThreadImpl.fromJSON/reviver restore path: fromJSON takes the lazy config
|
|
772
|
+
* branch, which silently reverts fallbackStreamingPlaceholderText to "..."
|
|
773
|
+
* (re-stranding the placeholder this daemon suppresses) and needs
|
|
774
|
+
* registerSingleton for state. */
|
|
775
|
+
const streamableThread = async (threadId: string) => {
|
|
776
|
+
const handle = bot.thread(threadId);
|
|
777
|
+
for await (const message of handle.messages) {
|
|
778
|
+
if (runtime.relayable(message)) {
|
|
779
|
+
const thread = new ThreadImpl({
|
|
780
|
+
adapter: slack,
|
|
781
|
+
stateAdapter: state,
|
|
782
|
+
channelId: handle.channelId,
|
|
783
|
+
id: threadId,
|
|
784
|
+
isDM: false,
|
|
785
|
+
currentMessage: message,
|
|
786
|
+
fallbackStreamingPlaceholderText: null,
|
|
787
|
+
});
|
|
788
|
+
return { thread, requesterIds: [message.author.userId] };
|
|
789
|
+
}
|
|
291
790
|
}
|
|
791
|
+
// no human message on record: card-less is all there is
|
|
792
|
+
return { thread: handle, requesterIds: [] };
|
|
292
793
|
};
|
|
293
794
|
|
|
294
|
-
|
|
795
|
+
bot.onNewMention(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: true }));
|
|
796
|
+
bot.onSubscribedMessage(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: false }));
|
|
295
797
|
|
|
296
|
-
|
|
297
|
-
|
|
798
|
+
// drain instead of dying mid-answer: stop taking new turns, let in-flight
|
|
799
|
+
// ones finish (bounded - a hung claude turn must not block a restart
|
|
800
|
+
// forever), then disconnect. A second signal forces an immediate exit.
|
|
801
|
+
// Registered BEFORE initialize() (post-0.19.1 review catch): the socket
|
|
802
|
+
// goes live inside initialize, so a turn could start while runDaemon was
|
|
803
|
+
// still suspended there and a signal in that window hit default
|
|
804
|
+
// disposition - an instant kill with no drain.
|
|
805
|
+
const DRAIN_MS = 300_000;
|
|
806
|
+
const shutdown = async (signal: string) => {
|
|
807
|
+
if (runtime.isDraining()) {
|
|
808
|
+
log("serve.forced_exit", { signal });
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
runtime.beginDrain(); // parked/retrying turns wake, post their drop notice, and finish
|
|
812
|
+
log("serve.draining", { signal, turns: runtime.activeTurns.size });
|
|
813
|
+
console.log(`${c.yellow("●")} ${signal}: draining ${count({ n: runtime.activeTurns.size, noun: "in-flight turn" })} (again to force)`);
|
|
814
|
+
// re-snapshot until stable inside the deadline: drain-window drop notices
|
|
815
|
+
// join activeTurns after the first snapshot and must still flush.
|
|
816
|
+
const deadline = Date.now() + DRAIN_MS;
|
|
817
|
+
while (runtime.activeTurns.size > 0 && Date.now() < deadline) {
|
|
818
|
+
await Promise.race([Promise.allSettled([...runtime.activeTurns]), delay(deadline - Date.now())]);
|
|
819
|
+
}
|
|
298
820
|
try {
|
|
299
|
-
await
|
|
300
|
-
}
|
|
301
|
-
|
|
821
|
+
await bot.shutdown();
|
|
822
|
+
} catch (e) {
|
|
823
|
+
// exit must be reached even when the socket teardown rejects; the
|
|
824
|
+
// second-signal force path must not be the only escape.
|
|
825
|
+
log("serve.shutdown_error", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
302
826
|
}
|
|
827
|
+
log("serve.stopped", { dropped: runtime.activeTurns.size });
|
|
828
|
+
process.exit(0);
|
|
303
829
|
};
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
830
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
831
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
832
|
+
// closing the foreground terminal sends SIGHUP, whose default disposition
|
|
833
|
+
// kills the daemon WITHOUT the "exit" event - so the process-exit hook that
|
|
834
|
+
// kill-groups the DETACHED claude child never runs, the child survives as
|
|
835
|
+
// an orphan still mutating the cwd, and the freed serve-lock lets the next
|
|
836
|
+
// generation resume the same session beside it (adversarial-review catch,
|
|
837
|
+
// exit-skip verified empirically on Bun; a mid-turn orphan does NOT die on
|
|
838
|
+
// its dead stdout pipe - also verified - which is why the reaper exists).
|
|
839
|
+
// Draining instead keeps the child owned; its Slack streaming needs no
|
|
840
|
+
// tty, so the turn can even finish. A SIGKILL/crash orphan is covered by
|
|
841
|
+
// reapOrphan via the marker's pid identity; the only unmarked window is
|
|
842
|
+
// spawn-to-persist, both inside the spawn hook BEFORE the SDK writes the
|
|
843
|
+
// prompt, and a prompt-less orphan exits on its dead stdin's EOF (verified
|
|
844
|
+
// against the real claude binary in the SDK's exact stdio shape: dead in
|
|
845
|
+
// 2s, zero API calls).
|
|
846
|
+
process.on("SIGHUP", () => void shutdown("SIGHUP"));
|
|
314
847
|
|
|
315
848
|
// initialize() starts the PERSISTENT Socket Mode client (auto-reconnecting)
|
|
316
849
|
// wired straight into event routing; the daemon only has to stay alive.
|
|
@@ -329,25 +862,15 @@ async function runDaemon(): Promise<number> {
|
|
|
329
862
|
for (const record of records) await state.subscribe(record.threadId);
|
|
330
863
|
log("serve.resubscribed", { threads: records.length });
|
|
331
864
|
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
draining = true;
|
|
342
|
-
log("serve.draining", { signal, turns: activeTurns.size });
|
|
343
|
-
console.log(`${c.yellow("●")} ${signal}: draining ${count({ n: activeTurns.size, noun: "in-flight turn" })} (again to force)`);
|
|
344
|
-
await Promise.race([Promise.allSettled([...activeTurns]), delay(DRAIN_MS)]);
|
|
345
|
-
await bot.shutdown();
|
|
346
|
-
log("serve.stopped", { dropped: activeTurns.size });
|
|
347
|
-
process.exit(0);
|
|
348
|
-
};
|
|
349
|
-
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
350
|
-
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
865
|
+
// threads whose activeTurn marker survived the previous daemon were killed
|
|
866
|
+
// mid-turn by a restart (live incident 2026-07-18: a redeploy silently
|
|
867
|
+
// killed a ship turn 8 minutes in and the thread just went dark). Each gets
|
|
868
|
+
// a notice and an auto-resumed turn, tracked so a drain waits for them too;
|
|
869
|
+
// the actionable decision is recomputed under the per-thread lock inside.
|
|
870
|
+
for (const record of records) {
|
|
871
|
+
if (!record.activeTurn) continue;
|
|
872
|
+
void runtime.tracked(runtime.recoverInterrupted(record, streamableThread));
|
|
873
|
+
}
|
|
351
874
|
|
|
352
875
|
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)`);
|
|
353
876
|
log("serve.started", { links: cfg.links.length });
|