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,288 @@
|
|
|
1
|
+
// Stdio MCP entry for the portable Agent Plugin (agent-plugin/).
|
|
2
|
+
// Tools wrap existing CLI commands. stdout is reserved for MCP JSON-RPC, so
|
|
3
|
+
// every CLI call captures console.log / console.error and returns the text.
|
|
4
|
+
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { cmdLs } from "../cli/ls.ts";
|
|
11
|
+
import { cmdStatus } from "../cli/status.ts";
|
|
12
|
+
import { cmdDoctor } from "../cli/doctor.ts";
|
|
13
|
+
import { cmdConfig } from "../cli/config.ts";
|
|
14
|
+
import { cmdSwitch } from "../cli/switch.ts";
|
|
15
|
+
import { cmdCodexSwitch } from "../cli/codexswitch.ts";
|
|
16
|
+
import { cmdCheck } from "../cli/check.ts";
|
|
17
|
+
|
|
18
|
+
const MUTATIONS_ENV = "TOKENMAXXING_AGENT_MUTATIONS";
|
|
19
|
+
const PACKAGE_ROOT = join(import.meta.dir, "../..");
|
|
20
|
+
|
|
21
|
+
function packageVersion(): string {
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")) as { version?: string };
|
|
24
|
+
return raw.version ?? "0.0.0";
|
|
25
|
+
} catch {
|
|
26
|
+
return "0.0.0";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Refuse ambient Claude store overrides the same way the CLI and SDK do. */
|
|
31
|
+
export function refuseAmbientStoreEnv(): string | null {
|
|
32
|
+
const nonEmpty = (v: string | undefined) => (v != null && v !== "" ? v : null);
|
|
33
|
+
return nonEmpty(process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR) ?? nonEmpty(process.env.CLAUDE_CONFIG_DIR);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Redact token-shaped spans so tool results never echo credentials. */
|
|
37
|
+
export function scrubSecrets(text: string): string {
|
|
38
|
+
return text
|
|
39
|
+
.replace(/\b(Bearer\s+)[A-Za-z0-9._\-+/=]+/gi, "$1[redacted]")
|
|
40
|
+
.replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/g, "[redacted]")
|
|
41
|
+
.replace(/\b(accessToken|refreshToken|claudeAiOauth)\b\s*[:=]\s*["']?[^"'}\s,]+/gi, "$1=[redacted]");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type CaptureResult = { code: number; stdout: string; stderr: string };
|
|
45
|
+
|
|
46
|
+
/** Serialize captureCli: console.log/error are process-global, so concurrent
|
|
47
|
+
* tool calls would interleave stdout into each other and corrupt JSON-RPC. */
|
|
48
|
+
let captureChain: Promise<unknown> = Promise.resolve();
|
|
49
|
+
|
|
50
|
+
/** Run a CLI cmd while keeping stdout clean for the MCP transport. */
|
|
51
|
+
export async function captureCli(run: () => number | Promise<number>): Promise<CaptureResult> {
|
|
52
|
+
const job = async (): Promise<CaptureResult> => {
|
|
53
|
+
const out: string[] = [];
|
|
54
|
+
const err: string[] = [];
|
|
55
|
+
const joinArgs = (args: unknown[]) => args.map((a) => (typeof a === "string" ? a : String(a))).join(" ");
|
|
56
|
+
const log = console.log;
|
|
57
|
+
const error = console.error;
|
|
58
|
+
console.log = (...args: unknown[]) => { out.push(joinArgs(args)); };
|
|
59
|
+
console.error = (...args: unknown[]) => { err.push(joinArgs(args)); };
|
|
60
|
+
try {
|
|
61
|
+
const code = await run();
|
|
62
|
+
return { code, stdout: out.join("\n"), stderr: err.join("\n") };
|
|
63
|
+
} finally {
|
|
64
|
+
console.log = log;
|
|
65
|
+
console.error = error;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const next = captureChain.then(job, job);
|
|
69
|
+
captureChain = next.then(
|
|
70
|
+
() => undefined,
|
|
71
|
+
() => undefined,
|
|
72
|
+
);
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function textResult(input: { text: string; isError?: boolean }) {
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text" as const, text: scrubSecrets(input.text) }],
|
|
79
|
+
...(input.isError ? { isError: true } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatCapture(cap: CaptureResult): string {
|
|
84
|
+
const parts: string[] = [];
|
|
85
|
+
if (cap.stdout) parts.push(cap.stdout);
|
|
86
|
+
if (cap.stderr) parts.push(cap.stderr);
|
|
87
|
+
parts.push(`exit ${cap.code}`);
|
|
88
|
+
return parts.join("\n").trimEnd();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function mutationsEnabled(): boolean {
|
|
92
|
+
return process.env[MUTATIONS_ENV] === "1";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mutationDenied(confirm: boolean): string | null {
|
|
96
|
+
if (!confirm) {
|
|
97
|
+
return `Mutating tools require confirm=true. Also set ${MUTATIONS_ENV}=1 in the MCP server environment after the user approves.`;
|
|
98
|
+
}
|
|
99
|
+
if (!mutationsEnabled()) {
|
|
100
|
+
return `Mutations are disabled. Set ${MUTATIONS_ENV}=1 in the MCP server environment only after the user explicitly approves a pool mutation.`;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const HELP_TEXT = `tokenmaxxing agent MCP
|
|
106
|
+
|
|
107
|
+
Read tools (always available):
|
|
108
|
+
pool_ls list pooled Claude and Codex accounts (labels/status only)
|
|
109
|
+
pool_status sample usage bars (free /usage path; never --force)
|
|
110
|
+
doctor verify install health (labels/status only)
|
|
111
|
+
config_get read effective config, or one key when provided
|
|
112
|
+
help this catalog
|
|
113
|
+
|
|
114
|
+
Mutating tools (confirm=true AND ${MUTATIONS_ENV}=1):
|
|
115
|
+
pool_switch Claude greedy/forced switch, or Codex when codex=true
|
|
116
|
+
pool_check one evaluate-and-maybe-swap pass
|
|
117
|
+
config_set write a config.json override
|
|
118
|
+
config_unset remove a config.json override
|
|
119
|
+
|
|
120
|
+
Hard deny (no tools):
|
|
121
|
+
status --force / metered pings
|
|
122
|
+
init / add / auth / rm / uninstall
|
|
123
|
+
credential blobs or token values
|
|
124
|
+
killing sessions or supervisors
|
|
125
|
+
|
|
126
|
+
Prefer these tools over raw shell for pool ops. Honor TOKENMAXXING_HOME for hermetic use.
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
export function createTokenmaxxingMcpServer(): McpServer {
|
|
130
|
+
const server = new McpServer({
|
|
131
|
+
name: "tokenmaxxing",
|
|
132
|
+
version: packageVersion(),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
server.registerTool(
|
|
136
|
+
"help",
|
|
137
|
+
{
|
|
138
|
+
description: "Catalog of tokenmaxxing MCP tools and hard safety rules. Use when deciding which pool tool to call.",
|
|
139
|
+
inputSchema: {},
|
|
140
|
+
},
|
|
141
|
+
async () => textResult({ text: HELP_TEXT }),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
server.registerTool(
|
|
145
|
+
"pool_ls",
|
|
146
|
+
{
|
|
147
|
+
description: "List pooled Claude and Codex accounts with active and needs-reauth flags. Labels and status only; never credential material.",
|
|
148
|
+
inputSchema: {},
|
|
149
|
+
},
|
|
150
|
+
async () => {
|
|
151
|
+
const cap = await captureCli(() => cmdLs());
|
|
152
|
+
return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
server.registerTool(
|
|
157
|
+
"pool_status",
|
|
158
|
+
{
|
|
159
|
+
description: "Show pool usage bars via the free /usage path. Never opens 5h windows. Do not request --force; that tool does not exist.",
|
|
160
|
+
inputSchema: {},
|
|
161
|
+
},
|
|
162
|
+
async () => {
|
|
163
|
+
const cap = await captureCli(() => cmdStatus(false));
|
|
164
|
+
return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
server.registerTool(
|
|
169
|
+
"doctor",
|
|
170
|
+
{
|
|
171
|
+
description: "Verify supervisor, hooks, timer, and credential identity health. Reports labels and pass/fail only; never returns credential blobs.",
|
|
172
|
+
inputSchema: {},
|
|
173
|
+
},
|
|
174
|
+
async () => {
|
|
175
|
+
const cap = await captureCli(() => cmdDoctor());
|
|
176
|
+
return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
server.registerTool(
|
|
181
|
+
"config_get",
|
|
182
|
+
{
|
|
183
|
+
description: "Read effective config with sources, or one dotted key when key is set (e.g. thresholds.session).",
|
|
184
|
+
inputSchema: {
|
|
185
|
+
key: z.string().optional().describe("Optional dotted config key; omit for the full effective table"),
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
async ({ key }) => {
|
|
189
|
+
const args = key ? ["get", key] : [];
|
|
190
|
+
const cap = await captureCli(() => cmdConfig(args));
|
|
191
|
+
return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
server.registerTool(
|
|
196
|
+
"pool_switch",
|
|
197
|
+
{
|
|
198
|
+
description: "Switch the Claude pool (or Codex when codex=true). Requires confirm=true and TOKENMAXXING_AGENT_MUTATIONS=1. Hot-swaps live Claude; Codex takes effect on next start.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
confirm: z.boolean().describe("Must be true after the user approves the mutation"),
|
|
201
|
+
selector: z.string().optional().describe("Optional account selector; omit for greedy best"),
|
|
202
|
+
codex: z.boolean().optional().describe("When true, run the Codex pool switch instead"),
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
async ({ confirm, selector, codex }) => {
|
|
206
|
+
const denied = mutationDenied(confirm);
|
|
207
|
+
if (denied) return textResult({ text: denied, isError: true });
|
|
208
|
+
const cap = await captureCli(() => (codex ? cmdCodexSwitch(selector) : cmdSwitch(selector)));
|
|
209
|
+
return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
server.registerTool(
|
|
214
|
+
"pool_check",
|
|
215
|
+
{
|
|
216
|
+
description: "Run one evaluate-and-maybe-swap pass (the periodic timer path). Requires confirm=true and TOKENMAXXING_AGENT_MUTATIONS=1.",
|
|
217
|
+
inputSchema: {
|
|
218
|
+
confirm: z.boolean().describe("Must be true after the user approves the mutation"),
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
async ({ confirm }) => {
|
|
222
|
+
const denied = mutationDenied(confirm);
|
|
223
|
+
if (denied) return textResult({ text: denied, isError: true });
|
|
224
|
+
const cap = await captureCli(() => cmdCheck());
|
|
225
|
+
return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
server.registerTool(
|
|
230
|
+
"config_set",
|
|
231
|
+
{
|
|
232
|
+
description: "Write a config.json override. Requires confirm=true and TOKENMAXXING_AGENT_MUTATIONS=1.",
|
|
233
|
+
inputSchema: {
|
|
234
|
+
confirm: z.boolean().describe("Must be true after the user approves the mutation"),
|
|
235
|
+
key: z.string().describe("Dotted config key"),
|
|
236
|
+
value: z.string().describe("JSON or literal string value"),
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
async ({ confirm, key, value }) => {
|
|
240
|
+
const denied = mutationDenied(confirm);
|
|
241
|
+
if (denied) return textResult({ text: denied, isError: true });
|
|
242
|
+
const cap = await captureCli(() => cmdConfig(["set", key, value]));
|
|
243
|
+
return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
server.registerTool(
|
|
248
|
+
"config_unset",
|
|
249
|
+
{
|
|
250
|
+
description: "Remove a config.json override. Requires confirm=true and TOKENMAXXING_AGENT_MUTATIONS=1.",
|
|
251
|
+
inputSchema: {
|
|
252
|
+
confirm: z.boolean().describe("Must be true after the user approves the mutation"),
|
|
253
|
+
key: z.string().describe("Dotted config key"),
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
async ({ confirm, key }) => {
|
|
257
|
+
const denied = mutationDenied(confirm);
|
|
258
|
+
if (denied) return textResult({ text: denied, isError: true });
|
|
259
|
+
const cap = await captureCli(() => cmdConfig(["unset", key]));
|
|
260
|
+
return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
return server;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Entrypoint for the Agent Plugin launcher (`agent-plugin/bin/tokenmaxxing-mcp`).
|
|
268
|
+
* Exported because that bin imports this module (so `import.meta.main` is false here). */
|
|
269
|
+
export async function main(): Promise<void> {
|
|
270
|
+
const ambient = refuseAmbientStoreEnv();
|
|
271
|
+
if (ambient != null) {
|
|
272
|
+
console.error(
|
|
273
|
+
`CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR is set (${ambient}): the pooled MCP surface requires the default Claude Code credential store. Unset it and retry.`,
|
|
274
|
+
);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
const server = createTokenmaxxingMcpServer();
|
|
278
|
+
const transport = new StdioServerTransport();
|
|
279
|
+
await server.connect(transport);
|
|
280
|
+
console.error("tokenmaxxing MCP server running on stdio");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (import.meta.main) {
|
|
284
|
+
main().catch((e) => {
|
|
285
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
286
|
+
process.exit(1);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// PermissionRequest hook for relay workers. When TOKENMAXXING_RELAY_SESSION is
|
|
2
|
+
// set, park on a pending marker until `relay decide` writes a decision, then
|
|
3
|
+
// return allow/deny. Under bypassPermissions (or when pings are disabled),
|
|
4
|
+
// auto-allow without surfacing. Never blocks non-relay sessions. Always exits 0
|
|
5
|
+
// with a JSON decision body Claude understands.
|
|
6
|
+
|
|
7
|
+
import { delay } from "es-toolkit";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { loadRelayConfig } from "../lib/relay/config.ts";
|
|
10
|
+
import {
|
|
11
|
+
clearDecision,
|
|
12
|
+
readDecision,
|
|
13
|
+
writePendingRequest,
|
|
14
|
+
} from "../lib/relay/markers.ts";
|
|
15
|
+
import { permissionPingsEnabled } from "../lib/relay/modes.ts";
|
|
16
|
+
import { readEntry, registryHas } from "../lib/relay/registry.ts";
|
|
17
|
+
import { RELAY_SESSION_ENV } from "../lib/relay/worker.ts";
|
|
18
|
+
import { log } from "../lib/log.ts";
|
|
19
|
+
|
|
20
|
+
const StdinSchema = z.looseObject({
|
|
21
|
+
tool_name: z.string().optional(),
|
|
22
|
+
tool_input: z.unknown().optional(),
|
|
23
|
+
request_id: z.string().optional(),
|
|
24
|
+
permission_suggestions: z.unknown().optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function readStdin(): Promise<string> {
|
|
28
|
+
const chunks: Uint8Array[] = [];
|
|
29
|
+
for await (const c of Bun.stdin.stream()) chunks.push(c);
|
|
30
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function allow(): string {
|
|
34
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } } });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function deny(): string {
|
|
38
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny" } } });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fallbackAllow(): string {
|
|
42
|
+
// Unknown Claude envelope shapes: fail open for non-relay; for relay we still
|
|
43
|
+
// prefer an explicit decision. Default allow keeps the worker unblocked if
|
|
44
|
+
// the host never answers within timeout only after we already tried.
|
|
45
|
+
return allow();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function handleRelayPermission(input: { rawStdin: string }): Promise<string> {
|
|
49
|
+
if (process.env.TOKENMAXXING_PROBE) return allow();
|
|
50
|
+
|
|
51
|
+
const sessionId = process.env[RELAY_SESSION_ENV];
|
|
52
|
+
if (sessionId == null || sessionId === "") return allow();
|
|
53
|
+
if (!registryHas({ sessionId })) return allow();
|
|
54
|
+
|
|
55
|
+
const entry = readEntry({ sessionId });
|
|
56
|
+
if (entry != null && !permissionPingsEnabled({ mode: entry.permissionMode })) {
|
|
57
|
+
return allow();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parsed = StdinSchema.safeParse((() => {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(input.rawStdin);
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
})());
|
|
67
|
+
const requestId = parsed.success && parsed.data.request_id
|
|
68
|
+
? parsed.data.request_id
|
|
69
|
+
: crypto.randomUUID();
|
|
70
|
+
const toolName = parsed.success ? (parsed.data.tool_name ?? "tool") : "tool";
|
|
71
|
+
const detail = parsed.success
|
|
72
|
+
? JSON.stringify(parsed.data.tool_input ?? parsed.data).slice(0, 500)
|
|
73
|
+
: input.rawStdin.slice(0, 500);
|
|
74
|
+
|
|
75
|
+
writePendingRequest({
|
|
76
|
+
sessionId,
|
|
77
|
+
requestId,
|
|
78
|
+
summary: `Permission needed: ${toolName}`,
|
|
79
|
+
detail,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const cfg = loadRelayConfig();
|
|
83
|
+
const deadline = Date.now() + cfg.decideTimeoutMs;
|
|
84
|
+
while (Date.now() < deadline) {
|
|
85
|
+
const decision = readDecision({ sessionId, requestId });
|
|
86
|
+
if (decision != null) {
|
|
87
|
+
clearDecision({ sessionId, requestId });
|
|
88
|
+
return decision.approve ? allow() : deny();
|
|
89
|
+
}
|
|
90
|
+
await delay(50);
|
|
91
|
+
}
|
|
92
|
+
log("relay.permission_timeout", { session: sessionId.slice(0, 8), requestId: requestId.slice(0, 8) });
|
|
93
|
+
return deny();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function runRelayPermissionHook(): Promise<number> {
|
|
97
|
+
try {
|
|
98
|
+
const out = await handleRelayPermission({ rawStdin: await readStdin() });
|
|
99
|
+
process.stdout.write(out);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
log("relay.permission_error", { err: e instanceof Error ? e.message : String(e) });
|
|
102
|
+
process.stdout.write(fallbackAllow());
|
|
103
|
+
}
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
package/src/entries/stophook.ts
CHANGED
|
@@ -11,6 +11,9 @@ import { z } from "zod";
|
|
|
11
11
|
import { paths } from "../lib/paths.ts";
|
|
12
12
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
13
13
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
14
|
+
import { writeTurnDoneMarker } from "../lib/relay/markers.ts";
|
|
15
|
+
import { registryHas } from "../lib/relay/registry.ts";
|
|
16
|
+
import { RELAY_SESSION_ENV } from "../lib/relay/worker.ts";
|
|
14
17
|
import { RespawnMarkerSchema } from "../lib/types.ts";
|
|
15
18
|
import { log } from "../lib/log.ts";
|
|
16
19
|
|
|
@@ -41,6 +44,14 @@ export async function runStopHook(): Promise<number> {
|
|
|
41
44
|
const pinnedSid = process.env.TOKENMAXXING_SESSION_ID;
|
|
42
45
|
|
|
43
46
|
try {
|
|
47
|
+
// Additive relay turn-done marker (never writes into respawn/). Only when
|
|
48
|
+
// a registry entry exists for this relay session.
|
|
49
|
+
const relaySid = process.env[RELAY_SESSION_ENV];
|
|
50
|
+
if (relaySid != null && registryHas({ sessionId: relaySid })) {
|
|
51
|
+
writeTurnDoneMarker({ sessionId: relaySid, source: "claude-stop" });
|
|
52
|
+
log("stop.relay_turn_done", { session: relaySid.slice(0, 8) });
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
// Anticipatory depleted swaps are only sane when the respawn marker below
|
|
45
56
|
// will actually pause the session until the reset.
|
|
46
57
|
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
|
package/src/lib/decide.ts
CHANGED
|
@@ -42,8 +42,7 @@ const SwapDecisionSchema = z.object({
|
|
|
42
42
|
reason: z.string(),
|
|
43
43
|
/** set when every account is depleted and the soonest recovery is known:
|
|
44
44
|
* epoch ms that account recovers. The wait target on depleted-wait;
|
|
45
|
-
* informational on a bare all-depleted (
|
|
46
|
-
* it - nothing here waits). */
|
|
45
|
+
* informational on a bare all-depleted (nothing here waits). */
|
|
47
46
|
waitUntil: z.number().optional(),
|
|
48
47
|
});
|
|
49
48
|
export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
|
|
@@ -318,8 +317,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
318
317
|
// truly walled do we fall through to the depleted-wait park below. The wall
|
|
319
318
|
// reading is the statusLine's authoritative rate_limits tee (the same data
|
|
320
319
|
// /rate-limit-options renders); a single-turn overshoot is caught one
|
|
321
|
-
// boundary later by the check timer or the next Stop hook
|
|
322
|
-
// path additionally stamps observed limits on errored results.
|
|
320
|
+
// boundary later by the check timer or the next Stop hook.
|
|
323
321
|
const hardCtx = { now, thresholds: hardBars(cfg), currentAccountUuid: null, switchFamilies };
|
|
324
322
|
const seat = seatOf(loadAccounts());
|
|
325
323
|
if (seat && !seat.needsReauth && !isExhausted(seat, hardCtx)) {
|
package/src/lib/install.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The wrapper is a 2-line `exec ... __supervise "$@"` shim so dispatch never
|
|
3
3
|
// depends on argv0 semantics.
|
|
4
4
|
|
|
5
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
5
|
+
import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { basename, dirname, join } from "node:path";
|
|
7
7
|
import { escape } from "es-toolkit";
|
|
8
8
|
import { z } from "zod";
|
|
@@ -54,6 +54,38 @@ export function skipImperativeTimer(): boolean {
|
|
|
54
54
|
return EnvFlagSchema.parse(process.env.TOKENMAXXING_SKIP_TIMER) != null;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function isNixStorePath(path: string): boolean {
|
|
58
|
+
return path === "/nix/store" || path.startsWith("/nix/store/");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isEacces(e: unknown): boolean {
|
|
62
|
+
return typeof e === "object" && e != null && "code" in e && e.code === "EACCES";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Home Manager (and similar) point ~/.zshrc at a nix-store file. Writing
|
|
66
|
+
* through that symlink throws EACCES; soft-skip instead. Still write through
|
|
67
|
+
* ordinary writable symlink targets (PR #36). */
|
|
68
|
+
function cannotWriteRcTarget(target: string): boolean {
|
|
69
|
+
if (EnvFlagSchema.parse(process.env.TOKENMAXXING_SKIP_SHELL_RC) != null) return true;
|
|
70
|
+
if (isNixStorePath(target)) return true;
|
|
71
|
+
if (!existsSync(target)) return false;
|
|
72
|
+
try {
|
|
73
|
+
accessSync(target, constants.W_OK);
|
|
74
|
+
return false;
|
|
75
|
+
} catch {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** User-facing lines when ensurePathInRc soft-skips a managed shell rc. */
|
|
81
|
+
export function managedShellRcSkipLines(): { headline: string; detail: string; exportLine: string } {
|
|
82
|
+
return {
|
|
83
|
+
headline: "shell rc is managed (Home Manager / nix-store) - PATH was not auto-edited",
|
|
84
|
+
detail: `put ${paths.binDir} on PATH via home.sessionPath (programs.tokenmaxxing Home Manager module sets this), e.g.`,
|
|
85
|
+
exportLine: `home.sessionPath = [ "${paths.binDir}" ];`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
/** Nix supervisor shim: prefer a PATH-stable `tokenmaxxing` (profile /
|
|
58
90
|
* current-system, excluding this binDir) so upgrades/GC of an old store
|
|
59
91
|
* generation stay reachable; fall back to bun+entry for the rare
|
|
@@ -394,8 +426,11 @@ export function shellRcPath(): string | null {
|
|
|
394
426
|
const PATH_LINE_MARK = "# tokenmaxxing PATH";
|
|
395
427
|
|
|
396
428
|
/** Idempotently append the supervisor-bin PATH line to `rc` (created if absent).
|
|
397
|
-
* A pre-existing hand-added line for the bin dir also counts as present.
|
|
398
|
-
|
|
429
|
+
* A pre-existing hand-added line for the bin dir also counts as present.
|
|
430
|
+
* Returns `"skipped"` when the resolved target is immutable (nix-store /
|
|
431
|
+
* non-writable / TOKENMAXXING_SKIP_SHELL_RC) so callers can print guidance
|
|
432
|
+
* instead of surfacing EACCES. */
|
|
433
|
+
export function ensurePathInRc(rc: string): "added" | "present" | "skipped" {
|
|
399
434
|
const dir = paths.binDir.startsWith(`${HOME}/`) ? `$HOME${paths.binDir.slice(HOME.length)}` : paths.binDir;
|
|
400
435
|
// Write through a dotfile-managed symlink, never over it: writeFileAtomic
|
|
401
436
|
// renames a sibling temp over its target, which would replace the link with
|
|
@@ -413,17 +448,29 @@ export function ensurePathInRc(rc: string): "added" | "present" {
|
|
|
413
448
|
// relocation.
|
|
414
449
|
const kept = lines.filter((line) => isCurrentExport(line) || !line.includes(PATH_LINE_MARK));
|
|
415
450
|
if (kept.length !== lines.length) {
|
|
451
|
+
if (cannotWriteRcTarget(target)) return "skipped";
|
|
416
452
|
const body = kept.join("\n");
|
|
417
453
|
const sep0 = body === "" || body.endsWith("\n") ? "" : "\n";
|
|
418
454
|
const addition = kept.some(isCurrentExport) ? "" : `export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`;
|
|
419
455
|
// preserve the rc's own mode: writeFileAtomic defaults to 0600, which
|
|
420
456
|
// would silently tighten a normally 0644 shell rc (PR #36 review catch)
|
|
421
|
-
|
|
457
|
+
try {
|
|
458
|
+
writeFileAtomic(target, `${body}${sep0}${addition}`, statSync(target).mode & 0o777);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
if (isEacces(e)) return "skipped";
|
|
461
|
+
throw e;
|
|
462
|
+
}
|
|
422
463
|
return "added";
|
|
423
464
|
}
|
|
424
465
|
if (lines.some(isCurrentExport)) return "present";
|
|
466
|
+
if (cannotWriteRcTarget(target)) return "skipped";
|
|
425
467
|
const sep = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
426
|
-
|
|
468
|
+
try {
|
|
469
|
+
appendFileSync(target, `${sep}export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`);
|
|
470
|
+
} catch (e) {
|
|
471
|
+
if (isEacces(e)) return "skipped";
|
|
472
|
+
throw e;
|
|
473
|
+
}
|
|
427
474
|
return "added";
|
|
428
475
|
}
|
|
429
476
|
|
|
@@ -468,7 +515,8 @@ export function findClaudeShadowers(rcText: string): ShellShadower[] {
|
|
|
468
515
|
* PATH` line pointing at an emptied binDir is exactly how the supervisor
|
|
469
516
|
* recursion incident started (.memory/supervisor-recursion-guards.md), so
|
|
470
517
|
* uninstall must not leave one behind (closing-review catch).
|
|
471
|
-
* Returns true when a line was removed.
|
|
518
|
+
* Returns true when a line was removed. Soft-skips (returns false, no throw)
|
|
519
|
+
* when the resolved target is immutable. */
|
|
472
520
|
export function removePathFromRc(rc: string): boolean {
|
|
473
521
|
if (!existsSync(rc)) return false;
|
|
474
522
|
// same symlink + mode treatment as ensurePathInRc: write through a
|
|
@@ -477,7 +525,13 @@ export function removePathFromRc(rc: string): boolean {
|
|
|
477
525
|
const lines = readFileSync(target, "utf8").split("\n");
|
|
478
526
|
const kept = lines.filter((line) => !line.includes(PATH_LINE_MARK));
|
|
479
527
|
if (kept.length === lines.length) return false;
|
|
480
|
-
|
|
528
|
+
if (cannotWriteRcTarget(target)) return false;
|
|
529
|
+
try {
|
|
530
|
+
writeFileAtomic(target, kept.join("\n"), statSync(target).mode & 0o777);
|
|
531
|
+
} catch (e) {
|
|
532
|
+
if (isEacces(e)) return false;
|
|
533
|
+
throw e;
|
|
534
|
+
}
|
|
481
535
|
return true;
|
|
482
536
|
}
|
|
483
537
|
|
package/src/lib/lock.ts
CHANGED
|
@@ -4,13 +4,9 @@
|
|
|
4
4
|
// process exit).
|
|
5
5
|
//
|
|
6
6
|
// The acquire is NON-BLOCKING (LOCK_EX|LOCK_NB) with an async retry loop: a
|
|
7
|
-
// blocking LOCK_EX from this runtime freezes the whole event loop
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// deadlock; a cross-process holder would freeze the daemon for its whole
|
|
11
|
-
// critical section (adversarial review catch, 2026-07-19). EWOULDBLOCK is
|
|
12
|
-
// told apart from real failures via errno, so a bad fd still fails fast
|
|
13
|
-
// instead of spinning.
|
|
7
|
+
// blocking LOCK_EX from this runtime freezes the whole event loop.
|
|
8
|
+
// EWOULDBLOCK is told apart from real failures via errno, so a bad fd still
|
|
9
|
+
// fails fast instead of spinning.
|
|
14
10
|
|
|
15
11
|
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
16
12
|
import { dirname } from "node:path";
|
package/src/lib/log.ts
CHANGED
|
@@ -18,10 +18,9 @@ function redact(s: string): string {
|
|
|
18
18
|
|
|
19
19
|
let echo: ((input: { event: string; parts: string }) => void) | null = null;
|
|
20
20
|
|
|
21
|
-
/** Tee every subsequent log() line to a terminal printer.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* file line gets. */
|
|
21
|
+
/** Tee every subsequent log() line to a terminal printer. Off by default
|
|
22
|
+
* (hooks and the statusline own their stdout protocol). The printer receives
|
|
23
|
+
* the same redacted parts the file line gets. */
|
|
25
24
|
export function setLogEcho(input: { printer: (input: { event: string; parts: string }) => void }): void {
|
|
26
25
|
echo = input.printer;
|
|
27
26
|
}
|
|
@@ -36,10 +35,9 @@ export function log(event: string, fields: Record<string, unknown> = {}): void {
|
|
|
36
35
|
})
|
|
37
36
|
.join(" ");
|
|
38
37
|
mkdirSync(dirname(paths.logFile), { recursive: true });
|
|
39
|
-
// Rotation cap: the check timer logs every 180s
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// total disk at ~2x the cap; older history is disposable diagnostics.
|
|
38
|
+
// Rotation cap: the check timer logs every 180s, so an uncapped
|
|
39
|
+
// append-only file grows forever on a live install. One .old generation
|
|
40
|
+
// bounds total disk at ~2x the cap; older history is disposable diagnostics.
|
|
43
41
|
if (existsSync(paths.logFile) && statSync(paths.logFile).size > LOG_MAX_BYTES) {
|
|
44
42
|
renameSync(paths.logFile, `${paths.logFile}.old`);
|
|
45
43
|
}
|
|
@@ -47,11 +45,10 @@ export function log(event: string, fields: Record<string, unknown> = {}): void {
|
|
|
47
45
|
} catch {
|
|
48
46
|
// logging must never throw into a hook / supervisor path
|
|
49
47
|
}
|
|
50
|
-
// separate from the file sink: an unwritable log file must not also silence
|
|
51
|
-
// the terminal echo (that is exactly when the daemon needs to stay visible).
|
|
48
|
+
// separate from the file sink: an unwritable log file must not also silence the echo.
|
|
52
49
|
try {
|
|
53
50
|
echo?.({ event, parts: line });
|
|
54
51
|
} catch {
|
|
55
|
-
// the echo printer must never throw into
|
|
52
|
+
// the echo printer must never throw into a caller path
|
|
56
53
|
}
|
|
57
54
|
}
|
package/src/lib/paths.ts
CHANGED
|
@@ -36,15 +36,9 @@ export const paths = {
|
|
|
36
36
|
sampleDir: join(TM_HOME, "sample"),
|
|
37
37
|
/** linux only: parked credential .json files (0700 dir, 0600 files). */
|
|
38
38
|
credsDir: join(TM_HOME, "creds"),
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
* single-instance flock (a new daemon generation blocks on it until the
|
|
43
|
-
* previous one - possibly still draining an in-flight turn - fully exits,
|
|
44
|
-
* so two generations never act on the same thread records or cwd). */
|
|
45
|
-
slackJson: join(TM_HOME, "slack.json"),
|
|
46
|
-
slackThreadsDir: join(TM_HOME, "slack-threads"),
|
|
47
|
-
serveLockFile: join(TM_HOME, "serve-lock"),
|
|
39
|
+
/** Durable tmux relay companion: config + per-session state (not respawn/). */
|
|
40
|
+
relayJson: join(TM_HOME, "relay.json"),
|
|
41
|
+
relayDir: join(TM_HOME, "relay"),
|
|
48
42
|
|
|
49
43
|
/** ~/.claude.json - holds the active `oauthAccount` identity object. */
|
|
50
44
|
claudeJson: env("TOKENMAXXING_CLAUDE_JSON", join(HOME, ".claude.json")),
|