tokenmaxxing 1.6.0 → 1.8.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 +5 -31
- package/LICENSE +21 -0
- package/README.md +1 -2
- package/agent-plugin/agents/tokenmaxxing-claude.md +43 -0
- package/agent-plugin/agents/tokenmaxxing-codex.md +40 -0
- package/agent-plugin/bin/tokenmaxxing-mcp +7 -0
- package/agent-plugin/hooks/cursor-relay.json +14 -0
- package/agent-plugin/mcp.json +10 -0
- package/agent-plugin/plugin.json +20 -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/relay-session/SKILL.md +118 -0
- package/agent-plugin/skills/relay-session/references/ipc.md +23 -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/cli/codexinit.ts +11 -2
- package/src/cli/init.ts +9 -3
- package/src/cli/relay.ts +323 -0
- package/src/entries/codexstophook.ts +10 -0
- package/src/entries/mcp.ts +288 -0
- package/src/entries/relaypermission.ts +105 -0
- package/src/entries/stophook.ts +11 -0
- package/src/lib/decide.ts +2 -4
- package/src/lib/install.ts +61 -7
- package/src/lib/lock.ts +3 -7
- package/src/lib/log.ts +8 -11
- package/src/lib/paths.ts +3 -9
- package/src/lib/relay/config.ts +84 -0
- package/src/lib/relay/decide.ts +75 -0
- package/src/lib/relay/gc.ts +80 -0
- package/src/lib/relay/install.ts +143 -0
- package/src/lib/relay/markers.ts +148 -0
- package/src/lib/relay/modes.ts +82 -0
- package/src/lib/relay/protocol.ts +61 -0
- package/src/lib/relay/registry.ts +175 -0
- package/src/lib/relay/tmux.ts +109 -0
- package/src/lib/relay/turn.ts +137 -0
- package/src/lib/relay/worker.ts +141 -0
- package/src/lib/usage.ts +6 -5
- package/src/main.ts +6 -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/lib/slackstate.ts
DELETED
|
@@ -1,352 +0,0 @@
|
|
|
1
|
-
// `xx serve` state. slack.json holds the Slack tokens plus the channel->repo
|
|
2
|
-
// links; it is credential material (xoxb-/xapp-), so it is written 0600 and its
|
|
3
|
-
// tokens must never be printed. Per-thread records under slack-threads/ pin the
|
|
4
|
-
// claude session id + cwd a Slack thread resumes into (resume is cwd-keyed, so
|
|
5
|
-
// the cwd must stay byte-stable for the thread's whole life). A present but
|
|
6
|
-
// unparseable slack.json THROWS (no silent empty config); absent = null.
|
|
7
|
-
//
|
|
8
|
-
// Retention tradeoff (intentional, closing-review critic gap): thread records
|
|
9
|
-
// have NO age-out - the only GC is the model-invoked finish_thread close-out.
|
|
10
|
-
// An abandoned thread's record persists indefinitely, ON PURPOSE: records are
|
|
11
|
-
// tiny JSON, every record is a resumable conversation, and a time-based
|
|
12
|
-
// reaper would silently kill threads the user still expects to revive with
|
|
13
|
-
// one @mention. Revisit only if slack-threads/ ever measurably bloats.
|
|
14
|
-
|
|
15
|
-
import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
import { omit } from "es-toolkit";
|
|
18
|
-
import { z } from "zod";
|
|
19
|
-
import { paths } from "./paths.ts";
|
|
20
|
-
import { writeFileAtomic } from "./atomic.ts";
|
|
21
|
-
|
|
22
|
-
/** Claude Agent SDK permission modes an unattended bridge may run under.
|
|
23
|
-
* acceptEdits (default) auto-approves file edits but not arbitrary Bash;
|
|
24
|
-
* bypassPermissions is full autonomy - a per-link, user-chosen risk posture. */
|
|
25
|
-
const ServePermissionModeSchema = z.enum(["default", "acceptEdits", "bypassPermissions", "dontAsk", "plan"]);
|
|
26
|
-
|
|
27
|
-
export const SlackLinkSchema = z.object({
|
|
28
|
-
/** Slack channel id (C.../G...). Names are not stored - ids are stable. */
|
|
29
|
-
channel: z.string(),
|
|
30
|
-
/** absolute path of the git repo this channel drives. */
|
|
31
|
-
repo: z.string(),
|
|
32
|
-
permissionMode: ServePermissionModeSchema.default("acceptEdits"),
|
|
33
|
-
/** optional model override for this repo's sessions. */
|
|
34
|
-
model: z.string().optional(),
|
|
35
|
-
});
|
|
36
|
-
export type SlackLink = z.infer<typeof SlackLinkSchema>;
|
|
37
|
-
|
|
38
|
-
export const SlackConfigSchema = z.object({
|
|
39
|
-
botToken: z.string().startsWith("xoxb-"),
|
|
40
|
-
appToken: z.string().startsWith("xapp-"),
|
|
41
|
-
/** the home workspace's team id (auth.test), the reference the external-author
|
|
42
|
-
* guard compares message origins against; captured at setup and ensured at
|
|
43
|
-
* daemon start, so it is only absent in configs saved before the guard. */
|
|
44
|
-
workspaceTeamId: z.string().optional(),
|
|
45
|
-
links: z.array(SlackLinkSchema).default([]),
|
|
46
|
-
});
|
|
47
|
-
export type SlackConfig = z.infer<typeof SlackConfigSchema>;
|
|
48
|
-
|
|
49
|
-
/** Team-origin fields Slack stamps on message/mention event payloads. */
|
|
50
|
-
const MessageOriginSchema = z.looseObject({
|
|
51
|
-
source_team: z.string().optional(),
|
|
52
|
-
team: z.string().optional(),
|
|
53
|
-
team_id: z.string().optional(),
|
|
54
|
-
user_team: z.string().optional(),
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
/** Outsiders must not drive sessions (owner rule 2026-07-16, ported from Slaude
|
|
58
|
-
* at its shutdown): in Slack Connect shared channels an external-workspace
|
|
59
|
-
* author carries team fields (`user_team` / `source_team` / `team`) that
|
|
60
|
-
* differ from the home workspace. Fail closed: reject a payload whose author
|
|
61
|
-
* origin is unreadable, absent, or disagrees on ANY present field. */
|
|
62
|
-
export function isOutsideAuthor(input: { raw: unknown; workspaceTeamId: string }): boolean {
|
|
63
|
-
const parsed = MessageOriginSchema.safeParse(input.raw);
|
|
64
|
-
if (!parsed.success) return true;
|
|
65
|
-
const fields = [parsed.data.user_team, parsed.data.source_team, parsed.data.team, parsed.data.team_id]
|
|
66
|
-
.filter((team): team is string => team !== undefined);
|
|
67
|
-
if (fields.length === 0) return true;
|
|
68
|
-
return fields.some((team) => team !== input.workspaceTeamId);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function loadSlackConfig(): SlackConfig | null {
|
|
72
|
-
if (!existsSync(paths.slackJson)) return null;
|
|
73
|
-
return SlackConfigSchema.parse(JSON.parse(readFileSync(paths.slackJson, "utf8")));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function saveSlackConfig(cfg: SlackConfig): void {
|
|
77
|
-
writeFileAtomic(paths.slackJson, JSON.stringify(SlackConfigSchema.parse(cfg), null, 2) + "\n", 0o600);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** A Slack channel id: C (public) or G (private/legacy) followed by uppercase
|
|
81
|
-
* alphanumerics. Structural check, no regex. */
|
|
82
|
-
export function isChannelId(s: string): boolean {
|
|
83
|
-
if (s.length < 2 || (s[0] !== "C" && s[0] !== "G")) return false;
|
|
84
|
-
const rest = s.slice(1);
|
|
85
|
-
return [...rest].every((ch) => (ch >= "0" && ch <= "9") || (ch >= "A" && ch <= "Z"));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ---- per-thread session records -------------------------------------------
|
|
89
|
-
|
|
90
|
-
/** Durable in-flight-turn marker. Written before a turn spawns and removed
|
|
91
|
-
* when it returns, so a marker that survives into the next daemon start means
|
|
92
|
-
* a restart killed the turn mid-run - startup then notifies the thread and
|
|
93
|
-
* auto-resumes it (2026-07-18 incident: a redeploy silently killed a ship
|
|
94
|
-
* turn 8 minutes in). A marker can also survive a RETURNED turn on purpose:
|
|
95
|
-
* a usage-limit deferral keeps it with resumeAt set, and the daemon resumes
|
|
96
|
-
* the turn itself once the pool recovers (2026-07-20 incident: limit-hit
|
|
97
|
-
* turns and depleted-pool drops sat dead until the user re-sent by hand).
|
|
98
|
-
* resumeCount caps the retries: every resumed attempt spends real quota, so
|
|
99
|
-
* a turn that keeps dying must not retry forever. */
|
|
100
|
-
const ActiveTurnSchema = z.object({
|
|
101
|
-
/** the original folded prompt, replayed verbatim when the killed turn never
|
|
102
|
-
* reached its init message (sessionId still null = nothing to resume). */
|
|
103
|
-
prompt: z.string(),
|
|
104
|
-
startedAt: z.string(),
|
|
105
|
-
resumeCount: z.number().int().nonnegative(),
|
|
106
|
-
/** epoch ms when the pool is expected usable again: present only on a
|
|
107
|
-
* usage-limit deferral. The daemon resumes the turn at this time (or at
|
|
108
|
-
* startup once it has passed); a still-depleted pool at the wake simply
|
|
109
|
-
* re-defers, bounded by resumeCount. */
|
|
110
|
-
resumeAt: z.number().int().positive().optional(),
|
|
111
|
-
/** the DETACHED claude child's process-group id, persisted at spawn: an
|
|
112
|
-
* uncatchable daemon death (SIGKILL, crash) skips the exit hook that kills
|
|
113
|
-
* the group, so recovery must reap a surviving orphan before resuming -
|
|
114
|
-
* two claude processes must never share the thread's cwd and session.
|
|
115
|
-
* Absent until the spawn callback fires. */
|
|
116
|
-
pid: z.number().int().positive().optional(),
|
|
117
|
-
/** the ps lstart token captured for that pid at spawn: pid + start time is
|
|
118
|
-
* the process identity, so recovery signals only a verified match - a
|
|
119
|
-
* recycled pid must never get the kill (cubic review catch; this machine
|
|
120
|
-
* runs the user's real claude sessions). Absent = identity unverifiable =
|
|
121
|
-
* never signal. */
|
|
122
|
-
pidStartedAt: z.string().optional(),
|
|
123
|
-
/** Slack id (message ts) of the turn's triggering message: it carries the
|
|
124
|
-
* status reactions (hourglass while running, check/x/question at settle),
|
|
125
|
-
* so a recovered turn can finish the lifecycle it started. Absent for
|
|
126
|
-
* turns whose trigger had no relayable message id (e.g. a resumed record
|
|
127
|
-
* from before this field existed) - status reactions just skip then. */
|
|
128
|
-
messageId: z.string().optional(),
|
|
129
|
-
/** the triggering turn's requester ids: a recovered turn that flags
|
|
130
|
-
* attention must persist the KILLED turn's actual askers, not whoever
|
|
131
|
-
* authored the thread's newest message at recovery time (vercel review
|
|
132
|
-
* catch on PR #43 - the wrong user would get nudged and answer-gated).
|
|
133
|
-
* Absent on older records: recovery falls back to the streamable
|
|
134
|
-
* handle's newest-author derivation. */
|
|
135
|
-
requesterIds: z.array(z.string()).optional(),
|
|
136
|
-
/** Slack ids of messages STEERED into this turn mid-run: they carry the
|
|
137
|
-
* same hourglass-to-terminal reaction lifecycle as the triggering
|
|
138
|
-
* message, so a killed or deferred turn's recovery must settle them too.
|
|
139
|
-
* Their text is already folded into `prompt` at steer time, which is what
|
|
140
|
-
* makes replays and retries include what the user steered in. An inbound
|
|
141
|
-
* takeover of a DEFERRED turn also adopts the held turn's unsettled ids
|
|
142
|
-
* here: the takeover serves their held prompt, and without the adoption
|
|
143
|
-
* the old trigger's hourglass would read "processing" forever. */
|
|
144
|
-
steeredMessageIds: z.array(z.string()).optional(),
|
|
145
|
-
});
|
|
146
|
-
export type ActiveTurn = z.infer<typeof ActiveTurnSchema>;
|
|
147
|
-
|
|
148
|
-
/** The thread is waiting on the user: set after a turn in which the model
|
|
149
|
-
* called need_attention (the ask-the-user flow), cleared when any relayable
|
|
150
|
-
* message or an asked user's reaction arrives. One nudge per ask: nudgedAt
|
|
151
|
-
* marks it spent, so the sweep can never mention-spam. */
|
|
152
|
-
const ThreadAttentionSchema = z.object({
|
|
153
|
-
/** the asked users: only their reactions count as an answer. */
|
|
154
|
-
requesterIds: z.array(z.string()),
|
|
155
|
-
askedAt: z.string(),
|
|
156
|
-
nudgedAt: z.string().optional(),
|
|
157
|
-
/** the asking turn's triggering message: carries the question-mark status
|
|
158
|
-
* reaction, removed once the user responds. */
|
|
159
|
-
messageId: z.string().optional(),
|
|
160
|
-
});
|
|
161
|
-
export type ThreadAttention = z.infer<typeof ThreadAttentionSchema>;
|
|
162
|
-
|
|
163
|
-
/** A user reaction observed while no answer was owed: folded into the next
|
|
164
|
-
* turn's prompt as context (the model sees it and can respond), then
|
|
165
|
-
* cleared. Bounded to the newest few so an emoji burst cannot grow the
|
|
166
|
-
* record without bound. */
|
|
167
|
-
const PendingReactionSchema = z.object({
|
|
168
|
-
userId: z.string(),
|
|
169
|
-
emoji: z.string(),
|
|
170
|
-
at: z.string(),
|
|
171
|
-
});
|
|
172
|
-
export type PendingReaction = z.infer<typeof PendingReactionSchema>;
|
|
173
|
-
|
|
174
|
-
export const SlackThreadSchema = z.object({
|
|
175
|
-
/** chat-sdk thread id, e.g. "slack:C0123:1721300000.123456". */
|
|
176
|
-
threadId: z.string(),
|
|
177
|
-
repo: z.string(),
|
|
178
|
-
/** the dir every turn of this thread runs in; resume is cwd-keyed, so it
|
|
179
|
-
* stays byte-stable for the thread's whole life (records from the removed
|
|
180
|
-
* worktree-per-thread era pin their old worktree and keep working). */
|
|
181
|
-
cwd: z.string(),
|
|
182
|
-
/** claude session id; null until the first turn's init message arrives. */
|
|
183
|
-
sessionId: z.string().nullable(),
|
|
184
|
-
createdAt: z.string(),
|
|
185
|
-
/** present only while a turn is running (or was killed mid-run). */
|
|
186
|
-
activeTurn: ActiveTurnSchema.optional(),
|
|
187
|
-
/** present only while the thread waits on the user's answer. */
|
|
188
|
-
attention: ThreadAttentionSchema.optional(),
|
|
189
|
-
/** reactions observed since the last turn, folded into the next prompt. */
|
|
190
|
-
pendingReactions: z.array(PendingReactionSchema).optional(),
|
|
191
|
-
});
|
|
192
|
-
export type SlackThread = z.infer<typeof SlackThreadSchema>;
|
|
193
|
-
|
|
194
|
-
/** Filesystem-safe key for a thread id: alnum kept, everything else "-". */
|
|
195
|
-
export function threadKey(threadId: string): string {
|
|
196
|
-
return [...threadId]
|
|
197
|
-
.map((ch) => ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || (ch >= "0" && ch <= "9") ? ch : "-"))
|
|
198
|
-
.join("");
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function threadFile(threadId: string): string {
|
|
202
|
-
return join(paths.slackThreadsDir, `${threadKey(threadId)}.json`);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export function loadSlackThread(threadId: string): SlackThread | null {
|
|
206
|
-
const f = threadFile(threadId);
|
|
207
|
-
if (!existsSync(f)) return null;
|
|
208
|
-
return SlackThreadSchema.parse(JSON.parse(readFileSync(f, "utf8")));
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function saveSlackThread(t: SlackThread): void {
|
|
212
|
-
writeFileAtomic(threadFile(t.threadId), JSON.stringify(SlackThreadSchema.parse(t), null, 2) + "\n");
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Drop a finished thread's record: the thread-level GC (cleanupThread). */
|
|
216
|
-
export function deleteSlackThread(threadId: string): void {
|
|
217
|
-
const f = threadFile(threadId);
|
|
218
|
-
if (existsSync(f)) unlinkSync(f);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export function listSlackThreads(): SlackThread[] {
|
|
222
|
-
if (!existsSync(paths.slackThreadsDir)) return [];
|
|
223
|
-
const out: SlackThread[] = [];
|
|
224
|
-
for (const f of readdirSync(paths.slackThreadsDir)) {
|
|
225
|
-
if (!f.endsWith(".json")) continue;
|
|
226
|
-
out.push(SlackThreadSchema.parse(JSON.parse(readFileSync(join(paths.slackThreadsDir, f), "utf8"))));
|
|
227
|
-
}
|
|
228
|
-
return out;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// ---- interrupted-turn resume decision (unit-tested) ------------------------
|
|
232
|
-
|
|
233
|
-
/** Resumed attempts spend real quota, so an interrupted turn retries at most
|
|
234
|
-
* this many times before startup gives up and asks for a human message. */
|
|
235
|
-
export const MAX_TURN_RESUMES = 3;
|
|
236
|
-
|
|
237
|
-
const ResumeDecisionSchema = z.union([
|
|
238
|
-
z.object({ kind: z.literal("give-up"), notice: z.string() }),
|
|
239
|
-
z.object({
|
|
240
|
-
kind: z.literal("resume"),
|
|
241
|
-
notice: z.string(),
|
|
242
|
-
prompt: z.string(),
|
|
243
|
-
/** session to resume; null = the kill landed before init assigned one,
|
|
244
|
-
* so the original prompt replays in a fresh session. */
|
|
245
|
-
sessionId: z.string().nullable(),
|
|
246
|
-
/** the incremented marker to persist BEFORE the resumed turn spawns, so
|
|
247
|
-
* a kill during the resume still counts toward the cap. */
|
|
248
|
-
marker: ActiveTurnSchema,
|
|
249
|
-
}),
|
|
250
|
-
]);
|
|
251
|
-
export type ResumeDecision = z.infer<typeof ResumeDecisionSchema>;
|
|
252
|
-
|
|
253
|
-
/** What to do with a thread whose activeTurn marker survived: either the
|
|
254
|
-
* previous daemon died mid-turn (no resumeAt - startup recovery) or a
|
|
255
|
-
* usage-limit deferral parked the turn (resumeAt set - the scheduler fires
|
|
256
|
-
* it once the pool recovers). Returns null for threads with no marker. The
|
|
257
|
-
* resumed marker drops resumeAt: the wake is consumed, and a still-depleted
|
|
258
|
-
* pool at the resume writes a fresh deferral with a fresh wake. */
|
|
259
|
-
export function resumeDecision(record: SlackThread): ResumeDecision | null {
|
|
260
|
-
const turn = record.activeTurn;
|
|
261
|
-
if (!turn) return null;
|
|
262
|
-
const deferred = turn.resumeAt !== undefined;
|
|
263
|
-
if (turn.resumeCount >= MAX_TURN_RESUMES) {
|
|
264
|
-
return {
|
|
265
|
-
kind: "give-up",
|
|
266
|
-
notice: deferred
|
|
267
|
-
? `this turn kept hitting the pool's usage limits and ${MAX_TURN_RESUMES} resume attempts were spent - giving up. Send a new message to continue.`
|
|
268
|
-
: `a daemon restart interrupted this turn, and ${MAX_TURN_RESUMES} resume attempts were interrupted too - giving up. Send a new message to continue.`,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
const marker = omit({ ...turn, resumeCount: turn.resumeCount + 1 }, ["resumeAt"]);
|
|
272
|
-
const attempt = marker.resumeCount > 1 ? ` (attempt ${marker.resumeCount}/${MAX_TURN_RESUMES})` : "";
|
|
273
|
-
if (record.sessionId === null) {
|
|
274
|
-
return {
|
|
275
|
-
kind: "resume",
|
|
276
|
-
notice: deferred
|
|
277
|
-
? `the account pool has recovered - running your held message${attempt}`
|
|
278
|
-
: `a daemon restart interrupted this turn before its session opened - starting it over${attempt}`,
|
|
279
|
-
prompt: turn.prompt,
|
|
280
|
-
sessionId: null,
|
|
281
|
-
marker,
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
return {
|
|
285
|
-
kind: "resume",
|
|
286
|
-
notice: deferred ? `the account pool has recovered - resuming this turn${attempt}` : `a daemon restart interrupted this turn - resuming${attempt}`,
|
|
287
|
-
prompt: deferred
|
|
288
|
-
? `Your previous turn stopped early because every pooled account was at its usage limit; the pool has recovered. Pick up exactly where you left off and finish the task. If the work was already complete, just summarize the final state. The original request was:\n\n${turn.prompt}`
|
|
289
|
-
: `A tokenmaxxing serve daemon restart killed your previous turn mid-run. Pick up exactly where you left off and finish the task. If the work was already complete, just summarize the final state. The original request was:\n\n${turn.prompt}`,
|
|
290
|
-
sessionId: record.sessionId,
|
|
291
|
-
marker,
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// ---- pure link edits (unit-tested) ----------------------------------------
|
|
296
|
-
|
|
297
|
-
export function upsertLink(cfg: SlackConfig, link: SlackLink): SlackConfig {
|
|
298
|
-
const links = cfg.links.filter((l) => l.channel !== link.channel);
|
|
299
|
-
links.push(link);
|
|
300
|
-
return { ...cfg, links };
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export function removeLink(cfg: SlackConfig, channel: string): SlackConfig | null {
|
|
304
|
-
if (!cfg.links.some((l) => l.channel === channel)) return null;
|
|
305
|
-
return { ...cfg, links: cfg.links.filter((l) => l.channel !== channel) };
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export function linkForChannel(cfg: SlackConfig, channel: string): SlackLink | null {
|
|
309
|
-
return cfg.links.find((l) => l.channel === channel) ?? null;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/** Chat SDK channel ids are adapter-prefixed ("slack:C0123"); links store the
|
|
313
|
-
* bare Slack id, so lookups must strip the prefix. */
|
|
314
|
-
export function bareChannelId(id: string): string {
|
|
315
|
-
return id.startsWith("slack:") ? id.slice("slack:".length) : id;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Strip a leading Slack mention OF THE BOT from message text. Two forms: raw
|
|
320
|
-
* mrkdwn ("<@U0123> rest") and the bare form the Chat SDK's incoming
|
|
321
|
-
* mrkdwn->markdown normalization produces ("@U0123 rest") - observed live
|
|
322
|
-
* 2026-07-18 when a relayed prompt arrived starting with the bot's bare
|
|
323
|
-
* "@U..." token. Only a
|
|
324
|
-
* token whose id equals botUserId is stripped (review catch 2026-07-18:
|
|
325
|
-
* handleTurn runs this over every subscribed message, so a follow-up starting
|
|
326
|
-
* with a colleague's mention must keep it - the prompt would otherwise lose
|
|
327
|
-
* who it is about). An unknown botUserId strips nothing: without the id we
|
|
328
|
-
* cannot tell the bot's mention from anyone else's.
|
|
329
|
-
*/
|
|
330
|
-
export function stripLeadingMention(input: { text: string; botUserId: string | null }): string {
|
|
331
|
-
const trimmed = input.text.trimStart();
|
|
332
|
-
if (input.botUserId === null) return input.text.trim();
|
|
333
|
-
if (trimmed.startsWith("<@")) {
|
|
334
|
-
const close = trimmed.indexOf(">");
|
|
335
|
-
if (close < 0) return input.text.trim();
|
|
336
|
-
const [id] = trimmed.slice(2, close).split("|");
|
|
337
|
-
if (id === input.botUserId) return trimmed.slice(close + 1).trim();
|
|
338
|
-
return input.text.trim();
|
|
339
|
-
}
|
|
340
|
-
if (trimmed.startsWith("@U") || trimmed.startsWith("@W")) {
|
|
341
|
-
let end = 1;
|
|
342
|
-
while (end < trimmed.length) {
|
|
343
|
-
const ch = trimmed[end] ?? "";
|
|
344
|
-
if ((ch >= "0" && ch <= "9") || (ch >= "A" && ch <= "Z")) end += 1;
|
|
345
|
-
else break;
|
|
346
|
-
}
|
|
347
|
-
const next = trimmed[end];
|
|
348
|
-
const atBoundary = next === undefined || next === " " || next === "\n" || next === "\t";
|
|
349
|
-
if (end - 1 >= 2 && atBoundary && trimmed.slice(1, end) === input.botUserId) return trimmed.slice(end).trim();
|
|
350
|
-
}
|
|
351
|
-
return input.text.trim();
|
|
352
|
-
}
|
package/src/lib/slackstream.ts
DELETED
|
@@ -1,300 +0,0 @@
|
|
|
1
|
-
// Maps Claude Agent SDK messages onto Chat SDK stream chunks so a relayed
|
|
2
|
-
// Slack turn shows the agent's process natively: task cards for thinking and
|
|
3
|
-
// tool calls (pending -> in_progress -> complete/error), streamed text via
|
|
4
|
-
// markdown_text, and a closing turn card with model/cost/duration. All task
|
|
5
|
-
// cards in a turn group into ONE collapsible Slack plan block (user ask
|
|
6
|
-
// 2026-07-20: "squash them into one dropdown"; the serve edge posts each turn
|
|
7
|
-
// as a StreamingPlan with groupTasks "plan", superseding the 2026-07-18
|
|
8
|
-
// separate-messages-around-tool-runs shape), so this mapper emits no segment
|
|
9
|
-
// breaks: a turn is one streamed message. TodoWrite is special-cased into one
|
|
10
|
-
// stable "Todos" checklist card per stream that updates in place as items
|
|
11
|
-
// progress. Structured chunks render only when the Slack app has the agent
|
|
12
|
-
// feature + assistant:write (the adapter drops them gracefully otherwise);
|
|
13
|
-
// plain text streams either way.
|
|
14
|
-
|
|
15
|
-
import { truncate } from "es-toolkit/compat";
|
|
16
|
-
import { z } from "zod";
|
|
17
|
-
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
18
|
-
import type { StreamChunk } from "chat";
|
|
19
|
-
|
|
20
|
-
const DETAILS_MAX = 400;
|
|
21
|
-
const OUTPUT_MAX = 600;
|
|
22
|
-
/** Slack docs claim a 256-char cap on task_update chunks, yet 400/600-char
|
|
23
|
-
* card fields render fine (live-verified 2026-07-18). 600 is the largest
|
|
24
|
-
* size observed rendering; staying inside that envelope beats trusting an
|
|
25
|
-
* unverified bigger budget (a ~15-line checklist still fits). */
|
|
26
|
-
const TODO_DETAILS_MAX = 600;
|
|
27
|
-
|
|
28
|
-
/** Natural activity labels for tool cards (user ask 2026-07-18: these five raw
|
|
29
|
-
* names read awkwardly in Slack). Every other tool name renders verbatim; a
|
|
30
|
-
* Map keeps prototype keys like "toString" from shadowing the fallback. */
|
|
31
|
-
const TOOL_TITLES = new Map([
|
|
32
|
-
["TaskCreate", "Creating task"],
|
|
33
|
-
["TaskUpdate", "Updating task"],
|
|
34
|
-
["ToolSearch", "Loading tools"],
|
|
35
|
-
["WebFetch", "Fetching page"],
|
|
36
|
-
["WebSearch", "Searching the web"],
|
|
37
|
-
]);
|
|
38
|
-
|
|
39
|
-
export function toolCardTitle(name: string): string {
|
|
40
|
-
return TOOL_TITLES.get(name) ?? name;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Tool-input fields worth showing on a task card, most human-readable first. */
|
|
44
|
-
const SUMMARY_FIELDS = ["command", "description", "file_path", "pattern", "prompt", "query", "url"] as const;
|
|
45
|
-
|
|
46
|
-
const OpenBlockSchema = z.object({
|
|
47
|
-
kind: z.enum(["thinking", "tool", "todo"]),
|
|
48
|
-
id: z.string(),
|
|
49
|
-
title: z.string(),
|
|
50
|
-
/** accumulated thinking text or partial tool-input JSON. */
|
|
51
|
-
acc: z.string(),
|
|
52
|
-
});
|
|
53
|
-
type OpenBlock = z.infer<typeof OpenBlockSchema>;
|
|
54
|
-
|
|
55
|
-
const StreamMapStateSchema = z.object({
|
|
56
|
-
/** open content blocks by stream index. */
|
|
57
|
-
open: z.record(z.string(), OpenBlockSchema),
|
|
58
|
-
/** tool_use id -> tool name, for labeling the eventual tool_result. */
|
|
59
|
-
toolTitles: z.record(z.string(), z.string()),
|
|
60
|
-
/** TodoWrite tool_use id -> stable checklist-card id: a successful result
|
|
61
|
-
* is suppressed as noise, but a FAILED write must flip the card to error
|
|
62
|
-
* (the optimistic checklist would otherwise claim a state that never took
|
|
63
|
-
* effect). */
|
|
64
|
-
todoCards: z.record(z.string(), z.string()),
|
|
65
|
-
thinkingCount: z.number(),
|
|
66
|
-
/** non-whitespace reply text already streamed this turn: a later main text
|
|
67
|
-
* block gets a "\n\n" separator, restoring the visual break the removed
|
|
68
|
-
* per-tool message split used to provide (without it, post-tool prose
|
|
69
|
-
* glues onto pre-tool prose and a block opening with "## " or a ```
|
|
70
|
-
* fence loses its line-start position). */
|
|
71
|
-
textStreamed: z.boolean(),
|
|
72
|
-
});
|
|
73
|
-
export type StreamMapState = z.infer<typeof StreamMapStateSchema>;
|
|
74
|
-
|
|
75
|
-
export function newStreamMapState(): StreamMapState {
|
|
76
|
-
return { open: {}, toolTitles: {}, todoCards: {}, thinkingCount: 0, textStreamed: false };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const StreamPartSchema = z.union([z.string(), z.custom<StreamChunk>()]);
|
|
80
|
-
export type StreamPart = z.infer<typeof StreamPartSchema>;
|
|
81
|
-
|
|
82
|
-
/** One human line out of a tool-input JSON blob; null when nothing fits. */
|
|
83
|
-
export function toolInputSummary(rawJson: string): string | null {
|
|
84
|
-
let parsed: unknown;
|
|
85
|
-
try {
|
|
86
|
-
parsed = JSON.parse(rawJson);
|
|
87
|
-
} catch {
|
|
88
|
-
return null; // partial or empty input JSON - show the bare tool name
|
|
89
|
-
}
|
|
90
|
-
const obj = z.record(z.string(), z.unknown()).safeParse(parsed);
|
|
91
|
-
if (!obj.success) return null;
|
|
92
|
-
for (const field of SUMMARY_FIELDS) {
|
|
93
|
-
const value = z.string().min(1).safeParse(obj.data[field]);
|
|
94
|
-
if (value.success) return truncate(value.data, { length: DETAILS_MAX });
|
|
95
|
-
}
|
|
96
|
-
const compact = JSON.stringify(parsed);
|
|
97
|
-
return compact === "{}" ? null : truncate(compact, { length: DETAILS_MAX });
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const TodoItemSchema = z.object({
|
|
101
|
-
content: z.string(),
|
|
102
|
-
status: z.enum(["pending", "in_progress", "completed"]),
|
|
103
|
-
activeForm: z.string(),
|
|
104
|
-
});
|
|
105
|
-
const TodoListSchema = z.object({ todos: z.array(TodoItemSchema) });
|
|
106
|
-
|
|
107
|
-
/** Same status iconography the Chat SDK's own Plan object renders with. */
|
|
108
|
-
const TODO_ICONS = { completed: "✅", in_progress: "🔄", pending: "⬜" } as const;
|
|
109
|
-
|
|
110
|
-
/** The stable checklist-card id: every TodoWrite in the same stream updates
|
|
111
|
-
* one card in place instead of stacking a new card per call. */
|
|
112
|
-
function todoCardId(parent: string | null): string {
|
|
113
|
-
return parent === null ? "todos" : `todos-${parent}`;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Checklist text out of a TodoWrite input blob; null when empty or junk.
|
|
117
|
-
* The in-progress item shows its activeForm ("Running tests") so the card
|
|
118
|
-
* reads as live narration, not a static list. An empty todos list maps to
|
|
119
|
-
* null on purpose: claude clears the list only after everything completed,
|
|
120
|
-
* and skipping that update keeps the finished all-checked card visible
|
|
121
|
-
* instead of blanking it (intentional tradeoff, PR #5 review). */
|
|
122
|
-
export function todoChecklist(rawJson: string): { text: string; allDone: boolean } | null {
|
|
123
|
-
let parsed: unknown;
|
|
124
|
-
try {
|
|
125
|
-
parsed = JSON.parse(rawJson);
|
|
126
|
-
} catch {
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
const list = TodoListSchema.safeParse(parsed);
|
|
130
|
-
if (!list.success || list.data.todos.length === 0) return null;
|
|
131
|
-
const lines = list.data.todos.map((t) => {
|
|
132
|
-
const label = t.status === "in_progress" && t.activeForm !== "" ? t.activeForm : t.content;
|
|
133
|
-
return `${TODO_ICONS[t.status]} ${label}`;
|
|
134
|
-
});
|
|
135
|
-
return {
|
|
136
|
-
text: truncate(lines.join("\n"), { length: TODO_DETAILS_MAX }),
|
|
137
|
-
allDone: list.data.todos.every((t) => t.status === "completed"),
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const ToolResultBlockSchema = z.object({
|
|
142
|
-
type: z.literal("tool_result"),
|
|
143
|
-
tool_use_id: z.string(),
|
|
144
|
-
content: z.union([z.string(), z.array(z.unknown())]).optional(),
|
|
145
|
-
is_error: z.boolean().optional(),
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
const TextPartSchema = z.object({ type: z.literal("text"), text: z.string() });
|
|
149
|
-
|
|
150
|
-
function resultText(content: z.infer<typeof ToolResultBlockSchema>["content"]): string | undefined {
|
|
151
|
-
if (content === undefined) return undefined;
|
|
152
|
-
const joined = Array.isArray(content)
|
|
153
|
-
? content
|
|
154
|
-
.flatMap((part) => {
|
|
155
|
-
const p = TextPartSchema.safeParse(part);
|
|
156
|
-
return p.success ? [p.data.text] : [];
|
|
157
|
-
})
|
|
158
|
-
.join("\n")
|
|
159
|
-
: content;
|
|
160
|
-
const trimmed = joined.trim();
|
|
161
|
-
return trimmed === "" ? undefined : truncate(trimmed, { length: OUTPUT_MAX });
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Consume one SDK message, mutating state, and return the stream chunks it
|
|
166
|
-
* produces (strings are streamed reply text; objects are native task cards,
|
|
167
|
-
* all of which Slack folds into the turn's single plan block). Subagent
|
|
168
|
-
* events (parent_tool_use_id set) contribute their TOOL cards to that plan
|
|
169
|
-
* (user ask 2026-07-18: subagent activity shows alongside tool calls) but
|
|
170
|
-
* never reply text or thinking cards: a subagent runs inside a top-level Task
|
|
171
|
-
* tool, so its churn decorates the turn rather than reshaping it. Open blocks
|
|
172
|
-
* are keyed per stream (parent + index) because concurrent subagent streams
|
|
173
|
-
* reuse index space.
|
|
174
|
-
*/
|
|
175
|
-
export function agentEventChunks(input: { state: StreamMapState; message: SDKMessage }): StreamPart[] {
|
|
176
|
-
const { state, message } = input;
|
|
177
|
-
if (message.type === "stream_event") {
|
|
178
|
-
const isMain = message.parent_tool_use_id === null;
|
|
179
|
-
const event = message.event;
|
|
180
|
-
if (event.type === "content_block_start") {
|
|
181
|
-
const key = `${message.parent_tool_use_id ?? "main"}:${event.index}`;
|
|
182
|
-
if (event.content_block.type === "text" && isMain && state.textStreamed) {
|
|
183
|
-
// a new text block after streamed text opens on a fresh paragraph:
|
|
184
|
-
// the whole turn is one Slack message now, and without the break
|
|
185
|
-
// post-tool prose would glue onto pre-tool prose mid-line.
|
|
186
|
-
return ["\n\n"];
|
|
187
|
-
}
|
|
188
|
-
if (event.content_block.type === "thinking" && isMain) {
|
|
189
|
-
state.thinkingCount += 1;
|
|
190
|
-
const id = `thinking-${state.thinkingCount}`;
|
|
191
|
-
state.open[key] = { kind: "thinking", id, title: "Thinking", acc: "" };
|
|
192
|
-
return [{ type: "task_update", id, title: "Thinking", status: "in_progress" }];
|
|
193
|
-
}
|
|
194
|
-
if (event.content_block.type === "tool_use") {
|
|
195
|
-
const { id, name } = event.content_block;
|
|
196
|
-
if (name === "TodoWrite") {
|
|
197
|
-
// bookkeeping, not a real tool run: no card until the list arrives,
|
|
198
|
-
// and no toolTitles entry (its "Todos have been modified" success
|
|
199
|
-
// result is noise; failures still surface via todoCards below).
|
|
200
|
-
const cardId = todoCardId(message.parent_tool_use_id);
|
|
201
|
-
state.open[key] = { kind: "todo", id: cardId, title: "Todos", acc: "" };
|
|
202
|
-
state.todoCards[id] = cardId;
|
|
203
|
-
return [];
|
|
204
|
-
}
|
|
205
|
-
const title = toolCardTitle(name);
|
|
206
|
-
state.open[key] = { kind: "tool", id, title, acc: "" };
|
|
207
|
-
state.toolTitles[id] = title;
|
|
208
|
-
return [{ type: "task_update", id, title, status: "in_progress" }];
|
|
209
|
-
}
|
|
210
|
-
return [];
|
|
211
|
-
}
|
|
212
|
-
if (event.type === "content_block_delta") {
|
|
213
|
-
const open = state.open[`${message.parent_tool_use_id ?? "main"}:${event.index}`];
|
|
214
|
-
if (event.delta.type === "text_delta") {
|
|
215
|
-
if (!isMain) return [];
|
|
216
|
-
// whitespace-only deltas do not count: a text block carrying only
|
|
217
|
-
// "\n\n" must not earn the next block a doubled separator.
|
|
218
|
-
if (event.delta.text.trim() !== "") state.textStreamed = true;
|
|
219
|
-
return [event.delta.text];
|
|
220
|
-
}
|
|
221
|
-
if (event.delta.type === "thinking_delta" && open) open.acc += event.delta.thinking;
|
|
222
|
-
if (event.delta.type === "input_json_delta" && open) open.acc += event.delta.partial_json;
|
|
223
|
-
return [];
|
|
224
|
-
}
|
|
225
|
-
if (event.type === "content_block_stop") {
|
|
226
|
-
const key = `${message.parent_tool_use_id ?? "main"}:${event.index}`;
|
|
227
|
-
const open = state.open[key];
|
|
228
|
-
if (!open) return [];
|
|
229
|
-
delete state.open[key];
|
|
230
|
-
if (open.kind === "thinking") {
|
|
231
|
-
return [{ type: "task_update", id: open.id, title: open.title, status: "complete", details: truncate(open.acc.trim(), { length: DETAILS_MAX }) }];
|
|
232
|
-
}
|
|
233
|
-
if (open.kind === "todo") {
|
|
234
|
-
const list = todoChecklist(open.acc);
|
|
235
|
-
if (list === null) return [];
|
|
236
|
-
return [{ type: "task_update", id: open.id, title: open.title, status: list.allDone ? "complete" : "in_progress", details: list.text }];
|
|
237
|
-
}
|
|
238
|
-
const details = toolInputSummary(open.acc);
|
|
239
|
-
return [{ type: "task_update", id: open.id, title: open.title, status: "in_progress", ...(details ? { details } : {}) }];
|
|
240
|
-
}
|
|
241
|
-
return [];
|
|
242
|
-
}
|
|
243
|
-
if (message.type === "user") {
|
|
244
|
-
const content = message.message.content;
|
|
245
|
-
if (!Array.isArray(content)) return [];
|
|
246
|
-
const chunks: StreamChunk[] = [];
|
|
247
|
-
for (const block of content) {
|
|
248
|
-
const parsed = ToolResultBlockSchema.safeParse(block);
|
|
249
|
-
if (!parsed.success) continue;
|
|
250
|
-
const todoCard = state.todoCards[parsed.data.tool_use_id];
|
|
251
|
-
if (todoCard !== undefined) {
|
|
252
|
-
if (parsed.data.is_error !== true) continue;
|
|
253
|
-
const output = resultText(parsed.data.content);
|
|
254
|
-
chunks.push({ type: "task_update", id: todoCard, title: "Todos", status: "error", ...(output ? { output } : {}) });
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
const title = state.toolTitles[parsed.data.tool_use_id];
|
|
258
|
-
if (title === undefined) continue;
|
|
259
|
-
const output = resultText(parsed.data.content);
|
|
260
|
-
chunks.push({
|
|
261
|
-
type: "task_update",
|
|
262
|
-
id: parsed.data.tool_use_id,
|
|
263
|
-
title,
|
|
264
|
-
status: parsed.data.is_error === true ? "error" : "complete",
|
|
265
|
-
...(output ? { output } : {}),
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
return chunks;
|
|
269
|
-
}
|
|
270
|
-
if (message.type === "system" && message.subtype === "local_command_output") {
|
|
271
|
-
// Slash-command relay (user ask 2026-07-18): a leading-slash prompt runs
|
|
272
|
-
// as a claude slash command CLI-side, and a local command's output
|
|
273
|
-
// (/usage, /context, ...) arrives as a non-streamed assistant message
|
|
274
|
-
// plus result.result with num_turns 0 - relayThread's no-text fallback
|
|
275
|
-
// posts that (verified live on SDK 0.3.214 + claude 2.1.214, fresh and
|
|
276
|
-
// resumed). This subtype is the SDK's documented wire surface for the
|
|
277
|
-
// same output, so map it too: if a claude update flips the engine to
|
|
278
|
-
// emitting it, the output still posts, and having posted text suppresses
|
|
279
|
-
// the result fallback so it never double-posts.
|
|
280
|
-
if (message.content.trim() === "") return [];
|
|
281
|
-
const sep = state.textStreamed ? "\n\n" : "";
|
|
282
|
-
state.textStreamed = true;
|
|
283
|
-
return [sep + message.content];
|
|
284
|
-
}
|
|
285
|
-
if (message.type === "result") {
|
|
286
|
-
const models = Object.keys(message.modelUsage).join(" ");
|
|
287
|
-
const cost = `$${message.total_cost_usd.toFixed(4)}`;
|
|
288
|
-
const secs = `${Math.round(message.duration_ms / 1000)}s`;
|
|
289
|
-
return [{
|
|
290
|
-
type: "task_update",
|
|
291
|
-
id: "turn",
|
|
292
|
-
title: "Turn",
|
|
293
|
-
// is_error can ride subtype "success" (a mid-turn usage limit does),
|
|
294
|
-
// and that turn must not render as complete.
|
|
295
|
-
status: message.subtype === "success" && message.is_error !== true ? "complete" : "error",
|
|
296
|
-
details: [models, cost, secs].filter((p) => p !== "").join(" "),
|
|
297
|
-
}];
|
|
298
|
-
}
|
|
299
|
-
return [];
|
|
300
|
-
}
|