tokenmaxxing 0.17.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.
- package/DESIGN.md +13 -1
- package/README.md +1 -0
- package/package.json +5 -1
- package/src/cli/doctor.ts +1 -0
- package/src/cli/render.ts +27 -0
- package/src/cli/serve.ts +266 -0
- package/src/cli/status.ts +18 -3
- package/src/entries/statusline.ts +53 -53
- package/src/entries/subagentstatusline.ts +78 -0
- package/src/lib/paths.ts +7 -0
- package/src/lib/picker.ts +11 -2
- package/src/lib/settings.ts +17 -6
- package/src/lib/slackbridge.ts +107 -0
- package/src/lib/slackstate.ts +125 -0
- package/src/lib/types.ts +22 -0
- package/src/main.ts +5 -0
package/DESIGN.md
CHANGED
|
@@ -28,7 +28,7 @@ It is a process/PTY manager only - spawn, forward the terminal, wait, restore te
|
|
|
28
28
|
## 2. What tokenmaxxing installs
|
|
29
29
|
|
|
30
30
|
- A `claude` **supervisor** on your PATH ahead of the real binary (`~/.config/tokenmaxxing/bin/claude`), or a shell function - you invoke it identically.
|
|
31
|
-
-
|
|
31
|
+
- Four `~/.claude/settings.json` entries (merged, preserving anything you already have): a transparent `statusLine` shim, a `subagentStatusLine` shim (per-subagent rows in the agents panel), a `Stop` hook, a `SessionStart` hook.
|
|
32
32
|
- **`~/.config/tokenmaxxing/`** - the single home for config and state:
|
|
33
33
|
- `config.json` - threshold, account order/policy.
|
|
34
34
|
- `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, lastUsage, resetsAt, needs_reauth}`.
|
|
@@ -83,6 +83,18 @@ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from the
|
|
|
83
83
|
|
|
84
84
|
---
|
|
85
85
|
|
|
86
|
+
## 5b. The Slack bridge (`xx serve`, 0.18.0)
|
|
87
|
+
|
|
88
|
+
A local Socket Mode daemon (no public URL) that turns Slack threads into Claude Code sessions on the pooled accounts. Stack (user decision 2026-07-18): Vercel's Chat SDK (`chat` + `@chat-adapter/slack`) for the Slack side; the Claude Agent SDK driven through `src/sdk.ts`'s pooled surface for the claude side - `xx serve` is that surface's first in-repo consumer. EVE (Vercel's agent framework) was researched and explicitly dropped: it owns its own model loop via AI Gateway, so it would replace Claude Code rather than drive it.
|
|
89
|
+
|
|
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
|
+
- **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
|
+
- **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`. Turn failures post a trimmed message-only diagnostic (never a raw error body). The socket loop aborts visibly after 3 consecutive connection failures.
|
|
94
|
+
- **Verified hermetically** (2026-07-18): schema/link management, worktree creation + idempotency, arg parsing, daemon fail-fast paths. **Not yet live-verified**: a real Socket Mode connection under Bun (needs real tokens; the underlying ws/undici primitives tested clean) and a real relayed turn (meters an account). Run one live smoke test before relying on it.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
86
98
|
## 6. Honest papercuts
|
|
87
99
|
- **Respawn hiccup (depleted pause only).** Plain swaps never restart the session. When the whole pool is depleted you see `claude` stop, a countdown, and a resume; anything typed in the split second before the SIGTERM is lost, and the supervisor resets terminal mode so nothing is left garbled.
|
|
88
100
|
- **Adoption lag.** macOS reads the keychain through a raw 30s cache, so at most the first turn after a swap can still meter the old account. The bars' headroom absorbs it.
|
package/README.md
CHANGED
|
@@ -45,6 +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
49
|
| `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
|
|
49
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) |
|
|
50
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.18.0",
|
|
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",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"typescript": "^5.6.0"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
|
|
44
|
+
"@chat-adapter/slack": "^4.34.0",
|
|
45
|
+
"@chat-adapter/state-memory": "^4.34.0",
|
|
46
|
+
"chat": "^4.34.0",
|
|
43
47
|
"es-toolkit": "^1.49.0",
|
|
44
48
|
"ky": "^2.0.2",
|
|
45
49
|
"zod": "^4.4.3"
|
package/src/cli/doctor.ts
CHANGED
|
@@ -33,6 +33,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
33
33
|
|
|
34
34
|
const s = checkSettings();
|
|
35
35
|
check(s.statusLineOk, "statusLine shim installed in settings.json", "run `tokenmaxxing init`");
|
|
36
|
+
check(s.subagentStatusLineOk, "subagentStatusLine shim installed in settings.json", "run `tokenmaxxing init`");
|
|
36
37
|
check(s.stopOk, "Stop hook installed in settings.json", "run `tokenmaxxing init`");
|
|
37
38
|
check(s.sessionStartOk, "SessionStart hook installed in settings.json", "run `tokenmaxxing init`");
|
|
38
39
|
check(checkTimerHealthy(), "periodic check timer active", timerActivationHint());
|
package/src/cli/render.ts
CHANGED
|
@@ -18,6 +18,33 @@ export function makeColors(enabled: boolean) {
|
|
|
18
18
|
|
|
19
19
|
export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
|
|
20
20
|
|
|
21
|
+
/** Used% -> RGB on a continuous green->yellow->red ramp, anchored at the
|
|
22
|
+
* semantic severity bands (pure green at 0, yellow at 75, red from 95) so the
|
|
23
|
+
* band boundaries read exactly like the old 3-color scheme. Blue stays 0. */
|
|
24
|
+
function rampRgb(usedPct: number): { r: number; g: number } {
|
|
25
|
+
const u = clamp(usedPct, 0, 100);
|
|
26
|
+
if (u >= 95) return { r: 255, g: 0 };
|
|
27
|
+
if (u >= 75) return { r: 255, g: Math.round(255 * (1 - (u - 75) / 20)) };
|
|
28
|
+
return { r: Math.round((255 * u) / 75), g: 255 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Usage-severity painter for the statusLine (user decision 2026-07-18: colors
|
|
32
|
+
* carry quota status instead of numbers). Truecolor when the terminal
|
|
33
|
+
* advertises it, else the nearest 256-color cube step; disabled -> identity,
|
|
34
|
+
* and callers must then fall back to numeric rendering (without color a
|
|
35
|
+
* drained window must not look fresh). */
|
|
36
|
+
export function makeUsagePaint(input: { enabled: boolean; truecolor: boolean }) {
|
|
37
|
+
return (usedPct: number) =>
|
|
38
|
+
(s: string): string => {
|
|
39
|
+
if (!input.enabled) return s;
|
|
40
|
+
const { r, g } = rampRgb(usedPct);
|
|
41
|
+
const code = input.truecolor
|
|
42
|
+
? `38;2;${r};${g};0`
|
|
43
|
+
: `38;5;${16 + 36 * Math.round(r / 51) + 6 * Math.round(g / 51)}`;
|
|
44
|
+
return `\x1b[${code}m${s}\x1b[0m`;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
21
48
|
/** Plan label for a claude account, e.g. "max 20x": subscription name plus the
|
|
22
49
|
* multiplier segment of the rate-limit tier id ("default_claude_max_20x").
|
|
23
50
|
* Structural, never exact-string: the multiplier is any <digits>x segment, so
|
package/src/cli/serve.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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[] = [];
|
|
@@ -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
|
|
4
|
-
// +added/-removed, then quota as
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// green ◆ (per-model by initial
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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,
|
|
21
|
-
import {
|
|
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
|
|
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
|
-
|
|
83
|
+
const body = ctxUsed != null ? col.bold(paint(ctxUsed)(modelName)) : col.bold(modelName);
|
|
84
|
+
info.push(body + (effort ? ` (${effort})` : ""));
|
|
78
85
|
}
|
|
79
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
111
|
+
windows.push(`${initial(family)}?`);
|
|
95
112
|
}
|
|
96
|
-
for (const [name, w] of Object.entries(ctx.perModel)) windows.push(seg(name
|
|
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,
|
|
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
|
|
128
|
+
[(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, ctx.now)],
|
|
118
129
|
);
|
|
119
|
-
|
|
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
|
|
135
|
+
if (week == null) return `${marker} ?`;
|
|
125
136
|
const weekUsed = used(week);
|
|
126
|
-
if (Math.round(weekUsed) <= 0) return
|
|
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
|
|
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
|
|
146
|
+
return `${marker} ${parts.join(" ")}`;
|
|
136
147
|
};
|
|
137
148
|
|
|
138
|
-
|
|
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
|
-
*
|
|
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),
|
package/src/lib/settings.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// Idempotent merge of tokenmaxxing's
|
|
2
|
-
// ~/.claude/settings.json: our statusLine, a Stop hook,
|
|
3
|
-
// Hooks APPEND to existing arrays; the statusLine
|
|
4
|
-
// tokenmaxxing renders
|
|
1
|
+
// Idempotent merge of tokenmaxxing's entries into the user-owned
|
|
2
|
+
// ~/.claude/settings.json: our statusLine + subagentStatusLine, a Stop hook,
|
|
3
|
+
// a SessionStart hook. Hooks APPEND to existing arrays; the statusLine slots
|
|
4
|
+
// are ours outright - tokenmaxxing renders them natively, so any other
|
|
5
|
+
// statusLine command is replaced.
|
|
5
6
|
|
|
6
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
8
|
import { join } from "node:path";
|
|
@@ -19,6 +20,7 @@ const HookGroupSchema = z.looseObject({ matcher: z.string().optional(), hooks: z
|
|
|
19
20
|
const StatusLineSchema = z.looseObject({ type: z.string(), command: z.string() });
|
|
20
21
|
const SettingsSchema = z.looseObject({
|
|
21
22
|
statusLine: StatusLineSchema.optional(),
|
|
23
|
+
subagentStatusLine: StatusLineSchema.optional(),
|
|
22
24
|
hooks: z.record(z.string(), z.array(HookGroupSchema)).optional(),
|
|
23
25
|
});
|
|
24
26
|
type Settings = z.infer<typeof SettingsSchema>;
|
|
@@ -26,6 +28,7 @@ type HookGroup = z.infer<typeof HookGroupSchema>;
|
|
|
26
28
|
|
|
27
29
|
const SUBCMD = {
|
|
28
30
|
statusline: "__statusline",
|
|
31
|
+
subagentStatusline: "__subagent-statusline",
|
|
29
32
|
stop: "__stop-hook",
|
|
30
33
|
sessionStart: "__session-start",
|
|
31
34
|
} as const;
|
|
@@ -44,6 +47,7 @@ export function isOurCommand(cmd: string | undefined): boolean {
|
|
|
44
47
|
if (!cmd) return false;
|
|
45
48
|
return (
|
|
46
49
|
cmd.includes(SUBCMD.statusline) ||
|
|
50
|
+
cmd.includes(SUBCMD.subagentStatusline) ||
|
|
47
51
|
cmd.includes(SUBCMD.stop) ||
|
|
48
52
|
cmd.includes(SUBCMD.sessionStart) ||
|
|
49
53
|
// also match the installed bin path even if the subcommand text changes
|
|
@@ -70,29 +74,35 @@ function removeHook(s: Settings, event: string, sub: string): void {
|
|
|
70
74
|
if (s.hooks![event]!.length === 0) delete s.hooks![event];
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
/** Install the
|
|
77
|
+
/** Install the entries: take both statusLine slots, append our hooks. */
|
|
74
78
|
export function installSettings(): void {
|
|
75
79
|
const s = readSettings();
|
|
76
80
|
s.statusLine = {
|
|
77
81
|
type: "command",
|
|
78
82
|
command: `${JSON.stringify(installedBin())} ${SUBCMD.statusline}`,
|
|
79
83
|
};
|
|
84
|
+
s.subagentStatusLine = {
|
|
85
|
+
type: "command",
|
|
86
|
+
command: `${JSON.stringify(installedBin())} ${SUBCMD.subagentStatusline}`,
|
|
87
|
+
};
|
|
80
88
|
appendHook(s, "Stop", SUBCMD.stop);
|
|
81
89
|
appendHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
82
90
|
writeSettings(s);
|
|
83
91
|
}
|
|
84
92
|
|
|
85
|
-
/** Remove our
|
|
93
|
+
/** Remove our entries. The statusLine slots are deleted only if they are ours. */
|
|
86
94
|
export function uninstallSettings(): void {
|
|
87
95
|
const s = readSettings();
|
|
88
96
|
removeHook(s, "Stop", SUBCMD.stop);
|
|
89
97
|
removeHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
90
98
|
if (s.statusLine && isOurCommand(s.statusLine.command)) delete s.statusLine;
|
|
99
|
+
if (s.subagentStatusLine && isOurCommand(s.subagentStatusLine.command)) delete s.subagentStatusLine;
|
|
91
100
|
writeSettings(s);
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
const SettingsCheckSchema = z.object({
|
|
95
104
|
statusLineOk: z.boolean(),
|
|
105
|
+
subagentStatusLineOk: z.boolean(),
|
|
96
106
|
stopOk: z.boolean(),
|
|
97
107
|
sessionStartOk: z.boolean(),
|
|
98
108
|
});
|
|
@@ -104,6 +114,7 @@ export function checkSettings(): SettingsCheck {
|
|
|
104
114
|
!!s.hooks?.[event]?.some((g) => g.hooks?.some((h) => h.command?.includes(sub)));
|
|
105
115
|
return {
|
|
106
116
|
statusLineOk: isOurCommand(s.statusLine?.command),
|
|
117
|
+
subagentStatusLineOk: isOurCommand(s.subagentStatusLine?.command),
|
|
107
118
|
stopOk: has("Stop", SUBCMD.stop),
|
|
108
119
|
sessionStartOk: has("SessionStart", SUBCMD.sessionStart),
|
|
109
120
|
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The Slack->claude relay. One claude turn per Slack message via the Agent SDK
|
|
2
|
+
// (re-query with resume, never a persistent streaming query: the SDK subprocess
|
|
3
|
+
// reads credentials at spawn, so per-turn spawns are what let the pool decision
|
|
4
|
+
// pick the freshest account at every boundary and let the daemon restart
|
|
5
|
+
// without losing threads). Verified against @anthropic-ai/claude-agent-sdk
|
|
6
|
+
// 0.3.214 and code.claude.com/docs 2026-07-18; both change monthly.
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
12
|
+
import { ensureBestAccount, pooledOptions, stopHookCheck } from "../sdk.ts";
|
|
13
|
+
import { paths } from "./paths.ts";
|
|
14
|
+
import { threadKey, type SlackLink } from "./slackstate.ts";
|
|
15
|
+
import { log } from "./log.ts";
|
|
16
|
+
|
|
17
|
+
/** Mutated in place by relayTurn so the caller can hand the generator straight
|
|
18
|
+
* to thread.post(AsyncIterable) and still read the turn's outcome afterward. */
|
|
19
|
+
export const TurnOutcomeSchema = z.object({
|
|
20
|
+
sessionId: z.string().nullable(),
|
|
21
|
+
failed: z.boolean(),
|
|
22
|
+
});
|
|
23
|
+
export type TurnOutcome = z.infer<typeof TurnOutcomeSchema>;
|
|
24
|
+
|
|
25
|
+
/** Run one git command against a repo; throws with trimmed stderr on failure. */
|
|
26
|
+
function git(repo: string, args: string[]): string {
|
|
27
|
+
const r = Bun.spawnSync(["git", "-C", repo, ...args], { stdout: "pipe", stderr: "pipe" });
|
|
28
|
+
const stderr = new TextDecoder().decode(r.stderr).trim();
|
|
29
|
+
if (r.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${stderr.slice(0, 200)}`);
|
|
30
|
+
return new TextDecoder().decode(r.stdout).trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The stable cwd a thread's turns run in. Worktree mode (default) creates
|
|
35
|
+
* `slack-worktrees/<threadKey>` on branch `tm-slack-<threadKey>` cut from the
|
|
36
|
+
* repo's current HEAD, once; both survive daemon restarts because resume is
|
|
37
|
+
* cwd-keyed. Worktrees are never auto-deleted (they hold the thread's work):
|
|
38
|
+
* clean up with `git worktree remove` when done.
|
|
39
|
+
*/
|
|
40
|
+
export function ensureThreadCwd(input: { link: SlackLink; threadId: string }): string {
|
|
41
|
+
if (!input.link.worktree) return input.link.repo;
|
|
42
|
+
const key = threadKey(input.threadId);
|
|
43
|
+
const dir = join(paths.slackWorktreesDir, key);
|
|
44
|
+
if (existsSync(dir)) return dir;
|
|
45
|
+
mkdirSync(paths.slackWorktreesDir, { recursive: true });
|
|
46
|
+
const branch = `tm-slack-${key}`;
|
|
47
|
+
const branchExists = Bun.spawnSync(["git", "-C", input.link.repo, "rev-parse", "--verify", "--quiet", branch]).exitCode === 0;
|
|
48
|
+
// a crash between branch creation and worktree add leaves the branch behind;
|
|
49
|
+
// reattach instead of failing on -b collision.
|
|
50
|
+
if (branchExists) git(input.link.repo, ["worktree", "add", dir, branch]);
|
|
51
|
+
else git(input.link.repo, ["worktree", "add", dir, "-b", branch]);
|
|
52
|
+
log("serve.worktree_created", { dir, branch });
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One claude turn, yielded as streaming text deltas (feed directly to
|
|
58
|
+
* thread.post). Never throws: a failure yields a short diagnostic line and
|
|
59
|
+
* sets outcome.failed (the daemon must keep serving other threads). Error
|
|
60
|
+
* text is message-only - a raw error body could echo request material.
|
|
61
|
+
*/
|
|
62
|
+
export async function* relayTurn(
|
|
63
|
+
input: { cwd: string; sessionId: string | null; prompt: string; link: SlackLink },
|
|
64
|
+
outcome: TurnOutcome,
|
|
65
|
+
): AsyncGenerator<string> {
|
|
66
|
+
let yieldedAny = false;
|
|
67
|
+
try {
|
|
68
|
+
// the switch decision runs at the spawn boundary, same as the CLI hooks.
|
|
69
|
+
await ensureBestAccount();
|
|
70
|
+
const q = query({
|
|
71
|
+
prompt: input.prompt,
|
|
72
|
+
options: {
|
|
73
|
+
...pooledOptions(),
|
|
74
|
+
cwd: input.cwd,
|
|
75
|
+
permissionMode: input.link.permissionMode,
|
|
76
|
+
includePartialMessages: true,
|
|
77
|
+
hooks: { Stop: [{ hooks: [stopHookCheck] }] },
|
|
78
|
+
...(input.link.model ? { model: input.link.model } : {}),
|
|
79
|
+
...(input.sessionId ? { resume: input.sessionId } : {}),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
let result: string | null = null;
|
|
83
|
+
for await (const message of q) {
|
|
84
|
+
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
|
+
if (message.type === "result") {
|
|
93
|
+
outcome.sessionId = message.session_id;
|
|
94
|
+
if (message.subtype === "success") result = message.result;
|
|
95
|
+
else outcome.failed = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// a turn that produced no streamed text (tool-only turns) still reports.
|
|
99
|
+
if (!yieldedAny && result) yield result;
|
|
100
|
+
if (!yieldedAny && !result && outcome.failed) yield "the turn ended without a result (limit or error) - trying again may help";
|
|
101
|
+
} catch (e) {
|
|
102
|
+
outcome.failed = true;
|
|
103
|
+
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
104
|
+
log("serve.turn_error", { err: detail });
|
|
105
|
+
yield `${yieldedAny ? "\n\n" : ""}tokenmaxxing: turn failed: ${detail}`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// `xx serve` state. slack.json holds the Slack tokens plus the channel->repo
|
|
2
|
+
// links; it is credential material (xoxb-/xapp-), so it is written 0600 and its
|
|
3
|
+
// tokens must never be printed. Per-thread records under slack-threads/ pin the
|
|
4
|
+
// claude session id + cwd a Slack thread resumes into (resume is cwd-keyed, so
|
|
5
|
+
// the cwd must stay byte-stable for the thread's whole life). A present but
|
|
6
|
+
// unparseable slack.json THROWS (no silent empty config); absent = null.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { paths } from "./paths.ts";
|
|
12
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
13
|
+
|
|
14
|
+
/** Claude Agent SDK permission modes an unattended bridge may run under.
|
|
15
|
+
* acceptEdits (default) auto-approves file edits but not arbitrary Bash;
|
|
16
|
+
* bypassPermissions is full autonomy - a per-link, user-chosen risk posture. */
|
|
17
|
+
export const ServePermissionModeSchema = z.enum(["default", "acceptEdits", "bypassPermissions", "dontAsk", "plan"]);
|
|
18
|
+
|
|
19
|
+
export const SlackLinkSchema = z.object({
|
|
20
|
+
/** Slack channel id (C.../G...). Names are not stored - ids are stable. */
|
|
21
|
+
channel: z.string(),
|
|
22
|
+
/** absolute path of the git repo this channel drives. */
|
|
23
|
+
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
|
+
permissionMode: ServePermissionModeSchema.default("acceptEdits"),
|
|
27
|
+
/** optional model override for this repo's sessions. */
|
|
28
|
+
model: z.string().optional(),
|
|
29
|
+
});
|
|
30
|
+
export type SlackLink = z.infer<typeof SlackLinkSchema>;
|
|
31
|
+
|
|
32
|
+
export const SlackConfigSchema = z.object({
|
|
33
|
+
botToken: z.string().startsWith("xoxb-"),
|
|
34
|
+
appToken: z.string().startsWith("xapp-"),
|
|
35
|
+
links: z.array(SlackLinkSchema).default([]),
|
|
36
|
+
});
|
|
37
|
+
export type SlackConfig = z.infer<typeof SlackConfigSchema>;
|
|
38
|
+
|
|
39
|
+
export function loadSlackConfig(): SlackConfig | null {
|
|
40
|
+
if (!existsSync(paths.slackJson)) return null;
|
|
41
|
+
return SlackConfigSchema.parse(JSON.parse(readFileSync(paths.slackJson, "utf8")));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveSlackConfig(cfg: SlackConfig): void {
|
|
45
|
+
writeFileAtomic(paths.slackJson, JSON.stringify(SlackConfigSchema.parse(cfg), null, 2) + "\n", 0o600);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A Slack channel id: C (public) or G (private/legacy) followed by uppercase
|
|
49
|
+
* alphanumerics. Structural check, no regex. */
|
|
50
|
+
export function isChannelId(s: string): boolean {
|
|
51
|
+
if (s.length < 2 || (s[0] !== "C" && s[0] !== "G")) return false;
|
|
52
|
+
const rest = s.slice(1);
|
|
53
|
+
return [...rest].every((ch) => (ch >= "0" && ch <= "9") || (ch >= "A" && ch <= "Z"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- per-thread session records -------------------------------------------
|
|
57
|
+
|
|
58
|
+
export const SlackThreadSchema = z.object({
|
|
59
|
+
/** chat-sdk thread id, e.g. "slack:C0123:1721300000.123456". */
|
|
60
|
+
threadId: z.string(),
|
|
61
|
+
repo: z.string(),
|
|
62
|
+
/** the dir every turn of this thread runs in (repo or its worktree). */
|
|
63
|
+
cwd: z.string(),
|
|
64
|
+
/** claude session id; null until the first turn's init message arrives. */
|
|
65
|
+
sessionId: z.string().nullable(),
|
|
66
|
+
createdAt: z.string(),
|
|
67
|
+
});
|
|
68
|
+
export type SlackThread = z.infer<typeof SlackThreadSchema>;
|
|
69
|
+
|
|
70
|
+
/** Filesystem-safe key for a thread id: alnum kept, everything else "-". */
|
|
71
|
+
export function threadKey(threadId: string): string {
|
|
72
|
+
return [...threadId]
|
|
73
|
+
.map((ch) => ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || (ch >= "0" && ch <= "9") ? ch : "-"))
|
|
74
|
+
.join("");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function threadFile(threadId: string): string {
|
|
78
|
+
return join(paths.slackThreadsDir, `${threadKey(threadId)}.json`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function loadSlackThread(threadId: string): SlackThread | null {
|
|
82
|
+
const f = threadFile(threadId);
|
|
83
|
+
if (!existsSync(f)) return null;
|
|
84
|
+
return SlackThreadSchema.parse(JSON.parse(readFileSync(f, "utf8")));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function saveSlackThread(t: SlackThread): void {
|
|
88
|
+
writeFileAtomic(threadFile(t.threadId), JSON.stringify(SlackThreadSchema.parse(t), null, 2) + "\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function listSlackThreads(): SlackThread[] {
|
|
92
|
+
if (!existsSync(paths.slackThreadsDir)) return [];
|
|
93
|
+
const out: SlackThread[] = [];
|
|
94
|
+
for (const f of readdirSync(paths.slackThreadsDir)) {
|
|
95
|
+
if (!f.endsWith(".json")) continue;
|
|
96
|
+
out.push(SlackThreadSchema.parse(JSON.parse(readFileSync(join(paths.slackThreadsDir, f), "utf8"))));
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- pure link edits (unit-tested) ----------------------------------------
|
|
102
|
+
|
|
103
|
+
export function upsertLink(cfg: SlackConfig, link: SlackLink): SlackConfig {
|
|
104
|
+
const links = cfg.links.filter((l) => l.channel !== link.channel);
|
|
105
|
+
links.push(link);
|
|
106
|
+
return { ...cfg, links };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function removeLink(cfg: SlackConfig, channel: string): SlackConfig | null {
|
|
110
|
+
if (!cfg.links.some((l) => l.channel === channel)) return null;
|
|
111
|
+
return { ...cfg, links: cfg.links.filter((l) => l.channel !== channel) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function linkForChannel(cfg: SlackConfig, channel: string): SlackLink | null {
|
|
115
|
+
return cfg.links.find((l) => l.channel === channel) ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Strip a leading Slack mention token ("<@U0123> rest") from message text. */
|
|
119
|
+
export function stripLeadingMention(text: string): string {
|
|
120
|
+
const trimmed = text.trimStart();
|
|
121
|
+
if (!trimmed.startsWith("<@")) return text.trim();
|
|
122
|
+
const close = trimmed.indexOf(">");
|
|
123
|
+
if (close < 0) return text.trim();
|
|
124
|
+
return trimmed.slice(close + 1).trim();
|
|
125
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -196,6 +196,28 @@ export const StatusLineStdinSchema = RateLimitsStdinSchema.extend({
|
|
|
196
196
|
effort: z.looseObject({ level: z.string().optional() }).nullable().optional().catch(undefined),
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
/** subagentStatusLine stdin: base session fields plus one entry per active
|
|
200
|
+
* subagent task (verified against the 2.1.214 bundle + docs 2026-07-18;
|
|
201
|
+
* tasks[].model/contextWindowSize need claude >= 2.1.205, effort >= 2.1.214).
|
|
202
|
+
* Same loose + catch degradation contract as StatusLineStdinSchema. */
|
|
203
|
+
export const SubagentStatusLineStdinSchema = z.looseObject({
|
|
204
|
+
tasks: z
|
|
205
|
+
.array(
|
|
206
|
+
z.looseObject({
|
|
207
|
+
id: z.string().optional(),
|
|
208
|
+
name: z.string().nullable().optional().catch(undefined),
|
|
209
|
+
description: z.string().nullable().optional().catch(undefined),
|
|
210
|
+
label: z.string().nullable().optional().catch(undefined),
|
|
211
|
+
model: z.string().nullable().optional().catch(undefined),
|
|
212
|
+
effort: z.string().nullable().optional().catch(undefined),
|
|
213
|
+
contextWindowSize: z.number().nullable().optional().catch(undefined),
|
|
214
|
+
tokenCount: z.number().nullable().optional().catch(undefined),
|
|
215
|
+
}),
|
|
216
|
+
)
|
|
217
|
+
.optional()
|
|
218
|
+
.catch(undefined),
|
|
219
|
+
});
|
|
220
|
+
|
|
199
221
|
/** Success body of the OAuth refresh grant. */
|
|
200
222
|
export const RefreshResponseSchema = z.looseObject({
|
|
201
223
|
access_token: z.string(),
|
package/src/main.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { basename } from "node:path";
|
|
7
7
|
import { runSupervisor } from "./entries/supervisor.ts";
|
|
8
8
|
import { runStatusline } from "./entries/statusline.ts";
|
|
9
|
+
import { runSubagentStatusline } from "./entries/subagentstatusline.ts";
|
|
9
10
|
import { runStopHook } from "./entries/stophook.ts";
|
|
10
11
|
import { runSessionStart } from "./entries/sessionstart.ts";
|
|
11
12
|
import { cmdInit } from "./cli/init.ts";
|
|
@@ -25,6 +26,7 @@ import { cmdRename } from "./cli/rename.ts";
|
|
|
25
26
|
import { cmdSwitch } from "./cli/switch.ts";
|
|
26
27
|
import { cmdCheck } from "./cli/check.ts";
|
|
27
28
|
import { cmdConfig } from "./cli/config.ts";
|
|
29
|
+
import { cmdServe } from "./cli/serve.ts";
|
|
28
30
|
import { uninstallSupervisor } from "./lib/install.ts";
|
|
29
31
|
import { c } from "./cli/render.ts";
|
|
30
32
|
|
|
@@ -45,6 +47,7 @@ function printHelp(): void {
|
|
|
45
47
|
${c.cyan("tokenmaxxing status --force")} ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; ${c.cyan("xx --force")} works too
|
|
46
48
|
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
|
|
47
49
|
${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
|
|
50
|
+
${c.cyan("tokenmaxxing serve")} [setup|link|unlink|links] Slack bridge daemon: mention the bot in a linked channel to open a claude session per thread (worktree by default)
|
|
48
51
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
49
52
|
${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
|
|
50
53
|
${c.cyan("tokenmaxxing rm")} <sel>
|
|
@@ -72,6 +75,7 @@ async function main(): Promise<number> {
|
|
|
72
75
|
|
|
73
76
|
switch (sub) {
|
|
74
77
|
case "__statusline": return runStatusline();
|
|
78
|
+
case "__subagent-statusline": return runSubagentStatusline();
|
|
75
79
|
case "__stop-hook": return runStopHook();
|
|
76
80
|
case "__session-start": return runSessionStart();
|
|
77
81
|
case "__codex-stop-hook": return runCodexStopHook();
|
|
@@ -80,6 +84,7 @@ async function main(): Promise<number> {
|
|
|
80
84
|
case "switch": return args[1] === "--codex" ? cmdCodexSwitch(args[2]) : cmdSwitch(args[1]);
|
|
81
85
|
case "check": return cmdCheck();
|
|
82
86
|
case "config": return cmdConfig(args.slice(1));
|
|
87
|
+
case "serve": return cmdServe(args.slice(1));
|
|
83
88
|
case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
|
|
84
89
|
case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
|
|
85
90
|
case "auth": return cmdAuth(args.slice(1));
|