mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
fix: read subagent descriptions directly from parent transcript
Instead of timing-based correlation between terminal output and subagent files, now reads the Task tool's description parameter directly from the parent session's transcript. This provides 100% reliable mapping between agent IDs and their short descriptions, fixing issues where multiple agents spawned in quick succession would get the same window title. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md
|
|||||||
|
|
||||||
Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs.
|
Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs.
|
||||||
|
|
||||||
**Version**: 0.1379 (must match `package.json`)
|
**Version**: 0.1380 (must match `package.json`)
|
||||||
|
|
||||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
||||||
|
|
||||||
@@ -582,6 +582,16 @@ Placeholders replaced:
|
|||||||
|
|
||||||
`./scripts/screen-manager.sh` - Interactive bash tool for managing screen sessions. Commands: `list`, `attach N`, `kill N,M`, `kill-all`, `info N`. Requires `jq` and `screen`.
|
`./scripts/screen-manager.sh` - Interactive bash tool for managing screen sessions. Commands: `list`, `attach N`, `kill N,M`, `kill-all`, `info N`. Requires `jq` and `screen`.
|
||||||
|
|
||||||
|
## Web UI Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| `Ctrl+Enter` | Quick-start session |
|
||||||
|
| `Ctrl+W` | Close session |
|
||||||
|
| `Ctrl+Tab` | Next session |
|
||||||
|
| `Ctrl+K` | Kill all sessions |
|
||||||
|
| `Ctrl+L` | Clear terminal |
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- `docs/respawn-state-machine.md` - Respawn controller states, idle detection, auto-accept
|
- `docs/respawn-state-machine.md` - Respawn controller states, idle detection, auto-accept
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"name": "claudeman",
|
||||||
"version": "0.1379",
|
"version": "0.1380",
|
||||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+104
-44
@@ -553,6 +553,73 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
return truncated.replace(/[.!?,:\s]+$/, '');
|
return truncated.replace(/[.!?,:\s]+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the short description from the parent session's transcript.
|
||||||
|
* This is the most reliable method because it reads the actual Task tool call
|
||||||
|
* that spawned this agent, which contains the description parameter.
|
||||||
|
*
|
||||||
|
* The parent transcript contains:
|
||||||
|
* 1. An assistant message with tool_use for Task, containing input.description
|
||||||
|
* 2. A progress entry with data.agentId and parentToolUseID linking them
|
||||||
|
*/
|
||||||
|
private extractDescriptionFromParentTranscript(
|
||||||
|
projectHash: string,
|
||||||
|
sessionId: string,
|
||||||
|
agentId: string
|
||||||
|
): string | undefined {
|
||||||
|
try {
|
||||||
|
// The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl
|
||||||
|
const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`);
|
||||||
|
if (!existsSync(transcriptPath)) return undefined;
|
||||||
|
|
||||||
|
const content = readFileSync(transcriptPath, 'utf8');
|
||||||
|
const lines = content.split('\n').filter((l) => l.trim());
|
||||||
|
|
||||||
|
// First pass: Find the progress entry for this agent to get parentToolUseID
|
||||||
|
let parentToolUseID: string | undefined;
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.type === 'progress' && entry.data?.agentId === agentId && entry.parentToolUseID) {
|
||||||
|
parentToolUseID = entry.parentToolUseID;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentToolUseID) return undefined;
|
||||||
|
|
||||||
|
// Second pass: Find the Task tool_use with this ID and extract description
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.type === 'assistant' && entry.message?.content) {
|
||||||
|
const content = entry.message.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content) {
|
||||||
|
if (
|
||||||
|
block.type === 'tool_use' &&
|
||||||
|
block.id === parentToolUseID &&
|
||||||
|
block.name === 'Task' &&
|
||||||
|
block.input?.description
|
||||||
|
) {
|
||||||
|
return block.input.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Failed to read transcript
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract description from agent file by finding first user message
|
* Extract description from agent file by finding first user message
|
||||||
*/
|
*/
|
||||||
@@ -683,41 +750,13 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
// Initial info
|
// Initial info
|
||||||
const stat = statSync(filePath);
|
const stat = statSync(filePath);
|
||||||
|
|
||||||
// Extract description from first user message in the JSONL
|
// Extract description - prefer reading from parent transcript (most reliable)
|
||||||
// The Task tool passes a "description" (short title) and "prompt" (full task)
|
// The parent transcript has the exact Task tool call with description parameter
|
||||||
// We want the short description, which may be in the first line or sentence
|
let description = this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
|
||||||
let description: string | undefined;
|
|
||||||
try {
|
|
||||||
const content = readFileSync(filePath, 'utf8');
|
|
||||||
const lines = content.split('\n').filter((l) => l.trim());
|
|
||||||
|
|
||||||
// Try each line until we find a user message with text
|
// Fallback: extract a smart title from the subagent's prompt if parent lookup failed
|
||||||
for (const line of lines.slice(0, 5)) {
|
if (!description) {
|
||||||
try {
|
description = this.extractDescriptionFromFile(filePath);
|
||||||
const entry = JSON.parse(line);
|
|
||||||
if (entry.type === 'user' && entry.message?.content) {
|
|
||||||
// Handle both formats: content can be a string or an array of content blocks
|
|
||||||
let text: string | undefined;
|
|
||||||
if (typeof entry.message.content === 'string') {
|
|
||||||
text = entry.message.content.trim();
|
|
||||||
} else if (Array.isArray(entry.message.content)) {
|
|
||||||
const firstContent = entry.message.content[0];
|
|
||||||
if (firstContent?.type === 'text' && firstContent.text) {
|
|
||||||
text = firstContent.text.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text) {
|
|
||||||
description = this.extractSmartTitle(text);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip malformed lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Failed to read description, continue without it
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const info: SubagentInfo = {
|
const info: SubagentInfo = {
|
||||||
@@ -764,7 +803,16 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
|
|
||||||
// Retry description extraction if missing (race condition fix)
|
// Retry description extraction if missing (race condition fix)
|
||||||
if (!existingInfo.description) {
|
if (!existingInfo.description) {
|
||||||
const extractedDescription = this.extractDescriptionFromFile(filePath);
|
// First try parent transcript (most reliable)
|
||||||
|
let extractedDescription = this.extractDescriptionFromParentTranscript(
|
||||||
|
existingInfo.projectHash,
|
||||||
|
existingInfo.sessionId,
|
||||||
|
agentId
|
||||||
|
);
|
||||||
|
// Fallback to subagent file
|
||||||
|
if (!extractedDescription) {
|
||||||
|
extractedDescription = this.extractDescriptionFromFile(filePath);
|
||||||
|
}
|
||||||
if (extractedDescription) {
|
if (extractedDescription) {
|
||||||
existingInfo.description = extractedDescription;
|
existingInfo.description = extractedDescription;
|
||||||
this.emit('subagent:updated', existingInfo);
|
this.emit('subagent:updated', existingInfo);
|
||||||
@@ -852,17 +900,29 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
|
|
||||||
// Check if this is first user message and description is missing
|
// Check if this is first user message and description is missing
|
||||||
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
|
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
|
||||||
let text: string | undefined;
|
// First try parent transcript (most reliable)
|
||||||
if (typeof entry.message.content === 'string') {
|
let description = this.extractDescriptionFromParentTranscript(
|
||||||
text = entry.message.content.trim();
|
info.projectHash,
|
||||||
} else if (Array.isArray(entry.message.content)) {
|
info.sessionId,
|
||||||
const firstContent = entry.message.content[0];
|
agentId
|
||||||
if (firstContent?.type === 'text' && firstContent.text) {
|
);
|
||||||
text = firstContent.text.trim();
|
// Fallback: extract smart title from the prompt content
|
||||||
|
if (!description) {
|
||||||
|
let text: string | undefined;
|
||||||
|
if (typeof entry.message.content === 'string') {
|
||||||
|
text = entry.message.content.trim();
|
||||||
|
} else if (Array.isArray(entry.message.content)) {
|
||||||
|
const firstContent = entry.message.content[0];
|
||||||
|
if (firstContent?.type === 'text' && firstContent.text) {
|
||||||
|
text = firstContent.text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
description = this.extractSmartTitle(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (text) {
|
if (description) {
|
||||||
info.description = this.extractSmartTitle(text);
|
info.description = description;
|
||||||
this.emit('subagent:updated', info);
|
this.emit('subagent:updated', info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-39
@@ -318,16 +318,13 @@ export class WebServer extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Set up event listeners for subagent watcher.
|
* Set up event listeners for subagent watcher.
|
||||||
* Broadcasts real-time subagent activity to SSE clients.
|
* Broadcasts real-time subagent activity to SSE clients.
|
||||||
|
*
|
||||||
|
* The SubagentWatcher now extracts descriptions directly from the parent session's
|
||||||
|
* transcript, which contains the exact Task tool call with the description parameter.
|
||||||
|
* This is more reliable than the previous timing-based correlation approach.
|
||||||
*/
|
*/
|
||||||
private setupSubagentWatcherListeners(): void {
|
private setupSubagentWatcherListeners(): void {
|
||||||
subagentWatcher.on('subagent:discovered', (info: SubagentInfo) => {
|
subagentWatcher.on('subagent:discovered', (info: SubagentInfo) => {
|
||||||
// Try to get a better description from the session's TaskTracker
|
|
||||||
// The TaskTracker has access to params.description from the Task tool call
|
|
||||||
const betterDescription = this.getTaskDescriptionForSubagent(info);
|
|
||||||
if (betterDescription && betterDescription !== info.description) {
|
|
||||||
info.description = betterDescription;
|
|
||||||
subagentWatcher.updateDescription(info.agentId, betterDescription);
|
|
||||||
}
|
|
||||||
this.broadcast('subagent:discovered', info);
|
this.broadcast('subagent:discovered', info);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -360,38 +357,6 @@ export class WebServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the short description from TaskTracker for a discovered subagent.
|
|
||||||
*
|
|
||||||
* When Claude Code spawns a subagent via the Task tool, it passes two parameters:
|
|
||||||
* - description: Short title like "Explore Python scripts" (3-5 words)
|
|
||||||
* - prompt: Full detailed task prompt
|
|
||||||
*
|
|
||||||
* The TaskTracker parses the Task tool call and has access to params.description,
|
|
||||||
* while the SubagentWatcher only sees the JSONL file which contains the full prompt.
|
|
||||||
*
|
|
||||||
* This method correlates them by finding the session whose working directory
|
|
||||||
* matches the subagent's project hash, then checking for recent tasks.
|
|
||||||
*/
|
|
||||||
private getTaskDescriptionForSubagent(info: SubagentInfo): string | undefined {
|
|
||||||
// Find sessions whose working directory matches this subagent's project
|
|
||||||
// Check ALL matching sessions since multiple sessions may have the same working directory
|
|
||||||
const subagentStartTime = new Date(info.startedAt).getTime();
|
|
||||||
|
|
||||||
for (const session of this.sessions.values()) {
|
|
||||||
const sessionProjectHash = subagentWatcher.getProjectHashForDir(session.workingDir);
|
|
||||||
if (sessionProjectHash === info.projectHash) {
|
|
||||||
// Found a matching session - check for recent task descriptions parsed from terminal
|
|
||||||
// The Session parses "Explore(Description)" patterns from Claude Code output
|
|
||||||
const description = session.findTaskDescriptionNear(subagentStartTime, 15000);
|
|
||||||
if (description) {
|
|
||||||
return description;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async setupRoutes(): Promise<void> {
|
private async setupRoutes(): Promise<void> {
|
||||||
// Serve static files
|
// Serve static files
|
||||||
await this.app.register(fastifyStatic, {
|
await this.app.register(fastifyStatic, {
|
||||||
|
|||||||
Reference in New Issue
Block a user