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
67 changes: 67 additions & 0 deletions .changeset/smoothness-pack-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@colony/process": minor
"@colony/config": minor
"@colony/storage": minor
"@colony/core": minor
"@colony/worker": minor
"@colony/mcp-server": minor
---

Smoothness pack: macOS idle-sleep prevention, desktop notifier slot, and
cross-task links.

`@colony/process`:

- New `notify({ level, title, body }, { provider, minLevel, log })` helper.
`provider: 'desktop'` fans out to `osascript` on darwin / `notify-send` on
linux; `'none'` is a no-op. Fire-and-forget: never awaits the spawned
helper, never throws, never blocks a hot path. Spawn failures are reported
via the optional `log` callback rather than crashing the caller.
- Re-exports `NotifyLevel`, `NotifyMessage`, `NotifyOptions`, plus a
`buildNotifyArgv` helper for testing.

`@colony/config`:

- New `notify` settings group: `provider: 'desktop' | 'none'` (default
`'none'` so a fresh install is silent) and `minLevel: 'info' | 'warn' |
'error'` (default `'warn'`). Picked up automatically by `colony config
show` and `settingsDocs()`.

`@colony/storage`:

- Schema bumps to v8. New `task_links` table stores cross-task edges as one
row per unordered pair (`low_id < high_id` enforced via CHECK), with
`created_by`, `created_at`, and an optional `note`.
- `Storage.linkTasks(p)` is idempotent — re-linking a pair preserves the
original metadata. `Storage.unlinkTasks(a, b)` returns whether a row was
removed. `Storage.linkedTasks(task_id)` returns the *other* side of each
edge with link metadata, regardless of which side originally linked.
- Self-links (`task_id_a === task_id_b`) are rejected as a caller bug.
- New types: `TaskLinkRow`, `NewTaskLink`, `LinkedTask`.

`@colony/core`:

- `TaskThread.linkedTasks()`, `TaskThread.link(other_task_id, created_by,
note?)`, `TaskThread.unlink(other_task_id)` — symmetric helpers around
the storage primitives.

`@colony/worker`:

- New `apps/worker/src/caffeinate.ts` holds a `caffeinate -i -w <pid>`
assertion on darwin while the embed loop is running, so a laptop lid-close
or system idle doesn't suspend long-running embedding backfills. No-op on
non-darwin and on missing binary; never started when the embedder failed
to load (the worker is then just a viewer + state file writer).
- Worker now emits a desktop notification via `@colony/process` when the
embedder fails to load, so users see a real signal instead of a stderr
line they may never read. Honours `settings.notify`.

`@colony/mcp-server`:

- New tools: `task_link(task_id, other_task_id, session_id, note?)`,
`task_unlink(task_id, other_task_id)`, `task_links(task_id)`. Symmetric:
callers don't need to think about ordering, and re-linking the same pair
is idempotent.

Inspired by patterns in agent-orchestrator (caffeinate, plugin-style
notifier slot) and hive (worktree connections / cross-task linking).
68 changes: 68 additions & 0 deletions apps/mcp-server/src/tools/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,72 @@ export function register(server: McpServer, ctx: ToolContext): void {
return { content: [{ type: 'text', text: JSON.stringify({ observation_id: id }) }] };
},
);

// --- task links ---
// Cross-task edges. Linking two tasks lets each side see the other's
// timeline + decisions in their own preface, without copy-paste. The
// storage layer stores one row per unordered pair; the MCP surface is
// symmetric so callers don't need to think about ordering.

server.tool(
'task_link',
'Link two tasks bidirectionally so each side sees the other in attention prefaces. Idempotent.',
{
task_id: z.number().int().positive(),
other_task_id: z.number().int().positive(),
session_id: z.string().min(1),
note: z.string().max(280).optional(),
},
async ({ task_id, other_task_id, session_id, note }) => {
if (task_id === other_task_id) {
return {
content: [
{ type: 'text', text: JSON.stringify({ error: 'cannot link a task to itself' }) },
],
isError: true,
};
}
const thread = new TaskThread(store, task_id);
const link = thread.link(other_task_id, session_id, note);
return {
content: [
{
type: 'text',
text: JSON.stringify({
low_id: link.low_id,
high_id: link.high_id,
created_at: link.created_at,
created_by: link.created_by,
note: link.note,
}),
},
],
};
},
);

server.tool(
'task_unlink',
'Drop the bidirectional link between two tasks. Returns { removed: boolean }.',
{
task_id: z.number().int().positive(),
other_task_id: z.number().int().positive(),
},
async ({ task_id, other_task_id }) => {
const thread = new TaskThread(store, task_id);
const removed = thread.unlink(other_task_id);
return { content: [{ type: 'text', text: JSON.stringify({ removed }) }] };
},
);

server.tool(
'task_links',
'List tasks linked to a task. Returns the other side of each edge with link metadata.',
{ task_id: z.number().int().positive() },
async ({ task_id }) => {
const thread = new TaskThread(store, task_id);
const links = thread.linkedTasks();
return { content: [{ type: 'text', text: JSON.stringify(links) }] };
},
);
}
3 changes: 3 additions & 0 deletions apps/mcp-server/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ describe('MCP server', () => {
'task_decline_handoff',
'task_foraging_report',
'task_hand_off',
'task_link',
'task_links',
'task_list',
'task_message',
'task_message_claim',
Expand All @@ -85,6 +87,7 @@ describe('MCP server', () => {
'task_propose',
'task_reinforce',
'task_timeline',
'task_unlink',
'task_updates_since',
'task_wake',
'timeline',
Expand Down
50 changes: 50 additions & 0 deletions apps/worker/src/caffeinate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type ChildProcess, spawn } from 'node:child_process';

export interface CaffeinateHandle {
stop: () => void;
}

/**
* Hold a `caffeinate -i` assertion on macOS while the worker is running, so a
* laptop lid-close or system idle doesn't suspend the long-running embedding
* backfill loop. `-i` blocks idle sleep only — display sleep and lid-close
* sleep on battery still work, matching the agent-orchestrator approach.
*
* On non-darwin platforms this is a no-op. If the binary is missing (rare —
* darwin always ships it under `/usr/bin/caffeinate`) we log once and return
* a no-op handle so the worker still boots.
*/
export function startCaffeinate(log: (line: string) => void): CaffeinateHandle {
if (process.platform !== 'darwin') {
return { stop: () => {} };
}

let child: ChildProcess | null = null;
try {
child = spawn('caffeinate', ['-i', '-w', String(process.pid)], {
stdio: 'ignore',
detached: false,
});
} catch (err) {
log(
`[colony worker] caffeinate unavailable: ${err instanceof Error ? err.message : String(err)}`,
);
return { stop: () => {} };
}

child.on('error', (err) => {
// ENOENT or permission denied — surface once, don't crash the worker.
log(`[colony worker] caffeinate spawn error: ${err.message}`);
});

return {
stop: () => {
if (!child || child.killed) return;
try {
child.kill('SIGTERM');
} catch {
// Process already exited or PID reused — nothing to clean up.
}
},
};
}
22 changes: 21 additions & 1 deletion apps/worker/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { expand } from '@colony/compress';
import { type Settings, loadSettings, resolveDataDir } from '@colony/config';
import { type HivemindOptions, MemoryStore, listPlans, readHivemind } from '@colony/core';
import { createEmbedder } from '@colony/embedding';
import { isMainEntry, removePidFile, writePidFile } from '@colony/process';
import { isMainEntry, notify, removePidFile, writePidFile } from '@colony/process';
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { type CaffeinateHandle, startCaffeinate } from './caffeinate.js';
import { type EmbedLoopHandle, startEmbedLoop, stateFilePath } from './embed-loop.js';
import { renderIndex, renderSession } from './viewer.js';

Expand Down Expand Up @@ -217,10 +218,12 @@ export async function start(): Promise<void> {
writePidFile(pidFilePath(settings));

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

const shutdown = async () => {
removePidFile(pidFilePath(settings));
caffeinate?.stop();
if (loop) await loop.stop();
for (const s of servers) s.close();
store.close();
Expand All @@ -244,6 +247,18 @@ export async function start(): Promise<void> {
} catch (err) {
embedderError = err instanceof Error ? err.message : String(err);
process.stderr.write(`[colony worker] embedder unavailable: ${embedderError}\n`);
notify(
{
level: 'warn',
title: 'colony: embedder unavailable',
body: `Semantic search disabled — BM25 still works. (${embedderError})`,
},
{
provider: settings.notify.provider,
minLevel: settings.notify.minLevel,
log: (line) => process.stderr.write(`${line}\n`),
},
);
}

if (embedder) {
Expand All @@ -255,6 +270,11 @@ export async function start(): Promise<void> {
shutdown().finally(() => process.exit(0));
},
});
// Only hold the idle-sleep assertion while there's actual background work
// to protect. If the embedder failed to load we skip caffeinate entirely
// — the worker is then effectively just a viewer + state file writer and
// doesn't need to keep the laptop awake.
caffeinate = startCaffeinate((line) => process.stderr.write(`${line}\n`));
} else {
// Still write a minimal state file so `colony status` has something to show.
writeFileSync(
Expand Down
15 changes: 15 additions & 0 deletions apps/worker/test/caffeinate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { startCaffeinate } from '../src/caffeinate.js';

describe('caffeinate', () => {
it('returns a no-op handle on non-darwin platforms', () => {
if (process.platform === 'darwin') return; // covered by the spawn path
const log: string[] = [];
const handle = startCaffeinate((line) => log.push(line));
expect(typeof handle.stop).toBe('function');
expect(log).toEqual([]);
// Idempotent stop — must not throw if called twice.
handle.stop();
handle.stop();
});
});
19 changes: 19 additions & 0 deletions packages/config/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export type CompressionIntensity = z.infer<typeof CompressionIntensity>;
export const EmbeddingProvider = z.enum(['local', 'ollama', 'openai', 'none']);
export type EmbeddingProvider = z.infer<typeof EmbeddingProvider>;

export const NotifyProvider = z.enum(['desktop', 'none']);
export type NotifyProvider = z.infer<typeof NotifyProvider>;

export const NotifyLevel = z.enum(['info', 'warn', 'error']);
export type NotifyLevel = z.infer<typeof NotifyLevel>;

export const SettingsSchema = z
.object({
dataDir: z
Expand Down Expand Up @@ -110,6 +116,19 @@ export const SettingsSchema = z
.record(z.string(), z.boolean())
.default({})
.describe('Installed IDE integrations (set by `colony install`).'),
notify: z
.object({
provider: NotifyProvider.default('none').describe(
'Desktop notification provider. desktop = native (osascript on macOS, notify-send on Linux); none = silent. Default off so colony is unobtrusive on a fresh install.',
),
minLevel: NotifyLevel.default('warn').describe(
'Drop messages below this level. error surfaces only failures; warn includes degraded states like a missing embedder.',
),
})
.default({ provider: 'none', minLevel: 'warn' })
.describe(
'Background notifications. The worker uses this to surface conditions you would otherwise only see by reading stderr or running `colony status`.',
),
foraging: z
.object({
enabled: z
Expand Down
37 changes: 36 additions & 1 deletion packages/core/src/task-thread.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { ObservationRow, TaskClaimRow, TaskParticipantRow, TaskRow } from '@colony/storage';
import type {
LinkedTask,
ObservationRow,
TaskClaimRow,
TaskLinkRow,
TaskParticipantRow,
TaskRow,
} from '@colony/storage';
import type { MemoryStore } from './memory-store.js';
import {
type AgentProfile,
Expand Down Expand Up @@ -324,6 +331,34 @@ export class TaskThread {
return this.store.storage.listClaims(this.task_id);
}

/**
* Tasks linked to this one, in either direction. Cross-task links let an
* agent on a "frontend" task see decisions/blockers from a paired
* "backend" task without copy-paste — the inbox / preface scans
* linkedTimeline() the same way it scans this task's own timeline.
*/
linkedTasks(): LinkedTask[] {
return this.store.storage.linkedTasks(this.task_id);
}

/**
* Symmetric link operation. Either side can call; the storage layer
* normalises (low_id, high_id) so re-links are idempotent. Note is
* optional and renders next to the link in attention prefaces.
*/
link(other_task_id: number, created_by: string, note?: string): TaskLinkRow {
return this.store.storage.linkTasks({
task_id_a: this.task_id,
task_id_b: other_task_id,
created_by,
...(note !== undefined ? { note } : {}),
});
}

unlink(other_task_id: number): boolean {
return this.store.storage.unlinkTasks(this.task_id, other_task_id);
}

timeline(limit = 50): ObservationRow[] {
return this.store.storage.taskTimeline(this.task_id, limit);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/process/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export { isMainEntry } from './is-main.js';
export { isAlive } from './alive.js';
export { readPidFile, writePidFile, removePidFile } from './pidfile.js';
export { spawnNodeScript } from './spawn.js';
export { notify, buildNotifyArgv } from './notify.js';
export type { NotifyLevel, NotifyMessage, NotifyOptions } from './notify.js';
Loading
Loading