@borgee/agents-host 0.2.74 → 0.2.94

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 (82) hide show
  1. package/README.md +11 -24
  2. package/dist/agents-host.d.ts +6 -4
  3. package/dist/agents-host.js +127 -16
  4. package/dist/background-runs.d.ts +24 -0
  5. package/dist/background-runs.js +184 -0
  6. package/dist/chat/chat-control-plane.d.ts +7 -1
  7. package/dist/chat/sdk-chat-control-plane.d.ts +7 -2
  8. package/dist/chat/sdk-chat-control-plane.js +22 -0
  9. package/dist/context/claude-file-brief.js +3 -3
  10. package/dist/context/injection.d.ts +18 -12
  11. package/dist/context/injection.js +53 -53
  12. package/dist/context/prompt.js +10 -10
  13. package/dist/context/resolved-working-folder.d.ts +3 -0
  14. package/dist/context/resolved-working-folder.js +106 -0
  15. package/dist/context/turn-preparation.js +1 -1
  16. package/dist/managed-daemon.js +30 -30
  17. package/dist/plugin-sdk.js +315 -29
  18. package/dist/plugin-sdk.js.map +3 -3
  19. package/dist/policy/authorization-audit.d.ts +1 -1
  20. package/dist/policy/copilot-permission.d.ts +9 -0
  21. package/dist/policy/copilot-permission.js +120 -1
  22. package/dist/progress-to-activity.d.ts +1 -1
  23. package/dist/progress-to-activity.js +1 -0
  24. package/dist/providers/claude/adapter.d.ts +2 -1
  25. package/dist/providers/claude/adapter.js +36 -1
  26. package/dist/providers/claude/background-run-observer.d.ts +32 -0
  27. package/dist/providers/claude/background-run-observer.js +381 -0
  28. package/dist/providers/claude/cli-client.d.ts +12 -0
  29. package/dist/providers/claude/cli-client.js +453 -72
  30. package/dist/providers/claude/foreground-handoff.d.ts +4 -0
  31. package/dist/providers/claude/foreground-handoff.js +45 -0
  32. package/dist/providers/claude/task-cancellation-protocol.d.ts +14 -0
  33. package/dist/providers/claude/task-cancellation-protocol.js +21 -0
  34. package/dist/providers/codex/adapter.d.ts +1 -1
  35. package/dist/providers/codex/adapter.js +7 -0
  36. package/dist/providers/codex/cli-client.js +8 -7
  37. package/dist/providers/codex/project-doc.js +6 -6
  38. package/dist/providers/copilot/activity-metadata.d.ts +3 -0
  39. package/dist/providers/copilot/activity-metadata.js +19 -0
  40. package/dist/providers/copilot/adapter.d.ts +6 -2
  41. package/dist/providers/copilot/adapter.js +27 -1
  42. package/dist/providers/copilot/cli-client.d.ts +37 -10
  43. package/dist/providers/copilot/cli-client.js +756 -122
  44. package/dist/providers/copilot/sdk-session.d.ts +149 -0
  45. package/dist/providers/copilot/sdk-session.js +981 -0
  46. package/dist/providers/create-provider.js +33 -0
  47. package/dist/providers/provider-adapter.d.ts +24 -1
  48. package/dist/providers/provider-adapter.js +17 -0
  49. package/dist/types.d.ts +40 -11
  50. package/dist/vendor/claude-agent-acp/LICENSE +191 -0
  51. package/dist/vendor/claude-agent-acp/NOTICE +8 -0
  52. package/dist/vendor/claude-agent-acp/dist/acp-agent.d.ts +1017 -0
  53. package/dist/vendor/claude-agent-acp/dist/acp-agent.d.ts.map +1 -0
  54. package/dist/vendor/claude-agent-acp/dist/acp-agent.js +6305 -0
  55. package/dist/vendor/claude-agent-acp/dist/borgee-foreground-handoff-bridge.js +322 -0
  56. package/dist/vendor/claude-agent-acp/dist/borgee-task-cancellation-bridge.js +101 -0
  57. package/dist/vendor/claude-agent-acp/dist/borgee-task-lifecycle-bridge.js +58 -0
  58. package/dist/vendor/claude-agent-acp/dist/elicitation.d.ts +130 -0
  59. package/dist/vendor/claude-agent-acp/dist/elicitation.d.ts.map +1 -0
  60. package/dist/vendor/claude-agent-acp/dist/elicitation.js +304 -0
  61. package/dist/vendor/claude-agent-acp/dist/index.d.ts +3 -0
  62. package/dist/vendor/claude-agent-acp/dist/index.d.ts.map +1 -0
  63. package/dist/vendor/claude-agent-acp/dist/index.js +75 -0
  64. package/dist/vendor/claude-agent-acp/dist/lib.d.ts +6 -0
  65. package/dist/vendor/claude-agent-acp/dist/lib.d.ts.map +1 -0
  66. package/dist/vendor/claude-agent-acp/dist/lib.js +5 -0
  67. package/dist/vendor/claude-agent-acp/dist/settings.d.ts +68 -0
  68. package/dist/vendor/claude-agent-acp/dist/settings.d.ts.map +1 -0
  69. package/dist/vendor/claude-agent-acp/dist/settings.js +185 -0
  70. package/dist/vendor/claude-agent-acp/dist/tools.d.ts +102 -0
  71. package/dist/vendor/claude-agent-acp/dist/tools.d.ts.map +1 -0
  72. package/dist/vendor/claude-agent-acp/dist/tools.js +1000 -0
  73. package/dist/vendor/claude-agent-acp/dist/utils.d.ts +16 -0
  74. package/dist/vendor/claude-agent-acp/dist/utils.d.ts.map +1 -0
  75. package/dist/vendor/claude-agent-acp/dist/utils.js +81 -0
  76. package/dist/vendor/claude-agent-acp/package.json +85 -0
  77. package/package.json +15 -10
  78. package/skills/borgee-agent/references/task-properties.md +3 -3
  79. package/skills/borgee-agent/scripts/borgee-agent.mjs +1 -1
  80. package/skills/borgee-agent/scripts/borgee-agent.py +1 -1
  81. package/dist/context/resolved-workspace.d.ts +0 -3
  82. package/dist/context/resolved-workspace.js +0 -106
package/README.md CHANGED
@@ -101,7 +101,7 @@ Rollback and hardening controls stay explicit:
101
101
  - `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES=<comma-list>` disables shipped gates for rollback
102
102
  - `AGENTS_HOST_INTERNAL_PROVIDER_IMPLEMENTATIONS=copilot:v1` forces Copilot back to the v1 adapter while its v2 gate stays enabled; stale `claude:v1|v2` entries are tolerated as no-ops for compatibility
103
103
  - `AGENTS_HOST_INTERNAL_POLICY_MODE=enforce` hardens the shipped `policy-audit-enforcement` gate beyond its default `audit-only` mode
104
- - `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES=provider-idle-shutdown` keeps every provider's shared ACP adapter process resident again; `PROVIDER_IDLE_SHUTDOWN_MINUTES=0` does the same without touching gates
104
+ - `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES=provider-idle-shutdown` keeps every provider runtime process resident again; `PROVIDER_IDLE_SHUTDOWN_MINUTES=0` does the same without touching gates
105
105
 
106
106
  Or with the CLI:
107
107
 
@@ -239,11 +239,11 @@ Notes:
239
239
  | `RUNTIME_PROVIDER` | `--provider` | no | `claude` | `claude`, `copilot`, or `codex` |
240
240
  | `CLAUDE_COMMAND` / `CLAUDE_ARGS` | `--claude-command` / `--claude-args` | no | `claude-agent-acp` / empty | Local Claude ACP adapter command + args. The shipped bundled `claude-agent-acp` path requires empty `CLAUDE_ARGS`; non-empty args are only valid when `CLAUDE_COMMAND` points at a custom ACP adapter command. Legacy `claude --print --permission-mode bypassPermissions` is tolerated as a compatibility alias. |
241
241
  | `CODEX_COMMAND` / `CODEX_ARGS` | `--codex-command` / `--codex-args` | no | `codex-acp` / empty | Local Codex ACP adapter command + args. The default launch resolves the bundled `@agentclientprotocol/codex-acp` entrypoint. |
242
- | `COPILOT_COMMAND` / `COPILOT_ARGS` | `--copilot-command` / `--copilot-args` | no | `copilot` / parsed but ignored by ACP runtime | Local Copilot CLI command. The persistent Copilot ACP prototype always launches `copilot --acp`. |
243
- | `COPILOT_SESSION_TTL_MINUTES` | `--copilot-session-ttl-minutes` | no | `2880` | Idle TTL for per-channel Copilot ACP sessions |
244
- | `PROVIDER_IDLE_SHUTDOWN_MINUTES` | `--provider-idle-shutdown-minutes` | no | `10` | Idle window before the agent's shared ACP adapter **process** is shut down and re-spawned on the next turn. Applies to Claude, Codex, and Copilot. `0` keeps the process resident. Must be non-negative and no larger than the Node.js timer limit (~35791.39 minutes); anything else is rejected at startup. |
242
+ | `COPILOT_COMMAND` / `COPILOT_ARGS` | `--copilot-command` / `--copilot-args` | no | `copilot` / parsed but ignored | Local Copilot CLI command. The host resolves it to an executable and starts the SDK headless JSON-RPC runtime; legacy `COPILOT_ARGS` remain ignored. |
243
+ | `COPILOT_SESSION_TTL_MINUTES` | `--copilot-session-ttl-minutes` | no | `2880` | Idle TTL for per-channel Copilot SDK sessions |
244
+ | `PROVIDER_IDLE_SHUTDOWN_MINUTES` | `--provider-idle-shutdown-minutes` | no | `10` | Idle window before the agent's shared provider runtime **process** is shut down and re-spawned on the next turn. Applies to Claude, Codex, and Copilot. `0` keeps the process resident. Must be non-negative and no larger than the Node.js timer limit (~35791.39 minutes); anything else is rejected at startup. |
245
245
 
246
- `COPILOT_SESSION_TTL_MINUTES` and `PROVIDER_IDLE_SHUTDOWN_MINUTES` are two different layers and do not replace each other. The first evicts an individual idle Copilot ACP *session* inside a running adapter process. The second shuts the shared adapter *process* itself down once every channel it hosts is idle, for all three providers, and takes every live session in that process with it. See [Conversation memory](#conversation-memory) for what happens to those sessions afterwards.
246
+ `COPILOT_SESSION_TTL_MINUTES` and `PROVIDER_IDLE_SHUTDOWN_MINUTES` are two different layers and do not replace each other. The first evicts an individual idle Copilot SDK *session* inside a running runtime process. The second shuts the shared provider *process* itself down once every channel it hosts is idle, for all three providers, and takes every live session in that process with it. See [Conversation memory](#conversation-memory) for what happens to those sessions afterwards.
247
247
 
248
248
  ## Local-config mode
249
249
 
@@ -479,14 +479,14 @@ 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 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`.
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 working folder `~/.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`. The bundled adapter advertises a versioned targeted task-stop extension backed by the Agent SDK's `Query.stopTask(taskId)`: accepting that request moves only the selected background run to `stopping`, while its provider lifecycle event decides the terminal state and the parent turn plus sibling workers continue unchanged. Custom ACP commands do not receive this capability and remain fail-closed.
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
486
486
  same-directory `AGENTS.md` project document inside a provider-visible
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
- resolved channel workspace `~/.borgee/channels/<encoded-channel-id>/workspace`.
489
+ resolved channel working folder `~/.borgee/channels/<encoded-channel-id>/workspace`.
490
490
  Task threads only switch execution onto an explicit existing
491
491
  `execution.local_directory`; otherwise they stay discussion-only on
492
492
  `~/.borgee/channels/<encoded-thread-id>/scratch/workspace`, and ACP
@@ -494,35 +494,22 @@ active:
494
494
  unavailable. The host persists the
495
495
  channel→ACP-session map in its state root and attempts ACP `session/resume`
496
496
  first when the adapter advertises it, otherwise `session/load`.
497
- - **Copilot**: one persistent `copilot --acp` subprocess is shared by a single
498
- `AgentsHost`, with one ACP session per Borgee channel. Same-channel turns are
499
- serialized; different channels keep isolated ACP sessions. The real ACP
500
- session cwd now comes from the resolved channel workspace
501
- `~/.borgee/channels/<encoded-channel-id>/workspace` by default. Task threads
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
507
- host persists the channel→ACP-session map in its state root and restores
508
- same-host continuity with ACP `session/resume` when available, otherwise
509
- `session/load` plus local replay cleanup on runtimes that advertise only
510
- load support.
497
+ - **Copilot**: one persistent SDK-managed headless Copilot subprocess is shared by a single `AgentsHost`, with one SDK session per Borgee channel. Same-channel turns are serialized; different channels keep isolated sessions. The real session cwd comes from the resolved channel working folder `~/.borgee/channels/<encoded-channel-id>/workspace` by default. 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 SDK permission requests are denied so filesystem and shell tools stay unavailable. The host persists the channel→session map in its state root and restores same-host continuity through SDK session resume. A replacement human prompt uses the SDK's foreground-only interrupt and keeps background work alive; explicit stop interrupts the foreground, cancels background agents, and cancels active background shell tasks in that session.
511
498
 
512
499
  We keep no transcript or history store of our own — only the native session id each provider should try to reopen next time. The transcripts themselves live in each CLI's own on-disk session store: `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for Claude, `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<timestamp>-<threadId>.jsonl` for Codex, and `~/.copilot/session-state/<guid>/` for Copilot. Because those stores are on disk rather than in the adapter process, a session can be reopened later by a *different* process on the same machine — which is what makes conversation memory survive both a host restart and the idle recycle below. Continuity is same-machine and same-`$HOME` only: another machine, or a different home directory, has no session to reopen.
513
500
 
514
501
  ### Idle adapter shutdown
515
502
 
516
- Each of those subprocesses is shut down once every channel it hosts has been idle for `PROVIDER_IDLE_SHUTDOWN_MINUTES` (default `10`), and a fresh one is spawned lazily on the next turn. "Idle" means the host has no active turn, no queued turn, no ACP session start in flight, and nothing still being processed for any channel of that agent; a turn that arrives mid-shutdown waits for the teardown and then starts the replacement rather than failing. Only the provider subprocess is recycled — the agent stays connected to Borgee, the loopback gateway keeps running, and the agents-host process itself is untouched.
503
+ Each of those subprocesses is shut down once every channel it hosts has been idle for `PROVIDER_IDLE_SHUTDOWN_MINUTES` (default `10`), and a fresh one is spawned lazily on the next turn. "Idle" means the host has no active turn, no queued turn, no provider session start in flight, and nothing still being processed for any channel of that agent; a turn that arrives mid-shutdown waits for the teardown and then starts the replacement rather than failing. Only the provider subprocess is recycled — the agent stays connected to Borgee, the loopback gateway keeps running, and the agents-host process itself is untouched.
517
504
 
518
- Conversation memory normally survives the recycle. Before the process goes away the host closes each live ACP session, and on the next turn it reopens the persisted session id against the CLI's on-disk store — Claude and Codex through ACP `session/resume`, Copilot through `session/load` (a sequential resume rather than a live re-sync; the replayed history is discarded locally instead of being re-posted to the channel). This is the same restore path a host restart takes, and its ordinary outcome is that the model still remembers the conversation.
505
+ Conversation memory normally survives the recycle. Before the process goes away the host closes each live provider session, and on the next turn it reopens the persisted session id against the CLI's on-disk store — Claude and Codex through ACP `session/resume`, Copilot through SDK session resume. This is the same restore path a host restart takes, and its ordinary outcome is that the model still remembers the conversation.
519
506
 
520
507
  When a restore does fail, the run does not break: the stale session id is dropped, a fresh `session/new` starts, and the turn still answers coherently because the host re-materializes the per-channel `context.json` and re-injects the same bootstrap context on **every** turn regardless. What is lost in that case is only the CLI-native transcript. The situations that actually cause it:
521
508
 
522
509
  - **A different machine or a different `$HOME`.** The session store is local, so continuity never crosses hosts.
523
510
  - **A stale Copilot session lock.** The Copilot CLI does not reliably unlink its `inuse.<pid>.lock` on an unclean exit ([github/copilot-cli#3255](https://github.com/github/copilot-cli/issues/3255), still open), so a force-killed process can leave a lock that makes the next restore believe the session is still in use. The idle teardown mitigates this: it gives the CLI a 5-second SIGTERM grace before any SIGKILL — twenty times the grace a fatal teardown uses — precisely so the CLI has time to unlink that lock. This is an internal default, not a knob.
524
511
  - **A very large Copilot transcript.** Long-running channels can hit upstream resume bugs: slow or memory-hungry resume ([#4251](https://github.com/github/copilot-cli/issues/4251)), a session that becomes unloadable once `events.jsonl` outgrows the V8 maximum string length ([#4325](https://github.com/github/copilot-cli/issues/4325)), and a resume-triggered compaction hang ([#4138](https://github.com/github/copilot-cli/issues/4138)). A Copilot resume may also reset the model back to the default ([#4397](https://github.com/github/copilot-cli/issues/4397)).
525
- - **Our own mapping rules, which are stricter than the CLI's.** We only reuse a persisted session id when the session's working directory *and* the set of directories exposed to it still match. So if the resolved channel workspace path changes, or if the injected provider-visible directory set changes, the host deliberately drops the old mapping and starts a fresh session even though the CLI could still load it.
512
+ - **Our own mapping rules, which are stricter than the CLI's.** We only reuse a persisted session id when the session's working directory *and* the set of directories exposed to it still match. So if the resolved channel working folder path changes, or if the injected provider-visible directory set changes, the host deliberately drops the old mapping and starts a fresh session even though the CLI could still load it.
526
513
 
527
514
  If you would rather keep the adapter process resident than take any of that, set `PROVIDER_IDLE_SHUTDOWN_MINUTES=0`, raise the window, or roll the behavior back entirely with `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES=provider-idle-shutdown`.
528
515
 
@@ -11,6 +11,7 @@ export declare class AgentsHost {
11
11
  private readonly runtime;
12
12
  private readonly provider;
13
13
  private readonly borgee;
14
+ private readonly backgroundRuns;
14
15
  private readonly logger;
15
16
  private readonly contextInjectionEnabled;
16
17
  private readonly collaborationEnabled;
@@ -33,6 +34,7 @@ export declare class AgentsHost {
33
34
  private readonly compactionSnapshotWriteQueueByChannel;
34
35
  private readonly missedCollaborationDiagnosticByChannel;
35
36
  private readonly channelTurnQueues;
37
+ private readonly autonomousReplyKeys;
36
38
  private readonly collaborationDrafts;
37
39
  private readonly collaborationChannels;
38
40
  private readonly participantDirectoryByUserId;
@@ -54,6 +56,7 @@ export declare class AgentsHost {
54
56
  stop(): Promise<void>;
55
57
  private trackActiveTurn;
56
58
  private dispatchMessage;
59
+ private dispatchAutonomousReply;
57
60
  private handleLegacyMessage;
58
61
  private handleCollaborationMessage;
59
62
  private detectProtocolKickoffCandidate;
@@ -144,10 +147,9 @@ export declare class AgentsHost {
144
147
  * exists — including its ending, which a window opened before it was replaced
145
148
  * would otherwise carry out on its behalf.
146
149
  *
147
- * Every turn reports, including the silent and deferred ones that deliver
148
- * nothing to the channel. The rail writes nothing anywhere, and the question
149
- * it answers — whether anything is happening in this channel — is worth the
150
- * same answer for a turn that keeps its reply to itself.
150
+ * Silent internal turns do not open a reporter. Deferred turns still report
151
+ * because they represent user-visible work even when final delivery happens
152
+ * elsewhere.
151
153
  */
152
154
  private beginTurnActivity;
153
155
  private canAcceptTurnOutput;
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path';
4
4
  import { SdkChatControlPlane } from './chat/sdk-chat-control-plane.js';
5
5
  import { HostLogger, summarizeError } from './debug.js';
6
6
  import { toReportedActivity } from './progress-to-activity.js';
7
+ import { BackgroundRunTracker } from './background-runs.js';
7
8
  import { ATTENTION_FOLLOW_SEMANTICS_COMPATIBILITY_GATE, COLLABORATION_CAPABILITIES_DIAGNOSTICS_COMPATIBILITY_GATE, COLLABORATION_OUTCOME_MODEL_COMPATIBILITY_GATE, COLLABORATION_SKILL_FIRST_COMPATIBILITY_GATE, CONTEXT_INJECTION_COMPATIBILITY_GATE, LOCALHOST_GATEWAY_COMPATIBILITY_GATE, POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE, resolveInternalPolicyMode, resolveInternalCompatibilityGates, SKILL_RUNTIME_COMPATIBILITY_GATE, TASK_THREAD_COLLABORATION_CONTRACT_COMPATIBILITY_GATE, TOKEN_BINDING_COMPATIBILITY_GATE, } from './compatibility-gates.js';
8
9
  import { buildNormalizedAttentionSnapshot, parsePersistedAttentionSnapshot, } from './context/attention.js';
9
10
  import { mergeCompactionSnapshot, parsePersistedCompactionSnapshot, } from './context/compaction.js';
@@ -149,15 +150,19 @@ class DefaultHostRuntime {
149
150
  });
150
151
  this.ensureStateRoot = deps.ensureStateRoot ?? ensurePrivateStateRoot;
151
152
  }
152
- async start(onMessage, onStopTurn) {
153
+ async start(onMessage, onStopTurn, onStopBackgroundRun, onActivityTransportReady) {
153
154
  await this.ensureStateRoot(this.config.stateRootDir);
154
155
  await this.gateway.start();
155
156
  try {
156
157
  this.controlPlane.onStopTurn?.(onStopTurn);
158
+ this.controlPlane.onStopBackgroundRun?.(onStopBackgroundRun);
159
+ this.controlPlane.onActivityTransportReady?.(onActivityTransportReady);
157
160
  await this.controlPlane.connect(onMessage);
158
161
  }
159
162
  catch (error) {
160
163
  this.controlPlane.onStopTurn?.(undefined);
164
+ this.controlPlane.onStopBackgroundRun?.(undefined);
165
+ this.controlPlane.onActivityTransportReady?.(undefined);
161
166
  await this.gateway.stop().catch(() => { });
162
167
  throw error;
163
168
  }
@@ -166,6 +171,8 @@ class DefaultHostRuntime {
166
171
  let thrown;
167
172
  try {
168
173
  this.controlPlane.onStopTurn?.(undefined);
174
+ this.controlPlane.onStopBackgroundRun?.(undefined);
175
+ this.controlPlane.onActivityTransportReady?.(undefined);
169
176
  await this.controlPlane.close();
170
177
  }
171
178
  catch (error) {
@@ -272,6 +279,7 @@ const AGENT_SESSION_TASK_PROPERTY_KEY = 'agent.session_id';
272
279
  const BLOCKED_AUTHOR_REFRESH_BACKOFF_MS = 1_000;
273
280
  const MAX_QUEUED_MESSAGES_PER_CHANNEL = 8;
274
281
  const COLLABORATION_SENDS_PER_TURN = 1;
282
+ const MAX_AUTONOMOUS_REPLY_DEDUPLICATION_KEYS = 256;
275
283
  /**
276
284
  * Minimal single-agent agents host: connects one local Claude/Copilot CLI
277
285
  * to exactly one Borgee agent over `@borgee/plugin-sdk` (BPP / `/ws/plugin`).
@@ -281,6 +289,7 @@ export class AgentsHost {
281
289
  runtime;
282
290
  provider;
283
291
  borgee;
292
+ backgroundRuns;
284
293
  logger;
285
294
  contextInjectionEnabled;
286
295
  collaborationEnabled;
@@ -303,6 +312,7 @@ export class AgentsHost {
303
312
  compactionSnapshotWriteQueueByChannel = new Map();
304
313
  missedCollaborationDiagnosticByChannel = new Map();
305
314
  channelTurnQueues = new Map();
315
+ autonomousReplyKeys = new Map();
306
316
  collaborationDrafts = new Map();
307
317
  collaborationChannels = new Map();
308
318
  participantDirectoryByUserId = new Map();
@@ -349,6 +359,16 @@ export class AgentsHost {
349
359
  });
350
360
  this.provider = this.runtime.provider;
351
361
  this.borgee = this.runtime.controlPlane;
362
+ const backgroundRunCapabilities = this.provider.capabilities?.backgroundRuns;
363
+ this.backgroundRuns = new BackgroundRunTracker((channelId, activity) => {
364
+ this.borgee.reportActivity?.({ channelId, activity });
365
+ }, {
366
+ lifecycleSupported: backgroundRunCapabilities?.lifecycle === 'supported',
367
+ targetedCancellationSupported: backgroundRunCapabilities?.targetedCancellation === 'supported',
368
+ cancel: this.provider.cancelBackgroundRun
369
+ ? (channelId, providerRunId) => (this.provider.cancelBackgroundRun(channelId, providerRunId))
370
+ : undefined,
371
+ });
352
372
  }
353
373
  async start() {
354
374
  if (this.started)
@@ -364,6 +384,7 @@ export class AgentsHost {
364
384
  const bufferedMessages = [];
365
385
  let bufferingMessages = true;
366
386
  try {
387
+ this.provider.onAutonomousReply?.((reply) => this.dispatchAutonomousReply(reply));
367
388
  await this.runtime.start((message) => {
368
389
  if (!this.started) {
369
390
  return;
@@ -373,7 +394,7 @@ export class AgentsHost {
373
394
  return;
374
395
  }
375
396
  this.dispatchMessage(message);
376
- }, async (channelId) => this.stopTurn(channelId));
397
+ }, async (channelId) => this.stopTurn(channelId), async (channelId, runId) => this.backgroundRuns.stop(channelId, runId), () => this.backgroundRuns.replay());
377
398
  const agentId = await this.ensureSelfAgentId();
378
399
  bufferingMessages = false;
379
400
  while (bufferedMessages.length > 0) {
@@ -393,6 +414,7 @@ export class AgentsHost {
393
414
  bufferedMessages.length = 0;
394
415
  this.started = false;
395
416
  this.controlPlaneClosed = true;
417
+ this.provider.onAutonomousReply?.(undefined);
396
418
  await this.runtime.stop().catch(() => { });
397
419
  throw error;
398
420
  }
@@ -401,6 +423,7 @@ export class AgentsHost {
401
423
  if (!this.started)
402
424
  return;
403
425
  this.started = false;
426
+ this.provider.onAutonomousReply?.(undefined);
404
427
  this.logger.debug('stopping host', {
405
428
  activeTurnCount: this.activeTurns.size,
406
429
  provider: this.config.agent.provider,
@@ -420,6 +443,7 @@ export class AgentsHost {
420
443
  this.activeCompactionByChannel.clear();
421
444
  this.compactionSnapshotLoadedChannels.clear();
422
445
  this.missedCollaborationDiagnosticByChannel.clear();
446
+ this.autonomousReplyKeys.clear();
423
447
  this.recordedAgentSessionByTask.clear();
424
448
  this.controlPlaneClosed = true;
425
449
  const pendingCompactionSnapshotWrites = [...this.compactionSnapshotWriteQueueByChannel.values()];
@@ -449,6 +473,57 @@ export class AgentsHost {
449
473
  : this.enqueueChannelTurn(message.channel_id, () => this.handleLegacyMessage(message));
450
474
  this.trackActiveTurn(task);
451
475
  }
476
+ dispatchAutonomousReply(reply) {
477
+ if (!this.started
478
+ || this.controlPlaneClosed
479
+ || !reply.channelId.trim()
480
+ || !reply.eventId.trim()
481
+ || !hasVisibleText(reply.text)) {
482
+ return;
483
+ }
484
+ const key = `${reply.channelId}\0${reply.eventId}`;
485
+ if (this.autonomousReplyKeys.has(key)) {
486
+ return;
487
+ }
488
+ this.autonomousReplyKeys.set(key, true);
489
+ while (this.autonomousReplyKeys.size > MAX_AUTONOMOUS_REPLY_DEDUPLICATION_KEYS) {
490
+ const oldest = this.autonomousReplyKeys.keys().next().value;
491
+ if (typeof oldest !== 'string') {
492
+ break;
493
+ }
494
+ this.autonomousReplyKeys.delete(oldest);
495
+ }
496
+ const task = this.enqueueChannelTurn(reply.channelId, async () => {
497
+ if (!this.started || this.controlPlaneClosed) {
498
+ this.autonomousReplyKeys.delete(key);
499
+ return;
500
+ }
501
+ try {
502
+ await this.borgee.postMessage({
503
+ channelId: reply.channelId,
504
+ body: reply.text,
505
+ });
506
+ this.logger.debug('posted provider autonomous reply', {
507
+ channelId: reply.channelId,
508
+ provider: this.config.agent.provider,
509
+ });
510
+ }
511
+ catch (error) {
512
+ this.autonomousReplyKeys.delete(key);
513
+ this.logger.debugError('failed to post provider autonomous reply', {
514
+ channelId: reply.channelId,
515
+ provider: this.config.agent.provider,
516
+ error,
517
+ });
518
+ this.logger.error('failed to post provider autonomous reply', {
519
+ channelId: reply.channelId,
520
+ provider: this.config.agent.provider,
521
+ error: summarizeError(error),
522
+ });
523
+ }
524
+ });
525
+ this.trackActiveTurn(task);
526
+ }
452
527
  async handleLegacyMessage(msg) {
453
528
  const normalized = await this.normalizeMessage(msg);
454
529
  if (!normalized) {
@@ -570,8 +645,20 @@ export class AgentsHost {
570
645
  if (this.isHumanWakeFiltered(msg, authorKind, attentionSnapshot)) {
571
646
  return;
572
647
  }
648
+ let foregroundTransition;
573
649
  if (authorKind !== 'agent' && channelState.activeTurn) {
574
650
  this.supersedeActiveTurn(msg.channel_id, channelState.activeTurn);
651
+ foregroundTransition = (async () => {
652
+ try {
653
+ await this.provider.interruptTurn?.(msg.channel_id);
654
+ }
655
+ catch (error) {
656
+ this.logger.error('failed to interrupt superseded provider turn; replacement remains queued', {
657
+ channelId: msg.channel_id,
658
+ error: summarizeError(error),
659
+ });
660
+ }
661
+ })();
575
662
  }
576
663
  if (authorKind !== 'agent') {
577
664
  channelState.recentTurn = undefined;
@@ -585,6 +672,7 @@ export class AgentsHost {
585
672
  attentionSnapshot,
586
673
  taskThreadContext,
587
674
  });
675
+ await foregroundTransition;
588
676
  await this.ensureCollaborationProcessor(msg.channel_id, channelState);
589
677
  }
590
678
  detectProtocolKickoffCandidate(anchorMessageId, content, selfAgentId) {
@@ -969,6 +1057,9 @@ export class AgentsHost {
969
1057
  async ensureCollaborationProcessor(channelId, channelState) {
970
1058
  if (channelState.processor) {
971
1059
  await channelState.processor;
1060
+ if (channelState.queue.length > 0 && !channelState.processor) {
1061
+ await this.ensureCollaborationProcessor(channelId, channelState);
1062
+ }
972
1063
  return;
973
1064
  }
974
1065
  const processor = this.processCollaborationQueue(channelId, channelState).finally(() => {
@@ -2230,7 +2321,10 @@ export class AgentsHost {
2230
2321
  this.collaborationEnabled &&
2231
2322
  turnExecutionId != null &&
2232
2323
  activeTurn?.protocol === undefined;
2233
- const observeProviderProgress = publishesCollaborationDraft || this.config.agent.provider === 'claude';
2324
+ const supportsProviderProgress = this.provider.capabilities?.progressUpdates?.support === 'supported';
2325
+ const supportsBackgroundRunLifecycle = this.provider.capabilities?.backgroundRuns?.lifecycle === 'supported';
2326
+ const reportsProviderActivity = !silentTurn && supportsProviderProgress;
2327
+ const observeProviderProgress = supportsProviderProgress || supportsBackgroundRunLifecycle;
2234
2328
  const turnContext = {
2235
2329
  eventType: msg.type,
2236
2330
  channelId,
@@ -2257,7 +2351,9 @@ export class AgentsHost {
2257
2351
  text: String(msg.content ?? msg.body ?? content),
2258
2352
  attachments: msg.attachments,
2259
2353
  });
2260
- const activityReporter = this.beginTurnActivity(channelId, activeTurn?.generation);
2354
+ const activityReporter = silentTurn
2355
+ ? undefined
2356
+ : this.beginTurnActivity(channelId, activeTurn?.generation);
2261
2357
  await this.ensureCompactionSnapshotLoaded(channelId);
2262
2358
  const compactionSnapshot = this.compactionSnapshotByChannel.get(channelId);
2263
2359
  try {
@@ -2304,20 +2400,32 @@ export class AgentsHost {
2304
2400
  }, observeProviderProgress
2305
2401
  ? {
2306
2402
  onProgress: (update) => {
2403
+ if (supportsBackgroundRunLifecycle) {
2404
+ this.backgroundRuns.observe(channelId, update);
2405
+ if (update.type === 'activity' && update.activity.background === true) {
2406
+ this.borgee.reportActivity?.({
2407
+ channelId,
2408
+ activity: { shape: 'activity', ...update.activity },
2409
+ });
2410
+ return;
2411
+ }
2412
+ }
2307
2413
  if (!this.canAcceptTurnOutput(channelId, activeTurn?.generation)) {
2308
2414
  return;
2309
2415
  }
2310
- if (update.type === 'compaction') {
2416
+ if (supportsProviderProgress && update.type === 'compaction') {
2311
2417
  this.noteCompactionProgress(channelId, update.compaction, {
2312
2418
  turnExecutionId: activeTurn?.turnExecutionId ?? undefined,
2313
2419
  });
2314
2420
  return;
2315
2421
  }
2316
2422
  const reportedActivity = toReportedActivity(update);
2317
- if (reportedActivity) {
2318
- activityReporter.progress(reportedActivity);
2423
+ if (reportsProviderActivity && reportedActivity) {
2424
+ activityReporter?.progress(reportedActivity);
2319
2425
  }
2320
- if (update.type !== 'text' || update.stream !== 'answer') {
2426
+ if (!supportsProviderProgress
2427
+ || update.type !== 'text'
2428
+ || update.stream !== 'answer') {
2321
2429
  return;
2322
2430
  }
2323
2431
  if (!publishesCollaborationDraft) {
@@ -2527,7 +2635,7 @@ export class AgentsHost {
2527
2635
  }
2528
2636
  finally {
2529
2637
  stopTyping();
2530
- activityReporter.end();
2638
+ activityReporter?.end();
2531
2639
  }
2532
2640
  }
2533
2641
  /**
@@ -2539,10 +2647,9 @@ export class AgentsHost {
2539
2647
  * exists — including its ending, which a window opened before it was replaced
2540
2648
  * would otherwise carry out on its behalf.
2541
2649
  *
2542
- * Every turn reports, including the silent and deferred ones that deliver
2543
- * nothing to the channel. The rail writes nothing anywhere, and the question
2544
- * it answers — whether anything is happening in this channel — is worth the
2545
- * same answer for a turn that keeps its reply to itself.
2650
+ * Silent internal turns do not open a reporter. Deferred turns still report
2651
+ * because they represent user-visible work even when final delivery happens
2652
+ * elsewhere.
2546
2653
  */
2547
2654
  beginTurnActivity(channelId, generation) {
2548
2655
  const reporter = this.borgee.reportTurnActivity({
@@ -2710,8 +2817,11 @@ export class AgentsHost {
2710
2817
  turnExecutionId: activeTurn.turnExecutionId,
2711
2818
  });
2712
2819
  }
2820
+ // Output acceptance checks `superseded`, so a stale foreground turn still
2821
+ // cannot stream, draft, or post a visible reply after the interrupt. The
2822
+ // id stays on the running turn until it unwinds so background children that
2823
+ // inherited it can finish their own gateway work cleanly.
2713
2824
  activeTurn.superseded = true;
2714
- activeTurn.turnExecutionId = null;
2715
2825
  }
2716
2826
  invalidateChannelTurn(channelId) {
2717
2827
  const activeTurn = this.collaborationChannels.get(channelId)?.activeTurn;
@@ -2721,7 +2831,9 @@ export class AgentsHost {
2721
2831
  }
2722
2832
  async stopTurn(channelId) {
2723
2833
  const activeTurn = this.collaborationChannels.get(channelId)?.activeTurn;
2724
- const aborted = await this.provider.cancelTurn?.(channelId) ?? false;
2834
+ const aborted = await (this.provider.cancelSession?.(channelId)
2835
+ ?? this.provider.cancelTurn?.(channelId)
2836
+ ?? false);
2725
2837
  if (!aborted) {
2726
2838
  return { aborted: false };
2727
2839
  }
@@ -2759,7 +2871,6 @@ export class AgentsHost {
2759
2871
  resolveAuthorizedCollaborationTurn(channelState, turnExecutionId) {
2760
2872
  const activeTurn = channelState?.activeTurn;
2761
2873
  if (activeTurn &&
2762
- !activeTurn.superseded &&
2763
2874
  activeTurn.turnExecutionId != null &&
2764
2875
  activeTurn.turnExecutionId === turnExecutionId) {
2765
2876
  return activeTurn;
@@ -0,0 +1,24 @@
1
+ import type { AgentActivity } from './plugin-sdk.js';
2
+ import type { ProviderProgressUpdate } from './types.js';
3
+ interface BackgroundRunTrackerOptions {
4
+ lifecycleSupported: boolean;
5
+ targetedCancellationSupported: boolean;
6
+ cancel?: (channelId: string, providerRunId: string) => Promise<boolean>;
7
+ }
8
+ export declare class BackgroundRunTracker {
9
+ private readonly report;
10
+ private readonly options;
11
+ private readonly now;
12
+ private readonly mintRunId;
13
+ private readonly runs;
14
+ private readonly runsById;
15
+ constructor(report: (channelId: string, activity: AgentActivity) => void, options: BackgroundRunTrackerOptions, now?: () => number, mintRunId?: (channelId: string, providerRunId: string, startedAt: number, executionId?: string) => string);
16
+ observe(channelId: string, update: ProviderProgressUpdate): void;
17
+ replay(): void;
18
+ stop(channelId: string, runId: string): Promise<{
19
+ outcome: 'stopped' | 'not_found' | 'unsupported';
20
+ reason?: string;
21
+ }>;
22
+ private publish;
23
+ }
24
+ export {};