tokenmaxxing 1.6.0 → 1.8.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.
Files changed (58) hide show
  1. package/DESIGN.md +5 -31
  2. package/LICENSE +21 -0
  3. package/README.md +1 -2
  4. package/agent-plugin/agents/tokenmaxxing-claude.md +43 -0
  5. package/agent-plugin/agents/tokenmaxxing-codex.md +40 -0
  6. package/agent-plugin/bin/tokenmaxxing-mcp +7 -0
  7. package/agent-plugin/hooks/cursor-relay.json +14 -0
  8. package/agent-plugin/mcp.json +10 -0
  9. package/agent-plugin/plugin.json +20 -0
  10. package/agent-plugin/skills/codex-pool/SKILL.md +23 -0
  11. package/agent-plugin/skills/codex-pool/references/codex.md +5 -0
  12. package/agent-plugin/skills/credentials-hygiene/SKILL.md +26 -0
  13. package/agent-plugin/skills/credentials-hygiene/references/credentials.md +6 -0
  14. package/agent-plugin/skills/doctor-diagnostics/SKILL.md +26 -0
  15. package/agent-plugin/skills/doctor-diagnostics/references/troubleshooting.md +5 -0
  16. package/agent-plugin/skills/pool-status/SKILL.md +27 -0
  17. package/agent-plugin/skills/pool-status/references/commands.md +8 -0
  18. package/agent-plugin/skills/relay-session/SKILL.md +118 -0
  19. package/agent-plugin/skills/relay-session/references/ipc.md +23 -0
  20. package/agent-plugin/skills/safe-contribution/SKILL.md +27 -0
  21. package/agent-plugin/skills/safe-contribution/references/ship.md +5 -0
  22. package/agent-plugin/skills/sdk-pairing/SKILL.md +33 -0
  23. package/agent-plugin/skills/sdk-pairing/references/sdk.md +6 -0
  24. package/agent-plugin/skills/switching-policy/SKILL.md +29 -0
  25. package/agent-plugin/skills/switching-policy/references/policy.md +7 -0
  26. package/package.json +3 -5
  27. package/src/cli/codexinit.ts +11 -2
  28. package/src/cli/init.ts +9 -3
  29. package/src/cli/relay.ts +323 -0
  30. package/src/entries/codexstophook.ts +10 -0
  31. package/src/entries/mcp.ts +288 -0
  32. package/src/entries/relaypermission.ts +105 -0
  33. package/src/entries/stophook.ts +11 -0
  34. package/src/lib/decide.ts +2 -4
  35. package/src/lib/install.ts +61 -7
  36. package/src/lib/lock.ts +3 -7
  37. package/src/lib/log.ts +8 -11
  38. package/src/lib/paths.ts +3 -9
  39. package/src/lib/relay/config.ts +84 -0
  40. package/src/lib/relay/decide.ts +75 -0
  41. package/src/lib/relay/gc.ts +80 -0
  42. package/src/lib/relay/install.ts +143 -0
  43. package/src/lib/relay/markers.ts +148 -0
  44. package/src/lib/relay/modes.ts +82 -0
  45. package/src/lib/relay/protocol.ts +61 -0
  46. package/src/lib/relay/registry.ts +175 -0
  47. package/src/lib/relay/tmux.ts +109 -0
  48. package/src/lib/relay/turn.ts +137 -0
  49. package/src/lib/relay/worker.ts +141 -0
  50. package/src/lib/usage.ts +6 -5
  51. package/src/main.ts +6 -6
  52. package/src/cli/serve.ts +0 -1790
  53. package/src/lib/slackbridge.ts +0 -1363
  54. package/src/lib/slackstate.ts +0 -352
  55. package/src/lib/slackstream.ts +0 -300
  56. package/src/serve-plugin/.claude-plugin/plugin.json +0 -4
  57. package/src/serve-plugin/skills/ask-the-user/SKILL.md +0 -41
  58. package/src/serve-plugin/skills/serve-session/SKILL.md +0 -50
@@ -0,0 +1,175 @@
1
+ // Per-session registry under $TOKENMAXXING_HOME/relay/sessions/<id>.json.
2
+ // Per-session flock only (high churn); never a global relay lock for turns.
3
+
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { z } from "zod";
7
+ import { paths } from "../paths.ts";
8
+ import { writeFileAtomic } from "../atomic.ts";
9
+ import { withLock } from "../lock.ts";
10
+ import { ClaudePermissionModeSchema, type ClaudePermissionMode } from "./modes.ts";
11
+ import { loadRelayConfig, type RelayWorker } from "./config.ts";
12
+
13
+ export const RelaySessionStateSchema = z.enum([
14
+ "idle",
15
+ "running",
16
+ "permission-needed",
17
+ "destroyed",
18
+ ]);
19
+ export type RelaySessionState = z.infer<typeof RelaySessionStateSchema>;
20
+
21
+ export const RelayRegistryEntrySchema = z.object({
22
+ sessionId: z.uuid(),
23
+ tmuxName: z.string().min(1),
24
+ worker: z.enum(["claude", "codex"]),
25
+ permissionMode: ClaudePermissionModeSchema,
26
+ cwd: z.string().min(1),
27
+ state: RelaySessionStateSchema,
28
+ createdAt: z.number().int().nonnegative(),
29
+ lastActiveAt: z.number().int().nonnegative(),
30
+ pendingRequestId: z.string().min(1).optional(),
31
+ });
32
+ export type RelayRegistryEntry = z.infer<typeof RelayRegistryEntrySchema>;
33
+
34
+ export function relaySessionsDir(): string {
35
+ return join(paths.relayDir, "sessions");
36
+ }
37
+
38
+ export function relayLocksDir(): string {
39
+ return join(paths.relayDir, "locks");
40
+ }
41
+
42
+ export function relayTurnDoneDir(): string {
43
+ return join(paths.relayDir, "turn-done");
44
+ }
45
+
46
+ export function relayPendingDir(): string {
47
+ return join(paths.relayDir, "pending");
48
+ }
49
+
50
+ export function relayDecisionsDir(): string {
51
+ return join(paths.relayDir, "decisions");
52
+ }
53
+
54
+ export function sessionEntryPath(input: { sessionId: string }): string {
55
+ return join(relaySessionsDir(), `${input.sessionId}.json`);
56
+ }
57
+
58
+ export function sessionLockPath(input: { sessionId: string }): string {
59
+ return join(relayLocksDir(), input.sessionId);
60
+ }
61
+
62
+ export function turnDonePath(input: { sessionId: string }): string {
63
+ return join(relayTurnDoneDir(), input.sessionId);
64
+ }
65
+
66
+ export function pendingRequestPath(input: { sessionId: string; requestId: string }): string {
67
+ return join(relayPendingDir(), input.sessionId, `${input.requestId}.json`);
68
+ }
69
+
70
+ export function decisionPath(input: { sessionId: string; requestId: string }): string {
71
+ return join(relayDecisionsDir(), input.sessionId, `${input.requestId}.json`);
72
+ }
73
+
74
+ export function tmuxNameFor(input: { sessionId: string; prefix?: string }): string {
75
+ const prefix = input.prefix ?? loadRelayConfig().sessionPrefix;
76
+ return `${prefix}${input.sessionId}`;
77
+ }
78
+
79
+ export function newSessionId(): string {
80
+ return crypto.randomUUID();
81
+ }
82
+
83
+ export function readEntry(input: { sessionId: string }): RelayRegistryEntry | null {
84
+ const path = sessionEntryPath(input);
85
+ if (!existsSync(path)) return null;
86
+ return RelayRegistryEntrySchema.parse(JSON.parse(readFileSync(path, "utf8")));
87
+ }
88
+
89
+ export function writeEntry(input: { entry: RelayRegistryEntry }): void {
90
+ mkdirSync(relaySessionsDir(), { recursive: true });
91
+ writeFileAtomic(sessionEntryPath({ sessionId: input.entry.sessionId }), JSON.stringify(input.entry, null, 2) + "\n");
92
+ }
93
+
94
+ export function deleteEntry(input: { sessionId: string }): void {
95
+ rmSync(sessionEntryPath(input), { force: true });
96
+ }
97
+
98
+ export async function withSessionLock<T>(input: {
99
+ sessionId: string;
100
+ fn: () => Promise<T> | T;
101
+ }): Promise<T> {
102
+ mkdirSync(relayLocksDir(), { recursive: true });
103
+ return withLock(sessionLockPath({ sessionId: input.sessionId }), input.fn);
104
+ }
105
+
106
+ export function listEntries(): RelayRegistryEntry[] {
107
+ const dir = relaySessionsDir();
108
+ if (!existsSync(dir)) return [];
109
+ const out: RelayRegistryEntry[] = [];
110
+ for (const name of readdirSync(dir)) {
111
+ if (!name.endsWith(".json")) continue;
112
+ const path = join(dir, name);
113
+ try {
114
+ out.push(RelayRegistryEntrySchema.parse(JSON.parse(readFileSync(path, "utf8"))));
115
+ } catch {
116
+ // skip corrupt; gc can reap
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ export function createEntry(input: {
123
+ sessionId: string;
124
+ worker: RelayWorker;
125
+ permissionMode: ClaudePermissionMode;
126
+ cwd: string;
127
+ now?: number;
128
+ }): RelayRegistryEntry {
129
+ const now = input.now ?? Date.now();
130
+ const entry = RelayRegistryEntrySchema.parse({
131
+ sessionId: input.sessionId,
132
+ tmuxName: tmuxNameFor({ sessionId: input.sessionId }),
133
+ worker: input.worker,
134
+ permissionMode: input.permissionMode,
135
+ cwd: input.cwd,
136
+ state: "idle",
137
+ createdAt: now,
138
+ lastActiveAt: now,
139
+ });
140
+ writeEntry({ entry });
141
+ return entry;
142
+ }
143
+
144
+ export function touchEntry(input: {
145
+ sessionId: string;
146
+ state?: RelaySessionState;
147
+ permissionMode?: ClaudePermissionMode;
148
+ pendingRequestId?: string | null;
149
+ now?: number;
150
+ }): RelayRegistryEntry {
151
+ const prev = readEntry({ sessionId: input.sessionId });
152
+ if (prev == null) throw new Error(`relay session not found: ${input.sessionId}`);
153
+ const next = RelayRegistryEntrySchema.parse({
154
+ ...prev,
155
+ state: input.state ?? prev.state,
156
+ permissionMode: input.permissionMode ?? prev.permissionMode,
157
+ lastActiveAt: input.now ?? Date.now(),
158
+ pendingRequestId: input.pendingRequestId === null
159
+ ? undefined
160
+ : (input.pendingRequestId ?? prev.pendingRequestId),
161
+ });
162
+ writeEntry({ entry: next });
163
+ return next;
164
+ }
165
+
166
+ /** True when a registry entry exists for this relay session id. */
167
+ export function registryHas(input: { sessionId: string }): boolean {
168
+ return readEntry(input) != null;
169
+ }
170
+
171
+ export function entryMtimeMs(input: { sessionId: string }): number | null {
172
+ const path = sessionEntryPath(input);
173
+ if (!existsSync(path)) return null;
174
+ return statSync(path).mtimeMs;
175
+ }
@@ -0,0 +1,109 @@
1
+ // Thin tmux wrapper. Exact session names only; never pattern-kill. Injectable
2
+ // for hermetic tests that must not require a real tmux server or workers.
3
+
4
+ import { z } from "zod";
5
+
6
+ export type TmuxBackend = {
7
+ hasSession: (input: { name: string }) => boolean;
8
+ newSession: (input: { name: string; cwd: string; command: string }) => void;
9
+ killSession: (input: { name: string }) => void;
10
+ sendKeys: (input: { name: string; text: string; enter?: boolean }) => void;
11
+ capturePane: (input: { name: string }) => string;
12
+ listSessions: () => string[];
13
+ };
14
+
15
+ function runTmux(args: string[]): { ok: boolean; stdout: string; stderr: string; code: number } {
16
+ const res = Bun.spawnSync(["tmux", ...args], {
17
+ stdout: "pipe",
18
+ stderr: "pipe",
19
+ env: process.env,
20
+ });
21
+ return {
22
+ ok: res.exitCode === 0,
23
+ code: res.exitCode ?? 1,
24
+ stdout: res.stdout.toString(),
25
+ stderr: res.stderr.toString(),
26
+ };
27
+ }
28
+
29
+ export const liveTmux: TmuxBackend = {
30
+ hasSession: ({ name }) => runTmux(["has-session", "-t", name]).ok,
31
+ newSession: ({ name, cwd, command }) => {
32
+ const res = runTmux(["new-session", "-d", "-s", name, "-c", cwd, command]);
33
+ if (!res.ok) throw new Error(`tmux new-session failed for ${name}: ${res.stderr.trim() || res.stdout.trim()}`);
34
+ },
35
+ killSession: ({ name }) => {
36
+ // Exact name only. Missing session is success for destroy/gc idempotence.
37
+ runTmux(["kill-session", "-t", name]);
38
+ },
39
+ sendKeys: ({ name, text, enter = true }) => {
40
+ const res = runTmux(["send-keys", "-t", name, "-l", "--", text]);
41
+ if (!res.ok) throw new Error(`tmux send-keys failed for ${name}: ${res.stderr.trim()}`);
42
+ if (enter) {
43
+ const enterRes = runTmux(["send-keys", "-t", name, "Enter"]);
44
+ if (!enterRes.ok) throw new Error(`tmux send-keys Enter failed for ${name}: ${enterRes.stderr.trim()}`);
45
+ }
46
+ },
47
+ capturePane: ({ name }) => {
48
+ const res = runTmux(["capture-pane", "-p", "-t", name, "-S", "-200"]);
49
+ if (!res.ok) return "";
50
+ return res.stdout;
51
+ },
52
+ listSessions: () => {
53
+ const res = runTmux(["list-sessions", "-F", "#{session_name}"]);
54
+ if (!res.ok) return [];
55
+ return res.stdout.split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
56
+ },
57
+ };
58
+
59
+ let backend: TmuxBackend = liveTmux;
60
+
61
+ export function getTmux(): TmuxBackend {
62
+ return backend;
63
+ }
64
+
65
+ /** Test-only: replace the tmux backend. */
66
+ export function setTmuxBackend(input: { backend: TmuxBackend }): void {
67
+ backend = input.backend;
68
+ }
69
+
70
+ export function resetTmuxBackend(): void {
71
+ backend = liveTmux;
72
+ }
73
+
74
+ const MemorySessionSchema = z.object({
75
+ name: z.string(),
76
+ cwd: z.string(),
77
+ command: stringOrEmpty(),
78
+ keys: z.array(z.string()).default([]),
79
+ pane: z.string().default(""),
80
+ });
81
+
82
+ function stringOrEmpty() {
83
+ return z.string();
84
+ }
85
+
86
+ /** In-memory tmux for hermetic tests. */
87
+ export function createMemoryTmux(): TmuxBackend & { sessions: Map<string, z.infer<typeof MemorySessionSchema>> } {
88
+ const sessions = new Map<string, z.infer<typeof MemorySessionSchema>>();
89
+ const api: TmuxBackend & { sessions: typeof sessions } = {
90
+ sessions,
91
+ hasSession: ({ name }) => sessions.has(name),
92
+ newSession: ({ name, cwd, command }) => {
93
+ if (sessions.has(name)) throw new Error(`tmux session already exists: ${name}`);
94
+ sessions.set(name, MemorySessionSchema.parse({ name, cwd, command, keys: [], pane: "" }));
95
+ },
96
+ killSession: ({ name }) => {
97
+ sessions.delete(name);
98
+ },
99
+ sendKeys: ({ name, text, enter = true }) => {
100
+ const s = sessions.get(name);
101
+ if (!s) throw new Error(`no tmux session: ${name}`);
102
+ s.keys.push(enter ? `${text}\n` : text);
103
+ s.pane += enter ? `${text}\n` : text;
104
+ },
105
+ capturePane: ({ name }) => sessions.get(name)?.pane ?? "",
106
+ listSessions: () => [...sessions.keys()],
107
+ };
108
+ return api;
109
+ }
@@ -0,0 +1,137 @@
1
+ // relay turn: ensure session, send prompt, wait until turn-done OR permission-needed.
2
+
3
+ import { delay } from "es-toolkit";
4
+ import { loadRelayConfig } from "./config.ts";
5
+ import {
6
+ clearPendingRequest,
7
+ clearTurnDoneMarker,
8
+ listPendingRequests,
9
+ readTurnDoneMarker,
10
+ } from "./markers.ts";
11
+ import { permissionPingsEnabled, type ClaudePermissionMode } from "./modes.ts";
12
+ import { formatRelayStdout, type RelayStdout } from "./protocol.ts";
13
+ import { readEntry, touchEntry, withSessionLock } from "./registry.ts";
14
+ import { getTmux } from "./tmux.ts";
15
+ import { ensureSession, sendPrompt } from "./worker.ts";
16
+ import type { RelayWorker } from "./config.ts";
17
+
18
+ const POLL_MS = 50;
19
+
20
+ export type TurnParams = {
21
+ sessionId?: string;
22
+ worker?: RelayWorker;
23
+ permissionMode?: ClaudePermissionMode;
24
+ cwd: string;
25
+ prompt?: string;
26
+ /** When true, do not send a prompt; only wait for the next marker. */
27
+ waitOnly?: boolean;
28
+ timeoutMs?: number;
29
+ now?: () => number;
30
+ sleep?: (ms: number) => Promise<void>;
31
+ };
32
+
33
+ export type TurnResult = {
34
+ exitCode: number;
35
+ stdout: string;
36
+ payload: RelayStdout;
37
+ entrySessionId: string;
38
+ };
39
+
40
+ export async function runTurn(input: TurnParams): Promise<TurnResult> {
41
+ const cfg = loadRelayConfig();
42
+ const sleep = input.sleep ?? delay;
43
+ const now = input.now ?? Date.now;
44
+ const timeoutMs = input.timeoutMs ?? cfg.turnTimeoutMs;
45
+
46
+ const entry = await ensureSession({
47
+ sessionId: input.sessionId,
48
+ worker: input.worker,
49
+ permissionMode: input.permissionMode,
50
+ cwd: input.cwd,
51
+ now: now(),
52
+ });
53
+
54
+ if (!input.waitOnly) {
55
+ const prompt = input.prompt ?? "";
56
+ if (prompt.trim() === "") throw new Error("relay turn requires a prompt (argv or stdin)");
57
+ await sendPrompt({ sessionId: entry.sessionId, prompt });
58
+ } else {
59
+ await withSessionLock({
60
+ sessionId: entry.sessionId,
61
+ fn: () => touchEntry({ sessionId: entry.sessionId, state: "running", now: now() }),
62
+ });
63
+ }
64
+
65
+ const deadline = now() + timeoutMs;
66
+ while (now() < deadline) {
67
+ const pending = listPendingRequests({ sessionId: entry.sessionId });
68
+ const live = readEntry({ sessionId: entry.sessionId });
69
+ const mode = live?.permissionMode ?? entry.permissionMode;
70
+ if (pending.length > 0 && permissionPingsEnabled({ mode })) {
71
+ const req = pending[0]!;
72
+ await withSessionLock({
73
+ sessionId: entry.sessionId,
74
+ fn: () => touchEntry({
75
+ sessionId: entry.sessionId,
76
+ state: "permission-needed",
77
+ pendingRequestId: req.requestId,
78
+ now: now(),
79
+ }),
80
+ });
81
+ const payload: RelayStdout = {
82
+ kind: "permission-needed",
83
+ sessionId: entry.sessionId,
84
+ permissionMode: mode,
85
+ requestId: req.requestId,
86
+ summary: req.summary,
87
+ detail: req.detail,
88
+ };
89
+ return {
90
+ exitCode: 0,
91
+ stdout: formatRelayStdout({ payload }),
92
+ payload,
93
+ entrySessionId: entry.sessionId,
94
+ };
95
+ }
96
+ // Under bypassPermissions, auto-clear any stray pending (should not fire).
97
+ if (pending.length > 0 && !permissionPingsEnabled({ mode })) {
98
+ for (const req of pending) {
99
+ clearPendingRequest({ sessionId: entry.sessionId, requestId: req.requestId });
100
+ }
101
+ }
102
+
103
+ const done = readTurnDoneMarker({ sessionId: entry.sessionId });
104
+ if (done != null) {
105
+ const text = getTmux().capturePane({ name: entry.tmuxName });
106
+ clearTurnDoneMarker({ sessionId: entry.sessionId });
107
+ await withSessionLock({
108
+ sessionId: entry.sessionId,
109
+ fn: () => touchEntry({
110
+ sessionId: entry.sessionId,
111
+ state: "idle",
112
+ pendingRequestId: null,
113
+ now: now(),
114
+ }),
115
+ });
116
+ const payload: RelayStdout = {
117
+ kind: "turn-done",
118
+ sessionId: entry.sessionId,
119
+ permissionMode: mode,
120
+ text,
121
+ };
122
+ return {
123
+ exitCode: 0,
124
+ stdout: formatRelayStdout({ payload }),
125
+ payload,
126
+ entrySessionId: entry.sessionId,
127
+ };
128
+ }
129
+
130
+ await sleep(POLL_MS);
131
+ }
132
+
133
+ throw new Error(`relay turn timed out after ${timeoutMs}ms (session ${entry.sessionId})`);
134
+ }
135
+
136
+ // re-export for callers that type worker from turn
137
+ export type { RelayWorker };
@@ -0,0 +1,141 @@
1
+ // Ensure a durable tmux worker for a relay session. Spawns tokenmaxxing-
2
+ // supervised claude/codex via PATH shims with pooled env intact.
3
+
4
+ import { join } from "node:path";
5
+ import { paths } from "../paths.ts";
6
+ import { claudeArgvForMode, codexArgvForMode, type ClaudePermissionMode } from "./modes.ts";
7
+ import { loadRelayConfig, type RelayWorker } from "./config.ts";
8
+ import {
9
+ createEntry,
10
+ readEntry,
11
+ touchEntry,
12
+ withSessionLock,
13
+ type RelayRegistryEntry,
14
+ } from "./registry.ts";
15
+ import { getTmux } from "./tmux.ts";
16
+ import { clearTurnDoneMarker } from "./markers.ts";
17
+
18
+ export const RELAY_SESSION_ENV = "TOKENMAXXING_RELAY_SESSION";
19
+
20
+ function shellQuote(input: { value: string }): string {
21
+ return `'${input.value.replaceAll("'", `'\\''`)}'`;
22
+ }
23
+
24
+ export function buildWorkerCommand(input: {
25
+ sessionId: string;
26
+ worker: RelayWorker;
27
+ permissionMode: ClaudePermissionMode;
28
+ binDir?: string;
29
+ }): string {
30
+ const binDir = input.binDir ?? paths.binDir;
31
+ // Keep $PATH expandable: only quote the binDir segment.
32
+ const envPrefix = `TOKENMAXXING_RELAY_SESSION=${shellQuote({ value: input.sessionId })} PATH=${shellQuote({ value: binDir })}:"$PATH"`;
33
+ if (input.worker === "claude") {
34
+ const modeArgs = claudeArgvForMode({ mode: input.permissionMode }).map((a) => shellQuote({ value: a })).join(" ");
35
+ // Interactive durable session pinned to the relay UUID as Claude session id.
36
+ return `${envPrefix} claude --session-id ${shellQuote({ value: input.sessionId })} ${modeArgs}`;
37
+ }
38
+ const modeArgs = codexArgvForMode({ mode: input.permissionMode }).map((a) => shellQuote({ value: a })).join(" ");
39
+ return `${envPrefix} codex ${modeArgs}`;
40
+ }
41
+
42
+ export async function ensureSession(input: {
43
+ sessionId?: string;
44
+ worker?: RelayWorker;
45
+ permissionMode?: ClaudePermissionMode;
46
+ cwd: string;
47
+ now?: number;
48
+ }): Promise<RelayRegistryEntry> {
49
+ const cfg = loadRelayConfig();
50
+ const sessionId = input.sessionId ?? crypto.randomUUID();
51
+ const worker = input.worker ?? cfg.defaultWorker;
52
+ const permissionMode = input.permissionMode ?? cfg.defaultPermissionMode;
53
+
54
+ return withSessionLock({
55
+ sessionId,
56
+ fn: () => {
57
+ const existing = readEntry({ sessionId });
58
+ const tmux = getTmux();
59
+ if (existing != null) {
60
+ if (!tmux.hasSession({ name: existing.tmuxName })) {
61
+ const command = buildWorkerCommand({
62
+ sessionId,
63
+ worker: existing.worker,
64
+ permissionMode: input.permissionMode ?? existing.permissionMode,
65
+ });
66
+ tmux.newSession({ name: existing.tmuxName, cwd: existing.cwd, command });
67
+ }
68
+ return touchEntry({
69
+ sessionId,
70
+ permissionMode: input.permissionMode,
71
+ state: "idle",
72
+ now: input.now,
73
+ });
74
+ }
75
+ const entry = createEntry({
76
+ sessionId,
77
+ worker,
78
+ permissionMode,
79
+ cwd: input.cwd,
80
+ now: input.now,
81
+ });
82
+ const command = buildWorkerCommand({ sessionId, worker, permissionMode });
83
+ tmux.newSession({ name: entry.tmuxName, cwd: input.cwd, command });
84
+ return entry;
85
+ },
86
+ });
87
+ }
88
+
89
+ export async function sendPrompt(input: {
90
+ sessionId: string;
91
+ prompt: string;
92
+ }): Promise<void> {
93
+ await withSessionLock({
94
+ sessionId: input.sessionId,
95
+ fn: () => {
96
+ const entry = readEntry({ sessionId: input.sessionId });
97
+ if (entry == null) throw new Error(`relay session not found: ${input.sessionId}`);
98
+ clearTurnDoneMarker({ sessionId: input.sessionId });
99
+ getTmux().sendKeys({ name: entry.tmuxName, text: input.prompt, enter: true });
100
+ touchEntry({ sessionId: input.sessionId, state: "running" });
101
+ },
102
+ });
103
+ }
104
+
105
+ export async function setLivePermissionMode(input: {
106
+ sessionId: string;
107
+ permissionMode: ClaudePermissionMode;
108
+ }): Promise<RelayRegistryEntry> {
109
+ return withSessionLock({
110
+ sessionId: input.sessionId,
111
+ fn: () => {
112
+ const entry = readEntry({ sessionId: input.sessionId });
113
+ if (entry == null) throw new Error(`relay session not found: ${input.sessionId}`);
114
+ // Best-effort: cycle Claude's Shift+Tab equivalent via a slash command when
115
+ // the worker is Claude. Codex needs a respawn for sandbox changes.
116
+ if (entry.worker === "claude" && getTmux().hasSession({ name: entry.tmuxName })) {
117
+ getTmux().sendKeys({
118
+ name: entry.tmuxName,
119
+ text: `/permissions ${input.permissionMode}`,
120
+ enter: true,
121
+ });
122
+ } else if (entry.worker === "codex") {
123
+ getTmux().killSession({ name: entry.tmuxName });
124
+ const command = buildWorkerCommand({
125
+ sessionId: input.sessionId,
126
+ worker: "codex",
127
+ permissionMode: input.permissionMode,
128
+ });
129
+ getTmux().newSession({ name: entry.tmuxName, cwd: entry.cwd, command });
130
+ }
131
+ return touchEntry({
132
+ sessionId: input.sessionId,
133
+ permissionMode: input.permissionMode,
134
+ });
135
+ },
136
+ });
137
+ }
138
+
139
+ export function workerBinHint(): string {
140
+ return join(paths.binDir, "claude");
141
+ }
package/src/lib/usage.ts CHANGED
@@ -199,11 +199,12 @@ export function parseUsageLimitEpoch(input: { text: string }): number | null {
199
199
 
200
200
  /**
201
201
  * Persist a limit observed in a turn RESULT into usage.json so the next
202
- * decision sees the depleted account immediately. A headless serve/SDK process
203
- * has no statusLine tee and `loadFreshSnapshots` skips re-probing inside the
204
- * poll TTL (and `/usage` is fail-silent against the just-limited active token
205
- * anyway), so without this write a post-limit retry re-decides off the stale
206
- * pre-limit snapshot and respawns the same depleted account. The session
202
+ * decision sees the depleted account immediately. Callers without a statusLine
203
+ * tee (headless Agent SDK integrations) should invoke this on errored limit
204
+ * results: `loadFreshSnapshots` skips re-probing inside the poll TTL and
205
+ * `/usage` is fail-silent against the just-limited active token, so without
206
+ * this write a post-limit retry re-decides off the stale pre-limit snapshot
207
+ * and respawns the same depleted account. The session
207
208
  * window is stamped 100% with the announced reset: whichever window actually
208
209
  * tripped, the account is unusable until then, and the hard path swaps away.
209
210
  * `org` is the identity captured AT THE SPAWN BOUNDARY of the turn that
package/src/main.ts CHANGED
@@ -3,9 +3,7 @@
3
3
  // `claude` (or `__supervise`), routes hook/statusLine subcommands, and otherwise
4
4
  // dispatches the `tokenmaxxing` CLI.
5
5
 
6
- import { existsSync } from "node:fs";
7
6
  import { basename } from "node:path";
8
- import { paths } from "./lib/paths.ts";
9
7
  import { runSupervisor } from "./entries/supervisor.ts";
10
8
  import { runStatusline } from "./entries/statusline.ts";
11
9
  import { runSubagentStatusline } from "./entries/subagentstatusline.ts";
@@ -29,7 +27,8 @@ import { cmdRename } from "./cli/rename.ts";
29
27
  import { cmdSwitch } from "./cli/switch.ts";
30
28
  import { cmdCheck } from "./cli/check.ts";
31
29
  import { cmdConfig } from "./cli/config.ts";
32
- import { cmdServe } from "./cli/serve.ts";
30
+ import { cmdRelay } from "./cli/relay.ts";
31
+ import { runRelayPermissionHook } from "./entries/relaypermission.ts";
33
32
  import { timerDeactivationHint, uninstallSupervisor } from "./lib/install.ts";
34
33
  import { c } from "./cli/render.ts";
35
34
 
@@ -50,7 +49,7 @@ function printHelp(): void {
50
49
  ${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
51
50
  ${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
52
51
  ${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
53
- ${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 in the repo checkout
52
+ ${c.cyan("tokenmaxxing relay")} durable tmux relay for host agents (turn/decide/status/…); see ${c.cyan("relay --help")}
54
53
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
55
54
  ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
56
55
  ${c.cyan("tokenmaxxing rm")} [--codex] <sel>
@@ -104,18 +103,19 @@ async function main(): Promise<number> {
104
103
  case "__stop-hook": return runStopHook();
105
104
  case "__session-start": return runSessionStart();
106
105
  case "__codex-stop-hook": return runCodexStopHook();
106
+ case "__relay-permission-hook": return runRelayPermissionHook();
107
107
  case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
108
108
  case "--force": return cmdStatus(true); // bare `xx --force` → status --force
109
109
  // --codex accepted anywhere, like init/add/status: the old args[1]-only
110
110
  // check made `xx switch <sel> --codex` silently run a real CLAUDE swap
111
111
  // (one email can hold both pools' accounts - closing-review catch).
112
+ case "relay": return cmdRelay(args.slice(1));
112
113
  case "switch": {
113
114
  const rest = args.slice(1).filter((a) => a !== "--codex");
114
115
  return args.includes("--codex") ? cmdCodexSwitch(rest[0]) : cmdSwitch(rest[0]);
115
116
  }
116
117
  case "check": return cmdCheck();
117
118
  case "config": return cmdConfig(args.slice(1));
118
- case "serve": return cmdServe(args.slice(1));
119
119
  case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
120
120
  case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
121
121
  case "auth": return cmdAuth(args.slice(1));
@@ -145,7 +145,7 @@ async function main(): Promise<number> {
145
145
  console.log(`removed ${removed.join(", ")}`);
146
146
  if (!out.timerDeactivated) console.log(c.yellow(`⚠ the check job may still be loaded - run: ${timerDeactivationHint()}`));
147
147
  if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
148
- console.log(`kept: accounts.json, config.json${existsSync(paths.slackJson) ? ", slack.json (Slack tokens)" : ""}, and every parked credential (claude - macOS: keychain items, Linux: creds/; codex: codex-creds/) - remove accounts with \`xx rm\` to delete their credentials`);
148
+ console.log(`kept: accounts.json, config.json, and every parked credential (claude - macOS: keychain items, Linux: creds/; codex: codex-creds/) - remove accounts with \`xx rm\` to delete their credentials`);
149
149
  return 0;
150
150
  }
151
151
  case "help":