@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
@@ -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',
@@ -9,5 +9,6 @@ export declare class ClaudeProviderAdapter implements ProviderAdapter {
9
9
  readonly capabilities: import("../provider-adapter.js").ProviderCapabilities;
10
10
  constructor(cli: ClaudeCliClient, turnPreparer: ProviderTurnPreparer);
11
11
  generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
12
+ cancelTurn(channelId: string): Promise<boolean>;
12
13
  dispose(): Promise<void>;
13
14
  }
@@ -25,6 +25,9 @@ export class ClaudeProviderAdapter {
25
25
  // session as it runs, so before this point there is nothing to report.
26
26
  return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.channelId) };
27
27
  }
28
+ async cancelTurn(channelId) {
29
+ return this.cli.cancelTurn(channelId);
30
+ }
28
31
  async dispose() {
29
32
  await this.cli.dispose();
30
33
  }
@@ -43,6 +43,7 @@ export declare class ClaudeCliClient {
43
43
  * worked a task without the agent being asked to report its own id.
44
44
  */
45
45
  sessionIdForChannel(channelId: string): string | undefined;
46
+ cancelTurn(channelId: string): Promise<boolean>;
46
47
  private readonly persistedSessions;
47
48
  private readonly closingSessions;
48
49
  private readonly pendingSessionStarts;
@@ -93,7 +94,7 @@ export declare class ClaudeCliClient {
93
94
  private invalidateSession;
94
95
  private closeSession;
95
96
  private rejectQueuedTurnsAfterSessionTaint;
96
- private findChannelIdBySessionId;
97
+ private findChannelStateBySessionId;
97
98
  private currentSessionStoreAgentId;
98
99
  private ensureSessionStoreLoaded;
99
100
  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';
@@ -8,9 +9,11 @@ import { assertClaudeCommandCompatibility, DEFAULT_CLAUDE_COMMAND, isLegacyClaud
8
9
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
9
10
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
10
11
  import { AcpProgressCollector } from '../acp-progress-collector.js';
12
+ import { ProviderTurnCancelledError } from '../provider-adapter.js';
11
13
  import { readClaudeActivityMetadata } from './activity-metadata.js';
12
14
  import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
13
15
  import { buildClaudeSessionPromptAppend } from '../../context/prompt.js';
16
+ import { isDiscussionOnlyResolvedWorkspace } from '../../context/resolved-workspace.js';
14
17
  import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
15
18
  const SESSION_TAINTED_ERRORS = new WeakSet();
16
19
  const IDLE_BACKEND_STOPPED_MESSAGE = 'Claude ACP backend stopped after idle timeout';
@@ -30,6 +33,7 @@ const DEFAULT_SESSION_CAPABILITIES = {
30
33
  };
31
34
  const CLAUDE_SDK_COMPACTION_EXPERIMENT_CONTEXT_TOKEN_THRESHOLD_ENV = 'CLAUDE_SDK_COMPACTION_EXPERIMENT_CONTEXT_TOKEN_THRESHOLD';
32
35
  const require = createRequire(import.meta.url);
36
+ const CLAUDE_CANCELLED_REPLY_TEXT = 'Info: Operation cancelled by user';
33
37
  const DEFAULT_RUNTIME = {
34
38
  spawn,
35
39
  client,
@@ -386,6 +390,22 @@ function hashSessionAppend(systemPromptAppend) {
386
390
  }
387
391
  return createHash('sha256').update(systemPromptAppend).digest('hex');
388
392
  }
393
+ function resolveSkillManualContentHash(promptContext) {
394
+ const skillMarkdownPath = promptContext?.skillRuntime?.skillMarkdownPath;
395
+ if (!skillMarkdownPath) {
396
+ return undefined;
397
+ }
398
+ try {
399
+ return createHash('sha256')
400
+ .update(skillMarkdownPath)
401
+ .update('\0')
402
+ .update(readFileSync(skillMarkdownPath))
403
+ .digest('hex');
404
+ }
405
+ catch {
406
+ return `unreadable:${skillMarkdownPath}`;
407
+ }
408
+ }
389
409
  function resolveSessionVisibilityKey(promptContext, systemPromptAppend, promptStrategy) {
390
410
  const resolvedPromptStrategy = resolveProjectionStrategy(promptStrategy);
391
411
  const explicitPromptStrategy = resolvedPromptStrategy === DEFAULT_PROJECTION_STRATEGY && promptStrategy == null
@@ -395,9 +415,11 @@ function resolveSessionVisibilityKey(promptContext, systemPromptAppend, promptSt
395
415
  : resolvedPromptStrategy;
396
416
  const additionalDirectories = resolveSessionAdditionalDirectories(promptContext);
397
417
  const sessionAppendHash = hashSessionAppend(systemPromptAppend);
418
+ const skillManualContentHash = resolveSkillManualContentHash(promptContext);
398
419
  const projectedBriefHash = promptContext?.claudeProjectedBriefHash;
399
420
  if (!additionalDirectories
400
421
  && !sessionAppendHash
422
+ && !skillManualContentHash
401
423
  && !projectedBriefHash
402
424
  && !promptContext?.gatewayCredentialPath
403
425
  && !explicitPromptStrategy) {
@@ -407,12 +429,14 @@ function resolveSessionVisibilityKey(promptContext, systemPromptAppend, promptSt
407
429
  return JSON.stringify({
408
430
  ...(additionalDirectories ? { additionalDirectories } : {}),
409
431
  sessionAppendHash,
432
+ ...(skillManualContentHash ? { skillManualContentHash } : {}),
410
433
  ...(projectedBriefHash ? { projectedBriefHash } : {}),
411
434
  ...(explicitPromptStrategy ? { promptStrategy: explicitPromptStrategy } : {}),
412
435
  });
413
436
  }
414
437
  return JSON.stringify({
415
438
  ...(additionalDirectories ? { additionalDirectories } : {}),
439
+ ...(skillManualContentHash ? { skillManualContentHash } : {}),
416
440
  ...(promptContext?.gatewayCredentialPath
417
441
  ? { gatewayCredentialPath: promptContext.gatewayCredentialPath }
418
442
  : {}),
@@ -473,6 +497,22 @@ export class ClaudeCliClient {
473
497
  sessionIdForChannel(channelId) {
474
498
  return this.channels.get(channelId)?.session?.sessionId;
475
499
  }
500
+ async cancelTurn(channelId) {
501
+ const state = this.channels.get(channelId);
502
+ const session = state?.session;
503
+ if (!state?.activeTurn || !session) {
504
+ return false;
505
+ }
506
+ this.idleBackendShutdown.cancel();
507
+ const activeSession = session;
508
+ if (typeof activeSession.cancel === 'function') {
509
+ await activeSession.cancel({ sessionId: session.sessionId });
510
+ return true;
511
+ }
512
+ const cancelMethod = this.runtime.methods.agent.session?.cancel ?? 'session/cancel';
513
+ await this.connection?.agent.notify(cancelMethod, { sessionId: session.sessionId });
514
+ return true;
515
+ }
476
516
  persistedSessions = new Map();
477
517
  closingSessions = new WeakSet();
478
518
  pendingSessionStarts = new Set();
@@ -721,6 +761,9 @@ export class ClaudeCliClient {
721
761
  state.activeTurn = turn;
722
762
  try {
723
763
  state.cwd = this.resolveSessionCwd(turn.preparedTurn.promptContext);
764
+ state.toolPermissionMode = isDiscussionOnlyResolvedWorkspace(turn.preparedTurn.promptContext)
765
+ ? 'deny-all'
766
+ : 'default';
724
767
  const sessionPromptAppend = resolveTurnSessionPromptAppend(turn.preparedTurn);
725
768
  state.visibilityKey = resolveSessionVisibilityKey(turn.preparedTurn.promptContext, sessionPromptAppend, turn.preparedTurn.projectionStrategy);
726
769
  await this.ensureStarted();
@@ -950,6 +993,11 @@ export class ClaudeCliClient {
950
993
  throw markSessionTainted(error);
951
994
  }
952
995
  const output = collector.getFinalText();
996
+ if (response.stopReason === 'cancelled') {
997
+ throw new ProviderTurnCancelledError('Claude ACP turn cancelled', {
998
+ publicReplyText: hasVisibleText(output) ? output : CLAUDE_CANCELLED_REPLY_TEXT,
999
+ });
1000
+ }
953
1001
  if (response.stopReason !== 'end_turn' && !hasVisibleText(output)) {
954
1002
  throw new Error(`Claude ACP turn stopped with stopReason "${response.stopReason}"`);
955
1003
  }
@@ -999,13 +1047,15 @@ export class ClaudeCliClient {
999
1047
  this.shutdownPromise = this.shutdownBackend(error);
1000
1048
  }
1001
1049
  handlePermissionRequest(params) {
1050
+ const state = this.findChannelStateBySessionId(params.sessionId);
1002
1051
  return resolveCopilotPermissionResponse({
1003
1052
  gateEnabled: true,
1004
1053
  policyMode: 'enforce',
1005
1054
  params,
1006
1055
  logger: this.logger,
1007
1056
  agentId: this.resolveSessionStoreAgentId()?.trim() || undefined,
1008
- channelId: this.findChannelIdBySessionId(params.sessionId),
1057
+ channelId: state?.channelId,
1058
+ toolPermissionMode: state?.toolPermissionMode ?? 'default',
1009
1059
  });
1010
1060
  }
1011
1061
  invalidateSession(channelId, state, session) {
@@ -1057,10 +1107,10 @@ export class ClaudeCliClient {
1057
1107
  }
1058
1108
  state.queue.length = 0;
1059
1109
  }
1060
- findChannelIdBySessionId(sessionId) {
1110
+ findChannelStateBySessionId(sessionId) {
1061
1111
  for (const state of this.channels.values()) {
1062
1112
  if (state.session?.sessionId === sessionId) {
1063
- return state.channelId;
1113
+ return state;
1064
1114
  }
1065
1115
  }
1066
1116
  return undefined;
@@ -9,5 +9,6 @@ export declare class CodexProviderAdapter implements ProviderAdapter {
9
9
  readonly capabilities: import("../provider-adapter.js").ProviderCapabilities;
10
10
  constructor(cli: CodexCliClient, turnPreparer: ProviderTurnPreparer);
11
11
  generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
12
+ cancelTurn(channelId: string): Promise<boolean>;
12
13
  dispose(): Promise<void>;
13
14
  }
@@ -25,6 +25,9 @@ export class CodexProviderAdapter {
25
25
  // session as it runs, so before this point there is nothing to report.
26
26
  return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.channelId) };
27
27
  }
28
+ async cancelTurn(channelId) {
29
+ return this.cli.cancelTurn(channelId);
30
+ }
28
31
  async dispose() {
29
32
  await this.cli.dispose();
30
33
  }
@@ -49,6 +49,7 @@ export declare class CodexCliClient {
49
49
  * worked a task without the agent being asked to report its own id.
50
50
  */
51
51
  sessionIdForChannel(channelId: string): string | undefined;
52
+ cancelTurn(channelId: string): Promise<boolean>;
52
53
  private readonly persistedSessions;
53
54
  private readonly closingSessions;
54
55
  private readonly pendingSessionStarts;
@@ -108,6 +109,8 @@ export declare class CodexCliClient {
108
109
  private failAll;
109
110
  private closeSession;
110
111
  private clearIdleTimer;
112
+ private handlePermissionRequest;
113
+ private findChannelStateBySessionId;
111
114
  private reconcileIdleChannelState;
112
115
  private ensureSessionStoreLoaded;
113
116
  private readPersistedSessionId;
@@ -6,8 +6,11 @@ import spawn from 'cross-spawn';
6
6
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
7
7
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
8
8
  import { AcpProgressCollector } from '../acp-progress-collector.js';
9
+ import { ProviderTurnCancelledError } from '../provider-adapter.js';
9
10
  import { assertCodexProjectDocumentSize, buildCodexProjectDocument } from './project-doc.js';
10
11
  import { isGatewayCredentialSidecarBasename } from '../../context/injection.js';
12
+ import { isDiscussionOnlyResolvedWorkspace } from '../../context/resolved-workspace.js';
13
+ import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
11
14
  import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
12
15
  const SESSION_TAINTED_ERRORS = new WeakSet();
13
16
  const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
@@ -32,6 +35,7 @@ const CODEX_CONTEXT_ROOT_DIRNAME = 'codex-context';
32
35
  const PROJECTION_UNAVAILABLE_PLACEHOLDER = '[codex-projection-unavailable]';
33
36
  const QUEUED_TURN_DROPPED_MESSAGE = 'Codex ACP session was reset after a failed turn; queued turns were dropped instead of replaying them on a fresh session';
34
37
  const require = createRequire(import.meta.url);
38
+ const CODEX_CANCELLED_REPLY_TEXT = 'Info: Operation cancelled by user';
35
39
  const DEFAULT_RUNTIME = {
36
40
  spawn,
37
41
  client,
@@ -193,26 +197,6 @@ function markSessionTainted(error) {
193
197
  function isSessionTainted(error) {
194
198
  return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
195
199
  }
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
200
  function resolveBundledCodexAdapterPath() {
217
201
  return require.resolve('@agentclientprotocol/codex-acp');
218
202
  }
@@ -248,6 +232,23 @@ export class CodexCliClient {
248
232
  sessionIdForChannel(channelId) {
249
233
  return this.channels.get(channelId)?.session?.sessionId;
250
234
  }
235
+ async cancelTurn(channelId) {
236
+ const state = this.channels.get(channelId);
237
+ const session = state?.session;
238
+ if (!state?.activeTurn || !session) {
239
+ return false;
240
+ }
241
+ this.clearIdleTimer(state);
242
+ this.idleBackendShutdown.cancel();
243
+ const activeSession = session;
244
+ if (typeof activeSession.cancel === 'function') {
245
+ await activeSession.cancel({ sessionId: session.sessionId });
246
+ return true;
247
+ }
248
+ const cancelMethod = this.runtime.methods.agent.session?.cancel ?? 'session/cancel';
249
+ await this.connection?.agent.notify(cancelMethod, { sessionId: session.sessionId });
250
+ return true;
251
+ }
251
252
  persistedSessions = new Map();
252
253
  closingSessions = new WeakSet();
253
254
  pendingSessionStarts = new Set();
@@ -423,7 +424,7 @@ export class CodexCliClient {
423
424
  const stream = this.runtime.ndJsonStream(output, input);
424
425
  const app = this.runtime
425
426
  .client({ name: 'borgee-agents-host' })
426
- .onRequest(this.runtime.methods.client.session.requestPermission, ({ params }) => (createPermissionResponse(params)));
427
+ .onRequest(this.runtime.methods.client.session.requestPermission, ({ params }) => (this.handlePermissionRequest(params)));
427
428
  const connection = app.connect(stream);
428
429
  this.connection = connection;
429
430
  void connection.closed.then(() => {
@@ -487,6 +488,9 @@ export class CodexCliClient {
487
488
  state.activeTurn = turn;
488
489
  try {
489
490
  state.cwd = await this.resolveSessionCwd(turn.preparedTurn.promptContext);
491
+ state.toolPermissionMode = isDiscussionOnlyResolvedWorkspace(turn.preparedTurn.promptContext)
492
+ ? 'deny-all'
493
+ : 'default';
490
494
  const projectedPromptContext = await this.refreshProjectedPromptContextBestEffort(turn.channelId, turn.preparedTurn.promptContext);
491
495
  state.additionalDirectories = this.resolveSessionAdditionalDirectories(projectedPromptContext);
492
496
  state.visibilityKey = this.resolveSessionVisibilityKey(projectedPromptContext);
@@ -846,10 +850,15 @@ export class CodexCliClient {
846
850
  catch (error) {
847
851
  throw markSessionTainted(error);
848
852
  }
853
+ const output = collector.getFinalText();
854
+ if (response.stopReason === 'cancelled') {
855
+ throw new ProviderTurnCancelledError('Codex ACP turn cancelled', {
856
+ publicReplyText: output || CODEX_CANCELLED_REPLY_TEXT,
857
+ });
858
+ }
849
859
  if (response.stopReason !== 'end_turn') {
850
860
  throw new Error(`Codex ACP turn stopped with stopReason "${response.stopReason}"`);
851
861
  }
852
- const output = collector.getFinalText();
853
862
  if (!output) {
854
863
  throw new Error('Codex ACP returned empty output');
855
864
  }
@@ -953,6 +962,25 @@ export class CodexCliClient {
953
962
  clearTimeout(state.idleTimer);
954
963
  state.idleTimer = undefined;
955
964
  }
965
+ handlePermissionRequest(params) {
966
+ const state = this.findChannelStateBySessionId(params.sessionId);
967
+ return resolveCopilotPermissionResponse({
968
+ gateEnabled: true,
969
+ policyMode: 'enforce',
970
+ params,
971
+ logger: this.logger,
972
+ channelId: state?.channelId,
973
+ toolPermissionMode: state?.toolPermissionMode ?? 'default',
974
+ });
975
+ }
976
+ findChannelStateBySessionId(sessionId) {
977
+ for (const state of this.channels.values()) {
978
+ if (state.session?.sessionId === sessionId) {
979
+ return state;
980
+ }
981
+ }
982
+ return undefined;
983
+ }
956
984
  reconcileIdleChannelState(channelId, state) {
957
985
  if (this.channels.get(channelId) !== state) {
958
986
  return;
@@ -9,5 +9,6 @@ export declare class CopilotProviderAdapter implements ProviderAdapter {
9
9
  readonly capabilities: import("../provider-adapter.js").ProviderCapabilities;
10
10
  constructor(cli: CopilotCliClient, turnPreparer: ProviderTurnPreparer);
11
11
  generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
12
+ cancelTurn(channelId: string): Promise<boolean>;
12
13
  dispose(): Promise<void>;
13
14
  }
@@ -25,6 +25,9 @@ export class CopilotProviderAdapter {
25
25
  // session as it runs, so before this point there is nothing to report.
26
26
  return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.channelId) };
27
27
  }
28
+ async cancelTurn(channelId) {
29
+ return this.cli.cancelTurn(channelId);
30
+ }
28
31
  async dispose() {
29
32
  await this.cli.dispose();
30
33
  }
@@ -51,6 +51,7 @@ export declare class CopilotCliClient {
51
51
  * worked a task without the agent being asked to report its own id.
52
52
  */
53
53
  sessionIdForChannel(channelId: string): string | undefined;
54
+ cancelTurn(channelId: string): Promise<boolean>;
54
55
  private readonly persistedSessions;
55
56
  private readonly closingSessions;
56
57
  private readonly pendingSessionStarts;
@@ -103,7 +104,7 @@ export declare class CopilotCliClient {
103
104
  private closeSession;
104
105
  private rejectQueuedTurnsAfterSessionTaint;
105
106
  private clearIdleTimer;
106
- private findChannelIdBySessionId;
107
+ private findChannelStateBySessionId;
107
108
  private reconcileIdleChannelState;
108
109
  private ensureSessionStoreLoaded;
109
110
  private readPersistedSessionId;
@@ -3,7 +3,9 @@ import spawn from 'cross-spawn';
3
3
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
4
4
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
5
5
  import { AcpProgressCollector } from '../acp-progress-collector.js';
6
+ import { ProviderTurnCancelledError } from '../provider-adapter.js';
6
7
  import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
8
+ import { isDiscussionOnlyResolvedWorkspace } from '../../context/resolved-workspace.js';
7
9
  import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
8
10
  const SESSION_TAINTED_ERRORS = new WeakSet();
9
11
  const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
@@ -169,6 +171,23 @@ export class CopilotCliClient {
169
171
  sessionIdForChannel(channelId) {
170
172
  return this.channels.get(channelId)?.session?.sessionId;
171
173
  }
174
+ async cancelTurn(channelId) {
175
+ const state = this.channels.get(channelId);
176
+ const session = state?.session;
177
+ if (!state?.activeTurn || !session) {
178
+ return false;
179
+ }
180
+ this.clearIdleTimer(state);
181
+ this.idleBackendShutdown.cancel();
182
+ const activeSession = session;
183
+ if (typeof activeSession.cancel === 'function') {
184
+ await activeSession.cancel({ sessionId: session.sessionId });
185
+ return true;
186
+ }
187
+ const cancelMethod = this.runtime.methods.agent.session?.cancel ?? 'session/cancel';
188
+ await this.connection?.agent.notify(cancelMethod, { sessionId: session.sessionId });
189
+ return true;
190
+ }
172
191
  persistedSessions = new Map();
173
192
  closingSessions = new WeakSet();
174
193
  pendingSessionStarts = new Set();
@@ -407,6 +426,9 @@ export class CopilotCliClient {
407
426
  state.activeTurn = turn;
408
427
  try {
409
428
  state.cwd = await this.resolveSessionCwd(turn.preparedTurn.promptContext);
429
+ state.toolPermissionMode = isDiscussionOnlyResolvedWorkspace(turn.preparedTurn.promptContext)
430
+ ? 'deny-all'
431
+ : 'default';
410
432
  await this.ensureStarted();
411
433
  await this.recycleSessionIfCwdChanged(channelId, state);
412
434
  const session = await this.getOrCreateSession(channelId, state);
@@ -647,6 +669,9 @@ export class CopilotCliClient {
647
669
  catch (error) {
648
670
  throw markSessionTainted(error);
649
671
  }
672
+ if (response.stopReason === 'cancelled') {
673
+ throw new ProviderTurnCancelledError('Copilot ACP turn cancelled');
674
+ }
650
675
  if (response.stopReason !== 'end_turn') {
651
676
  throw new Error(`Copilot ACP turn stopped with stopReason "${response.stopReason}"`);
652
677
  }
@@ -666,6 +691,7 @@ export class CopilotCliClient {
666
691
  return Promise.race([promise, this.fatalPromise]);
667
692
  }
668
693
  handlePermissionRequest(params) {
694
+ const state = this.findChannelStateBySessionId(params.sessionId);
669
695
  return resolveCopilotPermissionResponse({
670
696
  gateEnabled: this.permissionPolicy.gateEnabled ?? false,
671
697
  policyMode: this.permissionPolicy.policyMode ?? 'audit-only',
@@ -673,7 +699,8 @@ export class CopilotCliClient {
673
699
  logger: this.logger,
674
700
  auditSink: this.permissionPolicy.auditSink,
675
701
  agentId: this.resolveSessionStoreAgentId()?.trim() || undefined,
676
- channelId: this.findChannelIdBySessionId(params.sessionId),
702
+ channelId: state?.channelId,
703
+ toolPermissionMode: state?.toolPermissionMode ?? 'default',
677
704
  });
678
705
  }
679
706
  failAll(error) {
@@ -761,10 +788,10 @@ export class CopilotCliClient {
761
788
  clearTimeout(state.idleTimer);
762
789
  state.idleTimer = undefined;
763
790
  }
764
- findChannelIdBySessionId(sessionId) {
791
+ findChannelStateBySessionId(sessionId) {
765
792
  for (const state of this.channels.values()) {
766
793
  if (state.session?.sessionId === sessionId) {
767
- return state.channelId;
794
+ return state;
768
795
  }
769
796
  }
770
797
  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 {};
@@ -30,6 +30,9 @@ class ProviderV2CompatibilityAdapter {
30
30
  turn: preparedTurn,
31
31
  }, options);
32
32
  }
33
+ async cancelTurn(channelId) {
34
+ return this.provider.cancelTurn?.(channelId) ?? false;
35
+ }
33
36
  async dispose() {
34
37
  await this.provider.shutdown?.();
35
38
  }
@@ -47,6 +50,9 @@ class CliBackedProviderV2 {
47
50
  // session as it runs, so before this point there is nothing to report.
48
51
  return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.turn.channelId) };
49
52
  }
53
+ async cancelTurn(channelId) {
54
+ return this.cli.cancelTurn(channelId);
55
+ }
50
56
  async shutdown() {
51
57
  await this.cli.dispose();
52
58
  }
@@ -135,6 +141,8 @@ export function createProvider(config, debugLogger, options = {}) {
135
141
  ? options.localhostGateway
136
142
  : undefined,
137
143
  taskReader: options.controlPlane,
144
+ allowCrossAgentIndependentWorkspaceHandoff: config.allowCrossAgentIndependentWorkspaceHandoff,
145
+ resolveStableAgentId: config.resolveStableAgentId,
138
146
  })
139
147
  : undefined;
140
148
  const resolveInternalProjection = () => resolveInternalProjectionStrategy();
@@ -33,8 +33,15 @@ export declare function createHostedProviderCapabilities(params: {
33
33
  sharedHostFallback?: SharedHostAttachmentMetadataFallbackCapability;
34
34
  }): ProviderCapabilities;
35
35
  export declare const TEXT_ONLY_HOSTED_PROVIDER_CAPABILITIES: ProviderCapabilities;
36
+ export declare class ProviderTurnCancelledError extends Error {
37
+ readonly publicReplyText?: string;
38
+ constructor(message?: string, options?: {
39
+ publicReplyText?: string;
40
+ });
41
+ }
36
42
  export interface ProviderAdapter {
37
43
  readonly capabilities: ProviderCapabilities;
38
44
  generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
45
+ cancelTurn?(channelId: string): Promise<boolean>;
39
46
  dispose?(): Promise<void>;
40
47
  }
@@ -42,3 +42,11 @@ export const TEXT_ONLY_HOSTED_PROVIDER_CAPABILITIES = createHostedProviderCapabi
42
42
  delivery: [],
43
43
  },
44
44
  });
45
+ export class ProviderTurnCancelledError extends Error {
46
+ publicReplyText;
47
+ constructor(message = 'provider turn cancelled', options) {
48
+ super(message);
49
+ this.name = 'ProviderTurnCancelledError';
50
+ this.publicReplyText = options?.publicReplyText;
51
+ }
52
+ }
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.65",
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",