@akshar5/cohall 0.4.9 → 0.5.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,16 +17,18 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
 
18
18
  // packages/protocol/src/index.ts
19
19
  import { Schema } from "effect";
20
- var version = "0.4.9", maxSocketPayloadBytes, bounded = (maxLength) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maxLength)), optionalText = (maxLength) => Schema.String.check(Schema.isMaxLength(maxLength)), boundedArray = (schema, maxLength) => Schema.Array(schema).check(Schema.isMaxLength(maxLength)), uuid = (name) => Schema.String.check(Schema.isUUID(4)).pipe(Schema.brand(name)), isoTimestamp = (name) => Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/, {
20
+ var version = "0.5.0", maxSocketPayloadBytes, 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)), DeviceId, ThreadId, MessageId, TaskId, AuthSessionId, Timestamp, Platform, Provider, DeviceStatus, TaskStatus, TaskTraceEventKind, MessageRole, ConnectionRole, AuthSession, CreatePairingInput, PairingCredential, ExchangePairingInput, SessionCredential, PairingResult, Capability, Workspace, Device, Thread, Message, Task, TaskTraceEvent, TaskTrace, ThreadContext, CreateTaskInput, SocketEvent, ErrorResponse, terminalTaskStatuses, isTerminalTask = (task) => terminalTaskStatuses.has(task.status), now = () => Timestamp.make(new Date().toISOString()), makeDeviceId = () => DeviceId.make(crypto.randomUUID()), makeThreadId = () => ThreadId.make(crypto.randomUUID()), makeMessageId = () => MessageId.make(crypto.randomUUID()), makeTaskId = () => TaskId.make(crypto.randomUUID()), makeAuthSessionId = () => AuthSessionId.make(crypto.randomUUID()), decodeCreatePairingInput, decodeExchangePairingInput, decodeCreateTaskInput, decodeDevice, decodeSocketEvent;
22
+ })).pipe(Schema.brand(name)), DeviceId, ThreadId, MessageId, TaskId, AuthSessionId, OperationId, Timestamp, Platform, Provider, DeviceStatus, TaskStatus, OperationStatus, TaskTraceEventKind, MessageRole, ConnectionRole, AuthSession, CreatePairingInput, PairingCredential, ExchangePairingInput, SessionCredential, PairingResult, Capability, Workspace, Device, Thread, Message, Task, upgradeVersion, CreateUpgradeOperationsInput, DeviceOperation, TaskStatusCounts, ProviderUsage, DeviceUsage, UsageSummary, TaskTraceEvent, TaskTrace, ThreadContext, CreateTaskInput, SocketEvent, ErrorResponse, terminalTaskStatuses, isTerminalTask = (task) => terminalTaskStatuses.has(task.status), now = () => Timestamp.make(new Date().toISOString()), makeDeviceId = () => DeviceId.make(crypto.randomUUID()), makeThreadId = () => ThreadId.make(crypto.randomUUID()), makeMessageId = () => MessageId.make(crypto.randomUUID()), makeTaskId = () => TaskId.make(crypto.randomUUID()), makeAuthSessionId = () => AuthSessionId.make(crypto.randomUUID()), makeOperationId = () => OperationId.make(crypto.randomUUID()), decodeCreatePairingInput, decodeExchangePairingInput, decodeCreateTaskInput, decodeCreateUpgradeOperationsInput, decodeDevice, decodeSocketEvent;
23
23
  var init_src = __esm(() => {
24
24
  maxSocketPayloadBytes = 1024 * 1024;
25
+ count = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }));
25
26
  DeviceId = uuid("DeviceId");
26
27
  ThreadId = uuid("ThreadId");
27
28
  MessageId = uuid("MessageId");
28
29
  TaskId = uuid("TaskId");
29
30
  AuthSessionId = uuid("AuthSessionId");
31
+ OperationId = uuid("OperationId");
30
32
  Timestamp = isoTimestamp("Timestamp");
31
33
  Platform = Schema.Literals(["darwin", "linux", "windows", "unknown"]);
32
34
  Provider = Schema.Literals(["codex", "claude-code", "opencode"]);
@@ -40,6 +42,13 @@ var init_src = __esm(() => {
40
42
  "failed",
41
43
  "cancelled"
42
44
  ]);
45
+ OperationStatus = Schema.Literals([
46
+ "queued",
47
+ "assigned",
48
+ "running",
49
+ "completed",
50
+ "failed"
51
+ ]);
43
52
  TaskTraceEventKind = Schema.Literals([...TaskStatus.literals, "requeued"]);
44
53
  MessageRole = Schema.Literals(["human", "agent", "system"]);
45
54
  ConnectionRole = Schema.Literals(["client", "device"]);
@@ -130,6 +139,52 @@ var init_src = __esm(() => {
130
139
  startedAt: Schema.optionalKey(Timestamp),
131
140
  completedAt: Schema.optionalKey(Timestamp)
132
141
  });
142
+ upgradeVersion = Schema.String.check(Schema.isPattern(/^(?:latest|v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/, {
143
+ expected: "latest or an exact semantic version"
144
+ }));
145
+ CreateUpgradeOperationsInput = Schema.Struct({
146
+ target: upgradeVersion,
147
+ restart: Schema.Boolean
148
+ });
149
+ DeviceOperation = Schema.Struct({
150
+ id: OperationId,
151
+ kind: Schema.Literal("upgrade"),
152
+ status: OperationStatus,
153
+ targetDeviceId: DeviceId,
154
+ requestedVersion: upgradeVersion,
155
+ restart: Schema.Boolean,
156
+ result: Schema.optionalKey(optionalText(16384)),
157
+ error: Schema.optionalKey(optionalText(16384)),
158
+ createdAt: Timestamp,
159
+ updatedAt: Timestamp,
160
+ completedAt: Schema.optionalKey(Timestamp)
161
+ });
162
+ TaskStatusCounts = Schema.Struct({
163
+ queued: count,
164
+ assigned: count,
165
+ running: count,
166
+ cancelling: count,
167
+ completed: count,
168
+ failed: count,
169
+ cancelled: count
170
+ });
171
+ ProviderUsage = Schema.Struct({
172
+ provider: Provider,
173
+ tasks: count
174
+ });
175
+ DeviceUsage = Schema.Struct({
176
+ deviceId: DeviceId,
177
+ deviceName: bounded(128),
178
+ tasks: count,
179
+ byStatus: TaskStatusCounts,
180
+ byProvider: boundedArray(ProviderUsage, 3)
181
+ });
182
+ UsageSummary = Schema.Struct({
183
+ retainedTasks: count,
184
+ byStatus: TaskStatusCounts,
185
+ byProvider: boundedArray(ProviderUsage, 3),
186
+ devices: boundedArray(DeviceUsage, 256)
187
+ });
133
188
  TaskTraceEvent = Schema.Struct({
134
189
  kind: TaskTraceEventKind,
135
190
  at: Timestamp,
@@ -184,6 +239,11 @@ var init_src = __esm(() => {
184
239
  TaskCancelled: { taskId: TaskId },
185
240
  TaskSettled: { taskId: TaskId },
186
241
  CancelTask: { taskId: TaskId },
242
+ OperationAssigned: { operation: DeviceOperation },
243
+ OperationAccepted: { operationId: OperationId },
244
+ OperationFinished: { operationId: OperationId, result: optionalText(16384) },
245
+ OperationFailed: { operationId: OperationId, error: bounded(16384) },
246
+ OperationSettled: { operationId: OperationId },
187
247
  Error: { code: bounded(64), message: bounded(2048) }
188
248
  });
189
249
  ErrorResponse = Schema.Struct({
@@ -197,32 +257,33 @@ var init_src = __esm(() => {
197
257
  decodeCreatePairingInput = Schema.decodeUnknownEffect(CreatePairingInput);
198
258
  decodeExchangePairingInput = Schema.decodeUnknownEffect(ExchangePairingInput);
199
259
  decodeCreateTaskInput = Schema.decodeUnknownEffect(CreateTaskInput);
260
+ decodeCreateUpgradeOperationsInput = Schema.decodeUnknownEffect(CreateUpgradeOperationsInput);
200
261
  decodeDevice = Schema.decodeUnknownEffect(Device);
201
262
  decodeSocketEvent = Schema.decodeUnknownEffect(SocketEvent);
202
263
  });
203
264
 
204
265
  // apps/relay/src/config.ts
205
- import { Effect as Effect8, Schema as Schema9 } from "effect";
206
- import { access as access4, chmod as chmod4, mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5 } from "node:fs/promises";
207
- import { homedir as homedir4, platform as platform4 } from "node:os";
208
- import { join as join5, resolve as resolve2 } from "node:path";
266
+ import { Effect as Effect10, Schema as Schema10 } from "effect";
267
+ import { access as access5, chmod as chmod6, mkdir as mkdir6, readFile as readFile5, writeFile as writeFile7 } from "node:fs/promises";
268
+ import { homedir as homedir5, platform as platform4 } from "node:os";
269
+ import { join as join7, resolve as resolve3 } from "node:path";
209
270
  var RelayConfiguration, defaultDataDirectory = () => {
210
271
  if (platform4() === "win32") {
211
- return join5(process.env.LOCALAPPDATA ?? join5(homedir4(), "AppData", "Local"), "Cohall");
272
+ return join7(process.env.LOCALAPPDATA ?? join7(homedir5(), "AppData", "Local"), "Cohall");
212
273
  }
213
274
  if (platform4() === "darwin") {
214
- return join5(homedir4(), "Library", "Application Support", "Cohall", "relay");
275
+ return join7(homedir5(), "Library", "Application Support", "Cohall", "relay");
215
276
  }
216
- return join5(process.env.XDG_DATA_HOME ?? join5(homedir4(), ".local", "share"), "cohall");
277
+ return join7(process.env.XDG_DATA_HOME ?? join7(homedir5(), ".local", "share"), "cohall");
217
278
  }, generatedToken = () => Array.from(crypto.getRandomValues(new Uint8Array(32)), (value) => value.toString(16).padStart(2, "0")).join(""), tokenFrom = async (path) => {
218
- if (await access4(path).then(() => true).catch(() => false)) {
219
- return (await readFile4(path, "utf8")).trim();
279
+ if (await access5(path).then(() => true).catch(() => false)) {
280
+ return (await readFile5(path, "utf8")).trim();
220
281
  }
221
282
  const token = generatedToken();
222
- await writeFile5(path, `${token}
283
+ await writeFile7(path, `${token}
223
284
  `, { mode: 384 });
224
285
  if (platform4() !== "win32") {
225
- await chmod4(path, 384);
286
+ await chmod6(path, 384);
226
287
  }
227
288
  return token;
228
289
  }, booleanEnvironment = (name) => {
@@ -234,41 +295,41 @@ var RelayConfiguration, defaultDataDirectory = () => {
234
295
  return true;
235
296
  }
236
297
  throw new Error(`${name} must be true or false`);
237
- }, isLoopback = (host) => host === "127.0.0.1" || host === "localhost" || host === "::1", loadEnvironmentConfiguration;
298
+ }, isLoopback2 = (host) => host === "127.0.0.1" || host === "localhost" || host === "::1", loadEnvironmentConfiguration;
238
299
  var init_config = __esm(() => {
239
- RelayConfiguration = Schema9.Struct({
240
- host: Schema9.NonEmptyString,
241
- port: Schema9.Int.check(Schema9.isBetween({ minimum: 1, maximum: 65535 })),
242
- token: Schema9.String.check(Schema9.isMinLength(32)),
243
- dataDirectory: Schema9.NonEmptyString,
244
- databasePath: Schema9.NonEmptyString,
245
- allowRemote: Schema9.Boolean,
246
- historyTaskLimit: Schema9.Int.check(Schema9.isBetween({ minimum: 100, maximum: 1e5 }))
300
+ RelayConfiguration = Schema10.Struct({
301
+ host: Schema10.NonEmptyString,
302
+ port: Schema10.Int.check(Schema10.isBetween({ minimum: 1, maximum: 65535 })),
303
+ token: Schema10.String.check(Schema10.isMinLength(32)),
304
+ dataDirectory: Schema10.NonEmptyString,
305
+ databasePath: Schema10.NonEmptyString,
306
+ allowRemote: Schema10.Boolean,
307
+ historyTaskLimit: Schema10.Int.check(Schema10.isBetween({ minimum: 100, maximum: 1e5 }))
247
308
  });
248
- loadEnvironmentConfiguration = Effect8.tryPromise({
309
+ loadEnvironmentConfiguration = Effect10.tryPromise({
249
310
  try: async () => {
250
311
  const host = process.env.COHALL_RELAY_HOST ?? "127.0.0.1";
251
312
  const port = Number(process.env.COHALL_RELAY_PORT ?? "8787");
252
313
  const allowRemote = booleanEnvironment("COHALL_RELAY_ALLOW_REMOTE");
253
- if (!isLoopback(host) && !allowRemote) {
314
+ if (!isLoopback2(host) && !allowRemote) {
254
315
  throw new Error("Remote relay binding is disabled. Use a private network or TLS reverse proxy, then set COHALL_RELAY_ALLOW_REMOTE=true.");
255
316
  }
256
- const dataDirectory = resolve2(process.env.COHALL_DATA_DIR ?? defaultDataDirectory());
257
- await mkdir4(dataDirectory, { recursive: true, mode: 448 });
317
+ const dataDirectory = resolve3(process.env.COHALL_DATA_DIR ?? defaultDataDirectory());
318
+ await mkdir6(dataDirectory, { recursive: true, mode: 448 });
258
319
  if (platform4() !== "win32") {
259
- await chmod4(dataDirectory, 448);
320
+ await chmod6(dataDirectory, 448);
260
321
  }
261
- const token = process.env.COHALL_TOKEN ?? await tokenFrom(join5(dataDirectory, "owner-token"));
322
+ const token = process.env.COHALL_TOKEN ?? await tokenFrom(join7(dataDirectory, "owner-token"));
262
323
  if (token.length < 32) {
263
324
  throw new Error("The Cohall relay owner token must be at least 32 characters");
264
325
  }
265
326
  const historyTaskLimit = Number(process.env.COHALL_HISTORY_TASK_LIMIT ?? "1000");
266
- return Schema9.decodeUnknownSync(RelayConfiguration)({
327
+ return Schema10.decodeUnknownSync(RelayConfiguration)({
267
328
  host,
268
329
  port,
269
330
  token,
270
331
  dataDirectory,
271
- databasePath: join5(dataDirectory, "cohall.db"),
332
+ databasePath: join7(dataDirectory, "cohall.db"),
272
333
  allowRemote,
273
334
  historyTaskLimit
274
335
  });
@@ -278,7 +339,7 @@ var init_config = __esm(() => {
278
339
  });
279
340
 
280
341
  // apps/relay/src/hub.ts
281
- import { WebSocket as WebSocket2 } from "ws";
342
+ import { WebSocket as WebSocket3 } from "ws";
282
343
 
283
344
  class Hub {
284
345
  #principals = new Map;
@@ -294,7 +355,7 @@ class Hub {
294
355
  this.#ownerSockets.add(socket);
295
356
  } else {
296
357
  const existing = this.#sessions.get(principal.sessionId);
297
- if (existing !== undefined && existing.readyState === WebSocket2.OPEN) {
358
+ if (existing !== undefined && existing.readyState === WebSocket3.OPEN) {
298
359
  return false;
299
360
  }
300
361
  this.#sessions.set(principal.sessionId, socket);
@@ -325,7 +386,7 @@ class Hub {
325
386
  return false;
326
387
  }
327
388
  const previous = this.#devices.get(deviceId);
328
- if (previous !== undefined && previous !== socket && previous.readyState === WebSocket2.OPEN) {
389
+ if (previous !== undefined && previous !== socket && previous.readyState === WebSocket3.OPEN) {
329
390
  return false;
330
391
  }
331
392
  this.#devices.set(deviceId, socket);
@@ -371,11 +432,11 @@ class Hub {
371
432
  this.#devices.get(deviceId)?.close(4003, "Device forgotten");
372
433
  }
373
434
  hasDevice(deviceId) {
374
- return this.#devices.get(deviceId)?.readyState === WebSocket2.OPEN;
435
+ return this.#devices.get(deviceId)?.readyState === WebSocket3.OPEN;
375
436
  }
376
437
  sendToDevice(deviceId, event) {
377
438
  const socket = this.#devices.get(deviceId);
378
- if (socket?.readyState !== WebSocket2.OPEN || !this.isAuthorized(socket)) {
439
+ if (socket?.readyState !== WebSocket3.OPEN || !this.isAuthorized(socket)) {
379
440
  return false;
380
441
  }
381
442
  try {
@@ -389,7 +450,7 @@ class Hub {
389
450
  var init_hub = () => {};
390
451
 
391
452
  // apps/relay/src/database.ts
392
- import { DatabaseSync } from "node:sqlite";
453
+ import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
393
454
 
394
455
  class Statement {
395
456
  #statement;
@@ -412,7 +473,7 @@ class Statement {
412
473
  class Database {
413
474
  #database;
414
475
  constructor(path) {
415
- this.#database = new DatabaseSync(path);
476
+ this.#database = new DatabaseSync2(path);
416
477
  }
417
478
  query(sql) {
418
479
  return new Statement(this.#database.prepare(sql));
@@ -448,12 +509,12 @@ __export(exports_store, {
448
509
  RelayStore: () => exports_store,
449
510
  PersistenceError: () => PersistenceError
450
511
  });
451
- import { Context as Context2, Effect as Effect9, Layer as Layer2, Schema as Schema10 } from "effect";
452
- import { createHash, randomBytes } from "node:crypto";
512
+ import { Context as Context2, Effect as Effect11, Layer as Layer2, Schema as Schema11 } from "effect";
513
+ import { createHash as createHash2, randomBytes } from "node:crypto";
453
514
  var PersistenceError, Service2, operationError = (operation) => (cause) => new PersistenceError({
454
515
  operation,
455
516
  message: cause instanceof Error ? cause.message : String(cause)
456
- }), decode = (operation, schema, value) => Schema10.decodeUnknownEffect(schema)(value).pipe(Effect9.mapError(operationError(operation))), threadFromRow = (row) => decode("RelayStore.decodeThread", Thread, {
517
+ }), decode = (operation, schema, value) => Schema11.decodeUnknownEffect(schema)(value).pipe(Effect11.mapError(operationError(operation))), threadFromRow = (row) => decode("RelayStore.decodeThread", Thread, {
457
518
  id: row.id,
458
519
  title: row.title,
459
520
  createdAt: row.created_at,
@@ -499,14 +560,34 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
499
560
  ...row.error === null ? {} : { error: row.error },
500
561
  ...row.started_at === null ? {} : { startedAt: row.started_at },
501
562
  ...row.completed_at === null ? {} : { completedAt: row.completed_at }
563
+ }), operationFromRow = (row) => decode("RelayStore.decodeOperation", DeviceOperation, {
564
+ id: row.id,
565
+ kind: row.kind,
566
+ status: row.status,
567
+ targetDeviceId: row.target_device_id,
568
+ requestedVersion: row.requested_version,
569
+ restart: row.restart === 1,
570
+ createdAt: row.created_at,
571
+ updatedAt: row.updated_at,
572
+ ...row.result === null ? {} : { result: row.result },
573
+ ...row.error === null ? {} : { error: row.error },
574
+ ...row.completed_at === null ? {} : { completedAt: row.completed_at }
575
+ }), emptyStatusCounts = () => ({
576
+ queued: 0,
577
+ assigned: 0,
578
+ running: 0,
579
+ cancelling: 0,
580
+ completed: 0,
581
+ failed: 0,
582
+ cancelled: 0
502
583
  }), taskTraceEventFromRow = (row) => decode("RelayStore.decodeTaskTraceEvent", TaskTraceEvent, {
503
584
  kind: row.kind,
504
585
  at: row.created_at,
505
586
  ...row.detail === null ? {} : { detail: row.detail }
506
- }), rolesFromRow = (row) => Schema10.decodeUnknownSync(Schema10.Array(ConnectionRole))(JSON.parse(row.roles_json)), authSessionFromRow = (row) => {
587
+ }), rolesFromRow = (row) => Schema11.decodeUnknownSync(Schema11.Array(ConnectionRole))(JSON.parse(row.roles_json)), authSessionFromRow = (row) => {
507
588
  const roles = rolesFromRow(row);
508
589
  const role = roles[0];
509
- return role === undefined ? Effect9.fail(new PersistenceError({
590
+ return role === undefined ? Effect11.fail(new PersistenceError({
510
591
  operation: "RelayStore.decodeAuthSession",
511
592
  message: `Session ${row.id} has no role`
512
593
  })) : decode("RelayStore.decodeAuthSession", AuthSession, {
@@ -521,7 +602,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
521
602
  });
522
603
  }, secret = (prefix) => {
523
604
  return `cohall_${prefix}_${randomBytes(32).toString("hex")}`;
524
- }, tokenHash = (token) => createHash("sha256").update(token).digest("hex"), providerName = (provider) => {
605
+ }, tokenHash = (token) => createHash2("sha256").update(token).digest("hex"), providerName = (provider) => {
525
606
  switch (provider) {
526
607
  case "codex":
527
608
  return "Codex";
@@ -587,23 +668,36 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
587
668
  WHERE NOT EXISTS (SELECT 1 FROM tasks WHERE tasks.thread_id = threads.id)
588
669
  AND NOT EXISTS (SELECT 1 FROM messages WHERE messages.thread_id = threads.id)`).run();
589
670
  };
671
+ const pruneTerminalOperations = (preserveOperationId) => {
672
+ const exclusion = preserveOperationId === undefined ? "" : "AND id <> ?";
673
+ const offset = preserveOperationId === undefined ? retainedTerminalTasks : retainedTerminalTasks - 1;
674
+ const parameters = preserveOperationId === undefined ? [offset] : [preserveOperationId, offset];
675
+ db.query(`DELETE FROM device_operations
676
+ WHERE id IN (
677
+ SELECT id FROM device_operations
678
+ WHERE status IN ('completed', 'failed') ${exclusion}
679
+ ORDER BY completed_at DESC, updated_at DESC, id DESC
680
+ LIMIT -1 OFFSET ?
681
+ )`).run(...parameters);
682
+ };
590
683
  const queryTask = (taskId) => db.query("SELECT * FROM tasks WHERE id = ?").get(taskId);
591
- const getTask = Effect9.fn("RelayStore.getTask")(function* (taskId) {
592
- const row = yield* Effect9.try({
684
+ const queryOperation = (operationId) => db.query("SELECT * FROM device_operations WHERE id = ?").get(operationId);
685
+ const getTask = Effect11.fn("RelayStore.getTask")(function* (taskId) {
686
+ const row = yield* Effect11.try({
593
687
  try: () => queryTask(taskId),
594
688
  catch: operationError("RelayStore.getTask")
595
689
  });
596
690
  if (row === null) {
597
- return yield* Effect9.fail(new PersistenceError({
691
+ return yield* Effect11.fail(new PersistenceError({
598
692
  operation: "RelayStore.getTask",
599
693
  message: `Unknown task ${taskId}`
600
694
  }));
601
695
  }
602
696
  return yield* taskFromRow(row);
603
697
  });
604
- const traceTask = Effect9.fn("RelayStore.traceTask")(function* (taskId) {
698
+ const traceTask = Effect11.fn("RelayStore.traceTask")(function* (taskId) {
605
699
  const task = yield* getTask(taskId);
606
- const [deviceRow, eventRows] = yield* Effect9.try({
700
+ const [deviceRow, eventRows] = yield* Effect11.try({
607
701
  try: () => [
608
702
  db.query("SELECT * FROM devices WHERE id = ?").get(task.targetDeviceId),
609
703
  db.query(`SELECT * FROM (
@@ -614,14 +708,14 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
614
708
  catch: operationError("RelayStore.traceTask")
615
709
  });
616
710
  if (deviceRow === null) {
617
- return yield* Effect9.fail(new PersistenceError({
711
+ return yield* Effect11.fail(new PersistenceError({
618
712
  operation: "RelayStore.traceTask.device",
619
713
  message: `Unknown target device ${task.targetDeviceId}`
620
714
  }));
621
715
  }
622
716
  const targetDevice = yield* deviceFromRow(deviceRow);
623
717
  const selectedRows = eventRows.slice(-100);
624
- const recordedEvents = yield* Effect9.forEach(selectedRows, taskTraceEventFromRow);
718
+ const recordedEvents = yield* Effect11.forEach(selectedRows, taskTraceEventFromRow);
625
719
  const events = recordedEvents.length === 0 ? derivedTraceEvents(task) : recordedEvents;
626
720
  return TaskTrace.make({
627
721
  taskId: task.id,
@@ -641,15 +735,195 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
641
735
  ...task.error === undefined ? {} : { error: task.error }
642
736
  });
643
737
  });
644
- const listDevices = Effect9.fn("RelayStore.listDevices")(function* () {
645
- const rows = yield* Effect9.try({
738
+ const listDevices = Effect11.fn("RelayStore.listDevices")(function* () {
739
+ const rows = yield* Effect11.try({
646
740
  try: () => db.query("SELECT * FROM devices WHERE forgotten_at IS NULL ORDER BY name COLLATE NOCASE, id").all(),
647
741
  catch: operationError("RelayStore.listDevices")
648
742
  });
649
- return yield* Effect9.forEach(rows, deviceFromRow);
743
+ return yield* Effect11.forEach(rows, deviceFromRow);
744
+ });
745
+ const usage = Effect11.fn("RelayStore.usage")(function* () {
746
+ const rows = yield* Effect11.try({
747
+ try: () => db.query(`SELECT tasks.target_device_id AS device_id, devices.name AS device_name,
748
+ tasks.status, tasks.provider, COUNT(*) AS tasks
749
+ FROM tasks
750
+ JOIN devices ON devices.id = tasks.target_device_id
751
+ GROUP BY tasks.target_device_id, devices.name, tasks.status, tasks.provider
752
+ ORDER BY devices.name COLLATE NOCASE, tasks.target_device_id, tasks.provider`).all(),
753
+ catch: operationError("RelayStore.usage")
754
+ });
755
+ const totalStatus = emptyStatusCounts();
756
+ const totalProviders = new Map;
757
+ const devices = new Map;
758
+ let retainedTasks = 0;
759
+ for (const row of rows) {
760
+ const status = Schema11.decodeUnknownSync(TaskStatus)(row.status);
761
+ const provider = Schema11.decodeUnknownSync(Provider)(row.provider);
762
+ const deviceId = Schema11.decodeUnknownSync(DeviceId)(row.device_id);
763
+ retainedTasks += row.tasks;
764
+ totalStatus[status] += row.tasks;
765
+ totalProviders.set(provider, (totalProviders.get(provider) ?? 0) + row.tasks);
766
+ const current = devices.get(deviceId) ?? {
767
+ name: row.device_name,
768
+ status: emptyStatusCounts(),
769
+ providers: new Map
770
+ };
771
+ current.status[status] += row.tasks;
772
+ current.providers.set(provider, (current.providers.get(provider) ?? 0) + row.tasks);
773
+ devices.set(deviceId, current);
774
+ }
775
+ const providerUsage = (providers) => Provider.literals.flatMap((provider) => {
776
+ const tasks = providers.get(provider);
777
+ return tasks === undefined ? [] : [{ provider, tasks }];
778
+ });
779
+ return UsageSummary.make({
780
+ retainedTasks,
781
+ byStatus: TaskStatusCounts.make(totalStatus),
782
+ byProvider: providerUsage(totalProviders),
783
+ devices: [...devices.entries()].map(([deviceId, device]) => DeviceUsage.make({
784
+ deviceId,
785
+ deviceName: device.name,
786
+ tasks: Object.values(device.status).reduce((sum, tasks) => sum + tasks, 0),
787
+ byStatus: TaskStatusCounts.make(device.status),
788
+ byProvider: providerUsage(device.providers)
789
+ }))
790
+ });
791
+ });
792
+ const getOperation = Effect11.fn("RelayStore.getOperation")(function* (operationId) {
793
+ const row = yield* Effect11.try({
794
+ try: () => queryOperation(operationId),
795
+ catch: operationError("RelayStore.getOperation")
796
+ });
797
+ if (row === null) {
798
+ return yield* Effect11.fail(new PersistenceError({
799
+ operation: "RelayStore.getOperation",
800
+ message: `Unknown device operation ${operationId}`
801
+ }));
802
+ }
803
+ return yield* operationFromRow(row);
804
+ });
805
+ const listOperations = Effect11.fn("RelayStore.listOperations")(function* () {
806
+ const rows = yield* Effect11.try({
807
+ try: () => db.query("SELECT * FROM device_operations ORDER BY created_at DESC, id DESC LIMIT 50").all(),
808
+ catch: operationError("RelayStore.listOperations")
809
+ });
810
+ return yield* Effect11.forEach(rows, operationFromRow);
811
+ });
812
+ const createUpgradeOperations = Effect11.fn("RelayStore.createUpgradeOperations")(function* (input) {
813
+ const timestamp = now();
814
+ const operations = yield* Effect11.try({
815
+ try: () => db.transaction(() => {
816
+ const devices = db.query("SELECT * FROM devices WHERE forgotten_at IS NULL ORDER BY name COLLATE NOCASE, id").all();
817
+ if (devices.length === 0) {
818
+ throw new Error("No Cohall devices are registered");
819
+ }
820
+ if (devices.length > 256) {
821
+ throw new Error("All-device operations support at most 256 registered devices");
822
+ }
823
+ for (const device of devices) {
824
+ const outstanding = db.query(`SELECT COUNT(*) AS count FROM device_operations
825
+ WHERE target_device_id = ? AND status IN ('queued', 'assigned', 'running')`).get(device.id);
826
+ if ((outstanding?.count ?? 0) > 0) {
827
+ throw new Error(`${device.name} already has an upgrade operation in progress`);
828
+ }
829
+ }
830
+ pruneTerminalOperations();
831
+ return devices.map((device) => {
832
+ const operation = DeviceOperation.make({
833
+ id: makeOperationId(),
834
+ kind: "upgrade",
835
+ status: "queued",
836
+ targetDeviceId: DeviceId.make(device.id),
837
+ requestedVersion: input.target,
838
+ restart: input.restart,
839
+ createdAt: timestamp,
840
+ updatedAt: timestamp
841
+ });
842
+ db.query(`INSERT INTO device_operations (
843
+ id, kind, status, target_device_id, requested_version, restart,
844
+ created_at, updated_at
845
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(operation.id, operation.kind, operation.status, operation.targetDeviceId, operation.requestedVersion, operation.restart ? 1 : 0, operation.createdAt, operation.updatedAt);
846
+ return operation;
847
+ });
848
+ })(),
849
+ catch: operationError("RelayStore.createUpgradeOperations")
850
+ });
851
+ return operations;
650
852
  });
651
- const forgetDevice = Effect9.fn("RelayStore.forgetDevice")(function* (deviceId) {
652
- const row = yield* Effect9.try({
853
+ const pendingOperationsFor = Effect11.fn("RelayStore.pendingOperationsFor")(function* (deviceId) {
854
+ const rows = yield* Effect11.try({
855
+ try: () => db.query(`SELECT * FROM device_operations WHERE target_device_id = ? AND status = 'queued'
856
+ ORDER BY created_at, id LIMIT 10`).all(deviceId),
857
+ catch: operationError("RelayStore.pendingOperationsFor")
858
+ });
859
+ return yield* Effect11.forEach(rows, operationFromRow);
860
+ });
861
+ const transitionOperation = Effect11.fn("RelayStore.transitionOperation")(function* (operationId, expected, status, result, error) {
862
+ const current = yield* getOperation(operationId);
863
+ if (!expected.includes(current.status)) {
864
+ return current;
865
+ }
866
+ const timestamp = now();
867
+ const completed = status === "completed" || status === "failed" ? timestamp : undefined;
868
+ const placeholders = expected.map(() => "?").join(", ");
869
+ yield* Effect11.try({
870
+ try: () => db.transaction(() => {
871
+ const updated = db.query(`UPDATE device_operations
872
+ SET status = ?, result = ?, error = ?, updated_at = ?, completed_at = ?
873
+ WHERE id = ? AND status IN (${placeholders})`).run(status, result ?? current.result ?? null, error ?? current.error ?? null, timestamp, completed ?? current.completedAt ?? null, operationId, ...expected);
874
+ if (updated.changes === 1 && completed !== undefined) {
875
+ pruneTerminalOperations(operationId);
876
+ }
877
+ })(),
878
+ catch: operationError("RelayStore.transitionOperation")
879
+ });
880
+ return yield* getOperation(operationId);
881
+ });
882
+ const requireOperationTarget = Effect11.fn("RelayStore.requireOperationTarget")(function* (operationId, deviceId) {
883
+ const operation = yield* getOperation(operationId);
884
+ if (operation.targetDeviceId !== deviceId) {
885
+ return yield* Effect11.fail(new PersistenceError({
886
+ operation: "RelayStore.requireOperationTarget",
887
+ message: `Device ${deviceId} cannot update operation ${operationId}`
888
+ }));
889
+ }
890
+ return operation;
891
+ });
892
+ const assignOperation = Effect11.fn("RelayStore.assignOperation")(function* (operationId) {
893
+ const current = yield* getOperation(operationId);
894
+ if (current.status !== "queued") {
895
+ return current;
896
+ }
897
+ const timestamp = now();
898
+ yield* Effect11.try({
899
+ try: () => db.query(`UPDATE device_operations SET status = 'assigned', updated_at = ?
900
+ WHERE id = ? AND status = 'queued'
901
+ AND NOT EXISTS (
902
+ SELECT 1 FROM tasks WHERE target_device_id = ?
903
+ AND status IN ('assigned', 'running', 'cancelling')
904
+ )
905
+ AND NOT EXISTS (
906
+ SELECT 1 FROM device_operations active
907
+ WHERE active.target_device_id = ? AND active.id <> ?
908
+ AND active.status IN ('assigned', 'running')
909
+ )`).run(timestamp, operationId, current.targetDeviceId, current.targetDeviceId, operationId),
910
+ catch: operationError("RelayStore.assignOperation")
911
+ });
912
+ return yield* getOperation(operationId);
913
+ });
914
+ const acceptOperation = (operationId, deviceId) => requireOperationTarget(operationId, deviceId).pipe(Effect11.flatMap(() => transitionOperation(operationId, ["assigned"], "running")));
915
+ const finishOperation = (operationId, deviceId, result) => requireOperationTarget(operationId, deviceId).pipe(Effect11.flatMap(() => transitionOperation(operationId, ["queued", "assigned", "running"], "completed", result)));
916
+ const failOperation = (operationId, deviceId, error) => requireOperationTarget(operationId, deviceId).pipe(Effect11.flatMap(() => transitionOperation(operationId, ["queued", "assigned", "running"], "failed", undefined, error)));
917
+ const abandonOperation = (operationId) => transitionOperation(operationId, ["queued", "assigned", "running"], "failed", undefined, "Abandoned by the relay owner");
918
+ const requeueOperationsFor = Effect11.fn("RelayStore.requeueOperationsFor")(function* (deviceId) {
919
+ yield* Effect11.try({
920
+ try: () => db.query(`UPDATE device_operations SET status = 'queued', updated_at = ?
921
+ WHERE target_device_id = ? AND status IN ('assigned', 'running')`).run(now(), deviceId),
922
+ catch: operationError("RelayStore.requeueOperationsFor")
923
+ });
924
+ });
925
+ const forgetDevice = Effect11.fn("RelayStore.forgetDevice")(function* (deviceId) {
926
+ const row = yield* Effect11.try({
653
927
  try: () => db.transaction(() => {
654
928
  const device = db.query("SELECT * FROM devices WHERE id = ? AND forgotten_at IS NULL").get(deviceId);
655
929
  if (device === null) {
@@ -664,6 +938,10 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
664
938
  throw new Error(`Device ${deviceId} still has outstanding tasks`);
665
939
  }
666
940
  const timestamp = now();
941
+ db.query(`UPDATE device_operations
942
+ SET status = 'failed', error = ?, updated_at = ?, completed_at = ?
943
+ WHERE target_device_id = ? AND status IN ('queued', 'assigned', 'running')`).run("Target device was forgotten by the relay owner", timestamp, timestamp, deviceId);
944
+ pruneTerminalOperations();
667
945
  db.query("UPDATE devices SET forgotten_at = ? WHERE id = ?").run(timestamp, deviceId);
668
946
  db.query("UPDATE auth_sessions SET revoked_at = ? WHERE bound_device_id = ? AND revoked_at IS NULL").run(timestamp, deviceId);
669
947
  return device;
@@ -672,25 +950,27 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
672
950
  });
673
951
  return yield* deviceFromRow(row);
674
952
  });
675
- const recover = Effect9.fn("RelayStore.recover")(function* () {
676
- yield* Effect9.try({
953
+ const recover = Effect11.fn("RelayStore.recover")(function* () {
954
+ yield* Effect11.try({
677
955
  try: () => db.transaction(() => {
678
956
  const timestamp = now();
679
957
  const interrupted = db.query("SELECT id FROM tasks WHERE status IN ('assigned', 'running')").all();
680
958
  db.query("UPDATE devices SET status = 'offline'").run();
681
959
  db.query("UPDATE tasks SET status = 'queued', updated_at = ? WHERE status IN ('assigned', 'running')").run(timestamp);
960
+ db.query("UPDATE device_operations SET status = 'queued', updated_at = ? WHERE status IN ('assigned', 'running')").run(timestamp);
682
961
  for (const task of interrupted) {
683
962
  recordTaskTraceEvent(TaskId.make(task.id), "requeued", "Relay restarted before the task completed", timestamp);
684
963
  }
685
964
  db.query("DELETE FROM auth_pairings WHERE expires_at <= ? OR used_at IS NOT NULL").run(timestamp);
686
965
  db.query("UPDATE auth_sessions SET revoked_at = ? WHERE revoked_at IS NULL AND roles_json LIKE '%,%'").run(timestamp);
687
966
  pruneTerminalHistory();
967
+ pruneTerminalOperations();
688
968
  })(),
689
969
  catch: operationError("RelayStore.recover")
690
970
  });
691
971
  });
692
- const upsertDevice = Effect9.fn("RelayStore.upsertDevice")(function* (device) {
693
- yield* Effect9.try({
972
+ const upsertDevice = Effect11.fn("RelayStore.upsertDevice")(function* (device) {
973
+ yield* Effect11.try({
694
974
  try: () => db.query(`INSERT INTO devices (
695
975
  id, name, hostname, platform, architecture, status, providers_json,
696
976
  capabilities_json, workspaces_json, version, last_seen_at, connected_at
@@ -706,28 +986,28 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
706
986
  });
707
987
  return device;
708
988
  });
709
- const updateDeviceStatus = Effect9.fn("RelayStore.updateDeviceStatus")(function* (deviceId, status) {
989
+ const updateDeviceStatus = Effect11.fn("RelayStore.updateDeviceStatus")(function* (deviceId, status) {
710
990
  const timestamp = now();
711
- const result = yield* Effect9.try({
991
+ const result = yield* Effect11.try({
712
992
  try: () => db.query("UPDATE devices SET status = ?, last_seen_at = ? WHERE id = ?").run(status, timestamp, deviceId),
713
993
  catch: operationError("RelayStore.updateDeviceStatus")
714
994
  });
715
995
  if (result.changes !== 1) {
716
- return yield* Effect9.fail(new PersistenceError({
996
+ return yield* Effect11.fail(new PersistenceError({
717
997
  operation: "RelayStore.updateDeviceStatus",
718
998
  message: `Unknown device ${deviceId}`
719
999
  }));
720
1000
  }
721
1001
  const row = db.query("SELECT * FROM devices WHERE id = ?").get(deviceId);
722
1002
  if (row === null) {
723
- return yield* Effect9.fail(new PersistenceError({
1003
+ return yield* Effect11.fail(new PersistenceError({
724
1004
  operation: "RelayStore.updateDeviceStatus.read",
725
1005
  message: `Device ${deviceId} disappeared after update`
726
1006
  }));
727
1007
  }
728
1008
  return yield* deviceFromRow(row);
729
1009
  });
730
- const createDelegation2 = Effect9.fn("RelayStore.createDelegation")(function* (input, targetDeviceId, sourceDeviceId, providerSessionId) {
1010
+ const createDelegation2 = Effect11.fn("RelayStore.createDelegation")(function* (input, targetDeviceId, sourceDeviceId, providerSessionId) {
731
1011
  const timestamp = now();
732
1012
  const threadId = input.threadId ?? makeThreadId();
733
1013
  const task = Task.make({
@@ -761,7 +1041,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
761
1041
  taskId: task.id,
762
1042
  ...sourceDeviceId === undefined ? {} : { deviceId: sourceDeviceId }
763
1043
  });
764
- yield* Effect9.try({
1044
+ yield* Effect11.try({
765
1045
  try: () => db.transaction(() => {
766
1046
  const outstanding = db.query(`SELECT COUNT(*) AS count FROM tasks WHERE target_device_id = ?
767
1047
  AND status IN ('queued', 'assigned', 'running', 'cancelling')`).get(targetDeviceId);
@@ -789,18 +1069,18 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
789
1069
  });
790
1070
  return task;
791
1071
  });
792
- const threadContext2 = Effect9.fn("RelayStore.threadContext")(function* (threadId) {
793
- const threadRow = yield* Effect9.try({
1072
+ const threadContext2 = Effect11.fn("RelayStore.threadContext")(function* (threadId) {
1073
+ const threadRow = yield* Effect11.try({
794
1074
  try: () => db.query("SELECT * FROM threads WHERE id = ?").get(threadId),
795
1075
  catch: operationError("RelayStore.threadContext.thread")
796
1076
  });
797
1077
  if (threadRow === null) {
798
- return yield* Effect9.fail(new PersistenceError({
1078
+ return yield* Effect11.fail(new PersistenceError({
799
1079
  operation: "RelayStore.threadContext",
800
1080
  message: `Unknown thread ${threadId}`
801
1081
  }));
802
1082
  }
803
- const [messageRows, taskRows] = yield* Effect9.try({
1083
+ const [messageRows, taskRows] = yield* Effect11.try({
804
1084
  try: () => [
805
1085
  db.query(`SELECT * FROM (
806
1086
  SELECT * FROM messages WHERE thread_id = ?
@@ -813,10 +1093,10 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
813
1093
  ],
814
1094
  catch: operationError("RelayStore.threadContext.items")
815
1095
  });
816
- const [thread, messages, tasks] = yield* Effect9.all([
1096
+ const [thread, messages, tasks] = yield* Effect11.all([
817
1097
  threadFromRow(threadRow),
818
- Effect9.forEach(messageRows.slice(-20), messageFromRow),
819
- Effect9.forEach(taskRows.slice(-20), taskFromRow)
1098
+ Effect11.forEach(messageRows.slice(-20), messageFromRow),
1099
+ Effect11.forEach(taskRows.slice(-20), taskFromRow)
820
1100
  ]);
821
1101
  const recentMessages = recentWithin(messages, 256 * 1024);
822
1102
  const recentTasks = recentWithin(tasks, 768 * 1024);
@@ -827,22 +1107,22 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
827
1107
  truncated: messageRows.length > 20 || taskRows.length > 20 || recentMessages.truncated || recentTasks.truncated
828
1108
  });
829
1109
  });
830
- const pendingTasksFor = Effect9.fn("RelayStore.pendingTasksFor")(function* (deviceId) {
831
- const rows = yield* Effect9.try({
1110
+ const pendingTasksFor = Effect11.fn("RelayStore.pendingTasksFor")(function* (deviceId) {
1111
+ const rows = yield* Effect11.try({
832
1112
  try: () => db.query(`SELECT * FROM tasks WHERE target_device_id = ?
833
1113
  AND status IN ('queued', 'cancelling') ORDER BY created_at, id LIMIT 100`).all(deviceId),
834
1114
  catch: operationError("RelayStore.pendingTasksFor")
835
1115
  });
836
- return yield* Effect9.forEach(rows, taskFromRow);
1116
+ return yield* Effect11.forEach(rows, taskFromRow);
837
1117
  });
838
- const transition = Effect9.fn("RelayStore.transition")(function* (taskId, expected, update, event) {
1118
+ const transition = Effect11.fn("RelayStore.transition")(function* (taskId, expected, update, event) {
839
1119
  const current = yield* getTask(taskId);
840
1120
  if (!expected.includes(current.status)) {
841
1121
  return current;
842
1122
  }
843
1123
  const placeholders = expected.map(() => "?").join(", ");
844
1124
  const timestamp = now();
845
- yield* Effect9.try({
1125
+ yield* Effect11.try({
846
1126
  try: () => db.transaction(() => {
847
1127
  const result = db.query(`UPDATE tasks SET status = ?, provider_session_id = ?, result = ?, error = ?,
848
1128
  started_at = ?, completed_at = ?, updated_at = ?
@@ -858,20 +1138,24 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
858
1138
  });
859
1139
  return yield* getTask(taskId);
860
1140
  });
861
- const assignTask = Effect9.fn("RelayStore.assignTask")(function* (taskId) {
1141
+ const assignTask = Effect11.fn("RelayStore.assignTask")(function* (taskId) {
862
1142
  const current = yield* getTask(taskId);
863
1143
  if (current.status !== "queued") {
864
1144
  return current;
865
1145
  }
866
1146
  const timestamp = now();
867
- yield* Effect9.try({
1147
+ yield* Effect11.try({
868
1148
  try: () => db.transaction(() => {
869
1149
  const result = db.query(`UPDATE tasks SET status = 'assigned', updated_at = ?
870
1150
  WHERE id = ? AND status = 'queued' AND NOT EXISTS (
871
1151
  SELECT 1 FROM tasks active
872
1152
  WHERE active.target_device_id = ? AND active.id <> ?
873
1153
  AND active.status IN ('assigned', 'running', 'cancelling')
874
- )`).run(timestamp, taskId, current.targetDeviceId, taskId);
1154
+ ) AND NOT EXISTS (
1155
+ SELECT 1 FROM device_operations active
1156
+ WHERE active.target_device_id = ?
1157
+ AND active.status IN ('assigned', 'running')
1158
+ )`).run(timestamp, taskId, current.targetDeviceId, taskId, current.targetDeviceId);
875
1159
  if (result.changes === 1) {
876
1160
  recordTaskTraceEvent(taskId, "assigned", "Relay dispatched the task over the device WebSocket", timestamp);
877
1161
  }
@@ -880,23 +1164,23 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
880
1164
  });
881
1165
  return yield* getTask(taskId);
882
1166
  });
883
- const requireTarget = Effect9.fn("RelayStore.requireTarget")(function* (taskId, deviceId) {
1167
+ const requireTarget = Effect11.fn("RelayStore.requireTarget")(function* (taskId, deviceId) {
884
1168
  const task = yield* getTask(taskId);
885
1169
  if (task.targetDeviceId !== deviceId) {
886
- return yield* Effect9.fail(new PersistenceError({
1170
+ return yield* Effect11.fail(new PersistenceError({
887
1171
  operation: "RelayStore.requireTarget",
888
1172
  message: `Task ${taskId} belongs to another device`
889
1173
  }));
890
1174
  }
891
1175
  return task;
892
1176
  });
893
- const terminal = Effect9.fn("RelayStore.terminal")(function* (taskId, deviceId, status, result, error, providerSessionId) {
1177
+ const terminal = Effect11.fn("RelayStore.terminal")(function* (taskId, deviceId, status, result, error, providerSessionId) {
894
1178
  const current = yield* requireTarget(taskId, deviceId);
895
1179
  if (["completed", "failed", "cancelled"].includes(current.status)) {
896
1180
  return current;
897
1181
  }
898
1182
  const timestamp = now();
899
- yield* Effect9.try({
1183
+ yield* Effect11.try({
900
1184
  try: () => db.transaction(() => {
901
1185
  const updated = db.query(`UPDATE tasks SET status = ?, result = ?, error = ?, provider_session_id = ?,
902
1186
  completed_at = ?, updated_at = ? WHERE id = ?
@@ -926,7 +1210,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
926
1210
  });
927
1211
  return yield* getTask(taskId);
928
1212
  });
929
- const requestCancellation = Effect9.fn("RelayStore.requestCancellation")(function* (taskId) {
1213
+ const requestCancellation = Effect11.fn("RelayStore.requestCancellation")(function* (taskId) {
930
1214
  const current = yield* getTask(taskId);
931
1215
  if (["completed", "failed", "cancelled"].includes(current.status)) {
932
1216
  return current;
@@ -939,9 +1223,9 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
939
1223
  status: "cancelling"
940
1224
  }, { kind: "cancelling", detail: "Client requested task cancellation" });
941
1225
  });
942
- const requeueTasksFor = Effect9.fn("RelayStore.requeueTasksFor")(function* (deviceId) {
1226
+ const requeueTasksFor = Effect11.fn("RelayStore.requeueTasksFor")(function* (deviceId) {
943
1227
  const timestamp = now();
944
- yield* Effect9.try({
1228
+ yield* Effect11.try({
945
1229
  try: () => db.transaction(() => {
946
1230
  const interrupted = db.query(`SELECT id FROM tasks WHERE target_device_id = ?
947
1231
  AND status IN ('assigned', 'running')`).all(deviceId);
@@ -954,42 +1238,42 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
954
1238
  catch: operationError("RelayStore.requeueTasksFor")
955
1239
  });
956
1240
  });
957
- const sessionFor = Effect9.fn("RelayStore.sessionFor")(function* (threadId, deviceId, provider) {
958
- const row = yield* Effect9.try({
1241
+ const sessionFor = Effect11.fn("RelayStore.sessionFor")(function* (threadId, deviceId, provider) {
1242
+ const row = yield* Effect11.try({
959
1243
  try: () => db.query(`SELECT session_id FROM provider_sessions
960
1244
  WHERE thread_id = ? AND device_id = ? AND provider = ?`).get(threadId, deviceId, provider),
961
1245
  catch: operationError("RelayStore.sessionFor")
962
1246
  });
963
1247
  return row?.session_id;
964
1248
  });
965
- const createPairing = Effect9.fn("RelayStore.createPairing")(function* (input) {
1249
+ const createPairing = Effect11.fn("RelayStore.createPairing")(function* (input) {
966
1250
  const roles = [...new Set(input.roles)];
967
1251
  if (roles.length === 0) {
968
- return yield* Effect9.fail(new PersistenceError({
1252
+ return yield* Effect11.fail(new PersistenceError({
969
1253
  operation: "RelayStore.createPairing",
970
1254
  message: "A pairing must allow at least one role"
971
1255
  }));
972
1256
  }
973
1257
  const token = secret("pair");
974
1258
  const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
975
- yield* Effect9.try({
1259
+ yield* Effect11.try({
976
1260
  try: () => db.query(`INSERT INTO auth_pairings (token_hash, label, roles_json, expires_at, created_at)
977
1261
  VALUES (?, ?, ?, ?, ?)`).run(tokenHash(token), input.label, JSON.stringify(roles), expiresAt, now()),
978
1262
  catch: operationError("RelayStore.createPairing")
979
1263
  });
980
1264
  return PairingCredential.make({ token, expiresAt: Timestamp.make(expiresAt) });
981
1265
  });
982
- const exchangePairing2 = Effect9.fn("RelayStore.exchangePairing")(function* (token) {
1266
+ const exchangePairing2 = Effect11.fn("RelayStore.exchangePairing")(function* (token) {
983
1267
  const timestamp = now();
984
1268
  const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
985
- const created = yield* Effect9.try({
1269
+ const created = yield* Effect11.try({
986
1270
  try: () => db.transaction(() => {
987
1271
  const pairing = db.query(`SELECT * FROM auth_pairings
988
1272
  WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`).get(tokenHash(token), timestamp);
989
1273
  if (pairing === null) {
990
1274
  throw new Error("Pairing credential is invalid, expired, or already used");
991
1275
  }
992
- const roles = Schema10.decodeUnknownSync(Schema10.Array(ConnectionRole))(JSON.parse(pairing.roles_json));
1276
+ const roles = Schema11.decodeUnknownSync(Schema11.Array(ConnectionRole))(JSON.parse(pairing.roles_json));
993
1277
  const deviceId = roles.includes("device") ? makeDeviceId() : undefined;
994
1278
  const consumed = db.query("UPDATE auth_pairings SET used_at = ? WHERE token_hash = ? AND used_at IS NULL").run(timestamp, pairing.token_hash);
995
1279
  if (consumed.changes !== 1) {
@@ -1017,11 +1301,11 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1017
1301
  })(),
1018
1302
  catch: operationError("RelayStore.exchangePairing")
1019
1303
  });
1020
- const credentials = yield* Effect9.forEach(created, ({ token: sessionToken, row }) => authSessionFromRow(row).pipe(Effect9.map((session) => ({ token: sessionToken, session }))));
1304
+ const credentials = yield* Effect11.forEach(created, ({ token: sessionToken, row }) => authSessionFromRow(row).pipe(Effect11.map((session) => ({ token: sessionToken, session }))));
1021
1305
  return PairingResult.make({ credentials });
1022
1306
  });
1023
- const authenticateSession = Effect9.fn("RelayStore.authenticateSession")(function* (token, role) {
1024
- const row = yield* Effect9.try({
1307
+ const authenticateSession = Effect11.fn("RelayStore.authenticateSession")(function* (token, role) {
1308
+ const row = yield* Effect11.try({
1025
1309
  try: () => db.query(`SELECT * FROM auth_sessions
1026
1310
  WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ?`).get(tokenHash(token), now()),
1027
1311
  catch: operationError("RelayStore.authenticateSession")
@@ -1030,35 +1314,35 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1030
1314
  return;
1031
1315
  }
1032
1316
  const lastSeenAt = now();
1033
- yield* Effect9.try({
1317
+ yield* Effect11.try({
1034
1318
  try: () => db.query("UPDATE auth_sessions SET last_seen_at = ? WHERE id = ?").run(lastSeenAt, row.id),
1035
1319
  catch: operationError("RelayStore.authenticateSession.touch")
1036
1320
  });
1037
1321
  const session = yield* authSessionFromRow(row);
1038
1322
  return AuthSession.make({ ...session, lastSeenAt });
1039
1323
  });
1040
- const listAuthSessions = Effect9.fn("RelayStore.listAuthSessions")(function* () {
1041
- const rows = yield* Effect9.try({
1324
+ const listAuthSessions = Effect11.fn("RelayStore.listAuthSessions")(function* () {
1325
+ const rows = yield* Effect11.try({
1042
1326
  try: () => db.query("SELECT * FROM auth_sessions ORDER BY created_at DESC, id LIMIT 1000").all(),
1043
1327
  catch: operationError("RelayStore.listAuthSessions")
1044
1328
  });
1045
- return yield* Effect9.forEach(rows, authSessionFromRow);
1329
+ return yield* Effect11.forEach(rows, authSessionFromRow);
1046
1330
  });
1047
- const revokeAuthSession = Effect9.fn("RelayStore.revokeAuthSession")(function* (sessionId) {
1331
+ const revokeAuthSession = Effect11.fn("RelayStore.revokeAuthSession")(function* (sessionId) {
1048
1332
  const revokedAt = now();
1049
- const result = yield* Effect9.try({
1333
+ const result = yield* Effect11.try({
1050
1334
  try: () => db.query("UPDATE auth_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(revokedAt, sessionId),
1051
1335
  catch: operationError("RelayStore.revokeAuthSession")
1052
1336
  });
1053
1337
  if (result.changes !== 1) {
1054
- return yield* Effect9.fail(new PersistenceError({
1338
+ return yield* Effect11.fail(new PersistenceError({
1055
1339
  operation: "RelayStore.revokeAuthSession",
1056
1340
  message: `Unknown or already revoked session ${sessionId}`
1057
1341
  }));
1058
1342
  }
1059
1343
  const row = db.query("SELECT * FROM auth_sessions WHERE id = ?").get(sessionId);
1060
1344
  if (row === null) {
1061
- return yield* Effect9.fail(new PersistenceError({
1345
+ return yield* Effect11.fail(new PersistenceError({
1062
1346
  operation: "RelayStore.revokeAuthSession.read",
1063
1347
  message: `Session ${sessionId} disappeared after revocation`
1064
1348
  }));
@@ -1068,6 +1352,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1068
1352
  return Service2.of({
1069
1353
  recover,
1070
1354
  listDevices,
1355
+ usage,
1071
1356
  forgetDevice,
1072
1357
  upsertDevice,
1073
1358
  heartbeat: (deviceId, status) => updateDeviceStatus(deviceId, status),
@@ -1079,12 +1364,22 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1079
1364
  pendingTasksFor,
1080
1365
  assignTask,
1081
1366
  rollbackAssignment: (taskId) => transition(taskId, ["assigned"], { status: "queued" }, { kind: "requeued", detail: "Target device was unavailable during dispatch" }),
1082
- acceptTask: (taskId, deviceId) => requireTarget(taskId, deviceId).pipe(Effect9.flatMap(() => transition(taskId, ["assigned"], { status: "running", startedAt: now() }, { kind: "running", detail: "Target device accepted the task and started the provider" }))),
1367
+ acceptTask: (taskId, deviceId) => requireTarget(taskId, deviceId).pipe(Effect11.flatMap(() => transition(taskId, ["assigned"], { status: "running", startedAt: now() }, { kind: "running", detail: "Target device accepted the task and started the provider" }))),
1083
1368
  finishTask: (taskId, deviceId, result, providerSessionId) => terminal(taskId, deviceId, "completed", result, undefined, providerSessionId),
1084
1369
  failTask: (taskId, deviceId, error) => terminal(taskId, deviceId, "failed", undefined, error),
1085
1370
  acknowledgeCancellation: (taskId, deviceId) => terminal(taskId, deviceId, "cancelled"),
1086
1371
  requestCancellation,
1087
1372
  requeueTasksFor,
1373
+ createUpgradeOperations,
1374
+ listOperations,
1375
+ abandonOperation,
1376
+ pendingOperationsFor,
1377
+ assignOperation,
1378
+ rollbackOperation: (operationId) => transitionOperation(operationId, ["assigned"], "queued"),
1379
+ acceptOperation,
1380
+ finishOperation,
1381
+ failOperation,
1382
+ requeueOperationsFor,
1088
1383
  sessionFor,
1089
1384
  createPairing,
1090
1385
  exchangePairing: exchangePairing2,
@@ -1092,7 +1387,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1092
1387
  listAuthSessions,
1093
1388
  revokeAuthSession
1094
1389
  });
1095
- }, migrate = (db) => Effect9.try({
1390
+ }, migrate = (db) => Effect11.try({
1096
1391
  try: () => {
1097
1392
  db.exec("PRAGMA journal_mode = WAL");
1098
1393
  db.exec("PRAGMA foreign_keys = ON");
@@ -1140,6 +1435,15 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1140
1435
  provider TEXT NOT NULL, session_id TEXT NOT NULL, updated_at TEXT NOT NULL,
1141
1436
  PRIMARY KEY(thread_id, device_id, provider)
1142
1437
  );
1438
+ CREATE TABLE IF NOT EXISTS device_operations (
1439
+ id TEXT PRIMARY KEY, kind TEXT NOT NULL, status TEXT NOT NULL,
1440
+ target_device_id TEXT NOT NULL REFERENCES devices(id),
1441
+ requested_version TEXT NOT NULL, restart INTEGER NOT NULL,
1442
+ result TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
1443
+ completed_at TEXT
1444
+ );
1445
+ CREATE INDEX IF NOT EXISTS device_operations_target_status
1446
+ ON device_operations(target_device_id, status);
1143
1447
  CREATE TABLE IF NOT EXISTS auth_pairings (
1144
1448
  token_hash TEXT PRIMARY KEY, label TEXT NOT NULL, roles_json TEXT NOT NULL,
1145
1449
  expires_at TEXT NOT NULL, created_at TEXT NOT NULL, used_at TEXT
@@ -1157,15 +1461,15 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1157
1461
  }
1158
1462
  },
1159
1463
  catch: operationError("RelayStore.migrate")
1160
- }), layer2 = (path, retainedTerminalTasks = 1000) => Layer2.effect(Service2, Effect9.acquireRelease(Effect9.try({
1464
+ }), layer2 = (path, retainedTerminalTasks = 1000) => Layer2.effect(Service2, Effect11.acquireRelease(Effect11.try({
1161
1465
  try: () => new Database(path),
1162
1466
  catch: operationError("RelayStore.open")
1163
- }).pipe(Effect9.tap(migrate)), (db) => Effect9.sync(() => db.close())).pipe(Effect9.map((db) => makeService(db, retainedTerminalTasks)))), layerFromDatabase = (db) => Layer2.effect(Service2, migrate(db).pipe(Effect9.map(() => makeService(db))));
1467
+ }).pipe(Effect11.tap(migrate)), (db) => Effect11.sync(() => db.close())).pipe(Effect11.map((db) => makeService(db, retainedTerminalTasks)))), layerFromDatabase = (db) => Layer2.effect(Service2, migrate(db).pipe(Effect11.map(() => makeService(db))));
1164
1468
  var init_store = __esm(() => {
1165
1469
  init_src();
1166
1470
  init_database();
1167
1471
  init_store();
1168
- PersistenceError = class PersistenceError extends Schema10.TaggedErrorClass()("RelayStore.PersistenceError", { operation: Schema10.String, message: Schema10.String }) {
1472
+ PersistenceError = class PersistenceError extends Schema11.TaggedErrorClass()("RelayStore.PersistenceError", { operation: Schema11.String, message: Schema11.String }) {
1169
1473
  };
1170
1474
  Service2 = class Service2 extends Context2.Service()("@cohall/RelayStore") {
1171
1475
  };
@@ -1177,9 +1481,9 @@ __export(exports_main, {
1177
1481
  runRelay: () => runRelay,
1178
1482
  relayListenOptions: () => relayListenOptions
1179
1483
  });
1180
- import { Effect as Effect10, ManagedRuntime, Schema as Schema11 } from "effect";
1181
- import { createHash as createHash2, timingSafeEqual } from "node:crypto";
1182
- import { access as access5, chmod as chmod5 } from "node:fs/promises";
1484
+ import { Effect as Effect12, ManagedRuntime, Schema as Schema12 } from "effect";
1485
+ import { createHash as createHash3, timingSafeEqual } from "node:crypto";
1486
+ import { access as access6, chmod as chmod7 } from "node:fs/promises";
1183
1487
  import { createServer } from "node:http";
1184
1488
  import { platform as platform5 } from "node:os";
1185
1489
  import { WebSocketServer } from "ws";
@@ -1208,7 +1512,7 @@ var RequestError, relayListenOptions = (configuration, environment = process.env
1208
1512
  if (request.method === "GET" || request.method === "HEAD") {
1209
1513
  return Promise.resolve(undefined);
1210
1514
  }
1211
- return new Promise((resolve3, reject) => {
1515
+ return new Promise((resolve4, reject) => {
1212
1516
  const chunks = [];
1213
1517
  let size = 0;
1214
1518
  const cleanup = () => {
@@ -1227,7 +1531,7 @@ var RequestError, relayListenOptions = (configuration, environment = process.env
1227
1531
  };
1228
1532
  const onEnd = () => {
1229
1533
  cleanup();
1230
- resolve3(Buffer.concat(chunks, size));
1534
+ resolve4(Buffer.concat(chunks, size));
1231
1535
  };
1232
1536
  const onError = (cause) => {
1233
1537
  cleanup();
@@ -1276,17 +1580,17 @@ Content-Length: 0\r
1276
1580
  if ("destroy" in socket && typeof socket.destroy === "function") {
1277
1581
  socket.destroy();
1278
1582
  }
1279
- }, body = (request, decode2) => Effect10.tryPromise({
1583
+ }, body = (request, decode2) => Effect12.tryPromise({
1280
1584
  try: () => request.json(),
1281
1585
  catch: () => new RequestError({ status: 400, message: "Expected a JSON request body" })
1282
- }).pipe(Effect10.flatMap(decode2), Effect10.mapError((cause) => cause instanceof RequestError ? cause : new RequestError({ status: 400, message: String(cause).slice(0, 2048) }))), pathId = (schema, value) => Schema11.decodeUnknownEffect(schema)(value).pipe(Effect10.mapError(() => new RequestError({ status: 400, message: `Invalid identifier: ${value}` }))), scoreDevice = (device, input, sourceDeviceId) => {
1586
+ }).pipe(Effect12.flatMap(decode2), Effect12.mapError((cause) => cause instanceof RequestError ? cause : new RequestError({ status: 400, message: String(cause).slice(0, 2048) }))), pathId = (schema, value) => Schema12.decodeUnknownEffect(schema)(value).pipe(Effect12.mapError(() => new RequestError({ status: 400, message: `Invalid identifier: ${value}` }))), scoreDevice = (device, input, sourceDeviceId) => {
1283
1587
  const prompt = `${input.prompt} ${input.context ?? ""}`.toLowerCase();
1284
1588
  const needsXcode = /\b(xcode|ios|macos|swiftui|app store)\b/.test(prompt);
1285
1589
  const needsBrowser = /\b(browser|chrome|youtube|twitter|logged[- ]?in|signed[- ]?in|x\.com)\b/.test(prompt);
1286
1590
  return (device.status === "offline" ? 0 : 100) + (input.workspace !== undefined && device.workspaces.some((workspace) => input.workspace === workspace.path || input.workspace?.startsWith(`${workspace.path}/`)) ? 80 : 0) + (needsXcode && device.capabilities.some((capability) => capability.id === "xcode") ? 60 : 0) + (needsBrowser && device.capabilities.some((capability) => capability.id === "browser-session") ? 40 : 0) + (sourceDeviceId === device.id ? -5 : 0);
1287
- }, chooseDevice = (input, sourceDeviceId) => Effect10.gen(function* () {
1591
+ }, chooseDevice = (input, sourceDeviceId) => Effect12.gen(function* () {
1288
1592
  const store = yield* exports_store.Service;
1289
- const devices = yield* store.listDevices().pipe(Effect10.mapError((cause) => new RequestError({ status: 500, message: cause.message })));
1593
+ const devices = yield* store.listDevices().pipe(Effect12.mapError((cause) => new RequestError({ status: 500, message: cause.message })));
1290
1594
  const provider = input.provider ?? "codex";
1291
1595
  if (input.targetDeviceId !== undefined) {
1292
1596
  const target = devices.find((device) => device.id === input.targetDeviceId);
@@ -1313,26 +1617,26 @@ Content-Length: 0\r
1313
1617
  }
1314
1618
  return selected.id;
1315
1619
  }), runRelay = async () => {
1316
- const configuration = await Effect10.runPromise(loadEnvironmentConfiguration);
1620
+ const configuration = await Effect12.runPromise(loadEnvironmentConfiguration);
1317
1621
  process.umask(63);
1318
1622
  const runtime = ManagedRuntime.make(exports_store.layer(configuration.databasePath, configuration.historyTaskLimit));
1319
1623
  const run2 = (effect) => runtime.runPromise(effect);
1320
- await run2(Effect10.gen(function* () {
1624
+ await run2(Effect12.gen(function* () {
1321
1625
  const store = yield* exports_store.Service;
1322
1626
  yield* store.recover();
1323
1627
  }));
1324
- if (platform5() !== "win32" && await access5(configuration.databasePath).then(() => true).catch(() => false)) {
1325
- await chmod5(configuration.databasePath, 384);
1628
+ if (platform5() !== "win32" && await access6(configuration.databasePath).then(() => true).catch(() => false)) {
1629
+ await chmod7(configuration.databasePath, 384);
1326
1630
  }
1327
1631
  const hub = new Hub;
1328
1632
  let connections = 0;
1329
- const tokenDigest = (token) => createHash2("sha256").update(token).digest();
1330
- const ownerToken = (token) => timingSafeEqual(tokenDigest(token), tokenDigest(configuration.token));
1633
+ const tokenDigest = (token) => createHash3("sha256").update(token).digest();
1634
+ const ownerToken2 = (token) => timingSafeEqual(tokenDigest(token), tokenDigest(configuration.token));
1331
1635
  const authenticate = async (token, role) => {
1332
- if (ownerToken(token)) {
1636
+ if (ownerToken2(token)) {
1333
1637
  return "owner";
1334
1638
  }
1335
- return run2(Effect10.gen(function* () {
1639
+ return run2(Effect12.gen(function* () {
1336
1640
  const store = yield* exports_store.Service;
1337
1641
  return yield* store.authenticateSession(token, role);
1338
1642
  })).catch(() => {
@@ -1347,7 +1651,7 @@ Content-Length: 0\r
1347
1651
  return authenticate(authorization.slice("Bearer ".length), "client");
1348
1652
  };
1349
1653
  const dispatch = async (task) => {
1350
- const assigned = await run2(Effect10.gen(function* () {
1654
+ const assigned = await run2(Effect12.gen(function* () {
1351
1655
  const store = yield* exports_store.Service;
1352
1656
  return yield* store.assignTask(task.id);
1353
1657
  }));
@@ -1357,13 +1661,29 @@ Content-Length: 0\r
1357
1661
  if (hub.sendToDevice(assigned.targetDeviceId, SocketEvent.make({ _tag: "TaskAssigned", task: assigned }))) {
1358
1662
  return assigned;
1359
1663
  }
1360
- return run2(Effect10.gen(function* () {
1664
+ return run2(Effect12.gen(function* () {
1361
1665
  const store = yield* exports_store.Service;
1362
1666
  return yield* store.rollbackAssignment(task.id);
1363
1667
  }));
1364
1668
  };
1669
+ const dispatchOperation = async (operation) => {
1670
+ const assigned = await run2(Effect12.gen(function* () {
1671
+ const store = yield* exports_store.Service;
1672
+ return yield* store.assignOperation(operation.id);
1673
+ }));
1674
+ if (assigned.status !== "assigned") {
1675
+ return assigned;
1676
+ }
1677
+ if (hub.sendToDevice(assigned.targetDeviceId, SocketEvent.make({ _tag: "OperationAssigned", operation: assigned }))) {
1678
+ return assigned;
1679
+ }
1680
+ return run2(Effect12.gen(function* () {
1681
+ const store = yield* exports_store.Service;
1682
+ return yield* store.rollbackOperation(operation.id);
1683
+ }));
1684
+ };
1365
1685
  const dispatchPending = async (deviceId) => {
1366
- const tasks = await run2(Effect10.gen(function* () {
1686
+ const tasks = await run2(Effect12.gen(function* () {
1367
1687
  const store = yield* exports_store.Service;
1368
1688
  return yield* store.pendingTasksFor(deviceId);
1369
1689
  }));
@@ -1374,6 +1694,13 @@ Content-Length: 0\r
1374
1694
  await dispatch(task);
1375
1695
  }
1376
1696
  }
1697
+ const operations = await run2(Effect12.gen(function* () {
1698
+ const store = yield* exports_store.Service;
1699
+ return yield* store.pendingOperationsFor(deviceId);
1700
+ }));
1701
+ for (const operation of operations) {
1702
+ await dispatchOperation(operation);
1703
+ }
1377
1704
  };
1378
1705
  const sendError = (socket, code, message) => {
1379
1706
  socket.send(JSON.stringify(SocketEvent.make({
@@ -1383,10 +1710,10 @@ Content-Length: 0\r
1383
1710
  })));
1384
1711
  };
1385
1712
  const handleSocketEvent = async (socket, input) => {
1386
- const event = await Effect10.runPromise(Effect10.try({
1713
+ const event = await Effect12.runPromise(Effect12.try({
1387
1714
  try: () => JSON.parse(socketText(input)),
1388
1715
  catch: () => new RequestError({ status: 400, message: "Invalid socket JSON" })
1389
- }).pipe(Effect10.flatMap(decodeSocketEvent))).catch((cause) => {
1716
+ }).pipe(Effect12.flatMap(decodeSocketEvent))).catch((cause) => {
1390
1717
  sendError(socket, "invalid_event", cause instanceof Error ? cause.message : String(cause));
1391
1718
  return;
1392
1719
  });
@@ -1443,7 +1770,7 @@ Content-Length: 0\r
1443
1770
  socket.close(4009, "Device is already connected");
1444
1771
  return;
1445
1772
  }
1446
- await run2(Effect10.gen(function* () {
1773
+ await run2(Effect12.gen(function* () {
1447
1774
  const store = yield* exports_store.Service;
1448
1775
  yield* store.upsertDevice(device);
1449
1776
  }));
@@ -1458,7 +1785,10 @@ Content-Length: 0\r
1458
1785
  const settle = (taskId) => {
1459
1786
  socket.send(JSON.stringify(SocketEvent.make({ _tag: "TaskSettled", taskId })));
1460
1787
  };
1461
- const processed = await run2(Effect10.gen(function* () {
1788
+ const settleOperation = (operationId) => {
1789
+ socket.send(JSON.stringify(SocketEvent.make({ _tag: "OperationSettled", operationId })));
1790
+ };
1791
+ const processed = await run2(Effect12.gen(function* () {
1462
1792
  const store = yield* exports_store.Service;
1463
1793
  switch (event._tag) {
1464
1794
  case "DeviceHeartbeat":
@@ -1478,10 +1808,21 @@ Content-Length: 0\r
1478
1808
  case "TaskCancelled":
1479
1809
  yield* store.acknowledgeCancellation(event.taskId, deviceId);
1480
1810
  return;
1811
+ case "OperationAccepted":
1812
+ yield* store.acceptOperation(event.operationId, deviceId);
1813
+ return;
1814
+ case "OperationFinished":
1815
+ yield* store.finishOperation(event.operationId, deviceId, event.result);
1816
+ return;
1817
+ case "OperationFailed":
1818
+ yield* store.failOperation(event.operationId, deviceId, event.error);
1819
+ return;
1481
1820
  case "Connected":
1482
1821
  case "TaskAssigned":
1483
1822
  case "TaskSettled":
1484
1823
  case "CancelTask":
1824
+ case "OperationAssigned":
1825
+ case "OperationSettled":
1485
1826
  case "Error":
1486
1827
  return;
1487
1828
  }
@@ -1493,13 +1834,17 @@ Content-Length: 0\r
1493
1834
  settle(event.taskId);
1494
1835
  await dispatchPending(deviceId);
1495
1836
  }
1837
+ if (processed && (event._tag === "OperationFinished" || event._tag === "OperationFailed")) {
1838
+ settleOperation(event.operationId);
1839
+ await dispatchPending(deviceId);
1840
+ }
1496
1841
  };
1497
1842
  const api = async (request, url) => {
1498
1843
  if (url.pathname === "/api/health" && request.method === "GET") {
1499
1844
  return json({ ok: true, version });
1500
1845
  }
1501
1846
  if (url.pathname === "/api/auth/pair" && request.method === "POST") {
1502
- return run2(Effect10.gen(function* () {
1847
+ return run2(Effect12.gen(function* () {
1503
1848
  const store = yield* exports_store.Service;
1504
1849
  const input = yield* body(request, decodeExchangePairingInput);
1505
1850
  return yield* store.exchangePairing(input.token);
@@ -1509,12 +1854,12 @@ Content-Length: 0\r
1509
1854
  if (principal === undefined) {
1510
1855
  return json({ error: "Unauthorized" }, 401);
1511
1856
  }
1512
- const ownerOnly = url.pathname.startsWith("/api/auth/");
1857
+ const ownerOnly = url.pathname.startsWith("/api/auth/") || url.pathname.startsWith("/api/operations");
1513
1858
  if (ownerOnly && principal !== "owner") {
1514
1859
  return json({ error: "Owner token required" }, 403);
1515
1860
  }
1516
1861
  const sourceDeviceId = principal === "owner" ? undefined : principal.deviceId;
1517
- const effect = Effect10.gen(function* () {
1862
+ const effect = Effect12.gen(function* () {
1518
1863
  const store = yield* exports_store.Service;
1519
1864
  if (url.pathname === "/api/auth/pairings" && request.method === "POST") {
1520
1865
  const input = yield* body(request, decodeCreatePairingInput);
@@ -1533,6 +1878,23 @@ Content-Length: 0\r
1533
1878
  if (url.pathname === "/api/devices" && request.method === "GET") {
1534
1879
  return json(yield* store.listDevices());
1535
1880
  }
1881
+ if (url.pathname === "/api/usage" && request.method === "GET") {
1882
+ return json(yield* store.usage());
1883
+ }
1884
+ if (url.pathname === "/api/operations" && request.method === "GET") {
1885
+ return json(yield* store.listOperations());
1886
+ }
1887
+ if (url.pathname === "/api/operations/upgrades" && request.method === "POST") {
1888
+ const input = yield* body(request, decodeCreateUpgradeOperationsInput);
1889
+ const operations = yield* store.createUpgradeOperations(input);
1890
+ return json(yield* Effect12.tryPromise(() => Promise.all(operations.map(dispatchOperation))), 201);
1891
+ }
1892
+ const abandonOperation = url.pathname.match(/^\/api\/operations\/([^/]+)\/abandon$/);
1893
+ if (request.method === "POST" && abandonOperation?.[1] !== undefined) {
1894
+ const operation = yield* store.abandonOperation(yield* pathId(OperationId, abandonOperation[1]));
1895
+ hub.sendToDevice(operation.targetDeviceId, SocketEvent.make({ _tag: "OperationSettled", operationId: operation.id }));
1896
+ return json(operation);
1897
+ }
1536
1898
  const forgetDevice = url.pathname.match(/^\/api\/devices\/([^/]+)\/forget$/);
1537
1899
  if (request.method === "POST" && forgetDevice?.[1] !== undefined) {
1538
1900
  if (principal !== "owner") {
@@ -1554,7 +1916,7 @@ Content-Length: 0\r
1554
1916
  const target = yield* chooseDevice(input, sourceDeviceId);
1555
1917
  const providerSessionId = input.threadId === undefined ? undefined : yield* store.sessionFor(input.threadId, target, input.provider ?? "codex");
1556
1918
  const task2 = yield* store.createDelegation(input, target, sourceDeviceId, providerSessionId);
1557
- return json(yield* Effect10.tryPromise(() => dispatch(task2)), 201);
1919
+ return json(yield* Effect12.tryPromise(() => dispatch(task2)), 201);
1558
1920
  }
1559
1921
  const task = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
1560
1922
  if (request.method === "GET" && task?.[1] !== undefined) {
@@ -1578,8 +1940,8 @@ Content-Length: 0\r
1578
1940
  return json(yield* store.threadContext(yield* pathId(ThreadId, thread[1])));
1579
1941
  }
1580
1942
  return yield* new RequestError({ status: 404, message: "Route not found" });
1581
- }).pipe(Effect10.mapError((cause) => cause instanceof RequestError ? cause : new RequestError({
1582
- status: cause._tag !== "RelayStore.PersistenceError" ? 500 : cause.message.startsWith("Unknown") ? 404 : cause.message.includes("outstanding task limit") ? 429 : cause.message.includes("must be offline") || cause.message.includes("still has outstanding tasks") ? 409 : 500,
1943
+ }).pipe(Effect12.mapError((cause) => cause instanceof RequestError ? cause : new RequestError({
1944
+ status: cause._tag !== "RelayStore.PersistenceError" ? 500 : cause.message.startsWith("Unknown") ? 404 : 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,
1583
1945
  message: cause.message
1584
1946
  })));
1585
1947
  return run2(effect).catch((cause) => cause instanceof RequestError ? json({ error: cause.status === 500 ? "Relay request failed" : cause.message }, cause.status) : json({ error: "Relay request failed" }, 500));
@@ -1652,9 +2014,10 @@ Content-Length: 0\r
1652
2014
  if (deviceId === undefined) {
1653
2015
  return;
1654
2016
  }
1655
- run2(Effect10.gen(function* () {
2017
+ run2(Effect12.gen(function* () {
1656
2018
  const store = yield* exports_store.Service;
1657
2019
  yield* store.requeueTasksFor(deviceId);
2020
+ yield* store.requeueOperationsFor(deviceId);
1658
2021
  yield* store.markDeviceOffline(deviceId);
1659
2022
  })).catch((cause) => console.error(cause));
1660
2023
  });
@@ -1679,11 +2042,11 @@ Content-Length: 0\r
1679
2042
  websocketServer.emit("connection", websocket, request);
1680
2043
  });
1681
2044
  });
1682
- await new Promise((resolve3, reject) => {
2045
+ await new Promise((resolve4, reject) => {
1683
2046
  server.once("error", reject);
1684
2047
  server.listen(relayListenOptions(configuration), () => {
1685
2048
  server.off("error", reject);
1686
- resolve3();
2049
+ resolve4();
1687
2050
  });
1688
2051
  });
1689
2052
  const address = server.address();
@@ -1698,7 +2061,7 @@ Content-Length: 0\r
1698
2061
  for (const socket of websocketServer.clients) {
1699
2062
  socket.close(1001, "Relay shutting down");
1700
2063
  }
1701
- await new Promise((resolve3) => server.close(() => resolve3()));
2064
+ await new Promise((resolve4) => server.close(() => resolve4()));
1702
2065
  websocketServer.close();
1703
2066
  await runtime.dispose();
1704
2067
  };
@@ -1710,9 +2073,9 @@ var init_main = __esm(async () => {
1710
2073
  init_config();
1711
2074
  init_hub();
1712
2075
  init_store();
1713
- RequestError = class RequestError extends Schema11.TaggedErrorClass()("Relay.RequestError", {
1714
- status: Schema11.Int,
1715
- message: Schema11.String
2076
+ RequestError = class RequestError extends Schema12.TaggedErrorClass()("Relay.RequestError", {
2077
+ status: Schema12.Int,
2078
+ message: Schema12.String
1716
2079
  }) {
1717
2080
  };
1718
2081
  if (false) {}
@@ -1720,7 +2083,7 @@ var init_main = __esm(async () => {
1720
2083
 
1721
2084
  // apps/device/src/main.ts
1722
2085
  init_src();
1723
- import { Effect as Effect11 } from "effect";
2086
+ import { Effect as Effect13 } from "effect";
1724
2087
 
1725
2088
  // packages/client/src/index.ts
1726
2089
  init_src();
@@ -1840,7 +2203,11 @@ var make = (options) => {
1840
2203
  body: JSON.stringify(input)
1841
2204
  }),
1842
2205
  authSessions: () => request("RelayClient.authSessions", "/api/auth/sessions", Schema2.Array(AuthSession)),
1843
- revokeAuthSession: (sessionId) => request("RelayClient.revokeAuthSession", `/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, AuthSession, { method: "POST" })
2206
+ revokeAuthSession: (sessionId) => request("RelayClient.revokeAuthSession", `/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, AuthSession, { method: "POST" }),
2207
+ usage: () => request("RelayClient.usage", "/api/usage", UsageSummary),
2208
+ createUpgradeOperations: (input) => request("RelayClient.createUpgradeOperations", "/api/operations/upgrades", Schema2.Array(DeviceOperation), { method: "POST", body: JSON.stringify(input) }),
2209
+ operations: () => request("RelayClient.operations", "/api/operations", Schema2.Array(DeviceOperation)),
2210
+ abandonOperation: (operationId) => request("RelayClient.abandonOperation", `/api/operations/${encodeURIComponent(operationId)}/abandon`, DeviceOperation, { method: "POST" })
1844
2211
  });
1845
2212
  };
1846
2213
  var exchangePairing = (baseUrl, input) => Effect.gen(function* () {
@@ -2326,10 +2693,10 @@ var run = (options) => Effect2.tryPromise({
2326
2693
  });
2327
2694
 
2328
2695
  // apps/device/src/cli.ts
2329
- import { Effect as Effect5, Schema as Schema7 } from "effect";
2330
- import { access as access3, mkdir as mkdir3, readFile as readFile3, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
2331
- import { homedir as homedir3 } from "node:os";
2332
- import { dirname as dirname3, join as join4 } from "node:path";
2696
+ import { Effect as Effect7, Schema as Schema8 } from "effect";
2697
+ import { access as access4, mkdir as mkdir5, readFile as readFile4, stat as stat3, writeFile as writeFile6 } from "node:fs/promises";
2698
+ import { homedir as homedir4 } from "node:os";
2699
+ import { dirname as dirname5, join as join6 } from "node:path";
2333
2700
 
2334
2701
  // skills/cohall/SKILL.md
2335
2702
  var SKILL_default = `---
@@ -2553,6 +2920,7 @@ var defaultRelayDataDirectory = () => {
2553
2920
  }
2554
2921
  return join2(process.env.XDG_DATA_HOME ?? join2(homedir2(), ".local", "share"), "cohall");
2555
2922
  };
2923
+ var relayDataDirectory = () => resolve(process.env.COHALL_DATA_DIR ?? defaultRelayDataDirectory());
2556
2924
  var configurationPath = () => resolve(process.env.COHALL_CONFIG ?? join2(defaultConfigDirectory(), "config.json"));
2557
2925
  var normalizeRelayUrl = (input) => {
2558
2926
  const url = new URL(input);
@@ -2684,7 +3052,7 @@ var loadClientConfiguration = Effect3.tryPromise({
2684
3052
  var loadOwnerConfiguration = Effect3.tryPromise({
2685
3053
  try: async () => {
2686
3054
  const stored = await storedOrDefaults();
2687
- const ownerTokenPath = join2(resolve(process.env.COHALL_DATA_DIR ?? defaultRelayDataDirectory()), "owner-token");
3055
+ const ownerTokenPath = join2(relayDataDirectory(), "owner-token");
2688
3056
  const token = process.env.COHALL_TOKEN ?? await readFile(ownerTokenPath, "utf8").then((value) => value.trim()).catch(() => {
2689
3057
  return;
2690
3058
  });
@@ -2825,6 +3193,176 @@ var threadContext = Effect4.fn("Cohall.threadContext")(function* (client, thread
2825
3193
  return yield* client.threadContext(threadId);
2826
3194
  });
2827
3195
 
3196
+ // apps/device/src/device-overview.ts
3197
+ var deviceVersions = (devices, localVersion) => ({
3198
+ local_version: localVersion,
3199
+ current: devices.filter((device) => device.version === localVersion).length,
3200
+ different: devices.filter((device) => device.version !== localVersion).length,
3201
+ devices: devices.map((device) => ({
3202
+ id: device.id,
3203
+ name: device.name,
3204
+ status: device.status,
3205
+ version: device.version,
3206
+ current: device.version === localVersion
3207
+ }))
3208
+ });
3209
+ var allDeviceHealth = (devices, localVersion) => {
3210
+ const summaries = devices.map((device) => {
3211
+ const warnings = [
3212
+ ...device.status === "offline" ? ["Device is offline"] : [],
3213
+ ...device.providers.length === 0 ? ["No provider executable is available"] : [],
3214
+ ...device.version === localVersion ? [] : [`Running Cohall ${device.version}; local CLI is ${localVersion}`]
3215
+ ];
3216
+ return {
3217
+ id: device.id,
3218
+ name: device.name,
3219
+ hostname: device.hostname,
3220
+ platform: device.platform,
3221
+ architecture: device.architecture,
3222
+ status: device.status,
3223
+ version: device.version,
3224
+ providers: device.providers,
3225
+ workspaces: device.workspaces.map((workspace) => workspace.path),
3226
+ last_seen_at: device.lastSeenAt,
3227
+ warnings
3228
+ };
3229
+ });
3230
+ return {
3231
+ healthy: summaries.length > 0 && summaries.every((device) => device.warnings.length === 0),
3232
+ total: devices.length,
3233
+ online: devices.filter((device) => device.status === "online").length,
3234
+ busy: devices.filter((device) => device.status === "busy").length,
3235
+ offline: devices.filter((device) => device.status === "offline").length,
3236
+ warnings: summaries.length === 0 ? ["No Cohall devices are registered"] : [],
3237
+ devices: summaries
3238
+ };
3239
+ };
3240
+
3241
+ // apps/device/src/setup.ts
3242
+ import { Effect as Effect5 } from "effect";
3243
+ import { hostname as hostname2 } from "node:os";
3244
+ import { Writable } from "node:stream";
3245
+ import { createInterface } from "node:readline/promises";
3246
+ var joinRelay = async (options) => {
3247
+ const draft = await makeStoredConfiguration({
3248
+ relayUrl: options.relayUrl,
3249
+ ...options.deviceName === undefined ? {} : { deviceName: options.deviceName },
3250
+ ...options.workspaces === undefined ? {} : { workspaces: options.workspaces },
3251
+ ...options.providers === undefined ? {} : { providers: options.providers }
3252
+ });
3253
+ if (!options.clientOnly && draft.workspaces.length === 0) {
3254
+ throw new Error("At least one workspace is required when joining a device");
3255
+ }
3256
+ const paired = await Effect5.runPromise(exchangePairing(draft.relayUrl, { token: options.token }));
3257
+ const clientToken = paired.credentials.find((credential) => credential.session.role === "client")?.token;
3258
+ const deviceCredential = paired.credentials.find((credential) => credential.session.role === "device");
3259
+ if (options.clientOnly && clientToken === undefined) {
3260
+ throw new Error("The pairing credential did not include client access");
3261
+ }
3262
+ if (!options.clientOnly && deviceCredential === undefined) {
3263
+ throw new Error("The pairing credential did not include device access");
3264
+ }
3265
+ const {
3266
+ clientToken: _retainedClientToken,
3267
+ deviceToken: _retainedDeviceToken,
3268
+ ...configurationDraft
3269
+ } = draft;
3270
+ const configuration = StoredConfiguration.make({
3271
+ ...configurationDraft,
3272
+ deviceId: options.clientOnly ? configurationDraft.deviceId : deviceCredential?.session.deviceId ?? configurationDraft.deviceId,
3273
+ ...clientToken === undefined ? {} : { clientToken },
3274
+ ...options.clientOnly || deviceCredential === undefined ? {} : { deviceToken: deviceCredential.token }
3275
+ });
3276
+ await writeStoredConfiguration(configuration);
3277
+ return {
3278
+ configuration,
3279
+ roles: options.clientOnly ? paired.credentials.map((credential) => credential.session.role).filter((role) => role === "client") : paired.credentials.map((credential) => credential.session.role)
3280
+ };
3281
+ };
3282
+ var terminalPrompter = () => {
3283
+ let muted = false;
3284
+ const output = new Writable({
3285
+ write(chunk, _encoding, callback) {
3286
+ if (!muted) {
3287
+ process.stderr.write(chunk);
3288
+ }
3289
+ callback();
3290
+ }
3291
+ });
3292
+ const terminal = createInterface({ input: process.stdin, output, terminal: true });
3293
+ return {
3294
+ answer: async (label, fallback) => {
3295
+ const value = (await terminal.question(`${label} [${fallback}]: `)).trim();
3296
+ return value || fallback;
3297
+ },
3298
+ secret: async (label) => {
3299
+ const pending = terminal.question(`${label}: `);
3300
+ muted = true;
3301
+ try {
3302
+ return (await pending).trim();
3303
+ } finally {
3304
+ muted = false;
3305
+ process.stderr.write(`
3306
+ `);
3307
+ }
3308
+ },
3309
+ close: () => terminal.close()
3310
+ };
3311
+ };
3312
+ var guidedSetupInput = async (options, prompter) => {
3313
+ const existing = await readStoredConfiguration();
3314
+ const relayUrl = normalizeRelayUrl(options.relayUrl ?? await prompter.answer("Relay URL", existing?.relayUrl ?? "http://127.0.0.1:8787"));
3315
+ const credentials = credentialsForRelay(existing, relayUrl);
3316
+ const hasRequiredCredentials = credentials.clientToken !== undefined && (options.clientOnly || credentials.deviceToken !== undefined);
3317
+ const workspaces = options.clientOnly ? existing?.workspaces ?? [] : options.workspaces.length > 0 ? options.workspaces : [await prompter.answer("Workspace root", existing?.workspaces[0] ?? options.cwd)];
3318
+ const deviceName = options.clientOnly ? undefined : options.deviceName ?? await prompter.answer("Device name", existing?.deviceName ?? hostname2());
3319
+ const providers = options.providers ?? (options.clientOnly ? "auto" : await prompter.answer("Providers", existing?.providers?.join(",") ?? "auto"));
3320
+ const token = options.token ?? (hasRequiredCredentials ? undefined : await prompter.secret("Pairing token"));
3321
+ if (!hasRequiredCredentials && (token === undefined || token.length === 0)) {
3322
+ throw new Error("A pairing token is required");
3323
+ }
3324
+ if (token !== undefined && new TextEncoder().encode(token).byteLength > 256) {
3325
+ throw new Error("Pairing token exceeds 256 bytes");
3326
+ }
3327
+ return {
3328
+ relayUrl,
3329
+ ...token === undefined ? {} : { token },
3330
+ ...deviceName === undefined ? {} : { deviceName },
3331
+ workspaces,
3332
+ providers,
3333
+ reusedConfiguration: hasRequiredCredentials && token === undefined
3334
+ };
3335
+ };
3336
+
3337
+ // apps/device/src/relay-migration.ts
3338
+ init_src();
3339
+ import { Effect as Effect6, Schema as Schema7 } from "effect";
3340
+ import { createHash } from "node:crypto";
3341
+ import { createReadStream } from "node:fs";
3342
+ import {
3343
+ access as access3,
3344
+ chmod as chmod5,
3345
+ copyFile,
3346
+ lstat,
3347
+ mkdir as mkdir4,
3348
+ mkdtemp as mkdtemp2,
3349
+ readFile as readFile3,
3350
+ realpath as realpath4,
3351
+ rename as rename3,
3352
+ rm as rm3,
3353
+ stat as stat2,
3354
+ writeFile as writeFile5
3355
+ } from "node:fs/promises";
3356
+ import { basename, dirname as dirname4, join as join5, resolve as resolve2 } from "node:path";
3357
+ import { backup as sqliteBackup, DatabaseSync } from "node:sqlite";
3358
+ import { WebSocket } from "ws";
3359
+
3360
+ // apps/device/src/service.ts
3361
+ import { execFile as execFile3 } from "node:child_process";
3362
+ import { chmod as chmod4, mkdir as mkdir3, realpath as realpath3, writeFile as writeFile4 } from "node:fs/promises";
3363
+ import { homedir as homedir3 } from "node:os";
3364
+ import { dirname as dirname3, join as join4 } from "node:path";
3365
+
2828
3366
  // apps/device/src/upgrade.ts
2829
3367
  import { Schema as Schema6 } from "effect";
2830
3368
  import { execFile as execFile2 } from "node:child_process";
@@ -3288,6 +3826,466 @@ var upgrade = async (options) => {
3288
3826
  };
3289
3827
  };
3290
3828
 
3829
+ // apps/device/src/service.ts
3830
+ var xml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
3831
+ var systemdArgument = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
3832
+ var deviceServicePlan = (options) => {
3833
+ if (options.platform === "linux") {
3834
+ const path = join4(options.home, ".config", "systemd", "user", "cohall-device.service");
3835
+ return {
3836
+ file: {
3837
+ path,
3838
+ mode: 384,
3839
+ content: `[Unit]
3840
+ Description=Cohall device agent
3841
+ After=network-online.target
3842
+ Wants=network-online.target
3843
+
3844
+ [Service]
3845
+ Type=simple
3846
+ Environment=PATH=%h/.local/bin:%h/.npm-global/bin:%h/.bun/bin:%h/.local/share/pnpm:/usr/local/bin:/usr/bin:/bin
3847
+ ExecStart=${systemdArgument(options.entrypoint)} device
3848
+ Restart=always
3849
+ RestartSec=3
3850
+ UMask=0077
3851
+ NoNewPrivileges=true
3852
+ PrivateTmp=true
3853
+
3854
+ [Install]
3855
+ WantedBy=default.target
3856
+ `
3857
+ },
3858
+ commands: [
3859
+ { command: "systemctl", arguments: ["--user", "daemon-reload"] },
3860
+ { command: "systemctl", arguments: ["--user", "enable", "--now", "cohall-device.service"] }
3861
+ ],
3862
+ note: "The device starts after login. Run loginctl enable-linger $USER if it must run before login."
3863
+ };
3864
+ }
3865
+ if (options.platform === "darwin") {
3866
+ const label = "com.cohall.device";
3867
+ const path = join4(options.home, "Library", "LaunchAgents", `${label}.plist`);
3868
+ const domain = `gui/${options.uid ?? 0}`;
3869
+ return {
3870
+ file: {
3871
+ path,
3872
+ mode: 384,
3873
+ content: `<?xml version="1.0" encoding="UTF-8"?>
3874
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3875
+ <plist version="1.0">
3876
+ <dict>
3877
+ <key>Label</key>
3878
+ <string>${label}</string>
3879
+ <key>ProgramArguments</key>
3880
+ <array>
3881
+ <string>${xml(options.entrypoint)}</string>
3882
+ <string>device</string>
3883
+ </array>
3884
+ <key>EnvironmentVariables</key>
3885
+ <dict>
3886
+ <key>PATH</key>
3887
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
3888
+ </dict>
3889
+ <key>RunAtLoad</key>
3890
+ <true/>
3891
+ <key>KeepAlive</key>
3892
+ <dict>
3893
+ <key>NetworkState</key>
3894
+ <true/>
3895
+ <key>SuccessfulExit</key>
3896
+ <false/>
3897
+ </dict>
3898
+ <key>ThrottleInterval</key>
3899
+ <integer>3</integer>
3900
+ <key>ProcessType</key>
3901
+ <string>Background</string>
3902
+ </dict>
3903
+ </plist>
3904
+ `
3905
+ },
3906
+ commands: [
3907
+ { command: "launchctl", arguments: ["bootout", domain, path] },
3908
+ { command: "launchctl", arguments: ["bootstrap", domain, path] },
3909
+ { command: "launchctl", arguments: ["kickstart", "-k", `${domain}/${label}`] }
3910
+ ]
3911
+ };
3912
+ }
3913
+ throw new Error("Automatic device service installation supports Linux and macOS");
3914
+ };
3915
+ var defaultRunner2 = {
3916
+ run: async (command, arguments_, timeoutMs = 60000) => {
3917
+ const executable = await trustedExecutable(command);
3918
+ return new Promise((resolve2) => {
3919
+ execFile3(executable, [...arguments_], { encoding: "utf8", maxBuffer: 1024 * 1024, timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => resolve2({
3920
+ exitCode: typeof error?.code === "number" ? error.code : error === null ? 0 : 1,
3921
+ stdout,
3922
+ stderr,
3923
+ ...error === null ? {} : { error: error.message }
3924
+ }));
3925
+ });
3926
+ }
3927
+ };
3928
+ var checked2 = async (runner, command, arguments_, allowFailure = false) => {
3929
+ const result = await runner.run(command, arguments_, 60000);
3930
+ if (result.exitCode === 0 || allowFailure) {
3931
+ return result;
3932
+ }
3933
+ throw new Error(`${command} failed: ${result.stderr.trim() || result.stdout.trim() || result.error || `status ${result.exitCode}`}`);
3934
+ };
3935
+ var installDeviceService = async (options = {}) => {
3936
+ const entrypoint = await realpath3(options.entrypoint ?? process.argv[1] ?? "");
3937
+ packageInstallation(entrypoint);
3938
+ const platform3 = options.platform ?? process.platform;
3939
+ const runner = options.runner ?? defaultRunner2;
3940
+ if (platform3 === "win32") {
3941
+ const script = join4(dirname3(dirname3(entrypoint)), "deploy", "windows", "install-device.ps1");
3942
+ await checked2(runner, "powershell.exe", [
3943
+ "-NoProfile",
3944
+ "-ExecutionPolicy",
3945
+ "Bypass",
3946
+ "-File",
3947
+ script
3948
+ ]);
3949
+ return { installed: "scheduled-task:Cohall Device" };
3950
+ }
3951
+ const uid = options.uid ?? process.getuid?.();
3952
+ const plan = deviceServicePlan({
3953
+ platform: platform3,
3954
+ entrypoint,
3955
+ home: options.home ?? homedir3(),
3956
+ ...uid === undefined ? {} : { uid }
3957
+ });
3958
+ await mkdir3(dirname3(plan.file.path), { recursive: true, mode: 448 });
3959
+ await writeFile4(plan.file.path, plan.file.content, { mode: plan.file.mode });
3960
+ await chmod4(plan.file.path, plan.file.mode);
3961
+ for (const [index, command] of plan.commands.entries()) {
3962
+ await checked2(runner, command.command, command.arguments, platform3 === "darwin" && index === 0);
3963
+ }
3964
+ return {
3965
+ installed: plan.file.path,
3966
+ ...plan.note === undefined ? {} : { note: plan.note }
3967
+ };
3968
+ };
3969
+ var restartDeviceService = async (options = {}) => {
3970
+ const platform3 = options.platform ?? process.platform;
3971
+ const runner = options.runner ?? defaultRunner2;
3972
+ if (platform3 === "linux") {
3973
+ const service = "cohall-device.service";
3974
+ const check = await runner.run("systemctl", ["--user", "is-active", "--quiet", service], 1e4);
3975
+ if (check.exitCode !== 0) {
3976
+ return { running: false, restarted: false };
3977
+ }
3978
+ await checked2(runner, "systemctl", ["--user", "restart", service]);
3979
+ return { running: true, restarted: true, service };
3980
+ }
3981
+ if (platform3 === "darwin") {
3982
+ const service = "com.cohall.device";
3983
+ const target = `gui/${options.uid ?? process.getuid?.() ?? 0}/${service}`;
3984
+ const check = await runner.run("launchctl", ["print", target], 1e4);
3985
+ if (check.exitCode !== 0) {
3986
+ return { running: false, restarted: false };
3987
+ }
3988
+ await checked2(runner, "launchctl", ["kickstart", "-k", target]);
3989
+ return { running: true, restarted: true, service };
3990
+ }
3991
+ if (platform3 === "win32") {
3992
+ const service = "Cohall Device";
3993
+ const check = await runner.run("schtasks.exe", ["/Query", "/TN", service], 1e4);
3994
+ if (check.exitCode !== 0) {
3995
+ return { running: false, restarted: false };
3996
+ }
3997
+ await checked2(runner, "schtasks.exe", ["/End", "/TN", service], true);
3998
+ await checked2(runner, "schtasks.exe", ["/Run", "/TN", service]);
3999
+ return { running: true, restarted: true, service };
4000
+ }
4001
+ return { running: false, restarted: false };
4002
+ };
4003
+
4004
+ // apps/device/src/relay-migration.ts
4005
+ var sha256 = Schema7.String.check(Schema7.isPattern(/^[a-f0-9]{64}$/, { expected: "a lowercase SHA-256 digest" }));
4006
+ var RelayBackupManifest = Schema7.Struct({
4007
+ formatVersion: Schema7.Literal(1),
4008
+ cohallVersion: Schema7.NonEmptyString,
4009
+ createdAt: Timestamp,
4010
+ databaseSha256: sha256,
4011
+ ownerTokenSha256: sha256
4012
+ });
4013
+ var exists = (path) => access3(path).then(() => true).catch(() => false);
4014
+ var digest = async (path) => {
4015
+ const hash = createHash("sha256");
4016
+ for await (const chunk of createReadStream(path)) {
4017
+ hash.update(chunk);
4018
+ }
4019
+ return hash.digest("hex");
4020
+ };
4021
+ var verifyBackupMember = async (path, maximumBytes) => {
4022
+ const metadata = await lstat(path);
4023
+ if (!metadata.isFile()) {
4024
+ throw new Error(`Relay backup member must be a regular file: ${path}`);
4025
+ }
4026
+ if (maximumBytes !== undefined && metadata.size > maximumBytes) {
4027
+ throw new Error(`Relay backup member exceeds ${maximumBytes} bytes: ${path}`);
4028
+ }
4029
+ };
4030
+ var readManifest = async (path) => {
4031
+ await verifyBackupMember(path, 16384);
4032
+ return Schema7.decodeUnknownSync(RelayBackupManifest)(JSON.parse(await readFile3(path, "utf8")));
4033
+ };
4034
+ var verifyBackupDestinationParent = async (path) => {
4035
+ if (process.platform === "win32") {
4036
+ return;
4037
+ }
4038
+ for (let current = await realpath4(path);; current = dirname4(current)) {
4039
+ const metadata = await stat2(current);
4040
+ const writableByOthers = (metadata.mode & 18) !== 0;
4041
+ const sticky = (metadata.mode & 512) !== 0;
4042
+ if (writableByOthers && !sticky) {
4043
+ throw new Error(`Backup destination ancestors must not be group- or world-writable unless they use the sticky bit: ${current}`);
4044
+ }
4045
+ const parent = dirname4(current);
4046
+ if (parent === current) {
4047
+ return;
4048
+ }
4049
+ }
4050
+ };
4051
+ var ownerToken = async (dataDirectory) => {
4052
+ const path = join5(dataDirectory, "owner-token");
4053
+ const configured = process.env.COHALL_TOKEN;
4054
+ if (configured !== undefined) {
4055
+ return configured;
4056
+ }
4057
+ if (!await exists(path)) {
4058
+ throw new Error(`Relay owner token was not found at ${path}`);
4059
+ }
4060
+ if ((await stat2(path)).size > 512) {
4061
+ throw new Error("Relay owner token file exceeds 512 bytes");
4062
+ }
4063
+ return (await readFile3(path, "utf8")).trim();
4064
+ };
4065
+ var verifyDatabase = (path) => {
4066
+ const database = new DatabaseSync(path, { readOnly: true });
4067
+ try {
4068
+ const result = database.prepare("PRAGMA quick_check").get();
4069
+ if (result === undefined || Object.values(result)[0] !== "ok") {
4070
+ throw new Error(`SQLite integrity check failed for ${path}`);
4071
+ }
4072
+ } finally {
4073
+ database.close();
4074
+ }
4075
+ };
4076
+ var backupRelay = async (destinationInput) => {
4077
+ const sourceDirectory = relayDataDirectory();
4078
+ const sourceDatabase = join5(sourceDirectory, "cohall.db");
4079
+ if (!await exists(sourceDatabase)) {
4080
+ throw new Error(`Relay database was not found at ${sourceDatabase}`);
4081
+ }
4082
+ const destination = resolve2(destinationInput);
4083
+ if (await exists(destination)) {
4084
+ throw new Error(`Backup destination already exists: ${destination}`);
4085
+ }
4086
+ await verifyBackupDestinationParent(dirname4(destination));
4087
+ await mkdir4(destination, { mode: 448 });
4088
+ try {
4089
+ const databasePath = join5(destination, "cohall.db");
4090
+ const ownerTokenPath = join5(destination, "owner-token");
4091
+ const database = new DatabaseSync(sourceDatabase, { readOnly: true });
4092
+ try {
4093
+ await sqliteBackup(database, databasePath);
4094
+ } finally {
4095
+ database.close();
4096
+ }
4097
+ verifyDatabase(databasePath);
4098
+ const token = await ownerToken(sourceDirectory);
4099
+ if (token.length < 32 || new TextEncoder().encode(token).byteLength > 512) {
4100
+ throw new Error("The relay owner token must contain between 32 and 512 bytes");
4101
+ }
4102
+ await writeFile5(ownerTokenPath, `${token}
4103
+ `, { mode: 384 });
4104
+ await Promise.all([chmod5(databasePath, 384), chmod5(ownerTokenPath, 384)]);
4105
+ const createdAt = Timestamp.make(new Date().toISOString());
4106
+ const manifest = RelayBackupManifest.make({
4107
+ formatVersion: 1,
4108
+ cohallVersion: version,
4109
+ createdAt,
4110
+ databaseSha256: await digest(databasePath),
4111
+ ownerTokenSha256: await digest(ownerTokenPath)
4112
+ });
4113
+ await writeFile5(join5(destination, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
4114
+ `, {
4115
+ mode: 384
4116
+ });
4117
+ return {
4118
+ backup_directory: destination,
4119
+ database: databasePath,
4120
+ created_at: createdAt,
4121
+ warning: "This backup contains relay credentials and task history. Transfer and store it privately."
4122
+ };
4123
+ } catch (cause) {
4124
+ await rm3(destination, { recursive: true, force: true });
4125
+ throw cause;
4126
+ }
4127
+ };
4128
+ var restoreRelay = async (sourceInput) => {
4129
+ const source = resolve2(sourceInput);
4130
+ const sourceDatabase = join5(source, "cohall.db");
4131
+ const sourceOwnerToken = join5(source, "owner-token");
4132
+ const sourceManifest = join5(source, "manifest.json");
4133
+ for (const path of [sourceDatabase, sourceOwnerToken, sourceManifest]) {
4134
+ if (!await exists(path)) {
4135
+ throw new Error(`Relay backup is missing ${basename(path)}`);
4136
+ }
4137
+ }
4138
+ await Promise.all([
4139
+ verifyBackupMember(sourceDatabase),
4140
+ verifyBackupMember(sourceOwnerToken, 1024),
4141
+ verifyBackupMember(sourceManifest, 16384)
4142
+ ]);
4143
+ const target = relayDataDirectory();
4144
+ if (await exists(target)) {
4145
+ throw new Error(`Relay data directory already exists: ${target}. Restore into a new COHALL_DATA_DIR.`);
4146
+ }
4147
+ const parent = dirname4(target);
4148
+ await mkdir4(parent, { recursive: true, mode: 448 });
4149
+ const staging = await mkdtemp2(join5(parent, `.${basename(target)}.restore-`));
4150
+ let backupVersion = "";
4151
+ try {
4152
+ await chmod5(staging, 448);
4153
+ const databasePath = join5(staging, "cohall.db");
4154
+ const ownerTokenPath = join5(staging, "owner-token");
4155
+ const manifestPath = join5(staging, "migration-manifest.json");
4156
+ await Promise.all([
4157
+ copyFile(sourceDatabase, databasePath),
4158
+ copyFile(sourceOwnerToken, ownerTokenPath),
4159
+ copyFile(sourceManifest, manifestPath)
4160
+ ]);
4161
+ await Promise.all([
4162
+ chmod5(databasePath, 384),
4163
+ chmod5(ownerTokenPath, 384),
4164
+ chmod5(manifestPath, 384)
4165
+ ]);
4166
+ const manifest = await readManifest(manifestPath);
4167
+ if (await digest(databasePath) !== manifest.databaseSha256 || await digest(ownerTokenPath) !== manifest.ownerTokenSha256) {
4168
+ throw new Error("Relay backup checksum verification failed");
4169
+ }
4170
+ verifyDatabase(databasePath);
4171
+ const token = (await readFile3(ownerTokenPath, "utf8")).trim();
4172
+ if (token.length < 32 || new TextEncoder().encode(token).byteLength > 512) {
4173
+ throw new Error("Relay backup contains an invalid owner token");
4174
+ }
4175
+ backupVersion = manifest.cohallVersion;
4176
+ await rename3(staging, target);
4177
+ } catch (cause) {
4178
+ await rm3(staging, { recursive: true, force: true });
4179
+ throw cause;
4180
+ }
4181
+ return {
4182
+ restored: true,
4183
+ data_directory: target,
4184
+ backup_version: backupVersion,
4185
+ next: "Start the relay at its new address, then run `cohall relay use <new-url>` on each client and device."
4186
+ };
4187
+ };
4188
+ var websocketUrl = (relayUrl) => {
4189
+ const url = new URL(relayUrl);
4190
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
4191
+ url.pathname = "/ws/device";
4192
+ return url.toString();
4193
+ };
4194
+ var isLoopback = (url) => url.hostname === "localhost" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
4195
+ var verifyClientCredential = async (relayUrl, token) => {
4196
+ await Effect6.runPromise(exports_src.make({ baseUrl: relayUrl, token }).devices());
4197
+ };
4198
+ var verifyDeviceCredential = (relayUrl, token) => new Promise((resolvePromise, reject) => {
4199
+ const socket = new WebSocket(websocketUrl(relayUrl), {
4200
+ handshakeTimeout: 1e4,
4201
+ maxPayload: 1024 * 1024,
4202
+ perMessageDeflate: false
4203
+ });
4204
+ let settled = false;
4205
+ const finish = (error) => {
4206
+ if (settled) {
4207
+ return;
4208
+ }
4209
+ settled = true;
4210
+ clearTimeout(timeout);
4211
+ socket.terminate();
4212
+ if (error === undefined) {
4213
+ resolvePromise();
4214
+ return;
4215
+ }
4216
+ reject(error);
4217
+ };
4218
+ const timeout = setTimeout(() => finish(new Error("Timed out while verifying the device credential")), 1e4);
4219
+ socket.once("open", () => {
4220
+ socket.send(JSON.stringify(SocketEvent.make({ _tag: "Authenticate", token })));
4221
+ });
4222
+ socket.on("message", (data) => {
4223
+ try {
4224
+ const event = Schema7.decodeUnknownSync(SocketEvent)(JSON.parse(data.toString()));
4225
+ if (event._tag === "Connected") {
4226
+ finish();
4227
+ } else if (event._tag === "Error") {
4228
+ finish(new Error(event.message));
4229
+ }
4230
+ } catch (cause) {
4231
+ finish(new Error(`New relay returned an invalid device response: ${String(cause)}`));
4232
+ }
4233
+ });
4234
+ socket.once("error", (cause) => finish(cause));
4235
+ socket.once("close", () => finish(new Error("New relay rejected the stored device credential")));
4236
+ });
4237
+ var switchRelay = async (options) => {
4238
+ if (process.env.COHALL_RELAY_URL !== undefined) {
4239
+ throw new Error("Unset COHALL_RELAY_URL before changing the stored relay URL");
4240
+ }
4241
+ const configuration = await readStoredConfiguration();
4242
+ if (configuration === undefined) {
4243
+ throw new Error("This user has no stored Cohall configuration");
4244
+ }
4245
+ const relayUrl = normalizeRelayUrl(options.relayUrl);
4246
+ const parsedRelayUrl = new URL(relayUrl);
4247
+ if (parsedRelayUrl.protocol === "http:" && !isLoopback(parsedRelayUrl) && options.allowHttp !== true) {
4248
+ throw new Error("Refusing to send stored credentials over remote HTTP. Use HTTPS, or --allow-http only on an independently encrypted private network.");
4249
+ }
4250
+ const clientTokens = [configuration.clientToken, process.env.COHALL_CLIENT_TOKEN].filter((token) => token !== undefined);
4251
+ const deviceTokens = [configuration.deviceToken, process.env.COHALL_DEVICE_TOKEN].filter((token) => token !== undefined);
4252
+ if (clientTokens.length === 0 && deviceTokens.length === 0) {
4253
+ throw new Error("This configuration has no client or device credentials to verify");
4254
+ }
4255
+ const roles = [];
4256
+ if (clientTokens.length > 0) {
4257
+ for (const token of new Set(clientTokens)) {
4258
+ await (options.verifyClient ?? verifyClientCredential)(relayUrl, token);
4259
+ }
4260
+ roles.push("client");
4261
+ }
4262
+ if (deviceTokens.length > 0) {
4263
+ for (const token of new Set(deviceTokens)) {
4264
+ await (options.verifyDevice ?? verifyDeviceCredential)(relayUrl, token);
4265
+ }
4266
+ roles.push("device");
4267
+ }
4268
+ const updated = configuration.relayUrl !== relayUrl;
4269
+ if (updated) {
4270
+ await writeStoredConfiguration(StoredConfiguration.make({ ...configuration, relayUrl }));
4271
+ }
4272
+ let service = { running: false, restarted: false };
4273
+ let warning;
4274
+ if (updated && options.restart && deviceTokens.length > 0) {
4275
+ service = await (options.restartService ?? restartDeviceService)().catch((cause) => {
4276
+ warning = `Relay URL changed, but the device service could not restart: ${cause instanceof Error ? cause.message : String(cause)}`;
4277
+ return { running: true, restarted: false };
4278
+ });
4279
+ }
4280
+ return {
4281
+ updated,
4282
+ relay_url: relayUrl,
4283
+ verified_roles: roles,
4284
+ service,
4285
+ ...warning === undefined ? {} : { warning }
4286
+ };
4287
+ };
4288
+
3291
4289
  // apps/device/src/cli.ts
3292
4290
  var valueOptions = new Set([
3293
4291
  "context",
@@ -3308,7 +4306,16 @@ var valueOptions = new Set([
3308
4306
  "to",
3309
4307
  "workspace"
3310
4308
  ]);
3311
- var flagOptions = new Set(["client-only", "dry-run", "follow", "no-restart", "no-wait"]);
4309
+ var flagOptions = new Set([
4310
+ "all",
4311
+ "allow-http",
4312
+ "client-only",
4313
+ "dry-run",
4314
+ "follow",
4315
+ "no-restart",
4316
+ "no-wait",
4317
+ "service"
4318
+ ]);
3312
4319
  var aliases = new Map([
3313
4320
  ["-c", "context"],
3314
4321
  ["-p", "prompt"],
@@ -3319,6 +4326,7 @@ var aliases = new Map([
3319
4326
  var help = `Cohall connects agents running on your own devices.
3320
4327
 
3321
4328
  Usage:
4329
+ cohall init [--relay url] [--workspace path] [--providers list] [--service]
3322
4330
  cohall join --relay <url> --workspace <path> [--providers list] [--token-file <path>]
3323
4331
  cohall configure [--relay url] [--name name] [--workspace path]
3324
4332
  [--providers codex,opencode|auto] [--model id]
@@ -3336,10 +4344,18 @@ Usage:
3336
4344
  cohall sessions
3337
4345
  cohall revoke <session-id>
3338
4346
  cohall forget <device-id>
3339
- cohall doctor
4347
+ cohall doctor [--all]
4348
+ cohall versions
4349
+ cohall usage
3340
4350
  cohall upgrade [--to version|latest] [--no-restart] [--dry-run]
4351
+ cohall upgrade --all [--to version|latest] [--no-restart] [--dry-run]
4352
+ cohall upgrades [abandon <operation-id>]
4353
+ cohall service install
3341
4354
  cohall device
3342
4355
  cohall relay
4356
+ cohall relay backup <directory>
4357
+ cohall relay restore <directory>
4358
+ cohall relay use <url> [--no-restart] [--allow-http]
3343
4359
  cohall mcp
3344
4360
  cohall skill [install [agents|claude|opencode|all]]
3345
4361
  cohall integrations
@@ -3434,13 +4450,13 @@ var readInput = async (arguments_, name) => {
3434
4450
  throw new Error(`Use either --${name} or --${name}-file, not both`);
3435
4451
  }
3436
4452
  if (path !== undefined) {
3437
- if (!await access3(path).then(() => true).catch(() => false)) {
4453
+ if (!await access4(path).then(() => true).catch(() => false)) {
3438
4454
  throw new Error(`File does not exist: ${path}`);
3439
4455
  }
3440
- if ((await stat2(path)).size > 131072) {
4456
+ if ((await stat3(path)).size > 131072) {
3441
4457
  throw new Error(`${name} file exceeds 128 KiB`);
3442
4458
  }
3443
- return readFile3(path, "utf8");
4459
+ return readFile4(path, "utf8");
3444
4460
  }
3445
4461
  const result = direct === "-" ? await readStdin(131072, name) : direct;
3446
4462
  if (result !== undefined && result.length > 131072) {
@@ -3470,27 +4486,27 @@ var timeout = (arguments_) => {
3470
4486
  };
3471
4487
  var pairingToken = async (arguments_) => {
3472
4488
  const path = option(arguments_, "token-file");
3473
- if (path !== undefined && !await access3(path).then(() => true).catch(() => false)) {
4489
+ if (path !== undefined && !await access4(path).then(() => true).catch(() => false)) {
3474
4490
  throw new Error(`Pairing token file does not exist: ${path}`);
3475
4491
  }
3476
- if (path !== undefined && (await stat2(path)).size > 512) {
4492
+ if (path !== undefined && (await stat3(path)).size > 512) {
3477
4493
  throw new Error("Pairing token file exceeds 512 bytes");
3478
4494
  }
3479
- const token = (path === undefined ? await readStdin(512, "Pairing token") : await readFile3(path, "utf8")).trim();
4495
+ const token = (path === undefined ? await readStdin(512, "Pairing token") : await readFile4(path, "utf8")).trim();
3480
4496
  if (token.length === 0 || new TextEncoder().encode(token).byteLength > 256) {
3481
4497
  throw new Error("Provide a pairing token on stdin or with --token-file");
3482
4498
  }
3483
4499
  return token;
3484
4500
  };
3485
4501
  var client = async () => {
3486
- const configuration = await Effect5.runPromise(loadClientConfiguration);
4502
+ const configuration = await Effect7.runPromise(loadClientConfiguration);
3487
4503
  return {
3488
4504
  configuration,
3489
4505
  relay: exports_src.make({ baseUrl: configuration.relayUrl, token: configuration.token })
3490
4506
  };
3491
4507
  };
3492
4508
  var ownerClient = async () => {
3493
- const configuration = await Effect5.runPromise(loadOwnerConfiguration);
4509
+ const configuration = await Effect7.runPromise(loadOwnerConfiguration);
3494
4510
  return {
3495
4511
  configuration,
3496
4512
  relay: exports_src.make({ baseUrl: configuration.relayUrl, token: configuration.token })
@@ -3498,17 +4514,17 @@ var ownerClient = async () => {
3498
4514
  };
3499
4515
  var installSkill = async (target) => {
3500
4516
  const roots = {
3501
- agents: join4(homedir3(), ".agents", "skills", "cohall", "SKILL.md"),
3502
- claude: join4(homedir3(), ".claude", "skills", "cohall", "SKILL.md"),
3503
- opencode: join4(homedir3(), ".config", "opencode", "skills", "cohall", "SKILL.md")
4517
+ agents: join6(homedir4(), ".agents", "skills", "cohall", "SKILL.md"),
4518
+ claude: join6(homedir4(), ".claude", "skills", "cohall", "SKILL.md"),
4519
+ opencode: join6(homedir4(), ".config", "opencode", "skills", "cohall", "SKILL.md")
3504
4520
  };
3505
4521
  const selected = target === "all" ? Object.values(roots) : (target in roots) ? [roots[target]] : undefined;
3506
4522
  if (selected === undefined) {
3507
4523
  throw new Error("Skill target must be agents, claude, opencode, or all");
3508
4524
  }
3509
4525
  for (const path of selected) {
3510
- await mkdir3(dirname3(path), { recursive: true });
3511
- await writeFile4(path, SKILL_default);
4526
+ await mkdir5(dirname5(path), { recursive: true });
4527
+ await writeFile6(path, SKILL_default);
3512
4528
  }
3513
4529
  return selected;
3514
4530
  };
@@ -3516,10 +4532,150 @@ var printHelp = () => console.log(help);
3516
4532
  var printSkill = () => console.log(SKILL_default.trimEnd());
3517
4533
  var runCli = async (command, raw) => {
3518
4534
  const arguments_ = parseArguments(raw);
4535
+ if (command === "init") {
4536
+ allowOptions(arguments_, [
4537
+ "client-only",
4538
+ "name",
4539
+ "providers",
4540
+ "relay",
4541
+ "service",
4542
+ "token-file",
4543
+ "workspace"
4544
+ ]);
4545
+ noPositionals(arguments_, command);
4546
+ const token = option(arguments_, "token-file") === undefined ? undefined : await pairingToken(arguments_);
4547
+ const interactive = process.stdin.isTTY === true && process.stderr.isTTY === true;
4548
+ const relayUrl = option(arguments_, "relay");
4549
+ const deviceName = option(arguments_, "name");
4550
+ const providerInput = option(arguments_, "providers");
4551
+ const prompter = interactive ? terminalPrompter() : {
4552
+ answer: (_label, fallback) => Promise.resolve(fallback),
4553
+ secret: () => pairingToken(arguments_),
4554
+ close: () => {
4555
+ return;
4556
+ }
4557
+ };
4558
+ try {
4559
+ const input = await guidedSetupInput({
4560
+ clientOnly: arguments_.options.has("client-only"),
4561
+ workspaces: values(arguments_, "workspace"),
4562
+ cwd: process.cwd(),
4563
+ ...relayUrl === undefined ? {} : { relayUrl },
4564
+ ...token === undefined ? {} : { token },
4565
+ ...deviceName === undefined ? {} : { deviceName },
4566
+ ...providerInput === undefined ? {} : { providers: providerInput }
4567
+ }, prompter);
4568
+ const providers = input.providers === "auto" ? "auto" : parseProviders(input.providers);
4569
+ const existing = await readStoredConfiguration();
4570
+ const result = input.reusedConfiguration ? {
4571
+ configuration: await makeStoredConfiguration({
4572
+ relayUrl: input.relayUrl,
4573
+ ...input.deviceName === undefined ? {} : { deviceName: input.deviceName },
4574
+ workspaces: input.workspaces,
4575
+ providers
4576
+ }),
4577
+ roles: [
4578
+ ...existing?.clientToken === undefined ? [] : ["client"],
4579
+ ...arguments_.options.has("client-only") || existing?.deviceToken === undefined ? [] : ["device"]
4580
+ ]
4581
+ } : await joinRelay({
4582
+ relayUrl: input.relayUrl,
4583
+ token: input.token ?? "",
4584
+ clientOnly: arguments_.options.has("client-only"),
4585
+ ...input.deviceName === undefined ? {} : { deviceName: input.deviceName },
4586
+ workspaces: input.workspaces,
4587
+ providers
4588
+ });
4589
+ if (input.reusedConfiguration) {
4590
+ await writeStoredConfiguration(result.configuration);
4591
+ }
4592
+ const installedSkills = await installSkill("all");
4593
+ const service = arguments_.options.has("service") ? await installDeviceService() : undefined;
4594
+ print({
4595
+ initialized: true,
4596
+ reused_configuration: input.reusedConfiguration,
4597
+ config_path: configurationPath(),
4598
+ relay_url: result.configuration.relayUrl,
4599
+ device_id: result.configuration.deviceId,
4600
+ roles: result.roles,
4601
+ workspaces: result.configuration.workspaces,
4602
+ skills: installedSkills,
4603
+ ...service === undefined ? {} : { service },
4604
+ next: service === undefined && !arguments_.options.has("client-only") ? "Run `cohall service install` for background availability, or `cohall device` now." : "Run `cohall doctor` to verify this device."
4605
+ });
4606
+ } finally {
4607
+ prompter.close();
4608
+ }
4609
+ return;
4610
+ }
4611
+ if (command === "service") {
4612
+ allowOptions(arguments_, []);
4613
+ if (arguments_.positionals.length !== 1 || arguments_.positionals[0] !== "install") {
4614
+ throw new Error("Usage: cohall service install");
4615
+ }
4616
+ print(await installDeviceService());
4617
+ return;
4618
+ }
4619
+ if (command === "relay") {
4620
+ const action = arguments_.positionals[0];
4621
+ if (action === "backup") {
4622
+ allowOptions(arguments_, []);
4623
+ if (arguments_.positionals.length !== 2) {
4624
+ throw new Error("Usage: cohall relay backup <directory>");
4625
+ }
4626
+ print(await backupRelay(arguments_.positionals[1] ?? ""));
4627
+ return;
4628
+ }
4629
+ if (action === "restore") {
4630
+ allowOptions(arguments_, []);
4631
+ if (arguments_.positionals.length !== 2) {
4632
+ throw new Error("Usage: cohall relay restore <directory>");
4633
+ }
4634
+ print(await restoreRelay(arguments_.positionals[1] ?? ""));
4635
+ return;
4636
+ }
4637
+ if (action === "use") {
4638
+ allowOptions(arguments_, ["allow-http", "no-restart"]);
4639
+ if (arguments_.positionals.length !== 2) {
4640
+ throw new Error("Usage: cohall relay use <url> [--no-restart] [--allow-http]");
4641
+ }
4642
+ print(await switchRelay({
4643
+ relayUrl: arguments_.positionals[1] ?? "",
4644
+ restart: !arguments_.options.has("no-restart"),
4645
+ allowHttp: arguments_.options.has("allow-http")
4646
+ }));
4647
+ return;
4648
+ }
4649
+ throw new Error("Usage: cohall relay [backup <directory>|restore <directory>|use <url> [--no-restart] [--allow-http]]");
4650
+ }
3519
4651
  if (command === "upgrade") {
3520
- allowOptions(arguments_, ["dry-run", "no-restart", "to"]);
4652
+ allowOptions(arguments_, ["all", "dry-run", "no-restart", "to"]);
3521
4653
  noPositionals(arguments_, command);
3522
4654
  const target = option(arguments_, "to");
4655
+ if (arguments_.options.has("all")) {
4656
+ const normalizedTarget = normalizeUpgradeTarget(target);
4657
+ const { relay: relay2 } = await ownerClient();
4658
+ const restart = !arguments_.options.has("no-restart");
4659
+ if (arguments_.options.has("dry-run")) {
4660
+ const devices = await Effect7.runPromise(relay2.devices());
4661
+ print({
4662
+ dry_run: true,
4663
+ requested_version: normalizedTarget,
4664
+ restart,
4665
+ devices: devices.map((device) => ({ id: device.id, name: device.name }))
4666
+ });
4667
+ return;
4668
+ }
4669
+ const operations = await Effect7.runPromise(relay2.createUpgradeOperations({ target: normalizedTarget, restart }));
4670
+ print({
4671
+ queued: operations.length,
4672
+ requested_version: normalizedTarget,
4673
+ restart,
4674
+ operations,
4675
+ next: "Run `cohall upgrades` to inspect progress."
4676
+ });
4677
+ return;
4678
+ }
3523
4679
  print(await upgrade({
3524
4680
  currentVersion: version,
3525
4681
  ...target === undefined ? {} : { target },
@@ -3529,6 +4685,19 @@ var runCli = async (command, raw) => {
3529
4685
  }));
3530
4686
  return;
3531
4687
  }
4688
+ if (command === "upgrades") {
4689
+ allowOptions(arguments_, []);
4690
+ const { relay: relay2 } = await ownerClient();
4691
+ if (arguments_.positionals.length > 0) {
4692
+ if (arguments_.positionals.length !== 2 || arguments_.positionals[0] !== "abandon") {
4693
+ throw new Error("Usage: cohall upgrades [abandon <operation-id>]");
4694
+ }
4695
+ print(await Effect7.runPromise(relay2.abandonOperation(Schema8.decodeUnknownSync(OperationId)(arguments_.positionals[1]))));
4696
+ return;
4697
+ }
4698
+ print(await Effect7.runPromise(relay2.operations()));
4699
+ return;
4700
+ }
3532
4701
  if (command === "join") {
3533
4702
  allowOptions(arguments_, [
3534
4703
  "client-only",
@@ -3550,41 +4719,21 @@ var runCli = async (command, raw) => {
3550
4719
  if (!arguments_.options.has("client-only") && (workspaces?.length ?? 0) === 0) {
3551
4720
  throw new Error("At least one --workspace is required when joining a device");
3552
4721
  }
3553
- const draft = await makeStoredConfiguration({
4722
+ const clientOnly = arguments_.options.has("client-only");
4723
+ const { configuration: configuration2, roles } = await joinRelay({
3554
4724
  relayUrl,
4725
+ token,
4726
+ clientOnly,
3555
4727
  ...deviceName === undefined ? {} : { deviceName },
3556
4728
  ...workspaces === undefined ? {} : { workspaces },
3557
4729
  ...providers === undefined ? {} : { providers }
3558
4730
  });
3559
- const paired = await Effect5.runPromise(exchangePairing(relayUrl, { token }));
3560
- const clientToken = paired.credentials.find((credential) => credential.session.role === "client")?.token;
3561
- const deviceToken = paired.credentials.find((credential) => credential.session.role === "device")?.token;
3562
- const deviceId = paired.credentials.find((credential) => credential.session.role === "device")?.session.deviceId;
3563
- const clientOnly = arguments_.options.has("client-only");
3564
- if (clientOnly && clientToken === undefined) {
3565
- throw new Error("The pairing credential did not include client access");
3566
- }
3567
- if (!clientOnly && deviceToken === undefined) {
3568
- throw new Error("The pairing credential did not include device access");
3569
- }
3570
- const {
3571
- clientToken: _retainedClientToken,
3572
- deviceToken: _retainedDeviceToken,
3573
- ...configurationDraft
3574
- } = draft;
3575
- const configuration2 = StoredConfiguration.make({
3576
- ...configurationDraft,
3577
- deviceId: clientOnly ? configurationDraft.deviceId : deviceId ?? configurationDraft.deviceId,
3578
- ...clientToken === undefined ? {} : { clientToken },
3579
- ...clientOnly || deviceToken === undefined ? {} : { deviceToken }
3580
- });
3581
- await writeStoredConfiguration(configuration2);
3582
4731
  print({
3583
4732
  configured: true,
3584
4733
  config_path: configurationPath(),
3585
4734
  relay_url: configuration2.relayUrl,
3586
4735
  device_id: configuration2.deviceId,
3587
- roles: clientOnly ? paired.credentials.map((credential) => credential.session.role).filter((role) => role === "client") : paired.credentials.map((credential) => credential.session.role),
4736
+ roles,
3588
4737
  workspaces: configuration2.workspaces
3589
4738
  });
3590
4739
  return;
@@ -3616,7 +4765,7 @@ var runCli = async (command, raw) => {
3616
4765
  const existing = await readStoredConfiguration();
3617
4766
  const relayUrl = normalizeRelayUrl(option(arguments_, "relay") ?? existing?.relayUrl ?? "http://127.0.0.1:8787");
3618
4767
  const supplied = values(arguments_, "workspace");
3619
- const workspaces = supplied.length === 0 ? existing?.workspaces ?? [] : await Effect5.runPromise(parseWorkspaces("", JSON.stringify(supplied)));
4768
+ const workspaces = supplied.length === 0 ? existing?.workspaces ?? [] : await Effect7.runPromise(parseWorkspaces("", JSON.stringify(supplied)));
3620
4769
  const sandbox = option(arguments_, "sandbox");
3621
4770
  const model = option(arguments_, "model") ?? existing?.model;
3622
4771
  const providerInput = option(arguments_, "providers");
@@ -3631,7 +4780,7 @@ var runCli = async (command, raw) => {
3631
4780
  ...providers === undefined ? {} : { providers },
3632
4781
  ...model === undefined ? {} : { model },
3633
4782
  ...(sandbox ?? existing?.sandbox) === undefined ? {} : {
3634
- sandbox: Schema7.decodeUnknownSync(Schema7.Literals(["read-only", "workspace-write", "danger-full-access"]))(sandbox ?? existing?.sandbox)
4783
+ sandbox: Schema8.decodeUnknownSync(Schema8.Literals(["read-only", "workspace-write", "danger-full-access"]))(sandbox ?? existing?.sandbox)
3635
4784
  }
3636
4785
  });
3637
4786
  await writeStoredConfiguration(configuration2);
@@ -3670,8 +4819,13 @@ var runCli = async (command, raw) => {
3670
4819
  return;
3671
4820
  }
3672
4821
  if (command === "doctor") {
3673
- allowOptions(arguments_, []);
4822
+ allowOptions(arguments_, ["all"]);
3674
4823
  noPositionals(arguments_, command);
4824
+ if (arguments_.options.has("all")) {
4825
+ const { relay: relay2 } = await client();
4826
+ print(allDeviceHealth(await Effect7.runPromise(relay2.devices()), version));
4827
+ return;
4828
+ }
3675
4829
  const configuration2 = await readStoredConfiguration();
3676
4830
  const relayUrl = normalizeRelayUrl(process.env.COHALL_RELAY_URL ?? configuration2?.relayUrl ?? "http://127.0.0.1:8787");
3677
4831
  const storedCredentials = credentialsForRelay(configuration2, relayUrl);
@@ -3689,7 +4843,7 @@ var runCli = async (command, raw) => {
3689
4843
  const clientToken = process.env.COHALL_CLIENT_TOKEN ?? storedCredentials.clientToken;
3690
4844
  const hasDeviceCredential = process.env.COHALL_DEVICE_TOKEN !== undefined || storedCredentials.deviceToken !== undefined;
3691
4845
  const deviceId = process.env.COHALL_DEVICE_ID ?? configuration2?.deviceId;
3692
- const currentDevice = response?.ok !== true || clientToken === undefined || deviceId === undefined ? undefined : await Effect5.runPromise(exports_src.make({ baseUrl: relayUrl, token: clientToken }).devices()).then((devices) => devices.find((device) => device.id === deviceId)).catch(() => {
4846
+ const currentDevice = response?.ok !== true || clientToken === undefined || deviceId === undefined ? undefined : await Effect7.runPromise(exports_src.make({ baseUrl: relayUrl, token: clientToken }).devices()).then((devices) => devices.find((device) => device.id === deviceId)).catch(() => {
3693
4847
  return;
3694
4848
  });
3695
4849
  const versionWarning = deviceVersionWarning(version, currentDevice?.version);
@@ -3718,13 +4872,30 @@ var runCli = async (command, raw) => {
3718
4872
  });
3719
4873
  return;
3720
4874
  }
4875
+ if (command === "versions") {
4876
+ allowOptions(arguments_, []);
4877
+ noPositionals(arguments_, command);
4878
+ const { relay: relay2 } = await client();
4879
+ print(deviceVersions(await Effect7.runPromise(relay2.devices()), version));
4880
+ return;
4881
+ }
4882
+ if (command === "usage") {
4883
+ allowOptions(arguments_, []);
4884
+ noPositionals(arguments_, command);
4885
+ const { relay: relay2 } = await client();
4886
+ print({
4887
+ ...await Effect7.runPromise(relay2.usage()),
4888
+ scope: "Retained Cohall task activity; provider token and billing usage are not available."
4889
+ });
4890
+ return;
4891
+ }
3721
4892
  if (command === "pair" || command === "sessions" || command === "revoke" || command === "forget") {
3722
4893
  const { configuration: configuration2, relay: relay2 } = await ownerClient();
3723
4894
  if (command === "pair") {
3724
4895
  allowOptions(arguments_, ["client-only", "label"]);
3725
4896
  noPositionals(arguments_, command);
3726
4897
  const roles = arguments_.options.has("client-only") ? ["client"] : ["client", "device"];
3727
- const credential = await Effect5.runPromise(relay2.createPairing({ label: option(arguments_, "label") ?? "Cohall device", roles }));
4898
+ const credential = await Effect7.runPromise(relay2.createPairing({ label: option(arguments_, "label") ?? "Cohall device", roles }));
3728
4899
  print({
3729
4900
  relay_url: configuration2.relayUrl,
3730
4901
  pairing_token: credential.token,
@@ -3736,23 +4907,23 @@ var runCli = async (command, raw) => {
3736
4907
  if (command === "sessions") {
3737
4908
  allowOptions(arguments_, []);
3738
4909
  noPositionals(arguments_, command);
3739
- print(await Effect5.runPromise(relay2.authSessions()));
4910
+ print(await Effect7.runPromise(relay2.authSessions()));
3740
4911
  return;
3741
4912
  }
3742
4913
  if (command === "forget") {
3743
4914
  allowOptions(arguments_, []);
3744
- print(await Effect5.runPromise(relay2.forgetDevice(Schema7.decodeUnknownSync(DeviceId)(identifier(arguments_, "device id")))));
4915
+ print(await Effect7.runPromise(relay2.forgetDevice(Schema8.decodeUnknownSync(DeviceId)(identifier(arguments_, "device id")))));
3745
4916
  return;
3746
4917
  }
3747
4918
  allowOptions(arguments_, []);
3748
- print(await Effect5.runPromise(relay2.revokeAuthSession(Schema7.decodeUnknownSync(AuthSessionId)(identifier(arguments_, "session id")))));
4919
+ print(await Effect7.runPromise(relay2.revokeAuthSession(Schema8.decodeUnknownSync(AuthSessionId)(identifier(arguments_, "session id")))));
3749
4920
  return;
3750
4921
  }
3751
4922
  const { configuration, relay } = await client();
3752
4923
  if (command === "devices") {
3753
4924
  allowOptions(arguments_, []);
3754
4925
  noPositionals(arguments_, command);
3755
- print(await Effect5.runPromise(relay.devices()));
4926
+ print(await Effect7.runPromise(relay.devices()));
3756
4927
  return;
3757
4928
  }
3758
4929
  if (command === "delegate") {
@@ -3784,50 +4955,50 @@ var runCli = async (command, raw) => {
3784
4955
  const thread = option(arguments_, "thread");
3785
4956
  const workspace = option(arguments_, "workspace");
3786
4957
  const provider = option(arguments_, "provider");
3787
- const task = await Effect5.runPromise(createDelegation(relay, configuration, {
4958
+ const task = await Effect7.runPromise(createDelegation(relay, configuration, {
3788
4959
  prompt,
3789
4960
  ...target === undefined ? {} : { target },
3790
4961
  ...context === undefined ? {} : { context },
3791
- ...thread === undefined ? {} : { threadId: Schema7.decodeUnknownSync(ThreadId)(thread) },
4962
+ ...thread === undefined ? {} : { threadId: Schema8.decodeUnknownSync(ThreadId)(thread) },
3792
4963
  ...workspace === undefined ? {} : { workspace },
3793
- ...provider === undefined ? {} : { provider: Schema7.decodeUnknownSync(Provider)(provider) }
4964
+ ...provider === undefined ? {} : { provider: Schema8.decodeUnknownSync(Provider)(provider) }
3794
4965
  }));
3795
- print(taskResult(arguments_.options.has("no-wait") ? task : await Effect5.runPromise(waitForTask(relay, task, timeout(arguments_)))));
4966
+ print(taskResult(arguments_.options.has("no-wait") ? task : await Effect7.runPromise(waitForTask(relay, task, timeout(arguments_)))));
3796
4967
  return;
3797
4968
  }
3798
4969
  if (command === "status") {
3799
4970
  allowOptions(arguments_, []);
3800
- const id = Schema7.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
3801
- print(taskResult(await Effect5.runPromise(relay.getTask(id))));
4971
+ const id = Schema8.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
4972
+ print(taskResult(await Effect7.runPromise(relay.getTask(id))));
3802
4973
  return;
3803
4974
  }
3804
4975
  if (command === "trace") {
3805
4976
  allowOptions(arguments_, ["follow"]);
3806
- const id = Schema7.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
4977
+ const id = Schema8.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
3807
4978
  if (arguments_.options.has("follow")) {
3808
- await Effect5.runPromise(followTaskTrace(relay, id, printLine));
4979
+ await Effect7.runPromise(followTaskTrace(relay, id, printLine));
3809
4980
  return;
3810
4981
  }
3811
- print(await Effect5.runPromise(relay.traceTask(id)));
4982
+ print(await Effect7.runPromise(relay.traceTask(id)));
3812
4983
  return;
3813
4984
  }
3814
4985
  if (command === "wait") {
3815
4986
  allowOptions(arguments_, ["timeout"]);
3816
- const id = Schema7.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
3817
- const task = await Effect5.runPromise(relay.getTask(id));
3818
- print(taskResult(await Effect5.runPromise(waitForTask(relay, task, timeout(arguments_)))));
4987
+ const id = Schema8.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
4988
+ const task = await Effect7.runPromise(relay.getTask(id));
4989
+ print(taskResult(await Effect7.runPromise(waitForTask(relay, task, timeout(arguments_)))));
3819
4990
  return;
3820
4991
  }
3821
4992
  if (command === "cancel") {
3822
4993
  allowOptions(arguments_, []);
3823
- const id = Schema7.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
3824
- print(taskResult(await Effect5.runPromise(relay.cancelTask(id))));
4994
+ const id = Schema8.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
4995
+ print(taskResult(await Effect7.runPromise(relay.cancelTask(id))));
3825
4996
  return;
3826
4997
  }
3827
4998
  if (command === "thread") {
3828
4999
  allowOptions(arguments_, []);
3829
- const id = Schema7.decodeUnknownSync(ThreadId)(identifier(arguments_, "thread id"));
3830
- print(await Effect5.runPromise(threadContext(relay, id)));
5000
+ const id = Schema8.decodeUnknownSync(ThreadId)(identifier(arguments_, "thread id"));
5001
+ print(await Effect7.runPromise(threadContext(relay, id)));
3831
5002
  return;
3832
5003
  }
3833
5004
  throw new Error(`Unknown Cohall command: ${command}`);
@@ -3835,15 +5006,15 @@ var runCli = async (command, raw) => {
3835
5006
 
3836
5007
  // apps/device/src/daemon.ts
3837
5008
  init_src();
3838
- import { Effect as Effect6, Schedule as Schedule2, Schema as Schema8 } from "effect";
5009
+ import { Effect as Effect8, Schedule as Schedule2, Schema as Schema9 } from "effect";
3839
5010
  import { constants as constants3 } from "node:fs";
3840
- import { open, realpath as realpath3, stat as stat3 } from "node:fs/promises";
3841
- import { arch, hostname as hostname2, platform as platform3 } from "node:os";
3842
- import { basename, isAbsolute as isAbsolute3, relative } from "node:path";
3843
- import { WebSocket } from "ws";
5011
+ import { open, realpath as realpath5, stat as stat4 } from "node:fs/promises";
5012
+ import { arch, hostname as hostname3, platform as platform3 } from "node:os";
5013
+ import { basename as basename2, isAbsolute as isAbsolute3, relative } from "node:path";
5014
+ import { WebSocket as WebSocket2 } from "ws";
3844
5015
  var maxQueuedRelayMessages = 8;
3845
5016
 
3846
- class DeviceConnectionError extends Schema8.TaggedErrorClass()("Device.ConnectionError", { message: Schema8.String }) {
5017
+ class DeviceConnectionError extends Schema9.TaggedErrorClass()("Device.ConnectionError", { message: Schema9.String }) {
3847
5018
  }
3848
5019
  var socketUrl = (configuration) => {
3849
5020
  const url = new URL(configuration.relayUrl);
@@ -3887,7 +5058,7 @@ var describeDevice = (configuration, status = "online") => {
3887
5058
  return Device.make({
3888
5059
  id: configuration.id,
3889
5060
  name: configuration.name,
3890
- hostname: hostname2(),
5061
+ hostname: hostname3(),
3891
5062
  platform: platformName,
3892
5063
  architecture: arch(),
3893
5064
  status,
@@ -3895,7 +5066,7 @@ var describeDevice = (configuration, status = "online") => {
3895
5066
  capabilities: capabilities(providers),
3896
5067
  workspaces: configuration.workspaces.map((path) => ({
3897
5068
  path,
3898
- label: basename(path) || path
5069
+ label: basename2(path) || path
3899
5070
  })),
3900
5071
  version,
3901
5072
  lastSeenAt: now()
@@ -3906,7 +5077,7 @@ var allowedWorkspace = async (configuration, requested) => {
3906
5077
  if (selected === undefined) {
3907
5078
  throw new Error("No workspace is configured on this device");
3908
5079
  }
3909
- const candidate = await realpath3(selected);
5080
+ const candidate = await realpath5(selected);
3910
5081
  const allowed = configuration.workspaces.some((root) => {
3911
5082
  const child = relative(root, candidate);
3912
5083
  return child === "" || !child.startsWith("..") && !isAbsolute3(child);
@@ -3926,7 +5097,7 @@ var openAllowedWorkspace = async (configuration, requested) => {
3926
5097
  await handle.close();
3927
5098
  throw new Error(`Workspace ${path} is not a directory`);
3928
5099
  }
3929
- const cwd = operatingSystem2 === "linux" ? `/proc/self/fd/${handle.fd}` : operatingSystem2 === "darwin" ? `/dev/fd/${handle.fd}` : path;
5100
+ const cwd = operatingSystem2 === "linux" ? `/proc/self/fd/${handle.fd}` : path;
3930
5101
  return {
3931
5102
  cwd,
3932
5103
  validate: async () => {
@@ -3934,9 +5105,9 @@ var openAllowedWorkspace = async (configuration, requested) => {
3934
5105
  if (!current.isDirectory() || current.dev !== identity.dev || current.ino !== identity.ino) {
3935
5106
  throw new Error(`Workspace ${path} changed before provider startup`);
3936
5107
  }
3937
- if (operatingSystem2 === "win32") {
3938
- const currentPath = await realpath3(path);
3939
- const currentPathIdentity = await stat3(currentPath);
5108
+ if (operatingSystem2 !== "linux") {
5109
+ const currentPath = await realpath5(path);
5110
+ const currentPathIdentity = await stat4(currentPath);
3940
5111
  if (currentPathIdentity.dev !== identity.dev || currentPathIdentity.ino !== identity.ino) {
3941
5112
  throw new Error(`Workspace ${path} changed before provider startup`);
3942
5113
  }
@@ -3962,14 +5133,21 @@ ${task.prompt}${context}`
3962
5133
  `);
3963
5134
  };
3964
5135
  var send = (state, event) => {
3965
- if (state.socket?.readyState === WebSocket.OPEN) {
5136
+ if (state.socket?.readyState === WebSocket2.OPEN) {
3966
5137
  state.socket.send(JSON.stringify(event));
3967
5138
  }
3968
5139
  };
3969
5140
  var sendTerminal = (state, taskId, event) => {
3970
5141
  const payload = JSON.stringify(event);
3971
5142
  state.terminal.set(taskId, payload);
3972
- if (state.socket?.readyState === WebSocket.OPEN) {
5143
+ if (state.socket?.readyState === WebSocket2.OPEN) {
5144
+ state.socket.send(payload);
5145
+ }
5146
+ };
5147
+ var sendOperationTerminal = (state, operationId, event) => {
5148
+ const payload = JSON.stringify(event);
5149
+ state.operationTerminal.set(operationId, payload);
5150
+ if (state.socket?.readyState === WebSocket2.OPEN) {
3973
5151
  state.socket.send(payload);
3974
5152
  }
3975
5153
  };
@@ -3984,6 +5162,17 @@ var remember = (state, taskId) => {
3984
5162
  state.terminal.delete(oldest.value);
3985
5163
  }
3986
5164
  };
5165
+ var rememberOperation = (state, operationId) => {
5166
+ state.completedOperations.add(operationId);
5167
+ if (state.completedOperations.size <= 100) {
5168
+ return;
5169
+ }
5170
+ const oldest = state.completedOperations.values().next();
5171
+ if (!oldest.done) {
5172
+ state.completedOperations.delete(oldest.value);
5173
+ state.operationTerminal.delete(oldest.value);
5174
+ }
5175
+ };
3987
5176
  var sessionKey = (task) => `${task.threadId}:${task.provider}`;
3988
5177
  var execute = (configuration, state, task) => {
3989
5178
  if (state.tasks.has(task.id) || state.completed.has(task.id)) {
@@ -3992,8 +5181,8 @@ var execute = (configuration, state, task) => {
3992
5181
  const controller = new AbortController;
3993
5182
  state.tasks.set(task.id, controller);
3994
5183
  send(state, SocketEvent.make({ _tag: "TaskAccepted", taskId: task.id }));
3995
- const workflow = Effect6.gen(function* () {
3996
- const workspace = yield* Effect6.tryPromise({
5184
+ const workflow = Effect8.gen(function* () {
5185
+ const workspace = yield* Effect8.tryPromise({
3997
5186
  try: () => openAllowedWorkspace(configuration, task.workspace),
3998
5187
  catch: (cause) => new ProviderRunError({
3999
5188
  provider: task.provider,
@@ -4010,11 +5199,11 @@ var execute = (configuration, state, task) => {
4010
5199
  ...sessionId === undefined ? {} : { sessionId },
4011
5200
  ...configuration.model === undefined ? {} : { model: configuration.model },
4012
5201
  ...configuration.sandbox === undefined ? {} : { sandbox: configuration.sandbox }
4013
- }).pipe(Effect6.ensuring(Effect6.promise(() => workspace.close().catch(() => {
5202
+ }).pipe(Effect8.ensuring(Effect8.promise(() => workspace.close().catch(() => {
4014
5203
  return;
4015
5204
  }))));
4016
5205
  });
4017
- Effect6.runPromise(workflow, { signal: controller.signal }).then((result) => {
5206
+ Effect8.runPromise(workflow, { signal: controller.signal }).then((result) => {
4018
5207
  if (result.sessionId !== undefined) {
4019
5208
  state.sessions.set(sessionKey(task), result.sessionId);
4020
5209
  }
@@ -4034,12 +5223,71 @@ var execute = (configuration, state, task) => {
4034
5223
  }));
4035
5224
  }).finally(() => {
4036
5225
  state.tasks.delete(task.id);
5226
+ const operation = state.operationQueue.shift();
5227
+ if (operation !== undefined) {
5228
+ executeOperation(configuration, state, operation);
5229
+ return;
5230
+ }
5231
+ const next = state.queue.shift();
5232
+ if (next !== undefined && state.operation === undefined) {
5233
+ execute(configuration, state, next);
5234
+ }
5235
+ });
5236
+ };
5237
+ var executeOperation = (configuration, state, operation) => {
5238
+ if (state.operation !== undefined || state.completedOperations.has(operation.id)) {
5239
+ return;
5240
+ }
5241
+ state.operation = operation;
5242
+ send(state, SocketEvent.make({ _tag: "OperationAccepted", operationId: operation.id }));
5243
+ performDeviceOperation(operation, version).then((result) => {
5244
+ rememberOperation(state, operation.id);
5245
+ sendOperationTerminal(state, operation.id, SocketEvent.make({
5246
+ _tag: "OperationFinished",
5247
+ operationId: operation.id,
5248
+ result
5249
+ }));
5250
+ }).catch((cause) => {
5251
+ rememberOperation(state, operation.id);
5252
+ sendOperationTerminal(state, operation.id, SocketEvent.make({
5253
+ _tag: "OperationFailed",
5254
+ operationId: operation.id,
5255
+ error: (cause instanceof Error ? cause.message : String(cause)).slice(0, 16384)
5256
+ }));
5257
+ }).finally(() => {
5258
+ state.operation = undefined;
5259
+ const pending = state.operationQueue.shift();
5260
+ if (pending !== undefined) {
5261
+ executeOperation(configuration, state, pending);
5262
+ return;
5263
+ }
4037
5264
  const next = state.queue.shift();
4038
- if (next !== undefined) {
5265
+ if (next !== undefined && state.tasks.size === 0) {
4039
5266
  execute(configuration, state, next);
4040
5267
  }
4041
5268
  });
4042
5269
  };
5270
+ var scheduleOperation = (configuration, state, operation) => {
5271
+ if (state.completedOperations.has(operation.id) || state.operation?.id === operation.id || state.operationQueue.some((pending) => pending.id === operation.id)) {
5272
+ return;
5273
+ }
5274
+ if (state.tasks.size > 0 || state.operation !== undefined) {
5275
+ if (state.operationQueue.length >= 10) {
5276
+ state.socket?.close(4008, "Operation queue limit reached");
5277
+ return;
5278
+ }
5279
+ state.operationQueue.push(operation);
5280
+ return;
5281
+ }
5282
+ executeOperation(configuration, state, operation);
5283
+ };
5284
+ var performDeviceOperation = (operation, currentVersion, runUpgrade = upgrade) => runUpgrade({
5285
+ currentVersion,
5286
+ target: operation.requestedVersion,
5287
+ restart: operation.restart,
5288
+ dryRun: false,
5289
+ delegated: true
5290
+ }).then((result) => JSON.stringify(result));
4043
5291
  var schedule = (configuration, state, task) => {
4044
5292
  if (state.tasks.has(task.id) || state.completed.has(task.id) || state.queue.some((queued) => queued.id === task.id)) {
4045
5293
  return;
@@ -4047,7 +5295,7 @@ var schedule = (configuration, state, task) => {
4047
5295
  if (task.providerSessionId !== undefined) {
4048
5296
  state.sessions.set(sessionKey(task), task.providerSessionId);
4049
5297
  }
4050
- if (state.tasks.size > 0) {
5298
+ if (state.tasks.size > 0 || state.operation !== undefined) {
4051
5299
  if (state.queue.length >= 100) {
4052
5300
  state.socket?.close(4008, "Task queue limit reached");
4053
5301
  return;
@@ -4070,9 +5318,9 @@ var cancel = (state, taskId) => {
4070
5318
  remember(state, taskId);
4071
5319
  sendTerminal(state, taskId, SocketEvent.make({ _tag: "TaskCancelled", taskId }));
4072
5320
  };
4073
- var connect = (configuration, state) => Effect6.tryPromise({
5321
+ var connect = (configuration, state) => Effect8.tryPromise({
4074
5322
  try: (signal) => new Promise((complete) => {
4075
- const socket = new WebSocket(socketUrl(configuration), {
5323
+ const socket = new WebSocket2(socketUrl(configuration), {
4076
5324
  maxPayload: maxSocketPayloadBytes,
4077
5325
  perMessageDeflate: false
4078
5326
  });
@@ -4080,7 +5328,7 @@ var connect = (configuration, state) => Effect6.tryPromise({
4080
5328
  send(state, SocketEvent.make({
4081
5329
  _tag: "DeviceHeartbeat",
4082
5330
  deviceId: configuration.id,
4083
- status: state.tasks.size > 0 ? "busy" : "online"
5331
+ status: state.tasks.size > 0 || state.operation !== undefined ? "busy" : "online"
4084
5332
  }));
4085
5333
  }, 15000);
4086
5334
  let closed = false;
@@ -4114,19 +5362,22 @@ var connect = (configuration, state) => Effect6.tryPromise({
4114
5362
  return;
4115
5363
  }
4116
5364
  const text2 = Buffer.isBuffer(message) ? message.toString("utf8") : message instanceof ArrayBuffer ? Buffer.from(message).toString("utf8") : Array.isArray(message) ? Buffer.concat(message).toString("utf8") : Buffer.from(message).toString("utf8");
4117
- const event = await Effect6.runPromise(Effect6.try({
5365
+ const event = await Effect8.runPromise(Effect8.try({
4118
5366
  try: () => JSON.parse(text2),
4119
5367
  catch: () => new DeviceConnectionError({ message: "Relay sent invalid JSON" })
4120
- }).pipe(Effect6.flatMap(decodeSocketEvent)));
5368
+ }).pipe(Effect8.flatMap(decodeSocketEvent)));
4121
5369
  if (event._tag === "Connected") {
4122
5370
  state.socket = socket;
4123
5371
  socket.send(JSON.stringify(SocketEvent.make({
4124
5372
  _tag: "DeviceHello",
4125
- device: describeDevice(configuration, state.tasks.size > 0 ? "busy" : "online")
5373
+ device: describeDevice(configuration, state.tasks.size > 0 || state.operation !== undefined ? "busy" : "online")
4126
5374
  })));
4127
5375
  for (const payload of state.terminal.values()) {
4128
5376
  socket.send(payload);
4129
5377
  }
5378
+ for (const payload of state.operationTerminal.values()) {
5379
+ socket.send(payload);
5380
+ }
4130
5381
  return;
4131
5382
  }
4132
5383
  if (event._tag === "TaskAssigned") {
@@ -4141,6 +5392,14 @@ var connect = (configuration, state) => Effect6.tryPromise({
4141
5392
  state.terminal.delete(event.taskId);
4142
5393
  return;
4143
5394
  }
5395
+ if (event._tag === "OperationAssigned") {
5396
+ scheduleOperation(configuration, state, event.operation);
5397
+ return;
5398
+ }
5399
+ if (event._tag === "OperationSettled") {
5400
+ state.operationTerminal.delete(event.operationId);
5401
+ return;
5402
+ }
4144
5403
  if (event._tag === "Error") {
4145
5404
  console.error(`Relay error: ${event.message}`);
4146
5405
  }
@@ -4165,9 +5424,13 @@ var runDaemon = (configuration) => {
4165
5424
  queue: [],
4166
5425
  sessions: new Map,
4167
5426
  tasks: new Map,
4168
- completed: new Set
5427
+ completed: new Set,
5428
+ operation: undefined,
5429
+ operationQueue: [],
5430
+ operationTerminal: new Map,
5431
+ completedOperations: new Set
4169
5432
  };
4170
- return connect(configuration, state).pipe(Effect6.repeat({ schedule: Schedule2.spaced("2 seconds") }), Effect6.ensuring(Effect6.sync(() => {
5433
+ return connect(configuration, state).pipe(Effect8.repeat({ schedule: Schedule2.spaced("2 seconds") }), Effect8.ensuring(Effect8.sync(() => {
4171
5434
  state.socket?.close();
4172
5435
  for (const controller of state.tasks.values()) {
4173
5436
  controller.abort();
@@ -4179,7 +5442,7 @@ var runDaemon = (configuration) => {
4179
5442
  init_src();
4180
5443
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4181
5444
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4182
- import { Effect as Effect7 } from "effect";
5445
+ import { Effect as Effect9 } from "effect";
4183
5446
  import * as z from "zod/v4";
4184
5447
  var output = (value) => ({
4185
5448
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
@@ -4194,7 +5457,7 @@ var runMcp = async (configuration) => {
4194
5457
  title: "List Cohall devices",
4195
5458
  description: "List devices, their availability, local providers, capabilities, and allowed workspaces.",
4196
5459
  inputSchema: {}
4197
- }, async () => output(await Effect7.runPromise(client2.devices())));
5460
+ }, async () => output(await Effect9.runPromise(client2.devices())));
4198
5461
  server.registerTool("delegate", {
4199
5462
  title: "Delegate work to another device",
4200
5463
  description: "Send focused work to another Cohall device. 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.",
@@ -4209,7 +5472,7 @@ var runMcp = async (configuration) => {
4209
5472
  timeout_seconds: z.number().int().min(5).max(86400).default(900)
4210
5473
  }
4211
5474
  }, async ({ prompt, target, provider, context, thread_id, workspace, wait, timeout_seconds }) => {
4212
- const task = await Effect7.runPromise(createDelegation(client2, configuration, {
5475
+ const task = await Effect9.runPromise(createDelegation(client2, configuration, {
4213
5476
  prompt,
4214
5477
  provider,
4215
5478
  ...target === undefined ? {} : { target },
@@ -4217,18 +5480,18 @@ var runMcp = async (configuration) => {
4217
5480
  ...thread_id === undefined ? {} : { threadId: ThreadId.make(thread_id) },
4218
5481
  ...workspace === undefined ? {} : { workspace }
4219
5482
  }));
4220
- return output(taskResult(wait ? await Effect7.runPromise(waitForTask(client2, task, timeout_seconds)) : task));
5483
+ return output(taskResult(wait ? await Effect9.runPromise(waitForTask(client2, task, timeout_seconds)) : task));
4221
5484
  });
4222
5485
  server.registerTool("task_status", {
4223
5486
  title: "Get delegated task status",
4224
5487
  description: "Read the current state and result of a Cohall task.",
4225
5488
  inputSchema: { task_id: z.string().uuid() }
4226
- }, async ({ task_id }) => output(taskResult(await Effect7.runPromise(client2.getTask(TaskId.make(task_id))))));
5489
+ }, async ({ task_id }) => output(taskResult(await Effect9.runPromise(client2.getTask(TaskId.make(task_id))))));
4227
5490
  server.registerTool("task_trace", {
4228
5491
  title: "Trace a delegated task",
4229
5492
  description: "Read the redacted relay and device lifecycle for troubleshooting a Cohall task.",
4230
5493
  inputSchema: { task_id: z.string().uuid() }
4231
- }, async ({ task_id }) => output(await Effect7.runPromise(client2.traceTask(TaskId.make(task_id)))));
5494
+ }, async ({ task_id }) => output(await Effect9.runPromise(client2.traceTask(TaskId.make(task_id)))));
4232
5495
  server.registerTool("wait_task", {
4233
5496
  title: "Wait for delegated work",
4234
5497
  description: "Wait for a Cohall task to finish and return its final result.",
@@ -4237,19 +5500,19 @@ var runMcp = async (configuration) => {
4237
5500
  timeout_seconds: z.number().int().min(5).max(86400).default(900)
4238
5501
  }
4239
5502
  }, async ({ task_id, timeout_seconds }) => {
4240
- const task = await Effect7.runPromise(client2.getTask(TaskId.make(task_id)));
4241
- return output(taskResult(await Effect7.runPromise(waitForTask(client2, task, timeout_seconds))));
5503
+ const task = await Effect9.runPromise(client2.getTask(TaskId.make(task_id)));
5504
+ return output(taskResult(await Effect9.runPromise(waitForTask(client2, task, timeout_seconds))));
4242
5505
  });
4243
5506
  server.registerTool("cancel_task", {
4244
5507
  title: "Cancel delegated work",
4245
5508
  description: "Request cancellation and return its acknowledged or pending state.",
4246
5509
  inputSchema: { task_id: z.string().uuid() }
4247
- }, async ({ task_id }) => output(taskResult(await Effect7.runPromise(client2.cancelTask(TaskId.make(task_id))))));
5510
+ }, async ({ task_id }) => output(taskResult(await Effect9.runPromise(client2.cancelTask(TaskId.make(task_id))))));
4248
5511
  server.registerTool("thread_context", {
4249
5512
  title: "Read a Cohall thread",
4250
5513
  description: "Read shared task prompts and final results for a cross-device thread.",
4251
5514
  inputSchema: { thread_id: z.string().uuid() }
4252
- }, async ({ thread_id }) => output(await Effect7.runPromise(threadContext(client2, ThreadId.make(thread_id)))));
5515
+ }, async ({ thread_id }) => output(await Effect9.runPromise(threadContext(client2, ThreadId.make(thread_id)))));
4253
5516
  await server.connect(new StdioServerTransport);
4254
5517
  };
4255
5518
 
@@ -4269,21 +5532,21 @@ var main = async () => {
4269
5532
  printHelp();
4270
5533
  return;
4271
5534
  }
4272
- if (["device", "relay", "mcp"].includes(command) && process.argv.length > 3) {
5535
+ if (["device", "mcp"].includes(command) && process.argv.length > 3) {
4273
5536
  throw new Error(`${command} does not accept arguments`);
4274
5537
  }
4275
- if (command === "relay") {
5538
+ if (command === "relay" && commandArguments.length === 0) {
4276
5539
  const { runRelay: runRelay2 } = await init_main().then(() => exports_main);
4277
5540
  await runRelay2();
4278
5541
  return;
4279
5542
  }
4280
5543
  if (command === "device") {
4281
- const configuration = await Effect11.runPromise(loadDeviceConfiguration);
4282
- await Effect11.runPromise(runDaemon(configuration));
5544
+ const configuration = await Effect13.runPromise(loadDeviceConfiguration);
5545
+ await Effect13.runPromise(runDaemon(configuration));
4283
5546
  return;
4284
5547
  }
4285
5548
  if (command === "mcp") {
4286
- const configuration = await Effect11.runPromise(loadClientConfiguration);
5549
+ const configuration = await Effect13.runPromise(loadClientConfiguration);
4287
5550
  await runMcp(configuration);
4288
5551
  return;
4289
5552
  }
@@ -4294,4 +5557,4 @@ await main().catch((cause) => {
4294
5557
  process.exitCode = 1;
4295
5558
  });
4296
5559
 
4297
- //# debugId=1A046C86E8783E4364756E2164756E21
5560
+ //# debugId=42161A8EF448D66964756E2164756E21