chore: version packages

Release 1.9.8 (aicodeman) and 0.1.8 (xterm-zerolag-input).

Fixes macOS session start (`posix_spawnp failed.`, issues #6 and #204):
node-pty ships its macOS spawn-helper as mode 0644 and macOS launches every
PTY through it. `scripts/fix-node-pty.mjs` (npm run fix:node-pty) chmods every
helper, prebuilds/ included, then verifies by really opening a PTY; the blind
Node-22+ rebuild is gone. `spawnPtyWithHelperRepair()` self-heals an already
broken install on the first failed spawn.

Adds the phone home screen (session overview under 430px, per-device
`mobileOverviewEnabled`, default ON) and a guided Tailscale path in
install.sh, plus `install.sh tailscale` to retrofit it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-04 15:02:46 +02:00
parent aa4f423d8a
commit 23f258a85d
28 changed files with 3080 additions and 95 deletions
+274
View File
@@ -0,0 +1,274 @@
// Port: none (pure model + static markup assertions — no browser, no server).
//
// The phone home screen (src/web/public/mobile-overview.js) replaces the welcome
// overlay under 430px. Its grouping logic is the part that can silently go wrong:
// a session blocked on a permission prompt landing in "idle" is exactly the bug
// this surface exists to prevent. buildMobileOverviewModel() is pure for that
// reason, so it can be exercised here against plain objects.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
function loadOverviewApp(overrides: Record<string, any> = {}) {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
console,
window: {},
document: { getElementById: () => null },
MobileDetection: { getDeviceType: () => 'mobile' },
});
vm.runInContext(readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'), context, {
filename: 'mobile-overview.js',
});
const app = new (CodemanApp as any)();
app.getSessionName = (session: any) => session.name || session.workingDir?.split('/').pop() || session.id.slice(0, 8);
app._shortenHomePath = (p: string) => (p || '').replace(/^\/home\/[^/]+\//, '~/');
app.loadAppSettingsFromStorage = () => ({});
Object.assign(app, overrides);
return app;
}
const CASES = [
{ name: 'claudeman', path: '/home/arkon/default/claudeman', location: 'local' },
{ name: 'beta', path: '/home/arkon/codeman-cases/beta', location: 'local' },
{ name: 'boxed', path: '/srv/boxed', location: 'docker' },
];
function session(over: Record<string, any>) {
return { id: 'x', status: 'idle', mode: 'claude', workingDir: '/home/arkon/default/claudeman', ...over };
}
describe('mobile overview model', () => {
it('routes a session with a pending permission prompt into NEEDS YOU, not idle', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [session({ id: 'a', status: 'idle' })],
cases: CASES,
pendingHooks: new Map([['a', new Set(['permission_prompt'])]]),
});
expect(model.needsYou.map((r: any) => r.id)).toEqual(['a']);
expect(model.current).toHaveLength(0);
expect(model.needsYou[0].state).toBe('needs');
expect(model.needsYou[0].pill).toBe('needs you');
});
it('ranks an action hook above an idle hook above a stale busy status', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
// An idle_prompt hook on a session the server still calls 'busy': the hook
// is the newer signal, so it must win.
sessions: [
session({ id: 'busy-with-idle-hook', status: 'busy' }),
session({ id: 'elicit', status: 'busy' }),
session({ id: 'plain-busy', status: 'busy' }),
],
cases: CASES,
pendingHooks: new Map([
['busy-with-idle-hook', new Set(['idle_prompt'])],
['elicit', new Set(['elicitation_dialog'])],
]),
});
expect(model.needsYou.map((r: any) => r.id)).toEqual(['elicit', 'busy-with-idle-hook']);
expect(model.current.map((r: any) => r.id)).toEqual(['plain-busy']);
});
it('buckets busy / idle / stopped / error and labels each pill', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [
session({ id: 'w', status: 'busy' }),
session({ id: 'i', status: 'idle' }),
session({ id: 'd', status: 'stopped' }),
session({ id: 'e', status: 'error' }),
],
cases: CASES,
});
// Everything that is not blocked on you shares one "current" section,
// most demanding first.
expect(model.current.map((r: any) => [r.id, r.pill])).toEqual([
['w', 'working'],
['i', 'idle'],
['d', 'done'],
]);
expect(model.needsYou.map((r: any) => r.pill)).toEqual(['error']);
expect(model.sessionCount).toBe(4);
});
it('keeps the user tab order as the tiebreak inside a section', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [session({ id: 'first' }), session({ id: 'second' }), session({ id: 'third' })],
cases: CASES,
sessionOrder: ['third', 'first', 'second'],
});
expect(model.current.map((r: any) => r.id)).toEqual(['third', 'first', 'second']);
});
it('matches a session started in a subdirectory to its case (longest prefix)', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [
session({ id: 'sub', workingDir: '/home/arkon/default/claudeman/src/web' }),
session({ id: 'outside', workingDir: '/tmp/scratch' }),
],
cases: [...CASES, { name: 'claudeman-web', path: '/home/arkon/default/claudeman/src/web' }],
});
const rows = Object.fromEntries(model.current.map((r: any) => [r.id, r.caseName]));
expect(rows.sub).toBe('claudeman-web');
expect(rows.outside).toBe('');
});
it('lists past conversations newest first and never repeats a live session', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [session({ id: 'live-1' })],
cases: CASES,
history: [
// Same id as the running session: the unified list includes live rows,
// and showing one in both sections would be a duplicate.
{ sessionId: 'live-1', workingDir: '/home/arkon/default/claudeman', lastActivityAt: 500 },
{
sessionId: 'old-a',
workingDir: '/home/arkon/codeman-cases/beta',
firstPrompt: 'fix the mobile header',
claudeSessionId: 'claude-uuid-a',
lastActivityAt: 100,
},
{
sessionId: 'old-b',
workingDir: '/home/arkon/default/claudeman',
name: 'w4-claudeman',
lastActivityAt: 400,
},
],
});
expect(model.past.map((r: any) => r.id)).toEqual(['old-b', 'old-a']);
expect(model.past[1]).toMatchObject({
title: 'fix the mobile header',
caseName: 'beta',
claudeSessionId: 'claude-uuid-a',
workingDir: '/home/arkon/codeman-cases/beta',
});
// A row with no prompt falls back to its name, so it is never a bare UUID.
expect(model.past[0].title).toBe('w4-claudeman');
});
it('does not title a past row with the transcript reader placeholder', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [],
cases: CASES,
history: [
{ sessionId: 'blank', workingDir: '/home/arkon/default/claudeman', firstPrompt: '(no content)' },
{ sessionId: 'spaces', workingDir: '/home/arkon/codeman-cases/beta', firstPrompt: ' ' },
],
});
expect(model.past.map((r: any) => r.title)).toEqual(['claudeman', 'beta']);
});
it('accepts the live Map as-is and survives an empty state', () => {
const app = loadOverviewApp();
const fromMap = app.buildMobileOverviewModel({
sessions: new Map([['a', session({ id: 'a' })]]),
cases: CASES,
});
expect(fromMap.current.map((r: any) => r.id)).toEqual(['a']);
const empty = app.buildMobileOverviewModel({});
expect(empty).toMatchObject({ needsYou: [], current: [], past: [], sessionCount: 0 });
});
it('no longer builds a spaces section', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({ sessions: [session({ id: 'a' })], cases: CASES });
expect(model.spaces).toBeUndefined();
});
});
describe('mobile overview gate', () => {
it('is phone-width only, off in solo windows, and off when explicitly disabled', () => {
expect(loadOverviewApp().shouldUseMobileOverview()).toBe(true);
expect(loadOverviewApp({ isSoloWindow: true }).shouldUseMobileOverview()).toBe(false);
expect(
loadOverviewApp({
loadAppSettingsFromStorage: () => ({ mobileOverviewEnabled: false }),
}).shouldUseMobileOverview()
).toBe(false);
// An unset value must read as ON: phones that already have saved settings
// from before this feature existed have no key for it.
expect(loadOverviewApp({ loadAppSettingsFromStorage: () => ({ skin: 'og' }) }).shouldUseMobileOverview()).toBe(
true
);
});
});
describe('mobile overview wiring', () => {
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const mobileCss = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8');
const moduleSrc = readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8');
it('speaks the same status language as the session tabs', () => {
// A session that is fine reads green on the tabs; anything else here would
// mean two meanings for one color on the same screen.
expect(mobileCss).toMatch(/\.mobile-overview-dot--idle\s*\{\s*background:\s*var\(--green\)/);
expect(mobileCss).toMatch(/\.mobile-overview-dot--working\s*\{[^}]*var\(--green\)[^}]*animation:\s*pulse/);
// Waiting-for-input blinks yellow, asked-a-question blinks red, same as
// tab-alert-idle / tab-alert-action.
expect(mobileCss).toMatch(/\.mobile-overview-row--waiting\s*\{[^}]*animation:\s*mobile-overview-blink-yellow/);
expect(mobileCss).toMatch(/\.mobile-overview-row--needs\s*\{[^}]*animation:\s*mobile-overview-blink-red/);
expect(mobileCss).toContain('@keyframes mobile-overview-blink-red');
expect(mobileCss).toContain('@keyframes mobile-overview-blink-yellow');
// The alert must survive reduced-motion as a held color, not vanish.
expect(mobileCss).toMatch(/prefers-reduced-motion[^}]*\}[\s\S]*?\.mobile-overview-row--needs/);
});
it('reuses the toolbar Run button classes instead of its own palette', () => {
// The per-backend gradient lives in styles.css keyed on
// `.btn-toolbar.btn-run.mode-<backend>` (and light skins override exactly
// those); carrying the same classes keeps both Run buttons identical.
expect(moduleSrc).toContain('btn-toolbar btn-run mode-');
expect(moduleSrc).toContain('btn-toolbar btn-run-gear mode-');
// The two button rules (not the dropdown below them) must set no color at
// all, or they would win over the mode gradient.
const buttonRules = mobileCss.match(/\.mobile-overview-run(-caret)?\s*\{[^}]*\}/g) || [];
expect(buttonRules.length).toBe(2);
for (const rule of buttonRules) {
expect(rule).not.toMatch(/\b(background|color)\s*:/);
}
});
it('ships the container hidden and loads the module', () => {
expect(html).toMatch(/<div class="mobile-overview" id="mobileOverview" hidden><\/div>/);
expect(html).toContain('<script defer src="mobile-overview.js"></script>');
});
it('never gives .mobile-overview a bare display rule', () => {
// Desktop does not load mobile.css at all, so the [hidden] attribute is the
// only thing keeping the overview off desktop. A bare
// `.mobile-overview { display: … }` rule would beat the UA [hidden] rule.
const bareDisplay = /\.mobile-overview\s*\{[^}]*display\s*:/;
expect(bareDisplay.test(mobileCss)).toBe(false);
expect(mobileCss).toContain('.mobile-overview.visible {');
});
it('styles the overview from skin tokens rather than hardcoded colors', () => {
// Skins re-point the :root tokens, so a hex literal here is a rule that
// silently stays dark on the four light skins.
const rules = mobileCss.match(/\.mobile-overview[^{}]*\{[^}]*\}/g) || [];
expect(rules.length).toBeGreaterThan(10);
const hardcoded = rules.flatMap((rule) => rule.match(/:\s*#[0-9a-f]{3,8}\b/gi) || []);
expect(hardcoded).toEqual([]);
});
});
+215
View File
@@ -0,0 +1,215 @@
/**
* Regression tests for the node-pty spawn-helper repair (issues #6, #204).
*
* node-pty@1.1.0 publishes `prebuilds/darwin-<arch>/spawn-helper` with mode 0644,
* so on macOS every PTY spawn dies with `posix_spawnp failed.`. These tests pin
* the two things the old fix got wrong: it looked ONLY in `build/Release` (which
* does not exist on macOS, where the prebuilt binary is used), and it never
* checked whether the repair actually worked.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
isSpawnHelperFailure,
listSpawnHelpers,
repairSpawnHelperPermissions,
spawnPtyWithHelperRepair,
resetSpawnHelperRepairState,
SPAWN_HELPER_FIX_HINT,
} from '../src/utils/node-pty-repair.js';
/** Builds a fake node-pty tree; each entry is a directory that gets a spawn-helper. */
function makeFakePtyDir(helpers: Array<{ dir: string; mode: number }>): string {
const root = mkdtempSync(join(tmpdir(), 'codeman-node-pty-'));
for (const { dir, mode } of helpers) {
const full = join(root, dir);
mkdirSync(full, { recursive: true });
const helper = join(full, 'spawn-helper');
writeFileSync(helper, '#!/bin/sh\nexit 0\n');
chmodSync(helper, mode);
}
return root;
}
function modeOf(path: string): number {
return statSync(path).mode & 0o777;
}
describe('node-pty spawn-helper repair', () => {
const created: string[] = [];
beforeEach(() => resetSpawnHelperRepairState());
afterEach(() => {
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function fixture(helpers: Array<{ dir: string; mode: number }>): string {
const root = makeFakePtyDir(helpers);
created.push(root);
return root;
}
describe('isSpawnHelperFailure', () => {
it('matches the native error node-pty throws on macOS', () => {
expect(isSpawnHelperFailure(new Error('posix_spawnp failed.'))).toBe(true);
});
it('matches errors that name the helper directly', () => {
expect(isSpawnHelperFailure(new Error('ENOENT: no such file, spawn-helper'))).toBe(true);
});
it('ignores unrelated spawn failures', () => {
expect(isSpawnHelperFailure(new Error('cwd does not exist'))).toBe(false);
expect(isSpawnHelperFailure(undefined)).toBe(false);
});
});
describe('listSpawnHelpers', () => {
it('finds the prebuilt helper, which is the ONLY one that exists on macOS', () => {
// A stock macOS install has no build/ directory at all: node-pty ships a
// darwin prebuild, so node-gyp never runs.
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
expect(listSpawnHelpers(root)).toEqual([join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper')]);
});
it('finds helpers across build/Release, build/Debug and every prebuilds arch', () => {
const root = fixture([
{ dir: 'build/Release', mode: 0o755 },
{ dir: 'build/Debug', mode: 0o644 },
{ dir: 'prebuilds/darwin-arm64', mode: 0o644 },
{ dir: 'prebuilds/darwin-x64', mode: 0o644 },
]);
expect(listSpawnHelpers(root).sort()).toEqual(
[
join(root, 'build', 'Release', 'spawn-helper'),
join(root, 'build', 'Debug', 'spawn-helper'),
join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper'),
join(root, 'prebuilds', 'darwin-x64', 'spawn-helper'),
].sort()
);
});
it('returns nothing for a Linux install, which has no spawn-helper at all', () => {
const root = fixture([]);
mkdirSync(join(root, 'build', 'Release'), { recursive: true });
writeFileSync(join(root, 'build', 'Release', 'pty.node'), 'stub');
expect(listSpawnHelpers(root)).toEqual([]);
});
});
describe('repairSpawnHelperPermissions', () => {
it('adds the execute bit to the 0644 prebuilt helper', () => {
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
const repaired = repairSpawnHelperPermissions(root);
expect(repaired).toEqual([helper]);
expect(modeOf(helper) & 0o111).toBe(0o111);
});
it('is a no-op on an already-executable helper', () => {
const root = fixture([{ dir: 'build/Release', mode: 0o755 }]);
expect(repairSpawnHelperPermissions(root)).toEqual([]);
expect(modeOf(join(root, 'build', 'Release', 'spawn-helper'))).toBe(0o755);
});
it('repairs every copy, not just the first one found', () => {
const root = fixture([
{ dir: 'prebuilds/darwin-arm64', mode: 0o644 },
{ dir: 'prebuilds/darwin-x64', mode: 0o644 },
]);
expect(repairSpawnHelperPermissions(root)).toHaveLength(2);
for (const arch of ['darwin-arm64', 'darwin-x64']) {
expect(modeOf(join(root, 'prebuilds', arch, 'spawn-helper')) & 0o111).toBe(0o111);
}
});
it('preserves the non-execute permission bits it was given', () => {
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o640 }]);
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
repairSpawnHelperPermissions(root);
expect(modeOf(helper)).toBe(0o755 | 0o640);
});
it('returns nothing when node-pty has no helper to repair', () => {
expect(repairSpawnHelperPermissions(fixture([]))).toEqual([]);
});
});
describe('spawnPtyWithHelperRepair', () => {
it('passes the spawn result straight through when nothing is wrong', () => {
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o755 }]);
expect(spawnPtyWithHelperRepair(() => 'pty', root)).toBe('pty');
});
it('repairs and retries once after a posix_spawnp failure', () => {
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
let attempts = 0;
const result = spawnPtyWithHelperRepair(() => {
attempts++;
// Mirror the real failure: node-pty only throws while the helper is 0644.
if ((modeOf(helper) & 0o111) !== 0o111) throw new Error('posix_spawnp failed.');
return 'pty';
}, root);
expect(result).toBe('pty');
expect(attempts).toBe(2);
expect(modeOf(helper) & 0o111).toBe(0o111);
});
it('rethrows unrelated errors untouched, without chmodding anything', () => {
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
expect(() =>
spawnPtyWithHelperRepair(() => {
throw new Error('cwd does not exist');
}, root)
).toThrow('cwd does not exist');
expect(modeOf(helper)).toBe(0o644);
});
it('surfaces the manual fix command when the retry still fails', () => {
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
expect(() =>
spawnPtyWithHelperRepair(() => {
throw new Error('posix_spawnp failed.');
}, root)
).toThrow(SPAWN_HELPER_FIX_HINT);
});
it('surfaces the fix command when there is no helper to repair', () => {
const root = fixture([]);
expect(() =>
spawnPtyWithHelperRepair(() => {
throw new Error('posix_spawnp failed.');
}, root)
).toThrow(SPAWN_HELPER_FIX_HINT);
});
it('does not chmod-storm: only the first failure triggers a repair attempt', () => {
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
const boom = () => {
throw new Error('posix_spawnp failed.');
};
expect(() => spawnPtyWithHelperRepair(boom, root)).toThrow(SPAWN_HELPER_FIX_HINT);
// Second call: repair already attempted, so it fails fast with the hint and
// never re-walks the tree.
const untouched = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
expect(() => spawnPtyWithHelperRepair(boom, untouched)).toThrow(SPAWN_HELPER_FIX_HINT);
expect(modeOf(join(untouched, 'prebuilds', 'darwin-arm64', 'spawn-helper'))).toBe(0o644);
});
});
});