@akshar5/cohall 0.2.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/LICENSE +21 -0
- package/README.md +206 -0
- package/bin/cohall.js +3129 -0
- package/bin/cohall.js.map +23 -0
- package/package.json +74 -0
package/bin/cohall.js
ADDED
|
@@ -0,0 +1,3129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
17
|
+
|
|
18
|
+
// packages/protocol/src/index.ts
|
|
19
|
+
import { Schema } from "effect";
|
|
20
|
+
var version = "0.2.0", bounded = (maxLength) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maxLength)), optionalText = (maxLength) => Schema.String.check(Schema.isMaxLength(maxLength)), boundedArray = (schema, maxLength) => Schema.Array(schema).check(Schema.isMaxLength(maxLength)), uuid = (name) => Schema.String.check(Schema.isUUID(4)).pipe(Schema.brand(name)), isoTimestamp = (name) => Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/, {
|
|
21
|
+
expected: "an ISO-8601 UTC timestamp"
|
|
22
|
+
})).pipe(Schema.brand(name)), DeviceId, ThreadId, MessageId, TaskId, AuthSessionId, Timestamp, Platform, Provider, DeviceStatus, TaskStatus, MessageRole, ConnectionRole, AuthSession, CreatePairingInput, PairingCredential, ExchangePairingInput, SessionCredential, PairingResult, Capability, Workspace, Device, Thread, Message, Task, ThreadContext, CreateTaskInput, SocketEvent, ErrorResponse, terminalTaskStatuses, isTerminalTask = (task) => terminalTaskStatuses.has(task.status), now = () => Timestamp.make(new Date().toISOString()), makeDeviceId = () => DeviceId.make(crypto.randomUUID()), makeThreadId = () => ThreadId.make(crypto.randomUUID()), makeMessageId = () => MessageId.make(crypto.randomUUID()), makeTaskId = () => TaskId.make(crypto.randomUUID()), makeAuthSessionId = () => AuthSessionId.make(crypto.randomUUID()), decodeCreatePairingInput, decodeExchangePairingInput, decodeCreateTaskInput, decodeDevice, decodeSocketEvent;
|
|
23
|
+
var init_src = __esm(() => {
|
|
24
|
+
DeviceId = uuid("DeviceId");
|
|
25
|
+
ThreadId = uuid("ThreadId");
|
|
26
|
+
MessageId = uuid("MessageId");
|
|
27
|
+
TaskId = uuid("TaskId");
|
|
28
|
+
AuthSessionId = uuid("AuthSessionId");
|
|
29
|
+
Timestamp = isoTimestamp("Timestamp");
|
|
30
|
+
Platform = Schema.Literals(["darwin", "linux", "windows", "unknown"]);
|
|
31
|
+
Provider = Schema.Literals(["codex", "claude-code", "opencode"]);
|
|
32
|
+
DeviceStatus = Schema.Literals(["online", "busy", "offline"]);
|
|
33
|
+
TaskStatus = Schema.Literals([
|
|
34
|
+
"queued",
|
|
35
|
+
"assigned",
|
|
36
|
+
"running",
|
|
37
|
+
"cancelling",
|
|
38
|
+
"completed",
|
|
39
|
+
"failed",
|
|
40
|
+
"cancelled"
|
|
41
|
+
]);
|
|
42
|
+
MessageRole = Schema.Literals(["human", "agent", "system"]);
|
|
43
|
+
ConnectionRole = Schema.Literals(["client", "device"]);
|
|
44
|
+
AuthSession = Schema.Struct({
|
|
45
|
+
id: AuthSessionId,
|
|
46
|
+
label: bounded(128),
|
|
47
|
+
role: ConnectionRole,
|
|
48
|
+
createdAt: Timestamp,
|
|
49
|
+
expiresAt: Timestamp,
|
|
50
|
+
lastSeenAt: Timestamp,
|
|
51
|
+
deviceId: Schema.optionalKey(DeviceId),
|
|
52
|
+
revokedAt: Schema.optionalKey(Timestamp)
|
|
53
|
+
});
|
|
54
|
+
CreatePairingInput = Schema.Struct({
|
|
55
|
+
label: bounded(128),
|
|
56
|
+
roles: boundedArray(ConnectionRole, 2)
|
|
57
|
+
});
|
|
58
|
+
PairingCredential = Schema.Struct({
|
|
59
|
+
token: bounded(256),
|
|
60
|
+
expiresAt: Timestamp
|
|
61
|
+
});
|
|
62
|
+
ExchangePairingInput = Schema.Struct({
|
|
63
|
+
token: bounded(256)
|
|
64
|
+
});
|
|
65
|
+
SessionCredential = Schema.Struct({
|
|
66
|
+
token: bounded(256),
|
|
67
|
+
session: AuthSession
|
|
68
|
+
});
|
|
69
|
+
PairingResult = Schema.Struct({
|
|
70
|
+
credentials: boundedArray(SessionCredential, 2)
|
|
71
|
+
});
|
|
72
|
+
Capability = Schema.Struct({
|
|
73
|
+
id: bounded(64),
|
|
74
|
+
label: bounded(128),
|
|
75
|
+
detail: Schema.optionalKey(optionalText(512))
|
|
76
|
+
});
|
|
77
|
+
Workspace = Schema.Struct({
|
|
78
|
+
path: bounded(4096),
|
|
79
|
+
label: bounded(256)
|
|
80
|
+
});
|
|
81
|
+
Device = Schema.Struct({
|
|
82
|
+
id: DeviceId,
|
|
83
|
+
name: bounded(128),
|
|
84
|
+
hostname: bounded(256),
|
|
85
|
+
platform: Platform,
|
|
86
|
+
architecture: bounded(64),
|
|
87
|
+
status: DeviceStatus,
|
|
88
|
+
providers: boundedArray(Provider, 3),
|
|
89
|
+
capabilities: boundedArray(Capability, 64),
|
|
90
|
+
workspaces: boundedArray(Workspace, 64),
|
|
91
|
+
version: bounded(32),
|
|
92
|
+
lastSeenAt: Timestamp,
|
|
93
|
+
connectedAt: Schema.optionalKey(Timestamp)
|
|
94
|
+
});
|
|
95
|
+
Thread = Schema.Struct({
|
|
96
|
+
id: ThreadId,
|
|
97
|
+
title: bounded(256),
|
|
98
|
+
createdAt: Timestamp,
|
|
99
|
+
updatedAt: Timestamp,
|
|
100
|
+
defaultDeviceId: Schema.optionalKey(DeviceId)
|
|
101
|
+
});
|
|
102
|
+
Message = Schema.Struct({
|
|
103
|
+
id: MessageId,
|
|
104
|
+
threadId: ThreadId,
|
|
105
|
+
role: MessageRole,
|
|
106
|
+
authorName: bounded(128),
|
|
107
|
+
content: optionalText(131072),
|
|
108
|
+
createdAt: Timestamp,
|
|
109
|
+
taskId: Schema.optionalKey(TaskId),
|
|
110
|
+
deviceId: Schema.optionalKey(DeviceId)
|
|
111
|
+
});
|
|
112
|
+
Task = Schema.Struct({
|
|
113
|
+
id: TaskId,
|
|
114
|
+
threadId: ThreadId,
|
|
115
|
+
prompt: bounded(131072),
|
|
116
|
+
context: Schema.optionalKey(optionalText(131072)),
|
|
117
|
+
provider: Provider,
|
|
118
|
+
status: TaskStatus,
|
|
119
|
+
sourceDeviceId: Schema.optionalKey(DeviceId),
|
|
120
|
+
targetDeviceId: DeviceId,
|
|
121
|
+
parentTaskId: Schema.optionalKey(TaskId),
|
|
122
|
+
workspace: Schema.optionalKey(bounded(4096)),
|
|
123
|
+
providerSessionId: Schema.optionalKey(bounded(4096)),
|
|
124
|
+
result: Schema.optionalKey(optionalText(131072)),
|
|
125
|
+
error: Schema.optionalKey(optionalText(16384)),
|
|
126
|
+
createdAt: Timestamp,
|
|
127
|
+
updatedAt: Timestamp,
|
|
128
|
+
startedAt: Schema.optionalKey(Timestamp),
|
|
129
|
+
completedAt: Schema.optionalKey(Timestamp)
|
|
130
|
+
});
|
|
131
|
+
ThreadContext = Schema.Struct({
|
|
132
|
+
thread: Thread,
|
|
133
|
+
messages: boundedArray(Message, 20),
|
|
134
|
+
tasks: boundedArray(Task, 20),
|
|
135
|
+
truncated: Schema.Boolean
|
|
136
|
+
});
|
|
137
|
+
CreateTaskInput = Schema.Struct({
|
|
138
|
+
threadId: Schema.optionalKey(ThreadId),
|
|
139
|
+
title: Schema.optionalKey(bounded(256)),
|
|
140
|
+
prompt: bounded(131072),
|
|
141
|
+
context: Schema.optionalKey(optionalText(131072)),
|
|
142
|
+
provider: Schema.optionalKey(Provider),
|
|
143
|
+
targetDeviceId: Schema.optionalKey(DeviceId),
|
|
144
|
+
parentTaskId: Schema.optionalKey(TaskId),
|
|
145
|
+
workspace: Schema.optionalKey(bounded(4096))
|
|
146
|
+
});
|
|
147
|
+
SocketEvent = Schema.TaggedUnion({
|
|
148
|
+
Authenticate: { token: bounded(256) },
|
|
149
|
+
Connected: { serverVersion: bounded(32), connectedAt: Timestamp },
|
|
150
|
+
DeviceHello: { device: Device },
|
|
151
|
+
DeviceHeartbeat: { deviceId: DeviceId, status: DeviceStatus },
|
|
152
|
+
TaskAssigned: { task: Task },
|
|
153
|
+
TaskAccepted: { taskId: TaskId },
|
|
154
|
+
TaskFinished: {
|
|
155
|
+
taskId: TaskId,
|
|
156
|
+
result: optionalText(131072),
|
|
157
|
+
providerSessionId: Schema.optionalKey(bounded(4096))
|
|
158
|
+
},
|
|
159
|
+
TaskFailed: { taskId: TaskId, error: bounded(16384) },
|
|
160
|
+
TaskCancelled: { taskId: TaskId },
|
|
161
|
+
TaskSettled: { taskId: TaskId },
|
|
162
|
+
CancelTask: { taskId: TaskId },
|
|
163
|
+
Error: { code: bounded(64), message: bounded(2048) }
|
|
164
|
+
});
|
|
165
|
+
ErrorResponse = Schema.Struct({
|
|
166
|
+
error: bounded(2048)
|
|
167
|
+
});
|
|
168
|
+
terminalTaskStatuses = new Set([
|
|
169
|
+
"completed",
|
|
170
|
+
"failed",
|
|
171
|
+
"cancelled"
|
|
172
|
+
]);
|
|
173
|
+
decodeCreatePairingInput = Schema.decodeUnknownEffect(CreatePairingInput);
|
|
174
|
+
decodeExchangePairingInput = Schema.decodeUnknownEffect(ExchangePairingInput);
|
|
175
|
+
decodeCreateTaskInput = Schema.decodeUnknownEffect(CreateTaskInput);
|
|
176
|
+
decodeDevice = Schema.decodeUnknownEffect(Device);
|
|
177
|
+
decodeSocketEvent = Schema.decodeUnknownEffect(SocketEvent);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// apps/relay/src/config.ts
|
|
181
|
+
import { Effect as Effect8, Schema as Schema8 } from "effect";
|
|
182
|
+
import { access as access3, chmod as chmod2, mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
|
|
183
|
+
import { homedir as homedir3, platform as platform4 } from "node:os";
|
|
184
|
+
import { join as join4, resolve as resolve2 } from "node:path";
|
|
185
|
+
var RelayConfiguration, defaultDataDirectory = () => {
|
|
186
|
+
if (platform4() === "win32") {
|
|
187
|
+
return join4(process.env.LOCALAPPDATA ?? join4(homedir3(), "AppData", "Local"), "Cohall");
|
|
188
|
+
}
|
|
189
|
+
if (platform4() === "darwin") {
|
|
190
|
+
return join4(homedir3(), "Library", "Application Support", "Cohall", "relay");
|
|
191
|
+
}
|
|
192
|
+
return join4(process.env.XDG_DATA_HOME ?? join4(homedir3(), ".local", "share"), "cohall");
|
|
193
|
+
}, generatedToken = () => Array.from(crypto.getRandomValues(new Uint8Array(32)), (value) => value.toString(16).padStart(2, "0")).join(""), tokenFrom = async (path) => {
|
|
194
|
+
if (await access3(path).then(() => true).catch(() => false)) {
|
|
195
|
+
return (await readFile3(path, "utf8")).trim();
|
|
196
|
+
}
|
|
197
|
+
const token = generatedToken();
|
|
198
|
+
await writeFile3(path, `${token}
|
|
199
|
+
`, { mode: 384 });
|
|
200
|
+
if (platform4() !== "win32") {
|
|
201
|
+
await chmod2(path, 384);
|
|
202
|
+
}
|
|
203
|
+
return token;
|
|
204
|
+
}, booleanEnvironment = (name) => {
|
|
205
|
+
const value = process.env[name];
|
|
206
|
+
if (value === undefined || value === "false" || value === "0") {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (value === "true" || value === "1") {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`${name} must be true or false`);
|
|
213
|
+
}, isLoopback = (host) => host === "127.0.0.1" || host === "localhost" || host === "::1", loadEnvironmentConfiguration;
|
|
214
|
+
var init_config = __esm(() => {
|
|
215
|
+
RelayConfiguration = Schema8.Struct({
|
|
216
|
+
host: Schema8.NonEmptyString,
|
|
217
|
+
port: Schema8.Int.check(Schema8.isBetween({ minimum: 1, maximum: 65535 })),
|
|
218
|
+
token: Schema8.NonEmptyString,
|
|
219
|
+
dataDirectory: Schema8.NonEmptyString,
|
|
220
|
+
databasePath: Schema8.NonEmptyString,
|
|
221
|
+
allowRemote: Schema8.Boolean
|
|
222
|
+
});
|
|
223
|
+
loadEnvironmentConfiguration = Effect8.tryPromise({
|
|
224
|
+
try: async () => {
|
|
225
|
+
const host = process.env.COHALL_RELAY_HOST ?? "127.0.0.1";
|
|
226
|
+
const port = Number(process.env.COHALL_RELAY_PORT ?? "8787");
|
|
227
|
+
const allowRemote = booleanEnvironment("COHALL_RELAY_ALLOW_REMOTE");
|
|
228
|
+
if (!isLoopback(host) && !allowRemote) {
|
|
229
|
+
throw new Error("Remote relay binding is disabled. Use a private network or TLS reverse proxy, then set COHALL_RELAY_ALLOW_REMOTE=true.");
|
|
230
|
+
}
|
|
231
|
+
const dataDirectory = resolve2(process.env.COHALL_DATA_DIR ?? defaultDataDirectory());
|
|
232
|
+
await mkdir3(dataDirectory, { recursive: true, mode: 448 });
|
|
233
|
+
if (platform4() !== "win32") {
|
|
234
|
+
await chmod2(dataDirectory, 448);
|
|
235
|
+
}
|
|
236
|
+
const token = process.env.COHALL_TOKEN ?? await tokenFrom(join4(dataDirectory, "owner-token"));
|
|
237
|
+
return Schema8.decodeUnknownSync(RelayConfiguration)({
|
|
238
|
+
host,
|
|
239
|
+
port,
|
|
240
|
+
token,
|
|
241
|
+
dataDirectory,
|
|
242
|
+
databasePath: join4(dataDirectory, "cohall.db"),
|
|
243
|
+
allowRemote
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// apps/relay/src/hub.ts
|
|
251
|
+
import { WebSocket as WebSocket2 } from "ws";
|
|
252
|
+
|
|
253
|
+
class Hub {
|
|
254
|
+
#principals = new Map;
|
|
255
|
+
#devices = new Map;
|
|
256
|
+
#deviceIds = new Map;
|
|
257
|
+
attach(socket, principal) {
|
|
258
|
+
this.#principals.set(socket, principal);
|
|
259
|
+
if (socket.data.authDeadline !== undefined) {
|
|
260
|
+
clearTimeout(socket.data.authDeadline);
|
|
261
|
+
socket.data.authDeadline = undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
isAuthorized(socket) {
|
|
265
|
+
const principal = this.#principals.get(socket);
|
|
266
|
+
if (principal === undefined) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
if (principal.expiresAt !== undefined && Date.parse(principal.expiresAt) <= Date.now()) {
|
|
270
|
+
socket.close(4003, "Session expired");
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
boundDeviceId(socket) {
|
|
276
|
+
return this.#principals.get(socket)?.boundDeviceId;
|
|
277
|
+
}
|
|
278
|
+
registerDevice(deviceId, socket) {
|
|
279
|
+
const previous = this.#devices.get(deviceId);
|
|
280
|
+
if (previous !== undefined && previous !== socket && previous.readyState === WebSocket2.OPEN) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
this.#devices.set(deviceId, socket);
|
|
284
|
+
this.#deviceIds.set(socket, deviceId);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
detach(socket) {
|
|
288
|
+
if (socket.data.authDeadline !== undefined) {
|
|
289
|
+
clearTimeout(socket.data.authDeadline);
|
|
290
|
+
}
|
|
291
|
+
this.#principals.delete(socket);
|
|
292
|
+
const deviceId = this.#deviceIds.get(socket);
|
|
293
|
+
this.#deviceIds.delete(socket);
|
|
294
|
+
if (deviceId === undefined || this.#devices.get(deviceId) !== socket) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.#devices.delete(deviceId);
|
|
298
|
+
return deviceId;
|
|
299
|
+
}
|
|
300
|
+
deviceId(socket) {
|
|
301
|
+
return this.#deviceIds.get(socket);
|
|
302
|
+
}
|
|
303
|
+
closeSession(sessionId) {
|
|
304
|
+
for (const [socket, principal] of this.#principals) {
|
|
305
|
+
if (principal.sessionId === sessionId) {
|
|
306
|
+
socket.close(4003, "Session revoked");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
hasDevice(deviceId) {
|
|
311
|
+
return this.#devices.get(deviceId)?.readyState === WebSocket2.OPEN;
|
|
312
|
+
}
|
|
313
|
+
sendToDevice(deviceId, event) {
|
|
314
|
+
const socket = this.#devices.get(deviceId);
|
|
315
|
+
if (socket?.readyState !== WebSocket2.OPEN || !this.isAuthorized(socket)) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
socket.send(JSON.stringify(event));
|
|
320
|
+
return true;
|
|
321
|
+
} catch {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
var init_hub = () => {};
|
|
327
|
+
|
|
328
|
+
// apps/relay/src/database.ts
|
|
329
|
+
import { DatabaseSync } from "node:sqlite";
|
|
330
|
+
|
|
331
|
+
class Statement {
|
|
332
|
+
#statement;
|
|
333
|
+
constructor(statement) {
|
|
334
|
+
this.#statement = statement;
|
|
335
|
+
}
|
|
336
|
+
get(...parameters) {
|
|
337
|
+
const row = this.#statement.get(...parameters);
|
|
338
|
+
return row === undefined ? null : row;
|
|
339
|
+
}
|
|
340
|
+
all(...parameters) {
|
|
341
|
+
return this.#statement.all(...parameters);
|
|
342
|
+
}
|
|
343
|
+
run(...parameters) {
|
|
344
|
+
const result = this.#statement.run(...parameters);
|
|
345
|
+
return { changes: Number(result.changes) };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
class Database {
|
|
350
|
+
#database;
|
|
351
|
+
constructor(path) {
|
|
352
|
+
this.#database = new DatabaseSync(path);
|
|
353
|
+
}
|
|
354
|
+
query(sql) {
|
|
355
|
+
return new Statement(this.#database.prepare(sql));
|
|
356
|
+
}
|
|
357
|
+
exec(sql) {
|
|
358
|
+
this.#database.exec(sql);
|
|
359
|
+
}
|
|
360
|
+
transaction(operation) {
|
|
361
|
+
return () => {
|
|
362
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
363
|
+
try {
|
|
364
|
+
const result = operation();
|
|
365
|
+
this.#database.exec("COMMIT");
|
|
366
|
+
return result;
|
|
367
|
+
} catch (cause) {
|
|
368
|
+
this.#database.exec("ROLLBACK");
|
|
369
|
+
throw cause;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
close() {
|
|
374
|
+
this.#database.close();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
var init_database = () => {};
|
|
378
|
+
|
|
379
|
+
// apps/relay/src/store.ts
|
|
380
|
+
var exports_store = {};
|
|
381
|
+
__export(exports_store, {
|
|
382
|
+
layerFromDatabase: () => layerFromDatabase,
|
|
383
|
+
layer: () => layer2,
|
|
384
|
+
Service: () => Service2,
|
|
385
|
+
RelayStore: () => exports_store,
|
|
386
|
+
PersistenceError: () => PersistenceError
|
|
387
|
+
});
|
|
388
|
+
import { Context as Context2, Effect as Effect9, Layer as Layer2, Schema as Schema9 } from "effect";
|
|
389
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
390
|
+
var PersistenceError, Service2, operationError = (operation) => (cause) => new PersistenceError({
|
|
391
|
+
operation,
|
|
392
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
393
|
+
}), decode = (operation, schema, value) => Schema9.decodeUnknownEffect(schema)(value).pipe(Effect9.mapError(operationError(operation))), threadFromRow = (row) => decode("RelayStore.decodeThread", Thread, {
|
|
394
|
+
id: row.id,
|
|
395
|
+
title: row.title,
|
|
396
|
+
createdAt: row.created_at,
|
|
397
|
+
updatedAt: row.updated_at,
|
|
398
|
+
...row.default_device_id === null ? {} : { defaultDeviceId: row.default_device_id }
|
|
399
|
+
}), messageFromRow = (row) => decode("RelayStore.decodeMessage", Message, {
|
|
400
|
+
id: row.id,
|
|
401
|
+
threadId: row.thread_id,
|
|
402
|
+
role: row.role === "tool" ? "agent" : row.role,
|
|
403
|
+
authorName: row.author_name,
|
|
404
|
+
content: row.content,
|
|
405
|
+
createdAt: row.created_at,
|
|
406
|
+
...row.task_id === null ? {} : { taskId: row.task_id },
|
|
407
|
+
...row.device_id === null ? {} : { deviceId: row.device_id }
|
|
408
|
+
}), deviceFromRow = (row) => decode("RelayStore.decodeDevice", Device, {
|
|
409
|
+
id: row.id,
|
|
410
|
+
name: row.name,
|
|
411
|
+
hostname: row.hostname,
|
|
412
|
+
platform: row.platform,
|
|
413
|
+
architecture: row.architecture,
|
|
414
|
+
status: row.status,
|
|
415
|
+
providers: JSON.parse(row.providers_json),
|
|
416
|
+
capabilities: JSON.parse(row.capabilities_json),
|
|
417
|
+
workspaces: JSON.parse(row.workspaces_json),
|
|
418
|
+
version: row.version,
|
|
419
|
+
lastSeenAt: row.last_seen_at,
|
|
420
|
+
...row.connected_at === null ? {} : { connectedAt: row.connected_at }
|
|
421
|
+
}), taskFromRow = (row) => decode("RelayStore.decodeTask", Task, {
|
|
422
|
+
id: row.id,
|
|
423
|
+
threadId: row.thread_id,
|
|
424
|
+
prompt: row.prompt,
|
|
425
|
+
provider: row.provider,
|
|
426
|
+
status: row.status,
|
|
427
|
+
targetDeviceId: row.target_device_id,
|
|
428
|
+
createdAt: row.created_at,
|
|
429
|
+
updatedAt: row.updated_at,
|
|
430
|
+
...row.context === null ? {} : { context: row.context },
|
|
431
|
+
...row.source_device_id === null ? {} : { sourceDeviceId: row.source_device_id },
|
|
432
|
+
...row.parent_task_id === null ? {} : { parentTaskId: row.parent_task_id },
|
|
433
|
+
...row.workspace === null ? {} : { workspace: row.workspace },
|
|
434
|
+
...row.provider_session_id === null ? {} : { providerSessionId: row.provider_session_id },
|
|
435
|
+
...row.result === null ? {} : { result: row.result },
|
|
436
|
+
...row.error === null ? {} : { error: row.error },
|
|
437
|
+
...row.started_at === null ? {} : { startedAt: row.started_at },
|
|
438
|
+
...row.completed_at === null ? {} : { completedAt: row.completed_at }
|
|
439
|
+
}), rolesFromRow = (row) => Schema9.decodeUnknownSync(Schema9.Array(ConnectionRole))(JSON.parse(row.roles_json)), authSessionFromRow = (row) => {
|
|
440
|
+
const roles = rolesFromRow(row);
|
|
441
|
+
const role = roles[0];
|
|
442
|
+
return role === undefined ? Effect9.fail(new PersistenceError({
|
|
443
|
+
operation: "RelayStore.decodeAuthSession",
|
|
444
|
+
message: `Session ${row.id} has no role`
|
|
445
|
+
})) : decode("RelayStore.decodeAuthSession", AuthSession, {
|
|
446
|
+
id: row.id,
|
|
447
|
+
label: row.label,
|
|
448
|
+
role,
|
|
449
|
+
createdAt: row.created_at,
|
|
450
|
+
expiresAt: row.expires_at,
|
|
451
|
+
lastSeenAt: row.last_seen_at,
|
|
452
|
+
...row.bound_device_id === null ? {} : { deviceId: row.bound_device_id },
|
|
453
|
+
...row.revoked_at === null ? {} : { revokedAt: row.revoked_at }
|
|
454
|
+
});
|
|
455
|
+
}, secret = (prefix) => {
|
|
456
|
+
return `cohall_${prefix}_${randomBytes(32).toString("hex")}`;
|
|
457
|
+
}, tokenHash = (token) => createHash("sha256").update(token).digest("hex"), providerName = (provider) => {
|
|
458
|
+
switch (provider) {
|
|
459
|
+
case "codex":
|
|
460
|
+
return "Codex";
|
|
461
|
+
case "claude-code":
|
|
462
|
+
return "Claude Code";
|
|
463
|
+
case "opencode":
|
|
464
|
+
return "OpenCode";
|
|
465
|
+
}
|
|
466
|
+
}, recentWithin = (items, byteBudget) => {
|
|
467
|
+
const encoder = new TextEncoder;
|
|
468
|
+
const selected = [];
|
|
469
|
+
let bytes = 0;
|
|
470
|
+
for (let index = items.length - 1;index >= 0; index -= 1) {
|
|
471
|
+
const item = items[index];
|
|
472
|
+
if (item === undefined) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const itemBytes = encoder.encode(JSON.stringify(item)).byteLength;
|
|
476
|
+
if (bytes + itemBytes > byteBudget) {
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
bytes += itemBytes;
|
|
480
|
+
selected.unshift(item);
|
|
481
|
+
}
|
|
482
|
+
return { items: selected, truncated: selected.length < items.length };
|
|
483
|
+
}, makeService = (db) => {
|
|
484
|
+
const queryTask = (taskId) => db.query("SELECT * FROM tasks WHERE id = ?").get(taskId);
|
|
485
|
+
const getTask = Effect9.fn("RelayStore.getTask")(function* (taskId) {
|
|
486
|
+
const row = yield* Effect9.try({
|
|
487
|
+
try: () => queryTask(taskId),
|
|
488
|
+
catch: operationError("RelayStore.getTask")
|
|
489
|
+
});
|
|
490
|
+
if (row === null) {
|
|
491
|
+
return yield* Effect9.fail(new PersistenceError({
|
|
492
|
+
operation: "RelayStore.getTask",
|
|
493
|
+
message: `Unknown task ${taskId}`
|
|
494
|
+
}));
|
|
495
|
+
}
|
|
496
|
+
return yield* taskFromRow(row);
|
|
497
|
+
});
|
|
498
|
+
const listDevices = Effect9.fn("RelayStore.listDevices")(function* () {
|
|
499
|
+
const rows = yield* Effect9.try({
|
|
500
|
+
try: () => db.query("SELECT * FROM devices ORDER BY name COLLATE NOCASE, id").all(),
|
|
501
|
+
catch: operationError("RelayStore.listDevices")
|
|
502
|
+
});
|
|
503
|
+
return yield* Effect9.forEach(rows, deviceFromRow);
|
|
504
|
+
});
|
|
505
|
+
const recover = Effect9.fn("RelayStore.recover")(function* () {
|
|
506
|
+
yield* Effect9.try({
|
|
507
|
+
try: () => db.transaction(() => {
|
|
508
|
+
const timestamp = now();
|
|
509
|
+
db.query("UPDATE devices SET status = 'offline'").run();
|
|
510
|
+
db.query("UPDATE tasks SET status = 'queued', updated_at = ? WHERE status IN ('assigned', 'running')").run(timestamp);
|
|
511
|
+
db.query("DELETE FROM auth_pairings WHERE expires_at <= ? OR used_at IS NOT NULL").run(timestamp);
|
|
512
|
+
db.query("UPDATE auth_sessions SET revoked_at = ? WHERE revoked_at IS NULL AND roles_json LIKE '%,%'").run(timestamp);
|
|
513
|
+
})(),
|
|
514
|
+
catch: operationError("RelayStore.recover")
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
const upsertDevice = Effect9.fn("RelayStore.upsertDevice")(function* (device) {
|
|
518
|
+
yield* Effect9.try({
|
|
519
|
+
try: () => db.query(`INSERT INTO devices (
|
|
520
|
+
id, name, hostname, platform, architecture, status, providers_json,
|
|
521
|
+
capabilities_json, workspaces_json, version, last_seen_at, connected_at
|
|
522
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
523
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
524
|
+
name = excluded.name, hostname = excluded.hostname, platform = excluded.platform,
|
|
525
|
+
architecture = excluded.architecture, status = excluded.status,
|
|
526
|
+
providers_json = excluded.providers_json, capabilities_json = excluded.capabilities_json,
|
|
527
|
+
workspaces_json = excluded.workspaces_json, version = excluded.version,
|
|
528
|
+
last_seen_at = excluded.last_seen_at, connected_at = excluded.connected_at`).run(device.id, device.name, device.hostname, device.platform, device.architecture, device.status, JSON.stringify(device.providers), JSON.stringify(device.capabilities), JSON.stringify(device.workspaces), device.version, device.lastSeenAt, device.connectedAt ?? null),
|
|
529
|
+
catch: operationError("RelayStore.upsertDevice")
|
|
530
|
+
});
|
|
531
|
+
return device;
|
|
532
|
+
});
|
|
533
|
+
const updateDeviceStatus = Effect9.fn("RelayStore.updateDeviceStatus")(function* (deviceId, status) {
|
|
534
|
+
const timestamp = now();
|
|
535
|
+
const result = yield* Effect9.try({
|
|
536
|
+
try: () => db.query("UPDATE devices SET status = ?, last_seen_at = ? WHERE id = ?").run(status, timestamp, deviceId),
|
|
537
|
+
catch: operationError("RelayStore.updateDeviceStatus")
|
|
538
|
+
});
|
|
539
|
+
if (result.changes !== 1) {
|
|
540
|
+
return yield* Effect9.fail(new PersistenceError({
|
|
541
|
+
operation: "RelayStore.updateDeviceStatus",
|
|
542
|
+
message: `Unknown device ${deviceId}`
|
|
543
|
+
}));
|
|
544
|
+
}
|
|
545
|
+
const row = db.query("SELECT * FROM devices WHERE id = ?").get(deviceId);
|
|
546
|
+
if (row === null) {
|
|
547
|
+
return yield* Effect9.fail(new PersistenceError({
|
|
548
|
+
operation: "RelayStore.updateDeviceStatus.read",
|
|
549
|
+
message: `Device ${deviceId} disappeared after update`
|
|
550
|
+
}));
|
|
551
|
+
}
|
|
552
|
+
return yield* deviceFromRow(row);
|
|
553
|
+
});
|
|
554
|
+
const createDelegation2 = Effect9.fn("RelayStore.createDelegation")(function* (input, targetDeviceId, sourceDeviceId, providerSessionId) {
|
|
555
|
+
const timestamp = now();
|
|
556
|
+
const threadId = input.threadId ?? makeThreadId();
|
|
557
|
+
const task = Task.make({
|
|
558
|
+
id: makeTaskId(),
|
|
559
|
+
threadId,
|
|
560
|
+
prompt: input.prompt,
|
|
561
|
+
provider: input.provider ?? "codex",
|
|
562
|
+
status: "queued",
|
|
563
|
+
targetDeviceId,
|
|
564
|
+
createdAt: timestamp,
|
|
565
|
+
updatedAt: timestamp,
|
|
566
|
+
...input.context === undefined ? {} : { context: input.context },
|
|
567
|
+
...sourceDeviceId === undefined ? {} : { sourceDeviceId },
|
|
568
|
+
...input.parentTaskId === undefined ? {} : { parentTaskId: input.parentTaskId },
|
|
569
|
+
...input.workspace === undefined ? {} : { workspace: input.workspace },
|
|
570
|
+
...providerSessionId === undefined ? {} : { providerSessionId }
|
|
571
|
+
});
|
|
572
|
+
const thread = input.threadId === undefined ? Thread.make({
|
|
573
|
+
id: threadId,
|
|
574
|
+
title: input.title ?? input.prompt.slice(0, 72),
|
|
575
|
+
createdAt: timestamp,
|
|
576
|
+
updatedAt: timestamp
|
|
577
|
+
}) : undefined;
|
|
578
|
+
const message = Message.make({
|
|
579
|
+
id: makeMessageId(),
|
|
580
|
+
threadId,
|
|
581
|
+
role: "human",
|
|
582
|
+
authorName: sourceDeviceId === undefined ? "User" : "Remote agent",
|
|
583
|
+
content: input.prompt,
|
|
584
|
+
createdAt: timestamp,
|
|
585
|
+
taskId: task.id,
|
|
586
|
+
...sourceDeviceId === undefined ? {} : { deviceId: sourceDeviceId }
|
|
587
|
+
});
|
|
588
|
+
yield* Effect9.try({
|
|
589
|
+
try: () => db.transaction(() => {
|
|
590
|
+
const outstanding = db.query(`SELECT COUNT(*) AS count FROM tasks WHERE target_device_id = ?
|
|
591
|
+
AND status IN ('queued', 'assigned', 'running', 'cancelling')`).get(targetDeviceId);
|
|
592
|
+
if ((outstanding?.count ?? 0) >= 100) {
|
|
593
|
+
throw new Error(`Device ${targetDeviceId} has reached its outstanding task limit`);
|
|
594
|
+
}
|
|
595
|
+
if (thread !== undefined) {
|
|
596
|
+
db.query("INSERT INTO threads (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)").run(thread.id, thread.title, thread.createdAt, thread.updatedAt);
|
|
597
|
+
} else if (db.query("SELECT id FROM threads WHERE id = ?").get(threadId) === null) {
|
|
598
|
+
throw new Error(`Unknown thread ${threadId}`);
|
|
599
|
+
}
|
|
600
|
+
db.query(`INSERT INTO tasks (
|
|
601
|
+
id, thread_id, prompt, context, provider, status, source_device_id,
|
|
602
|
+
target_device_id, parent_task_id, workspace, provider_session_id,
|
|
603
|
+
created_at, updated_at
|
|
604
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(task.id, task.threadId, task.prompt, task.context ?? null, task.provider, task.status, task.sourceDeviceId ?? null, task.targetDeviceId, task.parentTaskId ?? null, task.workspace ?? null, task.providerSessionId ?? null, task.createdAt, task.updatedAt);
|
|
605
|
+
db.query(`INSERT INTO messages (
|
|
606
|
+
id, thread_id, role, kind, author_id, author_name, content, created_at,
|
|
607
|
+
task_id, device_id
|
|
608
|
+
) VALUES (?, ?, ?, 'chat', ?, ?, ?, ?, ?, ?)`).run(message.id, message.threadId, message.role, sourceDeviceId ?? "user", message.authorName, message.content, message.createdAt, message.taskId ?? null, message.deviceId ?? null);
|
|
609
|
+
db.query("UPDATE threads SET updated_at = ? WHERE id = ?").run(timestamp, threadId);
|
|
610
|
+
})(),
|
|
611
|
+
catch: operationError("RelayStore.createDelegation")
|
|
612
|
+
});
|
|
613
|
+
return task;
|
|
614
|
+
});
|
|
615
|
+
const threadContext2 = Effect9.fn("RelayStore.threadContext")(function* (threadId) {
|
|
616
|
+
const threadRow = yield* Effect9.try({
|
|
617
|
+
try: () => db.query("SELECT * FROM threads WHERE id = ?").get(threadId),
|
|
618
|
+
catch: operationError("RelayStore.threadContext.thread")
|
|
619
|
+
});
|
|
620
|
+
if (threadRow === null) {
|
|
621
|
+
return yield* Effect9.fail(new PersistenceError({
|
|
622
|
+
operation: "RelayStore.threadContext",
|
|
623
|
+
message: `Unknown thread ${threadId}`
|
|
624
|
+
}));
|
|
625
|
+
}
|
|
626
|
+
const [messageRows, taskRows] = yield* Effect9.try({
|
|
627
|
+
try: () => [
|
|
628
|
+
db.query(`SELECT * FROM (
|
|
629
|
+
SELECT * FROM messages WHERE thread_id = ?
|
|
630
|
+
ORDER BY created_at DESC, id DESC LIMIT 21
|
|
631
|
+
) ORDER BY created_at, id`).all(threadId),
|
|
632
|
+
db.query(`SELECT * FROM (
|
|
633
|
+
SELECT * FROM tasks WHERE thread_id = ?
|
|
634
|
+
ORDER BY created_at DESC, id DESC LIMIT 21
|
|
635
|
+
) ORDER BY created_at, id`).all(threadId)
|
|
636
|
+
],
|
|
637
|
+
catch: operationError("RelayStore.threadContext.items")
|
|
638
|
+
});
|
|
639
|
+
const [thread, messages, tasks] = yield* Effect9.all([
|
|
640
|
+
threadFromRow(threadRow),
|
|
641
|
+
Effect9.forEach(messageRows.slice(-20), messageFromRow),
|
|
642
|
+
Effect9.forEach(taskRows.slice(-20), taskFromRow)
|
|
643
|
+
]);
|
|
644
|
+
const recentMessages = recentWithin(messages, 256 * 1024);
|
|
645
|
+
const recentTasks = recentWithin(tasks, 768 * 1024);
|
|
646
|
+
return ThreadContext.make({
|
|
647
|
+
thread,
|
|
648
|
+
messages: recentMessages.items,
|
|
649
|
+
tasks: recentTasks.items,
|
|
650
|
+
truncated: messageRows.length > 20 || taskRows.length > 20 || recentMessages.truncated || recentTasks.truncated
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
const pendingTasksFor = Effect9.fn("RelayStore.pendingTasksFor")(function* (deviceId) {
|
|
654
|
+
const rows = yield* Effect9.try({
|
|
655
|
+
try: () => db.query(`SELECT * FROM tasks WHERE target_device_id = ?
|
|
656
|
+
AND status IN ('queued', 'cancelling') ORDER BY created_at, id LIMIT 100`).all(deviceId),
|
|
657
|
+
catch: operationError("RelayStore.pendingTasksFor")
|
|
658
|
+
});
|
|
659
|
+
return yield* Effect9.forEach(rows, taskFromRow);
|
|
660
|
+
});
|
|
661
|
+
const transition = Effect9.fn("RelayStore.transition")(function* (taskId, expected, update) {
|
|
662
|
+
const current = yield* getTask(taskId);
|
|
663
|
+
if (!expected.includes(current.status)) {
|
|
664
|
+
return current;
|
|
665
|
+
}
|
|
666
|
+
const placeholders = expected.map(() => "?").join(", ");
|
|
667
|
+
yield* Effect9.try({
|
|
668
|
+
try: () => db.query(`UPDATE tasks SET status = ?, provider_session_id = ?, result = ?, error = ?,
|
|
669
|
+
started_at = ?, completed_at = ?, updated_at = ?
|
|
670
|
+
WHERE id = ? AND status IN (${placeholders})`).run(update.status ?? current.status, update.providerSessionId ?? current.providerSessionId ?? null, update.result ?? current.result ?? null, update.error ?? current.error ?? null, update.startedAt ?? current.startedAt ?? null, update.completedAt ?? current.completedAt ?? null, now(), taskId, ...expected),
|
|
671
|
+
catch: operationError("RelayStore.transition")
|
|
672
|
+
});
|
|
673
|
+
return yield* getTask(taskId);
|
|
674
|
+
});
|
|
675
|
+
const assignTask = Effect9.fn("RelayStore.assignTask")(function* (taskId) {
|
|
676
|
+
const current = yield* getTask(taskId);
|
|
677
|
+
if (current.status !== "queued") {
|
|
678
|
+
return current;
|
|
679
|
+
}
|
|
680
|
+
yield* Effect9.try({
|
|
681
|
+
try: () => db.query(`UPDATE tasks SET status = 'assigned', updated_at = ?
|
|
682
|
+
WHERE id = ? AND status = 'queued' AND NOT EXISTS (
|
|
683
|
+
SELECT 1 FROM tasks active
|
|
684
|
+
WHERE active.target_device_id = ? AND active.id <> ?
|
|
685
|
+
AND active.status IN ('assigned', 'running', 'cancelling')
|
|
686
|
+
)`).run(now(), taskId, current.targetDeviceId, taskId),
|
|
687
|
+
catch: operationError("RelayStore.assignTask")
|
|
688
|
+
});
|
|
689
|
+
return yield* getTask(taskId);
|
|
690
|
+
});
|
|
691
|
+
const requireTarget = Effect9.fn("RelayStore.requireTarget")(function* (taskId, deviceId) {
|
|
692
|
+
const task = yield* getTask(taskId);
|
|
693
|
+
if (task.targetDeviceId !== deviceId) {
|
|
694
|
+
return yield* Effect9.fail(new PersistenceError({
|
|
695
|
+
operation: "RelayStore.requireTarget",
|
|
696
|
+
message: `Task ${taskId} belongs to another device`
|
|
697
|
+
}));
|
|
698
|
+
}
|
|
699
|
+
return task;
|
|
700
|
+
});
|
|
701
|
+
const terminal = Effect9.fn("RelayStore.terminal")(function* (taskId, deviceId, status, result, error, providerSessionId) {
|
|
702
|
+
const current = yield* requireTarget(taskId, deviceId);
|
|
703
|
+
if (["completed", "failed", "cancelled"].includes(current.status)) {
|
|
704
|
+
return current;
|
|
705
|
+
}
|
|
706
|
+
const timestamp = now();
|
|
707
|
+
yield* Effect9.try({
|
|
708
|
+
try: () => db.transaction(() => {
|
|
709
|
+
const updated = db.query(`UPDATE tasks SET status = ?, result = ?, error = ?, provider_session_id = ?,
|
|
710
|
+
completed_at = ?, updated_at = ? WHERE id = ?
|
|
711
|
+
AND status NOT IN ('completed', 'failed', 'cancelled')`).run(status, result ?? null, error ?? null, providerSessionId ?? current.providerSessionId ?? null, timestamp, timestamp, taskId);
|
|
712
|
+
if (updated.changes !== 1) {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (providerSessionId !== undefined) {
|
|
716
|
+
db.query(`INSERT INTO provider_sessions (thread_id, device_id, provider, session_id, updated_at)
|
|
717
|
+
VALUES (?, ?, ?, ?, ?)
|
|
718
|
+
ON CONFLICT(thread_id, device_id, provider) DO UPDATE SET
|
|
719
|
+
session_id = excluded.session_id, updated_at = excluded.updated_at`).run(current.threadId, deviceId, current.provider, providerSessionId, timestamp);
|
|
720
|
+
}
|
|
721
|
+
const content = result ?? error;
|
|
722
|
+
if (content !== undefined) {
|
|
723
|
+
const role = status === "completed" ? "agent" : "system";
|
|
724
|
+
db.query(`INSERT INTO messages (
|
|
725
|
+
id, thread_id, role, kind, author_id, author_name, content, created_at,
|
|
726
|
+
task_id, device_id
|
|
727
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(makeMessageId(), current.threadId, role, status === "failed" ? "error" : "chat", deviceId, status === "completed" ? providerName(current.provider) : "Cohall", content, timestamp, taskId, deviceId);
|
|
728
|
+
db.query("UPDATE threads SET updated_at = ? WHERE id = ?").run(timestamp, current.threadId);
|
|
729
|
+
}
|
|
730
|
+
})(),
|
|
731
|
+
catch: operationError("RelayStore.terminal")
|
|
732
|
+
});
|
|
733
|
+
return yield* getTask(taskId);
|
|
734
|
+
});
|
|
735
|
+
const requestCancellation = Effect9.fn("RelayStore.requestCancellation")(function* (taskId) {
|
|
736
|
+
const current = yield* getTask(taskId);
|
|
737
|
+
if (["completed", "failed", "cancelled"].includes(current.status)) {
|
|
738
|
+
return current;
|
|
739
|
+
}
|
|
740
|
+
return current.status === "queued" ? yield* transition(taskId, ["queued"], { status: "cancelled", completedAt: now() }) : yield* transition(taskId, ["assigned", "running", "cancelling"], {
|
|
741
|
+
status: "cancelling"
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
const requeueTasksFor = Effect9.fn("RelayStore.requeueTasksFor")(function* (deviceId) {
|
|
745
|
+
yield* Effect9.try({
|
|
746
|
+
try: () => db.query(`UPDATE tasks SET status = 'queued', updated_at = ?
|
|
747
|
+
WHERE target_device_id = ? AND status IN ('assigned', 'running')`).run(now(), deviceId),
|
|
748
|
+
catch: operationError("RelayStore.requeueTasksFor")
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
const sessionFor = Effect9.fn("RelayStore.sessionFor")(function* (threadId, deviceId, provider) {
|
|
752
|
+
const row = yield* Effect9.try({
|
|
753
|
+
try: () => db.query(`SELECT session_id FROM provider_sessions
|
|
754
|
+
WHERE thread_id = ? AND device_id = ? AND provider = ?`).get(threadId, deviceId, provider),
|
|
755
|
+
catch: operationError("RelayStore.sessionFor")
|
|
756
|
+
});
|
|
757
|
+
return row?.session_id;
|
|
758
|
+
});
|
|
759
|
+
const createPairing = Effect9.fn("RelayStore.createPairing")(function* (input) {
|
|
760
|
+
const roles = [...new Set(input.roles)];
|
|
761
|
+
if (roles.length === 0) {
|
|
762
|
+
return yield* Effect9.fail(new PersistenceError({
|
|
763
|
+
operation: "RelayStore.createPairing",
|
|
764
|
+
message: "A pairing must allow at least one role"
|
|
765
|
+
}));
|
|
766
|
+
}
|
|
767
|
+
const token = secret("pair");
|
|
768
|
+
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
|
769
|
+
yield* Effect9.try({
|
|
770
|
+
try: () => db.query(`INSERT INTO auth_pairings (token_hash, label, roles_json, expires_at, created_at)
|
|
771
|
+
VALUES (?, ?, ?, ?, ?)`).run(tokenHash(token), input.label, JSON.stringify(roles), expiresAt, now()),
|
|
772
|
+
catch: operationError("RelayStore.createPairing")
|
|
773
|
+
});
|
|
774
|
+
return PairingCredential.make({ token, expiresAt: Timestamp.make(expiresAt) });
|
|
775
|
+
});
|
|
776
|
+
const exchangePairing2 = Effect9.fn("RelayStore.exchangePairing")(function* (token) {
|
|
777
|
+
const timestamp = now();
|
|
778
|
+
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
|
779
|
+
const created = yield* Effect9.try({
|
|
780
|
+
try: () => db.transaction(() => {
|
|
781
|
+
const pairing = db.query(`SELECT * FROM auth_pairings
|
|
782
|
+
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`).get(tokenHash(token), timestamp);
|
|
783
|
+
if (pairing === null) {
|
|
784
|
+
throw new Error("Pairing credential is invalid, expired, or already used");
|
|
785
|
+
}
|
|
786
|
+
const roles = Schema9.decodeUnknownSync(Schema9.Array(ConnectionRole))(JSON.parse(pairing.roles_json));
|
|
787
|
+
const deviceId = roles.includes("device") ? makeDeviceId() : undefined;
|
|
788
|
+
const consumed = db.query("UPDATE auth_pairings SET used_at = ? WHERE token_hash = ? AND used_at IS NULL").run(timestamp, pairing.token_hash);
|
|
789
|
+
if (consumed.changes !== 1) {
|
|
790
|
+
throw new Error("Pairing credential was already used");
|
|
791
|
+
}
|
|
792
|
+
return [...new Set(roles)].map((role) => {
|
|
793
|
+
const sessionToken = secret("session");
|
|
794
|
+
const row = {
|
|
795
|
+
id: makeAuthSessionId(),
|
|
796
|
+
token_hash: tokenHash(sessionToken),
|
|
797
|
+
label: pairing.label,
|
|
798
|
+
roles_json: JSON.stringify([role]),
|
|
799
|
+
created_at: timestamp,
|
|
800
|
+
expires_at: expiresAt,
|
|
801
|
+
last_seen_at: timestamp,
|
|
802
|
+
bound_device_id: role === "device" ? deviceId ?? null : null,
|
|
803
|
+
revoked_at: null
|
|
804
|
+
};
|
|
805
|
+
db.query(`INSERT INTO auth_sessions (
|
|
806
|
+
id, token_hash, label, roles_json, created_at, expires_at, last_seen_at,
|
|
807
|
+
bound_device_id
|
|
808
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(row.id, row.token_hash, row.label, row.roles_json, row.created_at, row.expires_at, row.last_seen_at, row.bound_device_id);
|
|
809
|
+
return { token: sessionToken, row };
|
|
810
|
+
});
|
|
811
|
+
})(),
|
|
812
|
+
catch: operationError("RelayStore.exchangePairing")
|
|
813
|
+
});
|
|
814
|
+
const credentials = yield* Effect9.forEach(created, ({ token: sessionToken, row }) => authSessionFromRow(row).pipe(Effect9.map((session) => ({ token: sessionToken, session }))));
|
|
815
|
+
return PairingResult.make({ credentials });
|
|
816
|
+
});
|
|
817
|
+
const authenticateSession = Effect9.fn("RelayStore.authenticateSession")(function* (token, role) {
|
|
818
|
+
const row = yield* Effect9.try({
|
|
819
|
+
try: () => db.query(`SELECT * FROM auth_sessions
|
|
820
|
+
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ?`).get(tokenHash(token), now()),
|
|
821
|
+
catch: operationError("RelayStore.authenticateSession")
|
|
822
|
+
});
|
|
823
|
+
if (row === null || !rolesFromRow(row).includes(role)) {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const lastSeenAt = now();
|
|
827
|
+
yield* Effect9.try({
|
|
828
|
+
try: () => db.query("UPDATE auth_sessions SET last_seen_at = ? WHERE id = ?").run(lastSeenAt, row.id),
|
|
829
|
+
catch: operationError("RelayStore.authenticateSession.touch")
|
|
830
|
+
});
|
|
831
|
+
const session = yield* authSessionFromRow(row);
|
|
832
|
+
return AuthSession.make({ ...session, lastSeenAt });
|
|
833
|
+
});
|
|
834
|
+
const listAuthSessions = Effect9.fn("RelayStore.listAuthSessions")(function* () {
|
|
835
|
+
const rows = yield* Effect9.try({
|
|
836
|
+
try: () => db.query("SELECT * FROM auth_sessions ORDER BY created_at DESC, id LIMIT 1000").all(),
|
|
837
|
+
catch: operationError("RelayStore.listAuthSessions")
|
|
838
|
+
});
|
|
839
|
+
return yield* Effect9.forEach(rows, authSessionFromRow);
|
|
840
|
+
});
|
|
841
|
+
const revokeAuthSession = Effect9.fn("RelayStore.revokeAuthSession")(function* (sessionId) {
|
|
842
|
+
const revokedAt = now();
|
|
843
|
+
const result = yield* Effect9.try({
|
|
844
|
+
try: () => db.query("UPDATE auth_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(revokedAt, sessionId),
|
|
845
|
+
catch: operationError("RelayStore.revokeAuthSession")
|
|
846
|
+
});
|
|
847
|
+
if (result.changes !== 1) {
|
|
848
|
+
return yield* Effect9.fail(new PersistenceError({
|
|
849
|
+
operation: "RelayStore.revokeAuthSession",
|
|
850
|
+
message: `Unknown or already revoked session ${sessionId}`
|
|
851
|
+
}));
|
|
852
|
+
}
|
|
853
|
+
const row = db.query("SELECT * FROM auth_sessions WHERE id = ?").get(sessionId);
|
|
854
|
+
if (row === null) {
|
|
855
|
+
return yield* Effect9.fail(new PersistenceError({
|
|
856
|
+
operation: "RelayStore.revokeAuthSession.read",
|
|
857
|
+
message: `Session ${sessionId} disappeared after revocation`
|
|
858
|
+
}));
|
|
859
|
+
}
|
|
860
|
+
return yield* authSessionFromRow(row);
|
|
861
|
+
});
|
|
862
|
+
return Service2.of({
|
|
863
|
+
recover,
|
|
864
|
+
listDevices,
|
|
865
|
+
upsertDevice,
|
|
866
|
+
heartbeat: (deviceId, status) => updateDeviceStatus(deviceId, status),
|
|
867
|
+
markDeviceOffline: (deviceId) => updateDeviceStatus(deviceId, "offline"),
|
|
868
|
+
createDelegation: createDelegation2,
|
|
869
|
+
getTask,
|
|
870
|
+
threadContext: threadContext2,
|
|
871
|
+
pendingTasksFor,
|
|
872
|
+
assignTask,
|
|
873
|
+
rollbackAssignment: (taskId) => transition(taskId, ["assigned"], { status: "queued" }),
|
|
874
|
+
acceptTask: (taskId, deviceId) => requireTarget(taskId, deviceId).pipe(Effect9.flatMap(() => transition(taskId, ["assigned"], { status: "running", startedAt: now() }))),
|
|
875
|
+
finishTask: (taskId, deviceId, result, providerSessionId) => terminal(taskId, deviceId, "completed", result, undefined, providerSessionId),
|
|
876
|
+
failTask: (taskId, deviceId, error) => terminal(taskId, deviceId, "failed", undefined, error),
|
|
877
|
+
acknowledgeCancellation: (taskId, deviceId) => terminal(taskId, deviceId, "cancelled"),
|
|
878
|
+
requestCancellation,
|
|
879
|
+
requeueTasksFor,
|
|
880
|
+
sessionFor,
|
|
881
|
+
createPairing,
|
|
882
|
+
exchangePairing: exchangePairing2,
|
|
883
|
+
authenticateSession,
|
|
884
|
+
listAuthSessions,
|
|
885
|
+
revokeAuthSession
|
|
886
|
+
});
|
|
887
|
+
}, migrate = (db) => Effect9.try({
|
|
888
|
+
try: () => {
|
|
889
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
890
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
891
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
892
|
+
db.exec(`
|
|
893
|
+
CREATE TABLE IF NOT EXISTS devices (
|
|
894
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, hostname TEXT NOT NULL,
|
|
895
|
+
platform TEXT NOT NULL, architecture TEXT NOT NULL, status TEXT NOT NULL,
|
|
896
|
+
providers_json TEXT NOT NULL, capabilities_json TEXT NOT NULL,
|
|
897
|
+
workspaces_json TEXT NOT NULL, version TEXT NOT NULL, last_seen_at TEXT NOT NULL,
|
|
898
|
+
connected_at TEXT
|
|
899
|
+
);
|
|
900
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
901
|
+
id TEXT PRIMARY KEY, title TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
902
|
+
updated_at TEXT NOT NULL, archived_at TEXT,
|
|
903
|
+
default_device_id TEXT REFERENCES devices(id)
|
|
904
|
+
);
|
|
905
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
906
|
+
id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
907
|
+
role TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'chat', author_id TEXT NOT NULL,
|
|
908
|
+
author_name TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
909
|
+
task_id TEXT, reply_to TEXT, device_id TEXT REFERENCES devices(id)
|
|
910
|
+
);
|
|
911
|
+
CREATE INDEX IF NOT EXISTS messages_thread_created ON messages(thread_id, created_at);
|
|
912
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
913
|
+
id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
914
|
+
prompt TEXT NOT NULL, context TEXT, provider TEXT NOT NULL, status TEXT NOT NULL,
|
|
915
|
+
source_device_id TEXT REFERENCES devices(id),
|
|
916
|
+
target_device_id TEXT NOT NULL REFERENCES devices(id), parent_task_id TEXT,
|
|
917
|
+
workspace TEXT, provider_session_id TEXT, result TEXT, error TEXT,
|
|
918
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, started_at TEXT, completed_at TEXT
|
|
919
|
+
);
|
|
920
|
+
CREATE INDEX IF NOT EXISTS tasks_thread_created ON tasks(thread_id, created_at);
|
|
921
|
+
CREATE INDEX IF NOT EXISTS tasks_target_status ON tasks(target_device_id, status);
|
|
922
|
+
CREATE TABLE IF NOT EXISTS provider_sessions (
|
|
923
|
+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
924
|
+
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
925
|
+
provider TEXT NOT NULL, session_id TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
926
|
+
PRIMARY KEY(thread_id, device_id, provider)
|
|
927
|
+
);
|
|
928
|
+
CREATE TABLE IF NOT EXISTS auth_pairings (
|
|
929
|
+
token_hash TEXT PRIMARY KEY, label TEXT NOT NULL, roles_json TEXT NOT NULL,
|
|
930
|
+
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, used_at TEXT
|
|
931
|
+
);
|
|
932
|
+
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
933
|
+
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, label TEXT NOT NULL,
|
|
934
|
+
roles_json TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
|
|
935
|
+
last_seen_at TEXT NOT NULL, bound_device_id TEXT, revoked_at TEXT
|
|
936
|
+
);
|
|
937
|
+
CREATE INDEX IF NOT EXISTS auth_sessions_token ON auth_sessions(token_hash);
|
|
938
|
+
`);
|
|
939
|
+
},
|
|
940
|
+
catch: operationError("RelayStore.migrate")
|
|
941
|
+
}), layer2 = (path) => Layer2.effect(Service2, Effect9.acquireRelease(Effect9.try({
|
|
942
|
+
try: () => new Database(path),
|
|
943
|
+
catch: operationError("RelayStore.open")
|
|
944
|
+
}).pipe(Effect9.tap(migrate)), (db) => Effect9.sync(() => db.close())).pipe(Effect9.map(makeService))), layerFromDatabase = (db) => Layer2.effect(Service2, migrate(db).pipe(Effect9.map(() => makeService(db))));
|
|
945
|
+
var init_store = __esm(() => {
|
|
946
|
+
init_src();
|
|
947
|
+
init_database();
|
|
948
|
+
init_store();
|
|
949
|
+
PersistenceError = class PersistenceError extends Schema9.TaggedErrorClass()("RelayStore.PersistenceError", { operation: Schema9.String, message: Schema9.String }) {
|
|
950
|
+
};
|
|
951
|
+
Service2 = class Service2 extends Context2.Service()("@cohall/RelayStore") {
|
|
952
|
+
};
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
// apps/relay/src/main.ts
|
|
956
|
+
var exports_main = {};
|
|
957
|
+
__export(exports_main, {
|
|
958
|
+
runRelay: () => runRelay
|
|
959
|
+
});
|
|
960
|
+
import { Effect as Effect10, ManagedRuntime, Schema as Schema10 } from "effect";
|
|
961
|
+
import { createHash as createHash2, timingSafeEqual } from "node:crypto";
|
|
962
|
+
import { access as access4, chmod as chmod3 } from "node:fs/promises";
|
|
963
|
+
import { createServer } from "node:http";
|
|
964
|
+
import { platform as platform5 } from "node:os";
|
|
965
|
+
import { WebSocketServer } from "ws";
|
|
966
|
+
var RequestError, json = (value, status = 200) => Response.json(value, {
|
|
967
|
+
status,
|
|
968
|
+
headers: {
|
|
969
|
+
"cache-control": "no-store",
|
|
970
|
+
"x-content-type-options": "nosniff"
|
|
971
|
+
}
|
|
972
|
+
}), requestBody = (request, limit) => {
|
|
973
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
974
|
+
return Promise.resolve(undefined);
|
|
975
|
+
}
|
|
976
|
+
return new Promise((resolve3, reject) => {
|
|
977
|
+
const chunks = [];
|
|
978
|
+
let size = 0;
|
|
979
|
+
const cleanup = () => {
|
|
980
|
+
request.off("data", onData);
|
|
981
|
+
request.off("end", onEnd);
|
|
982
|
+
request.off("error", onError);
|
|
983
|
+
};
|
|
984
|
+
const onData = (chunk) => {
|
|
985
|
+
size += chunk.byteLength;
|
|
986
|
+
if (size <= limit) {
|
|
987
|
+
chunks.push(chunk);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
cleanup();
|
|
991
|
+
reject(new Error("Request body exceeded 256 KiB"));
|
|
992
|
+
};
|
|
993
|
+
const onEnd = () => {
|
|
994
|
+
cleanup();
|
|
995
|
+
resolve3(Buffer.concat(chunks, size));
|
|
996
|
+
};
|
|
997
|
+
const onError = (cause) => {
|
|
998
|
+
cleanup();
|
|
999
|
+
reject(cause);
|
|
1000
|
+
};
|
|
1001
|
+
request.on("data", onData);
|
|
1002
|
+
request.once("end", onEnd);
|
|
1003
|
+
request.once("error", onError);
|
|
1004
|
+
});
|
|
1005
|
+
}, webRequest = async (request) => {
|
|
1006
|
+
const headers = new Headers;
|
|
1007
|
+
for (let index = 0;index < request.rawHeaders.length; index += 2) {
|
|
1008
|
+
const name = request.rawHeaders[index];
|
|
1009
|
+
const value = request.rawHeaders[index + 1];
|
|
1010
|
+
if (name !== undefined && value !== undefined) {
|
|
1011
|
+
headers.append(name, value);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
const payload = await requestBody(request, 256 * 1024);
|
|
1015
|
+
return new Request(new URL(request.url ?? "/", "http://cohall.local"), {
|
|
1016
|
+
method: request.method ?? "GET",
|
|
1017
|
+
headers,
|
|
1018
|
+
...payload === undefined ? {} : { body: new Uint8Array(payload) }
|
|
1019
|
+
});
|
|
1020
|
+
}, sendResponse = async (target, response) => {
|
|
1021
|
+
target.statusCode = response.status;
|
|
1022
|
+
response.headers.forEach((value, name) => target.setHeader(name, value));
|
|
1023
|
+
target.end(Buffer.from(await response.arrayBuffer()));
|
|
1024
|
+
}, socketText = (message) => {
|
|
1025
|
+
if (Buffer.isBuffer(message)) {
|
|
1026
|
+
return message.toString("utf8");
|
|
1027
|
+
}
|
|
1028
|
+
if (message instanceof ArrayBuffer) {
|
|
1029
|
+
return Buffer.from(message).toString("utf8");
|
|
1030
|
+
}
|
|
1031
|
+
if (Array.isArray(message)) {
|
|
1032
|
+
return Buffer.concat(message).toString("utf8");
|
|
1033
|
+
}
|
|
1034
|
+
return Buffer.from(message).toString("utf8");
|
|
1035
|
+
}, rejectUpgrade = (socket, status, message) => {
|
|
1036
|
+
socket.write(`HTTP/1.1 ${status} ${message}\r
|
|
1037
|
+
Connection: close\r
|
|
1038
|
+
Content-Length: 0\r
|
|
1039
|
+
\r
|
|
1040
|
+
`);
|
|
1041
|
+
if ("destroy" in socket && typeof socket.destroy === "function") {
|
|
1042
|
+
socket.destroy();
|
|
1043
|
+
}
|
|
1044
|
+
}, body = (request, decode2) => Effect10.tryPromise({
|
|
1045
|
+
try: () => request.json(),
|
|
1046
|
+
catch: () => new RequestError({ status: 400, message: "Expected a JSON request body" })
|
|
1047
|
+
}).pipe(Effect10.flatMap(decode2), Effect10.mapError((cause) => cause instanceof RequestError ? cause : new RequestError({ status: 400, message: String(cause).slice(0, 2048) }))), pathId = (schema, value) => Schema10.decodeUnknownEffect(schema)(value).pipe(Effect10.mapError(() => new RequestError({ status: 400, message: `Invalid identifier: ${value}` }))), scoreDevice = (device, input, sourceDeviceId) => {
|
|
1048
|
+
const prompt = `${input.prompt} ${input.context ?? ""}`.toLowerCase();
|
|
1049
|
+
const needsXcode = /\b(xcode|ios|macos|swiftui|app store)\b/.test(prompt);
|
|
1050
|
+
const needsBrowser = /\b(browser|chrome|youtube|twitter|logged[- ]?in|signed[- ]?in|x\.com)\b/.test(prompt);
|
|
1051
|
+
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);
|
|
1052
|
+
}, chooseDevice = (input, sourceDeviceId) => Effect10.gen(function* () {
|
|
1053
|
+
const store = yield* exports_store.Service;
|
|
1054
|
+
const devices = yield* store.listDevices().pipe(Effect10.mapError((cause) => new RequestError({ status: 500, message: cause.message })));
|
|
1055
|
+
const provider = input.provider ?? "codex";
|
|
1056
|
+
if (input.targetDeviceId !== undefined) {
|
|
1057
|
+
const target = devices.find((device) => device.id === input.targetDeviceId);
|
|
1058
|
+
if (target === undefined) {
|
|
1059
|
+
return yield* new RequestError({
|
|
1060
|
+
status: 404,
|
|
1061
|
+
message: `Unknown target device ${input.targetDeviceId}`
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
if (!target.providers.includes(provider)) {
|
|
1065
|
+
return yield* new RequestError({
|
|
1066
|
+
status: 409,
|
|
1067
|
+
message: `${target.name} does not advertise the ${provider} provider`
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
return target.id;
|
|
1071
|
+
}
|
|
1072
|
+
const selected = devices.filter((device) => device.providers.includes(provider)).sort((left, right) => scoreDevice(right, input, sourceDeviceId) - scoreDevice(left, input, sourceDeviceId))[0];
|
|
1073
|
+
if (selected === undefined) {
|
|
1074
|
+
return yield* new RequestError({
|
|
1075
|
+
status: 409,
|
|
1076
|
+
message: `No Cohall device advertises the ${provider} provider`
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
return selected.id;
|
|
1080
|
+
}), runRelay = async () => {
|
|
1081
|
+
const configuration = await Effect10.runPromise(loadEnvironmentConfiguration);
|
|
1082
|
+
process.umask(63);
|
|
1083
|
+
const runtime = ManagedRuntime.make(exports_store.layer(configuration.databasePath));
|
|
1084
|
+
const run2 = (effect) => runtime.runPromise(effect);
|
|
1085
|
+
await run2(Effect10.gen(function* () {
|
|
1086
|
+
const store = yield* exports_store.Service;
|
|
1087
|
+
yield* store.recover();
|
|
1088
|
+
}));
|
|
1089
|
+
if (platform5() !== "win32" && await access4(configuration.databasePath).then(() => true).catch(() => false)) {
|
|
1090
|
+
await chmod3(configuration.databasePath, 384);
|
|
1091
|
+
}
|
|
1092
|
+
const hub = new Hub;
|
|
1093
|
+
let connections = 0;
|
|
1094
|
+
const tokenDigest = (token) => createHash2("sha256").update(token).digest();
|
|
1095
|
+
const ownerToken = (token) => timingSafeEqual(tokenDigest(token), tokenDigest(configuration.token));
|
|
1096
|
+
const authenticate = async (token, role) => {
|
|
1097
|
+
if (ownerToken(token)) {
|
|
1098
|
+
return "owner";
|
|
1099
|
+
}
|
|
1100
|
+
return run2(Effect10.gen(function* () {
|
|
1101
|
+
const store = yield* exports_store.Service;
|
|
1102
|
+
return yield* store.authenticateSession(token, role);
|
|
1103
|
+
})).catch(() => {
|
|
1104
|
+
return;
|
|
1105
|
+
});
|
|
1106
|
+
};
|
|
1107
|
+
const principalFor = async (request) => {
|
|
1108
|
+
const authorization = request.headers.get("authorization");
|
|
1109
|
+
if (authorization?.startsWith("Bearer ") !== true) {
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
return authenticate(authorization.slice("Bearer ".length), "client");
|
|
1113
|
+
};
|
|
1114
|
+
const dispatch = async (task) => {
|
|
1115
|
+
const assigned = await run2(Effect10.gen(function* () {
|
|
1116
|
+
const store = yield* exports_store.Service;
|
|
1117
|
+
return yield* store.assignTask(task.id);
|
|
1118
|
+
}));
|
|
1119
|
+
if (assigned.status !== "assigned") {
|
|
1120
|
+
return assigned;
|
|
1121
|
+
}
|
|
1122
|
+
if (hub.sendToDevice(assigned.targetDeviceId, SocketEvent.make({ _tag: "TaskAssigned", task: assigned }))) {
|
|
1123
|
+
return assigned;
|
|
1124
|
+
}
|
|
1125
|
+
return run2(Effect10.gen(function* () {
|
|
1126
|
+
const store = yield* exports_store.Service;
|
|
1127
|
+
return yield* store.rollbackAssignment(task.id);
|
|
1128
|
+
}));
|
|
1129
|
+
};
|
|
1130
|
+
const dispatchPending = async (deviceId) => {
|
|
1131
|
+
const tasks = await run2(Effect10.gen(function* () {
|
|
1132
|
+
const store = yield* exports_store.Service;
|
|
1133
|
+
return yield* store.pendingTasksFor(deviceId);
|
|
1134
|
+
}));
|
|
1135
|
+
for (const task of tasks) {
|
|
1136
|
+
if (task.status === "cancelling") {
|
|
1137
|
+
hub.sendToDevice(deviceId, SocketEvent.make({ _tag: "CancelTask", taskId: task.id }));
|
|
1138
|
+
} else {
|
|
1139
|
+
await dispatch(task);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
const sendError = (socket, code, message) => {
|
|
1144
|
+
socket.send(JSON.stringify(SocketEvent.make({
|
|
1145
|
+
_tag: "Error",
|
|
1146
|
+
code: code.slice(0, 64),
|
|
1147
|
+
message: message.slice(0, 2048)
|
|
1148
|
+
})));
|
|
1149
|
+
};
|
|
1150
|
+
const handleSocketEvent = async (socket, input) => {
|
|
1151
|
+
const event = await Effect10.runPromise(Effect10.try({
|
|
1152
|
+
try: () => JSON.parse(socketText(input)),
|
|
1153
|
+
catch: () => new RequestError({ status: 400, message: "Invalid socket JSON" })
|
|
1154
|
+
}).pipe(Effect10.flatMap(decodeSocketEvent))).catch((cause) => {
|
|
1155
|
+
sendError(socket, "invalid_event", cause instanceof Error ? cause.message : String(cause));
|
|
1156
|
+
return;
|
|
1157
|
+
});
|
|
1158
|
+
if (event === undefined) {
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
if (!hub.isAuthorized(socket)) {
|
|
1162
|
+
if (event._tag !== "Authenticate") {
|
|
1163
|
+
socket.close(4003, "Authentication required");
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
const principal = await authenticate(event.token, "device");
|
|
1167
|
+
if (principal === undefined) {
|
|
1168
|
+
socket.close(4003, "Authentication failed");
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
hub.attach(socket, {
|
|
1172
|
+
...principal === "owner" ? {} : { sessionId: principal.id },
|
|
1173
|
+
...principal === "owner" || principal.deviceId === undefined ? {} : { boundDeviceId: principal.deviceId },
|
|
1174
|
+
...principal === "owner" ? {} : { expiresAt: principal.expiresAt }
|
|
1175
|
+
});
|
|
1176
|
+
socket.send(JSON.stringify(SocketEvent.make({ _tag: "Connected", serverVersion: version, connectedAt: now() })));
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (event._tag === "Authenticate") {
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (event._tag === "DeviceHello") {
|
|
1183
|
+
const bound = hub.boundDeviceId(socket);
|
|
1184
|
+
if (bound !== undefined && bound !== event.device.id) {
|
|
1185
|
+
socket.close(4003, "Device identity does not match its session");
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
const device = Device.make({
|
|
1189
|
+
...event.device,
|
|
1190
|
+
status: "online",
|
|
1191
|
+
connectedAt: now(),
|
|
1192
|
+
lastSeenAt: now()
|
|
1193
|
+
});
|
|
1194
|
+
if (!hub.registerDevice(device.id, socket)) {
|
|
1195
|
+
socket.close(4009, "Device is already connected");
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
await run2(Effect10.gen(function* () {
|
|
1199
|
+
const store = yield* exports_store.Service;
|
|
1200
|
+
yield* store.upsertDevice(device);
|
|
1201
|
+
}));
|
|
1202
|
+
await dispatchPending(device.id);
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const deviceId = hub.deviceId(socket);
|
|
1206
|
+
if (deviceId === undefined) {
|
|
1207
|
+
sendError(socket, "device_hello_required", "Send DeviceHello before device events");
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
const settle = (taskId) => {
|
|
1211
|
+
socket.send(JSON.stringify(SocketEvent.make({ _tag: "TaskSettled", taskId })));
|
|
1212
|
+
};
|
|
1213
|
+
const processed = await run2(Effect10.gen(function* () {
|
|
1214
|
+
const store = yield* exports_store.Service;
|
|
1215
|
+
switch (event._tag) {
|
|
1216
|
+
case "DeviceHeartbeat":
|
|
1217
|
+
if (event.deviceId === deviceId) {
|
|
1218
|
+
yield* store.heartbeat(deviceId, event.status === "busy" ? "busy" : "online");
|
|
1219
|
+
}
|
|
1220
|
+
return;
|
|
1221
|
+
case "TaskAccepted":
|
|
1222
|
+
yield* store.acceptTask(event.taskId, deviceId);
|
|
1223
|
+
return;
|
|
1224
|
+
case "TaskFinished":
|
|
1225
|
+
yield* store.finishTask(event.taskId, deviceId, event.result, event.providerSessionId);
|
|
1226
|
+
return;
|
|
1227
|
+
case "TaskFailed":
|
|
1228
|
+
yield* store.failTask(event.taskId, deviceId, event.error);
|
|
1229
|
+
return;
|
|
1230
|
+
case "TaskCancelled":
|
|
1231
|
+
yield* store.acknowledgeCancellation(event.taskId, deviceId);
|
|
1232
|
+
return;
|
|
1233
|
+
case "Connected":
|
|
1234
|
+
case "TaskAssigned":
|
|
1235
|
+
case "TaskSettled":
|
|
1236
|
+
case "CancelTask":
|
|
1237
|
+
case "Error":
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
})).then(() => true).catch((cause) => {
|
|
1241
|
+
sendError(socket, "event_failed", cause instanceof Error ? cause.message : String(cause));
|
|
1242
|
+
return false;
|
|
1243
|
+
});
|
|
1244
|
+
if (processed && (event._tag === "TaskFinished" || event._tag === "TaskFailed" || event._tag === "TaskCancelled")) {
|
|
1245
|
+
settle(event.taskId);
|
|
1246
|
+
await dispatchPending(deviceId);
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
const api = async (request, url) => {
|
|
1250
|
+
if (url.pathname === "/api/health" && request.method === "GET") {
|
|
1251
|
+
return json({ ok: true, version });
|
|
1252
|
+
}
|
|
1253
|
+
if (url.pathname === "/api/auth/pair" && request.method === "POST") {
|
|
1254
|
+
return run2(Effect10.gen(function* () {
|
|
1255
|
+
const store = yield* exports_store.Service;
|
|
1256
|
+
const input = yield* body(request, decodeExchangePairingInput);
|
|
1257
|
+
return yield* store.exchangePairing(input.token);
|
|
1258
|
+
})).then((value) => json(value, 201)).catch(() => json({ error: "Pairing credential is invalid, expired, or already used" }, 401));
|
|
1259
|
+
}
|
|
1260
|
+
const principal = await principalFor(request);
|
|
1261
|
+
if (principal === undefined) {
|
|
1262
|
+
return json({ error: "Unauthorized" }, 401);
|
|
1263
|
+
}
|
|
1264
|
+
const ownerOnly = url.pathname.startsWith("/api/auth/");
|
|
1265
|
+
if (ownerOnly && principal !== "owner") {
|
|
1266
|
+
return json({ error: "Owner token required" }, 403);
|
|
1267
|
+
}
|
|
1268
|
+
const sourceDeviceId = principal === "owner" ? undefined : principal.deviceId;
|
|
1269
|
+
const effect = Effect10.gen(function* () {
|
|
1270
|
+
const store = yield* exports_store.Service;
|
|
1271
|
+
if (url.pathname === "/api/auth/pairings" && request.method === "POST") {
|
|
1272
|
+
const input = yield* body(request, decodeCreatePairingInput);
|
|
1273
|
+
return json(yield* store.createPairing(input), 201);
|
|
1274
|
+
}
|
|
1275
|
+
if (url.pathname === "/api/auth/sessions" && request.method === "GET") {
|
|
1276
|
+
return json(yield* store.listAuthSessions());
|
|
1277
|
+
}
|
|
1278
|
+
const revoke = url.pathname.match(/^\/api\/auth\/sessions\/([^/]+)\/revoke$/);
|
|
1279
|
+
if (request.method === "POST" && revoke?.[1] !== undefined) {
|
|
1280
|
+
const id = yield* pathId(AuthSessionId, revoke[1]);
|
|
1281
|
+
const session = yield* store.revokeAuthSession(id);
|
|
1282
|
+
hub.closeSession(id);
|
|
1283
|
+
return json(session);
|
|
1284
|
+
}
|
|
1285
|
+
if (url.pathname === "/api/devices" && request.method === "GET") {
|
|
1286
|
+
return json(yield* store.listDevices());
|
|
1287
|
+
}
|
|
1288
|
+
if (url.pathname === "/api/tasks" && request.method === "POST") {
|
|
1289
|
+
const input = yield* body(request, decodeCreateTaskInput);
|
|
1290
|
+
const target = yield* chooseDevice(input, sourceDeviceId);
|
|
1291
|
+
const providerSessionId = input.threadId === undefined ? undefined : yield* store.sessionFor(input.threadId, target, input.provider ?? "codex");
|
|
1292
|
+
const task2 = yield* store.createDelegation(input, target, sourceDeviceId, providerSessionId);
|
|
1293
|
+
return json(yield* Effect10.tryPromise(() => dispatch(task2)), 201);
|
|
1294
|
+
}
|
|
1295
|
+
const task = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
|
1296
|
+
if (request.method === "GET" && task?.[1] !== undefined) {
|
|
1297
|
+
return json(yield* store.getTask(yield* pathId(TaskId, task[1])));
|
|
1298
|
+
}
|
|
1299
|
+
const cancel2 = url.pathname.match(/^\/api\/tasks\/([^/]+)\/cancel$/);
|
|
1300
|
+
if (request.method === "POST" && cancel2?.[1] !== undefined) {
|
|
1301
|
+
const id = yield* pathId(TaskId, cancel2[1]);
|
|
1302
|
+
const updated = yield* store.requestCancellation(id);
|
|
1303
|
+
if (updated.status === "cancelling") {
|
|
1304
|
+
hub.sendToDevice(updated.targetDeviceId, SocketEvent.make({ _tag: "CancelTask", taskId: id }));
|
|
1305
|
+
}
|
|
1306
|
+
return json(updated);
|
|
1307
|
+
}
|
|
1308
|
+
const thread = url.pathname.match(/^\/api\/threads\/([^/]+)$/);
|
|
1309
|
+
if (request.method === "GET" && thread?.[1] !== undefined) {
|
|
1310
|
+
return json(yield* store.threadContext(yield* pathId(ThreadId, thread[1])));
|
|
1311
|
+
}
|
|
1312
|
+
return yield* new RequestError({ status: 404, message: "Route not found" });
|
|
1313
|
+
}).pipe(Effect10.mapError((cause) => cause instanceof RequestError ? cause : new RequestError({
|
|
1314
|
+
status: cause._tag !== "RelayStore.PersistenceError" ? 500 : cause.message.startsWith("Unknown") ? 404 : cause.message.includes("outstanding task limit") ? 429 : 500,
|
|
1315
|
+
message: cause.message
|
|
1316
|
+
})));
|
|
1317
|
+
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));
|
|
1318
|
+
};
|
|
1319
|
+
const websocketServer = new WebSocketServer({
|
|
1320
|
+
noServer: true,
|
|
1321
|
+
maxPayload: 256 * 1024,
|
|
1322
|
+
perMessageDeflate: false
|
|
1323
|
+
});
|
|
1324
|
+
websocketServer.on("connection", (rawSocket) => {
|
|
1325
|
+
const socket = rawSocket;
|
|
1326
|
+
socket.data = {
|
|
1327
|
+
processing: Promise.resolve(),
|
|
1328
|
+
authDeadline: undefined,
|
|
1329
|
+
preAuthFrameReceived: false,
|
|
1330
|
+
queuedMessages: 0,
|
|
1331
|
+
closed: false
|
|
1332
|
+
};
|
|
1333
|
+
connections += 1;
|
|
1334
|
+
socket.data.authDeadline = setTimeout(() => {
|
|
1335
|
+
if (!hub.isAuthorized(socket)) {
|
|
1336
|
+
socket.close(4003, "Authentication timeout");
|
|
1337
|
+
}
|
|
1338
|
+
}, 5000);
|
|
1339
|
+
let alive = true;
|
|
1340
|
+
socket.on("pong", () => {
|
|
1341
|
+
alive = true;
|
|
1342
|
+
});
|
|
1343
|
+
const heartbeat = setInterval(() => {
|
|
1344
|
+
if (!alive) {
|
|
1345
|
+
socket.terminate();
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
alive = false;
|
|
1349
|
+
socket.ping();
|
|
1350
|
+
}, 30000);
|
|
1351
|
+
heartbeat.unref();
|
|
1352
|
+
socket.on("message", (message) => {
|
|
1353
|
+
if (!hub.isAuthorized(socket)) {
|
|
1354
|
+
const size = Buffer.byteLength(socketText(message));
|
|
1355
|
+
if (socket.data.preAuthFrameReceived || size > 1024) {
|
|
1356
|
+
socket.close(4003, "Only one authentication frame is allowed");
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
socket.data.preAuthFrameReceived = true;
|
|
1360
|
+
socket.data.processing = handleSocketEvent(socket, message).catch((cause) => {
|
|
1361
|
+
sendError(socket, "event_failed", cause instanceof Error ? cause.message : String(cause));
|
|
1362
|
+
});
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (socket.data.queuedMessages >= 8) {
|
|
1366
|
+
socket.close(4008, "Message queue limit reached");
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
socket.data.queuedMessages += 1;
|
|
1370
|
+
socket.data.processing = socket.data.processing.then(() => socket.data.closed ? undefined : handleSocketEvent(socket, message)).catch((cause) => {
|
|
1371
|
+
sendError(socket, "event_failed", cause instanceof Error ? cause.message : String(cause));
|
|
1372
|
+
}).finally(() => {
|
|
1373
|
+
socket.data.queuedMessages = Math.max(0, socket.data.queuedMessages - 1);
|
|
1374
|
+
});
|
|
1375
|
+
});
|
|
1376
|
+
socket.once("close", () => {
|
|
1377
|
+
clearInterval(heartbeat);
|
|
1378
|
+
socket.data.closed = true;
|
|
1379
|
+
connections = Math.max(0, connections - 1);
|
|
1380
|
+
const deviceId = hub.detach(socket);
|
|
1381
|
+
if (deviceId === undefined) {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
run2(Effect10.gen(function* () {
|
|
1385
|
+
const store = yield* exports_store.Service;
|
|
1386
|
+
yield* store.requeueTasksFor(deviceId);
|
|
1387
|
+
yield* store.markDeviceOffline(deviceId);
|
|
1388
|
+
})).catch((cause) => console.error(cause));
|
|
1389
|
+
});
|
|
1390
|
+
});
|
|
1391
|
+
const server = createServer((incoming, outgoing) => {
|
|
1392
|
+
webRequest(incoming).then((request) => {
|
|
1393
|
+
const url = new URL(request.url);
|
|
1394
|
+
return url.pathname.startsWith("/api/") ? api(request, url) : json({ name: "Cohall relay", version, status: "running" });
|
|
1395
|
+
}).then((response) => sendResponse(outgoing, response)).catch((cause) => sendResponse(outgoing, json({ error: cause instanceof Error ? cause.message : "Invalid request" }, cause instanceof Error && cause.message.includes("256 KiB") ? 413 : 400)));
|
|
1396
|
+
});
|
|
1397
|
+
server.on("upgrade", (request, socket, head) => {
|
|
1398
|
+
const url = new URL(request.url ?? "/", "http://cohall.local");
|
|
1399
|
+
if (url.pathname !== "/ws/device") {
|
|
1400
|
+
rejectUpgrade(socket, 404, "Not Found");
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
if (connections >= 1024) {
|
|
1404
|
+
rejectUpgrade(socket, 503, "Service Unavailable");
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
websocketServer.handleUpgrade(request, socket, head, (websocket) => {
|
|
1408
|
+
websocketServer.emit("connection", websocket, request);
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
await new Promise((resolve3, reject) => {
|
|
1412
|
+
server.once("error", reject);
|
|
1413
|
+
server.listen(configuration.port, configuration.host, () => {
|
|
1414
|
+
server.off("error", reject);
|
|
1415
|
+
resolve3();
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
1418
|
+
console.log(`Cohall relay listening on http://${configuration.host}:${configuration.port}`);
|
|
1419
|
+
let stopping = false;
|
|
1420
|
+
const shutdown = async () => {
|
|
1421
|
+
if (stopping) {
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
stopping = true;
|
|
1425
|
+
for (const socket of websocketServer.clients) {
|
|
1426
|
+
socket.close(1001, "Relay shutting down");
|
|
1427
|
+
}
|
|
1428
|
+
await new Promise((resolve3) => server.close(() => resolve3()));
|
|
1429
|
+
websocketServer.close();
|
|
1430
|
+
await runtime.dispose();
|
|
1431
|
+
};
|
|
1432
|
+
process.once("SIGINT", () => void shutdown());
|
|
1433
|
+
process.once("SIGTERM", () => void shutdown());
|
|
1434
|
+
};
|
|
1435
|
+
var init_main = __esm(async () => {
|
|
1436
|
+
init_src();
|
|
1437
|
+
init_config();
|
|
1438
|
+
init_hub();
|
|
1439
|
+
init_store();
|
|
1440
|
+
RequestError = class RequestError extends Schema10.TaggedErrorClass()("Relay.RequestError", {
|
|
1441
|
+
status: Schema10.Int,
|
|
1442
|
+
message: Schema10.String
|
|
1443
|
+
}) {
|
|
1444
|
+
};
|
|
1445
|
+
if (false) {}
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
// apps/device/src/main.ts
|
|
1449
|
+
init_src();
|
|
1450
|
+
import { Effect as Effect11 } from "effect";
|
|
1451
|
+
|
|
1452
|
+
// packages/client/src/index.ts
|
|
1453
|
+
init_src();
|
|
1454
|
+
var exports_src = {};
|
|
1455
|
+
__export(exports_src, {
|
|
1456
|
+
make: () => make,
|
|
1457
|
+
layer: () => layer,
|
|
1458
|
+
exchangePairing: () => exchangePairing,
|
|
1459
|
+
Service: () => Service,
|
|
1460
|
+
RelayRequestError: () => RelayRequestError,
|
|
1461
|
+
RelayDecodeError: () => RelayDecodeError,
|
|
1462
|
+
RelayClient: () => exports_src
|
|
1463
|
+
});
|
|
1464
|
+
import { Context, Effect, Layer, Schema as Schema2 } from "effect";
|
|
1465
|
+
|
|
1466
|
+
class RelayRequestError extends Schema2.TaggedErrorClass()("RelayClient.RequestError", {
|
|
1467
|
+
operation: Schema2.String,
|
|
1468
|
+
message: Schema2.String,
|
|
1469
|
+
status: Schema2.optionalKey(Schema2.Number)
|
|
1470
|
+
}) {
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
class RelayDecodeError extends Schema2.TaggedErrorClass()("RelayClient.DecodeError", { operation: Schema2.String, message: Schema2.String }) {
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
class Service extends Context.Service()("@cohall/RelayClient") {
|
|
1477
|
+
}
|
|
1478
|
+
var normalizeBaseUrl = (url) => url.replace(/\/+$/, "");
|
|
1479
|
+
var jsonBody = async (response, operation) => {
|
|
1480
|
+
if (response.body === null) {
|
|
1481
|
+
throw new RelayDecodeError({ operation, message: "Relay returned an empty response" });
|
|
1482
|
+
}
|
|
1483
|
+
const reader = response.body.getReader();
|
|
1484
|
+
const chunks = [];
|
|
1485
|
+
let size = 0;
|
|
1486
|
+
while (true) {
|
|
1487
|
+
const chunk = await reader.read();
|
|
1488
|
+
if (chunk.done) {
|
|
1489
|
+
break;
|
|
1490
|
+
}
|
|
1491
|
+
size += chunk.value.byteLength;
|
|
1492
|
+
if (size > 2 * 1024 * 1024) {
|
|
1493
|
+
await reader.cancel();
|
|
1494
|
+
throw new RelayDecodeError({ operation, message: "Relay response exceeded 2 MiB" });
|
|
1495
|
+
}
|
|
1496
|
+
chunks.push(chunk.value);
|
|
1497
|
+
}
|
|
1498
|
+
const bytes = new Uint8Array(size);
|
|
1499
|
+
let offset = 0;
|
|
1500
|
+
for (const chunk of chunks) {
|
|
1501
|
+
bytes.set(chunk, offset);
|
|
1502
|
+
offset += chunk.byteLength;
|
|
1503
|
+
}
|
|
1504
|
+
const text = new TextDecoder().decode(bytes);
|
|
1505
|
+
try {
|
|
1506
|
+
return JSON.parse(text);
|
|
1507
|
+
} catch (cause) {
|
|
1508
|
+
throw new RelayDecodeError({
|
|
1509
|
+
operation,
|
|
1510
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
var responseMessage = async (response, operation) => {
|
|
1515
|
+
const json = await jsonBody(response, operation).catch(() => {
|
|
1516
|
+
return;
|
|
1517
|
+
});
|
|
1518
|
+
const decoded = Schema2.decodeUnknownOption(ErrorResponse)(json);
|
|
1519
|
+
return decoded._tag === "Some" ? decoded.value.error : `${response.status} ${response.statusText}`.trim();
|
|
1520
|
+
};
|
|
1521
|
+
var make = (options) => {
|
|
1522
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
1523
|
+
const request = (operation, path, schema, init) => Effect.gen(function* () {
|
|
1524
|
+
const response = yield* Effect.tryPromise({
|
|
1525
|
+
try: (signal) => fetch(`${baseUrl}${path}`, {
|
|
1526
|
+
...init,
|
|
1527
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]),
|
|
1528
|
+
headers: {
|
|
1529
|
+
authorization: `Bearer ${options.token}`,
|
|
1530
|
+
"content-type": "application/json",
|
|
1531
|
+
...init?.headers
|
|
1532
|
+
}
|
|
1533
|
+
}),
|
|
1534
|
+
catch: (cause) => new RelayRequestError({
|
|
1535
|
+
operation,
|
|
1536
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
1537
|
+
})
|
|
1538
|
+
});
|
|
1539
|
+
if (!response.ok) {
|
|
1540
|
+
return yield* Effect.fail(new RelayRequestError({
|
|
1541
|
+
operation,
|
|
1542
|
+
message: yield* Effect.promise(() => responseMessage(response, operation)),
|
|
1543
|
+
status: response.status
|
|
1544
|
+
}));
|
|
1545
|
+
}
|
|
1546
|
+
const json = yield* Effect.tryPromise({
|
|
1547
|
+
try: () => jsonBody(response, operation),
|
|
1548
|
+
catch: (cause) => cause instanceof RelayDecodeError ? cause : new RelayDecodeError({ operation, message: String(cause) })
|
|
1549
|
+
});
|
|
1550
|
+
return yield* Schema2.decodeUnknownEffect(schema)(json).pipe(Effect.mapError((cause) => new RelayDecodeError({ operation, message: String(cause) })));
|
|
1551
|
+
});
|
|
1552
|
+
return Service.of({
|
|
1553
|
+
devices: () => request("RelayClient.devices", "/api/devices", Schema2.Array(Device)),
|
|
1554
|
+
createTask: (input) => request("RelayClient.createTask", "/api/tasks", Task, {
|
|
1555
|
+
method: "POST",
|
|
1556
|
+
body: JSON.stringify(input)
|
|
1557
|
+
}),
|
|
1558
|
+
getTask: (taskId) => request("RelayClient.getTask", `/api/tasks/${encodeURIComponent(taskId)}`, Task),
|
|
1559
|
+
cancelTask: (taskId) => request("RelayClient.cancelTask", `/api/tasks/${encodeURIComponent(taskId)}/cancel`, Task, {
|
|
1560
|
+
method: "POST"
|
|
1561
|
+
}),
|
|
1562
|
+
threadContext: (threadId) => request("RelayClient.threadContext", `/api/threads/${encodeURIComponent(threadId)}`, ThreadContext),
|
|
1563
|
+
createPairing: (input) => request("RelayClient.createPairing", "/api/auth/pairings", PairingCredential, {
|
|
1564
|
+
method: "POST",
|
|
1565
|
+
body: JSON.stringify(input)
|
|
1566
|
+
}),
|
|
1567
|
+
authSessions: () => request("RelayClient.authSessions", "/api/auth/sessions", Schema2.Array(AuthSession)),
|
|
1568
|
+
revokeAuthSession: (sessionId) => request("RelayClient.revokeAuthSession", `/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, AuthSession, { method: "POST" })
|
|
1569
|
+
});
|
|
1570
|
+
};
|
|
1571
|
+
var exchangePairing = (baseUrl, input) => Effect.gen(function* () {
|
|
1572
|
+
const operation = "RelayClient.exchangePairing";
|
|
1573
|
+
const response = yield* Effect.tryPromise({
|
|
1574
|
+
try: (signal) => fetch(`${normalizeBaseUrl(baseUrl)}/api/auth/pair`, {
|
|
1575
|
+
method: "POST",
|
|
1576
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]),
|
|
1577
|
+
headers: { "content-type": "application/json" },
|
|
1578
|
+
body: JSON.stringify(input)
|
|
1579
|
+
}),
|
|
1580
|
+
catch: (cause) => new RelayRequestError({
|
|
1581
|
+
operation,
|
|
1582
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
1583
|
+
})
|
|
1584
|
+
});
|
|
1585
|
+
if (!response.ok) {
|
|
1586
|
+
return yield* Effect.fail(new RelayRequestError({
|
|
1587
|
+
operation,
|
|
1588
|
+
message: yield* Effect.promise(() => responseMessage(response, operation)),
|
|
1589
|
+
status: response.status
|
|
1590
|
+
}));
|
|
1591
|
+
}
|
|
1592
|
+
const json = yield* Effect.tryPromise({
|
|
1593
|
+
try: () => jsonBody(response, operation),
|
|
1594
|
+
catch: (cause) => cause instanceof RelayDecodeError ? cause : new RelayDecodeError({ operation, message: String(cause) })
|
|
1595
|
+
});
|
|
1596
|
+
return yield* Schema2.decodeUnknownEffect(PairingResult)(json).pipe(Effect.mapError((cause) => new RelayDecodeError({ operation, message: String(cause) })));
|
|
1597
|
+
});
|
|
1598
|
+
var layer = (options) => Layer.succeed(Service, make(options));
|
|
1599
|
+
|
|
1600
|
+
// apps/device/src/cli.ts
|
|
1601
|
+
init_src();
|
|
1602
|
+
|
|
1603
|
+
// packages/providers/src/index.ts
|
|
1604
|
+
init_src();
|
|
1605
|
+
import { Effect as Effect2, Schema as Schema3 } from "effect";
|
|
1606
|
+
import { accessSync, constants } from "node:fs";
|
|
1607
|
+
import { platform } from "node:os";
|
|
1608
|
+
import { delimiter, extname, isAbsolute, join } from "node:path";
|
|
1609
|
+
import { spawn } from "node:child_process";
|
|
1610
|
+
|
|
1611
|
+
class ProviderUnavailableError extends Schema3.TaggedErrorClass()("CohallProvider.Unavailable", { provider: Provider, message: Schema3.String }) {
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
class ProviderRunError extends Schema3.TaggedErrorClass()("CohallProvider.RunError", {
|
|
1615
|
+
provider: Provider,
|
|
1616
|
+
message: Schema3.String,
|
|
1617
|
+
exitCode: Schema3.optionalKey(Schema3.Number)
|
|
1618
|
+
}) {
|
|
1619
|
+
}
|
|
1620
|
+
var executables = {
|
|
1621
|
+
codex: "codex",
|
|
1622
|
+
"claude-code": "claude",
|
|
1623
|
+
opencode: "opencode"
|
|
1624
|
+
};
|
|
1625
|
+
var providerEnvironment = () => Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("COHALL_")));
|
|
1626
|
+
var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : undefined;
|
|
1627
|
+
var text = (value) => typeof value === "string" ? value : undefined;
|
|
1628
|
+
var executableCandidates = (command) => {
|
|
1629
|
+
if (platform() !== "win32" || extname(command).length > 0) {
|
|
1630
|
+
return [command];
|
|
1631
|
+
}
|
|
1632
|
+
return (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((extension) => extension.length > 0).map((extension) => `${command}${extension.toLowerCase()}`);
|
|
1633
|
+
};
|
|
1634
|
+
var findExecutable = (command) => {
|
|
1635
|
+
const paths = isAbsolute(command) || command.includes("/") || command.includes("\\") ? [""] : (process.env.PATH ?? "").split(delimiter);
|
|
1636
|
+
for (const directory of paths) {
|
|
1637
|
+
for (const candidate of executableCandidates(command)) {
|
|
1638
|
+
const path = directory.length === 0 ? candidate : join(directory, candidate);
|
|
1639
|
+
try {
|
|
1640
|
+
accessSync(path, platform() === "win32" ? constants.F_OK : constants.X_OK);
|
|
1641
|
+
return path;
|
|
1642
|
+
} catch {
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
return;
|
|
1648
|
+
};
|
|
1649
|
+
var available = (provider) => findExecutable(executables[provider]) !== undefined;
|
|
1650
|
+
var availableProviders = () => Provider.literals.filter(available);
|
|
1651
|
+
var boundedText = (stream, limit, onOverflow) => new Promise((resolve, reject) => {
|
|
1652
|
+
const chunks = [];
|
|
1653
|
+
let size = 0;
|
|
1654
|
+
const cleanup = () => {
|
|
1655
|
+
stream.off("data", onData);
|
|
1656
|
+
stream.off("end", onEnd);
|
|
1657
|
+
stream.off("error", onError);
|
|
1658
|
+
};
|
|
1659
|
+
const onData = (chunk) => {
|
|
1660
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
1661
|
+
size += bytes.byteLength;
|
|
1662
|
+
if (size <= limit) {
|
|
1663
|
+
chunks.push(bytes);
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
cleanup();
|
|
1667
|
+
onOverflow();
|
|
1668
|
+
reject(new Error(`Provider output exceeded ${Math.floor(limit / 1024)} KiB`));
|
|
1669
|
+
};
|
|
1670
|
+
const onEnd = () => {
|
|
1671
|
+
cleanup();
|
|
1672
|
+
resolve(Buffer.concat(chunks, size).toString("utf8"));
|
|
1673
|
+
};
|
|
1674
|
+
const onError = (cause) => {
|
|
1675
|
+
cleanup();
|
|
1676
|
+
reject(cause);
|
|
1677
|
+
};
|
|
1678
|
+
stream.on("data", onData);
|
|
1679
|
+
stream.once("end", onEnd);
|
|
1680
|
+
stream.once("error", onError);
|
|
1681
|
+
});
|
|
1682
|
+
var codexCommand = (options) => {
|
|
1683
|
+
const common = [
|
|
1684
|
+
"--json",
|
|
1685
|
+
"-c",
|
|
1686
|
+
'approval_policy="never"',
|
|
1687
|
+
...options.model === undefined ? [] : ["--model", options.model],
|
|
1688
|
+
...options.sandbox === undefined ? [] : ["-c", `sandbox_mode=${JSON.stringify(options.sandbox)}`]
|
|
1689
|
+
];
|
|
1690
|
+
return options.sessionId === undefined ? ["codex", "exec", ...common, "-"] : ["codex", "exec", "resume", ...common, options.sessionId, "-"];
|
|
1691
|
+
};
|
|
1692
|
+
var claudeCommand = (options) => [
|
|
1693
|
+
"claude",
|
|
1694
|
+
"-p",
|
|
1695
|
+
"--output-format",
|
|
1696
|
+
"json",
|
|
1697
|
+
...options.model === undefined ? [] : ["--model", options.model],
|
|
1698
|
+
...options.sessionId === undefined ? [] : ["--resume", options.sessionId]
|
|
1699
|
+
];
|
|
1700
|
+
var opencodeCommand = (options) => [
|
|
1701
|
+
"opencode",
|
|
1702
|
+
"run",
|
|
1703
|
+
"--format",
|
|
1704
|
+
"json",
|
|
1705
|
+
...options.model === undefined ? [] : ["--model", options.model],
|
|
1706
|
+
...options.sessionId === undefined ? [] : ["--session", options.sessionId]
|
|
1707
|
+
];
|
|
1708
|
+
var command = (options) => {
|
|
1709
|
+
switch (options.provider) {
|
|
1710
|
+
case "codex":
|
|
1711
|
+
return codexCommand(options);
|
|
1712
|
+
case "claude-code":
|
|
1713
|
+
return claudeCommand(options);
|
|
1714
|
+
case "opencode":
|
|
1715
|
+
return opencodeCommand(options);
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
var parseCodex = (stdout, existingSession) => {
|
|
1719
|
+
let result = "";
|
|
1720
|
+
let sessionId = existingSession;
|
|
1721
|
+
for (const line of stdout.split(`
|
|
1722
|
+
`)) {
|
|
1723
|
+
if (line.trim().length === 0) {
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
const event = record(JSON.parse(line));
|
|
1727
|
+
if (event === undefined) {
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
if (text(event.type) === "thread.started") {
|
|
1731
|
+
sessionId = text(event.thread_id) ?? sessionId;
|
|
1732
|
+
}
|
|
1733
|
+
if (text(event.type) !== "item.completed") {
|
|
1734
|
+
continue;
|
|
1735
|
+
}
|
|
1736
|
+
const item = record(event.item);
|
|
1737
|
+
if (text(item?.type) === "agent_message") {
|
|
1738
|
+
result = text(item?.text) ?? text(item?.content) ?? result;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
return {
|
|
1742
|
+
result: result || "Codex completed the task without a text response.",
|
|
1743
|
+
...sessionId === undefined ? {} : { sessionId }
|
|
1744
|
+
};
|
|
1745
|
+
};
|
|
1746
|
+
var parseClaude = (stdout, existingSession) => {
|
|
1747
|
+
const value = record(JSON.parse(stdout));
|
|
1748
|
+
const result = text(value?.result) ?? text(value?.content);
|
|
1749
|
+
if (result === undefined) {
|
|
1750
|
+
throw new Error("Claude Code returned JSON without a result");
|
|
1751
|
+
}
|
|
1752
|
+
const sessionId = text(value?.session_id) ?? text(value?.sessionId) ?? existingSession;
|
|
1753
|
+
return { result, ...sessionId === undefined ? {} : { sessionId } };
|
|
1754
|
+
};
|
|
1755
|
+
var textFromPart = (value) => {
|
|
1756
|
+
const part = record(value);
|
|
1757
|
+
if (part === undefined) {
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
return text(part.text) ?? text(part.content) ?? text(record(part.part)?.text);
|
|
1761
|
+
};
|
|
1762
|
+
var parseOpenCode = (stdout, existingSession) => {
|
|
1763
|
+
let result = "";
|
|
1764
|
+
let sessionId = existingSession;
|
|
1765
|
+
for (const line of stdout.split(`
|
|
1766
|
+
`)) {
|
|
1767
|
+
if (line.trim().length === 0) {
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
const event = record(JSON.parse(line));
|
|
1771
|
+
if (event === undefined) {
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
sessionId = text(event.sessionID) ?? text(event.sessionId) ?? text(event.session_id) ?? text(record(event.info)?.sessionID) ?? sessionId;
|
|
1775
|
+
const content = textFromPart(event.part) ?? textFromPart(event) ?? text(event.result);
|
|
1776
|
+
if (content !== undefined && content.length > 0) {
|
|
1777
|
+
result = content;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
if (result.length === 0) {
|
|
1781
|
+
throw new Error("OpenCode completed without a text response");
|
|
1782
|
+
}
|
|
1783
|
+
return { result, ...sessionId === undefined ? {} : { sessionId } };
|
|
1784
|
+
};
|
|
1785
|
+
var parse = (options, stdout) => {
|
|
1786
|
+
switch (options.provider) {
|
|
1787
|
+
case "codex":
|
|
1788
|
+
return parseCodex(stdout, options.sessionId);
|
|
1789
|
+
case "claude-code":
|
|
1790
|
+
return parseClaude(stdout, options.sessionId);
|
|
1791
|
+
case "opencode":
|
|
1792
|
+
return parseOpenCode(stdout, options.sessionId);
|
|
1793
|
+
}
|
|
1794
|
+
};
|
|
1795
|
+
var byteLength = (value) => new TextEncoder().encode(value).byteLength;
|
|
1796
|
+
var run = (options) => Effect2.tryPromise({
|
|
1797
|
+
try: async (signal) => {
|
|
1798
|
+
if (!available(options.provider)) {
|
|
1799
|
+
throw new ProviderUnavailableError({
|
|
1800
|
+
provider: options.provider,
|
|
1801
|
+
message: `${executables[options.provider]} is not available on this device`
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
const [executable, ...arguments_] = command(options);
|
|
1805
|
+
if (executable === undefined) {
|
|
1806
|
+
throw new ProviderRunError({
|
|
1807
|
+
provider: options.provider,
|
|
1808
|
+
message: `No command is configured for ${options.provider}`
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
const child = spawn(executable, arguments_, {
|
|
1812
|
+
cwd: options.cwd,
|
|
1813
|
+
env: {
|
|
1814
|
+
...providerEnvironment(),
|
|
1815
|
+
COHALL_PROVIDER: options.provider,
|
|
1816
|
+
COHALL_THREAD_ID: options.threadId
|
|
1817
|
+
},
|
|
1818
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1819
|
+
});
|
|
1820
|
+
const exited = new Promise((resolve, reject) => {
|
|
1821
|
+
child.once("error", reject);
|
|
1822
|
+
child.once("exit", (code) => resolve(code ?? 1));
|
|
1823
|
+
});
|
|
1824
|
+
let killTimer;
|
|
1825
|
+
const terminate = () => {
|
|
1826
|
+
if (child.exitCode !== null) {
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
child.kill("SIGTERM");
|
|
1830
|
+
killTimer ??= setTimeout(() => {
|
|
1831
|
+
if (child.exitCode === null) {
|
|
1832
|
+
child.kill("SIGKILL");
|
|
1833
|
+
}
|
|
1834
|
+
}, 5000);
|
|
1835
|
+
killTimer.unref();
|
|
1836
|
+
};
|
|
1837
|
+
signal.addEventListener("abort", terminate, { once: true });
|
|
1838
|
+
child.stdin.end(options.prompt);
|
|
1839
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1840
|
+
boundedText(child.stdout, 1024 * 1024, terminate),
|
|
1841
|
+
boundedText(child.stderr, 64 * 1024, terminate),
|
|
1842
|
+
exited
|
|
1843
|
+
]);
|
|
1844
|
+
signal.removeEventListener("abort", terminate);
|
|
1845
|
+
if (killTimer !== undefined && child.exitCode !== null) {
|
|
1846
|
+
clearTimeout(killTimer);
|
|
1847
|
+
}
|
|
1848
|
+
if (exitCode !== 0) {
|
|
1849
|
+
throw new ProviderRunError({
|
|
1850
|
+
provider: options.provider,
|
|
1851
|
+
message: (stderr.trim() || `${executables[options.provider]} exited with status ${exitCode}`).slice(0, 16384),
|
|
1852
|
+
exitCode
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
const result = parse(options, stdout);
|
|
1856
|
+
if (byteLength(result.result) > 131072) {
|
|
1857
|
+
throw new ProviderRunError({
|
|
1858
|
+
provider: options.provider,
|
|
1859
|
+
message: "Provider result exceeded 128 KiB"
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
if (result.sessionId !== undefined && byteLength(result.sessionId) > 4096) {
|
|
1863
|
+
throw new ProviderRunError({
|
|
1864
|
+
provider: options.provider,
|
|
1865
|
+
message: "Provider session ID exceeded 4 KiB"
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
return result;
|
|
1869
|
+
},
|
|
1870
|
+
catch: (cause) => {
|
|
1871
|
+
if (cause instanceof ProviderUnavailableError || cause instanceof ProviderRunError) {
|
|
1872
|
+
return cause;
|
|
1873
|
+
}
|
|
1874
|
+
return new ProviderRunError({
|
|
1875
|
+
provider: options.provider,
|
|
1876
|
+
message: (cause instanceof Error ? cause.message : String(cause)).slice(0, 16384)
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
// apps/device/src/cli.ts
|
|
1882
|
+
import { Effect as Effect5, Schema as Schema6 } from "effect";
|
|
1883
|
+
import { access as access2, mkdir as mkdir2, readFile as readFile2, stat, writeFile as writeFile2 } from "node:fs/promises";
|
|
1884
|
+
import { homedir as homedir2 } from "node:os";
|
|
1885
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
1886
|
+
|
|
1887
|
+
// skills/cohall/SKILL.md
|
|
1888
|
+
var SKILL_default = `---
|
|
1889
|
+
name: cohall
|
|
1890
|
+
description: Delegate work to an agent on another user-owned device through the Cohall CLI. Use when a task needs machine-local state or capabilities such as a signed-in browser, Xcode, a simulator, Docker, deployment access, a repository checkout, or tools unavailable on the current device.
|
|
1891
|
+
---
|
|
1892
|
+
|
|
1893
|
+
# Cohall
|
|
1894
|
+
|
|
1895
|
+
Cohall sends a focused task to an agent running on another device. The target
|
|
1896
|
+
keeps its local files, credentials, browser state, provider login, skills, and
|
|
1897
|
+
permissions. The relay carries prompts, final results, and task state; it is not
|
|
1898
|
+
a lead agent.
|
|
1899
|
+
|
|
1900
|
+
## Delegate work
|
|
1901
|
+
|
|
1902
|
+
1. List devices unless the target is already explicit:
|
|
1903
|
+
|
|
1904
|
+
\`\`\`bash
|
|
1905
|
+
npx -y @akshar5/cohall devices
|
|
1906
|
+
\`\`\`
|
|
1907
|
+
|
|
1908
|
+
2. Choose a target whose provider, workspaces, platform, and capabilities fit
|
|
1909
|
+
the task. Stay local when another device offers no material advantage.
|
|
1910
|
+
|
|
1911
|
+
3. Delegate one concrete outcome. Send only the context the target needs:
|
|
1912
|
+
|
|
1913
|
+
\`\`\`bash
|
|
1914
|
+
npx -y @akshar5/cohall delegate \\
|
|
1915
|
+
--target @macbook \\
|
|
1916
|
+
--provider codex \\
|
|
1917
|
+
--workspace /Users/me/dev/project \\
|
|
1918
|
+
--prompt 'Inspect the authenticated dashboard and identify why deployment 184 failed.' \\
|
|
1919
|
+
--context 'Focus on events after 15:00 UTC. Return evidence and source URLs.'
|
|
1920
|
+
\`\`\`
|
|
1921
|
+
|
|
1922
|
+
Providers are \`codex\`, \`claude-code\`, and \`opencode\`. Omit \`--provider\` to
|
|
1923
|
+
use Codex. Omit \`--target\` only when Cohall may choose a matching device.
|
|
1924
|
+
|
|
1925
|
+
4. The command waits by default and returns JSON. Treat work as successful only
|
|
1926
|
+
when \`status\` is \`completed\`; use \`result\` in the current task. Report a
|
|
1927
|
+
\`failed\`, \`cancelled\`, or \`cancelling\` state accurately.
|
|
1928
|
+
|
|
1929
|
+
5. Reuse \`thread_id\` for related follow-ups so the target provider can resume
|
|
1930
|
+
its local session:
|
|
1931
|
+
|
|
1932
|
+
\`\`\`bash
|
|
1933
|
+
npx -y @akshar5/cohall delegate \\
|
|
1934
|
+
--thread 11111111-1111-4111-8111-111111111111 \\
|
|
1935
|
+
--target @macbook \\
|
|
1936
|
+
--prompt 'Check whether yesterday’s deployment failed for the same reason.'
|
|
1937
|
+
\`\`\`
|
|
1938
|
+
|
|
1939
|
+
When an agent running as a Cohall task delegates again, \`COHALL_THREAD_ID\`
|
|
1940
|
+
automatically carries the current thread. Pass \`--thread\` explicitly from an
|
|
1941
|
+
ordinary shell or a separate agent turn.
|
|
1942
|
+
|
|
1943
|
+
## Context and safety
|
|
1944
|
+
|
|
1945
|
+
- Include the goal, constraints, relevant prior findings, and expected evidence.
|
|
1946
|
+
- Do not paste a full private transcript when a short brief is sufficient.
|
|
1947
|
+
- Never send provider credentials, Cohall tokens, cookies, or browser-profile data.
|
|
1948
|
+
- Request a path only when the target advertises a matching workspace root.
|
|
1949
|
+
- Use the same thread for clarification instead of creating duplicate tasks.
|
|
1950
|
+
- Do not submit the same work through both CLI and MCP.
|
|
1951
|
+
- Respect user confirmation requirements for consequential actions on the target.
|
|
1952
|
+
|
|
1953
|
+
## Asynchronous work
|
|
1954
|
+
|
|
1955
|
+
Queue work when the current agent can make independent progress:
|
|
1956
|
+
|
|
1957
|
+
\`\`\`bash
|
|
1958
|
+
npx -y @akshar5/cohall delegate --target @linux --no-wait \\
|
|
1959
|
+
--prompt 'Run the project test suite and report failures.'
|
|
1960
|
+
npx -y @akshar5/cohall status <task-id>
|
|
1961
|
+
npx -y @akshar5/cohall wait <task-id> --timeout 1800
|
|
1962
|
+
\`\`\`
|
|
1963
|
+
|
|
1964
|
+
The timeout error includes the task ID and last known status. The task continues
|
|
1965
|
+
unless cancelled:
|
|
1966
|
+
|
|
1967
|
+
\`\`\`bash
|
|
1968
|
+
npx -y @akshar5/cohall cancel <task-id>
|
|
1969
|
+
\`\`\`
|
|
1970
|
+
|
|
1971
|
+
Active cancellation is acknowledged by the target device; \`cancelling\` means
|
|
1972
|
+
the provider process has not confirmed termination yet.
|
|
1973
|
+
|
|
1974
|
+
## Read shared context
|
|
1975
|
+
|
|
1976
|
+
\`\`\`bash
|
|
1977
|
+
npx -y @akshar5/cohall thread <thread-id>
|
|
1978
|
+
\`\`\`
|
|
1979
|
+
|
|
1980
|
+
This returns a byte-bounded recent window of prompts, final responses, and task
|
|
1981
|
+
states. Check \`truncated\`; older history remains on the relay when it is true.
|
|
1982
|
+
|
|
1983
|
+
## Input forms
|
|
1984
|
+
|
|
1985
|
+
Use \`--prompt-file\` or stdin for multiline work without shell interpolation:
|
|
1986
|
+
|
|
1987
|
+
\`\`\`bash
|
|
1988
|
+
npx -y @akshar5/cohall delegate --target @macbook --prompt - <<'COHALL_PROMPT'
|
|
1989
|
+
Analyze the authenticated pages in the attached task context.
|
|
1990
|
+
Return the shared conclusions, disagreements, and source URLs.
|
|
1991
|
+
COHALL_PROMPT
|
|
1992
|
+
\`\`\`
|
|
1993
|
+
|
|
1994
|
+
Only one input may read stdin. Use \`--context-file\` when the prompt uses stdin.
|
|
1995
|
+
|
|
1996
|
+
## Diagnostics
|
|
1997
|
+
|
|
1998
|
+
If a command cannot connect or no target is available, run:
|
|
1999
|
+
|
|
2000
|
+
\`\`\`bash
|
|
2001
|
+
npx -y @akshar5/cohall doctor
|
|
2002
|
+
npx -y @akshar5/cohall devices
|
|
2003
|
+
\`\`\`
|
|
2004
|
+
|
|
2005
|
+
Return the diagnostic failure to the user rather than claiming remote work ran.
|
|
2006
|
+
`;
|
|
2007
|
+
|
|
2008
|
+
// apps/device/src/config.ts
|
|
2009
|
+
init_src();
|
|
2010
|
+
import { Effect as Effect3, Schema as Schema4 } from "effect";
|
|
2011
|
+
import { access, chmod, mkdir, readFile, realpath, rename, writeFile } from "node:fs/promises";
|
|
2012
|
+
import { homedir, hostname, platform as platform2 } from "node:os";
|
|
2013
|
+
import { dirname, join as join2, resolve } from "node:path";
|
|
2014
|
+
var WorkspaceList = Schema4.Array(Schema4.NonEmptyString).check(Schema4.isMaxLength(64));
|
|
2015
|
+
var Sandbox = Schema4.Literals(["read-only", "workspace-write", "danger-full-access"]);
|
|
2016
|
+
var StoredConfiguration = Schema4.Struct({
|
|
2017
|
+
version: Schema4.Literal(1),
|
|
2018
|
+
relayUrl: Schema4.NonEmptyString,
|
|
2019
|
+
deviceId: DeviceId,
|
|
2020
|
+
deviceName: Schema4.NonEmptyString,
|
|
2021
|
+
workspaces: WorkspaceList,
|
|
2022
|
+
clientToken: Schema4.optionalKey(Schema4.NonEmptyString),
|
|
2023
|
+
deviceToken: Schema4.optionalKey(Schema4.NonEmptyString),
|
|
2024
|
+
model: Schema4.optionalKey(Schema4.NonEmptyString),
|
|
2025
|
+
sandbox: Schema4.optionalKey(Sandbox)
|
|
2026
|
+
});
|
|
2027
|
+
var ClientConfiguration = Schema4.Struct({
|
|
2028
|
+
relayUrl: Schema4.NonEmptyString,
|
|
2029
|
+
token: Schema4.NonEmptyString,
|
|
2030
|
+
mcpThreadId: Schema4.optionalKey(Schema4.String)
|
|
2031
|
+
});
|
|
2032
|
+
var DeviceConfiguration = Schema4.Struct({
|
|
2033
|
+
relayUrl: Schema4.NonEmptyString,
|
|
2034
|
+
token: Schema4.NonEmptyString,
|
|
2035
|
+
id: DeviceId,
|
|
2036
|
+
name: Schema4.NonEmptyString,
|
|
2037
|
+
workspaces: WorkspaceList,
|
|
2038
|
+
model: Schema4.optionalKey(Schema4.NonEmptyString),
|
|
2039
|
+
sandbox: Schema4.optionalKey(Sandbox)
|
|
2040
|
+
});
|
|
2041
|
+
var defaultConfigDirectory = () => {
|
|
2042
|
+
if (platform2() === "win32") {
|
|
2043
|
+
return join2(process.env.APPDATA ?? join2(homedir(), "AppData", "Roaming"), "Cohall");
|
|
2044
|
+
}
|
|
2045
|
+
if (platform2() === "darwin") {
|
|
2046
|
+
return join2(homedir(), "Library", "Application Support", "Cohall");
|
|
2047
|
+
}
|
|
2048
|
+
return join2(process.env.XDG_CONFIG_HOME ?? join2(homedir(), ".config"), "cohall");
|
|
2049
|
+
};
|
|
2050
|
+
var configurationPath = () => resolve(process.env.COHALL_CONFIG ?? join2(defaultConfigDirectory(), "config.json"));
|
|
2051
|
+
var normalizeRelayUrl = (input) => {
|
|
2052
|
+
const url = new URL(input);
|
|
2053
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2054
|
+
throw new Error("Relay URL must use http or https");
|
|
2055
|
+
}
|
|
2056
|
+
if (url.username.length > 0 || url.password.length > 0 || url.search.length > 0 || url.hash.length > 0) {
|
|
2057
|
+
throw new Error("Relay URL cannot include credentials, a query, or a fragment");
|
|
2058
|
+
}
|
|
2059
|
+
if (url.pathname !== "/" && url.pathname !== "") {
|
|
2060
|
+
throw new Error("Relay URL cannot include a path");
|
|
2061
|
+
}
|
|
2062
|
+
url.pathname = "/";
|
|
2063
|
+
return url.toString().replace(/\/$/, "");
|
|
2064
|
+
};
|
|
2065
|
+
var parseWorkspaces = (workspaceList, workspaceJson) => Effect3.tryPromise({
|
|
2066
|
+
try: async () => {
|
|
2067
|
+
const values = workspaceJson === undefined ? workspaceList.split(",").map((workspace) => workspace.trim()).filter((workspace) => workspace.length > 0) : Schema4.decodeUnknownSync(WorkspaceList)(JSON.parse(workspaceJson));
|
|
2068
|
+
const canonical = await Promise.all(values.map((workspace) => realpath(resolve(workspace))));
|
|
2069
|
+
return [...new Set(canonical)];
|
|
2070
|
+
},
|
|
2071
|
+
catch: (cause) => new Error(`Workspace roots must be existing paths; JSON input must be an array of paths: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
2072
|
+
});
|
|
2073
|
+
var readStoredConfiguration = async () => {
|
|
2074
|
+
const path = configurationPath();
|
|
2075
|
+
if (!await access(path).then(() => true).catch(() => false)) {
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
if (platform2() !== "win32") {
|
|
2079
|
+
await chmod(path, 384);
|
|
2080
|
+
}
|
|
2081
|
+
try {
|
|
2082
|
+
return Schema4.decodeUnknownSync(StoredConfiguration)(JSON.parse(await readFile(path, "utf8")));
|
|
2083
|
+
} catch (cause) {
|
|
2084
|
+
throw new Error(`Invalid Cohall configuration at ${path}: ${String(cause)}`);
|
|
2085
|
+
}
|
|
2086
|
+
};
|
|
2087
|
+
var writeStoredConfiguration = async (configuration) => {
|
|
2088
|
+
const path = configurationPath();
|
|
2089
|
+
const directory = dirname(path);
|
|
2090
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
2091
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
2092
|
+
if (platform2() !== "win32") {
|
|
2093
|
+
await chmod(directory, 448);
|
|
2094
|
+
}
|
|
2095
|
+
await writeFile(temporary, `${JSON.stringify(configuration, null, 2)}
|
|
2096
|
+
`, { mode: 384 });
|
|
2097
|
+
await rename(temporary, path);
|
|
2098
|
+
if (platform2() !== "win32") {
|
|
2099
|
+
await chmod(path, 384);
|
|
2100
|
+
}
|
|
2101
|
+
};
|
|
2102
|
+
var makeStoredConfiguration = async (input) => {
|
|
2103
|
+
const existing = await readStoredConfiguration();
|
|
2104
|
+
const canonicalWorkspaces = input.workspaces === undefined ? existing?.workspaces ?? [] : await Effect3.runPromise(parseWorkspaces("", JSON.stringify(input.workspaces)));
|
|
2105
|
+
const clientToken = input.clientToken ?? existing?.clientToken;
|
|
2106
|
+
const deviceToken = input.deviceToken ?? existing?.deviceToken;
|
|
2107
|
+
return StoredConfiguration.make({
|
|
2108
|
+
version: 1,
|
|
2109
|
+
relayUrl: normalizeRelayUrl(input.relayUrl),
|
|
2110
|
+
deviceId: existing?.deviceId ?? makeDeviceId(),
|
|
2111
|
+
deviceName: input.deviceName ?? existing?.deviceName ?? hostname(),
|
|
2112
|
+
workspaces: canonicalWorkspaces,
|
|
2113
|
+
...clientToken === undefined ? {} : { clientToken },
|
|
2114
|
+
...deviceToken === undefined ? {} : { deviceToken },
|
|
2115
|
+
...existing?.model === undefined ? {} : { model: existing.model },
|
|
2116
|
+
...existing?.sandbox === undefined ? {} : { sandbox: existing.sandbox }
|
|
2117
|
+
});
|
|
2118
|
+
};
|
|
2119
|
+
var environmentWorkspaces = async (stored) => {
|
|
2120
|
+
const list = process.env.COHALL_DEVICE_WORKSPACES;
|
|
2121
|
+
const json = process.env.COHALL_DEVICE_WORKSPACES_JSON;
|
|
2122
|
+
if (list === undefined && json === undefined) {
|
|
2123
|
+
return stored?.workspaces ?? [];
|
|
2124
|
+
}
|
|
2125
|
+
return Effect3.runPromise(parseWorkspaces(list ?? "", json));
|
|
2126
|
+
};
|
|
2127
|
+
var environmentProvider = () => process.env.COHALL_MODEL ?? process.env.COHALL_CODEX_MODEL;
|
|
2128
|
+
var environmentSandbox = () => {
|
|
2129
|
+
const value = process.env.COHALL_SANDBOX ?? process.env.COHALL_CODEX_SANDBOX;
|
|
2130
|
+
return value === undefined ? undefined : Schema4.decodeUnknownSync(Sandbox)(value);
|
|
2131
|
+
};
|
|
2132
|
+
var storedOrDefaults = async () => {
|
|
2133
|
+
const stored = await readStoredConfiguration();
|
|
2134
|
+
return stored ?? StoredConfiguration.make({
|
|
2135
|
+
version: 1,
|
|
2136
|
+
relayUrl: "http://127.0.0.1:8787",
|
|
2137
|
+
deviceId: makeDeviceId(),
|
|
2138
|
+
deviceName: hostname(),
|
|
2139
|
+
workspaces: []
|
|
2140
|
+
});
|
|
2141
|
+
};
|
|
2142
|
+
var loadClientConfiguration = Effect3.tryPromise({
|
|
2143
|
+
try: async () => {
|
|
2144
|
+
const stored = await storedOrDefaults();
|
|
2145
|
+
const token = process.env.COHALL_CLIENT_TOKEN ?? stored.clientToken;
|
|
2146
|
+
if (token === undefined) {
|
|
2147
|
+
throw new Error(`No client credential. Run cohall join with a pairing token on stdin or set COHALL_CLIENT_TOKEN.`);
|
|
2148
|
+
}
|
|
2149
|
+
return ClientConfiguration.make({
|
|
2150
|
+
relayUrl: normalizeRelayUrl(process.env.COHALL_RELAY_URL ?? stored.relayUrl),
|
|
2151
|
+
token,
|
|
2152
|
+
...process.env.COHALL_THREAD_ID === undefined ? {} : { mcpThreadId: process.env.COHALL_THREAD_ID }
|
|
2153
|
+
});
|
|
2154
|
+
},
|
|
2155
|
+
catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
|
|
2156
|
+
});
|
|
2157
|
+
var loadOwnerConfiguration = Effect3.tryPromise({
|
|
2158
|
+
try: async () => {
|
|
2159
|
+
const stored = await storedOrDefaults();
|
|
2160
|
+
const token = process.env.COHALL_TOKEN;
|
|
2161
|
+
if (token === undefined) {
|
|
2162
|
+
throw new Error("No owner credential. Set COHALL_TOKEN on the owner-authenticated machine.");
|
|
2163
|
+
}
|
|
2164
|
+
return ClientConfiguration.make({
|
|
2165
|
+
relayUrl: normalizeRelayUrl(process.env.COHALL_RELAY_URL ?? stored.relayUrl),
|
|
2166
|
+
token
|
|
2167
|
+
});
|
|
2168
|
+
},
|
|
2169
|
+
catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
|
|
2170
|
+
});
|
|
2171
|
+
var loadDeviceConfiguration = Effect3.tryPromise({
|
|
2172
|
+
try: async () => {
|
|
2173
|
+
const stored = await storedOrDefaults();
|
|
2174
|
+
const token = process.env.COHALL_DEVICE_TOKEN ?? stored.deviceToken;
|
|
2175
|
+
if (token === undefined) {
|
|
2176
|
+
throw new Error(`No device credential. Run cohall join with a pairing token on stdin or set COHALL_DEVICE_TOKEN.`);
|
|
2177
|
+
}
|
|
2178
|
+
const workspaces = await environmentWorkspaces(stored);
|
|
2179
|
+
if (workspaces.length === 0) {
|
|
2180
|
+
throw new Error("No device workspace roots are configured");
|
|
2181
|
+
}
|
|
2182
|
+
const model = environmentProvider() ?? stored.model;
|
|
2183
|
+
const sandbox = environmentSandbox() ?? stored.sandbox;
|
|
2184
|
+
return DeviceConfiguration.make({
|
|
2185
|
+
relayUrl: normalizeRelayUrl(process.env.COHALL_RELAY_URL ?? stored.relayUrl),
|
|
2186
|
+
token,
|
|
2187
|
+
id: process.env.COHALL_DEVICE_ID === undefined ? stored.deviceId : DeviceId.make(process.env.COHALL_DEVICE_ID),
|
|
2188
|
+
name: process.env.COHALL_DEVICE_NAME ?? stored.deviceName,
|
|
2189
|
+
workspaces,
|
|
2190
|
+
...model === undefined ? {} : { model },
|
|
2191
|
+
...sandbox === undefined ? {} : { sandbox }
|
|
2192
|
+
});
|
|
2193
|
+
},
|
|
2194
|
+
catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
|
|
2195
|
+
});
|
|
2196
|
+
var knownProviders = Provider.literals;
|
|
2197
|
+
|
|
2198
|
+
// apps/device/src/delegation.ts
|
|
2199
|
+
init_src();
|
|
2200
|
+
import { Effect as Effect4, Schema as Schema5 } from "effect";
|
|
2201
|
+
|
|
2202
|
+
class DeviceSelectionError extends Schema5.TaggedErrorClass()("Cohall.DeviceSelectionError", { message: Schema5.String, devices: Schema5.Array(Schema5.String) }) {
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
class TaskWaitTimeoutError extends Schema5.TaggedErrorClass()("Cohall.TaskWaitTimeoutError", { message: Schema5.String, taskId: TaskId, status: TaskStatus }) {
|
|
2206
|
+
}
|
|
2207
|
+
var TaskResult = Schema5.Struct({
|
|
2208
|
+
task_id: TaskId,
|
|
2209
|
+
thread_id: ThreadId,
|
|
2210
|
+
status: TaskStatus,
|
|
2211
|
+
provider: Provider,
|
|
2212
|
+
target_device_id: DeviceId,
|
|
2213
|
+
result: Schema5.optionalKey(Schema5.String),
|
|
2214
|
+
error: Schema5.optionalKey(Schema5.String)
|
|
2215
|
+
});
|
|
2216
|
+
var matchingDevices = (devices, target) => {
|
|
2217
|
+
const normalized = target.replace(/^@/, "").toLowerCase();
|
|
2218
|
+
const byId = devices.find((device) => device.id === target);
|
|
2219
|
+
return byId === undefined ? devices.filter((device) => device.name.toLowerCase() === normalized || device.hostname.toLowerCase() === normalized) : [byId];
|
|
2220
|
+
};
|
|
2221
|
+
var taskResult = (task) => TaskResult.make({
|
|
2222
|
+
task_id: task.id,
|
|
2223
|
+
thread_id: task.threadId,
|
|
2224
|
+
status: task.status,
|
|
2225
|
+
provider: task.provider,
|
|
2226
|
+
target_device_id: task.targetDeviceId,
|
|
2227
|
+
...task.result === undefined ? {} : { result: task.result },
|
|
2228
|
+
...task.error === undefined ? {} : { error: task.error }
|
|
2229
|
+
});
|
|
2230
|
+
var createDelegation = Effect4.fn("Cohall.createDelegation")(function* (client, configuration, options) {
|
|
2231
|
+
const devices = yield* client.devices();
|
|
2232
|
+
const matches = options.target === undefined ? [] : matchingDevices(devices, options.target);
|
|
2233
|
+
if (options.target !== undefined && matches.length !== 1) {
|
|
2234
|
+
return yield* new DeviceSelectionError({
|
|
2235
|
+
message: matches.length === 0 ? `No Cohall device matches ${options.target}` : `${options.target} is ambiguous; use a device ID`,
|
|
2236
|
+
devices: matches.length === 0 ? devices.map((device) => `${device.name} (${device.id})`) : matches.map((device) => `${device.name} (${device.id})`)
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
if (devices.length === 0) {
|
|
2240
|
+
return yield* new DeviceSelectionError({
|
|
2241
|
+
message: "No Cohall devices are registered",
|
|
2242
|
+
devices: []
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
const target = matches[0];
|
|
2246
|
+
const inheritedThread = configuration.mcpThreadId;
|
|
2247
|
+
return yield* client.createTask({
|
|
2248
|
+
prompt: options.prompt,
|
|
2249
|
+
...target === undefined ? {} : { targetDeviceId: target.id },
|
|
2250
|
+
...options.threadId !== undefined ? { threadId: options.threadId } : inheritedThread === undefined ? {} : { threadId: ThreadId.make(inheritedThread) },
|
|
2251
|
+
...options.context === undefined ? {} : { context: options.context },
|
|
2252
|
+
...options.workspace === undefined ? {} : { workspace: options.workspace },
|
|
2253
|
+
...options.provider === undefined ? {} : { provider: options.provider }
|
|
2254
|
+
});
|
|
2255
|
+
});
|
|
2256
|
+
var waitForTask = Effect4.fn("Cohall.waitForTask")(function* (client, initial, timeoutSeconds) {
|
|
2257
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
2258
|
+
let task = initial;
|
|
2259
|
+
while (!isTerminalTask(task)) {
|
|
2260
|
+
if (Date.now() >= deadline) {
|
|
2261
|
+
return yield* new TaskWaitTimeoutError({
|
|
2262
|
+
message: `Task ${task.id} is still ${task.status} after ${timeoutSeconds} seconds`,
|
|
2263
|
+
taskId: task.id,
|
|
2264
|
+
status: task.status
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
yield* Effect4.sleep("1 second");
|
|
2268
|
+
task = yield* client.getTask(task.id);
|
|
2269
|
+
}
|
|
2270
|
+
return task;
|
|
2271
|
+
});
|
|
2272
|
+
var threadContext = Effect4.fn("Cohall.threadContext")(function* (client, threadId) {
|
|
2273
|
+
return yield* client.threadContext(threadId);
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
// apps/device/src/cli.ts
|
|
2277
|
+
var valueOptions = new Set([
|
|
2278
|
+
"context",
|
|
2279
|
+
"context-file",
|
|
2280
|
+
"label",
|
|
2281
|
+
"model",
|
|
2282
|
+
"name",
|
|
2283
|
+
"prompt",
|
|
2284
|
+
"prompt-file",
|
|
2285
|
+
"provider",
|
|
2286
|
+
"relay",
|
|
2287
|
+
"sandbox",
|
|
2288
|
+
"target",
|
|
2289
|
+
"thread",
|
|
2290
|
+
"timeout",
|
|
2291
|
+
"token-file",
|
|
2292
|
+
"workspace"
|
|
2293
|
+
]);
|
|
2294
|
+
var flagOptions = new Set(["client-only", "no-wait"]);
|
|
2295
|
+
var aliases = new Map([
|
|
2296
|
+
["-c", "context"],
|
|
2297
|
+
["-p", "prompt"],
|
|
2298
|
+
["-t", "target"],
|
|
2299
|
+
["-T", "thread"],
|
|
2300
|
+
["-w", "workspace"]
|
|
2301
|
+
]);
|
|
2302
|
+
var help = `Cohall connects agents running on your own devices.
|
|
2303
|
+
|
|
2304
|
+
Usage:
|
|
2305
|
+
cohall join --relay <url> --workspace <path> [--token-file <path>]
|
|
2306
|
+
cohall configure [--relay url] [--name name] [--workspace path] [--model id]
|
|
2307
|
+
cohall config
|
|
2308
|
+
cohall devices
|
|
2309
|
+
cohall delegate [prompt] [--target @device] [--provider provider]
|
|
2310
|
+
[--context text] [--thread uuid] [--workspace path]
|
|
2311
|
+
[--timeout seconds] [--no-wait]
|
|
2312
|
+
cohall status <task-id>
|
|
2313
|
+
cohall wait <task-id> [--timeout seconds]
|
|
2314
|
+
cohall cancel <task-id>
|
|
2315
|
+
cohall thread <thread-id>
|
|
2316
|
+
cohall pair [--label name] [--client-only]
|
|
2317
|
+
cohall sessions
|
|
2318
|
+
cohall revoke <session-id>
|
|
2319
|
+
cohall doctor
|
|
2320
|
+
cohall device
|
|
2321
|
+
cohall relay
|
|
2322
|
+
cohall mcp
|
|
2323
|
+
cohall skill [install [agents|claude|opencode|all]]
|
|
2324
|
+
cohall integrations
|
|
2325
|
+
|
|
2326
|
+
Providers: codex, claude-code, opencode.
|
|
2327
|
+
Configuration is stored per user; environment variables override it.`;
|
|
2328
|
+
var parseArguments = (values) => {
|
|
2329
|
+
const options = new Map;
|
|
2330
|
+
const positionals = [];
|
|
2331
|
+
for (let index = 0;index < values.length; index += 1) {
|
|
2332
|
+
const argument = values[index];
|
|
2333
|
+
if (argument === undefined) {
|
|
2334
|
+
break;
|
|
2335
|
+
}
|
|
2336
|
+
if (argument === "--") {
|
|
2337
|
+
positionals.push(...values.slice(index + 1));
|
|
2338
|
+
break;
|
|
2339
|
+
}
|
|
2340
|
+
if (!argument.startsWith("-")) {
|
|
2341
|
+
positionals.push(argument);
|
|
2342
|
+
continue;
|
|
2343
|
+
}
|
|
2344
|
+
const separator = argument.indexOf("=");
|
|
2345
|
+
const rawName = separator === -1 ? argument : argument.slice(0, separator);
|
|
2346
|
+
const inline = separator === -1 ? undefined : argument.slice(separator + 1);
|
|
2347
|
+
const name = aliases.get(rawName) ?? rawName.replace(/^--/, "");
|
|
2348
|
+
if (flagOptions.has(name)) {
|
|
2349
|
+
if (inline !== undefined) {
|
|
2350
|
+
throw new Error(`--${name} does not accept a value`);
|
|
2351
|
+
}
|
|
2352
|
+
options.set(name, true);
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
2355
|
+
if (!valueOptions.has(name)) {
|
|
2356
|
+
throw new Error(`Unknown option: ${argument}`);
|
|
2357
|
+
}
|
|
2358
|
+
const value = inline ?? values[index + 1];
|
|
2359
|
+
if (value === undefined || inline === undefined && value.startsWith("-") && value !== "-") {
|
|
2360
|
+
throw new Error(`${rawName} requires a value`);
|
|
2361
|
+
}
|
|
2362
|
+
const previous = options.get(name);
|
|
2363
|
+
options.set(name, previous === undefined || previous === true ? [value] : [...previous, value]);
|
|
2364
|
+
if (inline === undefined) {
|
|
2365
|
+
index += 1;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
return { options, positionals };
|
|
2369
|
+
};
|
|
2370
|
+
var values = (arguments_, name) => {
|
|
2371
|
+
const option = arguments_.options.get(name);
|
|
2372
|
+
if (option === true) {
|
|
2373
|
+
throw new Error(`--${name} requires a value`);
|
|
2374
|
+
}
|
|
2375
|
+
return option ?? [];
|
|
2376
|
+
};
|
|
2377
|
+
var option = (arguments_, name) => {
|
|
2378
|
+
const found = values(arguments_, name);
|
|
2379
|
+
if (found.length > 1 && name !== "workspace") {
|
|
2380
|
+
throw new Error(`--${name} may only be specified once`);
|
|
2381
|
+
}
|
|
2382
|
+
return found[0];
|
|
2383
|
+
};
|
|
2384
|
+
var allowOptions = (arguments_, names) => {
|
|
2385
|
+
const allowed = new Set(names);
|
|
2386
|
+
for (const name of arguments_.options.keys()) {
|
|
2387
|
+
if (!allowed.has(name)) {
|
|
2388
|
+
throw new Error(`--${name} is not valid for this command`);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
};
|
|
2392
|
+
var noPositionals = (arguments_, command2) => {
|
|
2393
|
+
if (arguments_.positionals.length > 0) {
|
|
2394
|
+
throw new Error(`${command2} does not accept positional arguments`);
|
|
2395
|
+
}
|
|
2396
|
+
};
|
|
2397
|
+
var identifier = (arguments_, label) => {
|
|
2398
|
+
const value = arguments_.positionals[0];
|
|
2399
|
+
if (value === undefined) {
|
|
2400
|
+
throw new Error(`${label} is required`);
|
|
2401
|
+
}
|
|
2402
|
+
if (arguments_.positionals.length > 1) {
|
|
2403
|
+
throw new Error(`Unexpected arguments after ${label}`);
|
|
2404
|
+
}
|
|
2405
|
+
return value;
|
|
2406
|
+
};
|
|
2407
|
+
var print = (value) => console.log(JSON.stringify(value, null, 2));
|
|
2408
|
+
var readInput = async (arguments_, name) => {
|
|
2409
|
+
const direct = option(arguments_, name);
|
|
2410
|
+
const path = option(arguments_, `${name}-file`);
|
|
2411
|
+
if (direct !== undefined && path !== undefined) {
|
|
2412
|
+
throw new Error(`Use either --${name} or --${name}-file, not both`);
|
|
2413
|
+
}
|
|
2414
|
+
if (path !== undefined) {
|
|
2415
|
+
if (!await access2(path).then(() => true).catch(() => false)) {
|
|
2416
|
+
throw new Error(`File does not exist: ${path}`);
|
|
2417
|
+
}
|
|
2418
|
+
if ((await stat(path)).size > 131072) {
|
|
2419
|
+
throw new Error(`${name} file exceeds 128 KiB`);
|
|
2420
|
+
}
|
|
2421
|
+
return readFile2(path, "utf8");
|
|
2422
|
+
}
|
|
2423
|
+
const result = direct === "-" ? await readStdin(131072, name) : direct;
|
|
2424
|
+
if (result !== undefined && result.length > 131072) {
|
|
2425
|
+
throw new Error(`${name} exceeds 128 KiB`);
|
|
2426
|
+
}
|
|
2427
|
+
return result;
|
|
2428
|
+
};
|
|
2429
|
+
var readStdin = async (limit, label) => {
|
|
2430
|
+
const chunks = [];
|
|
2431
|
+
let size = 0;
|
|
2432
|
+
for await (const chunk of process.stdin) {
|
|
2433
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk);
|
|
2434
|
+
size += bytes.byteLength;
|
|
2435
|
+
if (size > limit) {
|
|
2436
|
+
throw new Error(`${label} exceeds ${Math.floor(limit / 1024)} KiB`);
|
|
2437
|
+
}
|
|
2438
|
+
chunks.push(bytes);
|
|
2439
|
+
}
|
|
2440
|
+
return Buffer.concat(chunks, size).toString("utf8");
|
|
2441
|
+
};
|
|
2442
|
+
var timeout = (arguments_) => {
|
|
2443
|
+
const seconds = Number(option(arguments_, "timeout") ?? "900");
|
|
2444
|
+
if (!Number.isInteger(seconds) || seconds < 5 || seconds > 86400) {
|
|
2445
|
+
throw new Error("--timeout must be an integer between 5 and 86400");
|
|
2446
|
+
}
|
|
2447
|
+
return seconds;
|
|
2448
|
+
};
|
|
2449
|
+
var pairingToken = async (arguments_) => {
|
|
2450
|
+
const path = option(arguments_, "token-file");
|
|
2451
|
+
if (path !== undefined && !await access2(path).then(() => true).catch(() => false)) {
|
|
2452
|
+
throw new Error(`Pairing token file does not exist: ${path}`);
|
|
2453
|
+
}
|
|
2454
|
+
if (path !== undefined && (await stat(path)).size > 512) {
|
|
2455
|
+
throw new Error("Pairing token file exceeds 512 bytes");
|
|
2456
|
+
}
|
|
2457
|
+
const token = (path === undefined ? await readStdin(512, "Pairing token") : await readFile2(path, "utf8")).trim();
|
|
2458
|
+
if (token.length === 0 || new TextEncoder().encode(token).byteLength > 256) {
|
|
2459
|
+
throw new Error("Provide a pairing token on stdin or with --token-file");
|
|
2460
|
+
}
|
|
2461
|
+
return token;
|
|
2462
|
+
};
|
|
2463
|
+
var client = async () => {
|
|
2464
|
+
const configuration = await Effect5.runPromise(loadClientConfiguration);
|
|
2465
|
+
return {
|
|
2466
|
+
configuration,
|
|
2467
|
+
relay: exports_src.make({ baseUrl: configuration.relayUrl, token: configuration.token })
|
|
2468
|
+
};
|
|
2469
|
+
};
|
|
2470
|
+
var ownerClient = async () => {
|
|
2471
|
+
const configuration = await Effect5.runPromise(loadOwnerConfiguration);
|
|
2472
|
+
return {
|
|
2473
|
+
configuration,
|
|
2474
|
+
relay: exports_src.make({ baseUrl: configuration.relayUrl, token: configuration.token })
|
|
2475
|
+
};
|
|
2476
|
+
};
|
|
2477
|
+
var installSkill = async (target) => {
|
|
2478
|
+
const roots = {
|
|
2479
|
+
agents: join3(homedir2(), ".agents", "skills", "cohall", "SKILL.md"),
|
|
2480
|
+
claude: join3(homedir2(), ".claude", "skills", "cohall", "SKILL.md"),
|
|
2481
|
+
opencode: join3(homedir2(), ".config", "opencode", "skills", "cohall", "SKILL.md")
|
|
2482
|
+
};
|
|
2483
|
+
const selected = target === "all" ? Object.values(roots) : (target in roots) ? [roots[target]] : undefined;
|
|
2484
|
+
if (selected === undefined) {
|
|
2485
|
+
throw new Error("Skill target must be agents, claude, opencode, or all");
|
|
2486
|
+
}
|
|
2487
|
+
for (const path of selected) {
|
|
2488
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
2489
|
+
await writeFile2(path, SKILL_default);
|
|
2490
|
+
}
|
|
2491
|
+
return selected;
|
|
2492
|
+
};
|
|
2493
|
+
var printHelp = () => console.log(help);
|
|
2494
|
+
var printSkill = () => console.log(SKILL_default.trimEnd());
|
|
2495
|
+
var runCli = async (command2, raw) => {
|
|
2496
|
+
const arguments_ = parseArguments(raw);
|
|
2497
|
+
if (command2 === "join") {
|
|
2498
|
+
allowOptions(arguments_, ["client-only", "name", "relay", "token-file", "workspace"]);
|
|
2499
|
+
noPositionals(arguments_, command2);
|
|
2500
|
+
const token = await pairingToken(arguments_);
|
|
2501
|
+
const relayUrl = normalizeRelayUrl(option(arguments_, "relay") ?? "http://127.0.0.1:8787");
|
|
2502
|
+
const existing = await readStoredConfiguration();
|
|
2503
|
+
const suppliedWorkspaces = values(arguments_, "workspace");
|
|
2504
|
+
const workspaces = suppliedWorkspaces.length === 0 ? existing?.workspaces : suppliedWorkspaces;
|
|
2505
|
+
const deviceName = option(arguments_, "name");
|
|
2506
|
+
if (!arguments_.options.has("client-only") && (workspaces?.length ?? 0) === 0) {
|
|
2507
|
+
throw new Error("At least one --workspace is required when joining a device");
|
|
2508
|
+
}
|
|
2509
|
+
const draft = await makeStoredConfiguration({
|
|
2510
|
+
relayUrl,
|
|
2511
|
+
...deviceName === undefined ? {} : { deviceName },
|
|
2512
|
+
...workspaces === undefined ? {} : { workspaces }
|
|
2513
|
+
});
|
|
2514
|
+
const paired = await Effect5.runPromise(exchangePairing(relayUrl, { token }));
|
|
2515
|
+
const clientToken = paired.credentials.find((credential) => credential.session.role === "client")?.token;
|
|
2516
|
+
const deviceToken = paired.credentials.find((credential) => credential.session.role === "device")?.token;
|
|
2517
|
+
const deviceId = paired.credentials.find((credential) => credential.session.role === "device")?.session.deviceId;
|
|
2518
|
+
if (!arguments_.options.has("client-only") && deviceToken === undefined) {
|
|
2519
|
+
throw new Error("The pairing credential did not include device access");
|
|
2520
|
+
}
|
|
2521
|
+
const configuration2 = StoredConfiguration.make({
|
|
2522
|
+
...draft,
|
|
2523
|
+
deviceId: deviceId ?? draft.deviceId,
|
|
2524
|
+
...clientToken === undefined ? {} : { clientToken },
|
|
2525
|
+
...deviceToken === undefined ? {} : { deviceToken }
|
|
2526
|
+
});
|
|
2527
|
+
await writeStoredConfiguration(configuration2);
|
|
2528
|
+
print({
|
|
2529
|
+
configured: true,
|
|
2530
|
+
config_path: configurationPath(),
|
|
2531
|
+
relay_url: configuration2.relayUrl,
|
|
2532
|
+
device_id: configuration2.deviceId,
|
|
2533
|
+
roles: paired.credentials.map((credential) => credential.session.role),
|
|
2534
|
+
workspaces: configuration2.workspaces
|
|
2535
|
+
});
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
if (command2 === "config") {
|
|
2539
|
+
allowOptions(arguments_, []);
|
|
2540
|
+
noPositionals(arguments_, command2);
|
|
2541
|
+
const configuration2 = await readStoredConfiguration();
|
|
2542
|
+
print({
|
|
2543
|
+
config_path: configurationPath(),
|
|
2544
|
+
configured: configuration2 !== undefined,
|
|
2545
|
+
...configuration2 === undefined ? {} : {
|
|
2546
|
+
relay_url: configuration2.relayUrl,
|
|
2547
|
+
device_id: configuration2.deviceId,
|
|
2548
|
+
device_name: configuration2.deviceName,
|
|
2549
|
+
workspaces: configuration2.workspaces,
|
|
2550
|
+
client_credential: configuration2.clientToken !== undefined,
|
|
2551
|
+
device_credential: configuration2.deviceToken !== undefined,
|
|
2552
|
+
model: configuration2.model,
|
|
2553
|
+
sandbox: configuration2.sandbox
|
|
2554
|
+
}
|
|
2555
|
+
});
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
if (command2 === "configure") {
|
|
2559
|
+
allowOptions(arguments_, ["model", "name", "relay", "sandbox", "workspace"]);
|
|
2560
|
+
noPositionals(arguments_, command2);
|
|
2561
|
+
const existing = await readStoredConfiguration();
|
|
2562
|
+
const relayUrl = normalizeRelayUrl(option(arguments_, "relay") ?? existing?.relayUrl ?? "http://127.0.0.1:8787");
|
|
2563
|
+
const supplied = values(arguments_, "workspace");
|
|
2564
|
+
const workspaces = supplied.length === 0 ? existing?.workspaces ?? [] : await Effect5.runPromise(parseWorkspaces("", JSON.stringify(supplied)));
|
|
2565
|
+
const sandbox = option(arguments_, "sandbox");
|
|
2566
|
+
const model = option(arguments_, "model") ?? existing?.model;
|
|
2567
|
+
const configuration2 = StoredConfiguration.make({
|
|
2568
|
+
version: 1,
|
|
2569
|
+
relayUrl,
|
|
2570
|
+
deviceId: existing?.deviceId ?? makeDeviceId(),
|
|
2571
|
+
deviceName: option(arguments_, "name") ?? existing?.deviceName ?? "cohall-device",
|
|
2572
|
+
workspaces,
|
|
2573
|
+
...existing?.clientToken === undefined ? {} : { clientToken: existing.clientToken },
|
|
2574
|
+
...existing?.deviceToken === undefined ? {} : { deviceToken: existing.deviceToken },
|
|
2575
|
+
...model === undefined ? {} : { model },
|
|
2576
|
+
...(sandbox ?? existing?.sandbox) === undefined ? {} : {
|
|
2577
|
+
sandbox: Schema6.decodeUnknownSync(Schema6.Literals(["read-only", "workspace-write", "danger-full-access"]))(sandbox ?? existing?.sandbox)
|
|
2578
|
+
}
|
|
2579
|
+
});
|
|
2580
|
+
await writeStoredConfiguration(configuration2);
|
|
2581
|
+
print({ configured: true, config_path: configurationPath() });
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
if (command2 === "skill") {
|
|
2585
|
+
allowOptions(arguments_, []);
|
|
2586
|
+
if (arguments_.positionals.length === 0) {
|
|
2587
|
+
printSkill();
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
if (arguments_.positionals[0] !== "install" || arguments_.positionals.length > 2) {
|
|
2591
|
+
throw new Error("Usage: cohall skill [install [agents|claude|opencode|all]]");
|
|
2592
|
+
}
|
|
2593
|
+
print({ installed: await installSkill(arguments_.positionals[1] ?? "all") });
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
if (command2 === "integrations") {
|
|
2597
|
+
allowOptions(arguments_, []);
|
|
2598
|
+
noPositionals(arguments_, command2);
|
|
2599
|
+
print({
|
|
2600
|
+
cli_skill: "npx -y @akshar5/cohall skill install all",
|
|
2601
|
+
codex_mcp: "codex mcp add cohall -- npx -y @akshar5/cohall mcp",
|
|
2602
|
+
claude_mcp: "claude mcp add --scope user cohall -- npx -y @akshar5/cohall mcp",
|
|
2603
|
+
opencode_mcp: "Run opencode mcp add and use: npx -y @akshar5/cohall mcp"
|
|
2604
|
+
});
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
if (command2 === "doctor") {
|
|
2608
|
+
allowOptions(arguments_, []);
|
|
2609
|
+
noPositionals(arguments_, command2);
|
|
2610
|
+
const configuration2 = await readStoredConfiguration();
|
|
2611
|
+
const relayUrl = normalizeRelayUrl(process.env.COHALL_RELAY_URL ?? configuration2?.relayUrl ?? "http://127.0.0.1:8787");
|
|
2612
|
+
const response = await fetch(`${relayUrl}/api/health`, {
|
|
2613
|
+
signal: AbortSignal.timeout(1e4)
|
|
2614
|
+
}).catch(() => {
|
|
2615
|
+
return;
|
|
2616
|
+
});
|
|
2617
|
+
print({
|
|
2618
|
+
relay: response?.ok === true ? "reachable" : "unreachable",
|
|
2619
|
+
relay_url: relayUrl,
|
|
2620
|
+
config_path: configurationPath(),
|
|
2621
|
+
client_credential: process.env.COHALL_CLIENT_TOKEN !== undefined || configuration2?.clientToken !== undefined,
|
|
2622
|
+
device_credential: process.env.COHALL_DEVICE_TOKEN !== undefined || configuration2?.deviceToken !== undefined,
|
|
2623
|
+
workspaces: configuration2?.workspaces ?? [],
|
|
2624
|
+
providers: Object.fromEntries(Provider.literals.map((provider) => [
|
|
2625
|
+
provider,
|
|
2626
|
+
findExecutable(provider === "claude-code" ? "claude" : provider) ?? "not found"
|
|
2627
|
+
]))
|
|
2628
|
+
});
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
if (command2 === "pair" || command2 === "sessions" || command2 === "revoke") {
|
|
2632
|
+
const { configuration: configuration2, relay: relay2 } = await ownerClient();
|
|
2633
|
+
if (command2 === "pair") {
|
|
2634
|
+
allowOptions(arguments_, ["client-only", "label"]);
|
|
2635
|
+
noPositionals(arguments_, command2);
|
|
2636
|
+
const roles = arguments_.options.has("client-only") ? ["client"] : ["client", "device"];
|
|
2637
|
+
const credential = await Effect5.runPromise(relay2.createPairing({ label: option(arguments_, "label") ?? "Cohall device", roles }));
|
|
2638
|
+
print({
|
|
2639
|
+
relay_url: configuration2.relayUrl,
|
|
2640
|
+
pairing_token: credential.token,
|
|
2641
|
+
expires_at: credential.expiresAt,
|
|
2642
|
+
roles
|
|
2643
|
+
});
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
if (command2 === "sessions") {
|
|
2647
|
+
allowOptions(arguments_, []);
|
|
2648
|
+
noPositionals(arguments_, command2);
|
|
2649
|
+
print(await Effect5.runPromise(relay2.authSessions()));
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
allowOptions(arguments_, []);
|
|
2653
|
+
print(await Effect5.runPromise(relay2.revokeAuthSession(Schema6.decodeUnknownSync(AuthSessionId)(identifier(arguments_, "session id")))));
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
const { configuration, relay } = await client();
|
|
2657
|
+
if (command2 === "devices") {
|
|
2658
|
+
allowOptions(arguments_, []);
|
|
2659
|
+
noPositionals(arguments_, command2);
|
|
2660
|
+
print(await Effect5.runPromise(relay.devices()));
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
if (command2 === "delegate") {
|
|
2664
|
+
allowOptions(arguments_, [
|
|
2665
|
+
"context",
|
|
2666
|
+
"context-file",
|
|
2667
|
+
"no-wait",
|
|
2668
|
+
"prompt",
|
|
2669
|
+
"prompt-file",
|
|
2670
|
+
"provider",
|
|
2671
|
+
"target",
|
|
2672
|
+
"thread",
|
|
2673
|
+
"timeout",
|
|
2674
|
+
"workspace"
|
|
2675
|
+
]);
|
|
2676
|
+
if (option(arguments_, "prompt") === "-" && option(arguments_, "context") === "-") {
|
|
2677
|
+
throw new Error("Only one of --prompt and --context may read from stdin");
|
|
2678
|
+
}
|
|
2679
|
+
const suppliedPrompt = await readInput(arguments_, "prompt");
|
|
2680
|
+
const prompt = suppliedPrompt ?? arguments_.positionals.join(" ");
|
|
2681
|
+
if (prompt.trim().length === 0) {
|
|
2682
|
+
throw new Error("A prompt is required");
|
|
2683
|
+
}
|
|
2684
|
+
if (suppliedPrompt !== undefined && arguments_.positionals.length > 0) {
|
|
2685
|
+
throw new Error("Use either a positional prompt or --prompt, not both");
|
|
2686
|
+
}
|
|
2687
|
+
const context = await readInput(arguments_, "context");
|
|
2688
|
+
const target = option(arguments_, "target");
|
|
2689
|
+
const thread = option(arguments_, "thread");
|
|
2690
|
+
const workspace = option(arguments_, "workspace");
|
|
2691
|
+
const provider = option(arguments_, "provider");
|
|
2692
|
+
const task = await Effect5.runPromise(createDelegation(relay, configuration, {
|
|
2693
|
+
prompt,
|
|
2694
|
+
...target === undefined ? {} : { target },
|
|
2695
|
+
...context === undefined ? {} : { context },
|
|
2696
|
+
...thread === undefined ? {} : { threadId: Schema6.decodeUnknownSync(ThreadId)(thread) },
|
|
2697
|
+
...workspace === undefined ? {} : { workspace },
|
|
2698
|
+
...provider === undefined ? {} : { provider: Schema6.decodeUnknownSync(Provider)(provider) }
|
|
2699
|
+
}));
|
|
2700
|
+
print(taskResult(arguments_.options.has("no-wait") ? task : await Effect5.runPromise(waitForTask(relay, task, timeout(arguments_)))));
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
if (command2 === "status") {
|
|
2704
|
+
allowOptions(arguments_, []);
|
|
2705
|
+
const id = Schema6.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
|
|
2706
|
+
print(taskResult(await Effect5.runPromise(relay.getTask(id))));
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
if (command2 === "wait") {
|
|
2710
|
+
allowOptions(arguments_, ["timeout"]);
|
|
2711
|
+
const id = Schema6.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
|
|
2712
|
+
const task = await Effect5.runPromise(relay.getTask(id));
|
|
2713
|
+
print(taskResult(await Effect5.runPromise(waitForTask(relay, task, timeout(arguments_)))));
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
if (command2 === "cancel") {
|
|
2717
|
+
allowOptions(arguments_, []);
|
|
2718
|
+
const id = Schema6.decodeUnknownSync(TaskId)(identifier(arguments_, "task id"));
|
|
2719
|
+
print(taskResult(await Effect5.runPromise(relay.cancelTask(id))));
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
if (command2 === "thread") {
|
|
2723
|
+
allowOptions(arguments_, []);
|
|
2724
|
+
const id = Schema6.decodeUnknownSync(ThreadId)(identifier(arguments_, "thread id"));
|
|
2725
|
+
print(await Effect5.runPromise(threadContext(relay, id)));
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
throw new Error(`Unknown Cohall command: ${command2}`);
|
|
2729
|
+
};
|
|
2730
|
+
|
|
2731
|
+
// apps/device/src/daemon.ts
|
|
2732
|
+
init_src();
|
|
2733
|
+
import { Effect as Effect6, Schedule, Schema as Schema7 } from "effect";
|
|
2734
|
+
import { realpath as realpath2 } from "node:fs/promises";
|
|
2735
|
+
import { arch, hostname as hostname2, platform as platform3 } from "node:os";
|
|
2736
|
+
import { basename, isAbsolute as isAbsolute2, relative } from "node:path";
|
|
2737
|
+
import { WebSocket } from "ws";
|
|
2738
|
+
|
|
2739
|
+
class DeviceConnectionError extends Schema7.TaggedErrorClass()("Device.ConnectionError", { message: Schema7.String }) {
|
|
2740
|
+
}
|
|
2741
|
+
var socketUrl = (configuration) => {
|
|
2742
|
+
const url = new URL(configuration.relayUrl);
|
|
2743
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
2744
|
+
url.pathname = "/ws/device";
|
|
2745
|
+
return url.toString();
|
|
2746
|
+
};
|
|
2747
|
+
var providerLabel = (provider) => {
|
|
2748
|
+
switch (provider) {
|
|
2749
|
+
case "codex":
|
|
2750
|
+
return "Codex";
|
|
2751
|
+
case "claude-code":
|
|
2752
|
+
return "Claude Code";
|
|
2753
|
+
case "opencode":
|
|
2754
|
+
return "OpenCode";
|
|
2755
|
+
}
|
|
2756
|
+
};
|
|
2757
|
+
var capabilities = (providers) => {
|
|
2758
|
+
const values2 = providers.map((provider) => ({
|
|
2759
|
+
id: provider,
|
|
2760
|
+
label: providerLabel(provider),
|
|
2761
|
+
detail: `Uses this device's local ${providerLabel(provider)} login, configuration, and tools`
|
|
2762
|
+
}));
|
|
2763
|
+
if (findExecutable("google-chrome") !== undefined || findExecutable("chromium") !== undefined || platform3() === "darwin") {
|
|
2764
|
+
values2.push({ id: "browser-session", label: "Signed-in browser" });
|
|
2765
|
+
}
|
|
2766
|
+
if (findExecutable("xcodebuild") !== undefined) {
|
|
2767
|
+
values2.push({ id: "xcode", label: "Xcode" });
|
|
2768
|
+
}
|
|
2769
|
+
if (findExecutable("docker") !== undefined) {
|
|
2770
|
+
values2.push({ id: "docker", label: "Docker" });
|
|
2771
|
+
}
|
|
2772
|
+
return values2;
|
|
2773
|
+
};
|
|
2774
|
+
var describeDevice = (configuration, status = "online") => {
|
|
2775
|
+
const operatingSystem = platform3();
|
|
2776
|
+
const platformName = operatingSystem === "darwin" || operatingSystem === "linux" ? operatingSystem : operatingSystem === "win32" ? "windows" : "unknown";
|
|
2777
|
+
const providers = availableProviders();
|
|
2778
|
+
return Device.make({
|
|
2779
|
+
id: configuration.id,
|
|
2780
|
+
name: configuration.name,
|
|
2781
|
+
hostname: hostname2(),
|
|
2782
|
+
platform: platformName,
|
|
2783
|
+
architecture: arch(),
|
|
2784
|
+
status,
|
|
2785
|
+
providers,
|
|
2786
|
+
capabilities: capabilities(providers),
|
|
2787
|
+
workspaces: configuration.workspaces.map((path) => ({
|
|
2788
|
+
path,
|
|
2789
|
+
label: basename(path) || path
|
|
2790
|
+
})),
|
|
2791
|
+
version,
|
|
2792
|
+
lastSeenAt: now()
|
|
2793
|
+
});
|
|
2794
|
+
};
|
|
2795
|
+
var allowedWorkspace = async (configuration, requested) => {
|
|
2796
|
+
const selected = requested ?? configuration.workspaces[0];
|
|
2797
|
+
if (selected === undefined) {
|
|
2798
|
+
throw new Error("No workspace is configured on this device");
|
|
2799
|
+
}
|
|
2800
|
+
const candidate = await realpath2(selected);
|
|
2801
|
+
const allowed = configuration.workspaces.some((root) => {
|
|
2802
|
+
const child = relative(root, candidate);
|
|
2803
|
+
return child === "" || !child.startsWith("..") && !isAbsolute2(child);
|
|
2804
|
+
});
|
|
2805
|
+
if (!allowed) {
|
|
2806
|
+
throw new Error(`Workspace ${candidate} is outside this device's configured workspace roots`);
|
|
2807
|
+
}
|
|
2808
|
+
return candidate;
|
|
2809
|
+
};
|
|
2810
|
+
var promptFor = (task, deviceName) => {
|
|
2811
|
+
const context = task.context === undefined ? "" : `
|
|
2812
|
+
|
|
2813
|
+
Relevant context supplied by the sending agent:
|
|
2814
|
+
${task.context}`;
|
|
2815
|
+
return [
|
|
2816
|
+
`You are the Cohall agent running on ${deviceName}.`,
|
|
2817
|
+
"Complete the delegated task using this device's local workspace, tools, credentials, and signed-in services.",
|
|
2818
|
+
"Never read, reveal, copy, or use Cohall configuration files or Cohall authentication tokens.",
|
|
2819
|
+
"Return a concise, complete result with the evidence the sending agent needs.",
|
|
2820
|
+
`
|
|
2821
|
+
Task:
|
|
2822
|
+
${task.prompt}${context}`
|
|
2823
|
+
].join(`
|
|
2824
|
+
`);
|
|
2825
|
+
};
|
|
2826
|
+
var send = (state, event) => {
|
|
2827
|
+
if (state.socket?.readyState === WebSocket.OPEN) {
|
|
2828
|
+
state.socket.send(JSON.stringify(event));
|
|
2829
|
+
}
|
|
2830
|
+
};
|
|
2831
|
+
var sendTerminal = (state, taskId, event) => {
|
|
2832
|
+
const payload = JSON.stringify(event);
|
|
2833
|
+
state.terminal.set(taskId, payload);
|
|
2834
|
+
if (state.socket?.readyState === WebSocket.OPEN) {
|
|
2835
|
+
state.socket.send(payload);
|
|
2836
|
+
}
|
|
2837
|
+
};
|
|
2838
|
+
var remember = (state, taskId) => {
|
|
2839
|
+
state.completed.add(taskId);
|
|
2840
|
+
if (state.completed.size <= 1000) {
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
const oldest = state.completed.values().next();
|
|
2844
|
+
if (!oldest.done) {
|
|
2845
|
+
state.completed.delete(oldest.value);
|
|
2846
|
+
state.terminal.delete(oldest.value);
|
|
2847
|
+
}
|
|
2848
|
+
};
|
|
2849
|
+
var sessionKey = (task) => `${task.threadId}:${task.provider}`;
|
|
2850
|
+
var execute = (configuration, state, task) => {
|
|
2851
|
+
if (state.tasks.has(task.id) || state.completed.has(task.id)) {
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
const controller = new AbortController;
|
|
2855
|
+
state.tasks.set(task.id, controller);
|
|
2856
|
+
send(state, SocketEvent.make({ _tag: "TaskAccepted", taskId: task.id }));
|
|
2857
|
+
const workflow = Effect6.gen(function* () {
|
|
2858
|
+
const cwd = yield* Effect6.tryPromise({
|
|
2859
|
+
try: () => allowedWorkspace(configuration, task.workspace),
|
|
2860
|
+
catch: (cause) => new ProviderRunError({
|
|
2861
|
+
provider: task.provider,
|
|
2862
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
2863
|
+
})
|
|
2864
|
+
});
|
|
2865
|
+
const sessionId = task.providerSessionId ?? state.sessions.get(sessionKey(task));
|
|
2866
|
+
return yield* run({
|
|
2867
|
+
provider: task.provider,
|
|
2868
|
+
threadId: task.threadId,
|
|
2869
|
+
prompt: promptFor(task, configuration.name),
|
|
2870
|
+
cwd,
|
|
2871
|
+
...sessionId === undefined ? {} : { sessionId },
|
|
2872
|
+
...configuration.model === undefined ? {} : { model: configuration.model },
|
|
2873
|
+
...configuration.sandbox === undefined ? {} : { sandbox: configuration.sandbox }
|
|
2874
|
+
});
|
|
2875
|
+
});
|
|
2876
|
+
Effect6.runPromise(workflow, { signal: controller.signal }).then((result) => {
|
|
2877
|
+
if (result.sessionId !== undefined) {
|
|
2878
|
+
state.sessions.set(sessionKey(task), result.sessionId);
|
|
2879
|
+
}
|
|
2880
|
+
remember(state, task.id);
|
|
2881
|
+
sendTerminal(state, task.id, SocketEvent.make({
|
|
2882
|
+
_tag: "TaskFinished",
|
|
2883
|
+
taskId: task.id,
|
|
2884
|
+
result: result.result,
|
|
2885
|
+
...result.sessionId === undefined ? {} : { providerSessionId: result.sessionId }
|
|
2886
|
+
}));
|
|
2887
|
+
}).catch((cause) => {
|
|
2888
|
+
remember(state, task.id);
|
|
2889
|
+
sendTerminal(state, task.id, controller.signal.aborted ? SocketEvent.make({ _tag: "TaskCancelled", taskId: task.id }) : SocketEvent.make({
|
|
2890
|
+
_tag: "TaskFailed",
|
|
2891
|
+
taskId: task.id,
|
|
2892
|
+
error: (cause instanceof Error ? cause.message : String(cause)).slice(0, 16384)
|
|
2893
|
+
}));
|
|
2894
|
+
}).finally(() => {
|
|
2895
|
+
state.tasks.delete(task.id);
|
|
2896
|
+
const next = state.queue.shift();
|
|
2897
|
+
if (next !== undefined) {
|
|
2898
|
+
execute(configuration, state, next);
|
|
2899
|
+
}
|
|
2900
|
+
});
|
|
2901
|
+
};
|
|
2902
|
+
var schedule = (configuration, state, task) => {
|
|
2903
|
+
if (state.tasks.has(task.id) || state.completed.has(task.id) || state.queue.some((queued) => queued.id === task.id)) {
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2906
|
+
if (task.providerSessionId !== undefined) {
|
|
2907
|
+
state.sessions.set(sessionKey(task), task.providerSessionId);
|
|
2908
|
+
}
|
|
2909
|
+
if (state.tasks.size > 0) {
|
|
2910
|
+
if (state.queue.length >= 100) {
|
|
2911
|
+
state.socket?.close(4008, "Task queue limit reached");
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
state.queue.push(task);
|
|
2915
|
+
return;
|
|
2916
|
+
}
|
|
2917
|
+
execute(configuration, state, task);
|
|
2918
|
+
};
|
|
2919
|
+
var cancel = (state, taskId) => {
|
|
2920
|
+
const running = state.tasks.get(taskId);
|
|
2921
|
+
if (running !== undefined) {
|
|
2922
|
+
running.abort();
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
const index = state.queue.findIndex((task) => task.id === taskId);
|
|
2926
|
+
if (index !== -1) {
|
|
2927
|
+
state.queue.splice(index, 1);
|
|
2928
|
+
}
|
|
2929
|
+
remember(state, taskId);
|
|
2930
|
+
sendTerminal(state, taskId, SocketEvent.make({ _tag: "TaskCancelled", taskId }));
|
|
2931
|
+
};
|
|
2932
|
+
var connect = (configuration, state) => Effect6.tryPromise({
|
|
2933
|
+
try: (signal) => new Promise((complete) => {
|
|
2934
|
+
const socket = new WebSocket(socketUrl(configuration));
|
|
2935
|
+
const heartbeat = setInterval(() => {
|
|
2936
|
+
send(state, SocketEvent.make({
|
|
2937
|
+
_tag: "DeviceHeartbeat",
|
|
2938
|
+
deviceId: configuration.id,
|
|
2939
|
+
status: state.tasks.size > 0 ? "busy" : "online"
|
|
2940
|
+
}));
|
|
2941
|
+
}, 15000);
|
|
2942
|
+
let closed = false;
|
|
2943
|
+
const close = () => {
|
|
2944
|
+
if (closed) {
|
|
2945
|
+
return;
|
|
2946
|
+
}
|
|
2947
|
+
closed = true;
|
|
2948
|
+
clearInterval(heartbeat);
|
|
2949
|
+
if (state.socket === socket) {
|
|
2950
|
+
state.socket = undefined;
|
|
2951
|
+
}
|
|
2952
|
+
complete();
|
|
2953
|
+
};
|
|
2954
|
+
signal.addEventListener("abort", () => {
|
|
2955
|
+
socket.close();
|
|
2956
|
+
close();
|
|
2957
|
+
}, { once: true });
|
|
2958
|
+
socket.once("open", () => {
|
|
2959
|
+
socket.send(JSON.stringify(SocketEvent.make({ _tag: "Authenticate", token: configuration.token })));
|
|
2960
|
+
});
|
|
2961
|
+
socket.on("message", (message) => {
|
|
2962
|
+
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");
|
|
2963
|
+
state.processing = state.processing.then(async () => {
|
|
2964
|
+
const event = await Effect6.runPromise(Effect6.try({
|
|
2965
|
+
try: () => JSON.parse(text2),
|
|
2966
|
+
catch: () => new DeviceConnectionError({ message: "Relay sent invalid JSON" })
|
|
2967
|
+
}).pipe(Effect6.flatMap(decodeSocketEvent)));
|
|
2968
|
+
if (event._tag === "Connected") {
|
|
2969
|
+
state.socket = socket;
|
|
2970
|
+
socket.send(JSON.stringify(SocketEvent.make({
|
|
2971
|
+
_tag: "DeviceHello",
|
|
2972
|
+
device: describeDevice(configuration, state.tasks.size > 0 ? "busy" : "online")
|
|
2973
|
+
})));
|
|
2974
|
+
for (const payload of state.terminal.values()) {
|
|
2975
|
+
socket.send(payload);
|
|
2976
|
+
}
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2979
|
+
if (event._tag === "TaskAssigned") {
|
|
2980
|
+
schedule(configuration, state, event.task);
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2983
|
+
if (event._tag === "CancelTask") {
|
|
2984
|
+
cancel(state, event.taskId);
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
2987
|
+
if (event._tag === "TaskSettled") {
|
|
2988
|
+
state.terminal.delete(event.taskId);
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
if (event._tag === "Error") {
|
|
2992
|
+
console.error(`Relay error: ${event.message}`);
|
|
2993
|
+
}
|
|
2994
|
+
}).catch((cause) => {
|
|
2995
|
+
console.error(`Invalid relay event: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
2996
|
+
});
|
|
2997
|
+
});
|
|
2998
|
+
socket.once("close", close);
|
|
2999
|
+
socket.once("error", () => socket.close());
|
|
3000
|
+
}),
|
|
3001
|
+
catch: (cause) => new DeviceConnectionError({
|
|
3002
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
3003
|
+
})
|
|
3004
|
+
});
|
|
3005
|
+
var runDaemon = (configuration) => {
|
|
3006
|
+
const state = {
|
|
3007
|
+
socket: undefined,
|
|
3008
|
+
processing: Promise.resolve(),
|
|
3009
|
+
terminal: new Map,
|
|
3010
|
+
queue: [],
|
|
3011
|
+
sessions: new Map,
|
|
3012
|
+
tasks: new Map,
|
|
3013
|
+
completed: new Set
|
|
3014
|
+
};
|
|
3015
|
+
return connect(configuration, state).pipe(Effect6.repeat({ schedule: Schedule.spaced("2 seconds") }), Effect6.ensuring(Effect6.sync(() => {
|
|
3016
|
+
state.socket?.close();
|
|
3017
|
+
for (const controller of state.tasks.values()) {
|
|
3018
|
+
controller.abort();
|
|
3019
|
+
}
|
|
3020
|
+
})));
|
|
3021
|
+
};
|
|
3022
|
+
|
|
3023
|
+
// apps/device/src/mcp.ts
|
|
3024
|
+
init_src();
|
|
3025
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3026
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3027
|
+
import { Effect as Effect7 } from "effect";
|
|
3028
|
+
import * as z from "zod/v4";
|
|
3029
|
+
var output = (value) => ({
|
|
3030
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
|
|
3031
|
+
});
|
|
3032
|
+
var runMcp = async (configuration) => {
|
|
3033
|
+
const client2 = exports_src.make({
|
|
3034
|
+
baseUrl: configuration.relayUrl,
|
|
3035
|
+
token: configuration.token
|
|
3036
|
+
});
|
|
3037
|
+
const server = new McpServer({ name: "cohall", version });
|
|
3038
|
+
server.registerTool("list_devices", {
|
|
3039
|
+
title: "List Cohall devices",
|
|
3040
|
+
description: "List devices, their availability, local providers, capabilities, and allowed workspaces.",
|
|
3041
|
+
inputSchema: {}
|
|
3042
|
+
}, async () => output(await Effect7.runPromise(client2.devices())));
|
|
3043
|
+
server.registerTool("delegate", {
|
|
3044
|
+
title: "Delegate work to another device",
|
|
3045
|
+
description: "Send focused work to another Cohall device. Include only relevant context; Cohall cannot read the host transcript. Reuse thread_id for related follow-ups.",
|
|
3046
|
+
inputSchema: {
|
|
3047
|
+
prompt: z.string().min(1).max(131072),
|
|
3048
|
+
target: z.string().optional().describe("Device name, @name, hostname, or ID"),
|
|
3049
|
+
provider: z.enum(["codex", "claude-code", "opencode"]).default("codex"),
|
|
3050
|
+
context: z.string().max(131072).optional(),
|
|
3051
|
+
thread_id: z.string().uuid().optional(),
|
|
3052
|
+
workspace: z.string().max(4096).optional(),
|
|
3053
|
+
wait: z.boolean().default(true),
|
|
3054
|
+
timeout_seconds: z.number().int().min(5).max(86400).default(900)
|
|
3055
|
+
}
|
|
3056
|
+
}, async ({ prompt, target, provider, context, thread_id, workspace, wait, timeout_seconds }) => {
|
|
3057
|
+
const task = await Effect7.runPromise(createDelegation(client2, configuration, {
|
|
3058
|
+
prompt,
|
|
3059
|
+
provider,
|
|
3060
|
+
...target === undefined ? {} : { target },
|
|
3061
|
+
...context === undefined ? {} : { context },
|
|
3062
|
+
...thread_id === undefined ? {} : { threadId: ThreadId.make(thread_id) },
|
|
3063
|
+
...workspace === undefined ? {} : { workspace }
|
|
3064
|
+
}));
|
|
3065
|
+
return output(taskResult(wait ? await Effect7.runPromise(waitForTask(client2, task, timeout_seconds)) : task));
|
|
3066
|
+
});
|
|
3067
|
+
server.registerTool("task_status", {
|
|
3068
|
+
title: "Get delegated task status",
|
|
3069
|
+
description: "Read the current state and result of a Cohall task.",
|
|
3070
|
+
inputSchema: { task_id: z.string().uuid() }
|
|
3071
|
+
}, async ({ task_id }) => output(taskResult(await Effect7.runPromise(client2.getTask(TaskId.make(task_id))))));
|
|
3072
|
+
server.registerTool("wait_task", {
|
|
3073
|
+
title: "Wait for delegated work",
|
|
3074
|
+
description: "Wait for a Cohall task to finish and return its final result.",
|
|
3075
|
+
inputSchema: {
|
|
3076
|
+
task_id: z.string().uuid(),
|
|
3077
|
+
timeout_seconds: z.number().int().min(5).max(86400).default(900)
|
|
3078
|
+
}
|
|
3079
|
+
}, async ({ task_id, timeout_seconds }) => {
|
|
3080
|
+
const task = await Effect7.runPromise(client2.getTask(TaskId.make(task_id)));
|
|
3081
|
+
return output(taskResult(await Effect7.runPromise(waitForTask(client2, task, timeout_seconds))));
|
|
3082
|
+
});
|
|
3083
|
+
server.registerTool("cancel_task", {
|
|
3084
|
+
title: "Cancel delegated work",
|
|
3085
|
+
description: "Request cancellation and return its acknowledged or pending state.",
|
|
3086
|
+
inputSchema: { task_id: z.string().uuid() }
|
|
3087
|
+
}, async ({ task_id }) => output(taskResult(await Effect7.runPromise(client2.cancelTask(TaskId.make(task_id))))));
|
|
3088
|
+
server.registerTool("thread_context", {
|
|
3089
|
+
title: "Read a Cohall thread",
|
|
3090
|
+
description: "Read shared task prompts and final results for a cross-device thread.",
|
|
3091
|
+
inputSchema: { thread_id: z.string().uuid() }
|
|
3092
|
+
}, async ({ thread_id }) => output(await Effect7.runPromise(threadContext(client2, ThreadId.make(thread_id)))));
|
|
3093
|
+
await server.connect(new StdioServerTransport);
|
|
3094
|
+
};
|
|
3095
|
+
|
|
3096
|
+
// apps/device/src/main.ts
|
|
3097
|
+
var main = async () => {
|
|
3098
|
+
const command2 = process.argv[2];
|
|
3099
|
+
if (command2 === undefined || command2 === "help" || command2 === "--help" || command2 === "-h") {
|
|
3100
|
+
printHelp();
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
if (command2 === "--version" || command2 === "version") {
|
|
3104
|
+
console.log(version);
|
|
3105
|
+
return;
|
|
3106
|
+
}
|
|
3107
|
+
if (command2 === "relay") {
|
|
3108
|
+
const { runRelay: runRelay2 } = await init_main().then(() => exports_main);
|
|
3109
|
+
await runRelay2();
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
if (command2 === "device") {
|
|
3113
|
+
const configuration = await Effect11.runPromise(loadDeviceConfiguration);
|
|
3114
|
+
await Effect11.runPromise(runDaemon(configuration));
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
if (command2 === "mcp") {
|
|
3118
|
+
const configuration = await Effect11.runPromise(loadClientConfiguration);
|
|
3119
|
+
await runMcp(configuration);
|
|
3120
|
+
return;
|
|
3121
|
+
}
|
|
3122
|
+
await runCli(command2, process.argv.slice(3));
|
|
3123
|
+
};
|
|
3124
|
+
await main().catch((cause) => {
|
|
3125
|
+
console.error(JSON.stringify({ error: cause instanceof Error ? cause.message : String(cause) }, null, 2));
|
|
3126
|
+
process.exitCode = 1;
|
|
3127
|
+
});
|
|
3128
|
+
|
|
3129
|
+
//# debugId=7CC47D186DE52B1264756E2164756E21
|