@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
@@ -2,6 +2,7 @@ import { FileChannelContextStore } from '../context/injection.js';
2
2
  import { createProviderConnectionsSessionStore } from '../connections-state-store.js';
3
3
  import { ProviderTurnPreparer } from '../context/turn-preparation.js';
4
4
  import { CONNECTIONS_STATE_LAYER_COMPATIBILITY_GATE, CONTEXT_INJECTION_COMPATIBILITY_GATE, CODEX_PROVIDER_COMPATIBILITY_GATE, COPILOT_PROVIDER_V2_COMPATIBILITY_GATE, INTERNAL_PROVIDER_IMPLEMENTATIONS_ENV, LOCALHOST_GATEWAY_COMPATIBILITY_GATE, parseInternalProviderImplementationOverrides, POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE, resolveInternalPolicyMode, resolveInternalCompatibilityGates, SKILL_RUNTIME_COMPATIBILITY_GATE, } from '../compatibility-gates.js';
5
+ import { resolveIdleBackendShutdownMs } from './idle-backend-shutdown.js';
5
6
  import { ClaudeCliClient } from './claude/cli-client.js';
6
7
  import { ClaudeProviderAdapter } from './claude/adapter.js';
7
8
  import { FileClaudeChannelSessionStore } from './claude/session-store.js';
@@ -39,7 +40,9 @@ class CliBackedProviderV2 {
39
40
  const text = await this.cli.generateReply(input.turn, {
40
41
  onProgress: createAwaitingUserProgressHandler(options?.onProgress),
41
42
  });
42
- return parseProviderReply(text);
43
+ // Read the session AFTER the turn: a first turn on a channel opens the
44
+ // session as it runs, so before this point there is nothing to report.
45
+ return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.turn.channelId) };
43
46
  }
44
47
  async shutdown() {
45
48
  await this.cli.dispose();
@@ -57,8 +60,8 @@ function resolveCopilotImplementation(routingConfig) {
57
60
  }
58
61
  return routingConfig.implementationOverrides.copilot ?? 'v2';
59
62
  }
60
- function createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer) {
61
- const cli = new ClaudeCliClient(config.claudeCommand, config.claudeArgs, {}, createProviderConnectionsSessionStore({
63
+ function createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs) {
64
+ const cli = new ClaudeCliClient(config.claudeCommand, config.claudeArgs, { idleBackendShutdownMs }, createProviderConnectionsSessionStore({
62
65
  provider: 'claude',
63
66
  legacyStore: new FileClaudeChannelSessionStore({
64
67
  resolvePath: (agentId) => resolveClaudeSessionMapPath(config.stateRootDir, agentId),
@@ -69,10 +72,11 @@ function createClaudeProvider(config, debugLogger, connectionsStateGateEnabled,
69
72
  }), config.resolveStableAgentId, debugLogger);
70
73
  return new ClaudeProviderAdapter(cli, turnPreparer);
71
74
  }
72
- function createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, implementation, turnPreparer, policyAuditGateEnabled, authorizationAuditSink) {
75
+ function createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, implementation, turnPreparer, policyAuditGateEnabled, authorizationAuditSink, idleBackendShutdownMs) {
73
76
  const policyMode = resolveInternalPolicyMode(policyAuditGateEnabled);
74
77
  const cli = new CopilotCliClient(config.copilotCommand, config.copilotArgs, {
75
78
  idleSessionTtlMs: config.copilotSessionTtlMinutes * 60 * 1000,
79
+ idleBackendShutdownMs,
76
80
  }, createProviderConnectionsSessionStore({
77
81
  provider: 'copilot',
78
82
  legacyStore: new FileCopilotChannelSessionStore({
@@ -90,8 +94,8 @@ function createCopilotProvider(config, debugLogger, connectionsStateGateEnabled,
90
94
  ? new ProviderV2CompatibilityAdapter(new CopilotProviderV2(cli), turnPreparer)
91
95
  : new CopilotProviderAdapter(cli, turnPreparer);
92
96
  }
93
- function createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer) {
94
- const cli = new CodexCliClient(config.codexCommand, config.codexArgs, {}, createProviderConnectionsSessionStore({
97
+ function createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs) {
98
+ const cli = new CodexCliClient(config.codexCommand, config.codexArgs, { idleBackendShutdownMs }, createProviderConnectionsSessionStore({
95
99
  provider: 'codex',
96
100
  legacyStore: new FileCodexChannelSessionStore({
97
101
  resolvePath: (agentId) => resolveCodexSessionMapPath(config.stateRootDir, agentId),
@@ -112,6 +116,7 @@ export function createProvider(config, debugLogger, options = {}) {
112
116
  const skillRuntimeGateEnabled = routingConfig.compatibilityGates.has(SKILL_RUNTIME_COMPATIBILITY_GATE);
113
117
  const localhostGatewayGateEnabled = routingConfig.compatibilityGates.has(LOCALHOST_GATEWAY_COMPATIBILITY_GATE);
114
118
  const policyAuditGateEnabled = routingConfig.compatibilityGates.has(POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE);
119
+ const idleBackendShutdownMs = resolveIdleBackendShutdownMs(config.providerIdleShutdownMinutes, routingConfig.compatibilityGates);
115
120
  const channelContextStore = contextInjectionGateEnabled
116
121
  ? new FileChannelContextStore(config.stateRootDir, {
117
122
  skillRuntimeEnabled: skillRuntimeGateEnabled,
@@ -124,16 +129,16 @@ export function createProvider(config, debugLogger, options = {}) {
124
129
  const turnPreparer = new ProviderTurnPreparer(channelContextStore, debugLogger);
125
130
  switch (config.provider) {
126
131
  case 'claude': {
127
- return createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer);
132
+ return createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs);
128
133
  }
129
134
  case 'codex': {
130
135
  if (!routingConfig.compatibilityGates.has(CODEX_PROVIDER_COMPATIBILITY_GATE)) {
131
136
  throw new Error(`Unsupported provider: ${String(config.provider)} (enable ${CODEX_PROVIDER_COMPATIBILITY_GATE})`);
132
137
  }
133
- return createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer);
138
+ return createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs);
134
139
  }
135
140
  case 'copilot': {
136
- return createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, resolveCopilotImplementation(routingConfig), turnPreparer, policyAuditGateEnabled, options.authorizationAuditSink);
141
+ return createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, resolveCopilotImplementation(routingConfig), turnPreparer, policyAuditGateEnabled, options.authorizationAuditSink, idleBackendShutdownMs);
137
142
  }
138
143
  default:
139
144
  throw new Error(`Unsupported provider: ${String(config.provider)}`);
@@ -0,0 +1,16 @@
1
+ export declare const IDLE_BACKEND_SHUTDOWN_DISABLED_MS = 0;
2
+ export declare function resolveIdleBackendShutdownMs(idleShutdownMinutes: number, compatibilityGates: ReadonlySet<string>): number;
3
+ export interface IdleBackendShutdownSchedulerOptions {
4
+ idleShutdownMs: number;
5
+ isIdle: () => boolean;
6
+ shutdown: () => Promise<void>;
7
+ onShutdownFailed: (error: unknown) => void;
8
+ }
9
+ export declare class IdleBackendShutdownScheduler {
10
+ private readonly options;
11
+ private timer?;
12
+ constructor(options: IdleBackendShutdownSchedulerOptions);
13
+ get enabled(): boolean;
14
+ cancel(): void;
15
+ reconcile(): void;
16
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Idle shutdown policy for a provider's shared ACP adapter process.
3
+ *
4
+ * Every agent keeps one adapter child process resident across turns, so an
5
+ * agent nobody is talking to still pins a whole coding-CLI runtime in memory.
6
+ * The scheduler arms a timer whenever its owning client reports that no channel
7
+ * has work left, and hands the teardown back to the client once that timer
8
+ * expires; the client re-spawns the process lazily on the next turn.
9
+ *
10
+ * The `isIdle` probe is re-evaluated when the timer fires because a turn can
11
+ * arrive between arming and expiry, and the timer callback is the last point at
12
+ * which the shutdown can still be abandoned cheaply.
13
+ */
14
+ import { PROVIDER_IDLE_SHUTDOWN_COMPATIBILITY_GATE } from '../compatibility-gates.js';
15
+ export const IDLE_BACKEND_SHUTDOWN_DISABLED_MS = 0;
16
+ export function resolveIdleBackendShutdownMs(idleShutdownMinutes, compatibilityGates) {
17
+ if (!compatibilityGates.has(PROVIDER_IDLE_SHUTDOWN_COMPATIBILITY_GATE)) {
18
+ return IDLE_BACKEND_SHUTDOWN_DISABLED_MS;
19
+ }
20
+ return idleShutdownMinutes * 60 * 1000;
21
+ }
22
+ export class IdleBackendShutdownScheduler {
23
+ options;
24
+ timer;
25
+ constructor(options) {
26
+ this.options = options;
27
+ }
28
+ get enabled() {
29
+ return this.options.idleShutdownMs > IDLE_BACKEND_SHUTDOWN_DISABLED_MS;
30
+ }
31
+ cancel() {
32
+ if (!this.timer) {
33
+ return;
34
+ }
35
+ clearTimeout(this.timer);
36
+ this.timer = undefined;
37
+ }
38
+ reconcile() {
39
+ this.cancel();
40
+ if (!this.enabled || !this.options.isIdle()) {
41
+ return;
42
+ }
43
+ this.timer = setTimeout(() => {
44
+ this.timer = undefined;
45
+ if (!this.options.isIdle()) {
46
+ return;
47
+ }
48
+ void this.options.shutdown().catch((error) => {
49
+ this.options.onShutdownFailed(error);
50
+ });
51
+ }, this.options.idleShutdownMs);
52
+ }
53
+ }
package/dist/types.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface ProviderCommandConfig {
7
7
  copilotCommand: string;
8
8
  copilotArgs: string[];
9
9
  copilotSessionTtlMinutes: number;
10
+ /** Minutes a provider's shared ACP adapter process may stay idle before it is shut down; `0` keeps it resident. */
11
+ providerIdleShutdownMinutes: number;
10
12
  }
11
13
  export interface ProviderRuntimeConfig extends ProviderCommandConfig {
12
14
  provider: ProviderKind;
@@ -342,6 +344,11 @@ export interface ProviderReply {
342
344
  awaitingUser?: ProviderAwaitingUser;
343
345
  control?: ProviderTurnControl;
344
346
  controlMalformed?: boolean;
347
+ /**
348
+ * The provider session this turn ran in, when the provider exposes one.
349
+ * agents-host records it on the task as agent.session_id.
350
+ */
351
+ sessionId?: string;
345
352
  }
346
353
  export interface PostedMessage {
347
354
  messageId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@borgee/agents-host",
3
- "version": "0.2.33",
3
+ "version": "0.2.35",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -35,7 +35,7 @@
35
35
  "tsx": "^4.20.5",
36
36
  "typescript": "^5.9.3",
37
37
  "vitest": "^4.1.5",
38
- "@borgee/plugin-sdk": "0.2.5"
38
+ "@borgee/plugin-sdk": "0.4.0"
39
39
  },
40
40
  "scripts": {
41
41
  "predev": "pnpm --filter @borgee/plugin-sdk build",
@@ -25,11 +25,28 @@ Use one of the packaged local CLIs to inspect the current channel bootstrap payl
25
25
 
26
26
  The same CLIs also expose the task commands. Which ones apply depends on where the turn runs, which the injected `context.json` reports through `taskAssignmentContext`:
27
27
 
28
- - Parent channel (no `taskAssignmentContext.active`): `--create-task --title ...`, `--list-tasks`, `--get-task --task-id ...`, `--update-task --task-id ...`, `--read-task-history --task-id ...`
29
- - Task-assignment thread (`taskAssignmentContext.active: true`): `--get-task` and `--update-task` may omit `--task-id` and resolve the current thread task through the persisted `currentTaskId` or an agents-host local fallback; `--read-task-history --task-id ...` still works for that thread's own task, while `--create-task` and `--list-tasks` stay disabled and must be run from the parent channel
28
+ - Parent channel (no `taskAssignmentContext.active`): `--create-task --title ...`, `--list-tasks`, `--get-task --task-id ...`, `--update-task --task-id ...`, `--read-task-history --task-id ...`, `--set-property <key>=<value> --task-id ...`, `--delete-property <key> --task-id ...`
29
+ - Task-assignment thread (`taskAssignmentContext.active: true`): `--get-task`, `--update-task`, `--set-property` and `--delete-property` may omit `--task-id` and resolve the current thread task through the persisted `currentTaskId` or an agents-host local fallback; `--read-task-history --task-id ...` still works for that thread's own task, while `--create-task` and `--list-tasks` stay disabled and must be run from the parent channel
30
30
 
31
31
  `--read-task-history` reads the messages inside a task's thread and always requires an explicit `--task-id`; inside that task's own thread `--read-history` already reads the same messages, so the turn prompt offers the command in the parent channel only. It accepts the same `--limit` / `--before` / `--after` window as `--read-history` and answers the same not-found error for a task outside the current channel as for a task that does not exist.
32
32
 
33
+ ## Task properties
34
+
35
+ A task property associates a task with something outside it. Use them to record what a reader would otherwise have to hunt for in the thread — the PR that implements the task, the issue it came from. Read them back with `--get-task`: every task response carries a `properties` object (`{}` when it has none).
36
+
37
+ - Set: `node ./borgee-agent.mjs --context ... --auth-path ... --set-property "link.pr=https://github.com/org/repo/pull/12"`
38
+ - Delete: `node ./borgee-agent.mjs --context ... --auth-path ... --delete-property "link.pr"`
39
+
40
+ Pass `key=value` as ONE shell token so a value containing `=` survives intact. Each call writes exactly one key, which is what makes it safe to write a property while another agent is writing a different one on the same task — so never try to set several at once, just call again.
41
+
42
+ Registered keys:
43
+
44
+ - `link.pr` — the pull request implementing this task. Set it as soon as the PR exists, not at the end.
45
+ - `link.issue` — the issue or ticket the task originates from.
46
+ - `agent.session_id` — **do not write this**. agents-host records the provider session itself after each turn.
47
+
48
+ An unregistered key is rejected; a value is a plain string capped at 8 KiB. Properties are references, not notes — anything that needs prose belongs in the task description or a thread message.
49
+
33
50
  The private draft snapshot is read-only, collaboration-scoped, and separate from ordinary public channel messages.
34
51
  Auxiliary sends are only for short targeted escalation, reply-thread nudges, or mentions. They must not be used for the main final answer body, which still belongs to AgentsHost.
35
52
  Both CLIs are local-only, may only access the loopback gateway described above, and must not mutate files.
@@ -3,7 +3,7 @@
3
3
  import { readFile } from 'node:fs/promises';
4
4
 
5
5
  const TASK_THREAD_COLLECTION_COMMAND_ERROR
6
- = 'Task assignment threads only support --get-task and --update-task. Create/list tasks belong to the parent channel.';
6
+ = 'Task assignment threads only support --get-task, --update-task, --set-property and --delete-property. Create/list tasks belong to the parent channel.';
7
7
  const TASK_THREAD_MISSING_TASK_ID_ERROR
8
8
  = 'Task assignment thread could not resolve the current task from persisted context or local fallback. Pass --task-id explicitly.';
9
9
  // A window value outside the IEEE-754 safe integer range does not survive this CLI: it parses
@@ -28,6 +28,8 @@ function parseArgs(argv) {
28
28
  let assigneeId;
29
29
  let taskId;
30
30
  let status;
31
+ let propertyKey;
32
+ let propertyValue;
31
33
  const mentions = [];
32
34
 
33
35
  function setAction(nextAction) {
@@ -126,6 +128,32 @@ function parseArgs(argv) {
126
128
  setAction('update-task');
127
129
  continue;
128
130
  }
131
+ if (arg === '--set-property') {
132
+ setAction('set-property');
133
+ // key=value in one token so the pair cannot be split across flags and
134
+ // land half-applied.
135
+ const pair = argv[index + 1];
136
+ if (pair == null || !pair.includes('=')) {
137
+ throw new Error('--set-property requires <key>=<value>');
138
+ }
139
+ const separator = pair.indexOf('=');
140
+ propertyKey = pair.slice(0, separator);
141
+ propertyValue = pair.slice(separator + 1);
142
+ if (propertyKey.trim().length === 0) {
143
+ throw new Error('--set-property requires a non-empty key');
144
+ }
145
+ index += 1;
146
+ continue;
147
+ }
148
+ if (arg === '--delete-property') {
149
+ setAction('delete-property');
150
+ propertyKey = argv[index + 1];
151
+ if (propertyKey == null || propertyKey.trim().length === 0) {
152
+ throw new Error('--delete-property requires a key');
153
+ }
154
+ index += 1;
155
+ continue;
156
+ }
129
157
  if (arg === '--limit') {
130
158
  limit = parseIntegerOption(arg, argv[index + 1]);
131
159
  index += 1;
@@ -209,6 +237,8 @@ function parseArgs(argv) {
209
237
  assigneeId,
210
238
  taskId,
211
239
  status,
240
+ propertyKey: propertyKey?.trim(),
241
+ propertyValue,
212
242
  };
213
243
  }
214
244
 
@@ -370,6 +400,27 @@ function resolveGatewayRequest(payload, action, options) {
370
400
  usesCurrentThreadFallback: false,
371
401
  };
372
402
  }
403
+ case 'set-property':
404
+ case 'delete-property': {
405
+ const taskId = resolveTaskId(payload, action, options);
406
+ const method = action === 'set-property' ? 'PUT' : 'DELETE';
407
+ const requestBody = action === 'set-property' ? { value: options.propertyValue } : undefined;
408
+ const encodedKey = encodeURIComponent(String(options.propertyKey ?? ''));
409
+ if (payload.taskAssignmentContext?.active === true && explicitTaskId(options) === null) {
410
+ return {
411
+ url: new URL(`/v1/channels/${encodedChannelId}/current-task/properties/${encodedKey}`, gateway.baseUrl),
412
+ method,
413
+ body: requestBody,
414
+ usesCurrentThreadFallback: true,
415
+ };
416
+ }
417
+ return {
418
+ url: new URL(`/v1/tasks/${encodeURIComponent(taskId)}/properties/${encodedKey}`, gateway.baseUrl),
419
+ method,
420
+ body: requestBody,
421
+ usesCurrentThreadFallback: false,
422
+ };
423
+ }
373
424
  default:
374
425
  throw new Error(`Unsupported gateway action: ${action}`);
375
426
  }
@@ -480,6 +531,8 @@ const {
480
531
  assigneeId,
481
532
  taskId,
482
533
  status,
534
+ propertyKey,
535
+ propertyValue,
483
536
  } = parseArgs(process.argv.slice(2));
484
537
  const payload = JSON.parse(await readFile(contextPath, 'utf8'));
485
538
 
@@ -502,6 +555,8 @@ if (action === 'print-bootstrap') {
502
555
  assigneeId,
503
556
  taskId,
504
557
  status,
558
+ propertyKey,
559
+ propertyValue,
505
560
  });
506
561
  console.log(JSON.stringify(result, null, 2));
507
562
  }
@@ -11,8 +11,8 @@ from urllib.parse import quote, urlencode
11
11
  from urllib.request import Request, urlopen
12
12
 
13
13
  TASK_THREAD_COLLECTION_COMMAND_ERROR = (
14
- "Task assignment threads only support --get-task and --update-task. "
15
- "Create/list tasks belong to the parent channel."
14
+ "Task assignment threads only support --get-task, --update-task, --set-property "
15
+ "and --delete-property. Create/list tasks belong to the parent channel."
16
16
  )
17
17
  TASK_THREAD_MISSING_TASK_ID_ERROR = (
18
18
  "Task assignment thread could not resolve the current task from persisted context "
@@ -54,6 +54,8 @@ def parse_args(argv: list[str]) -> dict[str, object]:
54
54
  assignee_id: str | None = None
55
55
  task_id: str | None = None
56
56
  status: str | None = None
57
+ property_key: str | None = None
58
+ property_value: str | None = None
57
59
  index = 0
58
60
 
59
61
  def set_action(next_action: str) -> None:
@@ -155,6 +157,23 @@ def parse_args(argv: list[str]) -> dict[str, object]:
155
157
  elif arg == "--status":
156
158
  index += 1
157
159
  status = argv[index] if index < len(argv) else None
160
+ elif arg == "--set-property":
161
+ set_action("set-property")
162
+ index += 1
163
+ # key=value in one token so the pair cannot be split across flags
164
+ # and land half-applied.
165
+ pair = argv[index] if index < len(argv) else None
166
+ if pair is None or "=" not in pair:
167
+ raise ValueError("--set-property requires <key>=<value>")
168
+ property_key, property_value = pair.split("=", 1)
169
+ if not property_key.strip():
170
+ raise ValueError("--set-property requires a non-empty key")
171
+ elif arg == "--delete-property":
172
+ set_action("delete-property")
173
+ index += 1
174
+ property_key = argv[index] if index < len(argv) else None
175
+ if property_key is None or not property_key.strip():
176
+ raise ValueError("--delete-property requires a key")
158
177
  else:
159
178
  raise ValueError(f"Unknown argument: {arg}")
160
179
  index += 1
@@ -178,6 +197,8 @@ def parse_args(argv: list[str]) -> dict[str, object]:
178
197
  "assignee_id": assignee_id,
179
198
  "task_id": task_id,
180
199
  "status": status,
200
+ "property_key": property_key.strip() if isinstance(property_key, str) else None,
201
+ "property_value": property_value,
181
202
  }
182
203
 
183
204
 
@@ -303,6 +324,14 @@ def resolve_gateway_request(
303
324
  if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True and explicit_task_id(options) is None:
304
325
  return f"{base_url}/v1/channels/{channel_id}/current-task", "PATCH", request_body, True
305
326
  return f"{base_url}/v1/tasks/{quote(str(task_id), safe='')}", "PATCH", request_body, False
327
+ if action in ("set-property", "delete-property"):
328
+ task_id = resolve_task_id(payload, action, options)
329
+ method = "PUT" if action == "set-property" else "DELETE"
330
+ request_body = {"value": options.get("property_value")} if action == "set-property" else None
331
+ encoded_key = quote(str(options.get("property_key") or ""), safe="")
332
+ if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True and explicit_task_id(options) is None:
333
+ return f"{base_url}/v1/channels/{channel_id}/current-task/properties/{encoded_key}", method, request_body, True
334
+ return f"{base_url}/v1/tasks/{quote(str(task_id), safe='')}/properties/{encoded_key}", method, request_body, False
306
335
  raise ValueError(f"Unsupported gateway action: {action}")
307
336
 
308
337
 
@@ -431,6 +460,8 @@ else:
431
460
  "assignee_id": parsed["assignee_id"],
432
461
  "task_id": parsed["task_id"],
433
462
  "status": parsed["status"],
463
+ "property_key": parsed["property_key"],
464
+ "property_value": parsed["property_value"],
434
465
  },
435
466
  ),
436
467
  indent=2,