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
7 changes: 7 additions & 0 deletions .changeset/colony-session-cwd-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cavemem/hooks": patch
"@cavemem/storage": patch
"cavemem": patch
---

Bind hook-created sessions back to their repository cwd so colony views can see live Codex/Claude work instead of orphan `cwd: null` sessions.
18 changes: 16 additions & 2 deletions apps/cli/src/commands/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,13 @@ export function registerHookCommand(program: Command): void {
const hookName = name as HookName;
const raw = await readStdin();
const parsed = raw.trim() ? safeJson(raw) : {};
const sessionId = readString(parsed.session_id) ?? 'unknown';
const ide = opts.ide ?? readString(parsed.ide) ?? inferIdeFromSessionId(sessionId);
const input = {
session_id: typeof parsed.session_id === 'string' ? parsed.session_id : 'unknown',
...parsed,
...(opts.ide ? { ide: opts.ide } : {}),
session_id: sessionId,
cwd: readString(parsed.cwd) ?? process.cwd(),
...(ide ? { ide } : {}),
} as Parameters<typeof runHook>[1];

const result = await runHook(hookName, input);
Expand Down Expand Up @@ -83,6 +86,17 @@ function safeJson(s: string): Record<string, unknown> {
}
}

function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value : undefined;
}

function inferIdeFromSessionId(sessionId: string): string | undefined {
const prefix = sessionId.split('@')[0]?.toLowerCase();
if (prefix === 'codex') return 'codex';
if (prefix === 'claude' || prefix === 'claude-code') return 'claude-code';
return undefined;
}

function readStdin(): Promise<string> {
return new Promise((resolve) => {
if (process.stdin.isTTY) {
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/test/program.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { createProgram } from '../src/index.js';

describe('cavemem CLI program', () => {
describe('Colony CLI program', () => {
it('registers every top-level command', () => {
const program = createProgram();
const names = program.commands.map((c) => c.name()).sort();
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp-server/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ async function seed(): Promise<{ a: number; b: number }> {
}

beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'cavemem-mcp-'));
dir = mkdtempSync(join(tmpdir(), 'colony-mcp-'));
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings });
const server = buildServer(store, defaultSettings);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp-server/test/task-threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function seedTwoSessionTask(): { task_id: number; sessionA: string; sessionB: st
}

beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'cavemem-task-threads-'));
dir = mkdtempSync(join(tmpdir(), 'colony-task-threads-'));
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings });
const server = buildServer(store, defaultSettings);
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/test/embed-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function mockEmbedder(model: string, dim: number): Embedder {
}

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cavemem-embed-'));
dir = mkdtempSync(join(tmpdir(), 'colony-embed-'));
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: buildSettings() });
});

Expand Down
2 changes: 1 addition & 1 deletion apps/worker/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function seedFileLocks(repoRoot: string): void {
}

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cavemem-worker-'));
dir = mkdtempSync(join(tmpdir(), 'colony-worker-'));
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings });
app = buildApp(store);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/memory-store-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ let dir: string;
let store: MemoryStore;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cavemem-core-'));
dir = mkdtempSync(join(tmpdir(), 'colony-core-'));
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings });
store.startSession({ id: 's1', ide: 'test', cwd: '/tmp' });
store.addObservation({
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/pheromone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function seedTwoSessionTask(): number {
}

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cavemem-pheromone-'));
dir = mkdtempSync(join(tmpdir(), 'colony-pheromone-'));
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings });
});

Expand Down
11 changes: 6 additions & 5 deletions packages/core/test/proposal-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function seed(...ids: string[]): void {
}

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cavemem-proposal-'));
dir = mkdtempSync(join(tmpdir(), 'colony-proposal-'));
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings });
});

Expand Down Expand Up @@ -106,7 +106,8 @@ describe('ProposalSystem.reinforce', () => {
// The promoted task should exist on a synthetic branch so it doesn't
// collide with the source branch's task via the (repo_root, branch)
// UNIQUE constraint.
const task = store.storage.getTask(proposal!.task_id!);
if (!proposal?.task_id) throw new Error('expected promoted task id');
const task = store.storage.getTask(proposal.task_id);
expect(task?.branch).toBe(`b/proposal-${id}`);
expect(task?.title).toBe('the real thing');
});
Expand All @@ -124,12 +125,12 @@ describe('ProposalSystem.reinforce', () => {
});
proposals.reinforce({ proposal_id: id, session_id: 'B', kind: 'explicit' });
proposals.reinforce({ proposal_id: id, session_id: 'C', kind: 'explicit' });
const first_task_id = store.storage.getProposal(id)!.task_id;
const first_task_id = store.storage.getProposal(id)?.task_id;
expect(first_task_id).not.toBeNull();

const result = proposals.reinforce({ proposal_id: id, session_id: 'D', kind: 'explicit' });
expect(result.promoted).toBe(false);
expect(store.storage.getProposal(id)!.task_id).toBe(first_task_id);
expect(store.storage.getProposal(id)?.task_id).toBe(first_task_id);
});
});

Expand Down Expand Up @@ -229,7 +230,7 @@ describe('ProposalSystem.foragingReport', () => {
});
proposals.reinforce({ proposal_id: strong, session_id: 'B', kind: 'explicit' });
// Weak proposal: proposer only, strength ~1.0.
const weak = proposals.propose({
proposals.propose({
repo_root: '/r',
branch: 'b',
summary: 'weak',
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/response-thresholds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function seed(...ids: string[]): void {
}

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cavemem-thresholds-'));
dir = mkdtempSync(join(tmpdir(), 'colony-thresholds-'));
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings });
});

Expand Down Expand Up @@ -180,7 +180,7 @@ describe('TaskThread.handOff suggested_candidates integration', () => {
expect(meta.suggested_candidates?.find((c) => c.agent === 'claude-sender')).toBeUndefined();
});

it("leaves suggested_candidates undefined for directed handoffs", () => {
it('leaves suggested_candidates undefined for directed handoffs', () => {
seed('claude-a', 'codex-a');
const thread = TaskThread.open(store, {
repo_root: '/r',
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/task-thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function seed(...ids: string[]): void {
}

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cavemem-task-thread-'));
dir = mkdtempSync(join(tmpdir(), 'colony-task-thread-'));
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings });
});

Expand Down
105 changes: 105 additions & 0 deletions packages/hooks/src/active-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { detectRepoBranch } from '@cavemem/core';
import type { HookInput, HookName } from './types.js';

type ActiveSessionState = 'working' | 'thinking' | 'idle';

const ACTIVE_SESSIONS_RELATIVE_DIR = join('.omx', 'state', 'active-sessions');
const PREVIEW_LIMIT = 180;

export function upsertActiveSession(input: HookInput, hook: HookName): void {
const detected = detectFromInput(input);
if (!detected) return;

const filePath = activeSessionFilePath(detected.repo_root, input.session_id);
const existing = readExisting(filePath);
const now = new Date().toISOString();
const preview = taskPreview(input, hook);
const record = {
schemaVersion: 1,
repoRoot: detected.repo_root,
branch: detected.branch,
taskName: preview || existing?.taskName || 'Agent session',
latestTaskPreview: preview || existing?.latestTaskPreview || '',
agentName: agentName(input),
cliName: input.ide ?? agentName(input),
worktreePath: detected.repo_root,
taskMode: existing?.taskMode ?? '',
openspecTier: existing?.openspecTier ?? '',
taskRoutingReason: 'cavemem hook cwd binding',
startedAt: existing?.startedAt ?? now,
lastHeartbeatAt: now,
state: stateForHook(hook),
sessionKey: input.session_id,
};

mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
}

export function removeActiveSession(input: HookInput): void {
const detected = detectFromInput(input);
if (!detected) return;

const filePath = activeSessionFilePath(detected.repo_root, input.session_id);
if (existsSync(filePath)) unlinkSync(filePath);
}

function detectFromInput(input: Pick<HookInput, 'cwd'>) {
if (!input.cwd) return null;
return detectRepoBranch(input.cwd);
}

function activeSessionFilePath(repoRoot: string, sessionId: string): string {
return join(repoRoot, ACTIVE_SESSIONS_RELATIVE_DIR, `${sanitize(sessionId)}.json`);
}

function readExisting(filePath: string): Record<string, string> | null {
try {
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}

function sanitize(value: string): string {
const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
return cleaned || 'unknown-session';
}

function stateForHook(hook: HookName): ActiveSessionState {
if (hook === 'user-prompt-submit') return 'thinking';
if (hook === 'stop') return 'idle';
return 'working';
}

function agentName(input: Pick<HookInput, 'ide' | 'session_id'>): string {
if (input.ide === 'claude-code') return 'claude';
if (input.ide === 'codex') return 'codex';
const prefix = input.session_id.split('@')[0]?.toLowerCase();
if (prefix === 'claude' || prefix === 'claude-code') return 'claude';
if (prefix === 'codex') return 'codex';
return input.ide ?? 'agent';
}

function taskPreview(input: HookInput, hook: HookName): string {
const raw =
hook === 'user-prompt-submit'
? input.prompt
: hook === 'post-tool-use'
? `Tool: ${input.tool_name ?? input.tool ?? 'unknown'}`
: hook === 'stop'
? (input.turn_summary ?? input.last_assistant_message)
: hook === 'session-end'
? input.reason
: input.source
? `Session start: ${input.source}`
: 'Session start';
return typeof raw === 'string' ? oneLine(raw).slice(0, PREVIEW_LIMIT) : '';
}

function oneLine(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
6 changes: 2 additions & 4 deletions packages/hooks/src/handlers/session-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function buildTaskPreface(
// accepting. Purely advisory — anyone eligible can still accept.
if (h.meta.to_agent === 'any' && h.meta.suggested_candidates?.length) {
const top = h.meta.suggested_candidates[0];
if (!top) continue;
const mine = h.meta.suggested_candidates.find((c) => c.agent === agent);
const hints = [`top match: ${top.agent} (${top.score.toFixed(2)})`];
if (mine && mine.agent !== top.agent) {
Expand All @@ -121,10 +122,7 @@ export function buildTaskPreface(
* task_reinforce) or silently ignore. A quiet queue with zero pending
* is the right UX — the preface stays empty and doesn't waste context.
*/
export function buildProposalPreface(
store: MemoryStore,
input: Pick<HookInput, 'cwd'>,
): string {
export function buildProposalPreface(store: MemoryStore, input: Pick<HookInput, 'cwd'>): string {
const cwd = input.cwd;
if (!cwd) return '';
const detected = detectRepoBranch(cwd);
Expand Down
46 changes: 44 additions & 2 deletions packages/hooks/src/runner.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { join } from 'node:path';
import { loadSettings, resolveDataDir } from '@cavemem/config';
import { MemoryStore } from '@cavemem/core';
import { removeActiveSession, upsertActiveSession } from './active-session.js';
import { ensureWorkerRunning } from './auto-spawn.js';
import { postToolUse } from './handlers/post-tool-use.js';
import { sessionEnd } from './handlers/session-end.js';
import { sessionStart } from './handlers/session-start.js';
import { buildProposalPreface, buildTaskPreface, sessionStart } from './handlers/session-start.js';
import { stop } from './handlers/stop.js';
import { userPromptSubmit } from './handlers/user-prompt-submit.js';
import type { HookInput, HookName, HookResult } from './types.js';
Expand Down Expand Up @@ -35,22 +36,35 @@ export async function runHook(
store = new MemoryStore({ dbPath, settings });
}
try {
let bootstrapContext = '';
if (name !== 'session-start') {
materializeSession(store, input);
if (name !== 'session-end') {
bootstrapContext = ensureTaskBinding(store, input);
}
}

let context: string | undefined;
switch (name) {
case 'session-start':
upsertActiveSession(input, name);
context = await sessionStart(store, input);
break;
case 'user-prompt-submit':
context = await userPromptSubmit(store, input);
upsertActiveSession(input, name);
context = joinContext(bootstrapContext, await userPromptSubmit(store, input));
break;
case 'post-tool-use':
upsertActiveSession(input, name);
await postToolUse(store, input);
break;
case 'stop':
upsertActiveSession(input, name);
await stop(store, input);
break;
case 'session-end':
await sessionEnd(store, input);
removeActiveSession(input);
break;
}
// Fire-and-forget: ensure the worker is running so embeddings happen
Expand All @@ -72,3 +86,31 @@ export async function runHook(
if (!injected) store.close();
}
}

function materializeSession(store: MemoryStore, input: HookInput): void {
store.startSession({
id: input.session_id,
ide: input.ide ?? inferIdeFromSessionId(input.session_id) ?? 'unknown',
cwd: input.cwd ?? null,
});
}

function ensureTaskBinding(store: MemoryStore, input: HookInput): string {
if (!input.cwd) return '';
if (store.storage.findActiveTaskForSession(input.session_id) !== undefined) return '';
return joinContext(buildTaskPreface(store, input), buildProposalPreface(store, input));
}

function inferIdeFromSessionId(sessionId: string): string | undefined {
const prefix = sessionId.split('@')[0]?.toLowerCase();
if (prefix === 'codex') return 'codex';
if (prefix === 'claude' || prefix === 'claude-code') return 'claude-code';
return undefined;
}

function joinContext(...parts: Array<string | undefined>): string {
return parts
.map((p) => p?.trim())
.filter(Boolean)
.join('\n\n');
}
2 changes: 1 addition & 1 deletion packages/hooks/test/auto-claim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function seedTwoSessionTask(): number {
}

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cavemem-auto-claim-'));
dir = mkdtempSync(join(tmpdir(), 'colony-auto-claim-'));
store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings });
});

Expand Down
Loading
Loading