@roj-ai/sdk 0.1.27 → 0.1.28

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 (49) hide show
  1. package/dist/bootstrap.d.ts +2 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/plugins/resources/index.d.ts +1 -1
  6. package/dist/plugins/resources/index.d.ts.map +1 -1
  7. package/dist/plugins/resources/plugin.d.ts +7 -1
  8. package/dist/plugins/resources/plugin.d.ts.map +1 -1
  9. package/dist/plugins/resources/plugin.js +25 -5
  10. package/dist/plugins/resources/plugin.js.map +1 -1
  11. package/dist/plugins/resources/resources.integration.test.d.ts +2 -0
  12. package/dist/plugins/resources/resources.integration.test.d.ts.map +1 -0
  13. package/dist/plugins/resources/resources.integration.test.js +62 -0
  14. package/dist/plugins/resources/resources.integration.test.js.map +1 -0
  15. package/dist/plugins/resources/state.d.ts +2 -0
  16. package/dist/plugins/resources/state.d.ts.map +1 -1
  17. package/dist/plugins/resources/state.js +1 -0
  18. package/dist/plugins/resources/state.js.map +1 -1
  19. package/dist/plugins/services/plugin.d.ts +4 -0
  20. package/dist/plugins/services/plugin.d.ts.map +1 -1
  21. package/dist/plugins/services/plugin.js +34 -9
  22. package/dist/plugins/services/plugin.js.map +1 -1
  23. package/dist/plugins/services/prompt.d.ts.map +1 -1
  24. package/dist/plugins/services/prompt.js +3 -0
  25. package/dist/plugins/services/prompt.js.map +1 -1
  26. package/dist/plugins/services/schema.d.ts +38 -4
  27. package/dist/plugins/services/schema.d.ts.map +1 -1
  28. package/dist/plugins/services/service.d.ts +11 -1
  29. package/dist/plugins/services/service.d.ts.map +1 -1
  30. package/dist/plugins/services/service.js +182 -46
  31. package/dist/plugins/services/service.js.map +1 -1
  32. package/dist/plugins/services/services.integration.test.js +128 -3
  33. package/dist/plugins/services/services.integration.test.js.map +1 -1
  34. package/dist/testing/preset-helpers.d.ts +1 -0
  35. package/dist/testing/preset-helpers.d.ts.map +1 -1
  36. package/dist/testing/preset-helpers.js +1 -0
  37. package/dist/testing/preset-helpers.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/index.ts +8 -2
  40. package/src/plugins/resources/index.ts +1 -1
  41. package/src/plugins/resources/plugin.ts +35 -6
  42. package/src/plugins/resources/resources.integration.test.ts +68 -0
  43. package/src/plugins/resources/state.ts +2 -0
  44. package/src/plugins/services/plugin.ts +32 -9
  45. package/src/plugins/services/prompt.ts +3 -0
  46. package/src/plugins/services/schema.ts +40 -4
  47. package/src/plugins/services/service.ts +202 -52
  48. package/src/plugins/services/services.integration.test.ts +143 -4
  49. package/src/testing/preset-helpers.ts +2 -0
@@ -17,6 +17,8 @@ export const serviceEvents = createEventsFactory({
17
17
  toStatus: z.enum(['stopped', 'starting', 'ready', 'stopping', 'failed', 'paused']),
18
18
  port: z.number().optional(),
19
19
  error: z.string().optional(),
20
+ cwd: z.string().optional(),
21
+ command: z.string().optional(),
20
22
  pid: z.number().optional(),
21
23
  pidStartTime: z.number().optional(),
22
24
  }),
@@ -58,6 +60,8 @@ export const servicePlugin = definePlugin('services')
58
60
  serviceType: event.serviceType,
59
61
  status: event.toStatus,
60
62
  port: event.port,
63
+ cwd: event.cwd,
64
+ command: event.command,
61
65
  startedAt: event.timestamp,
62
66
  pid: event.pid,
63
67
  pidStartTime: event.pidStartTime,
@@ -71,6 +75,8 @@ export const servicePlugin = definePlugin('services')
71
75
  updated.startedAt = event.timestamp
72
76
  updated.error = undefined
73
77
  updated.port = event.port
78
+ updated.cwd = event.cwd
79
+ updated.command = event.command
74
80
  updated.pid = event.pid
75
81
  updated.pidStartTime = event.pidStartTime
76
82
  }
@@ -79,6 +85,12 @@ export const servicePlugin = definePlugin('services')
79
85
  if (event.port !== undefined) {
80
86
  updated.port = event.port
81
87
  }
88
+ if (event.cwd !== undefined) {
89
+ updated.cwd = event.cwd
90
+ }
91
+ if (event.command !== undefined) {
92
+ updated.command = event.command
93
+ }
82
94
  }
83
95
  if (event.toStatus === 'failed' && event.error) {
84
96
  updated.error = event.error
@@ -122,17 +134,19 @@ export const servicePlugin = definePlugin('services')
122
134
  })
123
135
  .context(async (ctx, pluginConfig) => {
124
136
  const executor = new ServiceExecutor(ctx.logger, pluginConfig.portPool, { fs: ctx.platform.fs, process: ctx.platform.process })
125
- executor.onStatusChanged = (sessionId, serviceType, status, port, error, pid, pidStartTime) => {
137
+ executor.onStatusChanged = (sessionId, serviceType, status, details) => {
126
138
  void ctx.emitEvent(serviceEvents.create('service_status_changed', {
127
139
  serviceType,
128
140
  toStatus: status,
129
- port,
130
- error,
131
- pid,
132
- pidStartTime,
141
+ port: details.port,
142
+ error: details.error,
143
+ cwd: details.cwd,
144
+ command: details.command,
145
+ pid: details.pid,
146
+ pidStartTime: details.pidStartTime,
133
147
  }))
134
148
  // Broadcast service status to connected clients (DO → SPA) via WS
135
- ctx.notify('serviceStatus', { sessionId: String(sessionId), serviceType, status, port })
149
+ ctx.notify('serviceStatus', { sessionId: String(sessionId), serviceType, status, port: details.port })
136
150
  }
137
151
  return { executor }
138
152
  })
@@ -160,7 +174,8 @@ export const servicePlugin = definePlugin('services')
160
174
  }
161
175
 
162
176
  const preferredPort = ctx.pluginState.get(input.serviceType)?.port
163
- await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
177
+ const startResult = await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
178
+ if (!startResult.ok) return Err(ValidationErrors.invalid(startResult.error.message))
164
179
  started.push(input.serviceType)
165
180
  } else {
166
181
  for (const svcConfig of ctx.pluginConfig.services) {
@@ -174,7 +189,8 @@ export const servicePlugin = definePlugin('services')
174
189
  }
175
190
  } else {
176
191
  const preferredPort = ctx.pluginState.get(svcConfig.type)?.port
177
- await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
192
+ const startResult = await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
193
+ if (!startResult.ok) return Err(ValidationErrors.invalid(startResult.error.message))
178
194
  started.push(svcConfig.type)
179
195
  }
180
196
  }
@@ -250,6 +266,8 @@ export const servicePlugin = definePlugin('services')
250
266
  serviceType: z.string(),
251
267
  status: z.string(),
252
268
  port: z.number().optional(),
269
+ cwd: z.string().optional(),
270
+ command: z.string().optional(),
253
271
  error: z.string().optional(),
254
272
  recentLogs: z.array(z.string()),
255
273
  }),
@@ -262,6 +280,8 @@ export const servicePlugin = definePlugin('services')
262
280
  serviceType: input.serviceType,
263
281
  status: status ?? 'stopped',
264
282
  port: stateEntry?.port,
283
+ cwd: stateEntry?.cwd,
284
+ command: stateEntry?.command,
265
285
  error: stateEntry?.error,
266
286
  recentLogs: logsResult.ok ? logsResult.value : [],
267
287
  })
@@ -340,7 +360,10 @@ export const servicePlugin = definePlugin('services')
340
360
  const status = ctx.pluginContext.executor.getStatus(svcConfig.type)
341
361
  if (status !== 'ready' && status !== 'starting') {
342
362
  const preferredPort = ctx.pluginState.get(svcConfig.type)?.port
343
- await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
363
+ const startResult = await ctx.pluginContext.executor.start(svcConfig, ctx.sessionId, ctx.sessionState.workspaceDir, preferredPort)
364
+ if (!startResult.ok) {
365
+ ctx.logger.debug('Auto-start service skipped', { serviceType: svcConfig.type, error: startResult.error.message })
366
+ }
344
367
  }
345
368
  }
346
369
  }
@@ -42,6 +42,9 @@ export function buildServiceStatusMessage(
42
42
  if (entry?.error) {
43
43
  line += ` (error: ${entry.error})`
44
44
  }
45
+ if (entry?.cwd) {
46
+ line += ` (cwd: ${entry.cwd})`
47
+ }
45
48
 
46
49
  lines.push(line)
47
50
  }
@@ -24,6 +24,12 @@ export type ServiceStatus = 'stopped' | 'starting' | 'ready' | 'stopping' | 'fai
24
24
  */
25
25
  export interface ServiceCommandArgs {
26
26
  port: number
27
+ /** Session id of the service owner. */
28
+ sessionId: string
29
+ /** Absolute path of the session workspace directory, when configured. */
30
+ workspaceDir?: string
31
+ /** Resolved working directory that will be passed to the child process. */
32
+ cwd?: string
27
33
  }
28
34
 
29
35
  /**
@@ -32,10 +38,30 @@ export interface ServiceCommandArgs {
32
38
  * to locate the web app inside a monorepo before launching the dev server.
33
39
  */
34
40
  export interface ServiceCwdArgs {
41
+ /** Session id of the service owner. */
42
+ sessionId: string
35
43
  /** Absolute path of the session workspace directory. */
36
44
  workspaceDir: string
37
45
  }
38
46
 
47
+ /**
48
+ * Arguments passed to service environment and readiness callbacks.
49
+ */
50
+ export interface ServiceStartArgs extends ServiceCommandArgs {
51
+ /** Recently captured service output lines. Empty before the process writes. */
52
+ logs: readonly string[]
53
+ }
54
+
55
+ /**
56
+ * Arguments passed to `availableWhen` before a service is started.
57
+ */
58
+ export interface ServiceAvailabilityArgs {
59
+ /** Session id of the service owner. */
60
+ sessionId: string
61
+ /** Absolute path of the session workspace directory, when configured. */
62
+ workspaceDir?: string
63
+ }
64
+
39
65
  /**
40
66
  * Service configuration declared inline in presets.
41
67
  * Defines how to spawn and monitor a background process.
@@ -45,8 +71,8 @@ export interface ServiceConfig {
45
71
  type: string
46
72
  /** Human-readable description */
47
73
  description: string
48
- /** Shell command to run, or a callback receiving allocated port */
49
- command: string | ((args: ServiceCommandArgs) => string)
74
+ /** Shell command to run, or a callback receiving runtime start context */
75
+ command: string | ((args: ServiceCommandArgs) => string | Promise<string>)
50
76
  /**
51
77
  * Working directory (defaults to the workspace dir).
52
78
  *
@@ -57,12 +83,18 @@ export interface ServiceConfig {
57
83
  * resolved the same way.
58
84
  */
59
85
  cwd?: string | ((args: ServiceCwdArgs) => string | Promise<string>)
60
- /** Additional environment variables */
61
- env?: Record<string, string>
86
+ /** Additional environment variables, resolved after cwd and before spawn */
87
+ env?: Record<string, string> | ((args: ServiceCommandArgs) => Record<string, string> | Promise<Record<string, string>>)
88
+ /** Return false to make start/autoStart skip this service for the current workspace. */
89
+ availableWhen?: (args: ServiceAvailabilityArgs) => boolean | Promise<boolean>
62
90
  /** Start automatically with session (default: false) */
63
91
  autoStart?: boolean
64
92
  /** Regex pattern to detect "ready" in stdout */
65
93
  readyPattern?: string
94
+ /** Callback polled until it reports that the service is ready. */
95
+ readyWhen?: (args: ServiceStartArgs) => boolean | Promise<boolean>
96
+ /** Poll interval for readyWhen in ms (default: 250) */
97
+ readyCheckIntervalMs?: number
66
98
  /** SIGTERM→SIGKILL timeout in ms (default: 5000) */
67
99
  gracefulStopMs?: number
68
100
  /** Ring buffer size for log lines (default: 200) */
@@ -86,6 +118,10 @@ export interface ServiceEntry {
86
118
  status: ServiceStatus
87
119
  port?: number
88
120
  error?: string
121
+ /** Resolved working directory used for the current/last start. */
122
+ cwd?: string
123
+ /** Resolved command used for the current/last start. */
124
+ command?: string
89
125
  startedAt?: number
90
126
  readyAt?: number
91
127
  stoppedAt?: number
@@ -13,7 +13,7 @@ import { Err, Ok } from '~/lib/utils/result.js'
13
13
  import type { FileSystem } from '~/platform/fs.js'
14
14
  import type { ChildProcess, ProcessRunner } from '~/platform/process.js'
15
15
  import type { PortPool } from '~/plugins/services/port-pool.js'
16
- import type { ServiceConfig, ServiceStatus } from '~/plugins/services/schema.js'
16
+ import type { ServiceConfig, ServiceStartArgs, ServiceStatus } from '~/plugins/services/schema.js'
17
17
  import type { ToolError } from '../../core/tools/executor.js'
18
18
  import type { Logger } from '../../lib/logger/logger.js'
19
19
  import { RingBuffer } from '../../lib/logger/ring-buffer.js'
@@ -57,9 +57,20 @@ interface RunningService {
57
57
  pid: number
58
58
  status: ServiceStatus
59
59
  port: number
60
+ cwd?: string
61
+ command: string
60
62
  logs: RingBuffer
61
63
  }
62
64
 
65
+ export interface ServiceStatusChangeDetails {
66
+ port?: number
67
+ error?: string
68
+ pid?: number
69
+ pidStartTime?: number
70
+ cwd?: string
71
+ command?: string
72
+ }
73
+
63
74
  /** Matches a port bind-conflict across the Node and Bun runtimes. */
64
75
  const PORT_CONFLICT_PATTERN = /EADDRINUSE|address already in use/i
65
76
 
@@ -98,10 +109,7 @@ export class ServiceExecutor {
98
109
  sessionId: string,
99
110
  serviceType: string,
100
111
  status: ServiceStatus,
101
- port?: number,
102
- error?: string,
103
- pid?: number,
104
- pidStartTime?: number,
112
+ details: ServiceStatusChangeDetails,
105
113
  ) => void
106
114
 
107
115
  constructor(logger: Logger, portPool: PortPool, deps: ServiceExecutorDeps) {
@@ -115,14 +123,46 @@ export class ServiceExecutor {
115
123
  sessionId: string,
116
124
  serviceType: string,
117
125
  status: ServiceStatus,
118
- port?: number,
119
- error?: string,
120
- pid?: number,
121
- pidStartTime?: number,
126
+ details: ServiceStatusChangeDetails = {},
122
127
  ): void {
123
- this.onStatusChanged?.(sessionId, serviceType, status, port, error, pid, pidStartTime)
128
+ this.onStatusChanged?.(sessionId, serviceType, status, details)
124
129
  if (status === 'ready' || status === 'failed' || status === 'stopped') {
125
- this.resolveWaiters(serviceType, status, error)
130
+ this.resolveWaiters(serviceType, status, details.error)
131
+ }
132
+ }
133
+
134
+ private async isAvailable(config: ServiceConfig, sessionId: SessionId, workspaceDir?: string): Promise<Result<boolean, ToolError>> {
135
+ if (!config.availableWhen) return Ok(true)
136
+
137
+ try {
138
+ return Ok(await config.availableWhen({ sessionId: String(sessionId), workspaceDir }))
139
+ } catch (error) {
140
+ const message = error instanceof Error ? error.message : String(error)
141
+ return Err({ message: `Service '${config.type}' availability check failed: ${message}`, recoverable: true })
142
+ }
143
+ }
144
+
145
+ private async resolveCwd(config: ServiceConfig, sessionId: SessionId, workspaceDir?: string): Promise<Result<string | undefined, ToolError>> {
146
+ try {
147
+ if (typeof config.cwd === 'function') {
148
+ if (!workspaceDir) {
149
+ return Err({
150
+ message: `Service '${config.type}' cwd resolver requires a workspaceDir`,
151
+ recoverable: true,
152
+ })
153
+ }
154
+ const raw = await config.cwd({ sessionId: String(sessionId), workspaceDir })
155
+ return Ok(resolve(workspaceDir, raw))
156
+ }
157
+
158
+ if (config.cwd !== undefined) {
159
+ return Ok(workspaceDir ? resolve(workspaceDir, config.cwd) : resolve(config.cwd))
160
+ }
161
+
162
+ return Ok(workspaceDir)
163
+ } catch (error) {
164
+ const message = error instanceof Error ? error.message : String(error)
165
+ return Err({ message: `Service '${config.type}' cwd resolver failed: ${message}`, recoverable: true })
126
166
  }
127
167
  }
128
168
 
@@ -214,44 +254,74 @@ export class ServiceExecutor {
214
254
  return Ok(undefined)
215
255
  }
216
256
 
257
+ const availability = await this.isAvailable(config, sessionId, workspaceDir)
258
+ if (!availability.ok) return availability
259
+ if (!availability.value) {
260
+ return Err({ message: `Service '${config.type}' is not available in this workspace`, recoverable: true })
261
+ }
262
+
217
263
  // Allocate port: reuse session-level allocation, then try preferred, then random
218
264
  let port = this.allocatedPorts.get(config.type)
265
+ let allocatedNow = false
219
266
  if (port === undefined) {
220
267
  port = this.portPool.allocatePreferred(preferredPort) ?? undefined
221
268
  if (port === undefined) {
222
- this.notifyStatusChanged(sessionId, config.type, 'failed', undefined, 'No ports available in pool')
269
+ this.notifyStatusChanged(sessionId, config.type, 'failed', { error: 'No ports available in pool' })
223
270
  return Err({ message: 'No ports available in pool', recoverable: true })
224
271
  }
225
272
  this.allocatedPorts.set(config.type, port)
273
+ allocatedNow = true
226
274
  }
227
275
 
228
- // Resolve cwd: a callback is invoked lazily at start (the workspace is
229
- // populated by now), a string is used directly. Either result is resolved
230
- // against the workspace dir so a relative path (e.g. 'packages/web' for a
231
- // monorepo) lands inside this session's worktree; absolute paths pass through.
232
- let cwd = workspaceDir
233
- if (config.cwd !== undefined) {
234
- const raw = typeof config.cwd === 'function'
235
- ? await config.cwd({ workspaceDir: workspaceDir ?? process.cwd() })
236
- : config.cwd
237
- cwd = workspaceDir ? resolve(workspaceDir, raw) : resolve(raw)
276
+ const cwdResult = await this.resolveCwd(config, sessionId, workspaceDir)
277
+ if (!cwdResult.ok) {
278
+ if (allocatedNow) {
279
+ this.allocatedPorts.delete(config.type)
280
+ this.portPool.release(port)
281
+ }
282
+ this.notifyStatusChanged(sessionId, config.type, 'failed', { port, error: cwdResult.error.message })
283
+ return cwdResult
238
284
  }
285
+
286
+ const cwd = cwdResult.value
239
287
  const logBufferSize = config.logBufferSize ?? 200
240
288
  const startupTimeoutMs = config.startupTimeoutMs ?? 30_000
241
289
 
242
290
  const readyRegex = config.readyPattern ? new RegExp(config.readyPattern) : undefined
243
291
 
244
- // Resolve command: callback gets port, string used as-is
245
- const command = typeof config.command === 'function'
246
- ? config.command({ port })
247
- : config.command
292
+ const startArgs = {
293
+ port,
294
+ sessionId: String(sessionId),
295
+ workspaceDir,
296
+ cwd,
297
+ }
298
+
299
+ let command: string
300
+ let serviceEnv: Record<string, string> | undefined
301
+ try {
302
+ command = typeof config.command === 'function'
303
+ ? await config.command(startArgs)
304
+ : config.command
305
+ serviceEnv = typeof config.env === 'function'
306
+ ? await config.env(startArgs)
307
+ : config.env
308
+ } catch (error) {
309
+ const message = error instanceof Error ? error.message : String(error)
310
+ const errorMessage = `Service '${config.type}' start resolver failed: ${message}`
311
+ if (allocatedNow) {
312
+ this.allocatedPorts.delete(config.type)
313
+ this.portPool.release(port)
314
+ }
315
+ this.notifyStatusChanged(sessionId, config.type, 'failed', { port, cwd, error: errorMessage })
316
+ return Err({ message: errorMessage, recoverable: true })
317
+ }
248
318
 
249
319
  const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'
250
320
  const shellFlag = process.platform === 'win32' ? '/c' : '-c'
251
321
 
252
322
  const child = this.processRunner.spawn(shell, [shellFlag, command], {
253
323
  cwd,
254
- env: { ...process.env, ...config.env, PORT: String(port) },
324
+ env: { ...process.env, ...serviceEnv, PORT: String(port) },
255
325
  detached: true,
256
326
  stdio: ['ignore', 'pipe', 'pipe'],
257
327
  })
@@ -263,12 +333,20 @@ export class ServiceExecutor {
263
333
  const current = this.services.get(config.type)
264
334
  if (current && current.process === child && current.status !== 'stopped' && current.status !== 'failed') {
265
335
  current.status = 'failed'
266
- this.notifyStatusChanged(sessionId, config.type, 'failed', undefined, error.message)
336
+ this.notifyStatusChanged(sessionId, config.type, 'failed', {
337
+ error: error.message,
338
+ cwd,
339
+ command,
340
+ })
267
341
  }
268
342
  })
269
343
 
270
344
  if (!child.pid) {
271
- this.notifyStatusChanged(sessionId, config.type, 'failed', undefined, 'Failed to spawn process')
345
+ if (allocatedNow) {
346
+ this.allocatedPorts.delete(config.type)
347
+ this.portPool.release(port)
348
+ }
349
+ this.notifyStatusChanged(sessionId, config.type, 'failed', { port, cwd, command, error: 'Failed to spawn process' })
272
350
  return Err({ message: 'Failed to spawn service process', recoverable: true })
273
351
  }
274
352
 
@@ -276,8 +354,8 @@ export class ServiceExecutor {
276
354
  // "our process" from "an unrelated process that grabbed this PID after ours died"
277
355
  const pidStartTime = await getProcessStartTime(this.fs, child.pid)
278
356
 
279
- // Emit starting event with PID, port (port is known at allocation time), and start time
280
- this.notifyStatusChanged(sessionId, config.type, 'starting', port, undefined, child.pid, pidStartTime)
357
+ // Emit starting event with PID, port, resolved cwd/command, and start time.
358
+ this.notifyStatusChanged(sessionId, config.type, 'starting', { port, pid: child.pid, pidStartTime, cwd, command })
281
359
 
282
360
  const logs = new RingBuffer(logBufferSize)
283
361
  const startTime = Date.now()
@@ -291,10 +369,70 @@ export class ServiceExecutor {
291
369
  pid: child.pid,
292
370
  status: 'starting',
293
371
  port,
372
+ cwd,
373
+ command,
294
374
  logs,
295
375
  }
296
376
  this.services.set(config.type, entry)
297
377
 
378
+ let startupTimer: ReturnType<typeof setTimeout> | undefined
379
+ let readyCheckTimer: ReturnType<typeof setInterval> | undefined
380
+ let readyCheckInFlight = false
381
+
382
+ const clearReadinessTimers = () => {
383
+ if (startupTimer) {
384
+ clearTimeout(startupTimer)
385
+ startupTimer = undefined
386
+ }
387
+ if (readyCheckTimer) {
388
+ clearInterval(readyCheckTimer)
389
+ readyCheckTimer = undefined
390
+ }
391
+ }
392
+
393
+ const markReady = (matchedLine?: string) => {
394
+ const current = this.services.get(config.type)
395
+ if (!current || current.process !== child || current.status !== 'starting') return
396
+
397
+ current.status = 'ready'
398
+ clearReadinessTimers()
399
+ this.portConflictRetries.delete(config.type)
400
+ const startupDurationMs = Date.now() - startTime
401
+ this.notifyStatusChanged(sessionId, config.type, 'ready', {
402
+ port: current.port,
403
+ cwd: current.cwd,
404
+ command: current.command,
405
+ })
406
+ this.logger.info('Service ready', {
407
+ serviceType: config.type,
408
+ port: current.port,
409
+ startupDurationMs,
410
+ matchedLine,
411
+ })
412
+ }
413
+
414
+ const readyArgs = (): ServiceStartArgs => ({
415
+ ...startArgs,
416
+ logs: logs.toArray(),
417
+ })
418
+
419
+ const checkReadyWhen = async () => {
420
+ if (!config.readyWhen || readyCheckInFlight) return
421
+ const current = this.services.get(config.type)
422
+ if (!current || current.process !== child || current.status !== 'starting') return
423
+
424
+ readyCheckInFlight = true
425
+ try {
426
+ if (await config.readyWhen(readyArgs())) {
427
+ markReady()
428
+ }
429
+ } catch {
430
+ // The service may not be reachable yet; keep polling until timeout.
431
+ } finally {
432
+ readyCheckInFlight = false
433
+ }
434
+ }
435
+
298
436
  const processLine = (line: string) => {
299
437
  logs.push(line)
300
438
  if (PORT_CONFLICT_PATTERN.test(line)) {
@@ -307,27 +445,17 @@ export class ServiceExecutor {
307
445
  this.logger.debug('Service output', { serviceType: config.type, line })
308
446
  }
309
447
 
310
- // Check for ready pattern
311
448
  if (readyRegex && current.status === 'starting') {
312
449
  if (readyRegex.test(line)) {
313
- current.status = 'ready'
314
- if (startupTimer) clearTimeout(startupTimer)
315
- this.portConflictRetries.delete(config.type)
316
- const startupDurationMs = Date.now() - startTime
317
- this.notifyStatusChanged(sessionId, config.type, 'ready', current.port)
318
- this.logger.info('Service ready', {
319
- serviceType: config.type,
320
- port: current.port,
321
- startupDurationMs,
322
- matchedLine: line,
323
- })
450
+ markReady(line)
324
451
  }
325
452
  }
453
+
454
+ void checkReadyWhen()
326
455
  }
327
456
 
328
457
  // Startup timeout — mark as failed if not ready in time
329
- let startupTimer: ReturnType<typeof setTimeout> | undefined
330
- if (readyRegex) {
458
+ if (readyRegex || config.readyWhen) {
331
459
  startupTimer = setTimeout(() => {
332
460
  const current = this.services.get(config.type)
333
461
  if (!current || current.process !== child || current.status !== 'starting') return
@@ -335,7 +463,13 @@ export class ServiceExecutor {
335
463
  current.status = 'failed'
336
464
  const errorMsg = `Service startup timed out after ${startupTimeoutMs}ms`
337
465
  this.logger.error(errorMsg, undefined, { serviceType: config.type })
338
- this.notifyStatusChanged(sessionId, config.type, 'failed', undefined, errorMsg)
466
+ clearReadinessTimers()
467
+ this.notifyStatusChanged(sessionId, config.type, 'failed', {
468
+ port: current.port,
469
+ cwd: current.cwd,
470
+ command: current.command,
471
+ error: errorMsg,
472
+ })
339
473
 
340
474
  // Kill the timed-out process
341
475
  try {
@@ -346,6 +480,14 @@ export class ServiceExecutor {
346
480
  }, startupTimeoutMs)
347
481
  }
348
482
 
483
+ if (config.readyWhen) {
484
+ const intervalMs = config.readyCheckIntervalMs ?? 250
485
+ readyCheckTimer = setInterval(() => {
486
+ void checkReadyWhen()
487
+ }, intervalMs)
488
+ void checkReadyWhen()
489
+ }
490
+
349
491
  // Pipe stdout/stderr line by line
350
492
  let stdoutPartial = ''
351
493
  child.stdout?.on('data', (data: Buffer) => {
@@ -369,7 +511,7 @@ export class ServiceExecutor {
369
511
 
370
512
  // Handle unexpected exit
371
513
  child.on('close', (code) => {
372
- if (startupTimer) clearTimeout(startupTimer)
514
+ clearReadinessTimers()
373
515
  // Flush remaining partial lines
374
516
  if (stdoutPartial) {
375
517
  processLine(stdoutPartial)
@@ -413,7 +555,12 @@ export class ServiceExecutor {
413
555
  // Unexpected exit
414
556
  current.status = 'failed'
415
557
  const errorMsg = `Process exited unexpectedly with code ${code}`
416
- this.notifyStatusChanged(sessionId, config.type, 'failed', undefined, errorMsg)
558
+ this.notifyStatusChanged(sessionId, config.type, 'failed', {
559
+ port: current.port,
560
+ cwd: current.cwd,
561
+ command: current.command,
562
+ error: errorMsg,
563
+ })
417
564
  this.logger.warn('Service process exited unexpectedly', {
418
565
  serviceType: config.type,
419
566
  code,
@@ -433,9 +580,8 @@ export class ServiceExecutor {
433
580
  })
434
581
 
435
582
  // If no ready pattern, immediately mark as ready
436
- if (!readyRegex) {
437
- entry.status = 'ready'
438
- this.notifyStatusChanged(sessionId, config.type, 'ready', port)
583
+ if (!readyRegex && !config.readyWhen) {
584
+ markReady()
439
585
  }
440
586
 
441
587
  return Ok(undefined)
@@ -564,7 +710,11 @@ export class ServiceExecutor {
564
710
  }
565
711
 
566
712
  entry.status = 'ready'
567
- this.notifyStatusChanged(sessionId, config.type, 'ready', entry.port)
713
+ this.notifyStatusChanged(sessionId, config.type, 'ready', {
714
+ port: entry.port,
715
+ cwd: entry.cwd,
716
+ command: entry.command,
717
+ })
568
718
 
569
719
  this.logger.info('Service resumed', { serviceType: config.type })
570
720
  return Ok(undefined)