@akshar5/cohall 0.8.0 → 0.10.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/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 { Effect, Schema } from "effect";
20
- var version = "0.8.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.10.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
- })).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) => {
22
+ })).pipe(Schema.brand(name)), BotId, Bot, DeviceId, ThreadId, MessageId, TaskId, TaskRequestId, TaskRunId, ClarificationId, AuthSessionId, OperationId, Timestamp, TaskDeadline, taskDeadlineError = "Task deadline exceeded", taskDeadlinePassed = (task, time = Date.now()) => task.expiresAt !== undefined && Date.parse(task.expiresAt) <= time, Platform, Provider, DeviceStatus, TaskStatus, OperationStatus, TaskTraceEventKind, MessageRole, ConnectionRole, AuthSession, CreatePairingInput, PairingCredential, ExchangePairingInput, SessionCredential, PairingResult, Capability, Workspace, Device, maxDevicePageDevices = 16, maxDevicePageResponseBytes, DevicePage, minimumDeviceOperationVersion = "0.5.0", parsedVersion = (value) => {
23
23
  const match = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
24
24
  if (match === null) {
25
25
  return;
@@ -56,7 +56,7 @@ var version = "0.8.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, maxClarificationBytes = 4096, maxTaskClarifications = 10, clarificationText, RequestTaskInput, AnswerTaskInput, TaskClarification, clarifications, validClarificationState = (task) => task.status !== "needs_input" || task.clarifications?.at(-1) !== undefined && task.clarifications.at(-1)?.answer === undefined ? undefined : "A task awaiting input must have an unanswered question", 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, decodeRequestTaskInput, decodeAnswerTaskInput, 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;
@@ -74,9 +74,16 @@ var init_src = __esm(() => {
74
74
  ThreadId = uuid("ThreadId");
75
75
  MessageId = uuid("MessageId");
76
76
  TaskId = uuid("TaskId");
77
+ TaskRequestId = uuid("TaskRequestId");
78
+ TaskRunId = uuid("TaskRunId");
79
+ ClarificationId = uuid("ClarificationId");
77
80
  AuthSessionId = uuid("AuthSessionId");
78
81
  OperationId = uuid("OperationId");
79
82
  Timestamp = isoTimestamp("Timestamp");
83
+ TaskDeadline = Timestamp.check(Schema.makeFilter((value) => {
84
+ const time = Date.parse(value);
85
+ return Number.isFinite(time) && new Date(time).toISOString().slice(0, 19) === value.slice(0, 19) ? undefined : "Expected a valid ISO-8601 UTC deadline";
86
+ }));
80
87
  Platform = Schema.Literals(["darwin", "linux", "windows", "unknown"]);
81
88
  Provider = Schema.Literals(["codex", "claude-code", "opencode", "grok-bot"]);
82
89
  DeviceStatus = Schema.Literals(["online", "busy", "offline"]);
@@ -84,6 +91,7 @@ var init_src = __esm(() => {
84
91
  "queued",
85
92
  "assigned",
86
93
  "running",
94
+ "needs_input",
87
95
  "cancelling",
88
96
  "completed",
89
97
  "failed",
@@ -96,7 +104,12 @@ var init_src = __esm(() => {
96
104
  "completed",
97
105
  "failed"
98
106
  ]);
99
- TaskTraceEventKind = Schema.Literals([...TaskStatus.literals, "requeued"]);
107
+ TaskTraceEventKind = Schema.Literals([
108
+ ...TaskStatus.literals,
109
+ "requeued",
110
+ "input_requested",
111
+ "input_answered"
112
+ ]);
100
113
  MessageRole = Schema.Literals(["human", "agent", "system"]);
101
114
  ConnectionRole = Schema.Literals(["client", "device"]);
102
115
  AuthSession = Schema.Struct({
@@ -149,8 +162,25 @@ var init_src = __esm(() => {
149
162
  workspaces: boundedArray(Workspace, 64),
150
163
  version: bounded(32),
151
164
  lastSeenAt: Timestamp,
152
- connectedAt: Schema.optionalKey(Timestamp)
165
+ connectedAt: Schema.optionalKey(Timestamp),
166
+ queue: Schema.optionalKey(Schema.Struct({
167
+ queued: count,
168
+ oldestQueuedAt: Schema.optionalKey(Timestamp)
169
+ }))
153
170
  });
171
+ maxDevicePageResponseBytes = 3 * 1024 * 1024;
172
+ DevicePage = Schema.Struct({
173
+ devices: boundedArray(Device, maxDevicePageDevices),
174
+ nextCursor: Schema.optionalKey(DeviceId)
175
+ }).check(Schema.makeFilter((page) => {
176
+ let previous;
177
+ for (const device of page.devices) {
178
+ if (previous !== undefined && device.id <= previous)
179
+ return "Device pages must be ordered by ID";
180
+ previous = device.id;
181
+ }
182
+ return page.nextCursor === undefined || page.nextCursor === previous ? undefined : "The next cursor must be the last device ID";
183
+ }));
154
184
  Thread = Schema.Struct({
155
185
  id: ThreadId,
156
186
  title: bounded(256),
@@ -180,8 +210,26 @@ var init_src = __esm(() => {
180
210
  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
211
  });
182
212
  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"));
213
+ TaskProgressInput = Schema.Struct({
214
+ 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"))
215
+ });
216
+ TaskProgress = Schema.Struct({ ...TaskProgressInput.fields, at: Timestamp });
217
+ clarificationText = bounded(maxClarificationBytes).check(Schema.makeFilter((value) => value.trim().length > 0 && new TextEncoder().encode(value).byteLength <= maxClarificationBytes ? undefined : `Expected nonblank text of at most ${maxClarificationBytes} UTF-8 bytes`)).pipe(Schema.brand("ClarificationText"));
218
+ RequestTaskInput = Schema.Struct({ runId: TaskRunId, question: clarificationText });
219
+ AnswerTaskInput = Schema.Struct({
220
+ requestId: ClarificationId,
221
+ answer: clarificationText
222
+ });
223
+ TaskClarification = Schema.Struct({
224
+ id: ClarificationId,
225
+ question: RequestTaskInput.fields.question,
226
+ at: Timestamp,
227
+ answer: Schema.optionalKey(Schema.Struct({ text: AnswerTaskInput.fields.answer, at: Timestamp }))
228
+ });
229
+ clarifications = boundedArray(TaskClarification, maxTaskClarifications);
183
230
  Task = Schema.Struct({
184
231
  id: TaskId,
232
+ runId: Schema.optionalKey(TaskRunId),
185
233
  threadId: ThreadId,
186
234
  prompt: bounded(131072),
187
235
  context: Schema.optionalKey(optionalText(131072)),
@@ -196,23 +244,27 @@ var init_src = __esm(() => {
196
244
  result: Schema.optionalKey(optionalText(131072)),
197
245
  inputAttachmentNames: Schema.optionalKey(boundedArray(AttachmentName, maxTaskAttachments)),
198
246
  error: Schema.optionalKey(optionalText(16384)),
247
+ progress: Schema.optionalKey(TaskProgress),
248
+ clarifications: Schema.optionalKey(clarifications),
199
249
  createdAt: Timestamp,
200
250
  updatedAt: Timestamp,
201
251
  startedAt: Schema.optionalKey(Timestamp),
202
- completedAt: Schema.optionalKey(Timestamp)
203
- }).check(Schema.makeFilter(validBotTarget));
252
+ completedAt: Schema.optionalKey(Timestamp),
253
+ expiresAt: Schema.optionalKey(TaskDeadline)
254
+ }).check(Schema.makeFilter(validBotTarget), Schema.makeFilter(validClarificationState));
204
255
  TaskInboxItem = Schema.Struct({
205
256
  id: TaskId,
206
257
  threadId: ThreadId,
207
258
  targetDeviceId: DeviceId,
208
259
  provider: Provider,
209
260
  botId: Schema.optionalKey(BotId),
210
- status: Schema.Literals(["completed", "failed", "cancelled"]),
261
+ status: Schema.Literals(["needs_input", "completed", "failed", "cancelled"]),
211
262
  promptPreview: optionalText(320),
212
263
  resultPreview: Schema.optionalKey(optionalText(1024)),
213
264
  errorPreview: Schema.optionalKey(optionalText(1024)),
214
- completedAt: Timestamp
215
- });
265
+ completedAt: Schema.optionalKey(Timestamp),
266
+ inputRequest: Schema.optionalKey(TaskClarification)
267
+ }).check(Schema.makeFilter((item) => item.status === "needs_input" ? item.inputRequest !== undefined && item.inputRequest.answer === undefined && item.completedAt === undefined ? undefined : "An input request must have an unanswered question and no completion time" : item.completedAt !== undefined && item.inputRequest === undefined ? undefined : "A completion must have a completion time and no input request"));
216
268
  TaskInbox = Schema.Struct({
217
269
  items: boundedArray(TaskInboxItem, 20),
218
270
  hasMore: Schema.Boolean
@@ -241,6 +293,7 @@ var init_src = __esm(() => {
241
293
  queued: count,
242
294
  assigned: count,
243
295
  running: count,
296
+ needs_input: Schema.optionalKey(count),
244
297
  cancelling: count,
245
298
  completed: count,
246
299
  failed: count,
@@ -282,10 +335,13 @@ var init_src = __esm(() => {
282
335
  updatedAt: Timestamp,
283
336
  startedAt: Schema.optionalKey(Timestamp),
284
337
  completedAt: Schema.optionalKey(Timestamp),
338
+ expiresAt: Schema.optionalKey(TaskDeadline),
285
339
  events: boundedArray(TaskTraceEvent, 100),
286
340
  truncated: Schema.Boolean,
287
- error: Schema.optionalKey(optionalText(16384))
288
- }).check(Schema.makeFilter(validBotTarget));
341
+ error: Schema.optionalKey(optionalText(16384)),
342
+ progress: Schema.optionalKey(TaskProgress),
343
+ clarifications: Schema.optionalKey(clarifications)
344
+ }).check(Schema.makeFilter(validBotTarget), Schema.makeFilter(validClarificationState));
289
345
  ThreadContext = Schema.Struct({
290
346
  thread: Thread,
291
347
  messages: boundedArray(Message, 20),
@@ -293,6 +349,7 @@ var init_src = __esm(() => {
293
349
  truncated: Schema.Boolean
294
350
  });
295
351
  CreateTaskInput = Schema.Struct({
352
+ requestId: Schema.optionalKey(TaskRequestId),
296
353
  threadId: Schema.optionalKey(ThreadId),
297
354
  title: Schema.optionalKey(bounded(256)),
298
355
  prompt: bounded(131072),
@@ -301,6 +358,7 @@ var init_src = __esm(() => {
301
358
  botId: Schema.optionalKey(BotId),
302
359
  targetDeviceId: Schema.optionalKey(DeviceId),
303
360
  parentTaskId: Schema.optionalKey(TaskId),
361
+ expiresAt: Schema.optionalKey(TaskDeadline),
304
362
  workspace: Schema.optionalKey(bounded(4096)),
305
363
  attachments: Schema.optionalKey(attachments)
306
364
  }).check(Schema.makeFilter(validBotTarget));
@@ -309,7 +367,9 @@ var init_src = __esm(() => {
309
367
  Connected: {
310
368
  serverVersion: bounded(32),
311
369
  connectedAt: Timestamp,
312
- taskAttachments: Schema.optionalKey(Schema.Boolean)
370
+ taskAttachments: Schema.optionalKey(Schema.Boolean),
371
+ taskClarification: Schema.optionalKey(Schema.Boolean),
372
+ taskDeadlines: Schema.optionalKey(Schema.Boolean)
313
373
  },
314
374
  DeviceHello: { device: Device },
315
375
  DeviceHeartbeat: {
@@ -318,17 +378,22 @@ var init_src = __esm(() => {
318
378
  bots: Schema.optionalKey(boundedArray(Bot, 256))
319
379
  },
320
380
  TaskAssigned: { task: Task },
321
- TaskAccepted: { taskId: TaskId },
381
+ TaskAccepted: { taskId: TaskId, runId: Schema.optionalKey(TaskRunId) },
322
382
  TaskFinished: {
323
383
  taskId: TaskId,
384
+ runId: Schema.optionalKey(TaskRunId),
324
385
  result: optionalText(131072),
325
386
  attachments: Schema.optionalKey(attachments),
326
387
  providerSessionId: Schema.optionalKey(bounded(4096))
327
388
  },
328
- TaskFailed: { taskId: TaskId, error: bounded(16384) },
329
- TaskCancelled: { taskId: TaskId },
330
- TaskSettled: { taskId: TaskId },
331
- CancelTask: { taskId: TaskId },
389
+ TaskInputRequested: {
390
+ taskId: TaskId,
391
+ ...RequestTaskInput.fields
392
+ },
393
+ TaskFailed: { taskId: TaskId, runId: Schema.optionalKey(TaskRunId), error: bounded(16384) },
394
+ TaskCancelled: { taskId: TaskId, runId: Schema.optionalKey(TaskRunId) },
395
+ TaskSettled: { taskId: TaskId, runId: Schema.optionalKey(TaskRunId) },
396
+ CancelTask: { taskId: TaskId, runId: Schema.optionalKey(TaskRunId) },
332
397
  OperationAssigned: { operation: DeviceOperation },
333
398
  OperationAccepted: { operationId: OperationId },
334
399
  OperationFinished: { operationId: OperationId, result: optionalText(16384) },
@@ -345,6 +410,9 @@ var init_src = __esm(() => {
345
410
  "cancelled"
346
411
  ]);
347
412
  decodeCreatePairingInput = Schema.decodeUnknownEffect(CreatePairingInput);
413
+ decodeTaskProgressInput = Schema.decodeUnknownEffect(TaskProgressInput);
414
+ decodeRequestTaskInput = Schema.decodeUnknownEffect(RequestTaskInput);
415
+ decodeAnswerTaskInput = Schema.decodeUnknownEffect(AnswerTaskInput);
348
416
  decodeExchangePairingInput = Schema.decodeUnknownEffect(ExchangePairingInput);
349
417
  decodeCreateUpgradeOperationsInput = Schema.decodeUnknownEffect(CreateUpgradeOperationsInput);
350
418
  decodeDevice = Schema.decodeUnknownEffect(Device);
@@ -355,7 +423,7 @@ var init_src = __esm(() => {
355
423
  import { Effect as Effect13, Schema as Schema14 } from "effect";
356
424
  import { access as access5, chmod as chmod7, mkdir as mkdir8, readFile as readFile5, writeFile as writeFile9 } from "node:fs/promises";
357
425
  import { homedir as homedir5, platform as platform5 } from "node:os";
358
- import { join as join9, resolve as resolve4 } from "node:path";
426
+ import { join as join9, resolve as resolve5 } from "node:path";
359
427
  var RelayConfiguration, defaultDataDirectory = () => {
360
428
  if (platform5() === "win32") {
361
429
  return join9(process.env.LOCALAPPDATA ?? join9(homedir5(), "AppData", "Local"), "Cohall");
@@ -403,7 +471,7 @@ var init_config = __esm(() => {
403
471
  if (!isLoopback2(host) && !allowRemote) {
404
472
  throw new Error("Remote relay binding is disabled. Use a private network or TLS reverse proxy, then set COHALL_RELAY_ALLOW_REMOTE=true.");
405
473
  }
406
- const dataDirectory = resolve4(process.env.COHALL_DATA_DIR ?? defaultDataDirectory());
474
+ const dataDirectory = resolve5(process.env.COHALL_DATA_DIR ?? defaultDataDirectory());
407
475
  await mkdir8(dataDirectory, { recursive: true, mode: 448 });
408
476
  if (platform5() !== "win32") {
409
477
  await chmod7(dataDirectory, 448);
@@ -596,13 +664,17 @@ var exports_store = {};
596
664
  __export(exports_store, {
597
665
  layerFromDatabase: () => layerFromDatabase,
598
666
  layer: () => layer2,
667
+ TaskRequestError: () => TaskRequestError,
668
+ TaskProgressError: () => TaskProgressError,
669
+ TaskInputError: () => TaskInputError,
670
+ TaskDeadlineError: () => TaskDeadlineError,
599
671
  Service: () => Service2,
600
672
  RelayStore: () => exports_store,
601
673
  PersistenceError: () => PersistenceError
602
674
  });
603
675
  import { Context as Context2, Effect as Effect14, Layer as Layer2, Schema as Schema15 } from "effect";
604
676
  import { createHash as createHash2, randomBytes } from "node:crypto";
605
- var PersistenceError, Service2, operationError = (operation) => (cause) => new PersistenceError({
677
+ var PersistenceError, TaskProgressError, TaskInputError, TaskDeadlineError, TaskRequestError, Service2, operationError = (operation) => (cause) => new PersistenceError({
606
678
  operation,
607
679
  message: cause instanceof Error ? cause.message : String(cause)
608
680
  }), decode2 = (operation, schema, value) => Schema15.decodeUnknownEffect(schema)(value).pipe(Effect14.mapError(operationError(operation))), threadFromRow = (row) => decode2("RelayStore.decodeThread", Thread, {
@@ -633,12 +705,20 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
633
705
  workspaces: JSON.parse(row.workspaces_json),
634
706
  version: row.version,
635
707
  lastSeenAt: row.last_seen_at,
636
- ...row.connected_at === null ? {} : { connectedAt: row.connected_at }
708
+ ...row.connected_at === null ? {} : { connectedAt: row.connected_at },
709
+ ...row.queued_tasks === undefined ? {} : {
710
+ queue: {
711
+ queued: row.queued_tasks,
712
+ ...row.oldest_queued_at === undefined || row.oldest_queued_at === null ? {} : { oldestQueuedAt: row.oldest_queued_at }
713
+ }
714
+ }
637
715
  }), taskFromRow = (db, row) => Effect14.try({
638
716
  try: () => db.query("SELECT name FROM task_attachments WHERE task_id = ? AND direction = 'input' ORDER BY name").all(row.id),
639
717
  catch: operationError("RelayStore.taskAttachments")
640
718
  }).pipe(Effect14.flatMap((attachments2) => decode2("RelayStore.decodeTask", Task, {
641
719
  id: row.id,
720
+ ...row.run_id === null ? {} : { runId: row.run_id },
721
+ ...row.clarifications_json === null ? {} : { clarifications: JSON.parse(row.clarifications_json) },
642
722
  threadId: row.thread_id,
643
723
  prompt: row.prompt,
644
724
  provider: row.provider,
@@ -647,6 +727,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
647
727
  targetDeviceId: row.target_device_id,
648
728
  createdAt: row.created_at,
649
729
  updatedAt: row.updated_at,
730
+ ...row.expires_at === null ? {} : { expiresAt: row.expires_at },
650
731
  ...row.context === null ? {} : { context: row.context },
651
732
  ...row.source_device_id === null ? {} : { sourceDeviceId: row.source_device_id },
652
733
  ...row.parent_task_id === null ? {} : { parentTaskId: row.parent_task_id },
@@ -655,6 +736,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
655
736
  ...row.result === null ? {} : { result: row.result },
656
737
  ...attachments2.length === 0 ? {} : { inputAttachmentNames: attachments2.map((item) => item.name) },
657
738
  ...row.error === null ? {} : { error: row.error },
739
+ ...row.progress_note === null ? {} : { progress: { note: row.progress_note, at: row.progress_at } },
658
740
  ...row.started_at === null ? {} : { startedAt: row.started_at },
659
741
  ...row.completed_at === null ? {} : { completedAt: row.completed_at }
660
742
  }))), inboxItemFromRow = (row) => decode2("RelayStore.decodeInboxItem", TaskInboxItem, {
@@ -665,7 +747,8 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
665
747
  ...row.bot_id === null ? {} : { botId: row.bot_id },
666
748
  status: row.status,
667
749
  promptPreview: row.prompt_preview,
668
- completedAt: row.completed_at,
750
+ ...row.completed_at === null ? {} : { completedAt: row.completed_at },
751
+ ...row.input_request_json === null ? {} : { inputRequest: JSON.parse(row.input_request_json) },
669
752
  ...row.result_preview === null ? {} : { resultPreview: row.result_preview },
670
753
  ...row.error_preview === null ? {} : { errorPreview: row.error_preview }
671
754
  }), operationFromRow = (row) => decode2("RelayStore.decodeOperation", DeviceOperation, {
@@ -684,6 +767,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
684
767
  queued: 0,
685
768
  assigned: 0,
686
769
  running: 0,
770
+ needs_input: 0,
687
771
  cancelling: 0,
688
772
  completed: 0,
689
773
  failed: 0,
@@ -755,7 +839,52 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
755
839
  events.push(TaskTraceEvent.make({ kind: task.status, at: task.completedAt, detail }));
756
840
  }
757
841
  return events;
758
- }, makeService = (db, retainedTerminalTasks = 1000) => {
842
+ }, interruptedTaskStatus = `CASE
843
+ WHEN json_array_length(clarifications_json) > 0
844
+ AND json_extract(clarifications_json, '$[#-1].answer') IS NULL THEN 'needs_input'
845
+ ELSE 'queued' END`, makeService = (db, retainedTerminalTasks = 1000) => {
846
+ const requestHash = (input) => {
847
+ const { requestId: _requestId, attachments: attachments2, ...fields } = input;
848
+ const normalized = {
849
+ ...fields,
850
+ provider: input.provider ?? (input.botId === undefined ? "codex" : "grok-bot"),
851
+ ...attachments2 === undefined || attachments2.length === 0 ? {} : {
852
+ attachments: attachments2.map(({ name, data }) => [
853
+ name,
854
+ Buffer.from(data, "base64").toString("base64")
855
+ ])
856
+ }
857
+ };
858
+ return createHash2("sha256").update(JSON.stringify(Object.entries(normalized).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0))).digest("hex");
859
+ };
860
+ const requestedTask = (input, principal) => {
861
+ if (input.requestId === undefined)
862
+ return null;
863
+ const saved = db.query("SELECT input_hash, task_id FROM task_requests WHERE requester_id = ? AND request_id = ?").get(principal === "owner" ? "owner" : principal.id, input.requestId.toLowerCase());
864
+ if (saved === null)
865
+ return null;
866
+ if (saved.input_hash !== requestHash(input))
867
+ throw new TaskRequestError({
868
+ status: 409,
869
+ message: "Request ID was already used with different task input"
870
+ });
871
+ if (saved.task_id === null)
872
+ throw new TaskRequestError({
873
+ status: 410,
874
+ message: "The original task is no longer retained; this request ID cannot be reused"
875
+ });
876
+ const task = db.query("SELECT * FROM tasks WHERE id = ?").get(saved.task_id);
877
+ if (task === null)
878
+ throw new Error(`Missing task ${saved.task_id} for retained request`);
879
+ return task;
880
+ };
881
+ const findDelegation = Effect14.fn("RelayStore.findDelegation")(function* (input, principal) {
882
+ const row = yield* Effect14.try({
883
+ try: () => requestedTask(input, principal),
884
+ catch: (cause) => cause instanceof TaskRequestError ? cause : operationError("RelayStore.findDelegation")(cause)
885
+ });
886
+ return row === null ? undefined : yield* taskFromRow(db, row);
887
+ });
759
888
  const recordTaskTraceEvent = (taskId, kind, detail, createdAt = now()) => {
760
889
  db.query(`INSERT INTO task_trace_events (task_id, kind, created_at, detail)
761
890
  VALUES (?, ?, ?, ?)`).run(taskId, kind, createdAt, detail);
@@ -795,7 +924,8 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
795
924
  substr(prompt, 1, 160) AS prompt_preview,
796
925
  substr(result, 1, 512) AS result_preview,
797
926
  substr(error, 1, 512) AS error_preview,
798
- COALESCE(completed_at, updated_at) AS completed_at`;
927
+ CASE WHEN status = 'needs_input' THEN NULL ELSE COALESCE(completed_at, updated_at) END AS completed_at,
928
+ CASE WHEN status = 'needs_input' THEN json_extract(clarifications_json, '$[#-1]') END AS input_request_json`;
799
929
  const queryOperation = (operationId) => db.query("SELECT * FROM device_operations WHERE id = ?").get(operationId);
800
930
  const getTask = Effect14.fn("RelayStore.getTask")(function* (taskId) {
801
931
  const row = yield* Effect14.try({
@@ -805,7 +935,8 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
805
935
  if (row === null) {
806
936
  return yield* Effect14.fail(new PersistenceError({
807
937
  operation: "RelayStore.getTask",
808
- message: `Unknown task ${taskId}`
938
+ message: `Unknown task ${taskId}`,
939
+ missingRecord: "task"
809
940
  }));
810
941
  }
811
942
  return yield* taskFromRow(db, row);
@@ -836,8 +967,8 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
836
967
  const requesterId = principal === "owner" ? "owner" : principal.id;
837
968
  const rows = yield* Effect14.try({
838
969
  try: () => db.query(`SELECT ${inboxColumns} FROM tasks WHERE requester_id = ? AND completion_seen_at IS NULL
839
- AND status IN ('completed', 'failed', 'cancelled')
840
- ORDER BY completed_at ASC, id ASC LIMIT 21`).all(requesterId),
970
+ AND status IN ('needs_input', 'completed', 'failed', 'cancelled')
971
+ ORDER BY COALESCE(completed_at, updated_at) ASC, id ASC LIMIT 21`).all(requesterId),
841
972
  catch: operationError("RelayStore.inboxFor")
842
973
  });
843
974
  return TaskInbox.make({
@@ -902,16 +1033,72 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
902
1033
  ...task.workspace === undefined ? {} : { workspace: task.workspace },
903
1034
  ...task.startedAt === undefined ? {} : { startedAt: task.startedAt },
904
1035
  ...task.completedAt === undefined ? {} : { completedAt: task.completedAt },
905
- ...task.error === undefined ? {} : { error: task.error }
1036
+ ...task.expiresAt === undefined ? {} : { expiresAt: task.expiresAt },
1037
+ ...task.error === undefined ? {} : { error: task.error },
1038
+ ...task.progress === undefined ? {} : { progress: task.progress },
1039
+ ...task.clarifications === undefined ? {} : { clarifications: task.clarifications }
906
1040
  });
907
1041
  });
908
1042
  const listDevices = Effect14.fn("RelayStore.listDevices")(function* () {
909
1043
  const rows = yield* Effect14.try({
910
- try: () => db.query("SELECT * FROM devices WHERE forgotten_at IS NULL ORDER BY name COLLATE NOCASE, id").all(),
1044
+ try: () => db.query(`SELECT devices.*, COUNT(tasks.id) AS queued_tasks,
1045
+ MIN(tasks.created_at) AS oldest_queued_at
1046
+ FROM devices LEFT JOIN tasks
1047
+ ON tasks.target_device_id = devices.id AND tasks.status = 'queued'
1048
+ WHERE devices.forgotten_at IS NULL
1049
+ GROUP BY devices.id ORDER BY devices.name COLLATE NOCASE, devices.id`).all(),
911
1050
  catch: operationError("RelayStore.listDevices")
912
1051
  });
913
1052
  return yield* Effect14.forEach(rows, deviceFromRow);
914
1053
  });
1054
+ const listDevicePage = Effect14.fn("RelayStore.listDevicePage")(function* (after) {
1055
+ const nextRow = yield* Effect14.try({
1056
+ try: () => db.query(`SELECT devices.*,
1057
+ (SELECT COUNT(*) FROM tasks
1058
+ WHERE target_device_id = devices.id AND status = 'queued') AS queued_tasks,
1059
+ (SELECT MIN(created_at) FROM tasks
1060
+ WHERE target_device_id = devices.id AND status = 'queued') AS oldest_queued_at
1061
+ FROM devices WHERE forgotten_at IS NULL AND devices.id > ?
1062
+ ORDER BY devices.id LIMIT 1`),
1063
+ catch: operationError("RelayStore.listDevicePage.query")
1064
+ });
1065
+ const devices = [];
1066
+ const targetBytes = 2 * 1024 * 1024 - 1024;
1067
+ let bytes = 0;
1068
+ let cursor = after ?? "";
1069
+ while (devices.length < maxDevicePageDevices) {
1070
+ const row = yield* Effect14.try({
1071
+ try: () => nextRow.get(cursor),
1072
+ catch: operationError("RelayStore.listDevicePage.read")
1073
+ });
1074
+ if (row === null)
1075
+ break;
1076
+ const device = yield* deviceFromRow(row);
1077
+ const size = Buffer.byteLength(JSON.stringify(device)) + (devices.length === 0 ? 0 : 1);
1078
+ if (devices.length > 0 && bytes + size > targetBytes)
1079
+ break;
1080
+ devices.push(device);
1081
+ bytes += size;
1082
+ cursor = device.id;
1083
+ if (bytes > targetBytes)
1084
+ break;
1085
+ }
1086
+ const last = devices.at(-1);
1087
+ const hasMore = last !== undefined && (yield* Effect14.try({
1088
+ try: () => db.query("SELECT 1 AS found FROM devices WHERE forgotten_at IS NULL AND id > ? ORDER BY id LIMIT 1").get(last.id) !== null,
1089
+ catch: operationError("RelayStore.listDevicePage.hasMore")
1090
+ }));
1091
+ const page = DevicePage.make({
1092
+ devices,
1093
+ ...hasMore && last !== undefined ? { nextCursor: last.id } : {}
1094
+ });
1095
+ if (Buffer.byteLength(JSON.stringify(page)) > maxDevicePageResponseBytes)
1096
+ return yield* new PersistenceError({
1097
+ operation: "RelayStore.listDevicePage",
1098
+ message: "A device page exceeds the transfer limit"
1099
+ });
1100
+ return page;
1101
+ });
915
1102
  const usage = Effect14.fn("RelayStore.usage")(function* () {
916
1103
  const rows = yield* Effect14.try({
917
1104
  try: () => db.query(`SELECT tasks.target_device_id AS device_id, devices.name AS device_name,
@@ -967,7 +1154,8 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
967
1154
  if (row === null) {
968
1155
  return yield* Effect14.fail(new PersistenceError({
969
1156
  operation: "RelayStore.getOperation",
970
- message: `Unknown device operation ${operationId}`
1157
+ message: `Unknown device operation ${operationId}`,
1158
+ missingRecord: "operation"
971
1159
  }));
972
1160
  }
973
1161
  return yield* operationFromRow(row);
@@ -1125,12 +1313,15 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1125
1313
  yield* Effect14.try({
1126
1314
  try: () => db.transaction(() => {
1127
1315
  const timestamp = now();
1128
- const interrupted = db.query("SELECT id FROM tasks WHERE status IN ('assigned', 'running')").all();
1316
+ const interrupted = db.query(`SELECT id, ${interruptedTaskStatus} AS status FROM tasks WHERE status IN ('assigned', 'running')`).all();
1129
1317
  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);
1318
+ db.query(`UPDATE tasks SET status = ${interruptedTaskStatus}, updated_at = ?, progress_note = NULL, progress_at = NULL,
1319
+ dispatched_at = CASE WHEN provider = 'grok-bot' THEN dispatched_at
1320
+ ELSE COALESCE(dispatched_at, started_at, updated_at) END
1321
+ WHERE status IN ('assigned', 'running')`).run(timestamp);
1131
1322
  db.query("UPDATE device_operations SET status = 'queued', updated_at = ? WHERE status IN ('assigned', 'running')").run(timestamp);
1132
1323
  for (const task of interrupted) {
1133
- recordTaskTraceEvent(TaskId.make(task.id), "requeued", "Relay restarted before the task completed", timestamp);
1324
+ recordTaskTraceEvent(TaskId.make(task.id), task.status === "needs_input" ? "needs_input" : "requeued", "Relay restarted before the task completed", timestamp);
1134
1325
  }
1135
1326
  db.query("DELETE FROM auth_pairings WHERE expires_at <= ? OR used_at IS NOT NULL").run(timestamp);
1136
1327
  db.query("UPDATE auth_sessions SET revoked_at = ? WHERE revoked_at IS NULL AND roles_json LIKE '%,%'").run(timestamp);
@@ -1179,52 +1370,68 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1179
1370
  }
1180
1371
  return yield* deviceFromRow(row);
1181
1372
  });
1182
- const createDelegation2 = Effect14.fn("RelayStore.createDelegation")(function* (input, targetDeviceId, principal, providerSessionId) {
1373
+ const createDelegation2 = Effect14.fn("RelayStore.createDelegation")(function* (input, targetDeviceId, principal, providerSessionId, requestedInput = input) {
1183
1374
  const requesterId = principal === "owner" ? "owner" : principal.id;
1184
1375
  const boundDeviceId = principal === "owner" ? undefined : principal.deviceId;
1185
1376
  const sourceDeviceId = yield* Effect14.try({
1186
1377
  try: () => boundDeviceId === undefined || db.query("SELECT id FROM devices WHERE id = ? AND forgotten_at IS NULL").get(boundDeviceId) === null ? undefined : boundDeviceId,
1187
1378
  catch: operationError("RelayStore.createDelegation.source")
1188
1379
  });
1189
- const timestamp = now();
1190
- const threadId = input.threadId ?? makeThreadId();
1191
- const task = Task.make({
1192
- id: makeTaskId(),
1193
- threadId,
1194
- prompt: input.prompt,
1195
- provider: input.provider ?? (input.botId === undefined ? "codex" : "grok-bot"),
1196
- ...input.botId === undefined ? {} : { botId: input.botId },
1197
- status: "queued",
1198
- targetDeviceId,
1199
- createdAt: timestamp,
1200
- updatedAt: timestamp,
1201
- ...input.context === undefined ? {} : { context: input.context },
1202
- ...sourceDeviceId === undefined ? {} : { sourceDeviceId },
1203
- ...input.parentTaskId === undefined ? {} : { parentTaskId: input.parentTaskId },
1204
- ...input.workspace === undefined ? {} : { workspace: input.workspace },
1205
- ...input.attachments === undefined || input.attachments.length === 0 ? {} : { inputAttachmentNames: input.attachments.map((attachment) => attachment.name) },
1206
- ...providerSessionId === undefined ? {} : { providerSessionId }
1207
- });
1208
- const thread = input.threadId === undefined ? Thread.make({
1209
- id: threadId,
1210
- title: input.title ?? input.prompt.slice(0, 72),
1211
- createdAt: timestamp,
1212
- updatedAt: timestamp
1213
- }) : undefined;
1214
- const message = Message.make({
1215
- id: makeMessageId(),
1216
- threadId,
1217
- role: "human",
1218
- authorName: sourceDeviceId === undefined ? "User" : "Remote agent",
1219
- content: input.prompt,
1220
- createdAt: timestamp,
1221
- taskId: task.id,
1222
- ...sourceDeviceId === undefined ? {} : { deviceId: sourceDeviceId }
1223
- });
1224
- yield* Effect14.try({
1380
+ const submission = yield* Effect14.try({
1225
1381
  try: () => db.transaction(() => {
1382
+ const existing = requestedTask(requestedInput, principal);
1383
+ if (existing !== null)
1384
+ return { existing };
1385
+ if (input.expiresAt !== undefined) {
1386
+ if (input.provider === "grok-bot" || input.botId !== undefined)
1387
+ throw new TaskDeadlineError({
1388
+ status: 400,
1389
+ message: "Task deadlines require a coding provider"
1390
+ });
1391
+ if (taskDeadlinePassed(input))
1392
+ throw new TaskDeadlineError({
1393
+ status: 400,
1394
+ message: "Task deadline must be in the future"
1395
+ });
1396
+ }
1397
+ const timestamp = now();
1398
+ const threadId = input.threadId ?? makeThreadId();
1399
+ const task = Task.make({
1400
+ id: makeTaskId(),
1401
+ threadId,
1402
+ prompt: input.prompt,
1403
+ provider: input.provider ?? (input.botId === undefined ? "codex" : "grok-bot"),
1404
+ ...input.botId === undefined ? {} : { botId: input.botId },
1405
+ status: "queued",
1406
+ targetDeviceId,
1407
+ createdAt: timestamp,
1408
+ updatedAt: timestamp,
1409
+ ...input.expiresAt === undefined ? {} : { expiresAt: Timestamp.make(new Date(input.expiresAt).toISOString()) },
1410
+ ...input.context === undefined ? {} : { context: input.context },
1411
+ ...sourceDeviceId === undefined ? {} : { sourceDeviceId },
1412
+ ...input.parentTaskId === undefined ? {} : { parentTaskId: input.parentTaskId },
1413
+ ...input.workspace === undefined ? {} : { workspace: input.workspace },
1414
+ ...input.attachments === undefined || input.attachments.length === 0 ? {} : { inputAttachmentNames: input.attachments.map((attachment) => attachment.name) },
1415
+ ...providerSessionId === undefined ? {} : { providerSessionId }
1416
+ });
1417
+ const thread = input.threadId === undefined ? Thread.make({
1418
+ id: threadId,
1419
+ title: input.title ?? input.prompt.slice(0, 72),
1420
+ createdAt: timestamp,
1421
+ updatedAt: timestamp
1422
+ }) : undefined;
1423
+ const message = Message.make({
1424
+ id: makeMessageId(),
1425
+ threadId,
1426
+ role: "human",
1427
+ authorName: sourceDeviceId === undefined ? "User" : "Remote agent",
1428
+ content: input.prompt,
1429
+ createdAt: timestamp,
1430
+ taskId: task.id,
1431
+ ...sourceDeviceId === undefined ? {} : { deviceId: sourceDeviceId }
1432
+ });
1226
1433
  const outstanding = db.query(`SELECT COUNT(*) AS count FROM tasks WHERE target_device_id = ?
1227
- AND status IN ('queued', 'assigned', 'running', 'cancelling')`).get(targetDeviceId);
1434
+ AND status IN ('queued', 'assigned', 'running', 'needs_input', 'cancelling')`).get(targetDeviceId);
1228
1435
  if ((outstanding?.count ?? 0) >= 100) {
1229
1436
  throw new Error(`Device ${targetDeviceId} has reached its outstanding task limit`);
1230
1437
  }
@@ -1236,8 +1443,8 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1236
1443
  db.query(`INSERT INTO tasks (
1237
1444
  id, thread_id, prompt, context, provider, status, source_device_id,
1238
1445
  requester_id, target_device_id, parent_task_id, workspace,
1239
- provider_session_id, bot_id, created_at, updated_at
1240
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(task.id, task.threadId, task.prompt, task.context ?? null, task.provider, task.status, task.sourceDeviceId ?? null, requesterId, task.targetDeviceId, task.parentTaskId ?? null, task.workspace ?? null, task.providerSessionId ?? null, task.botId ?? null, task.createdAt, task.updatedAt);
1446
+ provider_session_id, bot_id, created_at, updated_at, expires_at
1447
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(task.id, task.threadId, task.prompt, task.context ?? null, task.provider, task.status, task.sourceDeviceId ?? null, requesterId, task.targetDeviceId, task.parentTaskId ?? null, task.workspace ?? null, task.providerSessionId ?? null, task.botId ?? null, task.createdAt, task.updatedAt, task.expiresAt ?? null);
1241
1448
  for (const attachment of input.attachments ?? []) {
1242
1449
  const data = Buffer.from(attachment.data, "base64");
1243
1450
  if (data.length < 1 || data.length > maxAttachmentBytes) {
@@ -1245,16 +1452,20 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1245
1452
  }
1246
1453
  db.query("INSERT INTO task_attachments (task_id, name, direction, data) VALUES (?, ?, 'input', ?)").run(task.id, attachment.name, data);
1247
1454
  }
1455
+ if (requestedInput.requestId !== undefined) {
1456
+ db.query("INSERT INTO task_requests (requester_id, request_id, input_hash, task_id) VALUES (?, ?, ?, ?)").run(requesterId, requestedInput.requestId.toLowerCase(), requestHash(requestedInput), task.id);
1457
+ }
1248
1458
  recordTaskTraceEvent(task.id, "queued", "Relay accepted the task", timestamp);
1249
1459
  db.query(`INSERT INTO messages (
1250
1460
  id, thread_id, role, kind, author_id, author_name, content, created_at,
1251
1461
  task_id, device_id
1252
1462
  ) VALUES (?, ?, ?, 'chat', ?, ?, ?, ?, ?, ?)`).run(message.id, message.threadId, message.role, sourceDeviceId ?? "user", message.authorName, message.content, message.createdAt, message.taskId ?? null, message.deviceId ?? null);
1253
1463
  db.query("UPDATE threads SET updated_at = ? WHERE id = ?").run(timestamp, threadId);
1464
+ return { created: task };
1254
1465
  })(),
1255
- catch: operationError("RelayStore.createDelegation")
1466
+ catch: (cause) => cause instanceof TaskRequestError || cause instanceof TaskDeadlineError ? cause : operationError("RelayStore.createDelegation")(cause)
1256
1467
  });
1257
- return task;
1468
+ return "existing" in submission ? yield* taskFromRow(db, submission.existing) : submission.created;
1258
1469
  });
1259
1470
  const threadContext2 = Effect14.fn("RelayStore.threadContext")(function* (threadId) {
1260
1471
  const threadRow = yield* Effect14.try({
@@ -1325,15 +1536,45 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1325
1536
  });
1326
1537
  return yield* getTask(taskId);
1327
1538
  });
1539
+ const hasDispatchEvidence = Effect14.fn("RelayStore.hasDispatchEvidence")((taskId) => Effect14.try({
1540
+ try: () => db.query("SELECT 1 FROM tasks WHERE id = ? AND dispatched_at IS NOT NULL").get(taskId) !== null,
1541
+ catch: operationError("RelayStore.hasDispatchEvidence")
1542
+ }));
1543
+ const expireTask = Effect14.fn("RelayStore.expireTask")(function* (task) {
1544
+ if (!taskDeadlinePassed(task) || ["cancelling", "completed", "failed", "cancelled"].includes(task.status))
1545
+ return task;
1546
+ const neverDispatched = task.status === "queued" && task.runId === undefined && task.startedAt === undefined && !(yield* hasDispatchEvidence(task.id));
1547
+ return yield* transition(task.id, [task.status], {
1548
+ status: neverDispatched ? "failed" : "cancelling",
1549
+ error: taskDeadlineError,
1550
+ ...neverDispatched ? { completedAt: now() } : {}
1551
+ }, { kind: neverDispatched ? "failed" : "cancelling", detail: taskDeadlineError });
1552
+ });
1553
+ const expireTasks = Effect14.fn("RelayStore.expireTasks")(function* () {
1554
+ const rows = yield* Effect14.try({
1555
+ try: () => db.query(`SELECT * FROM tasks WHERE expires_at <= ? AND status IN ('queued', 'assigned', 'running', 'needs_input')
1556
+ ORDER BY expires_at, id LIMIT 100`).all(now()),
1557
+ catch: operationError("RelayStore.expireTasks")
1558
+ });
1559
+ return yield* Effect14.forEach(rows, (row) => taskFromRow(db, row).pipe(Effect14.flatMap(expireTask)));
1560
+ });
1328
1561
  const assignTask = Effect14.fn("RelayStore.assignTask")(function* (taskId) {
1329
- const current = yield* getTask(taskId);
1562
+ const current = yield* getTask(taskId).pipe(Effect14.flatMap(expireTask));
1330
1563
  if (current.status !== "queued") {
1331
1564
  return current;
1332
1565
  }
1333
1566
  const timestamp = now();
1334
1567
  yield* Effect14.try({
1335
1568
  try: () => db.transaction(() => {
1336
- const result = db.query(`UPDATE tasks SET status = 'assigned', updated_at = ?,
1569
+ const result = db.query(`UPDATE tasks SET status = 'assigned', updated_at = ?, run_id = CASE
1570
+ WHEN dispatched_at IS NOT NULL OR started_at IS NOT NULL OR (
1571
+ provider = 'grok-bot' AND NOT EXISTS (
1572
+ SELECT 1 FROM devices, json_each(devices.capabilities_json) capability
1573
+ WHERE devices.id = tasks.target_device_id
1574
+ AND json_extract(capability.value, '$.id') = 'task-clarification'
1575
+ )
1576
+ )
1577
+ THEN run_id ELSE COALESCE(run_id, ?) END,
1337
1578
  provider_session_id = CASE
1338
1579
  WHEN provider = 'grok-bot' THEN NULL
1339
1580
  WHEN started_at IS NULL THEN COALESCE((
@@ -1352,7 +1593,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1352
1593
  SELECT 1 FROM device_operations active
1353
1594
  WHERE active.target_device_id = ?
1354
1595
  AND active.status IN ('assigned', 'running')
1355
- )`).run(timestamp, taskId, current.targetDeviceId, taskId, taskSlot(current), current.targetDeviceId);
1596
+ )`).run(timestamp, crypto.randomUUID(), taskId, current.targetDeviceId, taskId, taskSlot(current), current.targetDeviceId);
1356
1597
  if (result.changes === 1) {
1357
1598
  recordTaskTraceEvent(taskId, "assigned", "Relay dispatched the task over the device WebSocket", timestamp);
1358
1599
  }
@@ -1371,22 +1612,195 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1371
1612
  }
1372
1613
  return task;
1373
1614
  });
1374
- const terminal = Effect14.fn("RelayStore.terminal")(function* (taskId, deviceId, status, result, error, providerSessionId, attachments2) {
1615
+ const reportTaskProgress = Effect14.fn("RelayStore.reportTaskProgress")(function* (taskId, deviceId, note) {
1616
+ const task = yield* getTask(taskId);
1617
+ if (task.targetDeviceId !== deviceId) {
1618
+ return yield* new TaskProgressError({
1619
+ status: 403,
1620
+ message: "Only the target device can report progress"
1621
+ });
1622
+ }
1623
+ const progress = TaskProgress.make({ note, at: now() });
1624
+ const changed = yield* Effect14.try({
1625
+ try: () => db.query(`UPDATE tasks SET progress_note = ?, progress_at = ?, updated_at = ?
1626
+ WHERE id = ? AND target_device_id = ? AND status = 'running'`).run(note, progress.at, progress.at, taskId, deviceId).changes,
1627
+ catch: operationError("RelayStore.reportTaskProgress")
1628
+ });
1629
+ if (changed !== 1) {
1630
+ return yield* new TaskProgressError({
1631
+ status: 409,
1632
+ message: "Task must be running to report progress"
1633
+ });
1634
+ }
1635
+ return progress;
1636
+ });
1637
+ const requestTaskInput = Effect14.fn("RelayStore.requestTaskInput")(function* (taskId, deviceId, input, turnFinished = false) {
1638
+ const task = yield* getTask(taskId);
1639
+ if (task.targetDeviceId !== deviceId) {
1640
+ return yield* new TaskInputError({
1641
+ status: 403,
1642
+ message: "Only the target device can request input"
1643
+ });
1644
+ }
1645
+ if (taskDeadlinePassed(task))
1646
+ return yield* new TaskInputError({ status: 409, message: taskDeadlineError });
1647
+ const active = task.status === "running" || turnFinished && (task.status === "queued" || task.status === "assigned");
1648
+ if (!active || task.runId !== input.runId) {
1649
+ return yield* new TaskInputError({
1650
+ status: 409,
1651
+ message: "Input can only be requested by the current running turn"
1652
+ });
1653
+ }
1654
+ const row = yield* Effect14.try({
1655
+ try: () => db.query("SELECT * FROM devices WHERE id = ?").get(deviceId),
1656
+ catch: operationError("RelayStore.requestTaskInput.device")
1657
+ });
1658
+ const device = row === null ? undefined : yield* deviceFromRow(row);
1659
+ if (!device?.capabilities.some((capability) => capability.id === "task-clarification")) {
1660
+ return yield* new TaskInputError({
1661
+ status: 409,
1662
+ message: "Upgrade the target worker before requesting clarification"
1663
+ });
1664
+ }
1665
+ const history = task.clarifications ?? [];
1666
+ const pending = history.at(-1);
1667
+ if (pending !== undefined && pending.answer === undefined) {
1668
+ if (pending.question === input.question)
1669
+ return pending;
1670
+ return yield* new TaskInputError({
1671
+ status: 409,
1672
+ message: "This task already has an unanswered question"
1673
+ });
1674
+ }
1675
+ if (history.length >= maxTaskClarifications) {
1676
+ return yield* new TaskInputError({
1677
+ status: 409,
1678
+ message: `A task supports at most ${maxTaskClarifications} clarifications`
1679
+ });
1680
+ }
1681
+ const question = TaskClarification.make({
1682
+ id: ClarificationId.make(crypto.randomUUID()),
1683
+ question: input.question,
1684
+ at: now()
1685
+ });
1686
+ const changed = yield* Effect14.try({
1687
+ try: () => db.transaction(() => {
1688
+ const result = db.query(`UPDATE tasks SET clarifications_json = ?, updated_at = ?
1689
+ WHERE id = ? AND status = ? AND run_id = ?
1690
+ AND COALESCE(clarifications_json, '[]') = ?`).run(JSON.stringify([...history, question]), question.at, taskId, task.status, input.runId, JSON.stringify(history));
1691
+ if (result.changes === 1)
1692
+ recordTaskTraceEvent(taskId, "input_requested", "Worker requested clarification and is ending its turn", question.at);
1693
+ return result.changes;
1694
+ })(),
1695
+ catch: operationError("RelayStore.requestTaskInput")
1696
+ });
1697
+ if (changed !== 1)
1698
+ return yield* new TaskInputError({
1699
+ status: 409,
1700
+ message: "Task changed while requesting input; check its status"
1701
+ });
1702
+ return question;
1703
+ });
1704
+ const answerTaskInput = Effect14.fn("RelayStore.answerTaskInput")(function* (taskId, principal, input) {
1705
+ const task = yield* getTask(taskId);
1706
+ const requester = yield* Effect14.try({
1707
+ try: () => queryTask(taskId)?.requester_id,
1708
+ catch: operationError("RelayStore.answerTaskInput.requester")
1709
+ });
1710
+ if (principal !== "owner" && (principal.role !== "client" || requester !== principal.id)) {
1711
+ return yield* new TaskInputError({
1712
+ status: 403,
1713
+ message: "Only the original requester or relay owner can answer"
1714
+ });
1715
+ }
1716
+ if (taskDeadlinePassed(task))
1717
+ return yield* new TaskInputError({ status: 409, message: taskDeadlineError });
1718
+ const history = task.clarifications ?? [];
1719
+ const question = history.at(-1);
1720
+ if (task.status !== "needs_input" || question?.id !== input.requestId || question.answer !== undefined) {
1721
+ return yield* new TaskInputError({
1722
+ status: 409,
1723
+ message: "This question is no longer awaiting an answer; check task status"
1724
+ });
1725
+ }
1726
+ const timestamp = now();
1727
+ const answered = TaskClarification.make({
1728
+ ...question,
1729
+ answer: { text: input.answer, at: timestamp }
1730
+ });
1731
+ const changed = yield* Effect14.try({
1732
+ try: () => db.transaction(() => {
1733
+ const result = db.query(`UPDATE tasks SET status = 'queued', run_id = ?, clarifications_json = ?, updated_at = ?,
1734
+ result = NULL, error = NULL, completed_at = NULL, progress_note = NULL, progress_at = NULL,
1735
+ started_at = CASE WHEN provider = 'grok-bot' THEN NULL ELSE started_at END,
1736
+ dispatched_at = NULL
1737
+ WHERE id = ? AND status = 'needs_input' AND clarifications_json = ?`).run(crypto.randomUUID(), JSON.stringify([...history.slice(0, -1), answered]), timestamp, taskId, JSON.stringify(history));
1738
+ if (result.changes === 1) {
1739
+ recordTaskTraceEvent(taskId, "input_answered", "Requester answered the worker's question", timestamp);
1740
+ recordTaskTraceEvent(taskId, "queued", "Task queued to resume with the answer", timestamp);
1741
+ }
1742
+ return result.changes;
1743
+ })(),
1744
+ catch: operationError("RelayStore.answerTaskInput")
1745
+ });
1746
+ if (changed !== 1)
1747
+ return yield* new TaskInputError({
1748
+ status: 409,
1749
+ message: "Task changed while answering; check its status"
1750
+ });
1751
+ return yield* getTask(taskId);
1752
+ });
1753
+ const matchesTaskRun = (task, runId) => task.runId === undefined || task.runId === runId || runId === undefined && (task.clarifications?.length ?? 0) === 0;
1754
+ const terminal = Effect14.fn("RelayStore.terminal")(function* (taskId, deviceId, status, result, error, providerSessionId, attachments2, runId) {
1375
1755
  const current = yield* requireTarget(taskId, deviceId);
1376
- if (["completed", "failed", "cancelled"].includes(current.status)) {
1756
+ if (!matchesTaskRun(current, runId) || current.status === "cancelling" && status !== "cancelled" || ["completed", "failed", "cancelled"].includes(current.status)) {
1377
1757
  return current;
1378
1758
  }
1759
+ if (taskDeadlinePassed(current) && status !== "cancelled")
1760
+ return yield* expireTask(current);
1761
+ const deadlineExpired = taskDeadlinePassed(current) && (current.status !== "cancelling" || current.error === taskDeadlineError);
1762
+ const finalStatus = status === "cancelled" && deadlineExpired ? "failed" : status;
1763
+ const finalError = finalStatus === "failed" && deadlineExpired ? taskDeadlineError : error;
1379
1764
  const timestamp = now();
1380
1765
  yield* Effect14.try({
1381
1766
  try: () => db.transaction(() => {
1767
+ const saveProviderSession = () => {
1768
+ if (current.provider === "grok-bot" || providerSessionId === undefined)
1769
+ return;
1770
+ db.query(`INSERT INTO provider_sessions (thread_id, device_id, provider, session_id, updated_at)
1771
+ VALUES (?, ?, ?, ?, ?)
1772
+ ON CONFLICT(thread_id, device_id, provider) DO UPDATE SET
1773
+ session_id = excluded.session_id, updated_at = excluded.updated_at`).run(current.threadId, deviceId, current.provider, providerSessionId, timestamp);
1774
+ };
1775
+ if (current.status === "needs_input" && status !== "cancelled") {
1776
+ if (current.provider === "grok-bot" || providerSessionId === undefined)
1777
+ return;
1778
+ const updated2 = db.query(`UPDATE tasks SET provider_session_id = ?, updated_at = ?
1779
+ WHERE id = ? AND status = 'needs_input' AND run_id IS ?`).run(providerSessionId, timestamp, taskId, current.runId ?? null);
1780
+ if (updated2.changes === 1)
1781
+ saveProviderSession();
1782
+ return;
1783
+ }
1784
+ if (status !== "cancelled" && current.clarifications?.at(-1)?.answer === undefined && (current.clarifications?.length ?? 0) > 0) {
1785
+ const changed = db.query(`UPDATE tasks SET status = 'needs_input', provider_session_id = ?, updated_at = ?,
1786
+ progress_note = NULL, progress_at = NULL
1787
+ WHERE id = ? AND status = ? AND run_id IS ?`).run(current.provider === "grok-bot" ? null : providerSessionId ?? current.providerSessionId ?? null, timestamp, taskId, current.status, current.runId ?? null);
1788
+ if (changed.changes === 1) {
1789
+ saveProviderSession();
1790
+ recordTaskTraceEvent(taskId, "needs_input", "Worker paused for clarification", timestamp);
1791
+ }
1792
+ return;
1793
+ }
1382
1794
  const updated = db.query(`UPDATE tasks SET status = ?, result = ?, error = ?, provider_session_id = ?,
1383
- completed_at = ?, updated_at = ? WHERE id = ?
1384
- 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);
1795
+ completed_at = ?, updated_at = ?, progress_note = NULL, progress_at = NULL WHERE id = ?
1796
+ AND status NOT IN ('completed', 'failed', 'cancelled')
1797
+ AND (status <> 'needs_input' OR ? = 'cancelled')
1798
+ AND (status <> 'cancelling' OR ? = 'cancelled') AND run_id IS ?`).run(finalStatus, result ?? null, finalError ?? null, current.provider === "grok-bot" ? null : providerSessionId ?? current.providerSessionId ?? null, timestamp, timestamp, taskId, status, status, current.runId ?? null);
1385
1799
  if (updated.changes !== 1) {
1386
1800
  return;
1387
1801
  }
1388
1802
  db.query("DELETE FROM task_attachments WHERE task_id = ? AND direction = 'output'").run(taskId);
1389
- if (status === "completed") {
1803
+ if (finalStatus === "completed") {
1390
1804
  if ((attachments2?.length ?? 0) > maxTaskAttachments) {
1391
1805
  throw new Error(`A task supports at most ${maxTaskAttachments} output files`);
1392
1806
  }
@@ -1398,20 +1812,15 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1398
1812
  db.query("INSERT INTO task_attachments (task_id, name, direction, data) VALUES (?, ?, 'output', ?)").run(taskId, attachment.name, data);
1399
1813
  }
1400
1814
  }
1401
- recordTaskTraceEvent(taskId, status, status === "completed" ? "Target device completed the task" : status === "failed" ? "Target device reported a task failure" : "Target device acknowledged cancellation", timestamp);
1402
- if (current.provider !== "grok-bot" && providerSessionId !== undefined) {
1403
- db.query(`INSERT INTO provider_sessions (thread_id, device_id, provider, session_id, updated_at)
1404
- VALUES (?, ?, ?, ?, ?)
1405
- ON CONFLICT(thread_id, device_id, provider) DO UPDATE SET
1406
- session_id = excluded.session_id, updated_at = excluded.updated_at`).run(current.threadId, deviceId, current.provider, providerSessionId, timestamp);
1407
- }
1408
- const content = result ?? error;
1815
+ recordTaskTraceEvent(taskId, finalStatus, finalStatus === "completed" ? "Target device completed the task" : finalStatus === "failed" ? "Target device reported a task failure" : "Target device acknowledged cancellation", timestamp);
1816
+ saveProviderSession();
1817
+ const content = result ?? finalError;
1409
1818
  if (content !== undefined) {
1410
- const role = status === "completed" ? "agent" : "system";
1819
+ const role = finalStatus === "completed" ? "agent" : "system";
1411
1820
  db.query(`INSERT INTO messages (
1412
1821
  id, thread_id, role, kind, author_id, author_name, content, created_at,
1413
1822
  task_id, device_id
1414
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(makeMessageId(), current.threadId, role, status === "failed" ? "error" : "chat", deviceId, status === "completed" ? providerName(current.provider) : "Cohall", content, timestamp, taskId, deviceId);
1823
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(makeMessageId(), current.threadId, role, finalStatus === "failed" ? "error" : "chat", deviceId, finalStatus === "completed" ? providerName(current.provider) : "Cohall", content, timestamp, taskId, deviceId);
1415
1824
  db.query("UPDATE threads SET updated_at = ? WHERE id = ?").run(timestamp, current.threadId);
1416
1825
  }
1417
1826
  pruneTerminalHistory(taskId);
@@ -1420,16 +1829,19 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1420
1829
  });
1421
1830
  return yield* getTask(taskId);
1422
1831
  });
1832
+ const pauseTaskForInput = Effect14.fn("RelayStore.pauseTaskForInput")(function* (taskId, deviceId, input) {
1833
+ const task = yield* requireTarget(taskId, deviceId);
1834
+ if (task.runId !== input.runId || task.status !== "queued" && task.status !== "assigned" && task.status !== "running")
1835
+ return task;
1836
+ return yield* requestTaskInput(taskId, deviceId, input, true).pipe(Effect14.andThen(terminal(taskId, deviceId, "completed", "", undefined, undefined, undefined, input.runId)), Effect14.catchTag("RelayStore.TaskInputError", (cause) => terminal(taskId, deviceId, "failed", undefined, cause.message, undefined, undefined, input.runId)));
1837
+ });
1423
1838
  const requestCancellation = Effect14.fn("RelayStore.requestCancellation")(function* (taskId) {
1424
- const current = yield* getTask(taskId);
1839
+ const current = yield* getTask(taskId).pipe(Effect14.flatMap(expireTask));
1425
1840
  if (["completed", "failed", "cancelled"].includes(current.status)) {
1426
1841
  return current;
1427
1842
  }
1428
- const dispatched = current.provider === "grok-bot" && (yield* Effect14.try({
1429
- try: () => db.query("SELECT 1 FROM tasks WHERE id = ? AND dispatched_at IS NOT NULL").get(taskId) !== null,
1430
- catch: operationError("RelayStore.requestCancellation")
1431
- }));
1432
- if (current.provider === "grok-bot" && (current.status !== "queued" || current.startedAt !== undefined || dispatched)) {
1843
+ const dispatched = yield* hasDispatchEvidence(taskId);
1844
+ if (current.provider === "grok-bot" && current.status !== "needs_input" && (current.status !== "queued" || current.startedAt !== undefined || dispatched)) {
1433
1845
  return yield* new PersistenceError({
1434
1846
  operation: "RelayStore.requestCancellation",
1435
1847
  message: "Stop this bot in Grok Bot; its gateway cannot cancel an individual Cohall request"
@@ -1439,7 +1851,8 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1439
1851
  return current;
1440
1852
  }
1441
1853
  const timestamp = now();
1442
- 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"], {
1854
+ const neverDispatched = current.status === "queued" && !dispatched && (current.provider === "grok-bot" || current.runId === undefined && current.startedAt === undefined);
1855
+ return neverDispatched || current.status === "needs_input" && current.provider === "grok-bot" ? yield* transition(taskId, ["queued", "needs_input"], { status: "cancelled", completedAt: timestamp }, { kind: "cancelled", detail: "Client cancelled the task before dispatch" }) : yield* transition(taskId, ["queued", "assigned", "running", "needs_input"], {
1443
1856
  status: "cancelling"
1444
1857
  }, { kind: "cancelling", detail: "Client requested task cancellation" });
1445
1858
  });
@@ -1447,12 +1860,14 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1447
1860
  const timestamp = now();
1448
1861
  yield* Effect14.try({
1449
1862
  try: () => db.transaction(() => {
1450
- const interrupted = db.query(`SELECT id FROM tasks WHERE target_device_id = ?
1863
+ const interrupted = db.query(`SELECT id, ${interruptedTaskStatus} AS status FROM tasks WHERE target_device_id = ?
1451
1864
  AND status IN ('assigned', 'running')`).all(deviceId);
1452
- db.query(`UPDATE tasks SET status = 'queued', updated_at = ?
1865
+ db.query(`UPDATE tasks SET status = ${interruptedTaskStatus}, updated_at = ?, progress_note = NULL, progress_at = NULL,
1866
+ dispatched_at = CASE WHEN provider = 'grok-bot' THEN dispatched_at
1867
+ ELSE COALESCE(dispatched_at, started_at, updated_at) END
1453
1868
  WHERE target_device_id = ? AND status IN ('assigned', 'running')`).run(timestamp, deviceId);
1454
1869
  for (const task of interrupted) {
1455
- recordTaskTraceEvent(TaskId.make(task.id), "requeued", "Target device disconnected before task completion", timestamp);
1870
+ recordTaskTraceEvent(TaskId.make(task.id), task.status === "needs_input" ? "needs_input" : "requeued", "Target device disconnected before task completion", timestamp);
1456
1871
  }
1457
1872
  })(),
1458
1873
  catch: operationError("RelayStore.requeueTasksFor")
@@ -1572,13 +1987,20 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1572
1987
  return Service2.of({
1573
1988
  recover,
1574
1989
  listDevices,
1990
+ listDevicePage,
1575
1991
  usage,
1576
1992
  forgetDevice,
1577
1993
  upsertDevice,
1578
1994
  heartbeat: (deviceId, status, bots) => updateDeviceStatus(deviceId, status, bots),
1579
1995
  markDeviceOffline: (deviceId) => updateDeviceStatus(deviceId, "offline"),
1580
1996
  createDelegation: createDelegation2,
1997
+ expireTasks,
1998
+ findDelegation,
1581
1999
  getTask,
2000
+ reportTaskProgress,
2001
+ requestTaskInput,
2002
+ pauseTaskForInput,
2003
+ answerTaskInput,
1582
2004
  listAttachments,
1583
2005
  readAttachment,
1584
2006
  inboxFor,
@@ -1594,10 +2016,13 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1594
2016
  catch: operationError("RelayStore.markTaskDispatched")
1595
2017
  }),
1596
2018
  rollbackAssignment: (taskId) => transition(taskId, ["assigned"], { status: "queued" }, { kind: "requeued", detail: "Target device was unavailable during dispatch" }),
1597
- acceptTask: (taskId, deviceId) => requireTarget(taskId, deviceId).pipe(Effect14.flatMap(() => transition(taskId, ["assigned"], { status: "running", startedAt: now() }, { kind: "running", detail: "Target device accepted the task and started the provider" }))),
1598
- finishTask: (taskId, deviceId, result, providerSessionId, attachments2) => terminal(taskId, deviceId, "completed", result, undefined, providerSessionId, attachments2),
1599
- failTask: (taskId, deviceId, error) => terminal(taskId, deviceId, "failed", undefined, error),
1600
- acknowledgeCancellation: (taskId, deviceId) => terminal(taskId, deviceId, "cancelled"),
2019
+ acceptTask: (taskId, deviceId, runId) => requireTarget(taskId, deviceId).pipe(Effect14.flatMap((task) => matchesTaskRun(task, runId) ? transition(taskId, ["assigned"], { status: "running", startedAt: task.startedAt ?? now() }, {
2020
+ kind: "running",
2021
+ detail: task.startedAt === undefined ? "Target device accepted the task and started the provider" : "Target device accepted the task again"
2022
+ }) : Effect14.succeed(task))),
2023
+ finishTask: (taskId, deviceId, result, providerSessionId, attachments2, runId) => terminal(taskId, deviceId, "completed", result, undefined, providerSessionId, attachments2, runId),
2024
+ failTask: (taskId, deviceId, error, runId) => terminal(taskId, deviceId, "failed", undefined, error, undefined, undefined, runId),
2025
+ acknowledgeCancellation: (taskId, deviceId, runId) => terminal(taskId, deviceId, "cancelled", undefined, undefined, undefined, undefined, runId),
1601
2026
  requestCancellation,
1602
2027
  requeueTasksFor,
1603
2028
  createUpgradeOperations,
@@ -1650,10 +2075,18 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1650
2075
  target_device_id TEXT NOT NULL REFERENCES devices(id), parent_task_id TEXT,
1651
2076
  workspace TEXT, provider_session_id TEXT, result TEXT, error TEXT,
1652
2077
  created_at TEXT NOT NULL, updated_at TEXT NOT NULL, started_at TEXT, completed_at TEXT,
1653
- dispatched_at TEXT
2078
+ dispatched_at TEXT, progress_note TEXT, progress_at TEXT,
2079
+ run_id TEXT, clarifications_json TEXT, expires_at TEXT
1654
2080
  );
1655
2081
  CREATE INDEX IF NOT EXISTS tasks_thread_created ON tasks(thread_id, created_at);
1656
2082
  CREATE INDEX IF NOT EXISTS tasks_target_status ON tasks(target_device_id, status);
2083
+ CREATE TABLE IF NOT EXISTS task_requests (
2084
+ requester_id TEXT NOT NULL, request_id TEXT NOT NULL, input_hash TEXT NOT NULL,
2085
+ task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
2086
+ PRIMARY KEY(requester_id, request_id)
2087
+ );
2088
+ CREATE INDEX IF NOT EXISTS task_requests_retained_task
2089
+ ON task_requests(task_id) WHERE task_id IS NOT NULL;
1657
2090
  CREATE TABLE IF NOT EXISTS task_attachments (
1658
2091
  task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1659
2092
  name TEXT NOT NULL, direction TEXT NOT NULL CHECK(direction IN ('input', 'output')),
@@ -1701,6 +2134,19 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1701
2134
  db.exec("ALTER TABLE devices ADD COLUMN bots_json TEXT");
1702
2135
  }
1703
2136
  const taskColumns = db.query("PRAGMA table_info(tasks)").all();
2137
+ if (!taskColumns.some((column) => column.name === "expires_at"))
2138
+ db.exec("ALTER TABLE tasks ADD COLUMN expires_at TEXT");
2139
+ db.exec("CREATE INDEX IF NOT EXISTS tasks_pending_deadline ON tasks(expires_at) WHERE status IN ('queued', 'assigned', 'running', 'needs_input')");
2140
+ if (!taskColumns.some((column) => column.name === "run_id"))
2141
+ db.exec("ALTER TABLE tasks ADD COLUMN run_id TEXT");
2142
+ if (!taskColumns.some((column) => column.name === "clarifications_json"))
2143
+ db.exec("ALTER TABLE tasks ADD COLUMN clarifications_json TEXT");
2144
+ if (!taskColumns.some((column) => column.name === "progress_note")) {
2145
+ db.exec("ALTER TABLE tasks ADD COLUMN progress_note TEXT");
2146
+ }
2147
+ if (!taskColumns.some((column) => column.name === "progress_at")) {
2148
+ db.exec("ALTER TABLE tasks ADD COLUMN progress_at TEXT");
2149
+ }
1704
2150
  if (!taskColumns.some((column) => column.name === "bot_id")) {
1705
2151
  db.exec("ALTER TABLE tasks ADD COLUMN bot_id TEXT");
1706
2152
  }
@@ -1719,6 +2165,13 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1719
2165
  WHERE provider = 'grok-bot' AND status NOT IN ('completed', 'failed', 'cancelled')`);
1720
2166
  })();
1721
2167
  }
2168
+ db.exec(`UPDATE tasks SET dispatched_at = COALESCE(started_at, updated_at)
2169
+ WHERE provider <> 'grok-bot' AND dispatched_at IS NULL AND run_id IS NULL
2170
+ AND status NOT IN ('completed', 'failed', 'cancelled')
2171
+ AND (status IN ('assigned', 'running') OR started_at IS NOT NULL OR EXISTS (
2172
+ SELECT 1 FROM task_trace_events
2173
+ WHERE task_id = tasks.id AND kind IN ('assigned', 'running')
2174
+ ))`);
1722
2175
  },
1723
2176
  catch: operationError("RelayStore.migrate")
1724
2177
  }), layer2 = (path, retainedTerminalTasks = 1000) => Layer2.effect(Service2, Effect14.acquireRelease(Effect14.try({
@@ -1729,7 +2182,19 @@ var init_store = __esm(() => {
1729
2182
  init_src();
1730
2183
  init_database();
1731
2184
  init_store();
1732
- PersistenceError = class PersistenceError extends Schema15.TaggedErrorClass()("RelayStore.PersistenceError", { operation: Schema15.String, message: Schema15.String }) {
2185
+ PersistenceError = class PersistenceError extends Schema15.TaggedErrorClass()("RelayStore.PersistenceError", {
2186
+ operation: Schema15.String,
2187
+ message: Schema15.String,
2188
+ missingRecord: Schema15.optionalKey(Schema15.Literals(["task", "operation"]))
2189
+ }) {
2190
+ };
2191
+ TaskProgressError = class TaskProgressError extends Schema15.TaggedErrorClass()("RelayStore.TaskProgressError", { status: Schema15.Literals([403, 409]), message: Schema15.String }) {
2192
+ };
2193
+ TaskInputError = class TaskInputError extends Schema15.TaggedErrorClass()("RelayStore.TaskInputError", { status: Schema15.Literals([403, 409]), message: Schema15.String }) {
2194
+ };
2195
+ TaskDeadlineError = class TaskDeadlineError extends Schema15.TaggedErrorClass()("RelayStore.TaskDeadlineError", { status: Schema15.Literals([400, 409]), message: Schema15.String }) {
2196
+ };
2197
+ TaskRequestError = class TaskRequestError extends Schema15.TaggedErrorClass()("RelayStore.TaskRequestError", { status: Schema15.Literals([409, 410]), message: Schema15.String }) {
1733
2198
  };
1734
2199
  Service2 = class Service2 extends Context2.Service()("@cohall/RelayStore") {
1735
2200
  };
@@ -1745,7 +2210,7 @@ __export(exports_main, {
1745
2210
  canReadTaskAttachments: () => canReadTaskAttachments,
1746
2211
  canDispatchTaskToDevice: () => canDispatchTaskToDevice
1747
2212
  });
1748
- import { Effect as Effect15, ManagedRuntime, Schema as Schema16 } from "effect";
2213
+ import { Effect as Effect15, ManagedRuntime, Schema as Schema16, Schedule as Schedule4 } from "effect";
1749
2214
  import { createHash as createHash3, timingSafeEqual } from "node:crypto";
1750
2215
  import { access as access6, chmod as chmod8 } from "node:fs/promises";
1751
2216
  import { createServer } from "node:http";
@@ -1776,7 +2241,7 @@ var RequestError, relayListenOptions = (configuration, environment = process.env
1776
2241
  if (request.method === "GET" || request.method === "HEAD") {
1777
2242
  return Promise.resolve(undefined);
1778
2243
  }
1779
- return new Promise((resolve5, reject) => {
2244
+ return new Promise((resolve6, reject) => {
1780
2245
  const chunks = [];
1781
2246
  let size = 0;
1782
2247
  const cleanup = () => {
@@ -1795,7 +2260,7 @@ var RequestError, relayListenOptions = (configuration, environment = process.env
1795
2260
  };
1796
2261
  const onEnd = () => {
1797
2262
  cleanup();
1798
- resolve5(Buffer.concat(chunks, size));
2263
+ resolve6(Buffer.concat(chunks, size));
1799
2264
  };
1800
2265
  const onError = (cause) => {
1801
2266
  cleanup();
@@ -1882,6 +2347,11 @@ Content-Length: 0\r
1882
2347
  const resolvedInput = parent === undefined ? input : { ...input, threadId: parent.threadId };
1883
2348
  const devices = yield* store.listDevices().pipe(Effect15.mapError((cause) => new RequestError({ status: 500, message: cause.message })));
1884
2349
  const provider = input.provider ?? (input.botId === undefined ? "codex" : "grok-bot");
2350
+ if (input.expiresAt !== undefined && provider === "grok-bot")
2351
+ return yield* new RequestError({
2352
+ status: 400,
2353
+ message: "Task deadlines require a coding provider"
2354
+ });
1885
2355
  const hasAttachments = (input.attachments?.length ?? 0) > 0;
1886
2356
  if (hasAttachments && provider === "grok-bot") {
1887
2357
  return yield* new RequestError({
@@ -1893,7 +2363,7 @@ Content-Length: 0\r
1893
2363
  provider,
1894
2364
  ...input.botId === undefined ? {} : { botId: input.botId }
1895
2365
  });
1896
- const blocked = new Set(ancestors.filter((task) => !isTerminalTask(task) && taskSlot(task) === slot).map((task) => task.targetDeviceId));
2366
+ const blocked = new Set(ancestors.filter((task) => !isTerminalTask(task) && task.status !== "needs_input" && taskSlot(task) === slot).map((task) => task.targetDeviceId));
1897
2367
  if (input.targetDeviceId !== undefined) {
1898
2368
  const target = devices.find((device) => device.id === input.targetDeviceId);
1899
2369
  if (target === undefined) {
@@ -1908,6 +2378,11 @@ Content-Length: 0\r
1908
2378
  message: `${target.name} does not advertise the ${provider} provider`
1909
2379
  });
1910
2380
  }
2381
+ if (input.expiresAt !== undefined && !target.capabilities.some((capability) => capability.id === "task-deadlines"))
2382
+ return yield* new RequestError({
2383
+ status: 409,
2384
+ message: `${target.name} does not support task deadlines; upgrade its Cohall worker`
2385
+ });
1911
2386
  if (hasAttachments && !target.capabilities.some((capability) => capability.id === "task-attachments")) {
1912
2387
  return yield* new RequestError({
1913
2388
  status: 409,
@@ -1928,7 +2403,7 @@ Content-Length: 0\r
1928
2403
  }
1929
2404
  return { input: resolvedInput, targetDeviceId: target.id };
1930
2405
  }
1931
- const providerDevices = devices.filter((device) => device.providers.includes(provider) && (input.botId === undefined || device.bots?.some((bot) => bot.id === input.botId)));
2406
+ const providerDevices = devices.filter((device) => device.providers.includes(provider) && (input.expiresAt === undefined || device.capabilities.some((capability) => capability.id === "task-deadlines")) && (input.botId === undefined || device.bots?.some((bot) => bot.id === input.botId)));
1932
2407
  const candidates = hasAttachments ? providerDevices.filter((device) => device.capabilities.some((capability) => capability.id === "task-attachments")) : providerDevices;
1933
2408
  if (input.botId !== undefined && candidates.length > 1) {
1934
2409
  return yield* new RequestError({
@@ -1940,11 +2415,11 @@ Content-Length: 0\r
1940
2415
  if (selected === undefined) {
1941
2416
  return yield* new RequestError({
1942
2417
  status: 409,
1943
- message: candidates.length > 0 ? "No available delegation target avoids an unfinished parent's worker slot; select another device or bot" : hasAttachments && providerDevices.length > 0 ? `No Cohall device advertising the ${provider} provider supports file attachments; upgrade a Cohall worker` : input.botId === undefined ? `No Cohall device advertises the ${provider} provider` : `No Cohall device advertises bot ${input.botId}; refresh the bot list`
2418
+ message: candidates.length > 0 ? "No available delegation target avoids an unfinished parent's worker slot; select another device or bot" : hasAttachments && providerDevices.length > 0 ? `No Cohall device advertising the ${provider} provider supports file attachments; upgrade a Cohall worker` : input.expiresAt !== undefined ? `No Cohall device advertising the ${provider} provider supports task deadlines; upgrade a Cohall worker` : input.botId === undefined ? `No Cohall device advertises the ${provider} provider` : `No Cohall device advertises bot ${input.botId}; refresh the bot list`
1944
2419
  });
1945
2420
  }
1946
2421
  return { input: resolvedInput, targetDeviceId: selected.id };
1947
- }), canReadTaskAttachments = (principal, task) => principal === "owner" || principal.role === "client" || principal.deviceId === task.targetDeviceId, canDispatchTaskToDevice = (task, device) => (task.inputAttachmentNames?.length ?? 0) === 0 || device?.capabilities.some((capability) => capability.id === "task-attachments") === true, runRelay = async () => {
2422
+ }), canReadTaskAttachments = (principal, task) => principal === "owner" || principal.role === "client" || principal.deviceId === task.targetDeviceId, requiresClarificationSupport = (task) => (task.clarifications?.length ?? 0) > 0 || task.provider === "grok-bot" && task.runId !== undefined, canDispatchTaskToDevice = (task, device) => (task.expiresAt === undefined || device?.capabilities.some((capability) => capability.id === "task-deadlines") === true) && ((task.inputAttachmentNames?.length ?? 0) === 0 || device?.capabilities.some((capability) => capability.id === "task-attachments") === true) && (!requiresClarificationSupport(task) || device?.capabilities.some((capability) => capability.id === "task-clarification") === true), runRelay = async () => {
1948
2423
  const configuration = await Effect15.runPromise(loadEnvironmentConfiguration);
1949
2424
  process.umask(63);
1950
2425
  const runtime = ManagedRuntime.make(exports_store.layer(configuration.databasePath, configuration.historyTaskLimit));
@@ -1979,20 +2454,39 @@ Content-Length: 0\r
1979
2454
  const token = authorization.slice("Bearer ".length);
1980
2455
  return await authenticate(token, "client") ?? (allowDevice ? authenticate(token, "device") : undefined);
1981
2456
  };
2457
+ const sendCancellation = (task) => {
2458
+ hub.sendToDevice(task.targetDeviceId, SocketEvent.make({
2459
+ _tag: "CancelTask",
2460
+ taskId: task.id,
2461
+ ...task.runId === undefined ? {} : { runId: task.runId }
2462
+ }));
2463
+ };
1982
2464
  const dispatch = async (task) => {
1983
2465
  const store = await run2(exports_store.Service);
1984
- if ((task.inputAttachmentNames?.length ?? 0) > 0) {
2466
+ if (task.expiresAt !== undefined || (task.inputAttachmentNames?.length ?? 0) > 0 || requiresClarificationSupport(task)) {
1985
2467
  const devices = await Effect15.runPromise(store.listDevices());
1986
2468
  const target = devices.find((device) => device.id === task.targetDeviceId);
2469
+ if (task.expiresAt !== undefined && !target?.capabilities.some((capability) => capability.id === "task-deadlines"))
2470
+ return task;
2471
+ if (requiresClarificationSupport(task) && !target?.capabilities.some((capability) => capability.id === "task-clarification"))
2472
+ return task;
1987
2473
  if (!canDispatchTaskToDevice(task, target)) {
1988
- return Effect15.runPromise(store.failTask(task.id, task.targetDeviceId, "Target worker no longer supports task file attachments. Upgrade the worker and retry."));
2474
+ return Effect15.runPromise(store.failTask(task.id, task.targetDeviceId, "Target worker no longer supports task file attachments. Upgrade the worker and retry.", task.runId));
1989
2475
  }
1990
2476
  }
2477
+ if (!hub.hasDevice(task.targetDeviceId))
2478
+ return task;
1991
2479
  const assigned = await Effect15.runPromise(store.assignTask(task.id));
1992
2480
  if (assigned.status !== "assigned") {
2481
+ if (assigned.status === "cancelling")
2482
+ sendCancellation(assigned);
1993
2483
  return assigned;
1994
2484
  }
1995
- if (hub.sendToDevice(assigned.targetDeviceId, SocketEvent.make({ _tag: "TaskAssigned", task: assigned }), assigned.provider === "grok-bot" ? () => Effect15.runSync(store.markTaskDispatched(assigned.id)) : undefined)) {
2485
+ const assignment = SocketEvent.make({ _tag: "TaskAssigned", task: assigned });
2486
+ if (Buffer.byteLength(JSON.stringify(assignment)) > maxSocketPayloadBytes) {
2487
+ return Effect15.runPromise(store.failTask(assigned.id, assigned.targetDeviceId, "Task prompt, context, and clarification history exceed the 1 MiB transfer limit. Retry with a shorter prompt or context.", assigned.runId));
2488
+ }
2489
+ if (hub.sendToDevice(assigned.targetDeviceId, assignment, assigned.provider === "grok-bot" ? () => Effect15.runSync(store.markTaskDispatched(assigned.id)) : undefined)) {
1996
2490
  return assigned;
1997
2491
  }
1998
2492
  return Effect15.runPromise(store.rollbackAssignment(task.id));
@@ -2029,7 +2523,7 @@ Content-Length: 0\r
2029
2523
  }));
2030
2524
  for (const task of tasks) {
2031
2525
  if (task.status === "cancelling") {
2032
- hub.sendToDevice(deviceId, SocketEvent.make({ _tag: "CancelTask", taskId: task.id }));
2526
+ sendCancellation(task);
2033
2527
  } else {
2034
2528
  await dispatch(task);
2035
2529
  }
@@ -2089,7 +2583,9 @@ Content-Length: 0\r
2089
2583
  _tag: "Connected",
2090
2584
  serverVersion: version,
2091
2585
  connectedAt: now(),
2092
- taskAttachments: true
2586
+ taskAttachments: true,
2587
+ taskClarification: true,
2588
+ taskDeadlines: true
2093
2589
  })));
2094
2590
  return;
2095
2591
  }
@@ -2125,12 +2621,18 @@ Content-Length: 0\r
2125
2621
  sendError(socket, "device_hello_required", "Send DeviceHello before device events");
2126
2622
  return;
2127
2623
  }
2128
- const settle = (taskId) => {
2129
- socket.send(JSON.stringify(SocketEvent.make({ _tag: "TaskSettled", taskId })));
2624
+ const settle = (taskId, runId) => {
2625
+ socket.send(JSON.stringify(SocketEvent.make({
2626
+ _tag: "TaskSettled",
2627
+ taskId,
2628
+ ...runId === undefined ? {} : { runId }
2629
+ })));
2130
2630
  };
2131
2631
  const settleOperation = (operationId) => {
2132
2632
  socket.send(JSON.stringify(SocketEvent.make({ _tag: "OperationSettled", operationId })));
2133
2633
  };
2634
+ const taskTerminal = event._tag === "TaskFinished" || event._tag === "TaskInputRequested" || event._tag === "TaskFailed" || event._tag === "TaskCancelled";
2635
+ const operationTerminal = event._tag === "OperationFinished" || event._tag === "OperationFailed";
2134
2636
  const processed = await run2(Effect15.gen(function* () {
2135
2637
  const store = yield* exports_store.Service;
2136
2638
  switch (event._tag) {
@@ -2140,16 +2642,23 @@ Content-Length: 0\r
2140
2642
  }
2141
2643
  return;
2142
2644
  case "TaskAccepted":
2143
- yield* store.acceptTask(event.taskId, deviceId);
2645
+ yield* store.acceptTask(event.taskId, deviceId, event.runId);
2144
2646
  return;
2145
2647
  case "TaskFinished":
2146
- yield* store.finishTask(event.taskId, deviceId, event.result, event.providerSessionId, event.attachments);
2648
+ yield* store.finishTask(event.taskId, deviceId, event.result, event.providerSessionId, event.attachments, event.runId);
2147
2649
  return;
2650
+ case "TaskInputRequested": {
2651
+ yield* store.pauseTaskForInput(event.taskId, deviceId, {
2652
+ runId: event.runId,
2653
+ question: event.question
2654
+ });
2655
+ return;
2656
+ }
2148
2657
  case "TaskFailed":
2149
- yield* store.failTask(event.taskId, deviceId, event.error);
2658
+ yield* store.failTask(event.taskId, deviceId, event.error, event.runId);
2150
2659
  return;
2151
2660
  case "TaskCancelled":
2152
- yield* store.acknowledgeCancellation(event.taskId, deviceId);
2661
+ yield* store.acknowledgeCancellation(event.taskId, deviceId, event.runId);
2153
2662
  return;
2154
2663
  case "OperationAccepted":
2155
2664
  yield* store.acceptOperation(event.operationId, deviceId);
@@ -2170,21 +2679,31 @@ Content-Length: 0\r
2170
2679
  return;
2171
2680
  }
2172
2681
  })).then(() => true).catch((cause) => {
2682
+ if (cause instanceof exports_store.PersistenceError && (taskTerminal && cause.missingRecord === "task" || operationTerminal && cause.missingRecord === "operation"))
2683
+ return true;
2173
2684
  sendError(socket, "event_failed", cause instanceof Error ? cause.message : String(cause));
2174
2685
  return false;
2175
2686
  });
2176
- if (processed && (event._tag === "TaskFinished" || event._tag === "TaskFailed" || event._tag === "TaskCancelled")) {
2177
- settle(event.taskId);
2687
+ if (processed && taskTerminal) {
2688
+ settle(event.taskId, event.runId);
2178
2689
  await dispatchPending(deviceId);
2179
2690
  }
2180
- if (processed && (event._tag === "OperationFinished" || event._tag === "OperationFailed")) {
2691
+ if (processed && operationTerminal) {
2181
2692
  settleOperation(event.operationId);
2182
2693
  await dispatchPending(deviceId);
2183
2694
  }
2184
2695
  };
2185
2696
  const api = async (request, url) => {
2186
2697
  if (url.pathname === "/api/health" && request.method === "GET") {
2187
- return json({ ok: true, version, taskAttachments: true });
2698
+ return json({
2699
+ ok: true,
2700
+ version,
2701
+ taskAttachments: true,
2702
+ taskProgress: true,
2703
+ taskClarification: true,
2704
+ taskDeadlines: true,
2705
+ taskIdempotency: true
2706
+ });
2188
2707
  }
2189
2708
  if (url.pathname === "/api/auth/pair" && request.method === "POST") {
2190
2709
  return run2(Effect15.gen(function* () {
@@ -2194,7 +2713,9 @@ Content-Length: 0\r
2194
2713
  })).then((value) => json(value, 201)).catch(() => json({ error: "Pairing credential is invalid, expired, or already used" }, 401));
2195
2714
  }
2196
2715
  const attachmentRoute = request.method === "GET" && /^\/api\/tasks\/[^/]+\/attachments(?:\/[^/]+)?$/.test(url.pathname);
2197
- const principal = await principalFor(request, attachmentRoute);
2716
+ const progressRoute = request.method === "POST" && /^\/api\/tasks\/[^/]+\/progress$/.test(url.pathname);
2717
+ const inputRoute = request.method === "POST" && /^\/api\/tasks\/[^/]+\/input$/.test(url.pathname);
2718
+ const principal = await principalFor(request, attachmentRoute || progressRoute || inputRoute);
2198
2719
  if (principal === undefined) {
2199
2720
  return json({ error: "Unauthorized" }, 401);
2200
2721
  }
@@ -2219,6 +2740,10 @@ Content-Length: 0\r
2219
2740
  hub.closeSession(id);
2220
2741
  return json(session);
2221
2742
  }
2743
+ if (url.pathname === "/api/devices/page" && request.method === "GET") {
2744
+ const after = url.searchParams.get("after");
2745
+ return json(yield* store.listDevicePage(after === null ? undefined : yield* pathId(DeviceId, after)));
2746
+ }
2222
2747
  if (url.pathname === "/api/devices" && request.method === "GET") {
2223
2748
  return json(yield* store.listDevices());
2224
2749
  }
@@ -2256,9 +2781,13 @@ Content-Length: 0\r
2256
2781
  return json(forgotten);
2257
2782
  }
2258
2783
  if (url.pathname === "/api/tasks" && request.method === "POST") {
2259
- const { input, targetDeviceId: target } = yield* resolveDelegation(yield* body(request, decodeCreateTaskInput), sourceDeviceId);
2784
+ const requestedInput = yield* body(request, decodeCreateTaskInput);
2785
+ const existing = yield* store.findDelegation(requestedInput, principal);
2786
+ if (existing !== undefined)
2787
+ return json(existing);
2788
+ const { input, targetDeviceId: target } = yield* resolveDelegation(requestedInput, sourceDeviceId);
2260
2789
  const providerSessionId = input.threadId === undefined || input.provider === "grok-bot" ? undefined : yield* store.sessionFor(input.threadId, target, input.provider ?? "codex");
2261
- const task2 = yield* store.createDelegation(input, target, principal, providerSessionId);
2790
+ const task2 = yield* store.createDelegation(input, target, principal, providerSessionId, requestedInput);
2262
2791
  return json(yield* Effect15.tryPromise(() => dispatch(task2)), 201);
2263
2792
  }
2264
2793
  if (url.pathname === "/api/inbox" && request.method === "GET") {
@@ -2313,12 +2842,50 @@ Content-Length: 0\r
2313
2842
  if (request.method === "GET" && trace?.[1] !== undefined) {
2314
2843
  return json(yield* store.traceTask(yield* pathId(TaskId, trace[1])));
2315
2844
  }
2845
+ const progress = url.pathname.match(/^\/api\/tasks\/([^/]+)\/progress$/);
2846
+ if (request.method === "POST" && progress?.[1] !== undefined) {
2847
+ const id = yield* pathId(TaskId, progress[1]);
2848
+ const input = yield* body(request, decodeTaskProgressInput);
2849
+ const task2 = yield* store.getTask(id);
2850
+ const deviceId = principal === "owner" ? task2.targetDeviceId : principal.deviceId;
2851
+ if (deviceId === undefined) {
2852
+ return yield* new RequestError({
2853
+ status: 403,
2854
+ message: "Only the target device can report progress"
2855
+ });
2856
+ }
2857
+ return json(yield* store.reportTaskProgress(id, deviceId, input.note));
2858
+ }
2316
2859
  const cancel2 = url.pathname.match(/^\/api\/tasks\/([^/]+)\/cancel$/);
2860
+ const inputRequest = url.pathname.match(/^\/api\/tasks\/([^/]+)\/input$/);
2861
+ if (request.method === "POST" && inputRequest?.[1] !== undefined) {
2862
+ const id = yield* pathId(TaskId, inputRequest[1]);
2863
+ const input = yield* body(request, decodeRequestTaskInput);
2864
+ const task2 = yield* store.getTask(id);
2865
+ const deviceId = principal === "owner" ? task2.targetDeviceId : principal.deviceId;
2866
+ if (deviceId === undefined)
2867
+ return yield* new RequestError({
2868
+ status: 403,
2869
+ message: "Only the target device can request input"
2870
+ });
2871
+ return json(yield* store.requestTaskInput(id, deviceId, input));
2872
+ }
2873
+ const answer = url.pathname.match(/^\/api\/tasks\/([^/]+)\/answer$/);
2874
+ if (request.method === "POST" && answer?.[1] !== undefined) {
2875
+ const task2 = yield* store.answerTaskInput(yield* pathId(TaskId, answer[1]), principal, yield* body(request, decodeAnswerTaskInput));
2876
+ return json(yield* Effect15.tryPromise({
2877
+ try: () => dispatch(task2),
2878
+ catch: (cause) => new RequestError({
2879
+ status: 500,
2880
+ message: cause instanceof Error ? cause.message : String(cause)
2881
+ })
2882
+ }));
2883
+ }
2317
2884
  if (request.method === "POST" && cancel2?.[1] !== undefined) {
2318
2885
  const id = yield* pathId(TaskId, cancel2[1]);
2319
2886
  const updated = yield* store.requestCancellation(id);
2320
2887
  if (updated.status === "cancelling") {
2321
- hub.sendToDevice(updated.targetDeviceId, SocketEvent.make({ _tag: "CancelTask", taskId: id }));
2888
+ sendCancellation(updated);
2322
2889
  }
2323
2890
  return json(updated);
2324
2891
  }
@@ -2327,7 +2894,7 @@ Content-Length: 0\r
2327
2894
  return json(yield* store.threadContext(yield* pathId(ThreadId, thread[1])));
2328
2895
  }
2329
2896
  return yield* new RequestError({ status: 404, message: "Route not found" });
2330
- }).pipe(Effect15.mapError((cause) => cause instanceof RequestError ? cause : new RequestError({
2897
+ }).pipe(Effect15.mapError((cause) => cause instanceof RequestError ? cause : cause._tag === "RelayStore.TaskProgressError" || cause._tag === "RelayStore.TaskInputError" || cause._tag === "RelayStore.TaskDeadlineError" || cause._tag === "RelayStore.TaskRequestError" ? new RequestError({ status: cause.status, message: cause.message }) : new RequestError({
2331
2898
  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
2899
  message: cause.message
2333
2900
  })));
@@ -2435,26 +3002,40 @@ Content-Length: 0\r
2435
3002
  websocketServer.emit("connection", websocket, request);
2436
3003
  });
2437
3004
  });
2438
- await new Promise((resolve5, reject) => {
3005
+ await new Promise((resolve6, reject) => {
2439
3006
  server.once("error", reject);
2440
3007
  server.listen(relayListenOptions(configuration), () => {
2441
3008
  server.off("error", reject);
2442
- resolve5();
3009
+ resolve6();
2443
3010
  });
2444
3011
  });
2445
3012
  const address = server.address();
2446
3013
  const listener = typeof address === "string" ? address : address?.family === "IPv6" ? `[${address.address}]:${address.port}` : `${address?.address ?? configuration.host}:${address?.port ?? configuration.port}`;
2447
3014
  console.log(`Cohall relay listening on http://${listener}`);
3015
+ const deadlineController = new AbortController;
3016
+ const enforceDeadlines = Effect15.gen(function* () {
3017
+ const store = yield* exports_store.Service;
3018
+ const overdue = yield* store.expireTasks();
3019
+ for (const task of overdue)
3020
+ if (task.status === "cancelling")
3021
+ sendCancellation(task);
3022
+ }).pipe(Effect15.catch((cause) => Effect15.logError(cause)), Effect15.repeat({ schedule: Schedule4.spaced("1 second") }));
3023
+ const deadlineWork = runtime.runPromise(enforceDeadlines, { signal: deadlineController.signal }).catch((cause) => {
3024
+ if (!deadlineController.signal.aborted)
3025
+ console.error(`Task deadline enforcement failed: ${cause instanceof Error ? cause.message : String(cause)}`);
3026
+ });
2448
3027
  let stopping = false;
2449
3028
  const shutdown = async () => {
2450
3029
  if (stopping) {
2451
3030
  return;
2452
3031
  }
2453
3032
  stopping = true;
3033
+ deadlineController.abort();
3034
+ await deadlineWork;
2454
3035
  for (const socket of websocketServer.clients) {
2455
3036
  socket.close(1001, "Relay shutting down");
2456
3037
  }
2457
- await new Promise((resolve5) => server.close(() => resolve5()));
3038
+ await new Promise((resolve6) => server.close(() => resolve6()));
2458
3039
  websocketServer.close();
2459
3040
  await runtime.dispose();
2460
3041
  };
@@ -2506,7 +3087,14 @@ class RelayDecodeError extends Schema2.TaggedErrorClass()("RelayClient.DecodeErr
2506
3087
  class Service extends Context.Service()("@cohall/RelayClient") {
2507
3088
  }
2508
3089
  var normalizeBaseUrl = (url) => url.replace(/\/+$/, "");
2509
- var jsonBody = async (response, operation) => {
3090
+ var compareDevices = (left, right) => {
3091
+ const fold = (name) => name.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
3092
+ const byName = Buffer.compare(Buffer.from(fold(left.name)), Buffer.from(fold(right.name)));
3093
+ return byName || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
3094
+ };
3095
+ var maxJsonResponseBytes = 2 * 1024 * 1024;
3096
+ var maxTraceResponseBytes = 4 * 1024 * 1024;
3097
+ var jsonBody = async (response, operation, maxBytes = maxJsonResponseBytes) => {
2510
3098
  if (response.body === null) {
2511
3099
  throw new RelayDecodeError({ operation, message: "Relay returned an empty response" });
2512
3100
  }
@@ -2519,9 +3107,12 @@ var jsonBody = async (response, operation) => {
2519
3107
  break;
2520
3108
  }
2521
3109
  size += chunk.value.byteLength;
2522
- if (size > 2 * 1024 * 1024) {
3110
+ if (size > maxBytes) {
2523
3111
  await reader.cancel();
2524
- throw new RelayDecodeError({ operation, message: "Relay response exceeded 2 MiB" });
3112
+ throw new RelayDecodeError({
3113
+ operation,
3114
+ message: `Relay response exceeded ${maxBytes / (1024 * 1024)} MiB`
3115
+ });
2525
3116
  }
2526
3117
  chunks.push(chunk.value);
2527
3118
  }
@@ -2550,42 +3141,80 @@ var responseMessage = async (response, operation) => {
2550
3141
  };
2551
3142
  var make = (options) => {
2552
3143
  const baseUrl = normalizeBaseUrl(options.baseUrl);
2553
- const request = (operation, path, schema, init) => Effect2.gen(function* () {
2554
- const response = yield* Effect2.tryPromise({
2555
- try: (signal) => fetch(`${baseUrl}${path}`, {
2556
- ...init,
2557
- signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]),
2558
- headers: {
2559
- authorization: `Bearer ${options.token}`,
2560
- "content-type": "application/json",
2561
- ...init?.headers
3144
+ const request = (operation, path, schema, init, maxResponseBytes = maxJsonResponseBytes) => Effect2.gen(function* () {
3145
+ const json = yield* Effect2.tryPromise({
3146
+ try: async (signal) => {
3147
+ const response = await fetch(`${baseUrl}${path}`, {
3148
+ ...init,
3149
+ signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]),
3150
+ headers: {
3151
+ authorization: `Bearer ${options.token}`,
3152
+ "content-type": "application/json",
3153
+ ...init?.headers
3154
+ }
3155
+ });
3156
+ if (!response.ok) {
3157
+ throw new RelayRequestError({
3158
+ operation,
3159
+ message: await responseMessage(response, operation),
3160
+ status: response.status
3161
+ });
2562
3162
  }
2563
- }),
2564
- catch: (cause) => new RelayRequestError({
3163
+ return jsonBody(response, operation, maxResponseBytes).catch((cause) => {
3164
+ throw cause instanceof RelayDecodeError ? cause : new RelayDecodeError({ operation, message: String(cause) });
3165
+ });
3166
+ },
3167
+ catch: (cause) => cause instanceof RelayRequestError || cause instanceof RelayDecodeError ? cause : new RelayRequestError({
2565
3168
  operation,
2566
3169
  message: cause instanceof Error ? cause.message : String(cause)
2567
3170
  })
2568
3171
  });
2569
- if (!response.ok) {
2570
- return yield* Effect2.fail(new RelayRequestError({
2571
- operation,
2572
- message: yield* Effect2.promise(() => responseMessage(response, operation)),
2573
- status: response.status
2574
- }));
2575
- }
2576
- const json = yield* Effect2.tryPromise({
2577
- try: () => jsonBody(response, operation),
2578
- catch: (cause) => cause instanceof RelayDecodeError ? cause : new RelayDecodeError({ operation, message: String(cause) })
2579
- });
2580
3172
  return yield* Schema2.decodeUnknownEffect(schema)(json).pipe(Effect2.mapError((cause) => new RelayDecodeError({ operation, message: String(cause) })));
2581
3173
  });
3174
+ const devicePage = (after) => request("RelayClient.devices", `/api/devices/page${after === undefined ? "" : `?after=${encodeURIComponent(after)}`}`, DevicePage, undefined, maxDevicePageResponseBytes);
2582
3175
  return Service.of({
2583
- devices: () => request("RelayClient.devices", "/api/devices", Schema2.Array(Device)),
3176
+ devices: () => Effect2.gen(function* () {
3177
+ const first = yield* devicePage().pipe(Effect2.catch((cause) => cause instanceof RelayRequestError && cause.status === 404 ? request("RelayClient.devices", "/api/devices", Schema2.Array(Device)) : Effect2.fail(cause)));
3178
+ if (!("devices" in first))
3179
+ return first;
3180
+ const devices = [...first.devices];
3181
+ let cursor = first.nextCursor;
3182
+ while (cursor !== undefined) {
3183
+ const page = yield* devicePage(cursor);
3184
+ const next = page.devices[0];
3185
+ if (next === undefined)
3186
+ break;
3187
+ if (next.id <= cursor)
3188
+ return yield* new RelayDecodeError({
3189
+ operation: "RelayClient.devices",
3190
+ message: "Relay device page did not advance"
3191
+ });
3192
+ devices.push(...page.devices);
3193
+ cursor = page.nextCursor;
3194
+ }
3195
+ return devices.sort(compareDevices);
3196
+ }),
2584
3197
  forgetDevice: (deviceId) => request("RelayClient.forgetDevice", `/api/devices/${encodeURIComponent(deviceId)}/forget`, Device, { method: "POST" }),
2585
3198
  createTask: (input) => Effect2.gen(function* () {
2586
- if ((input.attachments?.length ?? 0) > 0) {
2587
- const health = yield* request("RelayClient.attachmentSupport", "/api/health", Schema2.Struct({ taskAttachments: Schema2.optionalKey(Schema2.Boolean) }));
2588
- if (health.taskAttachments !== true) {
3199
+ const hasAttachments = (input.attachments?.length ?? 0) > 0;
3200
+ if (input.requestId !== undefined || input.expiresAt !== undefined || hasAttachments) {
3201
+ const health = yield* request("RelayClient.taskSupport", "/api/health", Schema2.Struct({
3202
+ taskIdempotency: Schema2.optionalKey(Schema2.Boolean),
3203
+ taskDeadlines: Schema2.optionalKey(Schema2.Boolean),
3204
+ taskAttachments: Schema2.optionalKey(Schema2.Boolean)
3205
+ }));
3206
+ if (input.requestId !== undefined && health.taskIdempotency !== true) {
3207
+ return yield* new RelayRequestError({
3208
+ operation: "RelayClient.createTask",
3209
+ message: "Upgrade the Cohall relay before using task request IDs"
3210
+ });
3211
+ }
3212
+ if (input.expiresAt !== undefined && health.taskDeadlines !== true)
3213
+ return yield* new RelayRequestError({
3214
+ operation: "RelayClient.createTask",
3215
+ message: "Upgrade the Cohall relay before using task deadlines"
3216
+ });
3217
+ if (hasAttachments && health.taskAttachments !== true) {
2589
3218
  return yield* new RelayRequestError({
2590
3219
  operation: "RelayClient.createTask",
2591
3220
  message: "Upgrade the Cohall relay before sending file attachments"
@@ -2597,7 +3226,27 @@ var make = (options) => {
2597
3226
  body: JSON.stringify(input)
2598
3227
  });
2599
3228
  }),
3229
+ requestTaskInput: (taskId, input) => Effect2.gen(function* () {
3230
+ const health = yield* request("RelayClient.clarificationSupport", "/api/health", Schema2.Struct({ taskClarification: Schema2.optionalKey(Schema2.Boolean) }));
3231
+ if (health.taskClarification !== true)
3232
+ return yield* new RelayRequestError({
3233
+ operation: "RelayClient.requestTaskInput",
3234
+ message: "Upgrade the Cohall relay before requesting clarification"
3235
+ });
3236
+ return yield* request("RelayClient.requestTaskInput", `/api/tasks/${encodeURIComponent(taskId)}/input`, TaskClarification, { method: "POST", body: JSON.stringify(input) });
3237
+ }),
3238
+ answerTaskInput: (taskId, input) => request("RelayClient.answerTaskInput", `/api/tasks/${encodeURIComponent(taskId)}/answer`, Task, { method: "POST", body: JSON.stringify(input) }),
2600
3239
  getTask: (taskId) => request("RelayClient.getTask", `/api/tasks/${encodeURIComponent(taskId)}`, Task),
3240
+ reportTaskProgress: (taskId, input) => Effect2.gen(function* () {
3241
+ const health = yield* request("RelayClient.progressSupport", "/api/health", Schema2.Struct({ taskProgress: Schema2.optionalKey(Schema2.Boolean) }));
3242
+ if (health.taskProgress !== true) {
3243
+ return yield* new RelayRequestError({
3244
+ operation: "RelayClient.reportTaskProgress",
3245
+ message: "Upgrade the Cohall relay before reporting task progress"
3246
+ });
3247
+ }
3248
+ return yield* request("RelayClient.reportTaskProgress", `/api/tasks/${encodeURIComponent(taskId)}/progress`, TaskProgress, { method: "POST", body: JSON.stringify(input) });
3249
+ }),
2601
3250
  listAttachments: (taskId) => request("RelayClient.listAttachments", `/api/tasks/${encodeURIComponent(taskId)}/attachments`, Schema2.Array(TaskAttachment)),
2602
3251
  readAttachment: (taskId, name, direction) => Effect2.tryPromise({
2603
3252
  try: async (signal) => {
@@ -2648,7 +3297,7 @@ var make = (options) => {
2648
3297
  }),
2649
3298
  inbox: () => request("RelayClient.inbox", "/api/inbox", TaskInbox),
2650
3299
  acknowledgeCompletion: (taskId) => request("RelayClient.acknowledgeCompletion", `/api/inbox/${encodeURIComponent(taskId)}/ack`, TaskInboxItem, { method: "POST" }),
2651
- traceTask: (taskId) => request("RelayClient.traceTask", `/api/tasks/${encodeURIComponent(taskId)}/trace`, TaskTrace),
3300
+ traceTask: (taskId) => request("RelayClient.traceTask", `/api/tasks/${encodeURIComponent(taskId)}/trace`, TaskTrace, undefined, maxTraceResponseBytes),
2652
3301
  cancelTask: (taskId) => request("RelayClient.cancelTask", `/api/tasks/${encodeURIComponent(taskId)}/cancel`, Task, {
2653
3302
  method: "POST"
2654
3303
  }),
@@ -2891,7 +3540,7 @@ var prepareCommand = async (options) => {
2891
3540
  }
2892
3541
  }
2893
3542
  };
2894
- var forEachJsonEvent = async (stdout, onEvent) => {
3543
+ var forEachJsonEvent = async (stdout, onEvent, onOversized) => {
2895
3544
  const limit = 1024 * 1024;
2896
3545
  let chunks = [];
2897
3546
  let size = 0;
@@ -2914,6 +3563,7 @@ var forEachJsonEvent = async (stdout, onEvent) => {
2914
3563
  const emit = () => {
2915
3564
  if (oversized) {
2916
3565
  oversized = false;
3566
+ onOversized();
2917
3567
  return;
2918
3568
  }
2919
3569
  const line = Buffer.concat(chunks, size).toString("utf8");
@@ -2945,6 +3595,7 @@ var forEachJsonEvent = async (stdout, onEvent) => {
2945
3595
  var parseCodex = async (stdout, existingSession) => {
2946
3596
  let result = "";
2947
3597
  let sessionId = existingSession;
3598
+ let discardedEvent = false;
2948
3599
  await forEachJsonEvent(stdout, (event) => {
2949
3600
  if (text(event.type) === "thread.started") {
2950
3601
  sessionId = text(event.thread_id) ?? sessionId;
@@ -2954,9 +3605,17 @@ var parseCodex = async (stdout, existingSession) => {
2954
3605
  }
2955
3606
  const item = record(event.item);
2956
3607
  if (text(item?.type) === "agent_message") {
2957
- result = text(item?.text) ?? text(item?.content) ?? result;
3608
+ const content = text(item?.text) ?? text(item?.content);
3609
+ result = content ?? result;
3610
+ if (content !== undefined && content.length > 0)
3611
+ discardedEvent = false;
2958
3612
  }
3613
+ }, () => {
3614
+ discardedEvent = true;
2959
3615
  });
3616
+ if (discardedEvent) {
3617
+ throw new Error("Provider JSON event exceeded 1 MiB without a later text response");
3618
+ }
2960
3619
  return {
2961
3620
  result: result || "Codex completed the task without a text response.",
2962
3621
  ...sessionId === undefined ? {} : { sessionId }
@@ -2993,17 +3652,26 @@ var openCodeError = (event) => {
2993
3652
  var parseOpenCode = async (stdout, existingSession) => {
2994
3653
  let result = "";
2995
3654
  let sessionId = existingSession;
3655
+ let discardedEvent = false;
2996
3656
  await forEachJsonEvent(stdout, (event) => {
2997
3657
  const failure = openCodeError(event);
2998
3658
  if (failure !== undefined) {
2999
3659
  throw new Error(failure);
3000
3660
  }
3001
3661
  sessionId = text(event.sessionID) ?? text(event.sessionId) ?? text(event.session_id) ?? text(record(event.info)?.sessionID) ?? sessionId;
3662
+ if (text(event.type) !== "text")
3663
+ return;
3002
3664
  const content = textFromPart(event.part) ?? textFromPart(event) ?? text(event.result);
3003
3665
  if (content !== undefined && content.length > 0) {
3004
3666
  result = content;
3667
+ discardedEvent = false;
3005
3668
  }
3669
+ }, () => {
3670
+ discardedEvent = true;
3006
3671
  });
3672
+ if (discardedEvent) {
3673
+ throw new Error("Provider JSON event exceeded 1 MiB without a later text response");
3674
+ }
3007
3675
  if (result.length === 0) {
3008
3676
  throw new Error("OpenCode produced no result; verify its authentication and use a supported project workspace");
3009
3677
  }
@@ -3089,7 +3757,8 @@ var run = (options) => Effect3.callback((resume, signal) => {
3089
3757
  ...providerEnvironment(),
3090
3758
  COHALL_PROVIDER: options.provider,
3091
3759
  COHALL_THREAD_ID: options.threadId,
3092
- ...options.taskId === undefined ? {} : { COHALL_TASK_ID: options.taskId }
3760
+ ...options.taskId === undefined ? {} : { COHALL_TASK_ID: options.taskId },
3761
+ ...options.runId === undefined ? {} : { COHALL_TASK_RUN_ID: options.runId }
3093
3762
  },
3094
3763
  extendEnv: false,
3095
3764
  detached: platform() !== "win32",
@@ -3181,7 +3850,7 @@ var run = (options) => Effect3.callback((resume, signal) => {
3181
3850
  import { Effect as Effect10, Schema as Schema11 } from "effect";
3182
3851
  import { access as access4, mkdir as mkdir7, readFile as readFile4, stat as stat4, writeFile as writeFile7 } from "node:fs/promises";
3183
3852
  import { homedir as homedir4 } from "node:os";
3184
- import { dirname as dirname6, join as join8, resolve as resolve3 } from "node:path";
3853
+ import { dirname as dirname6, join as join8, resolve as resolve4 } from "node:path";
3185
3854
 
3186
3855
  // skills/cohall/SKILL.md
3187
3856
  var SKILL_default = `---
@@ -3193,7 +3862,8 @@ description: Delegate through Cohall to another user-owned device or an existing
3193
3862
 
3194
3863
  Cohall sends a focused task to a coding agent or an existing named Grok Bot. The target
3195
3864
  keeps its local files, credentials, browser state, provider login, skills, and
3196
- permissions. The relay carries prompts, final results, task state, and explicitly attached files.
3865
+ permissions. The relay carries prompts, questions and answers, final results,
3866
+ task state, and explicitly attached files.
3197
3867
 
3198
3868
  Use the installed \`cohall\` executable when it is available. Fall back to
3199
3869
  \`npx -y @akshar5/cohall\` only when Cohall is not installed globally.
@@ -3250,9 +3920,19 @@ Do not delegate ordinary local work when the other device provides no advantage.
3250
3920
  to use Codex on a computer; selecting a named bot infers \`grok-bot\`.
3251
3921
  Omit \`--target\` only when Cohall may choose a matching computer.
3252
3922
 
3923
+ For a submission you may need to retry, generate and save a UUID v4 before
3924
+ sending, then pass \`--request-id <uuid>\` or MCP \`delegate.request_id\`. Use a
3925
+ device ID or \`@device-id/bot-id\` from discovery. After a lost response,
3926
+ repeat the same input and ID with the same client credential to recover
3927
+ the original task. Changed input returns 409; pruned task history returns
3928
+ 410 and the ID remains consumed. Check the original work before using a
3929
+ new ID. With request IDs, specify a target or provider for thread follow-ups,
3930
+ and explicitly select a Bot for Bot follow-ups.
3931
+
3253
3932
  5. The command waits by default and returns JSON. Treat work as successful only
3254
3933
  when \`status\` is \`completed\`; use \`result\` in the current task. Report a
3255
- \`failed\`, \`cancelled\`, or \`cancelling\` state accurately.
3934
+ \`failed\`, \`cancelled\`, or \`cancelling\` state accurately. Handle \`needs_input\`
3935
+ through the clarification workflow below.
3256
3936
 
3257
3937
  Pass an explicit local file with \`--attach path\` when the coding task needs
3258
3938
  it. The MCP \`delegate\` tool takes \`attachment_paths\`. A task accepts up to
@@ -3279,9 +3959,32 @@ and \`COHALL_TASK_ID\` carry its thread and parent task. Pass \`--thread\` and
3279
3959
  \`--parent\` explicitly from a Grok Bot using the IDs in its handoff.
3280
3960
 
3281
3961
  For work sent with \`--no-wait\`, check \`cohall inbox\` when returning to the task.
3282
- Read the full result with \`cohall status <task-id>\`, use it in the current work,
3283
- then run \`cohall inbox ack <task-id>\`. Results that arrive after a wait times
3284
- out appear in the same inbox.
3962
+ Answer pending questions using the workflow below. Read completed results with
3963
+ \`cohall status <task-id>\`, use them in the current work, then run
3964
+ \`cohall inbox ack <task-id>\`. Results that arrive after a wait times out appear
3965
+ in the same inbox.
3966
+
3967
+ ## Clarification
3968
+
3969
+ When \`delegate\`, \`send\`, \`status\`, or \`wait\` returns \`needs_input\`, read
3970
+ \`input_request.question\` and \`input_request.id\`. Inbox entries use \`inputRequest\`.
3971
+ Answer from known conversation facts; ask the user if the answer is missing.
3972
+ Resume the existing task with the original requester credential:
3973
+
3974
+ \`\`\`bash
3975
+ cohall answer <task-id> --request-id <question-id> --message 'Use main.'
3976
+ cohall wait <task-id> --timeout 1800
3977
+ \`\`\`
3978
+
3979
+ MCP uses \`task_answer\` with \`task_id\`, \`request_id\`, and \`answer\`, then \`wait_task\`.
3980
+ Only a terminal result completes the handoff. Questions require an answer or
3981
+ cancellation, not an inbox acknowledgement. A stale question ID is rejected;
3982
+ read status again before answering.
3983
+
3984
+ When receiving a delegated coding task and essential information is missing,
3985
+ run \`cohall request-input --question 'Your question'\` or \`task_request_input\`.
3986
+ The task and turn IDs are inherited. After the request succeeds, end the worker
3987
+ turn immediately; Cohall resumes the same task after the sender answers.
3285
3988
 
3286
3989
  ## Message named bots
3287
3990
 
@@ -3311,11 +4014,13 @@ Different bots and the computer's coding agent can run concurrently. Each
3311
4014
  individual bot processes one Cohall request at a time.
3312
4015
 
3313
4016
  When receiving a Cohall request inside a Grok Bot, finish with the \`cohall reply\`
3314
- command supplied in the handoff. Pass the final answer through \`--message-file\`
3315
- or \`--message -\` on stdin, then reply normally in chat. Use \`--error\` if unable
4017
+ command supplied in the handoff, including its \`--run-id\`. Pass the final answer
4018
+ through \`--message-file\` or \`--message -\` on stdin, then reply normally in chat. Use \`--error\` if unable
3316
4019
  to finish. This local receipt is what returns the result to the sending device;
3317
4020
  a chat message alone does not complete the Cohall task. The receipt survives
3318
- worker restarts and works while the relay is temporarily unreachable.
4021
+ worker restarts and works while the relay is temporarily unreachable. For
4022
+ essential missing information, use that callback with \`--question\`, then end
4023
+ the Bot turn. Its resumed handoff supplies a new run ID and the sender's answer.
3319
4024
 
3320
4025
  ## Context and safety
3321
4026
 
@@ -3323,7 +4028,7 @@ worker restarts and works while the relay is temporarily unreachable.
3323
4028
  - Add new relevant developments to \`--context\` when following up from another chat.
3324
4029
  - Never send provider credentials, Cohall tokens, cookies, or browser-profile data.
3325
4030
  - Request a path only when the target advertises a matching workspace root.
3326
- - Use the same thread for clarification instead of creating duplicate tasks.
4031
+ - Resume a clarification on its existing task instead of creating duplicate work.
3327
4032
  - Do not submit the same work through both CLI and MCP.
3328
4033
  - Respect user confirmation requirements for consequential actions on the target.
3329
4034
 
@@ -3344,16 +4049,25 @@ The relay itself must be reachable to accept a new task. Interrupted execution
3344
4049
  uses at-least-once delivery and may run again, so make consequential prompts safe
3345
4050
  to retry.
3346
4051
 
3347
- The timeout error includes the task ID and last known status. The task continues
3348
- unless cancelled:
4052
+ The timeout error includes the task ID and last known status. A wait timeout
4053
+ leaves the task running. When the user requires coding work to stop by a fixed
4054
+ time, pass \`--deadline <future-UTC-ISO-timestamp>\` to \`delegate\`, or \`deadline\`
4055
+ to MCP. The task's deadline includes queueing and clarification and remains
4056
+ unchanged on resume. Upgrade the requester, relay, and worker first. Named bots
4057
+ cannot use deadlines. An expired task fails with \`Task deadline exceeded\`.
4058
+ Tasks awaiting their first dispatch fail on the relay. Tasks that may have
4059
+ reached a worker stay \`cancelling\` until it acknowledges termination, including
4060
+ while it is offline. Without a deadline, stop work explicitly:
3349
4061
 
3350
4062
  \`\`\`bash
3351
4063
  cohall cancel <task-id>
3352
4064
  \`\`\`
3353
4065
 
3354
- For coding agents, active cancellation is acknowledged by the target device;
3355
- \`cancelling\` means the provider process has not confirmed termination yet.
3356
- Queued bot tasks can be cancelled. Stop an active bot in Grok Bot; its gateway
4066
+ For coding agents, cancellation after possible dispatch stays \`cancelling\` until
4067
+ the target confirms termination, including paused tasks and tasks requeued after
4068
+ a disconnect. Tasks awaiting their first dispatch cancel immediately. Paused
4069
+ bot tasks and bot tasks awaiting their first dispatch can be cancelled.
4070
+ Stop an active bot in Grok Bot; its gateway
3357
4071
  cannot safely cancel an individual Cohall request.
3358
4072
 
3359
4073
  ## Read shared context
@@ -3387,9 +4101,10 @@ Trace a known task before inspecting machine-local service logs:
3387
4101
  cohall trace <task-id> --follow
3388
4102
  \`\`\`
3389
4103
 
3390
- The trace is redacted and reports relay dispatch, device execution, retries,
3391
- and terminal state. Use \`cohall thread <thread-id>\` when prompt and result
3392
- history is relevant.
4104
+ The trace reports relay dispatch, device execution, retries, progress, questions,
4105
+ and terminal state. It omits prompts, final results, credentials, and provider
4106
+ session IDs; inspect progress and clarification text before sharing it.
4107
+ Use \`cohall thread <thread-id>\` when prompt and result history is relevant.
3393
4108
 
3394
4109
  If Cohall cannot connect or no target is available, run:
3395
4110
 
@@ -3427,7 +4142,8 @@ var ClientConfiguration = Schema4.Struct({
3427
4142
  relayUrl: Schema4.NonEmptyString,
3428
4143
  token: Schema4.NonEmptyString,
3429
4144
  mcpThreadId: Schema4.optionalKey(Schema4.String),
3430
- mcpTaskId: Schema4.optionalKey(Schema4.String)
4145
+ mcpTaskId: Schema4.optionalKey(Schema4.String),
4146
+ mcpTaskRunId: Schema4.optionalKey(TaskRunId)
3431
4147
  });
3432
4148
  var DeviceConfiguration = Schema4.Struct({
3433
4149
  relayUrl: Schema4.NonEmptyString,
@@ -3590,7 +4306,8 @@ var loadClientConfiguration = Effect4.tryPromise({
3590
4306
  relayUrl,
3591
4307
  token,
3592
4308
  ...process.env.COHALL_THREAD_ID === undefined ? {} : { mcpThreadId: process.env.COHALL_THREAD_ID },
3593
- ...process.env.COHALL_TASK_ID === undefined ? {} : { mcpTaskId: process.env.COHALL_TASK_ID }
4309
+ ...process.env.COHALL_TASK_ID === undefined ? {} : { mcpTaskId: process.env.COHALL_TASK_ID },
4310
+ ...process.env.COHALL_TASK_RUN_ID === undefined ? {} : { mcpTaskRunId: Schema4.decodeUnknownSync(TaskRunId)(process.env.COHALL_TASK_RUN_ID) }
3594
4311
  });
3595
4312
  },
3596
4313
  catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
@@ -3654,7 +4371,7 @@ import { Effect as Effect5, Schedule, Schema as Schema5 } from "effect";
3654
4371
  class DeviceSelectionError extends Schema5.TaggedErrorClass()("Cohall.DeviceSelectionError", { message: Schema5.String, devices: Schema5.Array(Schema5.String) }) {
3655
4372
  }
3656
4373
 
3657
- class TaskWaitTimeoutError extends Schema5.TaggedErrorClass()("Cohall.TaskWaitTimeoutError", { message: Schema5.String, taskId: TaskId, status: TaskStatus }) {
4374
+ class TaskWaitTimeoutError extends Schema5.TaggedErrorClass()("Cohall.TaskWaitTimeoutError", { message: Schema5.String, taskId: TaskId, status: Schema5.optionalKey(TaskStatus) }) {
3658
4375
  }
3659
4376
  var TaskResult = Schema5.Struct({
3660
4377
  task_id: TaskId,
@@ -3665,7 +4382,10 @@ var TaskResult = Schema5.Struct({
3665
4382
  bot_id: Schema5.optionalKey(BotId),
3666
4383
  result: Schema5.optionalKey(Schema5.String),
3667
4384
  error: Schema5.optionalKey(Schema5.String),
3668
- inbox_warning: Schema5.optionalKey(Schema5.String)
4385
+ inbox_warning: Schema5.optionalKey(Schema5.String),
4386
+ progress: Schema5.optionalKey(TaskProgress),
4387
+ input_request: Schema5.optionalKey(TaskClarification),
4388
+ expires_at: Schema5.optionalKey(TaskDeadline)
3669
4389
  });
3670
4390
  var matchingDevices = (devices, target) => {
3671
4391
  const value = target.replace(/^@/, "");
@@ -3709,21 +4429,33 @@ var selectTarget = Effect5.fn("Cohall.selectTarget")(function* (devices, target)
3709
4429
  ]
3710
4430
  });
3711
4431
  });
3712
- var taskResult = (task) => TaskResult.make({
3713
- task_id: task.id,
3714
- thread_id: task.threadId,
3715
- status: task.status,
3716
- provider: task.provider,
3717
- target_device_id: task.targetDeviceId,
3718
- ...task.botId === undefined ? {} : { bot_id: task.botId },
3719
- ...task.result === undefined ? {} : { result: task.result },
3720
- ...task.error === undefined ? {} : { error: task.error }
3721
- });
3722
- var acknowledgedTaskResult = async (client, task) => {
4432
+ var taskResult = (task) => {
4433
+ const question = task.clarifications?.at(-1);
4434
+ return TaskResult.make({
4435
+ task_id: task.id,
4436
+ thread_id: task.threadId,
4437
+ status: task.status,
4438
+ provider: task.provider,
4439
+ target_device_id: task.targetDeviceId,
4440
+ ...task.botId === undefined ? {} : { bot_id: task.botId },
4441
+ ...task.result === undefined ? {} : { result: task.result },
4442
+ ...task.error === undefined ? {} : { error: task.error },
4443
+ ...task.expiresAt === undefined ? {} : { expires_at: task.expiresAt },
4444
+ ...task.progress === undefined ? {} : { progress: task.progress },
4445
+ ...task.status === "needs_input" && question !== undefined ? { input_request: question } : {}
4446
+ });
4447
+ };
4448
+ var acknowledgedTaskResult = async (client, task, signal) => {
4449
+ signal?.throwIfAborted();
3723
4450
  const result = taskResult(task);
3724
- const acknowledgementError = await Effect5.runPromise(client.acknowledgeCompletion(task.id)).then(() => {
4451
+ if (!isTerminalTask(task))
4452
+ return result;
4453
+ const acknowledgementError = await Effect5.runPromise(client.acknowledgeCompletion(task.id), {
4454
+ signal
4455
+ }).then(() => {
3725
4456
  return;
3726
4457
  }).catch((cause) => {
4458
+ signal?.throwIfAborted();
3727
4459
  if (cause instanceof RelayRequestError && cause.status === 404)
3728
4460
  return;
3729
4461
  return cause instanceof Error ? cause.message : String(cause);
@@ -3733,16 +4465,51 @@ var acknowledgedTaskResult = async (client, task) => {
3733
4465
  inbox_warning: `Result received, but the inbox could not be cleared: ${acknowledgementError}`
3734
4466
  });
3735
4467
  };
4468
+ var requestInput = Effect5.fn("Cohall.requestInput")(function* (client, configuration, question, taskId, runId) {
4469
+ const id = taskId ?? (configuration.mcpTaskId === undefined ? undefined : yield* Schema5.decodeUnknownEffect(TaskId)(configuration.mcpTaskId));
4470
+ if (id === undefined)
4471
+ return yield* new RelayRequestError({
4472
+ operation: "Cohall.requestInput",
4473
+ message: "Task id is required outside delegated work"
4474
+ });
4475
+ const inheritedRun = id === configuration.mcpTaskId ? configuration.mcpTaskRunId : undefined;
4476
+ const turn = runId ?? inheritedRun ?? (yield* client.getTask(id)).runId;
4477
+ if (turn === undefined)
4478
+ return yield* new RelayRequestError({
4479
+ operation: "Cohall.requestInput",
4480
+ message: "Task has no current worker turn; upgrade the relay and worker"
4481
+ });
4482
+ return yield* client.requestTaskInput(id, { runId: turn, question });
4483
+ });
3736
4484
  var createDelegation = Effect5.fn("Cohall.createDelegation")(function* (client, configuration, options) {
3737
- const devices = yield* client.devices();
3738
- if (devices.length === 0) {
4485
+ if (options.requestId !== undefined && options.threadId !== undefined && options.target === undefined && options.provider === undefined && options.parentTaskId === undefined && configuration.mcpTaskId === undefined)
4486
+ return yield* new DeviceSelectionError({
4487
+ message: "With a request ID, select a target or provider explicitly for thread follow-ups",
4488
+ devices: []
4489
+ });
4490
+ const directTarget = (() => {
4491
+ if (options.requestId === undefined || options.target === undefined)
4492
+ return;
4493
+ const value = options.target.replace(/^@/, "");
4494
+ const separator = value.indexOf("/");
4495
+ const id = Schema5.decodeUnknownOption(DeviceId)(separator < 0 ? value : value.slice(0, separator));
4496
+ if (id._tag === "None")
4497
+ return;
4498
+ return {
4499
+ deviceId: id.value,
4500
+ botId: separator < 0 ? undefined : Schema5.decodeUnknownSync(BotId)(value.slice(separator + 1))
4501
+ };
4502
+ })();
4503
+ const needsDiscovery = directTarget === undefined && (options.requestId === undefined || options.target !== undefined);
4504
+ const devices = needsDiscovery ? yield* client.devices() : [];
4505
+ if (needsDiscovery && devices.length === 0) {
3739
4506
  return yield* new DeviceSelectionError({
3740
4507
  message: "No Cohall devices are registered",
3741
4508
  devices: []
3742
4509
  });
3743
4510
  }
3744
- let target = options.target === undefined ? undefined : yield* selectTarget(devices, options.target);
3745
- if (target === undefined && options.threadId !== undefined && options.provider === undefined && options.parentTaskId === undefined && configuration.mcpTaskId === undefined) {
4511
+ let target = directTarget ?? (options.target === undefined ? undefined : yield* selectTarget(devices, options.target));
4512
+ if (target === undefined && options.requestId === undefined && options.threadId !== undefined && options.provider === undefined && options.parentTaskId === undefined && configuration.mcpTaskId === undefined) {
3746
4513
  const context = yield* client.threadContext(options.threadId);
3747
4514
  const previous = context.tasks.findLast((task) => task.parentTaskId === undefined);
3748
4515
  if (previous === undefined && context.truncated) {
@@ -3773,6 +4540,7 @@ var createDelegation = Effect5.fn("Cohall.createDelegation")(function* (client,
3773
4540
  const parentTaskId = options.parentTaskId ?? (configuration.mcpTaskId === undefined ? undefined : yield* Schema5.decodeUnknownEffect(TaskId)(configuration.mcpTaskId));
3774
4541
  return yield* client.createTask({
3775
4542
  prompt: options.prompt,
4543
+ ...options.requestId === undefined ? {} : { requestId: options.requestId },
3776
4544
  ...target === undefined ? {} : { targetDeviceId: target.deviceId },
3777
4545
  ...botId === undefined ? {} : { botId },
3778
4546
  ...options.threadId !== undefined ? { threadId: options.threadId } : inheritedThread === undefined ? {} : { threadId: ThreadId.make(inheritedThread) },
@@ -3780,30 +4548,35 @@ var createDelegation = Effect5.fn("Cohall.createDelegation")(function* (client,
3780
4548
  ...options.workspace === undefined ? {} : { workspace: options.workspace },
3781
4549
  ...provider === undefined ? {} : { provider },
3782
4550
  ...parentTaskId === undefined ? {} : { parentTaskId },
3783
- ...options.attachments === undefined ? {} : { attachments: options.attachments }
4551
+ ...options.attachments === undefined ? {} : { attachments: options.attachments },
4552
+ ...options.expiresAt === undefined ? {} : { expiresAt: options.expiresAt }
3784
4553
  });
3785
4554
  });
3786
4555
  var waitForTask = Effect5.fn("Cohall.waitForTask")(function* (client, initial, timeoutSeconds) {
3787
- const deadline = Date.now() + timeoutSeconds * 1000;
3788
- let task = initial;
3789
- while (!isTerminalTask(task)) {
3790
- if (Date.now() >= deadline) {
3791
- return yield* new TaskWaitTimeoutError({
3792
- message: `Task ${task.id} is still ${task.status} after ${timeoutSeconds} seconds`,
3793
- taskId: task.id,
3794
- status: task.status
3795
- });
4556
+ const id = typeof initial === "string" ? initial : initial.id;
4557
+ let task = typeof initial === "string" ? undefined : initial;
4558
+ return yield* Effect5.gen(function* () {
4559
+ if (task === undefined)
4560
+ task = yield* client.getTask(id);
4561
+ while (!isTerminalTask(task) && task.status !== "needs_input") {
4562
+ yield* Effect5.sleep("1 second");
4563
+ task = yield* client.getTask(id);
3796
4564
  }
3797
- yield* Effect5.sleep("1 second");
3798
- task = yield* client.getTask(task.id);
3799
- }
3800
- return task;
4565
+ return task;
4566
+ }).pipe(Effect5.timeoutOrElse({
4567
+ duration: timeoutSeconds * 1000,
4568
+ orElse: () => new TaskWaitTimeoutError({
4569
+ message: task === undefined ? `Task ${id} did not return its status after ${timeoutSeconds} seconds` : `Task ${id} is still ${task.status} after ${timeoutSeconds} seconds`,
4570
+ taskId: id,
4571
+ ...task === undefined ? {} : { status: task.status }
4572
+ })
4573
+ }));
3801
4574
  });
3802
4575
  var followTaskTrace = Effect5.fn("Cohall.followTaskTrace")(function* (client, taskId, onTrace) {
3803
4576
  let revision;
3804
4577
  const poll = client.traceTask(taskId).pipe(Effect5.tap((trace) => {
3805
4578
  const latest = trace.events.at(-1);
3806
- const nextRevision = `${trace.status}:${latest?.kind ?? "none"}:${latest?.at ?? "none"}`;
4579
+ const nextRevision = `${trace.status}:${latest?.kind ?? "none"}:${latest?.at ?? "none"}:${trace.progress?.at ?? ""}:${trace.progress?.note ?? ""}:${JSON.stringify(trace.clarifications)}`;
3807
4580
  if (nextRevision === revision) {
3808
4581
  return Effect5.succeed(undefined);
3809
4582
  }
@@ -3850,6 +4623,7 @@ var allDeviceHealth = (devices, localVersion) => {
3850
4623
  providers: device.providers,
3851
4624
  workspaces: device.workspaces.map((workspace) => workspace.path),
3852
4625
  last_seen_at: device.lastSeenAt,
4626
+ ...device.queue === undefined ? {} : { queue: device.queue },
3853
4627
  warnings
3854
4628
  };
3855
4629
  });
@@ -3888,11 +4662,18 @@ var Dispatch = Schema6.Struct({ taskId: TaskId });
3888
4662
  var Reply = Schema6.Union([
3889
4663
  Schema6.Struct({
3890
4664
  result: Schema6.String.check(Schema6.isMaxLength(131072)),
3891
- error: Schema6.optionalKey(Schema6.Never)
4665
+ error: Schema6.optionalKey(Schema6.Never),
4666
+ question: Schema6.optionalKey(Schema6.Never)
3892
4667
  }),
3893
4668
  Schema6.Struct({
3894
4669
  error: Schema6.NonEmptyString.check(Schema6.isMaxLength(16384)),
3895
- result: Schema6.optionalKey(Schema6.Never)
4670
+ result: Schema6.optionalKey(Schema6.Never),
4671
+ question: Schema6.optionalKey(Schema6.Never)
4672
+ }),
4673
+ Schema6.Struct({
4674
+ question: RequestTaskInput.fields.question,
4675
+ result: Schema6.optionalKey(Schema6.Never),
4676
+ error: Schema6.optionalKey(Schema6.Never)
3896
4677
  })
3897
4678
  ]);
3898
4679
  var Receipt = Schema6.Struct({
@@ -3902,14 +4683,15 @@ var Receipt = Schema6.Struct({
3902
4683
  });
3903
4684
  var isMissing = (cause) => cause instanceof Error && ("code" in cause) && cause.code === "ENOENT";
3904
4685
  var alreadyExists = (cause) => cause instanceof Error && ("code" in cause) && cause.code === "EEXIST";
3905
- var paths = (taskId) => {
4686
+ var paths = (taskId, runId) => {
3906
4687
  const id = Schema6.decodeUnknownSync(TaskId)(taskId);
3907
4688
  const directory = join3(dirname2(configurationPath()), "bot-replies");
4689
+ const key = runId === undefined ? id : `${id}.${Schema6.decodeUnknownSync(TaskRunId)(runId)}`;
3908
4690
  return {
3909
4691
  directory,
3910
- pending: join3(directory, `${id}.pending.json`),
3911
- receipt: join3(directory, `${id}.reply.json`),
3912
- dispatch: join3(directory, `${id}.dispatch.json`)
4692
+ pending: join3(directory, `${key}.pending.json`),
4693
+ receipt: join3(directory, `${key}.reply.json`),
4694
+ dispatch: join3(directory, `${key}.dispatch.json`)
3913
4695
  };
3914
4696
  };
3915
4697
  var readJson = async (path, limit) => {
@@ -3938,8 +4720,8 @@ var readJson = async (path, limit) => {
3938
4720
  await file.close();
3939
4721
  }
3940
4722
  };
3941
- var pendingReply = async (taskId) => {
3942
- const value = await readJson(paths(taskId).pending, 4096);
4723
+ var pendingReply = async (taskId, runId) => {
4724
+ const value = await readJson(paths(taskId, runId).pending, 4096);
3943
4725
  if (value === undefined)
3944
4726
  return;
3945
4727
  const decoded = Schema6.decodeUnknownResult(Pending)(value);
@@ -3982,7 +4764,7 @@ var prepareBotReply = async (task) => {
3982
4764
  if (task.provider !== "grok-bot" || task.botId === undefined) {
3983
4765
  throw new Error("Only a named Grok Bot task can receive a bot reply");
3984
4766
  }
3985
- const files = paths(task.id);
4767
+ const files = paths(task.id, task.runId);
3986
4768
  await mkdir2(files.directory, { recursive: true, mode: 448 });
3987
4769
  await chmod3(files.directory, 448);
3988
4770
  const record2 = Pending.make({
@@ -3992,7 +4774,7 @@ var prepareBotReply = async (task) => {
3992
4774
  deadline: Date.now() + replyLifetimeMs
3993
4775
  });
3994
4776
  await publish(files.pending, record2);
3995
- const pending = await pendingReply(task.id);
4777
+ const pending = await pendingReply(task.id, task.runId);
3996
4778
  if (pending === undefined || pending.botId !== task.botId || pending.threadId !== task.threadId) {
3997
4779
  throw new Error("Bot reply manifest does not match this task");
3998
4780
  }
@@ -4009,19 +4791,19 @@ var prepareBotReply = async (task) => {
4009
4791
  dispatched: dispatch !== undefined
4010
4792
  };
4011
4793
  };
4012
- var claimBotDispatch = async (taskId) => {
4013
- const pending = await pendingReply(taskId);
4794
+ var claimBotDispatch = async (taskId, runId) => {
4795
+ const pending = await pendingReply(taskId, runId);
4014
4796
  if (pending === undefined)
4015
4797
  throw new Error("No pending bot reply for this task");
4016
4798
  if (Date.now() >= pending.deadline)
4017
4799
  throw new Error("Bot reply deadline has expired");
4018
- return publish(paths(taskId).dispatch, { taskId });
4800
+ return publish(paths(taskId, runId).dispatch, { taskId });
4019
4801
  };
4020
- var readBotReply = async (taskId) => {
4021
- const pending = await pendingReply(taskId);
4802
+ var readBotReply = async (taskId, runId) => {
4803
+ const pending = await pendingReply(taskId, runId);
4022
4804
  if (pending === undefined)
4023
4805
  return;
4024
- const value = await readJson(paths(taskId).receipt, 1024 * 1024);
4806
+ const value = await readJson(paths(taskId, runId).receipt, 1024 * 1024);
4025
4807
  if (value === undefined)
4026
4808
  return;
4027
4809
  const decoded = Schema6.decodeUnknownResult(Receipt)(value);
@@ -4033,15 +4815,15 @@ var readBotReply = async (taskId) => {
4033
4815
  }
4034
4816
  return decoded.success.reply;
4035
4817
  };
4036
- var writeBotReply = async (taskId, reply) => {
4818
+ var writeBotReply = async (taskId, reply, runId) => {
4037
4819
  const decoded = Schema6.decodeUnknownResult(Reply)(reply);
4038
4820
  if (decoded._tag === "Failure") {
4039
- throw new Error("Provide a result of at most 131072 characters or a nonempty error of at most 16384 characters");
4821
+ throw new Error("Provide a result of at most 131072 characters, a nonempty error of at most 16384 characters, or a clarification question of at most 4096 UTF-8 bytes");
4040
4822
  }
4041
- const pending = await pendingReply(taskId);
4823
+ const pending = await pendingReply(taskId, runId);
4042
4824
  if (pending === undefined)
4043
4825
  throw new Error("No pending bot reply for this task");
4044
- const existing = await readBotReply(taskId);
4826
+ const existing = await readBotReply(taskId, runId);
4045
4827
  if (existing !== undefined) {
4046
4828
  if (JSON.stringify(existing) === JSON.stringify(decoded.success))
4047
4829
  return;
@@ -4050,15 +4832,15 @@ var writeBotReply = async (taskId, reply) => {
4050
4832
  const submittedAt = Date.now();
4051
4833
  if (submittedAt >= pending.deadline)
4052
4834
  throw new Error("Bot reply deadline has expired");
4053
- if (await publish(paths(taskId).receipt, { taskId, submittedAt, reply: decoded.success }))
4835
+ if (await publish(paths(taskId, runId).receipt, { taskId, submittedAt, reply: decoded.success }))
4054
4836
  return;
4055
- const winner = await readBotReply(taskId);
4837
+ const winner = await readBotReply(taskId, runId);
4056
4838
  if (JSON.stringify(winner) !== JSON.stringify(decoded.success)) {
4057
4839
  throw new Error("A different reply has already been submitted for this task");
4058
4840
  }
4059
4841
  };
4060
- var cleanupBotReply = async (taskId) => {
4061
- const files = paths(taskId);
4842
+ var cleanupBotReply = async (taskId, runId) => {
4843
+ const files = paths(taskId, runId);
4062
4844
  for (const path of [files.pending, files.receipt, files.dispatch]) {
4063
4845
  await unlink(path).catch((cause) => {
4064
4846
  if (!isMissing(cause))
@@ -4208,29 +4990,43 @@ var requireTime = (deadline) => {
4208
4990
  throw new Error(timeoutMessage);
4209
4991
  };
4210
4992
  var finishReply = (reply) => {
4993
+ if (reply.question !== undefined)
4994
+ return { result: "", question: reply.question };
4211
4995
  if (reply.error !== undefined)
4212
4996
  throw new Error(reply.error);
4213
4997
  if (reply.result === undefined)
4214
4998
  throw new Error("Grok Bot returned an invalid Cohall reply");
4215
4999
  return { result: reply.result };
4216
5000
  };
4217
- var taskPrompt = (task, command) => [
4218
- task.prompt,
4219
- task.context === undefined ? "" : `Context:
5001
+ var taskPrompt = (task, command, supportsClarification) => {
5002
+ const reply = `${command} reply ${task.id}${task.runId === undefined ? "" : ` --run-id ${task.runId}`}`;
5003
+ return [
5004
+ task.prompt,
5005
+ task.context === undefined ? "" : `Context:
4220
5006
  ${task.context}`,
4221
- `This request came through Cohall. Thread: ${task.threadId}. Task: ${task.id}.`,
4222
- `You may use your own tools and delegate work to another Cohall device when the request calls for it. Discover devices with: ${command} devices`,
4223
- `Delegation example: ${command} delegate --thread ${task.threadId} --parent ${task.id} --target @device --provider codex --prompt 'Concrete task' --context 'Relevant context'`,
4224
- "When finished, hand the final answer back to Cohall so it reaches the requesting device. Write it to a UTF-8 file on this computer, then run:",
4225
- `${command} reply ${task.id} --message-file /absolute/path/to/your-answer.txt`,
4226
- `Alternatively pipe your answer into: ${command} reply ${task.id} --message -`,
4227
- `If you cannot complete this task, report the reason with: ${command} reply ${task.id} --error 'Reason'`,
4228
- "The reply command must succeed. Then send the same answer in this chat. A normal chat message alone does not complete the Cohall task."
4229
- ].filter(Boolean).join(`
5007
+ `This request came through Cohall. Thread: ${task.threadId}. Task: ${task.id}.`,
5008
+ `You may use your own tools and delegate work to another Cohall device when the request calls for it. Discover devices with: ${command} devices`,
5009
+ `Delegation example: ${command} delegate --thread ${task.threadId} --parent ${task.id} --target @device --provider codex --prompt 'Concrete task' --context 'Relevant context'`,
5010
+ "When finished, hand the final answer back to Cohall so it reaches the requesting device. Write it to a UTF-8 file on this computer, then run:",
5011
+ `${reply} --message-file /absolute/path/to/your-answer.txt`,
5012
+ `Alternatively pipe your answer into: ${reply} --message -`,
5013
+ `If you cannot complete this task, report the reason with: ${reply} --error 'Reason'`,
5014
+ ...supportsClarification && task.runId !== undefined ? [
5015
+ `If essential information is missing, run ${reply} --question 'Your question' and end this turn. Cohall will pause the task and send a new turn with the answer. Do not send a final reply after asking.`,
5016
+ ...(task.clarifications ?? []).flatMap((question) => question.answer === undefined ? [] : [
5017
+ `Clarification question:
5018
+ ${question.question}
5019
+ Sender's answer:
5020
+ ${question.answer.text}`
5021
+ ])
5022
+ ] : [],
5023
+ "The callback command must succeed. After submitting a final result, send the same answer in this chat. After submitting a question, end the turn. A normal chat message alone does not complete the Cohall task."
5024
+ ].filter(Boolean).join(`
4230
5025
 
4231
5026
  `);
4232
- var runGrokBot = async (path, task, signal) => {
4233
- const existing = await readBotReply(task.id);
5027
+ };
5028
+ var runGrokBot = async (path, task, signal, supportsClarification = false) => {
5029
+ const existing = await readBotReply(task.id, task.runId);
4234
5030
  if (existing !== undefined)
4235
5031
  return finishReply(existing);
4236
5032
  const { deadline, command, dispatched } = await prepareBotReply(task);
@@ -4241,11 +5037,11 @@ var runGrokBot = async (path, task, signal) => {
4241
5037
  const lookup = async (lookupSignal = signal) => {
4242
5038
  if (path === undefined)
4243
5039
  throw new Error("Configure this device's Grok Bot gateway discovery file first");
4244
- return rpc(await readConnection(path), "promptAcceptanceStatus", { accountSlot: "host", agentId, clientNonce: task.id }, Acceptance, lookupSignal);
5040
+ return rpc(await readConnection(path), "promptAcceptanceStatus", { accountSlot: "host", agentId, clientNonce: task.runId ?? task.id }, Acceptance, lookupSignal);
4245
5041
  };
4246
5042
  const waitForReply = (reconcile = false) => {
4247
5043
  const receipt = poll(async () => {
4248
- const receipt2 = await readBotReply(task.id);
5044
+ const receipt2 = await readBotReply(task.id, task.runId);
4249
5045
  if (receipt2 !== undefined)
4250
5046
  return finishReply(receipt2);
4251
5047
  requireTime(deadline);
@@ -4274,7 +5070,7 @@ var runGrokBot = async (path, task, signal) => {
4274
5070
  if (acceptance.outcome === "unknown-durability")
4275
5071
  throw new Error("Grok Bot cannot confirm whether this task was accepted; check its conversation before retrying");
4276
5072
  if (acceptance.outcome === "not-found") {
4277
- const receipt = await readBotReply(task.id);
5073
+ const receipt = await readBotReply(task.id, task.runId);
4278
5074
  if (receipt !== undefined)
4279
5075
  return finishReply(receipt);
4280
5076
  requireTime(deadline);
@@ -4286,20 +5082,20 @@ var runGrokBot = async (path, task, signal) => {
4286
5082
  acceptance = await lookup();
4287
5083
  if (acceptance.outcome === "not-found") {
4288
5084
  requireTime(deadline);
4289
- if (!await claimBotDispatch(task.id)) {
5085
+ if (!await claimBotDispatch(task.id, task.runId)) {
4290
5086
  return waitForReply(true);
4291
5087
  }
4292
5088
  try {
4293
5089
  await rpc(connection, "sendPrompt", {
4294
5090
  agentId,
4295
- prompt: taskPrompt(task, command),
4296
- clientNonce: task.id,
5091
+ prompt: taskPrompt(task, command, supportsClarification),
5092
+ clientNonce: task.runId ?? task.id,
4297
5093
  directAddressedAcceptance: true
4298
5094
  }, SendResult, signal);
4299
5095
  } catch {
4300
5096
  if (signal.aborted)
4301
5097
  throw new Error("Stopped waiting for Grok Bot; its run may still be active");
4302
- const receipt2 = await readBotReply(task.id);
5098
+ const receipt2 = await readBotReply(task.id, task.runId);
4303
5099
  if (receipt2 !== undefined)
4304
5100
  return finishReply(receipt2);
4305
5101
  return waitForReply(true);
@@ -4390,9 +5186,13 @@ var guidedSetupInput = async (options, prompter) => {
4390
5186
  const relayUrl = normalizeRelayUrl(options.relayUrl ?? await prompter.answer("Relay URL", existing?.relayUrl ?? "http://127.0.0.1:8787"));
4391
5187
  const credentials = credentialsForRelay(existing, relayUrl);
4392
5188
  const hasRequiredCredentials = credentials.clientToken !== undefined && (options.clientOnly || credentials.deviceToken !== undefined);
4393
- const workspaces = options.clientOnly ? existing?.workspaces ?? [] : options.workspaces.length > 0 ? options.workspaces : [await prompter.answer("Workspace root", existing?.workspaces[0] ?? options.cwd)];
5189
+ let workspaces = options.clientOnly ? existing?.workspaces ?? [] : options.workspaces;
5190
+ if (!options.clientOnly && workspaces.length === 0) {
5191
+ const root = await prompter.answer("Workspace root", existing?.workspaces[0] ?? options.cwd);
5192
+ workspaces = existing !== undefined && root === existing.workspaces[0] ? existing.workspaces : [root];
5193
+ }
4394
5194
  const deviceName = options.clientOnly ? undefined : options.deviceName ?? await prompter.answer("Device name", existing?.deviceName ?? hostname2());
4395
- const providers = options.providers ?? (options.clientOnly ? "auto" : await prompter.answer("Providers", existing?.providers?.join(",") ?? "auto"));
5195
+ const providers = options.providers ?? (options.clientOnly ? existing?.providers?.join(",") ?? "auto" : await prompter.answer("Providers", existing?.providers?.join(",") ?? "auto"));
4396
5196
  const token = options.token ?? (hasRequiredCredentials ? undefined : await prompter.secret("Pairing token"));
4397
5197
  if (!hasRequiredCredentials && (token === undefined || token.length === 0)) {
4398
5198
  throw new Error("A pairing token is required");
@@ -4423,21 +5223,21 @@ import {
4423
5223
  mkdir as mkdir5,
4424
5224
  mkdtemp as mkdtemp2,
4425
5225
  readFile as readFile3,
4426
- realpath as realpath4,
5226
+ realpath as realpath3,
4427
5227
  rename as rename3,
4428
5228
  rm as rm3,
4429
5229
  stat as stat3,
4430
5230
  writeFile as writeFile5
4431
5231
  } from "node:fs/promises";
4432
- import { basename, dirname as dirname5, join as join6, resolve as resolve2 } from "node:path";
5232
+ import { basename, dirname as dirname5, join as join6, resolve as resolve3 } from "node:path";
4433
5233
  import { backup as sqliteBackup, DatabaseSync } from "node:sqlite";
4434
5234
  import { WebSocket } from "ws";
4435
5235
 
4436
5236
  // apps/device/src/service.ts
4437
5237
  import { execFile as execFile2 } from "node:child_process";
4438
- import { chmod as chmod5, mkdir as mkdir4, realpath as realpath3, writeFile as writeFile4 } from "node:fs/promises";
5238
+ import { chmod as chmod5, mkdir as mkdir4, writeFile as writeFile4 } from "node:fs/promises";
4439
5239
  import { homedir as homedir3 } from "node:os";
4440
- import { dirname as dirname4, join as join5 } from "node:path";
5240
+ import { dirname as dirname4, isAbsolute as isAbsolute3, join as join5 } from "node:path";
4441
5241
 
4442
5242
  // apps/device/src/upgrade.ts
4443
5243
  import { Schema as Schema8 } from "effect";
@@ -4448,16 +5248,61 @@ import {
4448
5248
  chmod as chmod4,
4449
5249
  mkdir as mkdir3,
4450
5250
  readFile as readFile2,
5251
+ readdir,
4451
5252
  realpath as realpath2,
4452
5253
  rename as rename2,
4453
5254
  rm as rm2,
4454
5255
  stat as stat2,
4455
5256
  writeFile as writeFile3
4456
5257
  } from "node:fs/promises";
4457
- import { delimiter as delimiter2, dirname as dirname3, extname as extname2, isAbsolute as isAbsolute2, join as join4, relative } from "node:path";
5258
+ import { delimiter as delimiter2, dirname as dirname3, extname as extname2, isAbsolute as isAbsolute2, join as join4, relative, resolve as resolve2 } from "node:path";
4458
5259
  import { platform as operatingSystem } from "node:os";
4459
5260
  var packageName = "@akshar5/cohall";
4460
- var semanticVersion = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
5261
+ var minimumBunLookupVersion = "1.2.15";
5262
+ var semanticVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
5263
+ var parseVersion = (value) => {
5264
+ const [, major, minor, patch, prerelease] = semanticVersion.exec(value) ?? [];
5265
+ if (major === undefined || minor === undefined || patch === undefined)
5266
+ return;
5267
+ const identifiers = prerelease?.split(".") ?? [];
5268
+ if (identifiers.some((part) => /^0\d+$/.test(part)))
5269
+ return;
5270
+ return {
5271
+ core: [BigInt(major), BigInt(minor), BigInt(patch)],
5272
+ prerelease: identifiers
5273
+ };
5274
+ };
5275
+ var compareVersions = (left, right) => {
5276
+ const a = parseVersion(left);
5277
+ const b = parseVersion(right);
5278
+ if (a === undefined || b === undefined) {
5279
+ throw new Error(`Could not safely compare Cohall versions ${left} and ${right}`);
5280
+ }
5281
+ for (const [index, part] of a.core.entries()) {
5282
+ const other = b.core[index];
5283
+ if (other !== undefined && part !== other)
5284
+ return part > other ? 1 : -1;
5285
+ }
5286
+ if (a.prerelease.length === 0 || b.prerelease.length === 0) {
5287
+ return Number(b.prerelease.length > 0) - Number(a.prerelease.length > 0);
5288
+ }
5289
+ for (let index = 0;index < Math.max(a.prerelease.length, b.prerelease.length); index += 1) {
5290
+ const part = a.prerelease[index];
5291
+ const other = b.prerelease[index];
5292
+ if (part === undefined)
5293
+ return -1;
5294
+ if (other === undefined)
5295
+ return 1;
5296
+ if (part === other)
5297
+ continue;
5298
+ const numeric = /^\d+$/.test(part);
5299
+ const otherNumeric = /^\d+$/.test(other);
5300
+ if (numeric !== otherNumeric)
5301
+ return numeric ? -1 : 1;
5302
+ return numeric ? BigInt(part) > BigInt(other) ? 1 : -1 : part > other ? 1 : -1;
5303
+ }
5304
+ return 0;
5305
+ };
4461
5306
  var PackageManager = Schema8.Literals(["npm", "bun", "pnpm"]);
4462
5307
  var RestartReceipt = Schema8.Struct({
4463
5308
  version: Schema8.String,
@@ -4467,45 +5312,165 @@ var RestartReceipt = Schema8.Struct({
4467
5312
  restartedServices: Schema8.Array(Schema8.String),
4468
5313
  restartingService: Schema8.optionalKey(Schema8.String)
4469
5314
  });
5315
+ var pnpmEnvironment = () => Object.fromEntries([
5316
+ ["COREPACK_ENABLE_PROJECT_SPEC", "0"],
5317
+ ...["GLOBAL_DIR", "GLOBAL_BIN_DIR", "STORE_DIR"].flatMap((name) => {
5318
+ const value = process.env[`COHALL_PNPM_${name}`];
5319
+ return value === undefined ? [] : [
5320
+ [`npm_config_${name.toLowerCase()}`, value],
5321
+ [`pnpm_config_${name.toLowerCase()}`, value]
5322
+ ];
5323
+ })
5324
+ ]);
4470
5325
  var normalizePath = (path) => path.replaceAll("\\", "/");
5326
+ var pnpmGlobalDirectory = /^(.*\/global\/v?\d+)\//;
4471
5327
  var normalizeUpgradeTarget = (target) => {
4472
5328
  if (target === undefined || target === "latest") {
4473
5329
  return "latest";
4474
5330
  }
4475
5331
  const normalized = target.startsWith("v") ? target.slice(1) : target;
4476
- if (!semanticVersion.test(normalized)) {
5332
+ if (parseVersion(normalized) === undefined) {
4477
5333
  throw new Error("--to must be latest or an exact semantic version");
4478
5334
  }
4479
5335
  return normalized;
4480
5336
  };
4481
5337
  var deviceVersionWarning = (cliVersion, deviceVersion) => deviceVersion === undefined || deviceVersion === cliVersion ? undefined : `Running device daemon uses Cohall ${deviceVersion}, while this CLI is ${cliVersion}. Run upgrade through the executable configured by the device service.`;
4482
- var packageInstallation = (canonicalEntrypoint, entrypoint = canonicalEntrypoint) => {
5338
+ var packageInstallation = (canonicalEntrypoint, entrypoint = canonicalEntrypoint, runtimePlatform = process.platform) => {
4483
5339
  const path = normalizePath(canonicalEntrypoint);
4484
5340
  const marker = `/node_modules/${packageName}/`;
4485
5341
  const packageIndex = path.lastIndexOf(marker);
4486
5342
  if (packageIndex === -1) {
4487
5343
  throw new Error("cohall upgrade requires a global npm, Bun, or pnpm installation; package-runner and source-checkout executions cannot upgrade themselves");
4488
5344
  }
4489
- if (path.includes("/.npm/_npx/") || path.includes("/.bunx/") || path.includes("/pnpm/dlx/") || path.includes("/dlx/")) {
5345
+ if ([path, normalizePath(entrypoint)].some((candidate) => candidate.includes("/.npm/_npx/") || candidate.includes("/.bunx/") || candidate.includes("/dlx/"))) {
4490
5346
  throw new Error("This Cohall process is running from a temporary package-runner cache; install it globally before using cohall upgrade");
4491
5347
  }
4492
5348
  if (path.includes("/.bun/install/global/node_modules/")) {
4493
5349
  return { manager: "bun", entrypoint };
4494
5350
  }
4495
- if (path.includes("/pnpm/global/")) {
5351
+ if (pnpmGlobalDirectory.test(path) || /\/v\d+\/links\//.test(path) || path.includes("/node_modules/.pnpm/") || pnpmGlobalDirectory.test(normalizePath(entrypoint))) {
4496
5352
  return { manager: "pnpm", entrypoint };
4497
5353
  }
4498
5354
  const nodeModulesParent = path.slice(0, packageIndex);
4499
- const prefix = nodeModulesParent.endsWith("/lib") ? nodeModulesParent.slice(0, -"/lib".length) : nodeModulesParent;
5355
+ const prefix = runtimePlatform !== "win32" && nodeModulesParent.endsWith("/lib") ? nodeModulesParent.slice(0, -"/lib".length) : nodeModulesParent;
4500
5356
  return { manager: "npm", prefix, entrypoint };
4501
5357
  };
5358
+ var resolvePackageInstallation = async (entrypoint, options = {}) => {
5359
+ const canonicalEntrypoint = await realpath2(entrypoint);
5360
+ const runtimePlatform = options.platform ?? process.platform;
5361
+ const installation = packageInstallation(canonicalEntrypoint, resolve2(entrypoint), runtimePlatform);
5362
+ if (installation.manager !== "pnpm") {
5363
+ const prefix = installation.prefix;
5364
+ if (installation.manager === "npm" && prefix !== undefined) {
5365
+ const packageEntry2 = join4(prefix, ...runtimePlatform === "win32" ? [] : ["lib"], "node_modules", packageName, "bin", "cohall.js");
5366
+ const command = join4(prefix, ...runtimePlatform === "win32" ? [] : ["bin"], "cohall");
5367
+ const globalCommand = runtimePlatform === "win32" ? await readFile2(`${command}.cmd`, "utf8").then((shim) => /"%dp0%\\node_modules\\@akshar5\\cohall\\bin\\cohall\.js"\s+%\*/i.test(shim)).catch(() => false) : await realpath2(command).catch(() => {
5368
+ return;
5369
+ }) === canonicalEntrypoint;
5370
+ if (globalCommand && await realpath2(packageEntry2).catch(() => {
5371
+ return;
5372
+ }) === canonicalEntrypoint)
5373
+ return {
5374
+ ...installation,
5375
+ canonicalEntrypoint,
5376
+ pnpmExecutable: undefined,
5377
+ globalRoot: undefined
5378
+ };
5379
+ }
5380
+ const bun = await (options.resolveExecutable ?? trustedExecutable)("bun").catch(() => {
5381
+ return;
5382
+ });
5383
+ if (bun !== undefined) {
5384
+ const result2 = await (options.runner ?? defaultRunner).run(bun, ["pm", "ls", "--global"], 1e4);
5385
+ const root2 = result2.stdout.split(/\r?\n/, 1)[0]?.match(/^(.*?) node_modules \(\d+(?: installed)?\)$/)?.[1];
5386
+ if (result2.exitCode === 0 && root2 !== undefined && isAbsolute2(root2) && await realpath2(join4(root2, "node_modules", packageName, "bin", "cohall.js")).catch(() => {
5387
+ return;
5388
+ }) === canonicalEntrypoint)
5389
+ return {
5390
+ manager: "bun",
5391
+ entrypoint: resolve2(entrypoint),
5392
+ canonicalEntrypoint,
5393
+ globalDir: root2,
5394
+ pnpmExecutable: undefined,
5395
+ globalRoot: undefined
5396
+ };
5397
+ }
5398
+ throw new Error("This operation requires a verified global npm, Bun, or pnpm installation. Project-local packages cannot upgrade or install a service; run the global cohall command with its package manager's configuration.");
5399
+ }
5400
+ const lexicalPath = normalizePath(installation.entrypoint);
5401
+ const globalRoot = lexicalPath.match(pnpmGlobalDirectory)?.[1] ?? lexicalPath.match(/^(.*\/v?\d+)\//)?.[1];
5402
+ const globalEntrypointError = () => new Error("This operation requires a global pnpm entrypoint; project-local and shared-store executions cannot select a global installation. Run the global cohall command.");
5403
+ if (globalRoot === undefined)
5404
+ throw globalEntrypointError();
5405
+ const selectedManager = process.env.COHALL_PNPM_EXECUTABLE ?? "pnpm";
5406
+ const pnpmExecutable = options.resolveExecutable === undefined ? await trustedExecutable(selectedManager, { preserveSymlink: true }) : await options.resolveExecutable(selectedManager);
5407
+ const result = await checked(options.runner ?? defaultRunner, {
5408
+ command: pnpmExecutable,
5409
+ arguments: ["root", "--global"],
5410
+ environment: pnpmEnvironment()
5411
+ }, 1e4);
5412
+ const reported = normalizePath(result.stdout.trim().split(/\r?\n/).at(-1)?.trim() ?? "");
5413
+ const root = reported.endsWith("/node_modules") ? dirname3(reported) : reported;
5414
+ const selected = resolve2(globalRoot);
5415
+ const configured = isAbsolute2(root) ? resolve2(root) : undefined;
5416
+ if (configured === undefined || (process.platform === "win32" ? selected.toLowerCase() !== configured.toLowerCase() : selected !== configured))
5417
+ throw globalEntrypointError();
5418
+ const packageEntry = (root2) => join4(root2, "node_modules", packageName, "bin", "cohall.js");
5419
+ const entries = await readdir(globalRoot, { withFileTypes: true });
5420
+ const candidates = [
5421
+ packageEntry(globalRoot),
5422
+ ...entries.filter((entry) => entry.isSymbolicLink()).map((entry) => packageEntry(join4(globalRoot, entry.name)))
5423
+ ];
5424
+ for (const candidate of candidates) {
5425
+ if (await realpath2(candidate).catch(() => {
5426
+ return;
5427
+ }) === canonicalEntrypoint)
5428
+ return {
5429
+ ...installation,
5430
+ entrypoint: candidate,
5431
+ canonicalEntrypoint,
5432
+ globalDir: dirname3(globalRoot),
5433
+ globalRoot,
5434
+ pnpmExecutable
5435
+ };
5436
+ }
5437
+ throw new Error("Could not find a stable pnpm global executable for this installation. Run the global cohall command, then use cohall service install before upgrading.");
5438
+ };
5439
+ var pnpmServiceDirectories = async (executable, globalRoot, runner = defaultRunner) => {
5440
+ const query = async (arguments_) => {
5441
+ const result = await checked(runner, { command: executable, arguments: arguments_, environment: pnpmEnvironment() }, 1e4);
5442
+ const directory = result.stdout.trim().split(/\r?\n/).at(-1)?.trim() ?? "";
5443
+ if (!isAbsolute2(directory))
5444
+ throw new Error(`pnpm ${arguments_.join(" ")} did not report an absolute directory`);
5445
+ return directory;
5446
+ };
5447
+ const [bin, store] = await Promise.all([
5448
+ query(["bin", "--global"]),
5449
+ query(["--dir", globalRoot, "--ignore-workspace", "store", "path"])
5450
+ ]);
5451
+ return { bin, store };
5452
+ };
4502
5453
  var packageInstallCommand = (installation, target) => {
4503
5454
  const specification = `${packageName}@${target}`;
4504
5455
  switch (installation.manager) {
4505
5456
  case "bun":
4506
- return { command: "bun", arguments: ["add", "--global", specification] };
5457
+ return {
5458
+ command: "bun",
5459
+ arguments: ["add", "--global", specification],
5460
+ ...installation.globalDir === undefined ? {} : { environment: { BUN_INSTALL_GLOBAL_DIR: installation.globalDir } }
5461
+ };
4507
5462
  case "pnpm":
4508
- return { command: "pnpm", arguments: ["add", "--global", specification] };
5463
+ return {
5464
+ command: "pnpm",
5465
+ arguments: [
5466
+ "add",
5467
+ "--global",
5468
+ ...installation.globalDir === undefined ? [] : ["--global-dir", installation.globalDir],
5469
+ ...process.env.COHALL_PNPM_GLOBAL_BIN_DIR === undefined ? [] : ["--global-bin-dir", process.env.COHALL_PNPM_GLOBAL_BIN_DIR],
5470
+ specification
5471
+ ],
5472
+ environment: pnpmEnvironment()
5473
+ };
4509
5474
  case "npm":
4510
5475
  return {
4511
5476
  command: "npm",
@@ -4544,36 +5509,38 @@ var isTrustedGroupWritablePath = (options) => {
4544
5509
  const writableRoot = options.writableRoot;
4545
5510
  return options.platform === "darwin" && writableRoot !== undefined && options.uid !== undefined && options.ownerUid === options.uid && options.ownerGid === macosAdminGroup && macosHomebrewRoots.some((root) => containsPath(root, writableRoot)) && containsPath(writableRoot, options.canonical) && containsPath(writableRoot, options.path);
4546
5511
  };
4547
- var validateExecutable = async (candidate, installationRoot) => {
5512
+ var validateExecutable = async (candidate, installationRoot, preserveSymlink = false) => {
4548
5513
  const canonical = await realpath2(candidate);
4549
5514
  if (operatingSystem() === "win32") {
4550
- return canonical;
5515
+ return preserveSymlink ? resolve2(candidate) : canonical;
4551
5516
  }
4552
5517
  const uid = process.getuid?.();
4553
5518
  const writableRoot = installationRoot === undefined ? undefined : await realpath2(installationRoot);
4554
- for (let path = canonical;; path = dirname3(path)) {
4555
- const metadata = await stat2(path);
4556
- const trustedGroupWritablePath = isTrustedGroupWritablePath({
4557
- platform: operatingSystem(),
4558
- canonical,
4559
- writableRoot,
4560
- path,
4561
- uid,
4562
- ownerUid: metadata.uid,
4563
- ownerGid: metadata.gid
4564
- });
4565
- if ((metadata.mode & 2) !== 0 || (metadata.mode & 16) !== 0 && !trustedGroupWritablePath) {
4566
- throw new Error(`Refusing executable beneath group- or world-writable path ${path}`);
4567
- }
4568
- if (uid !== undefined && metadata.uid !== 0 && metadata.uid !== uid && !isTrustedSystemPath(operatingSystem(), path)) {
4569
- throw new Error(`Refusing executable owned by another user at ${path}`);
4570
- }
4571
- const parent = dirname3(path);
4572
- if (parent === path) {
4573
- break;
5519
+ for (const executablePath of preserveSymlink ? [canonical, resolve2(candidate)] : [canonical]) {
5520
+ for (let path = executablePath;; path = dirname3(path)) {
5521
+ const metadata = await stat2(path);
5522
+ const trustedGroupWritablePath = isTrustedGroupWritablePath({
5523
+ platform: operatingSystem(),
5524
+ canonical,
5525
+ writableRoot,
5526
+ path,
5527
+ uid,
5528
+ ownerUid: metadata.uid,
5529
+ ownerGid: metadata.gid
5530
+ });
5531
+ if ((metadata.mode & 2) !== 0 || (metadata.mode & 16) !== 0 && !trustedGroupWritablePath) {
5532
+ throw new Error(`Refusing executable beneath group- or world-writable path ${path}`);
5533
+ }
5534
+ if (uid !== undefined && metadata.uid !== 0 && metadata.uid !== uid && !isTrustedSystemPath(operatingSystem(), path)) {
5535
+ throw new Error(`Refusing executable owned by another user at ${path}`);
5536
+ }
5537
+ const parent = dirname3(path);
5538
+ if (parent === path) {
5539
+ break;
5540
+ }
4574
5541
  }
4575
5542
  }
4576
- return canonical;
5543
+ return preserveSymlink ? resolve2(candidate) : canonical;
4577
5544
  };
4578
5545
  var trustedExecutable = async (command, options = {}) => {
4579
5546
  const candidates = isAbsolute2(command) ? [command] : (process.env.PATH ?? "").split(delimiter2).filter(Boolean).flatMap((directory) => executableNames(command).map((name) => join4(directory, name)));
@@ -4583,7 +5550,7 @@ var trustedExecutable = async (command, options = {}) => {
4583
5550
  if (!available2)
4584
5551
  continue;
4585
5552
  try {
4586
- return await validateExecutable(path, options.writableRoot);
5553
+ return await validateExecutable(path, options.writableRoot, options.preserveSymlink);
4587
5554
  } catch (cause) {
4588
5555
  failure ??= cause;
4589
5556
  }
@@ -4625,6 +5592,43 @@ var trustedServices = async (services, resolveExecutable) => {
4625
5592
  }
4626
5593
  return secured;
4627
5594
  };
5595
+ var ScheduledTaskAction = Schema8.Struct({
5596
+ Execute: Schema8.NonEmptyString,
5597
+ Arguments: Schema8.NonEmptyString
5598
+ });
5599
+ var powershellLiteral = "(?:[^'\\r\\n]|'')+";
5600
+ var scheduledTaskBootstrap = new RegExp([
5601
+ "^\\$ErrorActionPreference = 'Stop'",
5602
+ `\\$env:COHALL_CONFIG = '${powershellLiteral}'`,
5603
+ `(?:\\$env:PNPM_HOME = '${powershellLiteral}'\\r?\\n\\$env:PATH = '${powershellLiteral}' \\+ ';' \\+ \\$env:PATH\\r?\\n)?(?:\\$env:COHALL_PNPM_EXECUTABLE = '${powershellLiteral}'\\r?\\n)?(?:\\$env:COHALL_PNPM_STORE_DIR = '${powershellLiteral}'\\r?\\n)?(?:\\$env:COHALL_PNPM_GLOBAL_DIR = '${powershellLiteral}'\\r?\\n)?(?:\\$env:COHALL_PNPM_GLOBAL_BIN_DIR = '${powershellLiteral}'\\r?\\n\\$env:PATH = '${powershellLiteral}' \\+ ';' \\+ \\$env:PATH\\r?\\n)?\\$env:PATH = '${powershellLiteral}' \\+ ';' \\+ \\$env:PATH`,
5604
+ `& '(${powershellLiteral})' '(${powershellLiteral})' device`,
5605
+ "exit \\$LASTEXITCODE(?:\\r?\\n)?$"
5606
+ ].join("\\r?\\n"));
5607
+ var scheduledTaskEntrypoint = (output) => {
5608
+ let value;
5609
+ try {
5610
+ value = JSON.parse(output);
5611
+ } catch {
5612
+ return;
5613
+ }
5614
+ const decoded = Schema8.decodeUnknownOption(ScheduledTaskAction)(value);
5615
+ if (decoded._tag === "None")
5616
+ return;
5617
+ const action = decoded.value;
5618
+ if (!/(?:^|[/\\])(?:powershell|pwsh)\.exe$/i.test(action.Execute))
5619
+ return;
5620
+ const encoded = /^-NoLogo\s+-NoProfile\s+-NonInteractive\s+-EncodedCommand\s+([A-Za-z0-9+/]+={0,2})\s*$/i.exec(action.Arguments)?.[1];
5621
+ if (encoded === undefined)
5622
+ return;
5623
+ const bytes = Buffer.from(encoded, "base64");
5624
+ if (bytes.length % 2 !== 0 || bytes.toString("base64") !== encoded)
5625
+ return;
5626
+ const bootstrap = scheduledTaskBootstrap.exec(bytes.toString("utf16le"));
5627
+ const node = bootstrap?.[1]?.replaceAll("''", "'");
5628
+ if (node === undefined || !/(?:^|[/\\])node\.exe$/i.test(node))
5629
+ return;
5630
+ return bootstrap?.[2]?.replaceAll("''", "'");
5631
+ };
4628
5632
  var serviceCandidates = (runtimePlatform, uid) => {
4629
5633
  if (runtimePlatform === "linux") {
4630
5634
  const systemd = (manager, unit, device) => {
@@ -4684,6 +5688,18 @@ var serviceCandidates = (runtimePlatform, uid) => {
4684
5688
  "if ((Get-ScheduledTask -TaskName 'Cohall Device' -ErrorAction SilentlyContinue).State -eq 'Running') { exit 0 } else { exit 1 }"
4685
5689
  ]
4686
5690
  },
5691
+ entrypoint: {
5692
+ inspect: {
5693
+ command: "powershell.exe",
5694
+ arguments: [
5695
+ "-NoProfile",
5696
+ "-NonInteractive",
5697
+ "-Command",
5698
+ "@((Get-ScheduledTask -TaskName 'Cohall Device' -TaskPath '\\' -ErrorAction Stop).Actions) | Select-Object Execute, Arguments | ConvertTo-Json -Compress"
5699
+ ]
5700
+ },
5701
+ parse: scheduledTaskEntrypoint
5702
+ },
4687
5703
  restart: [
4688
5704
  {
4689
5705
  command: "schtasks.exe",
@@ -4698,13 +5714,14 @@ var serviceCandidates = (runtimePlatform, uid) => {
4698
5714
  return [];
4699
5715
  };
4700
5716
  var defaultRunner = {
4701
- run: async (command, arguments_, timeoutMs = 300000) => {
5717
+ run: async (command, arguments_, timeoutMs = 300000, environment) => {
4702
5718
  const result = await execa2(command, arguments_, {
4703
5719
  encoding: "utf8",
4704
5720
  maxBuffer: 1024 * 1024,
4705
5721
  timeout: timeoutMs,
4706
5722
  windowsHide: true,
4707
5723
  stdin: "ignore",
5724
+ ...environment === undefined ? {} : { env: environment },
4708
5725
  reject: false,
4709
5726
  stripFinalNewline: false
4710
5727
  });
@@ -4717,7 +5734,7 @@ var defaultRunner = {
4717
5734
  }
4718
5735
  };
4719
5736
  var checked = async (runner, invocation, timeoutMs) => {
4720
- const result = await runner.run(invocation.command, invocation.arguments, timeoutMs);
5737
+ const result = await runner.run(invocation.command, invocation.arguments, timeoutMs, invocation.environment);
4721
5738
  if (result.exitCode === 0 || invocation.allowFailure === true) {
4722
5739
  return result;
4723
5740
  }
@@ -4735,7 +5752,7 @@ ${result.stderr}`))) {
4735
5752
  }
4736
5753
  return active;
4737
5754
  };
4738
- var assertServiceInstallations = async (runner, services, canonicalEntrypoint, entrypoint) => {
5755
+ var assertServiceInstallations = async (runner, services, installation) => {
4739
5756
  for (const service of services) {
4740
5757
  if (service.entrypoint === undefined) {
4741
5758
  continue;
@@ -4744,15 +5761,19 @@ var assertServiceInstallations = async (runner, services, canonicalEntrypoint, e
4744
5761
  const serviceEntrypoint = service.entrypoint.parse(`${result.stdout}
4745
5762
  ${result.stderr}`);
4746
5763
  if (serviceEntrypoint === undefined) {
4747
- throw new Error(`Could not determine the executable used by active ${service.label}`);
5764
+ throw new Error(`Could not determine the executable used by active ${service.label}${service.manager === "scheduled-task" ? ". Reinstall the task with cohall service install." : ""}`);
4748
5765
  }
4749
5766
  const canonicalServiceEntrypoint = await realpath2(serviceEntrypoint).catch((cause) => {
4750
5767
  throw new Error(`Could not resolve the executable used by active ${service.label}: ${cause instanceof Error ? cause.message : String(cause)}`);
4751
5768
  });
4752
- if (canonicalServiceEntrypoint === canonicalEntrypoint) {
5769
+ if (canonicalServiceEntrypoint === installation.canonicalEntrypoint) {
5770
+ const servicePath = resolve2(serviceEntrypoint);
5771
+ const sameEntrypoint = service.manager === "scheduled-task" ? servicePath.toLowerCase() === installation.entrypoint.toLowerCase() : servicePath === installation.entrypoint;
5772
+ if (installation.manager === "pnpm" && !sameEntrypoint)
5773
+ throw new Error(`Active ${service.label} is pinned to a pnpm package directory that changes during upgrades. ${service.device ? "Run cohall service install through the global cohall command before upgrading." : `Update the relay service executable to ${installation.entrypoint} before upgrading.`}`);
4753
5774
  continue;
4754
5775
  }
4755
- throw new Error(`Active ${service.label} uses ${serviceEntrypoint}, but this Cohall CLI uses ${entrypoint}. Run the service executable's upgrade command so its installation is updated before restart.`);
5776
+ throw new Error(`Active ${service.label} uses ${serviceEntrypoint}, but this Cohall CLI uses ${installation.entrypoint}. Run the service executable's upgrade command so its installation is updated before restart.`);
4756
5777
  }
4757
5778
  };
4758
5779
  var readReceipt = async (path) => readFile2(path, "utf8").then((content) => Schema8.decodeUnknownOption(RestartReceipt)(JSON.parse(content))).then((decoded) => decoded._tag === "Some" ? decoded.value : undefined).catch(() => {
@@ -4822,13 +5843,19 @@ var upgrade = async (options) => {
4822
5843
  const candidates = await trustedServices(serviceCandidates(runtimePlatform, uid), resolveExecutable);
4823
5844
  const statePath = options.statePath ?? restartReceiptPath();
4824
5845
  const previous = await readReceipt(statePath);
5846
+ const entrypoint = options.entrypoint ?? process.argv[1];
5847
+ if (entrypoint === undefined) {
5848
+ throw new Error("Could not resolve the Cohall executable path");
5849
+ }
5850
+ const installation = await resolvePackageInstallation(entrypoint, {
5851
+ runner,
5852
+ platform: runtimePlatform,
5853
+ ...options.resolveExecutable === undefined ? {} : { resolveExecutable }
5854
+ });
4825
5855
  if (previous?.version === options.currentVersion && (target === "latest" || target === previous.version)) {
4826
- const byId = new Map(candidates.map((service) => [service.id, service]));
4827
- const pending = previous.pendingServices.flatMap((id) => {
4828
- const service = byId.get(id);
4829
- return service === undefined ? [] : [service];
4830
- });
5856
+ const pending = candidates.filter((service) => previous.pendingServices.includes(service.id));
4831
5857
  const active = await activeServices(runner, pending);
5858
+ await assertServiceInstallations(runner, active, installation);
4832
5859
  if (options.dryRun || !options.restart) {
4833
5860
  return {
4834
5861
  upgraded: previous.fromVersion !== previous.version,
@@ -4837,13 +5864,13 @@ var upgrade = async (options) => {
4837
5864
  requested_version: previous.version,
4838
5865
  package_manager: previous.packageManager,
4839
5866
  services_restarted: previous.restartedServices,
4840
- services_pending_restart: active.map((service) => service.id),
5867
+ services_pending_restart: previous.pendingServices,
4841
5868
  resumed_after_restart: false,
4842
5869
  dry_run: options.dryRun
4843
5870
  };
4844
5871
  }
4845
5872
  const activeIds = new Set(active.map((service) => service.id));
4846
- const completedByRestart = pending.filter((service) => service.device && service.id === previous.restartingService && activeIds.has(service.id));
5873
+ const completedByRestart = pending.filter((service) => options.delegated === true && service.device && service.id === previous.restartingService && activeIds.has(service.id));
4847
5874
  const completedIds = new Set(completedByRestart.map((service) => service.id));
4848
5875
  const remaining = active.filter((service) => !completedIds.has(service.id));
4849
5876
  let resumed = {
@@ -4859,8 +5886,10 @@ var upgrade = async (options) => {
4859
5886
  if (remaining.length > 0) {
4860
5887
  await writeReceipt(statePath, resumed);
4861
5888
  resumed = await restartServices(runner, remaining, statePath, resumed, options.delegated === true);
5889
+ } else if (completedIds.size > 0) {
5890
+ await writeReceipt(statePath, resumed);
4862
5891
  }
4863
- if (resumed.restartingService === undefined) {
5892
+ if (resumed.pendingServices.length === 0) {
4864
5893
  await rm2(statePath, { force: true });
4865
5894
  }
4866
5895
  return {
@@ -4870,69 +5899,104 @@ var upgrade = async (options) => {
4870
5899
  requested_version: previous.version,
4871
5900
  package_manager: previous.packageManager,
4872
5901
  services_restarted: reportedRestartServices(resumed),
4873
- services_pending_restart: [],
5902
+ services_pending_restart: resumed.pendingServices.filter((id) => id !== resumed.restartingService),
4874
5903
  resumed_after_restart: true,
4875
5904
  dry_run: false
4876
5905
  };
4877
5906
  }
4878
- const entrypoint = options.entrypoint ?? process.argv[1];
4879
- if (entrypoint === undefined) {
4880
- throw new Error("Could not resolve the Cohall executable path");
4881
- }
4882
- const canonicalEntrypoint = await realpath2(entrypoint);
4883
- const installation = packageInstallation(canonicalEntrypoint, entrypoint);
4884
5907
  const services = await activeServices(runner, candidates);
4885
- await assertServiceInstallations(runner, services, canonicalEntrypoint, entrypoint);
4886
- if (options.dryRun) {
5908
+ await assertServiceInstallations(runner, services, installation);
5909
+ const pendingServices = [
5910
+ ...new Set([...previous?.pendingServices ?? [], ...services.map((service) => service.id)])
5911
+ ];
5912
+ const resolvePackageManager = () => {
5913
+ if (installation.pnpmExecutable !== undefined)
5914
+ return Promise.resolve(installation.pnpmExecutable);
5915
+ return options.resolveExecutable === undefined ? trustedExecutable(installation.manager, installation.prefix === undefined ? {} : { writableRoot: installation.prefix }) : resolveExecutable(installation.manager);
5916
+ };
5917
+ let packageManagerExecutable;
5918
+ let resolvedTarget = target;
5919
+ if (target === "latest") {
5920
+ packageManagerExecutable = await resolvePackageManager();
5921
+ if (installation.manager === "bun") {
5922
+ const information = await checked(runner, { command: packageManagerExecutable, arguments: ["--version"] }, 1e4);
5923
+ if (compareVersions(information.stdout.trim(), minimumBunLookupVersion) < 0) {
5924
+ throw new Error(`Latest upgrades require Bun ${minimumBunLookupVersion} or newer; upgrade Bun or use --to <exact-version>`);
5925
+ }
5926
+ }
5927
+ const lookup = await checked(runner, {
5928
+ command: packageManagerExecutable,
5929
+ arguments: [
5930
+ ...installation.manager === "bun" ? ["pm"] : [],
5931
+ "view",
5932
+ ...installation.manager === "pnpm" ? [] : ["--global"],
5933
+ ...installation.prefix === undefined ? [] : ["--prefix", installation.prefix],
5934
+ `${packageName}@latest`,
5935
+ "version",
5936
+ "--json"
5937
+ ],
5938
+ ...installation.manager === "pnpm" ? { environment: pnpmEnvironment() } : {}
5939
+ }, 30000);
5940
+ const latest = JSON.parse(lookup.stdout);
5941
+ if (typeof latest !== "string" || parseVersion(latest) === undefined) {
5942
+ throw new Error(`Could not resolve ${packageName}@latest to an exact semantic version`);
5943
+ }
5944
+ resolvedTarget = latest;
5945
+ }
5946
+ let nextVersion = await installedVersion(installation.entrypoint).catch((cause) => {
5947
+ if (target === "latest") {
5948
+ throw new Error("Could not verify the installed version before upgrading latest; use --to <version> to repair this installation", { cause });
5949
+ }
5950
+ return;
5951
+ });
5952
+ if (target === "latest" && (compareVersions(resolvedTarget, options.currentVersion) < 0 || compareVersions(resolvedTarget, nextVersion ?? options.currentVersion) < 0)) {
4887
5953
  return {
4888
5954
  upgraded: false,
4889
5955
  from_version: options.currentVersion,
4890
- installed_version: options.currentVersion,
5956
+ installed_version: nextVersion ?? options.currentVersion,
4891
5957
  requested_version: target,
4892
5958
  package_manager: installation.manager,
4893
5959
  services_restarted: [],
4894
- services_pending_restart: options.restart ? services.map((service) => service.id) : [],
5960
+ services_pending_restart: [],
4895
5961
  resumed_after_restart: false,
4896
- dry_run: true
5962
+ dry_run: options.dryRun
4897
5963
  };
4898
5964
  }
4899
- let nextVersion = await installedVersion(entrypoint).catch(() => {
4900
- return;
4901
- });
4902
- if (nextVersion === undefined || target !== nextVersion) {
4903
- const install = packageInstallCommand(installation, target);
4904
- const installExecutable = options.resolveExecutable === undefined ? await trustedExecutable(install.command, installation.prefix === undefined ? {} : { writableRoot: installation.prefix }) : await resolveExecutable(install.command);
4905
- await checked(runner, { ...install, command: installExecutable });
4906
- nextVersion = await installedVersion(entrypoint);
4907
- }
4908
- if (target !== "latest" && nextVersion !== target) {
4909
- throw new Error(`Installed Cohall ${nextVersion}, expected ${target}`);
4910
- }
4911
- const upgraded = nextVersion !== options.currentVersion;
4912
- if (!options.restart || services.length === 0) {
4913
- await rm2(statePath, { force: true });
5965
+ if (options.dryRun) {
4914
5966
  return {
4915
- upgraded,
5967
+ upgraded: false,
4916
5968
  from_version: options.currentVersion,
4917
- installed_version: nextVersion,
5969
+ installed_version: options.currentVersion,
4918
5970
  requested_version: target,
4919
5971
  package_manager: installation.manager,
4920
5972
  services_restarted: [],
4921
- services_pending_restart: options.restart ? [] : services.map((service) => service.id),
5973
+ services_pending_restart: pendingServices,
4922
5974
  resumed_after_restart: false,
4923
- dry_run: false
5975
+ dry_run: true
4924
5976
  };
4925
5977
  }
5978
+ if (nextVersion === undefined || resolvedTarget !== nextVersion) {
5979
+ const install = packageInstallCommand(installation, resolvedTarget);
5980
+ const installExecutable = packageManagerExecutable ?? await resolvePackageManager();
5981
+ await checked(runner, { ...install, command: installExecutable });
5982
+ nextVersion = await installedVersion(installation.entrypoint);
5983
+ }
5984
+ if (nextVersion !== resolvedTarget) {
5985
+ throw new Error(`Installed Cohall ${nextVersion}, expected ${resolvedTarget}`);
5986
+ }
5987
+ const upgraded = nextVersion !== options.currentVersion;
4926
5988
  const initial = {
4927
5989
  version: nextVersion,
4928
5990
  fromVersion: options.currentVersion,
4929
5991
  packageManager: installation.manager,
4930
- pendingServices: services.map((service) => service.id),
5992
+ pendingServices,
4931
5993
  restartedServices: []
4932
5994
  };
4933
- await writeReceipt(statePath, initial);
4934
- const completed = await restartServices(runner, services, statePath, initial, options.delegated === true);
4935
- if (completed.restartingService === undefined) {
5995
+ if (initial.pendingServices.length > 0) {
5996
+ await writeReceipt(statePath, initial);
5997
+ }
5998
+ const completed = options.restart ? await restartServices(runner, services, statePath, initial, options.delegated === true) : initial;
5999
+ if (completed.pendingServices.length === 0) {
4936
6000
  await rm2(statePath, { force: true });
4937
6001
  }
4938
6002
  return {
@@ -4942,7 +6006,7 @@ var upgrade = async (options) => {
4942
6006
  requested_version: target,
4943
6007
  package_manager: installation.manager,
4944
6008
  services_restarted: reportedRestartServices(completed),
4945
- services_pending_restart: [],
6009
+ services_pending_restart: completed.pendingServices.filter((id) => id !== completed.restartingService),
4946
6010
  resumed_after_restart: false,
4947
6011
  dry_run: false
4948
6012
  };
@@ -4952,20 +6016,36 @@ var upgrade = async (options) => {
4952
6016
  var xml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
4953
6017
  var systemdArgument = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("%", "%%").replaceAll(`
4954
6018
  `, "\\n").replaceAll("\r", "\\r")}"`;
6019
+ var pnpmHomeDirectory = (platform4, home) => {
6020
+ if (process.env.PNPM_HOME)
6021
+ return process.env.PNPM_HOME;
6022
+ if (process.env.XDG_DATA_HOME)
6023
+ return join5(process.env.XDG_DATA_HOME, "pnpm");
6024
+ if (platform4 === "darwin")
6025
+ return join5(home, "Library", "pnpm");
6026
+ if (platform4 !== "win32")
6027
+ return join5(home, ".local", "share", "pnpm");
6028
+ return process.env.LOCALAPPDATA ? join5(process.env.LOCALAPPDATA, "pnpm") : join5(home, ".pnpm");
6029
+ };
4955
6030
  var deviceServicePlan = (options) => {
6031
+ const pnpmHome = options.pnpm?.home ?? (options.platform === "darwin" ? join5(options.home, "Library", "pnpm") : join5(options.home, ".local", "share", "pnpm"));
4956
6032
  const servicePath = [
4957
6033
  dirname4(options.nodeExecutable),
4958
6034
  join5(options.home, ".local", "bin"),
4959
6035
  join5(options.home, ".npm-global", "bin"),
4960
6036
  join5(options.home, ".bun", "bin"),
4961
- options.platform === "darwin" ? join5(options.home, "Library", "pnpm") : join5(options.home, ".local", "share", "pnpm"),
6037
+ ...options.pnpm === undefined ? [] : [options.pnpm.bin],
6038
+ join5(pnpmHome, "bin"),
6039
+ pnpmHome,
4962
6040
  ...options.platform === "darwin" ? ["/opt/homebrew/bin"] : [],
4963
6041
  "/usr/local/bin",
4964
6042
  "/usr/bin",
4965
6043
  "/bin"
4966
6044
  ].join(":");
4967
6045
  if (options.platform === "linux") {
4968
- const path = join5(options.home, ".config", "systemd", "user", "cohall-device.service");
6046
+ const configured = process.env.XDG_CONFIG_HOME;
6047
+ const configHome = configured !== undefined && isAbsolute3(configured) ? configured : join5(options.home, ".config");
6048
+ const path = join5(configHome, "systemd", "user", "cohall-device.service");
4969
6049
  return {
4970
6050
  file: {
4971
6051
  path,
@@ -4979,7 +6059,12 @@ Wants=network-online.target
4979
6059
  Type=simple
4980
6060
  Environment=${systemdArgument(`PATH=${servicePath}`)}
4981
6061
  Environment=${systemdArgument(`COHALL_CONFIG=${options.configPath}`)}
4982
- ExecStart=${systemdArgument(options.entrypoint)} device
6062
+ ${options.pnpm === undefined ? "" : `Environment=${systemdArgument(`PNPM_HOME=${options.pnpm.home}`)}
6063
+ Environment=${systemdArgument(`COHALL_PNPM_EXECUTABLE=${options.pnpm.executable}`)}
6064
+ Environment=${systemdArgument(`COHALL_PNPM_GLOBAL_DIR=${options.pnpm.globalDir}`)}
6065
+ Environment=${systemdArgument(`COHALL_PNPM_STORE_DIR=${options.pnpm.store}`)}
6066
+ Environment=${systemdArgument(`COHALL_PNPM_GLOBAL_BIN_DIR=${options.pnpm.bin}`)}
6067
+ `}ExecStart=${systemdArgument(options.entrypoint)} device
4983
6068
  Restart=always
4984
6069
  RestartSec=3
4985
6070
  UMask=0077
@@ -5023,7 +6108,17 @@ WantedBy=default.target
5023
6108
  <string>${xml(servicePath)}</string>
5024
6109
  <key>COHALL_CONFIG</key>
5025
6110
  <string>${xml(options.configPath)}</string>
5026
- </dict>
6111
+ ${options.pnpm === undefined ? "" : ` <key>PNPM_HOME</key>
6112
+ <string>${xml(options.pnpm.home)}</string>
6113
+ <key>COHALL_PNPM_EXECUTABLE</key>
6114
+ <string>${xml(options.pnpm.executable)}</string>
6115
+ <key>COHALL_PNPM_STORE_DIR</key>
6116
+ <string>${xml(options.pnpm.store)}</string>
6117
+ <key>COHALL_PNPM_GLOBAL_DIR</key>
6118
+ <string>${xml(options.pnpm.globalDir)}</string>
6119
+ <key>COHALL_PNPM_GLOBAL_BIN_DIR</key>
6120
+ <string>${xml(options.pnpm.bin)}</string>
6121
+ `} </dict>
5027
6122
  <key>RunAtLoad</key>
5028
6123
  <true/>
5029
6124
  <key>KeepAlive</key>
@@ -5053,8 +6148,8 @@ WantedBy=default.target
5053
6148
  var defaultRunner2 = {
5054
6149
  run: async (command, arguments_, timeoutMs = 60000) => {
5055
6150
  const executable = await trustedExecutable(command);
5056
- return new Promise((resolve2) => {
5057
- execFile2(executable, [...arguments_], { encoding: "utf8", maxBuffer: 1024 * 1024, timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => resolve2({
6151
+ return new Promise((resolve3) => {
6152
+ execFile2(executable, [...arguments_], { encoding: "utf8", maxBuffer: 1024 * 1024, timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => resolve3({
5058
6153
  exitCode: typeof error?.code === "number" ? error.code : error === null ? 0 : 1,
5059
6154
  stdout,
5060
6155
  stderr,
@@ -5071,12 +6166,22 @@ var checked2 = async (runner, command, arguments_, allowFailure = false) => {
5071
6166
  throw new Error(`${command} failed: ${result.stderr.trim() || result.stdout.trim() || result.error || `status ${result.exitCode}`}`);
5072
6167
  };
5073
6168
  var installDeviceService = async (options = {}) => {
5074
- const entrypoint = await realpath3(options.entrypoint ?? process.argv[1] ?? "");
5075
- packageInstallation(entrypoint);
6169
+ const { entrypoint, canonicalEntrypoint, pnpmExecutable, globalDir, globalRoot } = await resolvePackageInstallation(options.entrypoint ?? process.argv[1] ?? "", {
6170
+ ...options.platform === undefined ? {} : { platform: options.platform },
6171
+ ...options.runner === undefined ? {} : { runner: options.runner },
6172
+ ...options.resolveExecutable === undefined ? {} : { resolveExecutable: options.resolveExecutable }
6173
+ });
5076
6174
  const platform4 = options.platform ?? process.platform;
6175
+ const home = options.home ?? homedir3();
6176
+ const pnpm = pnpmExecutable === undefined || globalDir === undefined || globalRoot === undefined ? undefined : {
6177
+ home: pnpmHomeDirectory(platform4, home),
6178
+ executable: pnpmExecutable,
6179
+ globalDir,
6180
+ ...await pnpmServiceDirectories(pnpmExecutable, globalRoot, options.runner)
6181
+ };
5077
6182
  const runner = options.runner ?? defaultRunner2;
5078
6183
  if (platform4 === "win32") {
5079
- const script = join5(dirname4(dirname4(entrypoint)), "deploy", "windows", "install-device.ps1");
6184
+ const script = join5(dirname4(dirname4(canonicalEntrypoint)), "deploy", "windows", "install-device.ps1");
5080
6185
  await checked2(runner, "powershell.exe", [
5081
6186
  "-NoProfile",
5082
6187
  "-ExecutionPolicy",
@@ -5088,7 +6193,19 @@ var installDeviceService = async (options = {}) => {
5088
6193
  "-Entrypoint",
5089
6194
  entrypoint,
5090
6195
  "-ConfigurationPath",
5091
- configurationPath()
6196
+ configurationPath(),
6197
+ ...pnpm === undefined ? [] : [
6198
+ "-PnpmHome",
6199
+ pnpm.home,
6200
+ "-PnpmExecutable",
6201
+ pnpm.executable,
6202
+ "-PnpmStoreDir",
6203
+ pnpm.store,
6204
+ "-PnpmGlobalDir",
6205
+ pnpm.globalDir,
6206
+ "-PnpmGlobalBinDir",
6207
+ pnpm.bin
6208
+ ]
5092
6209
  ]);
5093
6210
  return { installed: "scheduled-task:Cohall Device" };
5094
6211
  }
@@ -5096,9 +6213,10 @@ var installDeviceService = async (options = {}) => {
5096
6213
  const plan = deviceServicePlan({
5097
6214
  platform: platform4,
5098
6215
  entrypoint,
5099
- home: options.home ?? homedir3(),
6216
+ home,
5100
6217
  nodeExecutable: process.execPath,
5101
6218
  configPath: configurationPath(),
6219
+ ...pnpm === undefined ? {} : { pnpm },
5102
6220
  ...uid === undefined ? {} : { uid }
5103
6221
  });
5104
6222
  await mkdir4(dirname4(plan.file.path), { recursive: true, mode: 448 });
@@ -5115,36 +6233,18 @@ var installDeviceService = async (options = {}) => {
5115
6233
  var restartDeviceService = async (options = {}) => {
5116
6234
  const platform4 = options.platform ?? process.platform;
5117
6235
  const runner = options.runner ?? defaultRunner2;
5118
- if (platform4 === "linux") {
5119
- const service = "cohall-device.service";
5120
- const check = await runner.run("systemctl", ["--user", "is-active", "--quiet", service], 1e4);
5121
- if (check.exitCode !== 0) {
5122
- return { running: false, restarted: false };
5123
- }
5124
- await checked2(runner, "systemctl", ["--user", "restart", service]);
5125
- return { running: true, restarted: true, service };
6236
+ const service = serviceCandidates(platform4, options.uid ?? process.getuid?.()).find((candidate) => candidate.device);
6237
+ if (service === undefined)
6238
+ return { running: false, restarted: false };
6239
+ const check = await runner.run(service.check.command, service.check.arguments, 1e4);
6240
+ if (check.exitCode !== 0 || service.activeOutput !== undefined && !service.activeOutput.test(`${check.stdout}
6241
+ ${check.stderr}`)) {
6242
+ return { running: false, restarted: false };
5126
6243
  }
5127
- if (platform4 === "darwin") {
5128
- const service = "com.cohall.device";
5129
- const target = `gui/${options.uid ?? process.getuid?.() ?? 0}/${service}`;
5130
- const check = await runner.run("launchctl", ["print", target], 1e4);
5131
- if (check.exitCode !== 0) {
5132
- return { running: false, restarted: false };
5133
- }
5134
- await checked2(runner, "launchctl", ["kickstart", "-k", target]);
5135
- return { running: true, restarted: true, service };
5136
- }
5137
- if (platform4 === "win32") {
5138
- const service = "Cohall Device";
5139
- const check = await runner.run("schtasks.exe", ["/Query", "/TN", service], 1e4);
5140
- if (check.exitCode !== 0) {
5141
- return { running: false, restarted: false };
5142
- }
5143
- await checked2(runner, "schtasks.exe", ["/End", "/TN", service], true);
5144
- await checked2(runner, "schtasks.exe", ["/Run", "/TN", service]);
5145
- return { running: true, restarted: true, service };
6244
+ for (const command of service.restart) {
6245
+ await checked2(runner, command.command, command.arguments, command.allowFailure);
5146
6246
  }
5147
- return { running: false, restarted: false };
6247
+ return { running: true, restarted: true, service: service.label };
5148
6248
  };
5149
6249
 
5150
6250
  // apps/device/src/relay-migration.ts
@@ -5181,7 +6281,7 @@ var verifyBackupDestinationParent = async (path) => {
5181
6281
  if (process.platform === "win32") {
5182
6282
  return;
5183
6283
  }
5184
- for (let current = await realpath4(path);; current = dirname5(current)) {
6284
+ for (let current = await realpath3(path);; current = dirname5(current)) {
5185
6285
  const metadata = await stat3(current);
5186
6286
  const writableByOthers = (metadata.mode & 18) !== 0;
5187
6287
  const sticky = (metadata.mode & 512) !== 0;
@@ -5225,7 +6325,7 @@ var backupRelay = async (destinationInput) => {
5225
6325
  if (!await exists(sourceDatabase)) {
5226
6326
  throw new Error(`Relay database was not found at ${sourceDatabase}`);
5227
6327
  }
5228
- const destination = resolve2(destinationInput);
6328
+ const destination = resolve3(destinationInput);
5229
6329
  if (await exists(destination)) {
5230
6330
  throw new Error(`Backup destination already exists: ${destination}`);
5231
6331
  }
@@ -5272,7 +6372,7 @@ var backupRelay = async (destinationInput) => {
5272
6372
  }
5273
6373
  };
5274
6374
  var restoreRelay = async (sourceInput) => {
5275
- const source = resolve2(sourceInput);
6375
+ const source = resolve3(sourceInput);
5276
6376
  const sourceDatabase = join6(source, "cohall.db");
5277
6377
  const sourceOwnerToken = join6(source, "owner-token");
5278
6378
  const sourceManifest = join6(source, "manifest.json");
@@ -5417,9 +6517,9 @@ var switchRelay = async (options) => {
5417
6517
  }
5418
6518
  let service = { running: false, restarted: false };
5419
6519
  let warning;
5420
- if (updated && options.restart && deviceTokens.length > 0) {
6520
+ if (options.restart && deviceTokens.length > 0) {
5421
6521
  service = await (options.restartService ?? restartDeviceService)().catch((cause) => {
5422
- warning = `Relay URL changed, but the device service could not restart: ${cause instanceof Error ? cause.message : String(cause)}`;
6522
+ warning = `Relay URL is saved, but the device service could not restart: ${cause instanceof Error ? cause.message : String(cause)}. Retry cohall relay use ${relayUrl} to restart an active worker.`;
5423
6523
  return { running: true, restarted: false };
5424
6524
  });
5425
6525
  }
@@ -5436,9 +6536,33 @@ var switchRelay = async (options) => {
5436
6536
  init_src();
5437
6537
  import { Effect as Effect9, Schema as Schema10 } from "effect";
5438
6538
  import { constants as constants3 } from "node:fs";
5439
- import { lstat as lstat2, mkdtemp as mkdtemp3, mkdir as mkdir6, open as open3, readdir, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
6539
+ import {
6540
+ lstat as lstat2,
6541
+ mkdtemp as mkdtemp3,
6542
+ mkdir as mkdir6,
6543
+ open as open3,
6544
+ readdir as readdir2,
6545
+ rm as rm4,
6546
+ writeFile as writeFile6
6547
+ } from "node:fs/promises";
5440
6548
  import { tmpdir as tmpdir2 } from "node:os";
5441
6549
  import { basename as basename2, join as join7 } from "node:path";
6550
+ var readBoundedFile = async (handle) => {
6551
+ const buffer = Buffer.allocUnsafe(maxAttachmentBytes + 1);
6552
+ let offset = 0;
6553
+ while (offset < buffer.length) {
6554
+ const { bytesRead } = await handle.read({
6555
+ buffer,
6556
+ offset,
6557
+ length: buffer.length - offset,
6558
+ position: null
6559
+ });
6560
+ if (bytesRead === 0)
6561
+ break;
6562
+ offset += bytesRead;
6563
+ }
6564
+ return buffer.subarray(0, offset);
6565
+ };
5442
6566
  var readInputAttachments = async (paths2) => {
5443
6567
  if (paths2.length > maxTaskAttachments) {
5444
6568
  throw new Error(`A task supports at most ${maxTaskAttachments} input files`);
@@ -5461,7 +6585,7 @@ var readInputAttachments = async (paths2) => {
5461
6585
  if (!metadata.isFile() || metadata.dev !== source.dev || metadata.ino !== source.ino || metadata.size < 1 || metadata.size > maxAttachmentBytes) {
5462
6586
  throw new Error(`Attachment ${name} must be a regular file of 1 to ${maxAttachmentBytes} bytes`);
5463
6587
  }
5464
- const data = await handle.readFile();
6588
+ const data = await readBoundedFile(handle);
5465
6589
  if (data.length < 1 || data.length > maxAttachmentBytes) {
5466
6590
  throw new Error(`Attachment ${name} changed size while reading`);
5467
6591
  }
@@ -5471,31 +6595,32 @@ var readInputAttachments = async (paths2) => {
5471
6595
  }
5472
6596
  }));
5473
6597
  };
5474
- var prepareTaskFiles = async (configuration, taskId, inputNames, signal) => {
6598
+ var prepareTaskFiles = Effect9.fn("TaskFiles.prepare")(function* (configuration, taskId, inputNames) {
5475
6599
  const client = exports_src.make({ baseUrl: configuration.relayUrl, token: configuration.token });
5476
- const root = await mkdtemp3(join7(tmpdir2(), "cohall-task-"));
6600
+ const root = yield* Effect9.acquireRelease(Effect9.tryPromise({
6601
+ try: () => mkdtemp3(join7(tmpdir2(), "cohall-task-")),
6602
+ catch: (cause) => cause
6603
+ }), (root2) => Effect9.promise(() => rm4(root2, { recursive: true, force: true })));
5477
6604
  const input = join7(root, "input");
5478
6605
  const output = join7(root, "output");
5479
- try {
5480
- await Promise.all([mkdir6(input), mkdir6(output)]);
5481
- for (const name of inputNames) {
5482
- const data = await Effect9.runPromise(client.readAttachment(taskId, name), { signal });
5483
- await writeFile6(join7(input, name), data, { flag: "wx", mode: 384 });
5484
- }
5485
- return {
5486
- input,
5487
- output,
5488
- inputNames,
5489
- collectOutputs: () => collectOutputs(output),
5490
- cleanup: () => rm4(root, { recursive: true, force: true })
5491
- };
5492
- } catch (cause) {
5493
- await rm4(root, { recursive: true, force: true });
5494
- throw cause;
6606
+ yield* Effect9.tryPromise({ try: () => mkdir6(input), catch: (cause) => cause }).pipe(Effect9.uninterruptible);
6607
+ yield* Effect9.tryPromise({ try: () => mkdir6(output), catch: (cause) => cause }).pipe(Effect9.uninterruptible);
6608
+ for (const name of inputNames) {
6609
+ const data = yield* client.readAttachment(taskId, name);
6610
+ yield* Effect9.tryPromise({
6611
+ try: () => writeFile6(join7(input, name), data, { flag: "wx", mode: 384 }),
6612
+ catch: (cause) => cause
6613
+ }).pipe(Effect9.uninterruptible);
5495
6614
  }
5496
- };
6615
+ return {
6616
+ input,
6617
+ output,
6618
+ inputNames,
6619
+ collectOutputs: () => collectOutputs(output)
6620
+ };
6621
+ });
5497
6622
  var collectOutputs = async (directory) => {
5498
- const entries = await readdir(directory, { withFileTypes: true });
6623
+ const entries = await readdir2(directory, { withFileTypes: true });
5499
6624
  if (entries.length > maxTaskAttachments) {
5500
6625
  throw new Error(`A task can return at most ${maxTaskAttachments} files`);
5501
6626
  }
@@ -5518,7 +6643,7 @@ var collectOutputs = async (directory) => {
5518
6643
  if (!metadata.isFile() || !pathMetadata.isFile() || metadata.dev !== pathMetadata.dev || metadata.ino !== pathMetadata.ino || metadata.size < 1 || metadata.size > maxAttachmentBytes) {
5519
6644
  throw new Error(`Output ${name} must be a regular file of 1 to ${maxAttachmentBytes} bytes`);
5520
6645
  }
5521
- const data = await handle.readFile();
6646
+ const data = await readBoundedFile(handle);
5522
6647
  if (data.length < 1 || data.length > maxAttachmentBytes) {
5523
6648
  throw new Error(`Output ${name} changed size while reading`);
5524
6649
  }
@@ -5568,6 +6693,7 @@ var valueOptions = new Set([
5568
6693
  "attach",
5569
6694
  "context",
5570
6695
  "context-file",
6696
+ "deadline",
5571
6697
  "direction",
5572
6698
  "grok-gateway",
5573
6699
  "error",
@@ -5580,9 +6706,12 @@ var valueOptions = new Set([
5580
6706
  "parent",
5581
6707
  "prompt",
5582
6708
  "prompt-file",
6709
+ "question",
6710
+ "request-id",
5583
6711
  "provider",
5584
6712
  "providers",
5585
6713
  "relay",
6714
+ "run-id",
5586
6715
  "sandbox",
5587
6716
  "target",
5588
6717
  "thread",
@@ -5618,11 +6747,14 @@ Usage:
5618
6747
  [--grok-gateway path]
5619
6748
  cohall config
5620
6749
  cohall devices
6750
+ cohall progress [task-id] --message <note>
6751
+ cohall request-input [task-id] --question <text> [--run-id uuid]
6752
+ cohall answer <task-id> --request-id uuid --message <text>
5621
6753
  cohall bots
5622
6754
  cohall send [@device-or-bot] [prompt] [delegate options]
5623
6755
  cohall delegate [prompt] [--target @device-or-bot] [--provider provider]
5624
6756
  [--context text] [--thread uuid] [--parent task-id] [--workspace path]
5625
- [--attach path] [--timeout seconds] [--no-wait]
6757
+ [--attach path] [--request-id uuid] [--timeout seconds] [--deadline UTC-timestamp] [--no-wait]
5626
6758
  cohall status <task-id>
5627
6759
  cohall inbox [ack <task-id>]
5628
6760
  cohall trace <task-id> [--follow]
@@ -5630,7 +6762,7 @@ Usage:
5630
6762
  cohall attachments <task-id>
5631
6763
  cohall download <task-id> <name> --output path [--direction input|output]
5632
6764
  cohall cancel <task-id>
5633
- cohall reply <task-id> --message-file path | --message text | --message - | --error text
6765
+ cohall reply <task-id> [--run-id uuid] --message-file path | --message text | --message - | --error text | --question text
5634
6766
  cohall thread <thread-id>
5635
6767
  cohall pair [--label name] [--client-only]
5636
6768
  cohall sessions
@@ -5825,17 +6957,21 @@ var printSkill = () => console.log(SKILL_default.trimEnd());
5825
6957
  var runCli = async (command, raw) => {
5826
6958
  const arguments_ = parseArguments(raw);
5827
6959
  if (command === "reply") {
5828
- allowOptions(arguments_, ["message", "message-file", "error"]);
6960
+ allowOptions(arguments_, ["message", "message-file", "error", "question", "run-id"]);
5829
6961
  const id = Schema11.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
5830
6962
  const error = option(arguments_, "error");
5831
- if (error !== undefined && (option(arguments_, "message") !== undefined || option(arguments_, "message-file") !== undefined)) {
5832
- throw new Error("Use either --error or --message/--message-file, not both");
5833
- }
5834
- const message = error === undefined ? await readInput(arguments_, "message") : undefined;
5835
- if (error === undefined && message === undefined) {
5836
- throw new Error("A bot reply requires --message, --message-file, or --error");
5837
- }
5838
- await writeBotReply(id, error === undefined ? { result: message ?? "" } : { error });
6963
+ const question = option(arguments_, "question");
6964
+ const rawRunId = option(arguments_, "run-id");
6965
+ const runId = rawRunId === undefined ? undefined : Schema11.decodeUnknownSync(TaskRunId)(rawRunId);
6966
+ if ((error !== undefined || question !== undefined) && (option(arguments_, "message") !== undefined || option(arguments_, "message-file") !== undefined || error !== undefined && question !== undefined)) {
6967
+ throw new Error("Use one of --question, --error, or --message/--message-file");
6968
+ }
6969
+ const message = error === undefined && question === undefined ? await readInput(arguments_, "message") : undefined;
6970
+ if (error === undefined && question === undefined && message === undefined) {
6971
+ throw new Error("A bot reply requires --message, --message-file, --error, or --question");
6972
+ }
6973
+ const reply = question !== undefined ? { question: Schema11.decodeUnknownSync(RequestTaskInput.fields.question)(question) } : error === undefined ? { result: message ?? "" } : { error };
6974
+ await writeBotReply(id, reply, runId);
5839
6975
  print({ submitted: true, task_id: id });
5840
6976
  return;
5841
6977
  }
@@ -6086,7 +7222,7 @@ var runCli = async (command, raw) => {
6086
7222
  const sandbox = option(arguments_, "sandbox");
6087
7223
  const model = option(arguments_, "model") ?? existing?.model;
6088
7224
  const gatewayInput = option(arguments_, "grok-gateway") ?? existing?.grokGateway;
6089
- const grokGateway = gatewayInput === undefined ? undefined : resolve3(gatewayInput);
7225
+ const grokGateway = gatewayInput === undefined ? undefined : resolve4(gatewayInput);
6090
7226
  const providerInput = option(arguments_, "providers");
6091
7227
  const providers = providerInput === undefined ? existing?.providers : providerInput === "auto" ? undefined : parseProviders(providerInput);
6092
7228
  const configuration2 = StoredConfiguration.make({
@@ -6168,20 +7304,31 @@ var runCli = async (command, raw) => {
6168
7304
  const mcp = clientToken === undefined ? { status: "not configured" } : await checkMcp(process.argv[1] ?? "", relayUrl, clientToken);
6169
7305
  const hasDeviceCredential = process.env.COHALL_DEVICE_TOKEN !== undefined || storedCredentials.deviceToken !== undefined;
6170
7306
  const deviceId = process.env.COHALL_DEVICE_ID ?? configuration2?.deviceId;
6171
- 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(() => {
6172
- return;
6173
- });
7307
+ 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({
7308
+ onSuccess: (devices) => ({ status: "ok", devices }),
7309
+ onFailure: (error) => {
7310
+ const httpStatus = error instanceof RelayRequestError ? error.status : undefined;
7311
+ const reason = httpStatus === 401 || httpStatus === 403 ? "Client credential was rejected by the relay" : "Client relay check failed";
7312
+ return {
7313
+ status: "error",
7314
+ ...httpStatus === undefined ? {} : { http_status: httpStatus },
7315
+ error: `${reason}: ${error.message}`
7316
+ };
7317
+ }
7318
+ })));
7319
+ const currentDevice = clientAuthentication.status === "ok" ? clientAuthentication.devices.find((device) => device.id === deviceId) : undefined;
6174
7320
  const versionWarning = deviceVersionWarning(version, currentDevice?.version);
6175
7321
  const warnings = [
6176
7322
  ...response?.ok === true ? [] : ["Relay is unreachable"],
6177
7323
  ...versionWarning === undefined ? [] : [versionWarning],
6178
7324
  ...mcp.status === "error" ? [`MCP check failed: ${mcp.error}`] : [],
6179
- ...hasDeviceCredential && response?.ok === true && currentDevice?.status !== "online" && currentDevice?.status !== "busy" ? [
7325
+ ...clientAuthentication.status === "error" ? [clientAuthentication.error] : [],
7326
+ ...hasDeviceCredential && response?.ok === true && clientAuthentication.status !== "error" && currentDevice?.status !== "online" && currentDevice?.status !== "busy" ? [
6180
7327
  clientToken === undefined ? "Device status cannot be checked without a client credential" : "Device daemon is not connected or is not registered with the relay"
6181
7328
  ] : [],
6182
- ...selectedProviders?.flatMap((provider) => provider === "grok-bot" ? grokBots === undefined ? [
7329
+ ...(selectedProviders ?? (grokGateway === undefined ? [] : ["grok-bot"])).flatMap((provider) => provider === "grok-bot" ? grokBots === undefined ? [
6183
7330
  "Configured Grok Bot gateway is unavailable; check its discovery file and host process"
6184
- ] : [] : providerExecutables[provider] === "not found" ? [`Configured provider ${provider} is not installed or not on PATH`] : []) ?? []
7331
+ ] : [] : providerExecutables[provider] === "not found" ? [`Configured provider ${provider} is not installed or not on PATH`] : [])
6185
7332
  ];
6186
7333
  print({
6187
7334
  version,
@@ -6189,6 +7336,7 @@ var runCli = async (command, raw) => {
6189
7336
  relay_url: relayUrl,
6190
7337
  config_path: configurationPath(),
6191
7338
  client_credential: process.env.COHALL_CLIENT_TOKEN !== undefined || storedCredentials.clientToken !== undefined,
7339
+ client_authentication: clientAuthentication.status === "ok" ? { status: "ok" } : clientAuthentication,
6192
7340
  mcp,
6193
7341
  device_credential: hasDeviceCredential,
6194
7342
  workspaces: configuration2?.workspaces ?? [],
@@ -6256,6 +7404,43 @@ var runCli = async (command, raw) => {
6256
7404
  return;
6257
7405
  }
6258
7406
  const { configuration, relay } = await client();
7407
+ if (command === "request-input") {
7408
+ allowOptions(arguments_, ["question", "run-id"]);
7409
+ const rawQuestion = option(arguments_, "question");
7410
+ if (rawQuestion === undefined)
7411
+ throw new Error("--question is required");
7412
+ const question = Schema11.decodeUnknownSync(RequestTaskInput.fields.question)(rawQuestion);
7413
+ const id = arguments_.positionals.length === 0 ? undefined : Schema11.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
7414
+ const rawRunId = option(arguments_, "run-id");
7415
+ const runId = rawRunId === undefined ? undefined : Schema11.decodeUnknownSync(TaskRunId)(rawRunId);
7416
+ print(await Effect10.runPromise(requestInput(relay, configuration, question, id, runId)));
7417
+ return;
7418
+ }
7419
+ if (command === "answer") {
7420
+ allowOptions(arguments_, ["request-id", "message", "message-file"]);
7421
+ const id = Schema11.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
7422
+ const requestId = option(arguments_, "request-id");
7423
+ if (requestId === undefined)
7424
+ throw new Error("--request-id is required; use the input request id from status or inbox");
7425
+ const input = Schema11.decodeUnknownSync(AnswerTaskInput)({
7426
+ requestId,
7427
+ answer: await readInput(arguments_, "message")
7428
+ });
7429
+ print(taskResult(await Effect10.runPromise(relay.answerTaskInput(id, input))));
7430
+ return;
7431
+ }
7432
+ if (command === "progress") {
7433
+ allowOptions(arguments_, ["message"]);
7434
+ const rawId = arguments_.positionals.length === 0 ? configuration.mcpTaskId : identifier(arguments_, "task id");
7435
+ if (rawId === undefined)
7436
+ throw new Error("Task id is required outside delegated work");
7437
+ const note = option(arguments_, "message");
7438
+ if (note === undefined)
7439
+ throw new Error("--message is required");
7440
+ const input = Schema11.decodeUnknownSync(TaskProgressInput)({ note });
7441
+ print(await Effect10.runPromise(relay.reportTaskProgress(Schema11.decodeUnknownSync(TaskId)(rawId), input)));
7442
+ return;
7443
+ }
6259
7444
  if (command === "devices") {
6260
7445
  allowOptions(arguments_, []);
6261
7446
  noPositionals(arguments_, command);
@@ -6272,12 +7457,14 @@ var runCli = async (command, raw) => {
6272
7457
  allowOptions(arguments_, [
6273
7458
  "context",
6274
7459
  "context-file",
7460
+ "deadline",
6275
7461
  "attach",
6276
7462
  "no-wait",
6277
7463
  "parent",
6278
7464
  "prompt",
6279
7465
  "prompt-file",
6280
7466
  "provider",
7467
+ "request-id",
6281
7468
  "target",
6282
7469
  "thread",
6283
7470
  "timeout",
@@ -6307,15 +7494,19 @@ var runCli = async (command, raw) => {
6307
7494
  const workspace = option(arguments_, "workspace");
6308
7495
  const provider = option(arguments_, "provider");
6309
7496
  const parent = option(arguments_, "parent");
7497
+ const deadline = option(arguments_, "deadline");
7498
+ const requestId = option(arguments_, "request-id");
6310
7499
  const task = await Effect10.runPromise(createDelegation(relay, configuration, {
6311
7500
  prompt,
7501
+ ...requestId === undefined ? {} : { requestId: Schema11.decodeUnknownSync(TaskRequestId)(requestId) },
6312
7502
  ...target === undefined ? {} : { target },
6313
7503
  ...context === undefined ? {} : { context },
6314
7504
  ...thread === undefined ? {} : { threadId: Schema11.decodeUnknownSync(ThreadId)(thread) },
6315
7505
  ...parent === undefined ? {} : { parentTaskId: Schema11.decodeUnknownSync(TaskId)(parent) },
6316
7506
  ...workspace === undefined ? {} : { workspace },
6317
7507
  ...provider === undefined ? {} : { provider: Schema11.decodeUnknownSync(Provider)(provider) },
6318
- ...attachments2.length === 0 ? {} : { attachments: attachments2 }
7508
+ ...attachments2.length === 0 ? {} : { attachments: attachments2 },
7509
+ ...deadline === undefined ? {} : { expiresAt: Schema11.decodeUnknownSync(TaskDeadline)(deadline) }
6319
7510
  }));
6320
7511
  const completed = arguments_.options.has("no-wait") ? task : await Effect10.runPromise(waitForTask(relay, task, waitSeconds));
6321
7512
  print(arguments_.options.has("no-wait") ? taskResult(completed) : await acknowledgedTaskResult(relay, completed));
@@ -6361,7 +7552,7 @@ var runCli = async (command, raw) => {
6361
7552
  const direction = directionInput === undefined ? undefined : Schema11.decodeUnknownSync(AttachmentDirection)(directionInput);
6362
7553
  const data = await Effect10.runPromise(relay.readAttachment(id, name, direction));
6363
7554
  await writeFile7(destination, data, { flag: "wx", mode: 384 });
6364
- print({ task_id: id, name, path: resolve3(destination), bytes: data.length });
7555
+ print({ task_id: id, name, path: resolve4(destination), bytes: data.length });
6365
7556
  return;
6366
7557
  }
6367
7558
  if (command === "trace") {
@@ -6377,8 +7568,7 @@ var runCli = async (command, raw) => {
6377
7568
  if (command === "wait") {
6378
7569
  allowOptions(arguments_, ["timeout"]);
6379
7570
  const id = Schema11.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
6380
- const task = await Effect10.runPromise(relay.getTask(id));
6381
- print(taskResult(await Effect10.runPromise(waitForTask(relay, task, timeout(arguments_)))));
7571
+ print(taskResult(await Effect10.runPromise(waitForTask(relay, id, timeout(arguments_)))));
6382
7572
  return;
6383
7573
  }
6384
7574
  if (command === "cancel") {
@@ -6400,9 +7590,9 @@ var runCli = async (command, raw) => {
6400
7590
  init_src();
6401
7591
  import { Effect as Effect11, Schedule as Schedule3, Schema as Schema12 } from "effect";
6402
7592
  import { constants as constants4 } from "node:fs";
6403
- import { open as open4, realpath as realpath5, stat as stat5 } from "node:fs/promises";
7593
+ import { open as open4, realpath as realpath4, stat as stat5 } from "node:fs/promises";
6404
7594
  import { arch, hostname as hostname3, platform as platform4 } from "node:os";
6405
- import { basename as basename3, isAbsolute as isAbsolute3, relative as relative2 } from "node:path";
7595
+ import { basename as basename3, isAbsolute as isAbsolute4, relative as relative2 } from "node:path";
6406
7596
  import { WebSocket as WebSocket2 } from "ws";
6407
7597
  var maxQueuedRelayMessages = 8;
6408
7598
 
@@ -6435,6 +7625,8 @@ var capabilities = (providers) => {
6435
7625
  if (providers.some((provider) => provider !== "grok-bot")) {
6436
7626
  values2.push({ id: "task-attachments", label: "Task file attachments" });
6437
7627
  }
7628
+ values2.push({ id: "task-clarification", label: "Task clarification and resume" });
7629
+ values2.push({ id: "task-deadlines", label: "Task deadlines" });
6438
7630
  if (findExecutable("google-chrome") !== undefined || findExecutable("chromium") !== undefined || platform4() === "darwin") {
6439
7631
  values2.push({ id: "browser-session", label: "Signed-in browser" });
6440
7632
  }
@@ -6478,10 +7670,10 @@ var allowedWorkspace = async (configuration, requested) => {
6478
7670
  if (selected === undefined) {
6479
7671
  throw new Error("No workspace is configured on this device");
6480
7672
  }
6481
- const candidate = await realpath5(selected);
7673
+ const candidate = await realpath4(selected);
6482
7674
  const allowed = configuration.workspaces.some((root) => {
6483
7675
  const child = relative2(root, candidate);
6484
- return child === "" || !child.startsWith("..") && !isAbsolute3(child);
7676
+ return child === "" || !child.startsWith("..") && !isAbsolute4(child);
6485
7677
  });
6486
7678
  if (!allowed) {
6487
7679
  throw new Error(`Workspace ${candidate} is outside this device's configured workspace roots`);
@@ -6507,7 +7699,7 @@ var openAllowedWorkspace = async (configuration, requested) => {
6507
7699
  throw new Error(`Workspace ${path} changed before provider startup`);
6508
7700
  }
6509
7701
  if (operatingSystem2 !== "linux") {
6510
- const currentPath = await realpath5(path);
7702
+ const currentPath = await realpath4(path);
6511
7703
  const currentPathIdentity = await stat5(currentPath);
6512
7704
  if (currentPathIdentity.dev !== identity.dev || currentPathIdentity.ino !== identity.ino) {
6513
7705
  throw new Error(`Workspace ${path} changed before provider startup`);
@@ -6517,7 +7709,7 @@ var openAllowedWorkspace = async (configuration, requested) => {
6517
7709
  close: () => handle.close()
6518
7710
  };
6519
7711
  };
6520
- var promptFor = (task, deviceName, files) => {
7712
+ var promptFor = (task, deviceName, supportsClarification, files) => {
6521
7713
  const context = task.context === undefined ? "" : `
6522
7714
 
6523
7715
  Relevant context supplied by the sending agent:
@@ -6527,6 +7719,10 @@ ${task.context}`;
6527
7719
  "Complete the delegated task using this device's local workspace, tools, credentials, and signed-in services.",
6528
7720
  "Never read, reveal, copy, or use Cohall configuration files or Cohall authentication tokens.",
6529
7721
  "Return a concise, complete result with the evidence the sending agent needs.",
7722
+ "For long tasks, optionally report a brief milestone with `cohall progress --message 'Running tests'`. Do not include logs or secrets in progress notes.",
7723
+ ...supportsClarification ? [
7724
+ "If essential information is missing, run `cohall request-input --question 'Your question'` or the task_request_input MCP tool, then end this turn immediately. Cohall will pause the task and resume it when the sender answers. Do not guess, keep working, or report a final result after requesting input."
7725
+ ] : [],
6530
7726
  ...files === undefined ? [] : [
6531
7727
  ...files.inputNames.length === 0 ? [] : [
6532
7728
  `Input directory: ${files.input}`,
@@ -6537,7 +7733,13 @@ ${task.context}`;
6537
7733
  ],
6538
7734
  `
6539
7735
  Task:
6540
- ${task.prompt}${context}`
7736
+ ${task.prompt}${context}`,
7737
+ ...(task.clarifications ?? []).flatMap((question) => question.answer === undefined ? [] : [
7738
+ `Clarification question:
7739
+ ${question.question}
7740
+ Sender's answer:
7741
+ ${question.answer.text}`
7742
+ ])
6541
7743
  ].join(`
6542
7744
  `);
6543
7745
  };
@@ -6546,6 +7748,7 @@ var send = (state, event) => {
6546
7748
  state.socket.send(JSON.stringify(event));
6547
7749
  }
6548
7750
  };
7751
+ var taskRunKey = (taskId, runId) => `${taskId}:${runId ?? ""}`;
6549
7752
  var omitOutputFiles = (event, note) => {
6550
7753
  if (event._tag !== "TaskFinished" || (event.attachments?.length ?? 0) === 0) {
6551
7754
  return event;
@@ -6553,16 +7756,17 @@ var omitOutputFiles = (event, note) => {
6553
7756
  const message = `
6554
7757
 
6555
7758
  [Output files omitted: ${note}]`;
6556
- return SocketEvent.make({
7759
+ return SocketEvent.cases.TaskFinished.make({
6557
7760
  _tag: "TaskFinished",
6558
7761
  taskId: event.taskId,
7762
+ ...event.runId === undefined ? {} : { runId: event.runId },
6559
7763
  result: `${event.result.slice(0, 131072 - message.length)}${message}`,
6560
7764
  ...event.providerSessionId === undefined ? {} : { providerSessionId: event.providerSessionId }
6561
7765
  });
6562
7766
  };
6563
- var sendTerminal = (state, taskId, event) => {
7767
+ var sendTerminal = (state, event) => {
6564
7768
  const safeEvent = state.supportsAttachments ? event : omitOutputFiles(event, "the relay does not support attachments. Upgrade the relay and retry.");
6565
- state.terminal.set(taskId, safeEvent);
7769
+ state.terminal.set(taskRunKey(event.taskId, event.runId), safeEvent);
6566
7770
  if (state.socket?.readyState === WebSocket2.OPEN) {
6567
7771
  state.socket.send(JSON.stringify(safeEvent));
6568
7772
  }
@@ -6574,12 +7778,12 @@ var sendOperationTerminal = (state, operationId, event) => {
6574
7778
  state.socket.send(payload);
6575
7779
  }
6576
7780
  };
6577
- var remember = (state, taskId) => {
6578
- state.completed.add(taskId);
7781
+ var remember = (state, task) => {
7782
+ state.completed.add(taskRunKey(task.id, task.runId));
6579
7783
  if (state.completed.size <= 1000) {
6580
7784
  return;
6581
7785
  }
6582
- const oldest = state.completed.values().next();
7786
+ const oldest = state.completed.keys().next();
6583
7787
  if (!oldest.done) {
6584
7788
  state.completed.delete(oldest.value);
6585
7789
  state.terminal.delete(oldest.value);
@@ -6597,8 +7801,9 @@ var rememberOperation = (state, operationId) => {
6597
7801
  }
6598
7802
  };
6599
7803
  var sessionKey = (task) => `${task.threadId}:${task.provider}`;
7804
+ var hasCompletedRun = (state, task) => state.completed.has(taskRunKey(task.id, task.runId));
6600
7805
  var drain = (configuration, state) => {
6601
- if (state.operation !== undefined) {
7806
+ if (state.stopping || state.operation !== undefined) {
6602
7807
  return;
6603
7808
  }
6604
7809
  if (state.tasks.size === 0) {
@@ -6621,16 +7826,37 @@ var drain = (configuration, state) => {
6621
7826
  }
6622
7827
  };
6623
7828
  var execute = (configuration, state, task) => {
6624
- if (state.tasks.has(task.id) || state.completed.has(task.id)) {
7829
+ if (state.tasks.has(task.id) || hasCompletedRun(state, task)) {
6625
7830
  return;
6626
7831
  }
6627
7832
  const controller = new AbortController;
6628
- state.tasks.set(task.id, { task, controller });
6629
- send(state, SocketEvent.make({ _tag: "TaskAccepted", taskId: task.id }));
6630
- const workflow = Effect11.gen(function* () {
7833
+ let deadlineTimer;
7834
+ const enforceDeadline = () => {
7835
+ if (task.expiresAt === undefined)
7836
+ return;
7837
+ const remaining = Date.parse(task.expiresAt) - Date.now();
7838
+ if (remaining <= 0) {
7839
+ controller.abort();
7840
+ return;
7841
+ }
7842
+ deadlineTimer = setTimeout(enforceDeadline, Math.min(remaining, 2147483647));
7843
+ };
7844
+ enforceDeadline();
7845
+ send(state, SocketEvent.make({
7846
+ _tag: "TaskAccepted",
7847
+ taskId: task.id,
7848
+ ...task.runId === undefined ? {} : { runId: task.runId }
7849
+ }));
7850
+ const workflow = Effect11.scoped(Effect11.gen(function* () {
7851
+ if (configuration.providers !== undefined && !configuration.providers.includes(task.provider)) {
7852
+ return yield* new ProviderRunError({
7853
+ provider: task.provider,
7854
+ message: `${task.provider} is not enabled on this device. Update its configured providers and restart the worker before retrying.`
7855
+ });
7856
+ }
6631
7857
  if (task.provider === "grok-bot") {
6632
7858
  return yield* Effect11.tryPromise({
6633
- try: (signal) => runGrokBot(configuration.grokGateway, task, signal),
7859
+ try: (signal) => runGrokBot(configuration.grokGateway, task, signal, state.supportsClarification),
6634
7860
  catch: (cause) => new ProviderRunError({
6635
7861
  provider: task.provider,
6636
7862
  message: cause instanceof Error ? cause.message : String(cause)
@@ -6644,68 +7870,84 @@ var execute = (configuration, state, task) => {
6644
7870
  message: "The relay does not support task file attachments"
6645
7871
  });
6646
7872
  }
6647
- const workspace = yield* Effect11.tryPromise({
7873
+ const workspace = yield* Effect11.acquireRelease(Effect11.tryPromise({
6648
7874
  try: () => openAllowedWorkspace(configuration, task.workspace),
6649
7875
  catch: (cause) => new ProviderRunError({
6650
7876
  provider,
6651
7877
  message: cause instanceof Error ? cause.message : String(cause)
6652
7878
  })
6653
- });
6654
- return yield* Effect11.gen(function* () {
6655
- const files = state.supportsAttachments ? yield* Effect11.tryPromise({
6656
- try: (signal) => prepareTaskFiles(configuration, task.id, task.inputAttachmentNames ?? [], signal),
6657
- catch: (cause) => new ProviderRunError({
6658
- provider,
6659
- message: cause instanceof Error ? cause.message : String(cause)
6660
- })
6661
- }) : undefined;
6662
- const sessionId = task.providerSessionId ?? state.sessions.get(sessionKey(task));
6663
- return yield* run({
6664
- provider,
6665
- threadId: task.threadId,
6666
- taskId: task.id,
6667
- prompt: promptFor(task, configuration.name, files),
6668
- cwd: workspace.cwd,
6669
- beforeSpawn: workspace.validate,
6670
- ...sessionId === undefined ? {} : { sessionId },
6671
- ...configuration.model === undefined ? {} : { model: configuration.model },
6672
- ...configuration.sandbox === undefined ? {} : { sandbox: configuration.sandbox }
6673
- }).pipe(Effect11.flatMap((result) => files === undefined ? Effect11.succeed(result) : Effect11.tryPromise({
6674
- try: async () => ({ ...result, attachments: await files.collectOutputs() }),
6675
- catch: (cause) => new ProviderRunError({
6676
- provider,
6677
- message: cause instanceof Error ? cause.message : String(cause)
6678
- })
6679
- })), Effect11.ensuring(files === undefined ? Effect11.void : Effect11.promise(() => files.cleanup())));
6680
- }).pipe(Effect11.ensuring(Effect11.promise(() => workspace.close().catch(() => {
7879
+ }), (workspace2) => Effect11.promise(() => workspace2.close().catch(() => {
6681
7880
  return;
6682
- }))));
6683
- });
6684
- Effect11.runPromise(workflow, { signal: controller.signal }).then((result) => {
6685
- const finished = SocketEvent.make({
6686
- _tag: "TaskFinished",
7881
+ })));
7882
+ const files = state.supportsAttachments ? yield* prepareTaskFiles(configuration, task.id, task.inputAttachmentNames ?? []).pipe(Effect11.mapError((cause) => new ProviderRunError({
7883
+ provider,
7884
+ message: cause instanceof Error ? cause.message : String(cause)
7885
+ }))) : undefined;
7886
+ const sessionId = task.providerSessionId ?? state.sessions.get(sessionKey(task));
7887
+ return yield* run({
7888
+ provider,
7889
+ threadId: task.threadId,
6687
7890
  taskId: task.id,
6688
- result: result.result,
6689
- ...result.sessionId === undefined ? {} : { providerSessionId: result.sessionId },
6690
- ...result.attachments === undefined || result.attachments.length === 0 ? {} : { attachments: result.attachments }
6691
- });
7891
+ prompt: promptFor(task, configuration.name, state.supportsClarification, files),
7892
+ cwd: workspace.cwd,
7893
+ beforeSpawn: workspace.validate,
7894
+ ...sessionId === undefined ? {} : { sessionId },
7895
+ ...task.runId === undefined ? {} : { runId: task.runId },
7896
+ ...configuration.model === undefined ? {} : { model: configuration.model },
7897
+ ...configuration.sandbox === undefined ? {} : { sandbox: configuration.sandbox }
7898
+ }).pipe(Effect11.flatMap((result) => files === undefined ? Effect11.succeed(result) : Effect11.tryPromise({
7899
+ try: async () => ({ ...result, attachments: await files.collectOutputs() }),
7900
+ catch: (cause) => new ProviderRunError({
7901
+ provider,
7902
+ message: cause instanceof Error ? cause.message : String(cause)
7903
+ })
7904
+ }).pipe(Effect11.uninterruptible)));
7905
+ }));
7906
+ const done = Effect11.runPromise(workflow, { signal: controller.signal }).then((result) => {
7907
+ let finished;
7908
+ if (result.question !== undefined) {
7909
+ if (!state.supportsClarification || task.runId === undefined)
7910
+ throw new Error("Upgrade the relay before requesting clarification");
7911
+ finished = SocketEvent.cases.TaskInputRequested.make({
7912
+ _tag: "TaskInputRequested",
7913
+ taskId: task.id,
7914
+ runId: task.runId,
7915
+ question: result.question
7916
+ });
7917
+ } else {
7918
+ finished = SocketEvent.cases.TaskFinished.make({
7919
+ _tag: "TaskFinished",
7920
+ taskId: task.id,
7921
+ ...task.runId === undefined ? {} : { runId: task.runId },
7922
+ result: result.result,
7923
+ ...result.sessionId === undefined ? {} : { providerSessionId: result.sessionId },
7924
+ ...result.attachments === undefined || result.attachments.length === 0 ? {} : { attachments: result.attachments }
7925
+ });
7926
+ }
6692
7927
  const bounded2 = Buffer.byteLength(JSON.stringify(finished)) > maxSocketPayloadBytes ? omitOutputFiles(finished, "the combined result exceeded the 1 MiB transfer limit. Shorten the result or files and retry.") : finished;
6693
7928
  if (result.sessionId !== undefined) {
6694
7929
  state.sessions.set(sessionKey(task), result.sessionId);
6695
7930
  }
6696
- remember(state, task.id);
6697
- sendTerminal(state, task.id, bounded2);
7931
+ remember(state, task);
7932
+ sendTerminal(state, bounded2);
6698
7933
  }).catch((cause) => {
6699
- remember(state, task.id);
6700
- sendTerminal(state, task.id, controller.signal.aborted ? SocketEvent.make({ _tag: "TaskCancelled", taskId: task.id }) : SocketEvent.make({
7934
+ remember(state, task);
7935
+ sendTerminal(state, controller.signal.aborted ? SocketEvent.cases.TaskCancelled.make({
7936
+ _tag: "TaskCancelled",
7937
+ taskId: task.id,
7938
+ ...task.runId === undefined ? {} : { runId: task.runId }
7939
+ }) : SocketEvent.cases.TaskFailed.make({
6701
7940
  _tag: "TaskFailed",
6702
7941
  taskId: task.id,
7942
+ ...task.runId === undefined ? {} : { runId: task.runId },
6703
7943
  error: (cause instanceof Error ? cause.message : String(cause)).slice(0, 16384)
6704
7944
  }));
6705
7945
  }).finally(() => {
7946
+ clearTimeout(deadlineTimer);
6706
7947
  state.tasks.delete(task.id);
6707
7948
  drain(configuration, state);
6708
7949
  });
7950
+ state.tasks.set(task.id, { task, controller, done });
6709
7951
  };
6710
7952
  var executeOperation = (configuration, state, operation) => {
6711
7953
  if (state.operation !== undefined || state.completedOperations.has(operation.id)) {
@@ -6733,6 +7975,8 @@ var executeOperation = (configuration, state, operation) => {
6733
7975
  });
6734
7976
  };
6735
7977
  var scheduleOperation = (configuration, state, operation) => {
7978
+ if (state.stopping)
7979
+ return;
6736
7980
  if (state.completedOperations.has(operation.id) || state.operation?.id === operation.id || state.operationQueue.some((pending) => pending.id === operation.id)) {
6737
7981
  return;
6738
7982
  }
@@ -6754,7 +7998,18 @@ var performDeviceOperation = (operation, currentVersion, runUpgrade = upgrade) =
6754
7998
  delegated: true
6755
7999
  }).then((result) => JSON.stringify(result));
6756
8000
  var schedule = (configuration, state, task) => {
6757
- if (state.tasks.has(task.id) || state.completed.has(task.id) || state.queue.some((queued) => queued.id === task.id)) {
8001
+ if (state.stopping)
8002
+ return;
8003
+ if (hasCompletedRun(state, task) || state.queue.some((queued) => queued.id === task.id && queued.runId === task.runId)) {
8004
+ return;
8005
+ }
8006
+ const running = state.tasks.get(task.id);
8007
+ if (running !== undefined && running.task.runId === task.runId) {
8008
+ send(state, SocketEvent.make({
8009
+ _tag: "TaskAccepted",
8010
+ taskId: task.id,
8011
+ ...task.runId === undefined ? {} : { runId: task.runId }
8012
+ }));
6758
8013
  return;
6759
8014
  }
6760
8015
  if (task.providerSessionId !== undefined) {
@@ -6770,20 +8025,24 @@ var schedule = (configuration, state, task) => {
6770
8025
  }
6771
8026
  execute(configuration, state, task);
6772
8027
  };
6773
- var cancel = (state, taskId) => {
8028
+ var cancel = (state, taskId, runId) => {
6774
8029
  const running = state.tasks.get(taskId);
6775
- if (running !== undefined) {
8030
+ if (running !== undefined && (runId === undefined || running.task.runId === runId)) {
6776
8031
  if (running.task.provider !== "grok-bot") {
6777
8032
  running.controller.abort();
6778
8033
  }
6779
8034
  return;
6780
8035
  }
6781
- const index = state.queue.findIndex((task) => task.id === taskId);
8036
+ const index = state.queue.findIndex((task) => task.id === taskId && (runId === undefined || task.runId === runId));
6782
8037
  if (index !== -1) {
6783
8038
  state.queue.splice(index, 1);
6784
8039
  }
6785
- remember(state, taskId);
6786
- sendTerminal(state, taskId, SocketEvent.make({ _tag: "TaskCancelled", taskId }));
8040
+ remember(state, { id: taskId, ...runId === undefined ? {} : { runId } });
8041
+ sendTerminal(state, SocketEvent.cases.TaskCancelled.make({
8042
+ _tag: "TaskCancelled",
8043
+ taskId,
8044
+ ...runId === undefined ? {} : { runId }
8045
+ }));
6787
8046
  };
6788
8047
  var connect = (configuration, state) => Effect11.tryPromise({
6789
8048
  try: (signal) => new Promise((complete) => {
@@ -6792,6 +8051,15 @@ var connect = (configuration, state) => Effect11.tryPromise({
6792
8051
  perMessageDeflate: false,
6793
8052
  allowSynchronousEvents: false
6794
8053
  });
8054
+ let closed = false;
8055
+ let connectionDeadline = setTimeout(() => socket.terminate(), 1e4);
8056
+ const resetConnectionDeadline = () => {
8057
+ if (closed || state.socket !== socket)
8058
+ return;
8059
+ clearTimeout(connectionDeadline);
8060
+ connectionDeadline = setTimeout(() => socket.terminate(), 45000);
8061
+ };
8062
+ socket.on("ping", resetConnectionDeadline);
6795
8063
  let bots = [];
6796
8064
  let refreshing = false;
6797
8065
  const refreshBots = async () => {
@@ -6819,13 +8087,13 @@ var connect = (configuration, state) => Effect11.tryPromise({
6819
8087
  }));
6820
8088
  refreshBots();
6821
8089
  }, 15000);
6822
- let closed = false;
6823
8090
  let queuedMessages = 0;
6824
8091
  const close = () => {
6825
8092
  if (closed) {
6826
8093
  return;
6827
8094
  }
6828
8095
  closed = true;
8096
+ clearTimeout(connectionDeadline);
6829
8097
  clearInterval(heartbeat);
6830
8098
  if (state.socket === socket) {
6831
8099
  state.socket = undefined;
@@ -6833,7 +8101,7 @@ var connect = (configuration, state) => Effect11.tryPromise({
6833
8101
  complete();
6834
8102
  };
6835
8103
  signal.addEventListener("abort", () => {
6836
- socket.close();
8104
+ socket.terminate();
6837
8105
  close();
6838
8106
  }, { once: true });
6839
8107
  socket.once("open", () => {
@@ -6856,14 +8124,16 @@ var connect = (configuration, state) => Effect11.tryPromise({
6856
8124
  }).pipe(Effect11.flatMap(decodeSocketEvent)));
6857
8125
  if (event._tag === "Connected") {
6858
8126
  state.supportsAttachments = event.taskAttachments === true;
8127
+ state.supportsClarification = event.taskClarification === true;
6859
8128
  state.socket = socket;
8129
+ resetConnectionDeadline();
6860
8130
  socket.send(JSON.stringify(SocketEvent.make({
6861
8131
  _tag: "DeviceHello",
6862
8132
  device: describeDevice(configuration, state.tasks.size > 0 || state.operation !== undefined ? "busy" : "online", bots)
6863
8133
  })));
6864
- for (const [taskId, pending] of state.terminal) {
8134
+ for (const [key, pending] of state.terminal) {
6865
8135
  const safeEvent = state.supportsAttachments ? pending : omitOutputFiles(pending, "the relay does not support attachments. Upgrade the relay and retry.");
6866
- state.terminal.set(taskId, safeEvent);
8136
+ state.terminal.set(key, safeEvent);
6867
8137
  socket.send(JSON.stringify(safeEvent));
6868
8138
  }
6869
8139
  for (const payload of state.operationTerminal.values()) {
@@ -6877,12 +8147,12 @@ var connect = (configuration, state) => Effect11.tryPromise({
6877
8147
  return;
6878
8148
  }
6879
8149
  if (event._tag === "CancelTask") {
6880
- cancel(state, event.taskId);
8150
+ cancel(state, event.taskId, event.runId);
6881
8151
  return;
6882
8152
  }
6883
8153
  if (event._tag === "TaskSettled") {
6884
- state.terminal.delete(event.taskId);
6885
- cleanupBotReply(event.taskId).catch(() => {
8154
+ state.terminal.delete(taskRunKey(event.taskId, event.runId));
8155
+ cleanupBotReply(event.taskId, event.runId).catch(() => {
6886
8156
  console.error(`Could not remove local bot reply for settled task ${event.taskId}`);
6887
8157
  });
6888
8158
  return;
@@ -6914,7 +8184,9 @@ var connect = (configuration, state) => Effect11.tryPromise({
6914
8184
  var runDaemon = (configuration) => {
6915
8185
  const state = {
6916
8186
  socket: undefined,
8187
+ stopping: false,
6917
8188
  supportsAttachments: false,
8189
+ supportsClarification: false,
6918
8190
  processing: Promise.resolve(),
6919
8191
  terminal: new Map,
6920
8192
  queue: [],
@@ -6926,11 +8198,14 @@ var runDaemon = (configuration) => {
6926
8198
  operationTerminal: new Map,
6927
8199
  completedOperations: new Set
6928
8200
  };
6929
- return connect(configuration, state).pipe(Effect11.repeat({ schedule: Schedule3.spaced("2 seconds") }), Effect11.ensuring(Effect11.sync(() => {
8201
+ return connect(configuration, state).pipe(Effect11.repeat({ schedule: Schedule3.spaced("2 seconds") }), Effect11.ensuring(Effect11.promise(async () => {
8202
+ state.stopping = true;
6930
8203
  state.socket?.close();
6931
- for (const { controller } of state.tasks.values()) {
8204
+ const running = [...state.tasks.values()];
8205
+ for (const { controller } of running) {
6932
8206
  controller.abort();
6933
8207
  }
8208
+ await Promise.allSettled(running.map(({ done }) => done));
6934
8209
  })));
6935
8210
  };
6936
8211
 
@@ -6941,15 +8216,99 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6941
8216
  import { Effect as Effect12, Schema as Schema13 } from "effect";
6942
8217
  import * as z from "zod/v4";
6943
8218
  import { writeFile as writeFile8 } from "node:fs/promises";
6944
- var output = (value) => ({
6945
- content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
8219
+
8220
+ // apps/device/src/mcp-build-notice.ts
8221
+ import { execFile as execFile3 } from "node:child_process";
8222
+ import { stat as stat6 } from "node:fs/promises";
8223
+ import { promisify } from "node:util";
8224
+ var execute2 = promisify(execFile3);
8225
+ var fingerprint = async (entrypoint) => stat6(entrypoint, { bigint: true }).then((file) => `${file.dev}:${file.ino}:${file.size}:${file.mtimeNs}:${file.ctimeNs}`).catch(() => {
8226
+ return;
6946
8227
  });
8228
+ var createMcpBuildNotice = async (entrypoint, runningVersion, {
8229
+ checkIntervalMs = 60000,
8230
+ probeTimeoutMs = 2000,
8231
+ now: now2 = Date.now
8232
+ } = {}) => {
8233
+ let previous = entrypoint === undefined ? undefined : await fingerprint(entrypoint);
8234
+ let nextCheckAt = 0;
8235
+ const reported = new Set;
8236
+ return async (result) => {
8237
+ if (entrypoint === undefined || now2() < nextCheckAt)
8238
+ return result;
8239
+ nextCheckAt = now2() + checkIntervalMs;
8240
+ const current = await fingerprint(entrypoint);
8241
+ if (current === undefined || current === previous)
8242
+ return result;
8243
+ const installedVersion2 = await execute2(process.execPath, [entrypoint, "--version"], {
8244
+ timeout: probeTimeoutMs,
8245
+ killSignal: "SIGKILL",
8246
+ maxBuffer: 4096,
8247
+ windowsHide: true
8248
+ }).then(({ stdout }) => stdout.trim()).catch(() => {
8249
+ return;
8250
+ });
8251
+ if (installedVersion2 === undefined || !/^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test(installedVersion2)) {
8252
+ return result;
8253
+ }
8254
+ previous = current;
8255
+ if (installedVersion2 === runningVersion || reported.has(installedVersion2))
8256
+ return result;
8257
+ reported.add(installedVersion2);
8258
+ return {
8259
+ ...result,
8260
+ content: [
8261
+ ...result.content,
8262
+ {
8263
+ type: "text",
8264
+ text: `Cohall MCP is still running ${runningVersion}; its executable is now ${installedVersion2}. Restart the Cohall MCP connection in your agent host to load it.`
8265
+ }
8266
+ ]
8267
+ };
8268
+ };
8269
+ };
8270
+
8271
+ // apps/device/src/mcp.ts
6947
8272
  var runMcp = async (configuration) => {
8273
+ const buildNotice = await createMcpBuildNotice(process.argv[1], version);
8274
+ const output = (value) => buildNotice({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
6948
8275
  const client2 = exports_src.make({
6949
8276
  baseUrl: configuration.relayUrl,
6950
8277
  token: configuration.token
6951
8278
  });
6952
8279
  const server = new McpServer({ name: "cohall", version });
8280
+ server.registerTool("task_request_input", {
8281
+ title: "Ask for task clarification",
8282
+ description: "Ask the original sender for essential missing information. After this succeeds, end the current worker turn immediately. Cohall pauses when the turn exits and resumes the same task after an answer. Inherits the delegated task and turn when omitted.",
8283
+ inputSchema: {
8284
+ task_id: z.string().uuid().optional(),
8285
+ run_id: z.string().uuid().optional(),
8286
+ question: z.string().min(1).max(maxClarificationBytes)
8287
+ }
8288
+ }, async ({ task_id, run_id, question }) => output(await Effect12.runPromise(requestInput(client2, configuration, Schema13.decodeUnknownSync(RequestTaskInput.fields.question)(question), task_id === undefined ? undefined : Schema13.decodeUnknownSync(TaskId)(task_id), run_id === undefined ? undefined : Schema13.decodeUnknownSync(TaskRunId)(run_id)))));
8289
+ server.registerTool("task_answer", {
8290
+ title: "Answer a worker's question",
8291
+ description: "Answer the current clarification and resume the same task. Use input_request.id from task_status or delegate as request_id, or inputRequest.id from completion_inbox. Only the original requester or relay owner can answer. Use wait_task to collect the resumed result.",
8292
+ inputSchema: {
8293
+ task_id: z.string().uuid(),
8294
+ request_id: z.string().uuid(),
8295
+ answer: z.string().min(1).max(maxClarificationBytes)
8296
+ }
8297
+ }, async ({ task_id, request_id, answer }) => output(taskResult(await Effect12.runPromise(client2.answerTaskInput(Schema13.decodeUnknownSync(TaskId)(task_id), Schema13.decodeUnknownSync(AnswerTaskInput)({ requestId: request_id, answer }))))));
8298
+ server.registerTool("task_progress", {
8299
+ title: "Report task progress",
8300
+ 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.`,
8301
+ inputSchema: {
8302
+ task_id: z.string().uuid().optional(),
8303
+ note: z.string().min(1).max(maxProgressNoteBytes)
8304
+ }
8305
+ }, async ({ task_id, note }) => {
8306
+ const id = task_id ?? configuration.mcpTaskId;
8307
+ if (id === undefined)
8308
+ throw new Error("Task id is required outside delegated work");
8309
+ const input = Schema13.decodeUnknownSync(TaskProgressInput)({ note });
8310
+ return output(await Effect12.runPromise(client2.reportTaskProgress(Schema13.decodeUnknownSync(TaskId)(id), input)));
8311
+ });
6953
8312
  server.registerTool("list_devices", {
6954
8313
  title: "List Cohall devices",
6955
8314
  description: "List devices, their availability, local providers, capabilities, and allowed workspaces.",
@@ -6965,6 +8324,7 @@ var runMcp = async (configuration) => {
6965
8324
  description: "Send focused work to a Cohall device or named Grok Bot. When the request depends on the current conversation, distill its motivation, relevant facts, prior findings, constraints, and desired decision into context; Cohall cannot read the host transcript. Never forward unrelated transcript content. Reuse thread_id for related follow-ups. Grok Bots use their existing conversation; a Cohall thread does not isolate it.",
6966
8325
  inputSchema: {
6967
8326
  prompt: z.string().min(1).max(131072),
8327
+ request_id: z.string().uuid().optional().describe("A UUID v4 generated before submitting. Reuse with identical input and the same client credential after a lost response to recover the original task. Use stable device IDs or device/bot IDs for retries. For thread follow-ups, select a target or provider explicitly; select Bots explicitly."),
6968
8328
  target: z.string().optional().describe("Device name, hostname or ID; @BotName; or @device/BotName. Use list_bots targets to disambiguate."),
6969
8329
  provider: z.enum(Provider.literals).optional().describe("Inferred as grok-bot for a bot target; otherwise defaults to codex."),
6970
8330
  context: z.string().max(131072).optional().describe("Distilled context the target needs: why the user is asking, relevant facts and prior findings, constraints, and the intended decision. The calling agent must supply this when the prompt depends on its conversation; omit it only for a self-contained task."),
@@ -6973,10 +8333,12 @@ var runMcp = async (configuration) => {
6973
8333
  workspace: z.string().max(4096).optional().describe("Coding provider workspace. Omit for named Grok Bots."),
6974
8334
  attachment_paths: z.array(z.string()).max(2).optional().describe("Local files to send with the task, up to 2 files of 256 KiB each. Coding providers only."),
6975
8335
  wait: z.boolean().default(true),
6976
- timeout_seconds: z.number().int().min(5).max(86400).default(900)
8336
+ timeout_seconds: z.number().int().min(5).max(86400).default(900),
8337
+ deadline: z.string().optional().describe("Absolute ISO-8601 UTC deadline for coding work, preserved through clarification. Overdue work stops; timeout_seconds only limits waiting.")
6977
8338
  }
6978
8339
  }, async ({
6979
8340
  prompt,
8341
+ request_id,
6980
8342
  target,
6981
8343
  provider,
6982
8344
  context,
@@ -6985,25 +8347,28 @@ var runMcp = async (configuration) => {
6985
8347
  workspace,
6986
8348
  attachment_paths,
6987
8349
  wait,
6988
- timeout_seconds
6989
- }) => {
8350
+ timeout_seconds,
8351
+ deadline
8352
+ }, { signal }) => {
6990
8353
  const attachments2 = await readInputAttachments(attachment_paths ?? []);
6991
8354
  const task = await Effect12.runPromise(createDelegation(client2, configuration, {
6992
8355
  prompt,
8356
+ ...request_id === undefined ? {} : { requestId: Schema13.decodeUnknownSync(TaskRequestId)(request_id) },
6993
8357
  ...provider === undefined ? {} : { provider },
6994
8358
  ...target === undefined ? {} : { target },
6995
8359
  ...context === undefined ? {} : { context },
6996
8360
  ...thread_id === undefined ? {} : { threadId: ThreadId.make(thread_id) },
6997
8361
  ...parent_task_id === undefined ? {} : { parentTaskId: TaskId.make(parent_task_id) },
6998
8362
  ...workspace === undefined ? {} : { workspace },
6999
- ...attachments2.length === 0 ? {} : { attachments: attachments2 }
7000
- }));
7001
- const completed = wait ? await Effect12.runPromise(waitForTask(client2, task, timeout_seconds)) : task;
7002
- return output(wait ? await acknowledgedTaskResult(client2, completed) : taskResult(completed));
8363
+ ...attachments2.length === 0 ? {} : { attachments: attachments2 },
8364
+ ...deadline === undefined ? {} : { expiresAt: Schema13.decodeUnknownSync(TaskDeadline)(deadline) }
8365
+ }), { signal });
8366
+ const completed = wait ? await Effect12.runPromise(waitForTask(client2, task, timeout_seconds), { signal }) : task;
8367
+ return output(wait ? await acknowledgedTaskResult(client2, completed, signal) : taskResult(completed));
7003
8368
  });
7004
8369
  server.registerTool("completion_inbox", {
7005
- title: "List completed work",
7006
- description: "List up to 20 unacknowledged completed tasks sent by this client. hasMore signals additional entries. Use task_status for a full result, then acknowledge the task after handling it.",
8370
+ title: "List pending questions and completed work",
8371
+ description: "List up to 20 unanswered clarification requests and unacknowledged completed tasks sent by this client. hasMore signals additional entries. Answer questions with task_answer; use task_status for full results and acknowledge only handled completions.",
7007
8372
  inputSchema: {}
7008
8373
  }, async () => output(await Effect12.runPromise(client2.inbox())));
7009
8374
  server.registerTool("acknowledge_completion", {
@@ -7039,23 +8404,24 @@ var runMcp = async (configuration) => {
7039
8404
  });
7040
8405
  server.registerTool("task_trace", {
7041
8406
  title: "Trace a delegated task",
7042
- description: "Read the redacted relay and device lifecycle for troubleshooting a Cohall task.",
8407
+ description: "Read the relay and device lifecycle and latest worker progress note for troubleshooting a Cohall task.",
7043
8408
  inputSchema: { task_id: z.string().uuid() }
7044
8409
  }, async ({ task_id }) => output(await Effect12.runPromise(client2.traceTask(TaskId.make(task_id)))));
7045
8410
  server.registerTool("wait_task", {
7046
8411
  title: "Wait for delegated work",
7047
- description: "Wait for a Cohall task to finish and return its final result.",
8412
+ description: "Wait for a Cohall task to finish or need input. Answer a needs_input result with task_answer, then wait again.",
7048
8413
  inputSchema: {
7049
8414
  task_id: z.string().uuid(),
7050
8415
  timeout_seconds: z.number().int().min(5).max(86400).default(900)
7051
8416
  }
7052
- }, async ({ task_id, timeout_seconds }) => {
7053
- const task = await Effect12.runPromise(client2.getTask(TaskId.make(task_id)));
7054
- return output(taskResult(await Effect12.runPromise(waitForTask(client2, task, timeout_seconds))));
8417
+ }, async ({ task_id, timeout_seconds }, { signal }) => {
8418
+ return output(taskResult(await Effect12.runPromise(waitForTask(client2, TaskId.make(task_id), timeout_seconds), {
8419
+ signal
8420
+ })));
7055
8421
  });
7056
8422
  server.registerTool("cancel_task", {
7057
8423
  title: "Cancel delegated work",
7058
- description: "Request cancellation of coding work or a queued Bot task. Active Grok Bot tasks must be stopped in Grok Bot.",
8424
+ description: "Cancel coding work, a paused task, or a Bot task that has never been dispatched. Active Grok Bot tasks must be stopped in Grok Bot.",
7059
8425
  inputSchema: { task_id: z.string().uuid() }
7060
8426
  }, async ({ task_id }) => output(taskResult(await Effect12.runPromise(client2.cancelTask(TaskId.make(task_id))))));
7061
8427
  server.registerTool("thread_context", {
@@ -7092,7 +8458,19 @@ var main = async () => {
7092
8458
  }
7093
8459
  if (command === "device") {
7094
8460
  const configuration = await Effect16.runPromise(loadDeviceConfiguration);
7095
- await Effect16.runPromise(runDaemon(configuration));
8461
+ const controller = new AbortController;
8462
+ const stop = () => controller.abort();
8463
+ process.on("SIGINT", stop);
8464
+ process.on("SIGTERM", stop);
8465
+ try {
8466
+ await Effect16.runPromise(runDaemon(configuration), { signal: controller.signal }).catch((cause) => {
8467
+ if (!controller.signal.aborted)
8468
+ throw cause;
8469
+ });
8470
+ } finally {
8471
+ process.off("SIGINT", stop);
8472
+ process.off("SIGTERM", stop);
8473
+ }
7096
8474
  return;
7097
8475
  }
7098
8476
  if (command === "mcp") {
@@ -7107,4 +8485,4 @@ await main().catch((cause) => {
7107
8485
  process.exitCode = 1;
7108
8486
  });
7109
8487
 
7110
- //# debugId=9F11AB66C376A14E64756E2164756E21
8488
+ //# debugId=4195FFD0F10A67E564756E2164756E21