@directed/cli 0.1.2
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/README.md +81 -0
- package/dist/cli.js +1938 -0
- package/package.json +29 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1938 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { realpathSync } from "fs";
|
|
5
|
+
import { pathToFileURL } from "url";
|
|
6
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
7
|
+
import { execFileSync } from "child_process";
|
|
8
|
+
import { createRequire } from "module";
|
|
9
|
+
|
|
10
|
+
// src/config.ts
|
|
11
|
+
if (false) throw new Error("$HUB_URL is required, e.g. HUB_URL=https://hub.directed.ai");
|
|
12
|
+
var DEFAULTS = {
|
|
13
|
+
acceptMode: "send",
|
|
14
|
+
tmuxBin: "tmux",
|
|
15
|
+
hubUrl: "https://hub.directed.ai",
|
|
16
|
+
toChat: false,
|
|
17
|
+
chatTargetQuery: null,
|
|
18
|
+
openOnStart: true,
|
|
19
|
+
invitees: [],
|
|
20
|
+
inviteEmails: []
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// src/update-check.ts
|
|
24
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
25
|
+
import { homedir } from "os";
|
|
26
|
+
import { dirname, join } from "path";
|
|
27
|
+
var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
28
|
+
function statePath() {
|
|
29
|
+
const home = process.env.DIRECTED_HOME ?? homedir();
|
|
30
|
+
return join(home, ".directed", "state.json");
|
|
31
|
+
}
|
|
32
|
+
function stateLoad() {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(readFileSync(statePath(), "utf8"));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function stateSave(state) {
|
|
40
|
+
try {
|
|
41
|
+
const path = statePath();
|
|
42
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
43
|
+
writeFileSync(path, JSON.stringify(state));
|
|
44
|
+
} catch {
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function versionIsNewer(latest, current) {
|
|
48
|
+
const a = latest.split(".").map(Number);
|
|
49
|
+
const b = current.split(".").map(Number);
|
|
50
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
51
|
+
const x = a[i] ?? 0;
|
|
52
|
+
const y = b[i] ?? 0;
|
|
53
|
+
if (x !== y) return x > y;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
async function updateCheckRun(currentVersion, hubUrl, fetchImpl = fetch) {
|
|
58
|
+
const cached = stateLoad();
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
let latest = cached?.latestKnownVersion;
|
|
61
|
+
if (!cached || now - cached.lastCheckedAt > CHECK_INTERVAL_MS) {
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetchImpl(`${hubUrl}/cli/version`, { signal: AbortSignal.timeout(1500) });
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
latest = (await res.json()).version;
|
|
66
|
+
stateSave({ lastCheckedAt: now, latestKnownVersion: latest });
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (latest && versionIsNewer(latest, currentVersion)) {
|
|
72
|
+
process.stderr.write(`[directed] a new version is available (${currentVersion} -> ${latest}). Run 'directed upgrade' to update.
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/upgrade.ts
|
|
78
|
+
import { spawnSync } from "child_process";
|
|
79
|
+
import { mkdtempSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
80
|
+
import { tmpdir } from "os";
|
|
81
|
+
import { join as join2 } from "path";
|
|
82
|
+
async function upgradeRun(hubUrl, fetchImpl = fetch) {
|
|
83
|
+
const scriptUrl = `${hubUrl}/cli/install.sh`;
|
|
84
|
+
process.stdout.write(`[directed] fetching installer from ${scriptUrl}
|
|
85
|
+
`);
|
|
86
|
+
const res = await fetchImpl(scriptUrl);
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
console.error(`[directed] could not fetch installer: ${res.status} ${res.statusText}`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
const script = await res.text();
|
|
92
|
+
const dir = mkdtempSync(join2(tmpdir(), "directed-upgrade-"));
|
|
93
|
+
try {
|
|
94
|
+
const scriptPath = join2(dir, "install.sh");
|
|
95
|
+
writeFileSync2(scriptPath, script, { mode: 493 });
|
|
96
|
+
const result = spawnSync("bash", [scriptPath], {
|
|
97
|
+
stdio: "inherit",
|
|
98
|
+
env: { ...process.env, DIRECTED_CLI_BASE_URL: hubUrl }
|
|
99
|
+
});
|
|
100
|
+
return result.status ?? 1;
|
|
101
|
+
} finally {
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/transcript/to-transcript.ts
|
|
107
|
+
function turnToTranscript(turn) {
|
|
108
|
+
const text = turn.parts.filter((p) => p.kind === "text").map((p) => p.text).join("");
|
|
109
|
+
return { role: turn.role, text, clientTurnId: turn.id };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/transport.ts
|
|
113
|
+
var HubTransport = class {
|
|
114
|
+
constructor(hub, tokenRefresher, source, conversation, title = "", echoGuard = null, externalRef = null, destinationChatPublicId = null, memberIdentifiers = [], onUserTurn = null) {
|
|
115
|
+
this.hub = hub;
|
|
116
|
+
this.tokenRefresher = tokenRefresher;
|
|
117
|
+
this.source = source;
|
|
118
|
+
this.conversation = conversation;
|
|
119
|
+
this.title = title;
|
|
120
|
+
this.echoGuard = echoGuard;
|
|
121
|
+
this.externalRef = externalRef;
|
|
122
|
+
this.destinationChatPublicId = destinationChatPublicId;
|
|
123
|
+
this.memberIdentifiers = memberIdentifiers;
|
|
124
|
+
this.onUserTurn = onUserTurn;
|
|
125
|
+
}
|
|
126
|
+
hub;
|
|
127
|
+
tokenRefresher;
|
|
128
|
+
source;
|
|
129
|
+
conversation;
|
|
130
|
+
title;
|
|
131
|
+
echoGuard;
|
|
132
|
+
externalRef;
|
|
133
|
+
destinationChatPublicId;
|
|
134
|
+
memberIdentifiers;
|
|
135
|
+
onUserTurn;
|
|
136
|
+
off = null;
|
|
137
|
+
sid = null;
|
|
138
|
+
chatPublicId = null;
|
|
139
|
+
agentActorPublicId = null;
|
|
140
|
+
wasReattached = false;
|
|
141
|
+
skippedInviteeList = [];
|
|
142
|
+
// On reattach, the id of the newest turn the hub already has. The resumed
|
|
143
|
+
// transcript replays from the top, and turns at or before this one must not
|
|
144
|
+
// post: some were never posted at all (mention echoes the original run's
|
|
145
|
+
// echo guard suppressed), so no server-side dedup row exists to catch them.
|
|
146
|
+
// Cleared once the replay passes it; null means no filtering.
|
|
147
|
+
replayCutoffId = null;
|
|
148
|
+
// Publishes chain through here so turnAppend for turn N resolves before N+1
|
|
149
|
+
// is sent -- message_seq is allocated in POST-arrival order, so concurrent
|
|
150
|
+
// posts (the snapshot backlog fires many at once) would scramble it.
|
|
151
|
+
chain = Promise.resolve();
|
|
152
|
+
get sessionId() {
|
|
153
|
+
return this.sid;
|
|
154
|
+
}
|
|
155
|
+
get chatId() {
|
|
156
|
+
return this.chatPublicId;
|
|
157
|
+
}
|
|
158
|
+
// The vendor agent's actor id, so a mention listener knows which member an
|
|
159
|
+
// @-mention must target to reach this CLI.
|
|
160
|
+
get agentActorId() {
|
|
161
|
+
return this.agentActorPublicId;
|
|
162
|
+
}
|
|
163
|
+
// Whether the hub matched externalRef to an earlier session (a resumed CLI
|
|
164
|
+
// run) and re-attached this one to that session's existing chat.
|
|
165
|
+
get reattached() {
|
|
166
|
+
return this.wasReattached;
|
|
167
|
+
}
|
|
168
|
+
// Invitees the hub could not match to an active connection and did not seat.
|
|
169
|
+
get skippedInvitees() {
|
|
170
|
+
return this.skippedInviteeList;
|
|
171
|
+
}
|
|
172
|
+
async start() {
|
|
173
|
+
const token = await this.tokenRefresher.current();
|
|
174
|
+
const session = await this.hub.sessionStart(token, {
|
|
175
|
+
source: this.source,
|
|
176
|
+
title: this.title,
|
|
177
|
+
externalRef: this.externalRef ?? void 0,
|
|
178
|
+
destinationChatPublicId: this.destinationChatPublicId ?? void 0,
|
|
179
|
+
memberIdentifiers: this.memberIdentifiers
|
|
180
|
+
});
|
|
181
|
+
this.sid = session.terminalSessionPublicId;
|
|
182
|
+
this.chatPublicId = session.chatPublicId;
|
|
183
|
+
this.agentActorPublicId = session.agentActorPublicId;
|
|
184
|
+
this.wasReattached = session.reattached === true;
|
|
185
|
+
this.skippedInviteeList = session.skippedMemberIdentifiers ?? [];
|
|
186
|
+
this.replayCutoffId = this.wasReattached ? session.lastClientTurnId || null : null;
|
|
187
|
+
const enqueue = (turn) => this.enqueue(turn);
|
|
188
|
+
for (const turn of this.conversation.snapshot()) enqueue(turn);
|
|
189
|
+
this.off = this.conversation.subscribe(enqueue);
|
|
190
|
+
return session.chatUrl;
|
|
191
|
+
}
|
|
192
|
+
async stop() {
|
|
193
|
+
this.off?.();
|
|
194
|
+
this.off = null;
|
|
195
|
+
await this.chain;
|
|
196
|
+
if (this.sid === null) return;
|
|
197
|
+
try {
|
|
198
|
+
const token = await this.tokenRefresher.current();
|
|
199
|
+
await this.hub.sessionEnd(token, this.sid);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
process.stderr.write(`terminal session end post failed: ${String(e)}
|
|
202
|
+
`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
enqueue(turn) {
|
|
206
|
+
if (this.replayCutoffId !== null) {
|
|
207
|
+
if (turn.id === this.replayCutoffId) this.replayCutoffId = null;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const t = turnToTranscript(turn);
|
|
211
|
+
if (!t.text.trim()) return;
|
|
212
|
+
if (t.role === "user" && this.echoGuard?.consume(t.text)) return;
|
|
213
|
+
const sessionId = this.sid;
|
|
214
|
+
if (sessionId === null) return;
|
|
215
|
+
if (t.role === "user") this.onUserTurn?.(t.text);
|
|
216
|
+
this.chain = this.chain.then(async () => {
|
|
217
|
+
let token;
|
|
218
|
+
try {
|
|
219
|
+
token = await this.tokenRefresher.current();
|
|
220
|
+
} catch (e) {
|
|
221
|
+
process.stderr.write(`terminal session token refresh failed: ${String(e)}
|
|
222
|
+
`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
return this.hub.turnAppend(token, sessionId, t).then(() => {
|
|
226
|
+
}).catch((e) => {
|
|
227
|
+
process.stderr.write(`terminal session turn post failed: ${String(e)}
|
|
228
|
+
`);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// src/session.ts
|
|
235
|
+
import { spawn } from "child_process";
|
|
236
|
+
import { rmSync as rmSync2 } from "fs";
|
|
237
|
+
import { join as join3 } from "path";
|
|
238
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
239
|
+
import { StringDecoder } from "string_decoder";
|
|
240
|
+
|
|
241
|
+
// src/transcript/tailer.ts
|
|
242
|
+
import { open, readFile, stat } from "fs/promises";
|
|
243
|
+
var POLL_MS = 400;
|
|
244
|
+
var TranscriptTailer = class {
|
|
245
|
+
constructor(adapter, cwd, sinceMs, processId, onTurn, onLocate = null) {
|
|
246
|
+
this.adapter = adapter;
|
|
247
|
+
this.cwd = cwd;
|
|
248
|
+
this.sinceMs = sinceMs;
|
|
249
|
+
this.processId = processId;
|
|
250
|
+
this.onTurn = onTurn;
|
|
251
|
+
this.onLocate = onLocate;
|
|
252
|
+
}
|
|
253
|
+
adapter;
|
|
254
|
+
cwd;
|
|
255
|
+
sinceMs;
|
|
256
|
+
processId;
|
|
257
|
+
onTurn;
|
|
258
|
+
onLocate;
|
|
259
|
+
timer = null;
|
|
260
|
+
file = null;
|
|
261
|
+
offset = 0;
|
|
262
|
+
partial = "";
|
|
263
|
+
ticking = false;
|
|
264
|
+
start() {
|
|
265
|
+
if (this.timer) return;
|
|
266
|
+
void this.tick();
|
|
267
|
+
this.timer = setInterval(() => void this.tick(), POLL_MS);
|
|
268
|
+
}
|
|
269
|
+
stop() {
|
|
270
|
+
if (this.timer) {
|
|
271
|
+
clearInterval(this.timer);
|
|
272
|
+
this.timer = null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async tick() {
|
|
276
|
+
if (this.ticking) return;
|
|
277
|
+
this.ticking = true;
|
|
278
|
+
try {
|
|
279
|
+
if (this.file === null) {
|
|
280
|
+
await this.locate();
|
|
281
|
+
} else {
|
|
282
|
+
await this.tail();
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
this.ticking = false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async locate() {
|
|
289
|
+
const found = await this.adapter.locate(this.cwd, this.sinceMs, this.processId);
|
|
290
|
+
if (!found) return;
|
|
291
|
+
this.file = found;
|
|
292
|
+
this.onLocate?.(found);
|
|
293
|
+
await this.readAll(found);
|
|
294
|
+
}
|
|
295
|
+
async readAll(file) {
|
|
296
|
+
const buf = await readFile(file);
|
|
297
|
+
this.offset = buf.length;
|
|
298
|
+
this.consume(buf.toString("utf8"));
|
|
299
|
+
}
|
|
300
|
+
async tail() {
|
|
301
|
+
const file = this.file;
|
|
302
|
+
if (!file) return;
|
|
303
|
+
const { size } = await stat(file);
|
|
304
|
+
if (size <= this.offset) return;
|
|
305
|
+
const length = size - this.offset;
|
|
306
|
+
const handle = await open(file, "r");
|
|
307
|
+
try {
|
|
308
|
+
const buf = Buffer.alloc(length);
|
|
309
|
+
await handle.read(buf, 0, length, this.offset);
|
|
310
|
+
this.offset = size;
|
|
311
|
+
this.consume(buf.toString("utf8"));
|
|
312
|
+
} finally {
|
|
313
|
+
await handle.close();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
consume(text) {
|
|
317
|
+
const combined = this.partial + text;
|
|
318
|
+
const lines = combined.split("\n");
|
|
319
|
+
this.partial = lines.pop() ?? "";
|
|
320
|
+
for (const line of lines) {
|
|
321
|
+
const trimmed = line.trim();
|
|
322
|
+
if (!trimmed) continue;
|
|
323
|
+
let record;
|
|
324
|
+
try {
|
|
325
|
+
record = JSON.parse(trimmed);
|
|
326
|
+
} catch {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const turn = this.adapter.parse(record);
|
|
330
|
+
if (turn) this.onTurn(turn);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// src/session.ts
|
|
336
|
+
var BANNER = `
|
|
337
|
+
\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591
|
|
338
|
+
\u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591
|
|
339
|
+
\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591
|
|
340
|
+
\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
|
|
341
|
+
\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
|
|
342
|
+
\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
|
|
343
|
+
\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
|
|
344
|
+
\u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591
|
|
345
|
+
\u2591\u2591 \u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
|
|
346
|
+
\u2591\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591
|
|
347
|
+
\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591
|
|
348
|
+
\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
|
|
349
|
+
\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2588\u2588\u2591
|
|
350
|
+
\u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2588\u2588\u2588\u2591
|
|
351
|
+
\u2591\u2591\u2591\u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591
|
|
352
|
+
\u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2588\u2588\u2588\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
|
|
353
|
+
\u2591\u2588\u2588\u2588\u2591\u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2591\u2588\u2588\u2588\u2591\u2588\u2588\u2588\u2591\u2588\u2588\u2591\u2591\u2591\u2591\u2588\u2588\u2591
|
|
354
|
+
\u2591\u2588\u2588\u2591\u2591 \u2591\u2588\u2588\u2588\u2591\u2591\u2588\u2588\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2588\u2588\u2588\u2591 \u2591\u2588\u2588\u2591 \u2591\u2588\u2588\u2591
|
|
355
|
+
\u2591\u2588\u2588\u2588\u2591 \u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591 \u2591\u2588\u2588\u2591 \u2591\u2588\u2588\u2591 \u2591\u2588\u2588\u2591
|
|
356
|
+
\u2591\u2588\u2588\u2591 \u2591\u2591\u2591\u2591\u2591\u2588\u2588\u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2591 \u2591\u2588\u2588\u2591\u2591 \u2591\u2591 \u2591\u2591\u2588\u2588\u2591
|
|
357
|
+
\u2591\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2588\u2588\u2588\u2591 \u2591\u2591\u2591\u2591\u2591 \u2591\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2588\u2588\u2588\u2591
|
|
358
|
+
\u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591 \u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591
|
|
359
|
+
\u2591\u2591\u2591\u2591\u2591\u2591\u2591 \u2591\u2591\u2591\u2591\u2591\u2591
|
|
360
|
+
|
|
361
|
+
Directed helps you collaborate with AI.
|
|
362
|
+
`;
|
|
363
|
+
var SPLASH_MS = 3e3;
|
|
364
|
+
function bannerWrap(command, cols, rows) {
|
|
365
|
+
const lines = BANNER.replace(/^\n+|\n+$/g, "").split("\n");
|
|
366
|
+
const width = Math.max(...lines.map((l) => l.length));
|
|
367
|
+
const left = " ".repeat(Math.max(0, Math.floor((cols - width) / 2)));
|
|
368
|
+
const top = "\n".repeat(Math.max(0, Math.floor((rows - lines.length) / 2)));
|
|
369
|
+
const centered = top + lines.map((l) => l.length > 0 ? left + l : l).join("\n");
|
|
370
|
+
return ["sh", "-c", `clear; printf '%s
|
|
371
|
+
' '${centered}'; sleep ${SPLASH_MS / 1e3}; exec "$@"`, "sh", ...command];
|
|
372
|
+
}
|
|
373
|
+
var Session = class {
|
|
374
|
+
constructor(config, conversation, recorder, tmux, adapter, remote = null, echoGuard = null, onTranscriptLocate = null) {
|
|
375
|
+
this.config = config;
|
|
376
|
+
this.conversation = conversation;
|
|
377
|
+
this.recorder = recorder;
|
|
378
|
+
this.tmux = tmux;
|
|
379
|
+
this.adapter = adapter;
|
|
380
|
+
this.remote = remote;
|
|
381
|
+
this.echoGuard = echoGuard;
|
|
382
|
+
this.onTranscriptLocate = onTranscriptLocate;
|
|
383
|
+
}
|
|
384
|
+
config;
|
|
385
|
+
conversation;
|
|
386
|
+
recorder;
|
|
387
|
+
tmux;
|
|
388
|
+
adapter;
|
|
389
|
+
remote;
|
|
390
|
+
echoGuard;
|
|
391
|
+
onTranscriptLocate;
|
|
392
|
+
async run() {
|
|
393
|
+
const sinceMs = Date.now();
|
|
394
|
+
const cols = process.stdout.columns || 80;
|
|
395
|
+
const rows = process.stdout.rows || 24;
|
|
396
|
+
await this.tmux.newSession(bannerWrap(this.config.command, cols, rows), cols, rows);
|
|
397
|
+
const fifo = join3(tmpdir2(), `directed-${process.pid}.fifo`);
|
|
398
|
+
rmSync2(fifo, { force: true });
|
|
399
|
+
await exited(spawn("mkfifo", [fifo]));
|
|
400
|
+
const reader = spawn("cat", [fifo]);
|
|
401
|
+
const decoder = new StringDecoder("utf8");
|
|
402
|
+
reader.stdout.on("data", (d) => {
|
|
403
|
+
this.recorder.output(decoder.write(d));
|
|
404
|
+
});
|
|
405
|
+
await this.tmux.pipePane(fifo);
|
|
406
|
+
let tailer = null;
|
|
407
|
+
if (this.adapter) {
|
|
408
|
+
const processId = await this.tmux.panePid();
|
|
409
|
+
tailer = new TranscriptTailer(
|
|
410
|
+
this.adapter,
|
|
411
|
+
process.cwd(),
|
|
412
|
+
sinceMs,
|
|
413
|
+
processId,
|
|
414
|
+
(t) => this.conversation.add(t),
|
|
415
|
+
this.onTranscriptLocate
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
tailer?.start();
|
|
419
|
+
this.remote?.start((m) => {
|
|
420
|
+
this.echoGuard?.record(m.text);
|
|
421
|
+
void this.steer(m.text, m.author).catch(() => {
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
const sizeTimer = this.startSizePolling();
|
|
425
|
+
const code = await new Promise((resolve) => {
|
|
426
|
+
this.tmux.attach().on("exit", (c) => resolve(c ?? 0));
|
|
427
|
+
});
|
|
428
|
+
await this.remote?.stop();
|
|
429
|
+
if (sizeTimer) clearInterval(sizeTimer);
|
|
430
|
+
tailer?.stop();
|
|
431
|
+
reader.kill();
|
|
432
|
+
await this.tmux.kill();
|
|
433
|
+
rmSync2(fifo, { force: true });
|
|
434
|
+
return code;
|
|
435
|
+
}
|
|
436
|
+
startSizePolling() {
|
|
437
|
+
let last = "";
|
|
438
|
+
return setInterval(async () => {
|
|
439
|
+
const { cols, rows } = await this.tmux.paneSize().catch(() => ({ cols: 0, rows: 0 }));
|
|
440
|
+
if (!cols) return;
|
|
441
|
+
const key = `${cols}x${rows}`;
|
|
442
|
+
if (key !== last) {
|
|
443
|
+
last = key;
|
|
444
|
+
this.recorder.resize(cols, rows);
|
|
445
|
+
}
|
|
446
|
+
}, 1e3);
|
|
447
|
+
}
|
|
448
|
+
async steer(text, who) {
|
|
449
|
+
await this.tmux.sendText(text, this.config.acceptMode === "send");
|
|
450
|
+
this.recorder.marker(`${who}: ${text}`);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
function exited(child) {
|
|
454
|
+
return new Promise((resolve, reject) => {
|
|
455
|
+
child.on("error", reject);
|
|
456
|
+
child.on("exit", (c) => c === 0 ? resolve() : reject(new Error(`exit ${c}`)));
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// src/tmux.ts
|
|
461
|
+
import { spawn as spawn2 } from "child_process";
|
|
462
|
+
function defaultRunner(bin) {
|
|
463
|
+
return (args) => new Promise((resolve, reject) => {
|
|
464
|
+
const p = spawn2(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
465
|
+
let out = "", err = "";
|
|
466
|
+
p.stdout.on("data", (d) => out += d);
|
|
467
|
+
p.stderr.on("data", (d) => err += d);
|
|
468
|
+
p.on("error", reject);
|
|
469
|
+
p.on("exit", (code) => code === 0 ? resolve(out) : reject(new Error(`tmux ${args.join(" ")} failed: ${err.trim()}`)));
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
var Tmux = class {
|
|
473
|
+
constructor(bin, session, runner) {
|
|
474
|
+
this.bin = bin;
|
|
475
|
+
this.session = session;
|
|
476
|
+
this.runner = runner ?? defaultRunner(bin);
|
|
477
|
+
}
|
|
478
|
+
bin;
|
|
479
|
+
session;
|
|
480
|
+
runner;
|
|
481
|
+
inputBufferNumber = 0;
|
|
482
|
+
// Web and hub steers arrive independently; keep each paste and its submit
|
|
483
|
+
// key together so simultaneous messages cannot interleave in the pane.
|
|
484
|
+
inputWrite = Promise.resolve();
|
|
485
|
+
// Detached sessions default to 80x24 until a client attaches; sizing the
|
|
486
|
+
// pane up front means everything the child draws before attach (a resumed
|
|
487
|
+
// agent repaints its whole conversation immediately) wraps at the real
|
|
488
|
+
// terminal width, matching the recording header.
|
|
489
|
+
async newSession(command, cols, rows) {
|
|
490
|
+
await this.runner([
|
|
491
|
+
"new-session",
|
|
492
|
+
"-d",
|
|
493
|
+
"-s",
|
|
494
|
+
this.session,
|
|
495
|
+
"-x",
|
|
496
|
+
String(cols),
|
|
497
|
+
"-y",
|
|
498
|
+
String(rows),
|
|
499
|
+
"--",
|
|
500
|
+
...command
|
|
501
|
+
]);
|
|
502
|
+
await this.runner(["set-option", "-t", this.session, "status", "off"]);
|
|
503
|
+
}
|
|
504
|
+
async paneSize() {
|
|
505
|
+
const out = (await this.runner(["display-message", "-p", "-t", this.session, "#{pane_width}x#{pane_height}"])).trim();
|
|
506
|
+
const [cols, rows] = out.split("x").map(Number);
|
|
507
|
+
return { cols, rows };
|
|
508
|
+
}
|
|
509
|
+
async panePid() {
|
|
510
|
+
const out = await this.runner(["display-message", "-p", "-t", this.session, "#{pane_pid}"]);
|
|
511
|
+
const processId = Number(out.trim());
|
|
512
|
+
if (!Number.isSafeInteger(processId) || processId <= 0) {
|
|
513
|
+
throw new Error(`tmux returned an invalid pane PID: ${out.trim()}`);
|
|
514
|
+
}
|
|
515
|
+
return processId;
|
|
516
|
+
}
|
|
517
|
+
pipePane(fifo) {
|
|
518
|
+
return this.runner(["pipe-pane", "-o", "-t", this.session, `cat >> ${fifo}`]).then(() => {
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
async capturePane() {
|
|
522
|
+
return Buffer.from(await this.runner(["capture-pane", "-p", "-e", "-t", this.session]), "utf8");
|
|
523
|
+
}
|
|
524
|
+
sendText(text, submit) {
|
|
525
|
+
const write = this.inputWrite.then(() => this.textWrite(text, submit));
|
|
526
|
+
this.inputWrite = write.catch(() => {
|
|
527
|
+
});
|
|
528
|
+
return write;
|
|
529
|
+
}
|
|
530
|
+
async textWrite(text, submit) {
|
|
531
|
+
const buffer = `${this.session}-input-${this.inputBufferNumber}`;
|
|
532
|
+
this.inputBufferNumber += 1;
|
|
533
|
+
await this.runner(["set-buffer", "-b", buffer, "--", text]);
|
|
534
|
+
await this.runner(["paste-buffer", "-dpr", "-b", buffer, "-t", this.session]);
|
|
535
|
+
if (submit) await this.runner(["send-keys", "-t", this.session, "Enter"]);
|
|
536
|
+
}
|
|
537
|
+
async hasSession() {
|
|
538
|
+
return this.runner(["has-session", "-t", this.session]).then(() => true).catch(() => false);
|
|
539
|
+
}
|
|
540
|
+
attach() {
|
|
541
|
+
return spawn2(this.bin, ["attach", "-t", this.session], { stdio: "inherit" });
|
|
542
|
+
}
|
|
543
|
+
kill() {
|
|
544
|
+
return this.runner(["kill-session", "-t", this.session]).then(() => {
|
|
545
|
+
}).catch(() => {
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// src/conversation-log.ts
|
|
551
|
+
var ConversationLog = class {
|
|
552
|
+
turns = [];
|
|
553
|
+
subs = /* @__PURE__ */ new Set();
|
|
554
|
+
add(turn) {
|
|
555
|
+
this.turns.push(turn);
|
|
556
|
+
for (const s of this.subs) {
|
|
557
|
+
try {
|
|
558
|
+
s(turn);
|
|
559
|
+
} catch {
|
|
560
|
+
this.subs.delete(s);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
snapshot() {
|
|
565
|
+
return [...this.turns];
|
|
566
|
+
}
|
|
567
|
+
subscribe(cb) {
|
|
568
|
+
this.subs.add(cb);
|
|
569
|
+
return () => this.subs.delete(cb);
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
// src/recorder.ts
|
|
574
|
+
var Recorder = class {
|
|
575
|
+
startMs;
|
|
576
|
+
width;
|
|
577
|
+
height;
|
|
578
|
+
subs = /* @__PURE__ */ new Set();
|
|
579
|
+
constructor(cols, rows) {
|
|
580
|
+
this.startMs = Date.now();
|
|
581
|
+
this.width = cols;
|
|
582
|
+
this.height = rows;
|
|
583
|
+
}
|
|
584
|
+
output(data) {
|
|
585
|
+
this.append([this.elapsed(), "o", data]);
|
|
586
|
+
}
|
|
587
|
+
marker(label) {
|
|
588
|
+
this.append([this.elapsed(), "m", label]);
|
|
589
|
+
}
|
|
590
|
+
header() {
|
|
591
|
+
return {
|
|
592
|
+
version: 2,
|
|
593
|
+
width: this.width,
|
|
594
|
+
height: this.height,
|
|
595
|
+
timestamp: Math.floor(this.startMs / 1e3)
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
subscribe(cb) {
|
|
599
|
+
this.subs.add(cb);
|
|
600
|
+
return () => this.subs.delete(cb);
|
|
601
|
+
}
|
|
602
|
+
// Recorded as an "r" event so playback re-creates the pane geometry at the
|
|
603
|
+
// right point in time -- a replay against stale dims mis-wraps every line
|
|
604
|
+
// the app draws after the resize.
|
|
605
|
+
resize(cols, rows) {
|
|
606
|
+
if (cols === this.width && rows === this.height) return;
|
|
607
|
+
this.width = cols;
|
|
608
|
+
this.height = rows;
|
|
609
|
+
this.append([this.elapsed(), "r", `${cols}x${rows}`]);
|
|
610
|
+
}
|
|
611
|
+
elapsed() {
|
|
612
|
+
return (Date.now() - this.startMs) / 1e3;
|
|
613
|
+
}
|
|
614
|
+
append(ev) {
|
|
615
|
+
for (const sub of this.subs) {
|
|
616
|
+
try {
|
|
617
|
+
sub(ev);
|
|
618
|
+
} catch {
|
|
619
|
+
this.subs.delete(sub);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
// src/hub-recording.ts
|
|
626
|
+
var FLUSH_INTERVAL_MS = 250;
|
|
627
|
+
var FLUSH_BYTES_MAX = 16 * 1024;
|
|
628
|
+
var HubRecordingPublisher = class {
|
|
629
|
+
constructor(hub, tokenRefresher, sessionId, recorder) {
|
|
630
|
+
this.hub = hub;
|
|
631
|
+
this.tokenRefresher = tokenRefresher;
|
|
632
|
+
this.sessionId = sessionId;
|
|
633
|
+
this.recorder = recorder;
|
|
634
|
+
}
|
|
635
|
+
hub;
|
|
636
|
+
tokenRefresher;
|
|
637
|
+
sessionId;
|
|
638
|
+
recorder;
|
|
639
|
+
off = null;
|
|
640
|
+
buffer = [];
|
|
641
|
+
bufferBytes = 0;
|
|
642
|
+
timer = null;
|
|
643
|
+
seq = 0;
|
|
644
|
+
width = 0;
|
|
645
|
+
height = 0;
|
|
646
|
+
startedEpoch = 0;
|
|
647
|
+
// Publishes chain through here so chunk N posts before N+1 -- flushes can
|
|
648
|
+
// otherwise race (a byte-triggered flush vs. a slow prior POST) and scramble seq order.
|
|
649
|
+
chain = Promise.resolve();
|
|
650
|
+
start() {
|
|
651
|
+
const header = this.recorder.header();
|
|
652
|
+
this.width = header.width;
|
|
653
|
+
this.height = header.height;
|
|
654
|
+
this.startedEpoch = header.timestamp;
|
|
655
|
+
this.off = this.recorder.subscribe((ev) => this.append(ev));
|
|
656
|
+
}
|
|
657
|
+
async stop() {
|
|
658
|
+
this.off?.();
|
|
659
|
+
this.off = null;
|
|
660
|
+
this.flush();
|
|
661
|
+
await this.chain;
|
|
662
|
+
}
|
|
663
|
+
append(ev) {
|
|
664
|
+
this.buffer.push(ev);
|
|
665
|
+
this.bufferBytes += ev[2].length;
|
|
666
|
+
if (this.bufferBytes >= FLUSH_BYTES_MAX) {
|
|
667
|
+
this.flush();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (!this.timer) {
|
|
671
|
+
this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
flush() {
|
|
675
|
+
if (this.timer) {
|
|
676
|
+
clearTimeout(this.timer);
|
|
677
|
+
this.timer = null;
|
|
678
|
+
}
|
|
679
|
+
if (this.buffer.length === 0) return;
|
|
680
|
+
const events = this.buffer;
|
|
681
|
+
const offset = events[0][0];
|
|
682
|
+
this.buffer = [];
|
|
683
|
+
this.bufferBytes = 0;
|
|
684
|
+
const seq = this.seq;
|
|
685
|
+
this.seq += 1;
|
|
686
|
+
this.chain = this.chain.then(async () => {
|
|
687
|
+
let token;
|
|
688
|
+
try {
|
|
689
|
+
token = await this.tokenRefresher.current();
|
|
690
|
+
} catch (e) {
|
|
691
|
+
process.stderr.write(`terminal session token refresh failed: ${String(e)}
|
|
692
|
+
`);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
return this.hub.chunkAppend(token, this.sessionId, {
|
|
696
|
+
seq,
|
|
697
|
+
offset,
|
|
698
|
+
events,
|
|
699
|
+
width: this.width,
|
|
700
|
+
height: this.height,
|
|
701
|
+
startedEpoch: this.startedEpoch
|
|
702
|
+
}).catch((e) => {
|
|
703
|
+
process.stderr.write(`terminal session chunk post failed: ${String(e)}
|
|
704
|
+
`);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// src/hub-mentions.ts
|
|
711
|
+
function wsCtor() {
|
|
712
|
+
const g = globalThis;
|
|
713
|
+
return typeof g.WebSocket === "function" ? g.WebSocket : null;
|
|
714
|
+
}
|
|
715
|
+
var NON_WORD_RUN_RE = /^[^\w]*/;
|
|
716
|
+
function agentMentionStrip(text, hits) {
|
|
717
|
+
let out = text;
|
|
718
|
+
for (const r of [...hits].sort((a, b) => b.start - a.start)) {
|
|
719
|
+
if (r.start >= 0 && r.start <= r.end && r.end <= out.length) {
|
|
720
|
+
const tail = NON_WORD_RUN_RE.exec(out.slice(r.end));
|
|
721
|
+
const tailEnd = r.end + (tail ? tail[0].length : 0);
|
|
722
|
+
out = out.slice(0, r.start) + out.slice(tailEnd);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
function mentionPrompt(blocks, agentActorId) {
|
|
728
|
+
let mentioned = false;
|
|
729
|
+
const parts = [];
|
|
730
|
+
for (const b of blocks) {
|
|
731
|
+
if (b.kind !== "text" || typeof b.text !== "string") continue;
|
|
732
|
+
const hits = (b.mentions ?? []).filter((m) => m.actor_public_id === agentActorId);
|
|
733
|
+
if (hits.length > 0) mentioned = true;
|
|
734
|
+
const stripped = agentMentionStrip(b.text, hits).trim();
|
|
735
|
+
if (stripped) parts.push(stripped);
|
|
736
|
+
}
|
|
737
|
+
if (!mentioned) return null;
|
|
738
|
+
const text = parts.join("\n").trim();
|
|
739
|
+
return text.length > 0 ? text : null;
|
|
740
|
+
}
|
|
741
|
+
var RECONNECT_MAX_MS = 8e3;
|
|
742
|
+
var HubMentions = class {
|
|
743
|
+
constructor(hubUrl, tokenRefresher, chatId, agentActorId, sinceMs) {
|
|
744
|
+
this.hubUrl = hubUrl;
|
|
745
|
+
this.tokenRefresher = tokenRefresher;
|
|
746
|
+
this.chatId = chatId;
|
|
747
|
+
this.agentActorId = agentActorId;
|
|
748
|
+
this.sinceMs = sinceMs;
|
|
749
|
+
}
|
|
750
|
+
hubUrl;
|
|
751
|
+
tokenRefresher;
|
|
752
|
+
chatId;
|
|
753
|
+
agentActorId;
|
|
754
|
+
sinceMs;
|
|
755
|
+
ws = null;
|
|
756
|
+
stopped = false;
|
|
757
|
+
handler = null;
|
|
758
|
+
reconnectTimer = null;
|
|
759
|
+
attempts = 0;
|
|
760
|
+
// Upsert protocol: the same message re-broadcasts as it streams or gets a
|
|
761
|
+
// reaction. Fire an @-mention once by remembering the ids we've injected.
|
|
762
|
+
seen = /* @__PURE__ */ new Set();
|
|
763
|
+
start(inject) {
|
|
764
|
+
this.handler = inject;
|
|
765
|
+
this.connect();
|
|
766
|
+
}
|
|
767
|
+
async stop() {
|
|
768
|
+
this.stopped = true;
|
|
769
|
+
if (this.reconnectTimer) {
|
|
770
|
+
clearTimeout(this.reconnectTimer);
|
|
771
|
+
this.reconnectTimer = null;
|
|
772
|
+
}
|
|
773
|
+
this.ws?.close(1e3);
|
|
774
|
+
this.ws = null;
|
|
775
|
+
}
|
|
776
|
+
connect() {
|
|
777
|
+
if (this.stopped) return;
|
|
778
|
+
const Ctor = wsCtor();
|
|
779
|
+
if (!Ctor) {
|
|
780
|
+
process.stderr.write(
|
|
781
|
+
"[directed] this Node has no WebSocket (needs >=22); @-mentions won't reach the CLI.\n"
|
|
782
|
+
);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
this.tokenRefresher.current().then((token) => this.openSocket(Ctor, token)).catch((e) => {
|
|
786
|
+
process.stderr.write(`[directed] could not fetch a token for the mentions socket: ${String(e)}
|
|
787
|
+
`);
|
|
788
|
+
this.scheduleReconnect();
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
openSocket(Ctor, token) {
|
|
792
|
+
if (this.stopped) return;
|
|
793
|
+
const base = this.hubUrl.replace(/^http/, "ws");
|
|
794
|
+
const ws = new Ctor(`${base}/ws/chats/${this.chatId}/`, ["jwt", token]);
|
|
795
|
+
this.ws = ws;
|
|
796
|
+
ws.onopen = () => {
|
|
797
|
+
this.attempts = 0;
|
|
798
|
+
};
|
|
799
|
+
ws.onmessage = (ev) => this.onFrame(ev.data);
|
|
800
|
+
ws.onerror = () => {
|
|
801
|
+
};
|
|
802
|
+
ws.onclose = (ev) => {
|
|
803
|
+
this.ws = null;
|
|
804
|
+
if (this.stopped) return;
|
|
805
|
+
if (ev.code === 4001 || ev.code === 4003 || ev.code === 4004) {
|
|
806
|
+
process.stderr.write(
|
|
807
|
+
`[directed] chat socket closed (${ev.code}); @-mentions to the CLI are off.
|
|
808
|
+
`
|
|
809
|
+
);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (ev.code === 4002) {
|
|
813
|
+
this.tokenRefresher.refreshNow().then(() => this.scheduleReconnect()).catch((e) => {
|
|
814
|
+
process.stderr.write(`[directed] could not refresh the mentions socket token: ${String(e)}
|
|
815
|
+
`);
|
|
816
|
+
this.scheduleReconnect();
|
|
817
|
+
});
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
this.scheduleReconnect();
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
scheduleReconnect() {
|
|
824
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
825
|
+
const delay = Math.min(1e3 * 2 ** this.attempts, RECONNECT_MAX_MS);
|
|
826
|
+
this.attempts += 1;
|
|
827
|
+
this.reconnectTimer = setTimeout(() => {
|
|
828
|
+
this.reconnectTimer = null;
|
|
829
|
+
this.connect();
|
|
830
|
+
}, delay);
|
|
831
|
+
}
|
|
832
|
+
onFrame(raw) {
|
|
833
|
+
if (typeof raw !== "string") return;
|
|
834
|
+
let env;
|
|
835
|
+
try {
|
|
836
|
+
env = JSON.parse(raw);
|
|
837
|
+
} catch {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (env.kind !== "message_upsert") return;
|
|
841
|
+
const m = env.data;
|
|
842
|
+
if (!m || typeof m.public_id !== "string" || this.seen.has(m.public_id)) return;
|
|
843
|
+
if (m.actor?.public_id === this.agentActorId) return;
|
|
844
|
+
const createdMs = m.created_at ? Date.parse(m.created_at) : NaN;
|
|
845
|
+
if (Number.isFinite(createdMs) && createdMs <= this.sinceMs) return;
|
|
846
|
+
const text = mentionPrompt(m.blocks ?? [], this.agentActorId);
|
|
847
|
+
if (text === null) return;
|
|
848
|
+
this.seen.add(m.public_id);
|
|
849
|
+
this.handler?.({
|
|
850
|
+
text,
|
|
851
|
+
author: m.actor?.display_name ?? "someone",
|
|
852
|
+
seed: m.actor?.public_id ?? m.public_id,
|
|
853
|
+
ts: Number.isFinite(createdMs) ? createdMs : Date.now()
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
// src/echo-guard.ts
|
|
859
|
+
var ECHO_TTL_MS = 3e4;
|
|
860
|
+
function normalize(text) {
|
|
861
|
+
return text.trim();
|
|
862
|
+
}
|
|
863
|
+
var EchoGuard = class {
|
|
864
|
+
pending = [];
|
|
865
|
+
record(text) {
|
|
866
|
+
const norm = normalize(text);
|
|
867
|
+
if (!norm) return;
|
|
868
|
+
this.pending.push({ text: norm, at: Date.now() });
|
|
869
|
+
}
|
|
870
|
+
// True (and removes the entry) if `text` matches a still-live injected mention.
|
|
871
|
+
// Prunes expired entries on the way so they never suppress a real message.
|
|
872
|
+
consume(text) {
|
|
873
|
+
const now = Date.now();
|
|
874
|
+
this.pending = this.pending.filter((p) => now - p.at < ECHO_TTL_MS);
|
|
875
|
+
const norm = normalize(text);
|
|
876
|
+
const idx = this.pending.findIndex((p) => p.text === norm);
|
|
877
|
+
if (idx === -1) return false;
|
|
878
|
+
this.pending.splice(idx, 1);
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
// src/transcript/adapter.ts
|
|
884
|
+
import { basename as basename3 } from "path";
|
|
885
|
+
|
|
886
|
+
// src/transcript/claude.ts
|
|
887
|
+
import { readdir, stat as stat2 } from "fs/promises";
|
|
888
|
+
import { homedir as homedir2 } from "os";
|
|
889
|
+
import { basename, join as join4 } from "path";
|
|
890
|
+
function encodeProjectDir(cwd) {
|
|
891
|
+
return cwd.replace(/[/.]/g, "-");
|
|
892
|
+
}
|
|
893
|
+
var SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
894
|
+
var ClaudeAdapter = class {
|
|
895
|
+
async locate(cwd, sinceMs, _processId) {
|
|
896
|
+
const newest = await newestTranscript(projectDir(cwd));
|
|
897
|
+
if (!newest || newest.mtimeMs < sinceMs) return null;
|
|
898
|
+
return newest.path;
|
|
899
|
+
}
|
|
900
|
+
refFromPath(path) {
|
|
901
|
+
const name = basename(path);
|
|
902
|
+
if (!name.endsWith(".jsonl")) return null;
|
|
903
|
+
return name.slice(0, -".jsonl".length);
|
|
904
|
+
}
|
|
905
|
+
// Claude Code appends to the same <sessionId>.jsonl on --resume/--continue,
|
|
906
|
+
// so the resumed session's id is knowable before launch: from the flag's own
|
|
907
|
+
// argument, or from the newest transcript on disk for --continue. A bare
|
|
908
|
+
// --resume opens claude's interactive picker -- the target is unknowable, so
|
|
909
|
+
// it reads as a fresh session. --fork-session branches into a new id.
|
|
910
|
+
async resumeRef(command, cwd) {
|
|
911
|
+
const args = command.slice(1);
|
|
912
|
+
if (args.includes("--fork-session")) return null;
|
|
913
|
+
for (let i = 0; i < args.length; i++) {
|
|
914
|
+
const [flag, inline] = flagSplit(args[i]);
|
|
915
|
+
if (flag === "--resume" || flag === "-r" || flag === "--session-id") {
|
|
916
|
+
const candidate = inline ?? args[i + 1];
|
|
917
|
+
if (candidate !== void 0 && SESSION_ID_RE.test(candidate)) return candidate;
|
|
918
|
+
} else if (flag === "--continue" || flag === "-c") {
|
|
919
|
+
const newest = await newestTranscript(projectDir(cwd));
|
|
920
|
+
return newest ? this.refFromPath(newest.path) : null;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
parse(record) {
|
|
926
|
+
const r = record;
|
|
927
|
+
if (r.type === "user") return this.parseUser(r);
|
|
928
|
+
if (r.type === "assistant") return this.parseAssistant(r);
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
parseUser(r) {
|
|
932
|
+
const content = r.message?.content;
|
|
933
|
+
if (typeof content !== "string") return null;
|
|
934
|
+
return { id: r.uuid, role: "user", ts: Date.parse(r.timestamp), parts: [{ kind: "text", text: content }] };
|
|
935
|
+
}
|
|
936
|
+
parseAssistant(r) {
|
|
937
|
+
const content = r.message?.content;
|
|
938
|
+
if (!Array.isArray(content)) return null;
|
|
939
|
+
const parts = [];
|
|
940
|
+
for (const block of content) {
|
|
941
|
+
if (block.type === "text" && block.text !== void 0) parts.push({ kind: "text", text: block.text });
|
|
942
|
+
else if (block.type === "thinking" && block.thinking !== void 0) parts.push({ kind: "thinking", text: block.thinking });
|
|
943
|
+
else if (block.type === "tool_use" && block.name !== void 0) parts.push({ kind: "tool", name: block.name });
|
|
944
|
+
}
|
|
945
|
+
if (parts.length === 0) return null;
|
|
946
|
+
return { id: r.uuid, role: "assistant", ts: Date.parse(r.timestamp), parts };
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
function projectDir(cwd) {
|
|
950
|
+
return join4(homedir2(), ".claude/projects", encodeProjectDir(cwd));
|
|
951
|
+
}
|
|
952
|
+
function flagSplit(arg) {
|
|
953
|
+
const at = arg.indexOf("=");
|
|
954
|
+
if (at === -1) return [arg, void 0];
|
|
955
|
+
return [arg.slice(0, at), arg.slice(at + 1)];
|
|
956
|
+
}
|
|
957
|
+
async function newestTranscript(dir) {
|
|
958
|
+
let entries;
|
|
959
|
+
try {
|
|
960
|
+
entries = await readdir(dir);
|
|
961
|
+
} catch {
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
let newest = null;
|
|
965
|
+
for (const entry of entries) {
|
|
966
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
967
|
+
const path = join4(dir, entry);
|
|
968
|
+
const info = await stat2(path);
|
|
969
|
+
if (!newest || info.mtimeMs > newest.mtimeMs) newest = { path, mtimeMs: info.mtimeMs };
|
|
970
|
+
}
|
|
971
|
+
return newest;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// src/transcript/codex.ts
|
|
975
|
+
import { execFile } from "child_process";
|
|
976
|
+
import { open as open2, readdir as readdir2, readlink } from "fs/promises";
|
|
977
|
+
import { homedir as homedir3, platform } from "os";
|
|
978
|
+
import { basename as basename2, join as join5 } from "path";
|
|
979
|
+
var SESSION_ID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
980
|
+
var ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
981
|
+
var SESSION_META_BYTES = 1024 * 1024;
|
|
982
|
+
var CodexAdapter = class {
|
|
983
|
+
constructor(processRolloutPaths = rolloutPathsForProcess) {
|
|
984
|
+
this.processRolloutPaths = processRolloutPaths;
|
|
985
|
+
}
|
|
986
|
+
processRolloutPaths;
|
|
987
|
+
async locate(_cwd, _sinceMs, processId) {
|
|
988
|
+
const paths = await this.processRolloutPaths(processId);
|
|
989
|
+
const primaryPaths = [];
|
|
990
|
+
for (const path of paths) {
|
|
991
|
+
if (await isPrimaryRollout(path)) primaryPaths.push(path);
|
|
992
|
+
}
|
|
993
|
+
return primaryPaths.length === 1 ? primaryPaths[0] : null;
|
|
994
|
+
}
|
|
995
|
+
refFromPath(path) {
|
|
996
|
+
const match = ROLLOUT_RE.exec(basename2(path));
|
|
997
|
+
return match ? match[1] : null;
|
|
998
|
+
}
|
|
999
|
+
// `codex resume` reopens an existing rollout, whose filename keeps the
|
|
1000
|
+
// original session uuid: the flag's own argument names it, and --last means
|
|
1001
|
+
// the newest primary rollout in scope. A bare `codex resume` opens the
|
|
1002
|
+
// interactive picker -- the target is unknowable, so it reads as a fresh session.
|
|
1003
|
+
async resumeRef(command, cwd) {
|
|
1004
|
+
const args = command.slice(1);
|
|
1005
|
+
const subcommand = args.find((a) => !a.startsWith("-"));
|
|
1006
|
+
if (subcommand !== "resume") return null;
|
|
1007
|
+
const rest = args.slice(args.indexOf("resume") + 1);
|
|
1008
|
+
const id = rest.find((a) => SESSION_ID_RE2.test(a));
|
|
1009
|
+
if (id) return id;
|
|
1010
|
+
if (rest.includes("--last")) {
|
|
1011
|
+
const newest = await newestRollout(rest.includes("--all") ? null : cwd);
|
|
1012
|
+
return newest ? this.refFromPath(newest) : null;
|
|
1013
|
+
}
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
parse(record) {
|
|
1017
|
+
const r = record;
|
|
1018
|
+
if (r.type !== "response_item" || !r.payload) return null;
|
|
1019
|
+
const p = r.payload;
|
|
1020
|
+
const ts = Date.parse(r.timestamp);
|
|
1021
|
+
const id = p.id ?? `codex-${ts}`;
|
|
1022
|
+
if (p.type === "message") {
|
|
1023
|
+
const role = p.role === "assistant" ? "assistant" : p.role === "user" ? "user" : null;
|
|
1024
|
+
if (role === null) return null;
|
|
1025
|
+
const parts = [];
|
|
1026
|
+
for (const b of p.content ?? []) {
|
|
1027
|
+
if ((b.type === "input_text" || b.type === "output_text") && typeof b.text === "string") {
|
|
1028
|
+
parts.push({ kind: "text", text: b.text });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (parts.length === 0) return null;
|
|
1032
|
+
return { id, role, ts, parts };
|
|
1033
|
+
}
|
|
1034
|
+
if (p.type === "function_call" && typeof p.name === "string") {
|
|
1035
|
+
return { id, role: "assistant", ts, parts: [{ kind: "tool", name: p.name }] };
|
|
1036
|
+
}
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
async function newestRollout(cwd) {
|
|
1041
|
+
const dir = join5(homedir3(), ".codex", "sessions");
|
|
1042
|
+
let entries;
|
|
1043
|
+
try {
|
|
1044
|
+
entries = await readdir2(dir, { recursive: true });
|
|
1045
|
+
} catch {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
const rollouts = entries.filter((f) => {
|
|
1049
|
+
const b = basename2(f);
|
|
1050
|
+
return b.startsWith("rollout-") && b.endsWith(".jsonl");
|
|
1051
|
+
});
|
|
1052
|
+
rollouts.sort();
|
|
1053
|
+
rollouts.reverse();
|
|
1054
|
+
for (const rollout of rollouts) {
|
|
1055
|
+
const path = join5(dir, rollout);
|
|
1056
|
+
if (await isPrimaryRollout(path, cwd)) return path;
|
|
1057
|
+
}
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
async function rolloutPathsForProcess(processId) {
|
|
1061
|
+
if (platform() === "darwin") return macosRolloutPaths(processId);
|
|
1062
|
+
if (platform() === "linux") return linuxRolloutPaths(processId);
|
|
1063
|
+
return [];
|
|
1064
|
+
}
|
|
1065
|
+
async function macosRolloutPaths(processId) {
|
|
1066
|
+
return new Promise((resolve) => {
|
|
1067
|
+
execFile(
|
|
1068
|
+
"/usr/sbin/lsof",
|
|
1069
|
+
["-a", "-p", String(processId), "-Fn"],
|
|
1070
|
+
{ encoding: "utf8", maxBuffer: 4 * 1024 * 1024 },
|
|
1071
|
+
(error, stdout) => {
|
|
1072
|
+
if (error) {
|
|
1073
|
+
resolve([]);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
const paths = [];
|
|
1077
|
+
for (const line of stdout.split("\n")) {
|
|
1078
|
+
if (!line.startsWith("n")) continue;
|
|
1079
|
+
const path = line.slice(1);
|
|
1080
|
+
if (isRolloutPath(path)) paths.push(path);
|
|
1081
|
+
}
|
|
1082
|
+
resolve([...new Set(paths)]);
|
|
1083
|
+
}
|
|
1084
|
+
);
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
async function linuxRolloutPaths(processId) {
|
|
1088
|
+
const fdDir = `/proc/${processId}/fd`;
|
|
1089
|
+
let entries;
|
|
1090
|
+
try {
|
|
1091
|
+
entries = await readdir2(fdDir);
|
|
1092
|
+
} catch {
|
|
1093
|
+
return [];
|
|
1094
|
+
}
|
|
1095
|
+
const paths = [];
|
|
1096
|
+
for (const entry of entries) {
|
|
1097
|
+
let path;
|
|
1098
|
+
try {
|
|
1099
|
+
path = await readlink(join5(fdDir, entry));
|
|
1100
|
+
} catch {
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
if (isRolloutPath(path)) paths.push(path);
|
|
1104
|
+
}
|
|
1105
|
+
return [...new Set(paths)];
|
|
1106
|
+
}
|
|
1107
|
+
function isRolloutPath(path) {
|
|
1108
|
+
return ROLLOUT_RE.test(basename2(path));
|
|
1109
|
+
}
|
|
1110
|
+
async function isPrimaryRollout(path, cwd = null) {
|
|
1111
|
+
const line = await firstLine(path);
|
|
1112
|
+
if (line === null) return false;
|
|
1113
|
+
let record;
|
|
1114
|
+
try {
|
|
1115
|
+
record = JSON.parse(line);
|
|
1116
|
+
} catch {
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
if (!isRecord(record) || record.type !== "session_meta" || !isRecord(record.payload)) return false;
|
|
1120
|
+
const pathMatch = ROLLOUT_RE.exec(basename2(path));
|
|
1121
|
+
if (record.payload.source !== "cli" || record.payload.id !== pathMatch?.[1]) return false;
|
|
1122
|
+
return cwd === null || record.payload.cwd === cwd;
|
|
1123
|
+
}
|
|
1124
|
+
async function firstLine(path) {
|
|
1125
|
+
let handle;
|
|
1126
|
+
try {
|
|
1127
|
+
handle = await open2(path, "r");
|
|
1128
|
+
const buffer = Buffer.alloc(SESSION_META_BYTES);
|
|
1129
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
1130
|
+
const lineEnd = buffer.indexOf("\n", 0);
|
|
1131
|
+
if (bytesRead === 0 || lineEnd === -1 || lineEnd >= bytesRead) return null;
|
|
1132
|
+
return buffer.subarray(0, lineEnd).toString("utf8");
|
|
1133
|
+
} catch {
|
|
1134
|
+
return null;
|
|
1135
|
+
} finally {
|
|
1136
|
+
await handle?.close();
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
function isRecord(value) {
|
|
1140
|
+
return typeof value === "object" && value !== null;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// src/transcript/adapter.ts
|
|
1144
|
+
function adapterFor(command) {
|
|
1145
|
+
const program = command[0];
|
|
1146
|
+
if (program === void 0) return null;
|
|
1147
|
+
const name = basename3(program);
|
|
1148
|
+
if (name === "claude") return new ClaudeAdapter();
|
|
1149
|
+
if (name === "codex") return new CodexAdapter();
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
function sourceFor(command) {
|
|
1153
|
+
const program = command[0];
|
|
1154
|
+
if (program === void 0) return null;
|
|
1155
|
+
const name = basename3(program);
|
|
1156
|
+
if (name === "claude") return "claude_code";
|
|
1157
|
+
if (name === "codex") return "codex";
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// src/auth/hub.ts
|
|
1162
|
+
var HubClient = class {
|
|
1163
|
+
constructor(hubUrl, fetchImpl = fetch) {
|
|
1164
|
+
this.hubUrl = hubUrl;
|
|
1165
|
+
this.fetchImpl = fetchImpl;
|
|
1166
|
+
}
|
|
1167
|
+
hubUrl;
|
|
1168
|
+
fetchImpl;
|
|
1169
|
+
get url() {
|
|
1170
|
+
return this.hubUrl;
|
|
1171
|
+
}
|
|
1172
|
+
startUrl(redirectUri, challenge) {
|
|
1173
|
+
const params = `redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${encodeURIComponent(challenge)}&code_challenge_method=S256`;
|
|
1174
|
+
return `${this.hubUrl}/users/social/auth/mobile/start/?${params}`;
|
|
1175
|
+
}
|
|
1176
|
+
async exchange(code, verifier) {
|
|
1177
|
+
const body = await this.postJson("/api/auth/mobile/exchange/", {
|
|
1178
|
+
code,
|
|
1179
|
+
code_verifier: verifier
|
|
1180
|
+
});
|
|
1181
|
+
return tokenPairFrom(body);
|
|
1182
|
+
}
|
|
1183
|
+
async refresh(refreshToken) {
|
|
1184
|
+
const body = await this.postJson("/api/auth/mobile/refresh/", {
|
|
1185
|
+
refresh_token: refreshToken
|
|
1186
|
+
});
|
|
1187
|
+
return tokenPairFrom(body);
|
|
1188
|
+
}
|
|
1189
|
+
async me(accessToken) {
|
|
1190
|
+
const res = await this.fetchImpl(`${this.hubUrl}/api/auth/me/`, {
|
|
1191
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
1192
|
+
});
|
|
1193
|
+
if (!res.ok) {
|
|
1194
|
+
throw new Error(`GET /api/auth/me/ failed: ${res.status}`);
|
|
1195
|
+
}
|
|
1196
|
+
const body = await res.json();
|
|
1197
|
+
return {
|
|
1198
|
+
publicId: body.public_id,
|
|
1199
|
+
email: body.email,
|
|
1200
|
+
username: body.username,
|
|
1201
|
+
fullName: body.full_name
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
async sessionStart(accessToken, input2) {
|
|
1205
|
+
const res = await this.fetchImpl(`${this.hubUrl}/api/terminal-sessions/`, {
|
|
1206
|
+
method: "POST",
|
|
1207
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
|
|
1208
|
+
body: JSON.stringify({
|
|
1209
|
+
source: input2.source,
|
|
1210
|
+
title: input2.title ?? "",
|
|
1211
|
+
external_ref: input2.externalRef ?? "",
|
|
1212
|
+
destination_chat_public_id: input2.destinationChatPublicId ?? "",
|
|
1213
|
+
member_identifiers: input2.memberIdentifiers ?? []
|
|
1214
|
+
})
|
|
1215
|
+
});
|
|
1216
|
+
if (!res.ok) {
|
|
1217
|
+
const detail = await errorDetail(res);
|
|
1218
|
+
throw new Error(`POST /api/terminal-sessions/ failed: ${res.status}${detail ? ` (${detail})` : ""}`);
|
|
1219
|
+
}
|
|
1220
|
+
const b = await res.json();
|
|
1221
|
+
return {
|
|
1222
|
+
chatPublicId: b.chat_public_id,
|
|
1223
|
+
terminalSessionPublicId: b.terminal_session_public_id,
|
|
1224
|
+
chatUrl: b.chat_url,
|
|
1225
|
+
agentActorPublicId: b.agent_actor_public_id,
|
|
1226
|
+
reattached: b.reattached === true,
|
|
1227
|
+
lastClientTurnId: typeof b.last_client_turn_id === "string" ? b.last_client_turn_id : "",
|
|
1228
|
+
skippedMemberIdentifiers: Array.isArray(b.skipped_member_identifiers) ? b.skipped_member_identifiers : []
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
// Mint (or reuse) the chat's shareable join link. Redeeming it seats the
|
|
1232
|
+
// visitor as a chat member, so it works for people outside the host's
|
|
1233
|
+
// connections -- the email-invite path.
|
|
1234
|
+
async chatLinkCreate(accessToken, chatPublicId) {
|
|
1235
|
+
const res = await this.fetchImpl(`${this.hubUrl}/api/chats/${chatPublicId}/link/`, {
|
|
1236
|
+
method: "POST",
|
|
1237
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
|
|
1238
|
+
body: "{}"
|
|
1239
|
+
});
|
|
1240
|
+
if (!res.ok) throw new Error(`POST /api/chats/${chatPublicId}/link/ failed: ${res.status}`);
|
|
1241
|
+
const b = await res.json();
|
|
1242
|
+
return b.url;
|
|
1243
|
+
}
|
|
1244
|
+
async chatLookup(accessToken, query) {
|
|
1245
|
+
const params = new URLSearchParams({ q: query, limit: "10" });
|
|
1246
|
+
const res = await this.fetchImpl(`${this.hubUrl}/api/chats/lookup/?${params.toString()}`, {
|
|
1247
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
1248
|
+
});
|
|
1249
|
+
if (!res.ok) throw new Error(`GET /api/chats/lookup/ failed: ${res.status}`);
|
|
1250
|
+
const body = await res.json();
|
|
1251
|
+
const rows = [];
|
|
1252
|
+
for (const item of body.items) {
|
|
1253
|
+
rows.push({
|
|
1254
|
+
publicId: item.public_id,
|
|
1255
|
+
title: item.display_title,
|
|
1256
|
+
url: item.chat_url
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
return rows;
|
|
1260
|
+
}
|
|
1261
|
+
async sessionEnd(accessToken, sessionId) {
|
|
1262
|
+
const res = await this.fetchImpl(`${this.hubUrl}/api/terminal-sessions/${sessionId}/end/`, {
|
|
1263
|
+
method: "POST",
|
|
1264
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
|
|
1265
|
+
body: "{}"
|
|
1266
|
+
});
|
|
1267
|
+
if (!res.ok) throw new Error(`POST .../end/ failed: ${res.status}`);
|
|
1268
|
+
}
|
|
1269
|
+
// Stamp the tool's own session id on a hub session that started without one
|
|
1270
|
+
// (a fresh launch learns it only once the transcript file appears). Pairs a
|
|
1271
|
+
// later resume of the same agent session back to the same chat.
|
|
1272
|
+
async sessionRefSet(accessToken, sessionId, externalRef) {
|
|
1273
|
+
const res = await this.fetchImpl(`${this.hubUrl}/api/terminal-sessions/${sessionId}/external-ref/`, {
|
|
1274
|
+
method: "POST",
|
|
1275
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
|
|
1276
|
+
body: JSON.stringify({ external_ref: externalRef })
|
|
1277
|
+
});
|
|
1278
|
+
if (!res.ok) throw new Error(`POST .../external-ref/ failed: ${res.status}`);
|
|
1279
|
+
}
|
|
1280
|
+
async turnAppend(accessToken, sessionId, turn) {
|
|
1281
|
+
const res = await this.fetchImpl(`${this.hubUrl}/api/terminal-sessions/${sessionId}/turns/`, {
|
|
1282
|
+
method: "POST",
|
|
1283
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
|
|
1284
|
+
body: JSON.stringify({ role: turn.role, text: turn.text, client_turn_id: turn.clientTurnId })
|
|
1285
|
+
});
|
|
1286
|
+
if (!res.ok) throw new Error(`POST .../turns/ failed: ${res.status}`);
|
|
1287
|
+
const b = await res.json();
|
|
1288
|
+
return { messagePublicId: b.message_public_id };
|
|
1289
|
+
}
|
|
1290
|
+
async chunkAppend(accessToken, sessionId, chunk) {
|
|
1291
|
+
const res = await this.fetchImpl(`${this.hubUrl}/api/terminal-sessions/${sessionId}/chunks/`, {
|
|
1292
|
+
method: "POST",
|
|
1293
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
|
|
1294
|
+
body: JSON.stringify({
|
|
1295
|
+
seq: chunk.seq,
|
|
1296
|
+
offset: chunk.offset,
|
|
1297
|
+
events: chunk.events,
|
|
1298
|
+
width: chunk.width,
|
|
1299
|
+
height: chunk.height,
|
|
1300
|
+
started_epoch: chunk.startedEpoch
|
|
1301
|
+
})
|
|
1302
|
+
});
|
|
1303
|
+
if (!res.ok) throw new Error(`POST .../chunks/ failed: ${res.status}`);
|
|
1304
|
+
}
|
|
1305
|
+
async postJson(path, payload) {
|
|
1306
|
+
const res = await this.fetchImpl(`${this.hubUrl}${path}`, {
|
|
1307
|
+
method: "POST",
|
|
1308
|
+
headers: { "Content-Type": "application/json" },
|
|
1309
|
+
body: JSON.stringify(payload)
|
|
1310
|
+
});
|
|
1311
|
+
if (!res.ok) {
|
|
1312
|
+
throw new Error(`POST ${path} failed: ${res.status}`);
|
|
1313
|
+
}
|
|
1314
|
+
return await res.json();
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
async function errorDetail(res) {
|
|
1318
|
+
try {
|
|
1319
|
+
const body = await res.json();
|
|
1320
|
+
return typeof body.detail === "string" ? body.detail : "";
|
|
1321
|
+
} catch {
|
|
1322
|
+
return "";
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
function tokenPairFrom(body) {
|
|
1326
|
+
return {
|
|
1327
|
+
access: body.access_token,
|
|
1328
|
+
refresh: body.refresh_token,
|
|
1329
|
+
expiresAt: Date.now() + body.expires_in * 1e3
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// src/auth/store.ts
|
|
1334
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1335
|
+
import { homedir as homedir4 } from "os";
|
|
1336
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
1337
|
+
function authPath() {
|
|
1338
|
+
const home = process.env.DIRECTED_HOME ?? homedir4();
|
|
1339
|
+
return join6(home, ".directed", "auth.json");
|
|
1340
|
+
}
|
|
1341
|
+
function authLoad(hubUrl) {
|
|
1342
|
+
try {
|
|
1343
|
+
const raw = readFileSync2(authPath(), "utf8");
|
|
1344
|
+
const stored = JSON.parse(raw);
|
|
1345
|
+
if (stored.hubUrl !== hubUrl) return null;
|
|
1346
|
+
return stored;
|
|
1347
|
+
} catch {
|
|
1348
|
+
return null;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
function authSave(a) {
|
|
1352
|
+
const path = authPath();
|
|
1353
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
1354
|
+
writeFileSync3(path, JSON.stringify(a, null, 2), { mode: 384 });
|
|
1355
|
+
}
|
|
1356
|
+
function authClear() {
|
|
1357
|
+
const path = authPath();
|
|
1358
|
+
if (existsSync(path)) {
|
|
1359
|
+
rmSync3(path);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// src/auth/login.ts
|
|
1364
|
+
import http from "http";
|
|
1365
|
+
|
|
1366
|
+
// src/auth/pkce.ts
|
|
1367
|
+
import { createHash, randomBytes } from "crypto";
|
|
1368
|
+
function pkce() {
|
|
1369
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
1370
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
1371
|
+
return { verifier, challenge };
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// src/auth/login.ts
|
|
1375
|
+
var LOGIN_TIMEOUT_MS = 3e5;
|
|
1376
|
+
var SUCCESS_BODY = `<!doctype html>
|
|
1377
|
+
<html lang="en">
|
|
1378
|
+
<head>
|
|
1379
|
+
<meta charset="utf-8" />
|
|
1380
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1381
|
+
<meta name="color-scheme" content="light dark" />
|
|
1382
|
+
<title>Signed in - Directed AI</title>
|
|
1383
|
+
<style>
|
|
1384
|
+
:root {
|
|
1385
|
+
color-scheme: light dark;
|
|
1386
|
+
}
|
|
1387
|
+
body {
|
|
1388
|
+
margin: 0;
|
|
1389
|
+
min-height: 100vh;
|
|
1390
|
+
display: flex;
|
|
1391
|
+
align-items: center;
|
|
1392
|
+
justify-content: center;
|
|
1393
|
+
font-family: system-ui, sans-serif;
|
|
1394
|
+
-webkit-font-smoothing: antialiased;
|
|
1395
|
+
-moz-osx-font-smoothing: grayscale;
|
|
1396
|
+
background: #fff;
|
|
1397
|
+
color: #1a1a1a;
|
|
1398
|
+
}
|
|
1399
|
+
main {
|
|
1400
|
+
text-align: center;
|
|
1401
|
+
padding: 0 24px;
|
|
1402
|
+
}
|
|
1403
|
+
img {
|
|
1404
|
+
display: block;
|
|
1405
|
+
margin: 0 auto 12px;
|
|
1406
|
+
}
|
|
1407
|
+
h1 {
|
|
1408
|
+
font-size: 24px;
|
|
1409
|
+
font-weight: 600;
|
|
1410
|
+
letter-spacing: -0.02em;
|
|
1411
|
+
margin: 0 0 8px;
|
|
1412
|
+
}
|
|
1413
|
+
p {
|
|
1414
|
+
margin: 0;
|
|
1415
|
+
color: #6b6b6b;
|
|
1416
|
+
}
|
|
1417
|
+
@media (prefers-color-scheme: dark) {
|
|
1418
|
+
body {
|
|
1419
|
+
background: #1a1a1a;
|
|
1420
|
+
color: #f5f5f5;
|
|
1421
|
+
}
|
|
1422
|
+
p {
|
|
1423
|
+
color: #a0a0a0;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
</style>
|
|
1427
|
+
</head>
|
|
1428
|
+
<body>
|
|
1429
|
+
<main>
|
|
1430
|
+
<img
|
|
1431
|
+
src="https://res.cloudinary.com/dnriuttuy/image/upload/v1761205887/dino-black-with-white-outline_nk3vhs.svg"
|
|
1432
|
+
alt="Dino"
|
|
1433
|
+
width="40"
|
|
1434
|
+
height="40"
|
|
1435
|
+
/>
|
|
1436
|
+
<h1>You are signed in</h1>
|
|
1437
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
1438
|
+
</main>
|
|
1439
|
+
</body>
|
|
1440
|
+
</html>`;
|
|
1441
|
+
function codeFromRequestUrl(url) {
|
|
1442
|
+
const parsed = new URL(url ?? "/", "http://127.0.0.1");
|
|
1443
|
+
return parsed.searchParams.get("code");
|
|
1444
|
+
}
|
|
1445
|
+
function loginFlow(hub, open3) {
|
|
1446
|
+
const { verifier, challenge } = pkce();
|
|
1447
|
+
return new Promise((resolve, reject) => {
|
|
1448
|
+
let settled = false;
|
|
1449
|
+
const server = http.createServer((req, res) => {
|
|
1450
|
+
const code = codeFromRequestUrl(req.url);
|
|
1451
|
+
if (!code) {
|
|
1452
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
1453
|
+
res.end("Missing code");
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1457
|
+
res.end(SUCCESS_BODY);
|
|
1458
|
+
onCode(code);
|
|
1459
|
+
});
|
|
1460
|
+
const timer = setTimeout(() => {
|
|
1461
|
+
settle(() => reject(new Error("Login timed out after 300s")));
|
|
1462
|
+
}, LOGIN_TIMEOUT_MS);
|
|
1463
|
+
function settle(run) {
|
|
1464
|
+
if (settled) {
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
settled = true;
|
|
1468
|
+
clearTimeout(timer);
|
|
1469
|
+
server.close();
|
|
1470
|
+
run();
|
|
1471
|
+
}
|
|
1472
|
+
async function onCode(code) {
|
|
1473
|
+
try {
|
|
1474
|
+
const tok = await hub.exchange(code, verifier);
|
|
1475
|
+
const identity = await hub.me(tok.access);
|
|
1476
|
+
const stored = {
|
|
1477
|
+
access: tok.access,
|
|
1478
|
+
refresh: tok.refresh,
|
|
1479
|
+
expiresAt: tok.expiresAt,
|
|
1480
|
+
identity,
|
|
1481
|
+
hubUrl: hub.url
|
|
1482
|
+
};
|
|
1483
|
+
authSave(stored);
|
|
1484
|
+
settle(() => resolve(stored));
|
|
1485
|
+
} catch (err) {
|
|
1486
|
+
settle(() => reject(err instanceof Error ? err : new Error(String(err))));
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
server.on("error", (err) => {
|
|
1490
|
+
settle(() => reject(err));
|
|
1491
|
+
});
|
|
1492
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1493
|
+
const address = server.address();
|
|
1494
|
+
if (address === null || typeof address === "string") {
|
|
1495
|
+
settle(() => reject(new Error("Failed to determine loopback server port")));
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const redirectUri = `http://127.0.0.1:${address.port}/cb`;
|
|
1499
|
+
open3(hub.startUrl(redirectUri, challenge));
|
|
1500
|
+
});
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// src/auth/ensure.ts
|
|
1505
|
+
var EXPIRY_SKEW_MS = 6e4;
|
|
1506
|
+
async function ensureAuth(hub, opts) {
|
|
1507
|
+
const stored = authLoad(hub.url);
|
|
1508
|
+
if (stored && stored.expiresAt - EXPIRY_SKEW_MS > Date.now()) {
|
|
1509
|
+
return stored.identity;
|
|
1510
|
+
}
|
|
1511
|
+
if (stored) {
|
|
1512
|
+
try {
|
|
1513
|
+
const tok = await hub.refresh(stored.refresh);
|
|
1514
|
+
const identity = await hub.me(tok.access);
|
|
1515
|
+
authSave({
|
|
1516
|
+
access: tok.access,
|
|
1517
|
+
refresh: tok.refresh,
|
|
1518
|
+
expiresAt: tok.expiresAt,
|
|
1519
|
+
identity,
|
|
1520
|
+
hubUrl: hub.url
|
|
1521
|
+
});
|
|
1522
|
+
return identity;
|
|
1523
|
+
} catch {
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if (opts.autoLogin) {
|
|
1527
|
+
const fresh = await loginFlow(hub, opts.open);
|
|
1528
|
+
return fresh.identity;
|
|
1529
|
+
}
|
|
1530
|
+
throw new Error("Not signed in. Run `directed login` first.");
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// src/auth/browser.ts
|
|
1534
|
+
import { spawn as spawn3 } from "child_process";
|
|
1535
|
+
var OPENERS = {
|
|
1536
|
+
darwin: (url) => ["open", [url]],
|
|
1537
|
+
linux: (url) => ["xdg-open", [url]],
|
|
1538
|
+
win32: (url) => ["cmd", ["/c", "start", "", url]]
|
|
1539
|
+
};
|
|
1540
|
+
function openBrowser(url) {
|
|
1541
|
+
const opener = OPENERS[process.platform];
|
|
1542
|
+
if (!opener) {
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
const [command, args] = opener(url);
|
|
1546
|
+
try {
|
|
1547
|
+
const child = spawn3(command, args, { detached: true, stdio: "ignore" });
|
|
1548
|
+
child.on("error", () => {
|
|
1549
|
+
});
|
|
1550
|
+
child.unref();
|
|
1551
|
+
} catch {
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// src/auth/token-refresher.ts
|
|
1556
|
+
var EXPIRY_SKEW_MS2 = 6e4;
|
|
1557
|
+
var TokenRefresher = class {
|
|
1558
|
+
constructor(hub, stored) {
|
|
1559
|
+
this.hub = hub;
|
|
1560
|
+
this.stored = stored;
|
|
1561
|
+
}
|
|
1562
|
+
hub;
|
|
1563
|
+
stored;
|
|
1564
|
+
refreshing = null;
|
|
1565
|
+
async current() {
|
|
1566
|
+
if (this.stored.expiresAt - EXPIRY_SKEW_MS2 > Date.now()) {
|
|
1567
|
+
return this.stored.access;
|
|
1568
|
+
}
|
|
1569
|
+
return this.refreshNow();
|
|
1570
|
+
}
|
|
1571
|
+
// Unconditionally refreshes (deduped against a concurrent call), for when
|
|
1572
|
+
// the server has already said the token is bad -- a 401, or a chat-socket
|
|
1573
|
+
// close code 4002 -- regardless of what our local clock thinks.
|
|
1574
|
+
async refreshNow() {
|
|
1575
|
+
if (!this.refreshing) {
|
|
1576
|
+
this.refreshing = this.refresh();
|
|
1577
|
+
}
|
|
1578
|
+
try {
|
|
1579
|
+
return await this.refreshing;
|
|
1580
|
+
} finally {
|
|
1581
|
+
this.refreshing = null;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
async refresh() {
|
|
1585
|
+
const tok = await this.hub.refresh(this.stored.refresh);
|
|
1586
|
+
this.stored = { ...this.stored, access: tok.access, refresh: tok.refresh, expiresAt: tok.expiresAt };
|
|
1587
|
+
authSave(this.stored);
|
|
1588
|
+
return this.stored.access;
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1592
|
+
// src/chat-target.ts
|
|
1593
|
+
import { createInterface } from "readline/promises";
|
|
1594
|
+
import { stdin as input, stdout as output } from "process";
|
|
1595
|
+
var DEFAULT_TERMINAL = { input, output };
|
|
1596
|
+
function chatTargetChoice(answer, count) {
|
|
1597
|
+
const value = Number(answer.trim());
|
|
1598
|
+
if (!Number.isInteger(value) || value < 1 || value > count) return null;
|
|
1599
|
+
return value;
|
|
1600
|
+
}
|
|
1601
|
+
function chatTargetPublicId(value) {
|
|
1602
|
+
const trimmed = value.trim();
|
|
1603
|
+
try {
|
|
1604
|
+
const url = new URL(trimmed);
|
|
1605
|
+
const match = url.pathname.match(/\/chat\/([^/]+)\/?$/);
|
|
1606
|
+
return match ? decodeURIComponent(match[1]) : trimmed;
|
|
1607
|
+
} catch {
|
|
1608
|
+
return trimmed;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
function chatTargetExact(query, chatList) {
|
|
1612
|
+
const publicId = chatTargetPublicId(query);
|
|
1613
|
+
return chatList.some((chat) => chat.publicId === publicId) ? publicId : null;
|
|
1614
|
+
}
|
|
1615
|
+
function chatTargetMenu(chatList) {
|
|
1616
|
+
const lines = ["", "Recent Directed chats:"];
|
|
1617
|
+
for (const [index, chat] of chatList.entries()) {
|
|
1618
|
+
lines.push(`${index + 1}. ${chat.title}`);
|
|
1619
|
+
lines.push(` ${chat.url}`);
|
|
1620
|
+
}
|
|
1621
|
+
return `${lines.join("\n")}
|
|
1622
|
+
`;
|
|
1623
|
+
}
|
|
1624
|
+
async function chatTargetPick(hub, accessToken, query, terminal = DEFAULT_TERMINAL) {
|
|
1625
|
+
let prompt = null;
|
|
1626
|
+
try {
|
|
1627
|
+
const search = query.trim();
|
|
1628
|
+
if (search) {
|
|
1629
|
+
const chats2 = await hub.chatLookup(accessToken, search);
|
|
1630
|
+
const exactPublicId = chatTargetExact(search, chats2);
|
|
1631
|
+
if (exactPublicId) return exactPublicId;
|
|
1632
|
+
throw new Error("--into=<value> must resolve to an exact Directed chat URL or public id");
|
|
1633
|
+
}
|
|
1634
|
+
if (!terminal.input.isTTY || !terminal.output.isTTY) {
|
|
1635
|
+
throw new Error("--into needs an interactive terminal; use --into=<Directed chat URL or exact public id>");
|
|
1636
|
+
}
|
|
1637
|
+
const chats = await hub.chatLookup(accessToken, "");
|
|
1638
|
+
if (chats.length === 0) {
|
|
1639
|
+
throw new Error("No recent chats available; run without --into to start a new chat");
|
|
1640
|
+
}
|
|
1641
|
+
prompt = createInterface({ input: terminal.input, output: terminal.output });
|
|
1642
|
+
terminal.output.write(chatTargetMenu(chats));
|
|
1643
|
+
const answer = await prompt.question(`Choose a chat [1-${chats.length}]: `);
|
|
1644
|
+
const choice = chatTargetChoice(answer, chats.length);
|
|
1645
|
+
if (choice === null) throw new Error("Choose a listed chat number");
|
|
1646
|
+
return chats[choice - 1].publicId;
|
|
1647
|
+
} finally {
|
|
1648
|
+
prompt?.close();
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// src/cli.ts
|
|
1653
|
+
var require2 = createRequire(import.meta.url);
|
|
1654
|
+
var CLI_VERSION = require2("../package.json").version;
|
|
1655
|
+
function helpText() {
|
|
1656
|
+
return `directed - run an interactive coding agent in a Directed chat
|
|
1657
|
+
|
|
1658
|
+
Usage:
|
|
1659
|
+
directed [options] <command> [args...]
|
|
1660
|
+
directed login
|
|
1661
|
+
directed logout
|
|
1662
|
+
directed upgrade
|
|
1663
|
+
|
|
1664
|
+
Options:
|
|
1665
|
+
--accept-mode <send|stage> Submit chat steers immediately or stage them
|
|
1666
|
+
--chat Stream the session to a Directed chat
|
|
1667
|
+
--no-chat Do not stream the session to a Directed chat
|
|
1668
|
+
--into[=<URL or public id>] Choose a recent chat or give an exact target
|
|
1669
|
+
--with <people> Add @usernames or emails to the session chat
|
|
1670
|
+
--invite <emails> Open an email draft with the chat join link
|
|
1671
|
+
--open Open the session chat in a browser
|
|
1672
|
+
--no-open Do not open the session chat in a browser
|
|
1673
|
+
--name <label> Set the session label
|
|
1674
|
+
--tmux-bin <path> Use a specific tmux executable
|
|
1675
|
+
-h, --help Show this help
|
|
1676
|
+
|
|
1677
|
+
Everything after <command> is passed to that command unchanged.
|
|
1678
|
+
|
|
1679
|
+
Examples:
|
|
1680
|
+
directed codex
|
|
1681
|
+
directed --with @jane claude
|
|
1682
|
+
directed codex --help`;
|
|
1683
|
+
}
|
|
1684
|
+
function isHelpRequest(argv) {
|
|
1685
|
+
return argv[0] === "--help" || argv[0] === "-h";
|
|
1686
|
+
}
|
|
1687
|
+
function asMode(v) {
|
|
1688
|
+
if (v !== "send" && v !== "stage") throw new Error(`--accept-mode must be 'send' or 'stage', got '${v}'`);
|
|
1689
|
+
return v;
|
|
1690
|
+
}
|
|
1691
|
+
function commandSplit(argv) {
|
|
1692
|
+
let acceptMode = DEFAULTS.acceptMode;
|
|
1693
|
+
let sessionName = "", tmuxBin = DEFAULTS.tmuxBin, openOnStart = DEFAULTS.openOnStart, invitees = DEFAULTS.invitees, inviteEmails = DEFAULTS.inviteEmails;
|
|
1694
|
+
let toChatFlag;
|
|
1695
|
+
let chatTargetQuery = null;
|
|
1696
|
+
let i = 0;
|
|
1697
|
+
while (i < argv.length) {
|
|
1698
|
+
const a = argv[i];
|
|
1699
|
+
if (a === "--accept-mode") {
|
|
1700
|
+
acceptMode = asMode(argv[i + 1]);
|
|
1701
|
+
i += 2;
|
|
1702
|
+
} else if (a === "--name") {
|
|
1703
|
+
sessionName = argv[i + 1];
|
|
1704
|
+
i += 2;
|
|
1705
|
+
} else if (a === "--tmux-bin") {
|
|
1706
|
+
tmuxBin = argv[i + 1];
|
|
1707
|
+
i += 2;
|
|
1708
|
+
} else if (a === "--chat") {
|
|
1709
|
+
toChatFlag = true;
|
|
1710
|
+
i += 1;
|
|
1711
|
+
} else if (a === "--no-chat") {
|
|
1712
|
+
toChatFlag = false;
|
|
1713
|
+
i += 1;
|
|
1714
|
+
} else if (a === "--into") {
|
|
1715
|
+
chatTargetQuery = "";
|
|
1716
|
+
i += 1;
|
|
1717
|
+
} else if (a.startsWith("--into=")) {
|
|
1718
|
+
chatTargetQuery = a.slice("--into=".length);
|
|
1719
|
+
i += 1;
|
|
1720
|
+
} else if (a === "--open") {
|
|
1721
|
+
openOnStart = true;
|
|
1722
|
+
i += 1;
|
|
1723
|
+
} else if (a === "--no-open") {
|
|
1724
|
+
openOnStart = false;
|
|
1725
|
+
i += 1;
|
|
1726
|
+
} else if (a === "--with") {
|
|
1727
|
+
invitees = (argv[i + 1] ?? "").split(",").map((e) => e.trim()).filter(Boolean);
|
|
1728
|
+
i += 2;
|
|
1729
|
+
} else if (a === "--invite") {
|
|
1730
|
+
inviteEmails = (argv[i + 1] ?? "").split(",").map((e) => e.trim()).filter(Boolean);
|
|
1731
|
+
for (const email of inviteEmails) {
|
|
1732
|
+
if (!email.includes("@") || email.startsWith("@")) {
|
|
1733
|
+
throw new Error(`--invite takes emails only, got '${email}' (use --with for @usernames)`);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
i += 2;
|
|
1737
|
+
} else if (a.startsWith("-")) {
|
|
1738
|
+
throw new Error(`unknown directed option '${a}'; run 'directed --help'`);
|
|
1739
|
+
} else {
|
|
1740
|
+
break;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
const command = argv.slice(i);
|
|
1744
|
+
if (command.length === 0) throw new Error("usage: directed [flags] <command> [args...]");
|
|
1745
|
+
const toChat = toChatFlag ?? sourceFor(command) !== null;
|
|
1746
|
+
const chatTitle = sessionName;
|
|
1747
|
+
if (!sessionName) sessionName = command.join(" ");
|
|
1748
|
+
return {
|
|
1749
|
+
command,
|
|
1750
|
+
acceptMode,
|
|
1751
|
+
sessionName,
|
|
1752
|
+
chatTitle,
|
|
1753
|
+
tmuxBin,
|
|
1754
|
+
hubUrl: DEFAULTS.hubUrl,
|
|
1755
|
+
toChat,
|
|
1756
|
+
chatTargetQuery,
|
|
1757
|
+
openOnStart,
|
|
1758
|
+
invitees,
|
|
1759
|
+
inviteEmails
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
function inviteMailto(emails, joinUrl, hostName) {
|
|
1763
|
+
const subject = `${hostName} invited you to a live Directed session`;
|
|
1764
|
+
const body = [
|
|
1765
|
+
"Hi,",
|
|
1766
|
+
"",
|
|
1767
|
+
"I'm running a live coding session and want you in the chat -- follow along and jump in here:",
|
|
1768
|
+
"",
|
|
1769
|
+
joinUrl,
|
|
1770
|
+
"",
|
|
1771
|
+
"See you there,",
|
|
1772
|
+
hostName
|
|
1773
|
+
].join("\n");
|
|
1774
|
+
return `mailto:${emails.join(",")}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
1775
|
+
}
|
|
1776
|
+
async function inviteDraftOpen(hub, hubChat, emails, hostName) {
|
|
1777
|
+
try {
|
|
1778
|
+
const token = await hubChat.tokenRefresher.current();
|
|
1779
|
+
const joinUrl = await hub.chatLinkCreate(token, hubChat.chatId);
|
|
1780
|
+
process.stdout.write(`[directed] join link: ${joinUrl}
|
|
1781
|
+
[directed] opening mail draft to: ${emails.join(", ")}
|
|
1782
|
+
`);
|
|
1783
|
+
openBrowser(inviteMailto(emails, joinUrl, hostName));
|
|
1784
|
+
} catch (err) {
|
|
1785
|
+
process.stderr.write(`[directed] could not prepare the email invite (${err.message}); invite them from the chat instead.
|
|
1786
|
+
`);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
async function hubChatStart(hub, config, conversation, echoGuard, externalRef, destinationChatPublicId, recorder) {
|
|
1790
|
+
const source = sourceFor(config.command);
|
|
1791
|
+
if (!source) {
|
|
1792
|
+
process.stderr.write("[directed] chat streaming only works for claude and codex sessions; this tool has no transcript, skipping the chat.\n");
|
|
1793
|
+
return null;
|
|
1794
|
+
}
|
|
1795
|
+
const stored = authLoad(config.hubUrl);
|
|
1796
|
+
if (!stored) {
|
|
1797
|
+
process.stderr.write("[directed] no signed-in session; run 'directed login' to stream this session to a chat. Continuing without one.\n");
|
|
1798
|
+
return null;
|
|
1799
|
+
}
|
|
1800
|
+
const tokenRefresher = new TokenRefresher(hub, stored);
|
|
1801
|
+
const who = stored.identity.fullName || stored.identity.username;
|
|
1802
|
+
const chat = new HubTransport(
|
|
1803
|
+
hub,
|
|
1804
|
+
tokenRefresher,
|
|
1805
|
+
source,
|
|
1806
|
+
conversation,
|
|
1807
|
+
config.chatTitle,
|
|
1808
|
+
echoGuard,
|
|
1809
|
+
externalRef,
|
|
1810
|
+
destinationChatPublicId,
|
|
1811
|
+
config.invitees,
|
|
1812
|
+
(text) => recorder.marker(`${who}: ${text}`)
|
|
1813
|
+
);
|
|
1814
|
+
try {
|
|
1815
|
+
const url = await chat.start();
|
|
1816
|
+
const sessionId = chat.sessionId;
|
|
1817
|
+
const chatId = chat.chatId;
|
|
1818
|
+
const agentActorId = chat.agentActorId;
|
|
1819
|
+
if (sessionId === null || chatId === null || agentActorId === null) {
|
|
1820
|
+
throw new Error("terminal session ids missing after start");
|
|
1821
|
+
}
|
|
1822
|
+
const verb = chat.reattached ? "re-attached this session to its chat" : "streaming this session to a chat";
|
|
1823
|
+
process.stdout.write(`[directed] ${verb}: ${url}
|
|
1824
|
+
`);
|
|
1825
|
+
if (chat.skippedInvitees.length > 0) {
|
|
1826
|
+
process.stderr.write(`[directed] not added (not your active connections): ${chat.skippedInvitees.join(", ")}
|
|
1827
|
+
`);
|
|
1828
|
+
}
|
|
1829
|
+
if (config.openOnStart) setTimeout(() => openBrowser(url), SPLASH_MS);
|
|
1830
|
+
return { transport: chat, sessionId, chatId, agentActorId, tokenRefresher };
|
|
1831
|
+
} catch (err) {
|
|
1832
|
+
process.stderr.write(`[directed] could not open the chat (${err.message}); continuing locally.
|
|
1833
|
+
`);
|
|
1834
|
+
return null;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
async function main(argv = process.argv.slice(2)) {
|
|
1838
|
+
if (isHelpRequest(argv)) {
|
|
1839
|
+
process.stdout.write(`${helpText()}
|
|
1840
|
+
`);
|
|
1841
|
+
return 0;
|
|
1842
|
+
}
|
|
1843
|
+
if (argv[0] === "upgrade") {
|
|
1844
|
+
return upgradeRun(DEFAULTS.hubUrl);
|
|
1845
|
+
}
|
|
1846
|
+
await updateCheckRun(CLI_VERSION, DEFAULTS.hubUrl);
|
|
1847
|
+
if (argv[0] === "login") {
|
|
1848
|
+
const hub2 = new HubClient(DEFAULTS.hubUrl);
|
|
1849
|
+
const auth = await loginFlow(hub2, openBrowser);
|
|
1850
|
+
process.stdout.write(`[directed] signed in as ${auth.identity.email}
|
|
1851
|
+
`);
|
|
1852
|
+
return 0;
|
|
1853
|
+
}
|
|
1854
|
+
if (argv[0] === "logout") {
|
|
1855
|
+
authClear();
|
|
1856
|
+
process.stdout.write(`[directed] signed out
|
|
1857
|
+
`);
|
|
1858
|
+
return 0;
|
|
1859
|
+
}
|
|
1860
|
+
const config = commandSplit(argv);
|
|
1861
|
+
const hub = new HubClient(config.hubUrl);
|
|
1862
|
+
let identity;
|
|
1863
|
+
try {
|
|
1864
|
+
identity = await ensureAuth(hub, { autoLogin: true, open: openBrowser });
|
|
1865
|
+
} catch (err) {
|
|
1866
|
+
console.error(`[directed] ${err.message}`);
|
|
1867
|
+
return 1;
|
|
1868
|
+
}
|
|
1869
|
+
process.stdout.write(`[directed] signed in as ${identity.email}
|
|
1870
|
+
`);
|
|
1871
|
+
let destinationChatPublicId = null;
|
|
1872
|
+
if (config.toChat && config.chatTargetQuery !== null) {
|
|
1873
|
+
const stored = authLoad(config.hubUrl);
|
|
1874
|
+
if (!stored) {
|
|
1875
|
+
console.error("[directed] signed-in token missing after authentication");
|
|
1876
|
+
return 1;
|
|
1877
|
+
}
|
|
1878
|
+
try {
|
|
1879
|
+
const token = await new TokenRefresher(hub, stored).current();
|
|
1880
|
+
destinationChatPublicId = await chatTargetPick(hub, token, config.chatTargetQuery);
|
|
1881
|
+
} catch (err) {
|
|
1882
|
+
console.error(`[directed] could not choose a chat target: ${err.message}`);
|
|
1883
|
+
return 1;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
try {
|
|
1887
|
+
execFileSync(config.tmuxBin, ["-V"], { stdio: "ignore" });
|
|
1888
|
+
} catch {
|
|
1889
|
+
console.error(`directed needs tmux on PATH (or pass --tmux-bin <path>). Install it, e.g. 'brew install tmux'.`);
|
|
1890
|
+
return 1;
|
|
1891
|
+
}
|
|
1892
|
+
const cols = process.stdout.columns || 80;
|
|
1893
|
+
const rows = process.stdout.rows || 24;
|
|
1894
|
+
const conversation = new ConversationLog();
|
|
1895
|
+
const recorder = new Recorder(cols, rows);
|
|
1896
|
+
const adapter = adapterFor(config.command);
|
|
1897
|
+
const tmux = new Tmux(config.tmuxBin, `directed-${randomBytes2(4).toString("hex")}`);
|
|
1898
|
+
const echoGuard = config.toChat ? new EchoGuard() : null;
|
|
1899
|
+
const resumeRef = config.toChat && adapter ? await adapter.resumeRef(config.command, process.cwd()) : null;
|
|
1900
|
+
const hubChat = config.toChat ? await hubChatStart(hub, config, conversation, echoGuard, resumeRef, destinationChatPublicId, recorder) : null;
|
|
1901
|
+
if (config.inviteEmails.length > 0) {
|
|
1902
|
+
if (hubChat) {
|
|
1903
|
+
await inviteDraftOpen(hub, hubChat, config.inviteEmails, identity.fullName || identity.username);
|
|
1904
|
+
} else {
|
|
1905
|
+
process.stderr.write("[directed] --invite needs a hub chat; no invite sent.\n");
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
const mentions = hubChat ? new HubMentions(config.hubUrl, hubChat.tokenRefresher, hubChat.chatId, hubChat.agentActorId, Date.now()) : null;
|
|
1909
|
+
const refStamp = hubChat && adapter && !resumeRef ? (file) => {
|
|
1910
|
+
const ref = adapter.refFromPath(file);
|
|
1911
|
+
if (!ref) return;
|
|
1912
|
+
hubChat.tokenRefresher.current().then((token) => hub.sessionRefSet(token, hubChat.sessionId, ref)).catch((e) => {
|
|
1913
|
+
process.stderr.write(`terminal session ref post failed: ${String(e)}
|
|
1914
|
+
`);
|
|
1915
|
+
});
|
|
1916
|
+
} : null;
|
|
1917
|
+
const session = new Session(config, conversation, recorder, tmux, adapter, mentions, echoGuard, refStamp);
|
|
1918
|
+
const recording = hubChat ? new HubRecordingPublisher(hub, hubChat.tokenRefresher, hubChat.sessionId, recorder) : null;
|
|
1919
|
+
recording?.start();
|
|
1920
|
+
const code = await session.run();
|
|
1921
|
+
if (recording) await recording.stop();
|
|
1922
|
+
if (hubChat) await hubChat.transport.stop();
|
|
1923
|
+
return code;
|
|
1924
|
+
}
|
|
1925
|
+
var argv1 = process.argv[1] ? realpathSync(process.argv[1]) : "";
|
|
1926
|
+
if (import.meta.url === pathToFileURL(argv1).href) {
|
|
1927
|
+
main().then((code) => process.exit(code)).catch((err) => {
|
|
1928
|
+
console.error(err?.message ?? err);
|
|
1929
|
+
process.exit(1);
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
export {
|
|
1933
|
+
commandSplit,
|
|
1934
|
+
helpText,
|
|
1935
|
+
inviteMailto,
|
|
1936
|
+
isHelpRequest,
|
|
1937
|
+
main
|
|
1938
|
+
};
|