@directed/cli 0.1.2 → 0.1.4

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.
Files changed (3) hide show
  1. package/README.md +12 -4
  2. package/dist/cli.js +2891 -1680
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1,1378 +1,1167 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { realpathSync } from "fs";
4
+ import { realpathSync as realpathSync2 } from "fs";
5
+ import { basename as basename4 } from "path";
5
6
  import { pathToFileURL } from "url";
6
- import { randomBytes as randomBytes2 } from "crypto";
7
- import { execFileSync } from "child_process";
8
- import { createRequire } from "module";
7
+ import { randomBytes as randomBytes3 } from "crypto";
8
+ import { execFileSync, spawn as spawn2 } from "child_process";
9
+ import { createRequire as createRequire2 } from "module";
9
10
 
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
- };
11
+ // src/agent.ts
12
+ import { basename as basename3 } from "path";
22
13
 
23
- // src/update-check.ts
24
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
14
+ // src/claude.ts
15
+ import { readFile, readdir, stat, writeFile } from "fs/promises";
25
16
  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() {
17
+ import { basename, dirname, join as join2 } from "path";
18
+
19
+ // src/hook.ts
20
+ import { randomBytes } from "crypto";
21
+ import { mkdtempSync, rmSync } from "fs";
22
+ import { connect, createServer } from "net";
23
+ import { tmpdir } from "os";
24
+ import { join } from "path";
25
+ var HOOK_SOCKET_ENV = "DIRECTED_HOOK_SOCKET";
26
+ var HOOK_NONCE_ENV = "DIRECTED_HOOK_NONCE";
27
+ var STDIN_WAIT_MS = 2e3;
28
+ var TranscriptHook = class _TranscriptHook {
29
+ constructor(server, dir, socketPath, nonce) {
30
+ this.socketPath = socketPath;
31
+ this.nonce = nonce;
32
+ this.server = server;
33
+ this.dir = dir;
34
+ }
35
+ socketPath;
36
+ nonce;
37
+ server;
38
+ dir;
39
+ listeners = [];
40
+ last = null;
41
+ // A 0700 scratch directory an agent may drop launch config into. Removed with
42
+ // the socket when the session ends.
43
+ get dirPath() {
44
+ return this.dir;
45
+ }
46
+ // The socket lives in a 0700 directory: any process able to connect could
47
+ // otherwise name an arbitrary file as this session's transcript.
48
+ static async open() {
49
+ const dir = mkdtempSync(join(tmpdir(), "directed-hook-"));
50
+ const socketPath = join(dir, "hook.sock");
51
+ const nonce = randomBytes(16).toString("hex");
52
+ const server = createServer();
53
+ const hook = new _TranscriptHook(server, dir, socketPath, nonce);
54
+ server.on("connection", (socket) => {
55
+ let body = "";
56
+ socket.setEncoding("utf8");
57
+ socket.on("data", (d) => {
58
+ body += d;
59
+ });
60
+ socket.on("end", () => hook.accept(body));
61
+ socket.on("error", () => socket.destroy());
62
+ });
63
+ await new Promise((resolve, reject) => {
64
+ server.once("error", reject);
65
+ server.listen(socketPath, resolve);
66
+ });
67
+ server.unref();
68
+ return hook;
69
+ }
70
+ // Every announcement, not just the first. Claude fires SessionStart again on
71
+ // resume, /clear and compaction, and /clear starts a new transcript -- without
72
+ // this the reader would stay on the abandoned one for the rest of the session.
73
+ // A late listener gets the most recent one: the socket opens before the agent
74
+ // launches, but the reader it feeds is built after.
75
+ onAnnounce(listener) {
76
+ this.listeners.push(listener);
77
+ if (this.last !== null) listener(this.last);
78
+ }
79
+ close() {
80
+ this.server.close();
81
+ rmSync(this.dir, { recursive: true, force: true });
82
+ }
83
+ accept(body) {
84
+ const announced = announcementParse(body, this.nonce);
85
+ if (announced === null) return;
86
+ this.last = announced;
87
+ for (const listener of this.listeners) listener(announced);
88
+ }
89
+ };
90
+ function announcementParse(body, nonce) {
91
+ let payload;
33
92
  try {
34
- return JSON.parse(readFileSync(statePath(), "utf8"));
93
+ payload = JSON.parse(body);
35
94
  } catch {
36
95
  return null;
37
96
  }
97
+ if (typeof payload !== "object" || payload === null) return null;
98
+ const record = payload;
99
+ if (record.nonce !== nonce) return null;
100
+ const sessionId = record.session_id;
101
+ const transcriptPath = record.transcript_path;
102
+ const cwd = record.cwd;
103
+ if (typeof sessionId !== "string" || !sessionId) return null;
104
+ if (typeof transcriptPath !== "string" || !transcriptPath) return null;
105
+ return {
106
+ sessionId,
107
+ transcriptPath,
108
+ cwd: typeof cwd === "string" ? cwd : "",
109
+ trigger: typeof record.source === "string" ? record.source : "startup"
110
+ };
38
111
  }
39
- function stateSave(state) {
112
+ async function hookRun(env = process.env) {
113
+ const socketPath = env[HOOK_SOCKET_ENV];
114
+ const nonce = env[HOOK_NONCE_ENV];
115
+ if (!socketPath || !nonce) return 0;
116
+ const payload = await stdinRead();
117
+ let record;
40
118
  try {
41
- const path = statePath();
42
- mkdirSync(dirname(path), { recursive: true });
43
- writeFileSync(path, JSON.stringify(state));
119
+ record = JSON.parse(payload);
44
120
  } catch {
121
+ return 0;
45
122
  }
123
+ await socketSend(socketPath, JSON.stringify({ ...record, nonce }));
124
+ return 0;
46
125
  }
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;
126
+ function stdinRead() {
127
+ return new Promise((resolve) => {
128
+ let body = "";
129
+ const timer = setTimeout(() => resolve(body), STDIN_WAIT_MS);
130
+ process.stdin.setEncoding("utf8");
131
+ process.stdin.on("data", (d) => {
132
+ body += d;
133
+ });
134
+ process.stdin.on("end", () => {
135
+ clearTimeout(timer);
136
+ resolve(body);
137
+ });
138
+ process.stdin.on("error", () => {
139
+ clearTimeout(timer);
140
+ resolve(body);
141
+ });
142
+ });
143
+ }
144
+ function socketSend(socketPath, body) {
145
+ return new Promise((resolve) => {
146
+ const socket = connect(socketPath);
147
+ socket.on("connect", () => socket.end(body));
148
+ socket.on("close", () => resolve());
149
+ socket.on("error", () => {
150
+ socket.destroy();
151
+ resolve();
152
+ });
153
+ });
154
+ }
155
+
156
+ // src/claude.ts
157
+ function projectDirEncode(cwd) {
158
+ return cwd.replace(/[/.]/g, "-");
56
159
  }
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) {
160
+ 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;
161
+ var ClaudeAgent = class {
162
+ kind = "claude_code";
163
+ async transcriptLocate(cwd, sinceMs) {
164
+ const newest = await newestTranscript(projectDir(cwd));
165
+ if (!newest || newest.mtimeMs < sinceMs) return null;
166
+ return newest.path;
167
+ }
168
+ // Claude names a subagent in the result of the call that launched it, so the
169
+ // parent's own transcript is the whole discovery mechanism -- the .meta.json
170
+ // beside the child's file says nothing this does not.
171
+ transcriptChildren(line) {
172
+ let record;
62
173
  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
- }
174
+ record = JSON.parse(line);
68
175
  } catch {
176
+ return [];
69
177
  }
178
+ if (!isRecord(record)) return [];
179
+ const result = record.toolUseResult;
180
+ if (!isRecord(result) || typeof result.agentId !== "string" || !result.agentId)
181
+ return [];
182
+ return [{ key: childKey(result.agentId), callKey: toolUseId(record) }];
183
+ }
184
+ // Claude keeps a session's subagents in a directory named for that session,
185
+ // so the path follows from the root transcript and the child's own key.
186
+ async transcriptChildLocate(rootPath, child) {
187
+ const key = this.transcriptKey(rootPath);
188
+ if (key === null) return null;
189
+ const path = join2(
190
+ dirname(rootPath),
191
+ key,
192
+ "subagents",
193
+ `${child.key}.jsonl`
194
+ );
195
+ return await isFile(path) ? path : null;
196
+ }
197
+ // A settings file for this launch only, holding the user's own settings with
198
+ // our SessionStart hook added to them.
199
+ //
200
+ // --settings replaces rather than merges, so writing only our hook silently
201
+ // turned off every SessionStart hook the person had configured -- directed is
202
+ // meant to wrap their agent, not change how it behaves. The file is scoped to
203
+ // the run and removed with the hook socket; their own settings are never
204
+ // written to.
205
+ async hookArgs(hook) {
206
+ const path = join2(hook.dirPath, "claude-settings.json");
207
+ const command = `${HOOK_SOCKET_ENV}=${hook.socketPath} ${HOOK_NONCE_ENV}=${hook.nonce} ` + hookCommand();
208
+ await writeFile(
209
+ path,
210
+ JSON.stringify(settingsMerge(await settingsRead(), command))
211
+ );
212
+ return ["--settings", path];
70
213
  }
71
- if (latest && versionIsNewer(latest, currentVersion)) {
72
- process.stderr.write(`[directed] a new version is available (${currentVersion} -> ${latest}). Run 'directed upgrade' to update.
73
- `);
214
+ transcriptKey(path) {
215
+ const name = basename(path);
216
+ if (!name.endsWith(".jsonl")) return null;
217
+ return name.slice(0, -".jsonl".length);
218
+ }
219
+ // Claude names a transcript after its session, under the project directory
220
+ // for the workspace it ran in.
221
+ async transcriptPath(key) {
222
+ const path = join2(projectDir(process.cwd()), `${key}.jsonl`);
223
+ return await isFile(path) ? path : null;
224
+ }
225
+ // Claude Code appends to the same <sessionId>.jsonl on --resume/--continue,
226
+ // so the resumed session's id is knowable before launch: from the flag's own
227
+ // argument, or from the newest transcript on disk for --continue. A bare
228
+ // --resume opens claude's interactive picker -- the target is unknowable, so
229
+ // it reads as a fresh session. --fork-session branches into a new id.
230
+ async resumeKey(command, cwd) {
231
+ const args = command.slice(1);
232
+ if (args.includes("--fork-session")) return null;
233
+ for (let i = 0; i < args.length; i++) {
234
+ const [flag, inline] = flagSplit(args[i]);
235
+ if (flag === "--resume" || flag === "-r" || flag === "--session-id") {
236
+ const candidate = inline ?? args[i + 1];
237
+ if (candidate !== void 0 && SESSION_ID_RE.test(candidate))
238
+ return candidate;
239
+ } else if (flag === "--continue" || flag === "-c") {
240
+ const newest = await newestTranscript(projectDir(cwd));
241
+ return newest ? this.transcriptKey(newest.path) : null;
242
+ }
243
+ }
244
+ return null;
74
245
  }
246
+ };
247
+ function childKey(agentId) {
248
+ return agentId.startsWith("agent-") ? agentId : `agent-${agentId}`;
75
249
  }
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;
250
+ function toolUseId(record) {
251
+ const message = record.message;
252
+ if (!isRecord(message) || !Array.isArray(message.content)) return "";
253
+ for (const block of message.content) {
254
+ if (isRecord(block) && block.type === "tool_result" && typeof block.tool_use_id === "string") {
255
+ return block.tool_use_id;
256
+ }
90
257
  }
91
- const script = await res.text();
92
- const dir = mkdtempSync(join2(tmpdir(), "directed-upgrade-"));
258
+ return "";
259
+ }
260
+ async function isFile(path) {
93
261
  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 });
262
+ return (await stat(path)).isFile();
263
+ } catch {
264
+ return false;
103
265
  }
104
266
  }
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 };
267
+ function hookCommand() {
268
+ return `${JSON.stringify(process.execPath)} ${JSON.stringify(process.argv[1] ?? "")} __hook`;
110
269
  }
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;
270
+ function projectDir(cwd) {
271
+ return join2(homedir(), ".claude/projects", projectDirEncode(cwd));
272
+ }
273
+ function flagSplit(arg) {
274
+ const at = arg.indexOf("=");
275
+ if (at === -1) return [arg, void 0];
276
+ return [arg.slice(0, at), arg.slice(at + 1)];
277
+ }
278
+ async function newestTranscript(dir) {
279
+ let entries;
280
+ try {
281
+ entries = await readdir(dir);
282
+ } catch {
283
+ return null;
125
284
  }
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;
285
+ let newest = null;
286
+ for (const entry of entries) {
287
+ if (!entry.endsWith(".jsonl")) continue;
288
+ const path = join2(dir, entry);
289
+ const info = await stat(path);
290
+ if (!newest || info.mtimeMs > newest.mtimeMs)
291
+ newest = { path, mtimeMs: info.mtimeMs };
191
292
  }
192
- async stop() {
193
- this.off?.();
194
- this.off = null;
195
- await this.chain;
196
- if (this.sid === null) return;
293
+ return newest;
294
+ }
295
+ var SETTINGS_PATH_LIST = [
296
+ ".claude/settings.json",
297
+ ".claude/settings.local.json"
298
+ ];
299
+ async function settingsRead() {
300
+ const merged = {};
301
+ const pathList = [
302
+ join2(homedir(), ".claude", "settings.json"),
303
+ ...SETTINGS_PATH_LIST.map((relative) => join2(process.cwd(), relative))
304
+ ];
305
+ for (const path of pathList) {
197
306
  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;
307
+ const parsed = JSON.parse(await readFile(path, "utf8"));
308
+ if (typeof parsed === "object" && parsed !== null)
309
+ Object.assign(merged, parsed);
310
+ } catch {
209
311
  }
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
312
  }
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";
313
+ return merged;
314
+ }
315
+ function settingsMerge(settings, command) {
316
+ const hooks = isRecord(settings.hooks) ? { ...settings.hooks } : {};
317
+ const sessionStart = Array.isArray(hooks.SessionStart) ? [...hooks.SessionStart] : [];
318
+ sessionStart.push({ hooks: [{ type: "command", command }] });
319
+ return { ...settings, hooks: { ...hooks, SessionStart: sessionStart } };
320
+ }
321
+ function isRecord(value) {
322
+ return typeof value === "object" && value !== null && !Array.isArray(value);
323
+ }
240
324
 
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;
325
+ // src/codex.ts
326
+ import { open, readdir as readdir2, stat as stat2 } from "fs/promises";
327
+ import { homedir as homedir2 } from "os";
328
+ import { basename as basename2, join as join3 } from "path";
329
+ 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;
330
+ 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;
331
+ var SESSION_META_BYTES = 1024 * 1024;
332
+ var CodexAgent = class {
333
+ kind = "codex";
334
+ isAmbiguityReported = false;
335
+ // Ownership comes from the rollout's own session_meta, not from the process
336
+ // tree: the npm `codex` is a Node shim that spawns a native binary, and only
337
+ // the child holds the rollout open, so inspecting the pane PID found nothing.
338
+ // A rollout written since launch, in our cwd, with source "cli" is ours.
339
+ //
340
+ // Two codex sessions in one workspace produce two eligible rollouts, and
341
+ // nothing in either file says which pane wrote it. Taking the newest would
342
+ // sometimes stream a colleague's session into this chat, so ambiguity gives
343
+ // up instead: capturing nothing is recoverable, capturing the wrong session
344
+ // is not.
345
+ async transcriptLocate(cwd, sinceMs) {
346
+ const key = await lockKey(sinceMs);
347
+ if (key !== null) return rolloutFind(key);
348
+ const candidates = await rolloutList(cwd, sinceMs, 2);
349
+ if (candidates.length > 1) {
350
+ if (!this.isAmbiguityReported) {
351
+ this.isAmbiguityReported = true;
352
+ process.stderr.write(
353
+ "[directed] more than one codex session is running in this workspace; directed cannot tell which transcript is this one, so it is not capturing the conversation.\n"
354
+ );
355
+ }
356
+ return null;
357
+ }
358
+ return candidates[0] ?? null;
252
359
  }
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);
360
+ // Codex has a hook surface, but it will not run a hook it does not trust, and
361
+ // the only blanket override also un-trusts every other hook on the machine.
362
+ // Until directed has an install flow the user approves once, ownership here
363
+ // stays with locate().
364
+ async hookArgs() {
365
+ return null;
268
366
  }
269
- stop() {
270
- if (this.timer) {
271
- clearInterval(this.timer);
272
- this.timer = null;
273
- }
367
+ transcriptKey(path) {
368
+ const match = ROLLOUT_RE.exec(basename2(path));
369
+ return match ? match[1] : null;
274
370
  }
275
- async tick() {
276
- if (this.ticking) return;
277
- this.ticking = true;
371
+ // Codex names a subagent's own thread the moment it starts one, so the
372
+ // parent's transcript is the only thing consulted. Two builds say it two ways:
373
+ // as an event of its own, or as a spawn item naming every thread that one call
374
+ // started. Both give exact ids, so nothing here scans or guesses. A thread the
375
+ // caller already knows is ignored, which is what makes repeats harmless.
376
+ transcriptChildren(line) {
377
+ let record;
278
378
  try {
279
- if (this.file === null) {
280
- await this.locate();
281
- } else {
282
- await this.tail();
283
- }
284
- } finally {
285
- this.ticking = false;
379
+ record = JSON.parse(line);
380
+ } catch {
381
+ return [];
286
382
  }
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();
383
+ if (!isRecord2(record)) return [];
384
+ const payload = record.payload;
385
+ if (!isRecord2(payload)) return [];
386
+ if (payload.type === "sub_agent_activity") {
387
+ const key = payload.agent_thread_id;
388
+ if (typeof key !== "string" || !SESSION_ID_RE2.test(key)) return [];
389
+ return [
390
+ {
391
+ key,
392
+ callKey: typeof payload.event_id === "string" ? payload.event_id : ""
393
+ }
394
+ ];
314
395
  }
396
+ if (payload.type === "item_completed") return spawned(payload.item);
397
+ return [];
315
398
  }
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);
399
+ // A rollout's filename ends in its own thread id, so the child is found by
400
+ // matching that exactly. Filenames only: opening candidates to see whose they
401
+ // are is how a colleague's session ends up in this chat, and a near match is
402
+ // not this agent's file.
403
+ async transcriptChildLocate(_rootPath, child) {
404
+ return rolloutFind(child.key);
405
+ }
406
+ transcriptPath(key) {
407
+ return rolloutFind(key);
408
+ }
409
+ // `codex resume` reopens an existing rollout, whose filename keeps the
410
+ // original session uuid: the flag's own argument names it, and --last means
411
+ // the newest primary rollout in scope. A bare `codex resume` opens the
412
+ // interactive picker -- the target is unknowable, so it reads as a fresh session.
413
+ async resumeKey(command, cwd) {
414
+ const args = command.slice(1);
415
+ const subcommand = args.find((a) => !a.startsWith("-"));
416
+ if (subcommand !== "resume") return null;
417
+ const rest = args.slice(args.indexOf("resume") + 1);
418
+ const id = rest.find((a) => SESSION_ID_RE2.test(a));
419
+ if (id) return id;
420
+ if (rest.includes("--last")) {
421
+ const newest = await rolloutNewest(rest.includes("--all") ? null : cwd);
422
+ return newest ? this.transcriptKey(newest) : null;
331
423
  }
424
+ return null;
332
425
  }
333
426
  };
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);
427
+ function spawned(item) {
428
+ if (!isRecord2(item)) return [];
429
+ if (item.type !== "CollabAgentToolCall" || item.tool !== "spawn_agent")
430
+ return [];
431
+ const receivers = item.receiver_thread_ids;
432
+ if (!Array.isArray(receivers)) return [];
433
+ const callKey = typeof item.id === "string" ? item.id : "";
434
+ const found = [];
435
+ for (const key of receivers) {
436
+ if (typeof key !== "string" || !SESSION_ID_RE2.test(key)) continue;
437
+ found.push({ key, callKey });
438
+ }
439
+ return found;
440
+ }
441
+ async function lockKey(sinceMs) {
442
+ const dir = join3(homedir2(), ".codex", "thread-writer-locks");
443
+ let entries;
444
+ try {
445
+ entries = await readdir2(dir);
446
+ } catch {
447
+ return null;
447
448
  }
448
- async steer(text, who) {
449
- await this.tmux.sendText(text, this.config.acceptMode === "send");
450
- this.recorder.marker(`${who}: ${text}`);
449
+ const found = [];
450
+ for (const entry of entries) {
451
+ if (!entry.endsWith(".lock")) continue;
452
+ const key = entry.slice(0, -".lock".length);
453
+ if (!SESSION_ID_RE2.test(key)) continue;
454
+ if (!await isWrittenSince(join3(dir, entry), sinceMs)) continue;
455
+ found.push(key);
451
456
  }
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
- });
457
+ return found.length === 1 ? found[0] : null;
458
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()}`)));
459
+ async function rolloutFind(key) {
460
+ const dir = join3(homedir2(), ".codex", "sessions");
461
+ const suffix = `-${key}.jsonl`;
462
+ let entries;
463
+ try {
464
+ entries = await readdir2(dir, { recursive: true });
465
+ } catch {
466
+ return null;
467
+ }
468
+ const found = entries.find((entry) => {
469
+ const name = basename2(entry);
470
+ return name.startsWith("rollout-") && name.endsWith(suffix);
470
471
  });
472
+ return found === void 0 ? null : join3(dir, found);
471
473
  }
472
- var Tmux = class {
473
- constructor(bin, session, runner) {
474
- this.bin = bin;
475
- this.session = session;
476
- this.runner = runner ?? defaultRunner(bin);
474
+ async function rolloutNewest(cwd, sinceMs = 0) {
475
+ const found = await rolloutList(cwd, sinceMs, 1);
476
+ return found[0] ?? null;
477
+ }
478
+ async function rolloutList(cwd, sinceMs, limit) {
479
+ const dir = join3(homedir2(), ".codex", "sessions");
480
+ let entries;
481
+ try {
482
+ entries = await readdir2(dir, { recursive: true });
483
+ } catch {
484
+ return [];
477
485
  }
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"]);
486
+ const rollouts = entries.filter((f) => {
487
+ const b = basename2(f);
488
+ return b.startsWith("rollout-") && b.endsWith(".jsonl");
489
+ });
490
+ rollouts.sort();
491
+ rollouts.reverse();
492
+ const found = [];
493
+ for (const rollout of rollouts) {
494
+ const path = join3(dir, rollout);
495
+ if (sinceMs > 0 && !await isWrittenSince(path, sinceMs)) continue;
496
+ if (!await isPrimaryRollout(path, cwd)) continue;
497
+ found.push(path);
498
+ if (found.length >= limit) break;
503
499
  }
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 };
500
+ return found;
501
+ }
502
+ async function isWrittenSince(path, sinceMs) {
503
+ try {
504
+ return (await stat2(path)).mtimeMs >= sinceMs;
505
+ } catch {
506
+ return false;
508
507
  }
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;
508
+ }
509
+ async function isPrimaryRollout(path, cwd = null) {
510
+ const line = await firstLine(path);
511
+ if (line === null) return false;
512
+ let record;
513
+ try {
514
+ record = JSON.parse(line);
515
+ } catch {
516
+ return false;
516
517
  }
517
- pipePane(fifo) {
518
- return this.runner(["pipe-pane", "-o", "-t", this.session, `cat >> ${fifo}`]).then(() => {
519
- });
518
+ if (!isRecord2(record) || record.type !== "session_meta" || !isRecord2(record.payload))
519
+ return false;
520
+ const pathMatch = ROLLOUT_RE.exec(basename2(path));
521
+ if (record.payload.source !== "cli" || record.payload.id !== pathMatch?.[1])
522
+ return false;
523
+ return cwd === null || record.payload.cwd === cwd;
524
+ }
525
+ async function firstLine(path) {
526
+ let handle;
527
+ try {
528
+ handle = await open(path, "r");
529
+ const buffer = Buffer.alloc(SESSION_META_BYTES);
530
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
531
+ const lineEnd = buffer.indexOf("\n", 0);
532
+ if (bytesRead === 0 || lineEnd === -1 || lineEnd >= bytesRead) return null;
533
+ return buffer.subarray(0, lineEnd).toString("utf8");
534
+ } catch {
535
+ return null;
536
+ } finally {
537
+ await handle?.close();
520
538
  }
521
- async capturePane() {
522
- return Buffer.from(await this.runner(["capture-pane", "-p", "-e", "-t", this.session]), "utf8");
539
+ }
540
+ function isRecord2(value) {
541
+ return typeof value === "object" && value !== null;
542
+ }
543
+
544
+ // src/agent.ts
545
+ function agentResolve(command) {
546
+ const program = command[0];
547
+ if (program === void 0) return null;
548
+ const name = basename3(program);
549
+ if (name === "claude") return new ClaudeAgent();
550
+ if (name === "codex") return new CodexAgent();
551
+ return null;
552
+ }
553
+
554
+ // src/options.ts
555
+ function helpText() {
556
+ return `directed - run an interactive coding agent in a Directed chat
557
+
558
+ Usage:
559
+ directed [options] <command> [args...]
560
+ directed login
561
+ directed logout
562
+ directed whoami
563
+ directed upgrade
564
+
565
+ Options:
566
+ --steer-mode <send|stage> Submit chat steers immediately or stage them
567
+ --chat Stream the session to a Directed chat
568
+ --no-chat Do not stream the session to a Directed chat
569
+ --into[=<URL or public id>] Choose a recent chat or give an exact target
570
+ --with <people> Add @usernames or emails to the session chat
571
+ --invite <emails> Open an email draft with the chat join link
572
+ --open Open the session chat in a browser
573
+ --no-open Do not open the session chat in a browser
574
+ --name <label> Title the session chat
575
+ --tmux-bin <path> Use a specific tmux executable
576
+ -h, --help Show this help
577
+
578
+ Everything after <command> is passed to that command unchanged.
579
+
580
+ Examples:
581
+ directed codex
582
+ directed --with @jane claude
583
+ directed codex --help`;
584
+ }
585
+ function isHelpRequest(argv) {
586
+ return argv[0] === "--help" || argv[0] === "-h";
587
+ }
588
+ function optionsParse(argv) {
589
+ let steerMode = "send";
590
+ let title = "";
591
+ let tmuxBin = "tmux";
592
+ let chatOpen = true;
593
+ let chatMembers = [];
594
+ let chatInviteEmails = [];
595
+ let chatTarget = { kind: "new" };
596
+ let chatFlag;
597
+ let i = 0;
598
+ while (i < argv.length) {
599
+ const a = argv[i];
600
+ if (a === "--steer-mode") {
601
+ steerMode = steerModeParse(argv[i + 1]);
602
+ i += 2;
603
+ } else if (a === "--name") {
604
+ title = argv[i + 1] ?? "";
605
+ i += 2;
606
+ } else if (a === "--tmux-bin") {
607
+ tmuxBin = argv[i + 1] ?? tmuxBin;
608
+ i += 2;
609
+ } else if (a === "--chat") {
610
+ chatFlag = true;
611
+ i += 1;
612
+ } else if (a === "--no-chat") {
613
+ chatFlag = false;
614
+ i += 1;
615
+ } else if (a === "--into") {
616
+ chatTarget = { kind: "pick" };
617
+ i += 1;
618
+ } else if (a.startsWith("--into=")) {
619
+ chatTarget = { kind: "exact", query: a.slice("--into=".length) };
620
+ i += 1;
621
+ } else if (a === "--open") {
622
+ chatOpen = true;
623
+ i += 1;
624
+ } else if (a === "--no-open") {
625
+ chatOpen = false;
626
+ i += 1;
627
+ } else if (a === "--with") {
628
+ chatMembers = commaList(argv[i + 1]);
629
+ i += 2;
630
+ } else if (a === "--invite") {
631
+ chatInviteEmails = commaList(argv[i + 1]);
632
+ for (const email of chatInviteEmails) {
633
+ if (!email.includes("@") || email.startsWith("@")) {
634
+ throw new Error(`--invite takes emails only, got '${email}' (use --with for @usernames)`);
635
+ }
636
+ }
637
+ i += 2;
638
+ } else if (a.startsWith("-")) {
639
+ throw new Error(`unknown directed option '${a}'; run 'directed --help'`);
640
+ } else {
641
+ break;
642
+ }
523
643
  }
524
- sendText(text, submit) {
525
- const write = this.inputWrite.then(() => this.textWrite(text, submit));
526
- this.inputWrite = write.catch(() => {
527
- });
528
- return write;
644
+ const command = argv.slice(i);
645
+ if (command.length === 0) throw new Error("usage: directed [flags] <command> [args...]");
646
+ return {
647
+ command,
648
+ steerMode,
649
+ title,
650
+ tmuxBin,
651
+ chatEnabled: chatFlag ?? agentResolve(command) !== null,
652
+ chatTarget,
653
+ chatOpen,
654
+ chatMembers,
655
+ chatInviteEmails
656
+ };
657
+ }
658
+ function steerModeParse(value) {
659
+ if (value !== "send" && value !== "stage") {
660
+ throw new Error(`--steer-mode must be 'send' or 'stage', got '${value}'`);
529
661
  }
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"]);
662
+ return value;
663
+ }
664
+ function commaList(value) {
665
+ return (value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
666
+ }
667
+
668
+ // src/hub.ts
669
+ import { createRequire } from "module";
670
+ import { gzip } from "zlib";
671
+ import { promisify } from "util";
672
+ var gzipAsync = promisify(gzip);
673
+ if (false)
674
+ throw new Error("$HUB_URL is required, e.g. HUB_URL=https://hub.directed.ai");
675
+ var HUB_URL = "https://hub.directed.ai";
676
+ var require2 = createRequire(import.meta.url);
677
+ var CLI_VERSION = require2("../package.json").version;
678
+ var TIMEOUT_MS = 15e3;
679
+ var UPLOAD_TIMEOUT_MS = 6e4;
680
+ var HubError = class extends Error {
681
+ constructor(message, status) {
682
+ super(message);
683
+ this.status = status;
684
+ this.name = "HubError";
685
+ }
686
+ status;
687
+ // A 4xx will fail the same way forever, so the caller should stop retrying and
688
+ // move on. 401 is handled by a refresh before it ever reaches here; 408 and
689
+ // 429 are the timing exceptions and stay retryable.
690
+ get isPermanent() {
691
+ return this.status >= 400 && this.status < 500 && this.status !== 408 && this.status !== 429;
536
692
  }
537
- async hasSession() {
538
- return this.runner(["has-session", "-t", this.session]).then(() => true).catch(() => false);
693
+ };
694
+ var AuthenticationRequiredError = class extends HubError {
695
+ constructor(detail) {
696
+ super(detail, 401);
697
+ this.name = "AuthenticationRequiredError";
539
698
  }
540
- attach() {
541
- return spawn2(this.bin, ["attach", "-t", this.session], { stdio: "inherit" });
699
+ };
700
+ var TerminalHeldError = class extends HubError {
701
+ constructor(chatUrl) {
702
+ super("this conversation is already connected", 409);
703
+ this.chatUrl = chatUrl;
704
+ this.name = "TerminalHeldError";
542
705
  }
543
- kill() {
544
- return this.runner(["kill-session", "-t", this.session]).then(() => {
545
- }).catch(() => {
546
- });
706
+ chatUrl;
707
+ };
708
+ var UpgradeRequiredError = class extends HubError {
709
+ constructor(detail, minimumVersion) {
710
+ super(detail, 426);
711
+ this.minimumVersion = minimumVersion;
712
+ this.name = "UpgradeRequiredError";
547
713
  }
714
+ minimumVersion;
548
715
  };
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
- }
716
+ var Hub = class {
717
+ constructor(url = HUB_URL, fetchImpl = fetch) {
718
+ this.url = url;
719
+ this.fetchImpl = fetchImpl;
563
720
  }
564
- snapshot() {
565
- return [...this.turns];
721
+ url;
722
+ fetchImpl;
723
+ auth = null;
724
+ // Once a session is signed in, every authenticated call gets its token from
725
+ // here -- and a 401 refreshes and retries once instead of being reported as a
726
+ // permanent rejection that silently drops the record.
727
+ authAttach(auth) {
728
+ this.auth = auth;
729
+ }
730
+ socketUrl(path) {
731
+ return `${this.url.replace(/^http/, "ws")}${path}`;
732
+ }
733
+ authUrl(redirectUri, challenge) {
734
+ const params = new URLSearchParams({
735
+ redirect_uri: redirectUri,
736
+ code_challenge: challenge,
737
+ code_challenge_method: "S256"
738
+ });
739
+ return `${this.url}/users/social/auth/mobile/start/?${params.toString()}`;
566
740
  }
567
- subscribe(cb) {
568
- this.subs.add(cb);
569
- return () => this.subs.delete(cb);
741
+ async authExchange(code, verifier) {
742
+ const body = await this.json("/api/auth/mobile/exchange/", {
743
+ method: "POST",
744
+ json: { code, code_verifier: verifier },
745
+ token: null
746
+ });
747
+ return tokenPairFrom(body);
570
748
  }
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() {
749
+ async authRefresh(refreshToken) {
750
+ const body = await this.json("/api/auth/mobile/refresh/", {
751
+ method: "POST",
752
+ json: { refresh_token: refreshToken },
753
+ token: null
754
+ });
755
+ return tokenPairFrom(body);
756
+ }
757
+ async identityFind(token) {
758
+ const body = await this.json("/api/auth/me/", { token });
591
759
  return {
592
- version: 2,
593
- width: this.width,
594
- height: this.height,
595
- timestamp: Math.floor(this.startMs / 1e3)
760
+ publicId: body.public_id,
761
+ email: body.email,
762
+ username: body.username,
763
+ fullName: body.full_name
596
764
  };
597
765
  }
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}`]);
766
+ async chatList(query) {
767
+ const body = await this.json("/api/chats/lookup/", { query: { q: query, limit: "10" } });
768
+ return body.items.map((item) => ({
769
+ publicId: item.public_id,
770
+ title: item.display_title,
771
+ url: item.chat_url
772
+ }));
610
773
  }
611
- elapsed() {
612
- return (Date.now() - this.startMs) / 1e3;
774
+ // Mint (or reuse) the chat's shareable join link. Redeeming it seats the
775
+ // visitor as a chat member, so it works for people outside the host's
776
+ // connections -- the email-invite path.
777
+ async chatLinkCreate(chatPublicId2) {
778
+ const body = await this.json(
779
+ `/api/chats/${chatPublicId2}/link/`,
780
+ {
781
+ method: "POST",
782
+ json: {}
783
+ }
784
+ );
785
+ return body.url;
613
786
  }
614
- append(ev) {
615
- for (const sub of this.subs) {
616
- try {
617
- sub(ev);
618
- } catch {
619
- this.subs.delete(sub);
787
+ async terminalCreate(input2) {
788
+ const response = await this.request("/api/terminal-sessions/", {
789
+ method: "POST",
790
+ expect: [409, 426],
791
+ json: {
792
+ source: input2.source,
793
+ title: input2.title ?? "",
794
+ resume_key: input2.resumeKey ?? "",
795
+ destination_chat_public_id: input2.chatPublicId ?? "",
796
+ member_identifiers: input2.memberList ?? [],
797
+ git_start: gitSnapshotWire(input2.gitStart),
798
+ client_version: CLI_VERSION
620
799
  }
800
+ });
801
+ if (response.status === 409) {
802
+ const held = await response.json();
803
+ throw new TerminalHeldError(held.chat_url);
621
804
  }
805
+ if (response.status === 426) {
806
+ const stale = await response.json();
807
+ throw new UpgradeRequiredError(stale.detail, stale.minimum_version);
808
+ }
809
+ const body = await response.json();
810
+ return {
811
+ chatPublicId: body.chat_public_id,
812
+ terminalSessionPublicId: body.terminal_session_public_id,
813
+ chatUrl: body.chat_url,
814
+ agentActorPublicId: body.agent_actor_public_id,
815
+ reattached: body.reattached === true,
816
+ skippedMemberList: body.skipped_member_identifiers ?? [],
817
+ resumeSeq: body.resume_seq ?? 0
818
+ };
622
819
  }
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;
820
+ async terminalEnd(sessionId, state) {
821
+ await this.request(`/api/terminal-sessions/${sessionId}/end/`, {
822
+ method: "POST",
823
+ json: {
824
+ transcript_lost: state.transcriptLost,
825
+ detached: state.isDetached,
826
+ git_end: gitSnapshotWire(state.gitEnd)
827
+ }
828
+ });
634
829
  }
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));
830
+ // Reports that this CLI is still attached, and hears whether ownership ended.
831
+ // Control health proves itself independently over the control socket.
832
+ async terminalHeartbeat(sessionId) {
833
+ const response = await this.request(
834
+ `/api/terminal-sessions/${sessionId}/heartbeat/`,
835
+ { method: "POST", expect: [409], json: {} }
836
+ );
837
+ return { isEnded: response.status === 409 };
838
+ }
839
+ // A contiguous slice of one agent's transcript file, sent as the file's own
840
+ // lines. Compressed: transcripts are JSON text and shrink about 5x. A
841
+ // subagent's slice names the file that launched it, so lineage arrives with
842
+ // the data it describes.
843
+ async transcriptAppend(sessionId, batch) {
844
+ await this.request(`/api/terminal-sessions/${sessionId}/transcript/`, {
845
+ method: "POST",
846
+ query: {
847
+ file_key: batch.fileKey,
848
+ parent_key: batch.parentKey,
849
+ call_key: batch.callKey,
850
+ start_seq: String(batch.startSeq),
851
+ start_offset: String(batch.startOffset)
852
+ },
853
+ raw: {
854
+ body: await gzipAsync(Buffer.from(batch.body, "utf8")),
855
+ contentType: "application/x-ndjson"
856
+ },
857
+ timeoutMs: UPLOAD_TIMEOUT_MS
858
+ });
656
859
  }
657
- async stop() {
658
- this.off?.();
659
- this.off = null;
660
- this.flush();
661
- await this.chain;
860
+ // What the pane did with a steer, so the chat can show delivery rather than
861
+ // leaving the sender to guess.
862
+ async steerAck(sessionId, eventId, outcome) {
863
+ await this.request(
864
+ `/api/terminal-sessions/${sessionId}/steers/${eventId}/ack/`,
865
+ {
866
+ method: "POST",
867
+ json: { outcome }
868
+ }
869
+ );
662
870
  }
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;
871
+ async json(path, spec = {}) {
872
+ const response = await this.request(path, spec);
873
+ return await response.json();
874
+ }
875
+ // One timeout, one auth policy, one error shape. `token: null` means the call
876
+ // is unauthenticated; a string is an explicit token for the login path; absent
877
+ // means the attached session's token, refreshed once if the hub says it is bad.
878
+ async request(path, spec) {
879
+ let token;
880
+ try {
881
+ token = await this.tokenFor(spec);
882
+ } catch (error) {
883
+ if (spec.token === void 0 && this.auth !== null) {
884
+ authenticationRequired(error);
885
+ }
886
+ throw error;
669
887
  }
670
- if (!this.timer) {
671
- this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS);
888
+ const response = await this.send(path, spec, token);
889
+ if (response.status !== 401 || spec.token !== void 0 || this.auth === null) {
890
+ return this.checked(path, response, spec.expect);
891
+ }
892
+ let refreshedToken;
893
+ try {
894
+ refreshedToken = await this.auth.refresh();
895
+ } catch (error) {
896
+ authenticationRequired(error);
897
+ }
898
+ const retried = await this.send(path, spec, refreshedToken);
899
+ try {
900
+ return await this.checked(path, retried, spec.expect);
901
+ } catch (error) {
902
+ authenticationRequired(error);
672
903
  }
673
904
  }
674
- flush() {
675
- if (this.timer) {
676
- clearTimeout(this.timer);
677
- this.timer = null;
905
+ async tokenFor(spec) {
906
+ if (spec.token !== void 0) return spec.token;
907
+ if (this.auth === null)
908
+ throw new Error("hub call needs a signed-in session");
909
+ return this.auth.token();
910
+ }
911
+ async send(path, spec, token) {
912
+ const query = spec.query ? `?${new URLSearchParams(spec.query).toString()}` : "";
913
+ const headers = {};
914
+ if (token) headers.Authorization = `Bearer ${token}`;
915
+ let body;
916
+ if (spec.raw) {
917
+ headers["Content-Type"] = spec.raw.contentType;
918
+ headers["Content-Encoding"] = "gzip";
919
+ body = spec.raw.body;
920
+ } else if (spec.json !== void 0) {
921
+ headers["Content-Type"] = "application/json";
922
+ body = JSON.stringify(spec.json);
678
923
  }
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
- });
924
+ return this.fetchImpl(`${this.url}${path}${query}`, {
925
+ method: spec.method ?? "GET",
926
+ headers,
927
+ body,
928
+ signal: AbortSignal.timeout(spec.timeoutMs ?? TIMEOUT_MS)
706
929
  });
707
930
  }
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;
931
+ async checked(path, response, expect) {
932
+ if (response.ok || expect?.includes(response.status)) return response;
933
+ let detail = "";
835
934
  try {
836
- env = JSON.parse(raw);
935
+ const body = await response.clone().json();
936
+ if (typeof body.detail === "string") detail = ` (${body.detail})`;
837
937
  } catch {
838
- return;
938
+ detail = "";
839
939
  }
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
- });
940
+ throw new HubError(
941
+ `${path} failed: ${response.status}${detail}`,
942
+ response.status
943
+ );
855
944
  }
856
945
  };
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;
946
+ function authenticationRequired(error) {
947
+ if (error instanceof HubError && error.status === 401) {
948
+ throw new AuthenticationRequiredError(error.message);
880
949
  }
881
- };
950
+ throw error;
951
+ }
952
+ function gitSnapshotWire(snapshot) {
953
+ if (!snapshot) return null;
954
+ return {
955
+ sha: snapshot.sha,
956
+ branch: snapshot.branch,
957
+ root: snapshot.root,
958
+ dirty_paths: snapshot.dirtyPaths
959
+ };
960
+ }
961
+ function tokenPairFrom(body) {
962
+ return {
963
+ access: body.access_token,
964
+ refresh: body.refresh_token,
965
+ expiresAt: Date.now() + body.expires_in * 1e3
966
+ };
967
+ }
882
968
 
883
- // src/transcript/adapter.ts
884
- import { basename as basename3 } from "path";
969
+ // src/auth.ts
970
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
971
+ import { existsSync, mkdirSync, readFileSync, rmSync as rmSync2, writeFileSync } from "fs";
972
+ import http from "http";
973
+ import { homedir as homedir3 } from "os";
974
+ import { dirname as dirname2, join as join4 } from "path";
885
975
 
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 };
976
+ // src/prompt.ts
977
+ import { stdin, stderr } from "process";
978
+ var PROMPT_TERMINAL = { input: stdin, output: stderr };
979
+ var PromptCancelledError = class extends Error {
980
+ constructor() {
981
+ super("cancelled");
982
+ this.name = "PromptCancelledError";
947
983
  }
948
984
  };
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)];
985
+ function promptIsInteractive(terminal = PROMPT_TERMINAL) {
986
+ return terminal.input.isTTY === true && terminal.output.isTTY === true && typeof terminal.input.setRawMode === "function";
956
987
  }
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;
988
+ function promptAction(action, hasColors) {
989
+ const line = `> ${action}`;
990
+ return hasColors ? `\x1B[1;36m${line}\x1B[0m` : line;
972
991
  }
992
+ function promptConfirm(title, action, terminal = PROMPT_TERMINAL) {
993
+ if (!promptIsInteractive(terminal)) {
994
+ throw new Error("an interactive terminal is required to continue");
995
+ }
996
+ const input2 = terminal.input;
997
+ const wasRaw = input2.isRaw === true;
998
+ terminal.output.write(
999
+ `${title}
973
1000
 
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;
1001
+ ${promptAction(action, terminal.output.hasColors?.() === true)}
1002
+ `
1003
+ );
1004
+ return new Promise((resolve, reject) => {
1005
+ function finish(run) {
1006
+ input2.removeListener("data", keyRead);
1007
+ input2.setRawMode?.(wasRaw);
1008
+ input2.pause();
1009
+ run();
1013
1010
  }
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
- }
1011
+ function keyRead(chunk) {
1012
+ const text = chunk.toString();
1013
+ if (text.includes("")) {
1014
+ finish(() => reject(new PromptCancelledError()));
1015
+ } else if (text.includes("\r") || text.includes("\n")) {
1016
+ finish(resolve);
1030
1017
  }
1031
- if (parts.length === 0) return null;
1032
- return { id, role, ts, parts };
1033
1018
  }
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
- );
1019
+ input2.setRawMode?.(true);
1020
+ input2.resume();
1021
+ input2.on("data", keyRead);
1085
1022
  });
1086
1023
  }
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;
1024
+
1025
+ // src/auth.ts
1026
+ var EXPIRY_SKEW_MS = 6e4;
1027
+ var LOGIN_TIMEOUT_MS = 3e5;
1028
+ function authPath(hubUrl) {
1029
+ const home = process.env.DIRECTED_HOME ?? homedir3();
1030
+ const url = new URL(hubUrl);
1031
+ const key = url.port ? `${url.hostname}-${url.port}` : url.hostname;
1032
+ return join4(home, ".directed", "auth", `${key}.json`);
1123
1033
  }
1124
- async function firstLine(path) {
1125
- let handle;
1034
+ function authLoad(hubUrl) {
1126
1035
  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");
1036
+ const stored = JSON.parse(readFileSync(authPath(hubUrl), "utf8"));
1037
+ if (stored.hubUrl !== hubUrl) return null;
1038
+ return stored;
1133
1039
  } catch {
1134
1040
  return null;
1135
- } finally {
1136
- await handle?.close();
1137
1041
  }
1138
1042
  }
1139
- function isRecord(value) {
1140
- return typeof value === "object" && value !== null;
1043
+ function authSave(state) {
1044
+ const path = authPath(state.hubUrl);
1045
+ mkdirSync(dirname2(path), { recursive: true });
1046
+ writeFileSync(path, JSON.stringify(state, null, 2), { mode: 384 });
1141
1047
  }
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;
1048
+ function authClear(hubUrl) {
1049
+ const path = authPath(hubUrl);
1050
+ if (existsSync(path)) rmSync2(path);
1151
1051
  }
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;
1052
+ async function authPrompt(hub, open3, terminal) {
1053
+ await promptConfirm(
1054
+ `[directed] Sign in to ${hub.url} to share this session.`,
1055
+ "Press Enter to sign in and continue. Ctrl-C to exit.",
1056
+ terminal
1057
+ );
1058
+ return authLogin(hub, open3);
1159
1059
  }
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}`;
1060
+ var AuthSession = class {
1061
+ constructor(hub, state) {
1062
+ this.hub = hub;
1063
+ this.state = state;
1175
1064
  }
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);
1065
+ hub;
1066
+ state;
1067
+ refreshing = null;
1068
+ get identity() {
1069
+ return this.state.identity;
1182
1070
  }
1183
- async refresh(refreshToken) {
1184
- const body = await this.postJson("/api/auth/mobile/refresh/", {
1185
- refresh_token: refreshToken
1186
- });
1187
- return tokenPairFrom(body);
1071
+ async token() {
1072
+ if (this.state.expiresAt - EXPIRY_SKEW_MS > Date.now()) return this.state.access;
1073
+ return this.refresh();
1188
1074
  }
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}`);
1075
+ // Refreshes whatever the local clock believes, deduped against a concurrent
1076
+ // call. For when the server has already said the token is bad -- a 401, or a
1077
+ // socket close code 4002.
1078
+ async refresh() {
1079
+ if (!this.refreshing) this.refreshing = this.refreshOnce();
1080
+ try {
1081
+ return await this.refreshing;
1082
+ } finally {
1083
+ this.refreshing = null;
1195
1084
  }
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
1085
+ }
1086
+ async refreshOnce() {
1087
+ const pair = await this.hub.authRefresh(this.state.refresh);
1088
+ this.state = {
1089
+ ...this.state,
1090
+ access: pair.access,
1091
+ refresh: pair.refresh,
1092
+ expiresAt: pair.expiresAt
1202
1093
  };
1094
+ authSave(this.state);
1095
+ return this.state.access;
1203
1096
  }
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
- })
1097
+ };
1098
+ function authLogin(hub, open3) {
1099
+ const { verifier, challenge } = pkce();
1100
+ return new Promise((resolve, reject) => {
1101
+ let settled = false;
1102
+ const server = http.createServer((req, res) => {
1103
+ const code = codeFromRequestUrl(req.url);
1104
+ if (!code) {
1105
+ res.writeHead(400, { "Content-Type": "text/plain" });
1106
+ res.end("Missing code");
1107
+ return;
1108
+ }
1109
+ res.writeHead(200, { "Content-Type": "text/html" });
1110
+ res.end(SUCCESS_BODY);
1111
+ onCode(code);
1215
1112
  });
1216
- if (!res.ok) {
1217
- const detail = await errorDetail(res);
1218
- throw new Error(`POST /api/terminal-sessions/ failed: ${res.status}${detail ? ` (${detail})` : ""}`);
1113
+ const timer = setTimeout(() => {
1114
+ settle(() => reject(new Error("Login timed out after 300s")));
1115
+ }, LOGIN_TIMEOUT_MS);
1116
+ function settle(run) {
1117
+ if (settled) {
1118
+ return;
1119
+ }
1120
+ settled = true;
1121
+ clearTimeout(timer);
1122
+ server.close();
1123
+ run();
1219
1124
  }
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
- });
1125
+ async function onCode(code) {
1126
+ try {
1127
+ const tok = await hub.authExchange(code, verifier);
1128
+ const identity = await hub.identityFind(tok.access);
1129
+ const stored = {
1130
+ access: tok.access,
1131
+ refresh: tok.refresh,
1132
+ expiresAt: tok.expiresAt,
1133
+ identity,
1134
+ hubUrl: hub.url
1135
+ };
1136
+ authSave(stored);
1137
+ settle(() => resolve(stored));
1138
+ } catch (err) {
1139
+ settle(() => reject(err instanceof Error ? err : new Error(String(err))));
1140
+ }
1258
1141
  }
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
- })
1142
+ server.on("error", (err) => {
1143
+ settle(() => reject(err));
1302
1144
  });
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)
1145
+ server.listen(0, "127.0.0.1", () => {
1146
+ const address = server.address();
1147
+ if (address === null || typeof address === "string") {
1148
+ settle(() => reject(new Error("Failed to determine loopback server port")));
1149
+ return;
1150
+ }
1151
+ const redirectUri = `http://127.0.0.1:${address.port}/cb`;
1152
+ open3(hub.authUrl(redirectUri, challenge));
1310
1153
  });
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
- }
1154
+ });
1361
1155
  }
1362
-
1363
- // src/auth/login.ts
1364
- import http from "http";
1365
-
1366
- // src/auth/pkce.ts
1367
- import { createHash, randomBytes } from "crypto";
1368
1156
  function pkce() {
1369
- const verifier = randomBytes(32).toString("base64url");
1157
+ const verifier = randomBytes2(32).toString("base64url");
1370
1158
  const challenge = createHash("sha256").update(verifier).digest("base64url");
1371
1159
  return { verifier, challenge };
1372
1160
  }
1373
-
1374
- // src/auth/login.ts
1375
- var LOGIN_TIMEOUT_MS = 3e5;
1161
+ function codeFromRequestUrl(url) {
1162
+ const parsed = new URL(url ?? "/", "http://127.0.0.1");
1163
+ return parsed.searchParams.get("code");
1164
+ }
1376
1165
  var SUCCESS_BODY = `<!doctype html>
1377
1166
  <html lang="en">
1378
1167
  <head>
@@ -1438,491 +1227,1917 @@ var SUCCESS_BODY = `<!doctype html>
1438
1227
  </main>
1439
1228
  </body>
1440
1229
  </html>`;
1441
- function codeFromRequestUrl(url) {
1442
- const parsed = new URL(url ?? "/", "http://127.0.0.1");
1443
- return parsed.searchParams.get("code");
1230
+
1231
+ // src/chat.ts
1232
+ import { createInterface } from "readline/promises";
1233
+ import { stdin as input, stdout as output } from "process";
1234
+ var TERMINAL = { input, output };
1235
+ function chatChoice(answer, count) {
1236
+ const value = Number(answer.trim());
1237
+ if (!Number.isInteger(value) || value < 1 || value > count) return null;
1238
+ return value;
1444
1239
  }
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();
1240
+ function chatPublicId(value) {
1241
+ const trimmed = value.trim();
1242
+ try {
1243
+ const url = new URL(trimmed);
1244
+ const match = url.pathname.match(/\/chat\/([^/]+)\/?$/);
1245
+ return match ? decodeURIComponent(match[1]) : trimmed;
1246
+ } catch {
1247
+ return trimmed;
1248
+ }
1249
+ }
1250
+ function chatMenu(chatList) {
1251
+ const lines = ["", "Recent Directed chats:"];
1252
+ for (const [index, chat] of chatList.entries()) {
1253
+ lines.push(`${index + 1}. ${chat.title}`);
1254
+ lines.push(` ${chat.url}`);
1255
+ }
1256
+ return `${lines.join("\n")}
1257
+ `;
1258
+ }
1259
+ async function chatPick(hub, target, terminal = TERMINAL) {
1260
+ if (target.kind === "new") return null;
1261
+ if (target.kind === "exact") {
1262
+ const wanted = chatPublicId(target.query);
1263
+ const chatList2 = await hub.chatList(target.query.trim());
1264
+ if (chatList2.some((chat) => chat.publicId === wanted)) return wanted;
1265
+ throw new Error("--into=<value> must resolve to an exact Directed chat URL or public id");
1266
+ }
1267
+ if (!terminal.input.isTTY || !terminal.output.isTTY) {
1268
+ throw new Error("--into needs an interactive terminal; use --into=<Directed chat URL or exact public id>");
1269
+ }
1270
+ const chatList = await hub.chatList("");
1271
+ if (chatList.length === 0) {
1272
+ throw new Error("No recent chats available; run without --into to start a new chat");
1273
+ }
1274
+ const prompt = createInterface({ input: terminal.input, output: terminal.output });
1275
+ try {
1276
+ terminal.output.write(chatMenu(chatList));
1277
+ const answer = await prompt.question(`Choose a chat [1-${chatList.length}]: `);
1278
+ const choice = chatChoice(answer, chatList.length);
1279
+ if (choice === null) throw new Error("Choose a listed chat number");
1280
+ return chatList[choice - 1].publicId;
1281
+ } finally {
1282
+ prompt.close();
1283
+ }
1284
+ }
1285
+ function inviteMailto(emails, joinUrl, hostName) {
1286
+ const subject = `${hostName} invited you to a live Directed session`;
1287
+ const body = [
1288
+ "Hi,",
1289
+ "",
1290
+ "I'm running a live coding session and want you in the chat -- follow along and jump in here:",
1291
+ "",
1292
+ joinUrl,
1293
+ "",
1294
+ "See you there,",
1295
+ hostName
1296
+ ].join("\n");
1297
+ return `mailto:${emails.join(",")}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
1298
+ }
1299
+ async function chatInvite(linkCreate, emails, hostName, note) {
1300
+ try {
1301
+ const joinUrl = await linkCreate();
1302
+ note(`join link: ${joinUrl}`);
1303
+ note(`opening mail draft to: ${emails.join(", ")}`);
1304
+ return inviteMailto(emails, joinUrl, hostName);
1305
+ } catch (e) {
1306
+ note(`could not prepare the email invite (${e.message}); invite them from the chat instead.`);
1307
+ return null;
1308
+ }
1309
+ }
1310
+
1311
+ // src/capture.ts
1312
+ import { execFile } from "child_process";
1313
+ import { promisify as promisify2 } from "util";
1314
+
1315
+ // src/transcript.ts
1316
+ import { open as open2, stat as stat3 } from "fs/promises";
1317
+
1318
+ // src/outbox.ts
1319
+ var BACKOFF_MS = [500, 1500, 4e3, 1e4];
1320
+ var BYTES_MAX_DEFAULT = 32 * 1024 * 1024;
1321
+ var Outbox = class {
1322
+ constructor(label, spec) {
1323
+ this.label = label;
1324
+ this.spec = spec;
1325
+ this.bytesMax = spec.bytesMax ?? BYTES_MAX_DEFAULT;
1326
+ }
1327
+ label;
1328
+ spec;
1329
+ pending = [];
1330
+ pendingBytes = 0;
1331
+ inFlight = null;
1332
+ lostCount = 0;
1333
+ lastFailure = "";
1334
+ isStopped = false;
1335
+ worker = null;
1336
+ wake = null;
1337
+ bytesMax;
1338
+ // Why the last send did not land, for the one summary printed once the
1339
+ // terminal is the session's own again. Kept rather than narrated: a flapping
1340
+ // hub produced 62 identical lines over the top of a live agent, and the count
1341
+ // above is what actually matters.
1342
+ get lastError() {
1343
+ return this.lastFailure;
1344
+ }
1345
+ // What this stream produced that the hub never took, in the stream's own
1346
+ // units. Zero means the hub has everything.
1347
+ get unsentCount() {
1348
+ const queued = this.pending.reduce((sum, record) => sum + this.lossOf(record), 0);
1349
+ const flying = this.inFlight === null ? 0 : this.lossOf(this.inFlight);
1350
+ return queued + flying + this.lostCount;
1351
+ }
1352
+ add(record) {
1353
+ if (this.isStopped) return;
1354
+ this.pending.push(record);
1355
+ this.pendingBytes += this.spec.bytesOf(record);
1356
+ while (this.pendingBytes > this.bytesMax && this.pending.length > 0) {
1357
+ const dropped = this.pending.shift();
1358
+ this.pendingBytes -= this.spec.bytesOf(dropped);
1359
+ this.lostCount += this.lossOf(dropped);
1360
+ this.lastFailure = "backlog full";
1471
1361
  }
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
- }
1362
+ this.wake?.();
1363
+ this.workerStart();
1364
+ }
1365
+ // Sends what is left, until `deadline` passes. Bounded on purpose: a hub that
1366
+ // is down must never hold the terminal. Whatever is still queued is reported
1367
+ // as loss, which is the session's integrity state, not a warning.
1368
+ async flush(deadline) {
1369
+ this.workerStart();
1370
+ while ((this.pending.length > 0 || this.inFlight !== null) && Date.now() < deadline) {
1371
+ await Promise.race([sleep(50), this.worker ?? Promise.resolve()]);
1488
1372
  }
1489
- server.on("error", (err) => {
1490
- settle(() => reject(err));
1373
+ this.isStopped = true;
1374
+ this.wake?.();
1375
+ }
1376
+ lossOf(record) {
1377
+ return this.spec.lossOf ? this.spec.lossOf(record) : 1;
1378
+ }
1379
+ workerStart() {
1380
+ if (this.worker) return;
1381
+ this.worker = this.run().finally(() => {
1382
+ this.worker = null;
1491
1383
  });
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;
1384
+ }
1385
+ // One record at a time, backing off on failure and resuming by itself. The
1386
+ // old version returned after exhausting its retries and only woke when the
1387
+ // next record arrived -- so an idle pane could sit on a parked retry forever.
1388
+ async run() {
1389
+ let attempt = 0;
1390
+ while (!this.isStopped) {
1391
+ const record = this.pending.shift();
1392
+ if (record === void 0) return;
1393
+ this.pendingBytes -= this.spec.bytesOf(record);
1394
+ this.inFlight = record;
1395
+ const outcome = await this.attempt(record);
1396
+ this.inFlight = null;
1397
+ if (outcome === "sent") {
1398
+ attempt = 0;
1399
+ continue;
1497
1400
  }
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;
1401
+ if (outcome === "rejected") {
1402
+ this.lostCount += this.lossOf(record);
1403
+ attempt = 0;
1404
+ continue;
1405
+ }
1406
+ this.pending.unshift(record);
1407
+ this.pendingBytes += this.spec.bytesOf(record);
1408
+ await this.pause(backoffMs(attempt));
1409
+ attempt += 1;
1410
+ }
1510
1411
  }
1511
- if (stored) {
1412
+ async attempt(record) {
1512
1413
  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 {
1414
+ await this.spec.send(record);
1415
+ return "sent";
1416
+ } catch (e) {
1417
+ this.lastFailure = String(e);
1418
+ if (e instanceof HubError && e.isPermanent) return "rejected";
1419
+ return "retry";
1524
1420
  }
1525
1421
  }
1526
- if (opts.autoLogin) {
1527
- const fresh = await loginFlow(hub, opts.open);
1528
- return fresh.identity;
1422
+ // Sleeps, but returns early when a record arrives or the outbox stops, so a
1423
+ // shutdown never waits out a full backoff.
1424
+ pause(ms) {
1425
+ return new Promise((resolve) => {
1426
+ const timer = setTimeout(finish, ms);
1427
+ const self = this;
1428
+ this.wake = finish;
1429
+ function finish() {
1430
+ clearTimeout(timer);
1431
+ self.wake = null;
1432
+ resolve();
1433
+ }
1434
+ });
1529
1435
  }
1530
- throw new Error("Not signed in. Run `directed login` first.");
1436
+ };
1437
+ function backoffMs(attempt) {
1438
+ return BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
1439
+ }
1440
+ function sleep(ms) {
1441
+ return new Promise((resolve) => setTimeout(resolve, ms));
1531
1442
  }
1532
1443
 
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]]
1444
+ // src/transcript.ts
1445
+ var POLL_MS = 400;
1446
+ var LOCATE_BACKOFF_MS = [400, 800, 1600, 3200, 5e3];
1447
+ var MISSING_DEADLINE_MS = 15e3;
1448
+ var CHILD_DEADLINE_MS = 12e4;
1449
+ var READ_BYTES_MAX = 256 * 1024;
1450
+ var NEWLINE = 10;
1451
+ var FileReader = class {
1452
+ constructor(ref, path, emit) {
1453
+ this.ref = ref;
1454
+ this.path = path;
1455
+ this.emit = emit;
1456
+ }
1457
+ ref;
1458
+ path;
1459
+ emit;
1460
+ cursor = 0;
1461
+ partial = Buffer.alloc(0);
1462
+ lineSeq = 0;
1463
+ lineOffset = 0;
1464
+ // Bumped by every rewind, so a read that was awaiting the filesystem when one
1465
+ // happened does not emit its old bytes under the new line numbering.
1466
+ generation = 0;
1467
+ hasData = false;
1468
+ rewind() {
1469
+ this.generation += 1;
1470
+ this.cursor = 0;
1471
+ this.partial = Buffer.alloc(0);
1472
+ this.lineSeq = 0;
1473
+ this.lineOffset = 0;
1474
+ }
1475
+ // One fixed-size read at a time until the file is caught up. Reading every
1476
+ // byte written since the last poll would, on a resumed transcript, allocate
1477
+ // the whole file at once.
1478
+ async read() {
1479
+ const size = await sizeOrNull(this.path);
1480
+ if (size === null) return false;
1481
+ if (size < this.cursor) this.rewind();
1482
+ const generation = this.generation;
1483
+ const handle = await open2(this.path, "r");
1484
+ try {
1485
+ while (this.cursor < size) {
1486
+ const length = Math.min(READ_BYTES_MAX, size - this.cursor);
1487
+ const buffer = Buffer.alloc(length);
1488
+ await handle.read(buffer, 0, length, this.cursor);
1489
+ if (this.generation !== generation) return this.hasData;
1490
+ this.cursor += length;
1491
+ this.hasData = true;
1492
+ this.consume(buffer);
1493
+ }
1494
+ } finally {
1495
+ await handle.close();
1496
+ }
1497
+ return this.hasData;
1498
+ }
1499
+ // Decoding happens per line, never per read: a multibyte character split
1500
+ // across two reads would otherwise become replacement characters, which the
1501
+ // hub would then store as the agent's own text.
1502
+ consume(chunk) {
1503
+ const buffer = this.partial.length > 0 ? Buffer.concat([this.partial, chunk]) : chunk;
1504
+ let start = 0;
1505
+ for (; ; ) {
1506
+ const end = buffer.indexOf(NEWLINE, start);
1507
+ if (end === -1) break;
1508
+ const line = buffer.subarray(start, end);
1509
+ this.emit(
1510
+ this.ref.key,
1511
+ line.toString("utf8"),
1512
+ this.lineSeq,
1513
+ this.lineOffset
1514
+ );
1515
+ this.lineSeq += 1;
1516
+ this.lineOffset += line.length + 1;
1517
+ start = end + 1;
1518
+ }
1519
+ this.partial = start === 0 ? buffer : Buffer.from(buffer.subarray(start));
1520
+ }
1539
1521
  };
1540
- function openBrowser(url) {
1541
- const opener = OPENERS[process.platform];
1542
- if (!opener) {
1543
- return;
1522
+ var TranscriptReader = class {
1523
+ constructor(agent, cwd, sinceMs, onMissing) {
1524
+ this.agent = agent;
1525
+ this.cwd = cwd;
1526
+ this.sinceMs = sinceMs;
1527
+ this.onMissing = onMissing;
1544
1528
  }
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 {
1529
+ agent;
1530
+ cwd;
1531
+ sinceMs;
1532
+ onMissing;
1533
+ timer = null;
1534
+ fileMap = /* @__PURE__ */ new Map();
1535
+ pendingMap = /* @__PURE__ */ new Map();
1536
+ unresolvedList = [];
1537
+ rootPath = null;
1538
+ rootKey = null;
1539
+ rootListen = null;
1540
+ ticking = false;
1541
+ // The poll currently running, or a settled promise. tick() swallows its own
1542
+ // errors, so awaiting this never rejects.
1543
+ inFlight = Promise.resolve();
1544
+ startedMs = 0;
1545
+ locateFailures = 0;
1546
+ nextLocateMs = 0;
1547
+ isMissingReported = false;
1548
+ isReadErrorReported = false;
1549
+ sink = null;
1550
+ // Off while the agent is expected to announce its own transcript. Guessing in
1551
+ // the meantime can attach a concurrent session in the same workspace and post
1552
+ // its conversation into this chat, which no later correction undoes.
1553
+ isLocateAllowed = true;
1554
+ // False once the session ends means the terminal was recorded but no
1555
+ // conversation ever streamed. A file the agent announced but never wrote
1556
+ // counts as missing: naming a path is not producing one.
1557
+ get isReading() {
1558
+ for (const file of this.fileMap.values()) {
1559
+ if (file.hasData) return true;
1560
+ }
1561
+ return false;
1562
+ }
1563
+ // Subagents the transcripts said ran, whose own files never appeared. Their
1564
+ // work is missing from the chat and nothing else would say so.
1565
+ get unresolvedChildList() {
1566
+ return [...this.unresolvedList, ...this.pendingMap.keys()];
1567
+ }
1568
+ // Attach a stream. Every file is read again from the top, because a stream
1569
+ // that opened late still owes the hub the whole session and re-posting a line
1570
+ // the hub already holds is a no-op.
1571
+ sinkAttach(sink) {
1572
+ this.sink = sink;
1573
+ for (const file of this.fileMap.values()) {
1574
+ file.rewind();
1575
+ sink.fileOpen(file.ref);
1576
+ }
1577
+ }
1578
+ sinkDetach() {
1579
+ this.sink = null;
1580
+ }
1581
+ // Adopt a root transcript, overriding whatever transcriptLocate() may have
1582
+ // settled on. A second root -- Claude's /clear opens one -- joins the set
1583
+ // rather than replacing what came before.
1584
+ //
1585
+ // The key is passed in rather than read back off the path: where the agent
1586
+ // announced itself, the key is what it called its own session, and that is
1587
+ // the name a resume presents. Reading it off a filename works only for as
1588
+ // long as a vendor keeps naming files after sessions.
1589
+ fileAdd(path, key) {
1590
+ if (this.fileMap.has(key)) return;
1591
+ if (this.rootPath === null) {
1592
+ this.rootPath = path;
1593
+ this.rootKey = key;
1594
+ this.rootListen?.(key, path);
1595
+ }
1596
+ this.fileOpen({ key, parentKey: "", callKey: "" }, path);
1597
+ }
1598
+ // Called once with the name of the first conversation this reader opens.
1599
+ // Nothing else knows it: a fresh launch has no key until the agent writes one,
1600
+ // and the pane needs it to say on its tmux session which conversation is
1601
+ // running there -- which is what lets a later launch find this pane rather
1602
+ // than starting a second agent on the same conversation.
1603
+ onRootOpen(listen) {
1604
+ this.rootListen = listen;
1605
+ if (this.rootKey !== null && this.rootPath !== null) {
1606
+ listen(this.rootKey, this.rootPath);
1607
+ }
1608
+ }
1609
+ // Suppress guessing until locateAllow() -- called when the agent will report
1610
+ // its own session, and released if that report never arrives.
1611
+ locateDefer() {
1612
+ this.isLocateAllowed = false;
1613
+ }
1614
+ locateAllow() {
1615
+ this.isLocateAllowed = true;
1616
+ }
1617
+ start() {
1618
+ if (this.timer) return;
1619
+ this.startedMs = Date.now();
1620
+ this.tickStart();
1621
+ this.timer = setInterval(() => this.tickStart(), POLL_MS);
1622
+ }
1623
+ async stop() {
1624
+ if (this.timer) {
1625
+ clearInterval(this.timer);
1626
+ this.timer = null;
1627
+ }
1628
+ await this.inFlight;
1629
+ if (this.fileMap.size > 0) await this.tick();
1630
+ }
1631
+ fileOpen(ref, path) {
1632
+ const file = new FileReader(
1633
+ ref,
1634
+ path,
1635
+ (key, text, seq, offset) => this.emit(key, text, seq, offset)
1636
+ );
1637
+ this.fileMap.set(ref.key, file);
1638
+ this.sink?.fileOpen(ref);
1639
+ }
1640
+ // Starts a poll unless one is already running, and keeps its promise so stop()
1641
+ // can wait the running one out.
1642
+ tickStart() {
1643
+ if (this.ticking) return;
1644
+ this.inFlight = this.tick();
1645
+ }
1646
+ async tick() {
1647
+ this.ticking = true;
1648
+ try {
1649
+ if (this.fileMap.size === 0) await this.locate();
1650
+ for (const file of [...this.fileMap.values()]) {
1651
+ if (!await file.read()) this.missingCheck();
1652
+ }
1653
+ await this.pendingResolve();
1654
+ } catch (e) {
1655
+ this.isReadErrorReported = true;
1656
+ } finally {
1657
+ this.ticking = false;
1658
+ }
1659
+ }
1660
+ async locate() {
1661
+ if (!this.isLocateAllowed) return;
1662
+ if (Date.now() < this.nextLocateMs) return;
1663
+ const found = await this.agent.transcriptLocate(this.cwd, this.sinceMs);
1664
+ if (!found) {
1665
+ const wait = LOCATE_BACKOFF_MS[Math.min(this.locateFailures, LOCATE_BACKOFF_MS.length - 1)];
1666
+ this.locateFailures += 1;
1667
+ this.nextLocateMs = Date.now() + wait;
1668
+ this.missingCheck();
1669
+ return;
1670
+ }
1671
+ const key = this.agent.transcriptKey(found);
1672
+ if (key !== null) this.fileAdd(found, key);
1673
+ }
1674
+ // A child is opened only once its file exists, and only because a parent line
1675
+ // named it. One that never appears is given up on and reported, rather than
1676
+ // rescanning for the rest of the session.
1677
+ async pendingResolve() {
1678
+ const rootPath = this.rootPath;
1679
+ if (rootPath === null) return;
1680
+ for (const [key, pending] of [...this.pendingMap]) {
1681
+ const path = await this.agent.transcriptChildLocate(
1682
+ rootPath,
1683
+ pending.child
1684
+ );
1685
+ if (path !== null) {
1686
+ this.pendingMap.delete(key);
1687
+ this.fileOpen(
1688
+ { key, parentKey: pending.parentKey, callKey: pending.child.callKey },
1689
+ path
1690
+ );
1691
+ continue;
1692
+ }
1693
+ if (Date.now() - pending.since > CHILD_DEADLINE_MS) {
1694
+ this.pendingMap.delete(key);
1695
+ this.unresolvedList.push(key);
1696
+ }
1697
+ }
1698
+ }
1699
+ // Fires once, after the deadline, so a launch that will never find its
1700
+ // transcript stops looking identical to one whose agent is still starting.
1701
+ missingCheck() {
1702
+ if (this.isMissingReported) return;
1703
+ if (this.fileMap.size > 0 && this.isReading) return;
1704
+ if (Date.now() - this.startedMs < MISSING_DEADLINE_MS) return;
1705
+ this.isMissingReported = true;
1706
+ this.onMissing();
1707
+ }
1708
+ // One line's failure is that line's. Without this, a sink that throws would
1709
+ // abandon every line already framed in the same read, from a cursor that has
1710
+ // already moved past them.
1711
+ emit(key, text, seq, offset) {
1712
+ try {
1713
+ this.sink?.line(key, text, seq, offset);
1714
+ } catch (e) {
1715
+ this.isReadErrorReported = true;
1716
+ }
1717
+ this.childrenNote(key, text);
1718
+ }
1719
+ childrenNote(parentKey, text) {
1720
+ for (const child of this.agent.transcriptChildren(text)) {
1721
+ if (this.fileMap.has(child.key) || this.pendingMap.has(child.key))
1722
+ continue;
1723
+ this.pendingMap.set(child.key, { child, parentKey, since: Date.now() });
1724
+ }
1725
+ }
1726
+ };
1727
+ var FLUSH_INTERVAL_MS = 250;
1728
+ var FLUSH_BYTES_MAX = 512 * 1024;
1729
+ var FLUSH_LINES_MAX = 100;
1730
+ var TranscriptStream = class {
1731
+ constructor(session, reader) {
1732
+ this.session = session;
1733
+ this.reader = reader;
1734
+ reader.sinkAttach(this);
1735
+ }
1736
+ session;
1737
+ reader;
1738
+ bufferMap = /* @__PURE__ */ new Map();
1739
+ timer = null;
1740
+ unnamedCount = 0;
1741
+ outbox = new Outbox("transcript", {
1742
+ send: (batch) => this.session.transcriptAppend(batch),
1743
+ bytesOf: (batch) => batch.body.length,
1744
+ lossOf: (batch) => batch.lineCount
1745
+ });
1746
+ // Lines this session produced that the hub never took. Zero means the hub has
1747
+ // the whole transcript; anything else is a hole no derived view can see.
1748
+ get lastError() {
1749
+ return this.outbox.lastError;
1750
+ }
1751
+ get unsentCount() {
1752
+ return this.outbox.unsentCount + this.unnamedCount;
1753
+ }
1754
+ fileOpen(ref) {
1755
+ if (ref.parentKey) this.flushOne(ref.parentKey);
1756
+ const nextSeq = ref.key === this.session.resumeKey ? this.session.resumeSeq : 0;
1757
+ this.bufferMap.set(ref.key, {
1758
+ ref,
1759
+ lines: [],
1760
+ startSeq: 0,
1761
+ startOffset: 0,
1762
+ bytes: 0,
1763
+ nextSeq
1764
+ });
1765
+ this.outbox.add({
1766
+ fileKey: ref.key,
1767
+ parentKey: ref.parentKey,
1768
+ callKey: ref.callKey,
1769
+ startSeq: nextSeq,
1770
+ startOffset: 0,
1771
+ body: "",
1772
+ lineCount: 0
1773
+ });
1774
+ }
1775
+ line(key, text, seq, offset) {
1776
+ const buffer = this.bufferMap.get(key);
1777
+ if (buffer === void 0) {
1778
+ this.unnamedCount += 1;
1779
+ return;
1780
+ }
1781
+ if (seq < buffer.nextSeq) return;
1782
+ if (buffer.lines.length === 0) {
1783
+ buffer.startSeq = seq;
1784
+ buffer.startOffset = offset;
1785
+ }
1786
+ buffer.lines.push(text);
1787
+ buffer.bytes += Buffer.byteLength(text, "utf8") + 1;
1788
+ if (buffer.bytes >= FLUSH_BYTES_MAX || buffer.lines.length >= FLUSH_LINES_MAX) {
1789
+ this.flushOne(key);
1790
+ return;
1791
+ }
1792
+ if (!this.timer) {
1793
+ this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS);
1794
+ this.timer.unref();
1795
+ }
1796
+ }
1797
+ async stop(deadline) {
1798
+ this.reader.sinkDetach();
1799
+ this.flush();
1800
+ await this.outbox.flush(deadline);
1801
+ }
1802
+ flush() {
1803
+ if (this.timer) {
1804
+ clearTimeout(this.timer);
1805
+ this.timer = null;
1806
+ }
1807
+ for (const key of this.bufferMap.keys()) this.flushOne(key);
1808
+ }
1809
+ flushOne(key) {
1810
+ const buffer = this.bufferMap.get(key);
1811
+ if (buffer === void 0 || buffer.lines.length === 0) return;
1812
+ this.outbox.add({
1813
+ fileKey: key,
1814
+ parentKey: buffer.ref.parentKey,
1815
+ callKey: buffer.ref.callKey,
1816
+ startSeq: buffer.startSeq,
1817
+ startOffset: buffer.startOffset,
1818
+ // The trailing newline matters: the body is a slice of the file, and every
1819
+ // line in the file is terminated.
1820
+ body: `${buffer.lines.join("\n")}
1821
+ `,
1822
+ lineCount: buffer.lines.length
1823
+ });
1824
+ buffer.lines = [];
1825
+ buffer.bytes = 0;
1826
+ }
1827
+ };
1828
+ async function sizeOrNull(path) {
1829
+ try {
1830
+ return (await stat3(path)).size;
1831
+ } catch (e) {
1832
+ if (e.code === "ENOENT") return null;
1833
+ throw e;
1834
+ }
1835
+ }
1836
+
1837
+ // src/steering.ts
1838
+ var RECONNECT_MAX_MS = 8e3;
1839
+ var OPEN_TIMEOUT_MS = 15e3;
1840
+ var PING_INTERVAL_MS = 1e4;
1841
+ var PONG_STALE_MS = 25e3;
1842
+ var Steering = class {
1843
+ constructor(session, auth) {
1844
+ this.session = session;
1845
+ this.auth = auth;
1846
+ }
1847
+ session;
1848
+ auth;
1849
+ ws = null;
1850
+ isStopped = false;
1851
+ inject = null;
1852
+ reconnectTimer = null;
1853
+ openTimer = null;
1854
+ pingTimer = null;
1855
+ pongAt = 0;
1856
+ attempts = 0;
1857
+ isOpened = false;
1858
+ lastFailure = "";
1859
+ // Steers already typed into the pane, and what happened to each -- null while
1860
+ // one is still being typed. A reconnect replays what the hub still considers
1861
+ // undelivered, so the same steer arrives more than once: typing it again would
1862
+ // put the person's words into the agent twice, and staying silent would leave
1863
+ // the hub replaying it forever. The outcome is kept so a repeat is answered
1864
+ // with the same one.
1865
+ deliveredMap = /* @__PURE__ */ new Map();
1866
+ start(inject) {
1867
+ this.inject = inject;
1868
+ this.connect();
1869
+ }
1870
+ // Empty when steering worked. Otherwise why the chat could not reach this
1871
+ // pane, which is invisible from the chat's side: with no socket, no steer ever
1872
+ // arrives, so there is no undelivered row to look at.
1873
+ get failure() {
1874
+ if (this.isOpened && !this.lastFailure) return "";
1875
+ if (!this.lastFailure) return "";
1876
+ return `chat steers could not reach this terminal: ${this.lastFailure}`;
1877
+ }
1878
+ async stop() {
1879
+ this.isStopped = true;
1880
+ this.timersClear();
1881
+ this.socketDrop();
1882
+ }
1883
+ connect() {
1884
+ if (this.isStopped) return;
1885
+ const ctor = globalThis.WebSocket;
1886
+ if (typeof ctor !== "function") {
1887
+ this.lastFailure = "this Node has no WebSocket";
1888
+ return;
1889
+ }
1890
+ this.auth.token().then((token) => this.socketOpen(ctor, token)).catch((e) => {
1891
+ this.lastFailure = `could not authenticate the socket (${String(e)})`;
1892
+ this.reconnectSchedule();
1893
+ });
1894
+ }
1895
+ socketOpen(ctor, token) {
1896
+ if (this.isStopped) return;
1897
+ this.openTimerClear();
1898
+ this.pingTimerClear();
1899
+ this.socketDrop();
1900
+ const ws = new ctor(this.session.socketUrl, ["jwt", token]);
1901
+ this.ws = ws;
1902
+ this.openTimer = setTimeout(() => {
1903
+ this.socketLost(ws, "the socket did not open");
1904
+ }, OPEN_TIMEOUT_MS);
1905
+ ws.onopen = () => {
1906
+ this.attempts = 0;
1907
+ this.isOpened = true;
1908
+ this.lastFailure = "";
1909
+ this.openTimerClear();
1910
+ this.pongAt = Date.now();
1911
+ this.pingSend();
1912
+ this.pingTimer = setInterval(() => this.pingSend(), PING_INTERVAL_MS);
1913
+ this.pingTimer.unref();
1914
+ };
1915
+ ws.onmessage = (ev) => this.onFrame(ev.data);
1916
+ ws.onerror = (ev) => {
1917
+ this.socketLost(ws, `the control socket errored (${String(ev)})`);
1918
+ };
1919
+ ws.onclose = (ev) => {
1920
+ if (this.isStopped || this.ws !== ws) return;
1921
+ if (ev.code === 4004) {
1922
+ this.socketRetire(ws);
1923
+ this.lastFailure = "the hub says this session has ended";
1924
+ this.session.endedReceive();
1925
+ return;
1926
+ }
1927
+ if (ev.code === 4001 || ev.code === 4002 || ev.code === 4003) {
1928
+ this.socketRetire(ws);
1929
+ this.auth.refresh().catch((e) => {
1930
+ this.lastFailure = `could not refresh the socket token (${String(e)})`;
1931
+ }).finally(() => this.reconnectSchedule());
1932
+ return;
1933
+ }
1934
+ this.socketLost(ws, `the hub closed the control socket (${ev.code})`);
1935
+ };
1936
+ }
1937
+ // Every non-terminal transport failure comes through here. Error and close
1938
+ // commonly arrive for the same failure; socket identity makes the second a
1939
+ // no-op, and reconnectSchedule makes the first exactly one backed-off retry.
1940
+ socketLost(ws, failure) {
1941
+ if (this.isStopped || !this.socketRetire(ws)) return;
1942
+ this.lastFailure = failure;
1943
+ this.reconnectSchedule();
1944
+ }
1945
+ socketRetire(ws) {
1946
+ if (this.ws !== ws) return false;
1947
+ this.isOpened = false;
1948
+ this.openTimerClear();
1949
+ this.pingTimerClear();
1950
+ this.socketDrop();
1951
+ return true;
1952
+ }
1953
+ reconnectSchedule() {
1954
+ if (this.isStopped || this.reconnectTimer) return;
1955
+ const delay = Math.min(1e3 * 2 ** this.attempts, RECONNECT_MAX_MS);
1956
+ this.attempts += 1;
1957
+ this.reconnectTimer = setTimeout(() => {
1958
+ this.reconnectTimer = null;
1959
+ this.connect();
1960
+ }, delay);
1961
+ }
1962
+ onFrame(raw) {
1963
+ if (typeof raw !== "string") return;
1964
+ let frame;
1965
+ try {
1966
+ frame = JSON.parse(raw);
1967
+ } catch {
1968
+ return;
1969
+ }
1970
+ if (frame.kind === "pong") {
1971
+ this.pongAt = Date.now();
1972
+ return;
1973
+ }
1974
+ if (frame.kind !== "steer") return;
1975
+ const steer = steerParse(frame.data);
1976
+ if (steer === null) return;
1977
+ const done = this.deliveredMap.get(steer.eventId);
1978
+ if (done !== void 0) {
1979
+ if (done !== null)
1980
+ void this.session.steerAck(steer.eventId, done).catch(() => {
1981
+ });
1982
+ return;
1983
+ }
1984
+ this.deliveredMap.set(steer.eventId, null);
1985
+ void this.deliver(steer);
1986
+ }
1987
+ // Detaches the current socket's handlers before closing it, so its own close
1988
+ // event cannot clear a socket that has already replaced it.
1989
+ socketDrop() {
1990
+ const ws = this.ws;
1991
+ this.ws = null;
1992
+ if (ws === null) return;
1993
+ ws.onopen = null;
1994
+ ws.onclose = null;
1995
+ ws.onmessage = null;
1996
+ ws.onerror = null;
1997
+ ws.close(1e3);
1998
+ }
1999
+ // The control path proves itself over the control path. A durable "opened"
2000
+ // timestamp cannot notice a hub process disappearing before disconnect runs.
2001
+ pingSend() {
2002
+ const ws = this.ws;
2003
+ if (ws === null) return;
2004
+ if (Date.now() - this.pongAt > PONG_STALE_MS) {
2005
+ this.socketLost(ws, "the control socket stopped answering");
2006
+ return;
2007
+ }
2008
+ try {
2009
+ ws.send(JSON.stringify({ kind: "ping" }));
2010
+ } catch (e) {
2011
+ this.socketLost(
2012
+ ws,
2013
+ `could not ping the control socket (${String(e)})`
2014
+ );
2015
+ }
2016
+ }
2017
+ // The hub hears what happened either way. A steer the pane could not type is
2018
+ // the failure that used to be invisible: the sender saw their own message in
2019
+ // the chat and had no way to know it never arrived.
2020
+ async deliver(steer) {
2021
+ let outcome = "failed";
2022
+ try {
2023
+ outcome = await this.inject?.(steer) ?? "failed";
2024
+ } catch (e) {
2025
+ this.lastFailure = `could not type a steer into the pane (${String(e)})`;
2026
+ }
2027
+ this.deliveredMap.set(steer.eventId, outcome);
2028
+ try {
2029
+ await this.session.steerAck(steer.eventId, outcome);
2030
+ } catch (e) {
2031
+ this.lastFailure = `could not acknowledge a steer (${String(e)})`;
2032
+ }
2033
+ }
2034
+ timersClear() {
2035
+ if (this.reconnectTimer) {
2036
+ clearTimeout(this.reconnectTimer);
2037
+ this.reconnectTimer = null;
2038
+ }
2039
+ this.openTimerClear();
2040
+ this.pingTimerClear();
2041
+ }
2042
+ openTimerClear() {
2043
+ if (this.openTimer) {
2044
+ clearTimeout(this.openTimer);
2045
+ this.openTimer = null;
2046
+ }
2047
+ }
2048
+ pingTimerClear() {
2049
+ if (this.pingTimer) {
2050
+ clearInterval(this.pingTimer);
2051
+ this.pingTimer = null;
2052
+ }
2053
+ }
2054
+ };
2055
+ function steerParse(data) {
2056
+ if (!data) return null;
2057
+ const { eventId, messageId, text, author } = data;
2058
+ if (typeof eventId !== "string" || !eventId) return null;
2059
+ if (typeof text !== "string" || !text.trim()) return null;
2060
+ return {
2061
+ eventId,
2062
+ messageId: typeof messageId === "string" ? messageId : "",
2063
+ text,
2064
+ author: typeof author === "string" && author ? author : "someone"
2065
+ };
2066
+ }
2067
+
2068
+ // src/capture.ts
2069
+ var exec = promisify2(execFile);
2070
+ var HEARTBEAT_INTERVAL_MS = 3e4;
2071
+ var SHUTDOWN_MS = 8e3;
2072
+ var GIT_TIMEOUT_MS = 5e3;
2073
+ var Capture = class _Capture {
2074
+ constructor(note, session, reader, transcriptStream, steering, chatUrl, reattached, skippedMemberList) {
2075
+ this.note = note;
2076
+ this.session = session;
2077
+ this.reader = reader;
2078
+ this.transcriptStream = transcriptStream;
2079
+ this.steering = steering;
2080
+ this.chatUrl = chatUrl;
2081
+ this.reattached = reattached;
2082
+ this.skippedMemberList = skippedMemberList;
2083
+ }
2084
+ note;
2085
+ session;
2086
+ reader;
2087
+ transcriptStream;
2088
+ steering;
2089
+ chatUrl;
2090
+ reattached;
2091
+ skippedMemberList;
2092
+ stopWork = null;
2093
+ get chatPublicId() {
2094
+ return this.session.chatPublicId;
2095
+ }
2096
+ // Binds an already-open chat to what the pane is now producing.
2097
+ //
2098
+ // Opening is separate and happens first, before tmux owns the screen: every
2099
+ // reason a chat cannot be opened -- signed out, too old for this hub, hub
2100
+ // unreachable -- is a question for the person, and by here there is nowhere
2101
+ // left to ask it.
2102
+ static attach(session, auth, reader, note) {
2103
+ const transcriptStream = new TranscriptStream(session, reader);
2104
+ const steering = new Steering(session, auth);
2105
+ const capture = new _Capture(
2106
+ note,
2107
+ session,
2108
+ reader,
2109
+ transcriptStream,
2110
+ steering,
2111
+ session.chatUrl,
2112
+ session.reattached,
2113
+ session.skippedMemberList
2114
+ );
2115
+ session.endedWatch(() => void capture.remoteStop());
2116
+ return capture;
2117
+ }
2118
+ steerListen(inject) {
2119
+ this.steering.start(inject);
2120
+ }
2121
+ // Mint the chat's join link, for an email invite.
2122
+ linkCreate() {
2123
+ return this.session.linkCreate();
2124
+ }
2125
+ // Stop the producers, drain the stream with the time that remains, then post
2126
+ // the end state. `isDetached` means the agent ran on unwatched, which no loss
2127
+ // count can show, so the session says so.
2128
+ async stop(isDetached) {
2129
+ if (this.stopWork === null) this.stopWork = this.localStop(isDetached);
2130
+ await this.stopWork;
2131
+ }
2132
+ async localStop(isDetached) {
2133
+ const gitEnd = await gitSnapshot(process.cwd());
2134
+ const deadline = Date.now() + SHUTDOWN_MS;
2135
+ await this.steering.stop();
2136
+ await this.transcriptStream.stop(deadline);
2137
+ this.lossReport();
2138
+ await this.session.close({
2139
+ transcriptLost: this.transcriptStream.unsentCount,
2140
+ isDetached,
2141
+ gitEnd
2142
+ });
2143
+ }
2144
+ // Ownership can end while the pane is still running. Stop only collaboration:
2145
+ // the local tmux session and coding agent remain entirely outside this path.
2146
+ async remoteStop() {
2147
+ if (this.stopWork === null) this.stopWork = this.remoteStopWrite();
2148
+ await this.stopWork;
2149
+ }
2150
+ async remoteStopWrite() {
2151
+ await this.steering.stop();
2152
+ await this.transcriptStream.stop(Date.now());
2153
+ await this.session.close({
2154
+ transcriptLost: this.transcriptStream.unsentCount,
2155
+ isDetached: true,
2156
+ gitEnd: null
2157
+ });
2158
+ this.note("this chat connection ended; the local agent is still running");
2159
+ }
2160
+ // Said once, after the stream has drained and the pane has already given the
2161
+ // terminal back. The count is the session's integrity state; the last error
2162
+ // is the only part a person can act on.
2163
+ lossReport() {
2164
+ const lost = this.transcriptStream.unsentCount;
2165
+ if (lost > 0) {
2166
+ const why = this.transcriptStream.lastError;
2167
+ this.note(
2168
+ `${lost} transcript lines never reached the hub${why ? ` (${why})` : ""}`
2169
+ );
2170
+ }
2171
+ const unresolved = this.reader.unresolvedChildList;
2172
+ if (unresolved.length > 0) {
2173
+ this.note(
2174
+ `${unresolved.length} subagent transcript(s) never appeared, so their work is not in the chat`
2175
+ );
2176
+ }
2177
+ const failure = this.steering.failure;
2178
+ if (failure) this.note(failure);
2179
+ }
2180
+ };
2181
+ var HubSession = class _HubSession {
2182
+ constructor(hub, publicId, chatPublicId2, agentActorPublicId, chatUrl, reattached, skippedMemberList, resumeKey, resumeSeq) {
2183
+ this.hub = hub;
2184
+ this.publicId = publicId;
2185
+ this.chatPublicId = chatPublicId2;
2186
+ this.agentActorPublicId = agentActorPublicId;
2187
+ this.chatUrl = chatUrl;
2188
+ this.reattached = reattached;
2189
+ this.skippedMemberList = skippedMemberList;
2190
+ this.resumeKey = resumeKey;
2191
+ this.resumeSeq = resumeSeq;
2192
+ }
2193
+ hub;
2194
+ publicId;
2195
+ chatPublicId;
2196
+ agentActorPublicId;
2197
+ chatUrl;
2198
+ reattached;
2199
+ skippedMemberList;
2200
+ resumeKey;
2201
+ resumeSeq;
2202
+ heartbeatTimer = null;
2203
+ endedListener = null;
2204
+ isEnded = false;
2205
+ static async open(hub, options) {
2206
+ const started = await hub.terminalCreate({
2207
+ source: options.source,
2208
+ title: options.title,
2209
+ resumeKey: options.resumeKey ?? void 0,
2210
+ chatPublicId: options.chatPublicId ?? void 0,
2211
+ memberList: options.memberList,
2212
+ gitStart: options.gitStart
2213
+ });
2214
+ const session = new _HubSession(
2215
+ hub,
2216
+ started.terminalSessionPublicId,
2217
+ started.chatPublicId,
2218
+ started.agentActorPublicId,
2219
+ started.chatUrl,
2220
+ started.reattached,
2221
+ started.skippedMemberList,
2222
+ options.resumeKey ?? "",
2223
+ started.resumeSeq
2224
+ );
2225
+ session.heartbeatTimer = setInterval(
2226
+ () => void session.heartbeat(),
2227
+ HEARTBEAT_INTERVAL_MS
2228
+ );
2229
+ session.heartbeatTimer.unref();
2230
+ return session;
2231
+ }
2232
+ // A batch names its own file, and a subagent's names the parent that launched
2233
+ // it, so lineage arrives with the data it describes and the outbox retries
2234
+ // both together.
2235
+ async transcriptAppend(batch) {
2236
+ await this.liveRequest(
2237
+ () => this.hub.transcriptAppend(this.publicId, batch)
2238
+ );
2239
+ }
2240
+ async linkCreate() {
2241
+ return this.hub.chatLinkCreate(this.chatPublicId);
2242
+ }
2243
+ get socketUrl() {
2244
+ return this.hub.socketUrl(`/ws/terminal-sessions/${this.publicId}/`);
2245
+ }
2246
+ async steerAck(eventId, outcome) {
2247
+ await this.liveRequest(
2248
+ () => this.hub.steerAck(this.publicId, eventId, outcome)
2249
+ );
2250
+ }
2251
+ // Best-effort, like the heartbeat: the pane is already gone by the time this
2252
+ // runs, and a failed close is recovered by the hub's own staleness window.
2253
+ async close(state) {
2254
+ this.isEnded = true;
2255
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
2256
+ this.heartbeatTimer = null;
2257
+ await this.hub.terminalEnd(this.publicId, state).catch(() => {
2258
+ });
2259
+ }
2260
+ endedWatch(listener) {
2261
+ this.endedListener = listener;
2262
+ if (this.isEnded) listener();
2263
+ }
2264
+ endedReceive() {
2265
+ if (this.isEnded) return;
2266
+ this.isEnded = true;
2267
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
2268
+ this.heartbeatTimer = null;
2269
+ this.endedListener?.();
2270
+ }
2271
+ async liveRequest(request) {
2272
+ try {
2273
+ await request();
2274
+ } catch (e) {
2275
+ if (e instanceof HubError && e.status === 409) this.endedReceive();
2276
+ throw e;
2277
+ }
2278
+ }
2279
+ // A missed beat is recovered by the next one, and a hub that is unreachable
2280
+ // must never interrupt the local terminal.
2281
+ async heartbeat() {
2282
+ let state;
2283
+ try {
2284
+ state = await this.hub.terminalHeartbeat(this.publicId);
2285
+ } catch {
2286
+ return;
2287
+ }
2288
+ if (state.isEnded) {
2289
+ this.endedReceive();
2290
+ }
2291
+ }
2292
+ };
2293
+ async function gitSnapshot(cwd) {
2294
+ const sha = await git(["rev-parse", "HEAD"], cwd);
2295
+ if (sha === null) return null;
2296
+ const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
2297
+ const root = await git(["rev-parse", "--show-toplevel"], cwd);
2298
+ const status = await git(["status", "--porcelain", "-z"], cwd);
2299
+ return {
2300
+ sha: sha.trim(),
2301
+ branch: (branch ?? "").trim(),
2302
+ root: (root ?? "").trim(),
2303
+ dirtyPaths: dirtyPaths(status ?? "")
2304
+ };
2305
+ }
2306
+ function dirtyPaths(status) {
2307
+ const entries = status.split("\0");
2308
+ const paths = [];
2309
+ for (let i = 0; i < entries.length; i += 1) {
2310
+ const entry = entries[i];
2311
+ if (entry.length < 4) continue;
2312
+ paths.push(entry.slice(3));
2313
+ if (entry[0] === "R" || entry[1] === "R") i += 1;
2314
+ }
2315
+ return paths;
2316
+ }
2317
+ async function git(args, cwd) {
2318
+ try {
2319
+ const { stdout } = await exec("git", args, {
2320
+ cwd,
2321
+ timeout: GIT_TIMEOUT_MS,
2322
+ maxBuffer: 8 * 1024 * 1024
2323
+ });
2324
+ return stdout;
2325
+ } catch {
2326
+ return null;
2327
+ }
2328
+ }
2329
+
2330
+ // src/pane.ts
2331
+ var BANNER = `
2332
+ \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591
2333
+ \u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591
2334
+ \u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591
2335
+ \u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
2336
+ \u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
2337
+ \u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
2338
+ \u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
2339
+ \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
2340
+ \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
2341
+ \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
2342
+ \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
2343
+ \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
2344
+ \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
2345
+ \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
2346
+ \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
2347
+ \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
2348
+ \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
2349
+ \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
2350
+ \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
2351
+ \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
2352
+ \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
2353
+ \u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591 \u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591
2354
+ \u2591\u2591\u2591\u2591\u2591\u2591\u2591 \u2591\u2591\u2591\u2591\u2591\u2591
2355
+
2356
+ Directed helps you collaborate with AI.
2357
+ `;
2358
+ var BIND_DEADLINE_MS = 2e4;
2359
+ var HOOK_SETUP_MS = 2e3;
2360
+ var TRANSCRIPT_MISSING_NOTE = "no transcript found; nothing reaches the chat. The agent runs normally.";
2361
+ function bannerWrap(command, cols, rows) {
2362
+ const lines = BANNER.replace(/^\n+|\n+$/g, "").split("\n");
2363
+ const width = Math.max(...lines.map((l) => l.length));
2364
+ const left = " ".repeat(Math.max(0, Math.floor((cols - width) / 2)));
2365
+ const top = "\n".repeat(Math.max(0, Math.floor((rows - lines.length) / 2)));
2366
+ const centered = top + lines.map((l) => l.length > 0 ? left + l : l).join("\n");
2367
+ return [
2368
+ "sh",
2369
+ "-c",
2370
+ `clear; printf '%s
2371
+ ' '${centered}'; exec "$@"`,
2372
+ "sh",
2373
+ ...command
2374
+ ];
2375
+ }
2376
+ var PaneSession = class {
2377
+ constructor(options, tmux, agent, note) {
2378
+ this.options = options;
2379
+ this.tmux = tmux;
2380
+ this.agent = agent;
2381
+ this.note = note;
2382
+ }
2383
+ options;
2384
+ tmux;
2385
+ agent;
2386
+ note;
2387
+ bindTimer = null;
2388
+ hook = null;
2389
+ reader = null;
2390
+ isMissingNoted = false;
2391
+ // The transcript this pane is reading, for capture to attach a stream to.
2392
+ // Null when the command is not an agent directed can follow.
2393
+ get transcript() {
2394
+ return this.reader;
2395
+ }
2396
+ // Brings the pane up. Only tmux failing throws; everything else warns and the
2397
+ // session runs with less capture than it hoped for.
2398
+ //
2399
+ // `running` names a conversation already going in a tmux session this launch
2400
+ // is joining. Then nothing is started: the agent is where it was left, still
2401
+ // writing the transcript it names, and starting a second one on the same
2402
+ // conversation is what the vendor refuses anyway.
2403
+ async start(running = null) {
2404
+ const cols = process.stdout.columns || 80;
2405
+ const rows = process.stdout.rows || 24;
2406
+ const sinceMs = Date.now();
2407
+ let command = this.options.command;
2408
+ let isHooked = false;
2409
+ if (this.agent && running === null) {
2410
+ const agent = this.agent;
2411
+ try {
2412
+ this.hook = await TranscriptHook.open();
2413
+ const hook = this.hook;
2414
+ const hookArgs = await withDeadline(
2415
+ agent.hookArgs(hook),
2416
+ HOOK_SETUP_MS
2417
+ );
2418
+ if (hookArgs) {
2419
+ command = [command[0], ...hookArgs, ...command.slice(1)];
2420
+ isHooked = true;
2421
+ }
2422
+ } catch (e) {
2423
+ process.stderr.write(
2424
+ `[directed] transcript hook unavailable (${String(e)}); falling back to search.
2425
+ `
2426
+ );
2427
+ }
2428
+ }
2429
+ if (running === null) {
2430
+ await this.tmux.newSession(bannerWrap(command, cols, rows), cols, rows);
2431
+ }
2432
+ if (this.agent) {
2433
+ const agent = this.agent;
2434
+ this.reader = new TranscriptReader(
2435
+ agent,
2436
+ process.cwd(),
2437
+ sinceMs,
2438
+ () => this.transcriptMissingNote()
2439
+ );
2440
+ this.reader.onRootOpen((key, path) => {
2441
+ void this.tmux.conversationMark(agent.kind, key, path);
2442
+ });
2443
+ if (running !== null) {
2444
+ this.reader.fileAdd(running.path, running.key);
2445
+ } else if (isHooked) {
2446
+ this.reader.locateDefer();
2447
+ }
2448
+ this.reader.start();
2449
+ if (running === null) this.announceBind();
2450
+ }
2451
+ }
2452
+ // Hands the terminal to the pane and waits. Returns the child's exit code.
2453
+ async attach() {
2454
+ return new Promise((resolve) => {
2455
+ this.tmux.attach().on("exit", (code) => resolve(code ?? 0));
2456
+ });
2457
+ }
2458
+ // Whether the tmux session outlived the attach -- the person detached rather
2459
+ // than the agent exiting. Their work is still running, so it must not be killed.
2460
+ async isDetached() {
2461
+ return this.tmux.hasSession();
2462
+ }
2463
+ async steer(text) {
2464
+ await this.tmux.sendText(text, this.options.steerMode === "send");
2465
+ }
2466
+ // Unwinds everything acquired in start(), in reverse. `isDetached` decides
2467
+ // whether the agent keeps running: killing a session someone detached from
2468
+ // destroys work they expected to come back to.
2469
+ async stop(isDetached) {
2470
+ if (this.bindTimer) clearTimeout(this.bindTimer);
2471
+ await this.reader?.stop();
2472
+ if (!isDetached) await this.tmux.kill();
2473
+ this.hook?.close();
2474
+ if (this.reader && !this.reader.isReading) this.transcriptMissingNote();
2475
+ if (isDetached) {
2476
+ process.stdout.write(
2477
+ `[directed] detached; your agent is still running. Reattach with: tmux attach -t ${this.tmux.session}
2478
+ `
2479
+ );
2480
+ }
2481
+ }
2482
+ // Once: the reader says it after its deadline, and stop() says it for a
2483
+ // session that ended before the deadline came.
2484
+ transcriptMissingNote() {
2485
+ if (this.isMissingNoted) return;
2486
+ this.isMissingNoted = true;
2487
+ this.note(TRANSCRIPT_MISSING_NOTE);
2488
+ }
2489
+ announceBind() {
2490
+ const hook = this.hook;
2491
+ const reader = this.reader;
2492
+ if (!hook || !reader) return;
2493
+ let isBound = false;
2494
+ hook.onAnnounce((announced) => {
2495
+ isBound = true;
2496
+ reader.fileAdd(announced.transcriptPath, announced.sessionId);
2497
+ });
2498
+ this.bindTimer = setTimeout(() => {
2499
+ if (!isBound) reader.locateAllow();
2500
+ }, BIND_DEADLINE_MS);
2501
+ }
2502
+ };
2503
+ async function withDeadline(work, ms) {
2504
+ let timer;
2505
+ try {
2506
+ return await Promise.race([
2507
+ work,
2508
+ new Promise((_, reject) => {
2509
+ timer = setTimeout(
2510
+ () => reject(new Error(`timed out after ${ms}ms`)),
2511
+ ms
2512
+ );
2513
+ })
2514
+ ]);
2515
+ } finally {
2516
+ clearTimeout(timer);
2517
+ }
2518
+ }
2519
+
2520
+ // src/tmux.ts
2521
+ import { spawn } from "child_process";
2522
+ var SESSION_OPTIONS = [
2523
+ ["status", "off"],
2524
+ ["mouse", "on"],
2525
+ ["history-limit", "50000"]
2526
+ ];
2527
+ var TAG_SOURCE = "@directed-source";
2528
+ var TAG_KEY = "@directed-key";
2529
+ var TAG_PATH = "@directed-path";
2530
+ async function conversationSession(bin, source, key, runner) {
2531
+ const run = runner ?? defaultRunner(bin);
2532
+ let listed;
2533
+ try {
2534
+ listed = await run([
2535
+ "list-sessions",
2536
+ "-F",
2537
+ `#{session_name} #{${TAG_SOURCE}} #{${TAG_KEY}} #{${TAG_PATH}}`
2538
+ ]);
2539
+ } catch {
2540
+ return null;
2541
+ }
2542
+ for (const line of listed.split("\n")) {
2543
+ const [name, taggedSource, taggedKey, taggedPath] = line.split(" ");
2544
+ if (taggedSource === source && taggedKey === key && name) {
2545
+ return { session: name, path: taggedPath || null };
2546
+ }
2547
+ }
2548
+ return null;
2549
+ }
2550
+ function defaultRunner(bin) {
2551
+ return (args) => new Promise((resolve, reject) => {
2552
+ const p = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
2553
+ let out = "", err = "";
2554
+ p.stdout.on("data", (d) => out += d);
2555
+ p.stderr.on("data", (d) => err += d);
2556
+ p.on("error", reject);
2557
+ p.on("exit", (code) => code === 0 ? resolve(out) : reject(new Error(`tmux ${args.join(" ")} failed: ${err.trim()}`)));
2558
+ });
2559
+ }
2560
+ var Tmux = class {
2561
+ constructor(bin, session, runner) {
2562
+ this.bin = bin;
2563
+ this.session = session;
2564
+ this.runner = runner ?? defaultRunner(bin);
2565
+ }
2566
+ bin;
2567
+ session;
2568
+ runner;
2569
+ inputBufferNumber = 0;
2570
+ // Web and hub steers arrive independently; keep each paste and its submit
2571
+ // key together so simultaneous messages cannot interleave in the pane.
2572
+ inputWrite = Promise.resolve();
2573
+ // Detached sessions default to 80x24 until a client attaches; sizing the
2574
+ // pane up front means everything the child draws before attach (a resumed
2575
+ // agent repaints its whole conversation immediately) wraps at the real
2576
+ // terminal width.
2577
+ async newSession(command, cols, rows) {
2578
+ await this.runner([
2579
+ "new-session",
2580
+ "-d",
2581
+ "-s",
2582
+ this.session,
2583
+ "-x",
2584
+ String(cols),
2585
+ "-y",
2586
+ String(rows),
2587
+ "--",
2588
+ ...command
2589
+ ]);
2590
+ for (const [name, value] of SESSION_OPTIONS) {
2591
+ await this.runner(["set-option", "-t", this.session, name, value]).catch(() => "");
2592
+ }
2593
+ }
2594
+ // Names this pane's conversation on the tmux session itself, so a later launch
2595
+ // can find it. tmux outlives directed -- closing the terminal kills the
2596
+ // wrapper and leaves the agent running -- so the pane is the only place this
2597
+ // can be written where it will still be there to read.
2598
+ async conversationMark(source, key, path) {
2599
+ await this.runner(["set-option", "-t", this.session, TAG_SOURCE, source]).catch(() => "");
2600
+ await this.runner(["set-option", "-t", this.session, TAG_KEY, key]).catch(() => "");
2601
+ await this.runner(["set-option", "-t", this.session, TAG_PATH, path]).catch(() => "");
2602
+ }
2603
+ sendText(text, submit) {
2604
+ const write = this.inputWrite.then(() => this.textWrite(text, submit));
2605
+ this.inputWrite = write.catch(() => {
2606
+ });
2607
+ return write;
2608
+ }
2609
+ // A person who scrolled is in copy-mode, and a pane in copy-mode swallows the
2610
+ // paste and keeps the Enter: the steer never reaches the agent, and the next
2611
+ // one runs into it as one garbled line. Leaving copy-mode costs a scroll
2612
+ // position; staying in it costs the message.
2613
+ async modeExit() {
2614
+ const mode = await this.runner([
2615
+ "display-message",
2616
+ "-p",
2617
+ "-t",
2618
+ this.session,
2619
+ "#{pane_in_mode}"
2620
+ ]);
2621
+ if (mode.trim() !== "1") return;
2622
+ await this.runner(["send-keys", "-X", "-t", this.session, "cancel"]);
2623
+ }
2624
+ async textWrite(text, submit) {
2625
+ await this.modeExit().catch(() => {
2626
+ });
2627
+ const buffer = `${this.session}-input-${this.inputBufferNumber}`;
2628
+ this.inputBufferNumber += 1;
2629
+ await this.runner(["set-buffer", "-b", buffer, "--", text]);
2630
+ await this.runner(["paste-buffer", "-dpr", "-b", buffer, "-t", this.session]);
2631
+ if (submit) await this.runner(["send-keys", "-t", this.session, "Enter"]);
2632
+ }
2633
+ async hasSession() {
2634
+ return this.runner(["has-session", "-t", this.session]).then(() => true).catch(() => false);
2635
+ }
2636
+ attach() {
2637
+ return spawn(this.bin, ["attach", "-t", this.session], { stdio: "inherit" });
2638
+ }
2639
+ kill() {
2640
+ return this.runner(["kill-session", "-t", this.session]).then(() => {
2641
+ }).catch(() => {
2642
+ });
2643
+ }
2644
+ };
2645
+
2646
+ // src/update.ts
2647
+ import { mkdtempSync as mkdtempSync2, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "fs";
2648
+ import { spawnSync } from "child_process";
2649
+ import { homedir as homedir4, tmpdir as tmpdir2 } from "os";
2650
+ import { join as join5 } from "path";
2651
+ function installRoot() {
2652
+ return process.env.DIRECTED_CLI_INSTALL_ROOT ?? join5(homedir4(), ".directed");
2653
+ }
2654
+ function installChannel(entry = process.argv[1] ?? "") {
2655
+ let resolved;
2656
+ try {
2657
+ resolved = realpathSync(entry);
2658
+ } catch {
2659
+ return "unknown";
2660
+ }
2661
+ if (resolved.startsWith(`${realpathOr(installRoot())}/`)) return "curl";
2662
+ if (resolved.includes("/node_modules/")) return "npm";
2663
+ return "unknown";
2664
+ }
2665
+ function realpathOr(path) {
2666
+ try {
2667
+ return realpathSync(path);
2668
+ } catch {
2669
+ return path;
2670
+ }
2671
+ }
2672
+ async function updatePrompt(currentVersion, minimumVersion, terminal) {
2673
+ await promptConfirm(
2674
+ `[directed] Directed ${minimumVersion} or newer is required (you have ${currentVersion}).`,
2675
+ "Press Enter to upgrade and continue. Ctrl-C to exit.",
2676
+ terminal
2677
+ );
2678
+ }
2679
+ function updateInstructions(channel) {
2680
+ if (channel === "npm") return "npm install -g @directed/cli@latest";
2681
+ return "curl -fsSL https://hub.directed.ai/cli/install.sh | bash";
2682
+ }
2683
+ async function updateInstall(hubUrl, channel = installChannel(), fetchImpl = fetch) {
2684
+ if (channel === "npm") return npmInstall();
2685
+ if (channel === "curl") return curlInstall(hubUrl, fetchImpl);
2686
+ process.stderr.write(
2687
+ `[directed] this build was not installed by npm or the installer, so it cannot upgrade itself.
2688
+ Install a current one with: ${updateInstructions("curl")}
2689
+ `
2690
+ );
2691
+ return 1;
2692
+ }
2693
+ function npmInstall() {
2694
+ process.stdout.write("[directed] installing @directed/cli@latest from npm\n");
2695
+ const result = spawnSync("npm", ["install", "-g", "@directed/cli@latest"], {
2696
+ stdio: "inherit"
2697
+ });
2698
+ if (result.error) {
2699
+ process.stderr.write(`[directed] npm could not run (${result.error.message}).
2700
+ `);
2701
+ return 1;
2702
+ }
2703
+ return result.status ?? 1;
2704
+ }
2705
+ async function curlInstall(hubUrl, fetchImpl) {
2706
+ const scriptUrl = `${hubUrl}/cli/install.sh`;
2707
+ process.stdout.write(`[directed] fetching installer from ${scriptUrl}
2708
+ `);
2709
+ const res = await fetchImpl(scriptUrl);
2710
+ if (!res.ok) {
2711
+ console.error(`[directed] could not fetch installer: ${res.status} ${res.statusText}`);
2712
+ return 1;
2713
+ }
2714
+ const script = await res.text();
2715
+ const dir = mkdtempSync2(join5(tmpdir2(), "directed-upgrade-"));
2716
+ try {
2717
+ const scriptPath = join5(dir, "install.sh");
2718
+ writeFileSync2(scriptPath, script, { mode: 493 });
2719
+ const result = spawnSync("bash", [scriptPath], {
2720
+ stdio: "inherit",
2721
+ env: {
2722
+ ...process.env,
2723
+ DIRECTED_CLI_BASE_URL: hubUrl,
2724
+ DIRECTED_CLI_UPGRADE: "1"
2725
+ }
2726
+ });
2727
+ return result.status ?? 1;
2728
+ } finally {
2729
+ rmSync3(dir, { recursive: true, force: true });
1552
2730
  }
1553
2731
  }
2732
+ function updateLaunch(argv, channel = installChannel()) {
2733
+ const spawn3 = channel === "curl" ? { command: join5(installRoot(), "bin", "directed"), args: argv } : { command: process.execPath, args: [process.argv[1] ?? "", ...argv] };
2734
+ const result = spawnSync(spawn3.command, spawn3.args, {
2735
+ stdio: "inherit",
2736
+ env: { ...process.env, DIRECTED_CLI_RELAUNCHED: "1" }
2737
+ });
2738
+ if (result.error) throw result.error;
2739
+ return result.status ?? 1;
2740
+ }
1554
2741
 
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;
2742
+ // src/cli.ts
2743
+ var require3 = createRequire2(import.meta.url);
2744
+ async function main(argv = process.argv.slice(2)) {
2745
+ if (isHelpRequest(argv)) {
2746
+ process.stdout.write(`${helpText()}
2747
+ `);
2748
+ return 0;
1561
2749
  }
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;
2750
+ if (argv[0] === "__hook") return hookRun();
2751
+ if (argv[0] === "upgrade") return updateInstall(HUB_URL);
2752
+ if (argv[0] === "login") {
2753
+ const state = await authLogin(new Hub(), browserOpen);
2754
+ process.stdout.write(
2755
+ `[directed] signed in as ${state.identity.email} (${HUB_URL})
2756
+ `
2757
+ );
2758
+ return 0;
2759
+ }
2760
+ if (argv[0] === "logout") {
2761
+ authClear(HUB_URL);
2762
+ process.stdout.write(`[directed] signed out of ${HUB_URL}
2763
+ `);
2764
+ return 0;
2765
+ }
2766
+ if (argv[0] === "whoami") {
2767
+ const state = authLoad(HUB_URL);
2768
+ if (state === null) {
2769
+ process.stdout.write(
2770
+ `[directed] signed out (${HUB_URL}); run '${binName()} login'
2771
+ `
2772
+ );
2773
+ return 1;
1568
2774
  }
1569
- return this.refreshNow();
2775
+ process.stdout.write(`[directed] ${state.identity.email} (${HUB_URL})
2776
+ `);
2777
+ return 0;
2778
+ }
2779
+ const options = optionsParse(argv);
2780
+ try {
2781
+ execFileSync(options.tmuxBin, ["-V"], { stdio: "ignore" });
2782
+ } catch {
2783
+ console.error(
2784
+ "directed needs tmux on PATH (or pass --tmux-bin <path>). Install it, e.g. 'brew install tmux'."
2785
+ );
2786
+ return 1;
1570
2787
  }
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();
2788
+ return sessionRun(options, argv);
2789
+ }
2790
+ async function sessionRun(options, argv) {
2791
+ const hub = new Hub();
2792
+ const agent = agentResolve(options.command);
2793
+ const shouldShare = options.chatEnabled && agent !== null;
2794
+ let auth = shouldShare ? authSessionLoad(hub) : null;
2795
+ if (shouldShare && auth === null) {
2796
+ if (!promptIsInteractive()) {
2797
+ process.stderr.write(
2798
+ `[directed] sign-in is required to share this session; use an interactive terminal.
2799
+ `
2800
+ );
2801
+ return 1;
1577
2802
  }
1578
2803
  try {
1579
- return await this.refreshing;
1580
- } finally {
1581
- this.refreshing = null;
2804
+ const state = await authPrompt(hub, browserOpen, PROMPT_TERMINAL);
2805
+ auth = new AuthSession(hub, state);
2806
+ process.stderr.write(`[directed] signed in as ${state.identity.email}
2807
+ `);
2808
+ } catch (error) {
2809
+ if (error instanceof PromptCancelledError) return 130;
2810
+ process.stderr.write(
2811
+ `[directed] sign-in failed (${error.message}); the shared session was not started.
2812
+ `
2813
+ );
2814
+ return 1;
1582
2815
  }
1583
2816
  }
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;
2817
+ const isCapturing = shouldShare && auth !== null;
2818
+ let localOnly = null;
2819
+ if (options.chatEnabled && agent === null) {
2820
+ localOnly = "chat streaming only works for claude and codex sessions; this tool has no transcript.";
1589
2821
  }
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;
2822
+ let chatPublicId2 = null;
2823
+ if (isCapturing && auth !== null && options.chatTarget.kind !== "new") {
2824
+ const selected = await authRetry(hub, auth, async (currentAuth) => {
2825
+ hub.authAttach(currentAuth);
2826
+ return chatPick(hub, options.chatTarget);
2827
+ });
2828
+ if (selected.kind === "exit") return selected.code;
2829
+ auth = selected.auth;
2830
+ if (selected.kind === "failed") {
2831
+ console.error(`[directed] ${selected.error.message}`);
2832
+ return 1;
2833
+ }
2834
+ chatPublicId2 = selected.value;
1609
2835
  }
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}`);
2836
+ const held = [];
2837
+ let isTerminalFree = false;
2838
+ const note = (text) => {
2839
+ if (isTerminalFree) process.stderr.write(`[directed] ${text}
2840
+ `);
2841
+ else held.push(text);
2842
+ };
2843
+ const running = agent === null ? null : await runningFind(agent, options);
2844
+ let session = null;
2845
+ let isHeldRetrying = false;
2846
+ if (isCapturing && auth !== null && agent !== null) {
2847
+ const opened = await sessionOpen(
2848
+ hub,
2849
+ auth,
2850
+ agent,
2851
+ options,
2852
+ chatPublicId2,
2853
+ argv,
2854
+ running !== null
2855
+ );
2856
+ if (opened.kind === "exit") return opened.code;
2857
+ if (opened.kind === "opened") {
2858
+ auth = opened.auth;
2859
+ session = opened.session;
2860
+ }
2861
+ if (opened.kind === "held") {
2862
+ auth = opened.auth;
2863
+ isHeldRetrying = true;
2864
+ }
2865
+ if (opened.kind === "local") localOnly = opened.why;
1620
2866
  }
1621
- return `${lines.join("\n")}
1622
- `;
1623
- }
1624
- async function chatTargetPick(hub, accessToken, query, terminal = DEFAULT_TERMINAL) {
1625
- let prompt = null;
2867
+ const isSharing = session !== null || isHeldRetrying;
2868
+ if (localOnly !== null)
2869
+ process.stderr.write(`[directed] ${localOnly} Running locally.
2870
+ `);
2871
+ const tmux = new Tmux(
2872
+ options.tmuxBin,
2873
+ running?.session ?? `directed-${randomBytes3(4).toString("hex")}`
2874
+ );
2875
+ const pane = new PaneSession(options, tmux, isSharing ? agent : null, note);
2876
+ await pane.start(running?.pane ?? null);
2877
+ const captureAbort = new AbortController();
2878
+ const capturing = isSharing && auth !== null && agent !== null ? captureBind(
2879
+ session,
2880
+ hub,
2881
+ auth,
2882
+ agent,
2883
+ options,
2884
+ pane,
2885
+ chatPublicId2,
2886
+ note,
2887
+ captureAbort.signal
2888
+ ) : Promise.resolve(null);
1626
2889
  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;
2890
+ return await pane.attach();
1647
2891
  } finally {
1648
- prompt?.close();
2892
+ const isDetached = await pane.isDetached();
2893
+ captureAbort.abort();
2894
+ const capture = await capturing;
2895
+ await pane.stop(isDetached);
2896
+ isTerminalFree = true;
2897
+ for (const text of held) process.stderr.write(`[directed] ${text}
2898
+ `);
2899
+ held.length = 0;
2900
+ if (capture) await capture.stop(isDetached);
2901
+ if (localOnly !== null) process.stderr.write(`[directed] ${localOnly}
2902
+ `);
1649
2903
  }
1650
2904
  }
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";
2905
+ async function runningFind(agent, options) {
2906
+ const key = await agent.resumeKey(options.command, process.cwd());
2907
+ if (key === null) return null;
2908
+ const found = await conversationSession(options.tmuxBin, agent.kind, key);
2909
+ if (found === null) return null;
2910
+ const path = found.path ?? await agent.transcriptPath(key);
2911
+ if (path === null) return null;
2912
+ return { session: found.session, pane: { key, path } };
1686
2913
  }
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;
2914
+ function binName() {
2915
+ return basename4(process.argv[1] ?? "directed");
1690
2916
  }
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;
2917
+ async function sessionOpen(hub, auth, agent, options, chatPublicId2, argv, isRejoiningPane) {
2918
+ const opened = await authRetry(
2919
+ hub,
2920
+ auth,
2921
+ (currentAuth) => sessionOpenOnce(hub, currentAuth, agent, options, chatPublicId2)
2922
+ );
2923
+ if (opened.kind === "exit") return opened;
2924
+ if (opened.kind === "succeeded") {
2925
+ return { kind: "opened", session: opened.value, auth: opened.auth };
2926
+ }
2927
+ const error = opened.error;
2928
+ if (error instanceof UpgradeRequiredError) return upgradeRun(error, argv);
2929
+ if (error instanceof TerminalHeldError) {
2930
+ if (isRejoiningPane) {
2931
+ process.stderr.write(
2932
+ `[directed] joined the session streaming to ${error.chatUrl}; the original connection keeps posting.
2933
+ `
2934
+ );
2935
+ return { kind: "held", auth: opened.auth };
1741
2936
  }
2937
+ return {
2938
+ kind: "local",
2939
+ why: `already streaming to ${error.chatUrl} from another terminal.`
2940
+ };
1742
2941
  }
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
- };
2942
+ return sharingFailed(error.message);
1761
2943
  }
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) {
2944
+ async function authRetry(hub, auth, operation) {
1777
2945
  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;
2946
+ return { kind: "succeeded", value: await operation(auth), auth };
2947
+ } catch (error) {
2948
+ if (!(error instanceof AuthenticationRequiredError)) {
2949
+ return { kind: "failed", error, auth };
2950
+ }
1794
2951
  }
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;
2952
+ authClear(hub.url);
2953
+ if (!promptIsInteractive()) {
2954
+ process.stderr.write(
2955
+ `[directed] the saved sign-in is no longer valid; run '${binName()} login' from an interactive terminal, or use --no-chat.
2956
+ `
2957
+ );
2958
+ return { kind: "exit", code: 1 };
1799
2959
  }
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
- );
2960
+ let replacement;
1814
2961
  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}
2962
+ process.stderr.write(`[directed] the saved sign-in is no longer valid.
1824
2963
  `);
1825
- if (chat.skippedInvitees.length > 0) {
1826
- process.stderr.write(`[directed] not added (not your active connections): ${chat.skippedInvitees.join(", ")}
2964
+ const state = await authPrompt(hub, browserOpen, PROMPT_TERMINAL);
2965
+ replacement = new AuthSession(hub, state);
2966
+ process.stderr.write(`[directed] signed in as ${state.identity.email}
1827
2967
  `);
2968
+ } catch (error) {
2969
+ if (error instanceof PromptCancelledError) return { kind: "exit", code: 130 };
2970
+ process.stderr.write(
2971
+ `[directed] sign-in failed (${error.message}); the shared session was not started.
2972
+ `
2973
+ );
2974
+ return { kind: "exit", code: 1 };
2975
+ }
2976
+ try {
2977
+ return {
2978
+ kind: "succeeded",
2979
+ value: await operation(replacement),
2980
+ auth: replacement
2981
+ };
2982
+ } catch (error) {
2983
+ if (!(error instanceof AuthenticationRequiredError)) {
2984
+ return { kind: "failed", error, auth: replacement };
1828
2985
  }
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;
2986
+ authClear(hub.url);
2987
+ process.stderr.write(
2988
+ `[directed] sign-in rejected. Try '${binName()} login', or --no-chat.
2989
+ `
2990
+ );
2991
+ return { kind: "exit", code: 1 };
1835
2992
  }
1836
2993
  }
1837
- async function main(argv = process.argv.slice(2)) {
1838
- if (isHelpRequest(argv)) {
1839
- process.stdout.write(`${helpText()}
2994
+ async function sessionOpenOnce(hub, auth, agent, options, chatPublicId2) {
2995
+ hub.authAttach(auth);
2996
+ return HubSession.open(hub, {
2997
+ source: agent.kind,
2998
+ title: options.title,
2999
+ resumeKey: await agent.resumeKey(options.command, process.cwd()),
3000
+ chatPublicId: chatPublicId2,
3001
+ memberList: options.chatMembers,
3002
+ gitStart: await gitSnapshot(process.cwd())
3003
+ });
3004
+ }
3005
+ async function sharingFailed(reason) {
3006
+ const cause = /fetch failed|abort|timeout/i.test(reason) ? "can't reach the hub" : `could not start a shared session (${reason})`;
3007
+ const why = `${cause}; nothing will be shared or steerable.`;
3008
+ if (!promptIsInteractive()) {
3009
+ process.stderr.write(`[directed] ${why} Run with --no-chat to work locally.
1840
3010
  `);
1841
- return 0;
3011
+ return { kind: "exit", code: 1 };
1842
3012
  }
1843
- if (argv[0] === "upgrade") {
1844
- return upgradeRun(DEFAULTS.hubUrl);
3013
+ try {
3014
+ await promptConfirm(
3015
+ `[directed] ${why}`,
3016
+ "Press Enter to run locally. Ctrl-C to exit.",
3017
+ PROMPT_TERMINAL
3018
+ );
3019
+ } catch (e) {
3020
+ if (e instanceof PromptCancelledError) return { kind: "exit", code: 130 };
3021
+ throw e;
1845
3022
  }
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;
3023
+ return { kind: "local", why };
3024
+ }
3025
+ async function upgradeRun(refusal, argv) {
3026
+ const channel = installChannel();
3027
+ const version = CLI_VERSION.split("+")[0];
3028
+ if (process.env.DIRECTED_CLI_RELAUNCHED === "1") {
3029
+ process.stderr.write(
3030
+ `[directed] still running ${version} after upgrading, and this hub needs ${refusal.minimumVersion}.
3031
+ Install it with: ${updateInstructions(channel)}
3032
+ `
3033
+ );
3034
+ return { kind: "exit", code: 1 };
1853
3035
  }
1854
- if (argv[0] === "logout") {
1855
- authClear();
1856
- process.stdout.write(`[directed] signed out
1857
- `);
1858
- return 0;
3036
+ if (!promptIsInteractive() || channel === "unknown") {
3037
+ process.stderr.write(
3038
+ `[directed] ${refusal.message}
3039
+ Upgrade with: ${updateInstructions(channel)}
3040
+ `
3041
+ );
3042
+ return { kind: "exit", code: 1 };
1859
3043
  }
1860
- const config = commandSplit(argv);
1861
- const hub = new HubClient(config.hubUrl);
1862
- let identity;
1863
3044
  try {
1864
- identity = await ensureAuth(hub, { autoLogin: true, open: openBrowser });
1865
- } catch (err) {
1866
- console.error(`[directed] ${err.message}`);
1867
- return 1;
3045
+ await updatePrompt(version, refusal.minimumVersion, PROMPT_TERMINAL);
3046
+ } catch (e) {
3047
+ if (e instanceof PromptCancelledError) return { kind: "exit", code: 130 };
3048
+ throw e;
3049
+ }
3050
+ if (await updateInstall(HUB_URL, channel) !== 0) {
3051
+ process.stderr.write(
3052
+ `[directed] the upgrade did not finish; the shared session was not started.
3053
+ `
3054
+ );
3055
+ return { kind: "exit", code: 1 };
1868
3056
  }
1869
- process.stdout.write(`[directed] signed in as ${identity.email}
3057
+ process.stderr.write(`[directed] restarting on the new version
1870
3058
  `);
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
- }
3059
+ return { kind: "exit", code: updateLaunch(argv, channel) };
3060
+ }
3061
+ async function captureBind(opened, hub, auth, agent, options, pane, chatPublicId2, note, signal) {
3062
+ const reader = pane.transcript;
3063
+ if (reader === null) return null;
3064
+ let session = opened;
3065
+ while (session === null) {
3066
+ if (signal.aborted || !await retryWait(signal)) return null;
1878
3067
  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;
3068
+ session = await sessionOpenOnce(hub, auth, agent, options, chatPublicId2);
3069
+ } catch (e) {
3070
+ if (e instanceof TerminalHeldError) continue;
3071
+ note(`could not open the chat (${e.message}); this session stays local.`);
3072
+ return null;
1884
3073
  }
1885
3074
  }
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;
3075
+ const capture = Capture.attach(session, auth, reader, note);
3076
+ note(
3077
+ capture.reattached ? `re-attached to its chat: ${capture.chatUrl}` : `streaming to a chat: ${capture.chatUrl}`
3078
+ );
3079
+ if (capture.skippedMemberList.length > 0) {
3080
+ note(
3081
+ `not connections yet, so not added: ${capture.skippedMemberList.join(", ")}. Use --invite instead.`
3082
+ );
1891
3083
  }
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");
3084
+ if (options.chatOpen) browserOpen(capture.chatUrl);
3085
+ if (options.chatInviteEmails.length > 0) {
3086
+ const mailto = await chatInvite(
3087
+ () => capture.linkCreate(),
3088
+ options.chatInviteEmails,
3089
+ auth.identity.fullName || auth.identity.username,
3090
+ note
3091
+ );
3092
+ if (mailto) browserOpen(mailto);
3093
+ }
3094
+ capture.steerListen(async (steer) => {
3095
+ await pane.steer(steer.text);
3096
+ return options.steerMode === "send" ? "sent" : "staged";
3097
+ });
3098
+ return capture;
3099
+ }
3100
+ function retryWait(signal) {
3101
+ return new Promise((resolve) => {
3102
+ if (signal.aborted) {
3103
+ resolve(false);
3104
+ return;
3105
+ }
3106
+ const timer = setTimeout(() => finish(true), 5e3);
3107
+ const abort = () => finish(false);
3108
+ signal.addEventListener("abort", abort, { once: true });
3109
+ function finish(shouldRetry) {
3110
+ clearTimeout(timer);
3111
+ signal.removeEventListener("abort", abort);
3112
+ resolve(shouldRetry);
1906
3113
  }
3114
+ });
3115
+ }
3116
+ function authSessionLoad(hub) {
3117
+ const state = authLoad(hub.url);
3118
+ return state === null ? null : new AuthSession(hub, state);
3119
+ }
3120
+ var OPENERS = {
3121
+ darwin: (url) => ["open", [url]],
3122
+ linux: (url) => ["xdg-open", [url]],
3123
+ win32: (url) => ["cmd", ["/c", "start", "", url]]
3124
+ };
3125
+ function browserOpen(url) {
3126
+ const opener = OPENERS[process.platform];
3127
+ if (!opener) {
3128
+ return;
1907
3129
  }
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
- `);
3130
+ const [command, args] = opener(url);
3131
+ try {
3132
+ const child = spawn2(command, args, { detached: true, stdio: "ignore" });
3133
+ child.on("error", () => {
1915
3134
  });
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]) : "";
3135
+ child.unref();
3136
+ } catch {
3137
+ return;
3138
+ }
3139
+ }
3140
+ var argv1 = process.argv[1] ? realpathSync2(process.argv[1]) : "";
1926
3141
  if (import.meta.url === pathToFileURL(argv1).href) {
1927
3142
  main().then((code) => process.exit(code)).catch((err) => {
1928
3143
  console.error(err?.message ?? err);
@@ -1930,9 +3145,5 @@ if (import.meta.url === pathToFileURL(argv1).href) {
1930
3145
  });
1931
3146
  }
1932
3147
  export {
1933
- commandSplit,
1934
- helpText,
1935
- inviteMailto,
1936
- isHelpRequest,
1937
3148
  main
1938
3149
  };