@akshar5/cohall 0.9.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.9.0", maxSocketPayloadBytes, maxAttachmentBytes, maxTaskAttachments = 2, maxProgressNoteBytes = 1024, bounded = (maxLength) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maxLength)), optionalText = (maxLength) => Schema.String.check(Schema.isMaxLength(maxLength)), boundedArray = (schema, maxLength) => Schema.Array(schema).check(Schema.isMaxLength(maxLength)), count, uuid = (name) => Schema.String.check(Schema.isUUID(4)).pipe(Schema.brand(name)), isoTimestamp = (name) => Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/, {
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.9.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
- }, TaskProgressInput, TaskProgress, Task, TaskInboxItem, TaskInbox, taskSlot = (task) => task.provider === "grok-bot" ? `grok-bot:${task.botId}` : "cli", upgradeVersion, CreateUpgradeOperationsInput, DeviceOperation, TaskStatusCounts, ProviderUsage, DeviceUsage, UsageSummary, TaskTraceEvent, TaskTrace, ThreadContext, CreateTaskInput, SocketEvent, ErrorResponse, terminalTaskStatuses, isTerminalTask = (task) => terminalTaskStatuses.has(task.status), now = () => Timestamp.make(new Date().toISOString()), makeDeviceId = () => DeviceId.make(crypto.randomUUID()), makeThreadId = () => ThreadId.make(crypto.randomUUID()), makeMessageId = () => MessageId.make(crypto.randomUUID()), makeTaskId = () => TaskId.make(crypto.randomUUID()), makeAuthSessionId = () => AuthSessionId.make(crypto.randomUUID()), makeOperationId = () => OperationId.make(crypto.randomUUID()), decodeCreatePairingInput, decodeTaskProgressInput, decodeExchangePairingInput, decodeCreateTaskInput = (input) => Schema.decodeUnknownEffect(CreateTaskInput)(input).pipe(Effect.map((task) => ({
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({
@@ -155,6 +168,19 @@ var init_src = __esm(() => {
155
168
  oldestQueuedAt: Schema.optionalKey(Timestamp)
156
169
  }))
157
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
+ }));
158
184
  Thread = Schema.Struct({
159
185
  id: ThreadId,
160
186
  title: bounded(256),
@@ -188,8 +214,22 @@ var init_src = __esm(() => {
188
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"))
189
215
  });
190
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);
191
230
  Task = Schema.Struct({
192
231
  id: TaskId,
232
+ runId: Schema.optionalKey(TaskRunId),
193
233
  threadId: ThreadId,
194
234
  prompt: bounded(131072),
195
235
  context: Schema.optionalKey(optionalText(131072)),
@@ -205,23 +245,26 @@ var init_src = __esm(() => {
205
245
  inputAttachmentNames: Schema.optionalKey(boundedArray(AttachmentName, maxTaskAttachments)),
206
246
  error: Schema.optionalKey(optionalText(16384)),
207
247
  progress: Schema.optionalKey(TaskProgress),
248
+ clarifications: Schema.optionalKey(clarifications),
208
249
  createdAt: Timestamp,
209
250
  updatedAt: Timestamp,
210
251
  startedAt: Schema.optionalKey(Timestamp),
211
- completedAt: Schema.optionalKey(Timestamp)
212
- }).check(Schema.makeFilter(validBotTarget));
252
+ completedAt: Schema.optionalKey(Timestamp),
253
+ expiresAt: Schema.optionalKey(TaskDeadline)
254
+ }).check(Schema.makeFilter(validBotTarget), Schema.makeFilter(validClarificationState));
213
255
  TaskInboxItem = Schema.Struct({
214
256
  id: TaskId,
215
257
  threadId: ThreadId,
216
258
  targetDeviceId: DeviceId,
217
259
  provider: Provider,
218
260
  botId: Schema.optionalKey(BotId),
219
- status: Schema.Literals(["completed", "failed", "cancelled"]),
261
+ status: Schema.Literals(["needs_input", "completed", "failed", "cancelled"]),
220
262
  promptPreview: optionalText(320),
221
263
  resultPreview: Schema.optionalKey(optionalText(1024)),
222
264
  errorPreview: Schema.optionalKey(optionalText(1024)),
223
- completedAt: Timestamp
224
- });
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"));
225
268
  TaskInbox = Schema.Struct({
226
269
  items: boundedArray(TaskInboxItem, 20),
227
270
  hasMore: Schema.Boolean
@@ -250,6 +293,7 @@ var init_src = __esm(() => {
250
293
  queued: count,
251
294
  assigned: count,
252
295
  running: count,
296
+ needs_input: Schema.optionalKey(count),
253
297
  cancelling: count,
254
298
  completed: count,
255
299
  failed: count,
@@ -291,11 +335,13 @@ var init_src = __esm(() => {
291
335
  updatedAt: Timestamp,
292
336
  startedAt: Schema.optionalKey(Timestamp),
293
337
  completedAt: Schema.optionalKey(Timestamp),
338
+ expiresAt: Schema.optionalKey(TaskDeadline),
294
339
  events: boundedArray(TaskTraceEvent, 100),
295
340
  truncated: Schema.Boolean,
296
341
  error: Schema.optionalKey(optionalText(16384)),
297
- progress: Schema.optionalKey(TaskProgress)
298
- }).check(Schema.makeFilter(validBotTarget));
342
+ progress: Schema.optionalKey(TaskProgress),
343
+ clarifications: Schema.optionalKey(clarifications)
344
+ }).check(Schema.makeFilter(validBotTarget), Schema.makeFilter(validClarificationState));
299
345
  ThreadContext = Schema.Struct({
300
346
  thread: Thread,
301
347
  messages: boundedArray(Message, 20),
@@ -303,6 +349,7 @@ var init_src = __esm(() => {
303
349
  truncated: Schema.Boolean
304
350
  });
305
351
  CreateTaskInput = Schema.Struct({
352
+ requestId: Schema.optionalKey(TaskRequestId),
306
353
  threadId: Schema.optionalKey(ThreadId),
307
354
  title: Schema.optionalKey(bounded(256)),
308
355
  prompt: bounded(131072),
@@ -311,6 +358,7 @@ var init_src = __esm(() => {
311
358
  botId: Schema.optionalKey(BotId),
312
359
  targetDeviceId: Schema.optionalKey(DeviceId),
313
360
  parentTaskId: Schema.optionalKey(TaskId),
361
+ expiresAt: Schema.optionalKey(TaskDeadline),
314
362
  workspace: Schema.optionalKey(bounded(4096)),
315
363
  attachments: Schema.optionalKey(attachments)
316
364
  }).check(Schema.makeFilter(validBotTarget));
@@ -319,7 +367,9 @@ var init_src = __esm(() => {
319
367
  Connected: {
320
368
  serverVersion: bounded(32),
321
369
  connectedAt: Timestamp,
322
- taskAttachments: Schema.optionalKey(Schema.Boolean)
370
+ taskAttachments: Schema.optionalKey(Schema.Boolean),
371
+ taskClarification: Schema.optionalKey(Schema.Boolean),
372
+ taskDeadlines: Schema.optionalKey(Schema.Boolean)
323
373
  },
324
374
  DeviceHello: { device: Device },
325
375
  DeviceHeartbeat: {
@@ -328,17 +378,22 @@ var init_src = __esm(() => {
328
378
  bots: Schema.optionalKey(boundedArray(Bot, 256))
329
379
  },
330
380
  TaskAssigned: { task: Task },
331
- TaskAccepted: { taskId: TaskId },
381
+ TaskAccepted: { taskId: TaskId, runId: Schema.optionalKey(TaskRunId) },
332
382
  TaskFinished: {
333
383
  taskId: TaskId,
384
+ runId: Schema.optionalKey(TaskRunId),
334
385
  result: optionalText(131072),
335
386
  attachments: Schema.optionalKey(attachments),
336
387
  providerSessionId: Schema.optionalKey(bounded(4096))
337
388
  },
338
- TaskFailed: { taskId: TaskId, error: bounded(16384) },
339
- TaskCancelled: { taskId: TaskId },
340
- TaskSettled: { taskId: TaskId },
341
- 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) },
342
397
  OperationAssigned: { operation: DeviceOperation },
343
398
  OperationAccepted: { operationId: OperationId },
344
399
  OperationFinished: { operationId: OperationId, result: optionalText(16384) },
@@ -356,6 +411,8 @@ var init_src = __esm(() => {
356
411
  ]);
357
412
  decodeCreatePairingInput = Schema.decodeUnknownEffect(CreatePairingInput);
358
413
  decodeTaskProgressInput = Schema.decodeUnknownEffect(TaskProgressInput);
414
+ decodeRequestTaskInput = Schema.decodeUnknownEffect(RequestTaskInput);
415
+ decodeAnswerTaskInput = Schema.decodeUnknownEffect(AnswerTaskInput);
359
416
  decodeExchangePairingInput = Schema.decodeUnknownEffect(ExchangePairingInput);
360
417
  decodeCreateUpgradeOperationsInput = Schema.decodeUnknownEffect(CreateUpgradeOperationsInput);
361
418
  decodeDevice = Schema.decodeUnknownEffect(Device);
@@ -366,7 +423,7 @@ var init_src = __esm(() => {
366
423
  import { Effect as Effect13, Schema as Schema14 } from "effect";
367
424
  import { access as access5, chmod as chmod7, mkdir as mkdir8, readFile as readFile5, writeFile as writeFile9 } from "node:fs/promises";
368
425
  import { homedir as homedir5, platform as platform5 } from "node:os";
369
- import { join as join9, resolve as resolve4 } from "node:path";
426
+ import { join as join9, resolve as resolve5 } from "node:path";
370
427
  var RelayConfiguration, defaultDataDirectory = () => {
371
428
  if (platform5() === "win32") {
372
429
  return join9(process.env.LOCALAPPDATA ?? join9(homedir5(), "AppData", "Local"), "Cohall");
@@ -414,7 +471,7 @@ var init_config = __esm(() => {
414
471
  if (!isLoopback2(host) && !allowRemote) {
415
472
  throw new Error("Remote relay binding is disabled. Use a private network or TLS reverse proxy, then set COHALL_RELAY_ALLOW_REMOTE=true.");
416
473
  }
417
- const dataDirectory = resolve4(process.env.COHALL_DATA_DIR ?? defaultDataDirectory());
474
+ const dataDirectory = resolve5(process.env.COHALL_DATA_DIR ?? defaultDataDirectory());
418
475
  await mkdir8(dataDirectory, { recursive: true, mode: 448 });
419
476
  if (platform5() !== "win32") {
420
477
  await chmod7(dataDirectory, 448);
@@ -607,14 +664,17 @@ var exports_store = {};
607
664
  __export(exports_store, {
608
665
  layerFromDatabase: () => layerFromDatabase,
609
666
  layer: () => layer2,
667
+ TaskRequestError: () => TaskRequestError,
610
668
  TaskProgressError: () => TaskProgressError,
669
+ TaskInputError: () => TaskInputError,
670
+ TaskDeadlineError: () => TaskDeadlineError,
611
671
  Service: () => Service2,
612
672
  RelayStore: () => exports_store,
613
673
  PersistenceError: () => PersistenceError
614
674
  });
615
675
  import { Context as Context2, Effect as Effect14, Layer as Layer2, Schema as Schema15 } from "effect";
616
676
  import { createHash as createHash2, randomBytes } from "node:crypto";
617
- var PersistenceError, TaskProgressError, Service2, operationError = (operation) => (cause) => new PersistenceError({
677
+ var PersistenceError, TaskProgressError, TaskInputError, TaskDeadlineError, TaskRequestError, Service2, operationError = (operation) => (cause) => new PersistenceError({
618
678
  operation,
619
679
  message: cause instanceof Error ? cause.message : String(cause)
620
680
  }), decode2 = (operation, schema, value) => Schema15.decodeUnknownEffect(schema)(value).pipe(Effect14.mapError(operationError(operation))), threadFromRow = (row) => decode2("RelayStore.decodeThread", Thread, {
@@ -657,6 +717,8 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
657
717
  catch: operationError("RelayStore.taskAttachments")
658
718
  }).pipe(Effect14.flatMap((attachments2) => decode2("RelayStore.decodeTask", Task, {
659
719
  id: row.id,
720
+ ...row.run_id === null ? {} : { runId: row.run_id },
721
+ ...row.clarifications_json === null ? {} : { clarifications: JSON.parse(row.clarifications_json) },
660
722
  threadId: row.thread_id,
661
723
  prompt: row.prompt,
662
724
  provider: row.provider,
@@ -665,6 +727,7 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
665
727
  targetDeviceId: row.target_device_id,
666
728
  createdAt: row.created_at,
667
729
  updatedAt: row.updated_at,
730
+ ...row.expires_at === null ? {} : { expiresAt: row.expires_at },
668
731
  ...row.context === null ? {} : { context: row.context },
669
732
  ...row.source_device_id === null ? {} : { sourceDeviceId: row.source_device_id },
670
733
  ...row.parent_task_id === null ? {} : { parentTaskId: row.parent_task_id },
@@ -684,7 +747,8 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
684
747
  ...row.bot_id === null ? {} : { botId: row.bot_id },
685
748
  status: row.status,
686
749
  promptPreview: row.prompt_preview,
687
- 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) },
688
752
  ...row.result_preview === null ? {} : { resultPreview: row.result_preview },
689
753
  ...row.error_preview === null ? {} : { errorPreview: row.error_preview }
690
754
  }), operationFromRow = (row) => decode2("RelayStore.decodeOperation", DeviceOperation, {
@@ -703,6 +767,7 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
703
767
  queued: 0,
704
768
  assigned: 0,
705
769
  running: 0,
770
+ needs_input: 0,
706
771
  cancelling: 0,
707
772
  completed: 0,
708
773
  failed: 0,
@@ -774,7 +839,52 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
774
839
  events.push(TaskTraceEvent.make({ kind: task.status, at: task.completedAt, detail }));
775
840
  }
776
841
  return events;
777
- }, 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
+ });
778
888
  const recordTaskTraceEvent = (taskId, kind, detail, createdAt = now()) => {
779
889
  db.query(`INSERT INTO task_trace_events (task_id, kind, created_at, detail)
780
890
  VALUES (?, ?, ?, ?)`).run(taskId, kind, createdAt, detail);
@@ -814,7 +924,8 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
814
924
  substr(prompt, 1, 160) AS prompt_preview,
815
925
  substr(result, 1, 512) AS result_preview,
816
926
  substr(error, 1, 512) AS error_preview,
817
- 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`;
818
929
  const queryOperation = (operationId) => db.query("SELECT * FROM device_operations WHERE id = ?").get(operationId);
819
930
  const getTask = Effect14.fn("RelayStore.getTask")(function* (taskId) {
820
931
  const row = yield* Effect14.try({
@@ -824,7 +935,8 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
824
935
  if (row === null) {
825
936
  return yield* Effect14.fail(new PersistenceError({
826
937
  operation: "RelayStore.getTask",
827
- message: `Unknown task ${taskId}`
938
+ message: `Unknown task ${taskId}`,
939
+ missingRecord: "task"
828
940
  }));
829
941
  }
830
942
  return yield* taskFromRow(db, row);
@@ -855,8 +967,8 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
855
967
  const requesterId = principal === "owner" ? "owner" : principal.id;
856
968
  const rows = yield* Effect14.try({
857
969
  try: () => db.query(`SELECT ${inboxColumns} FROM tasks WHERE requester_id = ? AND completion_seen_at IS NULL
858
- AND status IN ('completed', 'failed', 'cancelled')
859
- 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),
860
972
  catch: operationError("RelayStore.inboxFor")
861
973
  });
862
974
  return TaskInbox.make({
@@ -921,8 +1033,10 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
921
1033
  ...task.workspace === undefined ? {} : { workspace: task.workspace },
922
1034
  ...task.startedAt === undefined ? {} : { startedAt: task.startedAt },
923
1035
  ...task.completedAt === undefined ? {} : { completedAt: task.completedAt },
1036
+ ...task.expiresAt === undefined ? {} : { expiresAt: task.expiresAt },
924
1037
  ...task.error === undefined ? {} : { error: task.error },
925
- ...task.progress === undefined ? {} : { progress: task.progress }
1038
+ ...task.progress === undefined ? {} : { progress: task.progress },
1039
+ ...task.clarifications === undefined ? {} : { clarifications: task.clarifications }
926
1040
  });
927
1041
  });
928
1042
  const listDevices = Effect14.fn("RelayStore.listDevices")(function* () {
@@ -937,6 +1051,54 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
937
1051
  });
938
1052
  return yield* Effect14.forEach(rows, deviceFromRow);
939
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
+ });
940
1102
  const usage = Effect14.fn("RelayStore.usage")(function* () {
941
1103
  const rows = yield* Effect14.try({
942
1104
  try: () => db.query(`SELECT tasks.target_device_id AS device_id, devices.name AS device_name,
@@ -992,7 +1154,8 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
992
1154
  if (row === null) {
993
1155
  return yield* Effect14.fail(new PersistenceError({
994
1156
  operation: "RelayStore.getOperation",
995
- message: `Unknown device operation ${operationId}`
1157
+ message: `Unknown device operation ${operationId}`,
1158
+ missingRecord: "operation"
996
1159
  }));
997
1160
  }
998
1161
  return yield* operationFromRow(row);
@@ -1150,13 +1313,15 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1150
1313
  yield* Effect14.try({
1151
1314
  try: () => db.transaction(() => {
1152
1315
  const timestamp = now();
1153
- 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();
1154
1317
  db.query("UPDATE devices SET status = 'offline'").run();
1155
- db.query(`UPDATE tasks SET status = 'queued', updated_at = ?, progress_note = NULL, progress_at = NULL
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
1156
1321
  WHERE status IN ('assigned', 'running')`).run(timestamp);
1157
1322
  db.query("UPDATE device_operations SET status = 'queued', updated_at = ? WHERE status IN ('assigned', 'running')").run(timestamp);
1158
1323
  for (const task of interrupted) {
1159
- 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);
1160
1325
  }
1161
1326
  db.query("DELETE FROM auth_pairings WHERE expires_at <= ? OR used_at IS NOT NULL").run(timestamp);
1162
1327
  db.query("UPDATE auth_sessions SET revoked_at = ? WHERE revoked_at IS NULL AND roles_json LIKE '%,%'").run(timestamp);
@@ -1205,52 +1370,68 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1205
1370
  }
1206
1371
  return yield* deviceFromRow(row);
1207
1372
  });
1208
- 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) {
1209
1374
  const requesterId = principal === "owner" ? "owner" : principal.id;
1210
1375
  const boundDeviceId = principal === "owner" ? undefined : principal.deviceId;
1211
1376
  const sourceDeviceId = yield* Effect14.try({
1212
1377
  try: () => boundDeviceId === undefined || db.query("SELECT id FROM devices WHERE id = ? AND forgotten_at IS NULL").get(boundDeviceId) === null ? undefined : boundDeviceId,
1213
1378
  catch: operationError("RelayStore.createDelegation.source")
1214
1379
  });
1215
- const timestamp = now();
1216
- const threadId = input.threadId ?? makeThreadId();
1217
- const task = Task.make({
1218
- id: makeTaskId(),
1219
- threadId,
1220
- prompt: input.prompt,
1221
- provider: input.provider ?? (input.botId === undefined ? "codex" : "grok-bot"),
1222
- ...input.botId === undefined ? {} : { botId: input.botId },
1223
- status: "queued",
1224
- targetDeviceId,
1225
- createdAt: timestamp,
1226
- updatedAt: timestamp,
1227
- ...input.context === undefined ? {} : { context: input.context },
1228
- ...sourceDeviceId === undefined ? {} : { sourceDeviceId },
1229
- ...input.parentTaskId === undefined ? {} : { parentTaskId: input.parentTaskId },
1230
- ...input.workspace === undefined ? {} : { workspace: input.workspace },
1231
- ...input.attachments === undefined || input.attachments.length === 0 ? {} : { inputAttachmentNames: input.attachments.map((attachment) => attachment.name) },
1232
- ...providerSessionId === undefined ? {} : { providerSessionId }
1233
- });
1234
- const thread = input.threadId === undefined ? Thread.make({
1235
- id: threadId,
1236
- title: input.title ?? input.prompt.slice(0, 72),
1237
- createdAt: timestamp,
1238
- updatedAt: timestamp
1239
- }) : undefined;
1240
- const message = Message.make({
1241
- id: makeMessageId(),
1242
- threadId,
1243
- role: "human",
1244
- authorName: sourceDeviceId === undefined ? "User" : "Remote agent",
1245
- content: input.prompt,
1246
- createdAt: timestamp,
1247
- taskId: task.id,
1248
- ...sourceDeviceId === undefined ? {} : { deviceId: sourceDeviceId }
1249
- });
1250
- yield* Effect14.try({
1380
+ const submission = yield* Effect14.try({
1251
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
+ });
1252
1433
  const outstanding = db.query(`SELECT COUNT(*) AS count FROM tasks WHERE target_device_id = ?
1253
- AND status IN ('queued', 'assigned', 'running', 'cancelling')`).get(targetDeviceId);
1434
+ AND status IN ('queued', 'assigned', 'running', 'needs_input', 'cancelling')`).get(targetDeviceId);
1254
1435
  if ((outstanding?.count ?? 0) >= 100) {
1255
1436
  throw new Error(`Device ${targetDeviceId} has reached its outstanding task limit`);
1256
1437
  }
@@ -1262,8 +1443,8 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1262
1443
  db.query(`INSERT INTO tasks (
1263
1444
  id, thread_id, prompt, context, provider, status, source_device_id,
1264
1445
  requester_id, target_device_id, parent_task_id, workspace,
1265
- provider_session_id, bot_id, created_at, updated_at
1266
- ) 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);
1267
1448
  for (const attachment of input.attachments ?? []) {
1268
1449
  const data = Buffer.from(attachment.data, "base64");
1269
1450
  if (data.length < 1 || data.length > maxAttachmentBytes) {
@@ -1271,16 +1452,20 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1271
1452
  }
1272
1453
  db.query("INSERT INTO task_attachments (task_id, name, direction, data) VALUES (?, ?, 'input', ?)").run(task.id, attachment.name, data);
1273
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
+ }
1274
1458
  recordTaskTraceEvent(task.id, "queued", "Relay accepted the task", timestamp);
1275
1459
  db.query(`INSERT INTO messages (
1276
1460
  id, thread_id, role, kind, author_id, author_name, content, created_at,
1277
1461
  task_id, device_id
1278
1462
  ) VALUES (?, ?, ?, 'chat', ?, ?, ?, ?, ?, ?)`).run(message.id, message.threadId, message.role, sourceDeviceId ?? "user", message.authorName, message.content, message.createdAt, message.taskId ?? null, message.deviceId ?? null);
1279
1463
  db.query("UPDATE threads SET updated_at = ? WHERE id = ?").run(timestamp, threadId);
1464
+ return { created: task };
1280
1465
  })(),
1281
- catch: operationError("RelayStore.createDelegation")
1466
+ catch: (cause) => cause instanceof TaskRequestError || cause instanceof TaskDeadlineError ? cause : operationError("RelayStore.createDelegation")(cause)
1282
1467
  });
1283
- return task;
1468
+ return "existing" in submission ? yield* taskFromRow(db, submission.existing) : submission.created;
1284
1469
  });
1285
1470
  const threadContext2 = Effect14.fn("RelayStore.threadContext")(function* (threadId) {
1286
1471
  const threadRow = yield* Effect14.try({
@@ -1351,15 +1536,45 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1351
1536
  });
1352
1537
  return yield* getTask(taskId);
1353
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
+ });
1354
1561
  const assignTask = Effect14.fn("RelayStore.assignTask")(function* (taskId) {
1355
- const current = yield* getTask(taskId);
1562
+ const current = yield* getTask(taskId).pipe(Effect14.flatMap(expireTask));
1356
1563
  if (current.status !== "queued") {
1357
1564
  return current;
1358
1565
  }
1359
1566
  const timestamp = now();
1360
1567
  yield* Effect14.try({
1361
1568
  try: () => db.transaction(() => {
1362
- 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,
1363
1578
  provider_session_id = CASE
1364
1579
  WHEN provider = 'grok-bot' THEN NULL
1365
1580
  WHEN started_at IS NULL THEN COALESCE((
@@ -1378,7 +1593,7 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1378
1593
  SELECT 1 FROM device_operations active
1379
1594
  WHERE active.target_device_id = ?
1380
1595
  AND active.status IN ('assigned', 'running')
1381
- )`).run(timestamp, taskId, current.targetDeviceId, taskId, taskSlot(current), current.targetDeviceId);
1596
+ )`).run(timestamp, crypto.randomUUID(), taskId, current.targetDeviceId, taskId, taskSlot(current), current.targetDeviceId);
1382
1597
  if (result.changes === 1) {
1383
1598
  recordTaskTraceEvent(taskId, "assigned", "Relay dispatched the task over the device WebSocket", timestamp);
1384
1599
  }
@@ -1419,22 +1634,173 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1419
1634
  }
1420
1635
  return progress;
1421
1636
  });
1422
- const terminal = Effect14.fn("RelayStore.terminal")(function* (taskId, deviceId, status, result, error, providerSessionId, attachments2) {
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) {
1423
1755
  const current = yield* requireTarget(taskId, deviceId);
1424
- if (["completed", "failed", "cancelled"].includes(current.status)) {
1756
+ if (!matchesTaskRun(current, runId) || current.status === "cancelling" && status !== "cancelled" || ["completed", "failed", "cancelled"].includes(current.status)) {
1425
1757
  return current;
1426
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;
1427
1764
  const timestamp = now();
1428
1765
  yield* Effect14.try({
1429
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
+ }
1430
1794
  const updated = db.query(`UPDATE tasks SET status = ?, result = ?, error = ?, provider_session_id = ?,
1431
1795
  completed_at = ?, updated_at = ?, progress_note = NULL, progress_at = NULL WHERE id = ?
1432
- AND status NOT IN ('completed', 'failed', 'cancelled')`).run(status, result ?? null, error ?? null, current.provider === "grok-bot" ? null : providerSessionId ?? current.providerSessionId ?? null, timestamp, timestamp, taskId);
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);
1433
1799
  if (updated.changes !== 1) {
1434
1800
  return;
1435
1801
  }
1436
1802
  db.query("DELETE FROM task_attachments WHERE task_id = ? AND direction = 'output'").run(taskId);
1437
- if (status === "completed") {
1803
+ if (finalStatus === "completed") {
1438
1804
  if ((attachments2?.length ?? 0) > maxTaskAttachments) {
1439
1805
  throw new Error(`A task supports at most ${maxTaskAttachments} output files`);
1440
1806
  }
@@ -1446,20 +1812,15 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1446
1812
  db.query("INSERT INTO task_attachments (task_id, name, direction, data) VALUES (?, ?, 'output', ?)").run(taskId, attachment.name, data);
1447
1813
  }
1448
1814
  }
1449
- recordTaskTraceEvent(taskId, status, status === "completed" ? "Target device completed the task" : status === "failed" ? "Target device reported a task failure" : "Target device acknowledged cancellation", timestamp);
1450
- if (current.provider !== "grok-bot" && providerSessionId !== undefined) {
1451
- db.query(`INSERT INTO provider_sessions (thread_id, device_id, provider, session_id, updated_at)
1452
- VALUES (?, ?, ?, ?, ?)
1453
- ON CONFLICT(thread_id, device_id, provider) DO UPDATE SET
1454
- session_id = excluded.session_id, updated_at = excluded.updated_at`).run(current.threadId, deviceId, current.provider, providerSessionId, timestamp);
1455
- }
1456
- 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;
1457
1818
  if (content !== undefined) {
1458
- const role = status === "completed" ? "agent" : "system";
1819
+ const role = finalStatus === "completed" ? "agent" : "system";
1459
1820
  db.query(`INSERT INTO messages (
1460
1821
  id, thread_id, role, kind, author_id, author_name, content, created_at,
1461
1822
  task_id, device_id
1462
- ) 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);
1463
1824
  db.query("UPDATE threads SET updated_at = ? WHERE id = ?").run(timestamp, current.threadId);
1464
1825
  }
1465
1826
  pruneTerminalHistory(taskId);
@@ -1468,16 +1829,19 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1468
1829
  });
1469
1830
  return yield* getTask(taskId);
1470
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
+ });
1471
1838
  const requestCancellation = Effect14.fn("RelayStore.requestCancellation")(function* (taskId) {
1472
- const current = yield* getTask(taskId);
1839
+ const current = yield* getTask(taskId).pipe(Effect14.flatMap(expireTask));
1473
1840
  if (["completed", "failed", "cancelled"].includes(current.status)) {
1474
1841
  return current;
1475
1842
  }
1476
- const dispatched = current.provider === "grok-bot" && (yield* Effect14.try({
1477
- try: () => db.query("SELECT 1 FROM tasks WHERE id = ? AND dispatched_at IS NOT NULL").get(taskId) !== null,
1478
- catch: operationError("RelayStore.requestCancellation")
1479
- }));
1480
- 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)) {
1481
1845
  return yield* new PersistenceError({
1482
1846
  operation: "RelayStore.requestCancellation",
1483
1847
  message: "Stop this bot in Grok Bot; its gateway cannot cancel an individual Cohall request"
@@ -1487,7 +1851,8 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1487
1851
  return current;
1488
1852
  }
1489
1853
  const timestamp = now();
1490
- 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"], {
1491
1856
  status: "cancelling"
1492
1857
  }, { kind: "cancelling", detail: "Client requested task cancellation" });
1493
1858
  });
@@ -1495,12 +1860,14 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1495
1860
  const timestamp = now();
1496
1861
  yield* Effect14.try({
1497
1862
  try: () => db.transaction(() => {
1498
- 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 = ?
1499
1864
  AND status IN ('assigned', 'running')`).all(deviceId);
1500
- db.query(`UPDATE tasks SET status = 'queued', updated_at = ?, progress_note = NULL, progress_at = NULL
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
1501
1868
  WHERE target_device_id = ? AND status IN ('assigned', 'running')`).run(timestamp, deviceId);
1502
1869
  for (const task of interrupted) {
1503
- 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);
1504
1871
  }
1505
1872
  })(),
1506
1873
  catch: operationError("RelayStore.requeueTasksFor")
@@ -1620,14 +1987,20 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1620
1987
  return Service2.of({
1621
1988
  recover,
1622
1989
  listDevices,
1990
+ listDevicePage,
1623
1991
  usage,
1624
1992
  forgetDevice,
1625
1993
  upsertDevice,
1626
1994
  heartbeat: (deviceId, status, bots) => updateDeviceStatus(deviceId, status, bots),
1627
1995
  markDeviceOffline: (deviceId) => updateDeviceStatus(deviceId, "offline"),
1628
1996
  createDelegation: createDelegation2,
1997
+ expireTasks,
1998
+ findDelegation,
1629
1999
  getTask,
1630
2000
  reportTaskProgress,
2001
+ requestTaskInput,
2002
+ pauseTaskForInput,
2003
+ answerTaskInput,
1631
2004
  listAttachments,
1632
2005
  readAttachment,
1633
2006
  inboxFor,
@@ -1643,10 +2016,13 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1643
2016
  catch: operationError("RelayStore.markTaskDispatched")
1644
2017
  }),
1645
2018
  rollbackAssignment: (taskId) => transition(taskId, ["assigned"], { status: "queued" }, { kind: "requeued", detail: "Target device was unavailable during dispatch" }),
1646
- 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" }))),
1647
- finishTask: (taskId, deviceId, result, providerSessionId, attachments2) => terminal(taskId, deviceId, "completed", result, undefined, providerSessionId, attachments2),
1648
- failTask: (taskId, deviceId, error) => terminal(taskId, deviceId, "failed", undefined, error),
1649
- 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),
1650
2026
  requestCancellation,
1651
2027
  requeueTasksFor,
1652
2028
  createUpgradeOperations,
@@ -1699,10 +2075,18 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1699
2075
  target_device_id TEXT NOT NULL REFERENCES devices(id), parent_task_id TEXT,
1700
2076
  workspace TEXT, provider_session_id TEXT, result TEXT, error TEXT,
1701
2077
  created_at TEXT NOT NULL, updated_at TEXT NOT NULL, started_at TEXT, completed_at TEXT,
1702
- dispatched_at TEXT, progress_note TEXT, progress_at TEXT
2078
+ dispatched_at TEXT, progress_note TEXT, progress_at TEXT,
2079
+ run_id TEXT, clarifications_json TEXT, expires_at TEXT
1703
2080
  );
1704
2081
  CREATE INDEX IF NOT EXISTS tasks_thread_created ON tasks(thread_id, created_at);
1705
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;
1706
2090
  CREATE TABLE IF NOT EXISTS task_attachments (
1707
2091
  task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1708
2092
  name TEXT NOT NULL, direction TEXT NOT NULL CHECK(direction IN ('input', 'output')),
@@ -1750,6 +2134,13 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1750
2134
  db.exec("ALTER TABLE devices ADD COLUMN bots_json TEXT");
1751
2135
  }
1752
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");
1753
2144
  if (!taskColumns.some((column) => column.name === "progress_note")) {
1754
2145
  db.exec("ALTER TABLE tasks ADD COLUMN progress_note TEXT");
1755
2146
  }
@@ -1774,6 +2165,13 @@ var PersistenceError, TaskProgressError, Service2, operationError = (operation)
1774
2165
  WHERE provider = 'grok-bot' AND status NOT IN ('completed', 'failed', 'cancelled')`);
1775
2166
  })();
1776
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
+ ))`);
1777
2175
  },
1778
2176
  catch: operationError("RelayStore.migrate")
1779
2177
  }), layer2 = (path, retainedTerminalTasks = 1000) => Layer2.effect(Service2, Effect14.acquireRelease(Effect14.try({
@@ -1784,10 +2182,20 @@ var init_store = __esm(() => {
1784
2182
  init_src();
1785
2183
  init_database();
1786
2184
  init_store();
1787
- 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
+ }) {
1788
2190
  };
1789
2191
  TaskProgressError = class TaskProgressError extends Schema15.TaggedErrorClass()("RelayStore.TaskProgressError", { status: Schema15.Literals([403, 409]), message: Schema15.String }) {
1790
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 }) {
2198
+ };
1791
2199
  Service2 = class Service2 extends Context2.Service()("@cohall/RelayStore") {
1792
2200
  };
1793
2201
  });
@@ -1802,7 +2210,7 @@ __export(exports_main, {
1802
2210
  canReadTaskAttachments: () => canReadTaskAttachments,
1803
2211
  canDispatchTaskToDevice: () => canDispatchTaskToDevice
1804
2212
  });
1805
- import { Effect as Effect15, ManagedRuntime, Schema as Schema16 } from "effect";
2213
+ import { Effect as Effect15, ManagedRuntime, Schema as Schema16, Schedule as Schedule4 } from "effect";
1806
2214
  import { createHash as createHash3, timingSafeEqual } from "node:crypto";
1807
2215
  import { access as access6, chmod as chmod8 } from "node:fs/promises";
1808
2216
  import { createServer } from "node:http";
@@ -1833,7 +2241,7 @@ var RequestError, relayListenOptions = (configuration, environment = process.env
1833
2241
  if (request.method === "GET" || request.method === "HEAD") {
1834
2242
  return Promise.resolve(undefined);
1835
2243
  }
1836
- return new Promise((resolve5, reject) => {
2244
+ return new Promise((resolve6, reject) => {
1837
2245
  const chunks = [];
1838
2246
  let size = 0;
1839
2247
  const cleanup = () => {
@@ -1852,7 +2260,7 @@ var RequestError, relayListenOptions = (configuration, environment = process.env
1852
2260
  };
1853
2261
  const onEnd = () => {
1854
2262
  cleanup();
1855
- resolve5(Buffer.concat(chunks, size));
2263
+ resolve6(Buffer.concat(chunks, size));
1856
2264
  };
1857
2265
  const onError = (cause) => {
1858
2266
  cleanup();
@@ -1939,6 +2347,11 @@ Content-Length: 0\r
1939
2347
  const resolvedInput = parent === undefined ? input : { ...input, threadId: parent.threadId };
1940
2348
  const devices = yield* store.listDevices().pipe(Effect15.mapError((cause) => new RequestError({ status: 500, message: cause.message })));
1941
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
+ });
1942
2355
  const hasAttachments = (input.attachments?.length ?? 0) > 0;
1943
2356
  if (hasAttachments && provider === "grok-bot") {
1944
2357
  return yield* new RequestError({
@@ -1950,7 +2363,7 @@ Content-Length: 0\r
1950
2363
  provider,
1951
2364
  ...input.botId === undefined ? {} : { botId: input.botId }
1952
2365
  });
1953
- 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));
1954
2367
  if (input.targetDeviceId !== undefined) {
1955
2368
  const target = devices.find((device) => device.id === input.targetDeviceId);
1956
2369
  if (target === undefined) {
@@ -1965,6 +2378,11 @@ Content-Length: 0\r
1965
2378
  message: `${target.name} does not advertise the ${provider} provider`
1966
2379
  });
1967
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
+ });
1968
2386
  if (hasAttachments && !target.capabilities.some((capability) => capability.id === "task-attachments")) {
1969
2387
  return yield* new RequestError({
1970
2388
  status: 409,
@@ -1985,7 +2403,7 @@ Content-Length: 0\r
1985
2403
  }
1986
2404
  return { input: resolvedInput, targetDeviceId: target.id };
1987
2405
  }
1988
- 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)));
1989
2407
  const candidates = hasAttachments ? providerDevices.filter((device) => device.capabilities.some((capability) => capability.id === "task-attachments")) : providerDevices;
1990
2408
  if (input.botId !== undefined && candidates.length > 1) {
1991
2409
  return yield* new RequestError({
@@ -1997,11 +2415,11 @@ Content-Length: 0\r
1997
2415
  if (selected === undefined) {
1998
2416
  return yield* new RequestError({
1999
2417
  status: 409,
2000
- 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`
2001
2419
  });
2002
2420
  }
2003
2421
  return { input: resolvedInput, targetDeviceId: selected.id };
2004
- }), 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 () => {
2005
2423
  const configuration = await Effect15.runPromise(loadEnvironmentConfiguration);
2006
2424
  process.umask(63);
2007
2425
  const runtime = ManagedRuntime.make(exports_store.layer(configuration.databasePath, configuration.historyTaskLimit));
@@ -2036,20 +2454,39 @@ Content-Length: 0\r
2036
2454
  const token = authorization.slice("Bearer ".length);
2037
2455
  return await authenticate(token, "client") ?? (allowDevice ? authenticate(token, "device") : undefined);
2038
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
+ };
2039
2464
  const dispatch = async (task) => {
2040
2465
  const store = await run2(exports_store.Service);
2041
- if ((task.inputAttachmentNames?.length ?? 0) > 0) {
2466
+ if (task.expiresAt !== undefined || (task.inputAttachmentNames?.length ?? 0) > 0 || requiresClarificationSupport(task)) {
2042
2467
  const devices = await Effect15.runPromise(store.listDevices());
2043
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;
2044
2473
  if (!canDispatchTaskToDevice(task, target)) {
2045
- 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));
2046
2475
  }
2047
2476
  }
2477
+ if (!hub.hasDevice(task.targetDeviceId))
2478
+ return task;
2048
2479
  const assigned = await Effect15.runPromise(store.assignTask(task.id));
2049
2480
  if (assigned.status !== "assigned") {
2481
+ if (assigned.status === "cancelling")
2482
+ sendCancellation(assigned);
2050
2483
  return assigned;
2051
2484
  }
2052
- 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)) {
2053
2490
  return assigned;
2054
2491
  }
2055
2492
  return Effect15.runPromise(store.rollbackAssignment(task.id));
@@ -2086,7 +2523,7 @@ Content-Length: 0\r
2086
2523
  }));
2087
2524
  for (const task of tasks) {
2088
2525
  if (task.status === "cancelling") {
2089
- hub.sendToDevice(deviceId, SocketEvent.make({ _tag: "CancelTask", taskId: task.id }));
2526
+ sendCancellation(task);
2090
2527
  } else {
2091
2528
  await dispatch(task);
2092
2529
  }
@@ -2146,7 +2583,9 @@ Content-Length: 0\r
2146
2583
  _tag: "Connected",
2147
2584
  serverVersion: version,
2148
2585
  connectedAt: now(),
2149
- taskAttachments: true
2586
+ taskAttachments: true,
2587
+ taskClarification: true,
2588
+ taskDeadlines: true
2150
2589
  })));
2151
2590
  return;
2152
2591
  }
@@ -2182,12 +2621,18 @@ Content-Length: 0\r
2182
2621
  sendError(socket, "device_hello_required", "Send DeviceHello before device events");
2183
2622
  return;
2184
2623
  }
2185
- const settle = (taskId) => {
2186
- 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
+ })));
2187
2630
  };
2188
2631
  const settleOperation = (operationId) => {
2189
2632
  socket.send(JSON.stringify(SocketEvent.make({ _tag: "OperationSettled", operationId })));
2190
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";
2191
2636
  const processed = await run2(Effect15.gen(function* () {
2192
2637
  const store = yield* exports_store.Service;
2193
2638
  switch (event._tag) {
@@ -2197,16 +2642,23 @@ Content-Length: 0\r
2197
2642
  }
2198
2643
  return;
2199
2644
  case "TaskAccepted":
2200
- yield* store.acceptTask(event.taskId, deviceId);
2645
+ yield* store.acceptTask(event.taskId, deviceId, event.runId);
2201
2646
  return;
2202
2647
  case "TaskFinished":
2203
- 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);
2649
+ return;
2650
+ case "TaskInputRequested": {
2651
+ yield* store.pauseTaskForInput(event.taskId, deviceId, {
2652
+ runId: event.runId,
2653
+ question: event.question
2654
+ });
2204
2655
  return;
2656
+ }
2205
2657
  case "TaskFailed":
2206
- yield* store.failTask(event.taskId, deviceId, event.error);
2658
+ yield* store.failTask(event.taskId, deviceId, event.error, event.runId);
2207
2659
  return;
2208
2660
  case "TaskCancelled":
2209
- yield* store.acknowledgeCancellation(event.taskId, deviceId);
2661
+ yield* store.acknowledgeCancellation(event.taskId, deviceId, event.runId);
2210
2662
  return;
2211
2663
  case "OperationAccepted":
2212
2664
  yield* store.acceptOperation(event.operationId, deviceId);
@@ -2227,21 +2679,31 @@ Content-Length: 0\r
2227
2679
  return;
2228
2680
  }
2229
2681
  })).then(() => true).catch((cause) => {
2682
+ if (cause instanceof exports_store.PersistenceError && (taskTerminal && cause.missingRecord === "task" || operationTerminal && cause.missingRecord === "operation"))
2683
+ return true;
2230
2684
  sendError(socket, "event_failed", cause instanceof Error ? cause.message : String(cause));
2231
2685
  return false;
2232
2686
  });
2233
- if (processed && (event._tag === "TaskFinished" || event._tag === "TaskFailed" || event._tag === "TaskCancelled")) {
2234
- settle(event.taskId);
2687
+ if (processed && taskTerminal) {
2688
+ settle(event.taskId, event.runId);
2235
2689
  await dispatchPending(deviceId);
2236
2690
  }
2237
- if (processed && (event._tag === "OperationFinished" || event._tag === "OperationFailed")) {
2691
+ if (processed && operationTerminal) {
2238
2692
  settleOperation(event.operationId);
2239
2693
  await dispatchPending(deviceId);
2240
2694
  }
2241
2695
  };
2242
2696
  const api = async (request, url) => {
2243
2697
  if (url.pathname === "/api/health" && request.method === "GET") {
2244
- return json({ ok: true, version, taskAttachments: true, taskProgress: 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
+ });
2245
2707
  }
2246
2708
  if (url.pathname === "/api/auth/pair" && request.method === "POST") {
2247
2709
  return run2(Effect15.gen(function* () {
@@ -2252,7 +2714,8 @@ Content-Length: 0\r
2252
2714
  }
2253
2715
  const attachmentRoute = request.method === "GET" && /^\/api\/tasks\/[^/]+\/attachments(?:\/[^/]+)?$/.test(url.pathname);
2254
2716
  const progressRoute = request.method === "POST" && /^\/api\/tasks\/[^/]+\/progress$/.test(url.pathname);
2255
- const principal = await principalFor(request, attachmentRoute || progressRoute);
2717
+ const inputRoute = request.method === "POST" && /^\/api\/tasks\/[^/]+\/input$/.test(url.pathname);
2718
+ const principal = await principalFor(request, attachmentRoute || progressRoute || inputRoute);
2256
2719
  if (principal === undefined) {
2257
2720
  return json({ error: "Unauthorized" }, 401);
2258
2721
  }
@@ -2277,6 +2740,10 @@ Content-Length: 0\r
2277
2740
  hub.closeSession(id);
2278
2741
  return json(session);
2279
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
+ }
2280
2747
  if (url.pathname === "/api/devices" && request.method === "GET") {
2281
2748
  return json(yield* store.listDevices());
2282
2749
  }
@@ -2314,9 +2781,13 @@ Content-Length: 0\r
2314
2781
  return json(forgotten);
2315
2782
  }
2316
2783
  if (url.pathname === "/api/tasks" && request.method === "POST") {
2317
- 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);
2318
2789
  const providerSessionId = input.threadId === undefined || input.provider === "grok-bot" ? undefined : yield* store.sessionFor(input.threadId, target, input.provider ?? "codex");
2319
- const task2 = yield* store.createDelegation(input, target, principal, providerSessionId);
2790
+ const task2 = yield* store.createDelegation(input, target, principal, providerSessionId, requestedInput);
2320
2791
  return json(yield* Effect15.tryPromise(() => dispatch(task2)), 201);
2321
2792
  }
2322
2793
  if (url.pathname === "/api/inbox" && request.method === "GET") {
@@ -2386,11 +2857,35 @@ Content-Length: 0\r
2386
2857
  return json(yield* store.reportTaskProgress(id, deviceId, input.note));
2387
2858
  }
2388
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
+ }
2389
2884
  if (request.method === "POST" && cancel2?.[1] !== undefined) {
2390
2885
  const id = yield* pathId(TaskId, cancel2[1]);
2391
2886
  const updated = yield* store.requestCancellation(id);
2392
2887
  if (updated.status === "cancelling") {
2393
- hub.sendToDevice(updated.targetDeviceId, SocketEvent.make({ _tag: "CancelTask", taskId: id }));
2888
+ sendCancellation(updated);
2394
2889
  }
2395
2890
  return json(updated);
2396
2891
  }
@@ -2399,7 +2894,7 @@ Content-Length: 0\r
2399
2894
  return json(yield* store.threadContext(yield* pathId(ThreadId, thread[1])));
2400
2895
  }
2401
2896
  return yield* new RequestError({ status: 404, message: "Route not found" });
2402
- }).pipe(Effect15.mapError((cause) => cause instanceof RequestError ? cause : cause._tag === "RelayStore.TaskProgressError" ? new RequestError({ status: cause.status, message: cause.message }) : 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({
2403
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,
2404
2899
  message: cause.message
2405
2900
  })));
@@ -2507,26 +3002,40 @@ Content-Length: 0\r
2507
3002
  websocketServer.emit("connection", websocket, request);
2508
3003
  });
2509
3004
  });
2510
- await new Promise((resolve5, reject) => {
3005
+ await new Promise((resolve6, reject) => {
2511
3006
  server.once("error", reject);
2512
3007
  server.listen(relayListenOptions(configuration), () => {
2513
3008
  server.off("error", reject);
2514
- resolve5();
3009
+ resolve6();
2515
3010
  });
2516
3011
  });
2517
3012
  const address = server.address();
2518
3013
  const listener = typeof address === "string" ? address : address?.family === "IPv6" ? `[${address.address}]:${address.port}` : `${address?.address ?? configuration.host}:${address?.port ?? configuration.port}`;
2519
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
+ });
2520
3027
  let stopping = false;
2521
3028
  const shutdown = async () => {
2522
3029
  if (stopping) {
2523
3030
  return;
2524
3031
  }
2525
3032
  stopping = true;
3033
+ deadlineController.abort();
3034
+ await deadlineWork;
2526
3035
  for (const socket of websocketServer.clients) {
2527
3036
  socket.close(1001, "Relay shutting down");
2528
3037
  }
2529
- await new Promise((resolve5) => server.close(() => resolve5()));
3038
+ await new Promise((resolve6) => server.close(() => resolve6()));
2530
3039
  websocketServer.close();
2531
3040
  await runtime.dispose();
2532
3041
  };
@@ -2578,7 +3087,14 @@ class RelayDecodeError extends Schema2.TaggedErrorClass()("RelayClient.DecodeErr
2578
3087
  class Service extends Context.Service()("@cohall/RelayClient") {
2579
3088
  }
2580
3089
  var normalizeBaseUrl = (url) => url.replace(/\/+$/, "");
2581
- 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) => {
2582
3098
  if (response.body === null) {
2583
3099
  throw new RelayDecodeError({ operation, message: "Relay returned an empty response" });
2584
3100
  }
@@ -2591,9 +3107,12 @@ var jsonBody = async (response, operation) => {
2591
3107
  break;
2592
3108
  }
2593
3109
  size += chunk.value.byteLength;
2594
- if (size > 2 * 1024 * 1024) {
3110
+ if (size > maxBytes) {
2595
3111
  await reader.cancel();
2596
- 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
+ });
2597
3116
  }
2598
3117
  chunks.push(chunk.value);
2599
3118
  }
@@ -2622,42 +3141,80 @@ var responseMessage = async (response, operation) => {
2622
3141
  };
2623
3142
  var make = (options) => {
2624
3143
  const baseUrl = normalizeBaseUrl(options.baseUrl);
2625
- const request = (operation, path, schema, init) => Effect2.gen(function* () {
2626
- const response = yield* Effect2.tryPromise({
2627
- try: (signal) => fetch(`${baseUrl}${path}`, {
2628
- ...init,
2629
- signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]),
2630
- headers: {
2631
- authorization: `Bearer ${options.token}`,
2632
- "content-type": "application/json",
2633
- ...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
+ });
2634
3162
  }
2635
- }),
2636
- 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({
2637
3168
  operation,
2638
3169
  message: cause instanceof Error ? cause.message : String(cause)
2639
3170
  })
2640
3171
  });
2641
- if (!response.ok) {
2642
- return yield* Effect2.fail(new RelayRequestError({
2643
- operation,
2644
- message: yield* Effect2.promise(() => responseMessage(response, operation)),
2645
- status: response.status
2646
- }));
2647
- }
2648
- const json = yield* Effect2.tryPromise({
2649
- try: () => jsonBody(response, operation),
2650
- catch: (cause) => cause instanceof RelayDecodeError ? cause : new RelayDecodeError({ operation, message: String(cause) })
2651
- });
2652
3172
  return yield* Schema2.decodeUnknownEffect(schema)(json).pipe(Effect2.mapError((cause) => new RelayDecodeError({ operation, message: String(cause) })));
2653
3173
  });
3174
+ const devicePage = (after) => request("RelayClient.devices", `/api/devices/page${after === undefined ? "" : `?after=${encodeURIComponent(after)}`}`, DevicePage, undefined, maxDevicePageResponseBytes);
2654
3175
  return Service.of({
2655
- 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
+ }),
2656
3197
  forgetDevice: (deviceId) => request("RelayClient.forgetDevice", `/api/devices/${encodeURIComponent(deviceId)}/forget`, Device, { method: "POST" }),
2657
3198
  createTask: (input) => Effect2.gen(function* () {
2658
- if ((input.attachments?.length ?? 0) > 0) {
2659
- const health = yield* request("RelayClient.attachmentSupport", "/api/health", Schema2.Struct({ taskAttachments: Schema2.optionalKey(Schema2.Boolean) }));
2660
- 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) {
2661
3218
  return yield* new RelayRequestError({
2662
3219
  operation: "RelayClient.createTask",
2663
3220
  message: "Upgrade the Cohall relay before sending file attachments"
@@ -2669,6 +3226,16 @@ var make = (options) => {
2669
3226
  body: JSON.stringify(input)
2670
3227
  });
2671
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) }),
2672
3239
  getTask: (taskId) => request("RelayClient.getTask", `/api/tasks/${encodeURIComponent(taskId)}`, Task),
2673
3240
  reportTaskProgress: (taskId, input) => Effect2.gen(function* () {
2674
3241
  const health = yield* request("RelayClient.progressSupport", "/api/health", Schema2.Struct({ taskProgress: Schema2.optionalKey(Schema2.Boolean) }));
@@ -2730,7 +3297,7 @@ var make = (options) => {
2730
3297
  }),
2731
3298
  inbox: () => request("RelayClient.inbox", "/api/inbox", TaskInbox),
2732
3299
  acknowledgeCompletion: (taskId) => request("RelayClient.acknowledgeCompletion", `/api/inbox/${encodeURIComponent(taskId)}/ack`, TaskInboxItem, { method: "POST" }),
2733
- 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),
2734
3301
  cancelTask: (taskId) => request("RelayClient.cancelTask", `/api/tasks/${encodeURIComponent(taskId)}/cancel`, Task, {
2735
3302
  method: "POST"
2736
3303
  }),
@@ -2973,7 +3540,7 @@ var prepareCommand = async (options) => {
2973
3540
  }
2974
3541
  }
2975
3542
  };
2976
- var forEachJsonEvent = async (stdout, onEvent) => {
3543
+ var forEachJsonEvent = async (stdout, onEvent, onOversized) => {
2977
3544
  const limit = 1024 * 1024;
2978
3545
  let chunks = [];
2979
3546
  let size = 0;
@@ -2996,6 +3563,7 @@ var forEachJsonEvent = async (stdout, onEvent) => {
2996
3563
  const emit = () => {
2997
3564
  if (oversized) {
2998
3565
  oversized = false;
3566
+ onOversized();
2999
3567
  return;
3000
3568
  }
3001
3569
  const line = Buffer.concat(chunks, size).toString("utf8");
@@ -3027,6 +3595,7 @@ var forEachJsonEvent = async (stdout, onEvent) => {
3027
3595
  var parseCodex = async (stdout, existingSession) => {
3028
3596
  let result = "";
3029
3597
  let sessionId = existingSession;
3598
+ let discardedEvent = false;
3030
3599
  await forEachJsonEvent(stdout, (event) => {
3031
3600
  if (text(event.type) === "thread.started") {
3032
3601
  sessionId = text(event.thread_id) ?? sessionId;
@@ -3036,9 +3605,17 @@ var parseCodex = async (stdout, existingSession) => {
3036
3605
  }
3037
3606
  const item = record(event.item);
3038
3607
  if (text(item?.type) === "agent_message") {
3039
- 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;
3040
3612
  }
3613
+ }, () => {
3614
+ discardedEvent = true;
3041
3615
  });
3616
+ if (discardedEvent) {
3617
+ throw new Error("Provider JSON event exceeded 1 MiB without a later text response");
3618
+ }
3042
3619
  return {
3043
3620
  result: result || "Codex completed the task without a text response.",
3044
3621
  ...sessionId === undefined ? {} : { sessionId }
@@ -3075,17 +3652,26 @@ var openCodeError = (event) => {
3075
3652
  var parseOpenCode = async (stdout, existingSession) => {
3076
3653
  let result = "";
3077
3654
  let sessionId = existingSession;
3655
+ let discardedEvent = false;
3078
3656
  await forEachJsonEvent(stdout, (event) => {
3079
3657
  const failure = openCodeError(event);
3080
3658
  if (failure !== undefined) {
3081
3659
  throw new Error(failure);
3082
3660
  }
3083
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;
3084
3664
  const content = textFromPart(event.part) ?? textFromPart(event) ?? text(event.result);
3085
3665
  if (content !== undefined && content.length > 0) {
3086
3666
  result = content;
3667
+ discardedEvent = false;
3087
3668
  }
3669
+ }, () => {
3670
+ discardedEvent = true;
3088
3671
  });
3672
+ if (discardedEvent) {
3673
+ throw new Error("Provider JSON event exceeded 1 MiB without a later text response");
3674
+ }
3089
3675
  if (result.length === 0) {
3090
3676
  throw new Error("OpenCode produced no result; verify its authentication and use a supported project workspace");
3091
3677
  }
@@ -3171,7 +3757,8 @@ var run = (options) => Effect3.callback((resume, signal) => {
3171
3757
  ...providerEnvironment(),
3172
3758
  COHALL_PROVIDER: options.provider,
3173
3759
  COHALL_THREAD_ID: options.threadId,
3174
- ...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 }
3175
3762
  },
3176
3763
  extendEnv: false,
3177
3764
  detached: platform() !== "win32",
@@ -3263,7 +3850,7 @@ var run = (options) => Effect3.callback((resume, signal) => {
3263
3850
  import { Effect as Effect10, Schema as Schema11 } from "effect";
3264
3851
  import { access as access4, mkdir as mkdir7, readFile as readFile4, stat as stat4, writeFile as writeFile7 } from "node:fs/promises";
3265
3852
  import { homedir as homedir4 } from "node:os";
3266
- 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";
3267
3854
 
3268
3855
  // skills/cohall/SKILL.md
3269
3856
  var SKILL_default = `---
@@ -3275,7 +3862,8 @@ description: Delegate through Cohall to another user-owned device or an existing
3275
3862
 
3276
3863
  Cohall sends a focused task to a coding agent or an existing named Grok Bot. The target
3277
3864
  keeps its local files, credentials, browser state, provider login, skills, and
3278
- 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.
3279
3867
 
3280
3868
  Use the installed \`cohall\` executable when it is available. Fall back to
3281
3869
  \`npx -y @akshar5/cohall\` only when Cohall is not installed globally.
@@ -3332,9 +3920,19 @@ Do not delegate ordinary local work when the other device provides no advantage.
3332
3920
  to use Codex on a computer; selecting a named bot infers \`grok-bot\`.
3333
3921
  Omit \`--target\` only when Cohall may choose a matching computer.
3334
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
+
3335
3932
  5. The command waits by default and returns JSON. Treat work as successful only
3336
3933
  when \`status\` is \`completed\`; use \`result\` in the current task. Report a
3337
- \`failed\`, \`cancelled\`, or \`cancelling\` state accurately.
3934
+ \`failed\`, \`cancelled\`, or \`cancelling\` state accurately. Handle \`needs_input\`
3935
+ through the clarification workflow below.
3338
3936
 
3339
3937
  Pass an explicit local file with \`--attach path\` when the coding task needs
3340
3938
  it. The MCP \`delegate\` tool takes \`attachment_paths\`. A task accepts up to
@@ -3361,9 +3959,32 @@ and \`COHALL_TASK_ID\` carry its thread and parent task. Pass \`--thread\` and
3361
3959
  \`--parent\` explicitly from a Grok Bot using the IDs in its handoff.
3362
3960
 
3363
3961
  For work sent with \`--no-wait\`, check \`cohall inbox\` when returning to the task.
3364
- Read the full result with \`cohall status <task-id>\`, use it in the current work,
3365
- then run \`cohall inbox ack <task-id>\`. Results that arrive after a wait times
3366
- 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.
3367
3988
 
3368
3989
  ## Message named bots
3369
3990
 
@@ -3393,11 +4014,13 @@ Different bots and the computer's coding agent can run concurrently. Each
3393
4014
  individual bot processes one Cohall request at a time.
3394
4015
 
3395
4016
  When receiving a Cohall request inside a Grok Bot, finish with the \`cohall reply\`
3396
- command supplied in the handoff. Pass the final answer through \`--message-file\`
3397
- 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
3398
4019
  to finish. This local receipt is what returns the result to the sending device;
3399
4020
  a chat message alone does not complete the Cohall task. The receipt survives
3400
- 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.
3401
4024
 
3402
4025
  ## Context and safety
3403
4026
 
@@ -3405,7 +4028,7 @@ worker restarts and works while the relay is temporarily unreachable.
3405
4028
  - Add new relevant developments to \`--context\` when following up from another chat.
3406
4029
  - Never send provider credentials, Cohall tokens, cookies, or browser-profile data.
3407
4030
  - Request a path only when the target advertises a matching workspace root.
3408
- - Use the same thread for clarification instead of creating duplicate tasks.
4031
+ - Resume a clarification on its existing task instead of creating duplicate work.
3409
4032
  - Do not submit the same work through both CLI and MCP.
3410
4033
  - Respect user confirmation requirements for consequential actions on the target.
3411
4034
 
@@ -3426,16 +4049,25 @@ The relay itself must be reachable to accept a new task. Interrupted execution
3426
4049
  uses at-least-once delivery and may run again, so make consequential prompts safe
3427
4050
  to retry.
3428
4051
 
3429
- The timeout error includes the task ID and last known status. The task continues
3430
- 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:
3431
4061
 
3432
4062
  \`\`\`bash
3433
4063
  cohall cancel <task-id>
3434
4064
  \`\`\`
3435
4065
 
3436
- For coding agents, active cancellation is acknowledged by the target device;
3437
- \`cancelling\` means the provider process has not confirmed termination yet.
3438
- 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
3439
4071
  cannot safely cancel an individual Cohall request.
3440
4072
 
3441
4073
  ## Read shared context
@@ -3469,9 +4101,10 @@ Trace a known task before inspecting machine-local service logs:
3469
4101
  cohall trace <task-id> --follow
3470
4102
  \`\`\`
3471
4103
 
3472
- The trace is redacted and reports relay dispatch, device execution, retries,
3473
- and terminal state. Use \`cohall thread <thread-id>\` when prompt and result
3474
- 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.
3475
4108
 
3476
4109
  If Cohall cannot connect or no target is available, run:
3477
4110
 
@@ -3509,7 +4142,8 @@ var ClientConfiguration = Schema4.Struct({
3509
4142
  relayUrl: Schema4.NonEmptyString,
3510
4143
  token: Schema4.NonEmptyString,
3511
4144
  mcpThreadId: Schema4.optionalKey(Schema4.String),
3512
- mcpTaskId: Schema4.optionalKey(Schema4.String)
4145
+ mcpTaskId: Schema4.optionalKey(Schema4.String),
4146
+ mcpTaskRunId: Schema4.optionalKey(TaskRunId)
3513
4147
  });
3514
4148
  var DeviceConfiguration = Schema4.Struct({
3515
4149
  relayUrl: Schema4.NonEmptyString,
@@ -3672,7 +4306,8 @@ var loadClientConfiguration = Effect4.tryPromise({
3672
4306
  relayUrl,
3673
4307
  token,
3674
4308
  ...process.env.COHALL_THREAD_ID === undefined ? {} : { mcpThreadId: process.env.COHALL_THREAD_ID },
3675
- ...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) }
3676
4311
  });
3677
4312
  },
3678
4313
  catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
@@ -3736,7 +4371,7 @@ import { Effect as Effect5, Schedule, Schema as Schema5 } from "effect";
3736
4371
  class DeviceSelectionError extends Schema5.TaggedErrorClass()("Cohall.DeviceSelectionError", { message: Schema5.String, devices: Schema5.Array(Schema5.String) }) {
3737
4372
  }
3738
4373
 
3739
- 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) }) {
3740
4375
  }
3741
4376
  var TaskResult = Schema5.Struct({
3742
4377
  task_id: TaskId,
@@ -3748,7 +4383,9 @@ var TaskResult = Schema5.Struct({
3748
4383
  result: Schema5.optionalKey(Schema5.String),
3749
4384
  error: Schema5.optionalKey(Schema5.String),
3750
4385
  inbox_warning: Schema5.optionalKey(Schema5.String),
3751
- progress: Schema5.optionalKey(TaskProgress)
4386
+ progress: Schema5.optionalKey(TaskProgress),
4387
+ input_request: Schema5.optionalKey(TaskClarification),
4388
+ expires_at: Schema5.optionalKey(TaskDeadline)
3752
4389
  });
3753
4390
  var matchingDevices = (devices, target) => {
3754
4391
  const value = target.replace(/^@/, "");
@@ -3792,22 +4429,33 @@ var selectTarget = Effect5.fn("Cohall.selectTarget")(function* (devices, target)
3792
4429
  ]
3793
4430
  });
3794
4431
  });
3795
- var taskResult = (task) => TaskResult.make({
3796
- task_id: task.id,
3797
- thread_id: task.threadId,
3798
- status: task.status,
3799
- provider: task.provider,
3800
- target_device_id: task.targetDeviceId,
3801
- ...task.botId === undefined ? {} : { bot_id: task.botId },
3802
- ...task.result === undefined ? {} : { result: task.result },
3803
- ...task.error === undefined ? {} : { error: task.error },
3804
- ...task.progress === undefined ? {} : { progress: task.progress }
3805
- });
3806
- 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();
3807
4450
  const result = taskResult(task);
3808
- 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(() => {
3809
4456
  return;
3810
4457
  }).catch((cause) => {
4458
+ signal?.throwIfAborted();
3811
4459
  if (cause instanceof RelayRequestError && cause.status === 404)
3812
4460
  return;
3813
4461
  return cause instanceof Error ? cause.message : String(cause);
@@ -3817,16 +4465,51 @@ var acknowledgedTaskResult = async (client, task) => {
3817
4465
  inbox_warning: `Result received, but the inbox could not be cleared: ${acknowledgementError}`
3818
4466
  });
3819
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
+ });
3820
4484
  var createDelegation = Effect5.fn("Cohall.createDelegation")(function* (client, configuration, options) {
3821
- const devices = yield* client.devices();
3822
- 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) {
3823
4506
  return yield* new DeviceSelectionError({
3824
4507
  message: "No Cohall devices are registered",
3825
4508
  devices: []
3826
4509
  });
3827
4510
  }
3828
- let target = options.target === undefined ? undefined : yield* selectTarget(devices, options.target);
3829
- 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) {
3830
4513
  const context = yield* client.threadContext(options.threadId);
3831
4514
  const previous = context.tasks.findLast((task) => task.parentTaskId === undefined);
3832
4515
  if (previous === undefined && context.truncated) {
@@ -3857,6 +4540,7 @@ var createDelegation = Effect5.fn("Cohall.createDelegation")(function* (client,
3857
4540
  const parentTaskId = options.parentTaskId ?? (configuration.mcpTaskId === undefined ? undefined : yield* Schema5.decodeUnknownEffect(TaskId)(configuration.mcpTaskId));
3858
4541
  return yield* client.createTask({
3859
4542
  prompt: options.prompt,
4543
+ ...options.requestId === undefined ? {} : { requestId: options.requestId },
3860
4544
  ...target === undefined ? {} : { targetDeviceId: target.deviceId },
3861
4545
  ...botId === undefined ? {} : { botId },
3862
4546
  ...options.threadId !== undefined ? { threadId: options.threadId } : inheritedThread === undefined ? {} : { threadId: ThreadId.make(inheritedThread) },
@@ -3864,30 +4548,35 @@ var createDelegation = Effect5.fn("Cohall.createDelegation")(function* (client,
3864
4548
  ...options.workspace === undefined ? {} : { workspace: options.workspace },
3865
4549
  ...provider === undefined ? {} : { provider },
3866
4550
  ...parentTaskId === undefined ? {} : { parentTaskId },
3867
- ...options.attachments === undefined ? {} : { attachments: options.attachments }
4551
+ ...options.attachments === undefined ? {} : { attachments: options.attachments },
4552
+ ...options.expiresAt === undefined ? {} : { expiresAt: options.expiresAt }
3868
4553
  });
3869
4554
  });
3870
4555
  var waitForTask = Effect5.fn("Cohall.waitForTask")(function* (client, initial, timeoutSeconds) {
3871
- const deadline = Date.now() + timeoutSeconds * 1000;
3872
- let task = initial;
3873
- while (!isTerminalTask(task)) {
3874
- if (Date.now() >= deadline) {
3875
- return yield* new TaskWaitTimeoutError({
3876
- message: `Task ${task.id} is still ${task.status} after ${timeoutSeconds} seconds`,
3877
- taskId: task.id,
3878
- status: task.status
3879
- });
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);
3880
4564
  }
3881
- yield* Effect5.sleep("1 second");
3882
- task = yield* client.getTask(task.id);
3883
- }
3884
- 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
+ }));
3885
4574
  });
3886
4575
  var followTaskTrace = Effect5.fn("Cohall.followTaskTrace")(function* (client, taskId, onTrace) {
3887
4576
  let revision;
3888
4577
  const poll = client.traceTask(taskId).pipe(Effect5.tap((trace) => {
3889
4578
  const latest = trace.events.at(-1);
3890
- const nextRevision = `${trace.status}:${latest?.kind ?? "none"}:${latest?.at ?? "none"}:${trace.progress?.at ?? ""}:${trace.progress?.note ?? ""}`;
4579
+ const nextRevision = `${trace.status}:${latest?.kind ?? "none"}:${latest?.at ?? "none"}:${trace.progress?.at ?? ""}:${trace.progress?.note ?? ""}:${JSON.stringify(trace.clarifications)}`;
3891
4580
  if (nextRevision === revision) {
3892
4581
  return Effect5.succeed(undefined);
3893
4582
  }
@@ -3973,11 +4662,18 @@ var Dispatch = Schema6.Struct({ taskId: TaskId });
3973
4662
  var Reply = Schema6.Union([
3974
4663
  Schema6.Struct({
3975
4664
  result: Schema6.String.check(Schema6.isMaxLength(131072)),
3976
- error: Schema6.optionalKey(Schema6.Never)
4665
+ error: Schema6.optionalKey(Schema6.Never),
4666
+ question: Schema6.optionalKey(Schema6.Never)
3977
4667
  }),
3978
4668
  Schema6.Struct({
3979
4669
  error: Schema6.NonEmptyString.check(Schema6.isMaxLength(16384)),
3980
- 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)
3981
4677
  })
3982
4678
  ]);
3983
4679
  var Receipt = Schema6.Struct({
@@ -3987,14 +4683,15 @@ var Receipt = Schema6.Struct({
3987
4683
  });
3988
4684
  var isMissing = (cause) => cause instanceof Error && ("code" in cause) && cause.code === "ENOENT";
3989
4685
  var alreadyExists = (cause) => cause instanceof Error && ("code" in cause) && cause.code === "EEXIST";
3990
- var paths = (taskId) => {
4686
+ var paths = (taskId, runId) => {
3991
4687
  const id = Schema6.decodeUnknownSync(TaskId)(taskId);
3992
4688
  const directory = join3(dirname2(configurationPath()), "bot-replies");
4689
+ const key = runId === undefined ? id : `${id}.${Schema6.decodeUnknownSync(TaskRunId)(runId)}`;
3993
4690
  return {
3994
4691
  directory,
3995
- pending: join3(directory, `${id}.pending.json`),
3996
- receipt: join3(directory, `${id}.reply.json`),
3997
- 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`)
3998
4695
  };
3999
4696
  };
4000
4697
  var readJson = async (path, limit) => {
@@ -4023,8 +4720,8 @@ var readJson = async (path, limit) => {
4023
4720
  await file.close();
4024
4721
  }
4025
4722
  };
4026
- var pendingReply = async (taskId) => {
4027
- const value = await readJson(paths(taskId).pending, 4096);
4723
+ var pendingReply = async (taskId, runId) => {
4724
+ const value = await readJson(paths(taskId, runId).pending, 4096);
4028
4725
  if (value === undefined)
4029
4726
  return;
4030
4727
  const decoded = Schema6.decodeUnknownResult(Pending)(value);
@@ -4067,7 +4764,7 @@ var prepareBotReply = async (task) => {
4067
4764
  if (task.provider !== "grok-bot" || task.botId === undefined) {
4068
4765
  throw new Error("Only a named Grok Bot task can receive a bot reply");
4069
4766
  }
4070
- const files = paths(task.id);
4767
+ const files = paths(task.id, task.runId);
4071
4768
  await mkdir2(files.directory, { recursive: true, mode: 448 });
4072
4769
  await chmod3(files.directory, 448);
4073
4770
  const record2 = Pending.make({
@@ -4077,7 +4774,7 @@ var prepareBotReply = async (task) => {
4077
4774
  deadline: Date.now() + replyLifetimeMs
4078
4775
  });
4079
4776
  await publish(files.pending, record2);
4080
- const pending = await pendingReply(task.id);
4777
+ const pending = await pendingReply(task.id, task.runId);
4081
4778
  if (pending === undefined || pending.botId !== task.botId || pending.threadId !== task.threadId) {
4082
4779
  throw new Error("Bot reply manifest does not match this task");
4083
4780
  }
@@ -4094,19 +4791,19 @@ var prepareBotReply = async (task) => {
4094
4791
  dispatched: dispatch !== undefined
4095
4792
  };
4096
4793
  };
4097
- var claimBotDispatch = async (taskId) => {
4098
- const pending = await pendingReply(taskId);
4794
+ var claimBotDispatch = async (taskId, runId) => {
4795
+ const pending = await pendingReply(taskId, runId);
4099
4796
  if (pending === undefined)
4100
4797
  throw new Error("No pending bot reply for this task");
4101
4798
  if (Date.now() >= pending.deadline)
4102
4799
  throw new Error("Bot reply deadline has expired");
4103
- return publish(paths(taskId).dispatch, { taskId });
4800
+ return publish(paths(taskId, runId).dispatch, { taskId });
4104
4801
  };
4105
- var readBotReply = async (taskId) => {
4106
- const pending = await pendingReply(taskId);
4802
+ var readBotReply = async (taskId, runId) => {
4803
+ const pending = await pendingReply(taskId, runId);
4107
4804
  if (pending === undefined)
4108
4805
  return;
4109
- const value = await readJson(paths(taskId).receipt, 1024 * 1024);
4806
+ const value = await readJson(paths(taskId, runId).receipt, 1024 * 1024);
4110
4807
  if (value === undefined)
4111
4808
  return;
4112
4809
  const decoded = Schema6.decodeUnknownResult(Receipt)(value);
@@ -4118,15 +4815,15 @@ var readBotReply = async (taskId) => {
4118
4815
  }
4119
4816
  return decoded.success.reply;
4120
4817
  };
4121
- var writeBotReply = async (taskId, reply) => {
4818
+ var writeBotReply = async (taskId, reply, runId) => {
4122
4819
  const decoded = Schema6.decodeUnknownResult(Reply)(reply);
4123
4820
  if (decoded._tag === "Failure") {
4124
- 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");
4125
4822
  }
4126
- const pending = await pendingReply(taskId);
4823
+ const pending = await pendingReply(taskId, runId);
4127
4824
  if (pending === undefined)
4128
4825
  throw new Error("No pending bot reply for this task");
4129
- const existing = await readBotReply(taskId);
4826
+ const existing = await readBotReply(taskId, runId);
4130
4827
  if (existing !== undefined) {
4131
4828
  if (JSON.stringify(existing) === JSON.stringify(decoded.success))
4132
4829
  return;
@@ -4135,15 +4832,15 @@ var writeBotReply = async (taskId, reply) => {
4135
4832
  const submittedAt = Date.now();
4136
4833
  if (submittedAt >= pending.deadline)
4137
4834
  throw new Error("Bot reply deadline has expired");
4138
- if (await publish(paths(taskId).receipt, { taskId, submittedAt, reply: decoded.success }))
4835
+ if (await publish(paths(taskId, runId).receipt, { taskId, submittedAt, reply: decoded.success }))
4139
4836
  return;
4140
- const winner = await readBotReply(taskId);
4837
+ const winner = await readBotReply(taskId, runId);
4141
4838
  if (JSON.stringify(winner) !== JSON.stringify(decoded.success)) {
4142
4839
  throw new Error("A different reply has already been submitted for this task");
4143
4840
  }
4144
4841
  };
4145
- var cleanupBotReply = async (taskId) => {
4146
- const files = paths(taskId);
4842
+ var cleanupBotReply = async (taskId, runId) => {
4843
+ const files = paths(taskId, runId);
4147
4844
  for (const path of [files.pending, files.receipt, files.dispatch]) {
4148
4845
  await unlink(path).catch((cause) => {
4149
4846
  if (!isMissing(cause))
@@ -4293,29 +4990,43 @@ var requireTime = (deadline) => {
4293
4990
  throw new Error(timeoutMessage);
4294
4991
  };
4295
4992
  var finishReply = (reply) => {
4993
+ if (reply.question !== undefined)
4994
+ return { result: "", question: reply.question };
4296
4995
  if (reply.error !== undefined)
4297
4996
  throw new Error(reply.error);
4298
4997
  if (reply.result === undefined)
4299
4998
  throw new Error("Grok Bot returned an invalid Cohall reply");
4300
4999
  return { result: reply.result };
4301
5000
  };
4302
- var taskPrompt = (task, command) => [
4303
- task.prompt,
4304
- 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:
4305
5006
  ${task.context}`,
4306
- `This request came through Cohall. Thread: ${task.threadId}. Task: ${task.id}.`,
4307
- `You may use your own tools and delegate work to another Cohall device when the request calls for it. Discover devices with: ${command} devices`,
4308
- `Delegation example: ${command} delegate --thread ${task.threadId} --parent ${task.id} --target @device --provider codex --prompt 'Concrete task' --context 'Relevant context'`,
4309
- "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:",
4310
- `${command} reply ${task.id} --message-file /absolute/path/to/your-answer.txt`,
4311
- `Alternatively pipe your answer into: ${command} reply ${task.id} --message -`,
4312
- `If you cannot complete this task, report the reason with: ${command} reply ${task.id} --error 'Reason'`,
4313
- "The reply command must succeed. Then send the same answer in this chat. A normal chat message alone does not complete the Cohall task."
4314
- ].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(`
4315
5025
 
4316
5026
  `);
4317
- var runGrokBot = async (path, task, signal) => {
4318
- 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);
4319
5030
  if (existing !== undefined)
4320
5031
  return finishReply(existing);
4321
5032
  const { deadline, command, dispatched } = await prepareBotReply(task);
@@ -4326,11 +5037,11 @@ var runGrokBot = async (path, task, signal) => {
4326
5037
  const lookup = async (lookupSignal = signal) => {
4327
5038
  if (path === undefined)
4328
5039
  throw new Error("Configure this device's Grok Bot gateway discovery file first");
4329
- 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);
4330
5041
  };
4331
5042
  const waitForReply = (reconcile = false) => {
4332
5043
  const receipt = poll(async () => {
4333
- const receipt2 = await readBotReply(task.id);
5044
+ const receipt2 = await readBotReply(task.id, task.runId);
4334
5045
  if (receipt2 !== undefined)
4335
5046
  return finishReply(receipt2);
4336
5047
  requireTime(deadline);
@@ -4359,7 +5070,7 @@ var runGrokBot = async (path, task, signal) => {
4359
5070
  if (acceptance.outcome === "unknown-durability")
4360
5071
  throw new Error("Grok Bot cannot confirm whether this task was accepted; check its conversation before retrying");
4361
5072
  if (acceptance.outcome === "not-found") {
4362
- const receipt = await readBotReply(task.id);
5073
+ const receipt = await readBotReply(task.id, task.runId);
4363
5074
  if (receipt !== undefined)
4364
5075
  return finishReply(receipt);
4365
5076
  requireTime(deadline);
@@ -4371,20 +5082,20 @@ var runGrokBot = async (path, task, signal) => {
4371
5082
  acceptance = await lookup();
4372
5083
  if (acceptance.outcome === "not-found") {
4373
5084
  requireTime(deadline);
4374
- if (!await claimBotDispatch(task.id)) {
5085
+ if (!await claimBotDispatch(task.id, task.runId)) {
4375
5086
  return waitForReply(true);
4376
5087
  }
4377
5088
  try {
4378
5089
  await rpc(connection, "sendPrompt", {
4379
5090
  agentId,
4380
- prompt: taskPrompt(task, command),
4381
- clientNonce: task.id,
5091
+ prompt: taskPrompt(task, command, supportsClarification),
5092
+ clientNonce: task.runId ?? task.id,
4382
5093
  directAddressedAcceptance: true
4383
5094
  }, SendResult, signal);
4384
5095
  } catch {
4385
5096
  if (signal.aborted)
4386
5097
  throw new Error("Stopped waiting for Grok Bot; its run may still be active");
4387
- const receipt2 = await readBotReply(task.id);
5098
+ const receipt2 = await readBotReply(task.id, task.runId);
4388
5099
  if (receipt2 !== undefined)
4389
5100
  return finishReply(receipt2);
4390
5101
  return waitForReply(true);
@@ -4475,9 +5186,13 @@ var guidedSetupInput = async (options, prompter) => {
4475
5186
  const relayUrl = normalizeRelayUrl(options.relayUrl ?? await prompter.answer("Relay URL", existing?.relayUrl ?? "http://127.0.0.1:8787"));
4476
5187
  const credentials = credentialsForRelay(existing, relayUrl);
4477
5188
  const hasRequiredCredentials = credentials.clientToken !== undefined && (options.clientOnly || credentials.deviceToken !== undefined);
4478
- 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
+ }
4479
5194
  const deviceName = options.clientOnly ? undefined : options.deviceName ?? await prompter.answer("Device name", existing?.deviceName ?? hostname2());
4480
- 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"));
4481
5196
  const token = options.token ?? (hasRequiredCredentials ? undefined : await prompter.secret("Pairing token"));
4482
5197
  if (!hasRequiredCredentials && (token === undefined || token.length === 0)) {
4483
5198
  throw new Error("A pairing token is required");
@@ -4508,21 +5223,21 @@ import {
4508
5223
  mkdir as mkdir5,
4509
5224
  mkdtemp as mkdtemp2,
4510
5225
  readFile as readFile3,
4511
- realpath as realpath4,
5226
+ realpath as realpath3,
4512
5227
  rename as rename3,
4513
5228
  rm as rm3,
4514
5229
  stat as stat3,
4515
5230
  writeFile as writeFile5
4516
5231
  } from "node:fs/promises";
4517
- 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";
4518
5233
  import { backup as sqliteBackup, DatabaseSync } from "node:sqlite";
4519
5234
  import { WebSocket } from "ws";
4520
5235
 
4521
5236
  // apps/device/src/service.ts
4522
5237
  import { execFile as execFile2 } from "node:child_process";
4523
- 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";
4524
5239
  import { homedir as homedir3 } from "node:os";
4525
- import { dirname as dirname4, join as join5 } from "node:path";
5240
+ import { dirname as dirname4, isAbsolute as isAbsolute3, join as join5 } from "node:path";
4526
5241
 
4527
5242
  // apps/device/src/upgrade.ts
4528
5243
  import { Schema as Schema8 } from "effect";
@@ -4533,16 +5248,61 @@ import {
4533
5248
  chmod as chmod4,
4534
5249
  mkdir as mkdir3,
4535
5250
  readFile as readFile2,
5251
+ readdir,
4536
5252
  realpath as realpath2,
4537
5253
  rename as rename2,
4538
5254
  rm as rm2,
4539
5255
  stat as stat2,
4540
5256
  writeFile as writeFile3
4541
5257
  } from "node:fs/promises";
4542
- 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";
4543
5259
  import { platform as operatingSystem } from "node:os";
4544
5260
  var packageName = "@akshar5/cohall";
4545
- 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
+ };
4546
5306
  var PackageManager = Schema8.Literals(["npm", "bun", "pnpm"]);
4547
5307
  var RestartReceipt = Schema8.Struct({
4548
5308
  version: Schema8.String,
@@ -4552,45 +5312,165 @@ var RestartReceipt = Schema8.Struct({
4552
5312
  restartedServices: Schema8.Array(Schema8.String),
4553
5313
  restartingService: Schema8.optionalKey(Schema8.String)
4554
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
+ ]);
4555
5325
  var normalizePath = (path) => path.replaceAll("\\", "/");
5326
+ var pnpmGlobalDirectory = /^(.*\/global\/v?\d+)\//;
4556
5327
  var normalizeUpgradeTarget = (target) => {
4557
5328
  if (target === undefined || target === "latest") {
4558
5329
  return "latest";
4559
5330
  }
4560
5331
  const normalized = target.startsWith("v") ? target.slice(1) : target;
4561
- if (!semanticVersion.test(normalized)) {
5332
+ if (parseVersion(normalized) === undefined) {
4562
5333
  throw new Error("--to must be latest or an exact semantic version");
4563
5334
  }
4564
5335
  return normalized;
4565
5336
  };
4566
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.`;
4567
- var packageInstallation = (canonicalEntrypoint, entrypoint = canonicalEntrypoint) => {
5338
+ var packageInstallation = (canonicalEntrypoint, entrypoint = canonicalEntrypoint, runtimePlatform = process.platform) => {
4568
5339
  const path = normalizePath(canonicalEntrypoint);
4569
5340
  const marker = `/node_modules/${packageName}/`;
4570
5341
  const packageIndex = path.lastIndexOf(marker);
4571
5342
  if (packageIndex === -1) {
4572
5343
  throw new Error("cohall upgrade requires a global npm, Bun, or pnpm installation; package-runner and source-checkout executions cannot upgrade themselves");
4573
5344
  }
4574
- 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/"))) {
4575
5346
  throw new Error("This Cohall process is running from a temporary package-runner cache; install it globally before using cohall upgrade");
4576
5347
  }
4577
5348
  if (path.includes("/.bun/install/global/node_modules/")) {
4578
5349
  return { manager: "bun", entrypoint };
4579
5350
  }
4580
- if (path.includes("/pnpm/global/")) {
5351
+ if (pnpmGlobalDirectory.test(path) || /\/v\d+\/links\//.test(path) || path.includes("/node_modules/.pnpm/") || pnpmGlobalDirectory.test(normalizePath(entrypoint))) {
4581
5352
  return { manager: "pnpm", entrypoint };
4582
5353
  }
4583
5354
  const nodeModulesParent = path.slice(0, packageIndex);
4584
- 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;
4585
5356
  return { manager: "npm", prefix, entrypoint };
4586
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
+ };
4587
5453
  var packageInstallCommand = (installation, target) => {
4588
5454
  const specification = `${packageName}@${target}`;
4589
5455
  switch (installation.manager) {
4590
5456
  case "bun":
4591
- 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
+ };
4592
5462
  case "pnpm":
4593
- 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
+ };
4594
5474
  case "npm":
4595
5475
  return {
4596
5476
  command: "npm",
@@ -4629,36 +5509,38 @@ var isTrustedGroupWritablePath = (options) => {
4629
5509
  const writableRoot = options.writableRoot;
4630
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);
4631
5511
  };
4632
- var validateExecutable = async (candidate, installationRoot) => {
5512
+ var validateExecutable = async (candidate, installationRoot, preserveSymlink = false) => {
4633
5513
  const canonical = await realpath2(candidate);
4634
5514
  if (operatingSystem() === "win32") {
4635
- return canonical;
5515
+ return preserveSymlink ? resolve2(candidate) : canonical;
4636
5516
  }
4637
5517
  const uid = process.getuid?.();
4638
5518
  const writableRoot = installationRoot === undefined ? undefined : await realpath2(installationRoot);
4639
- for (let path = canonical;; path = dirname3(path)) {
4640
- const metadata = await stat2(path);
4641
- const trustedGroupWritablePath = isTrustedGroupWritablePath({
4642
- platform: operatingSystem(),
4643
- canonical,
4644
- writableRoot,
4645
- path,
4646
- uid,
4647
- ownerUid: metadata.uid,
4648
- ownerGid: metadata.gid
4649
- });
4650
- if ((metadata.mode & 2) !== 0 || (metadata.mode & 16) !== 0 && !trustedGroupWritablePath) {
4651
- throw new Error(`Refusing executable beneath group- or world-writable path ${path}`);
4652
- }
4653
- if (uid !== undefined && metadata.uid !== 0 && metadata.uid !== uid && !isTrustedSystemPath(operatingSystem(), path)) {
4654
- throw new Error(`Refusing executable owned by another user at ${path}`);
4655
- }
4656
- const parent = dirname3(path);
4657
- if (parent === path) {
4658
- 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
+ }
4659
5541
  }
4660
5542
  }
4661
- return canonical;
5543
+ return preserveSymlink ? resolve2(candidate) : canonical;
4662
5544
  };
4663
5545
  var trustedExecutable = async (command, options = {}) => {
4664
5546
  const candidates = isAbsolute2(command) ? [command] : (process.env.PATH ?? "").split(delimiter2).filter(Boolean).flatMap((directory) => executableNames(command).map((name) => join4(directory, name)));
@@ -4668,7 +5550,7 @@ var trustedExecutable = async (command, options = {}) => {
4668
5550
  if (!available2)
4669
5551
  continue;
4670
5552
  try {
4671
- return await validateExecutable(path, options.writableRoot);
5553
+ return await validateExecutable(path, options.writableRoot, options.preserveSymlink);
4672
5554
  } catch (cause) {
4673
5555
  failure ??= cause;
4674
5556
  }
@@ -4710,6 +5592,43 @@ var trustedServices = async (services, resolveExecutable) => {
4710
5592
  }
4711
5593
  return secured;
4712
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
+ };
4713
5632
  var serviceCandidates = (runtimePlatform, uid) => {
4714
5633
  if (runtimePlatform === "linux") {
4715
5634
  const systemd = (manager, unit, device) => {
@@ -4769,6 +5688,18 @@ var serviceCandidates = (runtimePlatform, uid) => {
4769
5688
  "if ((Get-ScheduledTask -TaskName 'Cohall Device' -ErrorAction SilentlyContinue).State -eq 'Running') { exit 0 } else { exit 1 }"
4770
5689
  ]
4771
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
+ },
4772
5703
  restart: [
4773
5704
  {
4774
5705
  command: "schtasks.exe",
@@ -4783,13 +5714,14 @@ var serviceCandidates = (runtimePlatform, uid) => {
4783
5714
  return [];
4784
5715
  };
4785
5716
  var defaultRunner = {
4786
- run: async (command, arguments_, timeoutMs = 300000) => {
5717
+ run: async (command, arguments_, timeoutMs = 300000, environment) => {
4787
5718
  const result = await execa2(command, arguments_, {
4788
5719
  encoding: "utf8",
4789
5720
  maxBuffer: 1024 * 1024,
4790
5721
  timeout: timeoutMs,
4791
5722
  windowsHide: true,
4792
5723
  stdin: "ignore",
5724
+ ...environment === undefined ? {} : { env: environment },
4793
5725
  reject: false,
4794
5726
  stripFinalNewline: false
4795
5727
  });
@@ -4802,7 +5734,7 @@ var defaultRunner = {
4802
5734
  }
4803
5735
  };
4804
5736
  var checked = async (runner, invocation, timeoutMs) => {
4805
- const result = await runner.run(invocation.command, invocation.arguments, timeoutMs);
5737
+ const result = await runner.run(invocation.command, invocation.arguments, timeoutMs, invocation.environment);
4806
5738
  if (result.exitCode === 0 || invocation.allowFailure === true) {
4807
5739
  return result;
4808
5740
  }
@@ -4820,7 +5752,7 @@ ${result.stderr}`))) {
4820
5752
  }
4821
5753
  return active;
4822
5754
  };
4823
- var assertServiceInstallations = async (runner, services, canonicalEntrypoint, entrypoint) => {
5755
+ var assertServiceInstallations = async (runner, services, installation) => {
4824
5756
  for (const service of services) {
4825
5757
  if (service.entrypoint === undefined) {
4826
5758
  continue;
@@ -4829,15 +5761,19 @@ var assertServiceInstallations = async (runner, services, canonicalEntrypoint, e
4829
5761
  const serviceEntrypoint = service.entrypoint.parse(`${result.stdout}
4830
5762
  ${result.stderr}`);
4831
5763
  if (serviceEntrypoint === undefined) {
4832
- 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." : ""}`);
4833
5765
  }
4834
5766
  const canonicalServiceEntrypoint = await realpath2(serviceEntrypoint).catch((cause) => {
4835
5767
  throw new Error(`Could not resolve the executable used by active ${service.label}: ${cause instanceof Error ? cause.message : String(cause)}`);
4836
5768
  });
4837
- 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.`}`);
4838
5774
  continue;
4839
5775
  }
4840
- 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.`);
4841
5777
  }
4842
5778
  };
4843
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(() => {
@@ -4907,13 +5843,19 @@ var upgrade = async (options) => {
4907
5843
  const candidates = await trustedServices(serviceCandidates(runtimePlatform, uid), resolveExecutable);
4908
5844
  const statePath = options.statePath ?? restartReceiptPath();
4909
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
+ });
4910
5855
  if (previous?.version === options.currentVersion && (target === "latest" || target === previous.version)) {
4911
- const byId = new Map(candidates.map((service) => [service.id, service]));
4912
- const pending = previous.pendingServices.flatMap((id) => {
4913
- const service = byId.get(id);
4914
- return service === undefined ? [] : [service];
4915
- });
5856
+ const pending = candidates.filter((service) => previous.pendingServices.includes(service.id));
4916
5857
  const active = await activeServices(runner, pending);
5858
+ await assertServiceInstallations(runner, active, installation);
4917
5859
  if (options.dryRun || !options.restart) {
4918
5860
  return {
4919
5861
  upgraded: previous.fromVersion !== previous.version,
@@ -4922,13 +5864,13 @@ var upgrade = async (options) => {
4922
5864
  requested_version: previous.version,
4923
5865
  package_manager: previous.packageManager,
4924
5866
  services_restarted: previous.restartedServices,
4925
- services_pending_restart: active.map((service) => service.id),
5867
+ services_pending_restart: previous.pendingServices,
4926
5868
  resumed_after_restart: false,
4927
5869
  dry_run: options.dryRun
4928
5870
  };
4929
5871
  }
4930
5872
  const activeIds = new Set(active.map((service) => service.id));
4931
- 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));
4932
5874
  const completedIds = new Set(completedByRestart.map((service) => service.id));
4933
5875
  const remaining = active.filter((service) => !completedIds.has(service.id));
4934
5876
  let resumed = {
@@ -4944,8 +5886,10 @@ var upgrade = async (options) => {
4944
5886
  if (remaining.length > 0) {
4945
5887
  await writeReceipt(statePath, resumed);
4946
5888
  resumed = await restartServices(runner, remaining, statePath, resumed, options.delegated === true);
5889
+ } else if (completedIds.size > 0) {
5890
+ await writeReceipt(statePath, resumed);
4947
5891
  }
4948
- if (resumed.restartingService === undefined) {
5892
+ if (resumed.pendingServices.length === 0) {
4949
5893
  await rm2(statePath, { force: true });
4950
5894
  }
4951
5895
  return {
@@ -4955,69 +5899,104 @@ var upgrade = async (options) => {
4955
5899
  requested_version: previous.version,
4956
5900
  package_manager: previous.packageManager,
4957
5901
  services_restarted: reportedRestartServices(resumed),
4958
- services_pending_restart: [],
5902
+ services_pending_restart: resumed.pendingServices.filter((id) => id !== resumed.restartingService),
4959
5903
  resumed_after_restart: true,
4960
5904
  dry_run: false
4961
5905
  };
4962
5906
  }
4963
- const entrypoint = options.entrypoint ?? process.argv[1];
4964
- if (entrypoint === undefined) {
4965
- throw new Error("Could not resolve the Cohall executable path");
4966
- }
4967
- const canonicalEntrypoint = await realpath2(entrypoint);
4968
- const installation = packageInstallation(canonicalEntrypoint, entrypoint);
4969
5907
  const services = await activeServices(runner, candidates);
4970
- await assertServiceInstallations(runner, services, canonicalEntrypoint, entrypoint);
4971
- 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)) {
4972
5953
  return {
4973
5954
  upgraded: false,
4974
5955
  from_version: options.currentVersion,
4975
- installed_version: options.currentVersion,
5956
+ installed_version: nextVersion ?? options.currentVersion,
4976
5957
  requested_version: target,
4977
5958
  package_manager: installation.manager,
4978
5959
  services_restarted: [],
4979
- services_pending_restart: options.restart ? services.map((service) => service.id) : [],
5960
+ services_pending_restart: [],
4980
5961
  resumed_after_restart: false,
4981
- dry_run: true
5962
+ dry_run: options.dryRun
4982
5963
  };
4983
5964
  }
4984
- let nextVersion = await installedVersion(entrypoint).catch(() => {
4985
- return;
4986
- });
4987
- if (nextVersion === undefined || target !== nextVersion) {
4988
- const install = packageInstallCommand(installation, target);
4989
- const installExecutable = options.resolveExecutable === undefined ? await trustedExecutable(install.command, installation.prefix === undefined ? {} : { writableRoot: installation.prefix }) : await resolveExecutable(install.command);
4990
- await checked(runner, { ...install, command: installExecutable });
4991
- nextVersion = await installedVersion(entrypoint);
4992
- }
4993
- if (target !== "latest" && nextVersion !== target) {
4994
- throw new Error(`Installed Cohall ${nextVersion}, expected ${target}`);
4995
- }
4996
- const upgraded = nextVersion !== options.currentVersion;
4997
- if (!options.restart || services.length === 0) {
4998
- await rm2(statePath, { force: true });
5965
+ if (options.dryRun) {
4999
5966
  return {
5000
- upgraded,
5967
+ upgraded: false,
5001
5968
  from_version: options.currentVersion,
5002
- installed_version: nextVersion,
5969
+ installed_version: options.currentVersion,
5003
5970
  requested_version: target,
5004
5971
  package_manager: installation.manager,
5005
5972
  services_restarted: [],
5006
- services_pending_restart: options.restart ? [] : services.map((service) => service.id),
5973
+ services_pending_restart: pendingServices,
5007
5974
  resumed_after_restart: false,
5008
- dry_run: false
5975
+ dry_run: true
5009
5976
  };
5010
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;
5011
5988
  const initial = {
5012
5989
  version: nextVersion,
5013
5990
  fromVersion: options.currentVersion,
5014
5991
  packageManager: installation.manager,
5015
- pendingServices: services.map((service) => service.id),
5992
+ pendingServices,
5016
5993
  restartedServices: []
5017
5994
  };
5018
- await writeReceipt(statePath, initial);
5019
- const completed = await restartServices(runner, services, statePath, initial, options.delegated === true);
5020
- 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) {
5021
6000
  await rm2(statePath, { force: true });
5022
6001
  }
5023
6002
  return {
@@ -5027,7 +6006,7 @@ var upgrade = async (options) => {
5027
6006
  requested_version: target,
5028
6007
  package_manager: installation.manager,
5029
6008
  services_restarted: reportedRestartServices(completed),
5030
- services_pending_restart: [],
6009
+ services_pending_restart: completed.pendingServices.filter((id) => id !== completed.restartingService),
5031
6010
  resumed_after_restart: false,
5032
6011
  dry_run: false
5033
6012
  };
@@ -5037,20 +6016,36 @@ var upgrade = async (options) => {
5037
6016
  var xml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
5038
6017
  var systemdArgument = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("%", "%%").replaceAll(`
5039
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
+ };
5040
6030
  var deviceServicePlan = (options) => {
6031
+ const pnpmHome = options.pnpm?.home ?? (options.platform === "darwin" ? join5(options.home, "Library", "pnpm") : join5(options.home, ".local", "share", "pnpm"));
5041
6032
  const servicePath = [
5042
6033
  dirname4(options.nodeExecutable),
5043
6034
  join5(options.home, ".local", "bin"),
5044
6035
  join5(options.home, ".npm-global", "bin"),
5045
6036
  join5(options.home, ".bun", "bin"),
5046
- 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,
5047
6040
  ...options.platform === "darwin" ? ["/opt/homebrew/bin"] : [],
5048
6041
  "/usr/local/bin",
5049
6042
  "/usr/bin",
5050
6043
  "/bin"
5051
6044
  ].join(":");
5052
6045
  if (options.platform === "linux") {
5053
- 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");
5054
6049
  return {
5055
6050
  file: {
5056
6051
  path,
@@ -5064,7 +6059,12 @@ Wants=network-online.target
5064
6059
  Type=simple
5065
6060
  Environment=${systemdArgument(`PATH=${servicePath}`)}
5066
6061
  Environment=${systemdArgument(`COHALL_CONFIG=${options.configPath}`)}
5067
- 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
5068
6068
  Restart=always
5069
6069
  RestartSec=3
5070
6070
  UMask=0077
@@ -5108,7 +6108,17 @@ WantedBy=default.target
5108
6108
  <string>${xml(servicePath)}</string>
5109
6109
  <key>COHALL_CONFIG</key>
5110
6110
  <string>${xml(options.configPath)}</string>
5111
- </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>
5112
6122
  <key>RunAtLoad</key>
5113
6123
  <true/>
5114
6124
  <key>KeepAlive</key>
@@ -5138,8 +6148,8 @@ WantedBy=default.target
5138
6148
  var defaultRunner2 = {
5139
6149
  run: async (command, arguments_, timeoutMs = 60000) => {
5140
6150
  const executable = await trustedExecutable(command);
5141
- return new Promise((resolve2) => {
5142
- 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({
5143
6153
  exitCode: typeof error?.code === "number" ? error.code : error === null ? 0 : 1,
5144
6154
  stdout,
5145
6155
  stderr,
@@ -5156,12 +6166,22 @@ var checked2 = async (runner, command, arguments_, allowFailure = false) => {
5156
6166
  throw new Error(`${command} failed: ${result.stderr.trim() || result.stdout.trim() || result.error || `status ${result.exitCode}`}`);
5157
6167
  };
5158
6168
  var installDeviceService = async (options = {}) => {
5159
- const entrypoint = await realpath3(options.entrypoint ?? process.argv[1] ?? "");
5160
- 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
+ });
5161
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
+ };
5162
6182
  const runner = options.runner ?? defaultRunner2;
5163
6183
  if (platform4 === "win32") {
5164
- const script = join5(dirname4(dirname4(entrypoint)), "deploy", "windows", "install-device.ps1");
6184
+ const script = join5(dirname4(dirname4(canonicalEntrypoint)), "deploy", "windows", "install-device.ps1");
5165
6185
  await checked2(runner, "powershell.exe", [
5166
6186
  "-NoProfile",
5167
6187
  "-ExecutionPolicy",
@@ -5173,7 +6193,19 @@ var installDeviceService = async (options = {}) => {
5173
6193
  "-Entrypoint",
5174
6194
  entrypoint,
5175
6195
  "-ConfigurationPath",
5176
- 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
+ ]
5177
6209
  ]);
5178
6210
  return { installed: "scheduled-task:Cohall Device" };
5179
6211
  }
@@ -5181,9 +6213,10 @@ var installDeviceService = async (options = {}) => {
5181
6213
  const plan = deviceServicePlan({
5182
6214
  platform: platform4,
5183
6215
  entrypoint,
5184
- home: options.home ?? homedir3(),
6216
+ home,
5185
6217
  nodeExecutable: process.execPath,
5186
6218
  configPath: configurationPath(),
6219
+ ...pnpm === undefined ? {} : { pnpm },
5187
6220
  ...uid === undefined ? {} : { uid }
5188
6221
  });
5189
6222
  await mkdir4(dirname4(plan.file.path), { recursive: true, mode: 448 });
@@ -5200,36 +6233,18 @@ var installDeviceService = async (options = {}) => {
5200
6233
  var restartDeviceService = async (options = {}) => {
5201
6234
  const platform4 = options.platform ?? process.platform;
5202
6235
  const runner = options.runner ?? defaultRunner2;
5203
- if (platform4 === "linux") {
5204
- const service = "cohall-device.service";
5205
- const check = await runner.run("systemctl", ["--user", "is-active", "--quiet", service], 1e4);
5206
- if (check.exitCode !== 0) {
5207
- return { running: false, restarted: false };
5208
- }
5209
- await checked2(runner, "systemctl", ["--user", "restart", service]);
5210
- return { running: true, restarted: true, service };
5211
- }
5212
- if (platform4 === "darwin") {
5213
- const service = "com.cohall.device";
5214
- const target = `gui/${options.uid ?? process.getuid?.() ?? 0}/${service}`;
5215
- const check = await runner.run("launchctl", ["print", target], 1e4);
5216
- if (check.exitCode !== 0) {
5217
- return { running: false, restarted: false };
5218
- }
5219
- await checked2(runner, "launchctl", ["kickstart", "-k", target]);
5220
- 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 };
5221
6243
  }
5222
- if (platform4 === "win32") {
5223
- const service = "Cohall Device";
5224
- const check = await runner.run("schtasks.exe", ["/Query", "/TN", service], 1e4);
5225
- if (check.exitCode !== 0) {
5226
- return { running: false, restarted: false };
5227
- }
5228
- await checked2(runner, "schtasks.exe", ["/End", "/TN", service], true);
5229
- await checked2(runner, "schtasks.exe", ["/Run", "/TN", service]);
5230
- return { running: true, restarted: true, service };
6244
+ for (const command of service.restart) {
6245
+ await checked2(runner, command.command, command.arguments, command.allowFailure);
5231
6246
  }
5232
- return { running: false, restarted: false };
6247
+ return { running: true, restarted: true, service: service.label };
5233
6248
  };
5234
6249
 
5235
6250
  // apps/device/src/relay-migration.ts
@@ -5266,7 +6281,7 @@ var verifyBackupDestinationParent = async (path) => {
5266
6281
  if (process.platform === "win32") {
5267
6282
  return;
5268
6283
  }
5269
- for (let current = await realpath4(path);; current = dirname5(current)) {
6284
+ for (let current = await realpath3(path);; current = dirname5(current)) {
5270
6285
  const metadata = await stat3(current);
5271
6286
  const writableByOthers = (metadata.mode & 18) !== 0;
5272
6287
  const sticky = (metadata.mode & 512) !== 0;
@@ -5310,7 +6325,7 @@ var backupRelay = async (destinationInput) => {
5310
6325
  if (!await exists(sourceDatabase)) {
5311
6326
  throw new Error(`Relay database was not found at ${sourceDatabase}`);
5312
6327
  }
5313
- const destination = resolve2(destinationInput);
6328
+ const destination = resolve3(destinationInput);
5314
6329
  if (await exists(destination)) {
5315
6330
  throw new Error(`Backup destination already exists: ${destination}`);
5316
6331
  }
@@ -5357,7 +6372,7 @@ var backupRelay = async (destinationInput) => {
5357
6372
  }
5358
6373
  };
5359
6374
  var restoreRelay = async (sourceInput) => {
5360
- const source = resolve2(sourceInput);
6375
+ const source = resolve3(sourceInput);
5361
6376
  const sourceDatabase = join6(source, "cohall.db");
5362
6377
  const sourceOwnerToken = join6(source, "owner-token");
5363
6378
  const sourceManifest = join6(source, "manifest.json");
@@ -5502,9 +6517,9 @@ var switchRelay = async (options) => {
5502
6517
  }
5503
6518
  let service = { running: false, restarted: false };
5504
6519
  let warning;
5505
- if (updated && options.restart && deviceTokens.length > 0) {
6520
+ if (options.restart && deviceTokens.length > 0) {
5506
6521
  service = await (options.restartService ?? restartDeviceService)().catch((cause) => {
5507
- 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.`;
5508
6523
  return { running: true, restarted: false };
5509
6524
  });
5510
6525
  }
@@ -5521,9 +6536,33 @@ var switchRelay = async (options) => {
5521
6536
  init_src();
5522
6537
  import { Effect as Effect9, Schema as Schema10 } from "effect";
5523
6538
  import { constants as constants3 } from "node:fs";
5524
- 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";
5525
6548
  import { tmpdir as tmpdir2 } from "node:os";
5526
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
+ };
5527
6566
  var readInputAttachments = async (paths2) => {
5528
6567
  if (paths2.length > maxTaskAttachments) {
5529
6568
  throw new Error(`A task supports at most ${maxTaskAttachments} input files`);
@@ -5546,7 +6585,7 @@ var readInputAttachments = async (paths2) => {
5546
6585
  if (!metadata.isFile() || metadata.dev !== source.dev || metadata.ino !== source.ino || metadata.size < 1 || metadata.size > maxAttachmentBytes) {
5547
6586
  throw new Error(`Attachment ${name} must be a regular file of 1 to ${maxAttachmentBytes} bytes`);
5548
6587
  }
5549
- const data = await handle.readFile();
6588
+ const data = await readBoundedFile(handle);
5550
6589
  if (data.length < 1 || data.length > maxAttachmentBytes) {
5551
6590
  throw new Error(`Attachment ${name} changed size while reading`);
5552
6591
  }
@@ -5556,31 +6595,32 @@ var readInputAttachments = async (paths2) => {
5556
6595
  }
5557
6596
  }));
5558
6597
  };
5559
- var prepareTaskFiles = async (configuration, taskId, inputNames, signal) => {
6598
+ var prepareTaskFiles = Effect9.fn("TaskFiles.prepare")(function* (configuration, taskId, inputNames) {
5560
6599
  const client = exports_src.make({ baseUrl: configuration.relayUrl, token: configuration.token });
5561
- 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 })));
5562
6604
  const input = join7(root, "input");
5563
6605
  const output = join7(root, "output");
5564
- try {
5565
- await Promise.all([mkdir6(input), mkdir6(output)]);
5566
- for (const name of inputNames) {
5567
- const data = await Effect9.runPromise(client.readAttachment(taskId, name), { signal });
5568
- await writeFile6(join7(input, name), data, { flag: "wx", mode: 384 });
5569
- }
5570
- return {
5571
- input,
5572
- output,
5573
- inputNames,
5574
- collectOutputs: () => collectOutputs(output),
5575
- cleanup: () => rm4(root, { recursive: true, force: true })
5576
- };
5577
- } catch (cause) {
5578
- await rm4(root, { recursive: true, force: true });
5579
- 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);
5580
6614
  }
5581
- };
6615
+ return {
6616
+ input,
6617
+ output,
6618
+ inputNames,
6619
+ collectOutputs: () => collectOutputs(output)
6620
+ };
6621
+ });
5582
6622
  var collectOutputs = async (directory) => {
5583
- const entries = await readdir(directory, { withFileTypes: true });
6623
+ const entries = await readdir2(directory, { withFileTypes: true });
5584
6624
  if (entries.length > maxTaskAttachments) {
5585
6625
  throw new Error(`A task can return at most ${maxTaskAttachments} files`);
5586
6626
  }
@@ -5603,7 +6643,7 @@ var collectOutputs = async (directory) => {
5603
6643
  if (!metadata.isFile() || !pathMetadata.isFile() || metadata.dev !== pathMetadata.dev || metadata.ino !== pathMetadata.ino || metadata.size < 1 || metadata.size > maxAttachmentBytes) {
5604
6644
  throw new Error(`Output ${name} must be a regular file of 1 to ${maxAttachmentBytes} bytes`);
5605
6645
  }
5606
- const data = await handle.readFile();
6646
+ const data = await readBoundedFile(handle);
5607
6647
  if (data.length < 1 || data.length > maxAttachmentBytes) {
5608
6648
  throw new Error(`Output ${name} changed size while reading`);
5609
6649
  }
@@ -5653,6 +6693,7 @@ var valueOptions = new Set([
5653
6693
  "attach",
5654
6694
  "context",
5655
6695
  "context-file",
6696
+ "deadline",
5656
6697
  "direction",
5657
6698
  "grok-gateway",
5658
6699
  "error",
@@ -5665,9 +6706,12 @@ var valueOptions = new Set([
5665
6706
  "parent",
5666
6707
  "prompt",
5667
6708
  "prompt-file",
6709
+ "question",
6710
+ "request-id",
5668
6711
  "provider",
5669
6712
  "providers",
5670
6713
  "relay",
6714
+ "run-id",
5671
6715
  "sandbox",
5672
6716
  "target",
5673
6717
  "thread",
@@ -5704,11 +6748,13 @@ Usage:
5704
6748
  cohall config
5705
6749
  cohall devices
5706
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>
5707
6753
  cohall bots
5708
6754
  cohall send [@device-or-bot] [prompt] [delegate options]
5709
6755
  cohall delegate [prompt] [--target @device-or-bot] [--provider provider]
5710
6756
  [--context text] [--thread uuid] [--parent task-id] [--workspace path]
5711
- [--attach path] [--timeout seconds] [--no-wait]
6757
+ [--attach path] [--request-id uuid] [--timeout seconds] [--deadline UTC-timestamp] [--no-wait]
5712
6758
  cohall status <task-id>
5713
6759
  cohall inbox [ack <task-id>]
5714
6760
  cohall trace <task-id> [--follow]
@@ -5716,7 +6762,7 @@ Usage:
5716
6762
  cohall attachments <task-id>
5717
6763
  cohall download <task-id> <name> --output path [--direction input|output]
5718
6764
  cohall cancel <task-id>
5719
- 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
5720
6766
  cohall thread <thread-id>
5721
6767
  cohall pair [--label name] [--client-only]
5722
6768
  cohall sessions
@@ -5911,17 +6957,21 @@ var printSkill = () => console.log(SKILL_default.trimEnd());
5911
6957
  var runCli = async (command, raw) => {
5912
6958
  const arguments_ = parseArguments(raw);
5913
6959
  if (command === "reply") {
5914
- allowOptions(arguments_, ["message", "message-file", "error"]);
6960
+ allowOptions(arguments_, ["message", "message-file", "error", "question", "run-id"]);
5915
6961
  const id = Schema11.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
5916
6962
  const error = option(arguments_, "error");
5917
- if (error !== undefined && (option(arguments_, "message") !== undefined || option(arguments_, "message-file") !== undefined)) {
5918
- throw new Error("Use either --error or --message/--message-file, not both");
5919
- }
5920
- const message = error === undefined ? await readInput(arguments_, "message") : undefined;
5921
- if (error === undefined && message === undefined) {
5922
- throw new Error("A bot reply requires --message, --message-file, or --error");
5923
- }
5924
- 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);
5925
6975
  print({ submitted: true, task_id: id });
5926
6976
  return;
5927
6977
  }
@@ -6172,7 +7222,7 @@ var runCli = async (command, raw) => {
6172
7222
  const sandbox = option(arguments_, "sandbox");
6173
7223
  const model = option(arguments_, "model") ?? existing?.model;
6174
7224
  const gatewayInput = option(arguments_, "grok-gateway") ?? existing?.grokGateway;
6175
- const grokGateway = gatewayInput === undefined ? undefined : resolve3(gatewayInput);
7225
+ const grokGateway = gatewayInput === undefined ? undefined : resolve4(gatewayInput);
6176
7226
  const providerInput = option(arguments_, "providers");
6177
7227
  const providers = providerInput === undefined ? existing?.providers : providerInput === "auto" ? undefined : parseProviders(providerInput);
6178
7228
  const configuration2 = StoredConfiguration.make({
@@ -6276,9 +7326,9 @@ var runCli = async (command, raw) => {
6276
7326
  ...hasDeviceCredential && response?.ok === true && clientAuthentication.status !== "error" && currentDevice?.status !== "online" && currentDevice?.status !== "busy" ? [
6277
7327
  clientToken === undefined ? "Device status cannot be checked without a client credential" : "Device daemon is not connected or is not registered with the relay"
6278
7328
  ] : [],
6279
- ...selectedProviders?.flatMap((provider) => provider === "grok-bot" ? grokBots === undefined ? [
7329
+ ...(selectedProviders ?? (grokGateway === undefined ? [] : ["grok-bot"])).flatMap((provider) => provider === "grok-bot" ? grokBots === undefined ? [
6280
7330
  "Configured Grok Bot gateway is unavailable; check its discovery file and host process"
6281
- ] : [] : 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`] : [])
6282
7332
  ];
6283
7333
  print({
6284
7334
  version,
@@ -6354,6 +7404,31 @@ var runCli = async (command, raw) => {
6354
7404
  return;
6355
7405
  }
6356
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
+ }
6357
7432
  if (command === "progress") {
6358
7433
  allowOptions(arguments_, ["message"]);
6359
7434
  const rawId = arguments_.positionals.length === 0 ? configuration.mcpTaskId : identifier(arguments_, "task id");
@@ -6382,12 +7457,14 @@ var runCli = async (command, raw) => {
6382
7457
  allowOptions(arguments_, [
6383
7458
  "context",
6384
7459
  "context-file",
7460
+ "deadline",
6385
7461
  "attach",
6386
7462
  "no-wait",
6387
7463
  "parent",
6388
7464
  "prompt",
6389
7465
  "prompt-file",
6390
7466
  "provider",
7467
+ "request-id",
6391
7468
  "target",
6392
7469
  "thread",
6393
7470
  "timeout",
@@ -6417,15 +7494,19 @@ var runCli = async (command, raw) => {
6417
7494
  const workspace = option(arguments_, "workspace");
6418
7495
  const provider = option(arguments_, "provider");
6419
7496
  const parent = option(arguments_, "parent");
7497
+ const deadline = option(arguments_, "deadline");
7498
+ const requestId = option(arguments_, "request-id");
6420
7499
  const task = await Effect10.runPromise(createDelegation(relay, configuration, {
6421
7500
  prompt,
7501
+ ...requestId === undefined ? {} : { requestId: Schema11.decodeUnknownSync(TaskRequestId)(requestId) },
6422
7502
  ...target === undefined ? {} : { target },
6423
7503
  ...context === undefined ? {} : { context },
6424
7504
  ...thread === undefined ? {} : { threadId: Schema11.decodeUnknownSync(ThreadId)(thread) },
6425
7505
  ...parent === undefined ? {} : { parentTaskId: Schema11.decodeUnknownSync(TaskId)(parent) },
6426
7506
  ...workspace === undefined ? {} : { workspace },
6427
7507
  ...provider === undefined ? {} : { provider: Schema11.decodeUnknownSync(Provider)(provider) },
6428
- ...attachments2.length === 0 ? {} : { attachments: attachments2 }
7508
+ ...attachments2.length === 0 ? {} : { attachments: attachments2 },
7509
+ ...deadline === undefined ? {} : { expiresAt: Schema11.decodeUnknownSync(TaskDeadline)(deadline) }
6429
7510
  }));
6430
7511
  const completed = arguments_.options.has("no-wait") ? task : await Effect10.runPromise(waitForTask(relay, task, waitSeconds));
6431
7512
  print(arguments_.options.has("no-wait") ? taskResult(completed) : await acknowledgedTaskResult(relay, completed));
@@ -6471,7 +7552,7 @@ var runCli = async (command, raw) => {
6471
7552
  const direction = directionInput === undefined ? undefined : Schema11.decodeUnknownSync(AttachmentDirection)(directionInput);
6472
7553
  const data = await Effect10.runPromise(relay.readAttachment(id, name, direction));
6473
7554
  await writeFile7(destination, data, { flag: "wx", mode: 384 });
6474
- print({ task_id: id, name, path: resolve3(destination), bytes: data.length });
7555
+ print({ task_id: id, name, path: resolve4(destination), bytes: data.length });
6475
7556
  return;
6476
7557
  }
6477
7558
  if (command === "trace") {
@@ -6487,8 +7568,7 @@ var runCli = async (command, raw) => {
6487
7568
  if (command === "wait") {
6488
7569
  allowOptions(arguments_, ["timeout"]);
6489
7570
  const id = Schema11.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
6490
- const task = await Effect10.runPromise(relay.getTask(id));
6491
- print(taskResult(await Effect10.runPromise(waitForTask(relay, task, timeout(arguments_)))));
7571
+ print(taskResult(await Effect10.runPromise(waitForTask(relay, id, timeout(arguments_)))));
6492
7572
  return;
6493
7573
  }
6494
7574
  if (command === "cancel") {
@@ -6510,9 +7590,9 @@ var runCli = async (command, raw) => {
6510
7590
  init_src();
6511
7591
  import { Effect as Effect11, Schedule as Schedule3, Schema as Schema12 } from "effect";
6512
7592
  import { constants as constants4 } from "node:fs";
6513
- 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";
6514
7594
  import { arch, hostname as hostname3, platform as platform4 } from "node:os";
6515
- 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";
6516
7596
  import { WebSocket as WebSocket2 } from "ws";
6517
7597
  var maxQueuedRelayMessages = 8;
6518
7598
 
@@ -6545,6 +7625,8 @@ var capabilities = (providers) => {
6545
7625
  if (providers.some((provider) => provider !== "grok-bot")) {
6546
7626
  values2.push({ id: "task-attachments", label: "Task file attachments" });
6547
7627
  }
7628
+ values2.push({ id: "task-clarification", label: "Task clarification and resume" });
7629
+ values2.push({ id: "task-deadlines", label: "Task deadlines" });
6548
7630
  if (findExecutable("google-chrome") !== undefined || findExecutable("chromium") !== undefined || platform4() === "darwin") {
6549
7631
  values2.push({ id: "browser-session", label: "Signed-in browser" });
6550
7632
  }
@@ -6588,10 +7670,10 @@ var allowedWorkspace = async (configuration, requested) => {
6588
7670
  if (selected === undefined) {
6589
7671
  throw new Error("No workspace is configured on this device");
6590
7672
  }
6591
- const candidate = await realpath5(selected);
7673
+ const candidate = await realpath4(selected);
6592
7674
  const allowed = configuration.workspaces.some((root) => {
6593
7675
  const child = relative2(root, candidate);
6594
- return child === "" || !child.startsWith("..") && !isAbsolute3(child);
7676
+ return child === "" || !child.startsWith("..") && !isAbsolute4(child);
6595
7677
  });
6596
7678
  if (!allowed) {
6597
7679
  throw new Error(`Workspace ${candidate} is outside this device's configured workspace roots`);
@@ -6617,7 +7699,7 @@ var openAllowedWorkspace = async (configuration, requested) => {
6617
7699
  throw new Error(`Workspace ${path} changed before provider startup`);
6618
7700
  }
6619
7701
  if (operatingSystem2 !== "linux") {
6620
- const currentPath = await realpath5(path);
7702
+ const currentPath = await realpath4(path);
6621
7703
  const currentPathIdentity = await stat5(currentPath);
6622
7704
  if (currentPathIdentity.dev !== identity.dev || currentPathIdentity.ino !== identity.ino) {
6623
7705
  throw new Error(`Workspace ${path} changed before provider startup`);
@@ -6627,7 +7709,7 @@ var openAllowedWorkspace = async (configuration, requested) => {
6627
7709
  close: () => handle.close()
6628
7710
  };
6629
7711
  };
6630
- var promptFor = (task, deviceName, files) => {
7712
+ var promptFor = (task, deviceName, supportsClarification, files) => {
6631
7713
  const context = task.context === undefined ? "" : `
6632
7714
 
6633
7715
  Relevant context supplied by the sending agent:
@@ -6638,6 +7720,9 @@ ${task.context}`;
6638
7720
  "Never read, reveal, copy, or use Cohall configuration files or Cohall authentication tokens.",
6639
7721
  "Return a concise, complete result with the evidence the sending agent needs.",
6640
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
+ ] : [],
6641
7726
  ...files === undefined ? [] : [
6642
7727
  ...files.inputNames.length === 0 ? [] : [
6643
7728
  `Input directory: ${files.input}`,
@@ -6648,7 +7733,13 @@ ${task.context}`;
6648
7733
  ],
6649
7734
  `
6650
7735
  Task:
6651
- ${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
+ ])
6652
7743
  ].join(`
6653
7744
  `);
6654
7745
  };
@@ -6657,6 +7748,7 @@ var send = (state, event) => {
6657
7748
  state.socket.send(JSON.stringify(event));
6658
7749
  }
6659
7750
  };
7751
+ var taskRunKey = (taskId, runId) => `${taskId}:${runId ?? ""}`;
6660
7752
  var omitOutputFiles = (event, note) => {
6661
7753
  if (event._tag !== "TaskFinished" || (event.attachments?.length ?? 0) === 0) {
6662
7754
  return event;
@@ -6664,16 +7756,17 @@ var omitOutputFiles = (event, note) => {
6664
7756
  const message = `
6665
7757
 
6666
7758
  [Output files omitted: ${note}]`;
6667
- return SocketEvent.make({
7759
+ return SocketEvent.cases.TaskFinished.make({
6668
7760
  _tag: "TaskFinished",
6669
7761
  taskId: event.taskId,
7762
+ ...event.runId === undefined ? {} : { runId: event.runId },
6670
7763
  result: `${event.result.slice(0, 131072 - message.length)}${message}`,
6671
7764
  ...event.providerSessionId === undefined ? {} : { providerSessionId: event.providerSessionId }
6672
7765
  });
6673
7766
  };
6674
- var sendTerminal = (state, taskId, event) => {
7767
+ var sendTerminal = (state, event) => {
6675
7768
  const safeEvent = state.supportsAttachments ? event : omitOutputFiles(event, "the relay does not support attachments. Upgrade the relay and retry.");
6676
- state.terminal.set(taskId, safeEvent);
7769
+ state.terminal.set(taskRunKey(event.taskId, event.runId), safeEvent);
6677
7770
  if (state.socket?.readyState === WebSocket2.OPEN) {
6678
7771
  state.socket.send(JSON.stringify(safeEvent));
6679
7772
  }
@@ -6685,12 +7778,12 @@ var sendOperationTerminal = (state, operationId, event) => {
6685
7778
  state.socket.send(payload);
6686
7779
  }
6687
7780
  };
6688
- var remember = (state, taskId) => {
6689
- state.completed.add(taskId);
7781
+ var remember = (state, task) => {
7782
+ state.completed.add(taskRunKey(task.id, task.runId));
6690
7783
  if (state.completed.size <= 1000) {
6691
7784
  return;
6692
7785
  }
6693
- const oldest = state.completed.values().next();
7786
+ const oldest = state.completed.keys().next();
6694
7787
  if (!oldest.done) {
6695
7788
  state.completed.delete(oldest.value);
6696
7789
  state.terminal.delete(oldest.value);
@@ -6708,8 +7801,9 @@ var rememberOperation = (state, operationId) => {
6708
7801
  }
6709
7802
  };
6710
7803
  var sessionKey = (task) => `${task.threadId}:${task.provider}`;
7804
+ var hasCompletedRun = (state, task) => state.completed.has(taskRunKey(task.id, task.runId));
6711
7805
  var drain = (configuration, state) => {
6712
- if (state.operation !== undefined) {
7806
+ if (state.stopping || state.operation !== undefined) {
6713
7807
  return;
6714
7808
  }
6715
7809
  if (state.tasks.size === 0) {
@@ -6732,16 +7826,37 @@ var drain = (configuration, state) => {
6732
7826
  }
6733
7827
  };
6734
7828
  var execute = (configuration, state, task) => {
6735
- if (state.tasks.has(task.id) || state.completed.has(task.id)) {
7829
+ if (state.tasks.has(task.id) || hasCompletedRun(state, task)) {
6736
7830
  return;
6737
7831
  }
6738
7832
  const controller = new AbortController;
6739
- state.tasks.set(task.id, { task, controller });
6740
- send(state, SocketEvent.make({ _tag: "TaskAccepted", taskId: task.id }));
6741
- 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
+ }
6742
7857
  if (task.provider === "grok-bot") {
6743
7858
  return yield* Effect11.tryPromise({
6744
- try: (signal) => runGrokBot(configuration.grokGateway, task, signal),
7859
+ try: (signal) => runGrokBot(configuration.grokGateway, task, signal, state.supportsClarification),
6745
7860
  catch: (cause) => new ProviderRunError({
6746
7861
  provider: task.provider,
6747
7862
  message: cause instanceof Error ? cause.message : String(cause)
@@ -6755,68 +7870,84 @@ var execute = (configuration, state, task) => {
6755
7870
  message: "The relay does not support task file attachments"
6756
7871
  });
6757
7872
  }
6758
- const workspace = yield* Effect11.tryPromise({
7873
+ const workspace = yield* Effect11.acquireRelease(Effect11.tryPromise({
6759
7874
  try: () => openAllowedWorkspace(configuration, task.workspace),
6760
7875
  catch: (cause) => new ProviderRunError({
6761
7876
  provider,
6762
7877
  message: cause instanceof Error ? cause.message : String(cause)
6763
7878
  })
6764
- });
6765
- return yield* Effect11.gen(function* () {
6766
- const files = state.supportsAttachments ? yield* Effect11.tryPromise({
6767
- try: (signal) => prepareTaskFiles(configuration, task.id, task.inputAttachmentNames ?? [], signal),
6768
- catch: (cause) => new ProviderRunError({
6769
- provider,
6770
- message: cause instanceof Error ? cause.message : String(cause)
6771
- })
6772
- }) : undefined;
6773
- const sessionId = task.providerSessionId ?? state.sessions.get(sessionKey(task));
6774
- return yield* run({
6775
- provider,
6776
- threadId: task.threadId,
6777
- taskId: task.id,
6778
- prompt: promptFor(task, configuration.name, files),
6779
- cwd: workspace.cwd,
6780
- beforeSpawn: workspace.validate,
6781
- ...sessionId === undefined ? {} : { sessionId },
6782
- ...configuration.model === undefined ? {} : { model: configuration.model },
6783
- ...configuration.sandbox === undefined ? {} : { sandbox: configuration.sandbox }
6784
- }).pipe(Effect11.flatMap((result) => files === undefined ? Effect11.succeed(result) : Effect11.tryPromise({
6785
- try: async () => ({ ...result, attachments: await files.collectOutputs() }),
6786
- catch: (cause) => new ProviderRunError({
6787
- provider,
6788
- message: cause instanceof Error ? cause.message : String(cause)
6789
- })
6790
- })), Effect11.ensuring(files === undefined ? Effect11.void : Effect11.promise(() => files.cleanup())));
6791
- }).pipe(Effect11.ensuring(Effect11.promise(() => workspace.close().catch(() => {
7879
+ }), (workspace2) => Effect11.promise(() => workspace2.close().catch(() => {
6792
7880
  return;
6793
- }))));
6794
- });
6795
- Effect11.runPromise(workflow, { signal: controller.signal }).then((result) => {
6796
- const finished = SocketEvent.make({
6797
- _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,
6798
7890
  taskId: task.id,
6799
- result: result.result,
6800
- ...result.sessionId === undefined ? {} : { providerSessionId: result.sessionId },
6801
- ...result.attachments === undefined || result.attachments.length === 0 ? {} : { attachments: result.attachments }
6802
- });
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
+ }
6803
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;
6804
7928
  if (result.sessionId !== undefined) {
6805
7929
  state.sessions.set(sessionKey(task), result.sessionId);
6806
7930
  }
6807
- remember(state, task.id);
6808
- sendTerminal(state, task.id, bounded2);
7931
+ remember(state, task);
7932
+ sendTerminal(state, bounded2);
6809
7933
  }).catch((cause) => {
6810
- remember(state, task.id);
6811
- 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({
6812
7940
  _tag: "TaskFailed",
6813
7941
  taskId: task.id,
7942
+ ...task.runId === undefined ? {} : { runId: task.runId },
6814
7943
  error: (cause instanceof Error ? cause.message : String(cause)).slice(0, 16384)
6815
7944
  }));
6816
7945
  }).finally(() => {
7946
+ clearTimeout(deadlineTimer);
6817
7947
  state.tasks.delete(task.id);
6818
7948
  drain(configuration, state);
6819
7949
  });
7950
+ state.tasks.set(task.id, { task, controller, done });
6820
7951
  };
6821
7952
  var executeOperation = (configuration, state, operation) => {
6822
7953
  if (state.operation !== undefined || state.completedOperations.has(operation.id)) {
@@ -6844,6 +7975,8 @@ var executeOperation = (configuration, state, operation) => {
6844
7975
  });
6845
7976
  };
6846
7977
  var scheduleOperation = (configuration, state, operation) => {
7978
+ if (state.stopping)
7979
+ return;
6847
7980
  if (state.completedOperations.has(operation.id) || state.operation?.id === operation.id || state.operationQueue.some((pending) => pending.id === operation.id)) {
6848
7981
  return;
6849
7982
  }
@@ -6865,7 +7998,18 @@ var performDeviceOperation = (operation, currentVersion, runUpgrade = upgrade) =
6865
7998
  delegated: true
6866
7999
  }).then((result) => JSON.stringify(result));
6867
8000
  var schedule = (configuration, state, task) => {
6868
- 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
+ }));
6869
8013
  return;
6870
8014
  }
6871
8015
  if (task.providerSessionId !== undefined) {
@@ -6881,20 +8025,24 @@ var schedule = (configuration, state, task) => {
6881
8025
  }
6882
8026
  execute(configuration, state, task);
6883
8027
  };
6884
- var cancel = (state, taskId) => {
8028
+ var cancel = (state, taskId, runId) => {
6885
8029
  const running = state.tasks.get(taskId);
6886
- if (running !== undefined) {
8030
+ if (running !== undefined && (runId === undefined || running.task.runId === runId)) {
6887
8031
  if (running.task.provider !== "grok-bot") {
6888
8032
  running.controller.abort();
6889
8033
  }
6890
8034
  return;
6891
8035
  }
6892
- const index = state.queue.findIndex((task) => task.id === taskId);
8036
+ const index = state.queue.findIndex((task) => task.id === taskId && (runId === undefined || task.runId === runId));
6893
8037
  if (index !== -1) {
6894
8038
  state.queue.splice(index, 1);
6895
8039
  }
6896
- remember(state, taskId);
6897
- 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
+ }));
6898
8046
  };
6899
8047
  var connect = (configuration, state) => Effect11.tryPromise({
6900
8048
  try: (signal) => new Promise((complete) => {
@@ -6903,6 +8051,15 @@ var connect = (configuration, state) => Effect11.tryPromise({
6903
8051
  perMessageDeflate: false,
6904
8052
  allowSynchronousEvents: false
6905
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);
6906
8063
  let bots = [];
6907
8064
  let refreshing = false;
6908
8065
  const refreshBots = async () => {
@@ -6930,13 +8087,13 @@ var connect = (configuration, state) => Effect11.tryPromise({
6930
8087
  }));
6931
8088
  refreshBots();
6932
8089
  }, 15000);
6933
- let closed = false;
6934
8090
  let queuedMessages = 0;
6935
8091
  const close = () => {
6936
8092
  if (closed) {
6937
8093
  return;
6938
8094
  }
6939
8095
  closed = true;
8096
+ clearTimeout(connectionDeadline);
6940
8097
  clearInterval(heartbeat);
6941
8098
  if (state.socket === socket) {
6942
8099
  state.socket = undefined;
@@ -6944,7 +8101,7 @@ var connect = (configuration, state) => Effect11.tryPromise({
6944
8101
  complete();
6945
8102
  };
6946
8103
  signal.addEventListener("abort", () => {
6947
- socket.close();
8104
+ socket.terminate();
6948
8105
  close();
6949
8106
  }, { once: true });
6950
8107
  socket.once("open", () => {
@@ -6967,14 +8124,16 @@ var connect = (configuration, state) => Effect11.tryPromise({
6967
8124
  }).pipe(Effect11.flatMap(decodeSocketEvent)));
6968
8125
  if (event._tag === "Connected") {
6969
8126
  state.supportsAttachments = event.taskAttachments === true;
8127
+ state.supportsClarification = event.taskClarification === true;
6970
8128
  state.socket = socket;
8129
+ resetConnectionDeadline();
6971
8130
  socket.send(JSON.stringify(SocketEvent.make({
6972
8131
  _tag: "DeviceHello",
6973
8132
  device: describeDevice(configuration, state.tasks.size > 0 || state.operation !== undefined ? "busy" : "online", bots)
6974
8133
  })));
6975
- for (const [taskId, pending] of state.terminal) {
8134
+ for (const [key, pending] of state.terminal) {
6976
8135
  const safeEvent = state.supportsAttachments ? pending : omitOutputFiles(pending, "the relay does not support attachments. Upgrade the relay and retry.");
6977
- state.terminal.set(taskId, safeEvent);
8136
+ state.terminal.set(key, safeEvent);
6978
8137
  socket.send(JSON.stringify(safeEvent));
6979
8138
  }
6980
8139
  for (const payload of state.operationTerminal.values()) {
@@ -6988,12 +8147,12 @@ var connect = (configuration, state) => Effect11.tryPromise({
6988
8147
  return;
6989
8148
  }
6990
8149
  if (event._tag === "CancelTask") {
6991
- cancel(state, event.taskId);
8150
+ cancel(state, event.taskId, event.runId);
6992
8151
  return;
6993
8152
  }
6994
8153
  if (event._tag === "TaskSettled") {
6995
- state.terminal.delete(event.taskId);
6996
- cleanupBotReply(event.taskId).catch(() => {
8154
+ state.terminal.delete(taskRunKey(event.taskId, event.runId));
8155
+ cleanupBotReply(event.taskId, event.runId).catch(() => {
6997
8156
  console.error(`Could not remove local bot reply for settled task ${event.taskId}`);
6998
8157
  });
6999
8158
  return;
@@ -7025,7 +8184,9 @@ var connect = (configuration, state) => Effect11.tryPromise({
7025
8184
  var runDaemon = (configuration) => {
7026
8185
  const state = {
7027
8186
  socket: undefined,
8187
+ stopping: false,
7028
8188
  supportsAttachments: false,
8189
+ supportsClarification: false,
7029
8190
  processing: Promise.resolve(),
7030
8191
  terminal: new Map,
7031
8192
  queue: [],
@@ -7037,11 +8198,14 @@ var runDaemon = (configuration) => {
7037
8198
  operationTerminal: new Map,
7038
8199
  completedOperations: new Set
7039
8200
  };
7040
- 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;
7041
8203
  state.socket?.close();
7042
- for (const { controller } of state.tasks.values()) {
8204
+ const running = [...state.tasks.values()];
8205
+ for (const { controller } of running) {
7043
8206
  controller.abort();
7044
8207
  }
8208
+ await Promise.allSettled(running.map(({ done }) => done));
7045
8209
  })));
7046
8210
  };
7047
8211
 
@@ -7113,6 +8277,24 @@ var runMcp = async (configuration) => {
7113
8277
  token: configuration.token
7114
8278
  });
7115
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 }))))));
7116
8298
  server.registerTool("task_progress", {
7117
8299
  title: "Report task progress",
7118
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.`,
@@ -7142,6 +8324,7 @@ var runMcp = async (configuration) => {
7142
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.",
7143
8325
  inputSchema: {
7144
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."),
7145
8328
  target: z.string().optional().describe("Device name, hostname or ID; @BotName; or @device/BotName. Use list_bots targets to disambiguate."),
7146
8329
  provider: z.enum(Provider.literals).optional().describe("Inferred as grok-bot for a bot target; otherwise defaults to codex."),
7147
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."),
@@ -7150,10 +8333,12 @@ var runMcp = async (configuration) => {
7150
8333
  workspace: z.string().max(4096).optional().describe("Coding provider workspace. Omit for named Grok Bots."),
7151
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."),
7152
8335
  wait: z.boolean().default(true),
7153
- 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.")
7154
8338
  }
7155
8339
  }, async ({
7156
8340
  prompt,
8341
+ request_id,
7157
8342
  target,
7158
8343
  provider,
7159
8344
  context,
@@ -7162,25 +8347,28 @@ var runMcp = async (configuration) => {
7162
8347
  workspace,
7163
8348
  attachment_paths,
7164
8349
  wait,
7165
- timeout_seconds
7166
- }) => {
8350
+ timeout_seconds,
8351
+ deadline
8352
+ }, { signal }) => {
7167
8353
  const attachments2 = await readInputAttachments(attachment_paths ?? []);
7168
8354
  const task = await Effect12.runPromise(createDelegation(client2, configuration, {
7169
8355
  prompt,
8356
+ ...request_id === undefined ? {} : { requestId: Schema13.decodeUnknownSync(TaskRequestId)(request_id) },
7170
8357
  ...provider === undefined ? {} : { provider },
7171
8358
  ...target === undefined ? {} : { target },
7172
8359
  ...context === undefined ? {} : { context },
7173
8360
  ...thread_id === undefined ? {} : { threadId: ThreadId.make(thread_id) },
7174
8361
  ...parent_task_id === undefined ? {} : { parentTaskId: TaskId.make(parent_task_id) },
7175
8362
  ...workspace === undefined ? {} : { workspace },
7176
- ...attachments2.length === 0 ? {} : { attachments: attachments2 }
7177
- }));
7178
- const completed = wait ? await Effect12.runPromise(waitForTask(client2, task, timeout_seconds)) : task;
7179
- 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));
7180
8368
  });
7181
8369
  server.registerTool("completion_inbox", {
7182
- title: "List completed work",
7183
- 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.",
7184
8372
  inputSchema: {}
7185
8373
  }, async () => output(await Effect12.runPromise(client2.inbox())));
7186
8374
  server.registerTool("acknowledge_completion", {
@@ -7221,18 +8409,19 @@ var runMcp = async (configuration) => {
7221
8409
  }, async ({ task_id }) => output(await Effect12.runPromise(client2.traceTask(TaskId.make(task_id)))));
7222
8410
  server.registerTool("wait_task", {
7223
8411
  title: "Wait for delegated work",
7224
- 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.",
7225
8413
  inputSchema: {
7226
8414
  task_id: z.string().uuid(),
7227
8415
  timeout_seconds: z.number().int().min(5).max(86400).default(900)
7228
8416
  }
7229
- }, async ({ task_id, timeout_seconds }) => {
7230
- const task = await Effect12.runPromise(client2.getTask(TaskId.make(task_id)));
7231
- 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
+ })));
7232
8421
  });
7233
8422
  server.registerTool("cancel_task", {
7234
8423
  title: "Cancel delegated work",
7235
- 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.",
7236
8425
  inputSchema: { task_id: z.string().uuid() }
7237
8426
  }, async ({ task_id }) => output(taskResult(await Effect12.runPromise(client2.cancelTask(TaskId.make(task_id))))));
7238
8427
  server.registerTool("thread_context", {
@@ -7269,7 +8458,19 @@ var main = async () => {
7269
8458
  }
7270
8459
  if (command === "device") {
7271
8460
  const configuration = await Effect16.runPromise(loadDeviceConfiguration);
7272
- 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
+ }
7273
8474
  return;
7274
8475
  }
7275
8476
  if (command === "mcp") {
@@ -7284,4 +8485,4 @@ await main().catch((cause) => {
7284
8485
  process.exitCode = 1;
7285
8486
  });
7286
8487
 
7287
- //# debugId=48BD90839B9C2B1564756E2164756E21
8488
+ //# debugId=4195FFD0F10A67E564756E2164756E21