handmux 0.22.0 → 0.23.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/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/handmux.js +29 -28
- package/hooks/handmux-notify.sh +2 -4
- package/hooks/handmux-write.cjs +2 -24
- package/package.json +4 -4
- package/public/assets/index-C3N7OkyP.css +32 -0
- package/public/assets/index-D1DWcC5u.js +337 -0
- package/public/index.html +2 -2
- package/src/agents/claude.js +2 -0
- package/src/agents/codex.js +60 -13
- package/src/agents/index.js +2 -3
- package/src/browser/manager.js +37 -11
- package/src/browser/publicProxy.js +23 -1
- package/src/browser/routes.js +9 -0
- package/src/browser/siteVersion.js +124 -0
- package/src/claudeEvents.js +70 -27
- package/src/cli/codexManaged.js +77 -0
- package/src/cli/hookScaffold.js +8 -10
- package/src/cli/i18n/en.js +5 -8
- package/src/cli/i18n/zh.js +5 -8
- package/src/cli/legacyCodexHooks.js +49 -0
- package/src/codexAppServer.js +1620 -0
- package/src/codexDiff.js +108 -0
- package/src/codexTranscriptParse.js +413 -0
- package/{hooks/handmux-codex-usage.cjs → src/codexUsageSnapshot.cjs} +3 -15
- package/src/httpApi.js +4 -1
- package/src/orphans.js +3 -1
- package/src/routes/codex.js +473 -0
- package/src/routes/sessions.js +7 -2
- package/src/routes/system.js +9 -22
- package/src/routes/transcript.js +75 -19
- package/src/server.js +21 -13
- package/src/tmux/commands.js +11 -3
- package/src/transcriptReader.js +5 -5
- package/src/usage.js +4 -5
- package/src/workspace/agentRunner.js +4 -1
- package/src/workspace/capture.js +54 -2
- package/src/workspace/checkpointer.js +6 -2
- package/public/assets/index-CRAlQZDz.css +0 -32
- package/public/assets/index-CzlitgRX.js +0 -335
- package/src/cli/codexHooks.js +0 -123
package/src/routes/transcript.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
// Read a pane's
|
|
2
|
-
//
|
|
1
|
+
// Read a pane's agent session as normalized chat messages. Claude reads its Hook-bound jsonl and keeps its
|
|
2
|
+
// established cwd fallback. Managed Codex gets its exact thread id from App Server, then reads that
|
|
3
|
+
// thread's durable rollout; App Server's history snapshot does not contain every completed tool.
|
|
3
4
|
// Server-side paginated — the phone must never receive the whole transcript:
|
|
4
5
|
// - Recent window (default + polling): ?pane=&limit=10&since=<hash> — the last `limit` messages, with
|
|
5
6
|
// the same content-hash `since`省流 as /history (unchanged window → 204). `hasMore`/`firstSeq` tell the
|
|
6
7
|
// client whether/where an older page starts.
|
|
7
8
|
// - History page (scroll-up, not polled): ?pane=&before=<k>&limit=10 — the last `limit` messages with
|
|
8
9
|
// `k < before`, no hash.
|
|
9
|
-
// `k` = each message's global ordinal
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// `k` = each message's current global ordinal and pagination cursor. It is stable because both Claude and
|
|
11
|
+
// Codex transcripts come from their append-only durable logs.
|
|
12
|
+
// Only the requested page is copied and decorated with `k`; the full parsed transcript is never mapped/filtered
|
|
13
|
+
// on every poll. The underlying reader asynchronously scans once,
|
|
12
14
|
// then parses only appended complete lines; replacement/truncation resets it. `limit` clamps to [1,100],
|
|
13
15
|
// default 10. Mounted under /api.
|
|
14
16
|
import express from 'express';
|
|
@@ -16,10 +18,13 @@ import path from 'node:path';
|
|
|
16
18
|
import { createHash } from 'node:crypto';
|
|
17
19
|
import { isPaneId } from '../tmux/commands.js';
|
|
18
20
|
import { projectsDir } from '../agents/claude.js';
|
|
21
|
+
import { AGENTS } from '../agents/index.js';
|
|
19
22
|
import { resolveEncodedDirSession, encodeProjectDir } from '../agents/scanUtils.js';
|
|
20
23
|
import { transcriptReader } from '../transcriptReader.js';
|
|
21
24
|
import { parsePendingPrompt } from '../pendingPrompt.js';
|
|
22
25
|
import { readClaudeContext } from '../usage.js';
|
|
26
|
+
import { resolveCodexRollout, sessionsDir as codexSessionsDir } from '../agents/codex.js';
|
|
27
|
+
import { enrichCodexFileDiffs } from '../codexDiff.js';
|
|
23
28
|
|
|
24
29
|
// Pure index-based projection: O(page size), not O(transcript size). `before` stays exclusive, including
|
|
25
30
|
// for unusual fractional cursors (the old `k < before` filter included indices through ceil(before) - 1).
|
|
@@ -36,20 +41,39 @@ export function pageTranscript(parsed, before, limit) {
|
|
|
36
41
|
};
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
export function transcriptRoutes({
|
|
44
|
+
export function transcriptRoutes({
|
|
45
|
+
commands, claudeEvents, reader = transcriptReader, codexApp,
|
|
46
|
+
findCodexRollout = resolveCodexRollout, codexSessions = codexSessionsDir(),
|
|
47
|
+
}) {
|
|
40
48
|
const r = express.Router();
|
|
41
49
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// non-Claude hook record are rejected before any cwd→newest-Claude-session fallback or screen scraping.
|
|
50
|
+
// Bind every request to the pane's actual agent before touching a session file. A caller cannot claim
|
|
51
|
+
// Claude for a Codex pane (or vice versa) and make cwd fallback expose a different agent's conversation.
|
|
45
52
|
r.use(['/pending-prompt', '/context', '/transcript'], (req, res, next) => {
|
|
46
53
|
const pane = req.query.pane;
|
|
47
54
|
if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
|
|
48
|
-
const requested = req.query.agent;
|
|
49
|
-
const
|
|
50
|
-
|
|
55
|
+
const requested = typeof req.query.agent === 'string' && req.query.agent ? req.query.agent : null;
|
|
56
|
+
const hooked = claudeEvents?.paneSession ? claudeEvents.paneSession(pane) : null;
|
|
57
|
+
const bound = (claudeEvents?.paneAgent ? claudeEvents.paneAgent(pane) : null) || hooked?.agent || null;
|
|
58
|
+
// Managed Codex is verified again through its pane-owned App Server and exact thread id below. Claude
|
|
59
|
+
// still fails closed against any conflicting Hook identity before cwd fallback.
|
|
60
|
+
if (requested && bound && requested !== bound && requested !== 'codex') {
|
|
61
|
+
return res.status(409).json({ error: 'pane agent mismatch' });
|
|
62
|
+
}
|
|
63
|
+
const id = requested === 'codex' ? 'codex' : (bound || requested || 'claude');
|
|
64
|
+
const agent = AGENTS.find((candidate) => candidate.id === id);
|
|
65
|
+
if (!agent || (agent.id !== 'codex' && !agent.transcript?.createParser)) {
|
|
51
66
|
return res.status(409).json({ error: 'chat lens unsupported for this agent' });
|
|
52
67
|
}
|
|
68
|
+
req.chatAgent = agent;
|
|
69
|
+
req.chatSession = hooked;
|
|
70
|
+
next();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Claude's pending menus and context meter come from its TUI. Codex exposes both through App Server and
|
|
74
|
+
// therefore never calls these terminal-specific endpoints.
|
|
75
|
+
r.use(['/pending-prompt', '/context'], (req, res, next) => {
|
|
76
|
+
if (req.chatAgent.id !== 'claude') return res.status(409).json({ error: 'interactive chat controls unsupported for this agent' });
|
|
53
77
|
next();
|
|
54
78
|
});
|
|
55
79
|
|
|
@@ -69,7 +93,7 @@ export function transcriptRoutes({ commands, claudeEvents, reader = transcriptRe
|
|
|
69
93
|
// The 对话 composer polls this to show a small "模型 · 24%" chip. Best-effort: never 500 on a missing file.
|
|
70
94
|
r.get('/context', (req, res, next) => {
|
|
71
95
|
try {
|
|
72
|
-
const hooked =
|
|
96
|
+
const hooked = req.chatSession;
|
|
73
97
|
const sid = hooked && (hooked.sessionId || (hooked.transcriptPath ? path.basename(hooked.transcriptPath).replace(/\.jsonl$/, '') : null));
|
|
74
98
|
const ctx = sid ? readClaudeContext(sid) : null;
|
|
75
99
|
return res.json({ model: (ctx && ctx.model) || null, usedPercent: (ctx && typeof ctx.usedPercent === 'number') ? ctx.usedPercent : null });
|
|
@@ -80,16 +104,49 @@ export function transcriptRoutes({ commands, claudeEvents, reader = transcriptRe
|
|
|
80
104
|
const limit = Math.min(Math.max(Number(req.query.limit) || 10, 1), 100);
|
|
81
105
|
const before = req.query.before != null && req.query.before !== '' ? Number(req.query.before) : null;
|
|
82
106
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
if (req.chatAgent.id === 'codex') {
|
|
108
|
+
const empty = { messages: [], hash: '', session: null, hasMore: false, firstSeq: null };
|
|
109
|
+
try {
|
|
110
|
+
const discovered = await codexApp?.discover?.(req.query.pane);
|
|
111
|
+
if (!discovered?.managed) return res.json({ ...empty, unavailable: 'session-unmanaged' });
|
|
112
|
+
const sessionId = discovered?.threadId || null;
|
|
113
|
+
if (!sessionId) return res.json({ ...empty, unavailable: 'session-unbound' });
|
|
114
|
+
const file = await findCodexRollout(codexSessions, sessionId);
|
|
115
|
+
// A newly started thread has no rollout until its first turn. Keep the exact session binding and
|
|
116
|
+
// show an empty conversation; the next poll will discover the file once Codex creates it.
|
|
117
|
+
if (!file) return res.json({ ...empty, session: sessionId });
|
|
118
|
+
const parsed = await reader.read(file, req.chatAgent.transcript.createParser);
|
|
119
|
+
const page = pageTranscript(parsed, before, limit);
|
|
120
|
+
let { messages } = page;
|
|
121
|
+
const { firstSeq, hasMore } = page;
|
|
122
|
+
const needsFileLines = messages.some((message) => message.tool?.name === 'apply_patch'
|
|
123
|
+
&& message.tool.diff && !message.tool.diff.hunks?.length);
|
|
124
|
+
if (needsFileLines && codexApp?.read) {
|
|
125
|
+
try {
|
|
126
|
+
const opened = await codexApp.read(req.query.pane, sessionId);
|
|
127
|
+
messages = enrichCodexFileDiffs(messages, opened?.thread);
|
|
128
|
+
} catch { /* The rollout remains readable; an unavailable App Server must not fabricate lines. */ }
|
|
129
|
+
}
|
|
130
|
+
if (before == null) {
|
|
131
|
+
const hash = createHash('sha1').update(JSON.stringify(messages)).digest('hex').slice(0, 16);
|
|
132
|
+
if (req.query.since === hash) return res.status(204).end();
|
|
133
|
+
return res.json({ messages, hash, session: sessionId, hasMore, firstSeq });
|
|
134
|
+
}
|
|
135
|
+
return res.json({ messages, session: sessionId, hasMore, firstSeq });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return res.json({ ...empty, unavailable: 'app-server-unavailable', detail: error?.message || String(error) });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Claude binds pane→session via Hook transcript_path, with its established cwd fallback.
|
|
86
142
|
let file = null;
|
|
87
143
|
let sessionId = null;
|
|
88
144
|
const hooked = claudeEvents && claudeEvents.paneSession ? claudeEvents.paneSession(req.query.pane) : null;
|
|
89
|
-
if (hooked
|
|
145
|
+
if (hooked?.transcriptPath) {
|
|
90
146
|
file = hooked.transcriptPath;
|
|
91
147
|
sessionId = hooked.sessionId || path.basename(file).replace(/\.jsonl$/, '');
|
|
92
148
|
}
|
|
149
|
+
const empty = { messages: [], hash: '', session: sessionId || null, hasMore: false, firstSeq: null };
|
|
93
150
|
if (!file) {
|
|
94
151
|
const cwd = await commands.paneCurrentPath(req.query.pane);
|
|
95
152
|
const dir = projectsDir();
|
|
@@ -99,9 +156,8 @@ export function transcriptRoutes({ commands, claudeEvents, reader = transcriptRe
|
|
|
99
156
|
sessionId = resolved.sessionId;
|
|
100
157
|
}
|
|
101
158
|
}
|
|
102
|
-
const empty = { messages: [], hash: '', session: sessionId || null, hasMore: false, firstSeq: null };
|
|
103
159
|
if (!file) return res.json(empty);
|
|
104
|
-
const parsed = await reader.read(file);
|
|
160
|
+
const parsed = await reader.read(file, req.chatAgent.transcript.createParser);
|
|
105
161
|
const { messages, firstSeq, hasMore } = pageTranscript(parsed, before, limit);
|
|
106
162
|
if (before == null) {
|
|
107
163
|
const hash = createHash('sha1').update(JSON.stringify(messages)).digest('hex').slice(0, 16);
|
package/src/server.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createApiRouter } from './httpApi.js';
|
|
|
8
8
|
import { loadUploadExts } from './uploadTypes.js';
|
|
9
9
|
import { createClaudeEvents } from './claudeEvents.js';
|
|
10
10
|
import { syncHooks } from './cli/claudeHooks.js';
|
|
11
|
-
import {
|
|
11
|
+
import { removeLegacyCodexHooks } from './cli/legacyCodexHooks.js';
|
|
12
12
|
import { claudeStatePath } from './cli/state.js';
|
|
13
13
|
import * as commands from './tmux/commands.js';
|
|
14
14
|
import * as push from './push.js';
|
|
@@ -26,6 +26,7 @@ import { createGracefulShutdown, createWorkspaceBackground } from './workspace/c
|
|
|
26
26
|
import { createWorkspaceRuntime } from './workspace/runtime.js';
|
|
27
27
|
import { createBrowserWorkerClient } from './browser/workerClient.js';
|
|
28
28
|
import { createTerminalStream } from './terminalStream.js';
|
|
29
|
+
import { createCodexAppServer } from './codexAppServer.js';
|
|
29
30
|
|
|
30
31
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
31
32
|
|
|
@@ -52,12 +53,14 @@ const observeEnvironment = createEnvironmentProvider({
|
|
|
52
53
|
},
|
|
53
54
|
});
|
|
54
55
|
const stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(home);
|
|
56
|
+
let codexApp = null;
|
|
55
57
|
const workspaceBackground = createWorkspaceBackground({
|
|
56
58
|
store: workspaceStore,
|
|
57
59
|
tmux: workspaceTmux,
|
|
58
60
|
observeEnvironment,
|
|
59
61
|
lock: workspaceLock,
|
|
60
62
|
stateFile,
|
|
63
|
+
getCodexApp: () => codexApp,
|
|
61
64
|
});
|
|
62
65
|
const workspace = createWorkspaceRuntime({
|
|
63
66
|
store: workspaceStore,
|
|
@@ -67,11 +70,19 @@ const workspace = createWorkspaceRuntime({
|
|
|
67
70
|
home,
|
|
68
71
|
});
|
|
69
72
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
// Claude states come from Hooks; Codex states come only from App Server. Both feed the same inbox and
|
|
74
|
+
// workspace checkpointer, but no Codex Hook state is accepted as a fallback.
|
|
75
|
+
let events = null;
|
|
76
|
+
codexApp = createCodexAppServer({
|
|
77
|
+
home,
|
|
78
|
+
onStateChange: () => {
|
|
79
|
+
if (!events) return;
|
|
80
|
+
events.getStates().then(() => workspace.requestReconcile()).catch(() => {});
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
events = createClaudeEvents({ commands, push, codexApp, file: stateFile, onStateChange: workspace.requestReconcile });
|
|
74
84
|
events.start();
|
|
85
|
+
codexApp.start();
|
|
75
86
|
workspace.start().catch(() => {});
|
|
76
87
|
|
|
77
88
|
// Keep an already-opted-in user's Claude hooks in step with this handmux version on restart: newly-added
|
|
@@ -84,12 +95,9 @@ try {
|
|
|
84
95
|
stateFile,
|
|
85
96
|
});
|
|
86
97
|
} catch { /* best effort — hook sync never fails startup */ }
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
stateFile,
|
|
91
|
-
});
|
|
92
|
-
} catch { /* best effort — hook sync never fails startup */ }
|
|
98
|
+
// Versions before App Server support installed a marked Handmux block under ~/.codex. Remove only that
|
|
99
|
+
// exact legacy block and Handmux-owned files; all user Codex configuration and third-party hooks remain.
|
|
100
|
+
try { removeLegacyCodexHooks(home); } catch { /* best effort — migration never fails startup */ }
|
|
93
101
|
|
|
94
102
|
// Static directory preview remains for folders without a web server. Arbitrary sites and local ports
|
|
95
103
|
// use the built-in browser below; previewDomain may provide its dedicated public origin.
|
|
@@ -104,14 +112,13 @@ const handmuxOrigin = (() => {
|
|
|
104
112
|
}
|
|
105
113
|
})();
|
|
106
114
|
const browserWorker = createBrowserWorkerClient({ appToken: token, previewDomain, handmuxOrigin });
|
|
107
|
-
|
|
108
115
|
const app = express();
|
|
109
116
|
// Browser proxy leases stay behind normal Handmux auth. Client-owned direct tabs never enter
|
|
110
117
|
// server state; proxy operations and all claimed Hammerhead paths use the isolated worker.
|
|
111
118
|
app.use(browserWorker.publicHandler);
|
|
112
119
|
app.use('/api/browser-proxy', expressAuth(token), express.json(), browserWorker.apiHandler);
|
|
113
120
|
app.use('/api', createApiRouter({
|
|
114
|
-
token, events, uploadExts, previews, shortcuts: cfg.shortcuts, workspace, previewDomain,
|
|
121
|
+
token, events, uploadExts, previews, shortcuts: cfg.shortcuts, workspace, previewDomain, codexApp,
|
|
115
122
|
}));
|
|
116
123
|
app.use('/preview', preview.router);
|
|
117
124
|
app.use(preview.refererFallback);
|
|
@@ -172,6 +179,7 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
172
179
|
const shutdown = createGracefulShutdown({ events, workspace, browser: browserWorker, server });
|
|
173
180
|
const handleSignal = () => {
|
|
174
181
|
terminalStream.close();
|
|
182
|
+
codexApp.close();
|
|
175
183
|
shutdown().catch(() => {});
|
|
176
184
|
};
|
|
177
185
|
process.on('SIGINT', handleSignal);
|
package/src/tmux/commands.js
CHANGED
|
@@ -53,9 +53,9 @@ export async function listWindows(sessionId) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
export async function listPanes(windowId) {
|
|
56
|
-
const out = await runTmux(['list-panes', '-t', windowId, '-F', tmuxFormat(['pane_id', 'pane_active', 'pane_width', 'pane_height', 'pane_current_command', 'pane_current_path', 'pane_left', 'pane_top'])]);
|
|
57
|
-
return parseTmuxRows(out,
|
|
58
|
-
return { id, active: active === '1', width: Number(width), height: Number(height), command, cwd, left: Number(left), top: Number(top) };
|
|
56
|
+
const out = await runTmux(['list-panes', '-t', windowId, '-F', tmuxFormat(['pane_id', 'pane_active', 'pane_width', 'pane_height', 'pane_current_command', 'pane_current_path', 'pane_left', 'pane_top', 'pane_tty'])]);
|
|
57
|
+
return parseTmuxRows(out, 9, 'pane').map(([id, active, width, height, command, cwd, left, top, tty]) => {
|
|
58
|
+
return { id, active: active === '1', width: Number(width), height: Number(height), command, cwd, left: Number(left), top: Number(top), tty };
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -250,6 +250,14 @@ async function runStartupCmd(target, cmd) {
|
|
|
250
250
|
await sendEnter(target);
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
// Type a validated single-line command into an existing pane after its foreground TUI has returned to
|
|
254
|
+
// the shell. This does not kill or replace anything; callers must first prove the previous process exited.
|
|
255
|
+
export async function runPaneCommand(paneId, cmd) {
|
|
256
|
+
if (!isPaneId(paneId)) throw new Error(`invalid pane id: ${JSON.stringify(paneId)}`);
|
|
257
|
+
if (!isValidStartupCmd(cmd)) throw new Error('invalid startup command');
|
|
258
|
+
await runStartupCmd(paneId, cmd);
|
|
259
|
+
}
|
|
260
|
+
|
|
253
261
|
// Read a pane's working directory, so a new window can open in the dir you're working in.
|
|
254
262
|
export async function paneCurrentPath(paneId) {
|
|
255
263
|
const out = await runTmux(['display-message', '-p', '-t', paneId, '#{pane_current_path}']);
|
package/src/transcriptReader.js
CHANGED
|
@@ -47,13 +47,13 @@ export function createTranscriptReader({ maxEntries = 8, yieldEvery = 500 } = {}
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
async function load(file) {
|
|
50
|
+
async function load(file, createParser) {
|
|
51
51
|
let st;
|
|
52
52
|
try { st = await fsp.stat(file); } catch { cache.delete(file); return []; }
|
|
53
53
|
if (!st.isFile()) { cache.delete(file); return []; }
|
|
54
54
|
|
|
55
55
|
let entry = cache.get(file);
|
|
56
|
-
const sameFile = entry && entry.dev === st.dev && entry.ino === st.ino;
|
|
56
|
+
const sameFile = entry && entry.createParser === createParser && entry.dev === st.dev && entry.ino === st.ino;
|
|
57
57
|
if (sameFile && entry.size === st.size && entry.mtimeMs === st.mtimeMs) {
|
|
58
58
|
entry.usedAt = Date.now();
|
|
59
59
|
return entry.parser.messages;
|
|
@@ -64,7 +64,7 @@ export function createTranscriptReader({ maxEntries = 8, yieldEvery = 500 } = {}
|
|
|
64
64
|
// leak across /clear or log rotation.
|
|
65
65
|
const append = sameFile && st.size > entry.size;
|
|
66
66
|
if (!append) {
|
|
67
|
-
entry = { parser:
|
|
67
|
+
entry = { parser: createParser(), createParser, offset: 0, dev: st.dev, ino: st.ino, size: 0, mtimeMs: 0, usedAt: 0 };
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const { lines, offset, size } = await readCompleteLines(file, entry.offset);
|
|
@@ -88,9 +88,9 @@ export function createTranscriptReader({ maxEntries = 8, yieldEvery = 500 } = {}
|
|
|
88
88
|
return entry.parser.messages;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
async function read(file) {
|
|
91
|
+
async function read(file, createParser = createTranscriptParser) {
|
|
92
92
|
if (inflight.has(file)) return inflight.get(file);
|
|
93
|
-
const run = load(file).finally(() => inflight.delete(file));
|
|
93
|
+
const run = load(file, createParser).finally(() => inflight.delete(file));
|
|
94
94
|
inflight.set(file, run);
|
|
95
95
|
return run;
|
|
96
96
|
}
|
package/src/usage.js
CHANGED
|
@@ -14,7 +14,7 @@ import { pocketHome } from './cli/state.js';
|
|
|
14
14
|
const require = createRequire(import.meta.url);
|
|
15
15
|
const {
|
|
16
16
|
readLatestUsage, readSnapshot, writeSnapshot,
|
|
17
|
-
} = require('
|
|
17
|
+
} = require('./codexUsageSnapshot.cjs');
|
|
18
18
|
|
|
19
19
|
export function claudeUsagePath(home = homedir()) { return path.join(pocketHome(home), 'claude-usage.json'); }
|
|
20
20
|
export function claudeContextDir(home = homedir()) { return path.join(pocketHome(home), 'context'); }
|
|
@@ -63,8 +63,7 @@ function rolloutFilesByMtime(dir) {
|
|
|
63
63
|
return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
//
|
|
67
|
-
// the normal path reads the persistent snapshot written by Codex hooks.
|
|
66
|
+
// Zero-config reconciliation runs at most once per calibration interval and updates a persistent cache.
|
|
68
67
|
function scanCodexUsage(home) {
|
|
69
68
|
let latest = null;
|
|
70
69
|
for (const { file, mtimeMs } of rolloutFilesByMtime(codexSessionsDir(home))) {
|
|
@@ -75,8 +74,8 @@ function scanCodexUsage(home) {
|
|
|
75
74
|
return latest;
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
// Prefer the machine-wide
|
|
79
|
-
//
|
|
77
|
+
// Prefer the machine-wide cache. A bounded rollout scan refreshes it at most once per minute; App Server
|
|
78
|
+
// supplies live account rate limits separately below.
|
|
80
79
|
export function readCodexUsage(
|
|
81
80
|
home = homedir(),
|
|
82
81
|
{ now = Date.now(), calibrationMs = 60_000 } = {},
|
|
@@ -8,6 +8,7 @@ import { ensurePrivateDir, writeJsonAtomic } from './atomicJson.js';
|
|
|
8
8
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
9
|
const PANE_RE = /^%\d+$/;
|
|
10
10
|
const RUNNER_FILE = fileURLToPath(import.meta.url);
|
|
11
|
+
const HANDMUX_BIN = fileURLToPath(new URL('../../bin/handmux.js', import.meta.url));
|
|
11
12
|
|
|
12
13
|
function errorText(error) {
|
|
13
14
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -55,7 +56,9 @@ export function launchAgentRequest(requestInput, {
|
|
|
55
56
|
settleReady({ status: 'failed', error: earlyFailure(request, error, code) });
|
|
56
57
|
};
|
|
57
58
|
try {
|
|
58
|
-
|
|
59
|
+
const command = request.cmd === 'codex' ? process.execPath : request.cmd;
|
|
60
|
+
const args = request.cmd === 'codex' ? [HANDMUX_BIN, 'codex', ...request.args] : request.args;
|
|
61
|
+
child = spawn(command, args, { stdio: 'inherit', shell: false });
|
|
59
62
|
} catch (error) {
|
|
60
63
|
failReady(error);
|
|
61
64
|
finishCompletion({ code: null, signal: null, error: errorText(error) });
|
package/src/workspace/capture.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fsp from 'node:fs/promises';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { AGENTS } from '../agents/index.js';
|
|
4
|
+
import { resolveCodexRollout, sessionsDir as codexSessionsDir } from '../agents/codex.js';
|
|
4
5
|
import { canonicalizeSnapshot, WORKSPACE_SCHEMA_VERSION } from './schema.js';
|
|
5
6
|
|
|
6
7
|
function agentMap(agents) {
|
|
@@ -20,8 +21,11 @@ export async function readAgentBindings(stateFile, agents = AGENTS, readFile = f
|
|
|
20
21
|
const bindings = new Map();
|
|
21
22
|
for (const [paneRuntimeId, record] of Object.entries(state)) {
|
|
22
23
|
if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
// This is the Claude Hook state file. Legacy Codex-tagged rows are deliberately ignored; managed
|
|
25
|
+
// Codex identity belongs to App Server and must not be reconstructed from the removed Hook path.
|
|
26
|
+
if (record.agent !== undefined && record.agent !== 'claude') continue;
|
|
27
|
+
const id = 'claude';
|
|
28
|
+
if (!known.has(id)) continue;
|
|
25
29
|
const payload = record.payload;
|
|
26
30
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) continue;
|
|
27
31
|
const sessionId = payload.session_id;
|
|
@@ -37,6 +41,47 @@ export async function readAgentBindings(stateFile, agents = AGENTS, readFile = f
|
|
|
37
41
|
return bindings;
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
function topologyPanes(topology) {
|
|
45
|
+
return topology.windows.flatMap((window) => window.panes);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readManagedCodexBindings(topology, {
|
|
49
|
+
codexApp,
|
|
50
|
+
codexSessions = codexSessionsDir(),
|
|
51
|
+
agents = AGENTS,
|
|
52
|
+
findRollout = resolveCodexRollout,
|
|
53
|
+
access = fsp.access,
|
|
54
|
+
stat = fsp.stat,
|
|
55
|
+
} = {}) {
|
|
56
|
+
if (typeof codexApp?.discover !== 'function') return new Map();
|
|
57
|
+
const panes = topologyPanes(topology);
|
|
58
|
+
const discovered = new Map(await Promise.all(panes.map(async (pane) => {
|
|
59
|
+
try {
|
|
60
|
+
const state = await codexApp.discover(pane.runtimeId);
|
|
61
|
+
return [pane.runtimeId, state?.managed ? state.threadId : null];
|
|
62
|
+
} catch {
|
|
63
|
+
return [pane.runtimeId, null];
|
|
64
|
+
}
|
|
65
|
+
})));
|
|
66
|
+
|
|
67
|
+
const driver = agents.find((agent) => agent.id === 'codex');
|
|
68
|
+
if (!driver) return new Map();
|
|
69
|
+
const bindings = new Map();
|
|
70
|
+
for (const pane of panes) {
|
|
71
|
+
const sessionId = discovered.get(pane.runtimeId);
|
|
72
|
+
if (!driver.sessions?.isId(sessionId)) continue;
|
|
73
|
+
let transcriptPath;
|
|
74
|
+
try {
|
|
75
|
+
transcriptPath = await findRollout(codexSessions, sessionId);
|
|
76
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath) continue;
|
|
77
|
+
await access(transcriptPath, constants.R_OK);
|
|
78
|
+
if (!(await stat(transcriptPath)).isFile()) continue;
|
|
79
|
+
} catch { continue; }
|
|
80
|
+
bindings.set(pane.runtimeId, { id: 'codex', sessionId, transcriptPath });
|
|
81
|
+
}
|
|
82
|
+
return bindings;
|
|
83
|
+
}
|
|
84
|
+
|
|
40
85
|
export function attachBindings(topology, bindings) {
|
|
41
86
|
return {
|
|
42
87
|
...topology,
|
|
@@ -59,6 +104,9 @@ export async function captureWorkspace({
|
|
|
59
104
|
readFile = fsp.readFile,
|
|
60
105
|
access = fsp.access,
|
|
61
106
|
stat = fsp.stat,
|
|
107
|
+
codexApp,
|
|
108
|
+
codexSessions = codexSessionsDir(),
|
|
109
|
+
findCodexRollout = resolveCodexRollout,
|
|
62
110
|
now = Date.now,
|
|
63
111
|
}) {
|
|
64
112
|
try {
|
|
@@ -67,6 +115,10 @@ export async function captureWorkspace({
|
|
|
67
115
|
const topology = await tmux.captureTopology();
|
|
68
116
|
if (!topology || topology.status === 'unknown') return { status: 'unknown' };
|
|
69
117
|
const bindings = await readAgentBindings(stateFile, agents, readFile, access, stat);
|
|
118
|
+
const managedCodex = await readManagedCodexBindings(topology, {
|
|
119
|
+
codexApp, codexSessions, agents, findRollout: findCodexRollout, access, stat,
|
|
120
|
+
});
|
|
121
|
+
for (const [pane, binding] of managedCodex) bindings.set(pane, binding);
|
|
70
122
|
const after = await tmux.topologyFingerprint();
|
|
71
123
|
if (isUnknownFingerprint(after)) return { status: 'unknown' };
|
|
72
124
|
if (before !== after) return { status: 'changed-during-capture' };
|
|
@@ -152,13 +152,17 @@ export function createCheckpointer({
|
|
|
152
152
|
};
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
export function createWorkspaceBackground({
|
|
155
|
+
export function createWorkspaceBackground({
|
|
156
|
+
store, tmux, observeEnvironment, lock, stateFile, getCodexApp = () => null, codexSessions, now = Date.now,
|
|
157
|
+
} = {}) {
|
|
156
158
|
return createCheckpointer({
|
|
157
159
|
store,
|
|
158
160
|
observeEnvironment,
|
|
159
161
|
lock,
|
|
160
162
|
now,
|
|
161
|
-
capture: (environment) => captureWorkspace({
|
|
163
|
+
capture: (environment) => captureWorkspace({
|
|
164
|
+
tmux, stateFile, environment, codexApp: getCodexApp(), codexSessions, now,
|
|
165
|
+
}),
|
|
162
166
|
});
|
|
163
167
|
}
|
|
164
168
|
|