@roj-ai/sdk 0.1.24 → 0.1.26

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 (34) hide show
  1. package/dist/core/agents/agent.d.ts +6 -0
  2. package/dist/core/agents/agent.d.ts.map +1 -1
  3. package/dist/core/agents/agent.js +25 -1
  4. package/dist/core/agents/agent.js.map +1 -1
  5. package/dist/core/errors.d.ts +4 -0
  6. package/dist/core/errors.d.ts.map +1 -1
  7. package/dist/core/errors.js +4 -0
  8. package/dist/core/errors.js.map +1 -1
  9. package/dist/core/plugin-hooks.integration.test.js +114 -0
  10. package/dist/core/plugin-hooks.integration.test.js.map +1 -1
  11. package/dist/core/plugins/hook-types.d.ts +19 -0
  12. package/dist/core/plugins/hook-types.d.ts.map +1 -1
  13. package/dist/core/plugins/plugin-builder.d.ts +28 -1
  14. package/dist/core/plugins/plugin-builder.d.ts.map +1 -1
  15. package/dist/core/plugins/plugin-builder.js +19 -0
  16. package/dist/core/plugins/plugin-builder.js.map +1 -1
  17. package/dist/core/sessions/session.d.ts.map +1 -1
  18. package/dist/core/sessions/session.js +25 -1
  19. package/dist/core/sessions/session.js.map +1 -1
  20. package/dist/plugins/services/schema.d.ts +19 -2
  21. package/dist/plugins/services/schema.d.ts.map +1 -1
  22. package/dist/plugins/services/service.d.ts.map +1 -1
  23. package/dist/plugins/services/service.js +12 -1
  24. package/dist/plugins/services/service.js.map +1 -1
  25. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  26. package/package.json +2 -2
  27. package/src/core/agents/agent.ts +22 -1
  28. package/src/core/errors.ts +5 -0
  29. package/src/core/plugin-hooks.integration.test.ts +140 -0
  30. package/src/core/plugins/hook-types.ts +20 -0
  31. package/src/core/plugins/plugin-builder.ts +31 -1
  32. package/src/core/sessions/session.ts +24 -1
  33. package/src/plugins/services/schema.ts +20 -2
  34. package/src/plugins/services/service.ts +12 -1
@@ -17,5 +17,5 @@ export declare const errorHandler: (error: Error, c: Context<AppEnv>) => Respons
17
17
  type: string;
18
18
  message: string;
19
19
  };
20
- }, 100 | 500 | 404 | 409 | 400 | -1 | 429 | 401 | 200 | 501 | 201 | 300 | 102 | 103 | 202 | 203 | 206 | 207 | 208 | 226 | 301 | 302 | 303 | 305 | 306 | 307 | 308 | 402 | 403 | 405 | 406 | 407 | 408 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 431 | 451 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, "json">;
20
+ }, 100 | 500 | 404 | 409 | 400 | 403 | -1 | 429 | 401 | 200 | 501 | 201 | 300 | 102 | 103 | 202 | 203 | 206 | 207 | 208 | 226 | 301 | 302 | 303 | 305 | 306 | 307 | 308 | 402 | 405 | 406 | 407 | 408 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 431 | 451 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, "json">;
21
21
  //# sourceMappingURL=error-handler.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roj-ai/sdk",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -135,7 +135,7 @@
135
135
  "type-check": "tsc --noEmit"
136
136
  },
137
137
  "dependencies": {
138
- "@roj-ai/transport": "^0.1.24",
138
+ "@roj-ai/transport": "^0.1.26",
139
139
  "@hono/zod-validator": "0.7.6",
140
140
  "hono": "4.12.5",
141
141
  "ignore": "7.0.5",
@@ -1606,7 +1606,25 @@ export class Agent {
1606
1606
  for (const plugin of this.plugins) {
1607
1607
  if (!plugin.dequeue) continue
1608
1608
  const ctx = this.buildPluginHookContext(plugin, agentContext)
1609
- if (plugin.dequeue.hasPendingMessages(ctx)) return true
1609
+ if (!plugin.dequeue.hasPendingMessages(ctx)) continue
1610
+ // A beforeDequeue gate may hold this source's messages — held messages
1611
+ // must not count as work, or the agent would wake with nothing to do.
1612
+ if (this.isDequeueHeld(plugin.name, agentContext)) continue
1613
+ return true
1614
+ }
1615
+ return false
1616
+ }
1617
+
1618
+ /**
1619
+ * Run all plugins' beforeDequeue gates for a source plugin's pending messages.
1620
+ * Returns true if any gate holds them — held messages stay queued (not
1621
+ * consumed) and are re-delivered on a later cycle.
1622
+ */
1623
+ private isDequeueHeld(sourcePlugin: string, agentContext: AgentContext): boolean {
1624
+ for (const plugin of this.plugins) {
1625
+ if (!plugin.beforeDequeue) continue
1626
+ const ctx = { ...this.buildPluginHookContext(plugin, agentContext), sourcePlugin }
1627
+ if (plugin.beforeDequeue(ctx)?.action === 'hold') return true
1610
1628
  }
1611
1629
  return false
1612
1630
  }
@@ -1632,6 +1650,9 @@ export class Agent {
1632
1650
 
1633
1651
  for (const plugin of this.plugins) {
1634
1652
  if (!plugin.dequeue) continue
1653
+ // Skip sources held by a beforeDequeue gate — not collected means not
1654
+ // consumed, so the messages stay queued for a later cycle.
1655
+ if (this.isDequeueHeld(plugin.name, agentContext)) continue
1635
1656
  const ctx = this.buildPluginHookContext(plugin, agentContext)
1636
1657
  const result = plugin.dequeue.getPendingMessages(ctx)
1637
1658
  if (result) {
@@ -53,3 +53,8 @@ export const PresetErrors = {
53
53
  export const ValidationErrors = {
54
54
  invalid: (message: string) => createDomainError('validation_error', message, 400),
55
55
  }
56
+
57
+ export const MethodErrors = {
58
+ /** A `beforeMethod` plugin hook denied the call (e.g. a budget guard blocking new user input). */
59
+ denied: (message: string) => createDomainError('method_denied', message, 403),
60
+ }
@@ -4,7 +4,9 @@ import { MockLLMProvider } from '~/core/llm/mock.js'
4
4
  import { definePlugin } from '~/core/plugins/plugin-builder.js'
5
5
  import { ToolCallId } from '~/core/tools/schema.js'
6
6
  import { toolEvents } from '~/core/tools/state.js'
7
+ import { Ok } from '~/lib/utils/result.js'
7
8
  import { createTestPreset, TestHarness } from '~/testing/index.js'
9
+ import z from 'zod/v4'
8
10
 
9
11
  describe('core: plugin hooks', () => {
10
12
  // =========================================================================
@@ -373,6 +375,80 @@ describe('core: plugin hooks', () => {
373
375
 
374
376
  await harness.shutdown()
375
377
  })
378
+
379
+ describe('beforeMethod', () => {
380
+ it('returning { action: "deny" } → method call rejected and handler not run', async () => {
381
+ let handlerCalls = 0
382
+ const seenMethods: string[] = []
383
+
384
+ const gatedPlugin = definePlugin('gated')
385
+ .method('blocked', {
386
+ input: z.object({}),
387
+ output: z.object({ ok: z.boolean() }),
388
+ handler: async () => {
389
+ handlerCalls++
390
+ return Ok({ ok: true })
391
+ },
392
+ })
393
+ .sessionHook('beforeMethod', async (ctx) => {
394
+ seenMethods.push(ctx.method)
395
+ if (ctx.method === 'gated.blocked') {
396
+ return { action: 'deny', reason: 'blocked by test' }
397
+ }
398
+ return null
399
+ })
400
+ .build()
401
+
402
+ const harness = new TestHarness({
403
+ presets: [createTestPreset()],
404
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
405
+ systemPlugins: [gatedPlugin],
406
+ })
407
+
408
+ const session = await harness.createSession('test')
409
+ const result = await session.callPluginMethod('gated.blocked', {})
410
+
411
+ expect(result.ok).toBe(false)
412
+ if (!result.ok) {
413
+ expect(result.error.type).toBe('method_denied')
414
+ expect(result.error.message).toBe('blocked by test')
415
+ }
416
+ expect(handlerCalls).toBe(0)
417
+ expect(seenMethods).toContain('gated.blocked')
418
+
419
+ await harness.shutdown()
420
+ })
421
+
422
+ it('returning null → method call proceeds to the handler', async () => {
423
+ let handlerCalls = 0
424
+
425
+ const gatedPlugin = definePlugin('gated')
426
+ .method('allowed', {
427
+ input: z.object({}),
428
+ output: z.object({ ok: z.boolean() }),
429
+ handler: async () => {
430
+ handlerCalls++
431
+ return Ok({ ok: true })
432
+ },
433
+ })
434
+ .sessionHook('beforeMethod', async () => null)
435
+ .build()
436
+
437
+ const harness = new TestHarness({
438
+ presets: [createTestPreset()],
439
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
440
+ systemPlugins: [gatedPlugin],
441
+ })
442
+
443
+ const session = await harness.createSession('test')
444
+ const result = await session.callPluginMethod('gated.allowed', {})
445
+
446
+ expect(result.ok).toBe(true)
447
+ expect(handlerCalls).toBe(1)
448
+
449
+ await harness.shutdown()
450
+ })
451
+ })
376
452
  })
377
453
 
378
454
  // =========================================================================
@@ -425,4 +501,68 @@ describe('core: plugin hooks', () => {
425
501
  await harness.shutdown()
426
502
  })
427
503
  })
504
+
505
+ // =========================================================================
506
+ // beforeDequeue gate
507
+ // =========================================================================
508
+
509
+ describe('beforeDequeue', () => {
510
+ it('holding a source → messages do not wake the agent, and are re-delivered (not dropped) once released', async () => {
511
+ let holding = true
512
+
513
+ const gatePlugin = definePlugin('budget-gate')
514
+ .beforeDequeue((ctx) => {
515
+ if (holding && ctx.sourcePlugin === 'user-chat') {
516
+ return { action: 'hold' }
517
+ }
518
+ return null
519
+ })
520
+ .build()
521
+
522
+ const harness = new TestHarness({
523
+ presets: [createTestPreset()],
524
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
525
+ systemPlugins: [gatePlugin],
526
+ })
527
+
528
+ const session = await harness.createSession('test')
529
+
530
+ // Held: the user message is queued but must not trigger an inference.
531
+ await session.sendMessage('first')
532
+ await new Promise((r) => setTimeout(r, 50))
533
+ expect(harness.llmProvider.getLastRequest()).toBeUndefined()
534
+
535
+ // Released: a new message wakes the agent; the held 'first' must be
536
+ // re-delivered alongside 'second' (held, not dropped).
537
+ holding = false
538
+ await session.sendAndWaitForIdle('second')
539
+
540
+ const req = harness.llmProvider.getLastRequest()
541
+ expect(req).toBeDefined()
542
+ const serialized = JSON.stringify(req!.messages)
543
+ expect(serialized).toContain('first')
544
+ expect(serialized).toContain('second')
545
+
546
+ await harness.shutdown()
547
+ })
548
+
549
+ it('returning null → messages flow through normally', async () => {
550
+ const gatePlugin = definePlugin('budget-gate')
551
+ .beforeDequeue(() => null)
552
+ .build()
553
+
554
+ const harness = new TestHarness({
555
+ presets: [createTestPreset()],
556
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
557
+ systemPlugins: [gatePlugin],
558
+ })
559
+
560
+ const session = await harness.createSession('test')
561
+ await session.sendAndWaitForIdle('hello')
562
+
563
+ expect(harness.llmProvider.getLastRequest()).toBeDefined()
564
+
565
+ await harness.shutdown()
566
+ })
567
+ })
428
568
  })
@@ -41,6 +41,26 @@ export type OnErrorResult =
41
41
 
42
42
  export type OnPauseResult = null
43
43
 
44
+ /**
45
+ * Result of a `beforeMethod` session hook — lets a plugin veto a plugin-method
46
+ * call before its handler runs (e.g. a budget guard blocking new user messages
47
+ * while allowing internal agent-to-agent calls).
48
+ */
49
+ export type BeforeMethodResult =
50
+ | null
51
+ | { action: 'deny'; reason: string }
52
+
53
+ /**
54
+ * Result of a `beforeDequeue` hook — lets a plugin HOLD (not deliver) the
55
+ * pending messages of a given source plugin for the current cycle. Held
56
+ * messages stay queued (not consumed) and are re-delivered on a later cycle —
57
+ * e.g. a budget guard holding new user/upload input while letting inter-agent
58
+ * mailbox traffic through so running agents can finish.
59
+ */
60
+ export type BeforeDequeueResult =
61
+ | null
62
+ | { action: 'hold' }
63
+
44
64
  export type HandlerName =
45
65
  | 'onStart'
46
66
  | 'beforeInference'
@@ -26,7 +26,9 @@ import type { ToolCall } from '../tools/schema.js'
26
26
  import type {
27
27
  AfterInferenceResult,
28
28
  AfterToolCallResult,
29
+ BeforeDequeueResult,
29
30
  BeforeInferenceResult,
31
+ BeforeMethodResult,
30
32
  BeforeToolCallResult,
31
33
  OnCompleteResult,
32
34
  OnErrorResult,
@@ -237,6 +239,7 @@ type HookMap<TCtx> = {
237
239
  type SessionHookMap<TCtx> = {
238
240
  onSessionReady: (ctx: TCtx) => Promise<void>
239
241
  onSessionClose: (ctx: TCtx) => Promise<void>
242
+ beforeMethod: (ctx: TCtx & { method: string; input: unknown; agentId?: AgentId }) => Promise<BeforeMethodResult>
240
243
  }
241
244
 
242
245
  // ============================================================================
@@ -311,6 +314,7 @@ type ErasedAgentHookMap = {
311
314
  type ErasedSessionHookMap = {
312
315
  onSessionReady: (ctx: BaseSessionHookContext) => Promise<void>
313
316
  onSessionClose: (ctx: BaseSessionHookContext) => Promise<void>
317
+ beforeMethod: (ctx: BaseSessionHookContext & { method: string; input: unknown; agentId?: AgentId }) => Promise<BeforeMethodResult>
314
318
  }
315
319
 
316
320
  // ============================================================================
@@ -351,6 +355,8 @@ export interface ConfiguredPlugin {
351
355
  getPendingMessages: (ctx: BasePluginHookContext) => PluginPendingMessages | null
352
356
  markConsumed: (ctx: BasePluginHookContext, token: unknown) => Promise<void>
353
357
  }
358
+ /** Gate run before delivering another plugin's dequeued messages — return 'hold' to keep them queued this cycle. */
359
+ beforeDequeue?: (ctx: BasePluginHookContext & { sourcePlugin: string }) => BeforeDequeueResult
354
360
  slice?: StateSlice
355
361
  isEnabled?: (ctx: { pluginConfig: unknown; pluginAgentConfig: unknown; agentConfig: AgentConfig }) => boolean
356
362
  isSessionEnabled?: (ctx: { pluginConfig: unknown }) => boolean
@@ -443,6 +449,7 @@ interface BuilderConfig {
443
449
  getPendingMessages: (ctx: BasePluginHookContext) => PluginPendingMessages | null
444
450
  markConsumed: (ctx: BasePluginHookContext, token: unknown) => Promise<void>
445
451
  } | undefined
452
+ beforeDequeueFn: ((ctx: BasePluginHookContext & { sourcePlugin: string }) => BeforeDequeueResult) | undefined
446
453
  }
447
454
 
448
455
  // ============================================================================
@@ -486,6 +493,7 @@ export class PluginBuilder<
486
493
  sessionHooks: {},
487
494
  isEnabledFn: undefined,
488
495
  dequeueHook: undefined,
496
+ beforeDequeueFn: undefined,
489
497
  }
490
498
  }
491
499
 
@@ -748,6 +756,20 @@ export class PluginBuilder<
748
756
  return this
749
757
  }
750
758
 
759
+ /**
760
+ * Gate the delivery of OTHER plugins' dequeued messages. Runs once per source
761
+ * plugin that has pending messages, before they wake the agent; return
762
+ * `{ action: 'hold' }` to keep that source's messages queued this cycle
763
+ * (re-delivered later). `ctx.sourcePlugin` is the name of the plugin whose
764
+ * messages are being considered.
765
+ */
766
+ beforeDequeue(
767
+ fn: (ctx: PluginHookContext<TConfig, TMethods, TAgentConfig, TContext, TState, TNotifications, TDeps> & { sourcePlugin: string }) => BeforeDequeueResult,
768
+ ): this {
769
+ this._cfg.beforeDequeueFn = fn as BuilderConfig['beforeDequeueFn']
770
+ return this
771
+ }
772
+
751
773
  // --- Build ---
752
774
 
753
775
  build(): PluginDefinition<TName, TConfig, TAgentConfig, TManagerMethods, TMethods> {
@@ -811,11 +833,13 @@ function buildConfiguredPlugin(cfg: BuilderConfig, pluginConfig: unknown): Confi
811
833
  ) as Partial<ErasedAgentHookMap>
812
834
  : undefined
813
835
 
814
- const sessionHookEntries = Object.entries(cfg.sessionHooks)
836
+ const sessionHookEntries = Object.entries(cfg.sessionHooks) as [string, (ctx: BaseSessionHookContext) => Promise<unknown>][]
815
837
  const sessionHooks: Partial<ErasedSessionHookMap> | undefined = sessionHookEntries.length > 0
816
838
  ? Object.fromEntries(
817
839
  sessionHookEntries.map(([name, fn]) => [
818
840
  name,
841
+ // Session.ts provides the full context with extra fields (method, input,
842
+ // agentId for beforeMethod) — this wrapper just injects pluginConfig.
819
843
  (ctx: BaseSessionHookContext) => fn({ ...ctx, pluginConfig }),
820
844
  ]),
821
845
  ) as Partial<ErasedSessionHookMap>
@@ -872,6 +896,11 @@ function buildConfiguredPlugin(cfg: BuilderConfig, pluginConfig: unknown): Confi
872
896
  }
873
897
  : undefined
874
898
 
899
+ const beforeDequeueFn = cfg.beforeDequeueFn
900
+ const wrappedBeforeDequeue = beforeDequeueFn
901
+ ? (ctx: BasePluginHookContext & { sourcePlugin: string }) => beforeDequeueFn({ ...ctx, pluginConfig })
902
+ : undefined
903
+
875
904
  // Wrap isEnabled to pass pluginConfig
876
905
  const wrappedIsEnabled = cfg.isEnabledFn
877
906
  ? (ctx: { pluginConfig: unknown; pluginAgentConfig: unknown; agentConfig: AgentConfig }) => {
@@ -916,6 +945,7 @@ function buildConfiguredPlugin(cfg: BuilderConfig, pluginConfig: unknown): Confi
916
945
  getStatus: wrappedStatus,
917
946
  getSystemPrompt: wrappedSystemPrompt,
918
947
  dequeue: wrappedDequeue,
948
+ beforeDequeue: wrappedBeforeDequeue,
919
949
  slice,
920
950
  isEnabled: wrappedIsEnabled,
921
951
  isSessionEnabled: wrappedIsSessionEnabled,
@@ -12,7 +12,7 @@ import { COMMUNICATOR_ROLE, ORCHESTRATOR_ROLE } from '~/core/agents/agent-roles.
12
12
  import { AgentId, generateAgentId } from '~/core/agents/schema.js'
13
13
  import type { AgentState } from '~/core/agents/state.js'
14
14
  import { agentEvents, getChildren } from '~/core/agents/state.js'
15
- import { AgentErrors, type DomainError, SessionErrors, ValidationErrors } from '~/core/errors.js'
15
+ import { AgentErrors, type DomainError, MethodErrors, SessionErrors, ValidationErrors } from '~/core/errors.js'
16
16
  import { withSessionId } from '~/core/events/test-helpers.js'
17
17
  import type { DomainEvent } from '~/core/events/types.js'
18
18
  import type { LLMLogger } from '~/core/llm/logger.js'
@@ -478,6 +478,29 @@ export class Session {
478
478
  return Err(ValidationErrors.invalid(`Invalid input for ${method}: ${parsed.error.message}`))
479
479
  }
480
480
 
481
+ // beforeMethod gate — any plugin may veto this call before the handler runs
482
+ // (e.g. a budget guard blocking new user input). The hook receives `caller`,
483
+ // so a gate can allow internal AGENT_CALLER traffic (agent-to-agent) while
484
+ // denying external/user-originated calls. First deny wins.
485
+ for (const gatePlugin of this.plugins) {
486
+ if (!gatePlugin.sessionHooks?.beforeMethod) continue
487
+ try {
488
+ const gateCtx = {
489
+ ...this.buildSessionHookContext(gatePlugin),
490
+ caller: caller ?? DEFAULT_CALLER,
491
+ method,
492
+ input: parsed.data,
493
+ agentId,
494
+ }
495
+ const gate = await gatePlugin.sessionHooks.beforeMethod(gateCtx)
496
+ if (gate?.action === 'deny') {
497
+ return Err(MethodErrors.denied(gate.reason))
498
+ }
499
+ } catch (err) {
500
+ this.logger.error(`Plugin '${gatePlugin.name}' beforeMethod hook failed`, err instanceof Error ? err : undefined, { method })
501
+ }
502
+ }
503
+
481
504
  // Build MethodHandlerContext with plugin state, context, scheduleAgent, notify, and deps
482
505
  const sessionContext = this.buildSessionContext()
483
506
  const pluginState = plugin.slice ? plugin.slice.select(this.store.getState()) : undefined
@@ -26,6 +26,16 @@ export interface ServiceCommandArgs {
26
26
  port: number
27
27
  }
28
28
 
29
+ /**
30
+ * Arguments passed to a `cwd` resolver callback. Resolved lazily at service
31
+ * start, so the callback can inspect the (by-then-populated) workspace — e.g.
32
+ * to locate the web app inside a monorepo before launching the dev server.
33
+ */
34
+ export interface ServiceCwdArgs {
35
+ /** Absolute path of the session workspace directory. */
36
+ workspaceDir: string
37
+ }
38
+
29
39
  /**
30
40
  * Service configuration declared inline in presets.
31
41
  * Defines how to spawn and monitor a background process.
@@ -37,8 +47,16 @@ export interface ServiceConfig {
37
47
  description: string
38
48
  /** Shell command to run, or a callback receiving allocated port */
39
49
  command: string | ((args: ServiceCommandArgs) => string)
40
- /** Working directory (defaults to workspace dir) */
41
- cwd?: string
50
+ /**
51
+ * Working directory (defaults to the workspace dir).
52
+ *
53
+ * A string is resolved relative to the workspace dir (absolute paths are
54
+ * used as-is). A callback is invoked at service start with the workspace
55
+ * dir, so the directory can be detected lazily once the workspace is
56
+ * populated (e.g. the web package inside a monorepo); its return value is
57
+ * resolved the same way.
58
+ */
59
+ cwd?: string | ((args: ServiceCwdArgs) => string | Promise<string>)
42
60
  /** Additional environment variables */
43
61
  env?: Record<string, string>
44
62
  /** Start automatically with session (default: false) */
@@ -6,6 +6,7 @@
6
6
  * Ports are allocated from a global PortPool and injected via PORT env var.
7
7
  */
8
8
 
9
+ import { resolve } from 'node:path'
9
10
  import type { SessionId } from '~/core/sessions/schema.js'
10
11
  import type { Result } from '~/lib/utils/result.js'
11
12
  import { Err, Ok } from '~/lib/utils/result.js'
@@ -224,7 +225,17 @@ export class ServiceExecutor {
224
225
  this.allocatedPorts.set(config.type, port)
225
226
  }
226
227
 
227
- const cwd = config.cwd ?? workspaceDir
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)
238
+ }
228
239
  const logBufferSize = config.logBufferSize ?? 200
229
240
  const startupTimeoutMs = config.startupTimeoutMs ?? 30_000
230
241