@borgee/agents-host 0.2.56 → 0.2.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +11 -10
  2. package/dist/agents-host.d.ts +4 -1
  3. package/dist/agents-host.js +229 -20
  4. package/dist/chat/chat-control-plane.d.ts +2 -1
  5. package/dist/chat/sdk-chat-control-plane.d.ts +3 -2
  6. package/dist/chat/sdk-chat-control-plane.js +4 -0
  7. package/dist/compatibility-gates.js +2 -0
  8. package/dist/config.d.ts +2 -0
  9. package/dist/config.js +21 -0
  10. package/dist/context/injection.d.ts +20 -2
  11. package/dist/context/injection.js +146 -103
  12. package/dist/context/prompt.js +11 -7
  13. package/dist/context/resolved-workspace.d.ts +1 -0
  14. package/dist/context/resolved-workspace.js +50 -8
  15. package/dist/context/turn-preparation.js +8 -0
  16. package/dist/gateway/localhost-gateway.js +2 -5
  17. package/dist/local-config.js +11 -1
  18. package/dist/managed-daemon.js +18 -13
  19. package/dist/plugin-sdk.js +0 -17
  20. package/dist/plugin-sdk.js.map +2 -2
  21. package/dist/policy/copilot-permission.d.ts +1 -0
  22. package/dist/policy/copilot-permission.js +18 -0
  23. package/dist/policy/gateway-authorization.js +2 -2
  24. package/dist/providers/claude/adapter.d.ts +1 -0
  25. package/dist/providers/claude/adapter.js +3 -0
  26. package/dist/providers/claude/cli-client.d.ts +2 -1
  27. package/dist/providers/claude/cli-client.js +53 -3
  28. package/dist/providers/codex/adapter.d.ts +1 -0
  29. package/dist/providers/codex/adapter.js +3 -0
  30. package/dist/providers/codex/cli-client.d.ts +3 -0
  31. package/dist/providers/codex/cli-client.js +50 -22
  32. package/dist/providers/copilot/adapter.d.ts +1 -0
  33. package/dist/providers/copilot/adapter.js +3 -0
  34. package/dist/providers/copilot/cli-client.d.ts +2 -1
  35. package/dist/providers/copilot/cli-client.js +30 -3
  36. package/dist/providers/create-provider.d.ts +1 -1
  37. package/dist/providers/create-provider.js +8 -0
  38. package/dist/providers/provider-adapter.d.ts +7 -0
  39. package/dist/providers/provider-adapter.js +8 -0
  40. package/dist/types.d.ts +16 -3
  41. package/package.json +2 -2
  42. package/skills/borgee-agent/SKILL.md +4 -4
  43. package/skills/borgee-agent/references/errors.md +2 -2
  44. package/skills/borgee-agent/references/task-properties.md +4 -3
package/README.md CHANGED
@@ -479,7 +479,7 @@ successfully applied snapshot running.
479
479
  Each Borgee channel is mapped 1:1 to a provider-native session while it stays
480
480
  active:
481
481
 
482
- - **Claude**: one persistent `@agentclientprotocol/claude-agent-acp` subprocess is shared by a single `AgentsHost`, with one ACP session per Borgee channel. Same-channel turns are serialized; different channels keep isolated ACP sessions. The real ACP session cwd now comes from the resolved channel workspace `~/.borgee/channels/<encoded-channel-id>/workspace`. Task threads inherit that same channel workspace unless the bound task carries `workspace.mode=isolated`, in which case the real ACP session cwd switches to `~/.borgee/channels/<encoded-channel-id>/tasks/<encoded-task-id>/workspace`. Both writable workspace paths are explicitly not a claim that the target repository is already checked out there. The host persists the channel→ACP-session map in its state root, restores same-host continuity with ACP `session/resume` when available, retries stale restores with a fresh session immediately, and injects the per-channel local context paths through Claude ACP `_meta.systemPrompt.append`.
482
+ - **Claude**: one persistent `@agentclientprotocol/claude-agent-acp` subprocess is shared by a single `AgentsHost`, with one ACP session per Borgee channel. Same-channel turns are serialized; different channels keep isolated ACP sessions. The real ACP session cwd now comes from the resolved channel workspace `~/.borgee/channels/<encoded-channel-id>/workspace`. Task threads only switch execution onto an explicit existing `execution.local_directory`; otherwise they stay discussion-only on `~/.borgee/channels/<encoded-thread-id>/scratch/workspace`, and ACP permission requests are denied so filesystem and shell tools stay unavailable. The host persists the channel→ACP-session map in its state root, restores same-host continuity with ACP `session/resume` when available, retries stale restores with a fresh session immediately, and injects the per-channel local context paths through Claude ACP `_meta.systemPrompt.append`.
483
483
  - **Codex**: one persistent `@agentclientprotocol/codex-acp` subprocess is
484
484
  shared by a single `AgentsHost`, with one ACP session per Borgee channel. When
485
485
  the current turn has a materialized channel context payload, Codex refreshes a
@@ -487,12 +487,11 @@ active:
487
487
  per-channel projection directory and exposes that directory to Codex through
488
488
  ACP `additionalDirectories`. The real ACP session cwd now comes from the
489
489
  resolved channel workspace `~/.borgee/channels/<encoded-channel-id>/workspace`.
490
- Task threads inherit that same channel
491
- workspace unless the bound task carries `workspace.mode=isolated`, in which
492
- case the real ACP session cwd switches to
493
- `~/.borgee/channels/<encoded-channel-id>/tasks/<encoded-task-id>/workspace`. Both
494
- writable workspace paths are explicitly not a claim that the target
495
- repository is already checked out there. The host persists the
490
+ Task threads only switch execution onto an explicit existing
491
+ `execution.local_directory`; otherwise they stay discussion-only on
492
+ `~/.borgee/channels/<encoded-thread-id>/scratch/workspace`, and ACP
493
+ permission requests are denied so filesystem and shell tools stay
494
+ unavailable. The host persists the
496
495
  channel→ACP-session map in its state root and attempts ACP `session/resume`
497
496
  first when the adapter advertises it, otherwise `session/load`.
498
497
  - **Copilot**: one persistent `copilot --acp` subprocess is shared by a single
@@ -500,9 +499,11 @@ active:
500
499
  serialized; different channels keep isolated ACP sessions. The real ACP
501
500
  session cwd now comes from the resolved channel workspace
502
501
  `~/.borgee/channels/<encoded-channel-id>/workspace` by default. Task threads
503
- inherit that same channel workspace unless the bound task carries
504
- `workspace.mode=isolated`, in which case the real ACP session cwd switches
505
- to `~/.borgee/channels/<encoded-channel-id>/tasks/<encoded-task-id>/workspace`. The
502
+ only switch execution onto an explicit existing `execution.local_directory`;
503
+ otherwise they stay discussion-only on
504
+ `~/.borgee/channels/<encoded-thread-id>/scratch/workspace`, and ACP
505
+ permission requests are denied so filesystem and shell tools stay
506
+ unavailable. The
506
507
  host persists the channel→ACP-session map in its state root and restores
507
508
  same-host continuity with ACP `session/resume` when available, otherwise
508
509
  `session/load` plus local replay cleanup on runtimes that advertise only
@@ -1,6 +1,6 @@
1
1
  import type { ChatControlPlane } from './chat/chat-control-plane.js';
2
2
  import { type AgentsHostRuntimeOptions } from './debug.js';
3
- import type { ProviderAdapter } from './providers/provider-adapter.js';
3
+ import { type ProviderAdapter } from './providers/provider-adapter.js';
4
4
  import type { AgentsHostConfig } from './types.js';
5
5
  /**
6
6
  * Minimal single-agent agents host: connects one local Claude/Copilot CLI
@@ -125,6 +125,7 @@ export declare class AgentsHost {
125
125
  private revalidateAttentionSnapshotForTurn;
126
126
  private getAttentionSnapshotForTurn;
127
127
  private isHumanWakeFiltered;
128
+ private isOrdinaryAgentWakeFiltered;
128
129
  private applyAttentionUpdate;
129
130
  private commitAttentionSnapshot;
130
131
  private resolveTaskThreadCollaborationContractForTurn;
@@ -159,6 +160,8 @@ export declare class AgentsHost {
159
160
  private readCollaborationDraft;
160
161
  private supersedeActiveTurn;
161
162
  private invalidateChannelTurn;
163
+ private stopTurn;
164
+ private responseStateForExecuted;
162
165
  private rememberRecentTurn;
163
166
  private resolveAuthorizedCollaborationTurn;
164
167
  private updateAwaitingUserState;
@@ -9,13 +9,14 @@ import { buildNormalizedAttentionSnapshot, parsePersistedAttentionSnapshot, } fr
9
9
  import { mergeCompactionSnapshot, parsePersistedCompactionSnapshot, } from './context/compaction.js';
10
10
  import { projectTaskThreadCollaborationContract } from './context/task-thread-collaboration.js';
11
11
  import { createLocalhostGatewayController, LOCALHOST_GATEWAY_COLLABORATION_TARGET_COOLDOWN_MS, } from './gateway/localhost-gateway.js';
12
- import { extractTaskIdFromTaskAssignmentContent, resolveChannelContextPayloadPath, } from './context/injection.js';
12
+ import { extractTaskIdFromTaskAssignmentContent, resolveTaskAssignmentStatePath, } from './context/injection.js';
13
13
  import { AuthorizationAuditSink, } from './policy/authorization-audit.js';
14
14
  import { createProvider } from './providers/create-provider.js';
15
+ import { ProviderTurnCancelledError } from './providers/provider-adapter.js';
15
16
  import { resolveAttentionStatePath, resolveCompactionStatePath, resolveSharedProtocolKickoffDecisionPath, resolveSharedProtocolStatusPath, } from './state-paths.js';
16
17
  import { appendVisibleMention as appendVisibleBodyMention, extractVisibleMentionIds as extractCanonicalMentionIds, } from './visible-mentions.js';
17
- import { waitForTaskForThread } from './task-thread-resolution.js';
18
18
  import { buildHostedIncomingContentText, buildHostedTurnContentParts, extractHostedMessageAttachments, normalizeHostedMessageAttachment, } from './hosted-turn-content.js';
19
+ import { waitForTaskForThread } from './task-thread-resolution.js';
19
20
  const PRIVATE_STATE_ROOT_MODE = 0o700;
20
21
  const PROTOCOL_STATE_DIRNAME = 'protocol-state';
21
22
  const PROTOCOL_PROVIDER_TURN_TIMEOUT_MS = 120_000;
@@ -24,6 +25,9 @@ const PROTOCOL_KICKOFF_DECISION_POLL_INTERVAL_MS = 50;
24
25
  const PROTOCOL_KICKOFF_ROUND_LIMIT = 20;
25
26
  const COLLABORATION_LATE_SEND_GRACE_MS = 5_000;
26
27
  const ORDINARY_HUMAN_EVENT_KINDS = new Set(['', 'message']);
28
+ const TASK_EXECUTION_LOCAL_DIRECTORY_PROPERTY_KEY = 'execution.local_directory';
29
+ const TASK_EXECUTION_LOCAL_DIRECTORY_REMINDER = 'This task has no Execution Local Directory yet. Set it before asking me to work in a real local project directory.';
30
+ const TASK_EXECUTION_LOCAL_DIRECTORY_IN_USE_PREFIX = 'Using Execution Local Directory:';
27
31
  async function ensurePrivateStateRoot(path) {
28
32
  await mkdir(path, { recursive: true, mode: PRIVATE_STATE_ROOT_MODE });
29
33
  await chmod(path, PRIVATE_STATE_ROOT_MODE);
@@ -136,6 +140,7 @@ class DefaultHostRuntime {
136
140
  copilotArgs: config.copilotArgs,
137
141
  copilotSessionTtlMinutes: config.copilotSessionTtlMinutes,
138
142
  providerIdleShutdownMinutes: config.providerIdleShutdownMinutes,
143
+ allowCrossAgentIndependentWorkspaceHandoff: config.allowCrossAgentIndependentWorkspaceHandoff,
139
144
  }, deps.logger, {
140
145
  compatibilityGates,
141
146
  authorizationAuditSink: this.authorizationAuditSink,
@@ -144,13 +149,15 @@ class DefaultHostRuntime {
144
149
  });
145
150
  this.ensureStateRoot = deps.ensureStateRoot ?? ensurePrivateStateRoot;
146
151
  }
147
- async start(onMessage) {
152
+ async start(onMessage, onStopTurn) {
148
153
  await this.ensureStateRoot(this.config.stateRootDir);
149
154
  await this.gateway.start();
150
155
  try {
156
+ this.controlPlane.onStopTurn?.(onStopTurn);
151
157
  await this.controlPlane.connect(onMessage);
152
158
  }
153
159
  catch (error) {
160
+ this.controlPlane.onStopTurn?.(undefined);
154
161
  await this.gateway.stop().catch(() => { });
155
162
  throw error;
156
163
  }
@@ -158,6 +165,7 @@ class DefaultHostRuntime {
158
165
  async stop() {
159
166
  let thrown;
160
167
  try {
168
+ this.controlPlane.onStopTurn?.(undefined);
161
169
  await this.controlPlane.close();
162
170
  }
163
171
  catch (error) {
@@ -365,7 +373,7 @@ export class AgentsHost {
365
373
  return;
366
374
  }
367
375
  this.dispatchMessage(message);
368
- });
376
+ }, async (channelId) => this.stopTurn(channelId));
369
377
  const agentId = await this.ensureSelfAgentId();
370
378
  bufferingMessages = false;
371
379
  while (bufferedMessages.length > 0) {
@@ -461,6 +469,9 @@ export class AgentsHost {
461
469
  const authorKind = await this.classifyAuthor(normalized.authorId, false);
462
470
  await this.ensureAttentionSnapshotLoaded(msg.channel_id, channelState);
463
471
  attentionSnapshot = this.getAttentionSnapshotForTurn(msg.channel_id, taskThreadContext);
472
+ if (this.isOrdinaryAgentWakeFiltered(msg, authorKind)) {
473
+ return;
474
+ }
464
475
  if (this.isHumanWakeFiltered(msg, authorKind, attentionSnapshot)) {
465
476
  return;
466
477
  }
@@ -553,6 +564,9 @@ export class AgentsHost {
553
564
  }
554
565
  }
555
566
  }
567
+ if (this.isOrdinaryAgentWakeFiltered(msg, authorKind)) {
568
+ return;
569
+ }
556
570
  if (this.isHumanWakeFiltered(msg, authorKind, attentionSnapshot)) {
557
571
  return;
558
572
  }
@@ -668,7 +682,7 @@ export class AgentsHost {
668
682
  return null;
669
683
  }
670
684
  this.recordCollaborationOutcome(msg.channel_id, {
671
- responseState: executed.completed ? 'responded' : 'failed',
685
+ responseState: this.responseStateForExecuted(executed),
672
686
  deliveryState: executed.deliveryState ?? 'not-delivered',
673
687
  });
674
688
  this.updateMissedCollaborationDiagnostic(msg.channel_id, priorMissedCollaborationDiagnostic);
@@ -1103,7 +1117,7 @@ export class AgentsHost {
1103
1117
  channelState.blocked = undefined;
1104
1118
  this.awaitingUserByChannel.delete(channelId);
1105
1119
  this.recordCollaborationOutcome(channelId, {
1106
- responseState: executed.completed ? 'responded' : 'failed',
1120
+ responseState: this.responseStateForExecuted(executed),
1107
1121
  deliveryState: executed.deliveryState ?? 'not-delivered',
1108
1122
  ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1109
1123
  turnExecutionId: activeTurn.turnExecutionId ?? undefined,
@@ -1167,7 +1181,7 @@ export class AgentsHost {
1167
1181
  const visibleMessageId = deliveredMessageId ?? protocolState.lastProtocolMessageId;
1168
1182
  if (executed.reply?.controlMalformed) {
1169
1183
  this.recordCollaborationOutcome(channelId, {
1170
- responseState: executed.completed ? 'responded' : 'failed',
1184
+ responseState: this.responseStateForExecuted(executed),
1171
1185
  deliveryState: executed.deliveryState ?? 'not-delivered',
1172
1186
  ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1173
1187
  turnExecutionId: activeTurn.turnExecutionId ?? undefined,
@@ -1177,7 +1191,7 @@ export class AgentsHost {
1177
1191
  }
1178
1192
  if (!executed.reply?.control) {
1179
1193
  this.recordCollaborationOutcome(channelId, {
1180
- responseState: executed.completed ? 'responded' : 'failed',
1194
+ responseState: this.responseStateForExecuted(executed),
1181
1195
  deliveryState: executed.deliveryState ?? 'not-delivered',
1182
1196
  ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1183
1197
  turnExecutionId: activeTurn.turnExecutionId ?? undefined,
@@ -1211,7 +1225,7 @@ export class AgentsHost {
1211
1225
  }
1212
1226
  if (executed.reply.control.kind === 'start-protocol') {
1213
1227
  this.recordCollaborationOutcome(channelId, {
1214
- responseState: executed.completed ? 'responded' : 'failed',
1228
+ responseState: this.responseStateForExecuted(executed),
1215
1229
  deliveryState: executed.deliveryState ?? 'not-delivered',
1216
1230
  ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1217
1231
  turnExecutionId: activeTurn.turnExecutionId ?? undefined,
@@ -1222,7 +1236,7 @@ export class AgentsHost {
1222
1236
  }
1223
1237
  if (executed.reply.control.kind === 'attention-only') {
1224
1238
  this.recordCollaborationOutcome(channelId, {
1225
- responseState: executed.completed ? 'responded' : 'failed',
1239
+ responseState: this.responseStateForExecuted(executed),
1226
1240
  deliveryState: executed.deliveryState ?? 'not-delivered',
1227
1241
  ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1228
1242
  turnExecutionId: activeTurn.turnExecutionId ?? undefined,
@@ -1824,19 +1838,74 @@ export class AgentsHost {
1824
1838
  if (!this.contextInjectionEnabled) {
1825
1839
  return { state: 'inactive' };
1826
1840
  }
1841
+ const recoverFromControlPlane = async (preferredTaskId) => {
1842
+ try {
1843
+ if (preferredTaskId) {
1844
+ try {
1845
+ const preferredTask = await this.borgee.getTask({ taskId: preferredTaskId });
1846
+ if (preferredTask.threadId === channelId) {
1847
+ return preferredTask.id.trim().length > 0
1848
+ ? { state: 'recovered', currentTaskId: preferredTask.id }
1849
+ : { state: 'recovered' };
1850
+ }
1851
+ }
1852
+ catch {
1853
+ // Fall through to the visibility-based scan.
1854
+ }
1855
+ }
1856
+ const channels = await this.borgee.listChannels();
1857
+ const currentChannel = channels.find((channel) => channel.id === channelId);
1858
+ if (currentChannel && currentChannel.kind !== 'thread') {
1859
+ return { state: 'inactive' };
1860
+ }
1861
+ let sawTaskListSuccess = false;
1862
+ let sawTaskListFailure = false;
1863
+ for (const channel of channels) {
1864
+ let tasks;
1865
+ try {
1866
+ tasks = await this.borgee.listTasks({ channelId: channel.id });
1867
+ sawTaskListSuccess = true;
1868
+ }
1869
+ catch {
1870
+ sawTaskListFailure = true;
1871
+ continue;
1872
+ }
1873
+ for (const task of tasks) {
1874
+ if (task.threadId === channelId) {
1875
+ return task.id.trim().length > 0 ? { state: 'recovered', currentTaskId: task.id } : { state: 'recovered' };
1876
+ }
1877
+ }
1878
+ }
1879
+ return sawTaskListSuccess && !sawTaskListFailure ? { state: 'inactive' } : { state: 'unavailable' };
1880
+ }
1881
+ catch {
1882
+ return { state: 'unavailable' };
1883
+ }
1884
+ };
1827
1885
  try {
1828
- const content = await readFile(resolveChannelContextPayloadPath(this.config.stateRootDir, channelId), 'utf8');
1886
+ const content = await readFile(resolveTaskAssignmentStatePath(this.config.stateRootDir, channelId), 'utf8');
1829
1887
  const payload = JSON.parse(content);
1830
1888
  if (payload.taskAssignmentContext?.active !== true) {
1831
- return { state: 'inactive' };
1889
+ return await recoverFromControlPlane();
1832
1890
  }
1833
1891
  const currentTaskId = typeof payload.taskAssignmentContext.currentTaskId === 'string'
1834
1892
  ? payload.taskAssignmentContext.currentTaskId.trim()
1835
1893
  : '';
1836
- return currentTaskId ? { state: 'active', currentTaskId } : { state: 'active' };
1894
+ if (currentTaskId) {
1895
+ try {
1896
+ const task = await this.borgee.getTask({ taskId: currentTaskId });
1897
+ if (task.threadId === channelId) {
1898
+ return { state: 'active', currentTaskId: task.id };
1899
+ }
1900
+ }
1901
+ catch {
1902
+ // Fall through to the recovery scan below.
1903
+ }
1904
+ }
1905
+ return await recoverFromControlPlane(currentTaskId || undefined);
1837
1906
  }
1838
1907
  catch {
1839
- return { state: 'unavailable' };
1908
+ return await recoverFromControlPlane();
1840
1909
  }
1841
1910
  }
1842
1911
  async ensureAttentionSnapshotLoaded(channelId, channelState) {
@@ -1936,11 +2005,28 @@ export class AgentsHost {
1936
2005
  if (!ORDINARY_HUMAN_EVENT_KINDS.has(eventKind)) {
1937
2006
  return false;
1938
2007
  }
1939
- if (attentionSnapshot?.wakeMode === 'follow-visible-human') {
2008
+ if (msg.channel_type === 'dm'
2009
+ && attentionSnapshot?.wakeMode !== 'mute-non-mention') {
1940
2010
  return false;
1941
2011
  }
1942
- this.noteWakeSuppressedAttentionPolicy(msg, authorKind);
1943
- return true;
2012
+ if (attentionSnapshot?.wakeMode === 'mute-non-mention') {
2013
+ this.noteWakeSuppressedAttentionPolicy(msg, authorKind);
2014
+ return true;
2015
+ }
2016
+ return false;
2017
+ }
2018
+ isOrdinaryAgentWakeFiltered(msg, authorKind) {
2019
+ if (authorKind !== 'agent') {
2020
+ return false;
2021
+ }
2022
+ if (msg.message_type === 'task_assignment') {
2023
+ return false;
2024
+ }
2025
+ if (msg.type?.trim() === 'mention') {
2026
+ return false;
2027
+ }
2028
+ const eventKind = msg.type?.trim() ?? '';
2029
+ return ORDINARY_HUMAN_EVENT_KINDS.has(eventKind);
1944
2030
  }
1945
2031
  applyAttentionUpdate(channelId, baseSnapshot, attentionUpdate, taskThreadContext, options) {
1946
2032
  if (!this.attentionFollowSemanticsEnabled) {
@@ -2109,9 +2195,7 @@ export class AgentsHost {
2109
2195
  this.recordCollaborationOutcome(msg.channel_id, {
2110
2196
  responseState: executed.completed && executed.reply?.awaitingUser
2111
2197
  ? 'blocked'
2112
- : executed.completed
2113
- ? 'responded'
2114
- : 'failed',
2198
+ : this.responseStateForExecuted(executed),
2115
2199
  deliveryState: executed.deliveryState ?? 'not-delivered',
2116
2200
  ...(executed.completed && executed.reply?.awaitingUser
2117
2201
  ? {
@@ -2202,6 +2286,21 @@ export class AgentsHost {
2202
2286
  ...(taskThreadCollaborationContract ? { taskThreadCollaborationContract } : {}),
2203
2287
  ...(collaborationCapabilities ? { collaborationCapabilities } : {}),
2204
2288
  ...(missedCollaborationDiagnostic ? { missedCollaborationDiagnostic } : {}),
2289
+ ...((taskThreadContext.state === 'active'
2290
+ || taskThreadContext.state === 'recovered'
2291
+ || taskThreadContext.state === 'unavailable')
2292
+ ? {
2293
+ taskAssignmentContextOverride: {
2294
+ active: true,
2295
+ ...(taskThreadContext.state === 'active' && taskThreadContext.currentTaskId
2296
+ ? { currentTaskId: taskThreadContext.currentTaskId }
2297
+ : {}),
2298
+ },
2299
+ ...(taskThreadContext.state === 'active'
2300
+ ? { taskAssignmentContextOverridePersistence: 'persist' }
2301
+ : { taskAssignmentContextOverridePersistence: 'ephemeral' }),
2302
+ }
2303
+ : {}),
2205
2304
  }, observeProviderProgress
2206
2305
  ? {
2207
2306
  onProgress: (update) => {
@@ -2350,6 +2449,65 @@ export class AgentsHost {
2350
2449
  turnExecutionId: activeTurn.turnExecutionId,
2351
2450
  });
2352
2451
  }
2452
+ if (error instanceof ProviderTurnCancelledError) {
2453
+ const cancelledReplyText = error.publicReplyText?.trim();
2454
+ if (!silentTurn &&
2455
+ !deferDelivery &&
2456
+ typeof cancelledReplyText === 'string' &&
2457
+ cancelledReplyText.length > 0 &&
2458
+ this.started &&
2459
+ !this.controlPlaneClosed) {
2460
+ reply = { text: cancelledReplyText };
2461
+ publicReplyText = cancelledReplyText;
2462
+ visibleReplyBody = this.resolveVisibleTurnBody(activeTurn, reply, publicReplyText);
2463
+ try {
2464
+ const posted = await this.borgee.postMessage({
2465
+ channelId,
2466
+ body: visibleReplyBody,
2467
+ replyToId: activeTurn?.delivery?.replyToId,
2468
+ });
2469
+ this.logger.debug('posted cancelled turn feedback', {
2470
+ ...turnContext,
2471
+ durationMs: Date.now() - turnStartedAt,
2472
+ replyLength: publicReplyText.length,
2473
+ delivery: 'post',
2474
+ });
2475
+ return {
2476
+ reply,
2477
+ deliveredMessageId: posted.messageId,
2478
+ publicReplyText,
2479
+ visibleReplyBody,
2480
+ deliveryState: 'posted',
2481
+ stale: activeTurn?.superseded === true,
2482
+ completed: false,
2483
+ cancelled: true,
2484
+ };
2485
+ }
2486
+ catch (postError) {
2487
+ this.logger.debugError('failed to post cancelled turn feedback', {
2488
+ ...turnContext,
2489
+ error: postError,
2490
+ });
2491
+ this.logger.error('failed to post cancelled turn feedback', {
2492
+ ...turnContext,
2493
+ error: summarizeError(postError),
2494
+ });
2495
+ }
2496
+ }
2497
+ this.logger.debug('cancelled turn', {
2498
+ ...turnContext,
2499
+ durationMs: Date.now() - turnStartedAt,
2500
+ });
2501
+ return {
2502
+ reply,
2503
+ publicReplyText,
2504
+ visibleReplyBody,
2505
+ deliveryState: 'not-delivered',
2506
+ stale: activeTurn?.superseded === true,
2507
+ completed: false,
2508
+ cancelled: true,
2509
+ };
2510
+ }
2353
2511
  this.logger.debugError('failed to generate or send reply', {
2354
2512
  ...turnContext,
2355
2513
  error,
@@ -2561,6 +2719,29 @@ export class AgentsHost {
2561
2719
  activeTurn.turnExecutionId = null;
2562
2720
  }
2563
2721
  }
2722
+ async stopTurn(channelId) {
2723
+ const activeTurn = this.collaborationChannels.get(channelId)?.activeTurn;
2724
+ const aborted = await this.provider.cancelTurn?.(channelId) ?? false;
2725
+ if (!aborted) {
2726
+ return { aborted: false };
2727
+ }
2728
+ if (activeTurn) {
2729
+ this.supersedeActiveTurn(channelId, activeTurn);
2730
+ }
2731
+ else {
2732
+ this.recordCollaborationOutcome(channelId, {
2733
+ responseState: 'superseded',
2734
+ deliveryState: 'not-delivered',
2735
+ });
2736
+ }
2737
+ return { aborted: true };
2738
+ }
2739
+ responseStateForExecuted(executed) {
2740
+ if (executed.cancelled) {
2741
+ return 'superseded';
2742
+ }
2743
+ return executed.completed ? 'responded' : 'failed';
2744
+ }
2564
2745
  rememberRecentTurn(channelState, activeTurn) {
2565
2746
  if (!activeTurn.turnExecutionId) {
2566
2747
  channelState.recentTurn = undefined;
@@ -2759,6 +2940,34 @@ export class AgentsHost {
2759
2940
  task = null;
2760
2941
  }
2761
2942
  if (task && task.assigneeId === selfAgentId) {
2943
+ const executionLocalDirectory = task.properties?.[TASK_EXECUTION_LOCAL_DIRECTORY_PROPERTY_KEY];
2944
+ if (typeof executionLocalDirectory !== 'string' || executionLocalDirectory.trim().length === 0) {
2945
+ try {
2946
+ await this.borgee.postMessage({
2947
+ channelId: msg.channel_id,
2948
+ body: TASK_EXECUTION_LOCAL_DIRECTORY_REMINDER,
2949
+ });
2950
+ }
2951
+ catch (error) {
2952
+ this.logger.debugError('task local directory reminder post failed', {
2953
+ channelId: msg.channel_id,
2954
+ error,
2955
+ });
2956
+ }
2957
+ return null;
2958
+ }
2959
+ try {
2960
+ await this.borgee.postMessage({
2961
+ channelId: msg.channel_id,
2962
+ body: `${TASK_EXECUTION_LOCAL_DIRECTORY_IN_USE_PREFIX} ${executionLocalDirectory}`,
2963
+ });
2964
+ }
2965
+ catch (error) {
2966
+ this.logger.debugError('task local directory notice post failed', {
2967
+ channelId: msg.channel_id,
2968
+ error,
2969
+ });
2970
+ }
2762
2971
  if (task.status === 'open') {
2763
2972
  try {
2764
2973
  await this.borgee.updateTask({
@@ -1,6 +1,7 @@
1
- import type { ReportTurnActivityInput, TurnActivityReporter } from '../plugin-sdk.js';
1
+ import type { ReportTurnActivityInput, StopTurnHandler, TurnActivityReporter } from '../plugin-sdk.js';
2
2
  import type { ChannelSummary, ChannelHistoryEntry, ChannelMessageEvent, CreateTaskInput, DirectoryUser, MeResponseUser, PostMessageInput, PostedMessage, ReadChannelHistoryInput, Task, UpdateTaskInput } from '../types.js';
3
3
  export interface ChatControlPlane {
4
+ onStopTurn?(handler: StopTurnHandler | undefined): void;
4
5
  connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
5
6
  close(): Promise<void>;
6
7
  postMessage(input: PostMessageInput): Promise<PostedMessage>;
@@ -1,7 +1,7 @@
1
- import { type BorgeePluginClient, type BorgeePluginOptions, type InboundMessageEvent, type ReportTurnActivityInput, type TurnActivityReporter } from '../plugin-sdk.js';
1
+ import { type BorgeePluginClient, type BorgeePluginOptions, type InboundMessageEvent, type ReportTurnActivityInput, type StopTurnHandler, type TurnActivityReporter } from '../plugin-sdk.js';
2
2
  import type { ChannelSummary, ChannelHistoryEntry, ChannelMessageEvent, CreateTaskInput, DirectoryUser, MeResponseUser, PostMessageInput, PostedMessage, ReadChannelHistoryInput, Task, UpdateTaskInput } from '../types.js';
3
3
  import type { ChatControlPlane } from './chat-control-plane.js';
4
- type PluginClientLike = Pick<BorgeePluginClient, 'agentId' | 'close' | 'connect' | 'createTask' | 'deleteMessage' | 'editMessage' | 'getMe' | 'getTask' | 'listChannels' | 'listTasks' | 'listUsers' | 'on' | 'readHistory' | 'reportTurnActivity' | 'sendMessage' | 'startTyping' | 'updateTask' | 'setTaskProperty' | 'deleteTaskProperty'>;
4
+ type PluginClientLike = Pick<BorgeePluginClient, 'agentId' | 'close' | 'connect' | 'createTask' | 'deleteMessage' | 'editMessage' | 'getMe' | 'getTask' | 'listChannels' | 'listTasks' | 'listUsers' | 'on' | 'onStopTurn' | 'readHistory' | 'reportTurnActivity' | 'sendMessage' | 'startTyping' | 'updateTask' | 'setTaskProperty' | 'deleteTaskProperty'>;
5
5
  type PluginClientFactory = (options: BorgeePluginOptions) => PluginClientLike;
6
6
  type SdkChatControlPlaneOptions = Pick<BorgeePluginOptions, 'pluginId'>;
7
7
  /**
@@ -16,6 +16,7 @@ export declare class SdkChatControlPlane implements ChatControlPlane {
16
16
  private connected;
17
17
  private me;
18
18
  constructor(baseUrl: string, apiKey: string, createClient?: PluginClientFactory, options?: SdkChatControlPlaneOptions);
19
+ onStopTurn(handler: StopTurnHandler | undefined): void;
19
20
  connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
20
21
  close(): Promise<void>;
21
22
  postMessage(input: PostMessageInput): Promise<PostedMessage>;
@@ -14,6 +14,9 @@ export class SdkChatControlPlane {
14
14
  constructor(baseUrl, apiKey, createClient = createBorgeePlugin, options = {}) {
15
15
  this.client = createClient({ baseUrl, apiKey, ...options });
16
16
  }
17
+ onStopTurn(handler) {
18
+ this.client.onStopTurn(handler);
19
+ }
17
20
  async connect(onMessage) {
18
21
  this.unsubscribe = this.client.on('message', (event) => {
19
22
  const message = mapInboundToChannelMessage(event);
@@ -46,6 +49,7 @@ export class SdkChatControlPlane {
46
49
  this.pendingMessages = [];
47
50
  this.unsubscribe?.();
48
51
  this.unsubscribe = null;
52
+ this.client.onStopTurn(undefined);
49
53
  await this.client.close();
50
54
  }
51
55
  async postMessage(input) {
@@ -24,8 +24,10 @@ export const INTERNAL_CLAUDE_PROMPT_STRATEGY_ENV = 'AGENTS_HOST_INTERNAL_CLAUDE_
24
24
  export const MANAGED_RUNTIME_SETTINGS_SCHEMA_VERSION = 1;
25
25
  const VALID_PROVIDER_IMPLEMENTATION_ENTRIES = '"claude:v1", "claude:v2", "copilot:v1", or "copilot:v2"';
26
26
  export const DEFAULT_INTERNAL_COMPATIBILITY_GATES = Object.freeze([
27
+ ATTENTION_FOLLOW_SEMANTICS_COMPATIBILITY_GATE,
27
28
  COLLABORATION_CAPABILITIES_DIAGNOSTICS_COMPATIBILITY_GATE,
28
29
  COLLABORATION_OUTCOME_MODEL_COMPATIBILITY_GATE,
30
+ COLLABORATION_SKILL_FIRST_COMPATIBILITY_GATE,
29
31
  CONNECTIONS_STATE_LAYER_COMPATIBILITY_GATE,
30
32
  CONTEXT_INJECTION_COMPATIBILITY_GATE,
31
33
  CODEX_PROVIDER_COMPATIBILITY_GATE,
package/dist/config.d.ts CHANGED
@@ -3,6 +3,7 @@ export declare const MAX_TIMER_DELAY_MINUTES: number;
3
3
  export declare const DEFAULT_COPILOT_SESSION_TTL_MINUTES: number;
4
4
  export declare const PROVIDER_IDLE_SHUTDOWN_DISABLED_MINUTES = 0;
5
5
  export declare const DEFAULT_PROVIDER_IDLE_SHUTDOWN_MINUTES = 10;
6
+ export declare const DEFAULT_ALLOW_CROSS_AGENT_INDEPENDENT_WORKSPACE_HANDOFF = true;
6
7
  export declare const DEFAULT_CLAUDE_COMMAND = "claude-agent-acp";
7
8
  export declare const LEGACY_CLAUDE_COMMAND = "claude";
8
9
  export declare const LEGACY_CLAUDE_ONE_SHOT_ARGS: readonly ["--print", "--permission-mode", "bypassPermissions"];
@@ -13,6 +14,7 @@ export declare function parseArgs(value: string): string[];
13
14
  export declare function optionalStringArray(value: unknown, fieldName: string, sourceLabel: string): string[] | undefined;
14
15
  export declare function parseCopilotSessionTtlMinutesValue(value: unknown, sourceLabel: string): number;
15
16
  export declare function parseProviderIdleShutdownMinutesValue(value: unknown, sourceLabel: string): number;
17
+ export declare function parseBooleanValue(value: unknown, envName: string, sourceLabel: string): boolean;
16
18
  export declare function resolveProvider(rawValue: string, sourceLabel: string): ProviderKind;
17
19
  export declare function assertProviderCompatibility(provider: ProviderKind, sourceLabel: string, env?: NodeJS.ProcessEnv): void;
18
20
  export declare function isLegacyClaudeCompatibilityAlias(command: string, args: string[]): boolean;
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@ export const MAX_TIMER_DELAY_MINUTES = MAX_TIMER_DELAY_MS / 60_000;
6
6
  export const DEFAULT_COPILOT_SESSION_TTL_MINUTES = 2 * 24 * 60;
7
7
  export const PROVIDER_IDLE_SHUTDOWN_DISABLED_MINUTES = 0;
8
8
  export const DEFAULT_PROVIDER_IDLE_SHUTDOWN_MINUTES = 10;
9
+ export const DEFAULT_ALLOW_CROSS_AGENT_INDEPENDENT_WORKSPACE_HANDOFF = true;
9
10
  export const DEFAULT_CLAUDE_COMMAND = 'claude-agent-acp';
10
11
  export const LEGACY_CLAUDE_COMMAND = 'claude';
11
12
  export const LEGACY_CLAUDE_ONE_SHOT_ARGS = ['--print', '--permission-mode', 'bypassPermissions'];
@@ -18,6 +19,7 @@ export const DEFAULT_PROVIDER_COMMAND_CONFIG = {
18
19
  copilotArgs: ['-s', '--no-color', '--allow-all-tools', '--output-format', 'text'],
19
20
  copilotSessionTtlMinutes: DEFAULT_COPILOT_SESSION_TTL_MINUTES,
20
21
  providerIdleShutdownMinutes: DEFAULT_PROVIDER_IDLE_SHUTDOWN_MINUTES,
22
+ allowCrossAgentIndependentWorkspaceHandoff: DEFAULT_ALLOW_CROSS_AGENT_INDEPENDENT_WORKSPACE_HANDOFF,
21
23
  };
22
24
  function requireEnv(name, env) {
23
25
  return requireNonEmptyString(env[name], `Missing required environment variable: ${name}`);
@@ -75,6 +77,19 @@ export function parseProviderIdleShutdownMinutesValue(value, sourceLabel) {
75
77
  }
76
78
  return parsed;
77
79
  }
80
+ export function parseBooleanValue(value, envName, sourceLabel) {
81
+ if (typeof value === 'boolean') {
82
+ return value;
83
+ }
84
+ const normalized = String(value).trim().toLowerCase();
85
+ if (normalized === 'true') {
86
+ return true;
87
+ }
88
+ if (normalized === 'false') {
89
+ return false;
90
+ }
91
+ throw new Error(`Invalid ${envName} in ${sourceLabel}: expected true or false`);
92
+ }
78
93
  export function resolveProvider(rawValue, sourceLabel) {
79
94
  const raw = rawValue.trim().toLowerCase();
80
95
  if (raw === 'claude' || raw === 'codex' || raw === 'copilot') {
@@ -134,6 +149,8 @@ export function resolveProviderCommandConfig(overrides = {}) {
134
149
  copilotSessionTtlMinutes: overrides.copilotSessionTtlMinutes ?? DEFAULT_PROVIDER_COMMAND_CONFIG.copilotSessionTtlMinutes,
135
150
  providerIdleShutdownMinutes: overrides.providerIdleShutdownMinutes
136
151
  ?? DEFAULT_PROVIDER_COMMAND_CONFIG.providerIdleShutdownMinutes,
152
+ allowCrossAgentIndependentWorkspaceHandoff: overrides.allowCrossAgentIndependentWorkspaceHandoff
153
+ ?? DEFAULT_PROVIDER_COMMAND_CONFIG.allowCrossAgentIndependentWorkspaceHandoff,
137
154
  };
138
155
  }
139
156
  export function loadConfigFromEnv(env = process.env) {
@@ -153,6 +170,10 @@ export function loadConfigFromEnv(env = process.env) {
153
170
  providerIdleShutdownMinutes: env.PROVIDER_IDLE_SHUTDOWN_MINUTES && env.PROVIDER_IDLE_SHUTDOWN_MINUTES.trim().length > 0
154
171
  ? parseProviderIdleShutdownMinutesValue(env.PROVIDER_IDLE_SHUTDOWN_MINUTES, 'environment variable PROVIDER_IDLE_SHUTDOWN_MINUTES')
155
172
  : DEFAULT_PROVIDER_COMMAND_CONFIG.providerIdleShutdownMinutes,
173
+ allowCrossAgentIndependentWorkspaceHandoff: env.ALLOW_CROSS_AGENT_INDEPENDENT_WORKSPACE_HANDOFF
174
+ && env.ALLOW_CROSS_AGENT_INDEPENDENT_WORKSPACE_HANDOFF.trim().length > 0
175
+ ? parseBooleanValue(env.ALLOW_CROSS_AGENT_INDEPENDENT_WORKSPACE_HANDOFF, 'ALLOW_CROSS_AGENT_INDEPENDENT_WORKSPACE_HANDOFF', 'environment variable ALLOW_CROSS_AGENT_INDEPENDENT_WORKSPACE_HANDOFF')
176
+ : DEFAULT_PROVIDER_COMMAND_CONFIG.allowCrossAgentIndependentWorkspaceHandoff,
156
177
  });
157
178
  assertProviderCommandCompatibility(provider, providerConfig, 'environment variables');
158
179
  return {