diff --git a/apps/cli/src/commands/orchestrator/attach.ts b/apps/cli/src/commands/orchestrator/attach.ts index 88db341c..5d8ccc30 100644 --- a/apps/cli/src/commands/orchestrator/attach.ts +++ b/apps/cli/src/commands/orchestrator/attach.ts @@ -23,6 +23,7 @@ import { tildifyPath, type UnifiedSession, } from '../../lib/session/renderer.js' +import { formatAvailableSessions } from '../../lib/orchestrator/format.js' /** * Detect the terminal emulator from environment variables. @@ -137,6 +138,25 @@ export default class OrchestratorAttach extends PromptCommand { s.agentName.toLowerCase().includes(needle) || s.sessionId.toLowerCase().includes(needle), ) + + // Named session not found — surface available sessions instead of a generic "not running" + if (filteredOrchestrators.length === 0 && allOrchestrators.length > 0) { + const available = formatAvailableSessions(allOrchestrators) + const message = `No orchestrator session matches --name "${flags.name}". Available: ${available}` + if (jsonMode) { + outputErrorAsJson( + 'NAME_NOT_FOUND', + message, + createMetadata('orchestrator attach', flags), + ) + return + } + this.log('') + this.log(styles.warning(`No orchestrator session matches --name "${flags.name}".`)) + this.log(styles.muted(`Available: ${available}`)) + this.log('') + return + } } // Group sessions by HQ for scope filtering diff --git a/apps/cli/src/commands/orchestrator/stop.ts b/apps/cli/src/commands/orchestrator/stop.ts index 7119a535..2e339c6f 100644 --- a/apps/cli/src/commands/orchestrator/stop.ts +++ b/apps/cli/src/commands/orchestrator/stop.ts @@ -22,6 +22,7 @@ import { tildifyPath, type UnifiedSession, } from '../../lib/session/renderer.js' +import { formatAvailableSessions } from '../../lib/orchestrator/format.js' export default class OrchestratorStop extends PromptCommand { static description = 'Stop the running orchestrator' @@ -67,13 +68,14 @@ export default class OrchestratorStop extends PromptCommand { } // Always query machine-wide for orchestrators. - let orchestrators = collectAllSessions({ + const allOrchestrators = collectAllSessions({ hqPathFilter, roleFilter: 'orchestrator', includeAll: false, }) // Optional --name filter + let orchestrators = allOrchestrators if (flags.name) { const needle = flags.name.toLowerCase() orchestrators = orchestrators.filter( @@ -82,6 +84,24 @@ export default class OrchestratorStop extends PromptCommand { s.agentName.toLowerCase().includes(needle) || s.sessionId.toLowerCase().includes(needle), ) + + // Named session not found — surface available sessions + if (orchestrators.length === 0 && allOrchestrators.length > 0) { + const available = formatAvailableSessions(allOrchestrators) + if (jsonMode) { + outputErrorAsJson( + 'NAME_NOT_FOUND', + `No orchestrator session matches --name "${flags.name}". Available: ${available}`, + createMetadata('orchestrator stop', flags), + ) + return + } + this.log('') + this.log(styles.warning(`No orchestrator session matches --name "${flags.name}".`)) + this.log(styles.muted(`Available: ${available}`)) + this.log('') + return + } } if (orchestrators.length === 0) { diff --git a/apps/cli/src/lib/orchestrator/format.ts b/apps/cli/src/lib/orchestrator/format.ts new file mode 100644 index 00000000..099f3682 --- /dev/null +++ b/apps/cli/src/lib/orchestrator/format.ts @@ -0,0 +1,18 @@ +import { tildifyPath, type UnifiedSession } from '../session/renderer.js' + +/** + * Render a one-line summary of available orchestrator sessions for use in + * "name not found" error messages. + * + * Each entry is " ()" joined by ", ". + */ +export function formatAvailableSessions(sessions: UnifiedSession[]): string { + if (sessions.length === 0) return '(none)' + return sessions + .map(s => { + const location = s.hqPath ? tildifyPath(s.hqPath) : 'host' + const tag = s.environment === 'container' ? ', Docker' : '' + return `${s.agentName} (${location}${tag})` + }) + .join(', ') +} diff --git a/apps/cli/test/unit/orchestrator-format.test.ts b/apps/cli/test/unit/orchestrator-format.test.ts new file mode 100644 index 00000000..44f09b8f --- /dev/null +++ b/apps/cli/test/unit/orchestrator-format.test.ts @@ -0,0 +1,81 @@ +import { expect } from 'chai' +import * as os from 'node:os' +import * as path from 'node:path' +import { formatAvailableSessions } from '../../src/lib/orchestrator/format.js' +import type { UnifiedSession } from '../../src/lib/session/renderer.js' + +/** + * Tests for the formatAvailableSessions helper used by `prlt orchestrator + * attach` and `prlt orchestrator stop` to produce a useful "name not found" + * error message when --name does not match any running session (PRLT-1271). + */ +describe('formatAvailableSessions (PRLT-1271)', () => { + const fakeOrchestrator = (overrides: Partial): UnifiedSession => ({ + id: overrides.id ?? 'orch-1', + sessionId: overrides.sessionId ?? 'prlt-orchestrator-hq-main', + ticketId: 'orchestrator', + agentName: overrides.agentName ?? 'main', + status: 'running', + role: 'orchestrator', + environment: overrides.environment ?? 'host', + exists: true, + source: overrides.source ?? 'discovered', + hqPath: overrides.hqPath, + hqName: overrides.hqName, + repoPath: overrides.repoPath, + startedAt: overrides.startedAt, + containerId: overrides.containerId, + }) + + it('returns "(none)" for an empty session list', () => { + expect(formatAvailableSessions([])).to.equal('(none)') + }) + + it('formats a session with an HQ path using ~ expansion', () => { + const home = os.homedir() + const hq = path.join(home, 'Projects', 'backend') + const out = formatAvailableSessions([ + fakeOrchestrator({ agentName: 'main', hqPath: hq }), + ]) + expect(out).to.equal('main (~/Projects/backend)') + }) + + it('marks sessions with no HQ as host', () => { + const out = formatAvailableSessions([ + fakeOrchestrator({ agentName: 'reviewer', hqPath: undefined }), + ]) + expect(out).to.equal('reviewer (host)') + }) + + it('annotates Docker sessions', () => { + const out = formatAvailableSessions([ + fakeOrchestrator({ + agentName: 'backend', + hqPath: undefined, + environment: 'container', + }), + ]) + expect(out).to.equal('backend (host, Docker)') + }) + + it('joins multiple sessions with ", "', () => { + const home = os.homedir() + const out = formatAvailableSessions([ + fakeOrchestrator({ + agentName: 'main', + hqPath: path.join(home, 'Projects', 'backend'), + }), + fakeOrchestrator({ + agentName: 'backend', + hqPath: path.join(home, 'Projects', 'proletariat-hq'), + }), + fakeOrchestrator({ + agentName: 'reviewer', + hqPath: undefined, + }), + ]) + expect(out).to.equal( + 'main (~/Projects/backend), backend (~/Projects/proletariat-hq), reviewer (host)', + ) + }) +})