@akshar5/cohall 0.2.0 → 0.3.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 ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## [0.3.0](https://github.com/AksharP5/cohall/compare/v0.2.0...v0.3.0) (2026-08-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * add durable task tracing ([b7c2564](https://github.com/AksharP5/cohall/commit/b7c2564c770453a3c67821a9521aacac4f4a58e2))
9
+
10
+ ## 0.2.0 (2026-08-05)
11
+
12
+ - Initial public release of the CLI, embedded agent skill, optional MCP server,
13
+ self-hosted relay, and cross-device delegation runtime.
package/README.md CHANGED
@@ -117,12 +117,16 @@ npx -y @akshar5/cohall delegate --target @linux --no-wait \
117
117
  --prompt 'Run the test suite and report failures.'
118
118
  npx -y @akshar5/cohall wait <task-id> --timeout 1800
119
119
  npx -y @akshar5/cohall cancel <task-id>
120
+ npx -y @akshar5/cohall trace <task-id> --follow
120
121
  npx -y @akshar5/cohall thread <thread-id>
121
122
  ```
122
123
 
123
124
  Follow-ups using the same `thread_id` resume the provider session on the target
124
125
  device. Active cancellation remains `cancelling` until the target acknowledges
125
- that its local process stopped.
126
+ that its local process stopped. `trace` reports the durable relay and device
127
+ lifecycle without prompts, results, credentials, or provider session IDs.
128
+ `--follow` emits changed snapshots as newline-delimited JSON until the task is
129
+ terminal.
126
130
 
127
131
  ## Optional MCP
128
132
 
@@ -132,6 +136,7 @@ server exposes:
132
136
  - `list_devices`
133
137
  - `delegate`
134
138
  - `task_status`
139
+ - `task_trace`
135
140
  - `wait_task`
136
141
  - `cancel_task`
137
142
  - `thread_context`
package/bin/cohall.js CHANGED
@@ -17,9 +17,9 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
 
18
18
  // packages/protocol/src/index.ts
19
19
  import { Schema } from "effect";
20
- var version = "0.2.0", 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)), 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.3.0", 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)), 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
- })).pipe(Schema.brand(name)), DeviceId, ThreadId, MessageId, TaskId, AuthSessionId, Timestamp, Platform, Provider, DeviceStatus, TaskStatus, MessageRole, ConnectionRole, AuthSession, CreatePairingInput, PairingCredential, ExchangePairingInput, SessionCredential, PairingResult, Capability, Workspace, Device, Thread, Message, Task, 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()), decodeCreatePairingInput, decodeExchangePairingInput, decodeCreateTaskInput, decodeDevice, decodeSocketEvent;
22
+ })).pipe(Schema.brand(name)), DeviceId, ThreadId, MessageId, TaskId, AuthSessionId, Timestamp, Platform, Provider, DeviceStatus, TaskStatus, TaskTraceEventKind, MessageRole, ConnectionRole, AuthSession, CreatePairingInput, PairingCredential, ExchangePairingInput, SessionCredential, PairingResult, Capability, Workspace, Device, Thread, Message, Task, 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()), decodeCreatePairingInput, decodeExchangePairingInput, decodeCreateTaskInput, decodeDevice, decodeSocketEvent;
23
23
  var init_src = __esm(() => {
24
24
  DeviceId = uuid("DeviceId");
25
25
  ThreadId = uuid("ThreadId");
@@ -39,6 +39,7 @@ var init_src = __esm(() => {
39
39
  "failed",
40
40
  "cancelled"
41
41
  ]);
42
+ TaskTraceEventKind = Schema.Literals([...TaskStatus.literals, "requeued"]);
42
43
  MessageRole = Schema.Literals(["human", "agent", "system"]);
43
44
  ConnectionRole = Schema.Literals(["client", "device"]);
44
45
  AuthSession = Schema.Struct({
@@ -128,6 +129,28 @@ var init_src = __esm(() => {
128
129
  startedAt: Schema.optionalKey(Timestamp),
129
130
  completedAt: Schema.optionalKey(Timestamp)
130
131
  });
132
+ TaskTraceEvent = Schema.Struct({
133
+ kind: TaskTraceEventKind,
134
+ at: Timestamp,
135
+ detail: Schema.optionalKey(optionalText(512))
136
+ });
137
+ TaskTrace = Schema.Struct({
138
+ taskId: TaskId,
139
+ threadId: ThreadId,
140
+ status: TaskStatus,
141
+ provider: Provider,
142
+ sourceDeviceId: Schema.optionalKey(DeviceId),
143
+ targetDevice: Device,
144
+ parentTaskId: Schema.optionalKey(TaskId),
145
+ workspace: Schema.optionalKey(bounded(4096)),
146
+ createdAt: Timestamp,
147
+ updatedAt: Timestamp,
148
+ startedAt: Schema.optionalKey(Timestamp),
149
+ completedAt: Schema.optionalKey(Timestamp),
150
+ events: boundedArray(TaskTraceEvent, 100),
151
+ truncated: Schema.Boolean,
152
+ error: Schema.optionalKey(optionalText(16384))
153
+ });
131
154
  ThreadContext = Schema.Struct({
132
155
  thread: Thread,
133
156
  messages: boundedArray(Message, 20),
@@ -436,6 +459,10 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
436
459
  ...row.error === null ? {} : { error: row.error },
437
460
  ...row.started_at === null ? {} : { startedAt: row.started_at },
438
461
  ...row.completed_at === null ? {} : { completedAt: row.completed_at }
462
+ }), taskTraceEventFromRow = (row) => decode("RelayStore.decodeTaskTraceEvent", TaskTraceEvent, {
463
+ kind: row.kind,
464
+ at: row.created_at,
465
+ ...row.detail === null ? {} : { detail: row.detail }
439
466
  }), rolesFromRow = (row) => Schema9.decodeUnknownSync(Schema9.Array(ConnectionRole))(JSON.parse(row.roles_json)), authSessionFromRow = (row) => {
440
467
  const roles = rolesFromRow(row);
441
468
  const role = roles[0];
@@ -480,7 +507,31 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
480
507
  selected.unshift(item);
481
508
  }
482
509
  return { items: selected, truncated: selected.length < items.length };
510
+ }, derivedTraceEvents = (task) => {
511
+ const detail = "Derived from task timestamps; earlier trace events were not recorded";
512
+ const events = [
513
+ TaskTraceEvent.make({ kind: "queued", at: task.createdAt, detail })
514
+ ];
515
+ if (task.startedAt !== undefined) {
516
+ events.push(TaskTraceEvent.make({ kind: "running", at: task.startedAt, detail }));
517
+ } else if (task.status === "assigned") {
518
+ events.push(TaskTraceEvent.make({ kind: "assigned", at: task.updatedAt, detail }));
519
+ }
520
+ if (task.status === "cancelling") {
521
+ events.push(TaskTraceEvent.make({ kind: "cancelling", at: task.updatedAt, detail }));
522
+ }
523
+ if (task.completedAt !== undefined && (task.status === "completed" || task.status === "failed" || task.status === "cancelled")) {
524
+ events.push(TaskTraceEvent.make({ kind: task.status, at: task.completedAt, detail }));
525
+ }
526
+ return events;
483
527
  }, makeService = (db) => {
528
+ const recordTaskTraceEvent = (taskId, kind, detail, createdAt = now()) => {
529
+ db.query(`INSERT INTO task_trace_events (task_id, kind, created_at, detail)
530
+ VALUES (?, ?, ?, ?)`).run(taskId, kind, createdAt, detail);
531
+ db.query(`DELETE FROM task_trace_events WHERE task_id = ? AND id NOT IN (
532
+ SELECT id FROM task_trace_events WHERE task_id = ? ORDER BY id DESC LIMIT 1000
533
+ )`).run(taskId, taskId);
534
+ };
484
535
  const queryTask = (taskId) => db.query("SELECT * FROM tasks WHERE id = ?").get(taskId);
485
536
  const getTask = Effect9.fn("RelayStore.getTask")(function* (taskId) {
486
537
  const row = yield* Effect9.try({
@@ -495,6 +546,46 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
495
546
  }
496
547
  return yield* taskFromRow(row);
497
548
  });
549
+ const traceTask = Effect9.fn("RelayStore.traceTask")(function* (taskId) {
550
+ const task = yield* getTask(taskId);
551
+ const [deviceRow, eventRows] = yield* Effect9.try({
552
+ try: () => [
553
+ db.query("SELECT * FROM devices WHERE id = ?").get(task.targetDeviceId),
554
+ db.query(`SELECT * FROM (
555
+ SELECT id, kind, created_at, detail FROM task_trace_events
556
+ WHERE task_id = ? ORDER BY id DESC LIMIT 101
557
+ ) ORDER BY id`).all(taskId)
558
+ ],
559
+ catch: operationError("RelayStore.traceTask")
560
+ });
561
+ if (deviceRow === null) {
562
+ return yield* Effect9.fail(new PersistenceError({
563
+ operation: "RelayStore.traceTask.device",
564
+ message: `Unknown target device ${task.targetDeviceId}`
565
+ }));
566
+ }
567
+ const targetDevice = yield* deviceFromRow(deviceRow);
568
+ const selectedRows = eventRows.slice(-100);
569
+ const recordedEvents = yield* Effect9.forEach(selectedRows, taskTraceEventFromRow);
570
+ const events = recordedEvents.length === 0 ? derivedTraceEvents(task) : recordedEvents;
571
+ return TaskTrace.make({
572
+ taskId: task.id,
573
+ threadId: task.threadId,
574
+ status: task.status,
575
+ provider: task.provider,
576
+ targetDevice,
577
+ createdAt: task.createdAt,
578
+ updatedAt: task.updatedAt,
579
+ events,
580
+ truncated: eventRows.length > 100 || recordedEvents.length === 0 || recordedEvents[0]?.kind !== "queued",
581
+ ...task.sourceDeviceId === undefined ? {} : { sourceDeviceId: task.sourceDeviceId },
582
+ ...task.parentTaskId === undefined ? {} : { parentTaskId: task.parentTaskId },
583
+ ...task.workspace === undefined ? {} : { workspace: task.workspace },
584
+ ...task.startedAt === undefined ? {} : { startedAt: task.startedAt },
585
+ ...task.completedAt === undefined ? {} : { completedAt: task.completedAt },
586
+ ...task.error === undefined ? {} : { error: task.error }
587
+ });
588
+ });
498
589
  const listDevices = Effect9.fn("RelayStore.listDevices")(function* () {
499
590
  const rows = yield* Effect9.try({
500
591
  try: () => db.query("SELECT * FROM devices ORDER BY name COLLATE NOCASE, id").all(),
@@ -506,8 +597,12 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
506
597
  yield* Effect9.try({
507
598
  try: () => db.transaction(() => {
508
599
  const timestamp = now();
600
+ const interrupted = db.query("SELECT id FROM tasks WHERE status IN ('assigned', 'running')").all();
509
601
  db.query("UPDATE devices SET status = 'offline'").run();
510
602
  db.query("UPDATE tasks SET status = 'queued', updated_at = ? WHERE status IN ('assigned', 'running')").run(timestamp);
603
+ for (const task of interrupted) {
604
+ recordTaskTraceEvent(TaskId.make(task.id), "requeued", "Relay restarted before the task completed", timestamp);
605
+ }
511
606
  db.query("DELETE FROM auth_pairings WHERE expires_at <= ? OR used_at IS NOT NULL").run(timestamp);
512
607
  db.query("UPDATE auth_sessions SET revoked_at = ? WHERE revoked_at IS NULL AND roles_json LIKE '%,%'").run(timestamp);
513
608
  })(),
@@ -602,6 +697,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
602
697
  target_device_id, parent_task_id, workspace, provider_session_id,
603
698
  created_at, updated_at
604
699
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(task.id, task.threadId, task.prompt, task.context ?? null, task.provider, task.status, task.sourceDeviceId ?? null, task.targetDeviceId, task.parentTaskId ?? null, task.workspace ?? null, task.providerSessionId ?? null, task.createdAt, task.updatedAt);
700
+ recordTaskTraceEvent(task.id, "queued", "Relay accepted the task", timestamp);
605
701
  db.query(`INSERT INTO messages (
606
702
  id, thread_id, role, kind, author_id, author_name, content, created_at,
607
703
  task_id, device_id
@@ -658,16 +754,22 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
658
754
  });
659
755
  return yield* Effect9.forEach(rows, taskFromRow);
660
756
  });
661
- const transition = Effect9.fn("RelayStore.transition")(function* (taskId, expected, update) {
757
+ const transition = Effect9.fn("RelayStore.transition")(function* (taskId, expected, update, event) {
662
758
  const current = yield* getTask(taskId);
663
759
  if (!expected.includes(current.status)) {
664
760
  return current;
665
761
  }
666
762
  const placeholders = expected.map(() => "?").join(", ");
763
+ const timestamp = now();
667
764
  yield* Effect9.try({
668
- try: () => db.query(`UPDATE tasks SET status = ?, provider_session_id = ?, result = ?, error = ?,
669
- started_at = ?, completed_at = ?, updated_at = ?
670
- WHERE id = ? AND status IN (${placeholders})`).run(update.status ?? current.status, update.providerSessionId ?? current.providerSessionId ?? null, update.result ?? current.result ?? null, update.error ?? current.error ?? null, update.startedAt ?? current.startedAt ?? null, update.completedAt ?? current.completedAt ?? null, now(), taskId, ...expected),
765
+ try: () => db.transaction(() => {
766
+ const result = db.query(`UPDATE tasks SET status = ?, provider_session_id = ?, result = ?, error = ?,
767
+ started_at = ?, completed_at = ?, updated_at = ?
768
+ WHERE id = ? AND status IN (${placeholders})`).run(update.status ?? current.status, update.providerSessionId ?? current.providerSessionId ?? null, update.result ?? current.result ?? null, update.error ?? current.error ?? null, update.startedAt ?? current.startedAt ?? null, update.completedAt ?? current.completedAt ?? null, timestamp, taskId, ...expected);
769
+ if (result.changes === 1 && event !== undefined) {
770
+ recordTaskTraceEvent(taskId, event.kind, event.detail, timestamp);
771
+ }
772
+ })(),
671
773
  catch: operationError("RelayStore.transition")
672
774
  });
673
775
  return yield* getTask(taskId);
@@ -677,13 +779,19 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
677
779
  if (current.status !== "queued") {
678
780
  return current;
679
781
  }
782
+ const timestamp = now();
680
783
  yield* Effect9.try({
681
- try: () => db.query(`UPDATE tasks SET status = 'assigned', updated_at = ?
682
- WHERE id = ? AND status = 'queued' AND NOT EXISTS (
683
- SELECT 1 FROM tasks active
684
- WHERE active.target_device_id = ? AND active.id <> ?
685
- AND active.status IN ('assigned', 'running', 'cancelling')
686
- )`).run(now(), taskId, current.targetDeviceId, taskId),
784
+ try: () => db.transaction(() => {
785
+ const result = db.query(`UPDATE tasks SET status = 'assigned', updated_at = ?
786
+ WHERE id = ? AND status = 'queued' AND NOT EXISTS (
787
+ SELECT 1 FROM tasks active
788
+ WHERE active.target_device_id = ? AND active.id <> ?
789
+ AND active.status IN ('assigned', 'running', 'cancelling')
790
+ )`).run(timestamp, taskId, current.targetDeviceId, taskId);
791
+ if (result.changes === 1) {
792
+ recordTaskTraceEvent(taskId, "assigned", "Relay dispatched the task over the device WebSocket", timestamp);
793
+ }
794
+ })(),
687
795
  catch: operationError("RelayStore.assignTask")
688
796
  });
689
797
  return yield* getTask(taskId);
@@ -712,6 +820,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
712
820
  if (updated.changes !== 1) {
713
821
  return;
714
822
  }
823
+ recordTaskTraceEvent(taskId, status, status === "completed" ? "Target device completed the task" : status === "failed" ? "Target device reported a task failure" : "Target device acknowledged cancellation", timestamp);
715
824
  if (providerSessionId !== undefined) {
716
825
  db.query(`INSERT INTO provider_sessions (thread_id, device_id, provider, session_id, updated_at)
717
826
  VALUES (?, ?, ?, ?, ?)
@@ -737,14 +846,26 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
737
846
  if (["completed", "failed", "cancelled"].includes(current.status)) {
738
847
  return current;
739
848
  }
740
- return current.status === "queued" ? yield* transition(taskId, ["queued"], { status: "cancelled", completedAt: now() }) : yield* transition(taskId, ["assigned", "running", "cancelling"], {
849
+ if (current.status === "cancelling") {
850
+ return current;
851
+ }
852
+ const timestamp = now();
853
+ return current.status === "queued" ? yield* transition(taskId, ["queued"], { status: "cancelled", completedAt: timestamp }, { kind: "cancelled", detail: "Client cancelled the task before dispatch" }) : yield* transition(taskId, ["assigned", "running"], {
741
854
  status: "cancelling"
742
- });
855
+ }, { kind: "cancelling", detail: "Client requested task cancellation" });
743
856
  });
744
857
  const requeueTasksFor = Effect9.fn("RelayStore.requeueTasksFor")(function* (deviceId) {
858
+ const timestamp = now();
745
859
  yield* Effect9.try({
746
- try: () => db.query(`UPDATE tasks SET status = 'queued', updated_at = ?
747
- WHERE target_device_id = ? AND status IN ('assigned', 'running')`).run(now(), deviceId),
860
+ try: () => db.transaction(() => {
861
+ const interrupted = db.query(`SELECT id FROM tasks WHERE target_device_id = ?
862
+ AND status IN ('assigned', 'running')`).all(deviceId);
863
+ db.query(`UPDATE tasks SET status = 'queued', updated_at = ?
864
+ WHERE target_device_id = ? AND status IN ('assigned', 'running')`).run(timestamp, deviceId);
865
+ for (const task of interrupted) {
866
+ recordTaskTraceEvent(TaskId.make(task.id), "requeued", "Target device disconnected before task completion", timestamp);
867
+ }
868
+ })(),
748
869
  catch: operationError("RelayStore.requeueTasksFor")
749
870
  });
750
871
  });
@@ -867,11 +988,12 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
867
988
  markDeviceOffline: (deviceId) => updateDeviceStatus(deviceId, "offline"),
868
989
  createDelegation: createDelegation2,
869
990
  getTask,
991
+ traceTask,
870
992
  threadContext: threadContext2,
871
993
  pendingTasksFor,
872
994
  assignTask,
873
- rollbackAssignment: (taskId) => transition(taskId, ["assigned"], { status: "queued" }),
874
- acceptTask: (taskId, deviceId) => requireTarget(taskId, deviceId).pipe(Effect9.flatMap(() => transition(taskId, ["assigned"], { status: "running", startedAt: now() }))),
995
+ rollbackAssignment: (taskId) => transition(taskId, ["assigned"], { status: "queued" }, { kind: "requeued", detail: "Target device was unavailable during dispatch" }),
996
+ acceptTask: (taskId, deviceId) => requireTarget(taskId, deviceId).pipe(Effect9.flatMap(() => transition(taskId, ["assigned"], { status: "running", startedAt: now() }, { kind: "running", detail: "Target device accepted the task and started the provider" }))),
875
997
  finishTask: (taskId, deviceId, result, providerSessionId) => terminal(taskId, deviceId, "completed", result, undefined, providerSessionId),
876
998
  failTask: (taskId, deviceId, error) => terminal(taskId, deviceId, "failed", undefined, error),
877
999
  acknowledgeCancellation: (taskId, deviceId) => terminal(taskId, deviceId, "cancelled"),
@@ -919,6 +1041,13 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
919
1041
  );
920
1042
  CREATE INDEX IF NOT EXISTS tasks_thread_created ON tasks(thread_id, created_at);
921
1043
  CREATE INDEX IF NOT EXISTS tasks_target_status ON tasks(target_device_id, status);
1044
+ CREATE TABLE IF NOT EXISTS task_trace_events (
1045
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1046
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1047
+ kind TEXT NOT NULL, created_at TEXT NOT NULL, detail TEXT
1048
+ );
1049
+ CREATE INDEX IF NOT EXISTS task_trace_events_task_id
1050
+ ON task_trace_events(task_id, id);
922
1051
  CREATE TABLE IF NOT EXISTS provider_sessions (
923
1052
  thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
924
1053
  device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
@@ -1296,6 +1425,10 @@ Content-Length: 0\r
1296
1425
  if (request.method === "GET" && task?.[1] !== undefined) {
1297
1426
  return json(yield* store.getTask(yield* pathId(TaskId, task[1])));
1298
1427
  }
1428
+ const trace = url.pathname.match(/^\/api\/tasks\/([^/]+)\/trace$/);
1429
+ if (request.method === "GET" && trace?.[1] !== undefined) {
1430
+ return json(yield* store.traceTask(yield* pathId(TaskId, trace[1])));
1431
+ }
1299
1432
  const cancel2 = url.pathname.match(/^\/api\/tasks\/([^/]+)\/cancel$/);
1300
1433
  if (request.method === "POST" && cancel2?.[1] !== undefined) {
1301
1434
  const id = yield* pathId(TaskId, cancel2[1]);
@@ -1556,6 +1689,7 @@ var make = (options) => {
1556
1689
  body: JSON.stringify(input)
1557
1690
  }),
1558
1691
  getTask: (taskId) => request("RelayClient.getTask", `/api/tasks/${encodeURIComponent(taskId)}`, Task),
1692
+ traceTask: (taskId) => request("RelayClient.traceTask", `/api/tasks/${encodeURIComponent(taskId)}/trace`, TaskTrace),
1559
1693
  cancelTask: (taskId) => request("RelayClient.cancelTask", `/api/tasks/${encodeURIComponent(taskId)}/cancel`, Task, {
1560
1694
  method: "POST"
1561
1695
  }),
@@ -1897,12 +2031,15 @@ keeps its local files, credentials, browser state, provider login, skills, and
1897
2031
  permissions. The relay carries prompts, final results, and task state; it is not
1898
2032
  a lead agent.
1899
2033
 
2034
+ Use the installed \`cohall\` executable when it is available. Fall back to
2035
+ \`npx -y @akshar5/cohall\` only when Cohall is not installed globally.
2036
+
1900
2037
  ## Delegate work
1901
2038
 
1902
2039
  1. List devices unless the target is already explicit:
1903
2040
 
1904
2041
  \`\`\`bash
1905
- npx -y @akshar5/cohall devices
2042
+ cohall devices
1906
2043
  \`\`\`
1907
2044
 
1908
2045
  2. Choose a target whose provider, workspaces, platform, and capabilities fit
@@ -1911,7 +2048,7 @@ a lead agent.
1911
2048
  3. Delegate one concrete outcome. Send only the context the target needs:
1912
2049
 
1913
2050
  \`\`\`bash
1914
- npx -y @akshar5/cohall delegate \\
2051
+ cohall delegate \\
1915
2052
  --target @macbook \\
1916
2053
  --provider codex \\
1917
2054
  --workspace /Users/me/dev/project \\
@@ -1930,7 +2067,7 @@ a lead agent.
1930
2067
  its local session:
1931
2068
 
1932
2069
  \`\`\`bash
1933
- npx -y @akshar5/cohall delegate \\
2070
+ cohall delegate \\
1934
2071
  --thread 11111111-1111-4111-8111-111111111111 \\
1935
2072
  --target @macbook \\
1936
2073
  --prompt 'Check whether yesterday’s deployment failed for the same reason.'
@@ -1955,17 +2092,17 @@ ordinary shell or a separate agent turn.
1955
2092
  Queue work when the current agent can make independent progress:
1956
2093
 
1957
2094
  \`\`\`bash
1958
- npx -y @akshar5/cohall delegate --target @linux --no-wait \\
2095
+ cohall delegate --target @linux --no-wait \\
1959
2096
  --prompt 'Run the project test suite and report failures.'
1960
- npx -y @akshar5/cohall status <task-id>
1961
- npx -y @akshar5/cohall wait <task-id> --timeout 1800
2097
+ cohall status <task-id>
2098
+ cohall wait <task-id> --timeout 1800
1962
2099
  \`\`\`
1963
2100
 
1964
2101
  The timeout error includes the task ID and last known status. The task continues
1965
2102
  unless cancelled:
1966
2103
 
1967
2104
  \`\`\`bash
1968
- npx -y @akshar5/cohall cancel <task-id>
2105
+ cohall cancel <task-id>
1969
2106
  \`\`\`
1970
2107
 
1971
2108
  Active cancellation is acknowledged by the target device; \`cancelling\` means
@@ -1974,7 +2111,7 @@ the provider process has not confirmed termination yet.
1974
2111
  ## Read shared context
1975
2112
 
1976
2113
  \`\`\`bash
1977
- npx -y @akshar5/cohall thread <thread-id>
2114
+ cohall thread <thread-id>
1978
2115
  \`\`\`
1979
2116
 
1980
2117
  This returns a byte-bounded recent window of prompts, final responses, and task
@@ -1985,7 +2122,7 @@ states. Check \`truncated\`; older history remains on the relay when it is true.
1985
2122
  Use \`--prompt-file\` or stdin for multiline work without shell interpolation:
1986
2123
 
1987
2124
  \`\`\`bash
1988
- npx -y @akshar5/cohall delegate --target @macbook --prompt - <<'COHALL_PROMPT'
2125
+ cohall delegate --target @macbook --prompt - <<'COHALL_PROMPT'
1989
2126
  Analyze the authenticated pages in the attached task context.
1990
2127
  Return the shared conclusions, disagreements, and source URLs.
1991
2128
  COHALL_PROMPT
@@ -1995,11 +2132,21 @@ Only one input may read stdin. Use \`--context-file\` when the prompt uses stdin
1995
2132
 
1996
2133
  ## Diagnostics
1997
2134
 
1998
- If a command cannot connect or no target is available, run:
2135
+ Trace a known task before inspecting machine-local service logs:
2136
+
2137
+ \`\`\`bash
2138
+ cohall trace <task-id> --follow
2139
+ \`\`\`
2140
+
2141
+ The trace is redacted and reports relay dispatch, device execution, retries,
2142
+ and terminal state. Use \`cohall thread <thread-id>\` when prompt and result
2143
+ history is relevant.
2144
+
2145
+ If Cohall cannot connect or no target is available, run:
1999
2146
 
2000
2147
  \`\`\`bash
2001
- npx -y @akshar5/cohall doctor
2002
- npx -y @akshar5/cohall devices
2148
+ cohall doctor
2149
+ cohall devices
2003
2150
  \`\`\`
2004
2151
 
2005
2152
  Return the diagnostic failure to the user rather than claiming remote work ran.
@@ -2197,7 +2344,7 @@ var knownProviders = Provider.literals;
2197
2344
 
2198
2345
  // apps/device/src/delegation.ts
2199
2346
  init_src();
2200
- import { Effect as Effect4, Schema as Schema5 } from "effect";
2347
+ import { Effect as Effect4, Schedule, Schema as Schema5 } from "effect";
2201
2348
 
2202
2349
  class DeviceSelectionError extends Schema5.TaggedErrorClass()("Cohall.DeviceSelectionError", { message: Schema5.String, devices: Schema5.Array(Schema5.String) }) {
2203
2350
  }
@@ -2269,6 +2416,22 @@ var waitForTask = Effect4.fn("Cohall.waitForTask")(function* (client, initial, t
2269
2416
  }
2270
2417
  return task;
2271
2418
  });
2419
+ var followTaskTrace = Effect4.fn("Cohall.followTaskTrace")(function* (client, taskId, onTrace) {
2420
+ let revision;
2421
+ const poll = client.traceTask(taskId).pipe(Effect4.tap((trace) => {
2422
+ const latest = trace.events.at(-1);
2423
+ const nextRevision = `${trace.status}:${latest?.kind ?? "none"}:${latest?.at ?? "none"}`;
2424
+ if (nextRevision === revision) {
2425
+ return Effect4.succeed(undefined);
2426
+ }
2427
+ revision = nextRevision;
2428
+ return Effect4.sync(() => onTrace(trace));
2429
+ }));
2430
+ return yield* poll.pipe(Effect4.repeat({
2431
+ until: (trace) => terminalTaskStatuses.has(trace.status),
2432
+ schedule: Schedule.spaced("1 second")
2433
+ }));
2434
+ });
2272
2435
  var threadContext = Effect4.fn("Cohall.threadContext")(function* (client, threadId) {
2273
2436
  return yield* client.threadContext(threadId);
2274
2437
  });
@@ -2291,7 +2454,7 @@ var valueOptions = new Set([
2291
2454
  "token-file",
2292
2455
  "workspace"
2293
2456
  ]);
2294
- var flagOptions = new Set(["client-only", "no-wait"]);
2457
+ var flagOptions = new Set(["client-only", "follow", "no-wait"]);
2295
2458
  var aliases = new Map([
2296
2459
  ["-c", "context"],
2297
2460
  ["-p", "prompt"],
@@ -2310,6 +2473,7 @@ Usage:
2310
2473
  [--context text] [--thread uuid] [--workspace path]
2311
2474
  [--timeout seconds] [--no-wait]
2312
2475
  cohall status <task-id>
2476
+ cohall trace <task-id> [--follow]
2313
2477
  cohall wait <task-id> [--timeout seconds]
2314
2478
  cohall cancel <task-id>
2315
2479
  cohall thread <thread-id>
@@ -2405,6 +2569,7 @@ var identifier = (arguments_, label) => {
2405
2569
  return value;
2406
2570
  };
2407
2571
  var print = (value) => console.log(JSON.stringify(value, null, 2));
2572
+ var printLine = (value) => console.log(JSON.stringify(value));
2408
2573
  var readInput = async (arguments_, name) => {
2409
2574
  const direct = option(arguments_, name);
2410
2575
  const path = option(arguments_, `${name}-file`);
@@ -2706,6 +2871,16 @@ var runCli = async (command2, raw) => {
2706
2871
  print(taskResult(await Effect5.runPromise(relay.getTask(id))));
2707
2872
  return;
2708
2873
  }
2874
+ if (command2 === "trace") {
2875
+ allowOptions(arguments_, ["follow"]);
2876
+ const id = Schema6.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
2877
+ if (arguments_.options.has("follow")) {
2878
+ await Effect5.runPromise(followTaskTrace(relay, id, printLine));
2879
+ return;
2880
+ }
2881
+ print(await Effect5.runPromise(relay.traceTask(id)));
2882
+ return;
2883
+ }
2709
2884
  if (command2 === "wait") {
2710
2885
  allowOptions(arguments_, ["timeout"]);
2711
2886
  const id = Schema6.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
@@ -2730,7 +2905,7 @@ var runCli = async (command2, raw) => {
2730
2905
 
2731
2906
  // apps/device/src/daemon.ts
2732
2907
  init_src();
2733
- import { Effect as Effect6, Schedule, Schema as Schema7 } from "effect";
2908
+ import { Effect as Effect6, Schedule as Schedule2, Schema as Schema7 } from "effect";
2734
2909
  import { realpath as realpath2 } from "node:fs/promises";
2735
2910
  import { arch, hostname as hostname2, platform as platform3 } from "node:os";
2736
2911
  import { basename, isAbsolute as isAbsolute2, relative } from "node:path";
@@ -3012,7 +3187,7 @@ var runDaemon = (configuration) => {
3012
3187
  tasks: new Map,
3013
3188
  completed: new Set
3014
3189
  };
3015
- return connect(configuration, state).pipe(Effect6.repeat({ schedule: Schedule.spaced("2 seconds") }), Effect6.ensuring(Effect6.sync(() => {
3190
+ return connect(configuration, state).pipe(Effect6.repeat({ schedule: Schedule2.spaced("2 seconds") }), Effect6.ensuring(Effect6.sync(() => {
3016
3191
  state.socket?.close();
3017
3192
  for (const controller of state.tasks.values()) {
3018
3193
  controller.abort();
@@ -3069,6 +3244,11 @@ var runMcp = async (configuration) => {
3069
3244
  description: "Read the current state and result of a Cohall task.",
3070
3245
  inputSchema: { task_id: z.string().uuid() }
3071
3246
  }, async ({ task_id }) => output(taskResult(await Effect7.runPromise(client2.getTask(TaskId.make(task_id))))));
3247
+ server.registerTool("task_trace", {
3248
+ title: "Trace a delegated task",
3249
+ description: "Read the redacted relay and device lifecycle for troubleshooting a Cohall task.",
3250
+ inputSchema: { task_id: z.string().uuid() }
3251
+ }, async ({ task_id }) => output(await Effect7.runPromise(client2.traceTask(TaskId.make(task_id)))));
3072
3252
  server.registerTool("wait_task", {
3073
3253
  title: "Wait for delegated work",
3074
3254
  description: "Wait for a Cohall task to finish and return its final result.",
@@ -3126,4 +3306,4 @@ await main().catch((cause) => {
3126
3306
  process.exitCode = 1;
3127
3307
  });
3128
3308
 
3129
- //# debugId=7CC47D186DE52B1264756E2164756E21
3309
+ //# debugId=AB53FFE376B97AD664756E2164756E21