Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to the claude-plugins project will be documented in this fil

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`.

### vibe v1.0.74

Signal safety after a plugin test ended every process on an operator's machine, and the open lessons from the 2026-10-21 ledger entries, each with a mechanical check where one fits.

#### Fixed
- `helpers.mjs stopped` no longer signals a process group it does not own. A test recorded pid 1 and stopped it; `endProcesses` sent `kill(-1)`, which signals every process the user owns, twice. Pids of 1 or lower are refused at record time and never signalled, nor is the stopper's own pid or its parent. A whole group is signalled only for a pid recorded with `--group` whose process group id still equals the pid; any other pid is signalled alone. A pid the system reports as not ours (EPERM) is never signalled and does not block `stopped`. `pid` records each process's start time and command, and every signal re-reads them first: a reused pid (even one of the user's own apps, which launchd makes a group leader), a changed or unreadable identity, or an older record without one is never signalled. `endProcesses` takes an injected `kill`, so its tests check every decision without a real signal.
- Every group signal in the plugin (`run-bounded.mjs`, `precedent-check.mjs`, the vibe-workers ledger and Claude worker, and their tests) checks the pid above 1 first. The dist bundles are rebuilt.

#### Added
- `test_vibe_signal_safety_contract.py`: no plugin test passes 0, 1, a negative literal, its own pid or its parent's pid to a call that sends a signal, writes one into a `pids` ledger, or calls `endProcesses` without an injected `kill`; no plugin source signals a group (`process.kill(-x)`, `kill(-x)`, `kill -- -x`) unless `x` is checked above 1 just before.
- fleet-orchestrator: `board.mjs` keeps the fleet board every writer reads, flags rows behind the pushed head, and tracks rulings (saved once as a reference file) and ready QA checkpoints until each writer and QA guide is told directly; writers message each other (Codex through the app-server orchestrator's send, Claude through SendMessage).
- fleet-orchestrator: `qa-plan-check.mjs` makes zero human checkpoints the default and refuses a human checkpoint without a reason an agent cannot cover, a visual judgment included (agents compare screenshots with the reference). `helpers.mjs` records the processes a helper starts (`pid`), ends them on `stopped`, lists `ORPHAN` helpers, and keeps a stack handed to a QA guide (`hand`, `unhand`) from QA reruns.
- codex-fleet-monitor: `watch.sh` writes `watcher.json` and `watcher.mjs` reports a lapsed watcher; the supervisor template restarts with bootout then bootstrap, never kickstart, and respawns after 30 seconds.
- vibe: `push-cadence.mjs` and `pushDue` on every commit (a tested checkpoint is pushed at least every 90 minutes); `shepherd.mjs` polls a pull request every two minutes in the final stage and stops on a conflict; the final main merge pushes before its e2e run; `main-sync-push` refuses a migration that sorts before main's newest (`migration-order.mjs`); `e2e-lock.mjs` gives each run its own compose project and honors `--foreign-compose`; `precedent-check.mjs check` refuses a public-material or undocumented answer unless no browser could look.
- handoff: a repository can declare gates required when paths match (`.vibe/path-gates.json`); each matching gate is a required run on the published commit.

### code v2.0.1

#### Changed
Expand Down
2 changes: 1 addition & 1 deletion plugins/vibe/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "vibe",
"description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows",
"version": "1.0.73",
"version": "1.0.74",
"dependencies": ["closedloop-core"],
"author": {"name": "ClosedLoop", "email": "support@closedloop.ai"}
}
2 changes: 1 addition & 1 deletion plugins/vibe/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vibe",
"version": "1.0.73",
"version": "1.0.74",
"description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows",
"author": {"name": "ClosedLoop", "email": "support@closedloop.ai"},
"homepage": "https://github.com/closedloop-ai/claude-plugins",
Expand Down
4 changes: 3 additions & 1 deletion plugins/vibe/agents/vibe-verify-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ Validation is targeted, never a broad or full local suite. Run:
included), plus the inventory's `diffRules.requiredTests` and
`requiredChecks`, each through its owning package's runner on those test
files alone (for example
`pnpm --filter <package> exec vitest run <test files>`).
`pnpm --filter <package> exec vitest run <test files>`). A required test
whose path is a command comes from the repo's `.vibe/path-gates.json`: run
that command and record it in `testRuns` with the command as its `path`.

Never run `pnpm test`, `pnpm verify`, `pnpm test:affected`, `pnpm test:lanes --exec`, a
whole lane or a package's whole suite locally; CI runs the broad suites on the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,9 @@ if (args.includes('--help')) {
process.stdout.write(args.includes('resume') ? 'Usage: codex exec resume [SESSION_ID] [PROMPT]\\n-m, --model <MODEL>\\n' : '-C, --cd <DIR>\\n-m, --model <MODEL>\\n');
process.exit(0);
}
process.kill(process.pid, 'SIGKILL');
// Ends by a signal without signalling any pid; stderr is closed so abort's report does not replace the signal.
require('node:fs').closeSync(2);
process.abort();
`);
chmodSync(fakeCodex, 0o755);
const session = join(state, 'session.json');
Expand All @@ -458,7 +460,7 @@ process.kill(process.pid, 'SIGKILL');
const failed = spawnSync('node', [script, 'run', '--session-file', session, '--prompt-file', prompt, '--events-file', join(state, 'events.jsonl'), '--codex', fakeCodex], { encoding: 'utf8' });
assert.notEqual(failed.status, 0);
assert.doesNotMatch(failed.stderr, /Codex exited null/);
assert.match(JSON.parse(readFileSync(session, 'utf8')).lastFailure, /SIGKILL/);
assert.match(JSON.parse(readFileSync(session, 'utf8')).lastFailure, /SIGABRT/);

const healthy = setup().fakeCodex;
const retryPrompt = join(state, 'retry.txt');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function spawnPublicCli(args, env) {

async function killPublicCli(processHandle) {
assert.equal(processHandle.child.exitCode, null, 'public CLI must still be alive before forced termination');
assert.ok(processHandle.child.pid > 1, 'signalling group 1 would reach every process the user owns');
process.kill(-processHandle.child.pid, 'SIGKILL');
const exit = await processHandle.exited;
assert.equal(exit.code, null);
Expand Down
4 changes: 2 additions & 2 deletions plugins/vibe/skills/codex-fleet-monitor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ The label is what appears in the output lines.
scripts/watch.sh <workDir> [intervalSeconds]
```

Start it with `run_in_background`. You are notified when it exits. Read its output, handle every line, then start it again; it only watches while running. Editing `sessions.txt` or `prs.txt` takes effect on the next re-arm. `scripts/snapshot.sh <workDir>` prints the current state once.
Start it with `run_in_background`. You are notified when it exits. Read its output, handle every line, then start it again; it only watches while running. `watch.sh` records its pid, each tick and its exit in `<workDir>/watcher.json`, and `node scripts/watcher.mjs <workDir>` prints `watcher ok` or `watcher MISSING(<why>)` (exit 1): check it before ending any turn that handled an event, and re-arm on `MISSING`. Editing `sessions.txt` or `prs.txt` takes effect on the next re-arm. `scripts/snapshot.sh <workDir>` prints the current state once.

## Reading the output

Expand Down Expand Up @@ -121,7 +121,7 @@ Every action is an `EVENT recovery ...` line in the events log, which the `recov
- Each pass is bounded by `PASS_TIMEOUT_SECONDS` (default 300, enough for a daemon restart and the resumes after it). A pass that runs longer gets a `pass-timeout` event, its App Server connections are closed so it unwinds and releases its lock, and the loop goes on.
- After every pass it writes `<stateDir>/.fleet-recovery/heartbeat.json`: time, pid, interval, pass count, last pass duration, last error and RSS. The watcher's `supervisor` line reads it, so a hung, dead or leaking supervisor is an event. Once an hour it logs a `supervise: hourly` line (passes, errors, longest pass, RSS). A clean stop removes the heartbeat.
- It logs to stdout, which the templates append to `<workDir>/supervisor.log`; at `LOG_MAX_BYTES` (default 1 MB) it copies that file to `supervisor.log.1` and empties it. `SUPERVISOR_LOG` names the file when it is elsewhere. The events log rotates the same way to `events.log.1`.
- Keep it running with `templates/codex-fleet-supervisor.plist` (launchd, macOS) or `templates/codex-fleet-supervisor.service` (systemd user unit, Linux). Each template says where to save it and how to load it; nothing is installed for you.
- Keep it running with `templates/codex-fleet-supervisor.plist` (launchd, macOS) or `templates/codex-fleet-supervisor.service` (systemd user unit, Linux). Each template says where to save it and how to load it; nothing is installed for you. On macOS restart it with `launchctl bootout` then `launchctl bootstrap`, never `launchctl kickstart -k`, which hung the calling shell and left the job waiting its throttle before it respawned.

## Notes

Expand Down
23 changes: 23 additions & 0 deletions plugins/vibe/skills/codex-fleet-monitor/scripts/templates.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';

const PLIST = readFileSync(fileURLToPath(new URL('../templates/codex-fleet-supervisor.plist', import.meta.url)), 'utf8');

test('launchd respawns the supervisor within a minute', () => {
const throttle = /<key>ThrottleInterval<\/key>\s*<integer>(\d+)<\/integer>/.exec(PLIST);
assert.ok(throttle, 'the template sets ThrottleInterval');
assert.ok(Number(throttle[1]) <= 60, `ThrottleInterval ${throttle[1]} leaves the host without a supervisor for too long`);
});

test('the template restarts with bootout then bootstrap and never recommends kickstart', () => {
assert.match(PLIST, /launchctl bootout gui\/\$\(id -u\)\/codex-fleet-supervisor/);
assert.match(PLIST, /launchctl bootstrap gui\/\$\(id -u\)/);
const lines = PLIST.split('\n');
const kickstart = lines.map((line, index) => [line, index]).filter(([line]) => /kickstart/.test(line));
assert.ok(kickstart.length > 0, 'the template warns against kickstart');
for (const [line, index] of kickstart) {
assert.match(line, /never/i, `line ${index + 1} names kickstart without saying never: ${line.trim()}`);
}
});
11 changes: 10 additions & 1 deletion plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
#!/bin/zsh
# Usage: watch.sh <workDir> [intervalSeconds]. Exits with the diff on the first change.
K=${0:A:h}; W=${1:?workDir}; I=${2:-120}
# watcher.json records this watcher's pid and last tick, and its exit, so watcher.mjs can tell a lapsed watcher.
started=$(date -u +%Y-%m-%dT%H:%M:%SZ)
beat() {
local extra=${1:-}
print -r -- "{\"pid\":$$,\"startedAt\":\"$started\",\"lastTickAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"intervalSeconds\":$I$extra}" > $W/watcher.json.tmp && mv $W/watcher.json.tmp $W/watcher.json
}
beat
# Compare against the last snapshot the previous run saw, so a change between runs is still reported.
if [ -s $W/last-snapshot.txt ]; then base=$(cat $W/last-snapshot.txt); else base=$(zsh $K/snapshot.sh $W); echo "$base" > $W/last-snapshot.txt; fi
for _ in $(seq 1 200); do
sleep $I
# Each tick runs a recovery pass first (recover.mjs), so a transient failure is resumed without you.
now=$(RECOVER=1 zsh $K/snapshot.sh $W)
beat
if [ -n "$now" ] && [ "$now" != "$base" ]; then
echo "$now" > $W/last-snapshot.txt; echo EVENT; diff <(echo "$base") <(echo "$now") | grep '^[<>]'; exit 0
echo "$now" > $W/last-snapshot.txt; beat ",\"exitedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\""; echo EVENT; diff <(echo "$base") <(echo "$now") | grep '^[<>]'; exit 0
fi
done
beat ",\"exitedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\""
echo TIMEOUT; cat $W/last-snapshot.txt
72 changes: 72 additions & 0 deletions plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env node
// Is the watcher for a work dir running? A watcher re-arm was lost in an
// interrupted tool batch and nobody noticed for about two hours, so pushes sat
// unhandled. watch.sh writes <workDir>/watcher.json at start, after every
// tick and on exit; this reads it.
//
// Usage: watcher.mjs <workDir> [--now <iso>]
//
// Prints `watcher ok pid=<p> tick=<n>s ago`, or `watcher MISSING(<why>)` and
// exits 1. A watcher is live when watcher.json has no exitedAt, its pid is
// alive and its last tick is within twice its interval plus 120 seconds.

import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";

const TICK_SLACK_SECONDS = 120;

/** The watcher's state for `workDir`: `{ ok, line }`. */
export function watcherState(workDir, now = Date.now()) {
let state;
try {
state = JSON.parse(readFileSync(path.join(workDir, "watcher.json"), "utf8"));
} catch {
return missing("no watcher.json");
}
if (state.exitedAt) {
return missing(`exited ${Math.max(0, Math.floor((now - Date.parse(state.exitedAt)) / 60_000))}m ago`);
}
if (!isAlive(state.pid)) {
return missing(`pid ${state.pid} dead`);
}
const age = Math.max(0, Math.floor((now - Date.parse(state.lastTickAt ?? state.startedAt)) / 1000));
const interval = Number(state.intervalSeconds) || 0;
if (!Number.isFinite(age) || age > 2 * interval + TICK_SLACK_SECONDS) {
return missing(`no tick for ${Number.isFinite(age) ? age : "?"}s`);
}
return { ok: true, line: `watcher ok pid=${state.pid} tick=${age}s ago` };
}

function missing(why) {
return { ok: false, line: `watcher MISSING(${why})` };
}

function isAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code === "EPERM";
}
}

function main() {
const { values, positionals } = parseArgs({ allowPositionals: true, options: { now: { type: "string" } } });
const [workDir] = positionals;
if (!workDir) {
process.stderr.write("Usage: watcher.mjs <workDir> [--now <iso>]\n");
return 2;
}
const { ok, line } = watcherState(workDir, values.now ? Date.parse(values.now) : Date.now());
process.stdout.write(`${line}\n`);
return ok ? 0 : 1;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(main());
}
82 changes: 82 additions & 0 deletions plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { watcherState } from './watcher.mjs';

const WATCH = fileURLToPath(new URL('./watch.sh', import.meta.url));
const WATCHER = fileURLToPath(new URL('./watcher.mjs', import.meta.url));
const NOW = Date.parse('2026-10-21T12:00:00Z');

function workDir(t) {
const dir = mkdtempSync(join(tmpdir(), 'fleet-watcher-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}

const write = (dir, state) => writeFileSync(join(dir, 'watcher.json'), JSON.stringify(state));
const at = (seconds) => new Date(NOW - seconds * 1000).toISOString();

function deadPid() {
const child = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { encoding: 'utf8' });
return Number(child.stdout);
}

test('a live watcher that ticked recently is ok', (t) => {
const dir = workDir(t);
write(dir, { pid: process.pid, startedAt: at(600), lastTickAt: at(100), intervalSeconds: 120 });
assert.deepEqual(watcherState(dir, NOW), { ok: true, line: `watcher ok pid=${process.pid} tick=100s ago` });
});

test('each lapse is MISSING with its reason', (t) => {
const dir = workDir(t);
assert.deepEqual(watcherState(dir, NOW), { ok: false, line: 'watcher MISSING(no watcher.json)' });
write(dir, { pid: process.pid, startedAt: at(9000), lastTickAt: at(7300), intervalSeconds: 120, exitedAt: at(7200) });
assert.equal(watcherState(dir, NOW).line, 'watcher MISSING(exited 120m ago)');
const dead = deadPid();
write(dir, { pid: dead, startedAt: at(600), lastTickAt: at(10), intervalSeconds: 120 });
assert.equal(watcherState(dir, NOW).line, `watcher MISSING(pid ${dead} dead)`);
write(dir, { pid: process.pid, startedAt: at(900), lastTickAt: at(361), intervalSeconds: 120 });
assert.equal(watcherState(dir, NOW).line, 'watcher MISSING(no tick for 361s)');
write(dir, { pid: process.pid, startedAt: at(900), lastTickAt: at(360), intervalSeconds: 120 });
assert.equal(watcherState(dir, NOW).ok, true, 'twice the interval plus 120 seconds is still live');
});

test('the command prints the line and exits 1 for a missing watcher', (t) => {
const dir = workDir(t);
const missing = spawnSync(process.execPath, [WATCHER, dir], { encoding: 'utf8' });
assert.equal(missing.status, 1);
assert.equal(missing.stdout, 'watcher MISSING(no watcher.json)\n');
write(dir, { pid: process.pid, startedAt: at(60), lastTickAt: at(30), intervalSeconds: 120 });
const live = spawnSync(process.execPath, [WATCHER, dir, '--now', new Date(NOW).toISOString()], { encoding: 'utf8' });
assert.equal(live.status, 0);
});

test('watch.sh records its pid at start, and a killed watcher reads as dead', { timeout: 30_000 }, async (t) => {
const dir = workDir(t);
writeFileSync(join(dir, 'last-snapshot.txt'), 'session local a RUN\n');
// Its own process group, so killing it also ends the `sleep` it is waiting in.
const child = spawn('zsh', [WATCH, dir, '600'], { stdio: 'ignore', detached: true });
const killGroup = () => {
if (!(child.pid > 1)) return;
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
// Already gone.
}
};
t.after(killGroup);
const file = join(dir, 'watcher.json');
for (let i = 0; i < 100 && !existsSync(file); i += 1) await new Promise((resolve) => setTimeout(resolve, 100));
const state = JSON.parse(readFileSync(file, 'utf8'));
assert.equal(state.pid, child.pid);
assert.equal(state.intervalSeconds, 600);
assert.equal(watcherState(dir).ok, true);
const exited = new Promise((resolve) => child.on('exit', resolve));
killGroup();
await exited;
assert.equal(watcherState(dir).line, `watcher MISSING(pid ${child.pid} dead)`);
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
skill directory) and WORK_DIR (the supervisor's work dir), save it as
~/Library/LaunchAgents/codex-fleet-supervisor.plist, then run
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/codex-fleet-supervisor.plist
To restart it, run launchctl bootout gui/$(id -u)/codex-fleet-supervisor, then the same
launchctl bootstrap line. Never use launchctl kickstart -k, which hung the calling shell and left
the job waiting ThrottleInterval before it respawned.
The login shell puts node and codex on PATH. launchd appends to supervisor.log, and the supervisor
rotates it at LOG_MAX_BYTES (default 1 MB) into supervisor.log.1.
-->
Expand All @@ -23,7 +26,7 @@
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>300</integer>
<integer>30</integer>
<key>StandardOutPath</key>
<string>WORK_DIR/supervisor.log</string>
<key>StandardErrorPath</key>
Expand Down
Loading
Loading