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.
- package/.codex/skills/speckit/SKILL.md +173 -0
- package/.codex/skills/speckit/assets/templates/checklist-template.md +49 -0
- package/.codex/skills/speckit/assets/templates/notes-entrypoints-template.md +11 -0
- package/.codex/skills/speckit/assets/templates/notes-questions-template.md +7 -0
- package/.codex/skills/speckit/assets/templates/notes-readme-template.md +36 -0
- package/.codex/skills/speckit/assets/templates/notes-session-template.md +21 -0
- package/.codex/skills/speckit/assets/templates/plan-template.md +126 -0
- package/.codex/skills/speckit/assets/templates/spec-template.md +135 -0
- package/.codex/skills/speckit/assets/templates/tasks-template.md +269 -0
- package/.codex/skills/speckit/references/acceptance.md +183 -0
- package/.codex/skills/speckit/references/analyze.md +186 -0
- package/.codex/skills/speckit/references/checklist.md +302 -0
- package/.codex/skills/speckit/references/clarify-auto.md +69 -0
- package/.codex/skills/speckit/references/clarify-detailed.md +78 -0
- package/.codex/skills/speckit/references/clarify.md +189 -0
- package/.codex/skills/speckit/references/constitution.md +90 -0
- package/.codex/skills/speckit/references/group.md +89 -0
- package/.codex/skills/speckit/references/implement-task.md +115 -0
- package/.codex/skills/speckit/references/implement.md +129 -0
- package/.codex/skills/speckit/references/notes.md +82 -0
- package/.codex/skills/speckit/references/plan-deep.md +87 -0
- package/.codex/skills/speckit/references/plan-from-questions.md +115 -0
- package/.codex/skills/speckit/references/plan-from-review.md +89 -0
- package/.codex/skills/speckit/references/plan.md +97 -0
- package/.codex/skills/speckit/references/review-plan.md +156 -0
- package/.codex/skills/speckit/references/specify.md +246 -0
- package/.codex/skills/speckit/references/tasks.md +155 -0
- package/.codex/skills/speckit/references/taskstoissues.md +33 -0
- package/.codex/skills/speckit/scripts/bash/check-prerequisites.sh +206 -0
- package/.codex/skills/speckit/scripts/bash/common.sh +191 -0
- package/.codex/skills/speckit/scripts/bash/create-new-feature.sh +259 -0
- package/.codex/skills/speckit/scripts/bash/extract-coded-points.sh +322 -0
- package/.codex/skills/speckit/scripts/bash/extract-spec-ids.sh +238 -0
- package/.codex/skills/speckit/scripts/bash/extract-tasks.sh +295 -0
- package/.codex/skills/speckit/scripts/bash/extract-user-stories.sh +312 -0
- package/.codex/skills/speckit/scripts/bash/setup-notes.sh +182 -0
- package/.codex/skills/speckit/scripts/bash/setup-plan.sh +110 -0
- package/.codex/skills/speckit/scripts/bash/show-todo-tasks.sh +257 -0
- package/.codex/skills/speckit/scripts/bash/spec-group-checklist.sh +402 -0
- package/.codex/skills/speckit/scripts/bash/spec-group-members.sh +215 -0
- package/.codex/skills/speckit/scripts/bash/spec-registry-graph.sh +399 -0
- package/.specify/memory/constitution.md +67 -0
- package/.specify/templates/agent-file-template.md +28 -0
- package/.specify/templates/checklist-template.md +49 -0
- package/.specify/templates/plan-template.md +126 -0
- package/.specify/templates/spec-template.md +135 -0
- package/.specify/templates/tasks-template.md +269 -0
- package/README.md +128 -0
- package/README.zh-CN.md +127 -0
- package/bun.lock +269 -0
- package/dist/cli/commands/codex/forkHome.js +88 -0
- package/dist/cli/commands/codex/send.js +55 -0
- package/dist/cli/commands/codex/sessionInfo.js +42 -0
- package/dist/cli/commands/codex/spawn.js +68 -0
- package/dist/cli/commands/find.js +26 -0
- package/dist/cli/commands/paneKill.js +33 -0
- package/dist/cli/commands/paneSpawn.js +40 -0
- package/dist/cli/commands/paneTitle.js +33 -0
- package/dist/cli/commands/read.js +34 -0
- package/dist/cli/commands/send.js +51 -0
- package/dist/cli/commands/snapshot.js +19 -0
- package/dist/cli/commands/ui/select.js +41 -0
- package/dist/cli/commands/windowKill.js +25 -0
- package/dist/cli/commands/windowLs.js +15 -0
- package/dist/cli/commands/windowNew.js +28 -0
- package/dist/cli/commands/windowRename.js +25 -0
- package/dist/cli/index.js +365 -0
- package/dist/cli/parse.js +39 -0
- package/dist/lib/codex/forkHome.js +101 -0
- package/dist/lib/codex/isCodexPane.js +55 -0
- package/dist/lib/codex/send.js +58 -0
- package/dist/lib/codex/sessionInfo.js +449 -0
- package/dist/lib/codex/spawn.js +246 -0
- package/dist/lib/contracts/types.js +2 -0
- package/dist/lib/fs/safeRm.js +32 -0
- package/dist/lib/io/readStdin.js +14 -0
- package/dist/lib/os/process.js +55 -0
- package/dist/lib/output/format.js +95 -0
- package/dist/lib/proc/lsof.js +42 -0
- package/dist/lib/proc/ps.js +60 -0
- package/dist/lib/targeting/errors.js +13 -0
- package/dist/lib/targeting/resolvePaneTarget.js +91 -0
- package/dist/lib/targeting/resolveWindowTarget.js +40 -0
- package/dist/lib/targeting/scope.js +58 -0
- package/dist/lib/tmux/capturePane.js +20 -0
- package/dist/lib/tmux/exec.js +66 -0
- package/dist/lib/tmux/paneOps.js +29 -0
- package/dist/lib/tmux/paste.js +23 -0
- package/dist/lib/tmux/sendKeys.js +47 -0
- package/dist/lib/tmux/session.js +29 -0
- package/dist/lib/tmux/snapshotPanes.js +46 -0
- package/dist/lib/tmux/snapshotWindows.js +24 -0
- package/dist/lib/tmux/windowOps.js +32 -0
- package/dist/lib/ui/popupSelect.js +432 -0
- package/dist/lib/ui/popupSupport.js +76 -0
- package/package.json +23 -0
- package/src/cli/commands/codex/forkHome.ts +141 -0
- package/src/cli/commands/codex/send.ts +83 -0
- package/src/cli/commands/codex/sessionInfo.ts +59 -0
- package/src/cli/commands/codex/spawn.ts +90 -0
- package/src/cli/commands/find.ts +40 -0
- package/src/cli/commands/paneKill.ts +49 -0
- package/src/cli/commands/paneSpawn.ts +53 -0
- package/src/cli/commands/paneTitle.ts +50 -0
- package/src/cli/commands/read.ts +48 -0
- package/src/cli/commands/send.ts +71 -0
- package/src/cli/commands/snapshot.ts +28 -0
- package/src/cli/commands/ui/select.ts +49 -0
- package/src/cli/commands/windowKill.ts +35 -0
- package/src/cli/commands/windowLs.ts +20 -0
- package/src/cli/commands/windowNew.ts +40 -0
- package/src/cli/commands/windowRename.ts +36 -0
- package/src/cli/index.ts +430 -0
- package/src/lib/codex/forkHome.ts +148 -0
- package/src/lib/codex/isCodexPane.ts +56 -0
- package/src/lib/codex/send.ts +84 -0
- package/src/lib/codex/sessionInfo.ts +521 -0
- package/src/lib/codex/spawn.ts +305 -0
- package/src/lib/contracts/types.ts +30 -0
- package/src/lib/fs/safeRm.ts +32 -0
- package/src/lib/io/readStdin.ts +11 -0
- package/src/lib/output/format.ts +105 -0
- package/src/lib/proc/lsof.ts +44 -0
- package/src/lib/proc/ps.ts +70 -0
- package/src/lib/targeting/errors.ts +25 -0
- package/src/lib/targeting/resolvePaneTarget.ts +106 -0
- package/src/lib/targeting/resolveWindowTarget.ts +45 -0
- package/src/lib/targeting/scope.ts +76 -0
- package/src/lib/tmux/capturePane.ts +21 -0
- package/src/lib/tmux/exec.ts +90 -0
- package/src/lib/tmux/paneOps.ts +35 -0
- package/src/lib/tmux/paste.ts +20 -0
- package/src/lib/tmux/sendKeys.ts +72 -0
- package/src/lib/tmux/session.ts +27 -0
- package/src/lib/tmux/snapshotPanes.ts +52 -0
- package/src/lib/tmux/snapshotWindows.ts +23 -0
- package/src/lib/tmux/windowOps.ts +43 -0
- package/src/lib/ui/popupSelect.ts +561 -0
- package/src/lib/ui/popupSupport.ts +84 -0
- package/tests/e2e/codexForkHome.test.ts +146 -0
- package/tests/e2e/codexSessionInfo.test.ts +112 -0
- package/tests/e2e/codexTuiSend.test.ts +68 -0
- package/tests/integration/codexSpawn.test.ts +113 -0
- package/tests/integration/paneOps.test.ts +60 -0
- package/tests/integration/sendRead.test.ts +52 -0
- package/tests/integration/snapshot.test.ts +39 -0
- package/tests/integration/tmuxHarness.ts +39 -0
- package/tests/integration/windowOps.test.ts +60 -0
- package/tests/unit/codexSend.test.ts +105 -0
- package/tests/unit/codexSessionInfo.test.ts +88 -0
- package/tests/unit/codexSpawn.test.ts +34 -0
- package/tests/unit/keys.test.ts +30 -0
- package/tests/unit/outputFormat.test.ts +52 -0
- package/tests/unit/popupSelect.test.ts +77 -0
- package/tests/unit/popupSupport.test.ts +109 -0
- package/tests/unit/resolvePaneTarget.test.ts +43 -0
- package/tests/unit/resolveWindowTarget.test.ts +36 -0
- package/tests/unit/safeRm.test.ts +41 -0
- package/tests/unit/scope.test.ts +57 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { capturePane } from "../tmux/capturePane";
|
|
2
|
+
import { tmuxExec } from "../tmux/exec";
|
|
3
|
+
import { pasteText } from "../tmux/paste";
|
|
4
|
+
import { parseKeyExpression } from "../tmux/sendKeys";
|
|
5
|
+
|
|
6
|
+
export type CodexSendOptions = {
|
|
7
|
+
submitKey?: string;
|
|
8
|
+
submitDelayMs?: number;
|
|
9
|
+
postDelayMs?: number;
|
|
10
|
+
captureTailLines?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type CodexSendResult = {
|
|
14
|
+
mode: "text" | "keys";
|
|
15
|
+
submit?: string;
|
|
16
|
+
submit_delay_ms?: number;
|
|
17
|
+
post_delay_ms?: number;
|
|
18
|
+
capture_tail?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function sleep(ms: number): Promise<void> {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeTmuxKey(key: string): string {
|
|
26
|
+
const parsed = parseKeyExpression(key);
|
|
27
|
+
return parsed ?? key;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function codexSend(
|
|
31
|
+
paneId: string,
|
|
32
|
+
input: string,
|
|
33
|
+
options: CodexSendOptions = {}
|
|
34
|
+
): Promise<CodexSendResult> {
|
|
35
|
+
const submitKey = options.submitKey?.trim() ?? "";
|
|
36
|
+
const submitDelayMs = Math.max(0, options.submitDelayMs ?? 0);
|
|
37
|
+
const postDelayMs = Math.max(0, options.postDelayMs ?? 0);
|
|
38
|
+
const captureTailLines = options.captureTailLines;
|
|
39
|
+
|
|
40
|
+
const tmuxKey = parseKeyExpression(input);
|
|
41
|
+
if (tmuxKey) {
|
|
42
|
+
await tmuxExec(["send-keys", "-t", paneId, tmuxKey]);
|
|
43
|
+
if (postDelayMs > 0) {
|
|
44
|
+
await sleep(postDelayMs);
|
|
45
|
+
}
|
|
46
|
+
const capture_tail =
|
|
47
|
+
captureTailLines && captureTailLines > 0
|
|
48
|
+
? await capturePane(paneId, captureTailLines)
|
|
49
|
+
: undefined;
|
|
50
|
+
return {
|
|
51
|
+
mode: "keys",
|
|
52
|
+
...(capture_tail ? { capture_tail } : {})
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await pasteText(paneId, input);
|
|
57
|
+
if (submitKey) {
|
|
58
|
+
if (submitDelayMs > 0) {
|
|
59
|
+
await sleep(submitDelayMs);
|
|
60
|
+
}
|
|
61
|
+
await tmuxExec(["send-keys", "-t", paneId, normalizeTmuxKey(submitKey)]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (postDelayMs > 0) {
|
|
65
|
+
await sleep(postDelayMs);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const capture_tail =
|
|
69
|
+
captureTailLines && captureTailLines > 0
|
|
70
|
+
? await capturePane(paneId, captureTailLines)
|
|
71
|
+
: undefined;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
mode: "text",
|
|
75
|
+
...(submitKey
|
|
76
|
+
? {
|
|
77
|
+
submit: submitKey,
|
|
78
|
+
submit_delay_ms: submitDelayMs
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
...(postDelayMs > 0 ? { post_delay_ms: postDelayMs } : {}),
|
|
82
|
+
...(capture_tail ? { capture_tail } : {})
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import type { Dirent } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { listProcesses, type ProcessEntry } from "../proc/ps";
|
|
5
|
+
import { listOpenFiles } from "../proc/lsof";
|
|
6
|
+
import { tmuxExec } from "../tmux/exec";
|
|
7
|
+
|
|
8
|
+
const UUID_RE =
|
|
9
|
+
/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/g;
|
|
10
|
+
const SHELL_SNAPSHOT_RE =
|
|
11
|
+
/(?<path>\/[^\s"' ]*\/shell_snapshots\/(?<uuid>[0-9a-fA-F-]{36})\.(?:sh|ps1))/;
|
|
12
|
+
const CODEX_COMMAND_PATTERNS = [
|
|
13
|
+
/\/@openai\/codex\/.*\/vendor\/.*\/codex\/codex\b/i,
|
|
14
|
+
/\/@openai\/codex\/bin\/codex\b/i,
|
|
15
|
+
/(^|[\s/])codex([\s]|$)/i
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export type SessionInfoResult = {
|
|
19
|
+
sessionId: string;
|
|
20
|
+
rolloutPath: string;
|
|
21
|
+
method: string;
|
|
22
|
+
matchedPids?: number[];
|
|
23
|
+
selfPid?: number;
|
|
24
|
+
codexHome?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ShellSnapshotHit = {
|
|
28
|
+
sessionId: string;
|
|
29
|
+
snapshotPath: string;
|
|
30
|
+
codexHome?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function matchesCodexCommand(command: string): boolean {
|
|
34
|
+
return CODEX_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseSessionIdFromPath(value: string): string {
|
|
38
|
+
const matches = value.match(UUID_RE);
|
|
39
|
+
if (!matches || matches.length === 0) {
|
|
40
|
+
throw new Error(`unable to parse session id from rollout path: ${value}`);
|
|
41
|
+
}
|
|
42
|
+
return matches[matches.length - 1];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function inferCodexHomeFromRolloutPath(rolloutPath: string): string | undefined {
|
|
46
|
+
const parts = path.resolve(rolloutPath).split(path.sep);
|
|
47
|
+
const idx = parts.lastIndexOf("sessions");
|
|
48
|
+
if (idx <= 0) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
return parts.slice(0, idx).join(path.sep);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function inferCodexHomeFromSnapshotPath(snapshotPath: string): string | undefined {
|
|
55
|
+
const resolved = path.resolve(snapshotPath);
|
|
56
|
+
const parent = path.dirname(resolved);
|
|
57
|
+
if (path.basename(parent) !== "shell_snapshots") {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return path.dirname(parent);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tryParseShellSnapshot(cmdline: string): ShellSnapshotHit | null {
|
|
64
|
+
const match = SHELL_SNAPSHOT_RE.exec(cmdline);
|
|
65
|
+
if (!match || !match.groups) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const snapshotPath = match.groups.path;
|
|
69
|
+
const sessionId = match.groups.uuid;
|
|
70
|
+
const codexHome = inferCodexHomeFromSnapshotPath(snapshotPath);
|
|
71
|
+
return { sessionId, snapshotPath, codexHome };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function listRolloutFiles(codexHome: string, limit = 800): Promise<string[]> {
|
|
75
|
+
const sessionsDir = path.join(codexHome, "sessions");
|
|
76
|
+
const results: { path: string; mtimeMs: number }[] = [];
|
|
77
|
+
const stack: string[] = [sessionsDir];
|
|
78
|
+
|
|
79
|
+
while (stack.length) {
|
|
80
|
+
const current = stack.pop();
|
|
81
|
+
if (!current) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
let entries: Dirent[];
|
|
85
|
+
try {
|
|
86
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
87
|
+
} catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const full = path.join(current, entry.name);
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
stack.push(full);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!entry.isFile()) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl")) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const stat = await fs.stat(full);
|
|
104
|
+
results.push({ path: full, mtimeMs: stat.mtimeMs });
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (results.length >= limit) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (results.length >= limit) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
results.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
118
|
+
return results.map((item) => item.path);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function findRolloutBySessionId(
|
|
122
|
+
codexHome: string,
|
|
123
|
+
sessionId: string
|
|
124
|
+
): Promise<string | null> {
|
|
125
|
+
const rollouts = await listRolloutFiles(codexHome, 800);
|
|
126
|
+
const candidates = rollouts.filter((p) => path.basename(p).includes(sessionId));
|
|
127
|
+
if (candidates.length === 0) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return candidates[0];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function readSessionMetaCwd(rolloutPath: string, maxLines = 30): Promise<string | null> {
|
|
134
|
+
try {
|
|
135
|
+
const handle = await fs.open(rolloutPath, "r");
|
|
136
|
+
const stream = handle.createReadStream({ encoding: "utf8" });
|
|
137
|
+
let buffer = "";
|
|
138
|
+
let lineCount = 0;
|
|
139
|
+
for await (const chunk of stream) {
|
|
140
|
+
buffer += chunk;
|
|
141
|
+
let idx = buffer.indexOf("\n");
|
|
142
|
+
while (idx !== -1) {
|
|
143
|
+
const line = buffer.slice(0, idx).trim();
|
|
144
|
+
buffer = buffer.slice(idx + 1);
|
|
145
|
+
if (line) {
|
|
146
|
+
try {
|
|
147
|
+
const obj = JSON.parse(line);
|
|
148
|
+
if (obj?.type === "session_meta" && typeof obj?.payload === "object") {
|
|
149
|
+
const cwd = obj.payload?.cwd;
|
|
150
|
+
if (typeof cwd === "string" && cwd) {
|
|
151
|
+
await handle.close();
|
|
152
|
+
return cwd;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// ignore parse errors
|
|
157
|
+
}
|
|
158
|
+
lineCount += 1;
|
|
159
|
+
if (lineCount >= maxLines) {
|
|
160
|
+
await handle.close();
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
idx = buffer.indexOf("\n");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
await handle.close();
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildProcessMaps(processes: ProcessEntry[]): {
|
|
175
|
+
parentByPid: Map<number, number>;
|
|
176
|
+
commandByPid: Map<number, string>;
|
|
177
|
+
ttyByPid: Map<number, string>;
|
|
178
|
+
} {
|
|
179
|
+
const parentByPid = new Map<number, number>();
|
|
180
|
+
const commandByPid = new Map<number, string>();
|
|
181
|
+
const ttyByPid = new Map<number, string>();
|
|
182
|
+
for (const proc of processes) {
|
|
183
|
+
parentByPid.set(proc.pid, proc.ppid);
|
|
184
|
+
commandByPid.set(proc.pid, proc.command);
|
|
185
|
+
ttyByPid.set(proc.pid, proc.tty);
|
|
186
|
+
}
|
|
187
|
+
return { parentByPid, commandByPid, ttyByPid };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isDescendant(
|
|
191
|
+
pid: number,
|
|
192
|
+
ancestorPid: number,
|
|
193
|
+
parentByPid: Map<number, number>
|
|
194
|
+
): boolean {
|
|
195
|
+
let current = pid;
|
|
196
|
+
for (let i = 0; i < 400; i += 1) {
|
|
197
|
+
if (current === ancestorPid) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
current = parentByPid.get(current) ?? 0;
|
|
201
|
+
if (current <= 1) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function filterRolloutPaths(paths: string[]): string[] {
|
|
209
|
+
const out: string[] = [];
|
|
210
|
+
const seen = new Set<string>();
|
|
211
|
+
for (const filePath of paths) {
|
|
212
|
+
if (!filePath.includes("rollout-") || !filePath.endsWith(".jsonl")) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!filePath.includes(`${path.sep}sessions${path.sep}`)) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (seen.has(filePath)) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
seen.add(filePath);
|
|
222
|
+
out.push(filePath);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function latestExistingPath(paths: string[]): Promise<string> {
|
|
228
|
+
const candidates: { path: string; mtimeMs: number }[] = [];
|
|
229
|
+
for (const p of paths) {
|
|
230
|
+
try {
|
|
231
|
+
const stat = await fs.stat(p);
|
|
232
|
+
candidates.push({ path: p, mtimeMs: stat.mtimeMs });
|
|
233
|
+
} catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (candidates.length === 0) {
|
|
238
|
+
throw new Error("rollout file not found");
|
|
239
|
+
}
|
|
240
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
241
|
+
return candidates[0].path;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function getPanePid(paneId: string): Promise<number | undefined> {
|
|
245
|
+
try {
|
|
246
|
+
const result = await tmuxExec(["display-message", "-p", "-t", paneId, "#{pane_pid}"]);
|
|
247
|
+
const trimmed = result.stdout.trim();
|
|
248
|
+
const pid = Number(trimmed);
|
|
249
|
+
return Number.isFinite(pid) ? pid : undefined;
|
|
250
|
+
} catch {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function detectShellSnapshot(
|
|
256
|
+
processes: ProcessEntry[],
|
|
257
|
+
codexHome: string
|
|
258
|
+
): Promise<SessionInfoResult | null> {
|
|
259
|
+
const { parentByPid, commandByPid } = buildProcessMaps(processes);
|
|
260
|
+
let current = process.pid;
|
|
261
|
+
for (let i = 0; i < 80; i += 1) {
|
|
262
|
+
const cmdline = commandByPid.get(current) ?? "";
|
|
263
|
+
const hit = tryParseShellSnapshot(cmdline);
|
|
264
|
+
if (hit) {
|
|
265
|
+
const home = hit.codexHome ?? codexHome;
|
|
266
|
+
const rolloutPath = await findRolloutBySessionId(home, hit.sessionId);
|
|
267
|
+
if (rolloutPath) {
|
|
268
|
+
const inferredHome = inferCodexHomeFromRolloutPath(rolloutPath) ?? home;
|
|
269
|
+
return {
|
|
270
|
+
sessionId: hit.sessionId,
|
|
271
|
+
rolloutPath,
|
|
272
|
+
method: "shell-snapshot",
|
|
273
|
+
selfPid: process.pid,
|
|
274
|
+
codexHome: inferredHome
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
current = parentByPid.get(current) ?? 0;
|
|
279
|
+
if (current <= 1) {
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function pickRolloutForAnchorPid(
|
|
287
|
+
anchorPid: number,
|
|
288
|
+
processes: ProcessEntry[],
|
|
289
|
+
codexHome: string
|
|
290
|
+
): Promise<{ rolloutPath: string; matchedPids: number[]; method: string }> {
|
|
291
|
+
const { parentByPid, commandByPid } = buildProcessMaps(processes);
|
|
292
|
+
const matchedPids: number[] = [];
|
|
293
|
+
let paths: string[] = [];
|
|
294
|
+
const snapshotHits: ShellSnapshotHit[] = [];
|
|
295
|
+
for (const proc of processes) {
|
|
296
|
+
if (!matchesCodexCommand(proc.command)) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (!isDescendant(proc.pid, anchorPid, parentByPid)) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
matchedPids.push(proc.pid);
|
|
303
|
+
const snapshotHit = tryParseShellSnapshot(proc.command);
|
|
304
|
+
if (snapshotHit) {
|
|
305
|
+
snapshotHits.push(snapshotHit);
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const openFiles = await listOpenFiles(proc.pid);
|
|
309
|
+
paths = paths.concat(filterRolloutPaths(openFiles));
|
|
310
|
+
} catch {
|
|
311
|
+
// ignore
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const uniqMatched = Array.from(new Set(matchedPids));
|
|
316
|
+
if (paths.length > 0) {
|
|
317
|
+
const rolloutPath = await latestExistingPath(paths);
|
|
318
|
+
return { rolloutPath, matchedPids: uniqMatched, method: "anchor" };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
for (const hit of snapshotHits) {
|
|
322
|
+
const home = hit.codexHome ?? codexHome;
|
|
323
|
+
const rolloutPath = await findRolloutBySessionId(home, hit.sessionId);
|
|
324
|
+
if (rolloutPath) {
|
|
325
|
+
return { rolloutPath, matchedPids: uniqMatched, method: "anchor-shell-snapshot" };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const rollouts = await listRolloutFiles(codexHome, 200);
|
|
330
|
+
const uniqRollouts = Array.from(new Set(rollouts));
|
|
331
|
+
if (uniqRollouts.length === 1) {
|
|
332
|
+
return { rolloutPath: uniqRollouts[0], matchedPids: uniqMatched, method: "sessions-singleton" };
|
|
333
|
+
}
|
|
334
|
+
if (uniqRollouts.length > 1) {
|
|
335
|
+
const lines = uniqRollouts
|
|
336
|
+
.slice(0, 12)
|
|
337
|
+
.map((p) => `- ${p}`)
|
|
338
|
+
.join("\n");
|
|
339
|
+
throw new Error(
|
|
340
|
+
`no rollout paths found for anchor pid ${anchorPid} (found ${uniqRollouts.length} rollouts under codex home)\n${lines}`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
throw new Error(`no rollout paths found for anchor pid ${anchorPid}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function pickRolloutAuto(
|
|
348
|
+
processes: ProcessEntry[],
|
|
349
|
+
codexHome: string
|
|
350
|
+
): Promise<{ rolloutPath: string; matchedPids: number[]; method: string }> {
|
|
351
|
+
const { parentByPid, commandByPid, ttyByPid } = buildProcessMaps(processes);
|
|
352
|
+
const selfPid = process.pid;
|
|
353
|
+
let current = selfPid;
|
|
354
|
+
for (let i = 0; i < 600; i += 1) {
|
|
355
|
+
current = parentByPid.get(current) ?? 0;
|
|
356
|
+
if (current <= 1) {
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
const cmd = commandByPid.get(current) ?? "";
|
|
360
|
+
if (matchesCodexCommand(cmd)) {
|
|
361
|
+
try {
|
|
362
|
+
const picked = await pickRolloutForAnchorPid(current, processes, codexHome);
|
|
363
|
+
return { ...picked, method: "ancestor" };
|
|
364
|
+
} catch {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const selfTty = ttyByPid.get(selfPid) ?? "?";
|
|
371
|
+
if (selfTty && selfTty !== "?") {
|
|
372
|
+
const rolloutsByPid: { pid: number; path: string }[] = [];
|
|
373
|
+
for (const proc of processes) {
|
|
374
|
+
if (!matchesCodexCommand(proc.command)) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if ((ttyByPid.get(proc.pid) ?? "?") !== selfTty) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const openFiles = await listOpenFiles(proc.pid);
|
|
381
|
+
const filtered = filterRolloutPaths(openFiles);
|
|
382
|
+
if (filtered.length === 0) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
rolloutsByPid.push({ pid: proc.pid, path: await latestExistingPath(filtered) });
|
|
387
|
+
} catch {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const uniq = Array.from(new Set(rolloutsByPid.map((item) => item.path)));
|
|
392
|
+
if (uniq.length === 1) {
|
|
393
|
+
return { rolloutPath: uniq[0], matchedPids: [rolloutsByPid[0].pid], method: "tty" };
|
|
394
|
+
}
|
|
395
|
+
if (uniq.length > 1) {
|
|
396
|
+
const lines = rolloutsByPid
|
|
397
|
+
.slice(0, 12)
|
|
398
|
+
.map((item) => `- pid=${item.pid} rollout=${item.path}`)
|
|
399
|
+
.join("\n");
|
|
400
|
+
throw new Error(
|
|
401
|
+
`multiple codex sessions on same tty; provide pane/pid.\n${lines}`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const rolloutsByPid: { pid: number; path: string }[] = [];
|
|
407
|
+
for (const proc of processes) {
|
|
408
|
+
if (!matchesCodexCommand(proc.command)) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const openFiles = await listOpenFiles(proc.pid);
|
|
412
|
+
const filtered = filterRolloutPaths(openFiles);
|
|
413
|
+
if (filtered.length === 0) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
rolloutsByPid.push({ pid: proc.pid, path: await latestExistingPath(filtered) });
|
|
418
|
+
} catch {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const uniq = Array.from(new Set(rolloutsByPid.map((item) => item.path)));
|
|
423
|
+
if (uniq.length === 1) {
|
|
424
|
+
return { rolloutPath: uniq[0], matchedPids: [rolloutsByPid[0].pid], method: "global-singleton" };
|
|
425
|
+
}
|
|
426
|
+
if (uniq.length > 1) {
|
|
427
|
+
const lines = rolloutsByPid
|
|
428
|
+
.slice(0, 12)
|
|
429
|
+
.map((item) => `- pid=${item.pid} rollout=${item.path}`)
|
|
430
|
+
.join("\n");
|
|
431
|
+
throw new Error(
|
|
432
|
+
`multiple codex sessions found; provide pane/pid.\n${lines}`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
throw new Error("no active codex sessions detected");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export type SessionInfoOptions = {
|
|
439
|
+
paneId?: string;
|
|
440
|
+
panePid?: number;
|
|
441
|
+
codexHome?: string;
|
|
442
|
+
cd?: string;
|
|
443
|
+
allowScanSessions?: boolean;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
export async function resolveCodexSessionInfo(
|
|
447
|
+
options: SessionInfoOptions = {}
|
|
448
|
+
): Promise<SessionInfoResult> {
|
|
449
|
+
const codexHome =
|
|
450
|
+
options.codexHome?.trim() ||
|
|
451
|
+
process.env.CODEX_HOME?.trim() ||
|
|
452
|
+
path.join(process.env.HOME || "", ".codex");
|
|
453
|
+
|
|
454
|
+
let anchorPid = options.panePid;
|
|
455
|
+
if (!anchorPid && options.paneId) {
|
|
456
|
+
anchorPid = await getPanePid(options.paneId);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let picked;
|
|
460
|
+
if (anchorPid) {
|
|
461
|
+
const processes = await listProcesses();
|
|
462
|
+
picked = await pickRolloutForAnchorPid(anchorPid, processes, codexHome);
|
|
463
|
+
} else {
|
|
464
|
+
const processes = await listProcesses();
|
|
465
|
+
const snapshot = await detectShellSnapshot(processes, codexHome);
|
|
466
|
+
if (snapshot) {
|
|
467
|
+
return snapshot;
|
|
468
|
+
}
|
|
469
|
+
picked = await pickRolloutAuto(processes, codexHome);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const sessionId = parseSessionIdFromPath(picked.rolloutPath);
|
|
473
|
+
const inferredHome =
|
|
474
|
+
inferCodexHomeFromRolloutPath(picked.rolloutPath) ?? codexHome;
|
|
475
|
+
if (options.cd) {
|
|
476
|
+
const cwd = await readSessionMetaCwd(picked.rolloutPath);
|
|
477
|
+
if (cwd && path.resolve(cwd) !== path.resolve(options.cd)) {
|
|
478
|
+
if (options.allowScanSessions) {
|
|
479
|
+
const rollouts = await listRolloutFiles(codexHome, 800);
|
|
480
|
+
const matches: { sessionId: string; path: string }[] = [];
|
|
481
|
+
for (const rollout of rollouts) {
|
|
482
|
+
const metaCwd = await readSessionMetaCwd(rollout);
|
|
483
|
+
if (!metaCwd) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (path.resolve(metaCwd) !== path.resolve(options.cd)) {
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
matches.push({ sessionId: parseSessionIdFromPath(rollout), path: rollout });
|
|
490
|
+
}
|
|
491
|
+
if (matches.length === 1) {
|
|
492
|
+
return {
|
|
493
|
+
sessionId: matches[0].sessionId,
|
|
494
|
+
rolloutPath: matches[0].path,
|
|
495
|
+
method: "sessions-cwd",
|
|
496
|
+
codexHome: inferCodexHomeFromRolloutPath(matches[0].path) ?? codexHome
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (matches.length > 1) {
|
|
500
|
+
const lines = matches
|
|
501
|
+
.slice(0, 12)
|
|
502
|
+
.map((item) => `- ${item.sessionId} ${item.path}`)
|
|
503
|
+
.join("\n");
|
|
504
|
+
throw new Error(
|
|
505
|
+
`multiple sessions matched cwd; provide explicit session id or rollout path.\n${lines}`
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
throw new Error("detected rollout cwd does not match cd filter");
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
sessionId,
|
|
515
|
+
rolloutPath: picked.rolloutPath,
|
|
516
|
+
method: picked.method,
|
|
517
|
+
matchedPids: picked.matchedPids,
|
|
518
|
+
selfPid: process.pid,
|
|
519
|
+
codexHome: inferredHome
|
|
520
|
+
};
|
|
521
|
+
}
|