handmux 0.24.0 → 0.25.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 +29 -6
- package/README.zh-CN.md +29 -6
- package/bin/handmux.js +15 -3
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +200 -35
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +45 -3
- package/dist/hooks/handmux-write.cjs +175 -10
- package/dist/package.json +10 -9
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +22 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +52 -7
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +27 -9
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +17 -3
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +11 -2
- package/dist/src/claudeEvents.js +35 -9
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +49 -3
- package/dist/src/cli/codexManaged.js +6 -3
- package/dist/src/cli/hookScaffold.js +4 -1
- package/dist/src/cli/i18n/en.js +30 -4
- package/dist/src/cli/i18n/zh.js +30 -4
- package/dist/src/cli/piExtension.js +155 -0
- package/dist/src/cli/service.js +19 -9
- package/dist/src/cli/state.js +3 -0
- package/dist/src/cli/statusLine.js +19 -11
- package/dist/src/codexAppServer.js +1296 -159
- package/dist/src/codexQueueProtocol.js +41 -0
- package/dist/src/codexTranscriptParse.js +256 -145
- package/dist/src/codexUsageSnapshot.js +29 -6
- package/dist/src/git.js +13 -1
- package/dist/src/httpApi.js +35 -7
- package/dist/src/orphans.js +13 -3
- package/dist/src/paneInput.js +42 -0
- package/dist/src/privateStateStore.js +35 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/git.js +11 -0
- package/dist/src/routes/sessions.js +16 -4
- package/dist/src/routes/system.js +69 -5
- package/dist/src/routes/terminal.js +22 -16
- package/dist/src/server.js +109 -6
- package/dist/src/tmux/commands.js +29 -1
- package/dist/src/transcriptParse.js +22 -1
- package/dist/src/transcriptReader.js +100 -4
- package/dist/src/uploadTypes.js +2 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +103 -6
- package/dist/src/workspace/restore.js +51 -2
- package/dist/src/workspace/tmuxAdapter.js +10 -0
- package/package.json +6 -5
- package/dist/public/assets/index-CicdY9E9.css +0 -32
- package/dist/public/assets/index-D4VP5t8h.js +0 -337
- package/dist/src/routes/codex.js +0 -606
- package/dist/src/routes/transcript.js +0 -197
- package/dist/src/usage.js +0 -260
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
# stdin = Claude Code 原始 payload(JSON).
|
|
5
5
|
# (resume = PostToolUse on AskUserQuestion/ExitPlanMode:答完选项/批准计划 → 状态翻回进行中、带所选项。)
|
|
6
6
|
# (permreq = PermissionRequest:真实弹框一出现就发、带 tool_name → 比 permission_prompt 早亮「需要你」。)
|
|
7
|
-
#
|
|
8
|
-
# 不依赖服务进程是否在跑 → 永不阻塞 Claude(始终 exit 0)
|
|
7
|
+
# 只做本地落盘:更新 JSON latest state,并在启用时追加有界 Bridge event spool。不联网、
|
|
8
|
+
# 不依赖服务进程是否在跑 → 永不阻塞 Claude(始终 exit 0)。服务端/Connector 稍后消费这些文件。
|
|
9
9
|
# 真正的读-改-写交给同目录的 handmux-write.js(node:真 JSON 解析 + 文件锁,多 pane 并发 hook 不丢更新)。
|
|
10
10
|
CFG="$(dirname "$0")/handmux-notify.env"
|
|
11
11
|
[ -f "$CFG" ] && . "$CFG"
|
|
@@ -16,7 +16,49 @@ FILE="${HANDMUX_STATE:-$HOME/.claude/handmux-state.json}"
|
|
|
16
16
|
TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null)
|
|
17
17
|
[ -z "$TS" ] && TS=$(( $(date +%s) * 1000 ))
|
|
18
18
|
HOST=$(hostname 2>/dev/null || printf '')
|
|
19
|
+
# Capture the owning Claude process rather than this short-lived Hook process. The fingerprint lets the
|
|
20
|
+
# Connector reject an offline event after tmux has reused the pane for a different Claude generation.
|
|
21
|
+
# Missing/incomplete identity is intentionally tolerated for older Claude/platform variants: the writer
|
|
22
|
+
# then omits the additive field and the legacy latest-state path keeps working.
|
|
23
|
+
CLAUDE_PID=''
|
|
24
|
+
CLAUDE_STARTED_AT=''
|
|
25
|
+
CLAUDE_TTY=''
|
|
26
|
+
# Claude runs configured hooks asynchronously and may reparent the short-lived shell before it starts, so
|
|
27
|
+
# PPID is not authoritative. Resolve the exact `claude` process on this tmux pane's TTY first;
|
|
28
|
+
# retain ancestor walking only as a fallback for platforms where tmux/targeted ps is unavailable.
|
|
29
|
+
PANE_TTY=$(tmux display-message -p -t "$PANE" '#{pane_tty}' 2>/dev/null || printf '')
|
|
30
|
+
if [ -n "$PANE_TTY" ]; then
|
|
31
|
+
# During an async Hook Claude may block waiting for the child and temporarily lose the foreground `+`.
|
|
32
|
+
# Prefer the sole exact process; if more than one exact Claude shares the TTY, require one foreground owner.
|
|
33
|
+
CLAUDE_PROCESSES=$(ps -t "${PANE_TTY#/dev/}" -o pid=,stat=,comm= 2>/dev/null || printf '')
|
|
34
|
+
CLAUDE_PID=$(printf '%s\n' "$CLAUDE_PROCESSES" \
|
|
35
|
+
| awk '$3 == "claude" { count += 1; only = $1 } END { if (count == 1) print only }')
|
|
36
|
+
if [ -z "$CLAUDE_PID" ]; then
|
|
37
|
+
CLAUDE_PID=$(printf '%s\n' "$CLAUDE_PROCESSES" \
|
|
38
|
+
| awk '$3 == "claude" && $2 ~ /\+/ { count += 1; only = $1 } END { if (count == 1) print only }')
|
|
39
|
+
fi
|
|
40
|
+
fi
|
|
41
|
+
if [ -z "$CLAUDE_PID" ]; then
|
|
42
|
+
ANCESTOR_PID=$PPID
|
|
43
|
+
ANCESTOR_DEPTH=0
|
|
44
|
+
while [ -n "$ANCESTOR_PID" ] && [ "$ANCESTOR_PID" -gt 1 ] 2>/dev/null && [ "$ANCESTOR_DEPTH" -lt 32 ]; do
|
|
45
|
+
ANCESTOR_COMMAND=$(ps -p "$ANCESTOR_PID" -o comm= 2>/dev/null || printf '')
|
|
46
|
+
ANCESTOR_COMMAND=${ANCESTOR_COMMAND##*/}
|
|
47
|
+
if [ "$ANCESTOR_COMMAND" = 'claude' ]; then
|
|
48
|
+
CLAUDE_PID=$ANCESTOR_PID
|
|
49
|
+
break
|
|
50
|
+
fi
|
|
51
|
+
ANCESTOR_PID=$(ps -p "$ANCESTOR_PID" -o ppid= 2>/dev/null | tr -d ' ')
|
|
52
|
+
ANCESTOR_DEPTH=$((ANCESTOR_DEPTH + 1))
|
|
53
|
+
done
|
|
54
|
+
fi
|
|
55
|
+
if [ -n "$CLAUDE_PID" ]; then
|
|
56
|
+
# `lstart` follows the process locale on macOS; force C so standalone Node can parse it consistently.
|
|
57
|
+
CLAUDE_STARTED_AT=$(LC_ALL=C ps -p "$CLAUDE_PID" -o lstart= 2>/dev/null || printf '')
|
|
58
|
+
CLAUDE_TTY=$(ps -p "$CLAUDE_PID" -o tty= 2>/dev/null || printf '')
|
|
59
|
+
fi
|
|
19
60
|
# payload 经 stdin 原样流给 node(不在 shell 里转义,避免坏数据);pane 含 '%' 直接进 JSON 字段,
|
|
20
61
|
# 不再进 URL → 彻底告别旧的 "%110 被 url-decode 丢弃" 那类坑。
|
|
21
|
-
node "$(dirname "$0")/handmux-write.cjs" "$FILE" "$PANE" "$1" "$TS" "$HOST"
|
|
62
|
+
node "$(dirname "$0")/handmux-write.cjs" "$FILE" "$PANE" "$1" "$TS" "$HOST" \
|
|
63
|
+
"${HANDMUX_CLAUDE_EVENTS:-}" "$CLAUDE_PID" "$CLAUDE_STARTED_AT" "$CLAUDE_TTY" 2>/dev/null || true
|
|
22
64
|
exit 0
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// handmux hook writer. Updates
|
|
3
|
-
//
|
|
4
|
-
// node handmux-write.cjs <file> <pane> <src> <ts> <host>
|
|
2
|
+
// handmux hook writer. Updates one JSON latest-state file keyed by tmux pane and, when configured,
|
|
3
|
+
// appends the same effective lifecycle edge to a bounded durable Connector spool. Invoked by notify.sh:
|
|
4
|
+
// node handmux-write.cjs <file> <pane> <src> <ts> <host> <event-dir>
|
|
5
|
+
// [claude-pid] [claude-lstart] [claude-tty]
|
|
5
6
|
//
|
|
6
7
|
// .cjs (not .js): runs standalone via `node <file>`, including from ~/.claude/hooks (no package.json) —
|
|
7
8
|
// .cjs forces CommonJS everywhere; a bare .js under the server tree ("type":"module") would be ESM and
|
|
@@ -16,9 +17,37 @@
|
|
|
16
17
|
const fs = require('node:fs');
|
|
17
18
|
const path = require('node:path');
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
+
const crypto = require('node:crypto');
|
|
21
|
+
|
|
22
|
+
const [
|
|
23
|
+
, , file, pane, src, ts, host = '', eventDirectory = '',
|
|
24
|
+
claudePid = '', claudeStartedAt = '', claudeTty = '',
|
|
25
|
+
] = process.argv;
|
|
20
26
|
if (!file || !pane || !src) process.exit(0);
|
|
21
27
|
|
|
28
|
+
function processFingerprint() {
|
|
29
|
+
const pid = Number(String(claudePid).trim());
|
|
30
|
+
const startedAt = Date.parse(String(claudeStartedAt).trim());
|
|
31
|
+
const rawTty = String(claudeTty).trim();
|
|
32
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isFinite(startedAt)
|
|
33
|
+
|| !rawTty || rawTty === '?' || rawTty === '??') return undefined;
|
|
34
|
+
return {
|
|
35
|
+
pid,
|
|
36
|
+
startedAt,
|
|
37
|
+
tty: rawTty.startsWith('/dev/') ? rawTty : `/dev/${rawTty}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sourceProcess = processFingerprint();
|
|
42
|
+
|
|
43
|
+
function sameSourceProcess(previous) {
|
|
44
|
+
const prior = previous?.process;
|
|
45
|
+
if (!sourceProcess || !prior) return true; // retain compatibility with rows from older Hook versions
|
|
46
|
+
return prior.pid === sourceProcess.pid
|
|
47
|
+
&& prior.startedAt === sourceProcess.startedAt
|
|
48
|
+
&& prior.tty === sourceProcess.tty;
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
let payload = {};
|
|
23
52
|
try { payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { /* unreadable stdin → {} */ }
|
|
24
53
|
|
|
@@ -51,6 +80,116 @@ function ensurePrivateDirectory(directory) {
|
|
|
51
80
|
}
|
|
52
81
|
}
|
|
53
82
|
|
|
83
|
+
const MAX_EVENT_SPOOL_BYTES = 16 * 1024 * 1024;
|
|
84
|
+
|
|
85
|
+
function readEvent(fileName) {
|
|
86
|
+
try {
|
|
87
|
+
const value = JSON.parse(fs.readFileSync(fileName, 'utf8'));
|
|
88
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
89
|
+
} catch { return null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function allocateSequence(minimum = 0) {
|
|
93
|
+
if (!eventDirectory) return null;
|
|
94
|
+
ensurePrivateDirectory(eventDirectory);
|
|
95
|
+
const sequenceFile = path.join(eventDirectory, '.sequence');
|
|
96
|
+
let current = Number.isSafeInteger(minimum) && minimum > 0 ? minimum : 0;
|
|
97
|
+
try {
|
|
98
|
+
const parsed = Number(fs.readFileSync(sequenceFile, 'utf8'));
|
|
99
|
+
if (Number.isSafeInteger(parsed) && parsed >= 0) current = Math.max(current, parsed);
|
|
100
|
+
} catch { /* first event */ }
|
|
101
|
+
if (current === 0) {
|
|
102
|
+
try {
|
|
103
|
+
for (const name of fs.readdirSync(eventDirectory)) {
|
|
104
|
+
const match = /^event-(\d+)-/.exec(name);
|
|
105
|
+
if (match) current = Math.max(current, Number(match[1]) || 0);
|
|
106
|
+
}
|
|
107
|
+
} catch { /* empty spool */ }
|
|
108
|
+
}
|
|
109
|
+
const next = current + 1;
|
|
110
|
+
const temporary = `${sequenceFile}.${process.pid}.tmp`;
|
|
111
|
+
fs.writeFileSync(temporary, String(next), { mode: 0o600 });
|
|
112
|
+
fs.renameSync(temporary, sequenceFile);
|
|
113
|
+
fs.chmodSync(sequenceFile, 0o600);
|
|
114
|
+
return next;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function safeSequence(minimum = 0) {
|
|
118
|
+
try { return allocateSequence(minimum); } catch { return null; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeGapMarker(value) {
|
|
122
|
+
if (!value || !value.paneId) return;
|
|
123
|
+
const paneKey = crypto.createHash('sha256').update(String(value.paneId)).digest('hex').slice(0, 16);
|
|
124
|
+
const marker = path.join(eventDirectory, `gap-${paneKey}.json`);
|
|
125
|
+
const temporary = `${marker}.${process.pid}.tmp`;
|
|
126
|
+
fs.writeFileSync(temporary, JSON.stringify({
|
|
127
|
+
version: 1,
|
|
128
|
+
type: 'gap',
|
|
129
|
+
agent: 'claude',
|
|
130
|
+
eventId: `claude-gap-${Date.now()}-${process.pid}`,
|
|
131
|
+
paneId: value.paneId,
|
|
132
|
+
sessionId: typeof value.sessionId === 'string' ? value.sessionId : undefined,
|
|
133
|
+
...(value.process ? { process: value.process } : {}),
|
|
134
|
+
}), { mode: 0o600 });
|
|
135
|
+
fs.renameSync(temporary, marker);
|
|
136
|
+
fs.chmodSync(marker, 0o600);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pruneEventSpool() {
|
|
140
|
+
const entries = [];
|
|
141
|
+
let total = 0;
|
|
142
|
+
for (const name of fs.readdirSync(eventDirectory)) {
|
|
143
|
+
if (!/^event-\d+-\d+\.json$/.test(name)) continue;
|
|
144
|
+
const eventFile = path.join(eventDirectory, name);
|
|
145
|
+
try {
|
|
146
|
+
const size = fs.statSync(eventFile).size;
|
|
147
|
+
entries.push({ name, file: eventFile, size });
|
|
148
|
+
total += size;
|
|
149
|
+
} catch { /* concurrently consumed */ }
|
|
150
|
+
}
|
|
151
|
+
entries.sort((first, second) => first.name.localeCompare(second.name));
|
|
152
|
+
const gaps = new Map();
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (total <= MAX_EVENT_SPOOL_BYTES) break;
|
|
155
|
+
const dropped = readEvent(entry.file);
|
|
156
|
+
if (dropped?.paneId) gaps.set(dropped.paneId, {
|
|
157
|
+
paneId: dropped.paneId,
|
|
158
|
+
sessionId: dropped.sessionId,
|
|
159
|
+
process: dropped.process,
|
|
160
|
+
});
|
|
161
|
+
try { fs.unlinkSync(entry.file); total -= entry.size; } catch { /* already consumed */ }
|
|
162
|
+
}
|
|
163
|
+
for (const gap of gaps.values()) writeGapMarker(gap);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function writeBridgeEvent(sequence, rawTs, payload) {
|
|
167
|
+
if (!eventDirectory || !Number.isSafeInteger(sequence)) return;
|
|
168
|
+
const sessionId = typeof payload?.session_id === 'string' ? payload.session_id : undefined;
|
|
169
|
+
const event = {
|
|
170
|
+
version: 1,
|
|
171
|
+
type: 'event',
|
|
172
|
+
agent: 'claude',
|
|
173
|
+
eventId: `claude-hook-${sequence}`,
|
|
174
|
+
sequence,
|
|
175
|
+
paneId: pane,
|
|
176
|
+
src,
|
|
177
|
+
sourceOccurredAt: Number(rawTs) || 0,
|
|
178
|
+
...(sessionId ? { sessionId } : {}),
|
|
179
|
+
...(sourceProcess ? { process: sourceProcess } : {}),
|
|
180
|
+
payload,
|
|
181
|
+
};
|
|
182
|
+
const final = path.join(
|
|
183
|
+
eventDirectory,
|
|
184
|
+
`event-${String(sequence).padStart(16, '0')}-${process.pid}.json`,
|
|
185
|
+
);
|
|
186
|
+
const temporary = `${final}.tmp`;
|
|
187
|
+
fs.writeFileSync(temporary, JSON.stringify(event), { mode: 0o600 });
|
|
188
|
+
fs.renameSync(temporary, final);
|
|
189
|
+
fs.chmodSync(final, 0o600);
|
|
190
|
+
pruneEventSpool();
|
|
191
|
+
}
|
|
192
|
+
|
|
54
193
|
try {
|
|
55
194
|
ensurePrivateDirectory(path.dirname(file));
|
|
56
195
|
if (fs.existsSync(file)) fs.chmodSync(file, 0o600);
|
|
@@ -60,7 +199,12 @@ function update() {
|
|
|
60
199
|
let obj = {};
|
|
61
200
|
try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); if (j && typeof j === 'object' && !Array.isArray(j)) obj = j; }
|
|
62
201
|
catch { /* fresh / corrupt / half-written → start clean */ }
|
|
63
|
-
const
|
|
202
|
+
const previous = obj[pane];
|
|
203
|
+
const prevSrc = previous && previous.src;
|
|
204
|
+
const sequenceFloor = Object.values(obj).reduce((maximum, value) => (
|
|
205
|
+
Number.isSafeInteger(value?.sequence) ? Math.max(maximum, value.sequence) : maximum
|
|
206
|
+
), 0);
|
|
207
|
+
let applied = true;
|
|
64
208
|
if (isIdle) {
|
|
65
209
|
// idle after a resting state (done/needs/nothing) is just the "still waiting" reminder → drop and
|
|
66
210
|
// leave the file as it was (recording it would bump ts and re-surface an already-cleared 已完成).
|
|
@@ -68,7 +212,15 @@ function update() {
|
|
|
68
212
|
// no Stop hook fires there, so idle is the only signal the turn ended. Without this the pane would
|
|
69
213
|
// stay latched at 进行中 forever; treat it as a soft end and clear the pane.
|
|
70
214
|
if (prevSrc === 'prompt' || prevSrc === 'resume') { delete obj[pane]; }
|
|
71
|
-
else
|
|
215
|
+
else applied = false; // resting → drop without writing
|
|
216
|
+
} else if (src === 'notify' && payload?.notification_type === 'permission_prompt'
|
|
217
|
+
&& prevSrc === 'permreq'
|
|
218
|
+
&& sameSourceProcess(previous)
|
|
219
|
+
&& (!previous?.payload?.session_id || !payload?.session_id
|
|
220
|
+
|| previous.payload.session_id === payload.session_id)) {
|
|
221
|
+
// PermissionRequest is the authoritative opening edge. Claude later emits a generic Notification for
|
|
222
|
+
// the same blocked gate; retaining the earlier detailed row also prevents a second canonical unread.
|
|
223
|
+
applied = false;
|
|
72
224
|
} else if (src === 'end') {
|
|
73
225
|
// SessionEnd drops the pane on a clean exit. But /clear (and /resume) END the old session AND START a
|
|
74
226
|
// new one — SessionEnd(old) + SessionStart(new) fire as two async hooks, in either order. A late end for
|
|
@@ -77,23 +229,36 @@ function update() {
|
|
|
77
229
|
const curSid = obj[pane] && obj[pane].payload && obj[pane].payload.session_id;
|
|
78
230
|
const endSid = payload && payload.session_id;
|
|
79
231
|
if (!curSid || !endSid || curSid === endSid) delete obj[pane];
|
|
232
|
+
else applied = false;
|
|
80
233
|
} else {
|
|
81
|
-
|
|
234
|
+
// sequence is allocated under the same pane-state lock, so independently spawned async hooks share
|
|
235
|
+
// one durable source order. Existing readers ignore the additive field.
|
|
236
|
+
const sequence = safeSequence(sequenceFloor);
|
|
237
|
+
obj[pane] = {
|
|
238
|
+
ts: Number(ts) || 0, src, host, payload, agent: 'claude',
|
|
239
|
+
...(sourceProcess ? { process: sourceProcess } : {}),
|
|
240
|
+
...(sequence === null ? {} : { sequence }),
|
|
241
|
+
};
|
|
82
242
|
}
|
|
243
|
+
if (!applied) return;
|
|
244
|
+
const sequence = src === 'end' || isIdle ? safeSequence(sequenceFloor) : obj[pane]?.sequence;
|
|
83
245
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
84
246
|
fs.writeFileSync(tmp, JSON.stringify(obj), { mode: 0o600 });
|
|
85
247
|
fs.renameSync(tmp, file); // atomic: a torn write can't corrupt the file
|
|
86
248
|
fs.chmodSync(file, 0o600);
|
|
249
|
+
try { writeBridgeEvent(sequence, ts, payload); } catch { /* latest snapshot still recovers */ }
|
|
87
250
|
}
|
|
88
251
|
|
|
89
252
|
const lock = `${file}.lock`;
|
|
90
253
|
let held = false;
|
|
91
|
-
for (let i = 0; i < 60 && !held; i++) { // ~0.9s budget
|
|
254
|
+
for (let i = 0; i < 60 && !held; i++) { // ~0.9s budget; never corrupt order locklessly
|
|
92
255
|
try { fs.closeSync(fs.openSync(lock, 'wx', 0o600)); held = true; } // O_EXCL → atomic "I hold it"
|
|
93
256
|
catch {
|
|
94
257
|
try { if (Date.now() - fs.statSync(lock).mtimeMs > 3000) fs.unlinkSync(lock); } catch { /* steal a stale lock */ }
|
|
95
258
|
nap(15);
|
|
96
259
|
}
|
|
97
260
|
}
|
|
98
|
-
|
|
99
|
-
|
|
261
|
+
if (held) {
|
|
262
|
+
try { update(); } catch { /* best effort */ }
|
|
263
|
+
try { fs.unlinkSync(lock); } catch { /* ignore */ }
|
|
264
|
+
}
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handmux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "A mobile vibe-coding cockpit — built on tmux: drive your live session, Claude Code / Codex — anything a terminal can run — from your phone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -22,13 +22,14 @@
|
|
|
22
22
|
"pwa"
|
|
23
23
|
],
|
|
24
24
|
"engines": {
|
|
25
|
-
"node": ">=
|
|
25
|
+
"node": ">=22.16.0"
|
|
26
26
|
},
|
|
27
27
|
"bin": {
|
|
28
28
|
"handmux": "bin/handmux.js"
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"bin/handmux.js",
|
|
32
|
+
"bin/node-version.js",
|
|
32
33
|
"dist",
|
|
33
34
|
"README.md",
|
|
34
35
|
"README.zh-CN.md",
|
|
@@ -65,7 +66,7 @@
|
|
|
65
66
|
"@types/busboy": "^1.5.4",
|
|
66
67
|
"@types/compression": "^1.8.1",
|
|
67
68
|
"@types/express": "4.17.25",
|
|
68
|
-
"@types/node": "20.
|
|
69
|
+
"@types/node": "22.20.1",
|
|
69
70
|
"@types/supertest": "7.2.1",
|
|
70
71
|
"@types/web-push": "^3.6.4",
|
|
71
72
|
"@types/ws": "^8.18.1",
|
|
@@ -74,6 +75,12 @@
|
|
|
74
75
|
"vitest": "^2.0.0"
|
|
75
76
|
},
|
|
76
77
|
"whatsNew": [
|
|
78
|
+
{
|
|
79
|
+
"version": "0.25.0",
|
|
80
|
+
"date": "2026-09-01",
|
|
81
|
+
"zh": "Pi 原生接入 · 多 Agent 对话更可靠",
|
|
82
|
+
"en": "Native Pi support · more reliable agent chat"
|
|
83
|
+
},
|
|
77
84
|
{
|
|
78
85
|
"version": "0.24.0",
|
|
79
86
|
"date": "2026-08-11",
|
|
@@ -115,12 +122,6 @@
|
|
|
115
122
|
"date": "2026-07-27",
|
|
116
123
|
"zh": "实时终端长时间运行更流畅",
|
|
117
124
|
"en": "Smoother long-running live terminals"
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
"version": "0.20.1",
|
|
121
|
-
"date": "2026-07-26",
|
|
122
|
-
"zh": "聊天发送修复 · 电脑键盘不中断",
|
|
123
|
-
"en": "Reliable chat sending · uninterrupted desktop input"
|
|
124
125
|
}
|
|
125
126
|
]
|
|
126
127
|
}
|