handmux 0.29.0 → 0.29.1

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,20 @@ 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): on Linux the raw
57
+ # procfs start tick, on macOS the `lstart` string. A wall-clock `lstart` moves whenever the host steps
58
+ # the guest clock (WSL2), which makes one live process look like several.
59
+ case "$(uname -s)" in
60
+ Linux)
61
+ # comm may contain spaces/parentheses: drop "pid (comm) " first, then starttime is field 20.
62
+ CLAUDE_STARTED_AT=$(sed 's/.*) //' "/proc/$CLAUDE_PID/stat" 2>/dev/null \
63
+ | awk '{ print $20 }' 2>/dev/null || printf '')
64
+ ;;
65
+ *)
66
+ # `lstart` follows the process locale on macOS; force C so standalone Node can parse it consistently.
67
+ CLAUDE_STARTED_AT=$(LC_ALL=C ps -p "$CLAUDE_PID" -o lstart= 2>/dev/null || printf '')
68
+ ;;
69
+ esac
58
70
  CLAUDE_TTY=$(ps -p "$CLAUDE_PID" -o tty= 2>/dev/null || printf '')
59
71
  fi
60
72
  # 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.1",
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,12 @@
75
75
  "vitest": "^2.0.0"
76
76
  },
77
77
  "whatsNew": [
78
+ {
79
+ "version": "0.29.1",
80
+ "date": "2026-09-18",
81
+ "zh": "修复 Claude 对话入口丢失 · 长时间运行不再变卡",
82
+ "en": "Fix lost Claude chat entries · no slowdown after long runs"
83
+ },
78
84
  {
79
85
  "version": "0.29.0",
80
86
  "date": "2026-09-16",
@@ -116,12 +122,6 @@
116
122
  "date": "2026-09-02",
117
123
  "zh": "修复 Agent 对话断流、排队与工具路径",
118
124
  "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
  }