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/infer-ide-unknown-owner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@colony/core": patch
"@colony/hooks": patch
"@colony/worker": patch
---

Infer the IDE owner for sessions whose id is hyphen-delimited (e.g. `codex-colony-usage-limit-takeover-verify-...`). Previously `MemoryStore.ensureSession` hardcoded `ide = 'unknown'` and the hook-side inferrer only matched the `codex@...` / `claude@...` form, so every on-demand-materialised row landed as `unknown` in the viewer. The worker's session index now also shows an owner chip and re-infers legacy `unknown` rows at render time (italic + `?` suffix to signal the value is derived, not authoritative), and Hivemind lane cards tag the owner directly.
70 changes: 59 additions & 11 deletions apps/worker/src/viewer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { HivemindSession, HivemindSnapshot } from '@colony/core';
import { inferIdeFromSessionId, type HivemindSession, type HivemindSnapshot } from '@colony/core';
import type { SessionRow } from '@colony/storage';

const style = `
Expand All @@ -20,6 +20,11 @@ const style = `
h1 { margin: 0 0 4px; font-size: 20px; }
h2 { margin: 0 0 12px; font-size: 16px; color: #cfd5de; }
code { background: #1d2129; padding: 1px 4px; border-radius: 3px; }
.owner { display: inline-block; margin-right: 6px; padding: 1px 7px; border-radius: 999px; background: #1c2433; color: #cdd6e4; font-size: 11px; font-weight: 500; }
.owner[data-owner="codex"] { background: #1a2a1f; color: #8bd5a6; }
.owner[data-owner="claude-code"] { background: #2a2238; color: #c8b1ff; }
.owner[data-owner="unknown"] { background: #2a1f1f; color: #e48a8a; }
.owner[data-derived="true"] { font-style: italic; opacity: 0.85; }
`;

function layout(title: string, body: string): string {
Expand All @@ -33,23 +38,49 @@ function esc(s: string): string {
);
}

/**
* Backfill display-only owner when the stored ide is 'unknown' — legacy rows
* from before MemoryStore.ensureSession learned to infer. Mark the result
* `derived` so the viewer renders it italic + with a `?` suffix instead of
* claiming the store actually knows the owner.
*/
function resolveOwner(storedIde: string, sessionId: string): { ide: string; derived: boolean } {
if (storedIde && storedIde !== 'unknown') return { ide: storedIde, derived: false };
const inferred = inferIdeFromSessionId(sessionId);
if (inferred) return { ide: inferred, derived: true };
return { ide: 'unknown', derived: false };
}

function ownerChip(ide: string, derived: boolean): string {
const label = derived ? `${ide}?` : ide;
return `<span class="owner" data-owner="${esc(ide)}" data-derived="${String(derived)}">${esc(label)}</span>`;
}

export function renderIndex(sessions: SessionRow[], snapshot?: HivemindSnapshot): string {
const dashboard = snapshot ? renderHivemindDashboard(snapshot) : '';
if (sessions.length === 0) {
return layout('agents-hivemind', `${dashboard}<p>No memory sessions yet.</p>`);
}
const ownerCounts = new Map<string, number>();
const items = sessions
.map(
(s) => `
.map((s) => {
const owner = resolveOwner(s.ide, s.id);
ownerCounts.set(owner.ide, (ownerCounts.get(owner.ide) ?? 0) + 1);
const cwdHtml = s.cwd ? ` · ${esc(s.cwd)}` : '';
return `
<div class="card">
<a href="/sessions/${esc(s.id)}"><strong>${esc(s.id)}</strong></a>
<div class="meta">${esc(s.ide)} · ${esc(s.cwd ?? '')} · ${new Date(s.started_at).toISOString()}</div>
</div>`,
)
<div>${ownerChip(owner.ide, owner.derived)}<a href="/sessions/${esc(s.id)}"><strong>${esc(s.id)}</strong></a></div>
<div class="meta">${new Date(s.started_at).toISOString()}${cwdHtml}</div>
</div>`;
})
.join('');
const summary = [...ownerCounts.entries()]
.sort(([, a], [, b]) => b - a)
.map(([ide, n]) => `<span class="owner" data-owner="${esc(ide)}">${esc(ide)} · ${n}</span>`)
.join(' ');
return layout(
'agents-hivemind · sessions',
`${dashboard}<h2>Recent memory sessions</h2>${items}`,
`${dashboard}<h2>Recent memory sessions</h2><p class="meta">${summary}</p>${items}`,
);
}

Expand All @@ -66,9 +97,10 @@ export function renderSession(
</div>`,
)
.join('');
const owner = resolveOwner(session.ide, session.id);
return layout(
`agents-hivemind · ${session.id}`,
`<h2>${esc(session.id)} <span class="meta">(${esc(session.ide)})</span></h2><p><a href="/">&larr; all sessions</a></p>${rows}`,
`<h2>${ownerChip(owner.ide, owner.derived)}${esc(session.id)}</h2><p><a href="/">&larr; all sessions</a></p>${rows}`,
);
}

Expand Down Expand Up @@ -102,17 +134,33 @@ function renderLane(session: HivemindSession): string {
session.locked_file_count > 0
? `<div class="meta">GX locks ${session.locked_file_count}: ${esc(session.locked_file_preview.join(', '))}</div>`
: '';
const ownerIde = laneOwnerIde(session);
const ownerDerived = ownerIde !== session.agent && ownerIde !== session.cli;
return `
<div class="card lane" data-attention="${String(attention)}">
<strong>${esc(session.task || session.task_name || session.branch)}</strong>
<span class="badge" data-attention="${String(attention)}">${esc(session.activity)}</span>
<div>${ownerChip(ownerIde, ownerDerived)}<strong>${esc(session.task || session.task_name || session.branch)}</strong>
<span class="badge" data-attention="${String(attention)}">${esc(session.activity)}</span></div>
<div class="meta">${esc(session.agent)}/${esc(session.cli)} · ${esc(session.branch)} · ${esc(session.source)}</div>
<div class="meta">${esc(session.activity_summary)} Updated ${esc(session.updated_at || 'unknown')}.</div>
${lockSummary}
<div class="meta">${esc(session.worktree_path)}</div>
</div>`;
}

/**
* Pick the owner label for a lane card. Prefers concrete signals (agent
* name parsed from the `agent/<name>/...` branch, cli identifier) before
* falling back to a prefix inference on the session id. Keeps codex- vs
* claude-driven lanes visually distinguishable even when the hivemind
* telemetry only knew the generic `'agent'` fallback.
*/
function laneOwnerIde(session: HivemindSession): string {
if (session.agent && session.agent !== 'agent') return session.agent;
if (session.cli && session.cli !== 'unknown') return session.cli;
const inferred = inferIdeFromSessionId(session.session_key);
return inferred ?? session.agent ?? 'unknown';
}

function laneNeedsAttention(session: HivemindSession): boolean {
return ['dead', 'stalled', 'unknown'].includes(session.activity);
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
export { hybridRank } from './ranker.js';
export type { SearchResult, GetObservationsOptions, Observation, Session } from './types.js';
export { createSessionId } from './ids.js';
export { inferIdeFromSessionId } from './infer-ide.js';
export {
TaskThread,
type CoordinationKind,
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/infer-ide.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Best-effort mapping from a session id to the IDE / agent that created it.
*
* Hooks write `ide = input.ide ?? infer(session_id) ?? 'unknown'`. Without a
* broad matcher, ids like `codex-colony-usage-limit-takeover-verify-...` — the
* hyphen-delimited task-named sessions codex emits — fell through and landed
* in storage as `unknown`. The viewer then shows every such row as an
* unowned session, making it impossible to tell who ran what.
*
* Keep this list conservative: prefix inference is a heuristic, so we only
* return a known IDE id and never guess from arbitrary strings.
*/
export function inferIdeFromSessionId(sessionId: string): string | undefined {
if (!sessionId) return undefined;
const prefix = sessionId.split(/[@\-:/_]/)[0]?.toLowerCase();
if (!prefix) return undefined;
switch (prefix) {
case 'claude':
case 'claudecode':
return 'claude-code';
case 'codex':
return 'codex';
case 'gemini':
return 'gemini';
case 'cursor':
return 'cursor';
case 'windsurf':
return 'windsurf';
case 'aider':
return 'aider';
default:
return undefined;
}
}
3 changes: 2 additions & 1 deletion packages/core/src/memory-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { compress, expand, redactPrivate } from '@colony/compress';
import type { Settings } from '@colony/config';
import { type NewObservation, type ObservationRow, Storage } from '@colony/storage';
import { inferIdeFromSessionId } from './infer-ide.js';
import { cosine, hybridRank } from './ranker.js';
import type { GetObservationsOptions, Observation, SearchResult } from './types.js';

Expand Down Expand Up @@ -95,7 +96,7 @@ export class MemoryStore {
private ensureSession(id: string): void {
this.storage.createSession({
id,
ide: 'unknown',
ide: inferIdeFromSessionId(id) ?? 'unknown',
cwd: null,
started_at: Date.now(),
metadata: null,
Expand Down
29 changes: 29 additions & 0 deletions packages/core/test/infer-ide.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { inferIdeFromSessionId } from '../src/infer-ide.js';

describe('inferIdeFromSessionId', () => {
it('matches @-delimited prefixes (original form)', () => {
expect(inferIdeFromSessionId('codex@019dbcdf')).toBe('codex');
expect(inferIdeFromSessionId('claude@7a67fdea')).toBe('claude-code');
});

it('matches hyphen-delimited task-named session ids', () => {
// Regression: codex writes session ids like this when a task thread
// is the session key. Previously split('@') kept the whole string and
// the prefix never matched, so every row landed as 'unknown'.
expect(
inferIdeFromSessionId('codex-colony-usage-limit-takeover-verify-2026-04-24-20-48'),
).toBe('codex');
expect(inferIdeFromSessionId('claude-code-refactor-sidebar')).toBe('claude-code');
});

it('normalises claude and claude-code to claude-code', () => {
expect(inferIdeFromSessionId('claude:abc')).toBe('claude-code');
expect(inferIdeFromSessionId('claudecode/foo')).toBe('claude-code');
});

it('returns undefined for unknown prefixes and empty input', () => {
expect(inferIdeFromSessionId('some-random-id')).toBeUndefined();
expect(inferIdeFromSessionId('')).toBeUndefined();
});
});
9 changes: 1 addition & 8 deletions packages/hooks/src/runner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { join } from 'node:path';
import { loadSettings, resolveDataDir } from '@colony/config';
import { MemoryStore } from '@colony/core';
import { MemoryStore, inferIdeFromSessionId } from '@colony/core';
import { removeActiveSession, upsertActiveSession } from './active-session.js';
import { ensureWorkerRunning } from './auto-spawn.js';
import { postToolUse } from './handlers/post-tool-use.js';
Expand Down Expand Up @@ -101,13 +101,6 @@ function ensureTaskBinding(store: MemoryStore, input: HookInput): string {
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())
Expand Down