@roj-ai/sdk 0.1.27 → 0.1.29

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 (53) hide show
  1. package/dist/bootstrap.d.ts +2 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/core/sessions/session.d.ts.map +1 -1
  4. package/dist/core/sessions/session.js +6 -2
  5. package/dist/core/sessions/session.js.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/plugins/resources/index.d.ts +1 -1
  9. package/dist/plugins/resources/index.d.ts.map +1 -1
  10. package/dist/plugins/resources/plugin.d.ts +7 -1
  11. package/dist/plugins/resources/plugin.d.ts.map +1 -1
  12. package/dist/plugins/resources/plugin.js +25 -5
  13. package/dist/plugins/resources/plugin.js.map +1 -1
  14. package/dist/plugins/resources/resources.integration.test.d.ts +2 -0
  15. package/dist/plugins/resources/resources.integration.test.d.ts.map +1 -0
  16. package/dist/plugins/resources/resources.integration.test.js +62 -0
  17. package/dist/plugins/resources/resources.integration.test.js.map +1 -0
  18. package/dist/plugins/resources/state.d.ts +2 -0
  19. package/dist/plugins/resources/state.d.ts.map +1 -1
  20. package/dist/plugins/resources/state.js +1 -0
  21. package/dist/plugins/resources/state.js.map +1 -1
  22. package/dist/plugins/services/plugin.d.ts +4 -0
  23. package/dist/plugins/services/plugin.d.ts.map +1 -1
  24. package/dist/plugins/services/plugin.js +38 -10
  25. package/dist/plugins/services/plugin.js.map +1 -1
  26. package/dist/plugins/services/prompt.d.ts.map +1 -1
  27. package/dist/plugins/services/prompt.js +3 -0
  28. package/dist/plugins/services/prompt.js.map +1 -1
  29. package/dist/plugins/services/schema.d.ts +46 -4
  30. package/dist/plugins/services/schema.d.ts.map +1 -1
  31. package/dist/plugins/services/service.d.ts +11 -1
  32. package/dist/plugins/services/service.d.ts.map +1 -1
  33. package/dist/plugins/services/service.js +182 -46
  34. package/dist/plugins/services/service.js.map +1 -1
  35. package/dist/plugins/services/services.integration.test.js +240 -3
  36. package/dist/plugins/services/services.integration.test.js.map +1 -1
  37. package/dist/testing/preset-helpers.d.ts +1 -0
  38. package/dist/testing/preset-helpers.d.ts.map +1 -1
  39. package/dist/testing/preset-helpers.js +1 -0
  40. package/dist/testing/preset-helpers.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/core/sessions/session.ts +7 -3
  43. package/src/index.ts +8 -2
  44. package/src/plugins/resources/index.ts +1 -1
  45. package/src/plugins/resources/plugin.ts +35 -6
  46. package/src/plugins/resources/resources.integration.test.ts +68 -0
  47. package/src/plugins/resources/state.ts +2 -0
  48. package/src/plugins/services/plugin.ts +36 -10
  49. package/src/plugins/services/prompt.ts +3 -0
  50. package/src/plugins/services/schema.ts +48 -4
  51. package/src/plugins/services/service.ts +202 -52
  52. package/src/plugins/services/services.integration.test.ts +272 -4
  53. package/src/testing/preset-helpers.ts +2 -0
@@ -0,0 +1,68 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { MockLLMProvider } from '~/core/llm/mock.js'
6
+ import { createTestPreset, TestHarness } from '~/testing/index.js'
7
+ import { resourcesPlugin } from './plugin.js'
8
+ import { resourceEvents, type ResourcesState } from './state.js'
9
+
10
+ let currentHarness: TestHarness | undefined
11
+
12
+ afterEach(async () => {
13
+ if (currentHarness) {
14
+ await currentHarness.shutdown()
15
+ currentHarness = undefined
16
+ }
17
+ })
18
+
19
+ describe('resources plugin', () => {
20
+ it('resolves targetDir callback relative to workspace and records it in state', async () => {
21
+ const workspaceDir = await mkdtemp(join(tmpdir(), 'roj-resources-'))
22
+ const targetDir = join(workspaceDir, 'packages', 'web')
23
+
24
+ try {
25
+ const harness = new TestHarness({
26
+ presets: [
27
+ createTestPreset({
28
+ workspaceDir,
29
+ plugins: [
30
+ resourcesPlugin.configure({
31
+ targetDir: ({ sessionId, workspaceDir: resolvedWorkspaceDir }) => {
32
+ expect(sessionId.length).toBeGreaterThan(0)
33
+ expect(resolvedWorkspaceDir).toBe(workspaceDir)
34
+ return 'packages/web'
35
+ },
36
+ }),
37
+ ],
38
+ }),
39
+ ],
40
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
41
+ })
42
+ currentHarness = harness
43
+
44
+ const session = await harness.createSession('test')
45
+ const result = await session.callPluginMethod('resources.inject', {
46
+ sessionId: String(session.sessionId),
47
+ filename: 'hello.txt',
48
+ mimeType: 'text/plain',
49
+ size: 5,
50
+ fileBuffer: Buffer.from('hello'),
51
+ })
52
+
53
+ expect(result).toMatchObject({
54
+ ok: true,
55
+ value: { paths: ['hello.txt'] },
56
+ })
57
+ await expect(readFile(join(targetDir, 'hello.txt'), 'utf-8')).resolves.toBe('hello')
58
+
59
+ const resources = session.getPluginState<ResourcesState>('resources')
60
+ expect(resources?.resources[0]?.targetDir).toBe(targetDir)
61
+
62
+ const events = await session.getEventsByType(resourceEvents, 'resource_injected')
63
+ expect(events[0]?.targetDir).toBe(targetDir)
64
+ } finally {
65
+ await rm(workspaceDir, { recursive: true, force: true })
66
+ }
67
+ })
68
+ })
@@ -10,6 +10,7 @@ export const resourceEvents = createEventsFactory({
10
10
  filename: z4.string(),
11
11
  mimeType: z4.string(),
12
12
  paths: z4.array(z4.string()),
13
+ targetDir: z4.string().optional(),
13
14
  injectedAt: z4.number(),
14
15
  }),
15
16
  },
@@ -22,6 +23,7 @@ export interface InjectedResource {
22
23
  filename: string
23
24
  mimeType: string
24
25
  paths: string[]
26
+ targetDir?: string
25
27
  injectedAt: number
26
28
  }
27
29
 
@@ -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
  }
@@ -445,7 +468,10 @@ export const servicePlugin = definePlugin('services')
445
468
  })
446
469
  if (!result.ok) return Err({ message: result.error.message, recoverable: false })
447
470
 
448
- return Ok(JSON.stringify(result.value))
471
+ // Strip cwd: a real host path, meaningless (and misleading) inside
472
+ // the agent's virtual-root namespace.
473
+ const { cwd: _cwd, ...agentSafe } = result.value
474
+ return Ok(JSON.stringify(agentSafe))
449
475
  },
450
476
  }),
451
477
  createTool({
@@ -42,6 +42,9 @@ export function buildServiceStatusMessage(
42
42
  if (entry?.error) {
43
43
  line += ` (error: ${entry.error})`
44
44
  }
45
+ // Deliberately no cwd: it is a real host path, while sandboxed agents see the
46
+ // world through virtual roots — leaking it sends them chasing paths that
47
+ // don't exist in their namespace (see webmaster path-identity incident).
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) */
@@ -71,6 +103,14 @@ export interface ServiceConfig {
71
103
  startupTimeoutMs?: number
72
104
  /** Auto-pause configuration (interface prepared, not yet implemented) */
73
105
  autoPause?: { inactivityMs: number }
106
+ /**
107
+ * When false, the service is hidden from the owning agent: excluded from its
108
+ * service_* tools and the session-context status block. It still runs at the
109
+ * session level and stays fully controllable via the services.* methods —
110
+ * for infrastructure services (e.g. a CMS sidecar) driven by the platform,
111
+ * not by agents. Default: true.
112
+ */
113
+ agentVisible?: boolean
74
114
  }
75
115
 
76
116
  // ============================================================================
@@ -86,6 +126,10 @@ export interface ServiceEntry {
86
126
  status: ServiceStatus
87
127
  port?: number
88
128
  error?: string
129
+ /** Resolved working directory used for the current/last start. */
130
+ cwd?: string
131
+ /** Resolved command used for the current/last start. */
132
+ command?: string
89
133
  startedAt?: number
90
134
  readyAt?: number
91
135
  stoppedAt?: number