@akshar5/cohall 0.7.0 → 0.9.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0](https://github.com/AksharP5/cohall/compare/v0.8.0...v0.9.0) (2026-09-30)
4
+
5
+
6
+ ### Features
7
+
8
+ * **devices:** show queue depth and oldest wait ([#95](https://github.com/AksharP5/cohall/issues/95)) ([f9a6f94](https://github.com/AksharP5/cohall/commit/f9a6f943b61a22840ce4be73f79a965fb5a193c3))
9
+ * **tasks:** report bounded worker progress ([#97](https://github.com/AksharP5/cohall/issues/97)) ([ced7062](https://github.com/AksharP5/cohall/commit/ced706251800fb4daa47f5ef9fe8e2672c9d445a))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **doctor:** report rejected client credentials ([#92](https://github.com/AksharP5/cohall/issues/92)) ([8744622](https://github.com/AksharP5/cohall/commit/87446220f84753d18b29d670dc5636c3036ac502))
15
+ * **mcp:** warn when upgrades need a connection restart ([#99](https://github.com/AksharP5/cohall/issues/99)) ([106e4a6](https://github.com/AksharP5/cohall/commit/106e4a645d0cf0814195ca8387cc45b2cb630c86))
16
+
17
+ ## [0.8.0](https://github.com/AksharP5/cohall/compare/v0.7.0...v0.8.0) (2026-09-27)
18
+
19
+
20
+ ### Features
21
+
22
+ * **doctor:** verify MCP server exposes tools ([#89](https://github.com/AksharP5/cohall/issues/89)) ([bee2abc](https://github.com/AksharP5/cohall/commit/bee2abcf64bd5715a88c560d07b8515455cd90d1))
23
+
3
24
  ## [0.7.0](https://github.com/AksharP5/cohall/compare/v0.6.2...v0.7.0) (2026-09-26)
4
25
 
5
26
 
package/README.md CHANGED
@@ -8,6 +8,18 @@ One npm package provides a durable self-hosted relay, outbound-only device
8
8
  workers, a CLI, an installable agent skill, an optional MCP server, and local
9
9
  Codex, Claude Code, and OpenCode adapters, plus an experimental Grok Bot adapter.
10
10
 
11
+ https://github.com/user-attachments/assets/7db8ebb2-da77-484f-bba9-703a44f95f4a
12
+
13
+ <details>
14
+ <summary>Video music credits</summary>
15
+
16
+ Music: ["Hang Low" by Rewob (feat. DustyZonda)](https://ccmixter.org/files/rewob/69753),
17
+ including ["Hang Low a capella - Ashes and Dreams - main vox" by DustyZonda (feat. Liv Mircea)](https://ccmixter.org/files/DustyZonda/60053).
18
+ Both licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).
19
+ Trimmed and mixed for this video.
20
+
21
+ </details>
22
+
11
23
  ## How it works
12
24
 
13
25
  ```text
@@ -197,6 +209,10 @@ npx -y @akshar5/cohall wait <task-id> --timeout 1800
197
209
  npx -y @akshar5/cohall trace <task-id> --follow
198
210
  ```
199
211
 
212
+ Task status and traces include the worker's latest progress note when it reports
213
+ one. Workers can publish a brief milestone with `cohall progress --message
214
+ "Running tests"`; the task ID is inherited during delegated work.
215
+
200
216
  Completed tasks also appear in the sending client's inbox. This covers queued
201
217
  work and tasks that finish after a wait times out:
202
218
 
@@ -351,6 +367,9 @@ cohall usage
351
367
  ```
352
368
 
353
369
  `doctor --all` reports connectivity, providers, workspaces, and version drift.
370
+ It and `cohall devices` include each device's current queue count and the
371
+ creation time of its oldest queued task. Assigned, running, cancelling, and
372
+ terminal tasks do not count as queued. Queue fields are absent with older relays.
354
373
  `usage` reports retained Cohall task activity by device, status, and provider;
355
374
  provider token counts and billing are not available to the relay.
356
375
 
package/bin/cohall.js CHANGED
@@ -17,7 +17,7 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
 
18
18
  // packages/protocol/src/index.ts
19
19
  import { Effect, Schema } from "effect";
20
- var version = "0.7.0", maxSocketPayloadBytes, maxAttachmentBytes, maxTaskAttachments = 2, bounded = (maxLength) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maxLength)), optionalText = (maxLength) => Schema.String.check(Schema.isMaxLength(maxLength)), boundedArray = (schema, maxLength) => Schema.Array(schema).check(Schema.isMaxLength(maxLength)), count, uuid = (name) => Schema.String.check(Schema.isUUID(4)).pipe(Schema.brand(name)), isoTimestamp = (name) => Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/, {
20
+ var version = "0.9.0", maxSocketPayloadBytes, maxAttachmentBytes, maxTaskAttachments = 2, maxProgressNoteBytes = 1024, bounded = (maxLength) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maxLength)), optionalText = (maxLength) => Schema.String.check(Schema.isMaxLength(maxLength)), boundedArray = (schema, maxLength) => Schema.Array(schema).check(Schema.isMaxLength(maxLength)), count, uuid = (name) => Schema.String.check(Schema.isUUID(4)).pipe(Schema.brand(name)), isoTimestamp = (name) => Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/, {
21
21
  expected: "an ISO-8601 UTC timestamp"
22
22
  })).pipe(Schema.brand(name)), BotId, Bot, DeviceId, ThreadId, MessageId, TaskId, AuthSessionId, OperationId, Timestamp, Platform, Provider, DeviceStatus, TaskStatus, OperationStatus, TaskTraceEventKind, MessageRole, ConnectionRole, AuthSession, CreatePairingInput, PairingCredential, ExchangePairingInput, SessionCredential, PairingResult, Capability, Workspace, Device, minimumDeviceOperationVersion = "0.5.0", parsedVersion = (value) => {
23
23
  const match = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
@@ -56,7 +56,7 @@ var version = "0.7.0", maxSocketPayloadBytes, maxAttachmentBytes, maxTaskAttachm
56
56
  return "Grok Bots use their own computer context; omit workspace";
57
57
  }
58
58
  return provider === "grok-bot" === (input.botId !== undefined) ? undefined : "The grok-bot provider requires botId; other providers cannot select a bot";
59
- }, Task, TaskInboxItem, TaskInbox, taskSlot = (task) => task.provider === "grok-bot" ? `grok-bot:${task.botId}` : "cli", upgradeVersion, CreateUpgradeOperationsInput, DeviceOperation, TaskStatusCounts, ProviderUsage, DeviceUsage, UsageSummary, TaskTraceEvent, TaskTrace, ThreadContext, CreateTaskInput, SocketEvent, ErrorResponse, terminalTaskStatuses, isTerminalTask = (task) => terminalTaskStatuses.has(task.status), now = () => Timestamp.make(new Date().toISOString()), makeDeviceId = () => DeviceId.make(crypto.randomUUID()), makeThreadId = () => ThreadId.make(crypto.randomUUID()), makeMessageId = () => MessageId.make(crypto.randomUUID()), makeTaskId = () => TaskId.make(crypto.randomUUID()), makeAuthSessionId = () => AuthSessionId.make(crypto.randomUUID()), makeOperationId = () => OperationId.make(crypto.randomUUID()), decodeCreatePairingInput, decodeExchangePairingInput, decodeCreateTaskInput = (input) => Schema.decodeUnknownEffect(CreateTaskInput)(input).pipe(Effect.map((task) => ({
59
+ }, TaskProgressInput, TaskProgress, Task, TaskInboxItem, TaskInbox, taskSlot = (task) => task.provider === "grok-bot" ? `grok-bot:${task.botId}` : "cli", upgradeVersion, CreateUpgradeOperationsInput, DeviceOperation, TaskStatusCounts, ProviderUsage, DeviceUsage, UsageSummary, TaskTraceEvent, TaskTrace, ThreadContext, CreateTaskInput, SocketEvent, ErrorResponse, terminalTaskStatuses, isTerminalTask = (task) => terminalTaskStatuses.has(task.status), now = () => Timestamp.make(new Date().toISOString()), makeDeviceId = () => DeviceId.make(crypto.randomUUID()), makeThreadId = () => ThreadId.make(crypto.randomUUID()), makeMessageId = () => MessageId.make(crypto.randomUUID()), makeTaskId = () => TaskId.make(crypto.randomUUID()), makeAuthSessionId = () => AuthSessionId.make(crypto.randomUUID()), makeOperationId = () => OperationId.make(crypto.randomUUID()), decodeCreatePairingInput, decodeTaskProgressInput, decodeExchangePairingInput, decodeCreateTaskInput = (input) => Schema.decodeUnknownEffect(CreateTaskInput)(input).pipe(Effect.map((task) => ({
60
60
  ...task,
61
61
  provider: task.provider ?? (task.botId === undefined ? "codex" : "grok-bot")
62
62
  }))), decodeCreateUpgradeOperationsInput, decodeDevice, decodeSocketEvent;
@@ -149,7 +149,11 @@ var init_src = __esm(() => {
149
149
  workspaces: boundedArray(Workspace, 64),
150
150
  version: bounded(32),
151
151
  lastSeenAt: Timestamp,
152
- connectedAt: Schema.optionalKey(Timestamp)
152
+ connectedAt: Schema.optionalKey(Timestamp),
153
+ queue: Schema.optionalKey(Schema.Struct({
154
+ queued: count,
155
+ oldestQueuedAt: Schema.optionalKey(Timestamp)
156
+ }))
153
157
  });
154
158
  Thread = Schema.Struct({
155
159
  id: ThreadId,
@@ -180,6 +184,10 @@ var init_src = __esm(() => {
180
184
  data: Schema.String.check(Schema.isMaxLength(Math.ceil(maxAttachmentBytes / 3) * 4), Schema.makeFilter((value) => value.length > 0 && value.length % 4 === 0 && value.length / 4 * 3 - (value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0) <= maxAttachmentBytes && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) ? undefined : "Expected nonempty base64 data"))
181
185
  });
182
186
  attachments = boundedArray(InputAttachment, maxTaskAttachments).check(Schema.makeFilter((files) => new Set(files.map((file) => file.name.normalize("NFC").toLocaleLowerCase("en-US"))).size === files.length ? undefined : "Attachment names must be unique"));
187
+ TaskProgressInput = Schema.Struct({
188
+ note: bounded(maxProgressNoteBytes).check(Schema.makeFilter((value) => value.trim().length > 0 && new TextEncoder().encode(value).byteLength <= maxProgressNoteBytes ? undefined : `Expected a nonblank progress note of at most ${maxProgressNoteBytes} UTF-8 bytes`)).pipe(Schema.brand("TaskProgressNote"))
189
+ });
190
+ TaskProgress = Schema.Struct({ ...TaskProgressInput.fields, at: Timestamp });
183
191
  Task = Schema.Struct({
184
192
  id: TaskId,
185
193
  threadId: ThreadId,
@@ -196,6 +204,7 @@ var init_src = __esm(() => {
196
204
  result: Schema.optionalKey(optionalText(131072)),
197
205
  inputAttachmentNames: Schema.optionalKey(boundedArray(AttachmentName, maxTaskAttachments)),
198
206
  error: Schema.optionalKey(optionalText(16384)),
207
+ progress: Schema.optionalKey(TaskProgress),
199
208
  createdAt: Timestamp,
200
209
  updatedAt: Timestamp,
201
210
  startedAt: Schema.optionalKey(Timestamp),
@@ -284,7 +293,8 @@ var init_src = __esm(() => {
284
293
  completedAt: Schema.optionalKey(Timestamp),
285
294
  events: boundedArray(TaskTraceEvent, 100),
286
295
  truncated: Schema.Boolean,
287
- error: Schema.optionalKey(optionalText(16384))
296
+ error: Schema.optionalKey(optionalText(16384)),
297
+ progress: Schema.optionalKey(TaskProgress)
288
298
  }).check(Schema.makeFilter(validBotTarget));
289
299
  ThreadContext = Schema.Struct({
290
300
  thread: Thread,
@@ -345,6 +355,7 @@ var init_src = __esm(() => {
345
355
  "cancelled"
346
356
  ]);
347
357
  decodeCreatePairingInput = Schema.decodeUnknownEffect(CreatePairingInput);
358
+ decodeTaskProgressInput = Schema.decodeUnknownEffect(TaskProgressInput);
348
359
  decodeExchangePairingInput = Schema.decodeUnknownEffect(ExchangePairingInput);
349
360
  decodeCreateUpgradeOperationsInput = Schema.decodeUnknownEffect(CreateUpgradeOperationsInput);
350
361
  decodeDevice = Schema.decodeUnknownEffect(Device);
@@ -596,13 +607,14 @@ var exports_store = {};
596
607
  __export(exports_store, {
597
608
  layerFromDatabase: () => layerFromDatabase,
598
609
  layer: () => layer2,
610
+ TaskProgressError: () => TaskProgressError,
599
611
  Service: () => Service2,
600
612
  RelayStore: () => exports_store,
601
613
  PersistenceError: () => PersistenceError
602
614
  });
603
615
  import { Context as Context2, Effect as Effect14, Layer as Layer2, Schema as Schema15 } from "effect";
604
616
  import { createHash as createHash2, randomBytes } from "node:crypto";
605
- var PersistenceError, Service2, operationError = (operation) => (cause) => new PersistenceError({
617
+ var PersistenceError, TaskProgressError, Service2, operationError = (operation) => (cause) => new PersistenceError({
606
618
  operation,
607
619
  message: cause instanceof Error ? cause.message : String(cause)
608
620
  }), decode2 = (operation, schema, value) => Schema15.decodeUnknownEffect(schema)(value).pipe(Effect14.mapError(operationError(operation))), threadFromRow = (row) => decode2("RelayStore.decodeThread", Thread, {
@@ -633,7 +645,13 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
633
645
  workspaces: JSON.parse(row.workspaces_json),
634
646
  version: row.version,
635
647
  lastSeenAt: row.last_seen_at,
636
- ...row.connected_at === null ? {} : { connectedAt: row.connected_at }
648
+ ...row.connected_at === null ? {} : { connectedAt: row.connected_at },
649
+ ...row.queued_tasks === undefined ? {} : {
650
+ queue: {
651
+ queued: row.queued_tasks,
652
+ ...row.oldest_queued_at === undefined || row.oldest_queued_at === null ? {} : { oldestQueuedAt: row.oldest_queued_at }
653
+ }
654
+ }
637
655
  }), taskFromRow = (db, row) => Effect14.try({
638
656
  try: () => db.query("SELECT name FROM task_attachments WHERE task_id = ? AND direction = 'input' ORDER BY name").all(row.id),
639
657
  catch: operationError("RelayStore.taskAttachments")
@@ -655,6 +673,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
655
673
  ...row.result === null ? {} : { result: row.result },
656
674
  ...attachments2.length === 0 ? {} : { inputAttachmentNames: attachments2.map((item) => item.name) },
657
675
  ...row.error === null ? {} : { error: row.error },
676
+ ...row.progress_note === null ? {} : { progress: { note: row.progress_note, at: row.progress_at } },
658
677
  ...row.started_at === null ? {} : { startedAt: row.started_at },
659
678
  ...row.completed_at === null ? {} : { completedAt: row.completed_at }
660
679
  }))), inboxItemFromRow = (row) => decode2("RelayStore.decodeInboxItem", TaskInboxItem, {
@@ -902,12 +921,18 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
902
921
  ...task.workspace === undefined ? {} : { workspace: task.workspace },
903
922
  ...task.startedAt === undefined ? {} : { startedAt: task.startedAt },
904
923
  ...task.completedAt === undefined ? {} : { completedAt: task.completedAt },
905
- ...task.error === undefined ? {} : { error: task.error }
924
+ ...task.error === undefined ? {} : { error: task.error },
925
+ ...task.progress === undefined ? {} : { progress: task.progress }
906
926
  });
907
927
  });
908
928
  const listDevices = Effect14.fn("RelayStore.listDevices")(function* () {
909
929
  const rows = yield* Effect14.try({
910
- try: () => db.query("SELECT * FROM devices WHERE forgotten_at IS NULL ORDER BY name COLLATE NOCASE, id").all(),
930
+ try: () => db.query(`SELECT devices.*, COUNT(tasks.id) AS queued_tasks,
931
+ MIN(tasks.created_at) AS oldest_queued_at
932
+ FROM devices LEFT JOIN tasks
933
+ ON tasks.target_device_id = devices.id AND tasks.status = 'queued'
934
+ WHERE devices.forgotten_at IS NULL
935
+ GROUP BY devices.id ORDER BY devices.name COLLATE NOCASE, devices.id`).all(),
911
936
  catch: operationError("RelayStore.listDevices")
912
937
  });
913
938
  return yield* Effect14.forEach(rows, deviceFromRow);
@@ -1127,7 +1152,8 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1127
1152
  const timestamp = now();
1128
1153
  const interrupted = db.query("SELECT id FROM tasks WHERE status IN ('assigned', 'running')").all();
1129
1154
  db.query("UPDATE devices SET status = 'offline'").run();
1130
- db.query("UPDATE tasks SET status = 'queued', updated_at = ? WHERE status IN ('assigned', 'running')").run(timestamp);
1155
+ db.query(`UPDATE tasks SET status = 'queued', updated_at = ?, progress_note = NULL, progress_at = NULL
1156
+ WHERE status IN ('assigned', 'running')`).run(timestamp);
1131
1157
  db.query("UPDATE device_operations SET status = 'queued', updated_at = ? WHERE status IN ('assigned', 'running')").run(timestamp);
1132
1158
  for (const task of interrupted) {
1133
1159
  recordTaskTraceEvent(TaskId.make(task.id), "requeued", "Relay restarted before the task completed", timestamp);
@@ -1371,6 +1397,28 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1371
1397
  }
1372
1398
  return task;
1373
1399
  });
1400
+ const reportTaskProgress = Effect14.fn("RelayStore.reportTaskProgress")(function* (taskId, deviceId, note) {
1401
+ const task = yield* getTask(taskId);
1402
+ if (task.targetDeviceId !== deviceId) {
1403
+ return yield* new TaskProgressError({
1404
+ status: 403,
1405
+ message: "Only the target device can report progress"
1406
+ });
1407
+ }
1408
+ const progress = TaskProgress.make({ note, at: now() });
1409
+ const changed = yield* Effect14.try({
1410
+ try: () => db.query(`UPDATE tasks SET progress_note = ?, progress_at = ?, updated_at = ?
1411
+ WHERE id = ? AND target_device_id = ? AND status = 'running'`).run(note, progress.at, progress.at, taskId, deviceId).changes,
1412
+ catch: operationError("RelayStore.reportTaskProgress")
1413
+ });
1414
+ if (changed !== 1) {
1415
+ return yield* new TaskProgressError({
1416
+ status: 409,
1417
+ message: "Task must be running to report progress"
1418
+ });
1419
+ }
1420
+ return progress;
1421
+ });
1374
1422
  const terminal = Effect14.fn("RelayStore.terminal")(function* (taskId, deviceId, status, result, error, providerSessionId, attachments2) {
1375
1423
  const current = yield* requireTarget(taskId, deviceId);
1376
1424
  if (["completed", "failed", "cancelled"].includes(current.status)) {
@@ -1380,7 +1428,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1380
1428
  yield* Effect14.try({
1381
1429
  try: () => db.transaction(() => {
1382
1430
  const updated = db.query(`UPDATE tasks SET status = ?, result = ?, error = ?, provider_session_id = ?,
1383
- completed_at = ?, updated_at = ? WHERE id = ?
1431
+ completed_at = ?, updated_at = ?, progress_note = NULL, progress_at = NULL WHERE id = ?
1384
1432
  AND status NOT IN ('completed', 'failed', 'cancelled')`).run(status, result ?? null, error ?? null, current.provider === "grok-bot" ? null : providerSessionId ?? current.providerSessionId ?? null, timestamp, timestamp, taskId);
1385
1433
  if (updated.changes !== 1) {
1386
1434
  return;
@@ -1449,7 +1497,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1449
1497
  try: () => db.transaction(() => {
1450
1498
  const interrupted = db.query(`SELECT id FROM tasks WHERE target_device_id = ?
1451
1499
  AND status IN ('assigned', 'running')`).all(deviceId);
1452
- db.query(`UPDATE tasks SET status = 'queued', updated_at = ?
1500
+ db.query(`UPDATE tasks SET status = 'queued', updated_at = ?, progress_note = NULL, progress_at = NULL
1453
1501
  WHERE target_device_id = ? AND status IN ('assigned', 'running')`).run(timestamp, deviceId);
1454
1502
  for (const task of interrupted) {
1455
1503
  recordTaskTraceEvent(TaskId.make(task.id), "requeued", "Target device disconnected before task completion", timestamp);
@@ -1579,6 +1627,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1579
1627
  markDeviceOffline: (deviceId) => updateDeviceStatus(deviceId, "offline"),
1580
1628
  createDelegation: createDelegation2,
1581
1629
  getTask,
1630
+ reportTaskProgress,
1582
1631
  listAttachments,
1583
1632
  readAttachment,
1584
1633
  inboxFor,
@@ -1650,7 +1699,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1650
1699
  target_device_id TEXT NOT NULL REFERENCES devices(id), parent_task_id TEXT,
1651
1700
  workspace TEXT, provider_session_id TEXT, result TEXT, error TEXT,
1652
1701
  created_at TEXT NOT NULL, updated_at TEXT NOT NULL, started_at TEXT, completed_at TEXT,
1653
- dispatched_at TEXT
1702
+ dispatched_at TEXT, progress_note TEXT, progress_at TEXT
1654
1703
  );
1655
1704
  CREATE INDEX IF NOT EXISTS tasks_thread_created ON tasks(thread_id, created_at);
1656
1705
  CREATE INDEX IF NOT EXISTS tasks_target_status ON tasks(target_device_id, status);
@@ -1701,6 +1750,12 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1701
1750
  db.exec("ALTER TABLE devices ADD COLUMN bots_json TEXT");
1702
1751
  }
1703
1752
  const taskColumns = db.query("PRAGMA table_info(tasks)").all();
1753
+ if (!taskColumns.some((column) => column.name === "progress_note")) {
1754
+ db.exec("ALTER TABLE tasks ADD COLUMN progress_note TEXT");
1755
+ }
1756
+ if (!taskColumns.some((column) => column.name === "progress_at")) {
1757
+ db.exec("ALTER TABLE tasks ADD COLUMN progress_at TEXT");
1758
+ }
1704
1759
  if (!taskColumns.some((column) => column.name === "bot_id")) {
1705
1760
  db.exec("ALTER TABLE tasks ADD COLUMN bot_id TEXT");
1706
1761
  }
@@ -1731,6 +1786,8 @@ var init_store = __esm(() => {
1731
1786
  init_store();
1732
1787
  PersistenceError = class PersistenceError extends Schema15.TaggedErrorClass()("RelayStore.PersistenceError", { operation: Schema15.String, message: Schema15.String }) {
1733
1788
  };
1789
+ TaskProgressError = class TaskProgressError extends Schema15.TaggedErrorClass()("RelayStore.TaskProgressError", { status: Schema15.Literals([403, 409]), message: Schema15.String }) {
1790
+ };
1734
1791
  Service2 = class Service2 extends Context2.Service()("@cohall/RelayStore") {
1735
1792
  };
1736
1793
  });
@@ -2184,7 +2241,7 @@ Content-Length: 0\r
2184
2241
  };
2185
2242
  const api = async (request, url) => {
2186
2243
  if (url.pathname === "/api/health" && request.method === "GET") {
2187
- return json({ ok: true, version, taskAttachments: true });
2244
+ return json({ ok: true, version, taskAttachments: true, taskProgress: true });
2188
2245
  }
2189
2246
  if (url.pathname === "/api/auth/pair" && request.method === "POST") {
2190
2247
  return run2(Effect15.gen(function* () {
@@ -2194,7 +2251,8 @@ Content-Length: 0\r
2194
2251
  })).then((value) => json(value, 201)).catch(() => json({ error: "Pairing credential is invalid, expired, or already used" }, 401));
2195
2252
  }
2196
2253
  const attachmentRoute = request.method === "GET" && /^\/api\/tasks\/[^/]+\/attachments(?:\/[^/]+)?$/.test(url.pathname);
2197
- const principal = await principalFor(request, attachmentRoute);
2254
+ const progressRoute = request.method === "POST" && /^\/api\/tasks\/[^/]+\/progress$/.test(url.pathname);
2255
+ const principal = await principalFor(request, attachmentRoute || progressRoute);
2198
2256
  if (principal === undefined) {
2199
2257
  return json({ error: "Unauthorized" }, 401);
2200
2258
  }
@@ -2313,6 +2371,20 @@ Content-Length: 0\r
2313
2371
  if (request.method === "GET" && trace?.[1] !== undefined) {
2314
2372
  return json(yield* store.traceTask(yield* pathId(TaskId, trace[1])));
2315
2373
  }
2374
+ const progress = url.pathname.match(/^\/api\/tasks\/([^/]+)\/progress$/);
2375
+ if (request.method === "POST" && progress?.[1] !== undefined) {
2376
+ const id = yield* pathId(TaskId, progress[1]);
2377
+ const input = yield* body(request, decodeTaskProgressInput);
2378
+ const task2 = yield* store.getTask(id);
2379
+ const deviceId = principal === "owner" ? task2.targetDeviceId : principal.deviceId;
2380
+ if (deviceId === undefined) {
2381
+ return yield* new RequestError({
2382
+ status: 403,
2383
+ message: "Only the target device can report progress"
2384
+ });
2385
+ }
2386
+ return json(yield* store.reportTaskProgress(id, deviceId, input.note));
2387
+ }
2316
2388
  const cancel2 = url.pathname.match(/^\/api\/tasks\/([^/]+)\/cancel$/);
2317
2389
  if (request.method === "POST" && cancel2?.[1] !== undefined) {
2318
2390
  const id = yield* pathId(TaskId, cancel2[1]);
@@ -2327,7 +2399,7 @@ Content-Length: 0\r
2327
2399
  return json(yield* store.threadContext(yield* pathId(ThreadId, thread[1])));
2328
2400
  }
2329
2401
  return yield* new RequestError({ status: 404, message: "Route not found" });
2330
- }).pipe(Effect15.mapError((cause) => cause instanceof RequestError ? cause : new RequestError({
2402
+ }).pipe(Effect15.mapError((cause) => cause instanceof RequestError ? cause : cause._tag === "RelayStore.TaskProgressError" ? new RequestError({ status: cause.status, message: cause.message }) : new RequestError({
2331
2403
  status: cause._tag !== "RelayStore.PersistenceError" ? 500 : cause.message.startsWith("Unknown") ? 404 : cause.message.includes("cannot cancel an individual Cohall request") ? 409 : cause.message.includes("outstanding task limit") ? 429 : cause.message.includes("must be offline") || cause.message.includes("still has outstanding tasks") || cause.message.includes("upgrade operation in progress") || cause.message === "No Cohall devices are registered" || cause.message.startsWith("All-device operations support") ? 409 : 500,
2332
2404
  message: cause.message
2333
2405
  })));
@@ -2598,6 +2670,16 @@ var make = (options) => {
2598
2670
  });
2599
2671
  }),
2600
2672
  getTask: (taskId) => request("RelayClient.getTask", `/api/tasks/${encodeURIComponent(taskId)}`, Task),
2673
+ reportTaskProgress: (taskId, input) => Effect2.gen(function* () {
2674
+ const health = yield* request("RelayClient.progressSupport", "/api/health", Schema2.Struct({ taskProgress: Schema2.optionalKey(Schema2.Boolean) }));
2675
+ if (health.taskProgress !== true) {
2676
+ return yield* new RelayRequestError({
2677
+ operation: "RelayClient.reportTaskProgress",
2678
+ message: "Upgrade the Cohall relay before reporting task progress"
2679
+ });
2680
+ }
2681
+ return yield* request("RelayClient.reportTaskProgress", `/api/tasks/${encodeURIComponent(taskId)}/progress`, TaskProgress, { method: "POST", body: JSON.stringify(input) });
2682
+ }),
2601
2683
  listAttachments: (taskId) => request("RelayClient.listAttachments", `/api/tasks/${encodeURIComponent(taskId)}/attachments`, Schema2.Array(TaskAttachment)),
2602
2684
  readAttachment: (taskId, name, direction) => Effect2.tryPromise({
2603
2685
  try: async (signal) => {
@@ -3665,7 +3747,8 @@ var TaskResult = Schema5.Struct({
3665
3747
  bot_id: Schema5.optionalKey(BotId),
3666
3748
  result: Schema5.optionalKey(Schema5.String),
3667
3749
  error: Schema5.optionalKey(Schema5.String),
3668
- inbox_warning: Schema5.optionalKey(Schema5.String)
3750
+ inbox_warning: Schema5.optionalKey(Schema5.String),
3751
+ progress: Schema5.optionalKey(TaskProgress)
3669
3752
  });
3670
3753
  var matchingDevices = (devices, target) => {
3671
3754
  const value = target.replace(/^@/, "");
@@ -3717,7 +3800,8 @@ var taskResult = (task) => TaskResult.make({
3717
3800
  target_device_id: task.targetDeviceId,
3718
3801
  ...task.botId === undefined ? {} : { bot_id: task.botId },
3719
3802
  ...task.result === undefined ? {} : { result: task.result },
3720
- ...task.error === undefined ? {} : { error: task.error }
3803
+ ...task.error === undefined ? {} : { error: task.error },
3804
+ ...task.progress === undefined ? {} : { progress: task.progress }
3721
3805
  });
3722
3806
  var acknowledgedTaskResult = async (client, task) => {
3723
3807
  const result = taskResult(task);
@@ -3803,7 +3887,7 @@ var followTaskTrace = Effect5.fn("Cohall.followTaskTrace")(function* (client, ta
3803
3887
  let revision;
3804
3888
  const poll = client.traceTask(taskId).pipe(Effect5.tap((trace) => {
3805
3889
  const latest = trace.events.at(-1);
3806
- const nextRevision = `${trace.status}:${latest?.kind ?? "none"}:${latest?.at ?? "none"}`;
3890
+ const nextRevision = `${trace.status}:${latest?.kind ?? "none"}:${latest?.at ?? "none"}:${trace.progress?.at ?? ""}:${trace.progress?.note ?? ""}`;
3807
3891
  if (nextRevision === revision) {
3808
3892
  return Effect5.succeed(undefined);
3809
3893
  }
@@ -3850,6 +3934,7 @@ var allDeviceHealth = (devices, localVersion) => {
3850
3934
  providers: device.providers,
3851
3935
  workspaces: device.workspaces.map((workspace) => workspace.path),
3852
3936
  last_seen_at: device.lastSeenAt,
3937
+ ...device.queue === undefined ? {} : { queue: device.queue },
3853
3938
  warnings
3854
3939
  };
3855
3940
  });
@@ -5530,6 +5615,39 @@ var collectOutputs = async (directory) => {
5530
5615
  return files;
5531
5616
  };
5532
5617
 
5618
+ // apps/device/src/mcp-diagnostics.ts
5619
+ init_src();
5620
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5621
+ import {
5622
+ getDefaultEnvironment,
5623
+ StdioClientTransport
5624
+ } from "@modelcontextprotocol/sdk/client/stdio.js";
5625
+ var checkMcp = async (entrypoint, relayUrl, token) => {
5626
+ const client = new Client({ name: "cohall-doctor", version });
5627
+ const transport = new StdioClientTransport({
5628
+ command: process.execPath,
5629
+ args: [entrypoint, "mcp"],
5630
+ env: {
5631
+ ...getDefaultEnvironment(),
5632
+ COHALL_CONFIG: configurationPath(),
5633
+ COHALL_RELAY_URL: relayUrl,
5634
+ COHALL_CLIENT_TOKEN: token
5635
+ },
5636
+ stderr: "pipe"
5637
+ });
5638
+ try {
5639
+ await client.connect(transport, { timeout: 5000 });
5640
+ const { tools } = await client.listTools(undefined, { timeout: 5000 });
5641
+ if (tools.length === 0)
5642
+ return { status: "error", error: "MCP server exposed no tools" };
5643
+ return { status: "ok", tool_count: tools.length };
5644
+ } catch (cause) {
5645
+ return { status: "error", error: cause instanceof Error ? cause.message : String(cause) };
5646
+ } finally {
5647
+ await client.close();
5648
+ }
5649
+ };
5650
+
5533
5651
  // apps/device/src/cli.ts
5534
5652
  var valueOptions = new Set([
5535
5653
  "attach",
@@ -5585,6 +5703,7 @@ Usage:
5585
5703
  [--grok-gateway path]
5586
5704
  cohall config
5587
5705
  cohall devices
5706
+ cohall progress [task-id] --message <note>
5588
5707
  cohall bots
5589
5708
  cohall send [@device-or-bot] [prompt] [delegate options]
5590
5709
  cohall delegate [prompt] [--target @device-or-bot] [--provider provider]
@@ -6132,16 +6251,29 @@ var runCli = async (command, raw) => {
6132
6251
  return;
6133
6252
  });
6134
6253
  const clientToken = process.env.COHALL_CLIENT_TOKEN ?? storedCredentials.clientToken;
6254
+ const mcp = clientToken === undefined ? { status: "not configured" } : await checkMcp(process.argv[1] ?? "", relayUrl, clientToken);
6135
6255
  const hasDeviceCredential = process.env.COHALL_DEVICE_TOKEN !== undefined || storedCredentials.deviceToken !== undefined;
6136
6256
  const deviceId = process.env.COHALL_DEVICE_ID ?? configuration2?.deviceId;
6137
- const currentDevice = response?.ok !== true || clientToken === undefined || deviceId === undefined ? undefined : await Effect10.runPromise(exports_src.make({ baseUrl: relayUrl, token: clientToken }).devices()).then((devices) => devices.find((device) => device.id === deviceId)).catch(() => {
6138
- return;
6139
- });
6257
+ const clientAuthentication = clientToken === undefined ? { status: "not configured" } : response?.ok !== true ? { status: "not checked" } : await Effect10.runPromise(exports_src.make({ baseUrl: relayUrl, token: clientToken }).devices().pipe(Effect10.match({
6258
+ onSuccess: (devices) => ({ status: "ok", devices }),
6259
+ onFailure: (error) => {
6260
+ const httpStatus = error instanceof RelayRequestError ? error.status : undefined;
6261
+ const reason = httpStatus === 401 || httpStatus === 403 ? "Client credential was rejected by the relay" : "Client relay check failed";
6262
+ return {
6263
+ status: "error",
6264
+ ...httpStatus === undefined ? {} : { http_status: httpStatus },
6265
+ error: `${reason}: ${error.message}`
6266
+ };
6267
+ }
6268
+ })));
6269
+ const currentDevice = clientAuthentication.status === "ok" ? clientAuthentication.devices.find((device) => device.id === deviceId) : undefined;
6140
6270
  const versionWarning = deviceVersionWarning(version, currentDevice?.version);
6141
6271
  const warnings = [
6142
6272
  ...response?.ok === true ? [] : ["Relay is unreachable"],
6143
6273
  ...versionWarning === undefined ? [] : [versionWarning],
6144
- ...hasDeviceCredential && response?.ok === true && currentDevice?.status !== "online" && currentDevice?.status !== "busy" ? [
6274
+ ...mcp.status === "error" ? [`MCP check failed: ${mcp.error}`] : [],
6275
+ ...clientAuthentication.status === "error" ? [clientAuthentication.error] : [],
6276
+ ...hasDeviceCredential && response?.ok === true && clientAuthentication.status !== "error" && currentDevice?.status !== "online" && currentDevice?.status !== "busy" ? [
6145
6277
  clientToken === undefined ? "Device status cannot be checked without a client credential" : "Device daemon is not connected or is not registered with the relay"
6146
6278
  ] : [],
6147
6279
  ...selectedProviders?.flatMap((provider) => provider === "grok-bot" ? grokBots === undefined ? [
@@ -6154,6 +6286,8 @@ var runCli = async (command, raw) => {
6154
6286
  relay_url: relayUrl,
6155
6287
  config_path: configurationPath(),
6156
6288
  client_credential: process.env.COHALL_CLIENT_TOKEN !== undefined || storedCredentials.clientToken !== undefined,
6289
+ client_authentication: clientAuthentication.status === "ok" ? { status: "ok" } : clientAuthentication,
6290
+ mcp,
6157
6291
  device_credential: hasDeviceCredential,
6158
6292
  workspaces: configuration2?.workspaces ?? [],
6159
6293
  providers: providerExecutables,
@@ -6220,6 +6354,18 @@ var runCli = async (command, raw) => {
6220
6354
  return;
6221
6355
  }
6222
6356
  const { configuration, relay } = await client();
6357
+ if (command === "progress") {
6358
+ allowOptions(arguments_, ["message"]);
6359
+ const rawId = arguments_.positionals.length === 0 ? configuration.mcpTaskId : identifier(arguments_, "task id");
6360
+ if (rawId === undefined)
6361
+ throw new Error("Task id is required outside delegated work");
6362
+ const note = option(arguments_, "message");
6363
+ if (note === undefined)
6364
+ throw new Error("--message is required");
6365
+ const input = Schema11.decodeUnknownSync(TaskProgressInput)({ note });
6366
+ print(await Effect10.runPromise(relay.reportTaskProgress(Schema11.decodeUnknownSync(TaskId)(rawId), input)));
6367
+ return;
6368
+ }
6223
6369
  if (command === "devices") {
6224
6370
  allowOptions(arguments_, []);
6225
6371
  noPositionals(arguments_, command);
@@ -6491,6 +6637,7 @@ ${task.context}`;
6491
6637
  "Complete the delegated task using this device's local workspace, tools, credentials, and signed-in services.",
6492
6638
  "Never read, reveal, copy, or use Cohall configuration files or Cohall authentication tokens.",
6493
6639
  "Return a concise, complete result with the evidence the sending agent needs.",
6640
+ "For long tasks, optionally report a brief milestone with `cohall progress --message 'Running tests'`. Do not include logs or secrets in progress notes.",
6494
6641
  ...files === undefined ? [] : [
6495
6642
  ...files.inputNames.length === 0 ? [] : [
6496
6643
  `Input directory: ${files.input}`,
@@ -6905,15 +7052,81 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6905
7052
  import { Effect as Effect12, Schema as Schema13 } from "effect";
6906
7053
  import * as z from "zod/v4";
6907
7054
  import { writeFile as writeFile8 } from "node:fs/promises";
6908
- var output = (value) => ({
6909
- content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
7055
+
7056
+ // apps/device/src/mcp-build-notice.ts
7057
+ import { execFile as execFile3 } from "node:child_process";
7058
+ import { stat as stat6 } from "node:fs/promises";
7059
+ import { promisify } from "node:util";
7060
+ var execute2 = promisify(execFile3);
7061
+ var fingerprint = async (entrypoint) => stat6(entrypoint, { bigint: true }).then((file) => `${file.dev}:${file.ino}:${file.size}:${file.mtimeNs}:${file.ctimeNs}`).catch(() => {
7062
+ return;
6910
7063
  });
7064
+ var createMcpBuildNotice = async (entrypoint, runningVersion, {
7065
+ checkIntervalMs = 60000,
7066
+ probeTimeoutMs = 2000,
7067
+ now: now2 = Date.now
7068
+ } = {}) => {
7069
+ let previous = entrypoint === undefined ? undefined : await fingerprint(entrypoint);
7070
+ let nextCheckAt = 0;
7071
+ const reported = new Set;
7072
+ return async (result) => {
7073
+ if (entrypoint === undefined || now2() < nextCheckAt)
7074
+ return result;
7075
+ nextCheckAt = now2() + checkIntervalMs;
7076
+ const current = await fingerprint(entrypoint);
7077
+ if (current === undefined || current === previous)
7078
+ return result;
7079
+ const installedVersion2 = await execute2(process.execPath, [entrypoint, "--version"], {
7080
+ timeout: probeTimeoutMs,
7081
+ killSignal: "SIGKILL",
7082
+ maxBuffer: 4096,
7083
+ windowsHide: true
7084
+ }).then(({ stdout }) => stdout.trim()).catch(() => {
7085
+ return;
7086
+ });
7087
+ if (installedVersion2 === undefined || !/^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test(installedVersion2)) {
7088
+ return result;
7089
+ }
7090
+ previous = current;
7091
+ if (installedVersion2 === runningVersion || reported.has(installedVersion2))
7092
+ return result;
7093
+ reported.add(installedVersion2);
7094
+ return {
7095
+ ...result,
7096
+ content: [
7097
+ ...result.content,
7098
+ {
7099
+ type: "text",
7100
+ text: `Cohall MCP is still running ${runningVersion}; its executable is now ${installedVersion2}. Restart the Cohall MCP connection in your agent host to load it.`
7101
+ }
7102
+ ]
7103
+ };
7104
+ };
7105
+ };
7106
+
7107
+ // apps/device/src/mcp.ts
6911
7108
  var runMcp = async (configuration) => {
7109
+ const buildNotice = await createMcpBuildNotice(process.argv[1], version);
7110
+ const output = (value) => buildNotice({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
6912
7111
  const client2 = exports_src.make({
6913
7112
  baseUrl: configuration.relayUrl,
6914
7113
  token: configuration.token
6915
7114
  });
6916
7115
  const server = new McpServer({ name: "cohall", version });
7116
+ server.registerTool("task_progress", {
7117
+ title: "Report task progress",
7118
+ description: `Replace the latest progress note for a running task on this device. Use brief milestones, up to ${maxProgressNoteBytes} UTF-8 bytes. Inherits the delegated task ID when omitted.`,
7119
+ inputSchema: {
7120
+ task_id: z.string().uuid().optional(),
7121
+ note: z.string().min(1).max(maxProgressNoteBytes)
7122
+ }
7123
+ }, async ({ task_id, note }) => {
7124
+ const id = task_id ?? configuration.mcpTaskId;
7125
+ if (id === undefined)
7126
+ throw new Error("Task id is required outside delegated work");
7127
+ const input = Schema13.decodeUnknownSync(TaskProgressInput)({ note });
7128
+ return output(await Effect12.runPromise(client2.reportTaskProgress(Schema13.decodeUnknownSync(TaskId)(id), input)));
7129
+ });
6917
7130
  server.registerTool("list_devices", {
6918
7131
  title: "List Cohall devices",
6919
7132
  description: "List devices, their availability, local providers, capabilities, and allowed workspaces.",
@@ -7003,7 +7216,7 @@ var runMcp = async (configuration) => {
7003
7216
  });
7004
7217
  server.registerTool("task_trace", {
7005
7218
  title: "Trace a delegated task",
7006
- description: "Read the redacted relay and device lifecycle for troubleshooting a Cohall task.",
7219
+ description: "Read the relay and device lifecycle and latest worker progress note for troubleshooting a Cohall task.",
7007
7220
  inputSchema: { task_id: z.string().uuid() }
7008
7221
  }, async ({ task_id }) => output(await Effect12.runPromise(client2.traceTask(TaskId.make(task_id)))));
7009
7222
  server.registerTool("wait_task", {
@@ -7071,4 +7284,4 @@ await main().catch((cause) => {
7071
7284
  process.exitCode = 1;
7072
7285
  });
7073
7286
 
7074
- //# debugId=C3F729038365DD7F64756E2164756E21
7287
+ //# debugId=48BD90839B9C2B1564756E2164756E21