Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .changeset/extract-colony-process.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@colony/hooks': patch
'@colony/mcp-server': patch
'@colony/worker': patch
'@imdeadpool/colony': patch
---

Extract shared `isMainEntry`, pidfile helpers, `isAlive`, and the
`spawn(process.execPath, …)` wrapper into a new `@colony/process`
package. These utilities had divergent copies in four places
(`apps/cli/src/commands/lifecycle.ts`, `apps/cli/src/commands/worker.ts`,
`apps/mcp-server/src/server.ts`, `apps/worker/src/server.ts`, and
`packages/hooks/src/auto-spawn.ts`). The regex that decides whether
Node should be invoked via `execPath` — the Windows EFTYPE guard —
and the realpath-normalized bin-shim check both now live exactly once.

No behavior change. Internal helper refactor only.
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Claude Code works the same way Codex does in this repo: isolated `agent/*` branc

## Architectural rules

- Monorepo with pnpm workspaces. Dependency direction is strictly downward: `apps/*` may depend on `packages/*`; `packages/*` may depend on each other only in the order `config → compress → storage → { core, embedding } → hooks → installers`. (`core` and `embedding` are siblings — both consume `config` and `storage`, neither depends on the other.) No upward or sideways imports that break this order.
- Monorepo with pnpm workspaces. Dependency direction is strictly downward: `apps/*` may depend on `packages/*`; `packages/*` may depend on each other only in the order `process → config → compress → storage → { core, embedding } → hooks → installers`. (`core` and `embedding` are siblings — both consume `config` and `storage`, neither depends on the other. `process` has no upstream deps — only `node:` builtins.) No upward or sideways imports that break this order.
- All database I/O goes through `@colony/storage`. No other package opens the DB directly.
- Settings access goes through `@colony/config`. No direct reads from `~/.colony/settings.json` elsewhere.
- All user-visible strings default to the caveman intensity from settings (default `full`).
Expand All @@ -48,6 +48,7 @@ Claude Code works the same way Codex does in this repo: isolated `agent/*` branc
apps/cli user-facing binary
apps/worker local HTTP daemon: read-only viewer + embedding backfill loop
apps/mcp-server stdio MCP server
packages/process shared pidfile / spawn / isMainEntry helpers (no deps)
packages/config settings schema, loader, defaults, settingsDocs()
packages/compress compression engine + lexicon
packages/storage SQLite + FTS5 + vector adapter
Expand Down
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"@colony/hooks": "workspace:*",
"@colony/installers": "workspace:*",
"@colony/mcp-server": "workspace:*",
"@colony/process": "workspace:*",
"@colony/storage": "workspace:*",
"@colony/worker": "workspace:*",
"tsup": "^8.3.5",
Expand Down
59 changes: 20 additions & 39 deletions apps/cli/src/commands/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { spawn } from 'node:child_process';
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { loadSettings, resolveDataDir } from '@colony/config';
import {
isAlive,
readPidFile,
removePidFile,
spawnNodeScript,
writePidFile,
} from '@colony/process';
import type { Command } from 'commander';
import kleur from 'kleur';
import { resolveCliPath } from '../util/resolve.js';
Expand All @@ -17,67 +23,42 @@ function pidFile(): string {
return join(resolveDataDir(loadSettings().dataDir), 'worker.pid');
}

function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}

async function waitForPidOrPort(timeoutMs = 5000): Promise<boolean> {
const pf = pidFile();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (existsSync(pf)) {
const pid = Number(readFileSync(pf, 'utf8'));
if (pid > 0 && isAlive(pid)) return true;
}
const pid = readPidFile(pf);
if (pid !== null && isAlive(pid)) return true;
await new Promise((r) => setTimeout(r, 100));
}
return false;
}

function startWorker(silent = false): number | null {
const pf = pidFile();
if (existsSync(pf)) {
const pid = Number(readFileSync(pf, 'utf8'));
if (pid > 0 && isAlive(pid)) {
if (!silent) process.stdout.write(`${kleur.yellow('already running')} (pid ${pid})\n`);
return pid;
}
try {
unlinkSync(pf);
} catch {
// ignore
const existing = readPidFile(pf);
if (existing !== null) {
if (isAlive(existing)) {
if (!silent) process.stdout.write(`${kleur.yellow('already running')} (pid ${existing})\n`);
return existing;
}
removePidFile(pf);
}
// Spawn `node <cli> worker run` so Windows doesn't try to exec a .js directly.
const child = spawn(process.execPath, [resolveCliPath(), 'worker', 'run'], {
detached: true,
stdio: 'ignore',
env: process.env,
});
child.unref();
if (child.pid) writeFileSync(pf, String(child.pid));
const child = spawnNodeScript(resolveCliPath(), ['worker', 'run']);
if (child.pid) writePidFile(pf, child.pid);
return child.pid ?? null;
}

function stopWorker(): boolean {
const pf = pidFile();
if (!existsSync(pf)) return false;
const pid = Number(readFileSync(pf, 'utf8'));
const pid = readPidFile(pf);
if (pid === null) return false;
try {
process.kill(pid);
} catch {
// already dead
}
try {
unlinkSync(pf);
} catch {
// ignore
}
removePidFile(pf);
return true;
}

Expand Down
50 changes: 19 additions & 31 deletions apps/cli/src/commands/worker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { spawn } from 'node:child_process';
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { loadSettings, resolveDataDir } from '@colony/config';
import {
isAlive,
readPidFile,
removePidFile,
spawnNodeScript,
writePidFile,
} from '@colony/process';
import type { Command } from 'commander';
import kleur from 'kleur';
import { resolveCliPath } from '../util/resolve.js';
Expand All @@ -10,40 +15,23 @@ function pidFile(): string {
return join(resolveDataDir(loadSettings().dataDir), 'worker.pid');
}

function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}

export function registerWorkerCommand(program: Command): void {
const w = program.command('worker').description('Manage local worker daemon');

w.command('start')
.description('Start the worker in the background')
.action(async () => {
const pf = pidFile();
if (existsSync(pf)) {
const pid = Number(readFileSync(pf, 'utf8'));
if (isAlive(pid)) {
process.stdout.write(`${kleur.yellow('already running')} (pid ${pid})\n`);
const existing = readPidFile(pf);
if (existing !== null) {
if (isAlive(existing)) {
process.stdout.write(`${kleur.yellow('already running')} (pid ${existing})\n`);
return;
}
unlinkSync(pf);
removePidFile(pf);
}
// Spawn `node <cli> worker run` — not `<cli> worker run` — because on
// Windows the resolved cliPath is the .js file (npm's bin shim points
// at it), and spawn() can't execute a .js directly → EFTYPE.
const child = spawn(process.execPath, [resolveCliPath(), 'worker', 'run'], {
detached: true,
stdio: 'ignore',
env: process.env,
});
child.unref();
writeFileSync(pf, String(child.pid));
const child = spawnNodeScript(resolveCliPath(), ['worker', 'run']);
if (child.pid) writePidFile(pf, child.pid);
process.stdout.write(`${kleur.green('started')} (pid ${child.pid})\n`);
});

Expand All @@ -58,30 +46,30 @@ export function registerWorkerCommand(program: Command): void {
.description('Stop the worker daemon')
.action(async () => {
const pf = pidFile();
if (!existsSync(pf)) {
const pid = readPidFile(pf);
if (pid === null) {
process.stdout.write(`${kleur.dim('not running')}\n`);
return;
}
const pid = Number(readFileSync(pf, 'utf8'));
try {
process.kill(pid);
process.stdout.write(`${kleur.green('stopped')} (pid ${pid})\n`);
} catch (e) {
process.stdout.write(`${kleur.yellow('stale pidfile')} ${String(e)}\n`);
} finally {
unlinkSync(pf);
removePidFile(pf);
}
});

w.command('status')
.description('Show worker status')
.action(async () => {
const pf = pidFile();
if (!existsSync(pf)) {
const pid = readPidFile(pf);
if (pid === null) {
process.stdout.write(`${kleur.dim('not running')}\n`);
return;
}
const pid = Number(readFileSync(pf, 'utf8'));
process.stdout.write(
`${isAlive(pid) ? kleur.green('running') : kleur.red('dead')} (pid ${pid})\n`,
);
Expand Down
1 change: 1 addition & 0 deletions apps/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@colony/core": "workspace:*",
"@colony/embedding": "workspace:*",
"@colony/hooks": "workspace:*",
"@colony/process": "workspace:*",
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.8"
},
Expand Down
15 changes: 2 additions & 13 deletions apps/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
#!/usr/bin/env node
import { realpathSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { type Settings, loadSettings, resolveDataDir } from '@colony/config';
import {
type AgentCapabilities,
Expand All @@ -22,6 +20,7 @@ import {
} from '@colony/core';
import { createEmbedder } from '@colony/embedding';
import { type HookInput, type HookName, upsertActiveSession } from '@colony/hooks';
import { isMainEntry } from '@colony/process';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
Expand Down Expand Up @@ -867,19 +866,9 @@ export async function main(): Promise<void> {
await server.connect(transport);
}

if (isMainEntry()) {
if (isMainEntry(import.meta.url)) {
main().catch((err) => {
process.stderr.write(`[colony mcp] fatal: ${String(err)}\n`);
process.exit(1);
});
}

function isMainEntry(): boolean {
const argv = process.argv[1];
if (!argv) return false;
try {
return import.meta.url === pathToFileURL(realpathSync(argv)).href;
} catch {
return import.meta.url === pathToFileURL(argv).href;
}
}
1 change: 1 addition & 0 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@colony/config": "workspace:*",
"@colony/core": "workspace:*",
"@colony/embedding": "workspace:*",
"@colony/process": "workspace:*",
"@colony/storage": "workspace:*",
"hono": "^4.6.10",
"@hono/node-server": "^1.13.7"
Expand Down
32 changes: 5 additions & 27 deletions apps/worker/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
#!/usr/bin/env node
import { existsSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { expand } from '@colony/compress';
import { type Settings, loadSettings, resolveDataDir } from '@colony/config';
import { type HivemindOptions, MemoryStore, readHivemind } from '@colony/core';
import { createEmbedder } from '@colony/embedding';
import { isMainEntry, removePidFile, writePidFile } from '@colony/process';
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { type EmbedLoopHandle, startEmbedLoop, stateFilePath } from './embed-loop.js';
Expand Down Expand Up @@ -85,30 +85,18 @@ function pidFilePath(settings: Settings): string {
return join(resolveDataDir(settings.dataDir), 'worker.pid');
}

function writePidFile(settings: Settings): void {
writeFileSync(pidFilePath(settings), String(process.pid));
}

function removePidFile(settings: Settings): void {
try {
unlinkSync(pidFilePath(settings));
} catch {
// already gone
}
}

export async function start(): Promise<void> {
const settings = loadSettings();
const dbPath = join(resolveDataDir(settings.dataDir), 'data.db');
const store = new MemoryStore({ dbPath, settings });

writePidFile(settings);
writePidFile(pidFilePath(settings));

let loop: EmbedLoopHandle | undefined;
const servers: Array<ReturnType<typeof serve>> = [];

const shutdown = async () => {
removePidFile(settings);
removePidFile(pidFilePath(settings));
if (loop) await loop.stop();
for (const s of servers) s.close();
store.close();
Expand Down Expand Up @@ -174,19 +162,9 @@ export async function start(): Promise<void> {
);
}

if (isMainEntry()) {
if (isMainEntry(import.meta.url)) {
start().catch((err) => {
process.stderr.write(`[colony worker] fatal: ${String(err)}\n`);
process.exit(1);
});
}

function isMainEntry(): boolean {
const argv = process.argv[1];
if (!argv) return false;
try {
return import.meta.url === pathToFileURL(realpathSync(argv)).href;
} catch {
return import.meta.url === pathToFileURL(argv).href;
}
}
3 changes: 2 additions & 1 deletion packages/hooks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
},
"dependencies": {
"@colony/config": "workspace:*",
"@colony/core": "workspace:*"
"@colony/core": "workspace:*",
"@colony/process": "workspace:*"
},
"devDependencies": {
"tsup": "^8.3.5",
Expand Down
Loading
Loading