@hyperdrive.bot/bmad-workflow 1.0.25 → 1.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/commands/epics/create.d.ts +1 -0
  2. package/dist/commands/lock/acquire.d.ts +54 -0
  3. package/dist/commands/lock/acquire.js +193 -0
  4. package/dist/commands/lock/cleanup.d.ts +38 -0
  5. package/dist/commands/lock/cleanup.js +148 -0
  6. package/dist/commands/lock/list.d.ts +31 -0
  7. package/dist/commands/lock/list.js +123 -0
  8. package/dist/commands/lock/release.d.ts +42 -0
  9. package/dist/commands/lock/release.js +134 -0
  10. package/dist/commands/lock/status.d.ts +34 -0
  11. package/dist/commands/lock/status.js +109 -0
  12. package/dist/commands/stories/create.d.ts +1 -0
  13. package/dist/commands/stories/develop.d.ts +4 -0
  14. package/dist/commands/stories/develop.js +55 -5
  15. package/dist/commands/stories/qa.d.ts +1 -0
  16. package/dist/commands/stories/qa.js +31 -0
  17. package/dist/commands/stories/review.d.ts +1 -0
  18. package/dist/commands/workflow.d.ts +11 -0
  19. package/dist/commands/workflow.js +120 -4
  20. package/dist/models/agent-options.d.ts +33 -0
  21. package/dist/models/agent-result.d.ts +10 -1
  22. package/dist/models/dispatch.d.ts +16 -0
  23. package/dist/models/dispatch.js +8 -0
  24. package/dist/models/index.d.ts +3 -0
  25. package/dist/models/index.js +2 -0
  26. package/dist/models/lock.d.ts +80 -0
  27. package/dist/models/lock.js +69 -0
  28. package/dist/models/phase-result.d.ts +8 -0
  29. package/dist/models/provider.js +1 -1
  30. package/dist/models/workflow-callbacks.d.ts +37 -0
  31. package/dist/models/workflow-config.d.ts +50 -0
  32. package/dist/services/agents/agent-runner-factory.d.ts +24 -15
  33. package/dist/services/agents/agent-runner-factory.js +95 -15
  34. package/dist/services/agents/channel-agent-runner.d.ts +76 -0
  35. package/dist/services/agents/channel-agent-runner.js +256 -0
  36. package/dist/services/agents/channel-session-manager.d.ts +126 -0
  37. package/dist/services/agents/channel-session-manager.js +260 -0
  38. package/dist/services/agents/claude-agent-runner.d.ts +9 -50
  39. package/dist/services/agents/claude-agent-runner.js +221 -199
  40. package/dist/services/agents/gemini-agent-runner.js +3 -0
  41. package/dist/services/agents/index.d.ts +1 -0
  42. package/dist/services/agents/index.js +1 -0
  43. package/dist/services/agents/opencode-agent-runner.js +3 -0
  44. package/dist/services/file-system/file-manager.d.ts +11 -0
  45. package/dist/services/file-system/file-manager.js +26 -0
  46. package/dist/services/git/git-ops.d.ts +58 -0
  47. package/dist/services/git/git-ops.js +73 -0
  48. package/dist/services/git/index.d.ts +3 -0
  49. package/dist/services/git/index.js +2 -0
  50. package/dist/services/git/push-conflict-handler.d.ts +32 -0
  51. package/dist/services/git/push-conflict-handler.js +84 -0
  52. package/dist/services/lock/git-backed-lock-service.d.ts +76 -0
  53. package/dist/services/lock/git-backed-lock-service.js +173 -0
  54. package/dist/services/lock/lock-cleanup.d.ts +49 -0
  55. package/dist/services/lock/lock-cleanup.js +85 -0
  56. package/dist/services/lock/lock-service.d.ts +143 -0
  57. package/dist/services/lock/lock-service.js +290 -0
  58. package/dist/services/orchestration/locked-story-dispatcher.d.ts +40 -0
  59. package/dist/services/orchestration/locked-story-dispatcher.js +84 -0
  60. package/dist/services/orchestration/workflow-orchestrator.d.ts +31 -0
  61. package/dist/services/orchestration/workflow-orchestrator.js +181 -31
  62. package/dist/services/review/ai-review-scanner.js +1 -0
  63. package/dist/services/review/review-phase-executor.js +3 -0
  64. package/dist/services/review/self-heal-loop.js +1 -0
  65. package/dist/services/review/types.d.ts +2 -0
  66. package/dist/utils/errors.d.ts +17 -1
  67. package/dist/utils/errors.js +18 -0
  68. package/dist/utils/session-naming.d.ts +23 -0
  69. package/dist/utils/session-naming.js +30 -0
  70. package/dist/utils/shared-flags.d.ts +1 -0
  71. package/dist/utils/shared-flags.js +5 -0
  72. package/package.json +3 -2
@@ -2,30 +2,39 @@
2
2
  * Agent Runner Factory
3
3
  *
4
4
  * Creates the appropriate AIProviderRunner based on provider configuration.
5
+ * Includes both the legacy `createAgentRunner()` function and the new
6
+ * `AgentRunnerFactory` class with Channel transport discovery.
5
7
  */
6
8
  import type pino from 'pino';
9
+ import type { WorkflowCallbacks } from '../../models/workflow-callbacks.js';
10
+ import type { WorkflowConfig } from '../../models/workflow-config.js';
7
11
  import type { AIProvider } from '../../models/provider.js';
8
12
  import type { AIProviderRunner } from './agent-runner.js';
9
13
  /**
10
14
  * Create an AI provider runner for the specified provider
11
- *
12
- * @param provider - The AI provider to create a runner for
13
- * @param logger - Logger instance for structured logging
14
- * @returns An AIProviderRunner instance for the specified provider
15
- * @throws Error if the provider is not supported
16
- *
17
- * @example
18
- * ```typescript
19
- * const logger = createLogger({ namespace: 'agent-runner' })
20
- * const runner = createAgentRunner('claude', logger)
21
- * const result = await runner.runAgent('Hello', { agentType: 'dev' })
22
- * ```
23
15
  */
24
16
  export declare function createAgentRunner(provider: AIProvider, logger: pino.Logger): AIProviderRunner;
25
17
  /**
26
18
  * Check if a provider is supported
27
- *
28
- * @param provider - The provider to check
29
- * @returns true if the provider is supported
30
19
  */
31
20
  export declare function isProviderSupported(provider: string): provider is AIProvider;
21
+ /**
22
+ * AgentRunnerFactory — discovery-based runner selection
23
+ *
24
+ * When `config.useChannels` is true, discovers Channel-capable agents via
25
+ * `listAgents()` and returns a `ChannelAgentRunner` when a match is found.
26
+ * Falls back to the subprocess runner otherwise.
27
+ */
28
+ export declare class AgentRunnerFactory {
29
+ private readonly callbacks?;
30
+ private readonly logger;
31
+ constructor(logger: pino.Logger, callbacks?: WorkflowCallbacks);
32
+ /**
33
+ * Create an AIProviderRunner for the requested agent type.
34
+ */
35
+ create(agentType: string, config: WorkflowConfig, phaseName?: string): AIProviderRunner;
36
+ /**
37
+ * Fire onChannelFallback callback with defensive error handling.
38
+ */
39
+ private fireChannelFallback;
40
+ }
@@ -2,24 +2,35 @@
2
2
  * Agent Runner Factory
3
3
  *
4
4
  * Creates the appropriate AIProviderRunner based on provider configuration.
5
+ * Includes both the legacy `createAgentRunner()` function and the new
6
+ * `AgentRunnerFactory` class with Channel transport discovery.
5
7
  */
6
8
  import { ClaudeAgentRunner } from './claude-agent-runner.js';
7
9
  import { GeminiAgentRunner } from './gemini-agent-runner.js';
8
10
  import { OpenCodeAgentRunner } from './opencode-agent-runner.js';
11
+ // claude-channels is an optional file: dependency — not available in CI.
12
+ // Lazy-loaded at runtime only when --use-channels is set.
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ let _channelsModule = null;
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ let _channelRunnerModule = null;
17
+ function loadChannelsSync() {
18
+ if (!_channelsModule) {
19
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
20
+ _channelsModule = require('@hyperdrive.bot/claude-channels/dist/services/discovery.js');
21
+ }
22
+ return _channelsModule;
23
+ }
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ function loadChannelRunner() {
26
+ if (!_channelRunnerModule) {
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
28
+ _channelRunnerModule = require('./channel-agent-runner.js');
29
+ }
30
+ return _channelRunnerModule;
31
+ }
9
32
  /**
10
33
  * Create an AI provider runner for the specified provider
11
- *
12
- * @param provider - The AI provider to create a runner for
13
- * @param logger - Logger instance for structured logging
14
- * @returns An AIProviderRunner instance for the specified provider
15
- * @throws Error if the provider is not supported
16
- *
17
- * @example
18
- * ```typescript
19
- * const logger = createLogger({ namespace: 'agent-runner' })
20
- * const runner = createAgentRunner('claude', logger)
21
- * const result = await runner.runAgent('Hello', { agentType: 'dev' })
22
- * ```
23
34
  */
24
35
  export function createAgentRunner(provider, logger) {
25
36
  switch (provider) {
@@ -39,10 +50,79 @@ export function createAgentRunner(provider, logger) {
39
50
  }
40
51
  /**
41
52
  * Check if a provider is supported
42
- *
43
- * @param provider - The provider to check
44
- * @returns true if the provider is supported
45
53
  */
46
54
  export function isProviderSupported(provider) {
47
55
  return provider === 'claude' || provider === 'gemini' || provider === 'opencode';
48
56
  }
57
+ /**
58
+ * AgentRunnerFactory — discovery-based runner selection
59
+ *
60
+ * When `config.useChannels` is true, discovers Channel-capable agents via
61
+ * `listAgents()` and returns a `ChannelAgentRunner` when a match is found.
62
+ * Falls back to the subprocess runner otherwise.
63
+ */
64
+ export class AgentRunnerFactory {
65
+ callbacks;
66
+ logger;
67
+ constructor(logger, callbacks) {
68
+ this.logger = logger;
69
+ this.callbacks = callbacks;
70
+ }
71
+ /**
72
+ * Create an AIProviderRunner for the requested agent type.
73
+ */
74
+ create(agentType, config, phaseName) {
75
+ if (!config.useChannels) {
76
+ return createAgentRunner(config.provider ?? 'claude', this.logger);
77
+ }
78
+ let agents;
79
+ try {
80
+ const channels = loadChannelsSync();
81
+ agents = channels.listAgents();
82
+ }
83
+ catch (error) {
84
+ this.logger.warn(`[channel] Discovery failed: ${error.message} — falling back to subprocess`);
85
+ this.fireChannelFallback(agentType, 'not_discovered', phaseName);
86
+ return createAgentRunner(config.provider ?? 'claude', this.logger);
87
+ }
88
+ const matches = agents.filter((reg) => reg.name.toLowerCase() === agentType.toLowerCase());
89
+ if (matches.length === 0) {
90
+ this.logger.info(`[subprocess] Falling back to claude -p for ${agentType} — no Channel agent discovered`);
91
+ this.fireChannelFallback(agentType, 'not_discovered', phaseName);
92
+ return createAgentRunner(config.provider ?? 'claude', this.logger);
93
+ }
94
+ // Select most recently started agent
95
+ const matched = matches.length === 1
96
+ ? matches[0]
97
+ : matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
98
+ this.logger.info({ agentType, sessionId: matched.sessionId, webhookUrl: matched.webhookUrl }, `[channel] Discovered Channel agent for ${agentType}`);
99
+ try {
100
+ const { ChannelAgentRunner } = loadChannelRunner();
101
+ return new ChannelAgentRunner(this.logger, matched.webhookUrl, matched.sessionId, this.callbacks);
102
+ }
103
+ catch (error) {
104
+ this.logger.warn(`[channel] Failed to load ChannelAgentRunner: ${error.message} — falling back`);
105
+ this.fireChannelFallback(agentType, 'not_discovered', phaseName);
106
+ return createAgentRunner(config.provider ?? 'claude', this.logger);
107
+ }
108
+ }
109
+ /**
110
+ * Fire onChannelFallback callback with defensive error handling.
111
+ */
112
+ fireChannelFallback(agentType, reason, phaseName) {
113
+ const callback = this.callbacks?.onChannelFallback;
114
+ if (!callback)
115
+ return;
116
+ const context = {
117
+ agentType,
118
+ phaseName: phaseName ?? 'unknown',
119
+ reason,
120
+ };
121
+ try {
122
+ callback(context);
123
+ }
124
+ catch (error) {
125
+ this.logger.warn({ callbackName: 'onChannelFallback', error: error.message }, 'Callback error (ignored to prevent workflow interruption)');
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * ChannelAgentRunner Service
3
+ *
4
+ * Sends task envelopes to discovered Channel agent endpoints via HTTP POST
5
+ * and returns results as AgentResult. Used by the factory to delegate work
6
+ * to persistent, context-preserving Claude Code sessions.
7
+ */
8
+ import type pino from 'pino';
9
+ import type { AgentOptions, AgentResult, WorkflowCallbacks } from '../../models/index.js';
10
+ import type { AIProvider } from '../../models/provider.js';
11
+ import type { AIProviderRunner } from './agent-runner.js';
12
+ /**
13
+ * Error thrown when the channel agent endpoint is unreachable (ECONNREFUSED).
14
+ * The factory uses this signal to trigger subprocess fallback.
15
+ */
16
+ export declare class ChannelUnavailableError extends Error {
17
+ readonly name = "ChannelUnavailableError";
18
+ readonly webhookUrl: string;
19
+ constructor(webhookUrl: string, cause?: Error);
20
+ }
21
+ /**
22
+ * ChannelAgentRunner — sends task envelopes to channel agent endpoints via HTTP POST.
23
+ *
24
+ * Never throws on agent errors (returns AgentResult with success: false).
25
+ * The ONLY exception is ECONNREFUSED → ChannelUnavailableError, because
26
+ * the factory needs this signal to trigger subprocess fallback.
27
+ */
28
+ export declare class ChannelAgentRunner implements AIProviderRunner {
29
+ readonly provider: AIProvider;
30
+ private activeRequests;
31
+ private connected;
32
+ private readonly callbacks?;
33
+ private readonly logger;
34
+ private readonly port;
35
+ private readonly sessionId;
36
+ private readonly webhookUrl;
37
+ constructor(logger: pino.Logger, webhookUrl: string, sessionId: string, callbacks?: WorkflowCallbacks);
38
+ getActiveProcessCount(): number;
39
+ private fireChannelConnect;
40
+ /**
41
+ * Send a task to the Channel agent and return the result.
42
+ *
43
+ * **Task envelope structure:**
44
+ * The prompt is wrapped in a Channel envelope via `EnvelopeFactory.createTask()`:
45
+ * - `from`: `"bmad-orchestrator"` — identifies the caller
46
+ * - `to`: the target session ID discovered during factory creation
47
+ * - `type`: `"task"` — tells the Channel agent this is a work request
48
+ * - `payload`: `{ prompt, context? }` — the actual prompt plus optional metadata
49
+ * (systemPrompt, model) passed via the context field
50
+ * - `id` / `timestamp`: auto-generated for tracing and deduplication
51
+ *
52
+ * **Response parsing:**
53
+ * - `type: "result"` with `payload.output` (string) and `payload.exitCode` (number)
54
+ * → success path. `exitCode === 0` means the agent completed successfully.
55
+ * - `type: "error"` → the Channel agent encountered an internal error. Returned as
56
+ * `AgentResult` with `success: false` (never throws).
57
+ * - Any other `type` → treated as unexpected and returned as failure.
58
+ *
59
+ * **Timeout handling:**
60
+ * An `AbortController` is created per request with the configured timeout (default
61
+ * 300s). If the Channel agent doesn't respond within the timeout window, the fetch
62
+ * is aborted and an `AgentResult` with `success: false` is returned. The timer is
63
+ * always cleared in `finally` to prevent leaks.
64
+ *
65
+ * **Connection errors:**
66
+ * ECONNREFUSED is the only error that throws (`ChannelUnavailableError`) instead of
67
+ * returning a failed `AgentResult`. This allows the factory to catch it and trigger
68
+ * subprocess fallback.
69
+ *
70
+ * @param prompt - The task prompt to send to the Channel agent
71
+ * @param options - Agent options (agentType, timeout, model, systemPrompt, phaseName)
72
+ * @returns AgentResult with output, exitCode, duration, and transport: "channel"
73
+ * @throws ChannelUnavailableError when the endpoint is unreachable (ECONNREFUSED)
74
+ */
75
+ runAgent(prompt: string, options: Omit<AgentOptions, 'prompt'>): Promise<AgentResult>;
76
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * ChannelAgentRunner Service
3
+ *
4
+ * Sends task envelopes to discovered Channel agent endpoints via HTTP POST
5
+ * and returns results as AgentResult. Used by the factory to delegate work
6
+ * to persistent, context-preserving Claude Code sessions.
7
+ */
8
+ // Lazy-loaded: @hyperdrive.bot/claude-channels is optional (file: dep, not in CI)
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ let _envelopeFactory = null;
11
+ function getEnvelopeFactory() {
12
+ if (!_envelopeFactory) {
13
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
14
+ _envelopeFactory = require('@hyperdrive.bot/claude-channels/dist/envelope/factory.js').EnvelopeFactory;
15
+ }
16
+ return _envelopeFactory;
17
+ }
18
+ /**
19
+ * Error thrown when the channel agent endpoint is unreachable (ECONNREFUSED).
20
+ * The factory uses this signal to trigger subprocess fallback.
21
+ */
22
+ export class ChannelUnavailableError extends Error {
23
+ name = 'ChannelUnavailableError';
24
+ webhookUrl;
25
+ constructor(webhookUrl, cause) {
26
+ super(`Channel unavailable at ${webhookUrl}`);
27
+ this.webhookUrl = webhookUrl;
28
+ if (cause) {
29
+ this.cause = cause;
30
+ }
31
+ }
32
+ }
33
+ /**
34
+ * ChannelAgentRunner — sends task envelopes to channel agent endpoints via HTTP POST.
35
+ *
36
+ * Never throws on agent errors (returns AgentResult with success: false).
37
+ * The ONLY exception is ECONNREFUSED → ChannelUnavailableError, because
38
+ * the factory needs this signal to trigger subprocess fallback.
39
+ */
40
+ export class ChannelAgentRunner {
41
+ provider = 'claude';
42
+ activeRequests = 0;
43
+ connected = false;
44
+ callbacks;
45
+ logger;
46
+ port;
47
+ sessionId;
48
+ webhookUrl;
49
+ constructor(logger, webhookUrl, sessionId, callbacks) {
50
+ this.logger = logger;
51
+ this.webhookUrl = webhookUrl;
52
+ this.sessionId = sessionId;
53
+ this.callbacks = callbacks;
54
+ // Extract port from webhook URL
55
+ try {
56
+ this.port = new URL(webhookUrl).port ? Number.parseInt(new URL(webhookUrl).port, 10) : 0;
57
+ }
58
+ catch {
59
+ this.port = 0;
60
+ }
61
+ }
62
+ getActiveProcessCount() {
63
+ return this.activeRequests;
64
+ }
65
+ fireChannelConnect(agentType, phaseName) {
66
+ if (this.connected)
67
+ return;
68
+ this.connected = true;
69
+ const callback = this.callbacks?.onChannelConnect;
70
+ if (!callback)
71
+ return;
72
+ const context = {
73
+ agentType,
74
+ phaseName,
75
+ port: this.port,
76
+ sessionId: this.sessionId,
77
+ };
78
+ try {
79
+ callback(context);
80
+ }
81
+ catch (error) {
82
+ this.logger.warn({ callbackName: 'onChannelConnect', error: error.message }, 'Callback error (ignored to prevent workflow interruption)');
83
+ }
84
+ }
85
+ /**
86
+ * Send a task to the Channel agent and return the result.
87
+ *
88
+ * **Task envelope structure:**
89
+ * The prompt is wrapped in a Channel envelope via `EnvelopeFactory.createTask()`:
90
+ * - `from`: `"bmad-orchestrator"` — identifies the caller
91
+ * - `to`: the target session ID discovered during factory creation
92
+ * - `type`: `"task"` — tells the Channel agent this is a work request
93
+ * - `payload`: `{ prompt, context? }` — the actual prompt plus optional metadata
94
+ * (systemPrompt, model) passed via the context field
95
+ * - `id` / `timestamp`: auto-generated for tracing and deduplication
96
+ *
97
+ * **Response parsing:**
98
+ * - `type: "result"` with `payload.output` (string) and `payload.exitCode` (number)
99
+ * → success path. `exitCode === 0` means the agent completed successfully.
100
+ * - `type: "error"` → the Channel agent encountered an internal error. Returned as
101
+ * `AgentResult` with `success: false` (never throws).
102
+ * - Any other `type` → treated as unexpected and returned as failure.
103
+ *
104
+ * **Timeout handling:**
105
+ * An `AbortController` is created per request with the configured timeout (default
106
+ * 300s). If the Channel agent doesn't respond within the timeout window, the fetch
107
+ * is aborted and an `AgentResult` with `success: false` is returned. The timer is
108
+ * always cleared in `finally` to prevent leaks.
109
+ *
110
+ * **Connection errors:**
111
+ * ECONNREFUSED is the only error that throws (`ChannelUnavailableError`) instead of
112
+ * returning a failed `AgentResult`. This allows the factory to catch it and trigger
113
+ * subprocess fallback.
114
+ *
115
+ * @param prompt - The task prompt to send to the Channel agent
116
+ * @param options - Agent options (agentType, timeout, model, systemPrompt, phaseName)
117
+ * @returns AgentResult with output, exitCode, duration, and transport: "channel"
118
+ * @throws ChannelUnavailableError when the endpoint is unreachable (ECONNREFUSED)
119
+ */
120
+ async runAgent(prompt, options) {
121
+ const startTime = Date.now();
122
+ const timeout = options.timeout ?? 300_000;
123
+ // Build task envelope
124
+ const context = {};
125
+ if (options.systemPrompt)
126
+ context.systemPrompt = options.systemPrompt;
127
+ if (options.model)
128
+ context.model = options.model;
129
+ const EnvelopeFactory = getEnvelopeFactory();
130
+ const envelope = EnvelopeFactory.createTask('bmad-orchestrator', this.sessionId, prompt, Object.keys(context).length > 0 ? context : undefined);
131
+ this.logger.debug({ agentType: options.agentType, envelopeId: envelope.id, sessionId: this.sessionId }, 'Sending task envelope to channel agent');
132
+ const controller = new AbortController();
133
+ const timer = setTimeout(() => controller.abort(), timeout);
134
+ this.activeRequests++;
135
+ try {
136
+ const response = await fetch(this.webhookUrl, {
137
+ body: JSON.stringify(envelope),
138
+ headers: { 'Content-Type': 'application/json' },
139
+ method: 'POST',
140
+ signal: controller.signal,
141
+ });
142
+ // Parse response JSON
143
+ let body;
144
+ try {
145
+ body = await response.json();
146
+ }
147
+ catch {
148
+ this.logger.warn({ envelopeId: envelope.id, status: response.status }, 'Malformed channel response: invalid JSON');
149
+ return {
150
+ agentType: options.agentType,
151
+ duration: Date.now() - startTime,
152
+ errors: 'Malformed channel response: invalid JSON',
153
+ exitCode: 1,
154
+ output: '',
155
+ success: false,
156
+ transport: 'channel',
157
+ };
158
+ }
159
+ // Validate envelope structure
160
+ const parsed = body;
161
+ if (!parsed.type || !parsed.payload) {
162
+ this.logger.warn({ envelopeId: envelope.id, responseType: parsed.type }, 'Malformed channel response: missing type or payload');
163
+ return {
164
+ agentType: options.agentType,
165
+ duration: Date.now() - startTime,
166
+ errors: `Malformed channel response: missing ${!parsed.type ? 'type' : 'payload'} field`,
167
+ exitCode: 1,
168
+ output: '',
169
+ success: false,
170
+ transport: 'channel',
171
+ };
172
+ }
173
+ // Handle result envelope
174
+ if (parsed.type === 'result') {
175
+ const payload = parsed.payload;
176
+ if (typeof payload.output === 'string' && typeof payload.exitCode === 'number') {
177
+ const duration = Date.now() - startTime;
178
+ const exitCode = payload.exitCode;
179
+ // Fire onChannelConnect on first successful exchange
180
+ this.fireChannelConnect(options.agentType, options.phaseName ?? 'unknown');
181
+ this.logger.debug({ agentType: options.agentType, duration, envelopeId: envelope.id, exitCode }, 'Channel agent completed');
182
+ return {
183
+ agentType: options.agentType,
184
+ duration,
185
+ errors: '',
186
+ exitCode,
187
+ output: payload.output,
188
+ success: exitCode === 0,
189
+ transport: 'channel',
190
+ };
191
+ }
192
+ // Result type but malformed payload
193
+ this.logger.warn({ envelopeId: envelope.id }, 'Malformed channel response: result payload missing output or exitCode');
194
+ return {
195
+ agentType: options.agentType,
196
+ duration: Date.now() - startTime,
197
+ errors: 'Malformed channel response: result payload missing output or exitCode',
198
+ exitCode: 1,
199
+ output: '',
200
+ success: false,
201
+ transport: 'channel',
202
+ };
203
+ }
204
+ // Non-result envelope type (error, status, etc.)
205
+ const errorDescription = parsed.type === 'error'
206
+ ? `Channel returned error: ${JSON.stringify(parsed.payload)}`
207
+ : `Unexpected envelope type: ${parsed.type}`;
208
+ this.logger.warn({ envelopeId: envelope.id, responseType: parsed.type }, errorDescription);
209
+ return {
210
+ agentType: options.agentType,
211
+ duration: Date.now() - startTime,
212
+ errors: errorDescription,
213
+ exitCode: 1,
214
+ output: '',
215
+ success: false,
216
+ transport: 'channel',
217
+ };
218
+ }
219
+ catch (error) {
220
+ const err = error;
221
+ // Connection refused — throw ChannelUnavailableError for factory fallback
222
+ if (err.cause?.code === 'ECONNREFUSED' || (err instanceof TypeError && err.cause?.code === 'ECONNREFUSED')) {
223
+ this.logger.error({ webhookUrl: this.webhookUrl }, 'Channel agent unreachable (ECONNREFUSED)');
224
+ throw new ChannelUnavailableError(this.webhookUrl, err);
225
+ }
226
+ // Timeout via AbortController
227
+ if (err.name === 'AbortError') {
228
+ this.logger.warn({ envelopeId: envelope.id, timeout }, `Channel timeout after ${timeout}ms`);
229
+ return {
230
+ agentType: options.agentType,
231
+ duration: Date.now() - startTime,
232
+ errors: `Channel timeout after ${timeout}ms`,
233
+ exitCode: 1,
234
+ output: '',
235
+ success: false,
236
+ transport: 'channel',
237
+ };
238
+ }
239
+ // Unexpected error
240
+ this.logger.error({ envelopeId: envelope.id, error: err.message }, 'Unexpected channel error');
241
+ return {
242
+ agentType: options.agentType,
243
+ duration: Date.now() - startTime,
244
+ errors: `Unexpected channel error: ${err.message}`,
245
+ exitCode: 1,
246
+ output: '',
247
+ success: false,
248
+ transport: 'channel',
249
+ };
250
+ }
251
+ finally {
252
+ this.activeRequests--;
253
+ clearTimeout(timer);
254
+ }
255
+ }
256
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * ChannelSessionManager — Agent Lifecycle Manager
3
+ *
4
+ * Manages the lifecycle of Channel-connected Claude Code agent sessions:
5
+ * start (with discovery polling), health check (ping/pong), stop, and cleanup.
6
+ *
7
+ * Used by the orchestrator to pre-provision agent sessions before workflow runs
8
+ * and guarantee clean shutdown on errors or SIGINT.
9
+ */
10
+ import { type ChildProcess, spawn as nodeSpawn } from 'node:child_process';
11
+ import type pino from 'pino';
12
+ type AgentRegistration = {
13
+ name: string;
14
+ port?: number;
15
+ sessionId: string;
16
+ startedAt: string;
17
+ webhookUrl: string;
18
+ };
19
+ /**
20
+ * Handle to a managed agent session
21
+ */
22
+ export interface SessionHandle {
23
+ sessionId: string;
24
+ port: number;
25
+ agentType: string;
26
+ pid: number;
27
+ process: ChildProcess;
28
+ }
29
+ /**
30
+ * Configuration for starting an agent session
31
+ */
32
+ export interface SessionConfig {
33
+ command?: string;
34
+ model?: string;
35
+ systemPrompt?: string;
36
+ cwd?: string;
37
+ startTimeout?: number;
38
+ healthTimeout?: number;
39
+ }
40
+ /**
41
+ * Injectable dependencies for testing
42
+ */
43
+ export interface SessionManagerDeps {
44
+ listAgents?: () => AgentRegistration[];
45
+ deregisterAgent?: (sessionId: string) => void;
46
+ spawn?: typeof nodeSpawn;
47
+ }
48
+ /**
49
+ * Error thrown when agent session start times out waiting for discovery registration
50
+ */
51
+ export declare class SessionStartTimeoutError extends Error {
52
+ readonly agentType: string;
53
+ readonly timeoutMs: number;
54
+ constructor(agentType: string, timeoutMs: number);
55
+ }
56
+ /**
57
+ * Manages Channel-connected Claude Code agent session lifecycles.
58
+ *
59
+ * Handles spawning, discovery polling, health checks, graceful shutdown,
60
+ * and SIGINT cleanup for multiple concurrent agent sessions.
61
+ */
62
+ export declare class ChannelSessionManager {
63
+ private static sigintRegistered;
64
+ private readonly sessions;
65
+ private readonly logger;
66
+ private readonly listAgentsFn;
67
+ private readonly deregisterAgentFn;
68
+ private readonly spawnFn;
69
+ constructor(logger: pino.Logger, deps?: SessionManagerDeps);
70
+ /**
71
+ * Start a new agent session and wait for it to register via discovery.
72
+ *
73
+ * Spawns a Claude Code subprocess with --channel flag, then polls listAgents()
74
+ * at 500ms intervals until a new AgentRegistration appears. Returns a SessionHandle
75
+ * on success, throws SessionStartTimeoutError if discovery times out.
76
+ *
77
+ * @param agentType - Agent type identifier (used to match discovery registration)
78
+ * @param config - Session configuration
79
+ * @returns SessionHandle with session details
80
+ * @throws SessionStartTimeoutError if agent doesn't register within startTimeout
81
+ */
82
+ startAgentSession(agentType: string, config?: SessionConfig): Promise<SessionHandle>;
83
+ /**
84
+ * Send a ping envelope to the agent and check for pong response.
85
+ *
86
+ * @param handle - Session handle to health-check
87
+ * @param config - Optional config for health timeout override
88
+ * @returns true if agent responds with pong, false otherwise
89
+ */
90
+ checkHealth(handle: SessionHandle, config?: SessionConfig): Promise<boolean>;
91
+ /**
92
+ * Stop a single agent session: SIGTERM + deregister + remove from map.
93
+ *
94
+ * @param handle - Session handle to stop
95
+ */
96
+ stopSession(handle: SessionHandle): Promise<void>;
97
+ /**
98
+ * Stop all managed sessions. Best-effort: errors per session are logged, never thrown.
99
+ */
100
+ stopAll(): Promise<void>;
101
+ /**
102
+ * Shutdown method for orchestrator integration.
103
+ * Call this in the orchestrator's finally block.
104
+ */
105
+ shutdown(): Promise<void>;
106
+ /**
107
+ * Get the number of currently managed sessions.
108
+ */
109
+ get sessionCount(): number;
110
+ /**
111
+ * Get a session handle by sessionId.
112
+ */
113
+ getSession(sessionId: string): SessionHandle | undefined;
114
+ /**
115
+ * Register SIGINT handler for graceful cleanup.
116
+ * Uses a static flag to prevent duplicate registration.
117
+ */
118
+ private registerSigintHandler;
119
+ /**
120
+ * Reset the static SIGINT registration flag.
121
+ * Only for testing — do not use in production.
122
+ * @internal
123
+ */
124
+ static resetSigintFlag(): void;
125
+ }
126
+ export {};