mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36693b922e | ||
|
|
95a59b0e9f | ||
|
|
dae4d8fbee |
@@ -1,24 +1,5 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
## 1.0.0
|
|
||||||
|
|
||||||
### Major Changes
|
|
||||||
|
|
||||||
- # Codeman 1.0.0 🎉
|
|
||||||
|
|
||||||
The first stable release of Codeman — and it comes with a fresh new look.
|
|
||||||
|
|
||||||
**New: theme skins.** Codeman now ships a built-in skin switcher (App Settings → Display → Appearance):
|
|
||||||
- **OG Codeman** — the original look, preserved exactly.
|
|
||||||
- **Daylight Green** — a fresh emerald-on-slate theme.
|
|
||||||
- **Daylight Blue** — bright sky-blue on lifted slate (the new default).
|
|
||||||
|
|
||||||
Skins apply instantly, persist per device (with a pre-paint script so there's no flash on load), and re-theme any open terminals live. The system is built on `html[data-skin]` design tokens and self-hosted Manrope (UI) + JetBrains Mono (terminal) fonts — no external CDN, CSP-safe.
|
|
||||||
|
|
||||||
**1.0.0 milestone.** This marks the start of the stable 1.x line: the CLI, documented environment variables, and the `{ success, data }` HTTP/SSE API envelope follow semantic versioning (see `docs/versioning-policy.md`).
|
|
||||||
|
|
||||||
**Thank you to everyone who helped build Codeman.** This release is dedicated to all of our contributors for their work on the project: Ark0N, Aamer Akhter (@aakhter), Tenggan Zhang (@TeigenZhang), zhouyuan / @sunnyzhouy, jaypark, Marco Migozzi, Skúli Arnlaugsson, Aaron Fields, Loïc Sculier, and Noah Waldner (@noahwaldner). 💙
|
|
||||||
|
|
||||||
## 0.9.14
|
## 0.9.14
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ When user says "COM":
|
|||||||
|
|
||||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||||
|
|
||||||
**Version**: 1.0.0 (must match `package.json`)
|
**Version**: 0.9.14 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.0.0",
|
"version": "0.9.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.0.0",
|
"version": "0.9.14",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.0.0",
|
"version": "0.9.14",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Agent Artifact Index — joins each subagent to the files it produced.
|
||||||
|
*
|
||||||
|
* The load-bearing piece behind the Agent Gallery view. It answers "which files did
|
||||||
|
* this subagent write?" by subscribing to {@link subagentWatcher} events rather than
|
||||||
|
* re-reading transcripts: every `subagent:tool_call` for a file-producing tool
|
||||||
|
* (Write/Edit/MultiEdit/NotebookEdit) carries the agent id, session id, and the full
|
||||||
|
* tool input — including `file_path` — so the join is exact and free of duplicate I/O.
|
||||||
|
*
|
||||||
|
* What it stores is METADATA ONLY (paths, kinds, write counts). It never reads or
|
||||||
|
* serves file contents — guard enforcement (realpath, sensitive-path, attachment-guard,
|
||||||
|
* extension allowlist) lives in the serving layer that turns these paths into
|
||||||
|
* attachment/artifact ids. Treat the recorded path as untrusted: it originates from a
|
||||||
|
* subagent transcript and must pass the guard chain before anything reads it.
|
||||||
|
*
|
||||||
|
* Key exports:
|
||||||
|
* - `AgentArtifactIndex` class — EventEmitter; `start(source?)` / `stop()` lifecycle
|
||||||
|
* - `agentArtifactIndex` — pre-instantiated singleton
|
||||||
|
* - `AgentArtifact`, `ArtifactKind`, `ArtifactSource`, `AgentArtifactIndexEvents` — types
|
||||||
|
*
|
||||||
|
* Extension point: `recordExternalArtifact()` lets a later image-watcher correlation
|
||||||
|
* attribute Bash-written screenshots (which never appear as a Write tool_use) to the
|
||||||
|
* agent that was active when the file landed. The transcript join below stands alone.
|
||||||
|
*
|
||||||
|
* @emits artifact:added (new path for an agent), artifact:updated (re-write of a known
|
||||||
|
* path), artifact:error
|
||||||
|
* @dependencies subagent-watcher (event source), config/map-limits (bounds)
|
||||||
|
* @consumedby web/server (SSE broadcast as `agent:artifact`), gallery route
|
||||||
|
* @module agent-artifact-index
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { basename, extname, isAbsolute, resolve } from 'node:path';
|
||||||
|
import { MAX_ARTIFACTS_PER_AGENT, MAX_TRACKED_AGENTS } from './config/map-limits.js';
|
||||||
|
import { subagentWatcher, type SubagentToolCall } from './subagent-watcher.js';
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
export type ArtifactKind = 'html' | 'image' | 'pdf' | 'document' | 'markdown' | 'text' | 'code' | 'other';
|
||||||
|
|
||||||
|
/** How the artifact was attributed to its agent. */
|
||||||
|
export type ArtifactSource = 'write' | 'edit' | 'notebook' | 'external';
|
||||||
|
|
||||||
|
export interface AgentArtifact {
|
||||||
|
agentId: string;
|
||||||
|
sessionId: string;
|
||||||
|
/** Absolute path, normalized (`resolve`d) when absolute; left raw otherwise. */
|
||||||
|
path: string;
|
||||||
|
/** `basename(path)`. */
|
||||||
|
filename: string;
|
||||||
|
/** Lowercased extension including the dot (e.g. `.html`); `''` when none. */
|
||||||
|
ext: string;
|
||||||
|
/** Render hint derived from {@link ext}. Drives the gallery's preview mode. */
|
||||||
|
kind: ArtifactKind;
|
||||||
|
source: ArtifactSource;
|
||||||
|
/** Epoch ms when this path was first attributed to the agent. */
|
||||||
|
firstSeenAt: number;
|
||||||
|
/** Epoch ms of the most recent write/edit of this path. */
|
||||||
|
lastWriteAt: number;
|
||||||
|
/** Number of write/edit calls observed against this path. */
|
||||||
|
writeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed event map (Node 22 typed EventEmitter, matching {@link BashToolParser}). */
|
||||||
|
export interface AgentArtifactIndexEvents {
|
||||||
|
'artifact:added': [AgentArtifact];
|
||||||
|
'artifact:updated': [AgentArtifact];
|
||||||
|
'artifact:error': [Error];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal structural type for the event source, for testability without the real watcher. */
|
||||||
|
type ToolCallEmitter = Pick<EventEmitter, 'on' | 'off'>;
|
||||||
|
|
||||||
|
// ========== Constants ==========
|
||||||
|
|
||||||
|
/** Tools whose successful call means the agent produced/changed a file. */
|
||||||
|
const FILE_WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
||||||
|
|
||||||
|
const KIND_BY_EXT: Readonly<Record<string, ArtifactKind>> = {
|
||||||
|
'.html': 'html',
|
||||||
|
'.htm': 'html',
|
||||||
|
'.png': 'image',
|
||||||
|
'.jpg': 'image',
|
||||||
|
'.jpeg': 'image',
|
||||||
|
'.gif': 'image',
|
||||||
|
'.webp': 'image',
|
||||||
|
'.avif': 'image',
|
||||||
|
'.svg': 'image',
|
||||||
|
'.pdf': 'pdf',
|
||||||
|
'.docx': 'document',
|
||||||
|
'.pptx': 'document',
|
||||||
|
'.xlsx': 'document',
|
||||||
|
'.md': 'markdown',
|
||||||
|
'.markdown': 'markdown',
|
||||||
|
'.txt': 'text',
|
||||||
|
'.log': 'text',
|
||||||
|
'.csv': 'text',
|
||||||
|
'.js': 'code',
|
||||||
|
'.mjs': 'code',
|
||||||
|
'.cjs': 'code',
|
||||||
|
'.ts': 'code',
|
||||||
|
'.tsx': 'code',
|
||||||
|
'.jsx': 'code',
|
||||||
|
'.css': 'code',
|
||||||
|
'.json': 'code',
|
||||||
|
'.py': 'code',
|
||||||
|
'.sh': 'code',
|
||||||
|
'.yml': 'code',
|
||||||
|
'.yaml': 'code',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Helpers ==========
|
||||||
|
|
||||||
|
function classifyKind(ext: string): ArtifactKind {
|
||||||
|
return KIND_BY_EXT[ext] ?? 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceForTool(tool: string): ArtifactSource {
|
||||||
|
if (tool === 'Write') return 'write';
|
||||||
|
if (tool === 'NotebookEdit') return 'notebook';
|
||||||
|
return 'edit'; // Edit, MultiEdit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve `..`/`.` for absolute paths so repeat writes dedupe to one artifact.
|
||||||
|
* Relative paths are left raw: `resolve()` would anchor them to the SERVER cwd, not the
|
||||||
|
* agent's, producing a wrong absolute path. Write requires absolute paths, so this is rare.
|
||||||
|
*/
|
||||||
|
function normalizePath(p: string): string {
|
||||||
|
return isAbsolute(p) ? resolve(p) : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO transcript timestamp → epoch ms, falling back to now. */
|
||||||
|
function timestampToMs(ts: string | undefined): number {
|
||||||
|
if (ts) {
|
||||||
|
const n = Date.parse(ts);
|
||||||
|
if (!Number.isNaN(n)) return n;
|
||||||
|
}
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the target path from a file-tool's input (Write/Edit use `file_path`, NotebookEdit `notebook_path`). */
|
||||||
|
function extractFilePath(input: Record<string, unknown> | undefined): string | undefined {
|
||||||
|
const candidate = input?.file_path ?? input?.notebook_path;
|
||||||
|
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== AgentArtifactIndex ==========
|
||||||
|
|
||||||
|
export class AgentArtifactIndex extends EventEmitter<AgentArtifactIndexEvents> {
|
||||||
|
/** agentId → (normalized path → artifact). Outer map is insertion-ordered for FIFO eviction. */
|
||||||
|
private readonly byAgent = new Map<string, Map<string, AgentArtifact>>();
|
||||||
|
|
||||||
|
private source: ToolCallEmitter | null = null;
|
||||||
|
private readonly onToolCall = (call: SubagentToolCall): void => {
|
||||||
|
try {
|
||||||
|
if (!FILE_WRITE_TOOLS.has(call.tool)) return;
|
||||||
|
const rawPath = extractFilePath(call.fullInput);
|
||||||
|
if (!rawPath) return;
|
||||||
|
this.record(call.agentId, call.sessionId, rawPath, sourceForTool(call.tool), timestampToMs(call.timestamp));
|
||||||
|
} catch (err) {
|
||||||
|
this.emit('artifact:error', err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin attributing artifacts. Idempotent. Defaults to the real {@link subagentWatcher};
|
||||||
|
* tests pass a bare EventEmitter and drive `subagent:tool_call` directly.
|
||||||
|
*/
|
||||||
|
start(source: ToolCallEmitter = subagentWatcher): void {
|
||||||
|
if (this.source) {
|
||||||
|
if (this.source === source) return;
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
this.source = source;
|
||||||
|
source.on('subagent:tool_call', this.onToolCall as (...args: unknown[]) => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detach from the event source. Retains recorded artifacts (use {@link clear} to drop them). */
|
||||||
|
stop(): void {
|
||||||
|
if (this.source) {
|
||||||
|
this.source.off('subagent:tool_call', this.onToolCall as (...args: unknown[]) => void);
|
||||||
|
this.source = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attribute a file discovered outside the transcript — e.g. a screenshot written by a
|
||||||
|
* Bash `capture.mjs`, correlated by the image-watcher to the agent active when it landed.
|
||||||
|
* Marked `source: 'external'`. (Wired for a follow-up; not yet called by the image-watcher.)
|
||||||
|
*/
|
||||||
|
recordExternalArtifact(agentId: string, sessionId: string, absPath: string, atMs: number = Date.now()): void {
|
||||||
|
if (typeof absPath !== 'string' || absPath.length === 0) return;
|
||||||
|
this.record(agentId, sessionId, absPath, 'external', atMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record(agentId: string, sessionId: string, rawPath: string, source: ArtifactSource, atMs: number): void {
|
||||||
|
const path = normalizePath(rawPath);
|
||||||
|
|
||||||
|
let bucket = this.byAgent.get(agentId);
|
||||||
|
if (!bucket) {
|
||||||
|
// Bound the number of tracked agents (FIFO eviction of the oldest agent).
|
||||||
|
if (this.byAgent.size >= MAX_TRACKED_AGENTS) {
|
||||||
|
const oldest = this.byAgent.keys().next().value;
|
||||||
|
if (oldest !== undefined) this.byAgent.delete(oldest);
|
||||||
|
}
|
||||||
|
bucket = new Map();
|
||||||
|
this.byAgent.set(agentId, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = bucket.get(path);
|
||||||
|
if (existing) {
|
||||||
|
existing.lastWriteAt = Math.max(existing.lastWriteAt, atMs);
|
||||||
|
existing.firstSeenAt = Math.min(existing.firstSeenAt, atMs);
|
||||||
|
existing.writeCount += 1;
|
||||||
|
existing.sessionId = sessionId; // tolerate late/corrected session attribution
|
||||||
|
this.emit('artifact:updated', existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound artifacts per agent (FIFO eviction of the oldest path for this agent).
|
||||||
|
if (bucket.size >= MAX_ARTIFACTS_PER_AGENT) {
|
||||||
|
const oldest = bucket.keys().next().value;
|
||||||
|
if (oldest !== undefined) bucket.delete(oldest);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extname(path).toLowerCase();
|
||||||
|
const artifact: AgentArtifact = {
|
||||||
|
agentId,
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
filename: basename(path),
|
||||||
|
ext,
|
||||||
|
kind: classifyKind(ext),
|
||||||
|
source,
|
||||||
|
firstSeenAt: atMs,
|
||||||
|
lastWriteAt: atMs,
|
||||||
|
writeCount: 1,
|
||||||
|
};
|
||||||
|
bucket.set(path, artifact);
|
||||||
|
this.emit('artifact:added', artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Artifacts written by one agent, most-recently-written first. */
|
||||||
|
getArtifactsForAgent(agentId: string): AgentArtifact[] {
|
||||||
|
const bucket = this.byAgent.get(agentId);
|
||||||
|
if (!bucket) return [];
|
||||||
|
return [...bucket.values()].sort((a, b) => b.lastWriteAt - a.lastWriteAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Artifacts across every agent in a Codeman session, most-recently-written first. */
|
||||||
|
getArtifactsForSession(sessionId: string): AgentArtifact[] {
|
||||||
|
const out: AgentArtifact[] = [];
|
||||||
|
for (const bucket of this.byAgent.values()) {
|
||||||
|
for (const artifact of bucket.values()) {
|
||||||
|
if (artifact.sessionId === sessionId) out.push(artifact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.lastWriteAt - a.lastWriteAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every tracked artifact, most-recently-written first. */
|
||||||
|
getAllArtifacts(): AgentArtifact[] {
|
||||||
|
const out: AgentArtifact[] = [];
|
||||||
|
for (const bucket of this.byAgent.values()) out.push(...bucket.values());
|
||||||
|
return out.sort((a, b) => b.lastWriteAt - a.lastWriteAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
getArtifact(agentId: string, path: string): AgentArtifact | undefined {
|
||||||
|
return this.byAgent.get(agentId)?.get(normalizePath(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total number of tracked artifacts across all agents. */
|
||||||
|
count(): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const bucket of this.byAgent.values()) n += bucket.size;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop all artifacts for one agent (e.g. when its subagent window is dismissed). */
|
||||||
|
clearAgent(agentId: string): void {
|
||||||
|
this.byAgent.delete(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop everything. */
|
||||||
|
clear(): void {
|
||||||
|
this.byAgent.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-instantiated singleton, mirroring `subagentWatcher`. Call `.start()` to attach. */
|
||||||
|
export const agentArtifactIndex = new AgentArtifactIndex();
|
||||||
@@ -62,6 +62,12 @@ export const MAX_TRACKED_AGENTS = 500;
|
|||||||
*/
|
*/
|
||||||
export const MAX_PENDING_TOOL_CALLS = 100;
|
export const MAX_PENDING_TOOL_CALLS = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum distinct file artifacts tracked per subagent in the artifact index.
|
||||||
|
* Bounds memory for agents that write many files (FIFO eviction when exceeded).
|
||||||
|
*/
|
||||||
|
export const MAX_ARTIFACTS_PER_AGENT = 200;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TTL for orphaned pending tool calls (5 minutes).
|
* TTL for orphaned pending tool calls (5 minutes).
|
||||||
* If no tool_result received, entry is cleaned up.
|
* If no tool_result received, entry is cleaned up.
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ const SSE_EVENTS = {
|
|||||||
SUBAGENT_MESSAGE: 'subagent:message',
|
SUBAGENT_MESSAGE: 'subagent:message',
|
||||||
SUBAGENT_TOOL_RESULT: 'subagent:tool_result',
|
SUBAGENT_TOOL_RESULT: 'subagent:tool_result',
|
||||||
SUBAGENT_COMPLETED: 'subagent:completed',
|
SUBAGENT_COMPLETED: 'subagent:completed',
|
||||||
|
AGENT_ARTIFACT: 'agent:artifact',
|
||||||
|
|
||||||
// Images
|
// Images
|
||||||
IMAGE_DETECTED: 'image:detected',
|
IMAGE_DETECTED: 'image:detected',
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
type SubagentMessage,
|
type SubagentMessage,
|
||||||
type SubagentToolResult,
|
type SubagentToolResult,
|
||||||
} from '../subagent-watcher.js';
|
} from '../subagent-watcher.js';
|
||||||
|
import { agentArtifactIndex, type AgentArtifact } from '../agent-artifact-index.js';
|
||||||
import { imageWatcher } from '../image-watcher.js';
|
import { imageWatcher } from '../image-watcher.js';
|
||||||
import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js';
|
import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js';
|
||||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||||
@@ -248,6 +249,11 @@ export class WebServer extends EventEmitter {
|
|||||||
completed: (info: SubagentInfo) => void;
|
completed: (info: SubagentInfo) => void;
|
||||||
error: (error: Error, agentId?: string) => void;
|
error: (error: Error, agentId?: string) => void;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
private artifactIndexHandlers: {
|
||||||
|
added: (artifact: AgentArtifact) => void;
|
||||||
|
updated: (artifact: AgentArtifact) => void;
|
||||||
|
error: (error: Error) => void;
|
||||||
|
} | null = null;
|
||||||
private imageWatcherHandlers: {
|
private imageWatcherHandlers: {
|
||||||
detected: (event: ImageDetectedEvent) => void;
|
detected: (event: ImageDetectedEvent) => void;
|
||||||
attachmentDetected: (event: AttachmentDetectedEvent) => void;
|
attachmentDetected: (event: AttachmentDetectedEvent) => void;
|
||||||
@@ -412,6 +418,18 @@ export class WebServer extends EventEmitter {
|
|||||||
subagentWatcher.on('subagent:message', this.subagentWatcherHandlers.message);
|
subagentWatcher.on('subagent:message', this.subagentWatcherHandlers.message);
|
||||||
subagentWatcher.on('subagent:completed', this.subagentWatcherHandlers.completed);
|
subagentWatcher.on('subagent:completed', this.subagentWatcherHandlers.completed);
|
||||||
subagentWatcher.on('subagent:error', this.subagentWatcherHandlers.error);
|
subagentWatcher.on('subagent:error', this.subagentWatcherHandlers.error);
|
||||||
|
|
||||||
|
// Agent artifact index: attribute the files each subagent writes (from subagentWatcher's
|
||||||
|
// tool-call stream) and broadcast them as `agent:artifact` for the Agent Gallery view.
|
||||||
|
agentArtifactIndex.start(subagentWatcher);
|
||||||
|
this.artifactIndexHandlers = {
|
||||||
|
added: (artifact: AgentArtifact) => this.broadcast(SseEvent.AgentArtifact, artifact),
|
||||||
|
updated: (artifact: AgentArtifact) => this.broadcast(SseEvent.AgentArtifact, artifact),
|
||||||
|
error: (error: Error) => console.error('[AgentArtifactIndex] Error:', error.message),
|
||||||
|
};
|
||||||
|
agentArtifactIndex.on('artifact:added', this.artifactIndexHandlers.added);
|
||||||
|
agentArtifactIndex.on('artifact:updated', this.artifactIndexHandlers.updated);
|
||||||
|
agentArtifactIndex.on('artifact:error', this.artifactIndexHandlers.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -429,6 +447,13 @@ export class WebServer extends EventEmitter {
|
|||||||
subagentWatcher.off('subagent:error', this.subagentWatcherHandlers.error);
|
subagentWatcher.off('subagent:error', this.subagentWatcherHandlers.error);
|
||||||
this.subagentWatcherHandlers = null;
|
this.subagentWatcherHandlers = null;
|
||||||
}
|
}
|
||||||
|
if (this.artifactIndexHandlers) {
|
||||||
|
agentArtifactIndex.off('artifact:added', this.artifactIndexHandlers.added);
|
||||||
|
agentArtifactIndex.off('artifact:updated', this.artifactIndexHandlers.updated);
|
||||||
|
agentArtifactIndex.off('artifact:error', this.artifactIndexHandlers.error);
|
||||||
|
this.artifactIndexHandlers = null;
|
||||||
|
}
|
||||||
|
agentArtifactIndex.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ export const SubagentProgress = 'subagent:progress' as const;
|
|||||||
export const SubagentMessage = 'subagent:message' as const;
|
export const SubagentMessage = 'subagent:message' as const;
|
||||||
/** Subagent finished. */
|
/** Subagent finished. */
|
||||||
export const SubagentCompleted = 'subagent:completed' as const;
|
export const SubagentCompleted = 'subagent:completed' as const;
|
||||||
|
/** A file artifact was attributed to a subagent (newly added or re-written). Powers the Agent Gallery. */
|
||||||
|
export const AgentArtifact = 'agent:artifact' as const;
|
||||||
|
|
||||||
// ─── Scheduled Runs ──────────────────────────────────────────────────────────
|
// ─── Scheduled Runs ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -442,6 +444,7 @@ export const SseEvent = {
|
|||||||
SubagentProgress,
|
SubagentProgress,
|
||||||
SubagentMessage,
|
SubagentMessage,
|
||||||
SubagentCompleted,
|
SubagentCompleted,
|
||||||
|
AgentArtifact,
|
||||||
|
|
||||||
// Scheduled runs
|
// Scheduled runs
|
||||||
ScheduledCreated,
|
ScheduledCreated,
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Tests for AgentArtifactIndex — the subagent → produced-files join.
|
||||||
|
*
|
||||||
|
* Driven through a bare EventEmitter standing in for `subagentWatcher`, so no real
|
||||||
|
* transcript files, tmux, or server are involved.
|
||||||
|
*/
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { AgentArtifactIndex, type AgentArtifact } from '../src/agent-artifact-index.js';
|
||||||
|
import { MAX_ARTIFACTS_PER_AGENT } from '../src/config/map-limits.js';
|
||||||
|
|
||||||
|
/** Build a `subagent:tool_call` payload like the watcher emits. */
|
||||||
|
function toolCall(over: {
|
||||||
|
agentId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
tool: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
timestamp?: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
agentId: over.agentId ?? 'agent-1',
|
||||||
|
sessionId: over.sessionId ?? 'sess-1',
|
||||||
|
timestamp: over.timestamp ?? '2026-06-13T05:44:00.000Z',
|
||||||
|
tool: over.tool,
|
||||||
|
input: over.input,
|
||||||
|
toolUseId: 'toolu_x',
|
||||||
|
fullInput: over.input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AgentArtifactIndex', () => {
|
||||||
|
let index: AgentArtifactIndex;
|
||||||
|
let source: EventEmitter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
source = new EventEmitter();
|
||||||
|
index = new AgentArtifactIndex();
|
||||||
|
index.start(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
function emitCall(over: Parameters<typeof toolCall>[0]) {
|
||||||
|
source.emit('subagent:tool_call', toolCall(over));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('records a Write as an artifact and classifies HTML', () => {
|
||||||
|
const added: AgentArtifact[] = [];
|
||||||
|
index.on('artifact:added', (a) => added.push(a));
|
||||||
|
|
||||||
|
emitCall({
|
||||||
|
tool: 'Write',
|
||||||
|
input: { file_path: '/home/arkon/proj/mockups/01-aurora.html', content: '<!doctype html>' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(added).toHaveLength(1);
|
||||||
|
expect(added[0]).toMatchObject({
|
||||||
|
agentId: 'agent-1',
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
path: '/home/arkon/proj/mockups/01-aurora.html',
|
||||||
|
filename: '01-aurora.html',
|
||||||
|
ext: '.html',
|
||||||
|
kind: 'html',
|
||||||
|
source: 'write',
|
||||||
|
writeCount: 1,
|
||||||
|
});
|
||||||
|
expect(index.getArtifactsForAgent('agent-1')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes repeated writes/edits of the same path and counts them', () => {
|
||||||
|
const updated: AgentArtifact[] = [];
|
||||||
|
index.on('artifact:updated', (a) => updated.push(a));
|
||||||
|
|
||||||
|
emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '1' }, timestamp: '2026-06-13T05:00:00.000Z' });
|
||||||
|
emitCall({
|
||||||
|
tool: 'Edit',
|
||||||
|
input: { file_path: '/p/a.html', old_string: '1', new_string: '2' },
|
||||||
|
timestamp: '2026-06-13T05:01:00.000Z',
|
||||||
|
});
|
||||||
|
emitCall({ tool: 'Edit', input: { file_path: '/p/a.html' }, timestamp: '2026-06-13T05:02:00.000Z' });
|
||||||
|
|
||||||
|
const arts = index.getArtifactsForAgent('agent-1');
|
||||||
|
expect(arts).toHaveLength(1);
|
||||||
|
expect(arts[0].writeCount).toBe(3);
|
||||||
|
expect(arts[0].source).toBe('write'); // first attribution wins
|
||||||
|
expect(arts[0].lastWriteAt).toBe(Date.parse('2026-06-13T05:02:00.000Z'));
|
||||||
|
expect(arts[0].firstSeenAt).toBe(Date.parse('2026-06-13T05:00:00.000Z'));
|
||||||
|
expect(updated).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes ../. in absolute paths so they dedupe', () => {
|
||||||
|
emitCall({ tool: 'Write', input: { file_path: '/p/sub/../a.html', content: '' } });
|
||||||
|
emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '' } });
|
||||||
|
expect(index.getArtifactsForAgent('agent-1')).toHaveLength(1);
|
||||||
|
expect(index.getArtifactsForAgent('agent-1')[0].path).toBe('/p/a.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-file tools and writes missing a path', () => {
|
||||||
|
emitCall({ tool: 'Read', input: { file_path: '/p/a.html' } });
|
||||||
|
emitCall({ tool: 'Bash', input: { command: 'node capture.mjs' } });
|
||||||
|
emitCall({ tool: 'Write', input: { content: 'no path' } });
|
||||||
|
expect(index.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles NotebookEdit via notebook_path with notebook source', () => {
|
||||||
|
emitCall({ tool: 'NotebookEdit', input: { notebook_path: '/p/analysis.ipynb', new_source: 'x' } });
|
||||||
|
const arts = index.getArtifactsForAgent('agent-1');
|
||||||
|
expect(arts).toHaveLength(1);
|
||||||
|
expect(arts[0].source).toBe('notebook');
|
||||||
|
expect(arts[0].kind).toBe('other'); // .ipynb not in the render map
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['/p/x.png', 'image'],
|
||||||
|
['/p/x.PNG', 'image'],
|
||||||
|
['/p/x.svg', 'image'],
|
||||||
|
['/p/x.pdf', 'pdf'],
|
||||||
|
['/p/x.docx', 'document'],
|
||||||
|
['/p/x.pptx', 'document'],
|
||||||
|
['/p/x.md', 'markdown'],
|
||||||
|
['/p/x.txt', 'text'],
|
||||||
|
['/p/x.css', 'code'],
|
||||||
|
['/p/x.ts', 'code'],
|
||||||
|
['/p/x.bin', 'other'],
|
||||||
|
['/p/Makefile', 'other'],
|
||||||
|
])('classifies %s as %s', (file, kind) => {
|
||||||
|
emitCall({ tool: 'Write', input: { file_path: file, content: '' } });
|
||||||
|
expect(index.getAllArtifacts()[0].kind).toBe(kind);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups artifacts by agent and by session', () => {
|
||||||
|
emitCall({ agentId: 'a1', sessionId: 's1', tool: 'Write', input: { file_path: '/p/1.html', content: '' } });
|
||||||
|
emitCall({ agentId: 'a2', sessionId: 's1', tool: 'Write', input: { file_path: '/p/2.html', content: '' } });
|
||||||
|
emitCall({ agentId: 'a3', sessionId: 's2', tool: 'Write', input: { file_path: '/p/3.html', content: '' } });
|
||||||
|
|
||||||
|
expect(index.getArtifactsForAgent('a1')).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
index
|
||||||
|
.getArtifactsForSession('s1')
|
||||||
|
.map((a) => a.agentId)
|
||||||
|
.sort()
|
||||||
|
).toEqual(['a1', 'a2']);
|
||||||
|
expect(index.getArtifactsForSession('s2')).toHaveLength(1);
|
||||||
|
expect(index.count()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts results most-recently-written first', () => {
|
||||||
|
emitCall({
|
||||||
|
tool: 'Write',
|
||||||
|
input: { file_path: '/p/old.html', content: '' },
|
||||||
|
timestamp: '2026-06-13T01:00:00.000Z',
|
||||||
|
});
|
||||||
|
emitCall({
|
||||||
|
tool: 'Write',
|
||||||
|
input: { file_path: '/p/new.html', content: '' },
|
||||||
|
timestamp: '2026-06-13T09:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(index.getArtifactsForAgent('agent-1').map((a) => a.filename)).toEqual(['new.html', 'old.html']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records external artifacts (image-watcher correlation hook)', () => {
|
||||||
|
index.recordExternalArtifact(
|
||||||
|
'agent-1',
|
||||||
|
'sess-1',
|
||||||
|
'/p/shots/01-desktop.png',
|
||||||
|
Date.parse('2026-06-13T05:45:00.000Z')
|
||||||
|
);
|
||||||
|
const arts = index.getArtifactsForAgent('agent-1');
|
||||||
|
expect(arts).toHaveLength(1);
|
||||||
|
expect(arts[0]).toMatchObject({ source: 'external', kind: 'image', filename: '01-desktop.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds artifacts per agent with FIFO eviction', () => {
|
||||||
|
for (let i = 0; i < MAX_ARTIFACTS_PER_AGENT + 5; i++) {
|
||||||
|
emitCall({ tool: 'Write', input: { file_path: `/p/f${i}.html`, content: '' } });
|
||||||
|
}
|
||||||
|
expect(index.getArtifactsForAgent('agent-1')).toHaveLength(MAX_ARTIFACTS_PER_AGENT);
|
||||||
|
// Oldest (f0..f4) evicted; newest retained.
|
||||||
|
expect(index.getArtifact('agent-1', '/p/f0.html')).toBeUndefined();
|
||||||
|
expect(index.getArtifact('agent-1', `/p/f${MAX_ARTIFACTS_PER_AGENT + 4}.html`)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop() detaches from the source; clear() drops artifacts', () => {
|
||||||
|
emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '' } });
|
||||||
|
index.stop();
|
||||||
|
emitCall({ tool: 'Write', input: { file_path: '/p/b.html', content: '' } });
|
||||||
|
expect(index.count()).toBe(1); // second call ignored after stop
|
||||||
|
|
||||||
|
index.clear();
|
||||||
|
expect(index.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('start() is idempotent — no double counting on repeat start with same source', () => {
|
||||||
|
index.start(source);
|
||||||
|
index.start(source);
|
||||||
|
const added: AgentArtifact[] = [];
|
||||||
|
index.on('artifact:added', (a) => added.push(a));
|
||||||
|
emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '' } });
|
||||||
|
expect(added).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearAgent() drops only the named agent', () => {
|
||||||
|
emitCall({ agentId: 'a1', tool: 'Write', input: { file_path: '/p/1.html', content: '' } });
|
||||||
|
emitCall({ agentId: 'a2', tool: 'Write', input: { file_path: '/p/2.html', content: '' } });
|
||||||
|
index.clearAgent('a1');
|
||||||
|
expect(index.getArtifactsForAgent('a1')).toHaveLength(0);
|
||||||
|
expect(index.getArtifactsForAgent('a2')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user