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,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
+ }
@@ -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
+ }