tokenmaxxing 0.18.0 → 0.19.1
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 +8 -3
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/serve.ts +149 -45
- package/src/lib/decide.ts +1 -1
- package/src/lib/slackbridge.ts +106 -19
- package/src/lib/slackstate.ts +6 -0
- package/src/lib/slackstream.ts +192 -0
- package/src/lib/usage.ts +1 -1
package/DESIGN.md
CHANGED
|
@@ -90,8 +90,13 @@ A local Socket Mode daemon (no public URL) that turns Slack threads into Claude
|
|
|
90
90
|
- **Config**: `slack.json` (0600 - it holds the xoxb-/xapp- tokens) with per-channel links `{channel, repo, worktree, permissionMode, model?}`. `serve setup` prints the app manifest (minimal scopes: app_mentions:read, channels:history, groups:history, chat:write, files:write, users:read + socket mode) and prompts for the tokens; `serve link <channel-id> <repo>` manages links (channel IDs only - names drift, ids don't).
|
|
91
91
|
- **Thread = session**: a bot mention in a linked channel subscribes the thread, creates `slack-worktrees/<threadKey>` (branch `tm-slack-<threadKey>` cut from the repo's HEAD; `--no-worktree` links run in the repo itself), and records `{threadId, cwd, sessionId}` under `slack-threads/`. Resume is cwd-keyed in claude, so the cwd stays byte-stable for the thread's life; worktrees are never auto-deleted (they hold the thread's work).
|
|
92
92
|
- **Turn = spawn**: each thread message runs ONE `query()` with `resume: sessionId` (never a persistent streaming query - the SDK subprocess reads credentials at spawn, so per-turn spawns are what let `ensureBestAccount()` land each turn on the freshest account, and the daemon can restart without losing threads). `stopHookCheck` rides along as the SDK Stop hook. Streamed `text_delta`s feed `thread.post(AsyncIterable)` (the adapter debounces edits); tool-only turns post the final result text.
|
|
93
|
-
- **Safety posture**: per-link `permissionMode`, default `acceptEdits`; `--dangerous` opts a link into `bypassPermissions
|
|
94
|
-
- **
|
|
93
|
+
- **Safety posture**: per-link `permissionMode`, default `acceptEdits`; `--yolo` (alias `--dangerous`) opts a link into `bypassPermissions`, and relayThread pairs it with the SDK's mandatory `allowDangerouslySkipPermissions: true` opt-in. `AskUserQuestion` is disallowed (unanswerable over Slack; the model asks in prose instead). Turn failures post a trimmed message-only diagnostic (never a raw error body).
|
|
94
|
+
- **Socket lifecycle**: `bot.initialize()` starts the persistent auto-reconnecting SocketModeClient; the daemon then just stays alive. The leased `startSocketModeListener` API must never be looped: it returns instantly without `waitUntil` and the loop starves the event loop (live incident 2026-07-18 - connected but silent).
|
|
95
|
+
- **Id mapping**: Chat SDK ids are adapter-prefixed (`thread.channelId` = `slack:C0123`, `thread.id` = `slack:C0123:<threadTs>`) while links store bare Slack ids - lookups strip the prefix via `bareChannelId`. Subscriptions live in the daemon's memory state, so every mention re-subscribes its thread; queue-skipped messages (`context.skipped`) fold into the next prompt, with the queue-entry TTL raised to 900s; unlinked-channel traffic logs `serve.unlinked_channel` and stays silent in Slack.
|
|
96
|
+
- **Restart resilience** (0.19.0, from the 2026-07-18 dead-thread incident: a deploy restart cut a turn mid-answer and left the thread deaf to follow-ups): startup re-subscribes every `slack-threads/` record straight on the state adapter (`state.subscribe(threadId)`; message routing checks `stateAdapter.isSubscribed`, verified in chat 4.34.0), so open threads survive restarts without needing a fresh mention. SIGTERM/SIGINT drains instead of dying: new turns are dropped loudly (`serve.drain_dropped`), tracked in-flight turns get up to 300s to finish, then `Chat.shutdown()`; a second signal forces exit. When a `thread.post` rejects mid-turn (e.g. Slack finalizes an idle stream: `message_not_in_streaming_state`), relayThread drops the dead segment so the rest of the turn opens fresh messages instead of vanishing.
|
|
97
|
+
- **Slack-native output hygiene**: relayed turns run a small standalone `systemPrompt` telling the model replies render as Slack markdown, never HTML (a live turn once answered with a literal `<br>`; the SDK's default system prompt is minimal since 0.1.0, so the string replaces nothing), and whitespace-only text deltas do not count as reply text for segment breaking (no stranded near-blank messages).
|
|
98
|
+
- **Agent representation** (`src/lib/slackstream.ts`): turns stream natively (`chat.startStream`, which works in channel threads regardless of the assistant:write scope): thinking and tool calls as task cards ("Thinking"/tool name/"Turn", input summary + truncated output), reply text as native markdown with rendered code fences, and a segment break whenever a tool starts after streamed text, so one turn posts as separate ordered Slack messages around its tool runs.
|
|
99
|
+
- **Live-verified end-to-end** (2026-07-18, #tokenmaxxing-dogfooding): mention opens worktree + session, replies stream, thread follow-ups resume with context, cards + fenced code render, segmentation and queue folding behave. Plus the hermetic suite: schemas/links, worktree idempotency, stream mapping, fail-fast paths.
|
|
95
100
|
|
|
96
101
|
---
|
|
97
102
|
|
|
@@ -124,7 +129,7 @@ A local Socket Mode daemon (no public URL) that turns Slack threads into Claude
|
|
|
124
129
|
---
|
|
125
130
|
|
|
126
131
|
## 8. Stack
|
|
127
|
-
TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` supervisor, the statusLine shim, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (the Stop path runs every turn; bun's start-up stays low-millisecond). Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). The supervisor needs a real PTY layer (spawn claude on a pty, forward resize/signals, restore mode between runs).
|
|
132
|
+
TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` supervisor, the statusLine shim, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (the Stop path runs every turn; bun's start-up stays low-millisecond). Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). Shipping is PR-based since 2026-07-18: work reaches main only through a pull request (branch, PR, CI green, review handled, merge), and a release is a PR-landed version bump followed by `gh release create` (details in AGENTS.md "Release and CI"). The supervisor needs a real PTY layer (spawn claude on a pty, forward resize/signals, restore mode between runs).
|
|
128
133
|
|
|
129
134
|
---
|
|
130
135
|
|
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ claude # use claude as always
|
|
|
45
45
|
| `tokenmaxxing status --force` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh |
|
|
46
46
|
| `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
|
|
47
47
|
| `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
|
|
48
|
-
| `tokenmaxxing serve` | Slack bridge daemon (Socket Mode, no public URL): `setup` prints the app manifest and stores the two tokens, `link <channel-id> <repo>` ties a channel to a repo, then mentioning the bot in that channel opens a Claude Code session per thread (own git worktree by default) and thread messages relay in and out |
|
|
48
|
+
| `tokenmaxxing serve` | Slack bridge daemon (Socket Mode, no public URL): `setup` prints the app manifest and stores the two tokens, `link <channel-id> <repo>` ties a channel to a repo (`--yolo` for full-autonomy bypassPermissions sessions), then mentioning the bot in that channel opens a Claude Code session per thread (own git worktree by default) and thread messages relay in and out |
|
|
49
49
|
| `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
|
|
50
50
|
| `tokenmaxxing rename [--codex] <sel> <label>` / `rm <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
|
|
51
51
|
| `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/serve.ts
CHANGED
|
@@ -14,12 +14,15 @@
|
|
|
14
14
|
// serve run the daemon
|
|
15
15
|
|
|
16
16
|
import { existsSync, realpathSync } from "node:fs";
|
|
17
|
-
import {
|
|
17
|
+
import { delay } from "es-toolkit";
|
|
18
|
+
import { Chat, type StreamChunk } from "chat";
|
|
18
19
|
import { createSlackAdapter } from "@chat-adapter/slack";
|
|
19
20
|
import { createMemoryState } from "@chat-adapter/state-memory";
|
|
20
21
|
import {
|
|
22
|
+
bareChannelId,
|
|
21
23
|
isChannelId,
|
|
22
24
|
linkForChannel,
|
|
25
|
+
listSlackThreads,
|
|
23
26
|
loadSlackConfig,
|
|
24
27
|
loadSlackThread,
|
|
25
28
|
removeLink,
|
|
@@ -30,20 +33,24 @@ import {
|
|
|
30
33
|
SlackLinkSchema,
|
|
31
34
|
type SlackConfig,
|
|
32
35
|
} from "../lib/slackstate.ts";
|
|
33
|
-
import { ensureThreadCwd,
|
|
36
|
+
import { ensureThreadCwd, relayThread } from "../lib/slackbridge.ts";
|
|
34
37
|
import { log } from "../lib/log.ts";
|
|
35
38
|
import { c, count } from "./render.ts";
|
|
36
39
|
|
|
37
|
-
const SERVE_USAGE = "usage: tokenmaxxing serve [setup | link <channel-id> <repo> [--no-worktree] [--dangerous] [--model <m>] | unlink <channel-id> | links]";
|
|
40
|
+
const SERVE_USAGE = "usage: tokenmaxxing serve [setup | link <channel-id> <repo> [--no-worktree] [--yolo | --dangerous] [--model <m>] | unlink <channel-id> | links]";
|
|
38
41
|
|
|
39
42
|
/** The manifest the user pastes at api.slack.com/apps > From an app manifest.
|
|
40
|
-
* Scopes/events verified against docs.slack.dev 2026-07-18:
|
|
41
|
-
*
|
|
43
|
+
* Scopes/events verified against docs.slack.dev 2026-07-18: a channel-thread
|
|
44
|
+
* relay plus Slack's Agent messaging experience (agent_view + assistant:write
|
|
45
|
+
* power the DM assistant surface and typing status; channel-thread streaming
|
|
46
|
+
* works without them, verified live). Changing scopes on an existing app
|
|
47
|
+
* requires reinstalling it to the workspace. */
|
|
42
48
|
const APP_MANIFEST = `display_information:
|
|
43
49
|
name: tokenmaxxing
|
|
44
50
|
description: bridges Slack threads to Claude Code sessions
|
|
45
51
|
|
|
46
52
|
features:
|
|
53
|
+
agent_view: true
|
|
47
54
|
bot_user:
|
|
48
55
|
display_name: tokenmaxxing
|
|
49
56
|
always_online: true
|
|
@@ -52,6 +59,7 @@ oauth_config:
|
|
|
52
59
|
scopes:
|
|
53
60
|
bot:
|
|
54
61
|
- app_mentions:read
|
|
62
|
+
- assistant:write
|
|
55
63
|
- channels:history
|
|
56
64
|
- groups:history
|
|
57
65
|
- chat:write
|
|
@@ -61,9 +69,12 @@ oauth_config:
|
|
|
61
69
|
settings:
|
|
62
70
|
event_subscriptions:
|
|
63
71
|
bot_events:
|
|
72
|
+
- app_context_changed
|
|
73
|
+
- app_home_opened
|
|
64
74
|
- app_mention
|
|
65
75
|
- message.channels
|
|
66
76
|
- message.groups
|
|
77
|
+
- message.im
|
|
67
78
|
socket_mode_enabled: true
|
|
68
79
|
org_deploy_enabled: false
|
|
69
80
|
token_rotation_enabled: false`;
|
|
@@ -77,6 +88,7 @@ function printSetupInstructions(): void {
|
|
|
77
88
|
console.log("2. OAuth & Permissions > Install to Workspace, copy the Bot User OAuth Token (xoxb-...).");
|
|
78
89
|
console.log("3. Basic Information > App-Level Tokens > Generate (add the connections:write scope), copy the token (xapp-...).");
|
|
79
90
|
console.log(`4. Run ${c.cyan("tokenmaxxing serve setup")} and paste both tokens, then ${c.cyan("tokenmaxxing serve link <channel-id> <repo>")} and invite the bot to that channel.`);
|
|
91
|
+
console.log(`${c.dim("Existing app? Paste the manifest over App Manifest in its settings, then reinstall to the workspace (scope changes need it). Tokens stay valid unless you rotate them.")}`);
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
function cmdServeSetup(): number {
|
|
@@ -103,7 +115,8 @@ function cmdServeSetup(): number {
|
|
|
103
115
|
|
|
104
116
|
function cmdServeLink(argv: string[]): number {
|
|
105
117
|
const worktree = !argv.includes("--no-worktree");
|
|
106
|
-
|
|
118
|
+
// yolo mode = the SDK's bypassPermissions; --dangerous is the same switch.
|
|
119
|
+
const dangerous = argv.includes("--yolo") || argv.includes("--dangerous");
|
|
107
120
|
const modelIdx = argv.indexOf("--model");
|
|
108
121
|
const model = modelIdx >= 0 ? argv[modelIdx + 1] : undefined;
|
|
109
122
|
const rest = argv.filter((a, i) => !a.startsWith("--") && (modelIdx < 0 || i !== modelIdx + 1));
|
|
@@ -172,12 +185,6 @@ function cmdServeLinks(): number {
|
|
|
172
185
|
return 0;
|
|
173
186
|
}
|
|
174
187
|
|
|
175
|
-
/** One socket lease: how long each startSocketModeListener call holds the
|
|
176
|
-
* WebSocket before the loop reconnects (the adapter treats the listener as
|
|
177
|
-
* leased, not infinite). */
|
|
178
|
-
const SOCKET_LEASE_MS = 3_600_000;
|
|
179
|
-
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
180
|
-
|
|
181
188
|
async function runDaemon(): Promise<number> {
|
|
182
189
|
const cfg = loadSlackConfig();
|
|
183
190
|
if (!cfg) {
|
|
@@ -189,66 +196,163 @@ async function runDaemon(): Promise<number> {
|
|
|
189
196
|
return 1;
|
|
190
197
|
}
|
|
191
198
|
|
|
192
|
-
const slack = createSlackAdapter({
|
|
199
|
+
const slack = createSlackAdapter({
|
|
200
|
+
mode: "socket",
|
|
201
|
+
botToken: cfg.botToken,
|
|
202
|
+
appToken: cfg.appToken,
|
|
203
|
+
// Native append-streaming (chat.startStream) with task cards is the
|
|
204
|
+
// correct mode (user-confirmed live 2026-07-18): it works in channel
|
|
205
|
+
// threads even when auth.test reports no assistant:write, so never gate
|
|
206
|
+
// it on a scope probe. The adapter falls back to post-and-edit by itself
|
|
207
|
+
// when a workspace truly rejects streaming. agentView matches the
|
|
208
|
+
// manifest's Agent messaging experience for the DM surface.
|
|
209
|
+
agentView: true,
|
|
210
|
+
// the web-api default retry policy (tenRetriesInAboutThirtyMinutes) can
|
|
211
|
+
// stall a streamed turn ~30min on one rate-limited edit; this is
|
|
212
|
+
// @slack/web-api's fiveRetriesInFiveMinutes literal (dep not declared,
|
|
213
|
+
// so the values are inlined).
|
|
214
|
+
webClientOptions: { retryConfig: { retries: 5, factor: 3.86 }, timeout: 15_000 },
|
|
215
|
+
});
|
|
216
|
+
// held directly (not only via Chat) so startup can re-subscribe recorded
|
|
217
|
+
// threads: subscriptions live in this in-memory state and die with the
|
|
218
|
+
// process, and only a fresh mention would otherwise revive a thread.
|
|
219
|
+
const state = createMemoryState();
|
|
193
220
|
const bot = new Chat({
|
|
194
221
|
userName: "tokenmaxxing",
|
|
195
222
|
adapters: { slack },
|
|
196
|
-
state
|
|
223
|
+
state,
|
|
197
224
|
// per-thread lock with queueing: a message landing mid-turn waits its turn
|
|
198
|
-
// instead of
|
|
199
|
-
|
|
225
|
+
// instead of racing a second claude spawn on the same cwd. The default
|
|
226
|
+
// 90s queue-entry TTL silently discards anything queued behind a turn
|
|
227
|
+
// longer than that (claude turns routinely are), hence the override.
|
|
228
|
+
concurrency: { strategy: "queue", queueEntryTtlMs: 900_000 },
|
|
229
|
+
// without this a cards-only segment in post-and-edit fallback would
|
|
230
|
+
// strand a bare "..." placeholder message.
|
|
231
|
+
fallbackStreamingPlaceholderText: null,
|
|
200
232
|
logger: "warn",
|
|
201
233
|
});
|
|
202
234
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
235
|
+
// in-flight turns, tracked so a shutdown signal can drain them instead of
|
|
236
|
+
// killing a half-streamed answer (live incident 2026-07-18: a deploy
|
|
237
|
+
// restart cut a turn mid-sentence and the answer never reached Slack).
|
|
238
|
+
const activeTurns = new Set<Promise<void>>();
|
|
239
|
+
let draining = false;
|
|
240
|
+
|
|
241
|
+
const handleTurn = async (input: {
|
|
242
|
+
thread: { id: string; channelId: string; post: (m: AsyncIterable<string | StreamChunk>) => Promise<unknown>; subscribe: () => Promise<void>; startTyping: () => Promise<void> };
|
|
243
|
+
texts: string[];
|
|
244
|
+
isMention: boolean;
|
|
245
|
+
}) => {
|
|
246
|
+
const { thread, texts, isMention } = input;
|
|
247
|
+
if (draining) {
|
|
248
|
+
// the socket stays connected until the drain finishes; anything landing
|
|
249
|
+
// in that window is dropped loudly rather than spawning an unwaitable
|
|
250
|
+
// turn. The user re-sends after the restart.
|
|
251
|
+
log("serve.drain_dropped", { thread: thread.id });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const link = linkForChannel(cfg, bareChannelId(thread.channelId));
|
|
255
|
+
if (!link) {
|
|
256
|
+
log("serve.unlinked_channel", { channel: thread.channelId });
|
|
257
|
+
return; // not a linked channel - stay silent in Slack
|
|
258
|
+
}
|
|
259
|
+
log("serve.message", { thread: thread.id, isMention, texts: texts.length });
|
|
260
|
+
// texts carries queue-skipped messages plus the triggering one: the queue
|
|
261
|
+
// strategy hands a turn only the LATEST message and the rest via
|
|
262
|
+
// context.skipped, so they are folded into one prompt here.
|
|
263
|
+
const prompt = texts
|
|
264
|
+
.map((t) => stripLeadingMention(t))
|
|
265
|
+
.filter((t) => t !== "")
|
|
266
|
+
.join("\n\n");
|
|
207
267
|
if (!prompt) return;
|
|
208
268
|
let record = loadSlackThread(thread.id);
|
|
209
269
|
if (!record) {
|
|
210
270
|
if (!isMention) return; // only a mention opens a session
|
|
211
|
-
await thread.subscribe();
|
|
212
271
|
const cwd = ensureThreadCwd({ link, threadId: thread.id });
|
|
213
272
|
record = { threadId: thread.id, repo: link.repo, cwd, sessionId: null, createdAt: new Date().toISOString() };
|
|
214
273
|
saveSlackThread(record);
|
|
215
274
|
log("serve.thread_opened", { thread: thread.id, cwd });
|
|
216
275
|
}
|
|
217
|
-
|
|
218
|
-
|
|
276
|
+
// subscriptions live in the memory state, so a daemon restart forgets
|
|
277
|
+
// them; every mention re-subscribes to keep follow-up replies flowing.
|
|
278
|
+
if (isMention) await thread.subscribe();
|
|
279
|
+
// "is working..." assistant status; a no-op until the Slack app has the
|
|
280
|
+
// agent feature + assistant:write (the adapter warns instead of throwing).
|
|
281
|
+
await thread.startTyping();
|
|
282
|
+
const outcome = await relayThread({
|
|
283
|
+
cwd: record.cwd,
|
|
284
|
+
sessionId: record.sessionId,
|
|
285
|
+
prompt,
|
|
286
|
+
link,
|
|
287
|
+
post: (m) => thread.post(m),
|
|
288
|
+
});
|
|
219
289
|
if (outcome.sessionId !== record.sessionId) {
|
|
220
290
|
saveSlackThread({ ...record, sessionId: outcome.sessionId });
|
|
221
291
|
}
|
|
222
292
|
};
|
|
223
293
|
|
|
224
|
-
|
|
225
|
-
|
|
294
|
+
const relayable = (m: { author: { isMe: boolean; isBot?: boolean | "unknown" } }) => !m.author.isMe && m.author.isBot !== true;
|
|
295
|
+
|
|
296
|
+
const tracked = async (turn: Promise<void>) => {
|
|
297
|
+
activeTurns.add(turn);
|
|
298
|
+
try {
|
|
299
|
+
await turn;
|
|
300
|
+
} finally {
|
|
301
|
+
activeTurns.delete(turn);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
bot.onNewMention(async (thread, message, context) => {
|
|
306
|
+
const texts = [...(context?.skipped ?? []).filter(relayable), message].map((m) => m.text);
|
|
307
|
+
await tracked(handleTurn({ thread, texts, isMention: true }));
|
|
226
308
|
});
|
|
227
|
-
bot.onSubscribedMessage(async (thread, message) => {
|
|
228
|
-
if (message
|
|
229
|
-
|
|
309
|
+
bot.onSubscribedMessage(async (thread, message, context) => {
|
|
310
|
+
if (!relayable(message)) return; // never relay our own posts
|
|
311
|
+
const texts = [...(context?.skipped ?? []).filter(relayable), message].map((m) => m.text);
|
|
312
|
+
await tracked(handleTurn({ thread, texts, isMention: false }));
|
|
230
313
|
});
|
|
231
314
|
|
|
315
|
+
// initialize() starts the PERSISTENT Socket Mode client (auto-reconnecting)
|
|
316
|
+
// wired straight into event routing; the daemon only has to stay alive.
|
|
317
|
+
// Never call startSocketModeListener here: that is the serverless leased
|
|
318
|
+
// variant (it demands options.waitUntil and returns instantly without it),
|
|
319
|
+
// and awaiting it in a loop starved the event loop so hard the WebSocket
|
|
320
|
+
// never delivered a single event (live incident 2026-07-18).
|
|
232
321
|
await bot.initialize();
|
|
233
|
-
console.log(`${c.green("●")} serving ${count({ n: cfg.links.length, noun: "linked channel" })} over Slack Socket Mode - mention the bot in a linked channel to open a session`);
|
|
234
322
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
323
|
+
// subscriptions live in the memory state and died with the previous daemon;
|
|
324
|
+
// the durable slack-threads/ records say which threads are ours, so restore
|
|
325
|
+
// routing for them (message routing checks stateAdapter.isSubscribed,
|
|
326
|
+
// verified in chat 4.34.0). Without this a restart leaves every open thread
|
|
327
|
+
// deaf to non-mention follow-ups.
|
|
328
|
+
const records = listSlackThreads();
|
|
329
|
+
for (const record of records) await state.subscribe(record.threadId);
|
|
330
|
+
log("serve.resubscribed", { threads: records.length });
|
|
331
|
+
|
|
332
|
+
// drain instead of dying mid-answer: stop taking new turns, let in-flight
|
|
333
|
+
// ones finish (bounded - a hung claude turn must not block a restart
|
|
334
|
+
// forever), then disconnect. A second signal forces an immediate exit.
|
|
335
|
+
const DRAIN_MS = 300_000;
|
|
336
|
+
const shutdown = async (signal: string) => {
|
|
337
|
+
if (draining) {
|
|
338
|
+
log("serve.forced_exit", { signal });
|
|
339
|
+
process.exit(1);
|
|
250
340
|
}
|
|
251
|
-
|
|
341
|
+
draining = true;
|
|
342
|
+
log("serve.draining", { signal, turns: activeTurns.size });
|
|
343
|
+
console.log(`${c.yellow("●")} ${signal}: draining ${count({ n: activeTurns.size, noun: "in-flight turn" })} (again to force)`);
|
|
344
|
+
await Promise.race([Promise.allSettled([...activeTurns]), delay(DRAIN_MS)]);
|
|
345
|
+
await bot.shutdown();
|
|
346
|
+
log("serve.stopped", { dropped: activeTurns.size });
|
|
347
|
+
process.exit(0);
|
|
348
|
+
};
|
|
349
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
350
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
351
|
+
|
|
352
|
+
console.log(`${c.green("●")} serving ${count({ n: cfg.links.length, noun: "linked channel" })} over Slack Socket Mode - mention the bot in a linked channel to open a session (Ctrl-C to stop)`);
|
|
353
|
+
log("serve.started", { links: cfg.links.length });
|
|
354
|
+
await new Promise<never>(() => {});
|
|
355
|
+
return 0;
|
|
252
356
|
}
|
|
253
357
|
|
|
254
358
|
export async function cmdServe(argv: string[]): Promise<number> {
|
package/src/lib/decide.ts
CHANGED
|
@@ -111,7 +111,7 @@ function usageFresh(u: UsageState | null, org: string | null, ttl: number, now:
|
|
|
111
111
|
* are absent, org-drifted, or older than the poll TTL. ONE probe carries all
|
|
112
112
|
* three limit kinds, so a success refreshes BOTH files; anything less leaves a
|
|
113
113
|
* headless box (no rendering statusLine to tee) evaluating frozen or
|
|
114
|
-
* org-mismatched values forever - the 2026-07-12
|
|
114
|
+
* org-mismatched values forever - the 2026-07-12 ARM-box blindness. The
|
|
115
115
|
* refreshed usage carries model: null (whatever session stamped the old model
|
|
116
116
|
* may be gone), which gates every configured family. model-usage.json's ts also
|
|
117
117
|
* stamps FAILED attempts, so a busy live token cannot cause a probe storm
|
package/src/lib/slackbridge.ts
CHANGED
|
@@ -9,19 +9,64 @@ import { existsSync, mkdirSync } from "node:fs";
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
12
|
+
import type { StreamChunk } from "chat";
|
|
12
13
|
import { ensureBestAccount, pooledOptions, stopHookCheck } from "../sdk.ts";
|
|
13
14
|
import { paths } from "./paths.ts";
|
|
14
15
|
import { threadKey, type SlackLink } from "./slackstate.ts";
|
|
16
|
+
import { agentEventChunks, newStreamMapState, SegmentBreakSchema } from "./slackstream.ts";
|
|
15
17
|
import { log } from "./log.ts";
|
|
16
18
|
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
19
|
+
/** With systemPrompt omitted the SDK runs a MINIMAL system prompt (the
|
|
20
|
+
* claude_code preset is opt-in since SDK 0.1.0, re-verified for 0.3.214
|
|
21
|
+
* 2026-07-18), so this small standalone prompt replaces nothing. It exists
|
|
22
|
+
* because a relayed model once answered with a literal "<br>": Slack renders
|
|
23
|
+
* markdown, never HTML. */
|
|
24
|
+
const SLACK_SYSTEM_PROMPT =
|
|
25
|
+
"Your replies are relayed into a Slack thread and render as Slack-flavored markdown. Write plain markdown only - never HTML tags such as <br> (use real line breaks).";
|
|
26
|
+
|
|
19
27
|
export const TurnOutcomeSchema = z.object({
|
|
20
28
|
sessionId: z.string().nullable(),
|
|
21
29
|
failed: z.boolean(),
|
|
22
30
|
});
|
|
23
31
|
export type TurnOutcome = z.infer<typeof TurnOutcomeSchema>;
|
|
24
32
|
|
|
33
|
+
const SegmentChunkSchema = z.union([z.string(), z.custom<StreamChunk>()]);
|
|
34
|
+
type SegmentChunk = z.infer<typeof SegmentChunkSchema>;
|
|
35
|
+
|
|
36
|
+
/** A hand-pushed async iterable: relayThread feeds one of these per Slack
|
|
37
|
+
* message segment while thread.post concurrently drains it. */
|
|
38
|
+
function pushableStream(): {
|
|
39
|
+
iterable: AsyncIterable<SegmentChunk>;
|
|
40
|
+
push: (chunk: SegmentChunk) => void;
|
|
41
|
+
end: () => void;
|
|
42
|
+
} {
|
|
43
|
+
const queue: SegmentChunk[] = [];
|
|
44
|
+
let done = false;
|
|
45
|
+
let notify: (() => void) | null = null;
|
|
46
|
+
return {
|
|
47
|
+
push(chunk) {
|
|
48
|
+
queue.push(chunk);
|
|
49
|
+
notify?.();
|
|
50
|
+
},
|
|
51
|
+
end() {
|
|
52
|
+
done = true;
|
|
53
|
+
notify?.();
|
|
54
|
+
},
|
|
55
|
+
iterable: {
|
|
56
|
+
async *[Symbol.asyncIterator]() {
|
|
57
|
+
while (true) {
|
|
58
|
+
for (let next = queue.shift(); next !== undefined; next = queue.shift()) yield next;
|
|
59
|
+
if (done) return;
|
|
60
|
+
await new Promise<void>((resolve) => {
|
|
61
|
+
notify = resolve;
|
|
62
|
+
});
|
|
63
|
+
notify = null;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
25
70
|
/** Run one git command against a repo; throws with trimmed stderr on failure. */
|
|
26
71
|
function git(repo: string, args: string[]): string {
|
|
27
72
|
const r = Bun.spawnSync(["git", "-C", repo, ...args], { stdout: "pipe", stderr: "pipe" });
|
|
@@ -54,16 +99,50 @@ export function ensureThreadCwd(input: { link: SlackLink; threadId: string }): s
|
|
|
54
99
|
}
|
|
55
100
|
|
|
56
101
|
/**
|
|
57
|
-
* One claude turn
|
|
58
|
-
*
|
|
102
|
+
* One claude turn relayed into a Slack thread as a SEQUENCE of messages: reply
|
|
103
|
+
* text streams natively, thinking and tool calls stream as task_update cards
|
|
104
|
+
* (see slackstream.ts), and a segment_break (a tool starting after streamed
|
|
105
|
+
* text) closes the current Slack message and opens the next one, so a turn
|
|
106
|
+
* reads as separate messages around its tool runs (user ask 2026-07-18).
|
|
107
|
+
* Segments post strictly in order: the next opens only after the previous
|
|
108
|
+
* post resolves. Never throws: a failure posts a short diagnostic line and
|
|
59
109
|
* sets outcome.failed (the daemon must keep serving other threads). Error
|
|
60
110
|
* text is message-only - a raw error body could echo request material.
|
|
61
111
|
*/
|
|
62
|
-
export async function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
112
|
+
export async function relayThread(input: {
|
|
113
|
+
cwd: string;
|
|
114
|
+
sessionId: string | null;
|
|
115
|
+
prompt: string;
|
|
116
|
+
link: SlackLink;
|
|
117
|
+
post: (m: AsyncIterable<SegmentChunk>) => Promise<unknown>;
|
|
118
|
+
}): Promise<TurnOutcome> {
|
|
119
|
+
const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false };
|
|
120
|
+
let segment: ReturnType<typeof pushableStream> | null = null;
|
|
121
|
+
let lastPost: Promise<unknown> = Promise.resolve();
|
|
122
|
+
let postedText = false;
|
|
123
|
+
const push = async (chunk: SegmentChunk) => {
|
|
124
|
+
let seg = segment;
|
|
125
|
+
if (!seg) {
|
|
126
|
+
await lastPost; // strict message order: previous segment fully posted first
|
|
127
|
+
seg = pushableStream();
|
|
128
|
+
segment = seg;
|
|
129
|
+
const posted = seg;
|
|
130
|
+
lastPost = input.post(seg.iterable).catch((e: unknown) => {
|
|
131
|
+
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
132
|
+
log("serve.post_error", { err: detail });
|
|
133
|
+
// the consumer is gone (e.g. Slack finalized an idle stream:
|
|
134
|
+
// message_not_in_streaming_state) - drop the dead segment so the
|
|
135
|
+
// next chunk opens a fresh message instead of vanishing into it.
|
|
136
|
+
if (segment === posted) segment = null;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (!postedText && !(chunk instanceof Object)) postedText = true;
|
|
140
|
+
seg.push(chunk);
|
|
141
|
+
};
|
|
142
|
+
const breakSegment = () => {
|
|
143
|
+
segment?.end();
|
|
144
|
+
segment = null;
|
|
145
|
+
};
|
|
67
146
|
try {
|
|
68
147
|
// the switch decision runs at the spawn boundary, same as the CLI hooks.
|
|
69
148
|
await ensureBestAccount();
|
|
@@ -73,35 +152,43 @@ export async function* relayTurn(
|
|
|
73
152
|
...pooledOptions(),
|
|
74
153
|
cwd: input.cwd,
|
|
75
154
|
permissionMode: input.link.permissionMode,
|
|
155
|
+
// the SDK refuses bypassPermissions without this explicit opt-in.
|
|
156
|
+
...(input.link.permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
|
|
76
157
|
includePartialMessages: true,
|
|
158
|
+
systemPrompt: SLACK_SYSTEM_PROMPT,
|
|
159
|
+
// no one can answer an interactive question dialog through Slack;
|
|
160
|
+
// without the tool the model asks in prose and the user's thread
|
|
161
|
+
// reply becomes the next turn.
|
|
162
|
+
disallowedTools: ["AskUserQuestion"],
|
|
77
163
|
hooks: { Stop: [{ hooks: [stopHookCheck] }] },
|
|
78
164
|
...(input.link.model ? { model: input.link.model } : {}),
|
|
79
165
|
...(input.sessionId ? { resume: input.sessionId } : {}),
|
|
80
166
|
},
|
|
81
167
|
});
|
|
168
|
+
const mapState = newStreamMapState();
|
|
82
169
|
let result: string | null = null;
|
|
83
170
|
for await (const message of q) {
|
|
84
171
|
if (message.type === "system" && message.subtype === "init") outcome.sessionId = message.session_id;
|
|
85
|
-
if (message.type === "stream_event") {
|
|
86
|
-
const event = message.event;
|
|
87
|
-
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
|
|
88
|
-
yieldedAny = true;
|
|
89
|
-
yield event.delta.text;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
172
|
if (message.type === "result") {
|
|
93
173
|
outcome.sessionId = message.session_id;
|
|
94
174
|
if (message.subtype === "success") result = message.result;
|
|
95
175
|
else outcome.failed = true;
|
|
96
176
|
}
|
|
177
|
+
for (const part of agentEventChunks({ state: mapState, message })) {
|
|
178
|
+
if (SegmentBreakSchema.safeParse(part).success) breakSegment();
|
|
179
|
+
else await push(SegmentChunkSchema.parse(part));
|
|
180
|
+
}
|
|
97
181
|
}
|
|
98
182
|
// a turn that produced no streamed text (tool-only turns) still reports.
|
|
99
|
-
if (!
|
|
100
|
-
if (!
|
|
183
|
+
if (!postedText && result) await push(result);
|
|
184
|
+
if (!postedText && !result && outcome.failed) await push("the turn ended without a result (limit or error) - trying again may help");
|
|
101
185
|
} catch (e) {
|
|
102
186
|
outcome.failed = true;
|
|
103
187
|
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
104
188
|
log("serve.turn_error", { err: detail });
|
|
105
|
-
|
|
189
|
+
await push(`tokenmaxxing: turn failed: ${detail}`);
|
|
106
190
|
}
|
|
191
|
+
breakSegment();
|
|
192
|
+
await lastPost;
|
|
193
|
+
return outcome;
|
|
107
194
|
}
|
package/src/lib/slackstate.ts
CHANGED
|
@@ -115,6 +115,12 @@ export function linkForChannel(cfg: SlackConfig, channel: string): SlackLink | n
|
|
|
115
115
|
return cfg.links.find((l) => l.channel === channel) ?? null;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** Chat SDK channel ids are adapter-prefixed ("slack:C0123"); links store the
|
|
119
|
+
* bare Slack id, so lookups must strip the prefix. */
|
|
120
|
+
export function bareChannelId(id: string): string {
|
|
121
|
+
return id.startsWith("slack:") ? id.slice("slack:".length) : id;
|
|
122
|
+
}
|
|
123
|
+
|
|
118
124
|
/** Strip a leading Slack mention token ("<@U0123> rest") from message text. */
|
|
119
125
|
export function stripLeadingMention(text: string): string {
|
|
120
126
|
const trimmed = text.trimStart();
|
|
@@ -0,0 +1,192 @@
|
|
|
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. 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.
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
10
|
+
import type { StreamChunk } from "chat";
|
|
11
|
+
|
|
12
|
+
const DETAILS_MAX = 400;
|
|
13
|
+
const OUTPUT_MAX = 600;
|
|
14
|
+
|
|
15
|
+
/** Tool-input fields worth showing on a task card, most human-readable first. */
|
|
16
|
+
const SUMMARY_FIELDS = ["command", "description", "file_path", "pattern", "prompt", "query", "url"] as const;
|
|
17
|
+
|
|
18
|
+
const OpenBlockSchema = z.object({
|
|
19
|
+
kind: z.enum(["thinking", "tool"]),
|
|
20
|
+
id: z.string(),
|
|
21
|
+
title: z.string(),
|
|
22
|
+
/** accumulated thinking text or partial tool-input JSON. */
|
|
23
|
+
acc: z.string(),
|
|
24
|
+
});
|
|
25
|
+
type OpenBlock = z.infer<typeof OpenBlockSchema>;
|
|
26
|
+
|
|
27
|
+
export const StreamMapStateSchema = z.object({
|
|
28
|
+
/** open content blocks by stream index. */
|
|
29
|
+
open: z.record(z.string(), OpenBlockSchema),
|
|
30
|
+
/** tool_use id -> tool name, for labeling the eventual tool_result. */
|
|
31
|
+
toolTitles: z.record(z.string(), z.string()),
|
|
32
|
+
thinkingCount: z.number(),
|
|
33
|
+
/** reply text streamed since the last segment break. */
|
|
34
|
+
textSinceBreak: z.boolean(),
|
|
35
|
+
});
|
|
36
|
+
export type StreamMapState = z.infer<typeof StreamMapStateSchema>;
|
|
37
|
+
|
|
38
|
+
export function newStreamMapState(): StreamMapState {
|
|
39
|
+
return { open: {}, toolTitles: {}, thinkingCount: 0, textSinceBreak: false };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Emitted when a tool starts after streamed reply text: the bridge closes the
|
|
43
|
+
* current Slack message there and posts the rest as a new one, mirroring how
|
|
44
|
+
* an agent turn reads as separate messages around its tool runs. */
|
|
45
|
+
export const SegmentBreakSchema = z.object({ type: z.literal("segment_break") });
|
|
46
|
+
export type SegmentBreak = z.infer<typeof SegmentBreakSchema>;
|
|
47
|
+
|
|
48
|
+
export const StreamPartSchema = z.union([z.string(), z.custom<StreamChunk>(), SegmentBreakSchema]);
|
|
49
|
+
export type StreamPart = z.infer<typeof StreamPartSchema>;
|
|
50
|
+
|
|
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
|
+
/** One human line out of a tool-input JSON blob; null when nothing fits. */
|
|
56
|
+
export function toolInputSummary(rawJson: string): string | null {
|
|
57
|
+
let parsed: unknown;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(rawJson);
|
|
60
|
+
} catch {
|
|
61
|
+
return null; // partial or empty input JSON - show the bare tool name
|
|
62
|
+
}
|
|
63
|
+
const obj = z.record(z.string(), z.unknown()).safeParse(parsed);
|
|
64
|
+
if (!obj.success) return null;
|
|
65
|
+
for (const field of SUMMARY_FIELDS) {
|
|
66
|
+
const value = z.string().min(1).safeParse(obj.data[field]);
|
|
67
|
+
if (value.success) return truncate({ text: value.data, max: DETAILS_MAX });
|
|
68
|
+
}
|
|
69
|
+
const compact = JSON.stringify(parsed);
|
|
70
|
+
return compact === "{}" ? null : truncate({ text: compact, max: DETAILS_MAX });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ToolResultBlockSchema = z.object({
|
|
74
|
+
type: z.literal("tool_result"),
|
|
75
|
+
tool_use_id: z.string(),
|
|
76
|
+
content: z.union([z.string(), z.array(z.unknown())]).optional(),
|
|
77
|
+
is_error: z.boolean().optional(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const TextPartSchema = z.object({ type: z.literal("text"), text: z.string() });
|
|
81
|
+
|
|
82
|
+
function resultText(content: z.infer<typeof ToolResultBlockSchema>["content"]): string | undefined {
|
|
83
|
+
if (content === undefined) return undefined;
|
|
84
|
+
const joined = Array.isArray(content)
|
|
85
|
+
? content
|
|
86
|
+
.flatMap((part) => {
|
|
87
|
+
const p = TextPartSchema.safeParse(part);
|
|
88
|
+
return p.success ? [p.data.text] : [];
|
|
89
|
+
})
|
|
90
|
+
.join("\n")
|
|
91
|
+
: content;
|
|
92
|
+
const trimmed = joined.trim();
|
|
93
|
+
return trimmed === "" ? undefined : truncate({ text: trimmed, max: OUTPUT_MAX });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Consume one SDK message, mutating state, and return the stream chunks it
|
|
98
|
+
* produces (strings are streamed reply text; objects are native task cards).
|
|
99
|
+
* Subagent events (parent_tool_use_id set) contribute their TOOL cards to the
|
|
100
|
+
* timeline (user ask 2026-07-18: subagent activity shows as accordions like
|
|
101
|
+
* tool calls) but never reply text, thinking cards, or segment breaks: a
|
|
102
|
+
* subagent runs inside a top-level Task tool, so its churn decorates the
|
|
103
|
+
* current message rather than reshaping it. Open blocks are keyed per stream
|
|
104
|
+
* (parent + index) because concurrent subagent streams reuse index space.
|
|
105
|
+
*/
|
|
106
|
+
export function agentEventChunks(input: { state: StreamMapState; message: SDKMessage }): StreamPart[] {
|
|
107
|
+
const { state, message } = input;
|
|
108
|
+
if (message.type === "stream_event") {
|
|
109
|
+
const isMain = message.parent_tool_use_id === null;
|
|
110
|
+
const event = message.event;
|
|
111
|
+
if (event.type === "content_block_start") {
|
|
112
|
+
const key = `${message.parent_tool_use_id ?? "main"}:${event.index}`;
|
|
113
|
+
if (event.content_block.type === "thinking" && isMain) {
|
|
114
|
+
state.thinkingCount += 1;
|
|
115
|
+
const id = `thinking-${state.thinkingCount}`;
|
|
116
|
+
state.open[key] = { kind: "thinking", id, title: "Thinking", acc: "" };
|
|
117
|
+
return [{ type: "task_update", id, title: "Thinking", status: "in_progress" }];
|
|
118
|
+
}
|
|
119
|
+
if (event.content_block.type === "tool_use") {
|
|
120
|
+
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" };
|
|
124
|
+
if (isMain && state.textSinceBreak) {
|
|
125
|
+
state.textSinceBreak = false;
|
|
126
|
+
return [{ type: "segment_break" }, card];
|
|
127
|
+
}
|
|
128
|
+
return [card];
|
|
129
|
+
}
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
if (event.type === "content_block_delta") {
|
|
133
|
+
const open = state.open[`${message.parent_tool_use_id ?? "main"}:${event.index}`];
|
|
134
|
+
if (event.delta.type === "text_delta") {
|
|
135
|
+
if (!isMain) return [];
|
|
136
|
+
// whitespace-only deltas must not count as reply text: a "\n\n"
|
|
137
|
+
// before a tool call would otherwise break the segment and strand a
|
|
138
|
+
// near-blank Slack message.
|
|
139
|
+
if (event.delta.text.trim() !== "") state.textSinceBreak = true;
|
|
140
|
+
return [event.delta.text];
|
|
141
|
+
}
|
|
142
|
+
if (event.delta.type === "thinking_delta" && open) open.acc += event.delta.thinking;
|
|
143
|
+
if (event.delta.type === "input_json_delta" && open) open.acc += event.delta.partial_json;
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
if (event.type === "content_block_stop") {
|
|
147
|
+
const key = `${message.parent_tool_use_id ?? "main"}:${event.index}`;
|
|
148
|
+
const open = state.open[key];
|
|
149
|
+
if (!open) return [];
|
|
150
|
+
delete state.open[key];
|
|
151
|
+
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 }) }];
|
|
153
|
+
}
|
|
154
|
+
const details = toolInputSummary(open.acc);
|
|
155
|
+
return [{ type: "task_update", id: open.id, title: open.title, status: "in_progress", ...(details ? { details } : {}) }];
|
|
156
|
+
}
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
if (message.type === "user") {
|
|
160
|
+
const content = message.message.content;
|
|
161
|
+
if (!Array.isArray(content)) return [];
|
|
162
|
+
const chunks: StreamChunk[] = [];
|
|
163
|
+
for (const block of content) {
|
|
164
|
+
const parsed = ToolResultBlockSchema.safeParse(block);
|
|
165
|
+
if (!parsed.success) continue;
|
|
166
|
+
const title = state.toolTitles[parsed.data.tool_use_id];
|
|
167
|
+
if (title === undefined) continue;
|
|
168
|
+
const output = resultText(parsed.data.content);
|
|
169
|
+
chunks.push({
|
|
170
|
+
type: "task_update",
|
|
171
|
+
id: parsed.data.tool_use_id,
|
|
172
|
+
title,
|
|
173
|
+
status: parsed.data.is_error === true ? "error" : "complete",
|
|
174
|
+
...(output ? { output } : {}),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return chunks;
|
|
178
|
+
}
|
|
179
|
+
if (message.type === "result") {
|
|
180
|
+
const models = Object.keys(message.modelUsage).join(" ");
|
|
181
|
+
const cost = `$${message.total_cost_usd.toFixed(4)}`;
|
|
182
|
+
const secs = `${Math.round(message.duration_ms / 1000)}s`;
|
|
183
|
+
return [{
|
|
184
|
+
type: "task_update",
|
|
185
|
+
id: "turn",
|
|
186
|
+
title: "Turn",
|
|
187
|
+
status: message.subtype === "success" ? "complete" : "error",
|
|
188
|
+
details: [models, cost, secs].filter((p) => p !== "").join(" "),
|
|
189
|
+
}];
|
|
190
|
+
}
|
|
191
|
+
return [];
|
|
192
|
+
}
|
package/src/lib/usage.ts
CHANGED
|
@@ -76,7 +76,7 @@ export function matchedFamily(model: ModelInfo | null, families: string[]): stri
|
|
|
76
76
|
* unknown. The unknown case matters on headless boxes: a swap clears the
|
|
77
77
|
* snapshots and only an actively-rendering statusLine restores the model, so
|
|
78
78
|
* the periodic check ran model-blind for hours while the active account sat at
|
|
79
|
-
* its Fable cap (the 2026-07-12
|
|
79
|
+
* its Fable cap (the 2026-07-12 ARM-box incident). */
|
|
80
80
|
export function gatedFamilies(model: ModelInfo | null, families: string[]): string[] {
|
|
81
81
|
if (!model) return families;
|
|
82
82
|
const family = matchedFamily(model, families);
|