From 17a976fa2ead113009065bfa7d27150345a325f0 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 4 Oct 2026 21:36:44 +0200 Subject: [PATCH] fix(statusline): unique temp name per exporter refresh, so concurrent creates stay in tmux ensureStatusLineExporterScript() rewrites ~/.codeman/statusline-exporter.sh via a temp file + rename whenever the script content changes (a fresh data dir, or a release that changes it). The temp name was pid + Date.now(), so claude sessions created in the same millisecond (spawn_workers, a multi-tab Run) shared one temp path: the first rename consumed it and every other writer failed with ENOENT on chmod or rename. createSession() treats that as a mux failure and falls back to a direct PTY, so those sessions silently ran outside tmux (no reattach after a server restart) while quick-start still reported success. Measured on a fresh isolated instance, 4 concurrent claude quick-starts: master put 2 of 4 in tmux in both rounds; with this change 4 of 4, both rounds. The temp suffix now comes from randomBytes, like the skill writer in the same file and user-store.ts already do. The new test freezes Date.now() and runs eight refreshes at once; it fails on master with the same ENOENT. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/hooks-config.ts | 6 +++++- test/hooks-config.test.ts | 23 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 58edfc33..fda526c7 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -1031,7 +1031,11 @@ export async function ensureStatusLineExporterScript(): Promise { // render, and a truncate-then-write (plus a chmod AFTER the write) opened two // windows in which Claude Code could run an empty or non-executable file. // rename() swaps the complete, already-executable file in atomically. - const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`; + // ⚠️ The temp name must be unique per CALL, not per millisecond: sessions created + // concurrently (spawn_workers, a multi-tab Run) refresh this together, a shared + // name let the first rename consume the others' temp file, and their ENOENT + // dropped those sessions from tmux to the direct-PTY fallback. + const tmpPath = `${scriptPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; await writeFile(tmpPath, desired); await chmod(tmpPath, 0o755); await rename(tmpPath, scriptPath); diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 2f65bd9f..502a21b2 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -5,7 +5,7 @@ * hook definitions for desktop notifications. */ -import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest'; import { chmodSync, closeSync, @@ -26,6 +26,7 @@ import { spawn } from 'node:child_process'; import { applyStatusLineConfig, ensureCodemanHooks, + ensureStatusLineExporterScript, findEffectiveUserStatusLineCommand, generateBackgroundWakeScript, generateHooksConfig, @@ -1418,6 +1419,26 @@ describe('resolveStatusLineCliCommand', () => { expect(siblings).toEqual([]); }); + it('survives concurrent refreshes in the same millisecond (sessions created at once)', async () => { + const scriptPath = (await resolveStatusLineCliCommand(testDir, true))!; + writeFileSync(scriptPath, '#!/bin/sh\n# CODEMAN_STATUSLINE_EXPORTER_V0\necho stale\n'); + // A frozen clock makes every writer agree on the timestamp, which is what two + // session creates in one millisecond do. A temp name built from pid + Date.now() + // is then shared: the first rename consumes it, every later rename fails ENOENT, + // and that session's tmux create fell back to a direct PTY. + const clock = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + try { + const paths = await Promise.all(Array.from({ length: 8 }, () => ensureStatusLineExporterScript())); + expect(new Set(paths)).toEqual(new Set([scriptPath])); + } finally { + clock.mockRestore(); + } + expect(readFileSync(scriptPath, 'utf-8')).not.toContain('echo stale'); + expect(statSync(scriptPath).mode & 0o111).not.toBe(0); + const siblings = readdirSync(join(scriptPath, '..')).filter((f) => f.startsWith('statusline-exporter.sh.')); + expect(siblings).toEqual([]); + }); + it('never overrides a real, hand-authored statusLine', async () => { const claudeDir = join(testDir, '.claude'); mkdirSync(claudeDir, { recursive: true });