tokenmaxxing 1.6.0 → 1.7.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 +2 -30
- package/LICENSE +21 -0
- package/README.md +1 -2
- package/agent-plugin/bin/tokenmaxxing-mcp +7 -0
- package/agent-plugin/mcp.json +10 -0
- package/agent-plugin/plugin.json +14 -0
- package/agent-plugin/skills/codex-pool/SKILL.md +23 -0
- package/agent-plugin/skills/codex-pool/references/codex.md +5 -0
- package/agent-plugin/skills/credentials-hygiene/SKILL.md +26 -0
- package/agent-plugin/skills/credentials-hygiene/references/credentials.md +6 -0
- package/agent-plugin/skills/doctor-diagnostics/SKILL.md +26 -0
- package/agent-plugin/skills/doctor-diagnostics/references/troubleshooting.md +5 -0
- package/agent-plugin/skills/pool-status/SKILL.md +27 -0
- package/agent-plugin/skills/pool-status/references/commands.md +8 -0
- package/agent-plugin/skills/safe-contribution/SKILL.md +27 -0
- package/agent-plugin/skills/safe-contribution/references/ship.md +5 -0
- package/agent-plugin/skills/sdk-pairing/SKILL.md +33 -0
- package/agent-plugin/skills/sdk-pairing/references/sdk.md +6 -0
- package/agent-plugin/skills/switching-policy/SKILL.md +29 -0
- package/agent-plugin/skills/switching-policy/references/policy.md +7 -0
- package/package.json +3 -5
- package/src/entries/mcp.ts +288 -0
- package/src/lib/decide.ts +2 -4
- package/src/lib/lock.ts +3 -7
- package/src/lib/log.ts +8 -11
- package/src/lib/paths.ts +0 -9
- package/src/lib/usage.ts +6 -5
- package/src/main.ts +1 -6
- package/src/cli/serve.ts +0 -1790
- package/src/lib/slackbridge.ts +0 -1363
- package/src/lib/slackstate.ts +0 -352
- package/src/lib/slackstream.ts +0 -300
- package/src/serve-plugin/.claude-plugin/plugin.json +0 -4
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +0 -41
- package/src/serve-plugin/skills/serve-session/SKILL.md +0 -50
package/src/cli/serve.ts
DELETED
|
@@ -1,1790 +0,0 @@
|
|
|
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 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).
|
|
11
|
-
//
|
|
12
|
-
// serve setup print the app manifest + prompt for the two tokens
|
|
13
|
-
// serve link <ch> <repo> [--dangerous] [--model <m>]
|
|
14
|
-
// serve unlink <ch> remove a link
|
|
15
|
-
// serve links list links
|
|
16
|
-
// serve run the daemon
|
|
17
|
-
|
|
18
|
-
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
19
|
-
import { join } from "node:path";
|
|
20
|
-
import { delay, omit, uniq } from "es-toolkit";
|
|
21
|
-
import { z } from "zod";
|
|
22
|
-
import { Chat, ConsoleLogger, StreamingPlan, ThreadImpl, type StreamChunk } from "chat";
|
|
23
|
-
import { createSlackAdapter } from "@chat-adapter/slack";
|
|
24
|
-
import { MemoryStateAdapter } from "@chat-adapter/state-memory";
|
|
25
|
-
import {
|
|
26
|
-
bareChannelId,
|
|
27
|
-
isChannelId,
|
|
28
|
-
isOutsideAuthor,
|
|
29
|
-
linkForChannel,
|
|
30
|
-
listSlackThreads,
|
|
31
|
-
loadSlackConfig,
|
|
32
|
-
loadSlackThread,
|
|
33
|
-
removeLink,
|
|
34
|
-
resumeDecision,
|
|
35
|
-
saveSlackConfig,
|
|
36
|
-
saveSlackThread,
|
|
37
|
-
stripLeadingMention,
|
|
38
|
-
upsertLink,
|
|
39
|
-
SlackLinkSchema,
|
|
40
|
-
SlackThreadSchema,
|
|
41
|
-
type ActiveTurn,
|
|
42
|
-
type SlackConfig,
|
|
43
|
-
type SlackLink,
|
|
44
|
-
type SlackThread,
|
|
45
|
-
} from "../lib/slackstate.ts";
|
|
46
|
-
import { cleanupThread, fetchWorkspaceTeamId, killGroup, relayThread, type CleanupOutcome, type TurnOutcome } from "../lib/slackbridge.ts";
|
|
47
|
-
import { ensureBestAccount, type SwapDecision } from "../sdk.ts";
|
|
48
|
-
import { pidStartTime } from "../lib/proc.ts";
|
|
49
|
-
import { acquireLock } from "../lib/lock.ts";
|
|
50
|
-
import { paths } from "../lib/paths.ts";
|
|
51
|
-
import { log, setLogEcho } from "../lib/log.ts";
|
|
52
|
-
import { c, count } from "./render.ts";
|
|
53
|
-
|
|
54
|
-
const SERVE_USAGE = "usage: tokenmaxxing serve [setup | link <channel-id> <repo> [--yolo | --dangerous] [--model <m>] | unlink <channel-id> | links]";
|
|
55
|
-
|
|
56
|
-
/** The manifest the user pastes at api.slack.com/apps > From an app manifest.
|
|
57
|
-
* Scopes/events verified against docs.slack.dev 2026-07-18: a channel-thread
|
|
58
|
-
* relay plus Slack's Agent messaging experience (agent_view + assistant:write
|
|
59
|
-
* power the DM assistant surface and typing status; channel-thread streaming
|
|
60
|
-
* works without them, verified live). agent_view is an OBJECT whose required
|
|
61
|
-
* field is agent_description (max 300 chars; docs.slack.dev app-manifest
|
|
62
|
-
* reference, re-verified 2026-07-20) - a bare `agent_view: true` is rejected
|
|
63
|
-
* with "Must provide an object". reactions:write powers the status reactions
|
|
64
|
-
* the daemon sets on triggering messages; reactions:read + the reaction_added
|
|
65
|
-
* event feed user reactions back in (matching @chat-adapter/slack's own
|
|
66
|
-
* README manifest). Changing scopes on an existing app requires reinstalling
|
|
67
|
-
* it to the workspace. */
|
|
68
|
-
const APP_MANIFEST = `display_information:
|
|
69
|
-
name: tokenmaxxing
|
|
70
|
-
description: bridges Slack threads to Claude Code sessions
|
|
71
|
-
|
|
72
|
-
features:
|
|
73
|
-
agent_view:
|
|
74
|
-
agent_description: bridges Slack threads to Claude Code sessions
|
|
75
|
-
bot_user:
|
|
76
|
-
display_name: tokenmaxxing
|
|
77
|
-
always_online: true
|
|
78
|
-
|
|
79
|
-
oauth_config:
|
|
80
|
-
scopes:
|
|
81
|
-
bot:
|
|
82
|
-
- app_mentions:read
|
|
83
|
-
- assistant:write
|
|
84
|
-
- channels:history
|
|
85
|
-
- groups:history
|
|
86
|
-
- chat:write
|
|
87
|
-
- files:write
|
|
88
|
-
- im:history
|
|
89
|
-
- reactions:read
|
|
90
|
-
- reactions:write
|
|
91
|
-
- users:read
|
|
92
|
-
|
|
93
|
-
settings:
|
|
94
|
-
event_subscriptions:
|
|
95
|
-
bot_events:
|
|
96
|
-
- app_context_changed
|
|
97
|
-
- app_home_opened
|
|
98
|
-
- app_mention
|
|
99
|
-
- message.channels
|
|
100
|
-
- message.groups
|
|
101
|
-
- message.im
|
|
102
|
-
- reaction_added
|
|
103
|
-
socket_mode_enabled: true
|
|
104
|
-
org_deploy_enabled: false
|
|
105
|
-
token_rotation_enabled: false`;
|
|
106
|
-
|
|
107
|
-
function printSetupInstructions(): void {
|
|
108
|
-
console.log(c.bold("Slack app setup (one time)"));
|
|
109
|
-
console.log(`1. Open ${c.cyan("https://api.slack.com/apps")} > Create New App > From an app manifest, pick your workspace, and paste:`);
|
|
110
|
-
console.log();
|
|
111
|
-
console.log(APP_MANIFEST);
|
|
112
|
-
console.log();
|
|
113
|
-
console.log("2. OAuth & Permissions > Install to Workspace, copy the Bot User OAuth Token (xoxb-...).");
|
|
114
|
-
console.log("3. Basic Information > App-Level Tokens > Generate (add the connections:write scope), copy the token (xapp-...).");
|
|
115
|
-
console.log(`4. Run ${c.cyan("tokenmaxxing serve setup")} and paste both tokens, then ${c.cyan("tokenmaxxing serve link <channel-id> <repo>")} and invite the bot to that channel.`);
|
|
116
|
-
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.")}`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function cmdServeSetup(): Promise<number> {
|
|
120
|
-
printSetupInstructions();
|
|
121
|
-
console.log();
|
|
122
|
-
const botToken = prompt("bot token (xoxb-...):")?.trim();
|
|
123
|
-
const appToken = prompt("app token (xapp-...):")?.trim();
|
|
124
|
-
if (!botToken || !appToken) {
|
|
125
|
-
console.error(c.red("both tokens are required - nothing saved"));
|
|
126
|
-
return 1;
|
|
127
|
-
}
|
|
128
|
-
const existing = loadSlackConfig();
|
|
129
|
-
let cfg: SlackConfig;
|
|
130
|
-
try {
|
|
131
|
-
cfg = { botToken, appToken, links: existing?.links ?? [] };
|
|
132
|
-
saveSlackConfig(cfg);
|
|
133
|
-
} catch {
|
|
134
|
-
console.error(c.red("tokens rejected: the bot token must start with xoxb- and the app token with xapp-"));
|
|
135
|
-
return 1;
|
|
136
|
-
}
|
|
137
|
-
// the external-author guard needs the home workspace id; capture it from the
|
|
138
|
-
// token itself so the reference can never drift from the workspace the bot
|
|
139
|
-
// actually lives in (re-captured on every setup: new tokens may belong to a
|
|
140
|
-
// different workspace).
|
|
141
|
-
try {
|
|
142
|
-
cfg = { ...cfg, workspaceTeamId: await fetchWorkspaceTeamId({ botToken }) };
|
|
143
|
-
saveSlackConfig(cfg);
|
|
144
|
-
} catch (e) {
|
|
145
|
-
const detail = e instanceof Error ? e.message : String(e);
|
|
146
|
-
console.error(c.red(`tokens saved, but ${detail} - check the bot token; the daemon re-tries the capture at start`));
|
|
147
|
-
return 1;
|
|
148
|
-
}
|
|
149
|
-
console.log(`${c.green("✓")} saved to slack.json (0600) for workspace ${cfg.workspaceTeamId} with ${count({ n: cfg.links.length, noun: "link" })}`);
|
|
150
|
-
return 0;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function cmdServeLink(argv: string[]): number {
|
|
154
|
-
// yolo mode = the SDK's bypassPermissions; --dangerous is the same switch.
|
|
155
|
-
const dangerous = argv.includes("--yolo") || argv.includes("--dangerous");
|
|
156
|
-
const modelIdx = argv.indexOf("--model");
|
|
157
|
-
const model = modelIdx >= 0 ? argv[modelIdx + 1] : undefined;
|
|
158
|
-
const rest = argv.filter((a, i) => !a.startsWith("--") && (modelIdx < 0 || i !== modelIdx + 1));
|
|
159
|
-
const [channel, repo] = rest;
|
|
160
|
-
if (!channel || !repo) {
|
|
161
|
-
console.error(SERVE_USAGE);
|
|
162
|
-
return 2;
|
|
163
|
-
}
|
|
164
|
-
if (!isChannelId(channel)) {
|
|
165
|
-
console.error(c.red(`"${channel}" is not a Slack channel id (C.../G...). In Slack: right-click the channel > View channel details - the id is at the bottom.`));
|
|
166
|
-
return 1;
|
|
167
|
-
}
|
|
168
|
-
if (!existsSync(repo)) {
|
|
169
|
-
console.error(c.red(`repo path does not exist: ${repo}`));
|
|
170
|
-
return 1;
|
|
171
|
-
}
|
|
172
|
-
const repoReal = realpathSync(repo);
|
|
173
|
-
if (!existsSync(`${repoReal}/.git`)) {
|
|
174
|
-
console.error(c.red(`${repoReal} is not a git repository`));
|
|
175
|
-
return 1;
|
|
176
|
-
}
|
|
177
|
-
const cfg = loadSlackConfig();
|
|
178
|
-
if (!cfg) {
|
|
179
|
-
console.error(c.red("no slack.json yet - run `tokenmaxxing serve setup` first"));
|
|
180
|
-
return 1;
|
|
181
|
-
}
|
|
182
|
-
const link = SlackLinkSchema.parse({
|
|
183
|
-
channel,
|
|
184
|
-
repo: repoReal,
|
|
185
|
-
permissionMode: dangerous ? "bypassPermissions" : "acceptEdits",
|
|
186
|
-
...(model ? { model } : {}),
|
|
187
|
-
});
|
|
188
|
-
saveSlackConfig(upsertLink(cfg, link));
|
|
189
|
-
const flags = [link.permissionMode, ...(model ? [model] : [])].join(", ");
|
|
190
|
-
console.log(`${c.green("✓")} linked ${c.bold(channel)} → ${repoReal} (${flags})`);
|
|
191
|
-
return 0;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function cmdServeUnlink(channel: string | undefined): number {
|
|
195
|
-
if (!channel) {
|
|
196
|
-
console.error(SERVE_USAGE);
|
|
197
|
-
return 2;
|
|
198
|
-
}
|
|
199
|
-
const cfg = loadSlackConfig();
|
|
200
|
-
const next = cfg ? removeLink(cfg, channel) : null;
|
|
201
|
-
if (!next) {
|
|
202
|
-
console.error(c.red(`no link for channel ${channel}`));
|
|
203
|
-
return 1;
|
|
204
|
-
}
|
|
205
|
-
saveSlackConfig(next);
|
|
206
|
-
console.log(`${c.green("✓")} unlinked ${channel}`);
|
|
207
|
-
return 0;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function cmdServeLinks(): number {
|
|
211
|
-
const cfg = loadSlackConfig();
|
|
212
|
-
if (!cfg || cfg.links.length === 0) {
|
|
213
|
-
console.log(c.dim("no channel links - run `tokenmaxxing serve link <channel-id> <repo>`"));
|
|
214
|
-
return 0;
|
|
215
|
-
}
|
|
216
|
-
for (const l of cfg.links) {
|
|
217
|
-
const flags = [l.permissionMode, ...(l.model ? [l.model] : [])].join(", ");
|
|
218
|
-
console.log(`${c.bold(l.channel)} → ${l.repo} ${c.dim(`(${flags})`)}`);
|
|
219
|
-
}
|
|
220
|
-
return 0;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Event-name endings that pick the terminal paint: red for failures, yellow
|
|
224
|
-
* for degraded-but-continuing conditions, cyan otherwise. Structural endsWith
|
|
225
|
-
* checks so new events inherit sensible colors from their naming. */
|
|
226
|
-
const RED_EVENT_ENDINGS = ["error", "failed", "invalid_grant"];
|
|
227
|
-
const YELLOW_EVENT_ENDINGS = ["_dropped", "_drift", "_unparsed", "_gave_up", "_abort", "forced_exit", "contested", "draining"];
|
|
228
|
-
|
|
229
|
-
function eventPaint(event: string): (s: string) => string {
|
|
230
|
-
if (RED_EVENT_ENDINGS.some((ending) => event.endsWith(ending))) return c.red;
|
|
231
|
-
if (YELLOW_EVENT_ENDINGS.some((ending) => event.endsWith(ending))) return c.yellow;
|
|
232
|
-
return c.cyan;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** One terminal line per log() event while the daemon runs: the file log stays
|
|
236
|
-
* canonical; this makes `xx serve` observable without tailing tokenmaxxing.log.
|
|
237
|
-
* Field values can carry newlines (e.g. usage.probe_failed's stderr excerpt),
|
|
238
|
-
* so they are escaped to keep the one-line-per-event contract. Exported for
|
|
239
|
-
* tests. */
|
|
240
|
-
export function formatLogLine(input: { event: string; parts: string }): string {
|
|
241
|
-
const time = new Date().toLocaleTimeString("en-GB");
|
|
242
|
-
const parts = input.parts.replaceAll("\r", "\\r").replaceAll("\n", "\\n");
|
|
243
|
-
return `${c.dim(time)} ${eventPaint(input.event)(input.event)}${parts ? ` ${parts}` : ""}`;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** The slice of a Chat SDK thread the runtime touches. z.custom because it
|
|
247
|
-
* carries functions: the zod-native way to name the structural shape once for
|
|
248
|
-
* the daemon and test fakes alike. */
|
|
249
|
-
const ServeThreadSchema = z.custom<{
|
|
250
|
-
id: string;
|
|
251
|
-
channelId: string;
|
|
252
|
-
post: (m: string | AsyncIterable<string | StreamChunk> | StreamingPlan) => Promise<unknown>;
|
|
253
|
-
subscribe: () => Promise<void>;
|
|
254
|
-
unsubscribe: () => Promise<void>;
|
|
255
|
-
startTyping: () => Promise<void>;
|
|
256
|
-
}>();
|
|
257
|
-
type ServeThread = z.infer<typeof ServeThreadSchema>;
|
|
258
|
-
|
|
259
|
-
/** The slice of a Chat SDK message the author guard + folding read. id is the
|
|
260
|
-
* Slack message ts (verified in @chat-adapter/slack 4.34.0: Message.id =
|
|
261
|
-
* event.ts, exactly what reactions.add takes as timestamp), so it is the
|
|
262
|
-
* handle status reactions attach to. */
|
|
263
|
-
const ServeMessageSchema = z.custom<{
|
|
264
|
-
id: string;
|
|
265
|
-
text: string;
|
|
266
|
-
author: { userId: string; isMe: boolean; isBot?: boolean | "unknown" };
|
|
267
|
-
raw?: unknown;
|
|
268
|
-
}>();
|
|
269
|
-
type ServeMessage = z.infer<typeof ServeMessageSchema>;
|
|
270
|
-
|
|
271
|
-
/** Status reactions on the triggering message: hourglass while the turn runs,
|
|
272
|
-
* then exactly one terminal state. Color-of-the-moment for the whole thread
|
|
273
|
-
* list: which asks are being worked, which wait on the user, which are done. */
|
|
274
|
-
const STATUS_EMOJI = {
|
|
275
|
-
processing: "hourglass_flowing_sand",
|
|
276
|
-
done: "white_check_mark",
|
|
277
|
-
failed: "x",
|
|
278
|
-
attention: "question",
|
|
279
|
-
} as const;
|
|
280
|
-
|
|
281
|
-
/** One nudge per ask, this long after the asking turn settled: enough for a
|
|
282
|
-
* present user to answer on their own, short enough that a blocked thread
|
|
283
|
-
* does not sit forgotten. */
|
|
284
|
-
export const ATTENTION_NUDGE_MS = 600_000;
|
|
285
|
-
/** How often the daemon sweeps for overdue attention (runDaemon interval). */
|
|
286
|
-
export const NUDGE_SWEEP_MS = 60_000;
|
|
287
|
-
|
|
288
|
-
/** Reap a previous generation's detached claude child that survived an
|
|
289
|
-
* uncatchable daemon death (SIGKILL, crash: the "exit" event never fires
|
|
290
|
-
* on those, so the hook that kills the group never ran) - resuming beside
|
|
291
|
-
* a live orphan would put two claude processes on one cwd and session
|
|
292
|
-
* (adversarial-review catch). Signals fire ONLY on a verified pid+lstart
|
|
293
|
-
* identity match (cubic review catch: this machine runs the user's real
|
|
294
|
-
* claude sessions; a recycled pid must never get the kill). SIGTERM the
|
|
295
|
-
* group, escalate to SIGKILL if it lingers past the grace. */
|
|
296
|
-
async function reapOrphan(turn: ActiveTurn): Promise<void> {
|
|
297
|
-
if (turn.pid === undefined || turn.pidStartedAt === undefined) return;
|
|
298
|
-
if (pidStartTime(turn.pid) !== turn.pidStartedAt) return; // gone, or a recycled pid
|
|
299
|
-
log("serve.orphan_reaped", { pid: turn.pid });
|
|
300
|
-
killGroup(turn.pid);
|
|
301
|
-
for (let i = 0; i < 10; i++) {
|
|
302
|
-
await delay(500);
|
|
303
|
-
if (pidStartTime(turn.pid) !== turn.pidStartedAt) return;
|
|
304
|
-
}
|
|
305
|
-
killGroup(turn.pid, "SIGKILL");
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* The daemon's message-handling runtime, extracted from runDaemon as an
|
|
310
|
-
* injectable seam so tests can drive the REAL handler wiring (author guard,
|
|
311
|
-
* skipped-message folding, per-thread serialization, activeTurn markers,
|
|
312
|
-
* drain drops, finish close-out) against fake threads and a fake relay.
|
|
313
|
-
* runDaemon passes the production deps; behavior is identical. The startup
|
|
314
|
-
* interrupted-turn recovery stays inline in runDaemon (it needs the live bot
|
|
315
|
-
* and adapter for streamable thread handles) and reuses the chain/turn pieces
|
|
316
|
-
* exposed here.
|
|
317
|
-
*/
|
|
318
|
-
export function buildServeRuntime(seam: {
|
|
319
|
-
cfg: SlackConfig;
|
|
320
|
-
workspaceTeamId: string;
|
|
321
|
-
/** read per message: the adapter only learns its bot user id on connect. */
|
|
322
|
-
botUserId: () => string | null;
|
|
323
|
-
relay: (input: {
|
|
324
|
-
cwd: string;
|
|
325
|
-
sessionId: string | null;
|
|
326
|
-
prompt: string;
|
|
327
|
-
requesterIds: string[];
|
|
328
|
-
link: SlackLink;
|
|
329
|
-
post: (m: AsyncIterable<string | StreamChunk>) => Promise<unknown>;
|
|
330
|
-
/** fires when init assigns a session id the caller has not persisted yet
|
|
331
|
-
* (see relayThread). */
|
|
332
|
-
onSessionId?: (sessionId: string) => void;
|
|
333
|
-
/** fires with the detached claude child's pid at each spawn (see
|
|
334
|
-
* relayThread). */
|
|
335
|
-
onSpawn?: (pid: number) => void;
|
|
336
|
-
drainSignal?: AbortSignal;
|
|
337
|
-
/** steering seam (see relayThread): a live attempt's steer function, or
|
|
338
|
-
* null when the attempt ends. steer runs its onAccept callback
|
|
339
|
-
* synchronously inside acceptance, before the child sees the text. */
|
|
340
|
-
onSteer?: (steer: ((text: string, onAccept?: () => void) => boolean) | null) => void;
|
|
341
|
-
}) => Promise<TurnOutcome>;
|
|
342
|
-
cleanup: (input: { threadId: string }) => CleanupOutcome;
|
|
343
|
-
/** add/remove a status reaction on a message (production: the Slack
|
|
344
|
-
* adapter's reactions.add/remove). Callers never let a rejection escape:
|
|
345
|
-
* a missing scope or an already_reacted must not fail a turn. */
|
|
346
|
-
react: (input: { threadId: string; messageId: string; emoji: string; op: "add" | "remove" }) => Promise<void>;
|
|
347
|
-
/** post one standalone text message into a thread by id (production:
|
|
348
|
-
* bot.thread(threadId).post) - the nudge path, which runs outside any
|
|
349
|
-
* turn and needs no streaming. */
|
|
350
|
-
postToThread: (input: { threadId: string; text: string }) => Promise<void>;
|
|
351
|
-
/** does this user belong to the home workspace? Reaction events carry no
|
|
352
|
-
* team-origin fields, so the note path verifies reactors through this
|
|
353
|
-
* (production: users.info, cached). null = unverifiable = fail closed. */
|
|
354
|
-
isHomeUser: (input: { userId: string }) => Promise<boolean | null>;
|
|
355
|
-
/** builds a streamable proactive thread handle for marker recovery (startup
|
|
356
|
-
* resumes and deferred-turn wakes both need one). runDaemon passes a lazy
|
|
357
|
-
* closure over its bot-backed streamableThread; tests pass a fake. */
|
|
358
|
-
streamable: (threadId: string) => Promise<{ thread: ServeThread; requesterIds: string[] }>;
|
|
359
|
-
/** the pool decision a deferred wake pre-probes with before posting the
|
|
360
|
-
* recovery notice (production: ensureBestAccount; tests: a stub). */
|
|
361
|
-
decide: () => Promise<SwapDecision>;
|
|
362
|
-
}) {
|
|
363
|
-
const { cfg, workspaceTeamId } = seam;
|
|
364
|
-
/** short re-arm after a deferred wake fails transiently (Slack hiccup at
|
|
365
|
-
* the resume): the durable marker keeps deferring, so the retry loop ends
|
|
366
|
-
* the moment any marker-clearing path runs. */
|
|
367
|
-
const RESUME_RETRY_MS = 300_000;
|
|
368
|
-
// in-flight turns, tracked so a shutdown signal can drain them instead of
|
|
369
|
-
// killing a half-streamed answer (live incident 2026-07-18: a deploy
|
|
370
|
-
// restart cut a turn mid-sentence and the answer never reached Slack).
|
|
371
|
-
const activeTurns = new Set<Promise<void>>();
|
|
372
|
-
// aborts depleted-pool park/retry sleeps on drain, so a countdown never
|
|
373
|
-
// holds the restart hostage.
|
|
374
|
-
const drainAbort = new AbortController();
|
|
375
|
-
let draining = false;
|
|
376
|
-
// channels already diagnosed as unlinked this run (see handleTurn).
|
|
377
|
-
const unlinkedLogged = new Set<string>();
|
|
378
|
-
// corrupt thread records already logged as skipped this run (see nudgeSweep).
|
|
379
|
-
const sweepSkipLogged = new Set<string>();
|
|
380
|
-
// Steering registry: one acceptor per thread with a LIVE query attempt
|
|
381
|
-
// (registered by runTurn via the relay's onSteer hook, removed when the
|
|
382
|
-
// attempt ends). onMessage tries the acceptor before the inbox - steering
|
|
383
|
-
// is the default for a mid-turn reply (owner decision 2026-07-27); a
|
|
384
|
-
// refusal (attempt ending, mention-only text, out-of-order arrival) falls
|
|
385
|
-
// back to the inbox path. A thread only ever appears here after handleTurn
|
|
386
|
-
// ran under this daemon's cfg, so the channel is known linked.
|
|
387
|
-
const liveSteers = new Map<string, (m: { relayed: { text: string; authorId: string; id: string }[] }) => Promise<boolean>>();
|
|
388
|
-
// TURN-PRODUCING serialized work per thread (running plus waiting). Only
|
|
389
|
-
// turn producers count (adversarial-review catch: reaction notes and nudge
|
|
390
|
-
// bookkeeping ride the same serialized chain, and counting them silently
|
|
391
|
-
// disabled steering for the rest of any turn a user reacted to). A steer
|
|
392
|
-
// is only ordered when nothing waits behind the live turn.
|
|
393
|
-
const turnDepth = new Map<string, number>();
|
|
394
|
-
// Un-steered messages waiting for the next turn, folded and drained as ONE
|
|
395
|
-
// turn (sorted by Slack ts, which restores order for any upstream arrival
|
|
396
|
-
// race). This is the daemon-owned replacement for the chat queue's
|
|
397
|
-
// skipped-message folding. Each entry carries its delivery's mention flag,
|
|
398
|
-
// so the drained turn derives mention-ness from the RETAINED messages - a
|
|
399
|
-
// mention dropped by the overflow cap must not leave a phantom flag behind
|
|
400
|
-
// (cubic review catch on PR #50).
|
|
401
|
-
const pendingInbox = new Map<string, { text: string; authorId: string; id: string; isMention: boolean }[]>();
|
|
402
|
-
// Per-thread arrival ordering: the steer attempt and the inbox push for
|
|
403
|
-
// one message run to completion before the next message's do, so two
|
|
404
|
-
// near-simultaneous replies can never interleave at the acceptor's await
|
|
405
|
-
// points and land on the child's stdin out of order.
|
|
406
|
-
const arrivalChains = new Map<string, Promise<void>>();
|
|
407
|
-
|
|
408
|
-
/** Best-effort status reaction: reaction state is decoration, so every
|
|
409
|
-
* failure (missing reactions:write until the app is reinstalled,
|
|
410
|
-
* already_reacted, no_reaction on remove) is log-only and can never fail
|
|
411
|
-
* the turn it annotates. */
|
|
412
|
-
const setStatus = async (input: { threadId: string; messageId: string; emoji: string; op: "add" | "remove" }) => {
|
|
413
|
-
try {
|
|
414
|
-
await seam.react(input);
|
|
415
|
-
} catch (e) {
|
|
416
|
-
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
417
|
-
log("serve.reaction_error", { thread: input.threadId, emoji: input.emoji, op: input.op, err: detail });
|
|
418
|
-
}
|
|
419
|
-
};
|
|
420
|
-
|
|
421
|
-
/** One relayed turn with the durable activeTurn marker around it: written
|
|
422
|
-
* before the spawn, cleared when the turn returns, so a marker surviving
|
|
423
|
-
* into the next daemon start identifies a turn a restart killed mid-run.
|
|
424
|
-
* The session id persists the moment init assigns it - a first-turn kill
|
|
425
|
-
* must stay resumable. Returns the steered message ids alongside the
|
|
426
|
-
* outcome: they joined the turn mid-run, so the caller's settle must
|
|
427
|
-
* close their reaction lifecycle too. */
|
|
428
|
-
const runTurn = async (input: {
|
|
429
|
-
thread: { id: string; post: (m: StreamingPlan) => Promise<unknown> };
|
|
430
|
-
record: SlackThread;
|
|
431
|
-
prompt: string;
|
|
432
|
-
requesterIds: string[];
|
|
433
|
-
sessionId: string | null;
|
|
434
|
-
marker: ActiveTurn;
|
|
435
|
-
link: SlackLink;
|
|
436
|
-
}): Promise<{ outcome: TurnOutcome; steeredMessageIds: string[] }> => {
|
|
437
|
-
let record: SlackThread = { ...input.record, activeTurn: input.marker };
|
|
438
|
-
saveSlackThread(record);
|
|
439
|
-
// the whole turn (parks and retries included) reads as "being processed";
|
|
440
|
-
// a resumed marker's steered messages re-arm their hourglass too
|
|
441
|
-
// (setStatus swallows already_reacted).
|
|
442
|
-
for (const id of [input.marker.messageId, ...(input.marker.steeredMessageIds ?? [])]) {
|
|
443
|
-
if (id) await setStatus({ threadId: input.thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "add" });
|
|
444
|
-
}
|
|
445
|
-
let outcome: TurnOutcome | null = null;
|
|
446
|
-
let steeredMessageIds = input.marker.steeredMessageIds ?? [];
|
|
447
|
-
try {
|
|
448
|
-
outcome = await seam.relay({
|
|
449
|
-
cwd: record.cwd,
|
|
450
|
-
sessionId: input.sessionId,
|
|
451
|
-
prompt: input.prompt,
|
|
452
|
-
requesterIds: input.requesterIds,
|
|
453
|
-
link: input.link,
|
|
454
|
-
// every posted segment groups its task cards into one collapsible
|
|
455
|
-
// Slack plan block (task_display_mode "plan"; user ask 2026-07-20:
|
|
456
|
-
// "squash them into one dropdown") instead of a card-per-task
|
|
457
|
-
// timeline. Text-only segments (notices, plain replies) carry no
|
|
458
|
-
// tasks, so the wrap is a no-op for them.
|
|
459
|
-
post: (m) => input.thread.post(new StreamingPlan(m, { groupTasks: "plan" })),
|
|
460
|
-
onSpawn: (pid) => {
|
|
461
|
-
// the lstart token makes the pid a verifiable identity for the
|
|
462
|
-
// orphan reaper; a child dead before ps sees it persists without
|
|
463
|
-
// one, and an identity-less pid is never signaled. Built on the
|
|
464
|
-
// CURRENT marker (a steer may have grown it), with the previous
|
|
465
|
-
// spawn's identity dropped: a retry child must never inherit the
|
|
466
|
-
// dead child's lstart, or the reaper would skip (or mis-verify)
|
|
467
|
-
// the live group.
|
|
468
|
-
const startedAt = pidStartTime(pid);
|
|
469
|
-
const marker = omit(record.activeTurn ?? input.marker, ["pid", "pidStartedAt"]);
|
|
470
|
-
record = { ...record, activeTurn: { ...marker, pid, ...(startedAt === null ? {} : { pidStartedAt: startedAt }) } };
|
|
471
|
-
saveSlackThread(record);
|
|
472
|
-
},
|
|
473
|
-
onSessionId: (sessionId) => {
|
|
474
|
-
record = { ...record, sessionId };
|
|
475
|
-
saveSlackThread(record);
|
|
476
|
-
},
|
|
477
|
-
drainSignal: drainAbort.signal,
|
|
478
|
-
// While an attempt is steerable, a relayable mid-turn message folds
|
|
479
|
-
// into the RUNNING turn (owner decision 2026-07-27: steering is the
|
|
480
|
-
// default; the queued next turn is only the fallback). The acceptor
|
|
481
|
-
// runs its marker mutation synchronously after a successful steer:
|
|
482
|
-
// the steered text becomes part of the durable prompt (replays and
|
|
483
|
-
// retries must include it) before any await can interleave with the
|
|
484
|
-
// turn's own marker writes.
|
|
485
|
-
onSteer: (steerText) => {
|
|
486
|
-
if (steerText === null) {
|
|
487
|
-
liveSteers.delete(input.thread.id);
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
liveSteers.set(input.thread.id, async (m) => {
|
|
491
|
-
const stripped = m.relayed
|
|
492
|
-
.map((r) => ({ text: stripLeadingMention({ text: r.text, botUserId: seam.botUserId() }), authorId: r.authorId, id: r.id }))
|
|
493
|
-
.filter((r) => r.text !== "");
|
|
494
|
-
if (stripped.length === 0) return false;
|
|
495
|
-
// out-of-order insurance: Slack ids are timestamps, so a message
|
|
496
|
-
// OLDER than anything this turn already carries arrived late
|
|
497
|
-
// through an upstream race - refuse it into the inbox, whose
|
|
498
|
-
// drain sorts by ts, instead of folding it after its successor.
|
|
499
|
-
const seen = record.activeTurn ?? input.marker;
|
|
500
|
-
const newestSeen = Math.max(Number(seen.messageId ?? 0) || 0, ...(seen.steeredMessageIds ?? []).map((id) => Number(id) || 0));
|
|
501
|
-
if (stripped.some((r) => (Number(r.id) || 0) < newestSeen)) return false;
|
|
502
|
-
// per-turn steer budget (cursor security review, round 2 on
|
|
503
|
-
// PR #50): accepted steers grow the durable prompt and the
|
|
504
|
-
// child's queue outside the inbox's 100-entry cap, so a flood
|
|
505
|
-
// could balloon one turn without bound. Far past any human
|
|
506
|
-
// steering cadence, a reply takes the capped inbox path instead.
|
|
507
|
-
if ((seen.steeredMessageIds?.length ?? 0) + stripped.length > 25) return false;
|
|
508
|
-
// hourglass BEFORE the steer so a settle racing this acceptor
|
|
509
|
-
// can never leave an unremovable reaction; a refusal takes it
|
|
510
|
-
// back off.
|
|
511
|
-
for (const r of stripped) {
|
|
512
|
-
await setStatus({ threadId: input.thread.id, messageId: r.id, emoji: STATUS_EMOJI.processing, op: "add" });
|
|
513
|
-
}
|
|
514
|
-
// authors the turn does not know yet get named inline: the
|
|
515
|
-
// UserPromptSubmit context does not re-fire for folded mid-turn
|
|
516
|
-
// messages, so attribution rides the message itself.
|
|
517
|
-
const text = stripped
|
|
518
|
-
.map((r) => (input.requesterIds.includes(r.authorId) ? r.text : `Message from <@${r.authorId}>:\n${r.text}`))
|
|
519
|
-
.join("\n\n");
|
|
520
|
-
// The durable commit runs INSIDE steer's acceptance, in the same
|
|
521
|
-
// JS tick as its liveness check (adversarial-review catch, round
|
|
522
|
-
// 3: this acceptor runs on the arrival chain and can resume from
|
|
523
|
-
// its hourglass awaits AFTER the turn ended - a write-ahead save
|
|
524
|
-
// here resurrected the finished turn's marker on disk, and its
|
|
525
|
-
// refusal rollback clobbered post-turn state with a stale
|
|
526
|
-
// snapshot). Acceptance proves the turn is live, so the closure
|
|
527
|
-
// record is disk-faithful; the marker still grows durably BEFORE
|
|
528
|
-
// the text reaches the child's stdin (a crash in between replays
|
|
529
|
-
// a steer the child may never have seen - duplication over
|
|
530
|
-
// loss); and a refusal commits nothing, so a stale invocation is
|
|
531
|
-
// a harmless fall-through to the inbox.
|
|
532
|
-
let asked: SlackThread["attention"];
|
|
533
|
-
const commit = () => {
|
|
534
|
-
const mergedRequesters = uniq([...input.requesterIds, ...stripped.map((r) => r.authorId)]);
|
|
535
|
-
const marker = record.activeTurn ?? input.marker;
|
|
536
|
-
const grownIds = [...(marker.steeredMessageIds ?? []), ...stripped.map((r) => r.id)];
|
|
537
|
-
let next = { ...record, activeTurn: { ...marker, prompt: `${marker.prompt}\n\n${text}`, requesterIds: mergedRequesters, steeredMessageIds: grownIds } };
|
|
538
|
-
// the user responded: a pending ask is answered by the steer
|
|
539
|
-
// just like by a queued turn (adversarial-review catch:
|
|
540
|
-
// leaving it would strand the question mark and fire a
|
|
541
|
-
// spurious nudge about an ask this very message answered).
|
|
542
|
-
const pendingAsk = next.attention;
|
|
543
|
-
if (pendingAsk) next = omit(next, ["attention"]);
|
|
544
|
-
// all-or-nothing (cubic review catch, round 4): the durable
|
|
545
|
-
// save runs before ANY outer mutation, so a failed write
|
|
546
|
-
// leaves the turn's view untouched and the refusal fallback
|
|
547
|
-
// below starts from clean state - without this ordering the
|
|
548
|
-
// settle would stamp the never-delivered message with the
|
|
549
|
-
// turn's outcome emoji.
|
|
550
|
-
saveSlackThread(next);
|
|
551
|
-
steeredMessageIds = grownIds;
|
|
552
|
-
record = next;
|
|
553
|
-
asked = pendingAsk;
|
|
554
|
-
for (const r of stripped) {
|
|
555
|
-
if (!input.requesterIds.includes(r.authorId)) input.requesterIds.push(r.authorId);
|
|
556
|
-
}
|
|
557
|
-
};
|
|
558
|
-
// a throwing commit (the durable save failing) escapes steer()
|
|
559
|
-
// BEFORE the text reaches the child or the relay records it
|
|
560
|
-
// (cubic review catch, round 4): treat it as a refusal, so the
|
|
561
|
-
// message keeps its inbox fallback instead of vanishing into
|
|
562
|
-
// the generic task_crashed log with its hourglass stranded. If
|
|
563
|
-
// the disk stays broken, the inbox turn's own marker write
|
|
564
|
-
// surfaces it loudly through the crash-notice path.
|
|
565
|
-
let accepted = false;
|
|
566
|
-
try {
|
|
567
|
-
accepted = steerText(text, commit);
|
|
568
|
-
} catch (e) {
|
|
569
|
-
log("serve.steer_commit_failed", { thread: input.thread.id, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
570
|
-
}
|
|
571
|
-
if (!accepted) {
|
|
572
|
-
for (const r of stripped) {
|
|
573
|
-
await setStatus({ threadId: input.thread.id, messageId: r.id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
574
|
-
}
|
|
575
|
-
return false;
|
|
576
|
-
}
|
|
577
|
-
log("serve.steered", { thread: input.thread.id, texts: stripped.length });
|
|
578
|
-
if (asked?.messageId) {
|
|
579
|
-
await setStatus({ threadId: input.thread.id, messageId: asked.messageId, emoji: STATUS_EMOJI.attention, op: "remove" });
|
|
580
|
-
}
|
|
581
|
-
return true;
|
|
582
|
-
});
|
|
583
|
-
},
|
|
584
|
-
});
|
|
585
|
-
record = { ...record, sessionId: outcome.sessionId };
|
|
586
|
-
return { outcome, steeredMessageIds };
|
|
587
|
-
} finally {
|
|
588
|
-
// a failure DURING a drain is presumed to be the shutdown signal killing
|
|
589
|
-
// the claude child (terminal Ctrl-C and group signals hit the whole
|
|
590
|
-
// process group, so the child dies and relayThread returns failed while
|
|
591
|
-
// the daemon is still draining - codex review catch): keep the marker so
|
|
592
|
-
// the next generation auto-resumes, exactly the killed-turn state it
|
|
593
|
-
// exists to detect. Outside a drain, or on success, clear it.
|
|
594
|
-
// An announced drop is TERMINAL: relayThread told the user to resend, so
|
|
595
|
-
// retaining the marker would replay work the drop notice disclaimed
|
|
596
|
-
// (duplicate turns, quota, side effects). A turn whose child reached a
|
|
597
|
-
// SUCCESSFUL result is also terminal even when failed (that failure is
|
|
598
|
-
// Slack delivery, not a killed child - resuming would re-run completed
|
|
599
|
-
// work; adversarial-review catch). null outcome = relay threw =
|
|
600
|
-
// still presumed killed.
|
|
601
|
-
const presumedKilled = draining && (outcome === null || (outcome.failed && !outcome.announcedDrop && !outcome.resultReceived));
|
|
602
|
-
// A usage-limit DEFERRAL keeps the marker with resumeAt: the turn
|
|
603
|
-
// returned on purpose so the queue slot frees up, and the scheduler
|
|
604
|
-
// resumes it from this durable record once the pool recovers.
|
|
605
|
-
const deferUntil = outcome?.deferUntil ?? null;
|
|
606
|
-
// An unannounced drop OUTSIDE a drain still clears the marker on
|
|
607
|
-
// purpose (retention would re-execute the turn at the next restart; see
|
|
608
|
-
// notifyDelivered's doc) - but the loss must be operator-visible.
|
|
609
|
-
if (!draining && outcome !== null && outcome.failed && outcome.rateLimited && !outcome.announcedDrop && deferUntil === null) {
|
|
610
|
-
log("serve.drop_unannounced", { thread: input.thread.id });
|
|
611
|
-
}
|
|
612
|
-
// belt-and-braces: the relay clears its steer hook per attempt, but the
|
|
613
|
-
// registry entry must never outlive the turn that owns it.
|
|
614
|
-
liveSteers.delete(input.thread.id);
|
|
615
|
-
if (deferUntil !== null && record.activeTurn) {
|
|
616
|
-
record = { ...record, activeTurn: { ...record.activeTurn, resumeAt: deferUntil } };
|
|
617
|
-
saveSlackThread(record);
|
|
618
|
-
scheduleDeferred(record.threadId, deferUntil);
|
|
619
|
-
} else {
|
|
620
|
-
saveSlackThread(presumedKilled ? record : omit(record, ["activeTurn"]));
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
};
|
|
624
|
-
|
|
625
|
-
const handleTurn = async (input: {
|
|
626
|
-
thread: ServeThread;
|
|
627
|
-
/** every relayed message this turn (the inbox batch, ts-sorted), text
|
|
628
|
-
* paired with its author id: a decision may be owed to an earlier
|
|
629
|
-
* folded sender, and a sender whose whole message was the bot mention
|
|
630
|
-
* contributes no prompt text, so text and author filter together
|
|
631
|
-
* (review catches 2026-07-18). id is the Slack message ts; the LAST
|
|
632
|
-
* entry (the triggering relayable message) carries the status
|
|
633
|
-
* reactions. */
|
|
634
|
-
relayed: { text: string; authorId: string; id: string }[];
|
|
635
|
-
isMention: boolean;
|
|
636
|
-
}) => {
|
|
637
|
-
const { thread, isMention } = input;
|
|
638
|
-
const link = linkForChannel(cfg, bareChannelId(thread.channelId));
|
|
639
|
-
if (!link) {
|
|
640
|
-
// checked BEFORE the draining branch: unlinked channels are
|
|
641
|
-
// contractually log-only silent, and a drain-window drop notice posted
|
|
642
|
-
// into one would tell a user to resend a message that will never be
|
|
643
|
-
// served (closing-review catch). Logged once per channel per daemon run:
|
|
644
|
-
// with several daemons sharing one Slack app this fires on every
|
|
645
|
-
// load-balanced envelope for a sibling's channel (live incident
|
|
646
|
-
// 2026-07-20), and the diagnosis needs one line, not a stream.
|
|
647
|
-
if (!unlinkedLogged.has(thread.channelId)) {
|
|
648
|
-
unlinkedLogged.add(thread.channelId);
|
|
649
|
-
log("serve.unlinked_channel", {
|
|
650
|
-
channel: thread.channelId,
|
|
651
|
-
note: "not linked on this host; if another tokenmaxxing serve shares this Slack app, Slack delivers each socket event to only ONE of them and thread replies get lost - give every daemon its own Slack app",
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
return; // not a linked channel - stay silent in Slack
|
|
655
|
-
}
|
|
656
|
-
if (draining) {
|
|
657
|
-
// the socket stays connected until the drain finishes; anything landing
|
|
658
|
-
// in that window is dropped loudly rather than spawning an unwaitable
|
|
659
|
-
// turn - and the THREAD is told, not just the log (a silent drop reads
|
|
660
|
-
// as the bot thinking; slaude's recorded drop-notice rule). Tracked so
|
|
661
|
-
// the drain wait flushes it before exit; errors swallowed so the notice
|
|
662
|
-
// can never fail the drain.
|
|
663
|
-
log("serve.drain_dropped", { thread: thread.id });
|
|
664
|
-
void tracked(
|
|
665
|
-
(async () => {
|
|
666
|
-
try {
|
|
667
|
-
await thread.post(
|
|
668
|
-
(async function* () {
|
|
669
|
-
yield "tokenmaxxing is restarting - this message was dropped; please re-send it in a moment.";
|
|
670
|
-
})(),
|
|
671
|
-
);
|
|
672
|
-
} catch (e) {
|
|
673
|
-
// caught (never rethrown - the notice must not fail the drain)
|
|
674
|
-
// but logged: an unposted notice means the user saw nothing.
|
|
675
|
-
log("serve.drain_notice_failed", { thread: thread.id, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
676
|
-
}
|
|
677
|
-
})(),
|
|
678
|
-
);
|
|
679
|
-
return;
|
|
680
|
-
}
|
|
681
|
-
log("serve.message", { thread: thread.id, isMention, texts: input.relayed.length });
|
|
682
|
-
// relayed carries every message the inbox batched for this turn, folded
|
|
683
|
-
// into one prompt here. A message that is empty once its bot mention is
|
|
684
|
-
// stripped contributes neither prompt text nor a requester id (cursor
|
|
685
|
-
// review catch 2026-07-18).
|
|
686
|
-
const stripped = input.relayed
|
|
687
|
-
.map((m) => ({ text: stripLeadingMention({ text: m.text, botUserId: seam.botUserId() }), authorId: m.authorId }))
|
|
688
|
-
.filter((m) => m.text !== "");
|
|
689
|
-
let prompt = stripped.map((m) => m.text).join("\n\n");
|
|
690
|
-
const requesterIds = uniq(stripped.map((m) => m.authorId));
|
|
691
|
-
// the triggering message (the last relayable one), even when its own text
|
|
692
|
-
// was just the bot mention: the status reactions belong on the message
|
|
693
|
-
// the user watched the bot pick up.
|
|
694
|
-
const messageId = input.relayed.at(-1)?.id;
|
|
695
|
-
if (!prompt) return;
|
|
696
|
-
// this whole handler runs inside the per-thread `serialized` chain (call
|
|
697
|
-
// sites below), which startup resumes share too - so this load already
|
|
698
|
-
// sees any session id a resume persisted, and no second claude process
|
|
699
|
-
// can ever share this thread's cwd.
|
|
700
|
-
let record = loadSlackThread(thread.id);
|
|
701
|
-
if (!record) {
|
|
702
|
-
if (!isMention) return; // only a mention opens a session
|
|
703
|
-
record = { threadId: thread.id, repo: link.repo, cwd: link.repo, sessionId: null, createdAt: new Date().toISOString() };
|
|
704
|
-
saveSlackThread(record);
|
|
705
|
-
log("serve.thread_opened", { thread: thread.id, cwd: link.repo });
|
|
706
|
-
}
|
|
707
|
-
// Inside the serialized chain a surviving marker is either a PREVIOUS
|
|
708
|
-
// generation's killed turn (an inbound message can win the chain ahead of
|
|
709
|
-
// startup recovery, e.g. Slack redelivering the killed turn's unacked
|
|
710
|
-
// mention) or a LIMIT-DEFERRED turn holding its resumeAt promise. Reap a
|
|
711
|
-
// possible orphan either way, so two claude processes never share the
|
|
712
|
-
// thread's cwd and session (closing-review catch: the recovery-path reap
|
|
713
|
-
// alone loses this race).
|
|
714
|
-
if (record.activeTurn) await reapOrphan(record.activeTurn);
|
|
715
|
-
// An inbound message takes over a deferred thread (its wake timer dies
|
|
716
|
-
// with the takeover; runTurn's fresh marker replaces the deferred one),
|
|
717
|
-
// and the held prompt ALWAYS folds in front of the new text: silently
|
|
718
|
-
// discarding it was the adversarial-review MAJOR catch on PR #44 (it
|
|
719
|
-
// would re-lose exactly the 2026-07-20 two-message shape the deferral
|
|
720
|
-
// exists to save), and no spawn-progress signal on the marker can prove
|
|
721
|
-
// the held prompt ever reached the session (a child can spawn and die
|
|
722
|
-
// before init - vercel review catch). A completed turn never defers, so
|
|
723
|
-
// folding can never re-run finished work; at worst a mid-turn deferral's
|
|
724
|
-
// prompt re-appears alongside the session transcript that already holds
|
|
725
|
-
// its partial work, and the newer message steers.
|
|
726
|
-
const deferred = record.activeTurn?.resumeAt !== undefined ? record.activeTurn : null;
|
|
727
|
-
// a taken-over deferral's messages still wear their processing hourglass
|
|
728
|
-
// (a deferral settles nothing); this turn serves their held prompt, so
|
|
729
|
-
// it adopts their ids and settles them with its own outcome - without
|
|
730
|
-
// the adoption the old trigger's hourglass would read "processing"
|
|
731
|
-
// forever once the fold replaced its marker.
|
|
732
|
-
const adoptedIds = deferred ? [...(deferred.messageId ? [deferred.messageId] : []), ...(deferred.steeredMessageIds ?? [])] : [];
|
|
733
|
-
if (deferred) {
|
|
734
|
-
const timer = deferredTimers.get(thread.id);
|
|
735
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
736
|
-
deferredTimers.delete(thread.id);
|
|
737
|
-
prompt = `${deferred.prompt}\n\n${prompt}`;
|
|
738
|
-
log("serve.deferred_folded", { thread: thread.id });
|
|
739
|
-
}
|
|
740
|
-
// the user responded: the thread is no longer waiting on them. Clear the
|
|
741
|
-
// attention state and its question-mark reaction before the new turn
|
|
742
|
-
// runs, so a due nudge can never fire about an ask that just got its
|
|
743
|
-
// answer.
|
|
744
|
-
if (record.attention) {
|
|
745
|
-
const asked = record.attention;
|
|
746
|
-
record = omit(record, ["attention"]);
|
|
747
|
-
saveSlackThread(record);
|
|
748
|
-
if (asked.messageId) {
|
|
749
|
-
await setStatus({ threadId: thread.id, messageId: asked.messageId, emoji: STATUS_EMOJI.attention, op: "remove" });
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
// reactions observed since the last turn ride into this prompt as
|
|
753
|
-
// context, then clear: the model sees them without any metered
|
|
754
|
-
// reaction-triggered turn.
|
|
755
|
-
if (record.pendingReactions && record.pendingReactions.length > 0) {
|
|
756
|
-
const notes = record.pendingReactions
|
|
757
|
-
.map((r) => `<@${r.userId}> reacted :${r.emoji}: in this thread.`)
|
|
758
|
-
.join("\n");
|
|
759
|
-
prompt = `${prompt}\n\nSlack reactions since your last turn:\n${notes}`;
|
|
760
|
-
record = omit(record, ["pendingReactions"]);
|
|
761
|
-
saveSlackThread(record);
|
|
762
|
-
}
|
|
763
|
-
// subscriptions live in the memory state, so a daemon restart forgets
|
|
764
|
-
// them; every mention re-subscribes to keep follow-up replies flowing.
|
|
765
|
-
if (isMention) await thread.subscribe();
|
|
766
|
-
// "is working..." assistant status; a no-op until the Slack app has the
|
|
767
|
-
// agent feature + assistant:write (the adapter warns instead of throwing).
|
|
768
|
-
await thread.startTyping();
|
|
769
|
-
const startedAt = Date.now();
|
|
770
|
-
const { outcome, steeredMessageIds } = await runTurn({
|
|
771
|
-
thread,
|
|
772
|
-
record,
|
|
773
|
-
prompt,
|
|
774
|
-
requesterIds,
|
|
775
|
-
sessionId: record.sessionId,
|
|
776
|
-
marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds, ...(adoptedIds.length > 0 ? { steeredMessageIds: adoptedIds } : {}) },
|
|
777
|
-
link,
|
|
778
|
-
});
|
|
779
|
-
await settleTurn({ thread, outcome, startedAt, messageId, steeredMessageIds, requesterIds });
|
|
780
|
-
};
|
|
781
|
-
|
|
782
|
-
/** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
|
|
783
|
-
* log line, the status-reaction settle, the attention marking when the
|
|
784
|
-
* model asked the user, and the finish_thread garbage collection. Never
|
|
785
|
-
* throws into the caller - the daemon must keep serving. */
|
|
786
|
-
const settleTurn = async (input: {
|
|
787
|
-
thread: { id: string; post: (m: string | AsyncIterable<string | StreamChunk>) => Promise<unknown>; unsubscribe: () => Promise<void> };
|
|
788
|
-
outcome: TurnOutcome;
|
|
789
|
-
startedAt: number;
|
|
790
|
-
/** the triggering message carrying the status reactions; absent = skip. */
|
|
791
|
-
messageId?: string;
|
|
792
|
-
/** messages steered into the turn mid-run: they carry the same reaction
|
|
793
|
-
* lifecycle as the trigger and settle with the same emoji. */
|
|
794
|
-
steeredMessageIds?: string[];
|
|
795
|
-
/** the turn's asked users, persisted when the model flagged attention. */
|
|
796
|
-
requesterIds?: string[];
|
|
797
|
-
}) => {
|
|
798
|
-
const { thread, outcome, startedAt } = input;
|
|
799
|
-
log(outcome.deferUntil !== null ? "serve.turn_deferred" : outcome.failed ? "serve.turn_failed" : "serve.turn_done", {
|
|
800
|
-
thread: thread.id,
|
|
801
|
-
seconds: Math.round((Date.now() - startedAt) / 1000),
|
|
802
|
-
...(outcome.deferUntil === null ? {} : { resumeAt: outcome.deferUntil }),
|
|
803
|
-
});
|
|
804
|
-
// settle the status reaction: failed beats attention beats done (a failed
|
|
805
|
-
// ask never reads as a clean question mark), then drop the hourglass.
|
|
806
|
-
// Three review-caught exceptions: a drain-presumed-killed turn (same
|
|
807
|
-
// predicate as runTurn's marker retention) keeps its hourglass - it will
|
|
808
|
-
// auto-resume next start, and a terminal x nothing ever removes would
|
|
809
|
-
// read a later successful resume as failed; a usage-limit DEFERRAL is
|
|
810
|
-
// the other auto-resume case and keeps its hourglass for the identical
|
|
811
|
-
// reason (cursor + vercel review catch on PR #43: the durable marker
|
|
812
|
-
// promises a resume, so the triggering message must not read as failed
|
|
813
|
-
// for the whole deferral); and a finished thread settles as done even
|
|
814
|
-
// when the model also flagged attention, because the record deletion
|
|
815
|
-
// below makes the question mark unremovable forever.
|
|
816
|
-
const killedByDrain = draining && outcome.failed && !outcome.announcedDrop && !outcome.resultReceived;
|
|
817
|
-
const deferredForResume = outcome.deferUntil !== null;
|
|
818
|
-
if (!killedByDrain && !deferredForResume) {
|
|
819
|
-
const emoji = outcome.failed ? STATUS_EMOJI.failed : outcome.attention && !outcome.finish ? STATUS_EMOJI.attention : STATUS_EMOJI.done;
|
|
820
|
-
// steerLost: a steered follow-up's own drained turn failed after the
|
|
821
|
-
// primary turn succeeded, so the steered messages settle as failed -
|
|
822
|
-
// matching the in-thread re-send notice; a lost instruction must never
|
|
823
|
-
// read green (see TurnOutcomeSchema.steerLost for the per-turn
|
|
824
|
-
// attribution tradeoff).
|
|
825
|
-
const steeredEmoji = outcome.steerLost ? STATUS_EMOJI.failed : emoji;
|
|
826
|
-
for (const id of [input.messageId, ...(input.steeredMessageIds ?? [])]) {
|
|
827
|
-
if (!id) continue;
|
|
828
|
-
await setStatus({ threadId: thread.id, messageId: id, emoji: id === input.messageId ? emoji : steeredEmoji, op: "add" });
|
|
829
|
-
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
// the model asked the user for a decision: mark the thread waiting so the
|
|
833
|
-
// nudge sweep and the reaction-answer path can see it. Persisted even on
|
|
834
|
-
// a failed turn (the ask may have streamed before the failure; a spurious
|
|
835
|
-
// nudge beats a silently forgotten ask). Skipped on finish: the record is
|
|
836
|
-
// about to be deleted.
|
|
837
|
-
if (outcome.attention && !outcome.finish) {
|
|
838
|
-
const fresh = loadSlackThread(thread.id);
|
|
839
|
-
if (fresh) {
|
|
840
|
-
saveSlackThread({
|
|
841
|
-
...fresh,
|
|
842
|
-
attention: {
|
|
843
|
-
requesterIds: input.requesterIds ?? [],
|
|
844
|
-
askedAt: new Date().toISOString(),
|
|
845
|
-
...(input.messageId ? { messageId: input.messageId } : {}),
|
|
846
|
-
},
|
|
847
|
-
});
|
|
848
|
-
log("serve.attention_marked", { thread: thread.id });
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
// the user declared the work finished: close the thread now that the
|
|
852
|
-
// turn (and its claude subprocess) is over. Never throw into the caller -
|
|
853
|
-
// the daemon must keep serving other threads.
|
|
854
|
-
if (!outcome.finish) return;
|
|
855
|
-
if (outcome.deferUntil !== null) {
|
|
856
|
-
// finish is sticky across retries, so it can ride a deferred outcome -
|
|
857
|
-
// and cleanup would delete the very record the deferral just promised
|
|
858
|
-
// to resume (adversarial-review catch on PR #44). The deferral wins:
|
|
859
|
-
// the resumed turn finishes the remaining work, and the user closes
|
|
860
|
-
// the thread again once it actually lands.
|
|
861
|
-
log("serve.finish_deferred", { thread: thread.id });
|
|
862
|
-
return;
|
|
863
|
-
}
|
|
864
|
-
let result: CleanupOutcome;
|
|
865
|
-
try {
|
|
866
|
-
result = seam.cleanup({ threadId: thread.id });
|
|
867
|
-
} catch (e) {
|
|
868
|
-
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
869
|
-
log("serve.cleanup_error", { thread: thread.id, err: detail });
|
|
870
|
-
try {
|
|
871
|
-
await thread.post(`tokenmaxxing: cleanup failed: ${detail}`);
|
|
872
|
-
} catch (postErr) {
|
|
873
|
-
// the diagnostic is best-effort, but its failure is never silent.
|
|
874
|
-
log("serve.finish_notify_error", { thread: thread.id, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
|
|
875
|
-
}
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
// the Slack calls are guarded too: this whole path must never throw into
|
|
879
|
-
// the caller (review catch, PR #18) - the record is already gone, so a
|
|
880
|
-
// failed confirmation only gets logged.
|
|
881
|
-
try {
|
|
882
|
-
// a refusal keeps the subscription so the thread stays live for a retry.
|
|
883
|
-
if (result.removed) await thread.unsubscribe();
|
|
884
|
-
await thread.post(result.message);
|
|
885
|
-
} catch (e) {
|
|
886
|
-
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
887
|
-
log("serve.finish_notify_error", { thread: thread.id, err: detail });
|
|
888
|
-
}
|
|
889
|
-
log("serve.thread_finished", { thread: thread.id, removed: result.removed });
|
|
890
|
-
};
|
|
891
|
-
|
|
892
|
-
const relayable = (m: ServeMessage) => {
|
|
893
|
-
if (m.author.isMe || m.author.isBot === true) return false;
|
|
894
|
-
// outsiders must not drive sessions (owner rule 2026-07-16, ported from
|
|
895
|
-
// slaude): Slack Connect externals and cross-workspace guests are
|
|
896
|
-
// rejected fail-closed - silent in Slack, loud in the log.
|
|
897
|
-
if (isOutsideAuthor({ raw: m.raw, workspaceTeamId })) {
|
|
898
|
-
log("serve.outside_author", {});
|
|
899
|
-
return false;
|
|
900
|
-
}
|
|
901
|
-
return true;
|
|
902
|
-
};
|
|
903
|
-
|
|
904
|
-
/** The ownership funnel for every task the daemon spawns: registration in
|
|
905
|
-
* activeTurns so a shutdown drains it, and the daemon's terminal error
|
|
906
|
-
* boundary. Bun kills the WHOLE process on any unhandled rejection
|
|
907
|
-
* (default-mode exit verified 2026-07-27), so a `void tracked(...)`
|
|
908
|
-
* fire-and-forget whose task threw would otherwise take every concurrent
|
|
909
|
-
* session's turn down with it - one thread's bad state file must never
|
|
910
|
-
* end another thread's half-streamed answer. Site-specific handling (the
|
|
911
|
-
* in-thread crash notice in onMessage) stays at the site that has the
|
|
912
|
-
* context; whatever escapes lands here, logged, and the daemon keeps
|
|
913
|
-
* serving. */
|
|
914
|
-
const tracked = async (turn: Promise<void>) => {
|
|
915
|
-
activeTurns.add(turn);
|
|
916
|
-
try {
|
|
917
|
-
await turn;
|
|
918
|
-
} catch (e) {
|
|
919
|
-
log("serve.task_crashed", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
920
|
-
} finally {
|
|
921
|
-
activeTurns.delete(turn);
|
|
922
|
-
}
|
|
923
|
-
};
|
|
924
|
-
|
|
925
|
-
// Per-thread serialization owned HERE: with concurrent dispatch (see
|
|
926
|
-
// runDaemon's Chat config) chat provides no per-thread locking at all, so
|
|
927
|
-
// this chain is the ONLY thing keeping two claude turns off one thread's
|
|
928
|
-
// cwd and session. It predates the concurrent switch for the same reason
|
|
929
|
-
// in weaker form: chat's old queue lock expired 30s into any claude turn
|
|
930
|
-
// and let a second handler start anyway (review catch, PR #18).
|
|
931
|
-
const threadTurns = new Map<string, Promise<void>>();
|
|
932
|
-
const serialized = (threadId: string, run: () => Promise<void>) => {
|
|
933
|
-
const prev = threadTurns.get(threadId) ?? Promise.resolve();
|
|
934
|
-
const next = (async () => {
|
|
935
|
-
try {
|
|
936
|
-
await prev;
|
|
937
|
-
} catch { /* the previous turn's rejection was already surfaced to its own handler */ }
|
|
938
|
-
await run();
|
|
939
|
-
})();
|
|
940
|
-
threadTurns.set(threadId, next);
|
|
941
|
-
// GC observer: swallow next's rejection HERE only (the handler awaiting
|
|
942
|
-
// `next` still sees it), else the observer chain is an unhandled rejection
|
|
943
|
-
// (cubic review catch, PR #18).
|
|
944
|
-
void (async () => {
|
|
945
|
-
try {
|
|
946
|
-
await next;
|
|
947
|
-
} catch { /* surfaced to the awaiting handler */ }
|
|
948
|
-
if (threadTurns.get(threadId) === next) threadTurns.delete(threadId);
|
|
949
|
-
})();
|
|
950
|
-
return next;
|
|
951
|
-
};
|
|
952
|
-
/** serialized + the turnDepth count, for callers that produce a claude
|
|
953
|
-
* TURN (inbox drains, reaction answers, recoveries). Bookkeeping riders
|
|
954
|
-
* on the chain (reaction notes, nudges) use bare `serialized` so they
|
|
955
|
-
* never gate steering. */
|
|
956
|
-
const serializedTurn = (threadId: string, run: () => Promise<void>) => {
|
|
957
|
-
turnDepth.set(threadId, (turnDepth.get(threadId) ?? 0) + 1);
|
|
958
|
-
const next = serialized(threadId, run);
|
|
959
|
-
void next.catch(() => {}).finally(() => {
|
|
960
|
-
const depth = (turnDepth.get(threadId) ?? 1) - 1;
|
|
961
|
-
if (depth <= 0) turnDepth.delete(threadId);
|
|
962
|
-
else turnDepth.set(threadId, depth);
|
|
963
|
-
});
|
|
964
|
-
return next;
|
|
965
|
-
};
|
|
966
|
-
|
|
967
|
-
// both Chat SDK callbacks funnel here. Filter EVERY message, trigger
|
|
968
|
-
// included: an outsider (or our own post) arriving last must not discard
|
|
969
|
-
// relayable home-workspace messages delivered alongside it (review catch,
|
|
970
|
-
// PR #18). context.skipped is empty under concurrent dispatch but stays
|
|
971
|
-
// merged defensively - a chat release reintroducing batching must not
|
|
972
|
-
// silently drop messages.
|
|
973
|
-
const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
|
|
974
|
-
const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId, id: m.id }));
|
|
975
|
-
if (relayed.length === 0) return; // outsider mentions never open a session
|
|
976
|
-
// arrivals for one thread DECIDE strictly one at a time, in delivery
|
|
977
|
-
// order: the steer-or-inbox decision for this message completes before
|
|
978
|
-
// the next message's begins, so two near-simultaneous replies can never
|
|
979
|
-
// interleave at the acceptor's await points. The chain holds only the
|
|
980
|
-
// DECISION - a scheduled turn's completion is awaited outside it, or a
|
|
981
|
-
// long turn would hold every later arrival hostage and steering could
|
|
982
|
-
// never engage.
|
|
983
|
-
const prev = arrivalChains.get(input.thread.id) ?? Promise.resolve();
|
|
984
|
-
const job = (async () => {
|
|
985
|
-
try {
|
|
986
|
-
await prev;
|
|
987
|
-
} catch { /* the previous arrival's failure was surfaced to its own caller */ }
|
|
988
|
-
return dispatchArrival({ thread: input.thread, relayed, isMention: input.isMention });
|
|
989
|
-
})();
|
|
990
|
-
const link = job.then(
|
|
991
|
-
() => {},
|
|
992
|
-
() => {},
|
|
993
|
-
);
|
|
994
|
-
arrivalChains.set(input.thread.id, link);
|
|
995
|
-
void link.then(() => {
|
|
996
|
-
if (arrivalChains.get(input.thread.id) === link) arrivalChains.delete(input.thread.id);
|
|
997
|
-
});
|
|
998
|
-
// tracked from the DECISION on (codex review catch on PR #50): the drain
|
|
999
|
-
// waits on activeTurns, and an arrival still deciding at shutdown lived
|
|
1000
|
-
// nowhere else - the daemon could exit before the message reached the
|
|
1001
|
-
// inbox, a marker, or the drop notice.
|
|
1002
|
-
await tracked((async () => {
|
|
1003
|
-
const scheduled = await job;
|
|
1004
|
-
if (scheduled) await scheduled.turn;
|
|
1005
|
-
})());
|
|
1006
|
-
};
|
|
1007
|
-
|
|
1008
|
-
/** One message's steer-or-inbox decision. Steering first (owner decision
|
|
1009
|
-
* 2026-07-27): a reply landing while the thread's turn is running folds
|
|
1010
|
-
* into that turn instead of waiting behind it. Guarded to an empty inbox
|
|
1011
|
-
* and no waiting turn, or the fold would reorder this message ahead of
|
|
1012
|
-
* one already waiting; a drain keeps its loud-drop contract. Every
|
|
1013
|
-
* refusal falls through to the inbox, whose drain runs ALL waiting
|
|
1014
|
-
* messages as one folded turn - the daemon-owned replacement for the
|
|
1015
|
-
* chat queue's skipped-message folding, sorted by Slack ts so an
|
|
1016
|
-
* upstream arrival race cannot reorder the prompt. Returns the tracked
|
|
1017
|
-
* drain-turn promise when this arrival scheduled one, so the caller can
|
|
1018
|
-
* await the turn without holding the arrival chain. */
|
|
1019
|
-
const dispatchArrival = async (input: { thread: ServeThread; relayed: { text: string; authorId: string; id: string }[]; isMention: boolean }): Promise<{ turn: Promise<void> } | null> => {
|
|
1020
|
-
const threadId = input.thread.id;
|
|
1021
|
-
if (!draining && (pendingInbox.get(threadId)?.length ?? 0) === 0 && (turnDepth.get(threadId) ?? 0) <= 1) {
|
|
1022
|
-
const accept = liveSteers.get(threadId);
|
|
1023
|
-
if (accept && (await accept({ relayed: input.relayed }))) return null;
|
|
1024
|
-
}
|
|
1025
|
-
const inbox = pendingInbox.get(threadId) ?? [];
|
|
1026
|
-
const hadPending = inbox.length > 0;
|
|
1027
|
-
inbox.push(...input.relayed.map((r) => ({ ...r, isMention: input.isMention })));
|
|
1028
|
-
// hard cap, replacing the retired chat queue's maxQueueSize bound
|
|
1029
|
-
// (cursor security review on PR #50): without it a flood during one
|
|
1030
|
-
// long turn grows memory and the folded prompt without limit. Newest
|
|
1031
|
-
// dropped, LOUDLY - the old queue's silent drop-oldest ate the earliest
|
|
1032
|
-
// instructions, the worse failure.
|
|
1033
|
-
if (inbox.length > 100) {
|
|
1034
|
-
log("serve.inbox_dropped", { thread: threadId, dropped: inbox.length - 100 });
|
|
1035
|
-
inbox.length = 100;
|
|
1036
|
-
}
|
|
1037
|
-
pendingInbox.set(threadId, inbox);
|
|
1038
|
-
// one drain per non-empty inbox: later arrivals fold into the batch the
|
|
1039
|
-
// already-scheduled drain snapshots when it finally runs.
|
|
1040
|
-
if (hadPending) return null;
|
|
1041
|
-
const turn = tracked(serializedTurn(threadId, async () => {
|
|
1042
|
-
const batch = (pendingInbox.get(threadId) ?? []).sort((a, b) => Number(a.id) - Number(b.id));
|
|
1043
|
-
pendingInbox.delete(threadId);
|
|
1044
|
-
if (batch.length === 0) return;
|
|
1045
|
-
try {
|
|
1046
|
-
await handleTurn({ thread: input.thread, relayed: batch, isMention: batch.some((m) => m.isMention) });
|
|
1047
|
-
} catch (e) {
|
|
1048
|
-
// an escaped handleTurn throw (a state-file parse, a Slack API
|
|
1049
|
-
// rejection outside relayThread's never-throws boundary) previously
|
|
1050
|
-
// died in the chat SDK's catch-and-log: the user's message vanished
|
|
1051
|
-
// with no reply and no log line of ours (2026-07-27 report). Tell
|
|
1052
|
-
// the thread and keep the daemon serving.
|
|
1053
|
-
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
1054
|
-
log("serve.turn_crashed", { thread: input.thread.id, err: detail });
|
|
1055
|
-
// a surviving activeTurn marker means the turn is PRESERVED (a drain
|
|
1056
|
-
// kill kept it for the next generation's auto-resume): a "re-send it"
|
|
1057
|
-
// notice would invite a duplicate run and a failed x would misread a
|
|
1058
|
-
// guaranteed retry (cubic review catch, round 3) - log only, the
|
|
1059
|
-
// resume machinery owns the messaging. The read is best-effort: an
|
|
1060
|
-
// unreadable record (possibly the crash itself) takes the visible
|
|
1061
|
-
// crash path.
|
|
1062
|
-
let preserved = false;
|
|
1063
|
-
try {
|
|
1064
|
-
preserved = loadSlackThread(input.thread.id)?.activeTurn !== undefined;
|
|
1065
|
-
} catch { /* unreadable record: treat as not preserved */ }
|
|
1066
|
-
if (preserved) return;
|
|
1067
|
-
try {
|
|
1068
|
-
await input.thread.post(
|
|
1069
|
-
(async function* () {
|
|
1070
|
-
yield `tokenmaxxing: this message's handling crashed: ${detail}. If no reply landed above, re-send it.`;
|
|
1071
|
-
})(),
|
|
1072
|
-
);
|
|
1073
|
-
} catch (postErr) {
|
|
1074
|
-
log("serve.turn_crash_notice_failed", { thread: input.thread.id, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
|
|
1075
|
-
}
|
|
1076
|
-
// a crash after runTurn added the hourglass would otherwise read as
|
|
1077
|
-
// "processing" forever (codex review catch); setStatus never throws.
|
|
1078
|
-
const messageId = batch.at(-1)?.id;
|
|
1079
|
-
if (messageId) {
|
|
1080
|
-
await setStatus({ threadId: input.thread.id, messageId, emoji: STATUS_EMOJI.failed, op: "add" });
|
|
1081
|
-
await setStatus({ threadId: input.thread.id, messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
}));
|
|
1085
|
-
return { turn };
|
|
1086
|
-
};
|
|
1087
|
-
|
|
1088
|
-
/** A user reaction in a tracked thread. While the thread waits on an asked
|
|
1089
|
-
* user, THAT user's reaction TO THE ASK is their answer: it relays as a
|
|
1090
|
-
* normal turn for the model to interpret (thumbs up approves, thumbs down
|
|
1091
|
-
* declines - the model decides). "To the ask" is enforced structurally
|
|
1092
|
-
* (review catch: a mid-turn encouragement reaction queued behind the
|
|
1093
|
-
* asking turn must not auto-approve the question it never saw): the
|
|
1094
|
-
* reaction must have occurred AFTER askedAt (occurredAt = the Slack
|
|
1095
|
-
* event_ts) and sit on a message at or after the ask's triggering message
|
|
1096
|
-
* (Slack ids are timestamps, so >= compares post order). Anything that
|
|
1097
|
-
* fails a gate degrades to the unmetered note path, which folds into the
|
|
1098
|
-
* next turn's prompt. The answer path never pre-clears the attention
|
|
1099
|
-
* state (review catch): handleTurn's own consume step clears it at the
|
|
1100
|
-
* point the turn is committed, so a failed thread fetch, an unlinked
|
|
1101
|
-
* channel, or a drain landing mid-await leaves the ask intact for the
|
|
1102
|
-
* nudge and the next daemon generation.
|
|
1103
|
-
* Reactor identity: the answer path only trusts ids that already passed
|
|
1104
|
-
* the author guard as requesters (reaction events carry no team-origin
|
|
1105
|
-
* fields, so isOutsideAuthor cannot run here); the note path fail-closed
|
|
1106
|
-
* verifies the reactor against the home workspace via seam.isHomeUser
|
|
1107
|
-
* and drops non-home or unverifiable reactors loudly (review catch: the
|
|
1108
|
-
* outsiders-never-reach-claude invariant covers context lines too), and
|
|
1109
|
-
* only structurally valid emoji names are ever folded. Removals and
|
|
1110
|
-
* untracked threads are ignored, as are other bots; our own reactions
|
|
1111
|
-
* never arrive (chat core drops isMe reaction events before routing). */
|
|
1112
|
-
const onReaction = async (
|
|
1113
|
-
input: { threadId: string; messageId: string; emoji: string; userId: string; isBot?: boolean | "unknown"; added: boolean; occurredAt: number | null },
|
|
1114
|
-
streamable: (threadId: string) => Promise<{ thread: ServeThread }>,
|
|
1115
|
-
) => {
|
|
1116
|
-
if (!input.added || input.isBot === true) return;
|
|
1117
|
-
if (!loadSlackThread(input.threadId)) return; // untracked thread
|
|
1118
|
-
// unlinked channels are contractually silent AND inert (vercel review
|
|
1119
|
-
// catch on PR #43): a reaction stored to pendingReactions here would
|
|
1120
|
-
// reach claude after a re-link, the one leak every other unlinked path
|
|
1121
|
-
// already closes.
|
|
1122
|
-
if (!linkForChannel(cfg, bareChannelId(input.threadId.split(":").slice(0, 2).join(":")))) {
|
|
1123
|
-
log("serve.reaction_dropped", { thread: input.threadId, reason: "unlinked-channel" });
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
// bare `serialized` on purpose, so a reaction can never gate steering
|
|
1127
|
-
// (adversarial-review catch: counting these bookkeeping riders in
|
|
1128
|
-
// turnDepth silently disabled steering for the rest of any turn a user
|
|
1129
|
-
// reacted to). Accepted tradeoff (documented, WONTFIX): in the rare
|
|
1130
|
-
// shape where an ANSWER turn is queued here behind a live turn, a
|
|
1131
|
-
// fresh reply may steer the live turn ahead of the queued answer - both
|
|
1132
|
-
// still reach the model, and an answer coexisting with a live turn only
|
|
1133
|
-
// occurs in multi-user threads.
|
|
1134
|
-
await tracked(serialized(input.threadId, async () => {
|
|
1135
|
-
const fresh = loadSlackThread(input.threadId);
|
|
1136
|
-
if (!fresh) return; // finished while queued
|
|
1137
|
-
const asked = fresh.attention;
|
|
1138
|
-
const afterAsk = asked !== undefined && input.occurredAt !== null && input.occurredAt >= Date.parse(asked.askedAt);
|
|
1139
|
-
const onAskMessage = asked !== undefined
|
|
1140
|
-
&& (asked.messageId === undefined || (Number.isFinite(Number(input.messageId)) && Number(input.messageId) >= Number(asked.messageId)));
|
|
1141
|
-
// draining takes the durable note path even for an asked user: the
|
|
1142
|
-
// answer turn could not run anyway, and a "please re-send" drop notice
|
|
1143
|
-
// makes no sense for a reaction - the note survives the restart and
|
|
1144
|
-
// folds into the next turn.
|
|
1145
|
-
if (!draining && asked && asked.requesterIds.includes(input.userId) && afterAsk && onAskMessage) {
|
|
1146
|
-
log("serve.reaction_answer", { thread: input.threadId, emoji: input.emoji });
|
|
1147
|
-
try {
|
|
1148
|
-
const { thread } = await streamable(input.threadId);
|
|
1149
|
-
await handleTurn({
|
|
1150
|
-
thread,
|
|
1151
|
-
relayed: [{
|
|
1152
|
-
text: `<@${input.userId}> answered your pending question with the Slack reaction :${input.emoji}:. Interpret the reaction as their reply and continue.`,
|
|
1153
|
-
authorId: input.userId,
|
|
1154
|
-
id: input.messageId,
|
|
1155
|
-
}],
|
|
1156
|
-
isMention: false,
|
|
1157
|
-
});
|
|
1158
|
-
} catch (e) {
|
|
1159
|
-
// a crashed answer turn must not read as an accepted answer (codex
|
|
1160
|
-
// review catch): tell the thread and settle the reacted-to
|
|
1161
|
-
// message's status so it never reads as processing forever.
|
|
1162
|
-
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
1163
|
-
log("serve.reaction_crashed", { thread: input.threadId, err: detail });
|
|
1164
|
-
try {
|
|
1165
|
-
await seam.postToThread({ threadId: input.threadId, text: `tokenmaxxing: handling your reaction answer crashed: ${detail}. Reply in the thread to answer instead.` });
|
|
1166
|
-
} catch (postErr) {
|
|
1167
|
-
log("serve.reaction_crash_notice_failed", { thread: input.threadId, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
|
|
1168
|
-
}
|
|
1169
|
-
await setStatus({ threadId: input.threadId, messageId: input.messageId, emoji: STATUS_EMOJI.failed, op: "add" });
|
|
1170
|
-
await setStatus({ threadId: input.threadId, messageId: input.messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
1171
|
-
// handleTurn consumed the attention state (and its question mark)
|
|
1172
|
-
// before the turn ran; a crashed answer must not eat the ask (cubic
|
|
1173
|
-
// review catch, round 2). Restore both so the nudge sweep and the
|
|
1174
|
-
// reaction-answer gates keep working; the restore itself is
|
|
1175
|
-
// best-effort (the crash may BE an unreadable record).
|
|
1176
|
-
try {
|
|
1177
|
-
const cur = loadSlackThread(input.threadId);
|
|
1178
|
-
if (cur && !cur.attention) {
|
|
1179
|
-
saveSlackThread({ ...cur, attention: asked });
|
|
1180
|
-
if (asked.messageId) {
|
|
1181
|
-
await setStatus({ threadId: input.threadId, messageId: asked.messageId, emoji: STATUS_EMOJI.attention, op: "add" });
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
} catch (restoreErr) {
|
|
1185
|
-
log("serve.attention_restore_failed", { thread: input.threadId, err: (restoreErr instanceof Error ? restoreErr.message : String(restoreErr)).slice(0, 300) });
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
return;
|
|
1189
|
-
}
|
|
1190
|
-
const home = await seam.isHomeUser({ userId: input.userId });
|
|
1191
|
-
if (home !== true) {
|
|
1192
|
-
log("serve.reaction_dropped", { thread: input.threadId, reason: home === false ? "outside-author" : "unverifiable-author" });
|
|
1193
|
-
return;
|
|
1194
|
-
}
|
|
1195
|
-
const validEmoji = input.emoji.length > 0 && input.emoji.length <= 100
|
|
1196
|
-
&& [...input.emoji].every((ch) => (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "_" || ch === "-" || ch === "+" || ch === "'");
|
|
1197
|
-
if (!validEmoji) {
|
|
1198
|
-
log("serve.reaction_dropped", { thread: input.threadId, reason: "invalid-emoji-name" });
|
|
1199
|
-
return;
|
|
1200
|
-
}
|
|
1201
|
-
const notes = [...(fresh.pendingReactions ?? []), { userId: input.userId, emoji: input.emoji, at: new Date().toISOString() }].slice(-10);
|
|
1202
|
-
saveSlackThread({ ...fresh, pendingReactions: notes });
|
|
1203
|
-
log("serve.reaction_noted", { thread: input.threadId, emoji: input.emoji });
|
|
1204
|
-
}));
|
|
1205
|
-
};
|
|
1206
|
-
|
|
1207
|
-
/** One pass over every thread record: threads whose attention went
|
|
1208
|
-
* unanswered past ATTENTION_NUDGE_MS get one mention-tagging reminder.
|
|
1209
|
-
* Greedy and convergent: nudgedAt persists, so re-runs (and daemon
|
|
1210
|
-
* restarts) never repeat a nudge; a failed post retries next sweep,
|
|
1211
|
-
* logged each time. The per-thread work runs inside the serialized chain
|
|
1212
|
-
* so a sweep can never resurrect an attention state a concurrent turn
|
|
1213
|
-
* just cleared. */
|
|
1214
|
-
const nudgeSweep = async (input?: { now?: number }) => {
|
|
1215
|
-
if (draining) return;
|
|
1216
|
-
const now = input?.now ?? Date.now();
|
|
1217
|
-
// per-record parsing, not listSlackThreads: state files that fail to
|
|
1218
|
-
// parse THROW by contract, but here one corrupt record aborting the
|
|
1219
|
-
// whole sweep would silence every OTHER thread's overdue nudge on every
|
|
1220
|
-
// tick (codex review catch) - and before the daemon's rejection backstop
|
|
1221
|
-
// existed, this bare-interval throw was a whole-daemon crash killing
|
|
1222
|
-
// every in-flight turn (2026-07-27 report shape). The skip is logged
|
|
1223
|
-
// once per file per daemon run; a 60s tick would repeat it forever.
|
|
1224
|
-
let files: string[] = [];
|
|
1225
|
-
try {
|
|
1226
|
-
files = existsSync(paths.slackThreadsDir) ? readdirSync(paths.slackThreadsDir) : [];
|
|
1227
|
-
} catch (e) {
|
|
1228
|
-
log("serve.nudge_sweep_error", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
1229
|
-
return;
|
|
1230
|
-
}
|
|
1231
|
-
for (const f of files) {
|
|
1232
|
-
if (!f.endsWith(".json")) continue;
|
|
1233
|
-
let record: SlackThread;
|
|
1234
|
-
try {
|
|
1235
|
-
record = SlackThreadSchema.parse(JSON.parse(readFileSync(join(paths.slackThreadsDir, f), "utf8")));
|
|
1236
|
-
} catch (e) {
|
|
1237
|
-
if (!sweepSkipLogged.has(f)) {
|
|
1238
|
-
sweepSkipLogged.add(f);
|
|
1239
|
-
log("serve.nudge_record_skipped", { file: f, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
1240
|
-
}
|
|
1241
|
-
continue;
|
|
1242
|
-
}
|
|
1243
|
-
const asked = record.attention;
|
|
1244
|
-
if (!asked || asked.nudgedAt !== undefined || now - Date.parse(asked.askedAt) < ATTENTION_NUDGE_MS) continue;
|
|
1245
|
-
// unlinked channels are contractually silent in Slack (review catch:
|
|
1246
|
-
// `serve unlink` leaves thread records behind, and every other outbound
|
|
1247
|
-
// path honors the contract). Skipped without a log line on purpose - a
|
|
1248
|
-
// 60s sweep would otherwise repeat the same warning forever.
|
|
1249
|
-
if (!linkForChannel(cfg, bareChannelId(record.threadId.split(":").slice(0, 2).join(":")))) continue;
|
|
1250
|
-
void tracked(serialized(record.threadId, async () => {
|
|
1251
|
-
if (draining) return;
|
|
1252
|
-
const fresh = loadSlackThread(record.threadId);
|
|
1253
|
-
const due = fresh?.attention;
|
|
1254
|
-
if (!fresh || !due || due.nudgedAt !== undefined || now - Date.parse(due.askedAt) < ATTENTION_NUDGE_MS) return;
|
|
1255
|
-
const tags = due.requesterIds.map((id) => `<@${id}>`).join(" ");
|
|
1256
|
-
try {
|
|
1257
|
-
await seam.postToThread({
|
|
1258
|
-
threadId: record.threadId,
|
|
1259
|
-
text: `${tags || "the requester"} still waiting on your input above.`,
|
|
1260
|
-
});
|
|
1261
|
-
saveSlackThread({ ...fresh, attention: { ...due, nudgedAt: new Date().toISOString() } });
|
|
1262
|
-
log("serve.nudge_sent", { thread: record.threadId });
|
|
1263
|
-
} catch (e) {
|
|
1264
|
-
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
1265
|
-
log("serve.nudge_error", { thread: record.threadId, err: detail });
|
|
1266
|
-
}
|
|
1267
|
-
}));
|
|
1268
|
-
}
|
|
1269
|
-
};
|
|
1270
|
-
|
|
1271
|
-
/** Deferred-turn wakes: one process-local timer per thread, re-armed by a
|
|
1272
|
-
* later deferral. The durable marker (activeTurn.resumeAt) is the source
|
|
1273
|
-
* of truth - timers die with the process and startup re-arms or recovers
|
|
1274
|
-
* from the record. Node clamps setTimeout delays above 2^31-1ms to 1ms,
|
|
1275
|
-
* so the delay is capped instead: an early fire re-defers off the
|
|
1276
|
-
* still-depleted pool, bounded by resumeCount. */
|
|
1277
|
-
const deferredTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
1278
|
-
const scheduleDeferred = (threadId: string, resumeAt: number) => {
|
|
1279
|
-
const prev = deferredTimers.get(threadId);
|
|
1280
|
-
if (prev !== undefined) clearTimeout(prev);
|
|
1281
|
-
log("serve.resume_scheduled", { thread: threadId, resumeAt });
|
|
1282
|
-
const timer = setTimeout(() => {
|
|
1283
|
-
deferredTimers.delete(threadId);
|
|
1284
|
-
if (draining) return;
|
|
1285
|
-
try {
|
|
1286
|
-
const record = loadSlackThread(threadId);
|
|
1287
|
-
if (!record?.activeTurn) return; // superseded: a turn already cleared it
|
|
1288
|
-
void tracked(recoverInterrupted(record));
|
|
1289
|
-
} catch (e) {
|
|
1290
|
-
log("serve.resume_error", { thread: threadId, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
1291
|
-
}
|
|
1292
|
-
}, Math.min(Math.max(0, resumeAt - Date.now()), 2_147_483_647));
|
|
1293
|
-
deferredTimers.set(threadId, timer);
|
|
1294
|
-
};
|
|
1295
|
-
|
|
1296
|
-
/** Recover one thread whose activeTurn marker survived: a restart killed
|
|
1297
|
-
* that turn mid-run, or a usage-limit deferral parked it (resumeAt) and
|
|
1298
|
-
* the wake arrived. Notify the thread, then resume the session (or replay
|
|
1299
|
-
* the original prompt when the turn never reached init); past the retry
|
|
1300
|
-
* cap, give up loudly. EVERY branch runs inside the shared per-thread
|
|
1301
|
-
* `serialized` chain and recomputes the decision from a fresh reload
|
|
1302
|
-
* there: an inbound turn (or Slack redelivering the killed turn's unacked
|
|
1303
|
-
* mention) can win the chain first, handle the thread, and clear the
|
|
1304
|
-
* marker - acting on the startup snapshot would then re-run superseded
|
|
1305
|
-
* work and write stale record fields over the session id that turn
|
|
1306
|
-
* persisted (adversarial-review catch). Lives in the seam with
|
|
1307
|
-
* `streamable` INJECTED (the daemon passes its bot-backed handle builder,
|
|
1308
|
-
* tests a fake) so that superseded-recovery race is pinnable
|
|
1309
|
-
* (closing-review catch: the invariant had no test while inline). */
|
|
1310
|
-
const recoverInterrupted = async (record: SlackThread) => {
|
|
1311
|
-
try {
|
|
1312
|
-
const { thread, requesterIds } = await seam.streamable(record.threadId);
|
|
1313
|
-
const link = linkForChannel(cfg, bareChannelId(thread.channelId));
|
|
1314
|
-
// serializedTurn: a recovery runs a real claude turn, so it must gate
|
|
1315
|
-
// steering-order like any other queued turn.
|
|
1316
|
-
await serializedTurn(record.threadId, async () => {
|
|
1317
|
-
// a drain signal can land between the scan and this turn; leave the
|
|
1318
|
-
// marker at its previous count so the next start retries.
|
|
1319
|
-
if (draining) return;
|
|
1320
|
-
const fresh = loadSlackThread(record.threadId);
|
|
1321
|
-
const turn = fresh?.activeTurn;
|
|
1322
|
-
const decision = fresh ? resumeDecision(fresh) : null;
|
|
1323
|
-
if (!fresh || !turn || !decision) return; // superseded: an earlier turn already cleared the marker
|
|
1324
|
-
if (turn.resumeAt !== undefined && turn.resumeAt > Date.now()) {
|
|
1325
|
-
// a wake that queued behind an in-flight turn can find a RE-DEFERRED
|
|
1326
|
-
// marker whose new wake is hours out; resuming it now would post a
|
|
1327
|
-
// false "pool has recovered" notice and burn a resume attempt
|
|
1328
|
-
// (adversarial-review catch on PR #44). Re-arm and step aside, the
|
|
1329
|
-
// same guard the startup scan applies.
|
|
1330
|
-
scheduleDeferred(record.threadId, turn.resumeAt);
|
|
1331
|
-
return;
|
|
1332
|
-
}
|
|
1333
|
-
await reapOrphan(turn);
|
|
1334
|
-
if (!link) {
|
|
1335
|
-
// unlinked since the turn started: nothing can run here; drop the
|
|
1336
|
-
// marker and stay silent, like every unlinked-channel path.
|
|
1337
|
-
saveSlackThread(omit(fresh, ["activeTurn"]));
|
|
1338
|
-
log("serve.resume_unlinked", { thread: record.threadId });
|
|
1339
|
-
return;
|
|
1340
|
-
}
|
|
1341
|
-
if (decision.kind === "give-up") {
|
|
1342
|
-
log("serve.resume_gave_up", { thread: record.threadId });
|
|
1343
|
-
// post BEFORE clearing, mirroring the resume branch's ordering: a
|
|
1344
|
-
// kill or post failure here leaves the marker for the next restart
|
|
1345
|
-
// to retry the notice (at-least-once; worst case a duplicate
|
|
1346
|
-
// give-up notice), instead of the thread going permanently dark
|
|
1347
|
-
// with the user never told the daemon gave up.
|
|
1348
|
-
await thread.post(decision.notice);
|
|
1349
|
-
saveSlackThread(omit(fresh, ["activeTurn"]));
|
|
1350
|
-
// the abandoned turn's messages must not keep reading as processing.
|
|
1351
|
-
for (const id of [turn.messageId, ...(turn.steeredMessageIds ?? [])]) {
|
|
1352
|
-
if (!id) continue;
|
|
1353
|
-
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.failed, op: "add" });
|
|
1354
|
-
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
1355
|
-
}
|
|
1356
|
-
return;
|
|
1357
|
-
}
|
|
1358
|
-
if (turn.resumeAt !== undefined) {
|
|
1359
|
-
// the wake arrived, but the reset clock was extrapolated: confirm
|
|
1360
|
-
// the pool ACTUALLY recovered before posting the recovery notice
|
|
1361
|
-
// and spending one of the capped resume attempts - a still-depleted
|
|
1362
|
-
// pool with a KNOWN wake re-defers silently (no quota was spent, so
|
|
1363
|
-
// no attempt burns; cursor review catch on PR #44). This runs AFTER
|
|
1364
|
-
// the give-up branch on purpose: a turn at the resume cap gives up
|
|
1365
|
-
// honestly at its due wake instead of re-deferring forever (cubic
|
|
1366
|
-
// review catch). A still-depleted pool with an UNKNOWN wake drops
|
|
1367
|
-
// honestly (vercel + cubic review catch: falling through would post
|
|
1368
|
-
// a false "pool has recovered" notice and burn an attempt on a
|
|
1369
|
-
// spawn-boundary drop) - the drop-beats-false-promise principle
|
|
1370
|
-
// stands for the unknown-wake case, and a short silent re-arm loop
|
|
1371
|
-
// against a never-recovering pool would keep a zombie promise
|
|
1372
|
-
// alive instead. A probe failure falls through to the normal
|
|
1373
|
-
// resume, whose own decision path announces honestly.
|
|
1374
|
-
try {
|
|
1375
|
-
const verdict = await seam.decide();
|
|
1376
|
-
const depleted = verdict.reason === "all-depleted" || verdict.reason === "depleted-wait";
|
|
1377
|
-
if (depleted) {
|
|
1378
|
-
const wake = verdict.waitUntil ?? null;
|
|
1379
|
-
if (wake != null) {
|
|
1380
|
-
const resumeAt = wake + 5_000;
|
|
1381
|
-
saveSlackThread({ ...fresh, activeTurn: { ...turn, resumeAt } });
|
|
1382
|
-
scheduleDeferred(record.threadId, resumeAt);
|
|
1383
|
-
log("serve.resume_still_depleted", { thread: record.threadId, resumeAt });
|
|
1384
|
-
return;
|
|
1385
|
-
}
|
|
1386
|
-
log("serve.resume_dropped_unknown", { thread: record.threadId });
|
|
1387
|
-
await thread.post("the pool is still at its usage limit and its recovery time is now unknown - this held message is dropped; re-send it once the pool recovers.");
|
|
1388
|
-
// terminal exit on a linked channel: settle the trigger's status
|
|
1389
|
-
// or its hourglass reads "processing" forever (cubic review
|
|
1390
|
-
// catch on PR #43). The unlinked abandon above stays reactionless
|
|
1391
|
-
// on purpose: unlinked channels are contractually untouchable.
|
|
1392
|
-
for (const id of [turn.messageId, ...(turn.steeredMessageIds ?? [])]) {
|
|
1393
|
-
if (!id) continue;
|
|
1394
|
-
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.failed, op: "add" });
|
|
1395
|
-
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
1396
|
-
}
|
|
1397
|
-
saveSlackThread(omit(fresh, ["activeTurn"]));
|
|
1398
|
-
return;
|
|
1399
|
-
}
|
|
1400
|
-
} catch (e) {
|
|
1401
|
-
log("serve.resume_probe_error", { thread: record.threadId, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
log("serve.resume_interrupted", { thread: record.threadId, attempt: decision.marker.resumeCount });
|
|
1405
|
-
// the notice posts BEFORE runTurn persists the incremented marker,
|
|
1406
|
-
// on purpose: the cap bounds quota-SPENDING attempts (the spawn),
|
|
1407
|
-
// and a failed notice spends nothing - the marker survives at its
|
|
1408
|
-
// old count for the next restart to retry, at most once per
|
|
1409
|
-
// operator-triggered restart, each logged as serve.resume_error.
|
|
1410
|
-
// A permanently unpostable channel (bot kicked, archived) therefore
|
|
1411
|
-
// retries on every restart; unlinking it clears the marker.
|
|
1412
|
-
await thread.post(decision.notice);
|
|
1413
|
-
const startedAt = Date.now();
|
|
1414
|
-
// the KILLED turn's actual askers outrank the streamable handle's
|
|
1415
|
-
// newest-author derivation: a recovered need_attention turn must
|
|
1416
|
-
// nudge and answer-gate the users who were actually asked (vercel
|
|
1417
|
-
// review catch on PR #43); older markers without the field fall back.
|
|
1418
|
-
const resumedRequesterIds = decision.marker.requesterIds ?? requesterIds;
|
|
1419
|
-
const { outcome, steeredMessageIds } = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds: resumedRequesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
|
|
1420
|
-
await settleTurn({ thread, outcome, startedAt, messageId: decision.marker.messageId, steeredMessageIds, requesterIds: resumedRequesterIds });
|
|
1421
|
-
});
|
|
1422
|
-
} catch (e) {
|
|
1423
|
-
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
1424
|
-
log("serve.resume_error", { thread: record.threadId, err: detail });
|
|
1425
|
-
// a DEFERRED turn's wake must survive a transient failure here (a
|
|
1426
|
-
// Slack hiccup at an unattended 4am wake would otherwise strand the
|
|
1427
|
-
// held turn until the next restart - adversarial-review catch on
|
|
1428
|
-
// PR #44): re-arm a short retry while the marker still defers. The
|
|
1429
|
-
// marker-clearing paths (resume, supersession, unlink, give-up) all
|
|
1430
|
-
// end the loop; killed-turn (no resumeAt) startup semantics keep
|
|
1431
|
-
// their once-per-restart retry.
|
|
1432
|
-
try {
|
|
1433
|
-
const marker = loadSlackThread(record.threadId)?.activeTurn;
|
|
1434
|
-
if (marker?.resumeAt !== undefined && !draining) scheduleDeferred(record.threadId, Date.now() + RESUME_RETRY_MS);
|
|
1435
|
-
} catch {
|
|
1436
|
-
// the record itself is unreadable; the startup scan is the backstop.
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
};
|
|
1440
|
-
|
|
1441
|
-
return {
|
|
1442
|
-
/** in-flight turn promises; shutdown drains them. */
|
|
1443
|
-
activeTurns,
|
|
1444
|
-
isDraining: () => draining,
|
|
1445
|
-
/** stop taking new turns and wake parked/retrying ones. Pending deferred
|
|
1446
|
-
* wakes are cancelled: the markers are durable and the next generation
|
|
1447
|
-
* re-arms them at startup. */
|
|
1448
|
-
beginDrain: () => {
|
|
1449
|
-
draining = true;
|
|
1450
|
-
drainAbort.abort();
|
|
1451
|
-
for (const timer of deferredTimers.values()) clearTimeout(timer);
|
|
1452
|
-
deferredTimers.clear();
|
|
1453
|
-
},
|
|
1454
|
-
relayable,
|
|
1455
|
-
onMessage,
|
|
1456
|
-
onReaction,
|
|
1457
|
-
nudgeSweep,
|
|
1458
|
-
/** the pieces runDaemon's startup interrupted-turn recovery reuses, so a
|
|
1459
|
-
* resumed turn shares the exact chain and marker machinery of an inbound
|
|
1460
|
-
* one. */
|
|
1461
|
-
serialized,
|
|
1462
|
-
tracked,
|
|
1463
|
-
runTurn,
|
|
1464
|
-
settleTurn,
|
|
1465
|
-
recoverInterrupted,
|
|
1466
|
-
scheduleDeferred,
|
|
1467
|
-
};
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
/** users.info slice the home-workspace reactor check reads. */
|
|
1471
|
-
const UsersInfoSchema = z.looseObject({
|
|
1472
|
-
ok: z.boolean(),
|
|
1473
|
-
user: z.looseObject({ team_id: z.string().optional() }).optional(),
|
|
1474
|
-
});
|
|
1475
|
-
|
|
1476
|
-
/** The raw Slack reaction event slice the daemon reads: event_ts is when the
|
|
1477
|
-
* reaction happened, the discriminator that keeps a pre-ask reaction from
|
|
1478
|
-
* answering a question the user never saw. */
|
|
1479
|
-
const ReactionRawSchema = z.looseObject({ event_ts: z.string().optional() });
|
|
1480
|
-
|
|
1481
|
-
async function runDaemon(): Promise<number> {
|
|
1482
|
-
const homeUserCache = new Map<string, boolean>();
|
|
1483
|
-
let cfg = loadSlackConfig();
|
|
1484
|
-
if (!cfg) {
|
|
1485
|
-
printSetupInstructions();
|
|
1486
|
-
return 1;
|
|
1487
|
-
}
|
|
1488
|
-
if (cfg.links.length === 0) {
|
|
1489
|
-
console.error(c.red("no channel links - run `tokenmaxxing serve link <channel-id> <repo>` first"));
|
|
1490
|
-
return 1;
|
|
1491
|
-
}
|
|
1492
|
-
// the external-author guard compares every message's origin against the home
|
|
1493
|
-
// workspace. The reference is re-captured from the live token at EVERY start
|
|
1494
|
-
// (a stale persisted id would fail-closed reject the owner's own messages);
|
|
1495
|
-
// a failed capture fails the daemon fast - the guard never runs
|
|
1496
|
-
// reference-less, and the daemon is useless without Slack reachable anyway.
|
|
1497
|
-
const workspaceTeamId = await fetchWorkspaceTeamId({ botToken: cfg.botToken });
|
|
1498
|
-
if (cfg.workspaceTeamId !== workspaceTeamId) {
|
|
1499
|
-
cfg = { ...cfg, workspaceTeamId };
|
|
1500
|
-
saveSlackConfig(cfg);
|
|
1501
|
-
log("serve.team_captured", { team: workspaceTeamId });
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
// every log() event from here on (serve.* plus the in-process swap/decision
|
|
1505
|
-
// events fired by ensureBestAccount/stopHookCheck) also prints to the
|
|
1506
|
-
// terminal, so a foreground `xx serve` shows what it is doing live.
|
|
1507
|
-
setLogEcho({ printer: (entry) => console.log(formatLogLine(entry)) });
|
|
1508
|
-
|
|
1509
|
-
// single-instance guard, held for the process lifetime (the fd releases on
|
|
1510
|
-
// exit): without it a replacement daemon can start inside the previous
|
|
1511
|
-
// generation's drain window, read a still-RUNNING turn's activeTurn marker,
|
|
1512
|
-
// and resume it - two claude processes in one cwd (adversarial-review
|
|
1513
|
-
// catch). Blocking here makes a rolling restart wait out the drain instead.
|
|
1514
|
-
console.log(c.dim("acquiring the serve singleton lock (waits for a draining daemon to exit)"));
|
|
1515
|
-
await acquireLock(paths.serveLockFile);
|
|
1516
|
-
|
|
1517
|
-
const slack = createSlackAdapter({
|
|
1518
|
-
mode: "socket",
|
|
1519
|
-
botToken: cfg.botToken,
|
|
1520
|
-
appToken: cfg.appToken,
|
|
1521
|
-
// Native append-streaming (chat.startStream) with task cards is the
|
|
1522
|
-
// correct mode (user-confirmed live 2026-07-18): it works in channel
|
|
1523
|
-
// threads even when auth.test reports no assistant:write, so never gate
|
|
1524
|
-
// it on a scope probe. The adapter falls back to post-and-edit by itself
|
|
1525
|
-
// when a workspace truly rejects streaming. agentView matches the
|
|
1526
|
-
// manifest's Agent messaging experience for the DM surface.
|
|
1527
|
-
agentView: true,
|
|
1528
|
-
// the web-api default retry policy (tenRetriesInAboutThirtyMinutes) can
|
|
1529
|
-
// stall a streamed turn ~30min on one rate-limited edit; this is
|
|
1530
|
-
// @slack/web-api's fiveRetriesInFiveMinutes literal (dep not declared,
|
|
1531
|
-
// so the values are inlined).
|
|
1532
|
-
webClientOptions: { retryConfig: { retries: 5, factor: 3.86 }, timeout: 15_000 },
|
|
1533
|
-
// the adapter's default logger is info-level and prints a line per
|
|
1534
|
-
// redelivered socket envelope ("Processing socket mode retry") - steady
|
|
1535
|
-
// noise during restart catch-up. warn matches the Chat logger below;
|
|
1536
|
-
// real degradations (streaming fallback etc.) are warn-level and survive.
|
|
1537
|
-
logger: new ConsoleLogger("warn", "chat-sdk").child("slack"),
|
|
1538
|
-
});
|
|
1539
|
-
// held directly (not only via Chat) so startup can re-subscribe recorded
|
|
1540
|
-
// threads: subscriptions live in this in-memory state and die with the
|
|
1541
|
-
// process, and only a fresh mention would otherwise revive a thread.
|
|
1542
|
-
const state = new MemoryStateAdapter();
|
|
1543
|
-
const bot = new Chat({
|
|
1544
|
-
userName: "tokenmaxxing",
|
|
1545
|
-
adapters: { slack },
|
|
1546
|
-
state,
|
|
1547
|
-
// CONCURRENT dispatch (steering redesign, superseding the queue strategy
|
|
1548
|
-
// and its 1h-TTL/size-100 tuning): every message reaches onMessage the
|
|
1549
|
-
// moment Slack delivers it, in arrival order. The queue strategy's
|
|
1550
|
-
// 30s dispatch-lock lease made mid-turn messages invisible AND could
|
|
1551
|
-
// reorder them - a reply enqueued behind a live lease was only released
|
|
1552
|
-
// when a LATER message took the expired lock, dispatching newest-first
|
|
1553
|
-
// (adversarial-review catch; chat 4.34.0 handleQueueOrDebounce). The
|
|
1554
|
-
// daemon owns everything the queue used to provide: per-thread turn
|
|
1555
|
-
// ordering (the serialized chain), mid-turn folding (the steering path),
|
|
1556
|
-
// and batching of waiting messages (the per-thread inbox in
|
|
1557
|
-
// buildServeRuntime, drained sorted as ONE folded turn). Chat's
|
|
1558
|
-
// message-id dedupe runs before the concurrency branch, so the
|
|
1559
|
-
// app_mention + message.channels double-delivery stays deduped.
|
|
1560
|
-
concurrency: { strategy: "concurrent" },
|
|
1561
|
-
// without this a cards-only segment in post-and-edit fallback would
|
|
1562
|
-
// strand a bare "..." placeholder message.
|
|
1563
|
-
fallbackStreamingPlaceholderText: null,
|
|
1564
|
-
logger: "warn",
|
|
1565
|
-
});
|
|
1566
|
-
|
|
1567
|
-
// the message-handling runtime (author guard, folding, per-thread
|
|
1568
|
-
// serialization, activeTurn markers, drain drops, finish close-out) lives
|
|
1569
|
-
// in buildServeRuntime so tests can drive the real wiring; production deps
|
|
1570
|
-
// go in here.
|
|
1571
|
-
const runtime = buildServeRuntime({
|
|
1572
|
-
cfg,
|
|
1573
|
-
workspaceTeamId,
|
|
1574
|
-
botUserId: () => slack.botUserId ?? null,
|
|
1575
|
-
relay: relayThread,
|
|
1576
|
-
cleanup: cleanupThread,
|
|
1577
|
-
// reactions.add/remove; the runtime wraps every call in its own log-only
|
|
1578
|
-
// guard, so adapter failures (missing reactions:write until the app is
|
|
1579
|
-
// reinstalled with the current manifest) stay invisible to turns.
|
|
1580
|
-
react: async (input) => {
|
|
1581
|
-
if (input.op === "add") await slack.addReaction(input.threadId, input.messageId, input.emoji);
|
|
1582
|
-
else await slack.removeReaction(input.threadId, input.messageId, input.emoji);
|
|
1583
|
-
},
|
|
1584
|
-
// one standalone line (the nudge); a lazy handle posts fine card-less.
|
|
1585
|
-
postToThread: async (input) => {
|
|
1586
|
-
await bot.thread(input.threadId).post(input.text);
|
|
1587
|
-
},
|
|
1588
|
-
// reaction events carry no team-origin fields, so the note path verifies
|
|
1589
|
-
// reactors via users.info (scope users:read, already in the manifest).
|
|
1590
|
-
// Definitive answers cache for the daemon's lifetime; errors return null
|
|
1591
|
-
// (fail closed at the caller) without caching so transient failures heal.
|
|
1592
|
-
isHomeUser: async (input) => {
|
|
1593
|
-
const cached = homeUserCache.get(input.userId);
|
|
1594
|
-
if (cached !== undefined) return cached;
|
|
1595
|
-
try {
|
|
1596
|
-
const resp = UsersInfoSchema.parse(await slack.webClient.users.info({ user: input.userId }));
|
|
1597
|
-
const teamId = resp.user?.team_id;
|
|
1598
|
-
if (!resp.ok || teamId === undefined) return null;
|
|
1599
|
-
const home = teamId === workspaceTeamId;
|
|
1600
|
-
homeUserCache.set(input.userId, home);
|
|
1601
|
-
return home;
|
|
1602
|
-
} catch {
|
|
1603
|
-
return null;
|
|
1604
|
-
}
|
|
1605
|
-
},
|
|
1606
|
-
// lazy on purpose: streamableThread is declared just below and only ever
|
|
1607
|
-
// invoked long after startup (recovery runs and deferred wakes).
|
|
1608
|
-
streamable: (threadId) => streamableThread(threadId),
|
|
1609
|
-
decide: ensureBestAccount,
|
|
1610
|
-
});
|
|
1611
|
-
|
|
1612
|
-
/** A proactive thread handle that can still stream natively. bot.thread()
|
|
1613
|
-
* carries no currentMessage, and without one handleStream has no
|
|
1614
|
-
* recipientUserId/recipientTeamId, so the Slack adapter's stream() gate
|
|
1615
|
-
* falls back to card-less post-and-edit that also strands a blank message
|
|
1616
|
-
* per card-only segment (verified in chat 4.34.0 + @chat-adapter/slack).
|
|
1617
|
-
* Reusing the newest human message in the thread as currentMessage
|
|
1618
|
-
* restores the exact context an inbound turn would have, and its author is
|
|
1619
|
-
* the natural requester to tag on a resumed turn. The explicit ThreadImpl
|
|
1620
|
-
* constructor (published typed API) is deliberate over the prose-documented
|
|
1621
|
-
* ThreadImpl.fromJSON/reviver restore path: fromJSON takes the lazy config
|
|
1622
|
-
* branch, which silently reverts fallbackStreamingPlaceholderText to "..."
|
|
1623
|
-
* (re-stranding the placeholder this daemon suppresses) and needs
|
|
1624
|
-
* registerSingleton for state. */
|
|
1625
|
-
const streamableThread = async (threadId: string) => {
|
|
1626
|
-
const handle = bot.thread(threadId);
|
|
1627
|
-
for await (const message of handle.messages) {
|
|
1628
|
-
if (runtime.relayable(message)) {
|
|
1629
|
-
const thread = new ThreadImpl({
|
|
1630
|
-
adapter: slack,
|
|
1631
|
-
stateAdapter: state,
|
|
1632
|
-
channelId: handle.channelId,
|
|
1633
|
-
id: threadId,
|
|
1634
|
-
isDM: false,
|
|
1635
|
-
currentMessage: message,
|
|
1636
|
-
fallbackStreamingPlaceholderText: null,
|
|
1637
|
-
});
|
|
1638
|
-
return { thread, requesterIds: [message.author.userId] };
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
// no human message on record: card-less is all there is
|
|
1642
|
-
return { thread: handle, requesterIds: [] };
|
|
1643
|
-
};
|
|
1644
|
-
|
|
1645
|
-
bot.onNewMention(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: true }));
|
|
1646
|
-
bot.onSubscribedMessage(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: false }));
|
|
1647
|
-
// user reactions: an asked user's reaction answers the pending question,
|
|
1648
|
-
// anything else folds into the next turn as context (runtime.onReaction).
|
|
1649
|
-
// chat core already drops the bot's own reactions before routing.
|
|
1650
|
-
bot.onReaction(async (event) => {
|
|
1651
|
-
const raw = ReactionRawSchema.safeParse(event.raw);
|
|
1652
|
-
const eventTs = raw.success && raw.data.event_ts !== undefined ? Number(raw.data.event_ts) * 1000 : Number.NaN;
|
|
1653
|
-
await runtime.onReaction(
|
|
1654
|
-
{
|
|
1655
|
-
threadId: event.threadId,
|
|
1656
|
-
messageId: event.messageId,
|
|
1657
|
-
emoji: event.emoji.name,
|
|
1658
|
-
userId: event.user.userId,
|
|
1659
|
-
isBot: event.user.isBot,
|
|
1660
|
-
added: event.added,
|
|
1661
|
-
occurredAt: Number.isFinite(eventTs) ? eventTs : null,
|
|
1662
|
-
},
|
|
1663
|
-
streamableThread,
|
|
1664
|
-
);
|
|
1665
|
-
});
|
|
1666
|
-
|
|
1667
|
-
// drain instead of dying mid-answer: stop taking new turns, let in-flight
|
|
1668
|
-
// ones finish (bounded - a hung claude turn must not block a restart
|
|
1669
|
-
// forever), then disconnect. A second signal forces an immediate exit.
|
|
1670
|
-
// Registered BEFORE initialize() (post-0.19.1 review catch): the socket
|
|
1671
|
-
// goes live inside initialize, so a turn could start while runDaemon was
|
|
1672
|
-
// still suspended there and a signal in that window hit default
|
|
1673
|
-
// disposition - an instant kill with no drain.
|
|
1674
|
-
const DRAIN_MS = 300_000;
|
|
1675
|
-
const shutdown = async (signal: string) => {
|
|
1676
|
-
if (runtime.isDraining()) {
|
|
1677
|
-
log("serve.forced_exit", { signal });
|
|
1678
|
-
process.exit(1);
|
|
1679
|
-
}
|
|
1680
|
-
runtime.beginDrain(); // parked/retrying turns wake, post their drop notice, and finish
|
|
1681
|
-
log("serve.draining", { signal, turns: runtime.activeTurns.size });
|
|
1682
|
-
console.log(`${c.yellow("●")} ${signal}: draining ${count({ n: runtime.activeTurns.size, noun: "in-flight turn" })} (again to force)`);
|
|
1683
|
-
// re-snapshot until stable inside the deadline: drain-window drop notices
|
|
1684
|
-
// join activeTurns after the first snapshot and must still flush.
|
|
1685
|
-
const deadline = Date.now() + DRAIN_MS;
|
|
1686
|
-
while (runtime.activeTurns.size > 0 && Date.now() < deadline) {
|
|
1687
|
-
await Promise.race([Promise.allSettled([...runtime.activeTurns]), delay(deadline - Date.now())]);
|
|
1688
|
-
}
|
|
1689
|
-
try {
|
|
1690
|
-
await bot.shutdown();
|
|
1691
|
-
} catch (e) {
|
|
1692
|
-
// exit must be reached even when the socket teardown rejects; the
|
|
1693
|
-
// second-signal force path must not be the only escape.
|
|
1694
|
-
log("serve.shutdown_error", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
1695
|
-
}
|
|
1696
|
-
log("serve.stopped", { dropped: runtime.activeTurns.size });
|
|
1697
|
-
process.exit(0);
|
|
1698
|
-
};
|
|
1699
|
-
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
1700
|
-
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
1701
|
-
// closing the foreground terminal sends SIGHUP, whose default disposition
|
|
1702
|
-
// kills the daemon WITHOUT the "exit" event - so the process-exit hook that
|
|
1703
|
-
// kill-groups the DETACHED claude child never runs, the child survives as
|
|
1704
|
-
// an orphan still mutating the cwd, and the freed serve-lock lets the next
|
|
1705
|
-
// generation resume the same session beside it (adversarial-review catch,
|
|
1706
|
-
// exit-skip verified empirically on Bun; a mid-turn orphan does NOT die on
|
|
1707
|
-
// its dead stdout pipe - also verified - which is why the reaper exists).
|
|
1708
|
-
// Draining instead keeps the child owned; its Slack streaming needs no
|
|
1709
|
-
// tty, so the turn can even finish. A SIGKILL/crash orphan is covered by
|
|
1710
|
-
// reapOrphan via the marker's pid identity; the only unmarked window is
|
|
1711
|
-
// spawn-to-persist, both inside the spawn hook BEFORE the SDK writes the
|
|
1712
|
-
// prompt, and a prompt-less orphan exits on its dead stdin's EOF (verified
|
|
1713
|
-
// against the real claude binary in the SDK's exact stdio shape: dead in
|
|
1714
|
-
// 2s, zero API calls).
|
|
1715
|
-
process.on("SIGHUP", () => void shutdown("SIGHUP"));
|
|
1716
|
-
|
|
1717
|
-
// LAST-RESORT backstop, not the error strategy: `tracked` is the daemon's
|
|
1718
|
-
// own boundary, so this should stay idle - it exists for rejections minted
|
|
1719
|
-
// outside our funnels (the chat SDK's socket client, adapter internals),
|
|
1720
|
-
// where Bun's default is to kill the whole process (verified 2026-07-27)
|
|
1721
|
-
// and with it every concurrent session's in-flight turn, leaving
|
|
1722
|
-
// half-streamed Slack messages and no daemon to resume the markers.
|
|
1723
|
-
// Sync throws keep the default crash: an uncaughtException means state is
|
|
1724
|
-
// undefined and the durable markers make a restart the honest recovery.
|
|
1725
|
-
// message-only, like every other logged error here: a rejection minted by
|
|
1726
|
-
// an auth-carrying HTTP client must not persist its request into the log
|
|
1727
|
-
// (cursor review catch).
|
|
1728
|
-
process.on("unhandledRejection", (reason) => {
|
|
1729
|
-
log("serve.unhandled_rejection", { err: (reason instanceof Error ? reason.message : String(reason)).slice(0, 300) });
|
|
1730
|
-
});
|
|
1731
|
-
|
|
1732
|
-
// initialize() starts the PERSISTENT Socket Mode client (auto-reconnecting)
|
|
1733
|
-
// wired straight into event routing; the daemon only has to stay alive.
|
|
1734
|
-
// Never call startSocketModeListener here: that is the serverless leased
|
|
1735
|
-
// variant (it demands options.waitUntil and returns instantly without it),
|
|
1736
|
-
// and awaiting it in a loop starved the event loop so hard the WebSocket
|
|
1737
|
-
// never delivered a single event (live incident 2026-07-18).
|
|
1738
|
-
await bot.initialize();
|
|
1739
|
-
|
|
1740
|
-
// subscriptions live in the memory state and died with the previous daemon;
|
|
1741
|
-
// the durable slack-threads/ records say which threads are ours, so restore
|
|
1742
|
-
// routing for them (message routing checks stateAdapter.isSubscribed,
|
|
1743
|
-
// verified in chat 4.34.0). Without this a restart leaves every open thread
|
|
1744
|
-
// deaf to non-mention follow-ups.
|
|
1745
|
-
const records = listSlackThreads();
|
|
1746
|
-
for (const record of records) await state.subscribe(record.threadId);
|
|
1747
|
-
log("serve.resubscribed", { threads: records.length });
|
|
1748
|
-
|
|
1749
|
-
// overdue-attention sweep: one mention-tagging reminder per unanswered ask.
|
|
1750
|
-
// Never cleared: the sweep no-ops while draining, and shutdown exits the
|
|
1751
|
-
// process outright.
|
|
1752
|
-
setInterval(() => void runtime.nudgeSweep(), NUDGE_SWEEP_MS);
|
|
1753
|
-
|
|
1754
|
-
// threads whose activeTurn marker survived the previous daemon were either
|
|
1755
|
-
// killed mid-turn by a restart (live incident 2026-07-18: a redeploy
|
|
1756
|
-
// silently killed a ship turn 8 minutes in and the thread just went dark)
|
|
1757
|
-
// or deferred at a usage limit (resumeAt; 2026-07-20 incident: dropped
|
|
1758
|
-
// messages sat dead for hours after the pool recovered). A future resumeAt
|
|
1759
|
-
// re-arms its timer; everything else recovers now, tracked so a drain
|
|
1760
|
-
// waits for it; the actionable decision is recomputed under the per-thread
|
|
1761
|
-
// lock inside.
|
|
1762
|
-
for (const record of records) {
|
|
1763
|
-
const marker = record.activeTurn;
|
|
1764
|
-
if (!marker) continue;
|
|
1765
|
-
if (marker.resumeAt !== undefined && marker.resumeAt > Date.now()) {
|
|
1766
|
-
runtime.scheduleDeferred(record.threadId, marker.resumeAt);
|
|
1767
|
-
continue;
|
|
1768
|
-
}
|
|
1769
|
-
void runtime.tracked(runtime.recoverInterrupted(record));
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1772
|
-
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)`);
|
|
1773
|
-
log("serve.started", { links: cfg.links.length });
|
|
1774
|
-
await new Promise<never>(() => {});
|
|
1775
|
-
return 0;
|
|
1776
|
-
}
|
|
1777
|
-
|
|
1778
|
-
export async function cmdServe(argv: string[]): Promise<number> {
|
|
1779
|
-
const [sub, ...rest] = argv;
|
|
1780
|
-
switch (sub) {
|
|
1781
|
-
case undefined: return runDaemon();
|
|
1782
|
-
case "setup": return cmdServeSetup();
|
|
1783
|
-
case "link": return cmdServeLink(rest);
|
|
1784
|
-
case "unlink": return cmdServeUnlink(rest[0]);
|
|
1785
|
-
case "links": return cmdServeLinks();
|
|
1786
|
-
default:
|
|
1787
|
-
console.error(SERVE_USAGE);
|
|
1788
|
-
return 2;
|
|
1789
|
-
}
|
|
1790
|
-
}
|