tokenmaxxing 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/sample.ts CHANGED
@@ -68,13 +68,13 @@ function refreshPlanFields(account: Account, creds: OAuthCreds): void {
68
68
  export async function probeParkedUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
69
69
  const backup = parkedTarget(account.keychainItem);
70
70
  const parkedRaw = await readItem(backup);
71
- if (!parkedRaw) return { ok: false, reason: "no parked credential - re-add with `tokenmaxxing add`" };
71
+ if (!parkedRaw) return { ok: false, reason: "no parked credential - run `tokenmaxxing auth`" };
72
72
 
73
73
  let creds: OAuthCreds;
74
74
  try {
75
75
  creds = CredentialBlobSchema.parse(JSON.parse(parkedRaw)).claudeAiOauth;
76
76
  } catch (e) {
77
- return { ok: false, reason: `parked credential unreadable (${String((e as Error).message ?? e).slice(0, 80)}) - re-add with \`tokenmaxxing add\`` };
77
+ return { ok: false, reason: `parked credential unreadable (${String((e as Error).message ?? e).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
78
78
  }
79
79
 
80
80
  // Hand claude a token with comfortable headroom so it won't run its own refresh
@@ -1,7 +1,8 @@
1
- // Idempotent merge of tokenmaxxing's three entries into the user-owned
2
- // ~/.claude/settings.json: our statusLine, a Stop hook, a SessionStart hook.
3
- // Hooks APPEND to existing arrays; the statusLine slot is ours outright -
4
- // tokenmaxxing renders it natively, so any other statusLine command is replaced.
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 three entries: take the statusLine slot, append our hooks. */
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 three entries. The statusLine slot is deleted only if it is ours. */
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,10 +6,12 @@
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";
12
13
  import { cmdAdd } from "./cli/add.ts";
14
+ import { cmdAuth } from "./cli/auth.ts";
13
15
  import { cmdCodexAdd } from "./cli/codexadd.ts";
14
16
  import { cmdCodexInit } from "./cli/codexinit.ts";
15
17
  import { cmdCodexSwitch } from "./cli/codexswitch.ts";
@@ -24,6 +26,7 @@ import { cmdRename } from "./cli/rename.ts";
24
26
  import { cmdSwitch } from "./cli/switch.ts";
25
27
  import { cmdCheck } from "./cli/check.ts";
26
28
  import { cmdConfig } from "./cli/config.ts";
29
+ import { cmdServe } from "./cli/serve.ts";
27
30
  import { uninstallSupervisor } from "./lib/install.ts";
28
31
  import { c } from "./cli/render.ts";
29
32
 
@@ -37,12 +40,14 @@ function printHelp(): void {
37
40
  ${c.cyan("tokenmaxxing init --codex")} same for codex: import login, install codex supervisor + Stop hook (trust it via /hooks)
38
41
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
39
42
  ${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
43
+ ${c.cyan("tokenmaxxing auth")} [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every needs-reauth account, one by one)
40
44
  ${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
41
45
  ${c.cyan("tokenmaxxing ls")} list pooled accounts
42
46
  ${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
43
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
44
48
  ${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
45
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)
46
51
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
47
52
  ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
48
53
  ${c.cyan("tokenmaxxing rm")} <sel>
@@ -70,6 +75,7 @@ async function main(): Promise<number> {
70
75
 
71
76
  switch (sub) {
72
77
  case "__statusline": return runStatusline();
78
+ case "__subagent-statusline": return runSubagentStatusline();
73
79
  case "__stop-hook": return runStopHook();
74
80
  case "__session-start": return runSessionStart();
75
81
  case "__codex-stop-hook": return runCodexStopHook();
@@ -78,8 +84,10 @@ async function main(): Promise<number> {
78
84
  case "switch": return args[1] === "--codex" ? cmdCodexSwitch(args[2]) : cmdSwitch(args[1]);
79
85
  case "check": return cmdCheck();
80
86
  case "config": return cmdConfig(args.slice(1));
87
+ case "serve": return cmdServe(args.slice(1));
81
88
  case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
82
89
  case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
90
+ case "auth": return cmdAuth(args.slice(1));
83
91
  case "ls": return cmdLs();
84
92
  case "status": return cmdStatus(args.includes("--force"));
85
93
  case "watch": return cmdWatch(args[1]);