tokenmaxxing 0.17.0 → 0.19.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 +17 -2
- 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 +312 -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/decide.ts +1 -1
- 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 +180 -0
- package/src/lib/slackstate.ts +131 -0
- package/src/lib/slackstream.ts +189 -0
- package/src/lib/types.ts +22 -0
- package/src/lib/usage.ts +1 -1
- 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,21 @@ 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`; `--yolo` (alias `--dangerous`) opts a link into `bypassPermissions`, and relayThread pairs it with the SDK's mandatory `allowDangerouslySkipPermissions: true` opt-in. `AskUserQuestion` is disallowed (unanswerable over Slack; the model asks in prose instead). Turn failures post a trimmed message-only diagnostic (never a raw error body).
|
|
94
|
+
- **Socket lifecycle**: `bot.initialize()` starts the persistent auto-reconnecting SocketModeClient; the daemon then just stays alive. The leased `startSocketModeListener` API must never be looped: it returns instantly without `waitUntil` and the loop starves the event loop (live incident 2026-07-18 - connected but silent).
|
|
95
|
+
- **Id mapping**: Chat SDK ids are adapter-prefixed (`thread.channelId` = `slack:C0123`, `thread.id` = `slack:C0123:<threadTs>`) while links store bare Slack ids - lookups strip the prefix via `bareChannelId`. Subscriptions live in the daemon's memory state, so every mention re-subscribes its thread (a restarted daemon revives an old thread on the next mention); queue-skipped messages (`context.skipped`) fold into the next prompt, with the queue-entry TTL raised to 900s; unlinked-channel traffic logs `serve.unlinked_channel` and stays silent in Slack.
|
|
96
|
+
- **Agent representation** (`src/lib/slackstream.ts`): turns stream natively (`chat.startStream`, which works in channel threads regardless of the assistant:write scope): thinking and tool calls as task cards ("Thinking"/tool name/"Turn", input summary + truncated output), reply text as native markdown with rendered code fences, and a segment break whenever a tool starts after streamed text, so one turn posts as separate ordered Slack messages around its tool runs.
|
|
97
|
+
- **Live-verified end-to-end** (2026-07-18, #tokenmaxxing-dogfooding): mention opens worktree + session, replies stream, thread follow-ups resume with context, cards + fenced code render, segmentation and queue folding behave. Plus the hermetic suite: schemas/links, worktree idempotency, stream mapping, fail-fast paths.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
86
101
|
## 6. Honest papercuts
|
|
87
102
|
- **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
103
|
- **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.
|
|
@@ -112,7 +127,7 @@ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from the
|
|
|
112
127
|
---
|
|
113
128
|
|
|
114
129
|
## 8. Stack
|
|
115
|
-
TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` supervisor, the statusLine shim, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (the Stop path runs every turn; bun's start-up stays low-millisecond). Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). The supervisor needs a real PTY layer (spawn claude on a pty, forward resize/signals, restore mode between runs).
|
|
130
|
+
TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` supervisor, the statusLine shim, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (the Stop path runs every turn; bun's start-up stays low-millisecond). Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). Shipping is PR-based since 2026-07-18: work reaches main only through a pull request (branch, PR, CI green, review handled, merge), and a release is a PR-landed version bump followed by `gh release create` (details in AGENTS.md "Release and CI"). The supervisor needs a real PTY layer (spawn claude on a pty, forward resize/signals, restore mode between runs).
|
|
116
131
|
|
|
117
132
|
---
|
|
118
133
|
|
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 (`--yolo` for full-autonomy bypassPermissions sessions), then mentioning the bot in that channel opens a Claude Code session per thread (own git worktree by default) and thread messages relay in and out |
|
|
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.19.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,312 @@
|
|
|
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, type StreamChunk } from "chat";
|
|
18
|
+
import { createSlackAdapter } from "@chat-adapter/slack";
|
|
19
|
+
import { createMemoryState } from "@chat-adapter/state-memory";
|
|
20
|
+
import {
|
|
21
|
+
bareChannelId,
|
|
22
|
+
isChannelId,
|
|
23
|
+
linkForChannel,
|
|
24
|
+
loadSlackConfig,
|
|
25
|
+
loadSlackThread,
|
|
26
|
+
removeLink,
|
|
27
|
+
saveSlackConfig,
|
|
28
|
+
saveSlackThread,
|
|
29
|
+
stripLeadingMention,
|
|
30
|
+
upsertLink,
|
|
31
|
+
SlackLinkSchema,
|
|
32
|
+
type SlackConfig,
|
|
33
|
+
} from "../lib/slackstate.ts";
|
|
34
|
+
import { ensureThreadCwd, relayThread } from "../lib/slackbridge.ts";
|
|
35
|
+
import { log } from "../lib/log.ts";
|
|
36
|
+
import { c, count } from "./render.ts";
|
|
37
|
+
|
|
38
|
+
const SERVE_USAGE = "usage: tokenmaxxing serve [setup | link <channel-id> <repo> [--no-worktree] [--yolo | --dangerous] [--model <m>] | unlink <channel-id> | links]";
|
|
39
|
+
|
|
40
|
+
/** The manifest the user pastes at api.slack.com/apps > From an app manifest.
|
|
41
|
+
* Scopes/events verified against docs.slack.dev 2026-07-18: a channel-thread
|
|
42
|
+
* relay plus Slack's Agent messaging experience (agent_view + assistant:write
|
|
43
|
+
* power the DM assistant surface and typing status; channel-thread streaming
|
|
44
|
+
* works without them, verified live). Changing scopes on an existing app
|
|
45
|
+
* requires reinstalling it to the workspace. */
|
|
46
|
+
const APP_MANIFEST = `display_information:
|
|
47
|
+
name: tokenmaxxing
|
|
48
|
+
description: bridges Slack threads to Claude Code sessions
|
|
49
|
+
|
|
50
|
+
features:
|
|
51
|
+
agent_view: true
|
|
52
|
+
bot_user:
|
|
53
|
+
display_name: tokenmaxxing
|
|
54
|
+
always_online: true
|
|
55
|
+
|
|
56
|
+
oauth_config:
|
|
57
|
+
scopes:
|
|
58
|
+
bot:
|
|
59
|
+
- app_mentions:read
|
|
60
|
+
- assistant:write
|
|
61
|
+
- channels:history
|
|
62
|
+
- groups:history
|
|
63
|
+
- chat:write
|
|
64
|
+
- files:write
|
|
65
|
+
- users:read
|
|
66
|
+
|
|
67
|
+
settings:
|
|
68
|
+
event_subscriptions:
|
|
69
|
+
bot_events:
|
|
70
|
+
- app_context_changed
|
|
71
|
+
- app_home_opened
|
|
72
|
+
- app_mention
|
|
73
|
+
- message.channels
|
|
74
|
+
- message.groups
|
|
75
|
+
- message.im
|
|
76
|
+
socket_mode_enabled: true
|
|
77
|
+
org_deploy_enabled: false
|
|
78
|
+
token_rotation_enabled: false`;
|
|
79
|
+
|
|
80
|
+
function printSetupInstructions(): void {
|
|
81
|
+
console.log(c.bold("Slack app setup (one time)"));
|
|
82
|
+
console.log(`1. Open ${c.cyan("https://api.slack.com/apps")} > Create New App > From an app manifest, pick your workspace, and paste:`);
|
|
83
|
+
console.log();
|
|
84
|
+
console.log(APP_MANIFEST);
|
|
85
|
+
console.log();
|
|
86
|
+
console.log("2. OAuth & Permissions > Install to Workspace, copy the Bot User OAuth Token (xoxb-...).");
|
|
87
|
+
console.log("3. Basic Information > App-Level Tokens > Generate (add the connections:write scope), copy the token (xapp-...).");
|
|
88
|
+
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.`);
|
|
89
|
+
console.log(`${c.dim("Existing app? Paste the manifest over App Manifest in its settings, then reinstall to the workspace (scope changes need it). Tokens stay valid unless you rotate them.")}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function cmdServeSetup(): number {
|
|
93
|
+
printSetupInstructions();
|
|
94
|
+
console.log();
|
|
95
|
+
const botToken = prompt("bot token (xoxb-...):")?.trim();
|
|
96
|
+
const appToken = prompt("app token (xapp-...):")?.trim();
|
|
97
|
+
if (!botToken || !appToken) {
|
|
98
|
+
console.error(c.red("both tokens are required - nothing saved"));
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
const existing = loadSlackConfig();
|
|
102
|
+
let cfg: SlackConfig;
|
|
103
|
+
try {
|
|
104
|
+
cfg = { botToken, appToken, links: existing?.links ?? [] };
|
|
105
|
+
saveSlackConfig(cfg);
|
|
106
|
+
} catch {
|
|
107
|
+
console.error(c.red("tokens rejected: the bot token must start with xoxb- and the app token with xapp-"));
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
console.log(`${c.green("✓")} saved to slack.json (0600) with ${count({ n: cfg.links.length, noun: "link" })}`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cmdServeLink(argv: string[]): number {
|
|
115
|
+
const worktree = !argv.includes("--no-worktree");
|
|
116
|
+
// yolo mode = the SDK's bypassPermissions; --dangerous is the same switch.
|
|
117
|
+
const dangerous = argv.includes("--yolo") || argv.includes("--dangerous");
|
|
118
|
+
const modelIdx = argv.indexOf("--model");
|
|
119
|
+
const model = modelIdx >= 0 ? argv[modelIdx + 1] : undefined;
|
|
120
|
+
const rest = argv.filter((a, i) => !a.startsWith("--") && (modelIdx < 0 || i !== modelIdx + 1));
|
|
121
|
+
const [channel, repo] = rest;
|
|
122
|
+
if (!channel || !repo) {
|
|
123
|
+
console.error(SERVE_USAGE);
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
if (!isChannelId(channel)) {
|
|
127
|
+
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.`));
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
if (!existsSync(repo)) {
|
|
131
|
+
console.error(c.red(`repo path does not exist: ${repo}`));
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const repoReal = realpathSync(repo);
|
|
135
|
+
if (!existsSync(`${repoReal}/.git`)) {
|
|
136
|
+
console.error(c.red(`${repoReal} is not a git repository (worktree mode needs one)`));
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
const cfg = loadSlackConfig();
|
|
140
|
+
if (!cfg) {
|
|
141
|
+
console.error(c.red("no slack.json yet - run `tokenmaxxing serve setup` first"));
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
const link = SlackLinkSchema.parse({
|
|
145
|
+
channel,
|
|
146
|
+
repo: repoReal,
|
|
147
|
+
worktree,
|
|
148
|
+
permissionMode: dangerous ? "bypassPermissions" : "acceptEdits",
|
|
149
|
+
...(model ? { model } : {}),
|
|
150
|
+
});
|
|
151
|
+
saveSlackConfig(upsertLink(cfg, link));
|
|
152
|
+
const flags = [worktree ? "worktree" : "in-place", link.permissionMode, ...(model ? [model] : [])].join(", ");
|
|
153
|
+
console.log(`${c.green("✓")} linked ${c.bold(channel)} → ${repoReal} (${flags})`);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function cmdServeUnlink(channel: string | undefined): number {
|
|
158
|
+
if (!channel) {
|
|
159
|
+
console.error(SERVE_USAGE);
|
|
160
|
+
return 2;
|
|
161
|
+
}
|
|
162
|
+
const cfg = loadSlackConfig();
|
|
163
|
+
const next = cfg ? removeLink(cfg, channel) : null;
|
|
164
|
+
if (!next) {
|
|
165
|
+
console.error(c.red(`no link for channel ${channel}`));
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
saveSlackConfig(next);
|
|
169
|
+
console.log(`${c.green("✓")} unlinked ${channel}`);
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function cmdServeLinks(): number {
|
|
174
|
+
const cfg = loadSlackConfig();
|
|
175
|
+
if (!cfg || cfg.links.length === 0) {
|
|
176
|
+
console.log(c.dim("no channel links - run `tokenmaxxing serve link <channel-id> <repo>`"));
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
for (const l of cfg.links) {
|
|
180
|
+
const flags = [l.worktree ? "worktree" : "in-place", l.permissionMode, ...(l.model ? [l.model] : [])].join(", ");
|
|
181
|
+
console.log(`${c.bold(l.channel)} → ${l.repo} ${c.dim(`(${flags})`)}`);
|
|
182
|
+
}
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function runDaemon(): Promise<number> {
|
|
187
|
+
const cfg = loadSlackConfig();
|
|
188
|
+
if (!cfg) {
|
|
189
|
+
printSetupInstructions();
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
if (cfg.links.length === 0) {
|
|
193
|
+
console.error(c.red("no channel links - run `tokenmaxxing serve link <channel-id> <repo>` first"));
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const slack = createSlackAdapter({
|
|
198
|
+
mode: "socket",
|
|
199
|
+
botToken: cfg.botToken,
|
|
200
|
+
appToken: cfg.appToken,
|
|
201
|
+
// Native append-streaming (chat.startStream) with task cards is the
|
|
202
|
+
// correct mode (user-confirmed live 2026-07-18): it works in channel
|
|
203
|
+
// threads even when auth.test reports no assistant:write, so never gate
|
|
204
|
+
// it on a scope probe. The adapter falls back to post-and-edit by itself
|
|
205
|
+
// when a workspace truly rejects streaming. agentView matches the
|
|
206
|
+
// manifest's Agent messaging experience for the DM surface.
|
|
207
|
+
agentView: true,
|
|
208
|
+
// the web-api default retry policy (tenRetriesInAboutThirtyMinutes) can
|
|
209
|
+
// stall a streamed turn ~30min on one rate-limited edit; this is
|
|
210
|
+
// @slack/web-api's fiveRetriesInFiveMinutes literal (dep not declared,
|
|
211
|
+
// so the values are inlined).
|
|
212
|
+
webClientOptions: { retryConfig: { retries: 5, factor: 3.86 }, timeout: 15_000 },
|
|
213
|
+
});
|
|
214
|
+
const bot = new Chat({
|
|
215
|
+
userName: "tokenmaxxing",
|
|
216
|
+
adapters: { slack },
|
|
217
|
+
state: createMemoryState(),
|
|
218
|
+
// per-thread lock with queueing: a message landing mid-turn waits its turn
|
|
219
|
+
// instead of racing a second claude spawn on the same cwd. The default
|
|
220
|
+
// 90s queue-entry TTL silently discards anything queued behind a turn
|
|
221
|
+
// longer than that (claude turns routinely are), hence the override.
|
|
222
|
+
concurrency: { strategy: "queue", queueEntryTtlMs: 900_000 },
|
|
223
|
+
// without this a cards-only segment in post-and-edit fallback would
|
|
224
|
+
// strand a bare "..." placeholder message.
|
|
225
|
+
fallbackStreamingPlaceholderText: null,
|
|
226
|
+
logger: "warn",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const handleTurn = async (input: {
|
|
230
|
+
thread: { id: string; channelId: string; post: (m: AsyncIterable<string | StreamChunk>) => Promise<unknown>; subscribe: () => Promise<void>; startTyping: () => Promise<void> };
|
|
231
|
+
texts: string[];
|
|
232
|
+
isMention: boolean;
|
|
233
|
+
}) => {
|
|
234
|
+
const { thread, texts, isMention } = input;
|
|
235
|
+
const link = linkForChannel(cfg, bareChannelId(thread.channelId));
|
|
236
|
+
if (!link) {
|
|
237
|
+
log("serve.unlinked_channel", { channel: thread.channelId });
|
|
238
|
+
return; // not a linked channel - stay silent in Slack
|
|
239
|
+
}
|
|
240
|
+
log("serve.message", { thread: thread.id, isMention, texts: texts.length });
|
|
241
|
+
// texts carries queue-skipped messages plus the triggering one: the queue
|
|
242
|
+
// strategy hands a turn only the LATEST message and the rest via
|
|
243
|
+
// context.skipped, so they are folded into one prompt here.
|
|
244
|
+
const prompt = texts
|
|
245
|
+
.map((t) => stripLeadingMention(t))
|
|
246
|
+
.filter((t) => t !== "")
|
|
247
|
+
.join("\n\n");
|
|
248
|
+
if (!prompt) return;
|
|
249
|
+
let record = loadSlackThread(thread.id);
|
|
250
|
+
if (!record) {
|
|
251
|
+
if (!isMention) return; // only a mention opens a session
|
|
252
|
+
const cwd = ensureThreadCwd({ link, threadId: thread.id });
|
|
253
|
+
record = { threadId: thread.id, repo: link.repo, cwd, sessionId: null, createdAt: new Date().toISOString() };
|
|
254
|
+
saveSlackThread(record);
|
|
255
|
+
log("serve.thread_opened", { thread: thread.id, cwd });
|
|
256
|
+
}
|
|
257
|
+
// subscriptions live in the memory state, so a daemon restart forgets
|
|
258
|
+
// them; every mention re-subscribes to keep follow-up replies flowing.
|
|
259
|
+
if (isMention) await thread.subscribe();
|
|
260
|
+
// "is working..." assistant status; a no-op until the Slack app has the
|
|
261
|
+
// agent feature + assistant:write (the adapter warns instead of throwing).
|
|
262
|
+
await thread.startTyping();
|
|
263
|
+
const outcome = await relayThread({
|
|
264
|
+
cwd: record.cwd,
|
|
265
|
+
sessionId: record.sessionId,
|
|
266
|
+
prompt,
|
|
267
|
+
link,
|
|
268
|
+
post: (m) => thread.post(m),
|
|
269
|
+
});
|
|
270
|
+
if (outcome.sessionId !== record.sessionId) {
|
|
271
|
+
saveSlackThread({ ...record, sessionId: outcome.sessionId });
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const relayable = (m: { author: { isMe: boolean; isBot?: boolean | "unknown" } }) => !m.author.isMe && m.author.isBot !== true;
|
|
276
|
+
|
|
277
|
+
bot.onNewMention(async (thread, message, context) => {
|
|
278
|
+
const texts = [...(context?.skipped ?? []).filter(relayable), message].map((m) => m.text);
|
|
279
|
+
await handleTurn({ thread, texts, isMention: true });
|
|
280
|
+
});
|
|
281
|
+
bot.onSubscribedMessage(async (thread, message, context) => {
|
|
282
|
+
if (!relayable(message)) return; // never relay our own posts
|
|
283
|
+
const texts = [...(context?.skipped ?? []).filter(relayable), message].map((m) => m.text);
|
|
284
|
+
await handleTurn({ thread, texts, isMention: false });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// initialize() starts the PERSISTENT Socket Mode client (auto-reconnecting)
|
|
288
|
+
// wired straight into event routing; the daemon only has to stay alive.
|
|
289
|
+
// Never call startSocketModeListener here: that is the serverless leased
|
|
290
|
+
// variant (it demands options.waitUntil and returns instantly without it),
|
|
291
|
+
// and awaiting it in a loop starved the event loop so hard the WebSocket
|
|
292
|
+
// never delivered a single event (live incident 2026-07-18).
|
|
293
|
+
await bot.initialize();
|
|
294
|
+
console.log(`${c.green("●")} serving ${count({ n: cfg.links.length, noun: "linked channel" })} over Slack Socket Mode - mention the bot in a linked channel to open a session (Ctrl-C to stop)`);
|
|
295
|
+
log("serve.started", { links: cfg.links.length });
|
|
296
|
+
await new Promise<never>(() => {});
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function cmdServe(argv: string[]): Promise<number> {
|
|
301
|
+
const [sub, ...rest] = argv;
|
|
302
|
+
switch (sub) {
|
|
303
|
+
case undefined: return runDaemon();
|
|
304
|
+
case "setup": return cmdServeSetup();
|
|
305
|
+
case "link": return cmdServeLink(rest);
|
|
306
|
+
case "unlink": return cmdServeUnlink(rest[0]);
|
|
307
|
+
case "links": return cmdServeLinks();
|
|
308
|
+
default:
|
|
309
|
+
console.error(SERVE_USAGE);
|
|
310
|
+
return 2;
|
|
311
|
+
}
|
|
312
|
+
}
|
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[] = [];
|