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.
- package/DESIGN.md +5 -31
- package/LICENSE +21 -0
- package/README.md +1 -2
- package/agent-plugin/agents/tokenmaxxing-claude.md +43 -0
- package/agent-plugin/agents/tokenmaxxing-codex.md +40 -0
- package/agent-plugin/bin/tokenmaxxing-mcp +7 -0
- package/agent-plugin/hooks/cursor-relay.json +14 -0
- package/agent-plugin/mcp.json +10 -0
- package/agent-plugin/plugin.json +20 -0
- package/agent-plugin/skills/codex-pool/SKILL.md +23 -0
- package/agent-plugin/skills/codex-pool/references/codex.md +5 -0
- package/agent-plugin/skills/credentials-hygiene/SKILL.md +26 -0
- package/agent-plugin/skills/credentials-hygiene/references/credentials.md +6 -0
- package/agent-plugin/skills/doctor-diagnostics/SKILL.md +26 -0
- package/agent-plugin/skills/doctor-diagnostics/references/troubleshooting.md +5 -0
- package/agent-plugin/skills/pool-status/SKILL.md +27 -0
- package/agent-plugin/skills/pool-status/references/commands.md +8 -0
- package/agent-plugin/skills/relay-session/SKILL.md +118 -0
- package/agent-plugin/skills/relay-session/references/ipc.md +23 -0
- package/agent-plugin/skills/safe-contribution/SKILL.md +27 -0
- package/agent-plugin/skills/safe-contribution/references/ship.md +5 -0
- package/agent-plugin/skills/sdk-pairing/SKILL.md +33 -0
- package/agent-plugin/skills/sdk-pairing/references/sdk.md +6 -0
- package/agent-plugin/skills/switching-policy/SKILL.md +29 -0
- package/agent-plugin/skills/switching-policy/references/policy.md +7 -0
- package/package.json +3 -5
- package/src/cli/codexinit.ts +11 -2
- package/src/cli/init.ts +9 -3
- package/src/cli/relay.ts +323 -0
- package/src/entries/codexstophook.ts +10 -0
- package/src/entries/mcp.ts +288 -0
- package/src/entries/relaypermission.ts +105 -0
- package/src/entries/stophook.ts +11 -0
- package/src/lib/decide.ts +2 -4
- package/src/lib/install.ts +61 -7
- package/src/lib/lock.ts +3 -7
- package/src/lib/log.ts +8 -11
- package/src/lib/paths.ts +3 -9
- package/src/lib/relay/config.ts +84 -0
- package/src/lib/relay/decide.ts +75 -0
- package/src/lib/relay/gc.ts +80 -0
- package/src/lib/relay/install.ts +143 -0
- package/src/lib/relay/markers.ts +148 -0
- package/src/lib/relay/modes.ts +82 -0
- package/src/lib/relay/protocol.ts +61 -0
- package/src/lib/relay/registry.ts +175 -0
- package/src/lib/relay/tmux.ts +109 -0
- package/src/lib/relay/turn.ts +137 -0
- package/src/lib/relay/worker.ts +141 -0
- package/src/lib/usage.ts +6 -5
- package/src/main.ts +6 -6
- package/src/cli/serve.ts +0 -1790
- package/src/lib/slackbridge.ts +0 -1363
- package/src/lib/slackstate.ts +0 -352
- package/src/lib/slackstream.ts +0 -300
- package/src/serve-plugin/.claude-plugin/plugin.json +0 -4
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +0 -41
- 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.
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
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":
|