@borgee/agents-host 0.2.56 → 0.2.62

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,4 +1,4 @@
1
- import { promises as fs } from 'node:fs';
1
+ import { constants as fsConstants, promises as fs } from 'node:fs';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
@@ -7,14 +7,16 @@ import { findTaskForThread } from '../task-thread-resolution.js';
7
7
  import { buildClaudeFileBrief } from './claude-file-brief.js';
8
8
  const CHANNEL_CONTEXT_ROOT_DIRNAME = 'channel-context';
9
9
  const CHANNEL_CONTEXT_PAYLOAD_FILENAME = 'context.json';
10
+ const TASK_ASSIGNMENT_STATE_ROOT_DIRNAME = 'task-assignment-state';
11
+ const TASK_ASSIGNMENT_STATE_FILENAME = 'context.json';
10
12
  const CHANNEL_GATEWAY_CREDENTIAL_FILENAME = '.borgee-agent-gateway.json';
11
13
  const CLAUDE_PROJECTED_BRIEF_FILENAME = 'CLAUDE.md';
12
14
  const DEFAULT_HOME_BORGEE_DIRNAME = '.borgee';
13
15
  const CHANNEL_WORKSPACE_COLLECTION_DIRNAME = 'channels';
14
16
  const CHANNEL_WORKSPACE_DIRNAME = 'workspace';
15
17
  const TASK_ISOLATED_WORKSPACE_COLLECTION_DIRNAME = 'tasks';
16
- const TASK_WORKSPACE_MODE_PROPERTY_KEY = 'workspace.mode';
17
- const TASK_WORKSPACE_MODE_ISOLATED = 'isolated';
18
+ const TASK_THREAD_SCRATCH_WORKSPACE_DIRNAME = 'scratch';
19
+ const TASK_EXECUTION_LOCAL_DIRECTORY_PROPERTY_KEY = 'execution.local_directory';
18
20
  const DEFAULT_TASK_WORKSPACE_RESOLUTION_TIMEOUT_MS = 1_500;
19
21
  export class ChannelContextPreparationError extends Error {
20
22
  partialContext;
@@ -43,8 +45,11 @@ const DEFAULT_FILE_SYSTEM = {
43
45
  async rename(oldPath, newPath) {
44
46
  await fs.rename(oldPath, newPath);
45
47
  },
46
- async access(path) {
47
- await fs.access(path);
48
+ async access(path, mode) {
49
+ await fs.access(path, mode);
50
+ },
51
+ async stat(path) {
52
+ return await fs.stat(path);
48
53
  },
49
54
  };
50
55
  const BORGEE_AGENT_SKILL_DIRNAME = 'borgee-agent';
@@ -231,9 +236,9 @@ function buildTaskAssignmentContextForTurn(options, existingContext) {
231
236
  }
232
237
  return existingContext;
233
238
  }
234
- async function readExistingTaskAssignmentContext(payloadPath, fileSystem) {
239
+ async function readExistingTaskAssignmentContext(statePath, fileSystem) {
235
240
  try {
236
- const raw = await fileSystem.readFile(payloadPath, { encoding: 'utf8' });
241
+ const raw = await fileSystem.readFile(statePath, { encoding: 'utf8' });
237
242
  const payload = JSON.parse(raw);
238
243
  return sanitizeTaskAssignmentContext(payload.taskAssignmentContext);
239
244
  }
@@ -241,125 +246,124 @@ async function readExistingTaskAssignmentContext(payloadPath, fileSystem) {
241
246
  return undefined;
242
247
  }
243
248
  }
244
- function sanitizeResolvedWorkspaceContext(value) {
245
- if (!isRecord(value) || typeof value.rootPath !== 'string' || !isAbsolute(value.rootPath)) {
246
- return undefined;
249
+ function buildChannelResolvedWorkspace(workspaceCollectionRootDir, channelId) {
250
+ return {
251
+ authority: 'channel',
252
+ owningChannelId: channelId,
253
+ rootPath: resolveChannelWorkspaceDirectory(workspaceCollectionRootDir, channelId),
254
+ };
255
+ }
256
+ function buildTaskThreadScratchResolvedWorkspace(workspaceCollectionRootDir, channelId, reason = 'missing-or-invalid-execution-target') {
257
+ return {
258
+ authority: 'task-thread-scratch',
259
+ rootPath: resolveTaskThreadScratchWorkspaceDirectory(workspaceCollectionRootDir, channelId),
260
+ reason,
261
+ };
262
+ }
263
+ async function isCrossAgentTaskCreatedByAnotherAgent(params) {
264
+ const selfAgentId = params.resolveStableAgentId?.()?.trim();
265
+ if (!selfAgentId) {
266
+ return true;
247
267
  }
248
- if (value.authority === 'channel'
249
- && typeof value.owningChannelId === 'string'
250
- && value.owningChannelId.length > 0) {
251
- return {
252
- authority: 'channel',
253
- owningChannelId: value.owningChannelId,
254
- rootPath: value.rootPath,
255
- };
268
+ if (params.task.createdBy === selfAgentId) {
269
+ return false;
256
270
  }
257
- if (value.authority === 'task'
258
- && typeof value.taskId === 'string'
259
- && isUsableTaskId(value.taskId.trim())) {
260
- return {
261
- authority: 'task',
262
- taskId: value.taskId.trim(),
263
- rootPath: value.rootPath,
264
- };
271
+ if (!params.taskReader) {
272
+ return true;
265
273
  }
266
- return undefined;
274
+ const users = await params.taskReader.listUsers();
275
+ const creator = users.find((user) => user.id === params.task.createdBy);
276
+ if (!creator) {
277
+ return true;
278
+ }
279
+ return creator.kind === 'agent';
267
280
  }
268
- async function readExistingResolvedWorkspaceContext(payloadPath, fileSystem) {
281
+ async function withDeadline(promise, timeoutMs, timeoutMessage) {
282
+ if (timeoutMs <= 0) {
283
+ return await promise;
284
+ }
285
+ let timer;
269
286
  try {
270
- const raw = await fileSystem.readFile(payloadPath, { encoding: 'utf8' });
271
- const payload = JSON.parse(raw);
272
- return sanitizeResolvedWorkspaceContext(payload.resolvedWorkspace);
287
+ return await Promise.race([
288
+ promise,
289
+ new Promise((_, reject) => {
290
+ timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
291
+ }),
292
+ ]);
273
293
  }
274
- catch {
275
- return undefined;
294
+ finally {
295
+ if (timer) {
296
+ clearTimeout(timer);
297
+ }
276
298
  }
277
299
  }
278
- function buildChannelResolvedWorkspace(workspaceCollectionRootDir, channelId) {
279
- return {
280
- authority: 'channel',
281
- owningChannelId: channelId,
282
- rootPath: resolveChannelWorkspaceDirectory(workspaceCollectionRootDir, channelId),
283
- };
284
- }
285
- function buildStickyChannelResolvedWorkspace(workspaceCollectionRootDir, resolvedWorkspace) {
286
- if (resolvedWorkspace?.authority !== 'channel') {
287
- return undefined;
300
+ async function isExistingDirectory(fileSystem, path) {
301
+ if (!isAbsolute(path)) {
302
+ return false;
288
303
  }
289
- const expectedRootPath = resolveChannelWorkspaceDirectory(workspaceCollectionRootDir, resolvedWorkspace.owningChannelId);
290
- if (resolvedWorkspace.rootPath !== expectedRootPath) {
291
- return undefined;
304
+ try {
305
+ await fileSystem.access(path, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
306
+ if (typeof fileSystem.stat !== 'function') {
307
+ return true;
308
+ }
309
+ return (await fileSystem.stat(path)).isDirectory();
310
+ }
311
+ catch {
312
+ return false;
292
313
  }
293
- return {
294
- authority: 'channel',
295
- owningChannelId: resolvedWorkspace.owningChannelId,
296
- rootPath: expectedRootPath,
297
- };
298
314
  }
299
315
  async function resolveTaskThreadWorkspaceContext(params) {
300
316
  const channelWorkspace = buildChannelResolvedWorkspace(params.workspaceCollectionRootDir, params.channelId);
317
+ const discussionOnlyWorkspace = buildTaskThreadScratchResolvedWorkspace(params.workspaceCollectionRootDir, params.channelId);
301
318
  const taskAssignmentContext = params.taskAssignmentContext;
302
319
  const currentTaskId = taskAssignmentContext?.currentTaskId?.trim();
303
- if (taskAssignmentContext?.active !== true
304
- || !currentTaskId
305
- || !params.taskReader) {
320
+ if (taskAssignmentContext?.active !== true) {
306
321
  return { taskAssignmentContext, resolvedWorkspace: channelWorkspace };
307
322
  }
308
- const stickyTaskWorkspace = params.existingResolvedWorkspace?.authority === 'task'
309
- && params.existingResolvedWorkspace.taskId === currentTaskId
310
- ? {
311
- authority: 'task',
312
- taskId: currentTaskId,
313
- rootPath: resolveTaskWorkspaceDirectory(params.workspaceCollectionRootDir, params.channelId, currentTaskId),
314
- }
315
- : undefined;
316
- const stickyChannelWorkspace = buildStickyChannelResolvedWorkspace(params.workspaceCollectionRootDir, params.existingResolvedWorkspace);
323
+ if (!currentTaskId || !params.taskReader) {
324
+ return { taskAssignmentContext, resolvedWorkspace: discussionOnlyWorkspace };
325
+ }
317
326
  try {
318
327
  const task = await withDeadline(findTaskForThread(params.taskReader, params.channelId, currentTaskId), params.taskResolutionTimeoutMs, `workspace resolution timed out for task ${currentTaskId}`);
319
328
  if (!task) {
320
329
  return {
321
330
  taskAssignmentContext,
322
- resolvedWorkspace: channelWorkspace,
331
+ resolvedWorkspace: discussionOnlyWorkspace,
323
332
  };
324
333
  }
325
- async function withDeadline(promise, timeoutMs, timeoutMessage) {
326
- if (timeoutMs <= 0) {
327
- return await promise;
328
- }
329
- let timer;
330
- try {
331
- return await Promise.race([
332
- promise,
333
- new Promise((_, reject) => {
334
- timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
335
- }),
336
- ]);
337
- }
338
- finally {
339
- if (timer) {
340
- clearTimeout(timer);
341
- }
342
- }
334
+ const executionLocalDirectory = task.properties[TASK_EXECUTION_LOCAL_DIRECTORY_PROPERTY_KEY];
335
+ if (typeof executionLocalDirectory !== 'string'
336
+ || executionLocalDirectory.length === 0
337
+ || !(await isExistingDirectory(params.fileSystem, executionLocalDirectory))) {
338
+ return {
339
+ taskAssignmentContext,
340
+ resolvedWorkspace: discussionOnlyWorkspace,
341
+ };
343
342
  }
344
- if (task.properties[TASK_WORKSPACE_MODE_PROPERTY_KEY] !== TASK_WORKSPACE_MODE_ISOLATED) {
343
+ if (!params.allowCrossAgentIndependentWorkspaceHandoff
344
+ && await isCrossAgentTaskCreatedByAnotherAgent({
345
+ task,
346
+ taskReader: params.taskReader,
347
+ resolveStableAgentId: params.resolveStableAgentId,
348
+ })) {
345
349
  return {
346
350
  taskAssignmentContext,
347
- resolvedWorkspace: buildChannelResolvedWorkspace(params.workspaceCollectionRootDir, task.channelId),
351
+ resolvedWorkspace: buildTaskThreadScratchResolvedWorkspace(params.workspaceCollectionRootDir, params.channelId, 'cross-agent-independent-workspace-disabled'),
348
352
  };
349
353
  }
350
354
  return {
351
355
  taskAssignmentContext,
352
356
  resolvedWorkspace: {
353
- authority: 'task',
357
+ authority: 'task-execution-target',
354
358
  taskId: task.id,
355
- rootPath: resolveTaskWorkspaceDirectory(params.workspaceCollectionRootDir, params.channelId, task.id),
359
+ rootPath: executionLocalDirectory,
356
360
  },
357
361
  };
358
362
  }
359
363
  catch (error) {
360
364
  return {
361
365
  taskAssignmentContext,
362
- resolvedWorkspace: stickyTaskWorkspace ?? stickyChannelWorkspace ?? channelWorkspace,
366
+ resolvedWorkspace: discussionOnlyWorkspace,
363
367
  };
364
368
  }
365
369
  }
@@ -373,6 +377,12 @@ export function resolveChannelContextDirectory(stateRootDir, channelId) {
373
377
  export function resolveChannelContextPayloadPath(stateRootDir, channelId) {
374
378
  return join(resolveChannelContextDirectory(stateRootDir, channelId), CHANNEL_CONTEXT_PAYLOAD_FILENAME);
375
379
  }
380
+ export function resolveTaskAssignmentStateDirectory(stateRootDir, channelId) {
381
+ return resolve(stateRootDir, TASK_ASSIGNMENT_STATE_ROOT_DIRNAME, encodeChannelPathSegment(channelId));
382
+ }
383
+ export function resolveTaskAssignmentStatePath(stateRootDir, channelId) {
384
+ return join(resolveTaskAssignmentStateDirectory(stateRootDir, channelId), TASK_ASSIGNMENT_STATE_FILENAME);
385
+ }
376
386
  export function resolveTaskWorkspaceRootDirectory(startupWorkspaceRootDir) {
377
387
  return join(resolve(startupWorkspaceRootDir), TASK_ISOLATED_WORKSPACE_COLLECTION_DIRNAME);
378
388
  }
@@ -397,6 +407,9 @@ export function resolveTaskIsolatedWorkspaceDirectory(workspaceCollectionRootDir
397
407
  return join(resolveTaskWorkspaceRootDirectory(resolveChannelWorkspaceRootDirectory(workspaceCollectionRootDir, channelId)), encodeChannelPathSegment(taskId), CHANNEL_WORKSPACE_DIRNAME);
398
408
  }
399
409
  export const resolveTaskWorkspaceDirectory = resolveTaskIsolatedWorkspaceDirectory;
410
+ export function resolveTaskThreadScratchWorkspaceDirectory(workspaceCollectionRootDir, channelId) {
411
+ return join(resolveChannelWorkspaceRootDirectory(workspaceCollectionRootDir, channelId), TASK_THREAD_SCRATCH_WORKSPACE_DIRNAME, CHANNEL_WORKSPACE_DIRNAME);
412
+ }
400
413
  export function resolveChannelGatewayCredentialPath(stateRootDir, channelId) {
401
414
  return join(resolveChannelContextDirectory(stateRootDir, channelId), CHANNEL_GATEWAY_CREDENTIAL_FILENAME);
402
415
  }
@@ -499,6 +512,8 @@ export class FileChannelContextStore {
499
512
  workspaceCollectionRootDir;
500
513
  taskReader;
501
514
  taskResolutionTimeoutMs;
515
+ allowCrossAgentIndependentWorkspaceHandoff;
516
+ resolveStableAgentId;
502
517
  constructor(stateRootDir, options = {}) {
503
518
  this.stateRootDir = stateRootDir;
504
519
  this.fileSystem = options.fileSystem ?? DEFAULT_FILE_SYSTEM;
@@ -508,6 +523,9 @@ export class FileChannelContextStore {
508
523
  this.workspaceCollectionRootDir = resolveManagedWorkspaceCollectionRoot(options.workspaceCollectionRootDir, options.env, options.resolvedHomeDir);
509
524
  this.taskReader = options.taskReader;
510
525
  this.taskResolutionTimeoutMs = options.taskResolutionTimeoutMs ?? DEFAULT_TASK_WORKSPACE_RESOLUTION_TIMEOUT_MS;
526
+ this.allowCrossAgentIndependentWorkspaceHandoff
527
+ = options.allowCrossAgentIndependentWorkspaceHandoff ?? true;
528
+ this.resolveStableAgentId = options.resolveStableAgentId;
511
529
  }
512
530
  async prepare(inputOrChannelId, options) {
513
531
  const input = typeof inputOrChannelId === 'string'
@@ -519,10 +537,10 @@ export class FileChannelContextStore {
519
537
  const auxiliaryCollaborationEnabled = collaborationCommandsEnabled && collaborationRoutesAllowedForTurnMode;
520
538
  const directoryPath = resolveChannelContextDirectory(this.stateRootDir, input.channelId);
521
539
  const payloadPath = resolveChannelContextPayloadPath(this.stateRootDir, input.channelId);
540
+ const taskAssignmentStatePath = resolveTaskAssignmentStatePath(this.stateRootDir, input.channelId);
522
541
  const claudeProjectedBriefPath = resolveClaudeProjectedBriefPathFromPayloadPath(payloadPath);
523
542
  const gatewayCredentialPath = resolveGatewayCredentialPathFromPayloadPath(payloadPath);
524
- const existingTaskAssignmentContext = await readExistingTaskAssignmentContext(payloadPath, this.fileSystem);
525
- const existingResolvedWorkspace = await readExistingResolvedWorkspaceContext(payloadPath, this.fileSystem);
543
+ const existingTaskAssignmentContext = await readExistingTaskAssignmentContext(taskAssignmentStatePath, this.fileSystem);
526
544
  const skillRuntime = this.skillRuntimeEnabled
527
545
  ? await this.resolveSkillRuntimeBestEffort()
528
546
  : undefined;
@@ -548,34 +566,40 @@ export class FileChannelContextStore {
548
566
  : {}),
549
567
  }
550
568
  : undefined;
551
- const taskAssignmentContext = buildTaskAssignmentContextForTurn({
552
- incomingMessageType: input.incomingMessageType,
553
- incomingContent: input.incomingContent,
554
- }, existingTaskAssignmentContext);
569
+ const taskAssignmentContext = input.taskAssignmentContextOverride
570
+ ?? buildTaskAssignmentContextForTurn({
571
+ incomingMessageType: input.incomingMessageType,
572
+ incomingContent: input.incomingContent,
573
+ }, existingTaskAssignmentContext);
555
574
  const workspaceResolution = await resolveTaskThreadWorkspaceContext({
556
575
  channelId: input.channelId,
557
576
  workspaceCollectionRootDir: this.workspaceCollectionRootDir,
558
577
  taskAssignmentContext,
559
- existingResolvedWorkspace,
560
578
  taskReader: this.taskReader,
561
579
  taskResolutionTimeoutMs: this.taskResolutionTimeoutMs,
580
+ allowCrossAgentIndependentWorkspaceHandoff: this.allowCrossAgentIndependentWorkspaceHandoff,
581
+ resolveStableAgentId: this.resolveStableAgentId,
582
+ fileSystem: this.fileSystem,
562
583
  });
563
584
  const resolvedWorkspace = workspaceResolution.resolvedWorkspace;
585
+ const runtimeTaskAssignmentContext = workspaceResolution.taskAssignmentContext ?? taskAssignmentContext;
586
+ const persistedTaskAssignmentContext = input.taskAssignmentContextOverridePersistence === 'ephemeral'
587
+ ? existingTaskAssignmentContext
588
+ : runtimeTaskAssignmentContext;
564
589
  const runtimeSurface = buildRuntimeSurfaceForTurn({
565
590
  localhostGateway,
566
591
  collaboration: input.collaboration,
567
- taskAssignmentContext: workspaceResolution.taskAssignmentContext ?? taskAssignmentContext,
592
+ taskAssignmentContext: runtimeTaskAssignmentContext,
568
593
  });
569
- const payload = buildChannelContextPayload(input.channelId, runtimeSurface, input.collaborationOutcome, input.attentionSnapshot, input.compactionSnapshot, input.taskThreadCollaborationContract, input.collaborationCapabilities, input.missedCollaborationDiagnostic, skillRuntime, localhostGateway, workspaceResolution.taskAssignmentContext || resolvedWorkspace
594
+ const payload = buildChannelContextPayload(input.channelId, runtimeSurface, input.collaborationOutcome, input.attentionSnapshot, input.compactionSnapshot, input.taskThreadCollaborationContract, input.collaborationCapabilities, input.missedCollaborationDiagnostic, skillRuntime, localhostGateway, persistedTaskAssignmentContext || resolvedWorkspace
570
595
  ? {
571
- ...(workspaceResolution.taskAssignmentContext
572
- ? { taskAssignmentContext: workspaceResolution.taskAssignmentContext }
596
+ ...(persistedTaskAssignmentContext
597
+ ? { taskAssignmentContext: persistedTaskAssignmentContext }
573
598
  : {}),
574
599
  resolvedWorkspace,
575
600
  }
576
601
  : undefined);
577
602
  const gatewayCredential = buildBorgeeAgentGatewayCredential(input.channelId, issuedLocalhostGateway);
578
- let resolvedWorkspaceMaterialized = false;
579
603
  let claudeProjectedBriefWritten = false;
580
604
  let payloadWritten = false;
581
605
  let gatewayCredentialWritten = false;
@@ -601,14 +625,24 @@ export class FileChannelContextStore {
601
625
  }
602
626
  try {
603
627
  await this.fileSystem.mkdir(directoryPath, { recursive: true, mode: 0o700 });
604
- await this.fileSystem.mkdir(resolvedWorkspace.rootPath, { recursive: true, mode: 0o700 });
605
- resolvedWorkspaceMaterialized = true;
628
+ await this.fileSystem.mkdir(resolveTaskAssignmentStateDirectory(this.stateRootDir, input.channelId), { recursive: true, mode: 0o700 });
629
+ if (resolvedWorkspace.authority !== 'task-execution-target') {
630
+ await this.fileSystem.mkdir(resolvedWorkspace.rootPath, { recursive: true, mode: 0o700 });
631
+ }
606
632
  await pruneGatewayCredentialSidecars(this.fileSystem, directoryPath, gatewayCredential ? CHANNEL_GATEWAY_CREDENTIAL_FILENAME : undefined);
607
633
  if (!claudeProjectedBriefContent) {
608
634
  await unlinkIfPresent(this.fileSystem, claudeProjectedBriefPath);
609
635
  }
610
636
  await this.fileSystem.writeFile(payloadPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
611
637
  payloadWritten = true;
638
+ if (persistedTaskAssignmentContext) {
639
+ const stagingTaskAssignmentStatePath = join(resolveTaskAssignmentStateDirectory(this.stateRootDir, input.channelId), `.task-assignment-state.${randomUUID()}.json`);
640
+ await this.fileSystem.writeFile(stagingTaskAssignmentStatePath, `${JSON.stringify({ taskAssignmentContext: persistedTaskAssignmentContext }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
641
+ await this.fileSystem.rename(stagingTaskAssignmentStatePath, taskAssignmentStatePath);
642
+ }
643
+ else {
644
+ await unlinkIfPresent(this.fileSystem, taskAssignmentStatePath);
645
+ }
612
646
  if (gatewayCredential) {
613
647
  // Replaced atomically: a CLI invocation left over from an earlier turn must read either
614
648
  // the old credential or the new one, never a missing or half-written file, because the
@@ -639,14 +673,23 @@ export class FileChannelContextStore {
639
673
  }
640
674
  const partialContext = {
641
675
  directoryPath,
642
- payload,
676
+ payload: {
677
+ ...payload,
678
+ ...(resolvedWorkspace.authority === 'task-execution-target' && runtimeTaskAssignmentContext?.active === true
679
+ ? {
680
+ resolvedWorkspace: buildTaskThreadScratchResolvedWorkspace(this.workspaceCollectionRootDir, input.channelId),
681
+ }
682
+ : {}),
683
+ },
643
684
  claudeProjectedBriefPath: claudeProjectedBriefWritten ? claudeProjectedBriefPath : undefined,
644
685
  claudeProjectedBriefHash: claudeProjectedBriefWritten ? preparedContext.claudeProjectedBriefHash : undefined,
645
686
  payloadPath: payloadWritten ? payloadPath : undefined,
646
687
  gatewayCredentialPath: gatewayCredentialWritten ? gatewayCredentialPath : undefined,
647
688
  skillRuntime: payloadWritten ? skillRuntime : undefined,
648
689
  localhostGateway: payloadWritten ? localhostGateway : undefined,
649
- resolvedWorkspace: resolvedWorkspaceMaterialized ? resolvedWorkspace : undefined,
690
+ resolvedWorkspace: resolvedWorkspace.authority === 'task-execution-target' && runtimeTaskAssignmentContext?.active === true
691
+ ? buildTaskThreadScratchResolvedWorkspace(this.workspaceCollectionRootDir, input.channelId)
692
+ : resolvedWorkspace,
650
693
  };
651
694
  throw new ChannelContextPreparationError('failed to persist channel context payload', partialContext, { cause: error });
652
695
  }
@@ -39,7 +39,9 @@ function buildLocalhostGatewayPromptLines(context) {
39
39
  if (!context.skillRuntime) {
40
40
  return lines;
41
41
  }
42
- lines.push(context.taskAssignmentContext?.active === true
42
+ const taskThreadActive = context.taskAssignmentContext?.active === true
43
+ || context.runtimeSurface?.task.currentThread === 'task-assignment-thread';
44
+ lines.push(taskThreadActive
43
45
  ? 'This turn runs inside a task assignment thread.'
44
46
  : 'This turn runs in a parent channel, not a task thread.');
45
47
  if (context.runtimeSurface?.task.currentThread === 'parent-channel'
@@ -61,7 +63,7 @@ function buildLocalhostGatewayPromptLines(context) {
61
63
  const collaborationLive = context.localhostGateway.collaboration?.enabled === true
62
64
  && (context.collaborationTurnMode ?? 'ordinary') === 'ordinary';
63
65
  if (!collaborationLive) {
64
- lines.push('Auxiliary collaboration commands (users, draft, send, mention) are not available this turn.');
66
+ lines.push('Auxiliary collaboration commands (draft, send, mention) are not available this turn.');
65
67
  }
66
68
  else if (context.collaborationTurnExecutionId) {
67
69
  lines.push(`Pass --turn-execution-id ${context.collaborationTurnExecutionId} on send, mention and draft.`);
@@ -77,13 +79,15 @@ function buildLocalhostGatewayPromptLines(context) {
77
79
  function buildTaskAssignmentPromptLines(params) {
78
80
  const isTaskAssignmentTurn = params.incomingMessageType?.trim() === 'task_assignment';
79
81
  const taskAssignmentContext = params.promptContext?.taskAssignmentContext;
80
- if (!isTaskAssignmentTurn && taskAssignmentContext?.active !== true) {
82
+ const taskAssignmentThreadActive = taskAssignmentContext?.active === true
83
+ || params.promptContext?.runtimeSurface?.task.currentThread === 'task-assignment-thread';
84
+ if (!isTaskAssignmentTurn && !taskAssignmentThreadActive) {
81
85
  return [];
82
86
  }
83
87
  if (params.provider === 'claude'
84
88
  && params.forceInlineForClaude !== true
85
89
  && !isTaskAssignmentTurn
86
- && taskAssignmentContext?.active === true) {
90
+ && taskAssignmentThreadActive) {
87
91
  return [];
88
92
  }
89
93
  const lines = isTaskAssignmentTurn
@@ -180,7 +184,7 @@ function buildClaudeProjectedBriefTurnDeltaLines(context) {
180
184
  const collaborationLive = context.localhostGateway.collaboration?.enabled === true
181
185
  && (context.collaborationTurnMode ?? 'ordinary') === 'ordinary';
182
186
  if (!collaborationLive) {
183
- lines.push('Auxiliary collaboration commands (users, draft, send, mention) are not available this turn.');
187
+ lines.push('Auxiliary collaboration commands (draft, send, mention) are not available this turn.');
184
188
  }
185
189
  else if (context.collaborationTurnExecutionId) {
186
190
  lines.push(`Pass --turn-execution-id ${context.collaborationTurnExecutionId} on send, mention and draft.`);
@@ -291,8 +295,8 @@ function buildTurnControlAttentionLines(context) {
291
295
  const attentionLines = context?.attentionSnapshot
292
296
  ? [
293
297
  'Attention controls are projection-only host hints. They do not bypass server-side mention policy or widen message delivery.',
294
- `To follow ordinary delivered human messages in this channel, add "attentionUpdate":"follow-channel".`,
295
- `To stop follow-based human wake for this channel, add "attentionUpdate":"unfollow-channel".`,
298
+ `To pin a durable follow hint for ordinary delivered human messages in this channel, add "attentionUpdate":"follow-channel".`,
299
+ `To clear an explicit follow hint and return to server-policy wake for this channel, add "attentionUpdate":"unfollow-channel".`,
296
300
  `To mute ordinary delivered human wake while keeping explicit mentions and task assignments untouched, add "attentionUpdate":"mute-channel".`,
297
301
  `To set a durable channel-level attention hint, add "attentionUpdate":"claim-channel".`,
298
302
  ...(taskThreadAttentionAvailable
@@ -1,2 +1,3 @@
1
1
  import type { PreparedPromptContext, ProviderKind } from '../types.js';
2
2
  export declare function buildResolvedWorkspaceGuidanceLines(context: PreparedPromptContext | undefined, provider?: ProviderKind): string[];
3
+ export declare function isDiscussionOnlyResolvedWorkspace(context: PreparedPromptContext | undefined): boolean;
@@ -3,8 +3,11 @@ function buildWorkspaceRootLine(context) {
3
3
  if (!resolvedWorkspace) {
4
4
  return undefined;
5
5
  }
6
- if (resolvedWorkspace.authority === 'task') {
7
- return `Writable task-isolated workspace root for this task thread: ${resolvedWorkspace.rootPath}.`;
6
+ if (resolvedWorkspace.authority === 'task-execution-target') {
7
+ return `Explicit execution local directory for this task thread: ${resolvedWorkspace.rootPath}.`;
8
+ }
9
+ if (resolvedWorkspace.authority === 'task-thread-scratch') {
10
+ return `Discussion-only scratch workspace for this task thread: ${resolvedWorkspace.rootPath}.`;
8
11
  }
9
12
  if (context.taskAssignmentContext?.active === true) {
10
13
  return `Writable workspace root inherited from this task thread's channel: ${resolvedWorkspace.rootPath}.`;
@@ -15,8 +18,13 @@ function buildWorkspaceModeLine(context) {
15
18
  if (context.taskAssignmentContext?.active !== true || !context.resolvedWorkspace) {
16
19
  return undefined;
17
20
  }
18
- if (context.resolvedWorkspace.authority === 'task') {
19
- return 'This task thread runs in a task-isolated workspace instead of inheriting the channel workspace.';
21
+ if (context.resolvedWorkspace.authority === 'task-execution-target') {
22
+ return 'This task thread has a valid explicit execution.local_directory target, so execution is bound to that exact existing local directory.';
23
+ }
24
+ if (context.resolvedWorkspace.authority === 'task-thread-scratch') {
25
+ return context.resolvedWorkspace.reason === 'cross-agent-independent-workspace-disabled'
26
+ ? 'This task thread has an explicit execution.local_directory target, but this agents-host currently disables cross-agent independent-workspace handoff, so it is discussion-only.'
27
+ : 'This task thread has no valid explicit execution.local_directory target, so it is discussion-only.';
20
28
  }
21
29
  return 'This task thread inherits the channel workspace rather than using a task-isolated workspace.';
22
30
  }
@@ -24,8 +32,11 @@ function buildWorkspaceLocalityLine(context) {
24
32
  if (!context.resolvedWorkspace) {
25
33
  return undefined;
26
34
  }
27
- if (context.resolvedWorkspace.authority === 'task') {
28
- return 'This writable workspace is local to agents-host for this task thread. It does not imply that the target repository has already been checked out there.';
35
+ if (context.resolvedWorkspace.authority === 'task-execution-target') {
36
+ return 'This is a human-selected existing local directory. The host validates it before use and never creates it.';
37
+ }
38
+ if (context.resolvedWorkspace.authority === 'task-thread-scratch') {
39
+ return 'This scratch workspace is host-managed only for discussion turns. It is not a bound project workspace and does not imply that any target repository is checked out there.';
29
40
  }
30
41
  if (context.taskAssignmentContext?.active === true) {
31
42
  return 'This writable workspace is local to agents-host for this task thread because the task inherits its channel workspace. It does not imply that the target repository has already been checked out there.';
@@ -36,14 +47,34 @@ function buildCopilotCwdLine(context) {
36
47
  if (!context.resolvedWorkspace) {
37
48
  return undefined;
38
49
  }
39
- if (context.resolvedWorkspace.authority === 'task') {
40
- return 'GitHub Copilot runs this turn with that task-isolated workspace as its real cwd.';
50
+ if (context.resolvedWorkspace.authority === 'task-execution-target') {
51
+ return 'GitHub Copilot runs this turn with that exact local directory as its real cwd.';
52
+ }
53
+ if (context.resolvedWorkspace.authority === 'task-thread-scratch') {
54
+ return 'GitHub Copilot runs this turn with that host-managed scratch workspace as its real cwd.';
41
55
  }
42
56
  if (context.taskAssignmentContext?.active === true) {
43
57
  return 'GitHub Copilot runs this turn with that inherited channel workspace as its real cwd.';
44
58
  }
45
59
  return 'GitHub Copilot runs this turn with that workspace as its real cwd.';
46
60
  }
61
+ function buildDiscussionOnlyPermissionLine(context) {
62
+ if (context.resolvedWorkspace?.authority !== 'task-thread-scratch') {
63
+ return undefined;
64
+ }
65
+ return context.resolvedWorkspace.reason === 'cross-agent-independent-workspace-disabled'
66
+ ? 'Filesystem and shell tool permissions stay denied in this discussion-only task thread while this agents-host keeps cross-agent independent-workspace handoff disabled.'
67
+ : 'Filesystem and shell tool permissions stay denied in this discussion-only task thread until a human sets a valid execution.local_directory target.';
68
+ }
69
+ function buildProjectEntryPrecheckLine(context) {
70
+ if (context.taskAssignmentContext?.active !== true) {
71
+ return undefined;
72
+ }
73
+ if (context.resolvedWorkspace?.authority === 'task-execution-target') {
74
+ return 'Precheck before switching this task thread into your own local workspace or project directory: the task\'s parent channel must have exactly one human user. Cross-agent independent-workspace handoff is also governed by this agents-host\'s local policy switch.';
75
+ }
76
+ return 'Before a human switches this task thread into their own local workspace or project directory, verify that the task\'s parent channel has exactly one human user; otherwise keep the thread discussion-only. Cross-agent independent-workspace handoff is also governed by this agents-host\'s local policy switch.';
77
+ }
47
78
  export function buildResolvedWorkspaceGuidanceLines(context, provider) {
48
79
  if (!context?.resolvedWorkspace) {
49
80
  return [];
@@ -60,5 +91,16 @@ export function buildResolvedWorkspaceGuidanceLines(context, provider) {
60
91
  if (localityLine) {
61
92
  lines.push(localityLine);
62
93
  }
94
+ const permissionLine = buildDiscussionOnlyPermissionLine(context);
95
+ if (permissionLine) {
96
+ lines.push(permissionLine);
97
+ }
98
+ const precheckLine = buildProjectEntryPrecheckLine(context);
99
+ if (precheckLine) {
100
+ lines.push(precheckLine);
101
+ }
63
102
  return lines;
64
103
  }
104
+ export function isDiscussionOnlyResolvedWorkspace(context) {
105
+ return context?.resolvedWorkspace?.authority === 'task-thread-scratch';
106
+ }
@@ -127,6 +127,14 @@ export class ProviderTurnPreparer {
127
127
  ...(input.incomingMessageType !== undefined && incomingContent !== undefined
128
128
  ? { incomingContent }
129
129
  : {}),
130
+ ...(input.taskAssignmentContextOverride
131
+ ? {
132
+ taskAssignmentContextOverride: input.taskAssignmentContextOverride,
133
+ ...(input.taskAssignmentContextOverridePersistence
134
+ ? { taskAssignmentContextOverridePersistence: input.taskAssignmentContextOverridePersistence }
135
+ : {}),
136
+ }
137
+ : {}),
130
138
  };
131
139
  channelContext = await this.channelContextStore.prepare(prepareInput);
132
140
  }
@@ -550,11 +550,6 @@ class LoopbackLocalhostGatewayController {
550
550
  return;
551
551
  }
552
552
  case 'users': {
553
- if (!binding.payload?.localhostGateway?.collaboration?.enabled) {
554
- this.sendJson(response, 404, { error: 'not_found' });
555
- this.recordAudit('not-found', 404, decision.path, request.method ?? 'GET', decision.binding);
556
- return;
557
- }
558
553
  this.sendJson(response, 200, {
559
554
  users: await this.controlPlane.listUsers(),
560
555
  });
@@ -910,6 +905,8 @@ function mapGatewayControlPlaneError(error, fallbackReason) {
910
905
  // is broken, and it would retry the same bad call instead of correcting it.
911
906
  case 'bpp.task_property_key_unknown':
912
907
  return new GatewayHttpError(400, { error: 'unknown_property_key' }, 'bad-request');
908
+ case 'bpp.task_property_write_forbidden':
909
+ return new GatewayHttpError(403, { error: 'forbidden' }, 'forbidden');
913
910
  case 'bpp.task_property_value_invalid':
914
911
  return new GatewayHttpError(400, { error: 'invalid_property_value' }, 'bad-request');
915
912
  case 'bpp.task_property_value_too_long':
@@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { parseDocument, stringify } from 'yaml';
5
- import { assertProviderCompatibility, assertProviderCommandCompatibility, optionalNonEmptyString, optionalStringArray, parseCopilotSessionTtlMinutesValue, parseProviderIdleShutdownMinutesValue, requireNonEmptyString, resolveProvider, resolveProviderCommandConfig, } from './config.js';
5
+ import { assertProviderCompatibility, assertProviderCommandCompatibility, optionalNonEmptyString, optionalStringArray, parseCopilotSessionTtlMinutesValue, parseBooleanValue, parseProviderIdleShutdownMinutesValue, requireNonEmptyString, resolveProvider, resolveProviderCommandConfig, } from './config.js';
6
6
  import { resolveLocalConfigAgentStateRoot } from './state-paths.js';
7
7
  const SUPPORTED_CONFIG_EXTENSIONS = new Set(['.json', '.yaml', '.yml']);
8
8
  export const DEFAULT_LOCAL_HOST_CONFIG_FILENAME = 'agents-host.yaml';
@@ -127,6 +127,10 @@ function parseProviderCommandOverrides(value, sourceLabel) {
127
127
  if (value.providerIdleShutdownMinutes !== undefined && value.providerIdleShutdownMinutes !== null) {
128
128
  overrides.providerIdleShutdownMinutes = parseProviderIdleShutdownMinutesValue(value.providerIdleShutdownMinutes, sourceLabel);
129
129
  }
130
+ if (value.allowCrossAgentIndependentWorkspaceHandoff !== undefined
131
+ && value.allowCrossAgentIndependentWorkspaceHandoff !== null) {
132
+ overrides.allowCrossAgentIndependentWorkspaceHandoff = parseBooleanValue(value.allowCrossAgentIndependentWorkspaceHandoff, 'allowCrossAgentIndependentWorkspaceHandoff', sourceLabel);
133
+ }
130
134
  return overrides;
131
135
  }
132
136
  function toAgentsDir(hostConfigPath, rawAgentsDir) {
@@ -599,6 +603,9 @@ function renderAgentConfigYaml(agent) {
599
603
  if (agent.providerIdleShutdownMinutes !== undefined) {
600
604
  config.providerIdleShutdownMinutes = agent.providerIdleShutdownMinutes;
601
605
  }
606
+ if (agent.allowCrossAgentIndependentWorkspaceHandoff !== undefined) {
607
+ config.allowCrossAgentIndependentWorkspaceHandoff = agent.allowCrossAgentIndependentWorkspaceHandoff;
608
+ }
602
609
  return stringify(config);
603
610
  }
604
611
  function buildManagedAgentSnapshot(host, stateRootBaseDir, sourcePath, agent) {
@@ -638,6 +645,9 @@ function resolveAgentProviderCommandConfig(hostDefaults, agent) {
638
645
  ...(agent.providerIdleShutdownMinutes !== undefined
639
646
  ? { providerIdleShutdownMinutes: agent.providerIdleShutdownMinutes }
640
647
  : {}),
648
+ ...(agent.allowCrossAgentIndependentWorkspaceHandoff !== undefined
649
+ ? { allowCrossAgentIndependentWorkspaceHandoff: agent.allowCrossAgentIndependentWorkspaceHandoff }
650
+ : {}),
641
651
  });
642
652
  }
643
653
  export async function loadLocalConfigSnapshot(hostConfigPath, deps = {}) {