Skip to content
Open
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
19 changes: 19 additions & 0 deletions .changeset/native-windows-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'colonyq': patch
---

Native Windows support for the `colony` CLI. The bin entry was a POSIX shell
script (`bin/colony.sh`) that npm could not execute on Windows without WSL,
breaking every Windows install of the package. The shim is now a Node ES
module (`bin/colony.mjs`) using only `node:*` builtins, so npm's generated
`.cmd` / `.ps1` wrappers run it natively under cmd, PowerShell, and Git Bash.

The daemon fast-path for `colony bridge lifecycle --json` is preserved — the
HTTP POST to `127.0.0.1:$COLONY_WORKER_PORT/api/bridge/lifecycle` now goes
through `node:http`, with a `node:net` connect probe (1s) before the request
(2s) so the fallback latency stays close to the curl-based version when the
daemon isn't running. Stdin is buffered and replayed on fallback, preserving
rule #10 (a dead daemon must never lose or block a write).

CI now runs the build matrix on `ubuntu-latest`, `macos-latest`, and
`windows-latest` across Node 20 and 22 so this regression cannot recur.
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ jobs:
# The check fires automatically when the PR is marked ready for review
# (the `ready_for_review` trigger above), and always on push to main.
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ['20', '22']
steps:
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The signature property of the project is that **memory is stored compressed**. E
9. **No silent failures.** Hook and worker errors are logged as structured JSON; user-visible commands surface failures with a non-zero exit code and a short message.
10. **Writes never depend on the daemon being up.** Hooks must always complete the write before returning, and a dead worker must never lose or block a write. Two paths satisfy this:
- **Native colony hook handlers** (`colony hook run pre-tool-use`, etc.) write observations synchronously through `MemoryStore.addObservation` in the same process. No IPC, no network.
- **The OMX lifecycle bridge** (`colony bridge lifecycle`, called by external integrations like oh-my-codex) takes a fast path through the worker daemon at `POST /api/bridge/lifecycle` when it is running and reachable within ~2s. On any failure (curl missing, daemon down, non-200, timeout, unknown flags, or invocation without `--json`), the wrapper at `apps/cli/bin/colony.sh` buffers stdin to a temp file and falls back to invoking the Node CLI in-process — same write path as before, identical SQLite file. The contract is regression-tested in `apps/cli/test/bin-shim.test.ts`.
- **The OMX lifecycle bridge** (`colony bridge lifecycle`, called by external integrations like oh-my-codex) takes a fast path through the worker daemon at `POST /api/bridge/lifecycle` when it is running and reachable within ~2s. On any failure (daemon down, non-200, timeout, unknown flags, or invocation without `--json`), the cross-platform Node shim at `apps/cli/bin/colony.mjs` buffers stdin and falls back to invoking the CLI in-process — same write path as before, identical SQLite file. The contract is regression-tested in `apps/cli/test/bin-shim.test.ts`.

Hooks may *detach-spawn* the worker to kick off background embedding, but they must never wait on it. If the worker is down, writes still succeed; only the semantic-search side is degraded (BM25 keeps working).
11. **Read before edit tools.** Claude Code rejects `Edit` / `Update` / `MultiEdit` on an existing file unless that exact file path was read first in the current session. Before any edit tool call, run `Read` on the target file with the same relative path you will edit.
Expand Down
219 changes: 219 additions & 0 deletions apps/cli/bin/colony.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
#!/usr/bin/env node
// Colony CLI bin shim with daemon fast-path for `colony bridge lifecycle`.
//
// Why: every IDE tool event fires `colony bridge lifecycle ...` from external
// hook integrations (oh-my-codex's ColonyBridge.spawnSync, Codex/Claude Code
// settings). Cold-starting Node on each event pegs ~one core for ~300 ms.
// Multiplied across concurrent agents this is a measurable CPU storm. When
// the worker daemon is running, we POST the envelope to /api/bridge/lifecycle
// and skip the rest of the CLI bootstrap entirely.
//
// Rules:
// - Only `bridge lifecycle --json` is fast-pathed. Everything else falls
// through to the in-process CLI so behavior is unchanged.
// - Daemon unreachable / errored / unknown flags / missing --json / trailing
// positional args ⇒ fall back to the in-process CLI with stdin intact
// (we buffer it so it can be replayed).
// - Pure node:* builtins so the same shim runs on Linux, macOS, and Windows
// (cmd, PowerShell, Git Bash) — no curl, no /bin/sh.

import { connect } from 'node:net';
import { request } from 'node:http';
import { dirname, isAbsolute, resolve } from 'node:path';
import { Readable } from 'node:stream';
import { fileURLToPath, pathToFileURL } from 'node:url';

const HERE = dirname(fileURLToPath(import.meta.url));
const CLI_ENTRY = (() => {
// COLONY_CLI_ENTRY is a test-only seam: the bin-shim tests point it at a
// stub so they can assert on argv/stdin replay without booting the real CLI.
const override = process.env.COLONY_CLI_ENTRY;
if (override) return isAbsolute(override) ? override : resolve(HERE, '..', override);
return resolve(HERE, '..', 'dist', 'index.js');
})();

const fastEnv = (process.env.COLONY_BRIDGE_FAST ?? '1').toLowerCase();
const FAST_DISABLED =
fastEnv === '0' || fastEnv === 'false' || fastEnv === 'no' || fastEnv === 'off';

const PORT = Number(process.env.COLONY_WORKER_PORT ?? 37777);
const HOST = '127.0.0.1';
// Match the curl-based shell version: --connect-timeout 1, --max-time 2.
const CONNECT_TIMEOUT_MS = 1000;
const REQUEST_TIMEOUT_MS = 2000;

const argv = process.argv.slice(2);

await main().catch((err) => {
process.stderr.write(`colony: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});

async function main() {
// Non-fast-path-eligible commands take the unchanged CLI path immediately.
if (FAST_DISABLED || argv[0] !== 'bridge' || argv[1] !== 'lifecycle') {
await runCli(argv, null);
return;
}

const parsed = parseBridgeLifecycleFlags(argv.slice(2));

// Bail on unknown flags, missing --json (humans want pretty output), or
// trailing positional args we don't know how to forward. Same triage as
// the legacy shell shim.
if (!parsed.ok || !parsed.json || parsed.rest.length > 0) {
await runCli(rebuildSafeArgv(parsed), null);
return;
}

const body = await readAllStdin();
const served = await tryDaemon({ ide: parsed.ide, cwd: parsed.cwd, body });
if (served) return;

// Daemon unreachable or non-200 — fall back to the in-process CLI with the
// buffered envelope replayed on stdin.
await runCli(rebuildSafeArgv(parsed), body);
}

function parseBridgeLifecycleFlags(rest) {
const out = { ok: true, json: false, ide: '', cwd: '', rest: [] };
let i = 0;
while (i < rest.length) {
const a = rest[i];
if (a === '--json') {
out.json = true;
i += 1;
continue;
}
if (a === '--ide') {
out.ide = rest[i + 1] ?? '';
i += 2;
Comment on lines +88 to +90
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve malformed --ide/--cwd instead of dropping them

The new flag parser treats --ide/--cwd without a following value as empty strings and then rebuildSafeArgv removes them, so malformed invocations are silently rewritten (e.g. ... --json --ide becomes ... --json). Before this change, the shell shim failed fast on this invalid input; now the command can proceed with different semantics, hiding caller bugs and potentially running lifecycle writes with missing context.

Useful? React with 👍 / 👎.

continue;
}
if (a.startsWith('--ide=')) {
out.ide = a.slice('--ide='.length);
i += 1;
continue;
}
if (a === '--cwd') {
out.cwd = rest[i + 1] ?? '';
i += 2;
continue;
}
if (a.startsWith('--cwd=')) {
out.cwd = a.slice('--cwd='.length);
i += 1;
continue;
}
if (a === '--') {
out.rest = rest.slice(i + 1);
break;
}
out.ok = false;
out.rest = rest.slice(i);
break;
}
return out;
}

function rebuildSafeArgv(parsed) {
const out = ['bridge', 'lifecycle'];
if (parsed.json) out.push('--json');
if (parsed.ide) out.push('--ide', parsed.ide);
if (parsed.cwd) out.push('--cwd', parsed.cwd);
return out;
}

function readAllStdin() {
return new Promise((resolveOuter, rejectOuter) => {
if (process.stdin.isTTY) {
resolveOuter(Buffer.alloc(0));
return;
}
const chunks = [];
process.stdin.on('data', (c) => chunks.push(c));
process.stdin.on('end', () => resolveOuter(Buffer.concat(chunks)));
process.stdin.on('error', rejectOuter);
});
}

function probeDaemon() {
return new Promise((resolveOuter) => {
const socket = connect({ port: PORT, host: HOST });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard daemon probe against invalid COLONY_WORKER_PORT

Parsing COLONY_WORKER_PORT with Number(...) allows NaN/out-of-range values to reach net.connect, which throws synchronously and aborts the shim before CLI fallback. In this commit that means colony bridge lifecycle --json can exit with an error instead of replaying stdin to the in-process CLI when the port env var is malformed (for example COLONY_WORKER_PORT=abc), breaking the documented "daemon failures must fall back" contract for write-path safety.

Useful? React with 👍 / 👎.

let settled = false;
const finish = (ok) => {
if (settled) return;
settled = true;
socket.destroy();
resolveOuter(ok);
};
socket.setTimeout(CONNECT_TIMEOUT_MS);
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.once('timeout', () => finish(false));
});
}

async function tryDaemon({ ide, cwd, body }) {
if (!(await probeDaemon())) return false;
return new Promise((resolveOuter) => {
const req = request(
{
host: HOST,
port: PORT,
method: 'POST',
path: '/api/bridge/lifecycle',
headers: {
'content-type': 'application/json',
'content-length': body.length,
'x-colony-ide': ide,
'x-colony-cwd': cwd,
},
timeout: REQUEST_TIMEOUT_MS,
},
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
if (res.statusCode === 200) {
process.stdout.write(Buffer.concat(chunks));
resolveOuter(true);
} else {
resolveOuter(false);
}
});
res.on('error', () => resolveOuter(false));
},
);
req.on('error', () => resolveOuter(false));
req.on('timeout', () => {
req.destroy();
resolveOuter(false);
});
req.write(body);
req.end();
});
}

async function runCli(args, stdinBuffer) {
// Make isMainEntry() in dist/index.js succeed when we dynamic-import it:
// it compares import.meta.url against the realpath of process.argv[1].
// Pointing argv[1] at the resolved CLI entry makes the in-process import
// behave exactly like a direct `node dist/index.js` invocation.
process.argv = [process.argv[0], CLI_ENTRY, ...args];
if (stdinBuffer && stdinBuffer.length > 0) {
installReplayStdin(stdinBuffer);
}
await import(pathToFileURL(CLI_ENTRY).href);
}

function installReplayStdin(buf) {
const replay = Readable.from([buf]);
// Preserve a few properties consumers may sniff on process.stdin.
Object.assign(replay, { isTTY: false, fd: 0 });
Object.defineProperty(process, 'stdin', {
value: replay,
configurable: true,
writable: true,
});
}
4 changes: 2 additions & 2 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@
},
"type": "module",
"bin": {
"colony": "bin/colony.sh"
"colony": "bin/colony.mjs"
},
"main": "./dist/index.js",
"files": ["bin", "dist", "hooks-scripts", "README.md", "LICENSE"],
"files": ["bin/colony.mjs", "dist", "hooks-scripts", "README.md", "LICENSE"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch --onSuccess \"node dist/index.js\"",
Expand Down
Loading