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,305 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { tmuxExec } from "../tmux/exec";
5
+
6
+ export type CodexSpawnMeta = {
7
+ window_id: string;
8
+ action: "add_column" | "add_row" | "simple_split";
9
+ column?: number;
10
+ total_panes: number;
11
+ source?: string;
12
+ };
13
+
14
+ export type CodexSpawnResult = {
15
+ createdPaneId: string;
16
+ meta: CodexSpawnMeta;
17
+ };
18
+
19
+ type PaneLayoutRow = {
20
+ paneId: string;
21
+ left: number;
22
+ top: number;
23
+ path: string;
24
+ };
25
+
26
+ type Column = {
27
+ left: number;
28
+ count: number;
29
+ topPaneId: string;
30
+ topPath: string;
31
+ topTop: number;
32
+ bottomPaneId: string;
33
+ bottomPath: string;
34
+ bottomTop: number;
35
+ };
36
+
37
+ function parseNumber(value: string): number {
38
+ const num = Number(value);
39
+ return Number.isFinite(num) ? num : 0;
40
+ }
41
+
42
+ async function getWindowId(paneId: string): Promise<string> {
43
+ const result = await tmuxExec(["display-message", "-p", "-t", paneId, "#{window_id}"]);
44
+ const windowId = result.stdout.trim();
45
+ if (!windowId) {
46
+ throw new Error(`unable to resolve window_id from pane ${paneId}`);
47
+ }
48
+ return windowId;
49
+ }
50
+
51
+ async function getPaneCurrentPath(paneId: string): Promise<string> {
52
+ const result = await tmuxExec([
53
+ "display-message",
54
+ "-p",
55
+ "-t",
56
+ paneId,
57
+ "#{pane_current_path}"
58
+ ]);
59
+ return result.stdout.trim() || process.cwd();
60
+ }
61
+
62
+ async function listPaneLayout(windowId: string): Promise<PaneLayoutRow[]> {
63
+ const result = await tmuxExec([
64
+ "list-panes",
65
+ "-t",
66
+ windowId,
67
+ "-F",
68
+ "#{pane_id}|#{pane_left}|#{pane_top}|#{pane_current_path}"
69
+ ]);
70
+ const rows: PaneLayoutRow[] = [];
71
+ for (const line of result.stdout.split(/\r?\n/)) {
72
+ const trimmed = line.trim();
73
+ if (!trimmed) {
74
+ continue;
75
+ }
76
+ const [paneId, leftRaw, topRaw, panePath] = trimmed.split("|");
77
+ if (!paneId) {
78
+ continue;
79
+ }
80
+ rows.push({
81
+ paneId,
82
+ left: parseNumber(leftRaw),
83
+ top: parseNumber(topRaw),
84
+ path: (panePath ?? "").trim()
85
+ });
86
+ }
87
+ rows.sort((a, b) => (a.left !== b.left ? a.left - b.left : a.top - b.top));
88
+ return rows;
89
+ }
90
+
91
+ function buildColumns(rows: PaneLayoutRow[]): Column[] {
92
+ const columns: Column[] = [];
93
+ const byLeft = new Map<number, Column>();
94
+
95
+ for (const row of rows) {
96
+ let col = byLeft.get(row.left);
97
+ if (!col) {
98
+ col = {
99
+ left: row.left,
100
+ count: 0,
101
+ topPaneId: row.paneId,
102
+ topPath: row.path,
103
+ topTop: row.top,
104
+ bottomPaneId: row.paneId,
105
+ bottomPath: row.path,
106
+ bottomTop: row.top
107
+ };
108
+ byLeft.set(row.left, col);
109
+ columns.push(col);
110
+ }
111
+
112
+ col.count += 1;
113
+ if (row.top < col.topTop) {
114
+ col.topTop = row.top;
115
+ col.topPaneId = row.paneId;
116
+ col.topPath = row.path;
117
+ }
118
+ if (row.top > col.bottomTop) {
119
+ col.bottomTop = row.top;
120
+ col.bottomPaneId = row.paneId;
121
+ col.bottomPath = row.path;
122
+ }
123
+ }
124
+
125
+ columns.sort((a, b) => a.left - b.left);
126
+ return columns;
127
+ }
128
+
129
+ function resolveReadyFile(): string | null {
130
+ const dir = (process.env.CODEX_PANE_READY_DIR || process.env.TMPDIR || os.tmpdir()).trim();
131
+ if (!dir) {
132
+ return null;
133
+ }
134
+ const safeDir = dir.replace(/\/+$/, "");
135
+ const name = `codex_pane_ready_${process.pid}_${Date.now()}_${Math.random()
136
+ .toString(16)
137
+ .slice(2)}`;
138
+ return path.join(safeDir, name);
139
+ }
140
+
141
+ async function waitForReadyFile(filePath: string): Promise<void> {
142
+ const maxTries = parseNumber(process.env.CODEX_PANE_READY_MAX_TRIES || "120");
143
+ const sleepMs = parseNumber(process.env.CODEX_PANE_READY_SLEEP_MS || "50");
144
+
145
+ for (let i = 0; i < Math.max(1, maxTries); i += 1) {
146
+ try {
147
+ await fs.stat(filePath);
148
+ try {
149
+ await fs.rm(filePath, { force: true });
150
+ } catch {
151
+ // ignore
152
+ }
153
+ return;
154
+ } catch {
155
+ // keep waiting
156
+ }
157
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, sleepMs)));
158
+ }
159
+ }
160
+
161
+ async function splitWindow(
162
+ args: string[],
163
+ readyFile: string | null
164
+ ): Promise<{ paneId: string; readyUsed: boolean }> {
165
+ if (readyFile) {
166
+ try {
167
+ const result = await tmuxExec([
168
+ ...args,
169
+ "-e",
170
+ `CODEX_PANE_READY_FILE=${readyFile}`
171
+ ]);
172
+ return { paneId: result.stdout.trim(), readyUsed: true };
173
+ } catch {
174
+ // fall through
175
+ }
176
+ }
177
+ const result = await tmuxExec(args);
178
+ return { paneId: result.stdout.trim(), readyUsed: false };
179
+ }
180
+
181
+ export async function codexSpawn(
182
+ originPaneId: string,
183
+ options: { forceSimpleSplit?: boolean } = {}
184
+ ): Promise<CodexSpawnResult> {
185
+ const windowId = await getWindowId(originPaneId);
186
+ const layout = await listPaneLayout(windowId);
187
+ const totalPanes = layout.length;
188
+
189
+ const readyFile = resolveReadyFile();
190
+
191
+ if (options.forceSimpleSplit) {
192
+ const originPath = await getPaneCurrentPath(originPaneId);
193
+ const { paneId, readyUsed } = await splitWindow(
194
+ ["split-window", "-d", "-P", "-F", "#{pane_id}", "-t", originPaneId, "-c", originPath],
195
+ readyFile
196
+ );
197
+ if (!paneId) {
198
+ throw new Error("failed to create new pane");
199
+ }
200
+ if (readyUsed && readyFile) {
201
+ await waitForReadyFile(readyFile);
202
+ }
203
+ return {
204
+ createdPaneId: paneId,
205
+ meta: {
206
+ window_id: windowId,
207
+ action: "simple_split",
208
+ total_panes: totalPanes + 1,
209
+ source: "force-simple-split"
210
+ }
211
+ };
212
+ }
213
+
214
+ const columns = buildColumns(layout);
215
+ const colCount = columns.length;
216
+ if (colCount === 0) {
217
+ throw new Error(`no panes detected in window ${windowId}`);
218
+ }
219
+ if (colCount > 3) {
220
+ throw new Error("window has more than 3 columns; refusing to add pane");
221
+ }
222
+ if (columns[0].count !== 1) {
223
+ throw new Error(
224
+ `leftmost column has ${columns[0].count} panes; expected 1`
225
+ );
226
+ }
227
+ if (totalPanes >= 7) {
228
+ throw new Error("window already at max panes (1+3+3)");
229
+ }
230
+
231
+ if (colCount < 3) {
232
+ if (colCount === 2 && columns[1].count !== 1) {
233
+ throw new Error("right column already split; refusing to add a third column");
234
+ }
235
+ const target = columns[colCount - 1];
236
+ const { paneId, readyUsed } = await splitWindow(
237
+ [
238
+ "split-window",
239
+ "-h",
240
+ "-d",
241
+ "-P",
242
+ "-F",
243
+ "#{pane_id}",
244
+ "-t",
245
+ target.topPaneId,
246
+ "-c",
247
+ target.topPath
248
+ ],
249
+ readyFile
250
+ );
251
+ if (!paneId) {
252
+ throw new Error("failed to create new pane");
253
+ }
254
+ if (readyUsed && readyFile) {
255
+ await waitForReadyFile(readyFile);
256
+ }
257
+ return {
258
+ createdPaneId: paneId,
259
+ meta: {
260
+ window_id: windowId,
261
+ action: "add_column",
262
+ column: colCount + 1,
263
+ total_panes: totalPanes + 1
264
+ }
265
+ };
266
+ }
267
+
268
+ if (columns[1].count >= 3 && columns[2].count >= 3) {
269
+ throw new Error("columns 2 and 3 are full; refusing to add pane");
270
+ }
271
+
272
+ const targetColumnIndex = columns[1].count < 3 ? 1 : 2;
273
+ const targetColumn = columns[targetColumnIndex];
274
+ const { paneId, readyUsed } = await splitWindow(
275
+ [
276
+ "split-window",
277
+ "-v",
278
+ "-d",
279
+ "-P",
280
+ "-F",
281
+ "#{pane_id}",
282
+ "-t",
283
+ targetColumn.bottomPaneId,
284
+ "-c",
285
+ targetColumn.bottomPath
286
+ ],
287
+ readyFile
288
+ );
289
+ if (!paneId) {
290
+ throw new Error("failed to create new pane");
291
+ }
292
+ if (readyUsed && readyFile) {
293
+ await waitForReadyFile(readyFile);
294
+ }
295
+ return {
296
+ createdPaneId: paneId,
297
+ meta: {
298
+ window_id: windowId,
299
+ action: "add_row",
300
+ column: targetColumnIndex + 1,
301
+ total_panes: totalPanes + 1
302
+ }
303
+ };
304
+ }
305
+
@@ -0,0 +1,30 @@
1
+ export type WindowStatus = "active" | "inactive";
2
+ export type PaneStatus = "active" | "idle" | "dead";
3
+
4
+ export type WindowInfo = {
5
+ widx: number;
6
+ wid: string;
7
+ status: WindowStatus;
8
+ name: string;
9
+ };
10
+
11
+ export type PaneInfo = {
12
+ idx: number;
13
+ id: string;
14
+ pid?: number;
15
+ status: PaneStatus;
16
+ command: string;
17
+ title: string;
18
+ windowId?: string;
19
+ };
20
+
21
+ export type ResolvedWindow = {
22
+ widx: number;
23
+ wid: string;
24
+ name: string;
25
+ };
26
+
27
+ export type ResolvedPane = {
28
+ idx?: number;
29
+ id: string;
30
+ };
@@ -0,0 +1,32 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ function resolvePath(input: string): string {
5
+ return path.resolve(input);
6
+ }
7
+
8
+ export async function safeRemove(
9
+ targetPath: string,
10
+ allowedRoot: string
11
+ ): Promise<boolean> {
12
+ const resolvedTarget = resolvePath(targetPath);
13
+ const resolvedRoot = resolvePath(allowedRoot);
14
+
15
+ const relative = path.relative(resolvedRoot, resolvedTarget);
16
+ if (!relative || relative === "" || relative === ".") {
17
+ throw new Error("refuse to remove allowed root");
18
+ }
19
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
20
+ throw new Error("path is outside allowed_root");
21
+ }
22
+
23
+ try {
24
+ await fs.rm(resolvedTarget, { recursive: true, force: true });
25
+ return true;
26
+ } catch (error: unknown) {
27
+ if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") {
28
+ return false;
29
+ }
30
+ throw error;
31
+ }
32
+ }
@@ -0,0 +1,11 @@
1
+ export async function readStdin(): Promise<string> {
2
+ return await new Promise((resolve, reject) => {
3
+ let data = "";
4
+ process.stdin.setEncoding("utf8");
5
+ process.stdin.on("data", (chunk) => {
6
+ data += chunk;
7
+ });
8
+ process.stdin.on("end", () => resolve(data));
9
+ process.stdin.on("error", (error) => reject(error));
10
+ });
11
+ }
@@ -0,0 +1,105 @@
1
+ import type { PaneInfo, WindowInfo } from "../contracts/types";
2
+
3
+ const TITLE_SEPARATOR = " \u00b7 ";
4
+
5
+ type TableColumn = {
6
+ header: string;
7
+ values: string[];
8
+ };
9
+
10
+ function buildTable(columns: TableColumn[]): string {
11
+ const widths = columns.map((col) =>
12
+ Math.max(col.header.length, ...col.values.map((value) => value.length))
13
+ );
14
+ const header = columns
15
+ .map((col, index) => col.header.padEnd(widths[index]))
16
+ .join(" ");
17
+ const rows = columns[0].values.map((_, rowIndex) =>
18
+ columns
19
+ .map((col, colIndex) => col.values[rowIndex].padEnd(widths[colIndex]))
20
+ .join(" ")
21
+ );
22
+ return [header, ...rows].join("\n");
23
+ }
24
+
25
+ export function formatWindowStatus(status: WindowInfo["status"]): string {
26
+ return status === "active" ? "[Active]" : "[Idle]";
27
+ }
28
+
29
+ export function formatPaneStatus(status: PaneInfo["status"]): string {
30
+ if (status === "active") {
31
+ return "[Active]";
32
+ }
33
+ if (status === "dead") {
34
+ return "[Dead]";
35
+ }
36
+ return "[Idle]";
37
+ }
38
+
39
+ export function formatCommandTitle(command: string, title: string): string {
40
+ if (title) {
41
+ if (!command) {
42
+ return title;
43
+ }
44
+ return `${command}${TITLE_SEPARATOR}${title}`;
45
+ }
46
+ return command;
47
+ }
48
+
49
+ export function formatWindowTable(windows: WindowInfo[]): string {
50
+ return buildTable([
51
+ {
52
+ header: "WIDX",
53
+ values: windows.map((window) => String(window.widx))
54
+ },
55
+ {
56
+ header: "WID",
57
+ values: windows.map((window) => window.wid)
58
+ },
59
+ {
60
+ header: "STATUS",
61
+ values: windows.map((window) => formatWindowStatus(window.status))
62
+ },
63
+ {
64
+ header: "NAME",
65
+ values: windows.map((window) => window.name)
66
+ }
67
+ ]);
68
+ }
69
+
70
+ export function formatPaneTable(panes: PaneInfo[]): string {
71
+ return buildTable([
72
+ {
73
+ header: "IDX",
74
+ values: panes.map((pane) => String(pane.idx))
75
+ },
76
+ {
77
+ header: "ID",
78
+ values: panes.map((pane) => pane.id)
79
+ },
80
+ {
81
+ header: "STATUS",
82
+ values: panes.map((pane) => formatPaneStatus(pane.status))
83
+ },
84
+ {
85
+ header: "COMMAND/TITLE",
86
+ values: panes.map((pane) => formatCommandTitle(pane.command, pane.title))
87
+ }
88
+ ]);
89
+ }
90
+
91
+ export function formatJson(data: unknown): string {
92
+ return JSON.stringify(data);
93
+ }
94
+
95
+ export function ensureErrorPrefix(message: string): string {
96
+ const trimmed = message.trim();
97
+ if (!trimmed) {
98
+ return "Error: unknown error";
99
+ }
100
+ if (trimmed.toLowerCase().startsWith("error:")) {
101
+ const rest = trimmed.slice(trimmed.indexOf(":") + 1);
102
+ return `Error:${rest}`;
103
+ }
104
+ return `Error: ${trimmed}`;
105
+ }
@@ -0,0 +1,44 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export async function listOpenFiles(pid: number): Promise<string[]> {
4
+ return await new Promise<string[]>((resolve, reject) => {
5
+ const child = spawn("lsof", ["-p", String(pid), "-Fn"]);
6
+ let stdout = "";
7
+ let stderr = "";
8
+
9
+ if (child.stdout) {
10
+ child.stdout.on("data", (data) => {
11
+ stdout += data.toString();
12
+ });
13
+ }
14
+
15
+ if (child.stderr) {
16
+ child.stderr.on("data", (data) => {
17
+ stderr += data.toString();
18
+ });
19
+ }
20
+
21
+ child.on("error", (error) => {
22
+ reject(error);
23
+ });
24
+
25
+ child.on("close", (code) => {
26
+ const exitCode = code ?? 0;
27
+ if (exitCode !== 0) {
28
+ reject(new Error(stderr.trim() || stdout.trim() || `lsof exited with ${exitCode}`));
29
+ return;
30
+ }
31
+ const paths: string[] = [];
32
+ for (const line of stdout.split(/\r?\n/)) {
33
+ if (!line.startsWith("n")) {
34
+ continue;
35
+ }
36
+ const path = line.slice(1);
37
+ if (path) {
38
+ paths.push(path);
39
+ }
40
+ }
41
+ resolve(paths);
42
+ });
43
+ });
44
+ }
@@ -0,0 +1,70 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export type ProcessEntry = {
4
+ pid: number;
5
+ ppid: number;
6
+ tty: string;
7
+ command: string;
8
+ };
9
+
10
+ function parseLine(line: string): ProcessEntry | null {
11
+ const trimmed = line.trim();
12
+ if (!trimmed) {
13
+ return null;
14
+ }
15
+ const match = trimmed.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
16
+ if (!match) {
17
+ return null;
18
+ }
19
+ const pid = Number(match[1]);
20
+ const ppid = Number(match[2]);
21
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid)) {
22
+ return null;
23
+ }
24
+ return {
25
+ pid,
26
+ ppid,
27
+ tty: match[3],
28
+ command: match[4]
29
+ };
30
+ }
31
+
32
+ export async function listProcesses(): Promise<ProcessEntry[]> {
33
+ return await new Promise<ProcessEntry[]>((resolve, reject) => {
34
+ const child = spawn("ps", ["-axo", "pid=,ppid=,tty=,command=", "-ww"]);
35
+ let stdout = "";
36
+ let stderr = "";
37
+
38
+ if (child.stdout) {
39
+ child.stdout.on("data", (data) => {
40
+ stdout += data.toString();
41
+ });
42
+ }
43
+
44
+ if (child.stderr) {
45
+ child.stderr.on("data", (data) => {
46
+ stderr += data.toString();
47
+ });
48
+ }
49
+
50
+ child.on("error", (error) => {
51
+ reject(error);
52
+ });
53
+
54
+ child.on("close", (code) => {
55
+ const exitCode = code ?? 0;
56
+ if (exitCode !== 0) {
57
+ reject(new Error(stderr.trim() || stdout.trim() || `ps exited with ${exitCode}`));
58
+ return;
59
+ }
60
+ const entries: ProcessEntry[] = [];
61
+ for (const line of stdout.split(/\r?\n/)) {
62
+ const parsed = parseLine(line);
63
+ if (parsed) {
64
+ entries.push(parsed);
65
+ }
66
+ }
67
+ resolve(entries);
68
+ });
69
+ });
70
+ }
@@ -0,0 +1,25 @@
1
+ import type { PaneInfo, WindowInfo } from "../contracts/types";
2
+
3
+ export type TargetKind = "pane" | "window";
4
+ export type TargetErrorKind = "not_found" | "ambiguous" | "invalid";
5
+
6
+ export class TargetResolutionError extends Error {
7
+ readonly targetKind: TargetKind;
8
+ readonly kind: TargetErrorKind;
9
+ readonly target: string;
10
+ readonly candidates?: Array<PaneInfo | WindowInfo>;
11
+
12
+ constructor(options: {
13
+ targetKind: TargetKind;
14
+ kind: TargetErrorKind;
15
+ target: string;
16
+ message: string;
17
+ candidates?: Array<PaneInfo | WindowInfo>;
18
+ }) {
19
+ super(options.message);
20
+ this.targetKind = options.targetKind;
21
+ this.kind = options.kind;
22
+ this.target = options.target;
23
+ this.candidates = options.candidates;
24
+ }
25
+ }