tokenmaxxing 0.19.0 → 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.
- package/DESIGN.md +34 -23
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/cli/add.ts +1 -0
- package/src/cli/auth.ts +25 -14
- package/src/cli/check.ts +3 -2
- package/src/cli/codexadd.ts +44 -40
- package/src/cli/codexinit.ts +59 -12
- package/src/cli/codexswitch.ts +15 -1
- package/src/cli/config.ts +10 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/init.ts +54 -32
- package/src/cli/onboard.ts +62 -45
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +650 -78
- package/src/cli/status.ts +69 -23
- package/src/cli/switch.ts +54 -19
- package/src/entries/codexstophook.ts +123 -4
- package/src/entries/codexsupervisor.ts +87 -13
- package/src/entries/sessionstart.ts +1 -1
- package/src/entries/statusline.ts +56 -20
- package/src/entries/stophook.ts +23 -9
- package/src/entries/supervisor.ts +134 -18
- package/src/lib/atomic.ts +28 -6
- package/src/lib/claudebin.ts +2 -2
- package/src/lib/claudejson.ts +5 -5
- package/src/lib/claudelock.ts +112 -37
- package/src/lib/codexauth.ts +10 -2
- package/src/lib/codexbin.ts +1 -1
- package/src/lib/codexdecide.ts +149 -19
- package/src/lib/codexpick.ts +17 -6
- package/src/lib/codexpresence.ts +59 -21
- package/src/lib/codexsample.ts +17 -8
- package/src/lib/codexswap.ts +10 -1
- package/src/lib/credstore.ts +6 -2
- package/src/lib/decide.ts +114 -42
- package/src/lib/install.ts +125 -17
- package/src/lib/keychain.ts +41 -15
- package/src/lib/lock.ts +57 -35
- package/src/lib/log.ts +36 -7
- package/src/lib/oauth.ts +18 -11
- package/src/lib/paths.ts +17 -11
- package/src/lib/picker.ts +11 -3
- package/src/lib/proc.ts +37 -0
- package/src/lib/sample.ts +91 -31
- package/src/lib/sessions.ts +23 -1
- package/src/lib/settings.ts +59 -18
- package/src/lib/slackbridge.ts +583 -76
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +127 -21
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +79 -37
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +61 -7
- package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
- package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/lib/slackstate.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
}
|
package/src/lib/slackstream.ts
CHANGED
|
@@ -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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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];
|
|
@@ -133,7 +208,10 @@ export function agentEventChunks(input: { state: StreamMapState; message: SDKMes
|
|
|
133
208
|
const open = state.open[`${message.parent_tool_use_id ?? "main"}:${event.index}`];
|
|
134
209
|
if (event.delta.type === "text_delta") {
|
|
135
210
|
if (!isMain) return [];
|
|
136
|
-
|
|
211
|
+
// whitespace-only deltas must not count as reply text: a "\n\n"
|
|
212
|
+
// before a tool call would otherwise break the segment and strand a
|
|
213
|
+
// near-blank Slack message.
|
|
214
|
+
if (event.delta.text.trim() !== "") state.textSinceBreak = true;
|
|
137
215
|
return [event.delta.text];
|
|
138
216
|
}
|
|
139
217
|
if (event.delta.type === "thinking_delta" && open) open.acc += event.delta.thinking;
|
|
@@ -146,7 +224,12 @@ export function agentEventChunks(input: { state: StreamMapState; message: SDKMes
|
|
|
146
224
|
if (!open) return [];
|
|
147
225
|
delete state.open[key];
|
|
148
226
|
if (open.kind === "thinking") {
|
|
149
|
-
return [{ type: "task_update", id: open.id, title: open.title, status: "complete", details: truncate(
|
|
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 }];
|
|
150
233
|
}
|
|
151
234
|
const details = toolInputSummary(open.acc);
|
|
152
235
|
return [{ type: "task_update", id: open.id, title: open.title, status: "in_progress", ...(details ? { details } : {}) }];
|
|
@@ -160,6 +243,13 @@ export function agentEventChunks(input: { state: StreamMapState; message: SDKMes
|
|
|
160
243
|
for (const block of content) {
|
|
161
244
|
const parsed = ToolResultBlockSchema.safeParse(block);
|
|
162
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
|
+
}
|
|
163
253
|
const title = state.toolTitles[parsed.data.tool_use_id];
|
|
164
254
|
if (title === undefined) continue;
|
|
165
255
|
const output = resultText(parsed.data.content);
|
|
@@ -173,6 +263,20 @@ export function agentEventChunks(input: { state: StreamMapState; message: SDKMes
|
|
|
173
263
|
}
|
|
174
264
|
return chunks;
|
|
175
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
|
+
}
|
|
176
280
|
if (message.type === "result") {
|
|
177
281
|
const models = Object.keys(message.modelUsage).join(" ");
|
|
178
282
|
const cost = `$${message.total_cost_usd.toFixed(4)}`;
|
|
@@ -181,7 +285,9 @@ export function agentEventChunks(input: { state: StreamMapState; message: SDKMes
|
|
|
181
285
|
type: "task_update",
|
|
182
286
|
id: "turn",
|
|
183
287
|
title: "Turn",
|
|
184
|
-
|
|
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",
|
|
185
291
|
details: [models, cost, secs].filter((p) => p !== "").join(" "),
|
|
186
292
|
}];
|
|
187
293
|
}
|