tokenmaxxing 0.16.0 → 0.18.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.
@@ -0,0 +1,266 @@
1
+ // `tokenmaxxing serve` - the Slack bridge daemon. Socket Mode (no public URL):
2
+ // a mention in a linked channel opens a claude session for that thread (in its
3
+ // own git worktree by default), and every further thread message becomes one
4
+ // claude turn whose streamed output posts back into the thread. Stack chosen by
5
+ // the user 2026-07-18: Vercel Chat SDK (`chat` + `@chat-adapter/slack`) for
6
+ // Slack, the Claude Agent SDK driven through src/sdk.ts for claude (EVE was
7
+ // researched and dropped: it owns its own model loop instead of driving
8
+ // Claude Code).
9
+ //
10
+ // serve setup print the app manifest + prompt for the two tokens
11
+ // serve link <ch> <repo> [--no-worktree] [--dangerous] [--model <m>]
12
+ // serve unlink <ch> remove a link
13
+ // serve links list links
14
+ // serve run the daemon
15
+
16
+ import { existsSync, realpathSync } from "node:fs";
17
+ import { Chat } from "chat";
18
+ import { createSlackAdapter } from "@chat-adapter/slack";
19
+ import { createMemoryState } from "@chat-adapter/state-memory";
20
+ import {
21
+ isChannelId,
22
+ linkForChannel,
23
+ loadSlackConfig,
24
+ loadSlackThread,
25
+ removeLink,
26
+ saveSlackConfig,
27
+ saveSlackThread,
28
+ stripLeadingMention,
29
+ upsertLink,
30
+ SlackLinkSchema,
31
+ type SlackConfig,
32
+ } from "../lib/slackstate.ts";
33
+ import { ensureThreadCwd, relayTurn, type TurnOutcome } from "../lib/slackbridge.ts";
34
+ import { log } from "../lib/log.ts";
35
+ import { c, count } from "./render.ts";
36
+
37
+ const SERVE_USAGE = "usage: tokenmaxxing serve [setup | link <channel-id> <repo> [--no-worktree] [--dangerous] [--model <m>] | unlink <channel-id> | links]";
38
+
39
+ /** 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: exactly what a
41
+ * channel-thread relay needs, nothing more. */
42
+ const APP_MANIFEST = `display_information:
43
+ name: tokenmaxxing
44
+ description: bridges Slack threads to Claude Code sessions
45
+
46
+ features:
47
+ bot_user:
48
+ display_name: tokenmaxxing
49
+ always_online: true
50
+
51
+ oauth_config:
52
+ scopes:
53
+ bot:
54
+ - app_mentions:read
55
+ - channels:history
56
+ - groups:history
57
+ - chat:write
58
+ - files:write
59
+ - users:read
60
+
61
+ settings:
62
+ event_subscriptions:
63
+ bot_events:
64
+ - app_mention
65
+ - message.channels
66
+ - message.groups
67
+ socket_mode_enabled: true
68
+ org_deploy_enabled: false
69
+ token_rotation_enabled: false`;
70
+
71
+ function printSetupInstructions(): void {
72
+ console.log(c.bold("Slack app setup (one time)"));
73
+ console.log(`1. Open ${c.cyan("https://api.slack.com/apps")} > Create New App > From an app manifest, pick your workspace, and paste:`);
74
+ console.log();
75
+ console.log(APP_MANIFEST);
76
+ console.log();
77
+ console.log("2. OAuth & Permissions > Install to Workspace, copy the Bot User OAuth Token (xoxb-...).");
78
+ console.log("3. Basic Information > App-Level Tokens > Generate (add the connections:write scope), copy the token (xapp-...).");
79
+ 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.`);
80
+ }
81
+
82
+ function cmdServeSetup(): number {
83
+ printSetupInstructions();
84
+ console.log();
85
+ const botToken = prompt("bot token (xoxb-...):")?.trim();
86
+ const appToken = prompt("app token (xapp-...):")?.trim();
87
+ if (!botToken || !appToken) {
88
+ console.error(c.red("both tokens are required - nothing saved"));
89
+ return 1;
90
+ }
91
+ const existing = loadSlackConfig();
92
+ let cfg: SlackConfig;
93
+ try {
94
+ cfg = { botToken, appToken, links: existing?.links ?? [] };
95
+ saveSlackConfig(cfg);
96
+ } catch {
97
+ console.error(c.red("tokens rejected: the bot token must start with xoxb- and the app token with xapp-"));
98
+ return 1;
99
+ }
100
+ console.log(`${c.green("✓")} saved to slack.json (0600) with ${count({ n: cfg.links.length, noun: "link" })}`);
101
+ return 0;
102
+ }
103
+
104
+ function cmdServeLink(argv: string[]): number {
105
+ const worktree = !argv.includes("--no-worktree");
106
+ const dangerous = argv.includes("--dangerous");
107
+ const modelIdx = argv.indexOf("--model");
108
+ const model = modelIdx >= 0 ? argv[modelIdx + 1] : undefined;
109
+ const rest = argv.filter((a, i) => !a.startsWith("--") && (modelIdx < 0 || i !== modelIdx + 1));
110
+ const [channel, repo] = rest;
111
+ if (!channel || !repo) {
112
+ console.error(SERVE_USAGE);
113
+ return 2;
114
+ }
115
+ if (!isChannelId(channel)) {
116
+ console.error(c.red(`"${channel}" is not a Slack channel id (C.../G...). In Slack: right-click the channel > View channel details - the id is at the bottom.`));
117
+ return 1;
118
+ }
119
+ if (!existsSync(repo)) {
120
+ console.error(c.red(`repo path does not exist: ${repo}`));
121
+ return 1;
122
+ }
123
+ const repoReal = realpathSync(repo);
124
+ if (!existsSync(`${repoReal}/.git`)) {
125
+ console.error(c.red(`${repoReal} is not a git repository (worktree mode needs one)`));
126
+ return 1;
127
+ }
128
+ const cfg = loadSlackConfig();
129
+ if (!cfg) {
130
+ console.error(c.red("no slack.json yet - run `tokenmaxxing serve setup` first"));
131
+ return 1;
132
+ }
133
+ const link = SlackLinkSchema.parse({
134
+ channel,
135
+ repo: repoReal,
136
+ worktree,
137
+ permissionMode: dangerous ? "bypassPermissions" : "acceptEdits",
138
+ ...(model ? { model } : {}),
139
+ });
140
+ saveSlackConfig(upsertLink(cfg, link));
141
+ const flags = [worktree ? "worktree" : "in-place", link.permissionMode, ...(model ? [model] : [])].join(", ");
142
+ console.log(`${c.green("✓")} linked ${c.bold(channel)} → ${repoReal} (${flags})`);
143
+ return 0;
144
+ }
145
+
146
+ function cmdServeUnlink(channel: string | undefined): number {
147
+ if (!channel) {
148
+ console.error(SERVE_USAGE);
149
+ return 2;
150
+ }
151
+ const cfg = loadSlackConfig();
152
+ const next = cfg ? removeLink(cfg, channel) : null;
153
+ if (!next) {
154
+ console.error(c.red(`no link for channel ${channel}`));
155
+ return 1;
156
+ }
157
+ saveSlackConfig(next);
158
+ console.log(`${c.green("✓")} unlinked ${channel}`);
159
+ return 0;
160
+ }
161
+
162
+ function cmdServeLinks(): number {
163
+ const cfg = loadSlackConfig();
164
+ if (!cfg || cfg.links.length === 0) {
165
+ console.log(c.dim("no channel links - run `tokenmaxxing serve link <channel-id> <repo>`"));
166
+ return 0;
167
+ }
168
+ for (const l of cfg.links) {
169
+ const flags = [l.worktree ? "worktree" : "in-place", l.permissionMode, ...(l.model ? [l.model] : [])].join(", ");
170
+ console.log(`${c.bold(l.channel)} → ${l.repo} ${c.dim(`(${flags})`)}`);
171
+ }
172
+ return 0;
173
+ }
174
+
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
+ async function runDaemon(): Promise<number> {
182
+ const cfg = loadSlackConfig();
183
+ if (!cfg) {
184
+ printSetupInstructions();
185
+ return 1;
186
+ }
187
+ if (cfg.links.length === 0) {
188
+ console.error(c.red("no channel links - run `tokenmaxxing serve link <channel-id> <repo>` first"));
189
+ return 1;
190
+ }
191
+
192
+ const slack = createSlackAdapter({ mode: "socket", botToken: cfg.botToken, appToken: cfg.appToken });
193
+ const bot = new Chat({
194
+ userName: "tokenmaxxing",
195
+ adapters: { slack },
196
+ state: createMemoryState(),
197
+ // per-thread lock with queueing: a message landing mid-turn waits its turn
198
+ // instead of being dropped or racing a second claude spawn on the same cwd.
199
+ concurrency: "queue",
200
+ logger: "warn",
201
+ });
202
+
203
+ const handleTurn = async (thread: { id: string; channelId: string; post: (m: AsyncIterable<string>) => Promise<unknown>; subscribe: () => Promise<void> }, rawText: string, isMention: boolean) => {
204
+ const link = linkForChannel(cfg, thread.channelId);
205
+ if (!link) return; // not a linked channel - stay silent
206
+ const prompt = stripLeadingMention(rawText);
207
+ if (!prompt) return;
208
+ let record = loadSlackThread(thread.id);
209
+ if (!record) {
210
+ if (!isMention) return; // only a mention opens a session
211
+ await thread.subscribe();
212
+ const cwd = ensureThreadCwd({ link, threadId: thread.id });
213
+ record = { threadId: thread.id, repo: link.repo, cwd, sessionId: null, createdAt: new Date().toISOString() };
214
+ saveSlackThread(record);
215
+ log("serve.thread_opened", { thread: thread.id, cwd });
216
+ }
217
+ const outcome: TurnOutcome = { sessionId: record.sessionId, failed: false };
218
+ await thread.post(relayTurn({ cwd: record.cwd, sessionId: record.sessionId, prompt, link }, outcome));
219
+ if (outcome.sessionId !== record.sessionId) {
220
+ saveSlackThread({ ...record, sessionId: outcome.sessionId });
221
+ }
222
+ };
223
+
224
+ bot.onNewMention(async (thread, message) => {
225
+ await handleTurn(thread, message.text, true);
226
+ });
227
+ bot.onSubscribedMessage(async (thread, message) => {
228
+ if (message.author.isMe || message.author.isBot === true) return; // never relay our own posts
229
+ await handleTurn(thread, message.text, false);
230
+ });
231
+
232
+ 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
+
235
+ let consecutiveFailures = 0;
236
+ while (true) {
237
+ try {
238
+ await slack.startSocketModeListener({}, SOCKET_LEASE_MS);
239
+ consecutiveFailures = 0;
240
+ } catch (e) {
241
+ consecutiveFailures += 1;
242
+ const err = (e instanceof Error ? e.message : String(e)).slice(0, 200);
243
+ console.error(c.red(`socket listener failed (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${err}`));
244
+ log("serve.socket_error", { err, consecutiveFailures });
245
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
246
+ console.error(c.red("giving up after 3 consecutive socket failures - check the tokens with `tokenmaxxing serve setup`"));
247
+ return 1;
248
+ }
249
+ await Bun.sleep(5_000);
250
+ }
251
+ }
252
+ }
253
+
254
+ export async function cmdServe(argv: string[]): Promise<number> {
255
+ const [sub, ...rest] = argv;
256
+ switch (sub) {
257
+ case undefined: return runDaemon();
258
+ case "setup": return cmdServeSetup();
259
+ case "link": return cmdServeLink(rest);
260
+ case "unlink": return cmdServeUnlink(rest[0]);
261
+ case "links": return cmdServeLinks();
262
+ default:
263
+ console.error(SERVE_USAGE);
264
+ return 2;
265
+ }
266
+ }
package/src/cli/status.ts CHANGED
@@ -12,12 +12,13 @@
12
12
  // fallback: its own `/usage` fail-silents exactly when a live session is
13
13
  // running it, and that session's tee is fresher than any cache.
14
14
 
15
+ import { sortBy } from "es-toolkit";
15
16
  import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
16
17
  import { readOAuthAccount } from "../lib/claudejson.ts";
17
18
  import { probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
18
19
  import { withLock } from "../lib/lock.ts";
19
20
  import { codexPaths, paths } from "../lib/paths.ts";
20
- import { effectiveBars, isExhausted, nextWeeklyReset } from "../lib/picker.ts";
21
+ import { earliestReset, effectiveBars, isExhausted, nextWeeklyReset } from "../lib/picker.ts";
21
22
  import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
22
23
  import { liveCodexAccountId, sampleCodexAccount, type CodexSampleOutcome } from "../lib/codexsample.ts";
23
24
  import { isCodexExhausted } from "../lib/codexpick.ts";
@@ -115,7 +116,11 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
115
116
  console.log(` ${name.padEnd(5)} ${bar(pct)} ${c.dim(fmtReset(resetsAt, now))}`);
116
117
  };
117
118
 
118
- for (const a of idx.accounts) {
119
+ // Display order (user decision 2026-07-18): earliest upcoming reset first
120
+ // (5h or extrapolated weekly, from the just-refreshed samples), needs-reauth
121
+ // last; the ● marker identifies the active account wherever it sorts.
122
+ const displayAccounts = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
123
+ for (const a of displayAccounts) {
119
124
  const active = a.accountUuid === idx.activeAccountUuid;
120
125
  const outcome = outcomes.get(a.accountUuid);
121
126
  const failed = outcome ? !outcome.ok : false;
@@ -200,7 +205,17 @@ async function renderCodexSection(input: {
200
205
  console.log();
201
206
  const windowLabel = (window: CodexWindow) =>
202
207
  isSessionWindow({ window }) ? `${Math.round((window.windowSeconds ?? 0) / 3600)}h` : "week";
203
- for (const account of index.accounts) {
208
+ // Same display order as the claude pool: earliest upcoming reset first
209
+ // across every cached window, needs-reauth last.
210
+ const displayAccounts = sortBy(index.accounts, [
211
+ (a) => (a.needsReauth ? 1 : 0),
212
+ (a) => {
213
+ const windows = [...(a.lastUsage?.aggregate ?? []), ...Object.values(a.lastUsage?.perLimit ?? {}).flat()];
214
+ const resets = windows.flatMap((w) => (w.resetsAt != null && w.resetsAt > now ? [w.resetsAt] : []));
215
+ return resets.length > 0 ? Math.min(...resets) : Number.POSITIVE_INFINITY;
216
+ },
217
+ ]);
218
+ for (const account of displayAccounts) {
204
219
  const active = account.accountId === liveId;
205
220
  const marker = active ? c.green("●") : c.dim("○");
206
221
  const badges: string[] = [];
package/src/cli/switch.ts CHANGED
@@ -43,7 +43,7 @@ export async function cmdSwitch(selector?: string): Promise<number> {
43
43
  try {
44
44
  await performSwap(target);
45
45
  } catch (e) {
46
- if (e instanceof InvalidGrantError) { console.error(c.red(`${target.label}'s refresh token is dead - re-add it`)); return 1; }
46
+ if (e instanceof InvalidGrantError) { console.error(c.red(`${target.label}'s refresh token is dead - run \`tokenmaxxing auth ${target.label}\``)); return 1; }
47
47
  throw e;
48
48
  }
49
49
  console.log(`${c.green("↻")} switched to ${c.bold(target.label)}`);
@@ -86,7 +86,7 @@ export async function cmdSwitch(selector?: string): Promise<number> {
86
86
  // Either every account needs re-auth, or every account is blocked with no
87
87
  // recoverable bound (unparsed reset clocks AND no sample time - see log).
88
88
  const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
89
- if (reauth.length > 0) { console.error(c.yellow(`no switchable account - re-auth needed: ${reauth.join(", ")}`)); return 1; }
89
+ if (reauth.length > 0) { console.error(c.yellow(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`)); return 1; }
90
90
  // never freeze a label drift behind a no-op (see header).
91
91
  if (drifted && active) return swapTo(active);
92
92
  console.log(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"));
@@ -1,14 +1,18 @@
1
1
  // Native statusLine. Reads Claude's statusLine stdin, tees the rate-limit data
2
2
  // to usage.json (write-on-change, O(ms)), then renders ONE line:
3
- // worktree name (linked worktrees only), model (effort), ctx used,
4
- // +added/-removed, then quota as USED percent (bold, severity-colored)
5
- // glued after its time-to-reset: "◆ F26 2h5 1d38 ◇ F67 2d40 ◇ 2 full"
6
- // ("2h5" = resets in 2h, 5 used) - the active account's windows after a
7
- // green ◆ (per-model by initial first, "F?" when the cap applies but is
8
- // unmeasured, then session/5h, then week), then each parked account's week
9
- // after a cyan ◇, in swap-preference order so the first usable ◇ is where
10
- // the next swap lands. Adjacent untouched (or unsampled) parked accounts
11
- // collapse into one counted token. Blocks are joined by TWO spaces, tokens
3
+ // worktree name (linked worktrees only), model name painted by context fill
4
+ // (effort in parens), +added/-removed, then quota as COLOR (user decisions
5
+ // 2026-07-18): each window is its time-to-reset painted on a continuous
6
+ // green->yellow->red ramp by used% - "◆ 𝒇 2h 1d ◇ 2d ◇ full" - the
7
+ // active account's windows after a green ◆ (per-model by bare initial
8
+ // first, fable as 𝒇, other families uppercased; "𝒇?" unpainted when the
9
+ // cap applies but is unmeasured, then session/5h, then week), then EVERY
10
+ // parked account's week after its own cyan ◇ (red if needs-reauth),
11
+ // sorted by earliest upcoming reset (needs-reauth last). A window with no
12
+ // upcoming reset renders "0" (empty again); measured usage with an unknown
13
+ // reset clock renders a painted "?". With color disabled the numeric format
14
+ // returns ("2h5" = resets in 2h, 5 used; "ctx 42"): without color a drained
15
+ // window must not look fresh. Blocks are joined by TWO spaces, tokens
12
16
  // within a block by one. Per-model resets are omitted (they match the
13
17
  // weekly reset).
14
18
  // Must NEVER break the status line: render what parses, skip what doesn't.
@@ -17,14 +21,13 @@ import { sortBy } from "es-toolkit";
17
21
  import { z } from "zod";
18
22
  import { readOAuthAccount } from "../lib/claudejson.ts";
19
23
  import { loadAccounts, loadConfig, loadLastSwapAt, loadModelUsage, writeUsage } from "../lib/state.ts";
20
- import { familyTokens, gatedFamilies, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
21
- import { effectiveBars, isExhausted, swapPreference, weeklyExpiry } from "../lib/picker.ts";
24
+ import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
25
+ import { earliestReset, weeklyExpiry } from "../lib/picker.ts";
22
26
  import { worktreeName } from "../lib/worktree.ts";
23
- import { fmtResetShort, makeColors } from "../cli/render.ts";
27
+ import { fmtResetShort, makeColors, makeUsagePaint } from "../cli/render.ts";
24
28
  import {
25
29
  AccountsIndexSchema,
26
30
  StatusLineStdinSchema,
27
- ThresholdsSchema,
28
31
  UsageWindowSchema,
29
32
  type Account,
30
33
  type UsageState,
@@ -42,15 +45,16 @@ const RenderCtxSchema = z.object({
42
45
  perModel: z.record(z.string(), UsageWindowSchema),
43
46
  /** families (lowercased) whose per-model weekly cap gates a switch. */
44
47
  switchModels: z.array(z.string()),
45
- thresholds: ThresholdsSchema,
46
48
  /** linked-worktree basename, null in a main checkout. */
47
49
  worktree: z.string().nullable(),
48
50
  now: z.number(),
49
51
  color: z.boolean(),
52
+ /** terminal advertises 24-bit color (COLORTERM); false steps the ramp to the 256-color cube. */
53
+ truecolor: z.boolean(),
50
54
  });
51
55
  export type RenderCtx = z.infer<typeof RenderCtxSchema>;
52
56
 
53
- async function readStdin(): Promise<string> {
57
+ export async function readStdin(): Promise<string> {
54
58
  const chunks: Uint8Array[] = [];
55
59
  for await (const c of Bun.stdin.stream()) chunks.push(c);
56
60
  return Buffer.concat(chunks).toString("utf8");
@@ -59,8 +63,7 @@ async function readStdin(): Promise<string> {
59
63
  /** Pure renderer: statusLine stdin + tokenmaxxing state → the emitted line. */
60
64
  export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
61
65
  const col = makeColors(ctx.color);
62
- const severity = (u: number) => (u >= 95 ? col.red : u >= 75 ? col.yellow : col.green);
63
- const gauge = (u: number) => col.bold(severity(u)(`${Math.round(u)}`));
66
+ const paint = makeUsagePaint({ enabled: ctx.color, truecolor: ctx.truecolor });
64
67
  // Used quota in a window; one whose reset has passed is empty again.
65
68
  const used = (w: UsageWindow) => (w.resetsAt != null && w.resetsAt <= ctx.now ? 0 : w.usedPercentage);
66
69
  const reset = (epochMs: number | null) => fmtResetShort(epochMs, ctx.now);
@@ -71,29 +74,43 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
71
74
  // ---- info block: worktree, model, ctx, diff
72
75
  const info: string[] = [];
73
76
  if (ctx.worktree != null) info.push(ctx.worktree);
77
+ // The model name carries the context-fill color (user rule 2026-07-18, no
78
+ // ctx token); colorless mode keeps the numeric ctx token instead.
74
79
  const modelName = d?.model?.display_name ?? d?.model?.id;
80
+ const ctxUsed = d?.context_window?.used_percentage;
75
81
  if (modelName) {
76
82
  const effort = d?.effort?.level;
77
- info.push(col.bold(modelName) + (effort ? ` (${effort})` : ""));
83
+ const body = ctxUsed != null ? col.bold(paint(ctxUsed)(modelName)) : col.bold(modelName);
84
+ info.push(body + (effort ? ` (${effort})` : ""));
78
85
  }
79
- const ctxUsed = d?.context_window?.used_percentage;
80
- if (ctxUsed != null) info.push(`ctx ${gauge(ctxUsed)}`);
86
+ if (!ctx.color && ctxUsed != null) info.push(`ctx ${Math.round(ctxUsed)}`);
81
87
  const added = d?.cost?.total_lines_added ?? 0;
82
88
  const removed = d?.cost?.total_lines_removed ?? 0;
83
89
  // -removed stays unpainted: red means quota alarm and nothing else.
84
90
  if (added > 0 || removed > 0) info.push(`${col.green(`+${added}`)}/-${removed}`);
85
91
 
86
92
  // ---- active account block
87
- const seg = (label: string, w: UsageWindow, resetAt: number | null) =>
88
- `${label}${reset(resetAt)}${gauge(used(w))}`;
93
+ // A window token: color carries the used%, the text is the reset countdown
94
+ // (or the bare per-model initial - per-model resets are omitted). "0" = no
95
+ // upcoming reset (the window is empty again); a painted "?" = measured usage
96
+ // whose reset clock is unknown. Colorless mode glues the number back on.
97
+ const seg = (label: string, w: UsageWindow, resetAt: number | null) => {
98
+ const u = used(w);
99
+ if (!ctx.color) return `${label}${reset(resetAt)}${Math.round(u)}`;
100
+ const body = label !== "" ? label : reset(resetAt);
101
+ return col.bold(paint(u)(body !== "" ? body : u > 0 ? "?" : "0"));
102
+ };
103
+ // Per-model initial: fable renders 𝒇 (user rule 2026-07-18), family-matched
104
+ // as always; other families keep their uppercased first letter.
105
+ const initial = (name: string) => (familyTokens(name).includes("fable") ? "𝒇" : name.slice(0, 1).toUpperCase());
89
106
  const wins = parseStatusLineStdin(stdinObj);
90
107
  const windows: string[] = [];
91
108
  // A capacity-constrained model whose cap is unmeasured must not look safe.
92
109
  const family = matchedFamily(parseStatusLineModel(stdinObj), ctx.switchModels);
93
110
  if (family && !Object.keys(ctx.perModel).some((k) => familyTokens(k).includes(family))) {
94
- windows.push(`${family[0]!.toUpperCase()}?`);
111
+ windows.push(`${initial(family)}?`);
95
112
  }
96
- for (const [name, w] of Object.entries(ctx.perModel)) windows.push(seg(name.slice(0, 1), w, null));
113
+ for (const [name, w] of Object.entries(ctx.perModel)) windows.push(seg(initial(name), w, null));
97
114
  if (wins) {
98
115
  windows.push(seg("", wins.fiveHour, wins.fiveHour.resetsAt));
99
116
  windows.push(seg("", wins.sevenDay, wins.sevenDay.resetsAt));
@@ -105,49 +122,31 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
105
122
  ? `${col.green("◆")} ?`
106
123
  : "";
107
124
 
108
- // ---- parked accounts, in swap order: the first usable ◇ is the next target
109
- const pickCtx = {
110
- now: ctx.now,
111
- thresholds: ctx.thresholds,
112
- currentAccountUuid: ctx.accounts.activeAccountUuid,
113
- switchFamilies: gatedFamilies(parseStatusLineModel(stdinObj), ctx.switchModels),
114
- };
125
+ // ---- parked accounts, earliest upcoming reset first (needs-reauth last)
115
126
  const parked = sortBy(
116
127
  ctx.accounts.accounts.filter((a) => a.accountUuid !== ctx.accounts.activeAccountUuid),
117
- [(a) => (a.needsReauth || isExhausted(a, pickCtx) ? 1 : 0), ...swapPreference(ctx.now)],
128
+ [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, ctx.now)],
118
129
  );
119
- const poolSeg = (a: Account): { kind: "full" | "unknown" | "other"; text: string } => {
130
+ // Every parked account renders its own marker (user rule 2026-07-18: the
131
+ // old counted collapse "◇ 3 full" hid the pool size and read as confusing).
132
+ const poolSeg = (a: Account): string => {
120
133
  const marker = a.needsReauth ? col.red("✗") : col.cyan("◇");
121
- const mergeable = !a.needsReauth;
122
-
123
134
  const week = a.lastUsage?.sevenDay;
124
- if (week == null) return { kind: mergeable ? "unknown" : "other", text: `${marker} ?` };
135
+ if (week == null) return `${marker} ?`;
125
136
  const weekUsed = used(week);
126
- if (Math.round(weekUsed) <= 0) return { kind: mergeable ? "full" : "other", text: `${marker} ${col.green("full")}` };
137
+ if (Math.round(weekUsed) <= 0) return `${marker} ${paint(0)("full")}`;
127
138
 
128
139
  const parts: string[] = [];
129
140
  // A per-model weekly cap with more used than the aggregate is the binding constraint - surface it.
130
141
  for (const [name, w] of Object.entries(a.lastPerModel ?? {})) {
131
- if (used(w) > weekUsed) parts.push(seg(name.slice(0, 1), w, null));
142
+ if (used(w) > weekUsed) parts.push(seg(initial(name), w, null));
132
143
  }
133
144
  const expiry = weeklyExpiry(a, ctx.now);
134
145
  parts.push(seg("", week, Number.isFinite(expiry) ? expiry : null));
135
- return { kind: "other", text: `${marker} ${parts.join(" ")}` };
146
+ return `${marker} ${parts.join(" ")}`;
136
147
  };
137
148
 
138
- // Adjacent identical bare tokens collapse into one counted token ("◇ 3 full").
139
- const pool: string[] = [];
140
- const segs = parked.map(poolSeg);
141
- for (let i = 0; i < segs.length; ) {
142
- const s = segs[i]!;
143
- let j = i + 1;
144
- while (s.kind !== "other" && j < segs.length && segs[j]!.kind === s.kind) j++;
145
- if (j - i >= 2) pool.push(`${col.cyan("◇")} ${j - i} ${s.kind === "full" ? col.green("full") : "?"}`);
146
- else pool.push(s.text);
147
- i = j;
148
- }
149
-
150
- return [info.join(" "), active, ...pool].filter((l) => l !== "").join(" ");
149
+ return [info.join(" "), active, ...parked.map(poolSeg)].filter((l) => l !== "").join(" ");
151
150
  }
152
151
 
153
152
  export async function runStatusline(): Promise<number> {
@@ -178,14 +177,15 @@ export async function runStatusline(): Promise<number> {
178
177
  const modelUsage = loadModelUsage();
179
178
  const stdin = StatusLineStdinSchema.safeParse(obj);
180
179
  const dir = stdin.success ? (stdin.data.workspace?.current_dir ?? stdin.data.workspace?.project_dir ?? null) : null;
180
+ const colorterm = z.string().optional().parse(process.env.COLORTERM);
181
181
  const ctx: RenderCtx = {
182
182
  accounts: loadAccounts(),
183
183
  perModel: modelUsage && modelUsage.org === org ? modelUsage.perModel : {},
184
184
  switchModels: cfg.policy.switchModels,
185
- thresholds: effectiveBars(cfg),
186
185
  worktree: dir == null ? null : worktreeName(dir),
187
186
  now,
188
187
  color: !process.env.NO_COLOR,
188
+ truecolor: colorterm != null && (colorterm.includes("truecolor") || colorterm.includes("24bit")),
189
189
  };
190
190
  process.stdout.write(renderStatusline(obj, ctx) + "\n");
191
191
  return 0;
@@ -0,0 +1,78 @@
1
+ // Renders claude's subagentStatusLine: one {id, content} JSON line per active
2
+ // subagent task, so the agents panel shows each subagent's model, effort, and
3
+ // ctx fill the way the main statusline shows the session's (user ask
4
+ // 2026-07-18). This per-row panel surface is the only place subagent info can
5
+ // appear: the main statusLine command never learns which subagent the UI is
6
+ // viewing (verified against the 2.1.214 bundle). Row shape mirrors the main
7
+ // info block, info first so claude's end-truncation eats the label instead:
8
+ // "fable (high) <task label>" - the family name painted by the task's
9
+ // context fill (colorless mode keeps a numeric ctx token).
10
+ // A task we emit nothing for keeps claude's default row (emitting empty
11
+ // content would HIDE it). Must NEVER break the panel: render what parses,
12
+ // skip what doesn't.
13
+
14
+ import { z } from "zod";
15
+ import { makeColors, makeUsagePaint } from "../cli/render.ts";
16
+ import { readStdin } from "./statusline.ts";
17
+ import { SubagentStatusLineStdinSchema } from "../lib/types.ts";
18
+
19
+ const RowCtxSchema = z.object({ color: z.boolean(), truecolor: z.boolean() });
20
+ export type RowCtx = z.infer<typeof RowCtxSchema>;
21
+
22
+ /** "claude-fable-5" -> "fable" (lowercase family, matching the chart-label
23
+ * convention); an id without the claude- prefix passes through whole. */
24
+ function modelFamily(id: string): string {
25
+ const [head, family] = id.split("-");
26
+ return head === "claude" && family ? family : id;
27
+ }
28
+
29
+ /** Pure renderer: subagentStatusLine stdin -> {id, content} JSON lines. */
30
+ export function renderSubagentRows(stdinObj: unknown, ctx: RowCtx): string[] {
31
+ const parsed = SubagentStatusLineStdinSchema.safeParse(stdinObj);
32
+ if (!parsed.success) return [];
33
+ const col = makeColors(ctx.color);
34
+ const paint = makeUsagePaint({ enabled: ctx.color, truecolor: ctx.truecolor });
35
+
36
+ const rows: string[] = [];
37
+ for (const t of parsed.data.tasks ?? []) {
38
+ if (t.id == null) continue;
39
+ const pct =
40
+ t.tokenCount != null && t.contextWindowSize != null && t.contextWindowSize > 0
41
+ ? Math.round((t.tokenCount / t.contextWindowSize) * 100)
42
+ : null;
43
+ const info: string[] = [];
44
+ // The model name carries the task's context-fill color (same rule as the
45
+ // main line); colorless mode keeps the numeric ctx token instead.
46
+ if (t.model != null) {
47
+ const family = modelFamily(t.model);
48
+ const body = pct != null ? col.bold(paint(pct)(family)) : col.bold(family);
49
+ info.push(body + (t.effort ? ` (${t.effort})` : ""));
50
+ }
51
+ if (!ctx.color && pct != null) info.push(`ctx ${pct}`);
52
+
53
+ const label = t.label ?? t.description ?? t.name;
54
+ const parts: string[] = [];
55
+ if (info.length > 0) parts.push(info.join(" "));
56
+ if (label != null && label !== "") parts.push(label);
57
+ if (parts.length === 0) continue;
58
+ rows.push(JSON.stringify({ id: t.id, content: parts.join(" ") }));
59
+ }
60
+ return rows;
61
+ }
62
+
63
+ export async function runSubagentStatusline(): Promise<number> {
64
+ const raw = await readStdin();
65
+ let obj: unknown = null;
66
+ try {
67
+ obj = JSON.parse(raw);
68
+ } catch {
69
+ // malformed stdin - emit nothing, claude keeps its default rows
70
+ }
71
+ const colorterm = z.string().optional().parse(process.env.COLORTERM);
72
+ const rows = renderSubagentRows(obj, {
73
+ color: !process.env.NO_COLOR,
74
+ truecolor: colorterm != null && (colorterm.includes("truecolor") || colorterm.includes("24bit")),
75
+ });
76
+ if (rows.length > 0) process.stdout.write(rows.join("\n") + "\n");
77
+ return 0;
78
+ }
package/src/lib/paths.ts CHANGED
@@ -35,6 +35,13 @@ export const paths = {
35
35
  /** linux only: parked credential .json files (0700 dir, 0600 files). */
36
36
  credsDir: join(TM_HOME, "creds"),
37
37
 
38
+ /** `xx serve` slack bridge: tokens + channel->repo links (0600: holds the
39
+ * xoxb-/xapp- tokens), per-thread claude session records, and the git
40
+ * worktrees threads run in. */
41
+ slackJson: join(TM_HOME, "slack.json"),
42
+ slackThreadsDir: join(TM_HOME, "slack-threads"),
43
+ slackWorktreesDir: join(TM_HOME, "slack-worktrees"),
44
+
38
45
  /** ~/.claude.json - holds the active `oauthAccount` identity object. */
39
46
  claudeJson: env("TOKENMAXXING_CLAUDE_JSON", join(HOME, ".claude.json")),
40
47
  /** ~/.claude/settings.json - user-owned; we merge three entries into it. */
package/src/lib/picker.ts CHANGED
@@ -98,6 +98,16 @@ export function weeklyExpiry(a: Account, now: number): number {
98
98
  return nextWeeklyReset(a.lastUsage?.sevenDay.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
99
99
  }
100
100
 
101
+ /** Soonest upcoming reset among the account's cached windows: the 5h session
102
+ * reset if still ahead, else the extrapolated weekly expiry; Infinity with no
103
+ * known reset anchor (sorts last). Display order for `status` and the
104
+ * statusLine pool (user decision 2026-07-18) - deliberately decoupled from
105
+ * swapPreference, which keeps ranking actual swaps. */
106
+ export function earliestReset(a: Account, now: number): number {
107
+ const fiveHour = a.lastUsage?.fiveHour.resetsAt;
108
+ return Math.min(fiveHour != null && fiveHour > now ? fiveHour : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
109
+ }
110
+
101
111
  /** How far behind its own weekly pace the account is, measured forward: the
102
112
  * burn rate (percent per ms) its remaining weekly quota must be consumed at
103
113
  * to beat the reset that forfeits it. A backward-looking used/expected ratio
@@ -116,8 +126,7 @@ export function pacePressure(a: Account, now: number): number {
116
126
 
117
127
  /** The switch preference: furthest behind its own weekly pace first (highest
118
128
  * pacePressure), tiebreak soonest weekly expiry then lowest 7-day usage.
119
- * Shared with the statusLine pool ordering so the display order IS the
120
- * swap order. */
129
+ * Ranks swaps only; display surfaces order by earliestReset instead. */
121
130
  export const swapPreference = (now: number) => [
122
131
  (a: Account) => -pacePressure(a, now),
123
132
  (a: Account) => weeklyExpiry(a, now),