@borgee/agents-host 0.2.33 → 0.2.35

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 (40) hide show
  1. package/README.md +28 -7
  2. package/dist/agents-host.d.ts +17 -0
  3. package/dist/agents-host.js +52 -0
  4. package/dist/chat/chat-control-plane.d.ts +9 -0
  5. package/dist/chat/sdk-chat-control-plane.d.ts +10 -1
  6. package/dist/chat/sdk-chat-control-plane.js +6 -0
  7. package/dist/cli-args.d.ts +1 -1
  8. package/dist/cli-args.js +5 -0
  9. package/dist/compatibility-gates.d.ts +1 -0
  10. package/dist/compatibility-gates.js +2 -0
  11. package/dist/config.d.ts +4 -1
  12. package/dist/config.js +21 -3
  13. package/dist/context/main-session-delegation.d.ts +1 -0
  14. package/dist/context/main-session-delegation.js +6 -0
  15. package/dist/context/prompt.js +4 -2
  16. package/dist/gateway/localhost-gateway.js +66 -0
  17. package/dist/local-config.js +10 -1
  18. package/dist/managed-daemon.js +5 -0
  19. package/dist/plugin-sdk.js +58 -1
  20. package/dist/plugin-sdk.js.map +2 -2
  21. package/dist/policy/gateway-authorization.d.ts +18 -3
  22. package/dist/policy/gateway-authorization.js +33 -1
  23. package/dist/providers/claude/adapter.js +3 -1
  24. package/dist/providers/claude/cli-client.d.ts +25 -1
  25. package/dist/providers/claude/cli-client.js +127 -10
  26. package/dist/providers/codex/adapter.js +3 -1
  27. package/dist/providers/codex/cli-client.d.ts +25 -1
  28. package/dist/providers/codex/cli-client.js +127 -10
  29. package/dist/providers/codex/project-doc.js +3 -0
  30. package/dist/providers/copilot/adapter.js +3 -1
  31. package/dist/providers/copilot/cli-client.d.ts +25 -1
  32. package/dist/providers/copilot/cli-client.js +125 -10
  33. package/dist/providers/create-provider.js +14 -9
  34. package/dist/providers/idle-backend-shutdown.d.ts +16 -0
  35. package/dist/providers/idle-backend-shutdown.js +53 -0
  36. package/dist/types.d.ts +7 -0
  37. package/package.json +2 -2
  38. package/skills/borgee-agent/SKILL.md +19 -2
  39. package/skills/borgee-agent/borgee-agent.mjs +56 -1
  40. package/skills/borgee-agent/borgee-agent.py +33 -2
package/README.md CHANGED
@@ -89,7 +89,7 @@ The current shipped internal compatibility surface resolves gates in this order:
89
89
  2. add any explicit entries from `AGENTS_HOST_INTERNAL_COMPATIBILITY_GATES`
90
90
  3. subtract any explicit entries from `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES`
91
91
 
92
- The shipped default set now also includes `connections-state-layer`, `context-injection`, `skill-runtime`, `localhost-gateway`, `token-binding`, `collaboration-outcome-model`, `task-thread-collaboration-contract`, and `collaboration-capabilities-diagnostics`. `attention-follow-semantics`, `collaboration-skill-first`, and hosted `praestoclaw-provider` still remain opt-in.
92
+ The shipped default set now also includes `connections-state-layer`, `context-injection`, `skill-runtime`, `localhost-gateway`, `token-binding`, `collaboration-outcome-model`, `task-thread-collaboration-contract`, `collaboration-capabilities-diagnostics`, and `provider-idle-shutdown`. `attention-follow-semantics`, `collaboration-skill-first`, and hosted `praestoclaw-provider` still remain opt-in.
93
93
 
94
94
  Managed-runtime settings still preserve the managed rollback rule for `connections-state-layer` plus `token-binding`: when `managed-runtime-convergence` remains enabled, the managed surface forces that pair on; explicitly disabling `managed-runtime-convergence` removes that pair from the managed surface again unless you also explicitly re-enable them.
95
95
 
@@ -101,6 +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
105
 
105
106
  Or with the CLI:
106
107
 
@@ -240,6 +241,9 @@ Notes:
240
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. |
241
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`. |
242
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. |
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.
243
247
 
244
248
  ## Local-config mode
245
249
 
@@ -325,7 +329,8 @@ The spec shape is:
325
329
  "codexArgs": [],
326
330
  "copilotCommand": "copilot",
327
331
  "copilotArgs": ["-s", "--no-color", "--allow-all-tools", "--output-format", "text"],
328
- "copilotSessionTtlMinutes": 2880
332
+ "copilotSessionTtlMinutes": 2880,
333
+ "providerIdleShutdownMinutes": 10
329
334
  }
330
335
  },
331
336
  "agents": [
@@ -398,6 +403,7 @@ defaults:
398
403
  - --output-format
399
404
  - text
400
405
  copilotSessionTtlMinutes: 2880
406
+ providerIdleShutdownMinutes: 10
401
407
  ```
402
408
 
403
409
  Host config fields:
@@ -411,6 +417,7 @@ Host config fields:
411
417
  - `defaults.copilotCommand`
412
418
  - `defaults.copilotArgs`
413
419
  - `defaults.copilotSessionTtlMinutes`
420
+ - `defaults.providerIdleShutdownMinutes`
414
421
 
415
422
  Absent host defaults fall back to the same code-level defaults used by the
416
423
  standalone env flow, foreground `start`, and `start-managed`.
@@ -434,8 +441,8 @@ Agent config fields:
434
441
  - `enabled` (optional, default `true`)
435
442
  - `claudeCommand` / `claudeArgs` (optional): leave unset to use the shipped bundled `claude-agent-acp` path. On that bundled path `claudeArgs` must stay empty. Non-empty `claudeArgs` are only supported when `claudeCommand` points at a custom ACP adapter command.
436
443
  - optional overrides for `claudeCommand`, `claudeArgs`, `codexCommand`,
437
- `codexArgs`, `copilotCommand`, `copilotArgs`, and
438
- `copilotSessionTtlMinutes`
444
+ `codexArgs`, `copilotCommand`, `copilotArgs`,
445
+ `copilotSessionTtlMinutes`, and `providerIdleShutdownMinutes`
439
446
 
440
447
  Array overrides are **replacement**, not concatenation. For example, an agent
441
448
  `claudeArgs` value replaces the host default `claudeArgs` entirely.
@@ -504,9 +511,23 @@ active:
504
511
  state root and restores same-host continuity with ACP `session/resume` when
505
512
  available, otherwise `session/load` plus local replay cleanup on runtimes
506
513
  that advertise only load support.
507
- There is no separate transcript/history store on our side. Claude, Codex, and
508
- Copilot only persist the native session ids they should try to resume later;
509
- cross-host continuity still does not exist in this runtime.
514
+
515
+ 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.
516
+
517
+ ### Idle adapter shutdown
518
+
519
+ 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.
520
+
521
+ 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.
522
+
523
+ 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:
524
+
525
+ - **A different machine or a different `$HOME`.** The session store is local, so continuity never crosses hosts.
526
+ - **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.
527
+ - **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)).
528
+ - **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 an ordinary channel turn and an eligible `task_assignment` turn — which runs in its own task workspace — deliberately do not share a session, and the switch between them starts a fresh one even though the CLI could still load the old session.
529
+
530
+ 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`.
510
531
 
511
532
  ## Staying up to date
512
533
 
@@ -21,6 +21,11 @@ export declare class AgentsHost {
21
21
  private readonly activeTurns;
22
22
  private readonly awaitingUserByChannel;
23
23
  private readonly collaborationOutcomeByChannel;
24
+ /**
25
+ * The session id last recorded per task, so a multi-turn task writes the
26
+ * property once rather than on every turn that reuses the same session.
27
+ */
28
+ private readonly recordedAgentSessionByTask;
24
29
  private readonly attentionSnapshotByChannel;
25
30
  private readonly missedCollaborationDiagnosticByChannel;
26
31
  private readonly progressChannelQueues;
@@ -93,6 +98,18 @@ export declare class AgentsHost {
93
98
  private confirmProtocolMessage;
94
99
  private resolveAttentionStatePath;
95
100
  private buildDefaultAttentionSnapshot;
101
+ /**
102
+ * Record which provider session worked this task, as agent.session_id.
103
+ *
104
+ * agents-host writes it rather than the agent: the session id is runtime
105
+ * state the agent has no reliable view of, so asking it to report its own
106
+ * would make the association depend on the agent remembering to.
107
+ *
108
+ * Best-effort and off the turn's critical path — a task whose session id
109
+ * failed to record is still a task that ran, so a failure here must neither
110
+ * fail the turn nor delay it.
111
+ */
112
+ private recordAgentSessionOnTask;
96
113
  private buildTurnTaskThreadContext;
97
114
  private ensureAttentionSnapshotLoaded;
98
115
  private revalidateAttentionSnapshotForTurn;
@@ -135,6 +135,7 @@ class DefaultHostRuntime {
135
135
  copilotCommand: config.copilotCommand,
136
136
  copilotArgs: config.copilotArgs,
137
137
  copilotSessionTtlMinutes: config.copilotSessionTtlMinutes,
138
+ providerIdleShutdownMinutes: config.providerIdleShutdownMinutes,
138
139
  }, deps.logger, {
139
140
  compatibilityGates,
140
141
  authorizationAuditSink: this.authorizationAuditSink,
@@ -361,6 +362,11 @@ class DraftMessageController {
361
362
  return this.writeChain;
362
363
  }
363
364
  }
365
+ /**
366
+ * The registered task property agents-host writes to record which provider
367
+ * session worked a task. It matches the server's registry key.
368
+ */
369
+ const AGENT_SESSION_TASK_PROPERTY_KEY = 'agent.session_id';
364
370
  const BLOCKED_AUTHOR_REFRESH_BACKOFF_MS = 1_000;
365
371
  const MAX_QUEUED_MESSAGES_PER_CHANNEL = 8;
366
372
  const COLLABORATION_SENDS_PER_TURN = 1;
@@ -383,6 +389,11 @@ export class AgentsHost {
383
389
  activeTurns = new Set();
384
390
  awaitingUserByChannel = new Map();
385
391
  collaborationOutcomeByChannel = new Map();
392
+ /**
393
+ * The session id last recorded per task, so a multi-turn task writes the
394
+ * property once rather than on every turn that reuses the same session.
395
+ */
396
+ recordedAgentSessionByTask = new Map();
386
397
  attentionSnapshotByChannel = new Map();
387
398
  missedCollaborationDiagnosticByChannel = new Map();
388
399
  progressChannelQueues = new Map();
@@ -504,6 +515,7 @@ export class AgentsHost {
504
515
  this.collaborationOutcomeByChannel.clear();
505
516
  this.attentionSnapshotByChannel.clear();
506
517
  this.missedCollaborationDiagnosticByChannel.clear();
518
+ this.recordedAgentSessionByTask.clear();
507
519
  this.controlPlaneClosed = true;
508
520
  await this.runtime.stop();
509
521
  await Promise.allSettled([...this.activeTurns]);
@@ -1730,6 +1742,41 @@ export class AgentsHost {
1730
1742
  turnExecutionId: options?.turnExecutionId,
1731
1743
  });
1732
1744
  }
1745
+ /**
1746
+ * Record which provider session worked this task, as agent.session_id.
1747
+ *
1748
+ * agents-host writes it rather than the agent: the session id is runtime
1749
+ * state the agent has no reliable view of, so asking it to report its own
1750
+ * would make the association depend on the agent remembering to.
1751
+ *
1752
+ * Best-effort and off the turn's critical path — a task whose session id
1753
+ * failed to record is still a task that ran, so a failure here must neither
1754
+ * fail the turn nor delay it.
1755
+ */
1756
+ async recordAgentSessionOnTask(channelId, taskThreadContext, sessionId) {
1757
+ const taskId = taskThreadContext?.state === 'active' ? taskThreadContext.currentTaskId : undefined;
1758
+ if (!taskId || !sessionId) {
1759
+ return;
1760
+ }
1761
+ if (this.recordedAgentSessionByTask.get(taskId) === sessionId) {
1762
+ return;
1763
+ }
1764
+ try {
1765
+ await this.borgee.setTaskProperty({
1766
+ taskId,
1767
+ key: AGENT_SESSION_TASK_PROPERTY_KEY,
1768
+ value: sessionId,
1769
+ });
1770
+ this.recordedAgentSessionByTask.set(taskId, sessionId);
1771
+ }
1772
+ catch (error) {
1773
+ this.logger.debugError('best-effort agent session property write failed', {
1774
+ channelId,
1775
+ taskId,
1776
+ error,
1777
+ });
1778
+ }
1779
+ }
1733
1780
  async buildTurnTaskThreadContext(msg, channelId) {
1734
1781
  if (msg.message_type === 'task_assignment') {
1735
1782
  const currentTaskId = extractTaskIdFromTaskAssignmentContent(String(msg.content ?? msg.body ?? ''));
@@ -2126,6 +2173,11 @@ export class AgentsHost {
2126
2173
  : undefined);
2127
2174
  publicReplyText = resolvePublicReplyText(reply);
2128
2175
  visibleReplyBody = this.resolveVisibleTurnBody(activeTurn, reply, publicReplyText);
2176
+ // NOT awaited: nothing downstream reads the result, and awaiting it here
2177
+ // widens the window before the staleness check below, where a slow write
2178
+ // can get an already-finished reply discarded. The method swallows its own
2179
+ // errors, so there is nothing for a caller to handle.
2180
+ void this.recordAgentSessionOnTask(channelId, taskThreadContext, reply.sessionId);
2129
2181
  const appliedAttentionSnapshot = this.applyAttentionUpdate(channelId, attentionSnapshot, reply.controlMalformed
2130
2182
  ? undefined
2131
2183
  : (reply.control && 'attentionUpdate' in reply.control
@@ -18,4 +18,13 @@ export interface ChatControlPlane {
18
18
  taskId: string;
19
19
  }): Promise<Task>;
20
20
  updateTask(input: UpdateTaskInput): Promise<Task>;
21
+ setTaskProperty(input: {
22
+ taskId: string;
23
+ key: string;
24
+ value: string;
25
+ }): Promise<Task>;
26
+ deleteTaskProperty(input: {
27
+ taskId: string;
28
+ key: string;
29
+ }): Promise<Task>;
21
30
  }
@@ -1,7 +1,7 @@
1
1
  import { type BorgeePluginClient, type BorgeePluginOptions, type InboundMessageEvent } 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' | 'sendMessage' | 'startTyping' | 'updateTask'>;
4
+ type PluginClientLike = Pick<BorgeePluginClient, 'agentId' | 'close' | 'connect' | 'createTask' | 'deleteMessage' | 'editMessage' | 'getMe' | 'getTask' | 'listChannels' | 'listTasks' | 'listUsers' | 'on' | 'readHistory' | 'sendMessage' | 'startTyping' | 'updateTask' | 'setTaskProperty' | 'deleteTaskProperty'>;
5
5
  type PluginClientFactory = (options: BorgeePluginOptions) => PluginClientLike;
6
6
  type SdkChatControlPlaneOptions = Pick<BorgeePluginOptions, 'cursorStore' | 'pluginId'>;
7
7
  /**
@@ -34,6 +34,15 @@ export declare class SdkChatControlPlane implements ChatControlPlane {
34
34
  taskId: string;
35
35
  }): Promise<Task>;
36
36
  updateTask(input: UpdateTaskInput): Promise<Task>;
37
+ setTaskProperty(input: {
38
+ taskId: string;
39
+ key: string;
40
+ value: string;
41
+ }): Promise<Task>;
42
+ deleteTaskProperty(input: {
43
+ taskId: string;
44
+ key: string;
45
+ }): Promise<Task>;
37
46
  }
38
47
  export declare function mapInboundToChannelMessage(event: InboundMessageEvent): ChannelMessageEvent;
39
48
  export {};
@@ -123,6 +123,12 @@ export class SdkChatControlPlane {
123
123
  async updateTask(input) {
124
124
  return this.client.updateTask(input);
125
125
  }
126
+ async setTaskProperty(input) {
127
+ return this.client.setTaskProperty(input);
128
+ }
129
+ async deleteTaskProperty(input) {
130
+ return this.client.deleteTaskProperty(input);
131
+ }
126
132
  }
127
133
  export function mapInboundToChannelMessage(event) {
128
134
  return {
@@ -85,4 +85,4 @@ export declare function parseCleanupManagedArgs(argv: string[]): ParsedCleanupMa
85
85
  export declare function parseApplyManagedArgs(argv: string[]): ParsedApplyManagedArgs;
86
86
  /** `update` always installs the latest published release; it takes no options. */
87
87
  export declare function assertNoUpdateArgs(argv: string[]): void;
88
- export declare const USAGE = "Usage:\n agents-host start <serverUrl> <apiKey> [options]\n agents-host start-managed <serverUrl>\n agents-host start-managed <serverUrl> <apiKey> [agent-options]\n agents-host describe-managed <serverUrl>\n agents-host cleanup-managed <serverUrl> [--purge]\n agents-host apply-managed <serverUrl> --stdin\n agents-host apply-managed <serverUrl> --spec-json <json>\n agents-host log <serverUrl> [--lines <n>] [--follow]\n agents-host log <serverUrl> --path\n agents-host start --config <path-to-host-config> [--debug]\n agents-host validate --config <path-to-host-config>\n agents-host describe --config <path-to-host-config>\n agents-host print-layout --root <dir>\n agents-host generate-config --root <dir> --stdin\n agents-host generate-config --root <dir> --spec-json <json>\n agents-host update\n\nForeground start options:\n --debug Enable host/provider debug logs (or set AGENTS_HOST_DEBUG=1)\n\nSingle-agent agent options:\n --name <name> Display name (default: Assistant)\n --provider <claude|codex|copilot>\n Runtime provider (default: claude)\n --claude-command <cmd> Local Claude ACP adapter command (default: claude-agent-acp)\n --claude-args <args> Local Claude ACP adapter args (empty on the shipped bundled path)\n --codex-command <cmd> Local Codex ACP adapter command (default: codex-acp)\n --codex-args <args> Local Codex ACP adapter args\n --copilot-command <cmd> Local Copilot CLI command (default: copilot)\n --copilot-args <args> Ignored by the Copilot ACP prototype\n --copilot-session-ttl-minutes <minutes>\n Idle session TTL for Copilot ACP sessions (default: 2880)\n\nCommand summary:\n start <serverUrl> <apiKey> Start one foreground hosted agent (legacy-compatible behavior)\n start --config <path> Start agents + supervisor + watchers from a host config file\n start-managed <serverUrl> Restart or resume an existing per-serverUrl managed daemon\n start-managed <serverUrl> <apiKey>\n Upsert one agent into the per-serverUrl local daemon and exit after reconcile\n describe-managed <serverUrl> Print managed-runtime spec JSON for machine callers\n cleanup-managed <serverUrl> Stop one managed-runtime daemon locally; --purge also removes its runtime root\n apply-managed <serverUrl> Apply one managed-runtime full-set spec for machine callers\n log <serverUrl> Print the managed daemon log tail for one server runtime\n validate --config <path> Validate local-config files without starting agents or watchers\n describe --config <path> Print the current managed full-set spec as JSON\n print-layout --root <dir> Print the canonical default local-config layout as JSON\n generate-config --root <dir> --stdin Materialize canonical local-config files from stdin\n generate-config --root <dir> --spec-json <json>\n Compatibility input; JSON is exposed in process arguments\n update Update the globally installed @borgee/agents-host to the latest release\n\nStartup update check:\n Startup commands print an advisory notice on stderr when a newer release is published.\n Set AGENTS_HOST_DISABLE_UPDATE_CHECK=1 to turn that check off.\n\nExamples:\n agents-host start https://borgee.example.com bgr_xxxxxxxx --provider copilot --debug\n agents-host start-managed https://borgee.example.com\n agents-host start-managed https://borgee.example.com bgr_xxxxxxxx --provider copilot\n agents-host describe-managed https://borgee.example.com\n agents-host cleanup-managed https://borgee.example.com --purge\n printf '%s' '{\"host\":{\"borgeeBaseUrl\":\"https://borgee.example.com\"},\"agents\":[{\"key\":\"cp1\",\"name\":\"Copilot\",\"apiKey\":\"bgr_xxx\",\"provider\":\"copilot\"}]}' | agents-host apply-managed https://borgee.example.com --stdin\n agents-host log https://borgee.example.com --lines 200 --follow\n agents-host log https://borgee.example.com --path\n agents-host start --config ./agents-host.yaml --debug\n agents-host validate --config ./agents-host.yaml\n agents-host describe --config ./agents-host.yaml\n agents-host print-layout --root ./runtime-root\n printf '%s' '{\"host\":{\"borgeeBaseUrl\":\"https://borgee.example.com\"},\"agents\":[{\"key\":\"cp1\",\"name\":\"Copilot\",\"apiKey\":\"bgr_xxx\",\"provider\":\"copilot\"}]}' | agents-host generate-config --root ./runtime-root --stdin\n agents-host update\n";
88
+ export declare const USAGE = "Usage:\n agents-host start <serverUrl> <apiKey> [options]\n agents-host start-managed <serverUrl>\n agents-host start-managed <serverUrl> <apiKey> [agent-options]\n agents-host describe-managed <serverUrl>\n agents-host cleanup-managed <serverUrl> [--purge]\n agents-host apply-managed <serverUrl> --stdin\n agents-host apply-managed <serverUrl> --spec-json <json>\n agents-host log <serverUrl> [--lines <n>] [--follow]\n agents-host log <serverUrl> --path\n agents-host start --config <path-to-host-config> [--debug]\n agents-host validate --config <path-to-host-config>\n agents-host describe --config <path-to-host-config>\n agents-host print-layout --root <dir>\n agents-host generate-config --root <dir> --stdin\n agents-host generate-config --root <dir> --spec-json <json>\n agents-host update\n\nForeground start options:\n --debug Enable host/provider debug logs (or set AGENTS_HOST_DEBUG=1)\n\nSingle-agent agent options:\n --name <name> Display name (default: Assistant)\n --provider <claude|codex|copilot>\n Runtime provider (default: claude)\n --claude-command <cmd> Local Claude ACP adapter command (default: claude-agent-acp)\n --claude-args <args> Local Claude ACP adapter args (empty on the shipped bundled path)\n --codex-command <cmd> Local Codex ACP adapter command (default: codex-acp)\n --codex-args <args> Local Codex ACP adapter args\n --copilot-command <cmd> Local Copilot CLI command (default: copilot)\n --copilot-args <args> Ignored by the Copilot ACP prototype\n --copilot-session-ttl-minutes <minutes>\n Idle session TTL for Copilot ACP sessions (default: 2880)\n --provider-idle-shutdown-minutes <minutes>\n Shut the shared ACP adapter process down after this much idle\n time and re-spawn it on the next turn; 0 keeps it resident\n (default: 10)\n\nCommand summary:\n start <serverUrl> <apiKey> Start one foreground hosted agent (legacy-compatible behavior)\n start --config <path> Start agents + supervisor + watchers from a host config file\n start-managed <serverUrl> Restart or resume an existing per-serverUrl managed daemon\n start-managed <serverUrl> <apiKey>\n Upsert one agent into the per-serverUrl local daemon and exit after reconcile\n describe-managed <serverUrl> Print managed-runtime spec JSON for machine callers\n cleanup-managed <serverUrl> Stop one managed-runtime daemon locally; --purge also removes its runtime root\n apply-managed <serverUrl> Apply one managed-runtime full-set spec for machine callers\n log <serverUrl> Print the managed daemon log tail for one server runtime\n validate --config <path> Validate local-config files without starting agents or watchers\n describe --config <path> Print the current managed full-set spec as JSON\n print-layout --root <dir> Print the canonical default local-config layout as JSON\n generate-config --root <dir> --stdin Materialize canonical local-config files from stdin\n generate-config --root <dir> --spec-json <json>\n Compatibility input; JSON is exposed in process arguments\n update Update the globally installed @borgee/agents-host to the latest release\n\nStartup update check:\n Startup commands print an advisory notice on stderr when a newer release is published.\n Set AGENTS_HOST_DISABLE_UPDATE_CHECK=1 to turn that check off.\n\nExamples:\n agents-host start https://borgee.example.com bgr_xxxxxxxx --provider copilot --debug\n agents-host start-managed https://borgee.example.com\n agents-host start-managed https://borgee.example.com bgr_xxxxxxxx --provider copilot\n agents-host describe-managed https://borgee.example.com\n agents-host cleanup-managed https://borgee.example.com --purge\n printf '%s' '{\"host\":{\"borgeeBaseUrl\":\"https://borgee.example.com\"},\"agents\":[{\"key\":\"cp1\",\"name\":\"Copilot\",\"apiKey\":\"bgr_xxx\",\"provider\":\"copilot\"}]}' | agents-host apply-managed https://borgee.example.com --stdin\n agents-host log https://borgee.example.com --lines 200 --follow\n agents-host log https://borgee.example.com --path\n agents-host start --config ./agents-host.yaml --debug\n agents-host validate --config ./agents-host.yaml\n agents-host describe --config ./agents-host.yaml\n agents-host print-layout --root ./runtime-root\n printf '%s' '{\"host\":{\"borgeeBaseUrl\":\"https://borgee.example.com\"},\"agents\":[{\"key\":\"cp1\",\"name\":\"Copilot\",\"apiKey\":\"bgr_xxx\",\"provider\":\"copilot\"}]}' | agents-host generate-config --root ./runtime-root --stdin\n agents-host update\n";
package/dist/cli-args.js CHANGED
@@ -15,6 +15,7 @@ export const CLI_FLAG_TO_ENV = {
15
15
  'copilot-command': 'COPILOT_COMMAND',
16
16
  'copilot-args': 'COPILOT_ARGS',
17
17
  'copilot-session-ttl-minutes': 'COPILOT_SESSION_TTL_MINUTES',
18
+ 'provider-idle-shutdown-minutes': 'PROVIDER_IDLE_SHUTDOWN_MINUTES',
18
19
  };
19
20
  const SINGLE_AGENT_FLAG_NAMES = new Set(Object.keys(CLI_FLAG_TO_ENV));
20
21
  const FLAGS_ALLOWING_DASH_PREFIX_VALUE = new Set(['claude-args', 'codex-args', 'copilot-args']);
@@ -512,6 +513,10 @@ Single-agent agent options:
512
513
  --copilot-args <args> Ignored by the Copilot ACP prototype
513
514
  --copilot-session-ttl-minutes <minutes>
514
515
  Idle session TTL for Copilot ACP sessions (default: 2880)
516
+ --provider-idle-shutdown-minutes <minutes>
517
+ Shut the shared ACP adapter process down after this much idle
518
+ time and re-spawn it on the next turn; 0 keeps it resident
519
+ (default: 10)
515
520
 
516
521
  Command summary:
517
522
  start <serverUrl> <apiKey> Start one foreground hosted agent (legacy-compatible behavior)
@@ -13,6 +13,7 @@ export declare const COLLABORATION_CAPABILITIES_DIAGNOSTICS_COMPATIBILITY_GATE =
13
13
  export declare const TOKEN_BINDING_COMPATIBILITY_GATE = "token-binding";
14
14
  export declare const POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE = "policy-audit-enforcement";
15
15
  export declare const MANAGED_RUNTIME_CONVERGENCE_COMPATIBILITY_GATE = "managed-runtime-convergence";
16
+ export declare const PROVIDER_IDLE_SHUTDOWN_COMPATIBILITY_GATE = "provider-idle-shutdown";
16
17
  export declare const COMPATIBILITY_GATES_ENV = "AGENTS_HOST_INTERNAL_COMPATIBILITY_GATES";
17
18
  export declare const INTERNAL_DISABLED_COMPATIBILITY_GATES_ENV = "AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES";
18
19
  export declare const INTERNAL_POLICY_MODE_ENV = "AGENTS_HOST_INTERNAL_POLICY_MODE";
@@ -14,6 +14,7 @@ export const COLLABORATION_CAPABILITIES_DIAGNOSTICS_COMPATIBILITY_GATE = 'collab
14
14
  export const TOKEN_BINDING_COMPATIBILITY_GATE = 'token-binding';
15
15
  export const POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE = 'policy-audit-enforcement';
16
16
  export const MANAGED_RUNTIME_CONVERGENCE_COMPATIBILITY_GATE = 'managed-runtime-convergence';
17
+ export const PROVIDER_IDLE_SHUTDOWN_COMPATIBILITY_GATE = 'provider-idle-shutdown';
17
18
  export const COMPATIBILITY_GATES_ENV = 'AGENTS_HOST_INTERNAL_COMPATIBILITY_GATES';
18
19
  export const INTERNAL_DISABLED_COMPATIBILITY_GATES_ENV = 'AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES';
19
20
  export const INTERNAL_POLICY_MODE_ENV = 'AGENTS_HOST_INTERNAL_POLICY_MODE';
@@ -30,6 +31,7 @@ export const DEFAULT_INTERNAL_COMPATIBILITY_GATES = Object.freeze([
30
31
  LOCALHOST_GATEWAY_COMPATIBILITY_GATE,
31
32
  MANAGED_RUNTIME_CONVERGENCE_COMPATIBILITY_GATE,
32
33
  POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE,
34
+ PROVIDER_IDLE_SHUTDOWN_COMPATIBILITY_GATE,
33
35
  SKILL_RUNTIME_COMPATIBILITY_GATE,
34
36
  TASK_THREAD_COLLABORATION_CONTRACT_COMPATIBILITY_GATE,
35
37
  TOKEN_BINDING_COMPATIBILITY_GATE,
package/dist/config.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { AgentsHostConfig, ProviderCommandConfig, ProviderKind } from './types.js';
2
- export declare const MAX_COPILOT_SESSION_TTL_MINUTES: number;
2
+ export declare const MAX_TIMER_DELAY_MINUTES: number;
3
3
  export declare const DEFAULT_COPILOT_SESSION_TTL_MINUTES: number;
4
+ export declare const PROVIDER_IDLE_SHUTDOWN_DISABLED_MINUTES = 0;
5
+ export declare const DEFAULT_PROVIDER_IDLE_SHUTDOWN_MINUTES = 10;
4
6
  export declare const DEFAULT_CLAUDE_COMMAND = "claude-agent-acp";
5
7
  export declare const LEGACY_CLAUDE_COMMAND = "claude";
6
8
  export declare const LEGACY_CLAUDE_ONE_SHOT_ARGS: readonly ["--print", "--permission-mode", "bypassPermissions"];
@@ -10,6 +12,7 @@ export declare function optionalNonEmptyString(value: unknown, fieldName: string
10
12
  export declare function parseArgs(value: string): string[];
11
13
  export declare function optionalStringArray(value: unknown, fieldName: string, sourceLabel: string): string[] | undefined;
12
14
  export declare function parseCopilotSessionTtlMinutesValue(value: unknown, sourceLabel: string): number;
15
+ export declare function parseProviderIdleShutdownMinutesValue(value: unknown, sourceLabel: string): number;
13
16
  export declare function resolveProvider(rawValue: string, sourceLabel: string): ProviderKind;
14
17
  export declare function assertProviderCompatibility(provider: ProviderKind, sourceLabel: string, env?: NodeJS.ProcessEnv): void;
15
18
  export declare function isLegacyClaudeCompatibilityAlias(command: string, args: string[]): boolean;
package/dist/config.js CHANGED
@@ -2,8 +2,10 @@ import { basename } from 'node:path';
2
2
  import { resolveSingleAgentStateRoot } from './state-paths.js';
3
3
  import { CODEX_PROVIDER_COMPATIBILITY_GATE, resolveInternalCompatibilityGates, } from './compatibility-gates.js';
4
4
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
5
- export const MAX_COPILOT_SESSION_TTL_MINUTES = MAX_TIMER_DELAY_MS / 60_000;
5
+ 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
+ export const PROVIDER_IDLE_SHUTDOWN_DISABLED_MINUTES = 0;
8
+ export const DEFAULT_PROVIDER_IDLE_SHUTDOWN_MINUTES = 10;
7
9
  export const DEFAULT_CLAUDE_COMMAND = 'claude-agent-acp';
8
10
  export const LEGACY_CLAUDE_COMMAND = 'claude';
9
11
  export const LEGACY_CLAUDE_ONE_SHOT_ARGS = ['--print', '--permission-mode', 'bypassPermissions'];
@@ -15,6 +17,7 @@ export const DEFAULT_PROVIDER_COMMAND_CONFIG = {
15
17
  copilotCommand: 'copilot',
16
18
  copilotArgs: ['-s', '--no-color', '--allow-all-tools', '--output-format', 'text'],
17
19
  copilotSessionTtlMinutes: DEFAULT_COPILOT_SESSION_TTL_MINUTES,
20
+ providerIdleShutdownMinutes: DEFAULT_PROVIDER_IDLE_SHUTDOWN_MINUTES,
18
21
  };
19
22
  function requireEnv(name, env) {
20
23
  return requireNonEmptyString(env[name], `Missing required environment variable: ${name}`);
@@ -57,8 +60,18 @@ export function parseCopilotSessionTtlMinutesValue(value, sourceLabel) {
57
60
  if (!Number.isFinite(parsed) || parsed <= 0) {
58
61
  throw new Error(`Invalid COPILOT_SESSION_TTL_MINUTES in ${sourceLabel}: expected a positive number`);
59
62
  }
60
- if (parsed > MAX_COPILOT_SESSION_TTL_MINUTES) {
61
- throw new Error(`Invalid COPILOT_SESSION_TTL_MINUTES in ${sourceLabel}: must be <= ${MAX_COPILOT_SESSION_TTL_MINUTES.toFixed(2)} minutes to fit within the Node.js timer limit`);
63
+ if (parsed > MAX_TIMER_DELAY_MINUTES) {
64
+ throw new Error(`Invalid COPILOT_SESSION_TTL_MINUTES in ${sourceLabel}: must be <= ${MAX_TIMER_DELAY_MINUTES.toFixed(2)} minutes to fit within the Node.js timer limit`);
65
+ }
66
+ return parsed;
67
+ }
68
+ export function parseProviderIdleShutdownMinutesValue(value, sourceLabel) {
69
+ const parsed = typeof value === 'number' ? value : Number(String(value).trim());
70
+ if (!Number.isFinite(parsed) || parsed < PROVIDER_IDLE_SHUTDOWN_DISABLED_MINUTES) {
71
+ throw new Error(`Invalid PROVIDER_IDLE_SHUTDOWN_MINUTES in ${sourceLabel}: expected a non-negative number, where ${PROVIDER_IDLE_SHUTDOWN_DISABLED_MINUTES} keeps the shared adapter process resident`);
72
+ }
73
+ if (parsed > MAX_TIMER_DELAY_MINUTES) {
74
+ throw new Error(`Invalid PROVIDER_IDLE_SHUTDOWN_MINUTES in ${sourceLabel}: must be <= ${MAX_TIMER_DELAY_MINUTES.toFixed(2)} minutes to fit within the Node.js timer limit`);
62
75
  }
63
76
  return parsed;
64
77
  }
@@ -119,6 +132,8 @@ export function resolveProviderCommandConfig(overrides = {}) {
119
132
  copilotCommand: overrides.copilotCommand ?? DEFAULT_PROVIDER_COMMAND_CONFIG.copilotCommand,
120
133
  copilotArgs: [...(overrides.copilotArgs ?? DEFAULT_PROVIDER_COMMAND_CONFIG.copilotArgs)],
121
134
  copilotSessionTtlMinutes: overrides.copilotSessionTtlMinutes ?? DEFAULT_PROVIDER_COMMAND_CONFIG.copilotSessionTtlMinutes,
135
+ providerIdleShutdownMinutes: overrides.providerIdleShutdownMinutes
136
+ ?? DEFAULT_PROVIDER_COMMAND_CONFIG.providerIdleShutdownMinutes,
122
137
  };
123
138
  }
124
139
  export function loadConfigFromEnv(env = process.env) {
@@ -135,6 +150,9 @@ export function loadConfigFromEnv(env = process.env) {
135
150
  copilotSessionTtlMinutes: env.COPILOT_SESSION_TTL_MINUTES && env.COPILOT_SESSION_TTL_MINUTES.trim().length > 0
136
151
  ? parseCopilotSessionTtlMinutesValue(env.COPILOT_SESSION_TTL_MINUTES, 'environment variable COPILOT_SESSION_TTL_MINUTES')
137
152
  : DEFAULT_PROVIDER_COMMAND_CONFIG.copilotSessionTtlMinutes,
153
+ providerIdleShutdownMinutes: env.PROVIDER_IDLE_SHUTDOWN_MINUTES && env.PROVIDER_IDLE_SHUTDOWN_MINUTES.trim().length > 0
154
+ ? parseProviderIdleShutdownMinutesValue(env.PROVIDER_IDLE_SHUTDOWN_MINUTES, 'environment variable PROVIDER_IDLE_SHUTDOWN_MINUTES')
155
+ : DEFAULT_PROVIDER_COMMAND_CONFIG.providerIdleShutdownMinutes,
138
156
  });
139
157
  assertProviderCommandCompatibility(provider, providerConfig, 'environment variables');
140
158
  return {
@@ -0,0 +1 @@
1
+ export declare const MAIN_SESSION_DELEGATION_LINES: readonly string[];
@@ -0,0 +1,6 @@
1
+ export const MAIN_SESSION_DELEGATION_LINES = [
2
+ 'This session is your coordination surface: read the channel, answer the human, break work down, and keep task state current here.',
3
+ 'Delegate the actual work to subagents — research, code edits, long builds and test runs, and broad repository sweeps belong in a delegated worker instead of running inline here.',
4
+ 'Delegation changes who executes, not who owns: the work still belongs to this thread, and you still report the result here yourself.',
5
+ 'Stay responsive while delegated work runs, so a new incoming message never waits behind a long local operation.',
6
+ ];
@@ -2,6 +2,7 @@ import { AWAITING_USER_CONTROL_PREFIX } from '../providers/awaiting-user.js';
2
2
  import { buildAttentionSummaryLines } from './attention.js';
3
3
  import { buildCollaborationCapabilityDeclarationSummaryLines, buildMissedCollaborationDiagnosticSummaryLines, } from './collaboration-capabilities-diagnostics.js';
4
4
  import { buildCollaborationOutcomeSummaryLines } from './collaboration-outcome.js';
5
+ import { MAIN_SESSION_DELEGATION_LINES } from './main-session-delegation.js';
5
6
  import { buildTaskThreadCollaborationSummaryLines } from './task-thread-collaboration.js';
6
7
  function providerLabel(provider) {
7
8
  if (provider === 'copilot') {
@@ -45,10 +46,10 @@ function buildLocalhostGatewayPromptLines(context) {
45
46
  }
46
47
  lines.push('The read-only gateway commands listed in this prompt are already authorized for this turn and may be executed directly.', 'Do not ask the user for permission before using the read-only gateway commands listed below.', 'If the user asks about channel history, visible participants, or your current agent identity, run the relevant read command first and answer from its result instead of speculating about authorization.', `Node gateway checks: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --health`, `Node channel bootstrap: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --read-bootstrap`, `Node agent identity: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --get-me`, `Node channel history: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --read-history --limit 20`, `Python gateway checks: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --health`, `Python channel bootstrap: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --read-bootstrap`, `Python agent identity: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --get-me`, `Python channel history: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --read-history --limit 20`);
47
48
  if (isTaskAssignmentThread) {
48
- lines.push('Task-collection commands remain disabled inside this task thread; use the parent channel for create/list task operations.', `Node get current thread task: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --get-task`, `Node update current thread task: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --update-task [--status "<status>"] [--assignee-id "<assignee-id>"] [--title "<title>"]`, `Python get current thread task: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --get-task`, `Python update current thread task: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --update-task [--status "<status>"] [--assignee-id "<assignee-id>"] [--title "<title>"]`);
49
+ lines.push('Task-collection commands remain disabled inside this task thread; use the parent channel for create/list task operations.', `Node get current thread task: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --get-task`, `Node update current thread task: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --update-task [--status "<status>"] [--assignee-id "<assignee-id>"] [--title "<title>"]`, `Python get current thread task: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --get-task`, `Python update current thread task: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --update-task [--status "<status>"] [--assignee-id "<assignee-id>"] [--title "<title>"]`, `Node set current thread task property: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --set-property "<key>=<value>"`, `Node delete current thread task property: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --delete-property "<key>"`, `Python set current thread task property: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --set-property "<key>=<value>"`, `Python delete current thread task property: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --delete-property "<key>"`, 'Task property keys are a closed set: link.pr (the PR implementing the task) and link.issue (the originating issue). agent.session_id is written by agents-host — do not set it. One key per call; pass <key>=<value> as one argument.');
49
50
  }
50
51
  else {
51
- lines.push(`Node create task: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --create-task --title "<title>" [--description "<description>"] [--assignee-id "<assignee-id>"]`, `Node list tasks: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --list-tasks`, `Node get task: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --get-task --task-id "<task-id>"`, `Node read task thread history: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --read-task-history --task-id "<task-id>" --limit 20`, `Node update task: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --update-task --task-id "<task-id>" [--status "<status>"] [--assignee-id "<assignee-id>"] [--title "<title>"]`, `Python create task: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --create-task --title "<title>" [--description "<description>"] [--assignee-id "<assignee-id>"]`, `Python list tasks: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --list-tasks`, `Python get task: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --get-task --task-id "<task-id>"`, `Python read task thread history: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --read-task-history --task-id "<task-id>" --limit 20`, `Python update task: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --update-task --task-id "<task-id>" [--status "<status>"] [--assignee-id "<assignee-id>"] [--title "<title>"]`);
52
+ lines.push(`Node create task: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --create-task --title "<title>" [--description "<description>"] [--assignee-id "<assignee-id>"]`, `Node list tasks: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --list-tasks`, `Node get task: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --get-task --task-id "<task-id>"`, `Node read task thread history: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --read-task-history --task-id "<task-id>" --limit 20`, `Node update task: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --update-task --task-id "<task-id>" [--status "<status>"] [--assignee-id "<assignee-id>"] [--title "<title>"]`, `Python create task: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --create-task --title "<title>" [--description "<description>"] [--assignee-id "<assignee-id>"]`, `Python list tasks: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --list-tasks`, `Python get task: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --get-task --task-id "<task-id>"`, `Python read task thread history: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --read-task-history --task-id "<task-id>" --limit 20`, `Python update task: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --update-task --task-id "<task-id>" [--status "<status>"] [--assignee-id "<assignee-id>"] [--title "<title>"]`, `Node set task property: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --set-property "<key>=<value>" --task-id "<task-id>"`, `Node delete task property: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --delete-property "<key>" --task-id "<task-id>"`, `Python set task property: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --set-property "<key>=<value>" --task-id "<task-id>"`, `Python delete task property: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --auth-path ${context.gatewayAuthPath} --delete-property "<key>" --task-id "<task-id>"`, 'Task property keys are a closed set: link.pr (the PR implementing the task) and link.issue (the originating issue). agent.session_id is written by agents-host — do not set it. One key per call; pass <key>=<value> as one argument.');
52
53
  }
53
54
  if (context.localhostGateway.collaboration?.enabled &&
54
55
  (context.collaborationTurnMode ?? 'ordinary') === 'ordinary') {
@@ -221,6 +222,7 @@ export function buildPrompt(params) {
221
222
  'Be concise, helpful, and honest about uncertainty.',
222
223
  'Do not claim to have performed actions you did not actually perform.',
223
224
  'Keep the visible reply text concise.',
225
+ ...MAIN_SESSION_DELEGATION_LINES,
224
226
  ...buildTurnControlPromptLines(params.promptContext),
225
227
  `If the user asks who you are, what powers you, or which backend/provider you use, mention that you are currently running on ${providerLabel(params.provider)}.`,
226
228
  `Channel: ${params.channelId}`,
@@ -505,6 +505,23 @@ class LoopbackLocalhostGatewayController {
505
505
  this.recordAudit('authorized', 200, decision.path, request.method ?? 'GET', decision.binding);
506
506
  return;
507
507
  }
508
+ case 'current-task-property': {
509
+ const task = await this.loadCurrentThreadTask(binding);
510
+ const updatedTask = await this.applyTaskPropertyChange(request, task.id, decision.route.key);
511
+ this.sendTaskPropertyResult(response, decision, binding.channelId, updatedTask, request.method);
512
+ return;
513
+ }
514
+ case 'task-property': {
515
+ // Load first: the write must be refused for a task outside the bound
516
+ // channel BEFORE it lands, not reported as not-found afterwards.
517
+ const task = await this.loadAuthorizedTask(binding.channelId, {
518
+ taskId: decision.route.taskId,
519
+ resource: 'task',
520
+ });
521
+ const updatedTask = await this.applyTaskPropertyChange(request, task.id, decision.route.key);
522
+ this.sendTaskPropertyResult(response, decision, binding.channelId, updatedTask, request.method);
523
+ return;
524
+ }
508
525
  case 'task-history': {
509
526
  // The thread this reads is the one the server recorded on the task: thread_id is
510
527
  // written once by task creation and is not part of the task update whitelist, so no
@@ -612,6 +629,31 @@ class LoopbackLocalhostGatewayController {
612
629
  }
613
630
  return task;
614
631
  }
632
+ /**
633
+ * PUT writes the key, DELETE removes it. The method has already been
634
+ * narrowed to those two by the route's allowed-method table.
635
+ */
636
+ async applyTaskPropertyChange(request, taskId, key) {
637
+ try {
638
+ if (request.method === 'DELETE') {
639
+ return await this.controlPlane.deleteTaskProperty({ taskId, key });
640
+ }
641
+ const body = await readGatewayJsonBody(request);
642
+ return await this.controlPlane.setTaskProperty({ taskId, key, value: parseTaskPropertyValue(body) });
643
+ }
644
+ catch (error) {
645
+ throw mapGatewayControlPlaneError(error, 'authorized');
646
+ }
647
+ }
648
+ sendTaskPropertyResult(response, decision, boundChannelId, task, method) {
649
+ if (!isTaskInAuthorizedScope(boundChannelId, task)) {
650
+ this.sendJson(response, 404, { error: 'not_found' });
651
+ this.recordAudit('not-found', 404, decision.path, method ?? 'PUT', decision.binding);
652
+ return;
653
+ }
654
+ this.sendJson(response, 200, task);
655
+ this.recordAudit('authorized', 200, decision.path, method ?? 'PUT', decision.binding);
656
+ }
615
657
  async loadCurrentThreadTask(binding) {
616
658
  let task;
617
659
  const preferredTaskId = binding.payload?.taskAssignmentContext?.currentTaskId;
@@ -816,6 +858,17 @@ function parseUpdateTaskInput(taskId, body) {
816
858
  }
817
859
  return input;
818
860
  }
861
+ /**
862
+ * The value is required and must be a string. An absent or non-string value is
863
+ * a caller bug, not an instruction to store the empty string — storing that
864
+ * would silently blank a key the caller meant to set.
865
+ */
866
+ function parseTaskPropertyValue(body) {
867
+ if (!isRecord(body) || typeof body.value !== 'string') {
868
+ throw new GatewayHttpError(400, { error: 'invalid_property_value' }, 'bad-request');
869
+ }
870
+ return body.value;
871
+ }
819
872
  function isTaskInAuthorizedScope(boundChannelId, task) {
820
873
  return task.channelId === boundChannelId || task.threadId === boundChannelId;
821
874
  }
@@ -846,6 +899,19 @@ function mapGatewayControlPlaneError(error, fallbackReason) {
846
899
  case 'bpp.channel_id_required':
847
900
  case 'bpp.task_id_required':
848
901
  return new GatewayHttpError(400, { error: 'bad_request' }, 'bad-request');
902
+ // An unregistered key or an oversized value is the caller's own mistake.
903
+ // Falling through to 502 would tell the agent the server is broken, and it
904
+ // would retry the same bad call instead of correcting it.
905
+ case 'bpp.task_property_key_unknown':
906
+ return new GatewayHttpError(400, { error: 'unknown_property_key' }, 'bad-request');
907
+ case 'bpp.task_property_value_too_long':
908
+ return new GatewayHttpError(400, { error: 'property_value_too_long' }, 'bad-request');
909
+ // A distinct body, not the bare `not_found` the other routes use: the CLI
910
+ // reads that one on a current-task request as "the thread's task could not
911
+ // be resolved" and tells the agent to pass --task-id, which is the wrong
912
+ // correction for a key that simply was not set.
913
+ case 'bpp.task_property_not_found':
914
+ return new GatewayHttpError(404, { error: 'property_not_found' }, 'not-found');
849
915
  default:
850
916
  return new GatewayHttpError(502, { error: 'upstream_error' }, fallbackReason);
851
917
  }
@@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { parseDocument, stringify } from 'yaml';
5
- import { assertProviderCompatibility, assertProviderCommandCompatibility, optionalNonEmptyString, optionalStringArray, parseCopilotSessionTtlMinutesValue, requireNonEmptyString, resolveProvider, resolveProviderCommandConfig, } from './config.js';
5
+ import { assertProviderCompatibility, assertProviderCommandCompatibility, optionalNonEmptyString, optionalStringArray, parseCopilotSessionTtlMinutesValue, parseProviderIdleShutdownMinutesValue, requireNonEmptyString, resolveProvider, resolveProviderCommandConfig, } from './config.js';
6
6
  import { resolveLocalConfigAgentStateRoot } from './state-paths.js';
7
7
  const SUPPORTED_CONFIG_EXTENSIONS = new Set(['.json', '.yaml', '.yml']);
8
8
  export const DEFAULT_LOCAL_HOST_CONFIG_FILENAME = 'agents-host.yaml';
@@ -124,6 +124,9 @@ function parseProviderCommandOverrides(value, sourceLabel) {
124
124
  if (value.copilotSessionTtlMinutes !== undefined && value.copilotSessionTtlMinutes !== null) {
125
125
  overrides.copilotSessionTtlMinutes = parseCopilotSessionTtlMinutesValue(value.copilotSessionTtlMinutes, sourceLabel);
126
126
  }
127
+ if (value.providerIdleShutdownMinutes !== undefined && value.providerIdleShutdownMinutes !== null) {
128
+ overrides.providerIdleShutdownMinutes = parseProviderIdleShutdownMinutesValue(value.providerIdleShutdownMinutes, sourceLabel);
129
+ }
127
130
  return overrides;
128
131
  }
129
132
  function toAgentsDir(hostConfigPath, rawAgentsDir) {
@@ -593,6 +596,9 @@ function renderAgentConfigYaml(agent) {
593
596
  if (agent.copilotSessionTtlMinutes !== undefined) {
594
597
  config.copilotSessionTtlMinutes = agent.copilotSessionTtlMinutes;
595
598
  }
599
+ if (agent.providerIdleShutdownMinutes !== undefined) {
600
+ config.providerIdleShutdownMinutes = agent.providerIdleShutdownMinutes;
601
+ }
596
602
  return stringify(config);
597
603
  }
598
604
  function buildManagedAgentSnapshot(host, stateRootBaseDir, sourcePath, agent) {
@@ -629,6 +635,9 @@ function resolveAgentProviderCommandConfig(hostDefaults, agent) {
629
635
  ...(agent.copilotSessionTtlMinutes !== undefined
630
636
  ? { copilotSessionTtlMinutes: agent.copilotSessionTtlMinutes }
631
637
  : {}),
638
+ ...(agent.providerIdleShutdownMinutes !== undefined
639
+ ? { providerIdleShutdownMinutes: agent.providerIdleShutdownMinutes }
640
+ : {}),
632
641
  });
633
642
  }
634
643
  export async function loadLocalConfigSnapshot(hostConfigPath, deps = {}) {