handmux 0.22.1 → 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 +1 -1
- package/README.zh-CN.md +1 -1
- package/bin/handmux.js +29 -28
- package/hooks/handmux-notify.sh +2 -4
- package/hooks/handmux-write.cjs +2 -24
- package/package.json +3 -3
- 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/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-ClOByNCn.js +0 -335
- package/public/assets/index-rjrtUwoJ.css +0 -32
- package/src/cli/codexHooks.js +0 -123
package/public/index.html
CHANGED
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
@keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
|
49
49
|
@media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
|
|
50
50
|
</style>
|
|
51
|
-
<script type="module" crossorigin src="/assets/index-
|
|
52
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
51
|
+
<script type="module" crossorigin src="/assets/index-D1DWcC5u.js"></script>
|
|
52
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C3N7OkyP.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-C3N7OkyP.css"></noscript>
|
|
53
53
|
</head>
|
|
54
54
|
<body>
|
|
55
55
|
<div id="boot-splash" aria-hidden="true">
|
package/src/agents/claude.js
CHANGED
|
@@ -12,6 +12,7 @@ import path from 'node:path';
|
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import { resolveEncodedDirSession, isSessionUuid } from './scanUtils.js';
|
|
14
14
|
import { resolveByExecutable, executableBasename } from './processIdentity.js';
|
|
15
|
+
import { createTranscriptParser, parseTranscript } from '../transcriptParse.js';
|
|
15
16
|
|
|
16
17
|
// The NATIVE installer names the real binary by version (~/.local/share/claude/versions/2.1.196 —
|
|
17
18
|
// ~/.local/bin/claude is only a symlink to it), and tmux #{pane_current_command} follows that basename
|
|
@@ -142,6 +143,7 @@ export const claude = {
|
|
|
142
143
|
procMatch: /^(\S*\/)?claude(\s|$)/,
|
|
143
144
|
takeoverPrefix: 'cc', // tmux session name prefix for a takeover (cc-<label>-<n>)
|
|
144
145
|
classify: classifyClaude,
|
|
146
|
+
transcript: { createParser: createTranscriptParser, parse: parseTranscript },
|
|
145
147
|
sessions: {
|
|
146
148
|
isId: isSessionUuid,
|
|
147
149
|
dirOptKey: 'projectsDir', // scanOrphans option that overrides `dir` (back-compat + tests)
|
package/src/agents/codex.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// the SAME lifecycle events (UserPromptSubmit / PermissionRequest / Stop …) delivering the SAME stdin JSON
|
|
4
|
-
// fields (prompt / tool_input / last_assistant_message / stop_hook_active …). So handmux wires Codex through
|
|
5
|
-
// its config.toml hooks (see cli/codexHooks.js) reusing the shared Claude hook scripts, and Codex CLASSIFIES
|
|
6
|
-
// with the very same function as Claude — full working / 需要你 / done parity, not just turn-done.
|
|
1
|
+
// Codex process/session discovery. App Server owns chat, approvals, stopping, inbox state and exact thread
|
|
2
|
+
// identity; plain Codex is identified only as a live process until the user opts into managed mode.
|
|
7
3
|
import path from 'node:path';
|
|
8
4
|
import os from 'node:os';
|
|
9
5
|
import { promises as fsp } from 'node:fs';
|
|
10
6
|
import { readHead, readTail, firstCwd, isSessionUuid } from './scanUtils.js';
|
|
11
|
-
import { classifyClaude } from './claude.js';
|
|
12
7
|
import { resolveByExecutable, executableBasename } from './processIdentity.js';
|
|
8
|
+
import {
|
|
9
|
+
createCodexTranscriptParser, isCodexSyntheticUserText, parseCodexTranscript,
|
|
10
|
+
} from '../codexTranscriptParse.js';
|
|
13
11
|
|
|
14
12
|
export const sessionsDir = (home = os.homedir()) => path.join(home, '.codex', 'sessions');
|
|
15
13
|
|
|
@@ -20,10 +18,25 @@ export function rolloutSessionId(name) {
|
|
|
20
18
|
return m ? m[1] : null;
|
|
21
19
|
}
|
|
22
20
|
|
|
21
|
+
const UUID_PARTS = '([0-9a-f]{8})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{12})';
|
|
22
|
+
|
|
23
|
+
function lastUuidAfter(text, prefix) {
|
|
24
|
+
const matches = [...String(text || '').matchAll(new RegExp(`${prefix}\\s*${UUID_PARTS}`, 'gi'))];
|
|
25
|
+
const match = matches.at(-1);
|
|
26
|
+
return match ? match.slice(1).join('-').toLowerCase() : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A normal Codex exit prints the exact command for the session that just exited. Keep this deliberately
|
|
30
|
+
// stricter than a generic `codex resume` search so conversation text or shell history is not mistaken for
|
|
31
|
+
// the current exit notice.
|
|
32
|
+
export function codexExitSessionId(text) {
|
|
33
|
+
return lastUuidAfter(text, 'To continue this session, run\\s+codex\\s+resume');
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
// Last user turn out of a Codex rollout tail, for a recognizable one-line label. Codex records turns as
|
|
24
37
|
// response_item messages: {payload:{type:'message',role:'user',content:[{type:'input_text',text}]}} (and a
|
|
25
|
-
// flatter {type:'message',role:'user',...} in some versions). Skips the synthetic
|
|
26
|
-
// (
|
|
38
|
+
// flatter {type:'message',role:'user',...} in some versions). Skips the synthetic context turns Codex
|
|
39
|
+
// injects (including repository instructions after a resume). Best-effort → '' on miss.
|
|
27
40
|
export function codexUserSnippet(tailText, max = 80) {
|
|
28
41
|
const rows = String(tailText).split('\n');
|
|
29
42
|
for (let i = rows.length - 1; i >= 0; i--) {
|
|
@@ -38,8 +51,9 @@ export function codexUserSnippet(tailText, max = 80) {
|
|
|
38
51
|
let text = '';
|
|
39
52
|
if (typeof c === 'string') text = c;
|
|
40
53
|
else if (Array.isArray(c)) text = c.filter((b) => b && (b.type === 'input_text' || b.type === 'text')).map((b) => b.text).join(' ');
|
|
54
|
+
if (!text || isCodexSyntheticUserText(text)) continue;
|
|
41
55
|
text = text.replace(/\s+/g, ' ').trim();
|
|
42
|
-
if (!text
|
|
56
|
+
if (!text) continue;
|
|
43
57
|
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
44
58
|
}
|
|
45
59
|
return '';
|
|
@@ -82,6 +96,39 @@ async function recentRollouts(dir, limit = 80) {
|
|
|
82
96
|
return out.slice(0, limit);
|
|
83
97
|
}
|
|
84
98
|
|
|
99
|
+
// Resolve one known App Server thread id to its exact rollout. This never falls back by cwd: several
|
|
100
|
+
// Codex panes can share a project, while the UUID suffix is the durable one-to-one session identity.
|
|
101
|
+
const rolloutPathCache = new Map();
|
|
102
|
+
export async function resolveCodexRollout(dir, sessionId) {
|
|
103
|
+
if (!isSessionUuid(sessionId)) return null;
|
|
104
|
+
const key = `${dir}\0${sessionId}`;
|
|
105
|
+
const cached = rolloutPathCache.get(key);
|
|
106
|
+
if (cached) {
|
|
107
|
+
try { await fsp.access(cached); return cached; } catch { rolloutPathCache.delete(key); }
|
|
108
|
+
}
|
|
109
|
+
const suffix = `-${sessionId}.jsonl`;
|
|
110
|
+
let found = null;
|
|
111
|
+
async function descend(current, depth) {
|
|
112
|
+
if (found) return;
|
|
113
|
+
let entries;
|
|
114
|
+
try { entries = await fsp.readdir(current, { withFileTypes: true }); } catch { return; }
|
|
115
|
+
if (depth < 3) {
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (!entry.isDirectory()) continue;
|
|
118
|
+
await descend(path.join(current, entry.name), depth + 1);
|
|
119
|
+
if (found) return;
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const match = entries.find((entry) => entry.isFile()
|
|
124
|
+
&& entry.name.startsWith('rollout-') && entry.name.endsWith(suffix));
|
|
125
|
+
if (match) found = path.join(current, match.name);
|
|
126
|
+
}
|
|
127
|
+
await descend(dir, 0);
|
|
128
|
+
if (found) rolloutPathCache.set(key, found);
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
|
|
85
132
|
// Resolve a live orphan's cwd to its Codex session: the newest rollout whose recorded cwd matches. Same
|
|
86
133
|
// shape as Claude's resolver ({ sessionId, state, snippet, lastActivity }) so the orphan engine is agnostic.
|
|
87
134
|
export async function resolveCodexSession(dir, cwd, { busyMs = 8000, now = Date.now } = {}) {
|
|
@@ -94,7 +141,7 @@ export async function resolveCodexSession(dir, cwd, { busyMs = 8000, now = Date.
|
|
|
94
141
|
if (!sessionId) continue;
|
|
95
142
|
let snippet = '';
|
|
96
143
|
try { snippet = codexUserSnippet(await readTail(file)); } catch { /* best effort */ }
|
|
97
|
-
return { sessionId, state: now() - mtime < busyMs ? 'busy' : 'idle', snippet, lastActivity: Math.round(mtime) };
|
|
144
|
+
return { sessionId, state: now() - mtime < busyMs ? 'busy' : 'idle', snippet, lastActivity: Math.round(mtime), file };
|
|
98
145
|
}
|
|
99
146
|
return {};
|
|
100
147
|
}
|
|
@@ -107,8 +154,7 @@ export const codex = {
|
|
|
107
154
|
procNames: ['codex'],
|
|
108
155
|
procMatch: /^(\S*\/)?codex(\s|$)/,
|
|
109
156
|
takeoverPrefix: 'cx', // tmux session name prefix for a takeover (cx-<label>-<n>)
|
|
110
|
-
|
|
111
|
-
|
|
157
|
+
transcript: { createParser: createCodexTranscriptParser, parse: parseCodexTranscript },
|
|
112
158
|
sessions: {
|
|
113
159
|
isId: isSessionUuid,
|
|
114
160
|
dirOptKey: 'sessionsDir', // scanOrphans option that overrides `dir`
|
|
@@ -117,5 +163,6 @@ export const codex = {
|
|
|
117
163
|
// `codex resume <uuid>` continues the session from its rollout file.
|
|
118
164
|
resumeArgs: (id) => ['codex', 'resume', id],
|
|
119
165
|
resumeCmd: (id) => `codex resume ${id}`,
|
|
166
|
+
managedResumeCmd: (id) => `handmux codex resume ${id}`,
|
|
120
167
|
},
|
|
121
168
|
};
|
package/src/agents/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// The agent registry: the one place that lists which coding agents handmux drives. Adding a third agent is a
|
|
2
|
-
// new driver module (see claude.js / codex.js) plus one line here — the inbox pipeline
|
|
3
|
-
//
|
|
4
|
-
// so none of them hard-codes an agent name.
|
|
2
|
+
// new driver module (see claude.js / codex.js) plus one line here — the inbox pipeline and orphan/takeover
|
|
3
|
+
// engine consume drivers through this list, so process and session behavior stays centralized.
|
|
5
4
|
import { claude } from './claude.js';
|
|
6
5
|
import { codex } from './codex.js';
|
|
7
6
|
|
package/src/claudeEvents.js
CHANGED
|
@@ -13,9 +13,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
13
13
|
// Default lives under server/data (gitignored runtime data); override with CLAUDE_STATE_FILE.
|
|
14
14
|
export const DEFAULT_STATE_FILE = process.env.CLAUDE_STATE_FILE || path.resolve(here, '../data/claude-state.json');
|
|
15
15
|
|
|
16
|
-
// Classify a Claude hook event → inbox kind.
|
|
17
|
-
// re-exported here because tests and callers import it by this path. Per-pane classification in getStates
|
|
18
|
-
// dispatches through getAgent(entry.agent) so Codex (and future agents) classify with their own driver.
|
|
16
|
+
// Classify a Claude hook event → inbox kind. Re-exported because tests and callers import this path.
|
|
19
17
|
export const classifyEvent = claude.classify;
|
|
20
18
|
|
|
21
19
|
// Which display VIEW a kind pushes as — and so what the device notification fires for. permission→需要你,
|
|
@@ -28,10 +26,8 @@ const VIEW_LABEL = { needs: '需要你', done: '已完成' };
|
|
|
28
26
|
|
|
29
27
|
// The dedup key for a pane's current push view. `needs` is view-only: Claude signals one permission gate via
|
|
30
28
|
// TWO hooks (permreq then permission_prompt) at different ts, and both must collapse to a SINGLE 需要你. But
|
|
31
|
-
// `done` is ts-sensitive: each finished turn is a fresh "已完成 / 该你了". For Claude, two dones are
|
|
32
|
-
// separated by a 进行中 that re-arms the dedup
|
|
33
|
-
// ONLY event is turn-complete, with no working/prompt event in between — it's what makes turn 2, 3, … push
|
|
34
|
-
// instead of latching on turn 1's done forever.
|
|
29
|
+
// `done` is ts-sensitive: each finished turn is a fresh "已完成 / 该你了". For Claude, two dones are normally
|
|
30
|
+
// separated by a 进行中 that re-arms the dedup; the timestamp also protects unusual missing-start sequences.
|
|
35
31
|
function pushKey(view, ts) { return view === 'done' ? `done:${ts}` : view; }
|
|
36
32
|
|
|
37
33
|
// A 进行中 (working) is a LATCHED state: set by UserPromptSubmit, normally closed by Stop. But an ESC
|
|
@@ -146,6 +142,7 @@ function readStateFile(file) {
|
|
|
146
142
|
export function createClaudeEvents({
|
|
147
143
|
commands,
|
|
148
144
|
push,
|
|
145
|
+
codexApp = null,
|
|
149
146
|
file = DEFAULT_STATE_FILE,
|
|
150
147
|
now = () => Date.now(),
|
|
151
148
|
statMtime = defaultStatMtime,
|
|
@@ -169,6 +166,7 @@ export function createClaudeEvents({
|
|
|
169
166
|
function prime() {
|
|
170
167
|
const recorded = readStateFile(file);
|
|
171
168
|
for (const [pane, r] of Object.entries(recorded)) {
|
|
169
|
+
if (r?.agent === 'codex') continue; // legacy rows are never a Codex state source
|
|
172
170
|
const c = r && typeof r.src === 'string' ? getAgent(r.agent).classify(r.src, r.payload || {}) : null;
|
|
173
171
|
const view = c ? PUSH_VIEW[c.kind] : undefined;
|
|
174
172
|
if (view) lastPushed[pane] = pushKey(view, r.ts); // resting 需要你/已完成 → treat as seen, don't replay
|
|
@@ -190,6 +188,37 @@ export function createClaudeEvents({
|
|
|
190
188
|
try { await push.sendToSession(lp.session, payload, opts); } catch { /* best effort */ }
|
|
191
189
|
}
|
|
192
190
|
|
|
191
|
+
async function updatePush(pane, c, ts, lp, { gone = false, suppressPush = false } = {}) {
|
|
192
|
+
const kind = c ? c.kind : null;
|
|
193
|
+
const view = PUSH_VIEW[kind];
|
|
194
|
+
const key = view ? pushKey(view, ts) : undefined;
|
|
195
|
+
if (kind === 'idle') return;
|
|
196
|
+
if (gone || !view) {
|
|
197
|
+
lastPushed[pane] = null;
|
|
198
|
+
} else if (suppressPush) {
|
|
199
|
+
lastPushed[pane] = key;
|
|
200
|
+
} else if (lastPushed[pane] !== key && lp?.session) {
|
|
201
|
+
lastPushed[pane] = key;
|
|
202
|
+
await sendPush(pane, view, c, lp);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Agent identity is part of pane navigation, not inbox state. Resolve it from the pane's live foreground
|
|
207
|
+
// process so the phone can choose terminal/chat during the initial /panes load instead of waiting for the
|
|
208
|
+
// slower /states reconciliation pass. Work on copies because executable corroboration normalizes `cmd`.
|
|
209
|
+
async function identifyPaneAgents(panes = []) {
|
|
210
|
+
const livePanes = panes.map((pane) => ({
|
|
211
|
+
...pane,
|
|
212
|
+
cmd: typeof pane?.cmd === 'string' ? pane.cmd : (pane?.command || ''),
|
|
213
|
+
}));
|
|
214
|
+
try { await resolveVersionedComms(livePanes, run, commVerdicts); } catch { /* exact names still work */ }
|
|
215
|
+
try { await resolveCodexComms(livePanes, run, commVerdicts); } catch { /* exact names still work */ }
|
|
216
|
+
return Object.fromEntries(livePanes.flatMap((pane) => {
|
|
217
|
+
const agent = agentForProc(pane.cmd);
|
|
218
|
+
return agent ? [[pane.id, agent.id]] : [];
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
|
|
193
222
|
// Read the state file, reconcile every recorded pane against live tmux, and in ONE pass: (1) fire push
|
|
194
223
|
// for 需要你/已完成 transitions (deduped, global — independent of any caller's session filter), and
|
|
195
224
|
// (2) build the pane→state roster the inbox shows. A pane is dropped from the roster when its latest
|
|
@@ -201,17 +230,21 @@ export function createClaudeEvents({
|
|
|
201
230
|
const allow = allowedSessions == null ? null : new Set(allowedSessions);
|
|
202
231
|
const recorded = readStateFile(file);
|
|
203
232
|
let live = null;
|
|
233
|
+
let managedCodex = {};
|
|
204
234
|
try {
|
|
205
235
|
const panes = await commands.listLivePanes();
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
await resolveVersionedComms(panes, run, commVerdicts);
|
|
209
|
-
await resolveCodexComms(panes, run, commVerdicts);
|
|
236
|
+
const agents = await identifyPaneAgents(panes);
|
|
237
|
+
for (const pane of panes) if (agents[pane.id]) pane.cmd = agents[pane.id];
|
|
210
238
|
live = new Map(panes.map((p) => [p.id, p]));
|
|
239
|
+
managedCodex = codexApp?.inboxStates ? await codexApp.inboxStates(panes) : {};
|
|
211
240
|
} catch { /* tmux down */ }
|
|
212
241
|
|
|
213
242
|
const out = {};
|
|
214
243
|
for (const [pane, rec] of Object.entries(recorded)) {
|
|
244
|
+
// Older Handmux versions wrote Codex events into this Claude Hook state file. Codex is now strictly
|
|
245
|
+
// App Server-backed, so even an unmanaged pane must ignore those stale rows instead of reviving an
|
|
246
|
+
// approximate status or notification.
|
|
247
|
+
if (rec?.agent === 'codex') continue;
|
|
215
248
|
const agent = getAgent(rec && rec.agent);
|
|
216
249
|
let c = rec && typeof rec.src === 'string' ? agent.classify(rec.src, rec.payload || {}) : null;
|
|
217
250
|
// A 需要你 the user already resolved leaves no closing hook (see PERM_RESOLVED_GUARD_MS). statMtime
|
|
@@ -247,17 +280,7 @@ export function createClaudeEvents({
|
|
|
247
280
|
// 60s "still waiting" idle neither pushes nor disturbs the dedup (the pane is still in the same
|
|
248
281
|
// resting state). working / end / gone / unclassifiable re-arm the dedup for the next entry.
|
|
249
282
|
if (live) {
|
|
250
|
-
|
|
251
|
-
const view = PUSH_VIEW[kind]; // 'needs' | 'done' | undefined
|
|
252
|
-
const key = view ? pushKey(view, rec.ts) : undefined;
|
|
253
|
-
if (kind === 'idle') {
|
|
254
|
-
/* trailing idle reminder — no push, keep the dedup as the preceding done left it */
|
|
255
|
-
} else if (gone || !view) {
|
|
256
|
-
lastPushed[pane] = null; // 进行中 / 结束 / gone → re-arm for the next 需要你 / 已完成
|
|
257
|
-
} else if (lastPushed[pane] !== key && lp.session) {
|
|
258
|
-
lastPushed[pane] = key;
|
|
259
|
-
await sendPush(pane, view, c, lp);
|
|
260
|
-
}
|
|
283
|
+
await updatePush(pane, c, rec.ts, lp, { gone });
|
|
261
284
|
}
|
|
262
285
|
|
|
263
286
|
// (2) roster — drop ended / dead / claude-exited panes; resolve location from the live tmux row.
|
|
@@ -271,6 +294,22 @@ export function createClaudeEvents({
|
|
|
271
294
|
out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0, agent: agent.id };
|
|
272
295
|
}
|
|
273
296
|
|
|
297
|
+
// App Server is the sole Codex state source. A neutral state still leaves process presence below to
|
|
298
|
+
// identify the pane as Codex without guessing its activity.
|
|
299
|
+
if (live) {
|
|
300
|
+
for (const [pane, state] of Object.entries(managedCodex)) {
|
|
301
|
+
const lp = live.get(pane);
|
|
302
|
+
if (!lp) continue;
|
|
303
|
+
const c = state.kind ? { kind: state.kind, msg: state.msg || '' } : null;
|
|
304
|
+
await updatePush(pane, c, state.ts || 0, lp, { suppressPush: state.suppressPush });
|
|
305
|
+
if (allow && !allow.has(lp.session)) continue;
|
|
306
|
+
out[pane] = {
|
|
307
|
+
session: lp.session, window: lp.window, windowName: lp.windowName,
|
|
308
|
+
kind: c?.kind || null, msg: c?.msg || '', ...(c ? { ts: state.ts || 0 } : {}), agent: 'codex',
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
274
313
|
// (3) process presence — a pane whose FOREGROUND program IS a coding agent reads as "agent here" even
|
|
275
314
|
// with no active turn on record: a fresh session that hasn't prompted yet, or one right after `/clear`
|
|
276
315
|
// (whose SessionEnd dropped its roster entry above though the agent is still running). This is what the
|
|
@@ -334,23 +373,27 @@ export function createClaudeEvents({
|
|
|
334
373
|
// isn't a Claude pane / the recorded payload carries no session info, so callers can fall back cleanly.
|
|
335
374
|
function paneSession(pane) {
|
|
336
375
|
const rec = readStateFile(file)[pane];
|
|
376
|
+
if (rec?.agent === 'codex') return null;
|
|
337
377
|
const p = rec && rec.payload;
|
|
338
378
|
if (!p || typeof p !== 'object') return null;
|
|
339
379
|
const transcriptPath = typeof p.transcript_path === 'string' ? p.transcript_path : null;
|
|
340
380
|
const sessionId = typeof p.session_id === 'string' ? p.session_id : null;
|
|
341
381
|
const cwd = typeof p.cwd === 'string' ? p.cwd : null;
|
|
342
382
|
if (!transcriptPath && !sessionId) return null;
|
|
343
|
-
return {
|
|
383
|
+
return {
|
|
384
|
+
sessionId, transcriptPath, cwd,
|
|
385
|
+
agent: typeof rec.agent === 'string' ? rec.agent : undefined,
|
|
386
|
+
};
|
|
344
387
|
}
|
|
345
388
|
|
|
346
|
-
// Agent identity is needed independently of session metadata
|
|
347
|
-
//
|
|
348
|
-
// back to an unrelated Claude jsonl. Untagged legacy hook rows are Claude.
|
|
389
|
+
// Agent identity is needed independently of session metadata so the chat lens chooses the matching
|
|
390
|
+
// backend (Claude transcript or Codex App Server). Untagged legacy hook rows are Claude.
|
|
349
391
|
function paneAgent(pane) {
|
|
350
392
|
const rec = readStateFile(file)[pane];
|
|
351
393
|
if (!rec || typeof rec !== 'object') return null;
|
|
394
|
+
if (rec.agent === 'codex') return null;
|
|
352
395
|
return typeof rec.agent === 'string' && rec.agent ? rec.agent : 'claude';
|
|
353
396
|
}
|
|
354
397
|
|
|
355
|
-
return { getStates, start, stop, paneSession, paneAgent };
|
|
398
|
+
return { getStates, identifyPaneAgents, start, stop, paneSession, paneAgent };
|
|
356
399
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { spawn as spawnChild } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const PANE_RE = /^%\d+$/;
|
|
7
|
+
|
|
8
|
+
export function codexAppSocketPath(pane, home = os.homedir()) {
|
|
9
|
+
if (!PANE_RE.test(pane || '')) throw new Error('managed Codex requires a tmux pane');
|
|
10
|
+
return path.join(home, '.handmux', 'codex-app', `${pane.slice(1)}.sock`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function waitForSocket(socketPath, child, {
|
|
14
|
+
exists = fs.existsSync,
|
|
15
|
+
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
16
|
+
timeoutMs = 5_000,
|
|
17
|
+
now = Date.now,
|
|
18
|
+
} = {}) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
let exited = false;
|
|
21
|
+
let exitCode = null;
|
|
22
|
+
child.once('exit', (code) => { exited = true; exitCode = code; });
|
|
23
|
+
child.once('error', reject);
|
|
24
|
+
(async () => {
|
|
25
|
+
const started = now();
|
|
26
|
+
while (!exists(socketPath)) {
|
|
27
|
+
if (exited) throw new Error(`Codex App Server exited before startup (${exitCode ?? 'unknown'})`);
|
|
28
|
+
if (now() - started >= timeoutMs) throw new Error('Codex App Server startup timed out');
|
|
29
|
+
await wait(25);
|
|
30
|
+
}
|
|
31
|
+
resolve();
|
|
32
|
+
})().catch(reject);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function finish(child) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
child.once('error', reject);
|
|
39
|
+
child.once('exit', (code, signal) => resolve({ code, signal }));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One App Server belongs to one tmux pane. The TUI and Handmux connect to that pane-owned Unix socket,
|
|
44
|
+
// so thread identity, messages, approvals, settings, interrupts and inbox state share one runtime.
|
|
45
|
+
export async function runManagedCodex(args = [], {
|
|
46
|
+
home = os.homedir(),
|
|
47
|
+
env = process.env,
|
|
48
|
+
spawn = spawnChild,
|
|
49
|
+
mkdir = (dir) => fs.mkdirSync(dir, { recursive: true, mode: 0o700 }),
|
|
50
|
+
unlink = (file) => { try { fs.unlinkSync(file); } catch (error) { if (error?.code !== 'ENOENT') throw error; } },
|
|
51
|
+
waitOptions,
|
|
52
|
+
} = {}) {
|
|
53
|
+
const pane = env.TMUX_PANE;
|
|
54
|
+
const socketPath = codexAppSocketPath(pane, home);
|
|
55
|
+
mkdir(path.dirname(socketPath));
|
|
56
|
+
unlink(socketPath);
|
|
57
|
+
|
|
58
|
+
const managedEnv = { ...env, HANDMUX_CODEX_MANAGED: '1' };
|
|
59
|
+
const appServer = spawn('codex', ['app-server', '--listen', `unix://${socketPath}`], {
|
|
60
|
+
env: managedEnv,
|
|
61
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
62
|
+
});
|
|
63
|
+
let tui = null;
|
|
64
|
+
try {
|
|
65
|
+
await waitForSocket(socketPath, appServer, waitOptions);
|
|
66
|
+
tui = spawn('codex', ['--remote', `unix://${socketPath}`, ...args], {
|
|
67
|
+
env: managedEnv,
|
|
68
|
+
stdio: 'inherit',
|
|
69
|
+
});
|
|
70
|
+
const result = await finish(tui);
|
|
71
|
+
return Number.isInteger(result.code) ? result.code : 1;
|
|
72
|
+
} finally {
|
|
73
|
+
if (tui && tui.exitCode == null && tui.signalCode == null) tui.kill('SIGTERM');
|
|
74
|
+
if (appServer.exitCode == null && appServer.signalCode == null) appServer.kill('SIGTERM');
|
|
75
|
+
unlink(socketPath);
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/cli/hookScaffold.js
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
// Shared IO scaffolding for the
|
|
2
|
-
// statusLine capturer. The FORMAT each installer merges into differs (settings.json vs config.toml's marked
|
|
3
|
-
// region vs the single statusLine slot) and stays in its own module — but the plumbing underneath is
|
|
4
|
-
// identical: deploy the shared scripts, write the env, remove them on uninstall, and write atomically.
|
|
1
|
+
// Shared IO scaffolding for the Claude Code hook installer and statusLine capturer.
|
|
5
2
|
import fs from 'node:fs';
|
|
6
3
|
import path from 'node:path';
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export const HOOK_SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs', 'handmux-codex-usage.cjs'];
|
|
5
|
+
export const HOOK_SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs'];
|
|
6
|
+
const LEGACY_HOOK_SCRIPTS = ['handmux-codex-usage.cjs'];
|
|
11
7
|
|
|
12
8
|
// Atomic write (tmp + rename) so a crash can't leave a half-written config file. Text in, text out — callers
|
|
13
9
|
// pass raw TOML for config.toml, or use writeJsonAtomic for pretty-printed settings.json.
|
|
@@ -18,18 +14,20 @@ export function writeFileAtomic(file, text) {
|
|
|
18
14
|
}
|
|
19
15
|
export const writeJsonAtomic = (file, obj) => writeFileAtomic(file, JSON.stringify(obj, null, 2));
|
|
20
16
|
|
|
21
|
-
// Deploy the
|
|
22
|
-
// bytes for every agent — only the registration target (settings.json vs config.toml) differs per installer.
|
|
17
|
+
// Deploy the Claude notify/write scripts into `hooksDir` and point their env at the shared state file.
|
|
23
18
|
export function deployHookScripts(hooksDir, srcDir, stateFile) {
|
|
24
19
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
25
20
|
for (const f of HOOK_SCRIPTS) fs.copyFileSync(path.join(srcDir, f), path.join(hooksDir, f));
|
|
21
|
+
for (const f of LEGACY_HOOK_SCRIPTS) {
|
|
22
|
+
try { fs.unlinkSync(path.join(hooksDir, f)); } catch { /* absent */ }
|
|
23
|
+
}
|
|
26
24
|
fs.chmodSync(path.join(hooksDir, 'handmux-notify.sh'), 0o755);
|
|
27
25
|
fs.writeFileSync(path.join(hooksDir, 'handmux-notify.env'), `HANDMUX_STATE=${stateFile}\n`, { mode: 0o600 });
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
// Remove the deployed scripts + env (uninstall). Best-effort: a missing file is fine.
|
|
31
29
|
export function removeHookScripts(hooksDir) {
|
|
32
|
-
for (const f of [...HOOK_SCRIPTS, 'handmux-notify.env']) {
|
|
30
|
+
for (const f of [...HOOK_SCRIPTS, ...LEGACY_HOOK_SCRIPTS, 'handmux-notify.env']) {
|
|
33
31
|
try { fs.unlinkSync(path.join(hooksDir, f)); } catch { /* already gone */ }
|
|
34
32
|
}
|
|
35
33
|
}
|
package/src/cli/i18n/en.js
CHANGED
|
@@ -126,15 +126,11 @@ export default {
|
|
|
126
126
|
'access.hint': ' handmux status | stop',
|
|
127
127
|
|
|
128
128
|
// hooks
|
|
129
|
-
'hooks.confirmEnable': 'Enable
|
|
130
|
-
'hooks.installedShort': '✓ Agent hooks installed.',
|
|
129
|
+
'hooks.confirmEnable': 'Enable Claude Code states and notifications (inbox)?',
|
|
131
130
|
'hooks.noClaude': 'Claude Code not detected (~/.claude missing) — nothing to install.',
|
|
132
|
-
'hooks.noAgents': 'No coding agent detected (no ~/.claude, and codex not on PATH) — nothing to install.',
|
|
133
|
-
'hooks.installed': '✓ Claude hooks installed → ~/.claude/settings.json',
|
|
134
131
|
'hooks.installedClaude': '✓ Claude Code hooks installed → ~/.claude/settings.json',
|
|
135
|
-
'hooks.
|
|
136
|
-
'hooks.
|
|
137
|
-
'hooks.removed': '✓ Agent hooks removed.',
|
|
132
|
+
'hooks.installedHint': ' Restart or open a new Claude Code session to load them; the inbox lights up as panes report.',
|
|
133
|
+
'hooks.removed': '✓ Claude Code hooks removed.',
|
|
138
134
|
'hooks.usage': 'usage: handmux hooks install|uninstall',
|
|
139
135
|
|
|
140
136
|
// Claude statusLine usage capturer (powers the phone Usage page's 5h/weekly bars)
|
|
@@ -341,6 +337,7 @@ export default {
|
|
|
341
337
|
handmux stop | restart | status
|
|
342
338
|
handmux logs [--follow] [--lines N]
|
|
343
339
|
handmux push <title> <body> notify your phone from a script (--session X · --device K · --tag T · --url U)
|
|
340
|
+
handmux codex [args...] launch a Codex TUI synchronized with chat view
|
|
344
341
|
handmux restore [--dry-run] [--checkpoint <id>] [--session <name>]
|
|
345
342
|
handmux restore --list
|
|
346
343
|
handmux update upgrade to the latest published version (npm i -g handmux@latest)
|
|
@@ -350,7 +347,7 @@ The model: 'start' runs · 'setup' configures (writes ~/.handmux/config.json) ·
|
|
|
350
347
|
|
|
351
348
|
more:
|
|
352
349
|
handmux config show the effective config + where each value came from
|
|
353
|
-
handmux hooks install|uninstall enable/disable
|
|
350
|
+
handmux hooks install|uninstall enable/disable Claude Code notifications (inbox)
|
|
354
351
|
handmux service install|uninstall start at login (launchd/systemd)
|
|
355
352
|
handmux help flags one-run flags + env vars (scripting / headless / Docker)
|
|
356
353
|
--config PATH · --lang en|zh · --version, -v
|
package/src/cli/i18n/zh.js
CHANGED
|
@@ -125,15 +125,11 @@ export default {
|
|
|
125
125
|
'access.hint': ' handmux status | stop',
|
|
126
126
|
|
|
127
127
|
// hooks
|
|
128
|
-
'hooks.confirmEnable': '
|
|
129
|
-
'hooks.installedShort': '✓ agent hooks 已安装。',
|
|
128
|
+
'hooks.confirmEnable': '启用 Claude Code 状态与通知(收件箱)?',
|
|
130
129
|
'hooks.noClaude': '未检测到 Claude Code(缺少 ~/.claude)—— 无需安装。',
|
|
131
|
-
'hooks.noAgents': '未检测到编程 agent(无 ~/.claude,且 PATH 上没有 codex)—— 无需安装。',
|
|
132
|
-
'hooks.installed': '✓ Claude hooks 已安装 → ~/.claude/settings.json',
|
|
133
130
|
'hooks.installedClaude': '✓ Claude Code hooks 已安装 → ~/.claude/settings.json',
|
|
134
|
-
'hooks.
|
|
135
|
-
'hooks.
|
|
136
|
-
'hooks.removed': '✓ agent hooks 已移除。',
|
|
131
|
+
'hooks.installedHint': ' 重启或新开一个 Claude Code 会话以加载;窗格上报后收件箱就会亮起。',
|
|
132
|
+
'hooks.removed': '✓ Claude Code hooks 已移除。',
|
|
137
133
|
'hooks.usage': '用法:handmux hooks install|uninstall',
|
|
138
134
|
|
|
139
135
|
// Claude statusLine 用量捕获(点亮手机用量页的 5h/周额度条)
|
|
@@ -340,6 +336,7 @@ export default {
|
|
|
340
336
|
handmux stop | restart | status
|
|
341
337
|
handmux logs [--follow] [--lines N]
|
|
342
338
|
handmux push <标题> <正文> 从脚本推一条通知到手机(--session 会话 · --device 设备key · --tag · --url)
|
|
339
|
+
handmux codex [参数...] 启动与对话视图同步的 Codex TUI
|
|
343
340
|
handmux restore [--dry-run] [--checkpoint <id>] [--session <name>]
|
|
344
341
|
handmux restore --list
|
|
345
342
|
handmux update 升级到最新发布版本(npm i -g handmux@latest)
|
|
@@ -349,7 +346,7 @@ export default {
|
|
|
349
346
|
|
|
350
347
|
更多:
|
|
351
348
|
handmux config 显示生效的配置 + 每一项来自哪里
|
|
352
|
-
handmux hooks install|uninstall 启用/停用
|
|
349
|
+
handmux hooks install|uninstall 启用/停用 Claude Code 通知(收件箱)
|
|
353
350
|
handmux service install|uninstall 开机自启(launchd/systemd)
|
|
354
351
|
handmux help flags 一次性 flag + 环境变量(脚本 / 无头 / Docker)
|
|
355
352
|
--config PATH · --lang en|zh · --version, -v
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Cleanup for Handmux versions that used to install Codex lifecycle hooks. Current Codex integration is
|
|
2
|
+
// App Server-only; this module can only remove Handmux's exact marked region and files, never install or
|
|
3
|
+
// synchronize hooks and never touch user-owned Codex configuration outside the marker.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { writeFileAtomic } from './hookScaffold.js';
|
|
8
|
+
|
|
9
|
+
const BEGIN = '# >>> handmux codex-hooks >>>';
|
|
10
|
+
const END = '# <<< handmux codex-hooks <<<';
|
|
11
|
+
const LEGACY_FILES = [
|
|
12
|
+
'handmux-notify.sh',
|
|
13
|
+
'handmux-write.cjs',
|
|
14
|
+
'handmux-codex-usage.cjs',
|
|
15
|
+
'handmux-notify.env',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function stripLegacyCodexHooks(toml) {
|
|
19
|
+
const text = toml || '';
|
|
20
|
+
const start = text.indexOf(BEGIN);
|
|
21
|
+
if (start < 0) return text;
|
|
22
|
+
const end = text.indexOf(END, start);
|
|
23
|
+
if (end < 0) return text;
|
|
24
|
+
return (text.slice(0, start) + text.slice(end + END.length)).replace(/\n{3,}/g, '\n\n');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function removeLegacyCodexHooks(home = homedir()) {
|
|
28
|
+
const codexDir = path.join(home, '.codex');
|
|
29
|
+
const configuredPath = path.join(codexDir, 'config.toml');
|
|
30
|
+
let changed = false;
|
|
31
|
+
try {
|
|
32
|
+
const configFile = fs.lstatSync(configuredPath).isSymbolicLink()
|
|
33
|
+
? fs.realpathSync(configuredPath) : configuredPath;
|
|
34
|
+
const mode = fs.statSync(configFile).mode & 0o777;
|
|
35
|
+
const before = fs.readFileSync(configFile, 'utf8');
|
|
36
|
+
const after = stripLegacyCodexHooks(before);
|
|
37
|
+
if (after !== before) {
|
|
38
|
+
writeFileAtomic(configFile, after);
|
|
39
|
+
fs.chmodSync(configFile, mode);
|
|
40
|
+
changed = true;
|
|
41
|
+
}
|
|
42
|
+
} catch { /* no readable config */ }
|
|
43
|
+
|
|
44
|
+
const hooksDir = path.join(codexDir, 'hooks');
|
|
45
|
+
for (const name of LEGACY_FILES) {
|
|
46
|
+
try { fs.unlinkSync(path.join(hooksDir, name)); changed = true; } catch { /* absent or user-inaccessible */ }
|
|
47
|
+
}
|
|
48
|
+
return { changed };
|
|
49
|
+
}
|