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.
Files changed (58) hide show
  1. package/DESIGN.md +5 -31
  2. package/LICENSE +21 -0
  3. package/README.md +1 -2
  4. package/agent-plugin/agents/tokenmaxxing-claude.md +43 -0
  5. package/agent-plugin/agents/tokenmaxxing-codex.md +40 -0
  6. package/agent-plugin/bin/tokenmaxxing-mcp +7 -0
  7. package/agent-plugin/hooks/cursor-relay.json +14 -0
  8. package/agent-plugin/mcp.json +10 -0
  9. package/agent-plugin/plugin.json +20 -0
  10. package/agent-plugin/skills/codex-pool/SKILL.md +23 -0
  11. package/agent-plugin/skills/codex-pool/references/codex.md +5 -0
  12. package/agent-plugin/skills/credentials-hygiene/SKILL.md +26 -0
  13. package/agent-plugin/skills/credentials-hygiene/references/credentials.md +6 -0
  14. package/agent-plugin/skills/doctor-diagnostics/SKILL.md +26 -0
  15. package/agent-plugin/skills/doctor-diagnostics/references/troubleshooting.md +5 -0
  16. package/agent-plugin/skills/pool-status/SKILL.md +27 -0
  17. package/agent-plugin/skills/pool-status/references/commands.md +8 -0
  18. package/agent-plugin/skills/relay-session/SKILL.md +118 -0
  19. package/agent-plugin/skills/relay-session/references/ipc.md +23 -0
  20. package/agent-plugin/skills/safe-contribution/SKILL.md +27 -0
  21. package/agent-plugin/skills/safe-contribution/references/ship.md +5 -0
  22. package/agent-plugin/skills/sdk-pairing/SKILL.md +33 -0
  23. package/agent-plugin/skills/sdk-pairing/references/sdk.md +6 -0
  24. package/agent-plugin/skills/switching-policy/SKILL.md +29 -0
  25. package/agent-plugin/skills/switching-policy/references/policy.md +7 -0
  26. package/package.json +3 -5
  27. package/src/cli/codexinit.ts +11 -2
  28. package/src/cli/init.ts +9 -3
  29. package/src/cli/relay.ts +323 -0
  30. package/src/entries/codexstophook.ts +10 -0
  31. package/src/entries/mcp.ts +288 -0
  32. package/src/entries/relaypermission.ts +105 -0
  33. package/src/entries/stophook.ts +11 -0
  34. package/src/lib/decide.ts +2 -4
  35. package/src/lib/install.ts +61 -7
  36. package/src/lib/lock.ts +3 -7
  37. package/src/lib/log.ts +8 -11
  38. package/src/lib/paths.ts +3 -9
  39. package/src/lib/relay/config.ts +84 -0
  40. package/src/lib/relay/decide.ts +75 -0
  41. package/src/lib/relay/gc.ts +80 -0
  42. package/src/lib/relay/install.ts +143 -0
  43. package/src/lib/relay/markers.ts +148 -0
  44. package/src/lib/relay/modes.ts +82 -0
  45. package/src/lib/relay/protocol.ts +61 -0
  46. package/src/lib/relay/registry.ts +175 -0
  47. package/src/lib/relay/tmux.ts +109 -0
  48. package/src/lib/relay/turn.ts +137 -0
  49. package/src/lib/relay/worker.ts +141 -0
  50. package/src/lib/usage.ts +6 -5
  51. package/src/main.ts +6 -6
  52. package/src/cli/serve.ts +0 -1790
  53. package/src/lib/slackbridge.ts +0 -1363
  54. package/src/lib/slackstate.ts +0 -352
  55. package/src/lib/slackstream.ts +0 -300
  56. package/src/serve-plugin/.claude-plugin/plugin.json +0 -4
  57. package/src/serve-plugin/skills/ask-the-user/SKILL.md +0 -41
  58. package/src/serve-plugin/skills/serve-session/SKILL.md +0 -50
@@ -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
- }
@@ -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
- }
@@ -1,4 +0,0 @@
1
- {
2
- "name": "tokenmaxxing",
3
- "description": "Skills for Claude Code sessions relayed through tokenmaxxing serve (the Slack bridge)"
4
- }