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.
Files changed (121) hide show
  1. package/README.md +29 -6
  2. package/README.zh-CN.md +29 -6
  3. package/bin/handmux.js +15 -3
  4. package/bin/node-version.js +20 -0
  5. package/dist/bin/handmux.js +200 -35
  6. package/dist/connectors/bridgeClient.js +385 -0
  7. package/dist/connectors/claude/index.js +476 -0
  8. package/dist/connectors/pi/bridgeClient.js +8 -0
  9. package/dist/connectors/pi/index.js +851 -0
  10. package/dist/hooks/handmux-notify.sh +45 -3
  11. package/dist/hooks/handmux-write.cjs +175 -10
  12. package/dist/package.json +10 -9
  13. package/dist/public/assets/index-CTrqTqtT.css +32 -0
  14. package/dist/public/assets/index-DmjeMSQA.js +346 -0
  15. package/dist/public/index.html +2 -2
  16. package/dist/src/agent-runtime/adapter.js +204 -0
  17. package/dist/src/agent-runtime/bridge.js +1006 -0
  18. package/dist/src/agent-runtime/bridgeStore.js +25 -0
  19. package/dist/src/agent-runtime/bridgeTransport.js +619 -0
  20. package/dist/src/agent-runtime/bridgeTypes.js +13 -0
  21. package/dist/src/agent-runtime/builtinRuntime.js +193 -0
  22. package/dist/src/agent-runtime/conversation.js +2179 -0
  23. package/dist/src/agent-runtime/conversationActivation.js +75 -0
  24. package/dist/src/agent-runtime/conversationActivity.js +69 -0
  25. package/dist/src/agent-runtime/conversationControls.js +206 -0
  26. package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
  27. package/dist/src/agent-runtime/conversationStore.js +352 -0
  28. package/dist/src/agent-runtime/conversationTypes.js +1 -0
  29. package/dist/src/agent-runtime/conversationValidation.js +339 -0
  30. package/dist/src/agent-runtime/inbox.js +973 -0
  31. package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
  32. package/dist/src/agent-runtime/inboxStore.js +25 -0
  33. package/dist/src/agent-runtime/inboxTypes.js +1 -0
  34. package/dist/src/agent-runtime/interaction.js +829 -0
  35. package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
  36. package/dist/src/agent-runtime/interactionStore.js +18 -0
  37. package/dist/src/agent-runtime/interactionTypes.js +1 -0
  38. package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
  39. package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
  40. package/dist/src/agent-runtime/resourceTypes.js +1 -0
  41. package/dist/src/agent-runtime/resources.js +245 -0
  42. package/dist/src/agent-runtime/run.js +328 -0
  43. package/dist/src/agent-runtime/runtime.js +793 -0
  44. package/dist/src/agent-runtime/sessionControl.js +164 -0
  45. package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
  46. package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
  47. package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
  48. package/dist/src/agents/claude.js +22 -0
  49. package/dist/src/agents/claudeConversation.js +362 -0
  50. package/dist/src/agents/claudeInteraction.js +134 -0
  51. package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
  52. package/dist/src/agents/codex.js +52 -7
  53. package/dist/src/agents/codexConversation.js +1103 -0
  54. package/dist/src/agents/codexConversationActivation.js +121 -0
  55. package/dist/src/agents/codexConversationControls.js +256 -0
  56. package/dist/src/agents/codexInteraction.js +306 -0
  57. package/dist/src/agents/codexSessionControl.js +118 -0
  58. package/dist/src/agents/codexSubscriptionUsage.js +313 -0
  59. package/dist/src/agents/codexTerminalControl.js +23 -0
  60. package/dist/src/agents/conversationProjectionSafety.js +416 -0
  61. package/dist/src/agents/index.js +27 -9
  62. package/dist/src/agents/nativeInbox.js +264 -0
  63. package/dist/src/agents/nativeInboxSources.js +115 -0
  64. package/dist/src/agents/pi.js +28 -0
  65. package/dist/src/agents/piConversation.js +523 -0
  66. package/dist/src/agents/piConversationHistory.js +502 -0
  67. package/dist/src/agents/piInboxBridge.js +368 -0
  68. package/dist/src/agents/piSessionControl.js +32 -0
  69. package/dist/src/agents/processIdentity.js +17 -3
  70. package/dist/src/apiAccountProviders.js +194 -0
  71. package/dist/src/apiAccounts.js +592 -0
  72. package/dist/src/apiErrors.js +11 -2
  73. package/dist/src/claudeEvents.js +35 -9
  74. package/dist/src/cli/agentIntegration.js +131 -0
  75. package/dist/src/cli/agentLauncher.js +26 -0
  76. package/dist/src/cli/claudeHooks.js +49 -3
  77. package/dist/src/cli/codexManaged.js +6 -3
  78. package/dist/src/cli/hookScaffold.js +4 -1
  79. package/dist/src/cli/i18n/en.js +30 -4
  80. package/dist/src/cli/i18n/zh.js +30 -4
  81. package/dist/src/cli/piExtension.js +155 -0
  82. package/dist/src/cli/service.js +19 -9
  83. package/dist/src/cli/state.js +3 -0
  84. package/dist/src/cli/statusLine.js +19 -11
  85. package/dist/src/codexAppServer.js +1296 -159
  86. package/dist/src/codexQueueProtocol.js +41 -0
  87. package/dist/src/codexTranscriptParse.js +256 -145
  88. package/dist/src/codexUsageSnapshot.js +29 -6
  89. package/dist/src/git.js +13 -1
  90. package/dist/src/httpApi.js +35 -7
  91. package/dist/src/orphans.js +13 -3
  92. package/dist/src/paneInput.js +42 -0
  93. package/dist/src/privateStateStore.js +35 -0
  94. package/dist/src/projectTask/backup.js +224 -0
  95. package/dist/src/projectTask/lock.js +108 -0
  96. package/dist/src/projectTask/migrations.js +90 -0
  97. package/dist/src/projectTask/routes.js +155 -0
  98. package/dist/src/projectTask/runtime.js +182 -0
  99. package/dist/src/projectTask/schema.js +56 -0
  100. package/dist/src/projectTask/store.js +457 -0
  101. package/dist/src/routes/agents.js +631 -0
  102. package/dist/src/routes/apiAccounts.js +82 -0
  103. package/dist/src/routes/git.js +11 -0
  104. package/dist/src/routes/sessions.js +16 -4
  105. package/dist/src/routes/system.js +69 -5
  106. package/dist/src/routes/terminal.js +22 -16
  107. package/dist/src/server.js +109 -6
  108. package/dist/src/tmux/commands.js +29 -1
  109. package/dist/src/transcriptParse.js +22 -1
  110. package/dist/src/transcriptReader.js +100 -4
  111. package/dist/src/uploadTypes.js +2 -0
  112. package/dist/src/usagePaths.js +15 -0
  113. package/dist/src/workspace/agentRunner.js +103 -6
  114. package/dist/src/workspace/restore.js +51 -2
  115. package/dist/src/workspace/tmuxAdapter.js +10 -0
  116. package/package.json +6 -5
  117. package/dist/public/assets/index-CicdY9E9.css +0 -32
  118. package/dist/public/assets/index-D4VP5t8h.js +0 -337
  119. package/dist/src/routes/codex.js +0 -606
  120. package/dist/src/routes/transcript.js +0 -197
  121. 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
- # 只做一件事:把本次事件写进一个本地 JSON 状态文件(键=tmux pane,值=该 pane 最新事件)。不联网、
8
- # 不依赖服务进程是否在跑 → 永不阻塞 Claude(始终 exit 0)。服务端按需读这个文件(见 claudeEvents.js)。
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" 2>/dev/null || true
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 ONE JSON state file keyed by tmux pane id with this pane's latest
3
- // Claude event: { "%pane": { ts, src, host, payload }, ... }. Invoked by handmux-notify.sh:
4
- // node handmux-write.cjs <file> <pane> <src> <ts> <host> (stdin = Claude's raw hook payload JSON)
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 [, , file, pane, src, ts, host = ''] = process.argv;
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 prevSrc = obj[pane] && obj[pane].src;
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 return; // resting → drop without writing
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
- obj[pane] = { ts: Number(ts) || 0, src, host, payload };
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, then write lockless (best-effort)
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
- try { update(); } catch { /* best effort */ }
99
- if (held) { try { fs.unlinkSync(lock); } catch { /* ignore */ } }
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.24.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": ">=20"
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.19.43",
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
  }