@borgee/agents-host 0.2.29 → 0.2.32

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 (38) hide show
  1. package/README.md +82 -27
  2. package/dist/agents-host.d.ts +1 -1
  3. package/dist/agents-host.js +45 -18
  4. package/dist/cli-args.d.ts +3 -1
  5. package/dist/cli-args.js +18 -2
  6. package/dist/cli.d.ts +4 -0
  7. package/dist/cli.js +12 -1
  8. package/dist/compatibility-gates.js +4 -1
  9. package/dist/config.d.ts +8 -0
  10. package/dist/config.js +51 -13
  11. package/dist/context/prompt.js +1 -1
  12. package/dist/gateway/localhost-gateway.js +34 -2
  13. package/dist/index.js +4 -1
  14. package/dist/local-config.js +20 -1
  15. package/dist/managed-daemon.js +20 -6
  16. package/dist/policy/authorization-audit.d.ts +1 -0
  17. package/dist/policy/gateway-authorization.d.ts +1 -1
  18. package/dist/policy/gateway-authorization.js +27 -4
  19. package/dist/providers/claude/cli-client.d.ts +55 -16
  20. package/dist/providers/claude/cli-client.js +811 -345
  21. package/dist/providers/create-provider.js +8 -13
  22. package/dist/state-paths.d.ts +5 -0
  23. package/dist/state-paths.js +12 -0
  24. package/dist/types.d.ts +1 -0
  25. package/dist/update/package-installation.d.ts +52 -0
  26. package/dist/update/package-installation.js +90 -0
  27. package/dist/update/package-manager.d.ts +33 -0
  28. package/dist/update/package-manager.js +137 -0
  29. package/dist/update/semantic-version.d.ts +7 -0
  30. package/dist/update/semantic-version.js +76 -0
  31. package/dist/update/update-command.d.ts +9 -0
  32. package/dist/update/update-command.js +55 -0
  33. package/dist/update/update-notice.d.ts +39 -0
  34. package/dist/update/update-notice.js +161 -0
  35. package/package.json +5 -2
  36. package/skills/borgee-agent/SKILL.md +5 -3
  37. package/skills/borgee-agent/borgee-agent.mjs +56 -22
  38. package/skills/borgee-agent/borgee-agent.py +56 -27
@@ -0,0 +1,161 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { HostLogger, resolveAgentsHostDebugMode } from '../debug.js';
4
+ import { resolveUpdateCheckCachePath } from '../state-paths.js';
5
+ import { classifyPackageInstallation, resolvePackageLocation, } from './package-installation.js';
6
+ import { readConfiguredRegistry, readPublishedVersion } from './package-manager.js';
7
+ import { isNewerSemanticVersion, isSemanticVersion } from './semantic-version.js';
8
+ export const DISABLE_UPDATE_CHECK_ENV = 'AGENTS_HOST_DISABLE_UPDATE_CHECK';
9
+ const UPDATE_CHECK_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
10
+ const UPDATE_CHECK_TIMEOUT_MS = 5000;
11
+ /**
12
+ * Every failure under the update check is swallowed, so `AGENTS_HOST_DEBUG=1`
13
+ * is the only way an operator can find out why a notice never appeared.
14
+ */
15
+ function createUpdateCheckLogger(env, deps) {
16
+ return new HostLogger({ debug: resolveAgentsHostDebugMode(false, env), logger: deps.logger });
17
+ }
18
+ async function readCacheFile(path) {
19
+ return fs.readFile(path, 'utf8');
20
+ }
21
+ async function writeCacheFile(path, contents) {
22
+ await fs.mkdir(dirname(path), { recursive: true });
23
+ // Write-then-rename: a concurrent start never observes a half-written entry,
24
+ // and a symlink planted at the cache path is replaced rather than followed.
25
+ const tempPath = `${path}.${process.pid}.tmp`;
26
+ try {
27
+ await fs.writeFile(tempPath, contents, { encoding: 'utf8', mode: 0o600 });
28
+ await fs.rename(tempPath, path);
29
+ }
30
+ catch (error) {
31
+ await fs.rm(tempPath, { force: true });
32
+ throw error;
33
+ }
34
+ }
35
+ function parseCacheEntry(contents) {
36
+ const parsed = JSON.parse(contents);
37
+ if (typeof parsed !== 'object' || parsed === null) {
38
+ throw new Error('Update check cache is not an object');
39
+ }
40
+ const { checkedAt, registry, latestVersion } = parsed;
41
+ if (typeof checkedAt !== 'number' || !Number.isFinite(checkedAt)) {
42
+ throw new Error('Update check cache carries no checkedAt timestamp');
43
+ }
44
+ if (typeof registry !== 'string' || registry.length === 0) {
45
+ throw new Error('Update check cache carries no registry');
46
+ }
47
+ if (latestVersion === null) {
48
+ return { checkedAt, registry, latestVersion: null };
49
+ }
50
+ if (typeof latestVersion !== 'string' || !isSemanticVersion(latestVersion)) {
51
+ throw new Error('Update check cache carries no semantic latestVersion');
52
+ }
53
+ return { checkedAt, registry, latestVersion };
54
+ }
55
+ export function isUpdateCheckDisabled(env) {
56
+ return env[DISABLE_UPDATE_CHECK_ENV]?.trim() === '1';
57
+ }
58
+ /**
59
+ * Resolves the newest published release for the running installation, or `null`
60
+ * when there is nothing worth telling the operator.
61
+ *
62
+ * Source checkouts never check: their version is whatever the working tree
63
+ * says, and `agents-host update` is the wrong advice for a git tree. That is
64
+ * settled from the path alone, so `pnpm dev` never spawns a package manager.
65
+ * Which manager owns a real install is only asked once there is a notice to
66
+ * word, so a start with nothing to report never pays for that either.
67
+ */
68
+ export async function resolveAvailableUpdate(options = {}, deps = {}) {
69
+ const env = options.env ?? process.env;
70
+ if (isUpdateCheckDisabled(env)) {
71
+ return null;
72
+ }
73
+ const resolveLocation = deps.resolveLocation ?? (() => resolvePackageLocation());
74
+ const location = await resolveLocation();
75
+ if (location.nodeModulesOwnerDir === null) {
76
+ return null;
77
+ }
78
+ const latestVersion = await resolveLatestPublishedVersion(env, location, deps);
79
+ if (latestVersion === null || !isNewerSemanticVersion(latestVersion, location.version)) {
80
+ return null;
81
+ }
82
+ const classifyInstallation = deps.classifyInstallation ??
83
+ ((candidate) => classifyPackageInstallation(candidate));
84
+ return { installation: await classifyInstallation(location), latestVersion };
85
+ }
86
+ async function resolveLatestPublishedVersion(env, location, deps) {
87
+ const now = deps.now ?? Date.now;
88
+ const readCache = deps.readCache ?? readCacheFile;
89
+ const writeCache = deps.writeCache ?? writeCacheFile;
90
+ const readRegistry = deps.readRegistry ?? readConfiguredRegistry;
91
+ const readLatestVersion = deps.readLatestVersion ?? readPublishedVersion;
92
+ const logger = createUpdateCheckLogger(env, deps);
93
+ const cachePath = resolveUpdateCheckCachePath(env);
94
+ const [cached, registry] = await Promise.all([
95
+ readCache(cachePath)
96
+ .then(parseCacheEntry)
97
+ .catch((error) => {
98
+ // A missing or damaged advisory cache only costs one registry request.
99
+ logger.debugError('update check cache unreadable', error);
100
+ return null;
101
+ }),
102
+ readRegistry(UPDATE_CHECK_TIMEOUT_MS),
103
+ ]);
104
+ const checkedAt = now();
105
+ if (cached !== null && cached.registry === registry) {
106
+ // An entry stamped in the future — a clock that has since been corrected,
107
+ // or a poisoned cache — would otherwise stay "fresh" forever and pin the
108
+ // operator on a stale release, so only an age inside the window counts.
109
+ const age = checkedAt - cached.checkedAt;
110
+ if (age >= 0 && age < UPDATE_CHECK_CACHE_TTL_MS) {
111
+ return cached.latestVersion;
112
+ }
113
+ }
114
+ const persist = (latestVersion) => writeCache(cachePath, `${JSON.stringify({ checkedAt, registry, latestVersion })}\n`).catch((error) => {
115
+ logger.debugError('update check cache not persisted', error);
116
+ });
117
+ let latestVersion;
118
+ try {
119
+ latestVersion = await readLatestVersion(location.packageName, UPDATE_CHECK_TIMEOUT_MS);
120
+ }
121
+ catch (error) {
122
+ // Record the failed attempt: an unreachable registry must cost the deadline
123
+ // once per TTL, not on every single start.
124
+ await persist(null);
125
+ throw error;
126
+ }
127
+ await persist(latestVersion);
128
+ return latestVersion;
129
+ }
130
+ function renderUpdateNotice(update) {
131
+ const { installation, latestVersion } = update;
132
+ const headline = `update available: ${installation.version} -> ${latestVersion}`;
133
+ if (installation.kind === 'project') {
134
+ return [
135
+ headline,
136
+ `update ${installation.packageName} in ${installation.projectRootDir} to pick it up`,
137
+ ];
138
+ }
139
+ return [headline, 'run `agents-host update` to install it'];
140
+ }
141
+ /**
142
+ * Startup-path entry point. The notice is advisory, so every failure below it —
143
+ * offline machine, unreachable registry, unreadable cache — is logged in debug
144
+ * mode and then dropped: a registry outage must never keep an agent from
145
+ * starting. It writes to stderr so machine-readable stdout stays clean.
146
+ */
147
+ export async function emitUpdateNotice(options = {}, deps = {}) {
148
+ const logger = createUpdateCheckLogger(options.env ?? process.env, deps);
149
+ try {
150
+ const update = await resolveAvailableUpdate(options, deps);
151
+ if (update === null) {
152
+ return;
153
+ }
154
+ for (const line of renderUpdateNotice(update)) {
155
+ logger.error(line);
156
+ }
157
+ }
158
+ catch (error) {
159
+ logger.debugError('update check failed', error);
160
+ }
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@borgee/agents-host",
3
- "version": "0.2.29",
3
+ "version": "0.2.32",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -22,11 +22,12 @@
22
22
  },
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
+ "@agentclientprotocol/claude-agent-acp": "0.64.0",
25
26
  "@agentclientprotocol/codex-acp": "1.1.7",
26
27
  "@agentclientprotocol/sdk": "1.3.0",
27
28
  "cross-spawn": "^7.0.6",
28
29
  "yaml": "^2.8.1",
29
- "@borgee/plugin-sdk": "0.2.4"
30
+ "@borgee/plugin-sdk": "0.2.5"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/cross-spawn": "^6.0.6",
@@ -47,6 +48,8 @@
47
48
  "typecheck": "tsc --noEmit",
48
49
  "pretest": "pnpm --filter @borgee/plugin-sdk build",
49
50
  "test": "vitest run --testTimeout=10000",
51
+ "pretest:e2e": "pnpm --filter @borgee/plugin-sdk build",
52
+ "test:e2e": "vitest run --config vitest.e2e.config.ts --testTimeout=120000",
50
53
  "pretypecheck": "pnpm --filter @borgee/plugin-sdk build"
51
54
  }
52
55
  }
@@ -23,10 +23,12 @@ Use one of the packaged local CLIs to inspect the current channel bootstrap payl
23
23
  - Python auxiliary mention: `python3 ./borgee-agent.py --context /absolute/path/to/context.json --auth-path /absolute/path/to/.localhost-gateway-auth.json --turn-execution-id turn-id --send-mention user-id --body "Need review from <@user-id>"`
24
24
  - Python auxiliary reply: `python3 ./borgee-agent.py --context /absolute/path/to/context.json --auth-path /absolute/path/to/.localhost-gateway-auth.json --turn-execution-id turn-id --send-message --body "Following up here" --reply-to message-id`
25
25
 
26
- When the injected `context.json` includes `taskAssignmentContext.active: true`, the same CLIs also expose the task-thread compatibility surface:
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
- - In ordinary parent channels: `--create-task`, `--list-tasks`, `--get-task --task-id ...`, `--update-task --task-id ...`
29
- - In task-assignment threads: `--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; `--create-task` and `--list-tasks` stay disabled there 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 ...`
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
30
+
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.
30
32
 
31
33
  The private draft snapshot is read-only, collaboration-scoped, and separate from ordinary public channel messages.
32
34
  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.
@@ -6,6 +6,11 @@ const TASK_THREAD_COLLECTION_COMMAND_ERROR
6
6
  = 'Task assignment threads only support --get-task and --update-task. 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
+ // A window value outside the IEEE-754 safe integer range does not survive this CLI: it parses
10
+ // the value to a number and re-stringifies it into the query string, so `1000000000000000000000`
11
+ // leaves as `1e+21` and the gateway's `Number.parseInt(value, 10)` reads it back as `1` — a
12
+ // different page, silently. Both packaged CLIs accept exactly this grammar.
13
+ const INTEGER_ARGUMENT_PATTERN = /^[+-]?[0-9]+$/;
9
14
 
10
15
  function parseArgs(argv) {
11
16
  let contextPath;
@@ -36,10 +41,13 @@ function parseArgs(argv) {
36
41
  if (value == null) {
37
42
  throw new Error(`Missing value after ${flag}`);
38
43
  }
39
- const parsed = Number.parseInt(value, 10);
40
- if (!Number.isFinite(parsed)) {
44
+ if (!INTEGER_ARGUMENT_PATTERN.test(value)) {
41
45
  throw new Error(`Invalid integer for ${flag}: ${value}`);
42
46
  }
47
+ const parsed = Number(value);
48
+ if (!Number.isSafeInteger(parsed)) {
49
+ throw new Error(`Integer out of range for ${flag}: ${value}`);
50
+ }
43
51
  return parsed;
44
52
  }
45
53
 
@@ -80,6 +88,10 @@ function parseArgs(argv) {
80
88
  setAction('read-history');
81
89
  continue;
82
90
  }
91
+ if (arg === '--read-task-history') {
92
+ setAction('read-task-history');
93
+ continue;
94
+ }
83
95
  if (arg === '--read-draft') {
84
96
  setAction('read-draft');
85
97
  continue;
@@ -215,16 +227,39 @@ function ensureVisibleMentions(body, participantIds) {
215
227
  );
216
228
  }
217
229
 
218
- function explicitTaskIdProvided(options) {
219
- return typeof options.taskId === 'string' && options.taskId.trim().length > 0;
230
+ function explicitTaskId(options) {
231
+ const taskId = typeof options.taskId === 'string' ? options.taskId.trim() : '';
232
+ return taskId.length > 0 ? taskId : null;
233
+ }
234
+
235
+ function missingTaskIdError(action) {
236
+ return new Error(`Missing required --task-id <value> for --${action}`);
237
+ }
238
+
239
+ function requireExplicitTaskId(action, options) {
240
+ const taskId = explicitTaskId(options);
241
+ if (taskId === null) {
242
+ throw missingTaskIdError(action);
243
+ }
244
+ return taskId;
245
+ }
246
+
247
+ function applyHistoryWindow(url, options) {
248
+ if (options.limit != null) {
249
+ url.searchParams.set('limit', String(options.limit));
250
+ }
251
+ if (options.before != null) {
252
+ url.searchParams.set('before', String(options.before));
253
+ }
254
+ if (options.after != null) {
255
+ url.searchParams.set('after', String(options.after));
256
+ }
220
257
  }
221
258
 
222
259
  function resolveTaskId(payload, action, options) {
223
- const explicitTaskId = typeof options.taskId === 'string'
224
- ? options.taskId.trim()
225
- : '';
226
- if (explicitTaskId) {
227
- return explicitTaskId;
260
+ const taskId = explicitTaskId(options);
261
+ if (taskId !== null) {
262
+ return taskId;
228
263
  }
229
264
  if (payload.taskAssignmentContext?.active === true) {
230
265
  const persistedTaskId = typeof payload.taskAssignmentContext.currentTaskId === 'string'
@@ -235,7 +270,7 @@ function resolveTaskId(payload, action, options) {
235
270
  }
236
271
  return null;
237
272
  }
238
- throw new Error(`Missing required --task-id <value> for --${action}`);
273
+ throw missingTaskIdError(action);
239
274
  }
240
275
 
241
276
  function resolveGatewayRequest(payload, action, options) {
@@ -256,15 +291,13 @@ function resolveGatewayRequest(payload, action, options) {
256
291
  return { url: new URL(`/v1/channels/${encodedChannelId}/me`, gateway.baseUrl), method: 'GET' };
257
292
  case 'read-history': {
258
293
  const url = new URL(`/v1/channels/${encodedChannelId}/history`, gateway.baseUrl);
259
- if (options.limit != null) {
260
- url.searchParams.set('limit', String(options.limit));
261
- }
262
- if (options.before != null) {
263
- url.searchParams.set('before', String(options.before));
264
- }
265
- if (options.after != null) {
266
- url.searchParams.set('after', String(options.after));
267
- }
294
+ applyHistoryWindow(url, options);
295
+ return { url, method: 'GET' };
296
+ }
297
+ case 'read-task-history': {
298
+ const taskId = requireExplicitTaskId(action, options);
299
+ const url = new URL(`/v1/tasks/${encodeURIComponent(taskId)}/history`, gateway.baseUrl);
300
+ applyHistoryWindow(url, options);
268
301
  return { url, method: 'GET' };
269
302
  }
270
303
  case 'read-draft': {
@@ -298,7 +331,7 @@ function resolveGatewayRequest(payload, action, options) {
298
331
  return { url: new URL(`/v1/channels/${encodedChannelId}/tasks`, gateway.baseUrl), method: 'GET' };
299
332
  case 'get-task': {
300
333
  const taskId = resolveTaskId(payload, action, options);
301
- if (payload.taskAssignmentContext?.active === true && !explicitTaskIdProvided(options)) {
334
+ if (payload.taskAssignmentContext?.active === true && explicitTaskId(options) === null) {
302
335
  return {
303
336
  url: new URL(`/v1/channels/${encodedChannelId}/current-task`, gateway.baseUrl),
304
337
  method: 'GET',
@@ -317,11 +350,12 @@ function resolveGatewayRequest(payload, action, options) {
317
350
  ...(options.status != null ? { status: options.status } : {}),
318
351
  ...(options.assigneeId != null ? { assigneeId: options.assigneeId } : {}),
319
352
  ...(options.title != null ? { title: options.title } : {}),
353
+ ...(options.description != null ? { description: options.description } : {}),
320
354
  };
321
355
  if (Object.keys(requestBody).length === 0) {
322
- throw new Error('At least one of --status, --assignee-id, or --title is required for --update-task');
356
+ throw new Error('At least one of --status, --assignee-id, --title, or --description is required for --update-task');
323
357
  }
324
- if (payload.taskAssignmentContext?.active === true && !explicitTaskIdProvided(options)) {
358
+ if (payload.taskAssignmentContext?.active === true && explicitTaskId(options) === null) {
325
359
  return {
326
360
  url: new URL(`/v1/channels/${encodedChannelId}/current-task`, gateway.baseUrl),
327
361
  method: 'PATCH',
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
6
7
  import sys
7
8
  from pathlib import Path
8
9
  from urllib.error import HTTPError
@@ -17,15 +18,23 @@ TASK_THREAD_MISSING_TASK_ID_ERROR = (
17
18
  "Task assignment thread could not resolve the current task from persisted context "
18
19
  "or local fallback. Pass --task-id explicitly."
19
20
  )
21
+ # Python ints are arbitrary precision, so nothing can be rounded on the way into the query
22
+ # string; the bound is here only so both packaged CLIs accept and reject exactly the same
23
+ # inputs. It is the Node CLI that needs it: there an out-of-range magnitude re-stringifies as
24
+ # `1e+21`, which the gateway's `Number.parseInt(value, 10)` reads back as `1`.
25
+ INTEGER_ARGUMENT_PATTERN = re.compile(r"[+-]?[0-9]+")
26
+ MAX_SAFE_INTEGER = 2**53 - 1
20
27
 
21
28
 
22
29
  def parse_int(flag: str, value: str | None) -> int:
23
30
  if value is None:
24
31
  raise ValueError(f"Missing value after {flag}")
25
- try:
26
- return int(value)
27
- except ValueError as exc:
28
- raise ValueError(f"Invalid integer for {flag}: {value}") from exc
32
+ if not INTEGER_ARGUMENT_PATTERN.fullmatch(value):
33
+ raise ValueError(f"Invalid integer for {flag}: {value}")
34
+ parsed = int(value)
35
+ if abs(parsed) > MAX_SAFE_INTEGER:
36
+ raise ValueError(f"Integer out of range for {flag}: {value}")
37
+ return parsed
29
38
 
30
39
 
31
40
  def parse_args(argv: list[str]) -> dict[str, object]:
@@ -80,6 +89,8 @@ def parse_args(argv: list[str]) -> dict[str, object]:
80
89
  set_action("get-me")
81
90
  elif arg == "--read-history":
82
91
  set_action("read-history")
92
+ elif arg == "--read-task-history":
93
+ set_action("read-task-history")
83
94
  elif arg == "--read-draft":
84
95
  set_action("read-draft")
85
96
  elif arg == "--list-users":
@@ -185,22 +196,42 @@ def ensure_visible_mentions(body: str, participant_ids: list[str]) -> str:
185
196
  return next_body
186
197
 
187
198
 
188
- def explicit_task_id_provided(options: dict[str, object]) -> bool:
199
+ def explicit_task_id(options: dict[str, object]) -> str | None:
189
200
  task_id = options.get("task_id")
190
- return isinstance(task_id, str) and bool(task_id.strip())
201
+ trimmed = task_id.strip() if isinstance(task_id, str) else ""
202
+ return trimmed if trimmed else None
203
+
204
+
205
+ def missing_task_id_error(action: str) -> ValueError:
206
+ return ValueError(f"Missing required --task-id <value> for --{action}")
207
+
208
+
209
+ def require_explicit_task_id(action: str, options: dict[str, object]) -> str:
210
+ task_id = explicit_task_id(options)
211
+ if task_id is None:
212
+ raise missing_task_id_error(action)
213
+ return task_id
214
+
215
+
216
+ def history_query_suffix(options: dict[str, object]) -> str:
217
+ query: dict[str, str] = {}
218
+ for key in ("limit", "before", "after"):
219
+ if options.get(key) is not None:
220
+ query[key] = str(options[key])
221
+ return f"?{urlencode(query)}" if query else ""
191
222
 
192
223
 
193
224
  def resolve_task_id(payload: dict[str, object], action: str, options: dict[str, object]) -> str | None:
194
- task_id = options.get("task_id")
195
- if isinstance(task_id, str) and task_id.strip():
196
- return task_id.strip()
225
+ task_id = explicit_task_id(options)
226
+ if task_id is not None:
227
+ return task_id
197
228
  task_assignment_context = payload.get("taskAssignmentContext")
198
229
  if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True:
199
230
  current_task_id = task_assignment_context.get("currentTaskId")
200
231
  if isinstance(current_task_id, str) and current_task_id.strip():
201
232
  return current_task_id.strip()
202
233
  return None
203
- raise ValueError(f"Missing required --task-id <value> for --{action}")
234
+ raise missing_task_id_error(action)
204
235
 
205
236
 
206
237
  def resolve_gateway_request(
@@ -225,15 +256,10 @@ def resolve_gateway_request(
225
256
  if action == "get-me":
226
257
  return f"{base_url}/v1/channels/{channel_id}/me", "GET", None, False
227
258
  if action == "read-history":
228
- query: dict[str, str] = {}
229
- if options.get("limit") is not None:
230
- query["limit"] = str(options["limit"])
231
- if options.get("before") is not None:
232
- query["before"] = str(options["before"])
233
- if options.get("after") is not None:
234
- query["after"] = str(options["after"])
235
- suffix = f"?{urlencode(query)}" if query else ""
236
- return f"{base_url}/v1/channels/{channel_id}/history{suffix}", "GET", None, False
259
+ return f"{base_url}/v1/channels/{channel_id}/history{history_query_suffix(options)}", "GET", None, False
260
+ if action == "read-task-history":
261
+ task_id = quote(require_explicit_task_id(action, options), safe="")
262
+ return f"{base_url}/v1/tasks/{task_id}/history{history_query_suffix(options)}", "GET", None, False
237
263
  if action == "read-draft":
238
264
  turn_execution_id = options.get("turn_execution_id")
239
265
  if not isinstance(turn_execution_id, str) or not turn_execution_id.strip():
@@ -258,7 +284,7 @@ def resolve_gateway_request(
258
284
  return f"{base_url}/v1/channels/{channel_id}/tasks", "GET", None, False
259
285
  if action == "get-task":
260
286
  task_id = resolve_task_id(payload, action, options)
261
- if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True and not explicit_task_id_provided(options):
287
+ if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True and explicit_task_id(options) is None:
262
288
  return f"{base_url}/v1/channels/{channel_id}/current-task", "GET", None, True
263
289
  return f"{base_url}/v1/tasks/{quote(str(task_id), safe='')}", "GET", None, False
264
290
  if action == "update-task":
@@ -270,9 +296,11 @@ def resolve_gateway_request(
270
296
  request_body["assigneeId"] = options["assignee_id"]
271
297
  if options.get("title") is not None:
272
298
  request_body["title"] = options["title"]
299
+ if options.get("description") is not None:
300
+ request_body["description"] = options["description"]
273
301
  if not request_body:
274
- raise ValueError("At least one of --status, --assignee-id, or --title is required for --update-task")
275
- if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True and not explicit_task_id_provided(options):
302
+ raise ValueError("At least one of --status, --assignee-id, --title, or --description is required for --update-task")
303
+ if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True and explicit_task_id(options) is None:
276
304
  return f"{base_url}/v1/channels/{channel_id}/current-task", "PATCH", request_body, True
277
305
  return f"{base_url}/v1/tasks/{quote(str(task_id), safe='')}", "PATCH", request_body, False
278
306
  raise ValueError(f"Unsupported gateway action: {action}")
@@ -294,11 +322,11 @@ def read_gateway_auth(auth_path: Path | None, expected_channel_id: object) -> di
294
322
 
295
323
 
296
324
  def call_gateway(payload: dict[str, object], action: str, options: dict[str, object]) -> object:
325
+ url, method, request_body, uses_current_thread_fallback = resolve_gateway_request(payload, action, options)
297
326
  auth = read_gateway_auth(
298
327
  options.get("auth_path") if isinstance(options.get("auth_path"), Path) else None,
299
328
  payload.get("channelId"),
300
329
  )
301
- url, method, request_body, uses_current_thread_fallback = resolve_gateway_request(payload, action, options)
302
330
 
303
331
  data = None
304
332
  headers = {"Authorization": f"Bearer {auth['token']}"}
@@ -339,11 +367,12 @@ def call_gateway(payload: dict[str, object], action: str, options: dict[str, obj
339
367
  response_body = response.read().decode("utf-8")
340
368
  return json.loads(response_body) if response_body else None
341
369
  except HTTPError as exc:
342
- if uses_current_thread_fallback and exc.code == 404:
370
+ error_body = exc.read().decode("utf-8")
371
+ error_payload = json.loads(error_body) if error_body else None
372
+ if uses_current_thread_fallback and isinstance(error_payload, dict) and error_payload.get("error") == "not_found":
343
373
  raise RuntimeError(TASK_THREAD_MISSING_TASK_ID_ERROR) from exc
344
- raise RuntimeError(f"Gateway request failed: {exc}") from exc
345
- except Exception as exc:
346
- raise RuntimeError(f"Gateway request failed: {exc}") from exc
374
+ reported_body = json.dumps(error_payload, separators=(",", ":"), ensure_ascii=False)
375
+ raise RuntimeError(f"Gateway request failed with {exc.code}: {reported_body}") from exc
347
376
 
348
377
 
349
378
  def redact_bootstrap(payload: dict[str, object], context_path: Path) -> dict[str, object]: