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,84 @@
1
+ // $TOKENMAXXING_HOME/relay.json - sparse overrides merged with defaults.
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { z } from "zod";
5
+ import { paths } from "../paths.ts";
6
+ import { writeFileAtomic } from "../atomic.ts";
7
+ import { ClaudePermissionModeSchema } from "./modes.ts";
8
+
9
+ const WorkerSchema = z.enum(["claude", "codex"]);
10
+ export type RelayWorker = z.infer<typeof WorkerSchema>;
11
+
12
+ const HostsSchema = z.object({
13
+ cursor: z.object({ model: z.string().min(1).optional() }).default({}),
14
+ claude: z.object({ model: z.string().min(1).optional() }).default({}),
15
+ }).default({ cursor: {}, claude: {} });
16
+
17
+ export const RelayConfigFileSchema = z.object({
18
+ defaultWorker: WorkerSchema.optional(),
19
+ defaultPermissionMode: ClaudePermissionModeSchema.optional(),
20
+ turnTimeoutMs: z.number().int().positive().optional(),
21
+ decideTimeoutMs: z.number().int().positive().optional(),
22
+ idleTtlMs: z.number().int().positive().optional(),
23
+ sameSessionBusyMs: z.number().int().positive().optional(),
24
+ sessionPrefix: z.string().min(1).optional(),
25
+ hosts: HostsSchema.optional(),
26
+ });
27
+ export type RelayConfigFile = z.infer<typeof RelayConfigFileSchema>;
28
+
29
+ export const RelayConfigSchema = z.object({
30
+ defaultWorker: WorkerSchema,
31
+ defaultPermissionMode: ClaudePermissionModeSchema,
32
+ turnTimeoutMs: z.number().int().positive(),
33
+ decideTimeoutMs: z.number().int().positive(),
34
+ idleTtlMs: z.number().int().positive(),
35
+ sameSessionBusyMs: z.number().int().positive(),
36
+ sessionPrefix: z.string().min(1),
37
+ hosts: z.object({
38
+ cursor: z.object({ model: z.string().min(1).optional() }),
39
+ claude: z.object({ model: z.string().min(1).optional() }),
40
+ }),
41
+ });
42
+ export type RelayConfig = z.infer<typeof RelayConfigSchema>;
43
+
44
+ export const DEFAULT_RELAY_CONFIG: RelayConfig = {
45
+ defaultWorker: "claude",
46
+ defaultPermissionMode: "auto",
47
+ turnTimeoutMs: 30 * 60 * 1000,
48
+ decideTimeoutMs: 30 * 60 * 1000,
49
+ idleTtlMs: 60 * 60 * 1000,
50
+ sameSessionBusyMs: 50,
51
+ sessionPrefix: "xx-relay-",
52
+ hosts: { cursor: {}, claude: {} },
53
+ };
54
+
55
+ export function loadRelayConfig(): RelayConfig {
56
+ if (!existsSync(paths.relayJson)) return DEFAULT_RELAY_CONFIG;
57
+ const raw = RelayConfigFileSchema.parse(JSON.parse(readFileSync(paths.relayJson, "utf8")));
58
+ return RelayConfigSchema.parse({
59
+ ...DEFAULT_RELAY_CONFIG,
60
+ ...raw,
61
+ hosts: {
62
+ cursor: { ...DEFAULT_RELAY_CONFIG.hosts.cursor, ...raw.hosts?.cursor },
63
+ claude: { ...DEFAULT_RELAY_CONFIG.hosts.claude, ...raw.hosts?.claude },
64
+ },
65
+ defaultPermissionMode: raw.defaultPermissionMode ?? DEFAULT_RELAY_CONFIG.defaultPermissionMode,
66
+ });
67
+ }
68
+
69
+ export function writeRelayConfig(input: { file: RelayConfigFile }): void {
70
+ const validated = RelayConfigFileSchema.parse(input.file);
71
+ writeFileAtomic(paths.relayJson, JSON.stringify(validated, null, 2) + "\n");
72
+ }
73
+
74
+ export function mergeRelayConfigFile(input: { patch: RelayConfigFile }): RelayConfig {
75
+ const existing = existsSync(paths.relayJson)
76
+ ? RelayConfigFileSchema.parse(JSON.parse(readFileSync(paths.relayJson, "utf8")))
77
+ : {};
78
+ const next = RelayConfigFileSchema.parse({ ...existing, ...input.patch, hosts: {
79
+ cursor: { ...existing.hosts?.cursor, ...input.patch.hosts?.cursor },
80
+ claude: { ...existing.hosts?.claude, ...input.patch.hosts?.claude },
81
+ } });
82
+ writeRelayConfig({ file: next });
83
+ return loadRelayConfig();
84
+ }
@@ -0,0 +1,75 @@
1
+ // relay decide: approve/deny a pending permission ping and resume the wait.
2
+
3
+ import { delay } from "es-toolkit";
4
+ import { loadRelayConfig } from "./config.ts";
5
+ import {
6
+ clearPendingRequest,
7
+ listPendingRequests,
8
+ readPendingRequest,
9
+ writeDecision,
10
+ } from "./markers.ts";
11
+ import { runTurn, type TurnResult } from "./turn.ts";
12
+ import { readEntry, touchEntry, withSessionLock } from "./registry.ts";
13
+
14
+ export type DecideParams = {
15
+ sessionId: string;
16
+ requestId?: string;
17
+ approve: boolean;
18
+ /** After writing the decision, wait for turn-done or the next ping. */
19
+ wait?: boolean;
20
+ cwd?: string;
21
+ timeoutMs?: number;
22
+ now?: () => number;
23
+ sleep?: (ms: number) => Promise<void>;
24
+ };
25
+
26
+ export async function runDecide(input: DecideParams): Promise<{
27
+ decisionWritten: boolean;
28
+ requestId: string;
29
+ turn?: TurnResult;
30
+ }> {
31
+ const entry = readEntry({ sessionId: input.sessionId });
32
+ if (entry == null) throw new Error(`relay session not found: ${input.sessionId}`);
33
+
34
+ let requestId = input.requestId ?? entry.pendingRequestId;
35
+ if (requestId == null) {
36
+ const pending = listPendingRequests({ sessionId: input.sessionId });
37
+ requestId = pending[0]?.requestId;
38
+ }
39
+ if (requestId == null) throw new Error(`no pending permission request for session ${input.sessionId}`);
40
+
41
+ const pending = readPendingRequest({ sessionId: input.sessionId, requestId });
42
+ if (pending == null) throw new Error(`pending request not found: ${requestId}`);
43
+
44
+ writeDecision({
45
+ sessionId: input.sessionId,
46
+ requestId,
47
+ approve: input.approve,
48
+ now: (input.now ?? Date.now)(),
49
+ });
50
+ clearPendingRequest({ sessionId: input.sessionId, requestId });
51
+ await withSessionLock({
52
+ sessionId: input.sessionId,
53
+ fn: () => touchEntry({
54
+ sessionId: input.sessionId,
55
+ state: "running",
56
+ pendingRequestId: null,
57
+ now: (input.now ?? Date.now)(),
58
+ }),
59
+ });
60
+
61
+ if (input.wait === false) {
62
+ return { decisionWritten: true, requestId };
63
+ }
64
+
65
+ const cfg = loadRelayConfig();
66
+ const turn = await runTurn({
67
+ sessionId: input.sessionId,
68
+ cwd: input.cwd ?? entry.cwd,
69
+ waitOnly: true,
70
+ timeoutMs: input.timeoutMs ?? cfg.decideTimeoutMs,
71
+ now: input.now,
72
+ sleep: input.sleep ?? delay,
73
+ });
74
+ return { decisionWritten: true, requestId, turn };
75
+ }
@@ -0,0 +1,80 @@
1
+ // relay destroy / gc / status. Exact tmux session names only; never pattern-kill.
2
+
3
+ import { rmSync } from "node:fs";
4
+ import { loadRelayConfig } from "./config.ts";
5
+ import { clearSessionMarkers } from "./markers.ts";
6
+ import {
7
+ deleteEntry,
8
+ listEntries,
9
+ readEntry,
10
+ sessionLockPath,
11
+ withSessionLock,
12
+ type RelayRegistryEntry,
13
+ } from "./registry.ts";
14
+ import { getTmux } from "./tmux.ts";
15
+
16
+ export async function destroySession(input: { sessionId: string }): Promise<boolean> {
17
+ const entry = readEntry({ sessionId: input.sessionId });
18
+ if (entry == null) {
19
+ // Still try exact tmux name from config prefix in case registry was lost.
20
+ return false;
21
+ }
22
+ await withSessionLock({
23
+ sessionId: input.sessionId,
24
+ fn: () => {
25
+ getTmux().killSession({ name: entry.tmuxName });
26
+ clearSessionMarkers({ sessionId: input.sessionId });
27
+ deleteEntry({ sessionId: input.sessionId });
28
+ rmSync(sessionLockPath({ sessionId: input.sessionId }), { force: true });
29
+ },
30
+ });
31
+ return true;
32
+ }
33
+
34
+ export type GcResult = {
35
+ reaped: string[];
36
+ kept: string[];
37
+ };
38
+
39
+ /** Reap dead tmux sessions and idle sessions past idleTtlMs. */
40
+ export async function gcSessions(input: { now?: number; idleTtlMs?: number } = {}): Promise<GcResult> {
41
+ const cfg = loadRelayConfig();
42
+ const now = input.now ?? Date.now();
43
+ const idleTtlMs = input.idleTtlMs ?? cfg.idleTtlMs;
44
+ const reaped: string[] = [];
45
+ const kept: string[] = [];
46
+ const tmux = getTmux();
47
+
48
+ for (const entry of listEntries()) {
49
+ const alive = tmux.hasSession({ name: entry.tmuxName });
50
+ const idleTooLong = now - entry.lastActiveAt > idleTtlMs;
51
+ if (!alive || idleTooLong || entry.state === "destroyed") {
52
+ await destroySession({ sessionId: entry.sessionId });
53
+ reaped.push(entry.sessionId);
54
+ continue;
55
+ }
56
+ kept.push(entry.sessionId);
57
+ }
58
+ return { reaped, kept };
59
+ }
60
+
61
+ export function statusSessions(): RelayRegistryEntry[] {
62
+ const tmux = getTmux();
63
+ return listEntries().map((entry) => ({
64
+ ...entry,
65
+ // annotate liveness in a non-schema field via spread for printers; keep schema pure
66
+ })).map((entry) => {
67
+ void tmux.hasSession({ name: entry.tmuxName });
68
+ return entry;
69
+ });
70
+ }
71
+
72
+ export type StatusRow = RelayRegistryEntry & { tmuxAlive: boolean };
73
+
74
+ export function statusRows(): StatusRow[] {
75
+ const tmux = getTmux();
76
+ return listEntries().map((entry) => ({
77
+ ...entry,
78
+ tmuxAlive: tmux.hasSession({ name: entry.tmuxName }),
79
+ }));
80
+ }
@@ -0,0 +1,143 @@
1
+ // relay install: write thin host agent templates + optional hook snippets.
2
+ // Merge only tokenmaxxing-owned keys.
3
+
4
+ import { existsSync, mkdirSync, readFileSync, cpSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { z } from "zod";
7
+ import { writeFileAtomic } from "../atomic.ts";
8
+ import { paths } from "../paths.ts";
9
+ import { isOurHookCommand } from "../settings.ts";
10
+
11
+ const pluginRoot = () => join(import.meta.dir, "../../../agent-plugin");
12
+
13
+ function repoAgentsDir(): string {
14
+ return join(pluginRoot(), "agents");
15
+ }
16
+
17
+ function repoSkillDir(): string {
18
+ return join(pluginRoot(), "skills", "relay-session");
19
+ }
20
+
21
+ function repoHooksDir(): string {
22
+ return join(pluginRoot(), "hooks");
23
+ }
24
+
25
+ export type InstallTarget = "cursor" | "claude" | "all";
26
+
27
+ export type InstallResult = {
28
+ agentsWritten: string[];
29
+ skillWritten: boolean;
30
+ hooksMerged: string[];
31
+ };
32
+
33
+ function copyAgent(input: { name: string; destDir: string }): string {
34
+ mkdirSync(input.destDir, { recursive: true });
35
+ const src = join(repoAgentsDir(), input.name);
36
+ const dest = join(input.destDir, input.name);
37
+ if (!existsSync(src)) throw new Error(`missing agent template: ${src}`);
38
+ cpSync(src, dest);
39
+ return dest;
40
+ }
41
+
42
+ function copySkill(input: { destDir: string }): boolean {
43
+ const src = repoSkillDir();
44
+ if (!existsSync(src)) return false;
45
+ mkdirSync(input.destDir, { recursive: true });
46
+ cpSync(src, input.destDir, { recursive: true });
47
+ return true;
48
+ }
49
+
50
+ const CursorHooksSchema = z.looseObject({
51
+ version: z.number().optional(),
52
+ hooks: z.record(z.string(), z.unknown()).optional(),
53
+ });
54
+
55
+ const TOKENMAXXING_HOOK_KEY = "tokenmaxxingRelay";
56
+
57
+ /** Merge Cursor hooks.json: only the tokenmaxxingRelay key is ours. */
58
+ export function mergeCursorHooks(input: { hooksPath: string }): boolean {
59
+ const snippetPath = join(repoHooksDir(), "cursor-relay.json");
60
+ if (!existsSync(snippetPath)) return false;
61
+ const snippet = CursorHooksSchema.parse(JSON.parse(readFileSync(snippetPath, "utf8")));
62
+ let existing: z.infer<typeof CursorHooksSchema> = { version: 1, hooks: {} };
63
+ if (existsSync(input.hooksPath)) {
64
+ try {
65
+ existing = CursorHooksSchema.parse(JSON.parse(readFileSync(input.hooksPath, "utf8")));
66
+ } catch {
67
+ existing = { version: 1, hooks: {} };
68
+ }
69
+ }
70
+ existing.hooks ??= {};
71
+ const ours = snippet.hooks?.[TOKENMAXXING_HOOK_KEY];
72
+ if (ours === undefined) return false;
73
+ existing.hooks[TOKENMAXXING_HOOK_KEY] = ours;
74
+ mkdirSync(dirname(input.hooksPath), { recursive: true });
75
+ writeFileAtomic(input.hooksPath, JSON.stringify(existing, null, 2) + "\n");
76
+ return true;
77
+ }
78
+
79
+ const ClaudeSettingsLoose = z.looseObject({
80
+ hooks: z.record(z.string(), z.array(z.looseObject({
81
+ matcher: z.string().optional(),
82
+ hooks: z.array(z.looseObject({ type: z.string(), command: z.string() })).default([]),
83
+ }))).optional(),
84
+ });
85
+
86
+ const RELAY_PERM_SUB = "__relay-permission-hook";
87
+
88
+ /** Append PermissionRequest hook for relay; never remove foreign hooks. */
89
+ export function mergeClaudeRelayPermissionHook(input: { settingsPath?: string } = {}): boolean {
90
+ const settingsPath = input.settingsPath ?? paths.claudeSettings;
91
+ const bin = join(paths.binDir, "tokenmaxxing");
92
+ const command = `${JSON.stringify(bin)} ${RELAY_PERM_SUB}`;
93
+ let settings: z.infer<typeof ClaudeSettingsLoose> = {};
94
+ if (existsSync(settingsPath)) {
95
+ settings = ClaudeSettingsLoose.parse(JSON.parse(readFileSync(settingsPath, "utf8")));
96
+ }
97
+ settings.hooks ??= {};
98
+ settings.hooks.PermissionRequest ??= [];
99
+ const arr = settings.hooks.PermissionRequest;
100
+ const present = arr.some((g) => g.hooks.some((h) => h.command === command || isOurHookCommand(h.command, RELAY_PERM_SUB)));
101
+ if (!present) {
102
+ arr.push({ hooks: [{ type: "command", command }] });
103
+ }
104
+ mkdirSync(dirname(settingsPath), { recursive: true });
105
+ const mode = existsSync(settingsPath) ? 0o600 : 0o600;
106
+ writeFileAtomic(settingsPath, JSON.stringify(settings, null, 2) + "\n", mode);
107
+ return true;
108
+ }
109
+
110
+ export function installRelayHosts(input: {
111
+ target: InstallTarget;
112
+ cursorAgentsDir?: string;
113
+ claudeAgentsDir?: string;
114
+ cursorHooksPath?: string;
115
+ mergeHooks?: boolean;
116
+ }): InstallResult {
117
+ const home = process.env.HOME ?? "";
118
+ const cursorAgents = input.cursorAgentsDir ?? join(home, ".cursor", "agents");
119
+ const claudeAgents = input.claudeAgentsDir ?? join(home, ".claude", "agents");
120
+ const agentsWritten: string[] = [];
121
+ const hooksMerged: string[] = [];
122
+ let skillWritten = false;
123
+
124
+ if (input.target === "cursor" || input.target === "all") {
125
+ agentsWritten.push(copyAgent({ name: "tokenmaxxing-claude.md", destDir: cursorAgents }));
126
+ agentsWritten.push(copyAgent({ name: "tokenmaxxing-codex.md", destDir: cursorAgents }));
127
+ skillWritten = copySkill({ destDir: join(home, ".cursor", "skills", "relay-session") }) || skillWritten;
128
+ if (input.mergeHooks !== false) {
129
+ const hooksPath = input.cursorHooksPath ?? join(home, ".cursor", "hooks.json");
130
+ if (mergeCursorHooks({ hooksPath })) hooksMerged.push(hooksPath);
131
+ }
132
+ }
133
+ if (input.target === "claude" || input.target === "all") {
134
+ agentsWritten.push(copyAgent({ name: "tokenmaxxing-claude.md", destDir: claudeAgents }));
135
+ agentsWritten.push(copyAgent({ name: "tokenmaxxing-codex.md", destDir: claudeAgents }));
136
+ if (input.mergeHooks !== false) {
137
+ if (mergeClaudeRelayPermissionHook()) hooksMerged.push(paths.claudeSettings);
138
+ }
139
+ }
140
+ return { agentsWritten, skillWritten, hooksMerged };
141
+ }
142
+
143
+ export { RELAY_PERM_SUB };
@@ -0,0 +1,148 @@
1
+ // Additive turn-done and permission-needed markers under relayDir.
2
+ // Never writes into respawn/.
3
+
4
+ import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync } from "node:fs";
5
+ import { z } from "zod";
6
+ import { writeFileAtomic } from "../atomic.ts";
7
+ import {
8
+ decisionPath,
9
+ pendingRequestPath,
10
+ relayDecisionsDir,
11
+ relayPendingDir,
12
+ relayTurnDoneDir,
13
+ turnDonePath,
14
+ } from "./registry.ts";
15
+
16
+ export const TurnDoneMarkerSchema = z.object({
17
+ sessionId: z.uuid(),
18
+ ts: z.number().int().nonnegative(),
19
+ source: z.enum(["claude-stop", "codex-stop", "test"]).default("claude-stop"),
20
+ });
21
+ export type TurnDoneMarker = z.infer<typeof TurnDoneMarkerSchema>;
22
+
23
+ export const PendingRequestSchema = z.object({
24
+ requestId: z.string().min(1),
25
+ sessionId: z.uuid(),
26
+ summary: z.string(),
27
+ detail: z.string(),
28
+ ts: z.number().int().nonnegative(),
29
+ });
30
+ export type PendingRequest = z.infer<typeof PendingRequestSchema>;
31
+
32
+ export const DecisionSchema = z.object({
33
+ requestId: z.string().min(1),
34
+ sessionId: z.uuid(),
35
+ approve: z.boolean(),
36
+ ts: z.number().int().nonnegative(),
37
+ });
38
+ export type Decision = z.infer<typeof DecisionSchema>;
39
+
40
+ /** Write a turn-done marker for a relay session. Additive; never touches respawn/. */
41
+ export function writeTurnDoneMarker(input: {
42
+ sessionId: string;
43
+ source?: TurnDoneMarker["source"];
44
+ now?: number;
45
+ }): void {
46
+ mkdirSync(relayTurnDoneDir(), { recursive: true });
47
+ const payload = TurnDoneMarkerSchema.parse({
48
+ sessionId: input.sessionId,
49
+ ts: input.now ?? Date.now(),
50
+ source: input.source ?? "claude-stop",
51
+ });
52
+ writeFileAtomic(turnDonePath({ sessionId: input.sessionId }), JSON.stringify(payload) + "\n");
53
+ }
54
+
55
+ export function readTurnDoneMarker(input: { sessionId: string }): TurnDoneMarker | null {
56
+ const path = turnDonePath(input);
57
+ if (!existsSync(path)) return null;
58
+ return TurnDoneMarkerSchema.parse(JSON.parse(readFileSync(path, "utf8")));
59
+ }
60
+
61
+ export function clearTurnDoneMarker(input: { sessionId: string }): void {
62
+ rmSync(turnDonePath(input), { force: true });
63
+ }
64
+
65
+ export function writePendingRequest(input: {
66
+ sessionId: string;
67
+ requestId: string;
68
+ summary: string;
69
+ detail: string;
70
+ now?: number;
71
+ }): PendingRequest {
72
+ const payload = PendingRequestSchema.parse({
73
+ requestId: input.requestId,
74
+ sessionId: input.sessionId,
75
+ summary: input.summary,
76
+ detail: input.detail,
77
+ ts: input.now ?? Date.now(),
78
+ });
79
+ writeFileAtomic(
80
+ pendingRequestPath({ sessionId: input.sessionId, requestId: input.requestId }),
81
+ JSON.stringify(payload, null, 2) + "\n",
82
+ );
83
+ return payload;
84
+ }
85
+
86
+ export function readPendingRequest(input: { sessionId: string; requestId: string }): PendingRequest | null {
87
+ const path = pendingRequestPath(input);
88
+ if (!existsSync(path)) return null;
89
+ return PendingRequestSchema.parse(JSON.parse(readFileSync(path, "utf8")));
90
+ }
91
+
92
+ export function listPendingRequests(input: { sessionId: string }): PendingRequest[] {
93
+ const dir = `${relayPendingDir()}/${input.sessionId}`;
94
+ if (!existsSync(dir)) return [];
95
+ const out: PendingRequest[] = [];
96
+ for (const name of readdirSync(dir)) {
97
+ if (!name.endsWith(".json")) continue;
98
+ try {
99
+ out.push(PendingRequestSchema.parse(JSON.parse(readFileSync(`${dir}/${name}`, "utf8"))));
100
+ } catch {
101
+ // skip
102
+ }
103
+ }
104
+ return out.sort((a, b) => a.ts - b.ts);
105
+ }
106
+
107
+ export function clearPendingRequest(input: { sessionId: string; requestId: string }): void {
108
+ rmSync(pendingRequestPath(input), { force: true });
109
+ }
110
+
111
+ export function writeDecision(input: {
112
+ sessionId: string;
113
+ requestId: string;
114
+ approve: boolean;
115
+ now?: number;
116
+ }): Decision {
117
+ mkdirSync(`${relayDecisionsDir()}/${input.sessionId}`, { recursive: true });
118
+ const payload = DecisionSchema.parse({
119
+ requestId: input.requestId,
120
+ sessionId: input.sessionId,
121
+ approve: input.approve,
122
+ ts: input.now ?? Date.now(),
123
+ });
124
+ writeFileAtomic(
125
+ decisionPath({ sessionId: input.sessionId, requestId: input.requestId }),
126
+ JSON.stringify(payload, null, 2) + "\n",
127
+ );
128
+ return payload;
129
+ }
130
+
131
+ export function readDecision(input: { sessionId: string; requestId: string }): Decision | null {
132
+ const path = decisionPath(input);
133
+ if (!existsSync(path)) return null;
134
+ return DecisionSchema.parse(JSON.parse(readFileSync(path, "utf8")));
135
+ }
136
+
137
+ export function clearDecision(input: { sessionId: string; requestId: string }): void {
138
+ rmSync(decisionPath(input), { force: true });
139
+ }
140
+
141
+ /** Clear all marker artifacts for a session (destroy/gc). Does not touch respawn/. */
142
+ export function clearSessionMarkers(input: { sessionId: string }): void {
143
+ clearTurnDoneMarker(input);
144
+ const pendingDir = `${relayPendingDir()}/${input.sessionId}`;
145
+ const decisionsDir = `${relayDecisionsDir()}/${input.sessionId}`;
146
+ rmSync(pendingDir, { recursive: true, force: true });
147
+ rmSync(decisionsDir, { recursive: true, force: true });
148
+ }
@@ -0,0 +1,82 @@
1
+ // Claude Code permission-mode vocabulary for the relay worker, plus the Codex
2
+ // sandbox / ask-for-approval mapping. Host terms only: never invent alternate
3
+ // security labels. `manual` aliases Claude's `default` (≥2.1.200 UI name).
4
+
5
+ import { z } from "zod";
6
+
7
+ export const ClaudePermissionModes = [
8
+ "default",
9
+ "acceptEdits",
10
+ "plan",
11
+ "auto",
12
+ "dontAsk",
13
+ "bypassPermissions",
14
+ ] as const;
15
+
16
+ export const ClaudePermissionModeSchema = z.enum(ClaudePermissionModes);
17
+ export type ClaudePermissionMode = z.infer<typeof ClaudePermissionModeSchema>;
18
+
19
+ const AliasSchema = z.enum(["manual", "yolo", "dangerous"]);
20
+
21
+ /** Parse a host/CLI permission-mode token into a Claude mode. */
22
+ export function parsePermissionMode(input: { raw: string }): ClaudePermissionMode {
23
+ const trimmed = input.raw.trim();
24
+ const alias = AliasSchema.safeParse(trimmed);
25
+ if (alias.success) {
26
+ if (alias.data === "manual") return "default";
27
+ return "bypassPermissions";
28
+ }
29
+ return ClaudePermissionModeSchema.parse(trimmed);
30
+ }
31
+
32
+ export function tryParsePermissionMode(input: { raw: string }): ClaudePermissionMode | null {
33
+ try {
34
+ return parsePermissionMode(input);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export type CodexAskForApproval = "untrusted" | "on-failure" | "on-request" | "never";
41
+
42
+ export type CodexSpawnFlags = {
43
+ sandbox: "read-only" | "workspace-write" | "danger-full-access";
44
+ askForApproval: CodexAskForApproval;
45
+ };
46
+
47
+ /** Map a Claude permission-mode name onto Codex CLI flags. */
48
+ export function codexFlagsForMode(input: { mode: ClaudePermissionMode }): CodexSpawnFlags {
49
+ switch (input.mode) {
50
+ case "plan":
51
+ case "default":
52
+ return { sandbox: "read-only", askForApproval: "on-request" };
53
+ case "acceptEdits":
54
+ return { sandbox: "workspace-write", askForApproval: "on-request" };
55
+ case "auto":
56
+ return { sandbox: "workspace-write", askForApproval: "on-request" };
57
+ case "dontAsk":
58
+ return { sandbox: "read-only", askForApproval: "never" };
59
+ case "bypassPermissions":
60
+ return { sandbox: "danger-full-access", askForApproval: "never" };
61
+ }
62
+ }
63
+
64
+ /** Claude argv fragment for a permission mode (includes allow-dangerously when needed). */
65
+ export function claudeArgvForMode(input: { mode: ClaudePermissionMode }): string[] {
66
+ const args = ["--permission-mode", input.mode];
67
+ if (input.mode === "bypassPermissions") {
68
+ args.push("--dangerously-skip-permissions");
69
+ }
70
+ return args;
71
+ }
72
+
73
+ /** Codex argv fragment for a Claude-named mode. */
74
+ export function codexArgvForMode(input: { mode: ClaudePermissionMode }): string[] {
75
+ const flags = codexFlagsForMode(input);
76
+ return ["--sandbox", flags.sandbox, "--ask-for-approval", flags.askForApproval];
77
+ }
78
+
79
+ /** Under bypassPermissions, worker permission pings must not surface to main. */
80
+ export function permissionPingsEnabled(input: { mode: ClaudePermissionMode }): boolean {
81
+ return input.mode !== "bypassPermissions";
82
+ }
@@ -0,0 +1,61 @@
1
+ // Shared stdout contract for cheap host relay agents.
2
+
3
+ import type { ClaudePermissionMode } from "./modes.ts";
4
+
5
+ export type TurnOutput = {
6
+ sessionId: string;
7
+ permissionMode: ClaudePermissionMode;
8
+ kind: "turn-done";
9
+ text: string;
10
+ };
11
+
12
+ export type PermissionNeededOutput = {
13
+ sessionId: string;
14
+ permissionMode: ClaudePermissionMode;
15
+ kind: "permission-needed";
16
+ requestId: string;
17
+ summary: string;
18
+ detail: string;
19
+ };
20
+
21
+ export type RelayStdout = TurnOutput | PermissionNeededOutput;
22
+
23
+ export function formatRelayStdout(input: { payload: RelayStdout }): string {
24
+ const p = input.payload;
25
+ const lines = [
26
+ `session: ${p.sessionId}`,
27
+ `permission-mode: ${p.permissionMode}`,
28
+ ];
29
+ if (p.kind === "permission-needed") {
30
+ lines.push(`permission-needed: ${p.requestId}`);
31
+ lines.push(`summary: ${p.summary}`);
32
+ lines.push(`detail: ${p.detail}`);
33
+ lines.push(`session: ${p.sessionId}`);
34
+ lines.push(`permission-mode: ${p.permissionMode}`);
35
+ } else if (p.text.trim() !== "") {
36
+ lines.push(p.text.replace(/\s+$/, ""));
37
+ }
38
+ return lines.join("\n") + "\n";
39
+ }
40
+
41
+ export function parseRelayStdout(input: { text: string }): {
42
+ sessionId: string | null;
43
+ permissionMode: string | null;
44
+ requestId: string | null;
45
+ summary: string | null;
46
+ detail: string | null;
47
+ } {
48
+ let sessionId: string | null = null;
49
+ let permissionMode: string | null = null;
50
+ let requestId: string | null = null;
51
+ let summary: string | null = null;
52
+ let detail: string | null = null;
53
+ for (const line of input.text.split("\n")) {
54
+ if (line.startsWith("session: ")) sessionId = line.slice("session: ".length).trim();
55
+ else if (line.startsWith("permission-mode: ")) permissionMode = line.slice("permission-mode: ".length).trim();
56
+ else if (line.startsWith("permission-needed: ")) requestId = line.slice("permission-needed: ".length).trim();
57
+ else if (line.startsWith("summary: ")) summary = line.slice("summary: ".length);
58
+ else if (line.startsWith("detail: ")) detail = line.slice("detail: ".length);
59
+ }
60
+ return { sessionId, permissionMode, requestId, summary, detail };
61
+ }