tmux-agent 0.1.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 (161) hide show
  1. package/.codex/skills/speckit/SKILL.md +173 -0
  2. package/.codex/skills/speckit/assets/templates/checklist-template.md +49 -0
  3. package/.codex/skills/speckit/assets/templates/notes-entrypoints-template.md +11 -0
  4. package/.codex/skills/speckit/assets/templates/notes-questions-template.md +7 -0
  5. package/.codex/skills/speckit/assets/templates/notes-readme-template.md +36 -0
  6. package/.codex/skills/speckit/assets/templates/notes-session-template.md +21 -0
  7. package/.codex/skills/speckit/assets/templates/plan-template.md +126 -0
  8. package/.codex/skills/speckit/assets/templates/spec-template.md +135 -0
  9. package/.codex/skills/speckit/assets/templates/tasks-template.md +269 -0
  10. package/.codex/skills/speckit/references/acceptance.md +183 -0
  11. package/.codex/skills/speckit/references/analyze.md +186 -0
  12. package/.codex/skills/speckit/references/checklist.md +302 -0
  13. package/.codex/skills/speckit/references/clarify-auto.md +69 -0
  14. package/.codex/skills/speckit/references/clarify-detailed.md +78 -0
  15. package/.codex/skills/speckit/references/clarify.md +189 -0
  16. package/.codex/skills/speckit/references/constitution.md +90 -0
  17. package/.codex/skills/speckit/references/group.md +89 -0
  18. package/.codex/skills/speckit/references/implement-task.md +115 -0
  19. package/.codex/skills/speckit/references/implement.md +129 -0
  20. package/.codex/skills/speckit/references/notes.md +82 -0
  21. package/.codex/skills/speckit/references/plan-deep.md +87 -0
  22. package/.codex/skills/speckit/references/plan-from-questions.md +115 -0
  23. package/.codex/skills/speckit/references/plan-from-review.md +89 -0
  24. package/.codex/skills/speckit/references/plan.md +97 -0
  25. package/.codex/skills/speckit/references/review-plan.md +156 -0
  26. package/.codex/skills/speckit/references/specify.md +246 -0
  27. package/.codex/skills/speckit/references/tasks.md +155 -0
  28. package/.codex/skills/speckit/references/taskstoissues.md +33 -0
  29. package/.codex/skills/speckit/scripts/bash/check-prerequisites.sh +206 -0
  30. package/.codex/skills/speckit/scripts/bash/common.sh +191 -0
  31. package/.codex/skills/speckit/scripts/bash/create-new-feature.sh +259 -0
  32. package/.codex/skills/speckit/scripts/bash/extract-coded-points.sh +322 -0
  33. package/.codex/skills/speckit/scripts/bash/extract-spec-ids.sh +238 -0
  34. package/.codex/skills/speckit/scripts/bash/extract-tasks.sh +295 -0
  35. package/.codex/skills/speckit/scripts/bash/extract-user-stories.sh +312 -0
  36. package/.codex/skills/speckit/scripts/bash/setup-notes.sh +182 -0
  37. package/.codex/skills/speckit/scripts/bash/setup-plan.sh +110 -0
  38. package/.codex/skills/speckit/scripts/bash/show-todo-tasks.sh +257 -0
  39. package/.codex/skills/speckit/scripts/bash/spec-group-checklist.sh +402 -0
  40. package/.codex/skills/speckit/scripts/bash/spec-group-members.sh +215 -0
  41. package/.codex/skills/speckit/scripts/bash/spec-registry-graph.sh +399 -0
  42. package/.specify/memory/constitution.md +67 -0
  43. package/.specify/templates/agent-file-template.md +28 -0
  44. package/.specify/templates/checklist-template.md +49 -0
  45. package/.specify/templates/plan-template.md +126 -0
  46. package/.specify/templates/spec-template.md +135 -0
  47. package/.specify/templates/tasks-template.md +269 -0
  48. package/README.md +128 -0
  49. package/README.zh-CN.md +127 -0
  50. package/bun.lock +269 -0
  51. package/dist/cli/commands/codex/forkHome.js +88 -0
  52. package/dist/cli/commands/codex/send.js +55 -0
  53. package/dist/cli/commands/codex/sessionInfo.js +42 -0
  54. package/dist/cli/commands/codex/spawn.js +68 -0
  55. package/dist/cli/commands/find.js +26 -0
  56. package/dist/cli/commands/paneKill.js +33 -0
  57. package/dist/cli/commands/paneSpawn.js +40 -0
  58. package/dist/cli/commands/paneTitle.js +33 -0
  59. package/dist/cli/commands/read.js +34 -0
  60. package/dist/cli/commands/send.js +51 -0
  61. package/dist/cli/commands/snapshot.js +19 -0
  62. package/dist/cli/commands/ui/select.js +41 -0
  63. package/dist/cli/commands/windowKill.js +25 -0
  64. package/dist/cli/commands/windowLs.js +15 -0
  65. package/dist/cli/commands/windowNew.js +28 -0
  66. package/dist/cli/commands/windowRename.js +25 -0
  67. package/dist/cli/index.js +365 -0
  68. package/dist/cli/parse.js +39 -0
  69. package/dist/lib/codex/forkHome.js +101 -0
  70. package/dist/lib/codex/isCodexPane.js +55 -0
  71. package/dist/lib/codex/send.js +58 -0
  72. package/dist/lib/codex/sessionInfo.js +449 -0
  73. package/dist/lib/codex/spawn.js +246 -0
  74. package/dist/lib/contracts/types.js +2 -0
  75. package/dist/lib/fs/safeRm.js +32 -0
  76. package/dist/lib/io/readStdin.js +14 -0
  77. package/dist/lib/os/process.js +55 -0
  78. package/dist/lib/output/format.js +95 -0
  79. package/dist/lib/proc/lsof.js +42 -0
  80. package/dist/lib/proc/ps.js +60 -0
  81. package/dist/lib/targeting/errors.js +13 -0
  82. package/dist/lib/targeting/resolvePaneTarget.js +91 -0
  83. package/dist/lib/targeting/resolveWindowTarget.js +40 -0
  84. package/dist/lib/targeting/scope.js +58 -0
  85. package/dist/lib/tmux/capturePane.js +20 -0
  86. package/dist/lib/tmux/exec.js +66 -0
  87. package/dist/lib/tmux/paneOps.js +29 -0
  88. package/dist/lib/tmux/paste.js +23 -0
  89. package/dist/lib/tmux/sendKeys.js +47 -0
  90. package/dist/lib/tmux/session.js +29 -0
  91. package/dist/lib/tmux/snapshotPanes.js +46 -0
  92. package/dist/lib/tmux/snapshotWindows.js +24 -0
  93. package/dist/lib/tmux/windowOps.js +32 -0
  94. package/dist/lib/ui/popupSelect.js +432 -0
  95. package/dist/lib/ui/popupSupport.js +76 -0
  96. package/package.json +23 -0
  97. package/src/cli/commands/codex/forkHome.ts +141 -0
  98. package/src/cli/commands/codex/send.ts +83 -0
  99. package/src/cli/commands/codex/sessionInfo.ts +59 -0
  100. package/src/cli/commands/codex/spawn.ts +90 -0
  101. package/src/cli/commands/find.ts +40 -0
  102. package/src/cli/commands/paneKill.ts +49 -0
  103. package/src/cli/commands/paneSpawn.ts +53 -0
  104. package/src/cli/commands/paneTitle.ts +50 -0
  105. package/src/cli/commands/read.ts +48 -0
  106. package/src/cli/commands/send.ts +71 -0
  107. package/src/cli/commands/snapshot.ts +28 -0
  108. package/src/cli/commands/ui/select.ts +49 -0
  109. package/src/cli/commands/windowKill.ts +35 -0
  110. package/src/cli/commands/windowLs.ts +20 -0
  111. package/src/cli/commands/windowNew.ts +40 -0
  112. package/src/cli/commands/windowRename.ts +36 -0
  113. package/src/cli/index.ts +430 -0
  114. package/src/lib/codex/forkHome.ts +148 -0
  115. package/src/lib/codex/isCodexPane.ts +56 -0
  116. package/src/lib/codex/send.ts +84 -0
  117. package/src/lib/codex/sessionInfo.ts +521 -0
  118. package/src/lib/codex/spawn.ts +305 -0
  119. package/src/lib/contracts/types.ts +30 -0
  120. package/src/lib/fs/safeRm.ts +32 -0
  121. package/src/lib/io/readStdin.ts +11 -0
  122. package/src/lib/output/format.ts +105 -0
  123. package/src/lib/proc/lsof.ts +44 -0
  124. package/src/lib/proc/ps.ts +70 -0
  125. package/src/lib/targeting/errors.ts +25 -0
  126. package/src/lib/targeting/resolvePaneTarget.ts +106 -0
  127. package/src/lib/targeting/resolveWindowTarget.ts +45 -0
  128. package/src/lib/targeting/scope.ts +76 -0
  129. package/src/lib/tmux/capturePane.ts +21 -0
  130. package/src/lib/tmux/exec.ts +90 -0
  131. package/src/lib/tmux/paneOps.ts +35 -0
  132. package/src/lib/tmux/paste.ts +20 -0
  133. package/src/lib/tmux/sendKeys.ts +72 -0
  134. package/src/lib/tmux/session.ts +27 -0
  135. package/src/lib/tmux/snapshotPanes.ts +52 -0
  136. package/src/lib/tmux/snapshotWindows.ts +23 -0
  137. package/src/lib/tmux/windowOps.ts +43 -0
  138. package/src/lib/ui/popupSelect.ts +561 -0
  139. package/src/lib/ui/popupSupport.ts +84 -0
  140. package/tests/e2e/codexForkHome.test.ts +146 -0
  141. package/tests/e2e/codexSessionInfo.test.ts +112 -0
  142. package/tests/e2e/codexTuiSend.test.ts +68 -0
  143. package/tests/integration/codexSpawn.test.ts +113 -0
  144. package/tests/integration/paneOps.test.ts +60 -0
  145. package/tests/integration/sendRead.test.ts +52 -0
  146. package/tests/integration/snapshot.test.ts +39 -0
  147. package/tests/integration/tmuxHarness.ts +39 -0
  148. package/tests/integration/windowOps.test.ts +60 -0
  149. package/tests/unit/codexSend.test.ts +105 -0
  150. package/tests/unit/codexSessionInfo.test.ts +88 -0
  151. package/tests/unit/codexSpawn.test.ts +34 -0
  152. package/tests/unit/keys.test.ts +30 -0
  153. package/tests/unit/outputFormat.test.ts +52 -0
  154. package/tests/unit/popupSelect.test.ts +77 -0
  155. package/tests/unit/popupSupport.test.ts +109 -0
  156. package/tests/unit/resolvePaneTarget.test.ts +43 -0
  157. package/tests/unit/resolveWindowTarget.test.ts +36 -0
  158. package/tests/unit/safeRm.test.ts +41 -0
  159. package/tests/unit/scope.test.ts +57 -0
  160. package/tsconfig.json +14 -0
  161. package/vitest.config.ts +16 -0
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env node
2
+ import { Command, CommanderError } from "commander";
3
+ import { ensureErrorPrefix, formatPaneTable, formatWindowTable } from "../lib/output/format";
4
+ import type { PaneInfo, WindowInfo } from "../lib/contracts/types";
5
+ import { TargetResolutionError } from "../lib/targeting/errors";
6
+ import { windowLs } from "./commands/windowLs";
7
+ import { snapshot } from "./commands/snapshot";
8
+ import { find } from "./commands/find";
9
+ import { send } from "./commands/send";
10
+ import { read } from "./commands/read";
11
+ import { windowNewCommand } from "./commands/windowNew";
12
+ import { windowRenameCommand } from "./commands/windowRename";
13
+ import { windowKillCommand } from "./commands/windowKill";
14
+ import { paneSpawnCommand } from "./commands/paneSpawn";
15
+ import { paneTitleCommand } from "./commands/paneTitle";
16
+ import { paneKillCommand } from "./commands/paneKill";
17
+ import { codexSendCommand } from "./commands/codex/send";
18
+ import { codexSessionInfoCommand } from "./commands/codex/sessionInfo";
19
+ import { codexForkHomeCleanupCommand, codexForkHomePrepareCommand } from "./commands/codex/forkHome";
20
+ import { codexSpawnCommand } from "./commands/codex/spawn";
21
+ import { uiSelectCommand } from "./commands/ui/select";
22
+
23
+ function writeLine(stream: NodeJS.WritableStream, text: string): void {
24
+ if (!text.endsWith("\n")) {
25
+ stream.write(`${text}\n`);
26
+ return;
27
+ }
28
+ stream.write(text);
29
+ }
30
+
31
+ function formatError(error: unknown): string {
32
+ if (error instanceof TargetResolutionError) {
33
+ const base = ensureErrorPrefix(error.message);
34
+ if (error.kind === "ambiguous" && error.candidates?.length) {
35
+ if (error.targetKind === "pane") {
36
+ return `${base}\n${formatPaneTable(error.candidates as PaneInfo[])}`;
37
+ }
38
+ return `${base}\n${formatWindowTable(error.candidates as WindowInfo[])}`;
39
+ }
40
+ return base;
41
+ }
42
+
43
+ if (error instanceof Error) {
44
+ return ensureErrorPrefix(error.message);
45
+ }
46
+
47
+ return ensureErrorPrefix(String(error));
48
+ }
49
+
50
+ function parseOptionalBoolean(value: string | boolean | undefined): boolean {
51
+ if (value === undefined) {
52
+ return true;
53
+ }
54
+ if (typeof value === "boolean") {
55
+ return value;
56
+ }
57
+ const normalized = value.trim().toLowerCase();
58
+ return normalized !== "false";
59
+ }
60
+
61
+ function parseNonNegativeNumber(optionName: string): (value: string) => number {
62
+ return (value: string) => {
63
+ const num = Number(value);
64
+ if (!Number.isFinite(num) || num < 0) {
65
+ throw new Error(`invalid --${optionName}`);
66
+ }
67
+ return num;
68
+ };
69
+ }
70
+
71
+ function buildProgram(): Command {
72
+ const program = new Command();
73
+ program
74
+ .name("agent-tmux")
75
+ .description("LLM-friendly tmux control plane CLI for windows/panes.")
76
+ .exitOverride()
77
+ .configureOutput({
78
+ writeErr: () => undefined
79
+ });
80
+
81
+ const window = program.command("window");
82
+ window.command("ls")
83
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
84
+ .option("--session <session>", "tmux session name")
85
+ .action(async (options: { json: boolean; session?: string }) => {
86
+ const output = await windowLs({ json: options.json, session: options.session });
87
+ if (output) {
88
+ writeLine(process.stdout, output);
89
+ }
90
+ });
91
+
92
+ window.command("new")
93
+ .argument("<name>")
94
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
95
+ .option("--session <session>", "tmux session name")
96
+ .option("--command <command>", "initial command to run (or '-' to read from stdin)")
97
+ .action(async (name: string, options: { json: boolean; session?: string; command?: string }) => {
98
+ const output = await windowNewCommand(name, {
99
+ json: options.json,
100
+ session: options.session,
101
+ command: options.command
102
+ });
103
+ if (output) {
104
+ writeLine(process.stdout, output);
105
+ }
106
+ });
107
+
108
+ window.command("rename")
109
+ .argument("<windowTarget>")
110
+ .argument("<name>")
111
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
112
+ .option("--session <session>", "tmux session name")
113
+ .action(async (target: string, name: string, options: { json: boolean; session?: string }) => {
114
+ const output = await windowRenameCommand(target, name, {
115
+ json: options.json,
116
+ session: options.session
117
+ });
118
+ if (output) {
119
+ writeLine(process.stdout, output);
120
+ }
121
+ });
122
+
123
+ window.command("kill")
124
+ .argument("<windowTarget>")
125
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
126
+ .option("--session <session>", "tmux session name")
127
+ .action(async (target: string, options: { json: boolean; session?: string }) => {
128
+ const output = await windowKillCommand(target, {
129
+ json: options.json,
130
+ session: options.session
131
+ });
132
+ if (output) {
133
+ writeLine(process.stdout, output);
134
+ }
135
+ });
136
+
137
+ const pane = program.command("pane");
138
+ pane.command("spawn")
139
+ .argument("<command>")
140
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
141
+ .option("--session <session>", "tmux session name")
142
+ .option("--window <window>", "tmux window target")
143
+ .option("--title <title>", "pane title")
144
+ .action(async (command: string, options: { json: boolean; session?: string; window?: string; title?: string }) => {
145
+ const output = await paneSpawnCommand(command, {
146
+ json: options.json,
147
+ session: options.session,
148
+ window: options.window,
149
+ title: options.title
150
+ });
151
+ if (output) {
152
+ writeLine(process.stdout, output);
153
+ }
154
+ });
155
+
156
+ pane.command("title")
157
+ .argument("<paneTarget>")
158
+ .argument("<title>")
159
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
160
+ .option("--session <session>", "tmux session name")
161
+ .option("--window <window>", "tmux window target")
162
+ .action(async (target: string, title: string, options: { json: boolean; session?: string; window?: string }) => {
163
+ const output = await paneTitleCommand(target, title, {
164
+ json: options.json,
165
+ session: options.session,
166
+ window: options.window
167
+ });
168
+ if (output) {
169
+ writeLine(process.stdout, output);
170
+ }
171
+ });
172
+
173
+ pane.command("kill")
174
+ .argument("<paneTarget>")
175
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
176
+ .option("--session <session>", "tmux session name")
177
+ .option("--window <window>", "tmux window target")
178
+ .action(async (target: string, options: { json: boolean; session?: string; window?: string }) => {
179
+ const output = await paneKillCommand(target, {
180
+ json: options.json,
181
+ session: options.session,
182
+ window: options.window
183
+ });
184
+ if (output) {
185
+ writeLine(process.stdout, output);
186
+ }
187
+ });
188
+
189
+ program.command("snapshot")
190
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
191
+ .option("--session <session>", "tmux session name")
192
+ .option("--window <window>", "tmux window target")
193
+ .action(async (options: { json: boolean; session?: string; window?: string }) => {
194
+ const output = await snapshot({
195
+ json: options.json,
196
+ session: options.session,
197
+ window: options.window
198
+ });
199
+ if (output) {
200
+ writeLine(process.stdout, output);
201
+ }
202
+ });
203
+
204
+ program.command("find")
205
+ .argument("<query>")
206
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
207
+ .option("--session <session>", "tmux session name")
208
+ .option("--window <window>", "tmux window target")
209
+ .action(async (query: string, options: { json: boolean; session?: string; window?: string }) => {
210
+ const output = await find(query, {
211
+ json: options.json,
212
+ session: options.session,
213
+ window: options.window
214
+ });
215
+ if (output) {
216
+ writeLine(process.stdout, output);
217
+ }
218
+ });
219
+
220
+ program.command("send")
221
+ .argument("<paneTarget>")
222
+ .argument("<text>")
223
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
224
+ .option("--session <session>", "tmux session name")
225
+ .option("--window <window>", "tmux window target")
226
+ .option("--no-enter [boolean]", "do not submit Enter", parseOptionalBoolean, false)
227
+ .option("--enter-delay-ms <ms>", "delay before submitting Enter", parseNonNegativeNumber("enter-delay-ms"))
228
+ .action(async (
229
+ target: string,
230
+ text: string,
231
+ options: {
232
+ json: boolean;
233
+ session?: string;
234
+ window?: string;
235
+ noEnter: boolean;
236
+ enterDelayMs?: number;
237
+ }
238
+ ) => {
239
+ const output = await send(target, text, {
240
+ json: options.json,
241
+ session: options.session,
242
+ window: options.window,
243
+ noEnter: options.noEnter,
244
+ enterDelayMs: options.enterDelayMs
245
+ });
246
+ if (output) {
247
+ writeLine(process.stdout, output);
248
+ }
249
+ });
250
+
251
+ program.command("read")
252
+ .argument("<paneTarget>")
253
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
254
+ .option("--session <session>", "tmux session name")
255
+ .option("--window <window>", "tmux window target")
256
+ .option("--lines <n>", "lines to capture", parseNonNegativeNumber("lines"))
257
+ .action(async (
258
+ target: string,
259
+ options: { json: boolean; session?: string; window?: string; lines?: number }
260
+ ) => {
261
+ const output = await read(target, {
262
+ json: options.json,
263
+ session: options.session,
264
+ window: options.window,
265
+ lines: options.lines
266
+ });
267
+ if (output) {
268
+ writeLine(process.stdout, output);
269
+ }
270
+ });
271
+
272
+ const codex = program.command("codex");
273
+ codex.command("send")
274
+ .argument("<paneTarget>")
275
+ .argument("<text>")
276
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
277
+ .option("--session <session>", "tmux session name")
278
+ .option("--window <window>", "tmux window target")
279
+ .option("--submit <key>", "submit key (default Enter; use 'none' to disable)")
280
+ .option("--submit-delay-ms <ms>", "delay before submitting", parseNonNegativeNumber("submit-delay-ms"))
281
+ .option("--post-delay-ms <ms>", "delay after submit", parseNonNegativeNumber("post-delay-ms"))
282
+ .option("--capture-tail <n>", "capture tail lines (prints captured tail when not --json)", parseNonNegativeNumber("capture-tail"))
283
+ .action(async (
284
+ target: string,
285
+ text: string,
286
+ options: {
287
+ json: boolean;
288
+ session?: string;
289
+ window?: string;
290
+ submit?: string;
291
+ submitDelayMs?: number;
292
+ postDelayMs?: number;
293
+ captureTail?: number;
294
+ }
295
+ ) => {
296
+ const output = await codexSendCommand(target, text, {
297
+ json: options.json,
298
+ session: options.session,
299
+ window: options.window,
300
+ submit: options.submit,
301
+ submitDelayMs: options.submitDelayMs,
302
+ postDelayMs: options.postDelayMs,
303
+ captureTail: options.captureTail
304
+ });
305
+ if (output) {
306
+ writeLine(process.stdout, output);
307
+ }
308
+ });
309
+
310
+ codex.command("session-info")
311
+ .argument("<paneTarget>")
312
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
313
+ .option("--session <session>", "tmux session name")
314
+ .option("--window <window>", "tmux window target")
315
+ .action(async (target: string, options: { json: boolean; session?: string; window?: string }) => {
316
+ const output = await codexSessionInfoCommand(target, {
317
+ json: options.json,
318
+ session: options.session,
319
+ window: options.window
320
+ });
321
+ if (output) {
322
+ writeLine(process.stdout, output);
323
+ }
324
+ });
325
+
326
+ const forkHome = codex.command("fork-home");
327
+ forkHome.command("prepare")
328
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
329
+ .option("--parent-rollout <path>", "parent rollout path")
330
+ .option("--fork-home <path>", "fork CODEX_HOME path")
331
+ .option("--parent-codex-home <path>", "parent CODEX_HOME override")
332
+ .option("--run-id <id>", "run id for naming")
333
+ .option("--copy-config [boolean]", "copy config.toml", parseOptionalBoolean)
334
+ .option("--spec <spec>", "read json spec from stdin (use '-')")
335
+ .action(async (options: {
336
+ json: boolean;
337
+ parentRollout?: string;
338
+ forkHome?: string;
339
+ parentCodexHome?: string;
340
+ runId?: string;
341
+ copyConfig?: boolean;
342
+ spec?: string;
343
+ }) => {
344
+ const output = await codexForkHomePrepareCommand({
345
+ json: options.json,
346
+ parentRollout: options.parentRollout,
347
+ forkHome: options.forkHome,
348
+ parentCodexHome: options.parentCodexHome,
349
+ runId: options.runId,
350
+ copyConfig: options.copyConfig,
351
+ spec: options.spec
352
+ });
353
+ if (output) {
354
+ writeLine(process.stdout, output);
355
+ }
356
+ });
357
+
358
+ forkHome.command("cleanup")
359
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
360
+ .option("--path <path>", "path to remove")
361
+ .option("--allowed-root <root>", "allowed root guard")
362
+ .option("--spec <spec>", "read json spec from stdin (use '-')")
363
+ .action(async (options: { json: boolean; path?: string; allowedRoot?: string; spec?: string }) => {
364
+ const output = await codexForkHomeCleanupCommand({
365
+ json: options.json,
366
+ path: options.path,
367
+ allowedRoot: options.allowedRoot,
368
+ spec: options.spec
369
+ });
370
+ if (output) {
371
+ writeLine(process.stdout, output);
372
+ }
373
+ });
374
+
375
+ codex.command("spawn")
376
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
377
+ .option("--origin <paneTarget>", "origin pane target")
378
+ .option("--session <session>", "tmux session name")
379
+ .option("--window <window>", "tmux window target")
380
+ .option("--force-simple-split [boolean]", "force fallback split", parseOptionalBoolean, false)
381
+ .action(async (options: {
382
+ json: boolean;
383
+ origin?: string;
384
+ session?: string;
385
+ window?: string;
386
+ forceSimpleSplit: boolean;
387
+ }) => {
388
+ const output = await codexSpawnCommand({
389
+ json: options.json,
390
+ origin: options.origin,
391
+ session: options.session,
392
+ window: options.window,
393
+ forceSimpleSplit: options.forceSimpleSplit
394
+ });
395
+ if (output) {
396
+ writeLine(process.stdout, output);
397
+ }
398
+ });
399
+
400
+ const ui = program.command("ui");
401
+ ui.command("select")
402
+ .requiredOption("--spec <spec>", "read json spec from stdin (use '-')")
403
+ .option("--json [boolean]", "output json", parseOptionalBoolean, false)
404
+ .action(async (options: { json: boolean; spec: string }) => {
405
+ const output = await uiSelectCommand({ json: options.json, spec: options.spec });
406
+ if (output) {
407
+ writeLine(process.stdout, output);
408
+ }
409
+ });
410
+
411
+ return program;
412
+ }
413
+
414
+ async function run(): Promise<void> {
415
+ const argv = process.argv.slice(2);
416
+ if (argv.length === 0) {
417
+ throw new Error("missing command");
418
+ }
419
+ const program = buildProgram();
420
+ await program.parseAsync(process.argv);
421
+ }
422
+
423
+ run().catch((error) => {
424
+ if (error instanceof CommanderError && error.exitCode === 0) {
425
+ return;
426
+ }
427
+ const message = formatError(error);
428
+ writeLine(process.stderr, message);
429
+ process.exitCode = 1;
430
+ });
@@ -0,0 +1,148 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { safeRemove } from "../fs/safeRm";
4
+
5
+ export type ForkHomePrepareOptions = {
6
+ parentCodexHome?: string;
7
+ parentRolloutPath: string;
8
+ forkHome: string;
9
+ runId?: string;
10
+ copyConfig?: boolean;
11
+ };
12
+
13
+ export type ForkHomePrepareResult = {
14
+ action: "prepare";
15
+ parent_rollout_path: string;
16
+ fork_home: string;
17
+ fork_rollout_path: string;
18
+ copied_config: boolean;
19
+ auth_strategy: "symlink" | "copy" | "missing" | "failed";
20
+ credentials_strategy: "symlink" | "copy" | "missing" | "failed";
21
+ };
22
+
23
+ export type ForkHomeCleanupResult = {
24
+ action: "cleanup";
25
+ removed: boolean;
26
+ };
27
+
28
+ function resolvePath(value: string): string {
29
+ return path.resolve(value);
30
+ }
31
+
32
+ function defaultCodexHome(): string {
33
+ const env = process.env.CODEX_HOME?.trim();
34
+ if (env) {
35
+ return resolvePath(env);
36
+ }
37
+ return resolvePath(path.join(process.env.HOME || "", ".codex"));
38
+ }
39
+
40
+ async function copyOrSymlink(
41
+ src: string,
42
+ dst: string
43
+ ): Promise<"symlink" | "copy" | "missing" | "failed"> {
44
+ try {
45
+ await fs.stat(src);
46
+ } catch {
47
+ return "missing";
48
+ }
49
+
50
+ try {
51
+ await fs.mkdir(path.dirname(dst), { recursive: true });
52
+ try {
53
+ await fs.unlink(dst);
54
+ } catch {
55
+ // ignore
56
+ }
57
+ await fs.symlink(src, dst);
58
+ return "symlink";
59
+ } catch {
60
+ try {
61
+ await fs.copyFile(src, dst);
62
+ return "copy";
63
+ } catch {
64
+ return "failed";
65
+ }
66
+ }
67
+ }
68
+
69
+ function resolveForkRolloutPath(
70
+ parentCodexHome: string,
71
+ parentRolloutPath: string,
72
+ forkHome: string,
73
+ runId: string
74
+ ): string {
75
+ try {
76
+ const relative = path.relative(parentCodexHome, parentRolloutPath);
77
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
78
+ return path.join(forkHome, relative);
79
+ }
80
+ } catch {
81
+ // ignore
82
+ }
83
+ return path.join(forkHome, "sessions", `rollout-${runId}.jsonl`);
84
+ }
85
+
86
+ export async function prepareForkHome(
87
+ options: ForkHomePrepareOptions
88
+ ): Promise<ForkHomePrepareResult> {
89
+ const parentCodexHome = resolvePath(options.parentCodexHome || defaultCodexHome());
90
+ const parentRolloutPath = resolvePath(options.parentRolloutPath);
91
+ const forkHome = resolvePath(options.forkHome);
92
+ const runId = options.runId?.trim() || String(process.pid);
93
+ const copyConfig = options.copyConfig ?? true;
94
+
95
+ const stat = await fs.stat(parentRolloutPath);
96
+ if (!stat.isFile()) {
97
+ throw new Error(`parent_rollout_path is not a file: ${parentRolloutPath}`);
98
+ }
99
+
100
+ await fs.mkdir(forkHome, { recursive: true });
101
+
102
+ let copiedConfig = false;
103
+ if (copyConfig) {
104
+ const configPath = path.join(parentCodexHome, "config.toml");
105
+ try {
106
+ await fs.copyFile(configPath, path.join(forkHome, "config.toml"));
107
+ copiedConfig = true;
108
+ } catch {
109
+ copiedConfig = false;
110
+ }
111
+ }
112
+
113
+ const authStrategy = await copyOrSymlink(
114
+ path.join(parentCodexHome, "auth.json"),
115
+ path.join(forkHome, "auth.json")
116
+ );
117
+ const credentialsStrategy = await copyOrSymlink(
118
+ path.join(parentCodexHome, ".credentials.json"),
119
+ path.join(forkHome, ".credentials.json")
120
+ );
121
+
122
+ const forkRolloutPath = resolveForkRolloutPath(
123
+ parentCodexHome,
124
+ parentRolloutPath,
125
+ forkHome,
126
+ runId
127
+ );
128
+ await fs.mkdir(path.dirname(forkRolloutPath), { recursive: true });
129
+ await fs.copyFile(parentRolloutPath, forkRolloutPath);
130
+
131
+ return {
132
+ action: "prepare",
133
+ parent_rollout_path: parentRolloutPath,
134
+ fork_home: forkHome,
135
+ fork_rollout_path: forkRolloutPath,
136
+ copied_config: copiedConfig,
137
+ auth_strategy: authStrategy,
138
+ credentials_strategy: credentialsStrategy
139
+ };
140
+ }
141
+
142
+ export async function cleanupForkHome(
143
+ pathToRemove: string,
144
+ allowedRoot: string
145
+ ): Promise<ForkHomeCleanupResult> {
146
+ const removed = await safeRemove(pathToRemove, allowedRoot);
147
+ return { action: "cleanup", removed };
148
+ }
@@ -0,0 +1,56 @@
1
+ import type { PaneInfo } from "../contracts/types";
2
+ import { listProcesses, type ProcessEntry } from "../proc/ps";
3
+
4
+ const CODEX_PATTERN = /codex/i;
5
+
6
+ function matchesCodex(value: string): boolean {
7
+ return CODEX_PATTERN.test(value);
8
+ }
9
+
10
+ function hasCodexDescendant(processes: ProcessEntry[], rootPid: number): boolean {
11
+ const byParent = new Map<number, ProcessEntry[]>();
12
+ for (const process of processes) {
13
+ const list = byParent.get(process.ppid);
14
+ if (list) {
15
+ list.push(process);
16
+ } else {
17
+ byParent.set(process.ppid, [process]);
18
+ }
19
+ }
20
+
21
+ const queue: number[] = [rootPid];
22
+ const visited = new Set<number>();
23
+ while (queue.length) {
24
+ const current = queue.shift();
25
+ if (current === undefined || visited.has(current)) {
26
+ continue;
27
+ }
28
+ visited.add(current);
29
+ const children = byParent.get(current);
30
+ if (!children) {
31
+ continue;
32
+ }
33
+ for (const child of children) {
34
+ if (matchesCodex(child.command)) {
35
+ return true;
36
+ }
37
+ queue.push(child.pid);
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+
43
+ export async function isCodexPane(pane: PaneInfo): Promise<boolean> {
44
+ if (matchesCodex(pane.command) || matchesCodex(pane.title)) {
45
+ return true;
46
+ }
47
+ if (!pane.pid || !Number.isFinite(pane.pid)) {
48
+ return false;
49
+ }
50
+ try {
51
+ const processes = await listProcesses();
52
+ return hasCodexDescendant(processes, pane.pid);
53
+ } catch {
54
+ return false;
55
+ }
56
+ }