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 });