agent-relay 12.1.0 → 12.2.0

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.
@@ -1,15 +1,20 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import path from 'node:path';
2
3
  import { InvalidArgumentError } from 'commander';
3
- import { CloudFleetSandboxProvisionError, deleteCloudFleetSandbox, ensureCloudFleetSandbox, } from '@agent-relay/cloud';
4
+ import { findProjectRoot } from '@agent-relay/config';
5
+ import { CloudFleetSandboxProvisionError, deleteCloudFleetSandbox, ensureCloudFleetSandbox, materializeCloudRelayfileRepository, resolveWorkspaceByKey, } from '@agent-relay/cloud';
4
6
  import { HarnessDriverClient } from '@agent-relay/harness-driver';
5
7
  import { createWorkspaceClient, RelayPlacementError, } from '@agent-relay/sdk';
6
8
  import { withDefaults } from './core.js';
7
9
  import { buildRows, collectWithRetry, formatPretty, readRemoteLiveAgents, readLocalBrokerMaps, } from './fleet-agent.js';
8
10
  import { readBrokerConnection } from '../lib/broker-lifecycle.js';
11
+ import { spawnAgentWithClient } from '../lib/client-factory.js';
12
+ import { connectProjectBrokerClient } from '../lib/project-broker-client.js';
9
13
  import { isAvailableFleetNode } from '../lib/fleet-live-agents.js';
10
14
  import { declaredWorkforceMetadata } from '../lib/registration-metadata.js';
11
15
  import { redactSecrets } from '../lib/redact.js';
12
16
  import { attributableReleaseReason } from '../lib/release-reason.js';
17
+ import { resolveSandboxRepository } from '../lib/sandbox-repo.js';
13
18
  import { spawnPlacementReceipt } from '../lib/spawn-lifecycle.js';
14
19
  import { resolveAgentToken, resolveWorkspaceSelection, persistWorkspaceRelaycastTarget, resolveWorkspaceKeyWithSource, resolveWorkspaceTransport, } from '../lib/sdk-client.js';
15
20
  import { addSdkOptions, printJson, runSdk, sdkOptionsFromOpts, withSdkDefaults, } from '../lib/sdk-command.js';
@@ -20,6 +25,56 @@ const CLOUD_SANDBOX_ID_PATTERN = /^sbx_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-
20
25
  function spawnInvocationWithPlacement(invocation) {
21
26
  return { ...invocation, placement: spawnPlacementReceipt(invocation) };
22
27
  }
28
+ function assertSandboxRepositoryRevision(sandbox, selection) {
29
+ if (!selection)
30
+ return;
31
+ const expected = { [selection.repository]: selection.revision };
32
+ if (sandbox.outcome === 'provisioning_timeout') {
33
+ throw new Error(`Sandbox node '${sandbox.nodeName}' did not become ready within ${sandbox.waitedMs}ms; the repository revision was not verified.`);
34
+ }
35
+ const actual = sandbox.repoRevisions?.[selection.repository];
36
+ if (actual !== selection.revision || Object.keys(sandbox.repoRevisions ?? {}).length !== 1) {
37
+ throw new Error(`Cloud did not echo the requested repository revision for ${selection.repository}; update Cloud before retrying this sandbox launch.`);
38
+ }
39
+ // Keep the shape check explicit at the CLI boundary too: injected/test
40
+ // implementations and older Cloud clients must not bypass the attestation.
41
+ if (JSON.stringify(sandbox.repoRevisions) !== JSON.stringify(expected)) {
42
+ throw new Error(`Cloud returned an unexpected repository revision for ${selection.repository}.`);
43
+ }
44
+ }
45
+ function pathContains(parent, child) {
46
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
47
+ return (relative === '' ||
48
+ (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)));
49
+ }
50
+ function liveRelayfileMountPaths(materialization, requested) {
51
+ const contentRoot = materialization.contentRoot;
52
+ const sentinelRoot = path.posix.dirname(materialization.sentinelPath);
53
+ const requestedPaths = requested ?? [];
54
+ if (requestedPaths.length > 13) {
55
+ throw new Error('--sandbox-relayfile-path accepts at most 13 paths when a live repository, its source metadata, and workspace skills are mounted.');
56
+ }
57
+ const contentAncestor = requestedPaths.find((candidate) => {
58
+ const root = candidate
59
+ .trim()
60
+ .replace(/\/\*\*$/, '')
61
+ .replace(/\/$/, '');
62
+ return contentRoot === root || contentRoot.startsWith(`${root}/`);
63
+ });
64
+ if (contentAncestor && contentAncestor.trim() !== `${contentRoot}/**`) {
65
+ throw new Error(`Relayfile path ${JSON.stringify(contentAncestor)} contains the repository source root; omit it so Relay can mount ${contentRoot}/** as a decoded working tree.`);
66
+ }
67
+ return [...new Set([`${contentRoot}/**`, `${sentinelRoot}/**`, '/.skills/**', ...requestedPaths])];
68
+ }
69
+ function liveRelayfileWorkerCwd(mountRoot, materialization, relativeCwd) {
70
+ const root = mountRoot.replace(/\/+$/, '') || '/';
71
+ const sourceRoot = `${root === '/' ? '' : root}${materialization.contentRoot}`;
72
+ return relativeCwd ? `${sourceRoot}/${relativeCwd}` : sourceRoot;
73
+ }
74
+ function mountedRelayfilePath(mountRoot, remotePath) {
75
+ const root = mountRoot.replace(/\/+$/, '') || '/';
76
+ return `${root === '/' ? '' : root}${remotePath}`;
77
+ }
23
78
  // The targeted spawn path (relay.messaging.placement.spawn) returns an
24
79
  // invocation whose `placement` is the SDK's own evidence object
25
80
  // (`state: 'accepted' | 'ready'`, `confirmed`). `spawnPlacementReceipt`
@@ -67,13 +122,19 @@ function withFleetDefaults(overrides = {}) {
67
122
  return {
68
123
  core,
69
124
  sdk,
125
+ cwd: () => process.cwd(),
126
+ connectLocalBroker: async (cwd) => connectProjectBrokerClient(cwd),
70
127
  createFleetWorkspaceClient: (options) => {
71
128
  const { workspaceKey, baseUrl } = resolveWorkspaceTransport(options);
72
129
  return createWorkspaceClient({ workspaceKey, baseUrl });
73
130
  },
74
131
  resolveWorkspaceSelection,
132
+ resolveSandboxRepository,
133
+ findProjectRoot,
134
+ resolveWorkspaceByKey,
75
135
  persistWorkspaceRelaycastTarget,
76
136
  ensureCloudFleetSandbox,
137
+ materializeCloudRelayfileRepository,
77
138
  deleteCloudFleetSandbox,
78
139
  log: (...args) => console.log(...args),
79
140
  warn: (...args) => console.warn(...args),
@@ -138,13 +199,15 @@ export function registerFleetCommands(program, overrides = {}) {
138
199
  });
139
200
  addSdkOptions(group
140
201
  .command('spawn')
141
- .description('Spawn an agent on a fleet node')
202
+ .description('Spawn locally by default, or select a fleet node or Cloud sandbox explicitly')
142
203
  .argument('<cli>', 'AI CLI to launch', parseFleetCli)
143
204
  .requiredOption('--name <name>', 'Worker agent name')
144
205
  .requiredOption('--task <text>', 'Initial task instructions')
206
+ .option('--auto-place', 'Request automatic eligible-node placement in the Relay workspace')
145
207
  .option('--node <name>', 'Target a specific fleet node')
146
208
  .option('--target-node <name>', 'Alias for --node')
147
- .option('--sandbox', 'Provision a fresh Cloud sandbox node, mount this Relayfile workspace, and spawn there')
209
+ .option('--sandbox', 'Provision a fresh Cloud sandbox and spawn with a live Relayfile workspace/repository mount')
210
+ .option('--checkout', 'Materialize the current Git checkout at its exact pushed HEAD (static; requires --sandbox)')
148
211
  .option('--sandbox-name <name>', 'Explicit sandbox node name (custom unless --sandbox-id requires matching fleet-sandbox-<UUID>)')
149
212
  .option('--sandbox-id <id>', 'Reuse a caller-declared sbx_<UUID> identity for an exact replay')
150
213
  .option('--workspace-id <id>', 'Explicit Relay workspace identity required for sandbox provisioning')
@@ -154,7 +217,7 @@ export function registerFleetCommands(program, overrides = {}) {
154
217
  .option('--channel <name>', 'Channel for the worker to join')
155
218
  .option('--persona <persona>', 'Worker persona (automatic placement)')
156
219
  .option('--model <model>', 'Model powering the worker')
157
- .option('--cwd <path>', 'Absolute working directory for the spawned worker')
220
+ .option('--cwd <path>', 'Working directory: defaults to the caller directory for local spawn; maps a local repo-relative path with --sandbox; otherwise selects a path on the remote node')
158
221
  .option('--organization <organization>', 'Declared organization for workforce reporting')
159
222
  .option('--project <project>', 'Declared project for workforce reporting')
160
223
  .option('--workstream <workstream>', 'Declared workstream for workforce reporting')
@@ -164,14 +227,29 @@ export function registerFleetCommands(program, overrides = {}) {
164
227
  .option('--no-confirm', 'Report a targeted spawn as soon as the node accepts it, without waiting for the node to confirm the agent actually launched')
165
228
  .option('--confirm-timeout <ms>', 'How long a targeted spawn waits for the node to confirm the launch', '120000')).action(async (cli, options) => {
166
229
  await runSdk(deps.sdk, async () => {
167
- warnIfInferredFromProjectSession(options, deps.warn);
168
230
  const clientOptions = sdkOptionsFromOpts(options);
169
231
  const name = requiredText(options.name, 'Worker name');
170
232
  const task = requiredText(options.task, 'Task');
171
233
  let targetNode = optionalText(options.targetNode, 'Target node') ?? optionalText(options.node, 'Node');
172
234
  const useSandbox = options.sandbox === true;
235
+ // Explicit hosted credentials/transport and personas retain their legacy
236
+ // automatic-placement contract. Ambient credentials and a persisted
237
+ // Cloud target must never turn a flag-free local spawn into a remote one.
238
+ const automaticPlacement = options.autoPlace === true ||
239
+ ['workspaceKey', 'token', 'baseUrl', 'persona'].some((key) => options[key] !== undefined);
240
+ if (options.autoPlace === true && (useSandbox || targetNode)) {
241
+ throw new Error('--auto-place cannot be combined with --sandbox, --node, or --target-node.');
242
+ }
243
+ if (useSandbox || targetNode || automaticPlacement) {
244
+ warnIfInferredFromProjectSession(options, deps.warn);
245
+ }
246
+ const checkoutRepository = options.checkout === true;
173
247
  const sandboxName = optionalText(options.sandboxName, 'Sandbox name');
174
248
  const sandboxIdOption = optionalText(options.sandboxId, 'Sandbox ID');
249
+ // An explicit sandbox identity is a retained/replayable resource. Never
250
+ // delete it as collateral when a later verification or dispatch step
251
+ // fails; only clean up sandboxes whose identity this invocation minted.
252
+ const shouldCleanupSandbox = sandboxIdOption === undefined;
175
253
  const explicitWorkspaceId = optionalText(options.workspaceId, 'Workspace ID');
176
254
  if (sandboxIdOption !== undefined && !CLOUD_SANDBOX_ID_PATTERN.test(sandboxIdOption)) {
177
255
  throw new Error('--sandbox-id must match lowercase sbx_<UUID> using an RFC 4122 UUID.');
@@ -195,6 +273,9 @@ export function registerFleetCommands(program, overrides = {}) {
195
273
  if (!useSandbox && sandboxName) {
196
274
  throw new Error('--sandbox-name requires --sandbox.');
197
275
  }
276
+ if (!useSandbox && checkoutRepository) {
277
+ throw new Error('--checkout requires --sandbox.');
278
+ }
198
279
  if (!useSandbox && sandboxIdOption) {
199
280
  throw new Error('--sandbox-id requires --sandbox.');
200
281
  }
@@ -215,7 +296,8 @@ export function registerFleetCommands(program, overrides = {}) {
215
296
  }
216
297
  const channel = optionalText(options.channel, 'Channel');
217
298
  const model = optionalText(options.model, 'Model');
218
- let workerCwd = optionalText(options.cwd, 'Worker cwd');
299
+ const requestedCwd = optionalText(options.cwd, 'Worker cwd');
300
+ let workerCwd = requestedCwd;
219
301
  const organization = optionalText(options.organization, 'Organization');
220
302
  const project = optionalText(options.project, 'Project');
221
303
  const workstream = optionalText(options.workstream, 'Workstream');
@@ -229,26 +311,90 @@ export function registerFleetCommands(program, overrides = {}) {
229
311
  throw new Error('--confirm-timeout must be a positive number of milliseconds.');
230
312
  }
231
313
  let sandbox;
314
+ let sandboxRepository;
315
+ let liveRepository;
316
+ let attachProjectRoot;
232
317
  let workspaceRelay;
233
318
  let relaycastClientOptions = clientOptions;
234
319
  let legacyWorkspaceClientOptions = clientOptions;
235
320
  if (useSandbox) {
321
+ const coreProjectRoot = deps.core.getProjectPaths().projectRoot;
322
+ const hasExplicitProjectOverride = Boolean(deps.core.env?.AGENT_RELAY_PROJECT?.trim() || process.env.AGENT_RELAY_PROJECT?.trim());
323
+ if (checkoutRepository || mountSandboxRelayfile) {
324
+ // AGENT_RELAY_PROJECT selects the workspace namespace, while static
325
+ // checkout and live Relayfile source inference remain anchored to
326
+ // the actual Git tree. This also lets --cwd point at a sibling
327
+ // checkout when explicitly asked.
328
+ const repositoryRootHint = hasExplicitProjectOverride ? process.cwd() : coreProjectRoot;
329
+ sandboxRepository = deps.resolveSandboxRepository(repositoryRootHint, requestedCwd);
330
+ if (checkoutRepository && !sandboxRepository) {
331
+ throw new Error('--checkout requires a GitHub checkout with a clean, pushed commit.');
332
+ }
333
+ if (checkoutRepository && sandboxRepository) {
334
+ workerCwd = sandboxRepository.workerCwd;
335
+ }
336
+ else if (sandboxRepository) {
337
+ // Relative/local --cwd values were consumed by repository
338
+ // inference and must be remapped beneath the remote live source
339
+ // root after Cloud returns its provider-specific mount path.
340
+ workerCwd =
341
+ requestedCwd && /^\/(?:srv\/agent-workforce|workspace)(?:\/|$)/.test(requestedCwd)
342
+ ? requestedCwd
343
+ : undefined;
344
+ }
345
+ }
346
+ const localRequestedCwd = sandboxRepository &&
347
+ requestedCwd &&
348
+ !/^\/(?:srv\/agent-workforce|workspace)(?:\/|$)/.test(requestedCwd)
349
+ ? path.resolve(process.cwd(), requestedCwd)
350
+ : undefined;
351
+ // With --checkout, `--cwd` selects both the local checkout subdirectory
352
+ // and its Relay project namespace. Resolve an intentional nested pin
353
+ // before mapping that path to the static remote checkout; only
354
+ // placement-safe Git identity crosses the Cloud boundary.
355
+ const workspaceProjectRoot = hasExplicitProjectOverride
356
+ ? coreProjectRoot
357
+ : localRequestedCwd
358
+ ? deps.findProjectRoot(localRequestedCwd)
359
+ : sandboxRepository && pathContains(sandboxRepository.projectRoot, coreProjectRoot)
360
+ ? coreProjectRoot
361
+ : (sandboxRepository?.projectRoot ?? coreProjectRoot);
362
+ if (path.resolve(workspaceProjectRoot) !== path.resolve(coreProjectRoot)) {
363
+ attachProjectRoot = workspaceProjectRoot;
364
+ }
365
+ const sandboxClientOptions = {
366
+ ...clientOptions,
367
+ projectRoot: workspaceProjectRoot,
368
+ };
369
+ relaycastClientOptions = sandboxClientOptions;
236
370
  // Cloud must be the first network authority for a sandbox invocation.
237
371
  // A canonical Relaycast info call would both leak the workspace key and
238
372
  // make it impossible to prove that Cloud's isolated target is the one
239
373
  // subsequently used for registration and dispatch.
240
- const workspaceSelection = deps.resolveWorkspaceSelection(clientOptions);
374
+ const workspaceSelection = deps.resolveWorkspaceSelection({
375
+ ...sandboxClientOptions,
376
+ });
241
377
  legacyWorkspaceClientOptions = {
242
- ...clientOptions,
378
+ ...sandboxClientOptions,
243
379
  ...(sandboxProvider === 'agent37' ? {} : { ignorePersistedRelaycastTarget: true }),
244
380
  };
245
381
  let relayWorkspaceId = explicitWorkspaceId ?? workspaceSelection?.workspaceId?.trim();
382
+ // Older/rebound project pins contain only the canonical key. Resolve
383
+ // that exact selection with Cloud using a POST body, never a key URL
384
+ // or an ambient active workspace. Successful target persistence below
385
+ // records the identity for the next invocation.
386
+ if (!relayWorkspaceId &&
387
+ workspaceSelection?.key &&
388
+ (sandboxProvider === undefined || sandboxProvider === 'agent37')) {
389
+ const resolved = await deps.resolveWorkspaceByKey(workspaceSelection.key);
390
+ relayWorkspaceId = resolved.cloudWorkspaceId;
391
+ }
246
392
  // Legacy providers remain backward compatible: they may resolve the
247
393
  // workspace from canonical Relaycast. Agent37 may not, because even a
248
394
  // read there mutates rate-limit/presence accounting on the shared
249
395
  // service and defeats the zero-shared-traffic canary proof.
250
396
  if (!relayWorkspaceId && sandboxProvider === undefined) {
251
- throw new Error('Sandbox provisioning without --sandbox-provider requires a persisted Relay workspace identity; run `relay workspace pin` or pass --workspace-id.');
397
+ throw new Error('Sandbox provisioning without --sandbox-provider requires a persisted Relay workspace identity; run `relay workspace rebind <name>` or pass --workspace-id.');
252
398
  }
253
399
  if (!relayWorkspaceId && sandboxProvider !== undefined && sandboxProvider !== 'agent37') {
254
400
  workspaceRelay = deps.sdk.createWorkspaceRelay(legacyWorkspaceClientOptions);
@@ -257,9 +403,21 @@ export function registerFleetCommands(program, overrides = {}) {
257
403
  }
258
404
  if (!relayWorkspaceId) {
259
405
  throw new Error(sandboxProvider === 'agent37'
260
- ? 'Agent37 sandbox provisioning requires a persisted Relay workspace identity; run `relay workspace pin` or pass --workspace-id.'
406
+ ? 'Agent37 sandbox provisioning requires a persisted Relay workspace identity; run `relay workspace rebind <name>` or pass --workspace-id.'
261
407
  : 'The current Relay workspace did not report an ID for Cloud provisioning.');
262
408
  }
409
+ if (explicitWorkspaceId !== undefined &&
410
+ workspaceSelection?.workspaceId !== undefined &&
411
+ explicitWorkspaceId !== workspaceSelection.workspaceId.trim()) {
412
+ throw new Error('--workspace-id does not match the captured workspace identity.');
413
+ }
414
+ if (!checkoutRepository && mountSandboxRelayfile && sandboxRepository) {
415
+ liveRepository = await deps.materializeCloudRelayfileRepository({
416
+ workspaceId: relayWorkspaceId,
417
+ repository: sandboxRepository.repository,
418
+ revision: sandboxRepository.revision,
419
+ });
420
+ }
263
421
  const sandboxId = sandboxIdOption ?? (sandboxName === undefined ? `sbx_${randomUUID()}` : undefined);
264
422
  const deterministicSandboxName = sandboxId === undefined ? undefined : `fleet-sandbox-${sandboxId.slice('sbx_'.length)}`;
265
423
  if (sandboxIdOption !== undefined &&
@@ -276,28 +434,33 @@ export function registerFleetCommands(program, overrides = {}) {
276
434
  const workloadProfile = sandboxProvider === undefined || sandboxProvider === 'agent37'
277
435
  ? 'long-running-agent'
278
436
  : 'standard-long-running-agent';
279
- if (explicitWorkspaceId !== undefined &&
280
- workspaceSelection?.workspaceId !== undefined &&
281
- explicitWorkspaceId !== workspaceSelection.workspaceId.trim()) {
282
- throw new Error('--workspace-id does not match the captured workspace identity.');
283
- }
284
437
  try {
285
438
  sandbox = await deps.ensureCloudFleetSandbox({
286
439
  workspaceId: relayWorkspaceId,
287
440
  requiredCapability: `spawn:${cli}`,
288
441
  maxAgents: 1,
289
442
  mountRelayfile: mountSandboxRelayfile,
290
- ...(sandboxRelayfilePaths === undefined ? {} : { relayfilePaths: sandboxRelayfilePaths }),
443
+ ...(liveRepository
444
+ ? { relayfilePaths: liveRelayfileMountPaths(liveRepository, sandboxRelayfilePaths) }
445
+ : sandboxRelayfilePaths === undefined
446
+ ? {}
447
+ : { relayfilePaths: sandboxRelayfilePaths }),
291
448
  ...(sandboxId === undefined ? {} : { sandboxId }),
292
449
  forceProvision: true,
293
450
  ...(sandboxProvider === undefined ? {} : { providerId: sandboxProvider }),
294
451
  workloadProfile,
295
452
  waitTimeoutMs: 90_000,
296
453
  ...(effectiveSandboxName === undefined ? {} : { name: effectiveSandboxName }),
454
+ ...(checkoutRepository && sandboxRepository ? { repos: [sandboxRepository.repository] } : {}),
455
+ ...(checkoutRepository && sandboxRepository
456
+ ? { repoRevisions: { [sandboxRepository.repository]: sandboxRepository.revision } }
457
+ : {}),
297
458
  });
459
+ assertSandboxRepositoryRevision(sandbox, checkoutRepository ? sandboxRepository : undefined);
298
460
  }
299
461
  catch (error) {
300
- if (error instanceof CloudFleetSandboxProvisionError &&
462
+ if (shouldCleanupSandbox &&
463
+ error instanceof CloudFleetSandboxProvisionError &&
301
464
  error.confirmedProvisioned &&
302
465
  error.cloudWorkspaceId &&
303
466
  error.sandboxId) {
@@ -314,7 +477,8 @@ export function registerFleetCommands(program, overrides = {}) {
314
477
  else if (error instanceof CloudFleetSandboxProvisionError && error.outcomeUnknown) {
315
478
  deps.warn(`Cloud did not return a complete provisioning response. The outcome is unknown; check Cloud Fleet for node '${error.nodeName ?? effectiveSandboxName ?? 'the requested sandbox'}'${sandboxId === undefined ? '' : ` before retrying with --sandbox-id '${sandboxId}'`} so a sandbox is not left running.`);
316
479
  }
317
- else if (error instanceof CloudFleetSandboxProvisionError &&
480
+ else if (shouldCleanupSandbox &&
481
+ error instanceof CloudFleetSandboxProvisionError &&
318
482
  error.cloudWorkspaceId &&
319
483
  error.sandboxId) {
320
484
  await deps
@@ -327,6 +491,17 @@ export function registerFleetCommands(program, overrides = {}) {
327
491
  deps.warn(`Provisioning failed after Cloud created sandbox '${error.sandboxId}', and automatic cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
328
492
  });
329
493
  }
494
+ if (shouldCleanupSandbox && sandbox && sandbox.outcome !== 'reused') {
495
+ await deps
496
+ .deleteCloudFleetSandbox({
497
+ cloudWorkspaceId: sandbox.cloudWorkspaceId,
498
+ sandboxId: sandbox.sandboxId,
499
+ ...(sandbox.providerId === undefined ? {} : { providerId: sandbox.providerId }),
500
+ })
501
+ .catch((cleanupError) => {
502
+ deps.warn(`Sandbox repository verification failed and cleanup also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
503
+ });
504
+ }
330
505
  throw error;
331
506
  }
332
507
  if (sandbox.outcome !== 'provisioning_timeout' && sandbox.relaycastTarget) {
@@ -347,7 +522,7 @@ export function registerFleetCommands(program, overrides = {}) {
347
522
  : 'Cloud returned a Relaycast target for a different workspace.');
348
523
  }
349
524
  relaycastClientOptions = {
350
- ...clientOptions,
525
+ ...relaycastClientOptions,
351
526
  workspaceKey: target.relaycastApiKey,
352
527
  baseUrl: target.baseUrl,
353
528
  };
@@ -364,7 +539,7 @@ export function registerFleetCommands(program, overrides = {}) {
364
539
  }
365
540
  }
366
541
  catch (error) {
367
- if (sandbox.outcome === 'provisioned') {
542
+ if (shouldCleanupSandbox && sandbox.outcome === 'provisioned') {
368
543
  await deps
369
544
  .deleteCloudFleetSandbox({
370
545
  cloudWorkspaceId: sandbox.cloudWorkspaceId,
@@ -386,20 +561,22 @@ export function registerFleetCommands(program, overrides = {}) {
386
561
  relaycastClientOptions = legacyWorkspaceClientOptions;
387
562
  }
388
563
  if (sandbox.outcome === 'provisioning_timeout') {
389
- await deps
390
- .deleteCloudFleetSandbox({
391
- cloudWorkspaceId: sandbox.cloudWorkspaceId,
392
- sandboxId: sandbox.sandboxId,
393
- ...(sandbox.providerId === undefined ? {} : { providerId: sandbox.providerId }),
394
- })
395
- .catch((error) => {
396
- deps.warn(`The timed-out sandbox could not be cleaned up automatically: ${error instanceof Error ? error.message : String(error)}`);
397
- });
564
+ if (shouldCleanupSandbox) {
565
+ await deps
566
+ .deleteCloudFleetSandbox({
567
+ cloudWorkspaceId: sandbox.cloudWorkspaceId,
568
+ sandboxId: sandbox.sandboxId,
569
+ ...(sandbox.providerId === undefined ? {} : { providerId: sandbox.providerId }),
570
+ })
571
+ .catch((error) => {
572
+ deps.warn(`The timed-out sandbox could not be cleaned up automatically: ${error instanceof Error ? error.message : String(error)}`);
573
+ });
574
+ }
398
575
  throw new Error(`Sandbox node '${sandbox.nodeName}' did not become ready within ${sandbox.waitedMs}ms.`);
399
576
  }
400
577
  if (mountSandboxRelayfile &&
401
578
  (sandbox.outcome !== 'provisioned' || sandbox.relayfileMounted !== true)) {
402
- if (sandbox.outcome === 'provisioned') {
579
+ if (shouldCleanupSandbox && sandbox.outcome === 'provisioned') {
403
580
  await deps
404
581
  .deleteCloudFleetSandbox({
405
582
  cloudWorkspaceId: sandbox.cloudWorkspaceId,
@@ -413,6 +590,13 @@ export function registerFleetCommands(program, overrides = {}) {
413
590
  throw new Error('Cloud returned a sandbox node without the required Relayfile mount.');
414
591
  }
415
592
  targetNode = sandbox.nodeName;
593
+ if (liveRepository &&
594
+ sandboxRepository &&
595
+ !workerCwd &&
596
+ sandbox.outcome === 'provisioned' &&
597
+ sandbox.relayfileMounted) {
598
+ workerCwd = liveRelayfileWorkerCwd(sandbox.relayfileMountPath ?? '/workspace', liveRepository, sandboxRepository.repositoryRelativeCwd);
599
+ }
416
600
  if (!workerCwd && sandbox.outcome === 'provisioned' && sandbox.relayfileMounted) {
417
601
  workerCwd = sandbox.relayfileMountPath ?? '/workspace';
418
602
  }
@@ -454,6 +638,9 @@ export function registerFleetCommands(program, overrides = {}) {
454
638
  // the invocation and launches nothing, which is indistinguishable from
455
639
  // success here — so wait for the node to confirm unless asked not to.
456
640
  const confirm = options.confirm !== false;
641
+ const liveSandboxContext = sandbox?.outcome === 'provisioned' && liveRepository && sandboxRepository
642
+ ? `Agent Relay sandbox context: ${liveRepository.repository} is mounted as a live Relayfile working tree at ${liveRelayfileWorkerCwd(sandbox.relayfileMountPath ?? '/workspace', liveRepository, '')}. Its exact source revision is ${liveRepository.revision}; the same attestation is recorded at ${mountedRelayfilePath(sandbox.relayfileMountPath ?? '/workspace', liveRepository.sentinelPath)}. Workspace skills are under ${mountedRelayfilePath(sandbox.relayfileMountPath ?? '/workspace', '/.skills')}. The Relayfile daemon synchronizes this tree; it intentionally has no .git directory.`
643
+ : undefined;
457
644
  const invocation = await relay.messaging.placement.spawn({
458
645
  capability: `spawn:${cli}`,
459
646
  node: targetNode,
@@ -463,7 +650,15 @@ export function registerFleetCommands(program, overrides = {}) {
463
650
  input: {
464
651
  name,
465
652
  cli,
466
- task,
653
+ task: sandbox &&
654
+ checkoutRepository &&
655
+ sandboxRepository &&
656
+ mountSandboxRelayfile &&
657
+ sandbox.outcome === 'provisioned'
658
+ ? `${task}\n\nAgent Relay sandbox context: Relayfile records are available at ${sandbox.relayfileMountPath ?? '/workspace'}. The source checkout is separate; use ${workerCwd ?? 'the worker checkout'} for repository files and the mount for Relayfile records.`
659
+ : liveSandboxContext
660
+ ? `${task}\n\n${liveSandboxContext}`
661
+ : task,
467
662
  ...(channel ? { channels: [channel] } : {}),
468
663
  ...(model ? { model } : {}),
469
664
  ...(workerCwd ? { worker_cwd: workerCwd } : {}),
@@ -485,19 +680,15 @@ export function registerFleetCommands(program, overrides = {}) {
485
680
  ...(sandbox
486
681
  ? {
487
682
  sandbox: printableSandbox,
488
- attachCommand: `agent-relay node agent attach ${shellQuote(name)} ` +
489
- `--node ${shellQuote(targetNode)} --mode drive` +
490
- ((sandbox.outcome === 'provisioned' || sandbox.outcome === 'reused') &&
491
- sandbox.relaycastTarget
492
- ? ` --base-url ${shellQuote(sandbox.relaycastTarget.baseUrl)}`
493
- : ''),
683
+ attachCommand: sandboxAttachCommand(name, attachProjectRoot),
494
684
  }
495
685
  : {}),
496
686
  invocation: spawnInvocationWithMergedPlacement(invocation),
497
687
  });
498
688
  }
499
689
  catch (error) {
500
- if (sandbox?.outcome === 'provisioned' &&
690
+ if (shouldCleanupSandbox &&
691
+ sandbox?.outcome === 'provisioned' &&
501
692
  !(error instanceof RelayPlacementError && error.state === 'unconfirmed_may_be_running')) {
502
693
  await deps
503
694
  .deleteCloudFleetSandbox({
@@ -530,6 +721,31 @@ export function registerFleetCommands(program, overrides = {}) {
530
721
  throw new Error('--session-ref requires --node or --target-node.');
531
722
  }
532
723
  const persona = optionalText(options.persona, 'Persona');
724
+ if (!automaticPlacement) {
725
+ if (organization || project || workstream || role || objective) {
726
+ throw new Error('Workforce metadata requires --auto-place, --node, or --sandbox.');
727
+ }
728
+ const callerCwd = deps.cwd();
729
+ const localCwd = path.resolve(callerCwd, requestedCwd ?? '.');
730
+ const local = await deps.connectLocalBroker(deps.findProjectRoot(callerCwd));
731
+ try {
732
+ await spawnAgentWithClient(local, {
733
+ name,
734
+ cli,
735
+ task,
736
+ channels: [channel ?? 'general'],
737
+ ...(model ? { model } : {}),
738
+ // A broker can be shared by nested packages. Never inherit its
739
+ // startup directory when the caller requested a local checkout.
740
+ cwd: localCwd,
741
+ });
742
+ printJson(deps.sdk, { local: { name, cli, cwd: localCwd } });
743
+ }
744
+ finally {
745
+ local.disconnect();
746
+ }
747
+ return;
748
+ }
533
749
  const workspace = deps.createFleetWorkspaceClient(clientOptions);
534
750
  const invocation = await workspace.agents.spawn({
535
751
  name,
@@ -635,6 +851,10 @@ function optionalTextList(value, label) {
635
851
  function shellQuote(value) {
636
852
  return `'${value.replace(/'/g, `'\\''`)}'`;
637
853
  }
854
+ function sandboxAttachCommand(name, projectRoot) {
855
+ const attach = `agent-relay node agent attach ${shellQuote(name)} --mode drive`;
856
+ return projectRoot ? `cd ${shellQuote(projectRoot)} && ${attach}` : attach;
857
+ }
638
858
  /**
639
859
  * Warn (on stderr, so it never pollutes the JSON on stdout) when the workspace
640
860
  * key was inferred from the project's persisted session rather than named