Skip to content

Commit 6289dae

Browse files
authored
release: v0.4.2 — background daemon, env autoload, cwd-agnostic config, Linear pickup fixes (#50)
* feat: detach openswarm start into a background daemon - `openswarm start` now spawns a detached child and returns immediately - adds `openswarm stop` (SIGTERM + PID cleanup) and `openswarm status` - logs stream to ~/.config/openswarm/logs/openswarm.log - PID file at ~/.config/openswarm/openswarm.pid, with stale-file recovery - `--foreground` / `-F` preserves the previous attached behavior for debugging - bump to 0.4.2 * feat: bundle @intrect/cxt and expose it to spawned workers - add @intrect/cxt ^0.1.0 as a runtime dependency - inject OpenSwarm's own node_modules/.bin into PATH for every worker/planner CLI spawn via new buildWorkerEnv helper - user's shell PATH and ~/.claude/* are untouched — cxt is only visible to processes OpenSwarm itself starts - worker prompt templates (en/ko) now list cxt as an available tool so agents know they can run it for entity lookup, FTS search, and bad-smell scans * feat: auto-load .env so the daemon picks up LINEAR_API_KEY/LINEAR_TEAM_ID Before: `openswarm start` run from a non-interactive shell without the Linear (or Discord) vars exported silently fell through config validation with "Linear credentials not set — disabling Linear integration", so no issues were ever picked up even though the keys sat in .env. Now index.ts loads the first .env it finds (\$OPENSWARM_ENV → dir of \$OPENSWARM_CONFIG → cwd → ~/.config/openswarm → ~/.openswarm), without overwriting values already present in process.env. A shell export still wins. Verified: daemon now logs `[Linear] Fetching issues for all:false` → `[HB] ✓ Found 100 tasks from Linear`, vs the previous `⏭ Linear not configured — skipping`. * fix: pick up Linear issues across all configured teams + projects Heartbeat was missing analogModeling issues even with the project enabled, because the fetch + filter pipeline silently dropped them at three points: - enabledProjects matched paths byte-equal, so macOS/Windows case-insensitive filesystems treated AnalogModeling and analogModeling as different entries. Fixed in autonomousRunner.isProjectEnabled. - linear.getMyIssues issued a single { team: { in: [...] } } query capped at first: 50 — one busy team monopolized the page and starved smaller teams. Switched to an adaptive fetch: one wide query first, fall back to per-team fan-out only when the first page saturates. PER_TEAM cap raised from 50 to 100. - core/service.ts heartbeat fetcher was using full mode (3 resolver calls per issue: project + comments + labels). At ~200 issues × 4 HB/hour that was tripping Linear's 3500 req/hr ceiling. Switched to slim mode (1 call per issue, project only). Comment-based task-state hydration is sacrificed on this path. - taskState.getTaskReadiness only reactivated decomposed tasks if Linear state was Todo or In Progress. In Review now counts as actionable too, so reviewer feedback on a decomposed task gets picked up rather than ignored.
1 parent 5c33d5f commit 6289dae

15 files changed

Lines changed: 567 additions & 39 deletions

File tree

package-lock.json

Lines changed: 22 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@intrect/openswarm",
3-
"version": "0.4.1",
3+
"version": "0.4.2",
44
"description": "Autonomous AI agent orchestrator — Claude, GPT, Codex, and local models (Ollama/LMStudio/llama.cpp)",
55
"license": "GPL-3.0",
66
"type": "module",
@@ -43,6 +43,7 @@
4343
},
4444
"dependencies": {
4545
"@anthropic-ai/sdk": "^0.72.1",
46+
"@intrect/cxt": "^0.1.0",
4647
"@intrect/openswarm": "^0.2.1",
4748
"@lancedb/lancedb": "^0.23.0",
4849
"@linear/sdk": "^19.0.0",

src/adapters/base.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import fs from 'node:fs/promises';
88
import type { CliAdapter, CliRunOptions, CliRunResult } from './types.js';
99
import { parseCliStreamChunk } from '../agents/cliStreamParser.js';
1010
import { registerProcess } from './processRegistry.js';
11+
import { buildWorkerEnv } from './envPath.js';
1112

1213
/**
1314
* Spawn a CLI process using the given adapter and options.
@@ -40,7 +41,10 @@ export async function spawnCli(
4041
const proc = spawn(cmd, {
4142
shell: true,
4243
cwd: options.cwd,
43-
env: process.env,
44+
// Inject OpenSwarm's bundled node_modules/.bin (gives workers access
45+
// to `cxt` and other shipped CLIs) without touching the user's shell
46+
// PATH or ~/.claude/ config.
47+
env: buildWorkerEnv(process.env),
4448
stdio: ['ignore', 'pipe', 'pipe'],
4549
});
4650

src/adapters/envPath.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// ============================================
2+
// OpenSwarm - Worker environment PATH helper
3+
// ============================================
4+
//
5+
// Workers spawned by OpenSwarm need access to bundled CLI dependencies
6+
// (notably `cxt` from @intrect/cxt) without the user having them installed
7+
// globally. We inject OpenSwarm's own `node_modules/.bin` into PATH for the
8+
// spawned process only — user's shell PATH and ~/.claude/* are untouched.
9+
10+
import { existsSync } from 'node:fs';
11+
import { dirname, join, resolve } from 'node:path';
12+
import { delimiter } from 'node:path';
13+
import { fileURLToPath } from 'node:url';
14+
15+
/**
16+
* Resolve OpenSwarm's bundled `node_modules/.bin` directory.
17+
*
18+
* envPath.js lives at `<pkg>/dist/adapters/envPath.js` after build, so the
19+
* package root is two directories up. During `npm run dev` / `tsx`, the file
20+
* is at `<pkg>/src/adapters/envPath.ts` — same relative structure.
21+
*
22+
* Returns null if the .bin directory does not exist (e.g. dev checkout
23+
* without `npm install`), so callers can fall back to process.env.PATH as-is.
24+
*/
25+
export function getBundledBinDir(): string | null {
26+
const here = dirname(fileURLToPath(import.meta.url));
27+
const pkgRoot = resolve(here, '..', '..');
28+
const binDir = join(pkgRoot, 'node_modules', '.bin');
29+
return existsSync(binDir) ? binDir : null;
30+
}
31+
32+
/**
33+
* Build an env object for spawned workers with OpenSwarm's bundled `.bin`
34+
* directory prepended to PATH. Keeps every other env var untouched.
35+
*
36+
* Prepending (not appending) means a locally-bundled `cxt` wins over an
37+
* older global install, which matters when we start pinning cxt versions.
38+
*/
39+
export function buildWorkerEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
40+
const binDir = getBundledBinDir();
41+
if (binDir === null) return { ...base };
42+
43+
const existingPath = base.PATH ?? base.Path ?? '';
44+
// Avoid duplicate entries if this env is reused across spawns.
45+
const parts = existingPath.split(delimiter).filter(Boolean);
46+
if (parts[0] === binDir) {
47+
return { ...base };
48+
}
49+
const nextPath = [binDir, ...parts.filter((p) => p !== binDir)].join(delimiter);
50+
51+
return { ...base, PATH: nextPath };
52+
}

src/automation/autonomousRunner.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,29 @@ export class AutonomousRunner {
7676
// Explicitly enabled project paths (allow-list; empty = nothing runs)
7777
private enabledProjects = new Set<string>();
7878

79+
/**
80+
* macOS (APFS default) and Windows have case-insensitive filesystems by
81+
* default, so `/Users/x/dev/AnalogModeling` and `/Users/x/dev/analogModeling`
82+
* refer to the same directory. Do the enabled-set comparison in a case-
83+
* insensitive way on those platforms so UI-captured casing doesn't
84+
* mismatch Linear's project-name casing.
85+
*/
86+
private get pathsCaseInsensitive(): boolean {
87+
return process.platform === 'darwin' || process.platform === 'win32';
88+
}
89+
90+
private normalizePath(p: string): string {
91+
return this.pathsCaseInsensitive ? p.toLowerCase() : p;
92+
}
93+
7994
/** Check if a resolved path is under any enabled project */
8095
private isProjectEnabled(resolvedPath: string): boolean {
8196
if (this.enabledProjects.size === 0) return false;
82-
if (this.enabledProjects.has(resolvedPath)) return true;
83-
// Check if resolvedPath is a subdirectory of any enabled project
97+
const needle = this.normalizePath(resolvedPath);
8498
for (const enabled of this.enabledProjects) {
85-
if (resolvedPath.startsWith(enabled + '/')) return true;
99+
const hay = this.normalizePath(enabled);
100+
if (hay === needle) return true;
101+
if (needle.startsWith(hay + '/')) return true;
86102
}
87103
return false;
88104
}

src/cli.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,72 @@ program
166166

167167
program
168168
.command('start')
169-
.description('Start the full daemon (requires config.yaml with Discord + Linear)')
169+
.description('Start the full daemon in the background (use --foreground to stay attached)')
170+
.option('-F, --foreground', 'Run in the foreground instead of detaching (for debugging / LaunchAgent)')
171+
.action(async (opts: { foreground?: boolean }) => {
172+
if (opts.foreground) {
173+
// Dynamic import triggers the top-level main() in index.ts
174+
await import('./index.js');
175+
return;
176+
}
177+
178+
const { startDaemon } = await import('./cli/daemon.js');
179+
try {
180+
const { pid, logFile } = startDaemon();
181+
console.log(`OpenSwarm started in background (pid ${pid}).`);
182+
console.log(` logs: ${logFile}`);
183+
console.log(` stop: openswarm stop`);
184+
console.log(` status: openswarm status`);
185+
} catch (err) {
186+
console.error(`Failed to start: ${(err as Error).message}`);
187+
process.exit(1);
188+
}
189+
});
190+
191+
// openswarm stop
192+
193+
program
194+
.command('stop')
195+
.description('Stop the background daemon (sends SIGTERM)')
196+
.option('-t, --timeout <ms>', 'Max time to wait for graceful shutdown (default 10000)', '10000')
197+
.action(async (opts: { timeout: string }) => {
198+
const timeoutMs = parseInt(opts.timeout, 10);
199+
const { stopDaemon } = await import('./cli/daemon.js');
200+
try {
201+
const stopped = await stopDaemon(Number.isFinite(timeoutMs) ? timeoutMs : 10_000);
202+
if (!stopped) {
203+
console.log('OpenSwarm is not running.');
204+
return;
205+
}
206+
console.log('OpenSwarm stopped.');
207+
} catch (err) {
208+
console.error(`Failed to stop: ${(err as Error).message}`);
209+
process.exit(1);
210+
}
211+
});
212+
213+
// openswarm status
214+
215+
program
216+
.command('status')
217+
.description('Report daemon status (pid, uptime, log path)')
170218
.action(async () => {
171-
// Dynamic import triggers the top-level main() in index.ts
172-
await import('./index.js');
219+
const { getDaemonStatus } = await import('./cli/daemon.js');
220+
const status = getDaemonStatus();
221+
if (!status.running) {
222+
console.log('OpenSwarm is not running.');
223+
console.log(` pid file: ${status.pidFile}`);
224+
console.log(` log file: ${status.logFile}`);
225+
return;
226+
}
227+
const uptime = status.uptimeSeconds ?? 0;
228+
const h = Math.floor(uptime / 3600);
229+
const m = Math.floor((uptime % 3600) / 60);
230+
const s = uptime % 60;
231+
console.log(`OpenSwarm is running.`);
232+
console.log(` pid: ${status.pid}`);
233+
console.log(` uptime: ${h}h ${m}m ${s}s`);
234+
console.log(` logs: ${status.logFile}`);
173235
});
174236

175237
// openswarm dash

0 commit comments

Comments
 (0)