@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.
@@ -10,6 +10,7 @@ export interface CopilotPermissionPolicyContext {
10
10
  auditSink?: AuthorizationAuditSinkLike;
11
11
  agentId?: string;
12
12
  channelId?: string;
13
+ toolPermissionMode?: 'default' | 'deny-all';
13
14
  }
14
15
  export declare function resolveCopilotPermissionResponse(context: CopilotPermissionPolicyContext): RequestPermissionResponse;
15
16
  export declare function createLegacyMissingAllowOptionError(): Error;
@@ -84,6 +84,24 @@ function appendAuditRecord(context, effectiveDecision, reason, policyDecision) {
84
84
  });
85
85
  }
86
86
  export function resolveCopilotPermissionResponse(context) {
87
+ if (context.toolPermissionMode === 'deny-all') {
88
+ const rejectOption = resolveRejectOption(context.params.options);
89
+ const deniedDecision = rejectOption
90
+ ? {
91
+ outcome: 'selected',
92
+ optionId: rejectOption.optionId,
93
+ optionKind: rejectOption.kind,
94
+ reason: 'discussion-only-task-thread',
95
+ toolKind: normalizeToolKind(context.params.toolCall.kind),
96
+ }
97
+ : {
98
+ outcome: 'cancelled',
99
+ reason: 'discussion-only-task-thread',
100
+ toolKind: normalizeToolKind(context.params.toolCall.kind),
101
+ };
102
+ appendAuditRecord(context, deniedDecision, deniedDecision.reason, deniedDecision);
103
+ return decisionToResponse(deniedDecision);
104
+ }
87
105
  if (!context.gateEnabled) {
88
106
  const legacyOption = legacySelectPermissionOption(context.params.options);
89
107
  return {
@@ -33,7 +33,7 @@ function decodePathSegment(segment) {
33
33
  export function matchChannelRoute(pathname, allowCollaborationRoutes = false) {
34
34
  const resources = allowCollaborationRoutes
35
35
  ? '(bootstrap|me|history|draft|users|messages|tasks|current-task)'
36
- : '(bootstrap|me|history|tasks|current-task)';
36
+ : '(bootstrap|me|history|users|tasks|current-task)';
37
37
  const match = new RegExp(`^/v1/channels/([^/]+)/${resources}$`).exec(pathname);
38
38
  if (!match) {
39
39
  return null;
@@ -112,7 +112,7 @@ function allowedMethodsForRoute(route, collaborationRoutesEnabled) {
112
112
  export function evaluateGatewayAuthorization(input) {
113
113
  const url = new URL(input.request.url ?? '/', input.baseUrl);
114
114
  const hiddenCollaborationRoute = !input.allowCollaborationRoutes
115
- && ['draft', 'users', 'messages'].includes(matchChannelRoute(url.pathname, true)?.resource ?? '');
115
+ && ['draft', 'messages'].includes(matchChannelRoute(url.pathname, true)?.resource ?? '');
116
116
  if (hasVisibleHeader(input.request.headers.origin)) {
117
117
  return {
118
118
  reason: 'browser-origin-not-allowed',
@@ -93,7 +93,7 @@ export declare class ClaudeCliClient {
93
93
  private invalidateSession;
94
94
  private closeSession;
95
95
  private rejectQueuedTurnsAfterSessionTaint;
96
- private findChannelIdBySessionId;
96
+ private findChannelStateBySessionId;
97
97
  private currentSessionStoreAgentId;
98
98
  private ensureSessionStoreLoaded;
99
99
  private readPersistedSessionId;
@@ -1,5 +1,6 @@
1
1
  import { Readable, Writable } from 'node:stream';
2
2
  import { createHash } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
3
4
  import { createRequire } from 'node:module';
4
5
  import { dirname, resolve } from 'node:path';
5
6
  import spawn from 'cross-spawn';
@@ -11,6 +12,7 @@ import { AcpProgressCollector } from '../acp-progress-collector.js';
11
12
  import { readClaudeActivityMetadata } from './activity-metadata.js';
12
13
  import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
13
14
  import { buildClaudeSessionPromptAppend } from '../../context/prompt.js';
15
+ import { isDiscussionOnlyResolvedWorkspace } from '../../context/resolved-workspace.js';
14
16
  import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
15
17
  const SESSION_TAINTED_ERRORS = new WeakSet();
16
18
  const IDLE_BACKEND_STOPPED_MESSAGE = 'Claude ACP backend stopped after idle timeout';
@@ -386,6 +388,22 @@ function hashSessionAppend(systemPromptAppend) {
386
388
  }
387
389
  return createHash('sha256').update(systemPromptAppend).digest('hex');
388
390
  }
391
+ function resolveSkillManualContentHash(promptContext) {
392
+ const skillMarkdownPath = promptContext?.skillRuntime?.skillMarkdownPath;
393
+ if (!skillMarkdownPath) {
394
+ return undefined;
395
+ }
396
+ try {
397
+ return createHash('sha256')
398
+ .update(skillMarkdownPath)
399
+ .update('\0')
400
+ .update(readFileSync(skillMarkdownPath))
401
+ .digest('hex');
402
+ }
403
+ catch {
404
+ return `unreadable:${skillMarkdownPath}`;
405
+ }
406
+ }
389
407
  function resolveSessionVisibilityKey(promptContext, systemPromptAppend, promptStrategy) {
390
408
  const resolvedPromptStrategy = resolveProjectionStrategy(promptStrategy);
391
409
  const explicitPromptStrategy = resolvedPromptStrategy === DEFAULT_PROJECTION_STRATEGY && promptStrategy == null
@@ -395,9 +413,11 @@ function resolveSessionVisibilityKey(promptContext, systemPromptAppend, promptSt
395
413
  : resolvedPromptStrategy;
396
414
  const additionalDirectories = resolveSessionAdditionalDirectories(promptContext);
397
415
  const sessionAppendHash = hashSessionAppend(systemPromptAppend);
416
+ const skillManualContentHash = resolveSkillManualContentHash(promptContext);
398
417
  const projectedBriefHash = promptContext?.claudeProjectedBriefHash;
399
418
  if (!additionalDirectories
400
419
  && !sessionAppendHash
420
+ && !skillManualContentHash
401
421
  && !projectedBriefHash
402
422
  && !promptContext?.gatewayCredentialPath
403
423
  && !explicitPromptStrategy) {
@@ -407,12 +427,14 @@ function resolveSessionVisibilityKey(promptContext, systemPromptAppend, promptSt
407
427
  return JSON.stringify({
408
428
  ...(additionalDirectories ? { additionalDirectories } : {}),
409
429
  sessionAppendHash,
430
+ ...(skillManualContentHash ? { skillManualContentHash } : {}),
410
431
  ...(projectedBriefHash ? { projectedBriefHash } : {}),
411
432
  ...(explicitPromptStrategy ? { promptStrategy: explicitPromptStrategy } : {}),
412
433
  });
413
434
  }
414
435
  return JSON.stringify({
415
436
  ...(additionalDirectories ? { additionalDirectories } : {}),
437
+ ...(skillManualContentHash ? { skillManualContentHash } : {}),
416
438
  ...(promptContext?.gatewayCredentialPath
417
439
  ? { gatewayCredentialPath: promptContext.gatewayCredentialPath }
418
440
  : {}),
@@ -721,6 +743,9 @@ export class ClaudeCliClient {
721
743
  state.activeTurn = turn;
722
744
  try {
723
745
  state.cwd = this.resolveSessionCwd(turn.preparedTurn.promptContext);
746
+ state.toolPermissionMode = isDiscussionOnlyResolvedWorkspace(turn.preparedTurn.promptContext)
747
+ ? 'deny-all'
748
+ : 'default';
724
749
  const sessionPromptAppend = resolveTurnSessionPromptAppend(turn.preparedTurn);
725
750
  state.visibilityKey = resolveSessionVisibilityKey(turn.preparedTurn.promptContext, sessionPromptAppend, turn.preparedTurn.projectionStrategy);
726
751
  await this.ensureStarted();
@@ -999,13 +1024,15 @@ export class ClaudeCliClient {
999
1024
  this.shutdownPromise = this.shutdownBackend(error);
1000
1025
  }
1001
1026
  handlePermissionRequest(params) {
1027
+ const state = this.findChannelStateBySessionId(params.sessionId);
1002
1028
  return resolveCopilotPermissionResponse({
1003
1029
  gateEnabled: true,
1004
1030
  policyMode: 'enforce',
1005
1031
  params,
1006
1032
  logger: this.logger,
1007
1033
  agentId: this.resolveSessionStoreAgentId()?.trim() || undefined,
1008
- channelId: this.findChannelIdBySessionId(params.sessionId),
1034
+ channelId: state?.channelId,
1035
+ toolPermissionMode: state?.toolPermissionMode ?? 'default',
1009
1036
  });
1010
1037
  }
1011
1038
  invalidateSession(channelId, state, session) {
@@ -1057,10 +1084,10 @@ export class ClaudeCliClient {
1057
1084
  }
1058
1085
  state.queue.length = 0;
1059
1086
  }
1060
- findChannelIdBySessionId(sessionId) {
1087
+ findChannelStateBySessionId(sessionId) {
1061
1088
  for (const state of this.channels.values()) {
1062
1089
  if (state.session?.sessionId === sessionId) {
1063
- return state.channelId;
1090
+ return state;
1064
1091
  }
1065
1092
  }
1066
1093
  return undefined;
@@ -108,6 +108,8 @@ export declare class CodexCliClient {
108
108
  private failAll;
109
109
  private closeSession;
110
110
  private clearIdleTimer;
111
+ private handlePermissionRequest;
112
+ private findChannelStateBySessionId;
111
113
  private reconcileIdleChannelState;
112
114
  private ensureSessionStoreLoaded;
113
115
  private readPersistedSessionId;
@@ -8,6 +8,8 @@ import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js
8
8
  import { AcpProgressCollector } from '../acp-progress-collector.js';
9
9
  import { assertCodexProjectDocumentSize, buildCodexProjectDocument } from './project-doc.js';
10
10
  import { isGatewayCredentialSidecarBasename } from '../../context/injection.js';
11
+ import { isDiscussionOnlyResolvedWorkspace } from '../../context/resolved-workspace.js';
12
+ import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
11
13
  import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
12
14
  const SESSION_TAINTED_ERRORS = new WeakSet();
13
15
  const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
@@ -193,26 +195,6 @@ function markSessionTainted(error) {
193
195
  function isSessionTainted(error) {
194
196
  return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
195
197
  }
196
- function createPermissionResponse(params) {
197
- const options = Array.isArray(params.options) ? params.options : [];
198
- const preferredKinds = ['allow_once', 'allow_always', 'reject_once', 'reject_always'];
199
- for (const kind of preferredKinds) {
200
- const match = options.find((option) => option.kind === kind);
201
- if (match) {
202
- return {
203
- outcome: {
204
- outcome: 'selected',
205
- optionId: match.optionId,
206
- },
207
- };
208
- }
209
- }
210
- return {
211
- outcome: {
212
- outcome: 'cancelled',
213
- },
214
- };
215
- }
216
198
  function resolveBundledCodexAdapterPath() {
217
199
  return require.resolve('@agentclientprotocol/codex-acp');
218
200
  }
@@ -423,7 +405,7 @@ export class CodexCliClient {
423
405
  const stream = this.runtime.ndJsonStream(output, input);
424
406
  const app = this.runtime
425
407
  .client({ name: 'borgee-agents-host' })
426
- .onRequest(this.runtime.methods.client.session.requestPermission, ({ params }) => (createPermissionResponse(params)));
408
+ .onRequest(this.runtime.methods.client.session.requestPermission, ({ params }) => (this.handlePermissionRequest(params)));
427
409
  const connection = app.connect(stream);
428
410
  this.connection = connection;
429
411
  void connection.closed.then(() => {
@@ -487,6 +469,9 @@ export class CodexCliClient {
487
469
  state.activeTurn = turn;
488
470
  try {
489
471
  state.cwd = await this.resolveSessionCwd(turn.preparedTurn.promptContext);
472
+ state.toolPermissionMode = isDiscussionOnlyResolvedWorkspace(turn.preparedTurn.promptContext)
473
+ ? 'deny-all'
474
+ : 'default';
490
475
  const projectedPromptContext = await this.refreshProjectedPromptContextBestEffort(turn.channelId, turn.preparedTurn.promptContext);
491
476
  state.additionalDirectories = this.resolveSessionAdditionalDirectories(projectedPromptContext);
492
477
  state.visibilityKey = this.resolveSessionVisibilityKey(projectedPromptContext);
@@ -953,6 +938,25 @@ export class CodexCliClient {
953
938
  clearTimeout(state.idleTimer);
954
939
  state.idleTimer = undefined;
955
940
  }
941
+ handlePermissionRequest(params) {
942
+ const state = this.findChannelStateBySessionId(params.sessionId);
943
+ return resolveCopilotPermissionResponse({
944
+ gateEnabled: true,
945
+ policyMode: 'enforce',
946
+ params,
947
+ logger: this.logger,
948
+ channelId: state?.channelId,
949
+ toolPermissionMode: state?.toolPermissionMode ?? 'default',
950
+ });
951
+ }
952
+ findChannelStateBySessionId(sessionId) {
953
+ for (const state of this.channels.values()) {
954
+ if (state.session?.sessionId === sessionId) {
955
+ return state;
956
+ }
957
+ }
958
+ return undefined;
959
+ }
956
960
  reconcileIdleChannelState(channelId, state) {
957
961
  if (this.channels.get(channelId) !== state) {
958
962
  return;
@@ -103,7 +103,7 @@ export declare class CopilotCliClient {
103
103
  private closeSession;
104
104
  private rejectQueuedTurnsAfterSessionTaint;
105
105
  private clearIdleTimer;
106
- private findChannelIdBySessionId;
106
+ private findChannelStateBySessionId;
107
107
  private reconcileIdleChannelState;
108
108
  private ensureSessionStoreLoaded;
109
109
  private readPersistedSessionId;
@@ -4,6 +4,7 @@ import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientpr
4
4
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
5
5
  import { AcpProgressCollector } from '../acp-progress-collector.js';
6
6
  import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
7
+ import { isDiscussionOnlyResolvedWorkspace } from '../../context/resolved-workspace.js';
7
8
  import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
8
9
  const SESSION_TAINTED_ERRORS = new WeakSet();
9
10
  const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
@@ -407,6 +408,9 @@ export class CopilotCliClient {
407
408
  state.activeTurn = turn;
408
409
  try {
409
410
  state.cwd = await this.resolveSessionCwd(turn.preparedTurn.promptContext);
411
+ state.toolPermissionMode = isDiscussionOnlyResolvedWorkspace(turn.preparedTurn.promptContext)
412
+ ? 'deny-all'
413
+ : 'default';
410
414
  await this.ensureStarted();
411
415
  await this.recycleSessionIfCwdChanged(channelId, state);
412
416
  const session = await this.getOrCreateSession(channelId, state);
@@ -666,6 +670,7 @@ export class CopilotCliClient {
666
670
  return Promise.race([promise, this.fatalPromise]);
667
671
  }
668
672
  handlePermissionRequest(params) {
673
+ const state = this.findChannelStateBySessionId(params.sessionId);
669
674
  return resolveCopilotPermissionResponse({
670
675
  gateEnabled: this.permissionPolicy.gateEnabled ?? false,
671
676
  policyMode: this.permissionPolicy.policyMode ?? 'audit-only',
@@ -673,7 +678,8 @@ export class CopilotCliClient {
673
678
  logger: this.logger,
674
679
  auditSink: this.permissionPolicy.auditSink,
675
680
  agentId: this.resolveSessionStoreAgentId()?.trim() || undefined,
676
- channelId: this.findChannelIdBySessionId(params.sessionId),
681
+ channelId: state?.channelId,
682
+ toolPermissionMode: state?.toolPermissionMode ?? 'default',
677
683
  });
678
684
  }
679
685
  failAll(error) {
@@ -761,10 +767,10 @@ export class CopilotCliClient {
761
767
  clearTimeout(state.idleTimer);
762
768
  state.idleTimer = undefined;
763
769
  }
764
- findChannelIdBySessionId(sessionId) {
770
+ findChannelStateBySessionId(sessionId) {
765
771
  for (const state of this.channels.values()) {
766
772
  if (state.session?.sessionId === sessionId) {
767
- return state.channelId;
773
+ return state;
768
774
  }
769
775
  }
770
776
  return undefined;
@@ -8,7 +8,7 @@ interface CreateProviderOptions {
8
8
  compatibilityGates?: ReadonlySet<string>;
9
9
  localhostGateway?: LocalhostGatewayContextPublisher;
10
10
  authorizationAuditSink?: AuthorizationAuditSinkLike;
11
- controlPlane?: Pick<ChatControlPlane, 'getTask' | 'listChannels' | 'listTasks'>;
11
+ controlPlane?: Pick<ChatControlPlane, 'getTask' | 'listChannels' | 'listTasks' | 'listUsers'>;
12
12
  }
13
13
  export declare function createProvider(config: ProviderRuntimeConfig, debugLogger?: DebugLogger, options?: CreateProviderOptions): ProviderAdapter;
14
14
  export {};
@@ -135,6 +135,8 @@ export function createProvider(config, debugLogger, options = {}) {
135
135
  ? options.localhostGateway
136
136
  : undefined,
137
137
  taskReader: options.controlPlane,
138
+ allowCrossAgentIndependentWorkspaceHandoff: config.allowCrossAgentIndependentWorkspaceHandoff,
139
+ resolveStableAgentId: config.resolveStableAgentId,
138
140
  })
139
141
  : undefined;
140
142
  const resolveInternalProjection = () => resolveInternalProjectionStrategy();
package/dist/types.d.ts CHANGED
@@ -10,6 +10,12 @@ export interface ProviderCommandConfig {
10
10
  copilotSessionTtlMinutes: number;
11
11
  /** Minutes a provider's shared ACP adapter process may stay idle before it is shut down; `0` keeps it resident. */
12
12
  providerIdleShutdownMinutes: number;
13
+ /**
14
+ * Whether this agents-host may let a hosted agent enter an independent
15
+ * execution.local_directory workspace for a task thread created by another
16
+ * agent. Undefined keeps the default allow behavior.
17
+ */
18
+ allowCrossAgentIndependentWorkspaceHandoff?: boolean;
13
19
  }
14
20
  export interface ProviderRuntimeConfig extends ProviderCommandConfig {
15
21
  provider: ProviderKind;
@@ -286,6 +292,8 @@ export interface ProviderInput {
286
292
  taskThreadCollaborationContract?: TaskThreadCollaborationContract;
287
293
  collaborationCapabilities?: CollaborationCapabilityDeclaration;
288
294
  missedCollaborationDiagnostic?: MissedCollaborationDiagnostic;
295
+ taskAssignmentContextOverride?: TaskAssignmentThreadContext;
296
+ taskAssignmentContextOverridePersistence?: 'persist' | 'ephemeral';
289
297
  }
290
298
  export interface ProviderProtocolContext {
291
299
  anchorMessageId: string;
@@ -334,12 +342,17 @@ export interface ChannelResolvedWorkspaceContext {
334
342
  owningChannelId: string;
335
343
  rootPath: string;
336
344
  }
337
- export interface TaskResolvedWorkspaceContext {
338
- authority: 'task';
345
+ export interface TaskThreadScratchWorkspaceContext {
346
+ authority: 'task-thread-scratch';
347
+ rootPath: string;
348
+ reason?: 'missing-or-invalid-execution-target' | 'cross-agent-independent-workspace-disabled';
349
+ }
350
+ export interface TaskExecutionTargetResolvedWorkspaceContext {
351
+ authority: 'task-execution-target';
339
352
  taskId: string;
340
353
  rootPath: string;
341
354
  }
342
- export type ResolvedWorkspaceContext = ChannelResolvedWorkspaceContext | TaskResolvedWorkspaceContext;
355
+ export type ResolvedWorkspaceContext = ChannelResolvedWorkspaceContext | TaskThreadScratchWorkspaceContext | TaskExecutionTargetResolvedWorkspaceContext;
343
356
  export interface RuntimeSurface {
344
357
  schemaVersion: 1;
345
358
  turnMode: ProviderCollaborationTurnMode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@borgee/agents-host",
3
- "version": "0.2.56",
3
+ "version": "0.2.62",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -35,7 +35,7 @@
35
35
  "tsx": "^4.20.5",
36
36
  "typescript": "^5.9.3",
37
37
  "vitest": "^4.1.5",
38
- "@borgee/plugin-sdk": "0.10.0"
38
+ "@borgee/plugin-sdk": "0.12.0"
39
39
  },
40
40
  "scripts": {
41
41
  "predev": "pnpm --filter @borgee/plugin-sdk build",
@@ -64,15 +64,15 @@ Everything below writes that leading interpreter and script path as `borgee-agen
64
64
 
65
65
  On `task get`, `task update`, `task set-property` and `task delete-property` the task id is an optional leading positional. Inside a task thread, omitting it addresses this thread's own task: the host's published binding names it, and where the host published none the gateway scans the parent channel's visible tasks for this thread instead — so pass the id explicitly when that scan cannot land on a single task. `task list` and `task create` are refused inside a task thread; they belong to the parent channel. In a parent channel every one of those ids is required, and omitting it answers `not_found`.
66
66
 
67
- The keys `task set-property` and `task delete-property` accept are a closed set, and a value has a size limit: see `references/task-properties.md`. In this slice, `workspace.mode` is the formal task-workspace switch: `inherit` keeps a task thread on its channel workspace, while `isolated` moves that task thread onto its own task workspace.
67
+ The keys `task set-property` and `task delete-property` accept are a closed set, and a value has a size limit: see `references/task-properties.md`. In this slice, `workspace.mode` still records the host-managed scratch isolation hint, while `execution.local_directory` is a human-owned execution target that hosted agents may read but must not retarget through the generic task-property rail.
68
68
 
69
69
  ## What is available on a turn
70
70
 
71
- `health`, `bootstrap`, `whoami`, `history` and the task commands are live on every turn whose prompt names a gateway credential file.
71
+ `health`, `bootstrap`, `whoami`, `history`, `users` and the task commands are live on every turn whose prompt names a gateway credential file.
72
72
 
73
- `users`, `draft`, `send` and `mention` are live only where collaboration is enabled; the prompt says when it is not, and the gateway answers `not_found` for all four. `draft`, `send` and `mention` additionally need the turn execution id the prompt carries: no command returns that id and the gateway credential file does not hold it.
73
+ `draft`, `send` and `mention` are live only where collaboration is enabled; the prompt says when they are not, and the gateway answers `not_found` for all three. They additionally need the turn execution id the prompt carries: no command returns that id and the gateway credential file does not hold it.
74
74
 
75
- A `not_found` from `draft` therefore has two readings — collaboration is off, or the host holds no draft for this turn yet — so take it as an answer about the draft, not as evidence that `users`, `send` and `mention` have gone.
75
+ A `not_found` from `draft` therefore has two readings — collaboration is off, or the host holds no draft for this turn yet — so take it as an answer about the draft, not as evidence that `users`, `send` and `mention` have all gone.
76
76
 
77
77
  Whether you are in a parent channel or inside a task assignment thread is in the prompt too, and it decides which task grammar above applies.
78
78
 
@@ -22,8 +22,8 @@
22
22
  | 429 `collaboration_target_cooldown` | The same reply target or mention set was addressed moments ago. | Do not repeat it. |
23
23
  | 404 `not_found` on `task get` / `task update` / a property command with no task id | You are not inside a task thread, so there is no current task to resolve. | Pass the task id. |
24
24
  | 404 `not_found` on a task command with a task id | The task does not exist, or belongs to another channel. | Check the id with `task list`. |
25
- | 404 `not_found` on `users`, `send`, `mention` | Collaboration is not enabled for this turn, so those commands do not exist. | Do not use them; the turn prompt says when they are live. |
26
- | 404 `not_found` on `draft` | Either collaboration is not enabled for this turn, or the host holds no draft for it yet — a draft exists only once the turn has produced visible reply text. | Not a verdict on the other collaboration commands: `users`, `send` and `mention` may well answer on this same turn. Carry on and read the draft later if you still need it. |
25
+ | 404 `not_found` on `send`, `mention` | Collaboration is not enabled for this turn, so those commands do not exist. | Do not use them; the turn prompt says when they are live. |
26
+ | 404 `not_found` on `draft` | Either collaboration is not enabled for this turn, or the host holds no draft for it yet — a draft exists only once the turn has produced visible reply text. | Not a verdict on the other collaboration commands: `users` may still answer on this same turn, and `send` / `mention` may answer once collaboration is live. Carry on and read the draft later if you still need it. |
27
27
  | 404 `bootstrap_unavailable` | The host has not published this turn's channel payload yet. | Retry the read once; if it persists, continue without it. |
28
28
  | 400 `task_thread_collection_not_allowed` | `task list` or `task create` inside a task thread. | Those belong to the parent channel. |
29
29
  | 400 `multiline_message_body_not_allowed`, `message_body_too_verbose` | An auxiliary send must be one line of at most 12 words, and the `<@id>` the CLI appends counts as one of them — each extra `--mention` costs another. | Shorten it to a single-line notice. |
@@ -1,6 +1,6 @@
1
1
  # Task properties
2
2
 
3
- A task property associates a task with something that lives outside it, or with a closed task-level runtime choice. Use one to record what a reader would otherwise have to hunt for in the thread — the pull request that implements the task, the issue it came from, or the task workspace mode it should run with.
3
+ A task property associates a task with something that lives outside it, or with a closed task-level runtime choice. Use one to record what a reader would otherwise have to hunt for in the thread — the pull request that implements the task, the issue it came from, or the execution metadata a human attached to it.
4
4
 
5
5
  Read them back with `task get`: every task response carries a `properties` object, `{}` when the task has none.
6
6
 
@@ -21,7 +21,8 @@ The key set is closed; writing an unregistered key is rejected with `unknown_pro
21
21
  | --- | --- |
22
22
  | `link.pr` | The pull request that implements this task. Set it as soon as the PR exists, not at the end. |
23
23
  | `link.issue` | The issue or ticket the task originates from. |
24
- | `workspace.mode` | The task workspace binding for task threads. `inherit` keeps the task on its channel workspace. `isolated` switches it to its task-isolated workspace. Deleting the key returns to the default `inherit` behavior. |
24
+ | `execution.local_directory` | The explicit local directory a human selected as this task thread's execution target. New tasks default it from the immediately previous task thread in the same channel: if that previous task carried a value, the new task inherits it; if the previous task left it unset, the new task also starts unset. Hosted agents can read it, but the generic agent task-property rail must not write it. Humans set it through the existing user rail. The extra single-human-user guard applies when a task thread is about to switch into somebody's own local workspace/project directory, not as a blanket rule on every property write. A separate agents-host local policy switch governs whether a hosted agent may enter an independent workspace for a task thread that originated from another agent's task. |
25
+ | `workspace.mode` | The host-managed scratch-workspace isolation hint for task threads. `inherit` keeps the scratch workspace on the shared host-managed path, while `isolated` reserves the task's own host-managed scratch workspace shape. Deleting the key returns to the default `inherit` behavior. |
25
26
  | `agent.session_id` | Do not write this. It is registered, so a write is accepted and lands — overwriting the host's record of which provider session worked this task. The host writes it itself after each turn. |
26
27
 
27
28
  ## One key per call
@@ -30,4 +31,4 @@ Each call writes exactly one key, and that is what makes it safe to write a prop
30
31
 
31
32
  ## Value
32
33
 
33
- A value is a plain string of at most 8 KiB; a longer one is rejected with `property_value_too_long`. It is a reference — a URL, an identifier — never a document. Some registered keys are closed enums: `workspace.mode` accepts only `inherit` or `isolated`.
34
+ A value is a plain string of at most 8 KiB; a longer one is rejected with `property_value_too_long`. It is a reference — a URL, an identifier — never a document. `execution.local_directory` must be a non-empty absolute path string. Before a human lets a task thread switch into their own local workspace/project directory, first verify that the task's parent channel has exactly one human user; otherwise keep the thread discussion-only. If a hosted agent is trying to enter an independent workspace for a task thread that originated from another agent's task, this agents-host's local handoff policy must also allow it. Some registered keys are closed enums: `workspace.mode` accepts only `inherit` or `isolated`.