tokenmaxxing 1.7.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.
@@ -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/main.ts CHANGED
@@ -27,6 +27,8 @@ import { cmdRename } from "./cli/rename.ts";
27
27
  import { cmdSwitch } from "./cli/switch.ts";
28
28
  import { cmdCheck } from "./cli/check.ts";
29
29
  import { cmdConfig } from "./cli/config.ts";
30
+ import { cmdRelay } from "./cli/relay.ts";
31
+ import { runRelayPermissionHook } from "./entries/relaypermission.ts";
30
32
  import { timerDeactivationHint, uninstallSupervisor } from "./lib/install.ts";
31
33
  import { c } from "./cli/render.ts";
32
34
 
@@ -47,6 +49,7 @@ function printHelp(): void {
47
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
48
50
  ${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
49
51
  ${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
52
+ ${c.cyan("tokenmaxxing relay")} … durable tmux relay for host agents (turn/decide/status/…); see ${c.cyan("relay --help")}
50
53
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
51
54
  ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
52
55
  ${c.cyan("tokenmaxxing rm")} [--codex] <sel>
@@ -100,11 +103,13 @@ async function main(): Promise<number> {
100
103
  case "__stop-hook": return runStopHook();
101
104
  case "__session-start": return runSessionStart();
102
105
  case "__codex-stop-hook": return runCodexStopHook();
106
+ case "__relay-permission-hook": return runRelayPermissionHook();
103
107
  case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
104
108
  case "--force": return cmdStatus(true); // bare `xx --force` → status --force
105
109
  // --codex accepted anywhere, like init/add/status: the old args[1]-only
106
110
  // check made `xx switch <sel> --codex` silently run a real CLAUDE swap
107
111
  // (one email can hold both pools' accounts - closing-review catch).
112
+ case "relay": return cmdRelay(args.slice(1));
108
113
  case "switch": {
109
114
  const rest = args.slice(1).filter((a) => a !== "--codex");
110
115
  return args.includes("--codex") ? cmdCodexSwitch(rest[0]) : cmdSwitch(rest[0]);