tokenmaxxing 0.19.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/DESIGN.md +33 -24
  2. package/README.md +4 -4
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +1 -0
  5. package/src/cli/auth.ts +25 -14
  6. package/src/cli/check.ts +3 -2
  7. package/src/cli/codexadd.ts +44 -40
  8. package/src/cli/codexinit.ts +59 -12
  9. package/src/cli/codexswitch.ts +15 -1
  10. package/src/cli/config.ts +10 -1
  11. package/src/cli/doctor.ts +3 -3
  12. package/src/cli/init.ts +54 -32
  13. package/src/cli/onboard.ts +62 -45
  14. package/src/cli/render.ts +0 -16
  15. package/src/cli/rm.ts +40 -2
  16. package/src/cli/serve.ts +629 -115
  17. package/src/cli/status.ts +69 -23
  18. package/src/cli/switch.ts +54 -19
  19. package/src/entries/codexstophook.ts +123 -4
  20. package/src/entries/codexsupervisor.ts +87 -13
  21. package/src/entries/sessionstart.ts +1 -1
  22. package/src/entries/statusline.ts +56 -20
  23. package/src/entries/stophook.ts +23 -9
  24. package/src/entries/supervisor.ts +134 -18
  25. package/src/lib/atomic.ts +28 -6
  26. package/src/lib/claudebin.ts +2 -2
  27. package/src/lib/claudejson.ts +5 -5
  28. package/src/lib/claudelock.ts +112 -37
  29. package/src/lib/codexauth.ts +10 -2
  30. package/src/lib/codexbin.ts +1 -1
  31. package/src/lib/codexdecide.ts +149 -19
  32. package/src/lib/codexpick.ts +17 -6
  33. package/src/lib/codexpresence.ts +59 -21
  34. package/src/lib/codexsample.ts +17 -8
  35. package/src/lib/codexswap.ts +10 -1
  36. package/src/lib/credstore.ts +6 -2
  37. package/src/lib/decide.ts +114 -42
  38. package/src/lib/install.ts +125 -17
  39. package/src/lib/keychain.ts +41 -15
  40. package/src/lib/lock.ts +57 -35
  41. package/src/lib/log.ts +36 -7
  42. package/src/lib/oauth.ts +18 -11
  43. package/src/lib/paths.ts +17 -11
  44. package/src/lib/picker.ts +11 -3
  45. package/src/lib/proc.ts +37 -0
  46. package/src/lib/sample.ts +91 -31
  47. package/src/lib/sessions.ts +23 -1
  48. package/src/lib/settings.ts +59 -18
  49. package/src/lib/slackbridge.ts +574 -81
  50. package/src/lib/slackstate.ts +159 -12
  51. package/src/lib/slackstream.ts +123 -20
  52. package/src/lib/state.ts +131 -35
  53. package/src/lib/swap.ts +109 -47
  54. package/src/lib/types.ts +79 -37
  55. package/src/lib/usage.ts +114 -16
  56. package/src/main.ts +61 -7
  57. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  58. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  59. package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
@@ -4,8 +4,15 @@
4
4
  // claude session id + cwd a Slack thread resumes into (resume is cwd-keyed, so
5
5
  // the cwd must stay byte-stable for the thread's whole life). A present but
6
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.
7
14
 
8
- import { existsSync, readFileSync, readdirSync } from "node:fs";
15
+ import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
9
16
  import { join } from "node:path";
10
17
  import { z } from "zod";
11
18
  import { paths } from "./paths.ts";
@@ -14,15 +21,13 @@ import { writeFileAtomic } from "./atomic.ts";
14
21
  /** Claude Agent SDK permission modes an unattended bridge may run under.
15
22
  * acceptEdits (default) auto-approves file edits but not arbitrary Bash;
16
23
  * bypassPermissions is full autonomy - a per-link, user-chosen risk posture. */
17
- export const ServePermissionModeSchema = z.enum(["default", "acceptEdits", "bypassPermissions", "dontAsk", "plan"]);
24
+ const ServePermissionModeSchema = z.enum(["default", "acceptEdits", "bypassPermissions", "dontAsk", "plan"]);
18
25
 
19
26
  export const SlackLinkSchema = z.object({
20
27
  /** Slack channel id (C.../G...). Names are not stored - ids are stable. */
21
28
  channel: z.string(),
22
29
  /** absolute path of the git repo this channel drives. */
23
30
  repo: z.string(),
24
- /** run each thread in its own git worktree (user default 2026-07-18: true). */
25
- worktree: z.boolean().default(true),
26
31
  permissionMode: ServePermissionModeSchema.default("acceptEdits"),
27
32
  /** optional model override for this repo's sessions. */
28
33
  model: z.string().optional(),
@@ -32,10 +37,36 @@ export type SlackLink = z.infer<typeof SlackLinkSchema>;
32
37
  export const SlackConfigSchema = z.object({
33
38
  botToken: z.string().startsWith("xoxb-"),
34
39
  appToken: z.string().startsWith("xapp-"),
40
+ /** the home workspace's team id (auth.test), the reference the external-author
41
+ * guard compares message origins against; captured at setup and ensured at
42
+ * daemon start, so it is only absent in configs saved before the guard. */
43
+ workspaceTeamId: z.string().optional(),
35
44
  links: z.array(SlackLinkSchema).default([]),
36
45
  });
37
46
  export type SlackConfig = z.infer<typeof SlackConfigSchema>;
38
47
 
48
+ /** Team-origin fields Slack stamps on message/mention event payloads. */
49
+ const MessageOriginSchema = z.looseObject({
50
+ source_team: z.string().optional(),
51
+ team: z.string().optional(),
52
+ team_id: z.string().optional(),
53
+ user_team: z.string().optional(),
54
+ });
55
+
56
+ /** Outsiders must not drive sessions (owner rule 2026-07-16, ported from Slaude
57
+ * at its shutdown): in Slack Connect shared channels an external-workspace
58
+ * author carries team fields (`user_team` / `source_team` / `team`) that
59
+ * differ from the home workspace. Fail closed: reject a payload whose author
60
+ * origin is unreadable, absent, or disagrees on ANY present field. */
61
+ export function isOutsideAuthor(input: { raw: unknown; workspaceTeamId: string }): boolean {
62
+ const parsed = MessageOriginSchema.safeParse(input.raw);
63
+ if (!parsed.success) return true;
64
+ const fields = [parsed.data.user_team, parsed.data.source_team, parsed.data.team, parsed.data.team_id]
65
+ .filter((team): team is string => team !== undefined);
66
+ if (fields.length === 0) return true;
67
+ return fields.some((team) => team !== input.workspaceTeamId);
68
+ }
69
+
39
70
  export function loadSlackConfig(): SlackConfig | null {
40
71
  if (!existsSync(paths.slackJson)) return null;
41
72
  return SlackConfigSchema.parse(JSON.parse(readFileSync(paths.slackJson, "utf8")));
@@ -55,15 +86,46 @@ export function isChannelId(s: string): boolean {
55
86
 
56
87
  // ---- per-thread session records -------------------------------------------
57
88
 
89
+ /** Durable in-flight-turn marker. Written before a turn spawns and removed
90
+ * when it returns, so a marker that survives into the next daemon start means
91
+ * a restart killed the turn mid-run - startup then notifies the thread and
92
+ * auto-resumes it (2026-07-18 incident: a redeploy silently killed a ship
93
+ * turn 8 minutes in). resumeCount caps the retries: every resumed attempt
94
+ * spends real quota, so a turn that keeps dying must not retry forever. */
95
+ const ActiveTurnSchema = z.object({
96
+ /** the original folded prompt, replayed verbatim when the killed turn never
97
+ * reached its init message (sessionId still null = nothing to resume). */
98
+ prompt: z.string(),
99
+ startedAt: z.string(),
100
+ resumeCount: z.number().int().nonnegative(),
101
+ /** the DETACHED claude child's process-group id, persisted at spawn: an
102
+ * uncatchable daemon death (SIGKILL, crash) skips the exit hook that kills
103
+ * the group, so recovery must reap a surviving orphan before resuming -
104
+ * two claude processes must never share the thread's cwd and session.
105
+ * Absent until the spawn callback fires. */
106
+ pid: z.number().int().positive().optional(),
107
+ /** the ps lstart token captured for that pid at spawn: pid + start time is
108
+ * the process identity, so recovery signals only a verified match - a
109
+ * recycled pid must never get the kill (cubic review catch; this machine
110
+ * runs the user's real claude sessions). Absent = identity unverifiable =
111
+ * never signal. */
112
+ pidStartedAt: z.string().optional(),
113
+ });
114
+ export type ActiveTurn = z.infer<typeof ActiveTurnSchema>;
115
+
58
116
  export const SlackThreadSchema = z.object({
59
117
  /** chat-sdk thread id, e.g. "slack:C0123:1721300000.123456". */
60
118
  threadId: z.string(),
61
119
  repo: z.string(),
62
- /** the dir every turn of this thread runs in (repo or its worktree). */
120
+ /** the dir every turn of this thread runs in; resume is cwd-keyed, so it
121
+ * stays byte-stable for the thread's whole life (records from the removed
122
+ * worktree-per-thread era pin their old worktree and keep working). */
63
123
  cwd: z.string(),
64
124
  /** claude session id; null until the first turn's init message arrives. */
65
125
  sessionId: z.string().nullable(),
66
126
  createdAt: z.string(),
127
+ /** present only while a turn is running (or was killed mid-run). */
128
+ activeTurn: ActiveTurnSchema.optional(),
67
129
  });
68
130
  export type SlackThread = z.infer<typeof SlackThreadSchema>;
69
131
 
@@ -88,6 +150,12 @@ export function saveSlackThread(t: SlackThread): void {
88
150
  writeFileAtomic(threadFile(t.threadId), JSON.stringify(SlackThreadSchema.parse(t), null, 2) + "\n");
89
151
  }
90
152
 
153
+ /** Drop a finished thread's record: the thread-level GC (cleanupThread). */
154
+ export function deleteSlackThread(threadId: string): void {
155
+ const f = threadFile(threadId);
156
+ if (existsSync(f)) unlinkSync(f);
157
+ }
158
+
91
159
  export function listSlackThreads(): SlackThread[] {
92
160
  if (!existsSync(paths.slackThreadsDir)) return [];
93
161
  const out: SlackThread[] = [];
@@ -98,6 +166,59 @@ export function listSlackThreads(): SlackThread[] {
98
166
  return out;
99
167
  }
100
168
 
169
+ // ---- interrupted-turn resume decision (unit-tested) ------------------------
170
+
171
+ /** Resumed attempts spend real quota, so an interrupted turn retries at most
172
+ * this many times before startup gives up and asks for a human message. */
173
+ export const MAX_TURN_RESUMES = 3;
174
+
175
+ const ResumeDecisionSchema = z.union([
176
+ z.object({ kind: z.literal("give-up"), notice: z.string() }),
177
+ z.object({
178
+ kind: z.literal("resume"),
179
+ notice: z.string(),
180
+ prompt: z.string(),
181
+ /** session to resume; null = the kill landed before init assigned one,
182
+ * so the original prompt replays in a fresh session. */
183
+ sessionId: z.string().nullable(),
184
+ /** the incremented marker to persist BEFORE the resumed turn spawns, so
185
+ * a kill during the resume still counts toward the cap. */
186
+ marker: ActiveTurnSchema,
187
+ }),
188
+ ]);
189
+ export type ResumeDecision = z.infer<typeof ResumeDecisionSchema>;
190
+
191
+ /** What startup should do with a thread whose activeTurn marker survived the
192
+ * previous daemon. Returns null for threads with no surviving marker. */
193
+ export function resumeDecision(record: SlackThread): ResumeDecision | null {
194
+ const turn = record.activeTurn;
195
+ if (!turn) return null;
196
+ if (turn.resumeCount >= MAX_TURN_RESUMES) {
197
+ return {
198
+ kind: "give-up",
199
+ notice: `a daemon restart interrupted this turn, and ${MAX_TURN_RESUMES} resume attempts were interrupted too - giving up. Send a new message to continue.`,
200
+ };
201
+ }
202
+ const marker = { ...turn, resumeCount: turn.resumeCount + 1 };
203
+ const attempt = marker.resumeCount > 1 ? ` (attempt ${marker.resumeCount}/${MAX_TURN_RESUMES})` : "";
204
+ if (record.sessionId === null) {
205
+ return {
206
+ kind: "resume",
207
+ notice: `a daemon restart interrupted this turn before its session opened - starting it over${attempt}`,
208
+ prompt: turn.prompt,
209
+ sessionId: null,
210
+ marker,
211
+ };
212
+ }
213
+ return {
214
+ kind: "resume",
215
+ notice: `a daemon restart interrupted this turn - resuming${attempt}`,
216
+ prompt: `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}`,
217
+ sessionId: record.sessionId,
218
+ marker,
219
+ };
220
+ }
221
+
101
222
  // ---- pure link edits (unit-tested) ----------------------------------------
102
223
 
103
224
  export function upsertLink(cfg: SlackConfig, link: SlackLink): SlackConfig {
@@ -121,11 +242,37 @@ export function bareChannelId(id: string): string {
121
242
  return id.startsWith("slack:") ? id.slice("slack:".length) : id;
122
243
  }
123
244
 
124
- /** Strip a leading Slack mention token ("<@U0123> rest") from message text. */
125
- export function stripLeadingMention(text: string): string {
126
- const trimmed = text.trimStart();
127
- if (!trimmed.startsWith("<@")) return text.trim();
128
- const close = trimmed.indexOf(">");
129
- if (close < 0) return text.trim();
130
- return trimmed.slice(close + 1).trim();
245
+ /**
246
+ * Strip a leading Slack mention OF THE BOT from message text. Two forms: raw
247
+ * mrkdwn ("<@U0123> rest") and the bare form the Chat SDK's incoming
248
+ * mrkdwn->markdown normalization produces ("@U0123 rest") - observed live
249
+ * 2026-07-18 when a relayed prompt arrived starting "@U0BHS1YKNSK". Only a
250
+ * token whose id equals botUserId is stripped (review catch 2026-07-18:
251
+ * handleTurn runs this over every subscribed message, so a follow-up starting
252
+ * with a colleague's mention must keep it - the prompt would otherwise lose
253
+ * who it is about). An unknown botUserId strips nothing: without the id we
254
+ * cannot tell the bot's mention from anyone else's.
255
+ */
256
+ export function stripLeadingMention(input: { text: string; botUserId: string | null }): string {
257
+ const trimmed = input.text.trimStart();
258
+ if (input.botUserId === null) return input.text.trim();
259
+ if (trimmed.startsWith("<@")) {
260
+ const close = trimmed.indexOf(">");
261
+ if (close < 0) return input.text.trim();
262
+ const [id] = trimmed.slice(2, close).split("|");
263
+ if (id === input.botUserId) return trimmed.slice(close + 1).trim();
264
+ return input.text.trim();
265
+ }
266
+ if (trimmed.startsWith("@U") || trimmed.startsWith("@W")) {
267
+ let end = 1;
268
+ while (end < trimmed.length) {
269
+ const ch = trimmed[end] ?? "";
270
+ if ((ch >= "0" && ch <= "9") || (ch >= "A" && ch <= "Z")) end += 1;
271
+ else break;
272
+ }
273
+ const next = trimmed[end];
274
+ const atBoundary = next === undefined || next === " " || next === "\n" || next === "\t";
275
+ if (end - 1 >= 2 && atBoundary && trimmed.slice(1, end) === input.botUserId) return trimmed.slice(end).trim();
276
+ }
277
+ return input.text.trim();
131
278
  }
@@ -1,22 +1,45 @@
1
1
  // Maps Claude Agent SDK messages onto Chat SDK stream chunks so a relayed
2
2
  // Slack turn shows the agent's process natively: task cards for thinking and
3
3
  // tool calls (pending -> in_progress -> complete/error), streamed text via
4
- // markdown_text, and a closing turn card with model/cost/duration. Structured
5
- // chunks render only when the Slack app has the agent feature + assistant:write
6
- // (the adapter drops them gracefully otherwise); plain text streams either way.
4
+ // markdown_text, and a closing turn card with model/cost/duration. TodoWrite
5
+ // is special-cased into one stable "Todos" checklist card per stream that
6
+ // updates in place as items progress. Structured chunks render only when the
7
+ // Slack app has the agent feature + assistant:write (the adapter drops them
8
+ // gracefully otherwise); plain text streams either way.
7
9
 
10
+ import { truncate } from "es-toolkit/compat";
8
11
  import { z } from "zod";
9
12
  import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
10
13
  import type { StreamChunk } from "chat";
11
14
 
12
15
  const DETAILS_MAX = 400;
13
16
  const OUTPUT_MAX = 600;
17
+ /** Slack docs claim a 256-char cap on task_update chunks, yet 400/600-char
18
+ * card fields render fine (live-verified 2026-07-18). 600 is the largest
19
+ * size observed rendering; staying inside that envelope beats trusting an
20
+ * unverified bigger budget (a ~15-line checklist still fits). */
21
+ const TODO_DETAILS_MAX = 600;
22
+
23
+ /** Natural activity labels for tool cards (user ask 2026-07-18: these five raw
24
+ * names read awkwardly in Slack). Every other tool name renders verbatim; a
25
+ * Map keeps prototype keys like "toString" from shadowing the fallback. */
26
+ const TOOL_TITLES = new Map([
27
+ ["TaskCreate", "Creating task"],
28
+ ["TaskUpdate", "Updating task"],
29
+ ["ToolSearch", "Loading tools"],
30
+ ["WebFetch", "Fetching page"],
31
+ ["WebSearch", "Searching the web"],
32
+ ]);
33
+
34
+ export function toolCardTitle(name: string): string {
35
+ return TOOL_TITLES.get(name) ?? name;
36
+ }
14
37
 
15
38
  /** Tool-input fields worth showing on a task card, most human-readable first. */
16
39
  const SUMMARY_FIELDS = ["command", "description", "file_path", "pattern", "prompt", "query", "url"] as const;
17
40
 
18
41
  const OpenBlockSchema = z.object({
19
- kind: z.enum(["thinking", "tool"]),
42
+ kind: z.enum(["thinking", "tool", "todo"]),
20
43
  id: z.string(),
21
44
  title: z.string(),
22
45
  /** accumulated thinking text or partial tool-input JSON. */
@@ -24,11 +47,16 @@ const OpenBlockSchema = z.object({
24
47
  });
25
48
  type OpenBlock = z.infer<typeof OpenBlockSchema>;
26
49
 
27
- export const StreamMapStateSchema = z.object({
50
+ const StreamMapStateSchema = z.object({
28
51
  /** open content blocks by stream index. */
29
52
  open: z.record(z.string(), OpenBlockSchema),
30
53
  /** tool_use id -> tool name, for labeling the eventual tool_result. */
31
54
  toolTitles: z.record(z.string(), z.string()),
55
+ /** TodoWrite tool_use id -> stable checklist-card id: a successful result
56
+ * is suppressed as noise, but a FAILED write must flip the card to error
57
+ * (the optimistic checklist would otherwise claim a state that never took
58
+ * effect). */
59
+ todoCards: z.record(z.string(), z.string()),
32
60
  thinkingCount: z.number(),
33
61
  /** reply text streamed since the last segment break. */
34
62
  textSinceBreak: z.boolean(),
@@ -36,22 +64,17 @@ export const StreamMapStateSchema = z.object({
36
64
  export type StreamMapState = z.infer<typeof StreamMapStateSchema>;
37
65
 
38
66
  export function newStreamMapState(): StreamMapState {
39
- return { open: {}, toolTitles: {}, thinkingCount: 0, textSinceBreak: false };
67
+ return { open: {}, toolTitles: {}, todoCards: {}, thinkingCount: 0, textSinceBreak: false };
40
68
  }
41
69
 
42
70
  /** Emitted when a tool starts after streamed reply text: the bridge closes the
43
71
  * current Slack message there and posts the rest as a new one, mirroring how
44
72
  * an agent turn reads as separate messages around its tool runs. */
45
73
  export const SegmentBreakSchema = z.object({ type: z.literal("segment_break") });
46
- export type SegmentBreak = z.infer<typeof SegmentBreakSchema>;
47
74
 
48
- export const StreamPartSchema = z.union([z.string(), z.custom<StreamChunk>(), SegmentBreakSchema]);
75
+ const StreamPartSchema = z.union([z.string(), z.custom<StreamChunk>(), SegmentBreakSchema]);
49
76
  export type StreamPart = z.infer<typeof StreamPartSchema>;
50
77
 
51
- function truncate(input: { text: string; max: number }): string {
52
- return input.text.length > input.max ? `${input.text.slice(0, input.max)}...` : input.text;
53
- }
54
-
55
78
  /** One human line out of a tool-input JSON blob; null when nothing fits. */
56
79
  export function toolInputSummary(rawJson: string): string | null {
57
80
  let parsed: unknown;
@@ -64,10 +87,51 @@ export function toolInputSummary(rawJson: string): string | null {
64
87
  if (!obj.success) return null;
65
88
  for (const field of SUMMARY_FIELDS) {
66
89
  const value = z.string().min(1).safeParse(obj.data[field]);
67
- if (value.success) return truncate({ text: value.data, max: DETAILS_MAX });
90
+ if (value.success) return truncate(value.data, { length: DETAILS_MAX });
68
91
  }
69
92
  const compact = JSON.stringify(parsed);
70
- return compact === "{}" ? null : truncate({ text: compact, max: DETAILS_MAX });
93
+ return compact === "{}" ? null : truncate(compact, { length: DETAILS_MAX });
94
+ }
95
+
96
+ const TodoItemSchema = z.object({
97
+ content: z.string(),
98
+ status: z.enum(["pending", "in_progress", "completed"]),
99
+ activeForm: z.string(),
100
+ });
101
+ const TodoListSchema = z.object({ todos: z.array(TodoItemSchema) });
102
+
103
+ /** Same status iconography the Chat SDK's own Plan object renders with. */
104
+ const TODO_ICONS = { completed: "✅", in_progress: "🔄", pending: "⬜" } as const;
105
+
106
+ /** The stable checklist-card id: every TodoWrite in the same stream updates
107
+ * one card in place instead of stacking a new card per call. */
108
+ function todoCardId(parent: string | null): string {
109
+ return parent === null ? "todos" : `todos-${parent}`;
110
+ }
111
+
112
+ /** Checklist text out of a TodoWrite input blob; null when empty or junk.
113
+ * The in-progress item shows its activeForm ("Running tests") so the card
114
+ * reads as live narration, not a static list. An empty todos list maps to
115
+ * null on purpose: claude clears the list only after everything completed,
116
+ * and skipping that update keeps the finished all-checked card visible
117
+ * instead of blanking it (intentional tradeoff, PR #5 review). */
118
+ export function todoChecklist(rawJson: string): { text: string; allDone: boolean } | null {
119
+ let parsed: unknown;
120
+ try {
121
+ parsed = JSON.parse(rawJson);
122
+ } catch {
123
+ return null;
124
+ }
125
+ const list = TodoListSchema.safeParse(parsed);
126
+ if (!list.success || list.data.todos.length === 0) return null;
127
+ const lines = list.data.todos.map((t) => {
128
+ const label = t.status === "in_progress" && t.activeForm !== "" ? t.activeForm : t.content;
129
+ return `${TODO_ICONS[t.status]} ${label}`;
130
+ });
131
+ return {
132
+ text: truncate(lines.join("\n"), { length: TODO_DETAILS_MAX }),
133
+ allDone: list.data.todos.every((t) => t.status === "completed"),
134
+ };
71
135
  }
72
136
 
73
137
  const ToolResultBlockSchema = z.object({
@@ -90,7 +154,7 @@ function resultText(content: z.infer<typeof ToolResultBlockSchema>["content"]):
90
154
  .join("\n")
91
155
  : content;
92
156
  const trimmed = joined.trim();
93
- return trimmed === "" ? undefined : truncate({ text: trimmed, max: OUTPUT_MAX });
157
+ return trimmed === "" ? undefined : truncate(trimmed, { length: OUTPUT_MAX });
94
158
  }
95
159
 
96
160
  /**
@@ -118,9 +182,20 @@ export function agentEventChunks(input: { state: StreamMapState; message: SDKMes
118
182
  }
119
183
  if (event.content_block.type === "tool_use") {
120
184
  const { id, name } = event.content_block;
121
- state.open[key] = { kind: "tool", id, title: name, acc: "" };
122
- state.toolTitles[id] = name;
123
- const card: StreamPart = { type: "task_update", id, title: name, status: "in_progress" };
185
+ if (name === "TodoWrite") {
186
+ // bookkeeping, not a real tool run: no card until the list arrives,
187
+ // no segment break, and no toolTitles entry (its "Todos have been
188
+ // modified" success result is noise; failures still surface via
189
+ // todoCards below).
190
+ const cardId = todoCardId(message.parent_tool_use_id);
191
+ state.open[key] = { kind: "todo", id: cardId, title: "Todos", acc: "" };
192
+ state.todoCards[id] = cardId;
193
+ return [];
194
+ }
195
+ const title = toolCardTitle(name);
196
+ state.open[key] = { kind: "tool", id, title, acc: "" };
197
+ state.toolTitles[id] = title;
198
+ const card: StreamPart = { type: "task_update", id, title, status: "in_progress" };
124
199
  if (isMain && state.textSinceBreak) {
125
200
  state.textSinceBreak = false;
126
201
  return [{ type: "segment_break" }, card];
@@ -149,7 +224,12 @@ export function agentEventChunks(input: { state: StreamMapState; message: SDKMes
149
224
  if (!open) return [];
150
225
  delete state.open[key];
151
226
  if (open.kind === "thinking") {
152
- return [{ type: "task_update", id: open.id, title: open.title, status: "complete", details: truncate({ text: open.acc.trim(), max: DETAILS_MAX }) }];
227
+ return [{ type: "task_update", id: open.id, title: open.title, status: "complete", details: truncate(open.acc.trim(), { length: DETAILS_MAX }) }];
228
+ }
229
+ if (open.kind === "todo") {
230
+ const list = todoChecklist(open.acc);
231
+ if (list === null) return [];
232
+ return [{ type: "task_update", id: open.id, title: open.title, status: list.allDone ? "complete" : "in_progress", details: list.text }];
153
233
  }
154
234
  const details = toolInputSummary(open.acc);
155
235
  return [{ type: "task_update", id: open.id, title: open.title, status: "in_progress", ...(details ? { details } : {}) }];
@@ -163,6 +243,13 @@ export function agentEventChunks(input: { state: StreamMapState; message: SDKMes
163
243
  for (const block of content) {
164
244
  const parsed = ToolResultBlockSchema.safeParse(block);
165
245
  if (!parsed.success) continue;
246
+ const todoCard = state.todoCards[parsed.data.tool_use_id];
247
+ if (todoCard !== undefined) {
248
+ if (parsed.data.is_error !== true) continue;
249
+ const output = resultText(parsed.data.content);
250
+ chunks.push({ type: "task_update", id: todoCard, title: "Todos", status: "error", ...(output ? { output } : {}) });
251
+ continue;
252
+ }
166
253
  const title = state.toolTitles[parsed.data.tool_use_id];
167
254
  if (title === undefined) continue;
168
255
  const output = resultText(parsed.data.content);
@@ -176,6 +263,20 @@ export function agentEventChunks(input: { state: StreamMapState; message: SDKMes
176
263
  }
177
264
  return chunks;
178
265
  }
266
+ if (message.type === "system" && message.subtype === "local_command_output") {
267
+ // Slash-command relay (user ask 2026-07-18): a leading-slash prompt runs
268
+ // as a claude slash command CLI-side, and a local command's output
269
+ // (/usage, /context, ...) arrives as a non-streamed assistant message
270
+ // plus result.result with num_turns 0 - relayThread's no-text fallback
271
+ // posts that (verified live on SDK 0.3.214 + claude 2.1.214, fresh and
272
+ // resumed). This subtype is the SDK's documented wire surface for the
273
+ // same output, so map it too: if a claude update flips the engine to
274
+ // emitting it, the output still posts, and having posted text suppresses
275
+ // the result fallback so it never double-posts.
276
+ if (message.content.trim() === "") return [];
277
+ state.textSinceBreak = true;
278
+ return [message.content];
279
+ }
179
280
  if (message.type === "result") {
180
281
  const models = Object.keys(message.modelUsage).join(" ");
181
282
  const cost = `$${message.total_cost_usd.toFixed(4)}`;
@@ -184,7 +285,9 @@ export function agentEventChunks(input: { state: StreamMapState; message: SDKMes
184
285
  type: "task_update",
185
286
  id: "turn",
186
287
  title: "Turn",
187
- status: message.subtype === "success" ? "complete" : "error",
288
+ // is_error can ride subtype "success" (a mid-turn usage limit does),
289
+ // and that turn must not render as complete.
290
+ status: message.subtype === "success" && message.is_error !== true ? "complete" : "error",
188
291
  details: [models, cost, secs].filter((p) => p !== "").join(" "),
189
292
  }];
190
293
  }