handmux 0.29.0 → 0.29.2

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.
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import { classifyClaude } from '../../src/agents/claude.js';
5
5
  import { ClaudeNativeTailReader } from '../../src/agents/claudeNativeTail.js';
6
6
  import { LocalConnectorBridgeClient } from '../bridgeClient.js';
7
+ const DEFAULT_SPOOL_MAX_FILES = 2_000;
8
+ const DEFAULT_SPOOL_RETENTION_MS = 24 * 60 * 60_000;
7
9
  const EVENT_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$/;
8
10
  function isRecord(value) {
9
11
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -187,6 +189,8 @@ export class ClaudeHookBridgeConnector {
187
189
  #panes;
188
190
  #process;
189
191
  #pollMs;
192
+ #spoolMaxFiles;
193
+ #spoolRetentionMs;
190
194
  #retryDelayMs;
191
195
  #maxRetryDelayMs;
192
196
  #logger;
@@ -198,11 +202,13 @@ export class ClaudeHookBridgeConnector {
198
202
  #tail = Promise.resolve();
199
203
  #started = false;
200
204
  #closed = false;
201
- constructor({ nativeTail = new ClaudeNativeTailReader(), socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory, panes, process, pollMs = 250, retryDelayMs = 100, maxRetryDelayMs = 5_000, logger = () => { }, createClient = (options) => new LocalConnectorBridgeClient(options), }) {
205
+ constructor({ nativeTail = new ClaudeNativeTailReader(), socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory, panes, process, pollMs = 250, retryDelayMs = 100, maxRetryDelayMs = 5_000, logger = () => { }, createClient = (options) => new LocalConnectorBridgeClient(options), spoolMaxFiles = DEFAULT_SPOOL_MAX_FILES, spoolRetentionMs = DEFAULT_SPOOL_RETENTION_MS, }) {
202
206
  if (![socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory].every(path.isAbsolute)
203
207
  || !panes || !process || !Number.isSafeInteger(pollMs) || pollMs < 50
204
208
  || !Number.isSafeInteger(retryDelayMs) || retryDelayMs <= 0
205
- || !Number.isSafeInteger(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs) {
209
+ || !Number.isSafeInteger(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs
210
+ || !Number.isSafeInteger(spoolMaxFiles) || spoolMaxFiles <= 0
211
+ || !Number.isSafeInteger(spoolRetentionMs) || spoolRetentionMs <= 0) {
206
212
  throw new TypeError('Claude Hook Bridge Connector requires private paths and Runtime identity sources');
207
213
  }
208
214
  this.#socketPath = socketPath;
@@ -216,6 +222,8 @@ export class ClaudeHookBridgeConnector {
216
222
  this.#pollMs = pollMs;
217
223
  this.#retryDelayMs = retryDelayMs;
218
224
  this.#maxRetryDelayMs = maxRetryDelayMs;
225
+ this.#spoolMaxFiles = spoolMaxFiles;
226
+ this.#spoolRetentionMs = spoolRetentionMs;
219
227
  this.#logger = logger;
220
228
  this.#createClient = createClient;
221
229
  }
@@ -276,6 +284,40 @@ export class ClaudeHookBridgeConnector {
276
284
  return [];
277
285
  }
278
286
  }
287
+ // Retention for Hook source files that never reach an acknowledgement: a pane that is gone, a client
288
+ // that never drains, or a durable publish that keeps failing leaves its file behind, and every poll
289
+ // re-reads it — an observed 170k files over nine days. Files are listed oldest-first, so a cap drops
290
+ // the stuck ones and an age window only ever expires files no one is waiting on. `gap-*` markers are
291
+ // not touched: they are bounded by pane/session and carry a degradation signal until confirmed.
292
+ #pruneSpool(files) {
293
+ const events = files.filter((file) => path.basename(file).startsWith('event-'));
294
+ if (events.length === 0)
295
+ return files;
296
+ const now = Date.now();
297
+ const dropped = new Set();
298
+ for (const [index, file] of events.entries()) {
299
+ const beyondCap = this.#spoolMaxFiles > 0 && index < events.length - this.#spoolMaxFiles;
300
+ let expired = false;
301
+ try {
302
+ expired = now - fs.statSync(file).mtimeMs > this.#spoolRetentionMs;
303
+ }
304
+ catch { /* removed */ }
305
+ if (beyondCap || expired)
306
+ dropped.add(file);
307
+ }
308
+ if (dropped.size === 0)
309
+ return files;
310
+ for (const file of dropped) {
311
+ try {
312
+ fs.unlinkSync(file);
313
+ }
314
+ catch { /* concurrently removed */ }
315
+ }
316
+ this.#logger?.('Dropped Claude Hook source files that could not be delivered', {
317
+ dropped: dropped.size, remaining: events.length - dropped.size,
318
+ });
319
+ return files.filter((file) => !dropped.has(file));
320
+ }
279
321
  async #reconcile() {
280
322
  if (this.#closed)
281
323
  return;
@@ -297,7 +339,7 @@ export class ClaudeHookBridgeConnector {
297
339
  return pending;
298
340
  };
299
341
  const events = [];
300
- for (const eventFile of this.#eventFiles()) {
342
+ for (const eventFile of this.#pruneSpool(this.#eventFiles())) {
301
343
  let event = null;
302
344
  try {
303
345
  const stat = fs.lstatSync(eventFile);
@@ -53,8 +53,18 @@ if [ -z "$CLAUDE_PID" ]; then
53
53
  done
54
54
  fi
55
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 '')
56
+ # Start time in the same form the server reads (processStartedAt in tmuxRuntime.ts): the raw procfs start
57
+ # tick where procfs exists, otherwise the `lstart` string. A wall-clock `lstart` moves whenever the host
58
+ # steps the guest clock (WSL2), which makes one live process look like several. The proc root is
59
+ # overridable so tests can supply a fixture instead of the host's live process table.
60
+ PROC_ROOT="${HANDMUX_PROC_ROOT:-/proc}"
61
+ # comm may contain spaces/parentheses: drop "pid (comm) " first, then starttime is field 20.
62
+ CLAUDE_STARTED_AT=$(sed 's/.*) //' "$PROC_ROOT/$CLAUDE_PID/stat" 2>/dev/null \
63
+ | awk '{ print $20 }' 2>/dev/null || printf '')
64
+ if [ -z "$CLAUDE_STARTED_AT" ]; then
65
+ # `lstart` follows the process locale on macOS; force C so standalone Node can parse it consistently.
66
+ CLAUDE_STARTED_AT=$(LC_ALL=C ps -p "$CLAUDE_PID" -o lstart= 2>/dev/null || printf '')
67
+ fi
58
68
  CLAUDE_TTY=$(ps -p "$CLAUDE_PID" -o tty= 2>/dev/null || printf '')
59
69
  fi
60
70
  # payload 经 stdin 原样流给 node(不在 shell 里转义,避免坏数据);pane 含 '%' 直接进 JSON 字段,
@@ -27,7 +27,10 @@ if (!file || !pane || !src) process.exit(0);
27
27
 
28
28
  function processFingerprint() {
29
29
  const pid = Number(String(claudePid).trim());
30
- const startedAt = Date.parse(String(claudeStartedAt).trim());
30
+ // The Hook reports the platform's start value (see handmux-notify.sh): a raw procfs tick on Linux, an
31
+ // lstart string on macOS. A digit-only value stays numeric — Date.parse would make it NaN.
32
+ const rawStartedAt = String(claudeStartedAt).trim();
33
+ const startedAt = /^\d+$/.test(rawStartedAt) ? Number(rawStartedAt) : Date.parse(rawStartedAt);
31
34
  const rawTty = String(claudeTty).trim();
32
35
  if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isFinite(startedAt)
33
36
  || !rawTty || rawTty === '?' || rawTty === '??') return undefined;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handmux",
3
- "version": "0.29.0",
3
+ "version": "0.29.2",
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",
@@ -75,6 +75,18 @@
75
75
  "vitest": "^2.0.0"
76
76
  },
77
77
  "whatsNew": [
78
+ {
79
+ "version": "0.29.2",
80
+ "date": "2026-09-18",
81
+ "zh": "修复 Linux 下 Claude 事件上报的兼容问题",
82
+ "en": "Compatibility fix for Claude event reporting on Linux"
83
+ },
84
+ {
85
+ "version": "0.29.1",
86
+ "date": "2026-09-18",
87
+ "zh": "修复 Claude 对话入口丢失 · 长时间运行不再变卡",
88
+ "en": "Fix lost Claude chat entries · no slowdown after long runs"
89
+ },
78
90
  {
79
91
  "version": "0.29.0",
80
92
  "date": "2026-09-16",
@@ -110,18 +122,6 @@
110
122
  "date": "2026-09-04",
111
123
  "zh": "对话精细复制 · Pi 上下文状态",
112
124
  "en": "Precise chat copy · Pi context status"
113
- },
114
- {
115
- "version": "0.25.2",
116
- "date": "2026-09-02",
117
- "zh": "修复 Agent 对话断流、排队与工具路径",
118
- "en": "Fixed Agent chat streams, queues, and tool paths"
119
- },
120
- {
121
- "version": "0.25.1",
122
- "date": "2026-09-02",
123
- "zh": "修复 Codex 幽灵任务与对话空白",
124
- "en": "Fixed Codex ghost tasks and blank chats"
125
125
  }
126
126
  ]
127
127
  }