Skip to content
Open
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
69 changes: 69 additions & 0 deletions docs/plans/2026-03-09-dynamic-agent-lifecycle-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Dynamic Agent Lifecycle — Design

## Problem

Pixel Agents only detects Claude Code sessions running in VS Code integrated terminals
within the current workspace. This misses:

1. **Subagent sessions** — Agent tool spawns write to `<session>/subagents/*.jsonl`
2. **External sessions** — `claude -p` subprocess calls (e.g., Security Analyst bots)
3. **Cross-workspace sessions** — Claude Code in other project directories

Result: The office feels empty even when many agents are actively working.

## Solution: Global Session Discovery + Headless Agents

### Core Idea

Scan ALL directories under `~/.claude/projects/` for active JSONL files.
Sessions without a VS Code terminal become "headless agents" — visible characters
that work, type, and react, but cannot be focused to a terminal.

### Architecture Changes

#### 1. AgentState (types.ts)

```typescript
terminalRef?: vscode.Terminal; // optional (was required)
isHeadless?: boolean; // true for non-terminal agents
sourceDir?: string; // which project dir this came from
```

#### 2. Global Scanner (fileWatcher.ts)

- `ensureGlobalScan()` — scans `~/.claude/projects/*/` for active JONLs
- Recursive: also scans `<session-uuid>/subagents/*.jsonl`
- Smart filter: only JONLs actively growing (modified <10min, >3KB)
- Single shared interval timer for all directories

#### 3. Headless Agent Manager (agentManager.ts)

- `addHeadlessAgent(jsonlFile, projectDir)` — creates agent without terminal
- Focus action: opens JSONL file in editor (read-only)
- Auto-despawn: 5 minutes without JSONL growth → remove agent

#### 4. Lifecycle

| Event | Action |
|-------|--------|
| New active JSONL found | Spawn agent (matrix effect) |
| JSONL stops growing (5min) | Despawn agent (matrix effect) |
| Terminal closed | Despawn immediately |
| Subagent JSONL appears | Spawn as sub-character |

### Files Changed

| File | Change |
|------|--------|
| `src/types.ts` | `terminalRef` optional, `isHeadless` flag |
| `src/constants.ts` | Timeout constants |
| `src/fileWatcher.ts` | Global scan, subagent scan |
| `src/agentManager.ts` | `addHeadlessAgent()`, headless focus |
| `src/PixelAgentsViewProvider.ts` | Global scan init, auto-despawn |

### What Stays the Same

- Webview rendering (already agent-type agnostic)
- Character state machine (IDLE/WALK/TYPE)
- Tool event parsing (transcriptParser.ts)
- Subagent character spawning (already works via `Subtask:` prefix)
103 changes: 99 additions & 4 deletions src/PixelAgentsViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,14 @@ import {
sendFloorTilesToWebview,
sendWallTilesToWebview,
} from './assetLoader.js';
import { GLOBAL_KEY_SOUND_ENABLED, WORKSPACE_KEY_AGENT_SEATS } from './constants.js';
import { ensureProjectScan } from './fileWatcher.js';
import {
GLOBAL_KEY_SOUND_ENABLED,
GLOBAL_SCAN_INTERVAL_MS,
HEADLESS_ACTIVITY_CHECK_INTERVAL_MS,
HEADLESS_INACTIVITY_TIMEOUT_MS,
WORKSPACE_KEY_AGENT_SEATS,
} from './constants.js';
import { checkHeadlessActivity, ensureProjectScan, globalScanForAgents } from './fileWatcher.js';
import type { LayoutWatcher } from './layoutPersistence.js';
import { readLayoutFromFile, watchLayoutFile, writeLayoutToFile } from './layoutPersistence.js';
import type { AgentState } from './types.js';
Expand All @@ -47,6 +53,10 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
knownJsonlFiles = new Set<string>();
projectScanTimer = { current: null as ReturnType<typeof setInterval> | null };

// Global agent discovery timers
globalScanTimer: ReturnType<typeof setInterval> | null = null;
headlessCheckTimer: ReturnType<typeof setInterval> | null = null;

// Bundled default layout (loaded from assets/default-layout.json)
defaultLayout: Record<string, unknown> | null = null;

Expand Down Expand Up @@ -93,12 +103,33 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
} else if (message.type === 'focusAgent') {
const agent = this.agents.get(message.id);
if (agent) {
agent.terminalRef.show();
if (agent.terminalRef) {
agent.terminalRef.show();
} else if (agent.isHeadless) {
// Headless agent — open JSONL file in editor as read-only preview
const uri = vscode.Uri.file(agent.jsonlFile);
vscode.window.showTextDocument(uri, { preview: true });
}
}
} else if (message.type === 'closeAgent') {
const agent = this.agents.get(message.id);
if (agent) {
agent.terminalRef.dispose();
if (agent.terminalRef) {
agent.terminalRef.dispose();
} else {
// Headless agent — remove directly (no terminal to dispose)
removeAgent(
message.id,
this.agents,
this.fileWatchers,
this.pollingTimers,
this.waitingTimers,
this.permissionTimers,
this.jsonlPollTimers,
this.persistAgents,
);
this.webview?.postMessage({ type: 'agentClosed', id: message.id });
}
}
} else if (message.type === 'saveAgentSeats') {
// Store seat assignments in a separate key (never touched by persistAgents)
Expand Down Expand Up @@ -261,6 +292,9 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
})();
}
sendExistingAgents(this.agents, this.context, this.webview);

// Start global agent discovery — scan ALL ~/.claude/projects/ for active sessions
this.startGlobalScan();
} else if (message.type === 'openSessionsFolder') {
const projectDir = getProjectDirPath();
if (projectDir && fs.existsSync(projectDir)) {
Expand Down Expand Up @@ -369,9 +403,70 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
});
}

/**
* Start global agent discovery — scans ALL ~/.claude/projects/ directories
* for active JSONL files and creates headless agents for untracked sessions.
* Also periodically checks headless agents for inactivity and despawns them.
*/
private startGlobalScan(): void {
if (this.globalScanTimer) return;
console.log('[Pixel Agents] Starting global agent discovery');

const doScan = (): void => {
try {
globalScanForAgents(
this.knownJsonlFiles,
this.nextAgentId,
this.agents,
this.fileWatchers,
this.pollingTimers,
this.waitingTimers,
this.permissionTimers,
this.webview,
this.persistAgents,
);
} catch (err) {
console.error('[Pixel Agents] Global scan error:', err);
}
};

// Initial scan (delayed to let normal agent restore complete first)
setTimeout(doScan, 3000);

// Periodic scan for new agents
this.globalScanTimer = setInterval(doScan, GLOBAL_SCAN_INTERVAL_MS);

// Periodic headless activity check (auto-despawn inactive headless agents)
this.headlessCheckTimer = setInterval(() => {
try {
checkHeadlessActivity(
this.agents,
this.fileWatchers,
this.pollingTimers,
this.waitingTimers,
this.permissionTimers,
this.jsonlPollTimers,
this.webview,
this.persistAgents,
HEADLESS_INACTIVITY_TIMEOUT_MS,
);
} catch (err) {
console.error('[Pixel Agents] Headless activity check error:', err);
}
}, HEADLESS_ACTIVITY_CHECK_INTERVAL_MS);
}

dispose() {
this.layoutWatcher?.dispose();
this.layoutWatcher = null;
if (this.globalScanTimer) {
clearInterval(this.globalScanTimer);
this.globalScanTimer = null;
}
if (this.headlessCheckTimer) {
clearInterval(this.headlessCheckTimer);
this.headlessCheckTimer = null;
}
for (const id of [...this.agents.keys()]) {
removeAgent(
id,
Expand Down
19 changes: 14 additions & 5 deletions src/agentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,11 @@ export function persistAgents(
for (const agent of agents.values()) {
persisted.push({
id: agent.id,
terminalName: agent.terminalRef.name,
terminalName: agent.terminalRef?.name,
jsonlFile: agent.jsonlFile,
projectDir: agent.projectDir,
folderName: agent.folderName,
isHeadless: agent.isHeadless,
});
}
context.workspaceState.update(WORKSPACE_KEY_AGENTS, persisted);
Expand Down Expand Up @@ -217,12 +218,17 @@ export function restoreAgents(
let restoredProjectDir: string | null = null;

for (const p of persisted) {
const terminal = liveTerminals.find((t) => t.name === p.terminalName);
if (!terminal) continue;
// For headless agents, skip terminal lookup — they don't have one
let terminal: vscode.Terminal | undefined;
if (!p.isHeadless) {
terminal = p.terminalName ? liveTerminals.find((t) => t.name === p.terminalName) : undefined;
if (!terminal) continue; // Terminal-based agent whose terminal is gone
}

const agent: AgentState = {
id: p.id,
terminalRef: terminal,
isHeadless: p.isHeadless,
projectDir: p.projectDir,
jsonlFile: p.jsonlFile,
fileOffset: 0,
Expand All @@ -236,15 +242,18 @@ export function restoreAgents(
permissionSent: false,
hadToolsInTurn: false,
folderName: p.folderName,
lastActivityMs: Date.now(),
};

agents.set(p.id, agent);
knownJsonlFiles.add(p.jsonlFile);
console.log(`[Pixel Agents] Restored agent ${p.id} → terminal "${p.terminalName}"`);
console.log(
`[Pixel Agents] Restored agent ${p.id} → ${p.isHeadless ? 'headless' : `terminal "${p.terminalName}"`}`,
);

if (p.id > maxId) maxId = p.id;
// Extract terminal index from name like "Claude Code #3"
const match = p.terminalName.match(/#(\d+)$/);
const match = p.terminalName?.match(/#(\d+)$/);
if (match) {
const idx = parseInt(match[1], 10);
if (idx > maxIdx) maxIdx = idx;
Expand Down
7 changes: 7 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ export const LAYOUT_FILE_POLL_INTERVAL_MS = 2000;
// ── Settings Persistence ────────────────────────────────────
export const GLOBAL_KEY_SOUND_ENABLED = 'pixel-agents.soundEnabled';

// ── Global Agent Discovery ──────────────────────────────────
export const GLOBAL_SCAN_INTERVAL_MS = 2000;
export const HEADLESS_INACTIVITY_TIMEOUT_MS = 300_000; // 5 minutes → despawn
export const HEADLESS_ACTIVITY_CHECK_INTERVAL_MS = 30_000; // check every 30s
export const ACTIVE_JSONL_MIN_SIZE = 3000; // 3KB minimum to be considered active
export const ACTIVE_JSONL_MAX_AGE_MS = 600_000; // 10 minutes

// ── VS Code Identifiers ─────────────────────────────────────
export const VIEW_ID = 'pixel-agents.panelView';
export const COMMAND_SHOW_PANEL = 'pixel-agents.showPanel';
Expand Down
Loading