pi-harness-runtime 1.1.136 → 1.1.138

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 (131) hide show
  1. package/harness/langchain/daemon.ts +128 -25
  2. package/harness/langchain/run.ts +1 -1
  3. package/index.ts +41 -2
  4. package/package.json +1 -1
  5. package/packages/a2a-adapter/package.json +1 -1
  6. package/packages/architecture-generator/package.json +1 -1
  7. package/packages/auth/package.json +1 -1
  8. package/packages/autonomous-refactor/package.json +1 -1
  9. package/packages/autonomous-runtime/package.json +1 -1
  10. package/packages/cache-strategy/package.json +1 -1
  11. package/packages/capability-registry/package.json +1 -1
  12. package/packages/checkpoint/package.json +1 -1
  13. package/packages/cli-plugin-sdk/package.json +1 -1
  14. package/packages/clipboard/package.json +1 -1
  15. package/packages/clipboard-plugin/package.json +1 -1
  16. package/packages/code-generation/package.json +1 -1
  17. package/packages/code-review/package.json +1 -1
  18. package/packages/codex-adapter/package.json +1 -1
  19. package/packages/codex-watcher/dist/src/index.d.ts +42 -0
  20. package/packages/codex-watcher/dist/src/index.d.ts.map +1 -0
  21. package/packages/codex-watcher/dist/src/index.js +198 -0
  22. package/packages/codex-watcher/dist/src/index.js.map +1 -0
  23. package/packages/codex-watcher/dist/src/parser.d.ts +36 -0
  24. package/packages/codex-watcher/dist/src/parser.d.ts.map +1 -0
  25. package/packages/codex-watcher/dist/src/parser.js +151 -0
  26. package/packages/codex-watcher/dist/src/parser.js.map +1 -0
  27. package/packages/codex-watcher/dist/src/session.d.ts +9 -0
  28. package/packages/codex-watcher/dist/src/session.d.ts.map +1 -0
  29. package/packages/codex-watcher/dist/src/session.js +131 -0
  30. package/packages/codex-watcher/dist/src/session.js.map +1 -0
  31. package/packages/codex-watcher/dist/src/types.d.ts +63 -0
  32. package/packages/codex-watcher/dist/src/types.d.ts.map +1 -0
  33. package/packages/codex-watcher/dist/src/types.js +17 -0
  34. package/packages/codex-watcher/dist/src/types.js.map +1 -0
  35. package/packages/codex-watcher/package.json +26 -0
  36. package/packages/codex-watcher/src/index.ts +244 -0
  37. package/packages/codex-watcher/src/parser.ts +210 -0
  38. package/packages/codex-watcher/src/session.ts +151 -0
  39. package/packages/codex-watcher/src/types.ts +67 -0
  40. package/packages/codex-watcher/test/watch.test.ts +150 -0
  41. package/packages/config-capture/package.json +1 -1
  42. package/packages/config-capture/src/index.ts +5 -16
  43. package/packages/context-compiler/package.json +1 -1
  44. package/packages/context-discovery/package.json +1 -1
  45. package/packages/context-manager/package.json +1 -1
  46. package/packages/cookie-sanitizer/package.json +1 -1
  47. package/packages/cost-optimizer/package.json +1 -1
  48. package/packages/dependency-analyzer/package.json +1 -1
  49. package/packages/django-plugin/package.json +1 -1
  50. package/packages/doc-generator/package.json +1 -1
  51. package/packages/evaluation-engine/package.json +1 -1
  52. package/packages/evaluation-runner/package.json +1 -1
  53. package/packages/event-bus/package.json +1 -1
  54. package/packages/event-store/package.json +1 -1
  55. package/packages/experience-replay/package.json +1 -1
  56. package/packages/feedback-collector/package.json +1 -1
  57. package/packages/file-copy-helper/package.json +1 -1
  58. package/packages/framework-detector/package.json +1 -1
  59. package/packages/framework-plugin-sdk/package.json +1 -1
  60. package/packages/frappe-plugin/package.json +1 -1
  61. package/packages/generic-web-plugin/package.json +1 -1
  62. package/packages/health-monitor/package.json +1 -1
  63. package/packages/intent-analyzer/package.json +1 -1
  64. package/packages/jev-judge/package.json +1 -1
  65. package/packages/knowledge-graph/package.json +1 -1
  66. package/packages/knowledge-retrieval/package.json +1 -1
  67. package/packages/laravel-plugin/package.json +1 -1
  68. package/packages/learning-engine/package.json +1 -1
  69. package/packages/mcp-adapter/package.json +1 -1
  70. package/packages/memory-engine/package.json +1 -1
  71. package/packages/milestone-manager/package.json +1 -1
  72. package/packages/model-registry/package.json +1 -1
  73. package/packages/nextjs-plugin/package.json +1 -1
  74. package/packages/notification/package.json +1 -1
  75. package/packages/observability/package.json +1 -1
  76. package/packages/okf-indexer/package.json +1 -1
  77. package/packages/performance-optimizer/package.json +1 -1
  78. package/packages/privilege-broker/package.json +1 -1
  79. package/packages/project-analyzer/package.json +1 -1
  80. package/packages/project-bootstrap/package.json +1 -1
  81. package/packages/projection-engine/package.json +1 -1
  82. package/packages/prompt-compiler/package.json +1 -1
  83. package/packages/prompt-versioning/package.json +1 -1
  84. package/packages/provider-adapter-sdk/package.json +1 -1
  85. package/packages/provider-router/package.json +1 -1
  86. package/packages/provider-selector/package.json +1 -1
  87. package/packages/providers/package.json +1 -1
  88. package/packages/qdrant-skills/package.json +1 -1
  89. package/packages/quota-manager/package.json +1 -1
  90. package/packages/rate-limiter/package.json +1 -1
  91. package/packages/react-vite-plugin/package.json +1 -1
  92. package/packages/release-manager/package.json +1 -1
  93. package/packages/requirement-compiler/package.json +1 -1
  94. package/packages/runtime/package.json +1 -1
  95. package/packages/scheduler/package.json +1 -1
  96. package/packages/scheduler-adapter/package.json +1 -1
  97. package/packages/session/package.json +1 -1
  98. package/packages/session-api/package.json +1 -1
  99. package/packages/session-export/package.json +1 -1
  100. package/packages/shared-context/package.json +1 -1
  101. package/packages/skill-mcp-client/package.json +1 -1
  102. package/packages/skill-registry/package.json +1 -1
  103. package/packages/skills/package.json +1 -1
  104. package/packages/sprint-planner/package.json +1 -1
  105. package/packages/subscription-engine/package.json +1 -1
  106. package/packages/task-compiler/package.json +1 -1
  107. package/packages/tencentdb-memory/package.json +1 -1
  108. package/packages/tencentdb-sync/package.json +1 -1
  109. package/packages/test-data-generator/package.json +1 -1
  110. package/packages/test-generator/package.json +1 -1
  111. package/packages/todo-bd-sync/package.json +1 -1
  112. package/packages/token-estimation/package.json +1 -1
  113. package/packages/token-optimizer/package.json +1 -1
  114. package/packages/trajectory/package.json +1 -1
  115. package/packages/tui/dist/harness-header.d.ts +18 -14
  116. package/packages/tui/dist/harness-header.d.ts.map +1 -1
  117. package/packages/tui/dist/harness-header.js +110 -64
  118. package/packages/tui/dist/harness-header.js.map +1 -1
  119. package/packages/tui/dist/index.d.ts +1 -2
  120. package/packages/tui/dist/index.d.ts.map +1 -1
  121. package/packages/tui/dist/index.js +1 -2
  122. package/packages/tui/dist/index.js.map +1 -1
  123. package/packages/tui/package.json +1 -1
  124. package/packages/tui/src/harness-header.ts +156 -77
  125. package/packages/tui/src/index.ts +6 -2
  126. package/packages/tui/test/harness-header.test.ts +380 -100
  127. package/packages/types/package.json +1 -1
  128. package/packages/workflow-events/package.json +1 -1
  129. package/packages/workspace-scanner/package.json +1 -1
  130. package/packages/worktree/package.json +1 -1
  131. package/packages/write-review/package.json +1 -1
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@pi-harness/codex-watcher",
3
+ "version": "1.1.138",
4
+ "description": "Watches Codex CLI sessions and emits visible messages for PING-PONG monitoring",
5
+ "type": "module",
6
+ "main": "./dist/src/index.js",
7
+ "types": "./dist/src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/src/index.js",
11
+ "types": "./dist/src/index.d.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "test": "bun test"
17
+ },
18
+ "dependencies": {},
19
+ "devDependencies": {
20
+ "@types/node": "^22.0.0",
21
+ "typescript": "^5.7.0"
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ]
26
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Codex Session Watcher — Main API
3
+ *
4
+ * Watches the active Codex CLI session and emits visible messages (user + assistant)
5
+ * so the harness PING-PONG middleware can feed them to pi.dev.
6
+ *
7
+ * Usage:
8
+ * import { startWatcher, getLatestSession, getVisibleMessages } from "@pi-harness/codex-watcher";
9
+ *
10
+ * // One-shot: get latest messages from the current session
11
+ * const session = getLatestSession();
12
+ * const messages = await getVisibleMessages(session.rolloutPath, { fromOrdinal: 0 });
13
+ *
14
+ * // Continuous: poll the active session and emit messages
15
+ * const handle = startWatcher({
16
+ * onMessages: (msgs) => console.log(msgs),
17
+ * });
18
+ * // later: handle.stop();
19
+ */
20
+
21
+ import { existsSync, openSync, readFileSync, statSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { classifyMessage, parseNewMessages } from "./parser.js";
25
+ import { getAllSessions, getLatestSession } from "./session.js";
26
+ import type {
27
+ CodexMessage,
28
+ CodexSession,
29
+ WatcherHandle,
30
+ WatcherOptions,
31
+ } from "./types.js";
32
+
33
+ export type { CodexMessage, CodexSession, WatcherHandle, WatcherOptions };
34
+ export { classifyMessage, getAllSessions, getLatestSession };
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // One-shot API
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** Read all visible messages from a rollout file, starting from a given ordinal. */
41
+ export async function getVisibleMessages(
42
+ rolloutPath: string,
43
+ options: { fromOrdinal?: number; limit?: number } = {},
44
+ ): Promise<CodexMessage[]> {
45
+ const { fromOrdinal = 0, limit } = options;
46
+ if (!existsSync(rolloutPath)) return [];
47
+
48
+ const content = readFileSync(rolloutPath, "utf8");
49
+ const lines = content.split("\n").filter((l: string) => l.trim());
50
+
51
+ let messages = parseNewMessages(lines, fromOrdinal);
52
+ if (limit) messages = messages.slice(-limit);
53
+
54
+ if (messages.length > 0) {
55
+ messages[messages.length - 1] = {
56
+ ...messages[messages.length - 1],
57
+ isLatest: true,
58
+ };
59
+ }
60
+
61
+ return messages;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Streaming watcher API
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Start watching the active Codex session and call `onMessages` whenever
70
+ * new visible messages appear in the rollout file.
71
+ *
72
+ * @returns WatcherHandle — call handle.stop() to stop watching
73
+ *
74
+ * The watcher:
75
+ * 1. Polls session_index.jsonl to detect active session changes
76
+ * 2. Tracks file position so it only reads new lines (no re-read of full file)
77
+ * 3. Calls onMessages with incremental messages each poll cycle
78
+ */
79
+ export function startWatcher(options: WatcherOptions = {}): WatcherHandle {
80
+ const {
81
+ pollIntervalMs = 2000,
82
+ codexDir,
83
+ onMessages,
84
+ onSessionChange,
85
+ } = options;
86
+
87
+ const home = codexDir ?? join(homedir(), ".codex");
88
+ let stopped = false;
89
+ let currentFd: ReturnType<typeof openSync> | null = null;
90
+ let currentPath: string | null = null;
91
+ let currentOrdinal = 0;
92
+ let pollCount = 0;
93
+ const maxIdlePolls = options.maxIdlePolls ?? Infinity;
94
+
95
+ // Track last known session to detect changes
96
+ let lastSessionId: string | null = null;
97
+
98
+ function stop() {
99
+ stopped = true;
100
+ if (currentFd !== null) {
101
+ try {
102
+ const { closeSync } = require("node:fs") as typeof import("node:fs");
103
+ closeSync(currentFd);
104
+ } catch {
105
+ // ignore
106
+ }
107
+ }
108
+ }
109
+
110
+ function isRunning() {
111
+ return !stopped;
112
+ }
113
+
114
+ function latestSession() {
115
+ return getLatestSession(home);
116
+ }
117
+
118
+ function latestOrdinal() {
119
+ return currentOrdinal;
120
+ }
121
+
122
+ function openRollout(path: string) {
123
+ if (currentFd !== null && currentPath !== path) {
124
+ try {
125
+ const { closeSync } = require("node:fs") as typeof import("node:fs");
126
+ closeSync(currentFd);
127
+ } catch {
128
+ // ignore
129
+ }
130
+ }
131
+
132
+ // stat to get current size
133
+ const stat = statSync(path);
134
+ currentFd = openSync(path, "r");
135
+ currentPath = path;
136
+ currentOrdinal = 0;
137
+ return { fd: currentFd, size: stat.size };
138
+ }
139
+
140
+ function readNewLines(
141
+ fd: number,
142
+ knownSize: number,
143
+ ): {
144
+ lines: string[];
145
+ newSize: number;
146
+ } {
147
+ const { readSync, fstatSync } =
148
+ require("node:fs") as typeof import("node:fs");
149
+ const stat = fstatSync(fd);
150
+ const newSize = stat.size;
151
+
152
+ if (newSize <= knownSize) {
153
+ return { lines: [], newSize: knownSize };
154
+ }
155
+
156
+ const buf = Buffer.alloc(newSize - knownSize);
157
+ const bytesRead = readSync(fd, buf, 0, buf.length, knownSize);
158
+ const text = buf.subarray(0, bytesRead).toString("utf8");
159
+ const lines = text.split("\n").filter((l) => l.trim());
160
+
161
+ return { lines, newSize };
162
+ }
163
+
164
+ async function poll() {
165
+ if (stopped) return;
166
+
167
+ try {
168
+ // Check for session change
169
+ const session = getLatestSession(home);
170
+ const sessionId = session?.id ?? null;
171
+
172
+ if (sessionId !== lastSessionId) {
173
+ // New session or session ended
174
+ if (session) {
175
+ // Open new rollout
176
+ openRollout(session.rolloutPath);
177
+ currentOrdinal = 0;
178
+ // Read all existing lines as "historical"
179
+ const existing = await getVisibleMessages(session.rolloutPath, {
180
+ fromOrdinal: 0,
181
+ });
182
+ if (existing.length > 0) {
183
+ currentOrdinal = existing[existing.length - 1].ordinal;
184
+ }
185
+ onSessionChange?.(session);
186
+ } else {
187
+ onSessionChange?.(null);
188
+ }
189
+ lastSessionId = sessionId;
190
+ pollCount = 0;
191
+ }
192
+
193
+ if (!session || !currentFd || currentPath !== session.rolloutPath) {
194
+ if (!stopped) {
195
+ await sleep(pollIntervalMs);
196
+ poll();
197
+ }
198
+ return;
199
+ }
200
+
201
+ const stat = statSync(session.rolloutPath);
202
+ const { lines } = readNewLines(currentFd, stat.size);
203
+
204
+ if (lines.length > 0) {
205
+ pollCount = 0;
206
+ const messages = parseNewMessages(lines, currentOrdinal);
207
+ if (messages.length > 0) {
208
+ currentOrdinal = messages[messages.length - 1].ordinal;
209
+ messages[messages.length - 1] = {
210
+ ...messages[messages.length - 1],
211
+ isLatest: true,
212
+ };
213
+ onMessages?.(messages);
214
+ }
215
+ } else {
216
+ pollCount++;
217
+ if (pollCount > maxIdlePolls) {
218
+ stop();
219
+ return;
220
+ }
221
+ }
222
+ } catch (_err) {
223
+ // Session file may not exist yet; just wait
224
+ }
225
+
226
+ if (!stopped) {
227
+ await sleep(pollIntervalMs);
228
+ poll();
229
+ }
230
+ }
231
+
232
+ // Start polling
233
+ poll();
234
+
235
+ return { stop, isRunning, latestSession, latestOrdinal };
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Helpers
240
+ // ---------------------------------------------------------------------------
241
+
242
+ function sleep(ms: number): Promise<void> {
243
+ return new Promise((resolve) => setTimeout(resolve, ms));
244
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Parser — extracts visible messages from Codex JSONL rollout lines.
3
+ *
4
+ * Safe subset (no developer/reasoning):
5
+ * type == "response_item" && payload.role == "user" → input_text content
6
+ * type == "event_msg" && payload.type == "item_completed"
7
+ * && payload.item.type == "AgentMessage"
8
+ * && payload.item.content[].type == "Text" → visible assistant text
9
+ */
10
+
11
+ import type { CodexMessage } from "./types.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Raw event shapes (from Codex rollout JSONL)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ interface RawEvent {
18
+ ordinal: number;
19
+ timestamp: string;
20
+ type: string;
21
+ payload: unknown;
22
+ }
23
+
24
+ interface AgentMessageContent {
25
+ type: "Text";
26
+ text: string;
27
+ }
28
+
29
+ interface AgentMessageItem {
30
+ type: "AgentMessage";
31
+ content: AgentMessageContent[];
32
+ }
33
+
34
+ interface EventMsgPayload {
35
+ type: "item_completed";
36
+ item: AgentMessageItem;
37
+ }
38
+
39
+ interface ResponseItemPayload {
40
+ type: string;
41
+ role?: string;
42
+ content?: Array<{ type: string; text?: string }>;
43
+ phase?: string;
44
+ }
45
+
46
+ interface RawEventMsg extends RawEvent {
47
+ type: "event_msg";
48
+ payload: EventMsgPayload;
49
+ }
50
+
51
+ interface RawResponseItem extends RawEvent {
52
+ type: "response_item";
53
+ payload: ResponseItemPayload;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Parse a single JSONL line into a CodexMessage
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Parse a single JSONL line from a Codex rollout file.
62
+ * Returns null if the line is not a visible message or is malformed.
63
+ */
64
+ export function parseRolloutLine(line: string): CodexMessage | null {
65
+ if (!line.trim()) return null;
66
+
67
+ let raw: RawEvent;
68
+ try {
69
+ raw = JSON.parse(line) as RawEvent;
70
+ } catch {
71
+ return null;
72
+ }
73
+
74
+ // Assistant: visible text from AgentMessage item_completed events
75
+ if (isEventMsg(raw)) {
76
+ const msg = parseAssistantMessage(raw);
77
+ if (msg) return msg;
78
+ }
79
+
80
+ // User: input_text from response_item events
81
+ if (isResponseItem(raw)) {
82
+ const msg = parseUserMessage(raw);
83
+ if (msg) return msg;
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Parse all visible messages from a JSONL file.
91
+ * Reads from a given byte offset so callers can do incremental reads.
92
+ *
93
+ * @param lines Lines to parse (typically newly read lines)
94
+ * @param latestOrdinal The last ordinal already known (messages with ordinal ≤ this are skipped)
95
+ * @returns Messages with ordinal > latestOrdinal
96
+ */
97
+ export function parseNewMessages(
98
+ lines: string[],
99
+ latestOrdinal = 0,
100
+ ): CodexMessage[] {
101
+ const messages: CodexMessage[] = [];
102
+ for (const line of lines) {
103
+ const msg = parseRolloutLine(line);
104
+ if (msg && msg.ordinal > latestOrdinal) {
105
+ messages.push(msg);
106
+ }
107
+ }
108
+ // Sort by ordinal (ascending) to preserve order
109
+ messages.sort((a, b) => a.ordinal - b.ordinal);
110
+ return messages;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Type guards
115
+ // ---------------------------------------------------------------------------
116
+
117
+ function isEventMsg(raw: RawEvent): raw is RawEventMsg {
118
+ return raw.type === "event_msg";
119
+ }
120
+
121
+ function isResponseItem(raw: RawEvent): raw is RawResponseItem {
122
+ return raw.type === "response_item";
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Parse specific message types
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function parseAssistantMessage(raw: RawEventMsg): CodexMessage | null {
130
+ const p = raw.payload as EventMsgPayload;
131
+ if (p.type !== "item_completed") return null;
132
+ if (p.item?.type !== "AgentMessage") return null;
133
+
134
+ // Collect all Text content blocks
135
+ const texts: string[] = [];
136
+ for (const content of p.item.content ?? []) {
137
+ if (content.type === "Text" && content.text) {
138
+ texts.push(content.text);
139
+ }
140
+ }
141
+
142
+ if (texts.length === 0) return null;
143
+
144
+ return {
145
+ ordinal: raw.ordinal,
146
+ timestamp: raw.timestamp,
147
+ role: "assistant",
148
+ text: texts.join("\n"),
149
+ isLatest: false,
150
+ };
151
+ }
152
+
153
+ function parseUserMessage(raw: RawResponseItem): CodexMessage | null {
154
+ const p = raw.payload;
155
+ if (p.type !== "message") return null;
156
+ if (p.role !== "user") return null;
157
+
158
+ const texts: string[] = [];
159
+ for (const content of p.content ?? []) {
160
+ if (content.type === "input_text" && content.text) {
161
+ texts.push(content.text);
162
+ }
163
+ }
164
+
165
+ if (texts.length === 0) return null;
166
+
167
+ return {
168
+ ordinal: raw.ordinal,
169
+ timestamp: raw.timestamp,
170
+ role: "user",
171
+ text: texts.join("\n"),
172
+ isLatest: false,
173
+ };
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Text extraction utilities
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Extract code blocks from assistant message text.
182
+ * Useful for detecting "plan" vs "code" in the Codex output.
183
+ */
184
+ export function extractCodeBlocks(text: string): string[] {
185
+ const blocks: string[] = [];
186
+ for (const match of text.matchAll(/```[\w]*\n?([\s\S]*?)```/g)) {
187
+ blocks.push(match[1]?.trim() ?? "");
188
+ }
189
+ return blocks;
190
+ }
191
+
192
+ /**
193
+ * Detect whether the message is a plan (markdown outline) vs code output.
194
+ * Returns "plan" if it looks like a planning document, "code" if mostly code,
195
+ * "mixed" if both, "unknown" otherwise.
196
+ */
197
+ export function classifyMessage(
198
+ text: string,
199
+ ): "plan" | "code" | "mixed" | "unknown" {
200
+ const codeBlocks = extractCodeBlocks(text);
201
+ const codeCharCount = codeBlocks.join("").replace(/\s/g, "").length;
202
+ const nonCodeCharCount = text
203
+ .replace(/```[\s\S]*?```/g, "")
204
+ .replace(/\s/g, "").length;
205
+
206
+ if (codeCharCount === 0 && nonCodeCharCount > 0) return "plan";
207
+ if (nonCodeCharCount === 0 && codeCharCount > 0) return "code";
208
+ if (codeCharCount > 0 && nonCodeCharCount > 0) return "mixed";
209
+ return "unknown";
210
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Session discovery — reads ~/.codex/session_index.jsonl to find active sessions.
3
+ */
4
+
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import type { CodexSession } from "./types.js";
9
+
10
+ /** Find the most recently active Codex session. */
11
+ export function getLatestSession(codexDir?: string): CodexSession | null {
12
+ const indexPath = getSessionIndexPath(codexDir);
13
+ if (!existsSync(indexPath)) return null;
14
+
15
+ let latest: SessionIndexEntry | null = null;
16
+
17
+ for (const line of readLines(indexPath)) {
18
+ const entry = parseSessionIndexLine(line);
19
+ if (!entry) continue;
20
+ if (!latest || entry.updated_at > latest.updated_at) {
21
+ latest = entry;
22
+ }
23
+ }
24
+
25
+ if (!latest) return null;
26
+
27
+ return {
28
+ id: latest.id,
29
+ threadName: latest.thread_name,
30
+ updatedAt: latest.updated_at,
31
+ rolloutPath: findRolloutPath(latest.id, codexDir),
32
+ };
33
+ }
34
+
35
+ /** Get all sessions sorted by most recent first. */
36
+ export function getAllSessions(codexDir?: string): CodexSession[] {
37
+ const indexPath = getSessionIndexPath(codexDir);
38
+ if (!existsSync(indexPath)) return [];
39
+
40
+ const sessions: SessionIndexEntry[] = [];
41
+ for (const line of readLines(indexPath)) {
42
+ const entry = parseSessionIndexLine(line);
43
+ if (entry) sessions.push(entry);
44
+ }
45
+
46
+ return sessions
47
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at))
48
+ .map((e) => ({
49
+ id: e.id,
50
+ threadName: e.thread_name,
51
+ updatedAt: e.updated_at,
52
+ rolloutPath: findRolloutPath(e.id, codexDir),
53
+ }));
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Internal helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function getSessionIndexPath(codexDir?: string): string {
61
+ const home = codexDir ?? join(homedir(), ".codex");
62
+ return join(home, "session_index.jsonl");
63
+ }
64
+
65
+ function findRolloutPath(sessionId: string, codexDir?: string): string {
66
+ const home = codexDir ?? join(homedir(), ".codex");
67
+ const sessionsDir = join(home, "sessions");
68
+
69
+ // The rollout path follows: sessions/YYYY/MM/DD/rollout-YYYY-MM-DDTHH-MM-SS-<sessionId>.jsonl
70
+ // We need to find the file whose name ends with <sessionId>.jsonl
71
+ // Since there can be multiple sessions per day, iterate all files.
72
+ try {
73
+ const { readdirSync } = require("node:fs") as typeof import("node:fs");
74
+ for (const dirPath of walkDirs(sessionsDir)) {
75
+ for (const file of readdirSync(dirPath)) {
76
+ if (file.endsWith(`${sessionId}.jsonl`)) {
77
+ return join(dirPath, file);
78
+ }
79
+ }
80
+ }
81
+ } catch {
82
+ // walkSync may not be available on all Node versions; fall back to find
83
+ }
84
+ return findRolloutPathFallback(sessionId, sessionsDir);
85
+ }
86
+
87
+ function findRolloutPathFallback(
88
+ sessionId: string,
89
+ sessionsDir: string,
90
+ ): string {
91
+ const found = walkForSession(sessionsDir, sessionId);
92
+ return found ?? join(sessionsDir, "unknown", `${sessionId}.jsonl`);
93
+ }
94
+
95
+ /** Recursively walk all subdirectories, yielding each directory path. */
96
+ function* walkDirs(dir: string): Generator<string> {
97
+ const { readdirSync, statSync } =
98
+ require("node:fs") as typeof import("node:fs");
99
+ for (const entry of readdirSync(dir)) {
100
+ const full = join(dir, entry);
101
+ const stat = statSync(full);
102
+ if (stat.isDirectory()) {
103
+ yield full;
104
+ yield* walkDirs(full);
105
+ }
106
+ }
107
+ }
108
+
109
+ /** Find the rollout file for a given session ID by walking directories. */
110
+ function walkForSession(sessionsDir: string, sessionId: string): string | null {
111
+ const { readdirSync } = require("node:fs") as typeof import("node:fs");
112
+ for (const dirPath of walkDirs(sessionsDir)) {
113
+ for (const file of readdirSync(dirPath)) {
114
+ if (file.startsWith("rollout-") && file.endsWith(`${sessionId}.jsonl`)) {
115
+ return join(dirPath, file);
116
+ }
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+
122
+ interface SessionIndexEntry {
123
+ id: string;
124
+ thread_name: string;
125
+ updated_at: string;
126
+ }
127
+
128
+ function parseSessionIndexLine(line: string): SessionIndexEntry | null {
129
+ try {
130
+ const obj = JSON.parse(line) as Record<string, unknown>;
131
+ if (typeof obj.id !== "string" || typeof obj.thread_name !== "string") {
132
+ return null;
133
+ }
134
+ return {
135
+ id: obj.id as string,
136
+ thread_name: obj.thread_name as string,
137
+ updated_at: String(obj.updated_at ?? ""),
138
+ };
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ function readLines(filePath: string): string[] {
145
+ try {
146
+ const content = readFileSync(filePath, "utf8");
147
+ return content.split("\n").filter((l) => l.trim().length > 0);
148
+ } catch {
149
+ return [];
150
+ }
151
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Codex Session Watcher — types
3
+ *
4
+ * Watches ~/.codex/ for active Codex CLI sessions and parses visible
5
+ * communication between the user and Codex (Planner role in PING-PONG).
6
+ *
7
+ * Key file locations:
8
+ * ~/.codex/session_index.jsonl — session registry
9
+ * ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl — session transcripts
10
+ *
11
+ * Visible message schema (safe subset — no developer/reasoning):
12
+ * type == "event_msg" && payload.type == "item_completed"
13
+ * && payload.item.type == "AgentMessage"
14
+ * && payload.item.content[].type == "Text"
15
+ */
16
+
17
+ export interface CodexSession {
18
+ /** Codex session ID (UUID) */
19
+ id: string;
20
+ /** Human-readable session name */
21
+ threadName: string;
22
+ /** ISO-8601 when session was last active */
23
+ updatedAt: string;
24
+ /** Absolute path to the rollout transcript file */
25
+ rolloutPath: string;
26
+ }
27
+
28
+ export interface CodexMessage {
29
+ /** Unique ordinal within the session (monotonically increasing) */
30
+ ordinal: number;
31
+ /** ISO-8601 timestamp */
32
+ timestamp: string;
33
+ /** Role: "user" (human) or "assistant" (Codex) */
34
+ role: "user" | "assistant";
35
+ /** Raw text content (markdown, code blocks, etc.) */
36
+ text: string;
37
+ /** Whether this message is the latest known message */
38
+ isLatest: boolean;
39
+ }
40
+
41
+ export interface CodexEvent {
42
+ /** Monotonically increasing ordinal within the session */
43
+ ordinal: number;
44
+ timestamp: string;
45
+ /** Raw JSON payload from the JSONL line */
46
+ raw: unknown;
47
+ }
48
+
49
+ export interface WatcherOptions {
50
+ /** How often to poll session_index.jsonl and active rollout file (ms). Default: 2000 */
51
+ pollIntervalMs?: number;
52
+ /** Absolute path to Codex home. Default: ~/.codex */
53
+ codexDir?: string;
54
+ /** Called with new visible messages each poll cycle */
55
+ onMessages?: (messages: CodexMessage[]) => void;
56
+ /** Called when a new session becomes active */
57
+ onSessionChange?: (session: CodexSession | null) => void;
58
+ /** Stop watching after N polls with no new lines. Default: Infinity */
59
+ maxIdlePolls?: number;
60
+ }
61
+
62
+ export interface WatcherHandle {
63
+ stop: () => void;
64
+ isRunning: () => boolean;
65
+ latestSession: () => CodexSession | null;
66
+ latestOrdinal: () => number;
67
+ }