@directed/cli 0.1.2 → 0.1.3

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 (2) hide show
  1. package/dist/cli.js +2880 -1656
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -2,826 +2,2014 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { realpathSync } 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";
7
+ import { randomBytes as randomBytes3 } from "crypto";
8
+ import { execFileSync, spawn as spawn3 } from "child_process";
8
9
  import { createRequire } 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
+ });
56
143
  }
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) {
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, "-");
159
+ }
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;
74
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;
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
+ return found.length === 1 ? found[0] : null;
458
+ }
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);
457
471
  });
472
+ return found === void 0 ? null : join3(dir, found);
458
473
  }
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()}`)));
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 [];
485
+ }
486
+ const rollouts = entries.filter((f) => {
487
+ const b = basename2(f);
488
+ return b.startsWith("rollout-") && b.endsWith(".jsonl");
470
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;
499
+ }
500
+ return found;
471
501
  }
472
- var Tmux = class {
473
- constructor(bin, session, runner) {
474
- this.bin = bin;
475
- this.session = session;
476
- this.runner = runner ?? defaultRunner(bin);
502
+ async function isWrittenSince(path, sinceMs) {
503
+ try {
504
+ return (await stat2(path)).mtimeMs >= sinceMs;
505
+ } catch {
506
+ return false;
477
507
  }
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"]);
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;
503
517
  }
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 };
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();
508
538
  }
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()}`);
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;
514
642
  }
515
- return processId;
516
643
  }
517
- pipePane(fifo) {
518
- return this.runner(["pipe-pane", "-o", "-t", this.session, `cat >> ${fifo}`]).then(() => {
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}'`);
661
+ }
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 { gzip } from "zlib";
670
+ import { promisify } from "util";
671
+ var gzipAsync = promisify(gzip);
672
+ if (false)
673
+ throw new Error("$HUB_URL is required, e.g. HUB_URL=https://hub.directed.ai");
674
+ var HUB_URL = "https://hub.directed.ai";
675
+ var TIMEOUT_MS = 15e3;
676
+ var UPLOAD_TIMEOUT_MS = 6e4;
677
+ var HubError = class extends Error {
678
+ constructor(message, status) {
679
+ super(message);
680
+ this.status = status;
681
+ this.name = "HubError";
682
+ }
683
+ status;
684
+ // A 4xx will fail the same way forever, so the caller should stop retrying and
685
+ // move on. 401 is handled by a refresh before it ever reaches here; 408 and
686
+ // 429 are the timing exceptions and stay retryable.
687
+ get isPermanent() {
688
+ return this.status >= 400 && this.status < 500 && this.status !== 408 && this.status !== 429;
689
+ }
690
+ };
691
+ var TerminalHeldError = class extends HubError {
692
+ constructor(chatUrl) {
693
+ super("this conversation is already connected", 409);
694
+ this.chatUrl = chatUrl;
695
+ this.name = "TerminalHeldError";
696
+ }
697
+ chatUrl;
698
+ };
699
+ var Hub = class {
700
+ constructor(url = HUB_URL, fetchImpl = fetch) {
701
+ this.url = url;
702
+ this.fetchImpl = fetchImpl;
703
+ }
704
+ url;
705
+ fetchImpl;
706
+ auth = null;
707
+ // Once a session is signed in, every authenticated call gets its token from
708
+ // here -- and a 401 refreshes and retries once instead of being reported as a
709
+ // permanent rejection that silently drops the record.
710
+ authAttach(auth) {
711
+ this.auth = auth;
712
+ }
713
+ socketUrl(path) {
714
+ return `${this.url.replace(/^http/, "ws")}${path}`;
715
+ }
716
+ authUrl(redirectUri, challenge) {
717
+ const params = new URLSearchParams({
718
+ redirect_uri: redirectUri,
719
+ code_challenge: challenge,
720
+ code_challenge_method: "S256"
519
721
  });
722
+ return `${this.url}/users/social/auth/mobile/start/?${params.toString()}`;
520
723
  }
521
- async capturePane() {
522
- return Buffer.from(await this.runner(["capture-pane", "-p", "-e", "-t", this.session]), "utf8");
724
+ async authExchange(code, verifier) {
725
+ const body = await this.json("/api/auth/mobile/exchange/", {
726
+ method: "POST",
727
+ json: { code, code_verifier: verifier },
728
+ token: null
729
+ });
730
+ return tokenPairFrom(body);
523
731
  }
524
- sendText(text, submit) {
525
- const write = this.inputWrite.then(() => this.textWrite(text, submit));
526
- this.inputWrite = write.catch(() => {
732
+ async authRefresh(refreshToken) {
733
+ const body = await this.json("/api/auth/mobile/refresh/", {
734
+ method: "POST",
735
+ json: { refresh_token: refreshToken },
736
+ token: null
527
737
  });
528
- return write;
738
+ return tokenPairFrom(body);
529
739
  }
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"]);
740
+ async identityFind(token) {
741
+ const body = await this.json("/api/auth/me/", { token });
742
+ return {
743
+ publicId: body.public_id,
744
+ email: body.email,
745
+ username: body.username,
746
+ fullName: body.full_name
747
+ };
536
748
  }
537
- async hasSession() {
538
- return this.runner(["has-session", "-t", this.session]).then(() => true).catch(() => false);
749
+ async chatList(query) {
750
+ const body = await this.json("/api/chats/lookup/", { query: { q: query, limit: "10" } });
751
+ return body.items.map((item) => ({
752
+ publicId: item.public_id,
753
+ title: item.display_title,
754
+ url: item.chat_url
755
+ }));
539
756
  }
540
- attach() {
541
- return spawn2(this.bin, ["attach", "-t", this.session], { stdio: "inherit" });
757
+ // Mint (or reuse) the chat's shareable join link. Redeeming it seats the
758
+ // visitor as a chat member, so it works for people outside the host's
759
+ // connections -- the email-invite path.
760
+ async chatLinkCreate(chatPublicId2) {
761
+ const body = await this.json(
762
+ `/api/chats/${chatPublicId2}/link/`,
763
+ {
764
+ method: "POST",
765
+ json: {}
766
+ }
767
+ );
768
+ return body.url;
542
769
  }
543
- kill() {
544
- return this.runner(["kill-session", "-t", this.session]).then(() => {
545
- }).catch(() => {
770
+ async terminalCreate(input2) {
771
+ const response = await this.request("/api/terminal-sessions/", {
772
+ method: "POST",
773
+ expect: [409],
774
+ json: {
775
+ source: input2.source,
776
+ title: input2.title ?? "",
777
+ resume_key: input2.resumeKey ?? "",
778
+ destination_chat_public_id: input2.chatPublicId ?? "",
779
+ member_identifiers: input2.memberList ?? [],
780
+ git_start: gitSnapshotWire(input2.gitStart)
781
+ }
782
+ });
783
+ if (response.status === 409) {
784
+ const held = await response.json();
785
+ throw new TerminalHeldError(held.chat_url);
786
+ }
787
+ const body = await response.json();
788
+ return {
789
+ chatPublicId: body.chat_public_id,
790
+ terminalSessionPublicId: body.terminal_session_public_id,
791
+ chatUrl: body.chat_url,
792
+ agentActorPublicId: body.agent_actor_public_id,
793
+ reattached: body.reattached === true,
794
+ skippedMemberList: body.skipped_member_identifiers ?? [],
795
+ resumeSeq: body.resume_seq ?? 0
796
+ };
797
+ }
798
+ async terminalEnd(sessionId, state) {
799
+ await this.request(`/api/terminal-sessions/${sessionId}/end/`, {
800
+ method: "POST",
801
+ json: {
802
+ transcript_lost: state.transcriptLost,
803
+ recording_lost: state.recordingLost,
804
+ detached: state.isDetached,
805
+ git_end: gitSnapshotWire(state.gitEnd)
806
+ }
807
+ });
808
+ }
809
+ // Reports that this CLI is still attached, and hears whether ownership ended.
810
+ // Control health proves itself independently over the control socket.
811
+ async terminalHeartbeat(sessionId) {
812
+ const response = await this.request(
813
+ `/api/terminal-sessions/${sessionId}/heartbeat/`,
814
+ { method: "POST", expect: [409], json: {} }
815
+ );
816
+ return { isEnded: response.status === 409 };
817
+ }
818
+ // A contiguous slice of one agent's transcript file, sent as the file's own
819
+ // lines. Compressed: transcripts are JSON text and shrink about 5x, and this
820
+ // runs alongside the recording on the same uplink. A subagent's slice names
821
+ // the file that launched it, so lineage arrives with the data it describes.
822
+ async transcriptAppend(sessionId, batch) {
823
+ await this.request(`/api/terminal-sessions/${sessionId}/transcript/`, {
824
+ method: "POST",
825
+ query: {
826
+ file_key: batch.fileKey,
827
+ parent_key: batch.parentKey,
828
+ call_key: batch.callKey,
829
+ start_seq: String(batch.startSeq),
830
+ start_offset: String(batch.startOffset)
831
+ },
832
+ raw: {
833
+ body: await gzipAsync(Buffer.from(batch.body, "utf8")),
834
+ contentType: "application/x-ndjson"
835
+ },
836
+ timeoutMs: UPLOAD_TIMEOUT_MS
837
+ });
838
+ }
839
+ async recordingAppend(sessionId, chunk) {
840
+ await this.request(`/api/terminal-sessions/${sessionId}/recording/`, {
841
+ method: "POST",
842
+ json: {
843
+ seq: chunk.seq,
844
+ offset: chunk.offset,
845
+ events: chunk.events,
846
+ width: chunk.width,
847
+ height: chunk.height,
848
+ started_epoch: chunk.startedEpoch
849
+ }
850
+ });
851
+ }
852
+ // What the pane did with a steer, so the chat can show delivery rather than
853
+ // leaving the sender to guess.
854
+ async steerAck(sessionId, eventId, outcome) {
855
+ await this.request(
856
+ `/api/terminal-sessions/${sessionId}/steers/${eventId}/ack/`,
857
+ {
858
+ method: "POST",
859
+ json: { outcome }
860
+ }
861
+ );
862
+ }
863
+ async json(path, spec = {}) {
864
+ const response = await this.request(path, spec);
865
+ return await response.json();
866
+ }
867
+ // One timeout, one auth policy, one error shape. `token: null` means the call
868
+ // is unauthenticated; a string is an explicit token for the login path; absent
869
+ // means the attached session's token, refreshed once if the hub says it is bad.
870
+ async request(path, spec) {
871
+ const response = await this.send(path, spec, await this.tokenFor(spec));
872
+ if (response.status !== 401 || spec.token !== void 0 || this.auth === null) {
873
+ return this.checked(path, response, spec.expect);
874
+ }
875
+ return this.checked(
876
+ path,
877
+ await this.send(path, spec, await this.auth.refresh()),
878
+ spec.expect
879
+ );
880
+ }
881
+ async tokenFor(spec) {
882
+ if (spec.token !== void 0) return spec.token;
883
+ if (this.auth === null)
884
+ throw new Error("hub call needs a signed-in session");
885
+ return this.auth.token();
886
+ }
887
+ async send(path, spec, token) {
888
+ const query = spec.query ? `?${new URLSearchParams(spec.query).toString()}` : "";
889
+ const headers = {};
890
+ if (token) headers.Authorization = `Bearer ${token}`;
891
+ let body;
892
+ if (spec.raw) {
893
+ headers["Content-Type"] = spec.raw.contentType;
894
+ headers["Content-Encoding"] = "gzip";
895
+ body = spec.raw.body;
896
+ } else if (spec.json !== void 0) {
897
+ headers["Content-Type"] = "application/json";
898
+ body = JSON.stringify(spec.json);
899
+ }
900
+ return this.fetchImpl(`${this.url}${path}${query}`, {
901
+ method: spec.method ?? "GET",
902
+ headers,
903
+ body,
904
+ signal: AbortSignal.timeout(spec.timeoutMs ?? TIMEOUT_MS)
905
+ });
906
+ }
907
+ async checked(path, response, expect) {
908
+ if (response.ok || expect?.includes(response.status)) return response;
909
+ let detail = "";
910
+ try {
911
+ const body = await response.clone().json();
912
+ if (typeof body.detail === "string") detail = ` (${body.detail})`;
913
+ } catch {
914
+ detail = "";
915
+ }
916
+ throw new HubError(
917
+ `${path} failed: ${response.status}${detail}`,
918
+ response.status
919
+ );
920
+ }
921
+ };
922
+ function gitSnapshotWire(snapshot) {
923
+ if (!snapshot) return null;
924
+ return {
925
+ sha: snapshot.sha,
926
+ branch: snapshot.branch,
927
+ root: snapshot.root,
928
+ dirty_paths: snapshot.dirtyPaths
929
+ };
930
+ }
931
+ function tokenPairFrom(body) {
932
+ return {
933
+ access: body.access_token,
934
+ refresh: body.refresh_token,
935
+ expiresAt: Date.now() + body.expires_in * 1e3
936
+ };
937
+ }
938
+
939
+ // src/auth.ts
940
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
941
+ import { existsSync, mkdirSync, readFileSync, rmSync as rmSync2, writeFileSync } from "fs";
942
+ import http from "http";
943
+ import { homedir as homedir3 } from "os";
944
+ import { dirname as dirname2, join as join4 } from "path";
945
+ var EXPIRY_SKEW_MS = 6e4;
946
+ var LOGIN_TIMEOUT_MS = 3e5;
947
+ function authPath(hubUrl) {
948
+ const home = process.env.DIRECTED_HOME ?? homedir3();
949
+ const url = new URL(hubUrl);
950
+ const key = url.port ? `${url.hostname}-${url.port}` : url.hostname;
951
+ return join4(home, ".directed", "auth", `${key}.json`);
952
+ }
953
+ function authLoad(hubUrl) {
954
+ try {
955
+ const stored = JSON.parse(readFileSync(authPath(hubUrl), "utf8"));
956
+ if (stored.hubUrl !== hubUrl) return null;
957
+ return stored;
958
+ } catch {
959
+ return null;
960
+ }
961
+ }
962
+ function authSave(state) {
963
+ const path = authPath(state.hubUrl);
964
+ mkdirSync(dirname2(path), { recursive: true });
965
+ writeFileSync(path, JSON.stringify(state, null, 2), { mode: 384 });
966
+ }
967
+ function authClear(hubUrl) {
968
+ const path = authPath(hubUrl);
969
+ if (existsSync(path)) rmSync2(path);
970
+ }
971
+ var AuthSession = class {
972
+ constructor(hub, state) {
973
+ this.hub = hub;
974
+ this.state = state;
975
+ }
976
+ hub;
977
+ state;
978
+ refreshing = null;
979
+ get identity() {
980
+ return this.state.identity;
981
+ }
982
+ async token() {
983
+ if (this.state.expiresAt - EXPIRY_SKEW_MS > Date.now()) return this.state.access;
984
+ return this.refresh();
985
+ }
986
+ // Refreshes whatever the local clock believes, deduped against a concurrent
987
+ // call. For when the server has already said the token is bad -- a 401, or a
988
+ // socket close code 4002.
989
+ async refresh() {
990
+ if (!this.refreshing) this.refreshing = this.refreshOnce();
991
+ try {
992
+ return await this.refreshing;
993
+ } finally {
994
+ this.refreshing = null;
995
+ }
996
+ }
997
+ async refreshOnce() {
998
+ const pair = await this.hub.authRefresh(this.state.refresh);
999
+ this.state = {
1000
+ ...this.state,
1001
+ access: pair.access,
1002
+ refresh: pair.refresh,
1003
+ expiresAt: pair.expiresAt
1004
+ };
1005
+ authSave(this.state);
1006
+ return this.state.access;
1007
+ }
1008
+ };
1009
+ function authLogin(hub, open3) {
1010
+ const { verifier, challenge } = pkce();
1011
+ return new Promise((resolve, reject) => {
1012
+ let settled = false;
1013
+ const server = http.createServer((req, res) => {
1014
+ const code = codeFromRequestUrl(req.url);
1015
+ if (!code) {
1016
+ res.writeHead(400, { "Content-Type": "text/plain" });
1017
+ res.end("Missing code");
1018
+ return;
1019
+ }
1020
+ res.writeHead(200, { "Content-Type": "text/html" });
1021
+ res.end(SUCCESS_BODY);
1022
+ onCode(code);
1023
+ });
1024
+ const timer = setTimeout(() => {
1025
+ settle(() => reject(new Error("Login timed out after 300s")));
1026
+ }, LOGIN_TIMEOUT_MS);
1027
+ function settle(run) {
1028
+ if (settled) {
1029
+ return;
1030
+ }
1031
+ settled = true;
1032
+ clearTimeout(timer);
1033
+ server.close();
1034
+ run();
1035
+ }
1036
+ async function onCode(code) {
1037
+ try {
1038
+ const tok = await hub.authExchange(code, verifier);
1039
+ const identity = await hub.identityFind(tok.access);
1040
+ const stored = {
1041
+ access: tok.access,
1042
+ refresh: tok.refresh,
1043
+ expiresAt: tok.expiresAt,
1044
+ identity,
1045
+ hubUrl: hub.url
1046
+ };
1047
+ authSave(stored);
1048
+ settle(() => resolve(stored));
1049
+ } catch (err) {
1050
+ settle(() => reject(err instanceof Error ? err : new Error(String(err))));
1051
+ }
1052
+ }
1053
+ server.on("error", (err) => {
1054
+ settle(() => reject(err));
1055
+ });
1056
+ server.listen(0, "127.0.0.1", () => {
1057
+ const address = server.address();
1058
+ if (address === null || typeof address === "string") {
1059
+ settle(() => reject(new Error("Failed to determine loopback server port")));
1060
+ return;
1061
+ }
1062
+ const redirectUri = `http://127.0.0.1:${address.port}/cb`;
1063
+ open3(hub.authUrl(redirectUri, challenge));
1064
+ });
1065
+ });
1066
+ }
1067
+ function pkce() {
1068
+ const verifier = randomBytes2(32).toString("base64url");
1069
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
1070
+ return { verifier, challenge };
1071
+ }
1072
+ function codeFromRequestUrl(url) {
1073
+ const parsed = new URL(url ?? "/", "http://127.0.0.1");
1074
+ return parsed.searchParams.get("code");
1075
+ }
1076
+ var SUCCESS_BODY = `<!doctype html>
1077
+ <html lang="en">
1078
+ <head>
1079
+ <meta charset="utf-8" />
1080
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1081
+ <meta name="color-scheme" content="light dark" />
1082
+ <title>Signed in - Directed AI</title>
1083
+ <style>
1084
+ :root {
1085
+ color-scheme: light dark;
1086
+ }
1087
+ body {
1088
+ margin: 0;
1089
+ min-height: 100vh;
1090
+ display: flex;
1091
+ align-items: center;
1092
+ justify-content: center;
1093
+ font-family: system-ui, sans-serif;
1094
+ -webkit-font-smoothing: antialiased;
1095
+ -moz-osx-font-smoothing: grayscale;
1096
+ background: #fff;
1097
+ color: #1a1a1a;
1098
+ }
1099
+ main {
1100
+ text-align: center;
1101
+ padding: 0 24px;
1102
+ }
1103
+ img {
1104
+ display: block;
1105
+ margin: 0 auto 12px;
1106
+ }
1107
+ h1 {
1108
+ font-size: 24px;
1109
+ font-weight: 600;
1110
+ letter-spacing: -0.02em;
1111
+ margin: 0 0 8px;
1112
+ }
1113
+ p {
1114
+ margin: 0;
1115
+ color: #6b6b6b;
1116
+ }
1117
+ @media (prefers-color-scheme: dark) {
1118
+ body {
1119
+ background: #1a1a1a;
1120
+ color: #f5f5f5;
1121
+ }
1122
+ p {
1123
+ color: #a0a0a0;
1124
+ }
1125
+ }
1126
+ </style>
1127
+ </head>
1128
+ <body>
1129
+ <main>
1130
+ <img
1131
+ src="https://res.cloudinary.com/dnriuttuy/image/upload/v1761205887/dino-black-with-white-outline_nk3vhs.svg"
1132
+ alt="Dino"
1133
+ width="40"
1134
+ height="40"
1135
+ />
1136
+ <h1>You are signed in</h1>
1137
+ <p>You can close this tab and return to your terminal.</p>
1138
+ </main>
1139
+ </body>
1140
+ </html>`;
1141
+
1142
+ // src/chat.ts
1143
+ import { createInterface } from "readline/promises";
1144
+ import { stdin as input, stdout as output } from "process";
1145
+ var TERMINAL = { input, output };
1146
+ function chatChoice(answer, count) {
1147
+ const value = Number(answer.trim());
1148
+ if (!Number.isInteger(value) || value < 1 || value > count) return null;
1149
+ return value;
1150
+ }
1151
+ function chatPublicId(value) {
1152
+ const trimmed = value.trim();
1153
+ try {
1154
+ const url = new URL(trimmed);
1155
+ const match = url.pathname.match(/\/chat\/([^/]+)\/?$/);
1156
+ return match ? decodeURIComponent(match[1]) : trimmed;
1157
+ } catch {
1158
+ return trimmed;
1159
+ }
1160
+ }
1161
+ function chatMenu(chatList) {
1162
+ const lines = ["", "Recent Directed chats:"];
1163
+ for (const [index, chat] of chatList.entries()) {
1164
+ lines.push(`${index + 1}. ${chat.title}`);
1165
+ lines.push(` ${chat.url}`);
1166
+ }
1167
+ return `${lines.join("\n")}
1168
+ `;
1169
+ }
1170
+ async function chatPick(hub, target, terminal = TERMINAL) {
1171
+ if (target.kind === "new") return null;
1172
+ if (target.kind === "exact") {
1173
+ const wanted = chatPublicId(target.query);
1174
+ const chatList2 = await hub.chatList(target.query.trim());
1175
+ if (chatList2.some((chat) => chat.publicId === wanted)) return wanted;
1176
+ throw new Error("--into=<value> must resolve to an exact Directed chat URL or public id");
1177
+ }
1178
+ if (!terminal.input.isTTY || !terminal.output.isTTY) {
1179
+ throw new Error("--into needs an interactive terminal; use --into=<Directed chat URL or exact public id>");
1180
+ }
1181
+ const chatList = await hub.chatList("");
1182
+ if (chatList.length === 0) {
1183
+ throw new Error("No recent chats available; run without --into to start a new chat");
1184
+ }
1185
+ const prompt = createInterface({ input: terminal.input, output: terminal.output });
1186
+ try {
1187
+ terminal.output.write(chatMenu(chatList));
1188
+ const answer = await prompt.question(`Choose a chat [1-${chatList.length}]: `);
1189
+ const choice = chatChoice(answer, chatList.length);
1190
+ if (choice === null) throw new Error("Choose a listed chat number");
1191
+ return chatList[choice - 1].publicId;
1192
+ } finally {
1193
+ prompt.close();
1194
+ }
1195
+ }
1196
+ function inviteMailto(emails, joinUrl, hostName) {
1197
+ const subject = `${hostName} invited you to a live Directed session`;
1198
+ const body = [
1199
+ "Hi,",
1200
+ "",
1201
+ "I'm running a live coding session and want you in the chat -- follow along and jump in here:",
1202
+ "",
1203
+ joinUrl,
1204
+ "",
1205
+ "See you there,",
1206
+ hostName
1207
+ ].join("\n");
1208
+ return `mailto:${emails.join(",")}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
1209
+ }
1210
+ async function chatInvite(linkCreate, emails, hostName, note) {
1211
+ try {
1212
+ const joinUrl = await linkCreate();
1213
+ note(`join link: ${joinUrl}`);
1214
+ note(`opening mail draft to: ${emails.join(", ")}`);
1215
+ return inviteMailto(emails, joinUrl, hostName);
1216
+ } catch (e) {
1217
+ note(`could not prepare the email invite (${e.message}); invite them from the chat instead.`);
1218
+ return null;
1219
+ }
1220
+ }
1221
+
1222
+ // src/capture.ts
1223
+ import { execFile } from "child_process";
1224
+ import { promisify as promisify2 } from "util";
1225
+
1226
+ // src/outbox.ts
1227
+ var BACKOFF_MS = [500, 1500, 4e3, 1e4];
1228
+ var BYTES_MAX_DEFAULT = 32 * 1024 * 1024;
1229
+ var Outbox = class {
1230
+ constructor(label, spec) {
1231
+ this.label = label;
1232
+ this.spec = spec;
1233
+ this.bytesMax = spec.bytesMax ?? BYTES_MAX_DEFAULT;
1234
+ }
1235
+ label;
1236
+ spec;
1237
+ pending = [];
1238
+ pendingBytes = 0;
1239
+ inFlight = null;
1240
+ lostCount = 0;
1241
+ lastFailure = "";
1242
+ isStopped = false;
1243
+ worker = null;
1244
+ wake = null;
1245
+ bytesMax;
1246
+ // Why the last send did not land, for the one summary printed once the
1247
+ // terminal is the session's own again. Kept rather than narrated: a flapping
1248
+ // hub produced 62 identical lines over the top of a live agent, and the count
1249
+ // above is what actually matters.
1250
+ get lastError() {
1251
+ return this.lastFailure;
1252
+ }
1253
+ // What this stream produced that the hub never took, in the stream's own
1254
+ // units. Zero means the hub has everything.
1255
+ get unsentCount() {
1256
+ const queued = this.pending.reduce((sum, record) => sum + this.lossOf(record), 0);
1257
+ const flying = this.inFlight === null ? 0 : this.lossOf(this.inFlight);
1258
+ return queued + flying + this.lostCount;
1259
+ }
1260
+ add(record) {
1261
+ if (this.isStopped) return;
1262
+ this.pending.push(record);
1263
+ this.pendingBytes += this.spec.bytesOf(record);
1264
+ while (this.pendingBytes > this.bytesMax && this.pending.length > 0) {
1265
+ const dropped = this.pending.shift();
1266
+ this.pendingBytes -= this.spec.bytesOf(dropped);
1267
+ this.lostCount += this.lossOf(dropped);
1268
+ this.lastFailure = "backlog full";
1269
+ }
1270
+ this.wake?.();
1271
+ this.workerStart();
1272
+ }
1273
+ // Sends what is left, until `deadline` passes. Bounded on purpose: a hub that
1274
+ // is down must never hold the terminal. Whatever is still queued is reported
1275
+ // as loss, which is the session's integrity state, not a warning.
1276
+ async flush(deadline) {
1277
+ this.workerStart();
1278
+ while ((this.pending.length > 0 || this.inFlight !== null) && Date.now() < deadline) {
1279
+ await Promise.race([sleep(50), this.worker ?? Promise.resolve()]);
1280
+ }
1281
+ this.isStopped = true;
1282
+ this.wake?.();
1283
+ }
1284
+ lossOf(record) {
1285
+ return this.spec.lossOf ? this.spec.lossOf(record) : 1;
1286
+ }
1287
+ workerStart() {
1288
+ if (this.worker) return;
1289
+ this.worker = this.run().finally(() => {
1290
+ this.worker = null;
546
1291
  });
547
1292
  }
548
- };
549
-
550
- // src/conversation-log.ts
551
- var ConversationLog = class {
552
- turns = [];
553
- subs = /* @__PURE__ */ new Set();
554
- add(turn) {
555
- this.turns.push(turn);
556
- for (const s of this.subs) {
557
- try {
558
- s(turn);
559
- } catch {
560
- this.subs.delete(s);
1293
+ // One record at a time, backing off on failure and resuming by itself. The
1294
+ // old version returned after exhausting its retries and only woke when the
1295
+ // next record arrived -- so an idle pane could sit on a parked retry forever.
1296
+ async run() {
1297
+ let attempt = 0;
1298
+ while (!this.isStopped) {
1299
+ const record = this.pending.shift();
1300
+ if (record === void 0) return;
1301
+ this.pendingBytes -= this.spec.bytesOf(record);
1302
+ this.inFlight = record;
1303
+ const outcome = await this.attempt(record);
1304
+ this.inFlight = null;
1305
+ if (outcome === "sent") {
1306
+ attempt = 0;
1307
+ continue;
1308
+ }
1309
+ if (outcome === "rejected") {
1310
+ this.lostCount += this.lossOf(record);
1311
+ attempt = 0;
1312
+ continue;
561
1313
  }
1314
+ this.pending.unshift(record);
1315
+ this.pendingBytes += this.spec.bytesOf(record);
1316
+ await this.pause(backoffMs(attempt));
1317
+ attempt += 1;
562
1318
  }
563
1319
  }
564
- snapshot() {
565
- return [...this.turns];
1320
+ async attempt(record) {
1321
+ try {
1322
+ await this.spec.send(record);
1323
+ return "sent";
1324
+ } catch (e) {
1325
+ this.lastFailure = String(e);
1326
+ if (e instanceof HubError && e.isPermanent) return "rejected";
1327
+ return "retry";
1328
+ }
566
1329
  }
567
- subscribe(cb) {
568
- this.subs.add(cb);
569
- return () => this.subs.delete(cb);
1330
+ // Sleeps, but returns early when a record arrives or the outbox stops, so a
1331
+ // shutdown never waits out a full backoff.
1332
+ pause(ms) {
1333
+ return new Promise((resolve) => {
1334
+ const timer = setTimeout(finish, ms);
1335
+ const self = this;
1336
+ this.wake = finish;
1337
+ function finish() {
1338
+ clearTimeout(timer);
1339
+ self.wake = null;
1340
+ resolve();
1341
+ }
1342
+ });
570
1343
  }
571
1344
  };
1345
+ function backoffMs(attempt) {
1346
+ return BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
1347
+ }
1348
+ function sleep(ms) {
1349
+ return new Promise((resolve) => setTimeout(resolve, ms));
1350
+ }
572
1351
 
573
- // src/recorder.ts
574
- var Recorder = class {
575
- startMs;
1352
+ // src/recording.ts
1353
+ var FLUSH_INTERVAL_MS = 250;
1354
+ var FLUSH_BYTES_MAX = 16 * 1024;
1355
+ var BACKLOG_BYTES_MAX = 4 * 1024 * 1024;
1356
+ var Recording = class {
1357
+ constructor(headerWidth, headerHeight) {
1358
+ this.headerWidth = headerWidth;
1359
+ this.headerHeight = headerHeight;
1360
+ this.width = headerWidth;
1361
+ this.height = headerHeight;
1362
+ }
1363
+ headerWidth;
1364
+ headerHeight;
1365
+ startMs = monotonicMs();
1366
+ startedEpoch = Math.floor(Date.now() / 1e3);
1367
+ buffer = [];
1368
+ bufferBytes = 0;
1369
+ droppedCount = 0;
1370
+ sink = null;
1371
+ timer = null;
1372
+ isStopped = false;
1373
+ // The pane's size now, for deciding whether a poll saw a change. The header
1374
+ // size below is the asciicast's opening geometry and never moves -- every
1375
+ // later size is an "r" event, and playback rebuilds from the two together.
576
1376
  width;
577
1377
  height;
578
- subs = /* @__PURE__ */ new Set();
579
- constructor(cols, rows) {
580
- this.startMs = Date.now();
581
- this.width = cols;
582
- this.height = rows;
1378
+ get header() {
1379
+ return { width: this.headerWidth, height: this.headerHeight, startedEpoch: this.startedEpoch };
583
1380
  }
584
- output(data) {
1381
+ // Events the pane produced that no stream ever took.
1382
+ get droppedEventCount() {
1383
+ return this.droppedCount;
1384
+ }
1385
+ write(data) {
585
1386
  this.append([this.elapsed(), "o", data]);
586
1387
  }
587
- marker(label) {
1388
+ mark(label) {
588
1389
  this.append([this.elapsed(), "m", label]);
589
1390
  }
590
- header() {
591
- return {
592
- version: 2,
593
- width: this.width,
594
- height: this.height,
595
- timestamp: Math.floor(this.startMs / 1e3)
596
- };
597
- }
598
- subscribe(cb) {
599
- this.subs.add(cb);
600
- return () => this.subs.delete(cb);
601
- }
602
1391
  // 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.
1392
+ // right point in time -- a replay against stale dims mis-wraps every line the
1393
+ // app draws after the resize.
605
1394
  resize(cols, rows) {
606
1395
  if (cols === this.width && rows === this.height) return;
1396
+ const event = [this.elapsed(), "r", `${cols}x${rows}`];
607
1397
  this.width = cols;
608
1398
  this.height = rows;
609
- this.append([this.elapsed(), "r", `${cols}x${rows}`]);
1399
+ this.append(event);
610
1400
  }
611
- elapsed() {
612
- return (Date.now() - this.startMs) / 1e3;
1401
+ // Attach a stream. Whatever the pane recorded while waiting goes first, so a
1402
+ // capture that opened three seconds late still has the whole session.
1403
+ sinkAttach(sink) {
1404
+ this.sink = sink;
1405
+ this.flush();
613
1406
  }
614
- append(ev) {
615
- for (const sub of this.subs) {
616
- try {
617
- sub(ev);
618
- } catch {
619
- this.subs.delete(sub);
1407
+ sinkDetach() {
1408
+ this.flush();
1409
+ this.sink = null;
1410
+ }
1411
+ // The last flush. Anything the pane says afterwards is past the end of the
1412
+ // recording and is dropped rather than published into a session already closed.
1413
+ stop() {
1414
+ this.flush();
1415
+ this.isStopped = true;
1416
+ }
1417
+ elapsed() {
1418
+ return (monotonicMs() - this.startMs) / 1e3;
1419
+ }
1420
+ append(event) {
1421
+ if (this.isStopped) return;
1422
+ this.buffer.push(event);
1423
+ this.bufferBytes += event[2].length;
1424
+ if (this.sink === null) {
1425
+ while (this.bufferBytes > BACKLOG_BYTES_MAX && this.buffer.length > 0) {
1426
+ const dropped = this.buffer.shift();
1427
+ this.bufferBytes -= dropped[2].length;
1428
+ this.droppedCount += 1;
620
1429
  }
1430
+ return;
1431
+ }
1432
+ if (this.bufferBytes >= FLUSH_BYTES_MAX) {
1433
+ this.flush();
1434
+ return;
1435
+ }
1436
+ if (!this.timer) this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS);
1437
+ }
1438
+ flush() {
1439
+ if (this.timer) {
1440
+ clearTimeout(this.timer);
1441
+ this.timer = null;
621
1442
  }
1443
+ if (this.buffer.length === 0 || this.sink === null) return;
1444
+ const events = this.buffer;
1445
+ this.buffer = [];
1446
+ this.bufferBytes = 0;
1447
+ this.sink(events);
1448
+ }
1449
+ };
1450
+ var RecordingStream = class {
1451
+ constructor(session, recording) {
1452
+ this.session = session;
1453
+ this.recording = recording;
1454
+ recording.sinkAttach((events) => this.chunkAdd(events));
1455
+ }
1456
+ session;
1457
+ recording;
1458
+ seq = 0;
1459
+ outbox = new Outbox("recording", {
1460
+ send: (chunk) => this.chunkPost(chunk),
1461
+ bytesOf: (chunk) => chunk.events.reduce((sum, event) => sum + event[2].length, 0)
1462
+ });
1463
+ // Chunks this session produced that the hub never took, plus events the pane
1464
+ // recorded before any stream existed.
1465
+ get lastError() {
1466
+ return this.outbox.lastError;
1467
+ }
1468
+ get unsentCount() {
1469
+ return this.outbox.unsentCount + this.recording.droppedEventCount;
1470
+ }
1471
+ async stop(deadline) {
1472
+ this.recording.sinkDetach();
1473
+ await this.outbox.flush(deadline);
1474
+ }
1475
+ chunkAdd(events) {
1476
+ const seq = this.seq;
1477
+ this.seq += 1;
1478
+ this.outbox.add({ seq, offset: events[0][0], events });
1479
+ }
1480
+ // Throws on failure so the outbox retries: the token refresh and the POST are
1481
+ // both worth another attempt, and a dropped chunk is a permanent gap.
1482
+ //
1483
+ // The header rides on every chunk because the hub keeps whichever arrives
1484
+ // first, and a retry can make that any of them.
1485
+ chunkPost(chunk) {
1486
+ const header = this.recording.header;
1487
+ return this.session.recordingAppend({
1488
+ seq: chunk.seq,
1489
+ offset: chunk.offset,
1490
+ events: chunk.events,
1491
+ width: header.width,
1492
+ height: header.height,
1493
+ startedEpoch: header.startedEpoch
1494
+ });
622
1495
  }
623
1496
  };
1497
+ function monotonicMs() {
1498
+ return Number(process.hrtime.bigint() / 1000000n);
1499
+ }
624
1500
 
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;
1501
+ // src/transcript.ts
1502
+ import { open as open2, stat as stat3 } from "fs/promises";
1503
+ var POLL_MS = 400;
1504
+ var LOCATE_BACKOFF_MS = [400, 800, 1600, 3200, 5e3];
1505
+ var MISSING_DEADLINE_MS = 15e3;
1506
+ var CHILD_DEADLINE_MS = 12e4;
1507
+ var READ_BYTES_MAX = 256 * 1024;
1508
+ var NEWLINE = 10;
1509
+ var FileReader = class {
1510
+ constructor(ref, path, emit) {
1511
+ this.ref = ref;
1512
+ this.path = path;
1513
+ this.emit = emit;
1514
+ }
1515
+ ref;
1516
+ path;
1517
+ emit;
1518
+ cursor = 0;
1519
+ partial = Buffer.alloc(0);
1520
+ lineSeq = 0;
1521
+ lineOffset = 0;
1522
+ // Bumped by every rewind, so a read that was awaiting the filesystem when one
1523
+ // happened does not emit its old bytes under the new line numbering.
1524
+ generation = 0;
1525
+ hasData = false;
1526
+ rewind() {
1527
+ this.generation += 1;
1528
+ this.cursor = 0;
1529
+ this.partial = Buffer.alloc(0);
1530
+ this.lineSeq = 0;
1531
+ this.lineOffset = 0;
1532
+ }
1533
+ // One fixed-size read at a time until the file is caught up. Reading every
1534
+ // byte written since the last poll would, on a resumed transcript, allocate
1535
+ // the whole file at once.
1536
+ async read() {
1537
+ const size = await sizeOrNull(this.path);
1538
+ if (size === null) return false;
1539
+ if (size < this.cursor) this.rewind();
1540
+ const generation = this.generation;
1541
+ const handle = await open2(this.path, "r");
1542
+ try {
1543
+ while (this.cursor < size) {
1544
+ const length = Math.min(READ_BYTES_MAX, size - this.cursor);
1545
+ const buffer = Buffer.alloc(length);
1546
+ await handle.read(buffer, 0, length, this.cursor);
1547
+ if (this.generation !== generation) return this.hasData;
1548
+ this.cursor += length;
1549
+ this.hasData = true;
1550
+ this.consume(buffer);
1551
+ }
1552
+ } finally {
1553
+ await handle.close();
1554
+ }
1555
+ return this.hasData;
1556
+ }
1557
+ // Decoding happens per line, never per read: a multibyte character split
1558
+ // across two reads would otherwise become replacement characters, which the
1559
+ // hub would then store as the agent's own text.
1560
+ consume(chunk) {
1561
+ const buffer = this.partial.length > 0 ? Buffer.concat([this.partial, chunk]) : chunk;
1562
+ let start = 0;
1563
+ for (; ; ) {
1564
+ const end = buffer.indexOf(NEWLINE, start);
1565
+ if (end === -1) break;
1566
+ const line = buffer.subarray(start, end);
1567
+ this.emit(
1568
+ this.ref.key,
1569
+ line.toString("utf8"),
1570
+ this.lineSeq,
1571
+ this.lineOffset
1572
+ );
1573
+ this.lineSeq += 1;
1574
+ this.lineOffset += line.length + 1;
1575
+ start = end + 1;
1576
+ }
1577
+ this.partial = start === 0 ? buffer : Buffer.from(buffer.subarray(start));
634
1578
  }
635
- hub;
636
- tokenRefresher;
637
- sessionId;
638
- recorder;
639
- off = null;
640
- buffer = [];
641
- bufferBytes = 0;
1579
+ };
1580
+ var TranscriptReader = class {
1581
+ constructor(agent, cwd, sinceMs, onMissing) {
1582
+ this.agent = agent;
1583
+ this.cwd = cwd;
1584
+ this.sinceMs = sinceMs;
1585
+ this.onMissing = onMissing;
1586
+ }
1587
+ agent;
1588
+ cwd;
1589
+ sinceMs;
1590
+ onMissing;
642
1591
  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();
1592
+ fileMap = /* @__PURE__ */ new Map();
1593
+ pendingMap = /* @__PURE__ */ new Map();
1594
+ unresolvedList = [];
1595
+ rootPath = null;
1596
+ rootKey = null;
1597
+ rootListen = null;
1598
+ ticking = false;
1599
+ // The poll currently running, or a settled promise. tick() swallows its own
1600
+ // errors, so awaiting this never rejects.
1601
+ inFlight = Promise.resolve();
1602
+ startedMs = 0;
1603
+ locateFailures = 0;
1604
+ nextLocateMs = 0;
1605
+ isMissingReported = false;
1606
+ isReadErrorReported = false;
1607
+ sink = null;
1608
+ // Off while the agent is expected to announce its own transcript. Guessing in
1609
+ // the meantime can attach a concurrent session in the same workspace and post
1610
+ // its conversation into this chat, which no later correction undoes.
1611
+ isLocateAllowed = true;
1612
+ // False once the session ends means the terminal was recorded but no
1613
+ // conversation ever streamed. A file the agent announced but never wrote
1614
+ // counts as missing: naming a path is not producing one.
1615
+ get isReading() {
1616
+ for (const file of this.fileMap.values()) {
1617
+ if (file.hasData) return true;
1618
+ }
1619
+ return false;
1620
+ }
1621
+ // Subagents the transcripts said ran, whose own files never appeared. Their
1622
+ // work is missing from the chat and nothing else would say so.
1623
+ get unresolvedChildList() {
1624
+ return [...this.unresolvedList, ...this.pendingMap.keys()];
1625
+ }
1626
+ // Attach a stream. Every file is read again from the top, because a stream
1627
+ // that opened late still owes the hub the whole session and re-posting a line
1628
+ // the hub already holds is a no-op.
1629
+ sinkAttach(sink) {
1630
+ this.sink = sink;
1631
+ for (const file of this.fileMap.values()) {
1632
+ file.rewind();
1633
+ sink.fileOpen(file.ref);
1634
+ }
1635
+ }
1636
+ sinkDetach() {
1637
+ this.sink = null;
1638
+ }
1639
+ // Adopt a root transcript, overriding whatever transcriptLocate() may have
1640
+ // settled on. A second root -- Claude's /clear opens one -- joins the set
1641
+ // rather than replacing what came before.
1642
+ //
1643
+ // The key is passed in rather than read back off the path: where the agent
1644
+ // announced itself, the key is what it called its own session, and that is
1645
+ // the name a resume presents. Reading it off a filename works only for as
1646
+ // long as a vendor keeps naming files after sessions.
1647
+ fileAdd(path, key) {
1648
+ if (this.fileMap.has(key)) return;
1649
+ if (this.rootPath === null) {
1650
+ this.rootPath = path;
1651
+ this.rootKey = key;
1652
+ this.rootListen?.(key, path);
1653
+ }
1654
+ this.fileOpen({ key, parentKey: "", callKey: "" }, path);
1655
+ }
1656
+ // Called once with the name of the first conversation this reader opens.
1657
+ // Nothing else knows it: a fresh launch has no key until the agent writes one,
1658
+ // and the pane needs it to say on its tmux session which conversation is
1659
+ // running there -- which is what lets a later launch find this pane rather
1660
+ // than starting a second agent on the same conversation.
1661
+ onRootOpen(listen) {
1662
+ this.rootListen = listen;
1663
+ if (this.rootKey !== null && this.rootPath !== null) {
1664
+ listen(this.rootKey, this.rootPath);
1665
+ }
1666
+ }
1667
+ // Suppress guessing until locateAllow() -- called when the agent will report
1668
+ // its own session, and released if that report never arrives.
1669
+ locateDefer() {
1670
+ this.isLocateAllowed = false;
1671
+ }
1672
+ locateAllow() {
1673
+ this.isLocateAllowed = true;
1674
+ }
650
1675
  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));
1676
+ if (this.timer) return;
1677
+ this.startedMs = Date.now();
1678
+ this.tickStart();
1679
+ this.timer = setInterval(() => this.tickStart(), POLL_MS);
656
1680
  }
657
1681
  async stop() {
658
- this.off?.();
659
- this.off = null;
660
- this.flush();
661
- await this.chain;
1682
+ if (this.timer) {
1683
+ clearInterval(this.timer);
1684
+ this.timer = null;
1685
+ }
1686
+ await this.inFlight;
1687
+ if (this.fileMap.size > 0) await this.tick();
1688
+ }
1689
+ fileOpen(ref, path) {
1690
+ const file = new FileReader(
1691
+ ref,
1692
+ path,
1693
+ (key, text, seq, offset) => this.emit(key, text, seq, offset)
1694
+ );
1695
+ this.fileMap.set(ref.key, file);
1696
+ this.sink?.fileOpen(ref);
662
1697
  }
663
- append(ev) {
664
- this.buffer.push(ev);
665
- this.bufferBytes += ev[2].length;
666
- if (this.bufferBytes >= FLUSH_BYTES_MAX) {
667
- this.flush();
1698
+ // Starts a poll unless one is already running, and keeps its promise so stop()
1699
+ // can wait the running one out.
1700
+ tickStart() {
1701
+ if (this.ticking) return;
1702
+ this.inFlight = this.tick();
1703
+ }
1704
+ async tick() {
1705
+ this.ticking = true;
1706
+ try {
1707
+ if (this.fileMap.size === 0) await this.locate();
1708
+ for (const file of [...this.fileMap.values()]) {
1709
+ if (!await file.read()) this.missingCheck();
1710
+ }
1711
+ await this.pendingResolve();
1712
+ } catch (e) {
1713
+ this.isReadErrorReported = true;
1714
+ } finally {
1715
+ this.ticking = false;
1716
+ }
1717
+ }
1718
+ async locate() {
1719
+ if (!this.isLocateAllowed) return;
1720
+ if (Date.now() < this.nextLocateMs) return;
1721
+ const found = await this.agent.transcriptLocate(this.cwd, this.sinceMs);
1722
+ if (!found) {
1723
+ const wait = LOCATE_BACKOFF_MS[Math.min(this.locateFailures, LOCATE_BACKOFF_MS.length - 1)];
1724
+ this.locateFailures += 1;
1725
+ this.nextLocateMs = Date.now() + wait;
1726
+ this.missingCheck();
1727
+ return;
1728
+ }
1729
+ const key = this.agent.transcriptKey(found);
1730
+ if (key !== null) this.fileAdd(found, key);
1731
+ }
1732
+ // A child is opened only once its file exists, and only because a parent line
1733
+ // named it. One that never appears is given up on and reported, rather than
1734
+ // rescanning for the rest of the session.
1735
+ async pendingResolve() {
1736
+ const rootPath = this.rootPath;
1737
+ if (rootPath === null) return;
1738
+ for (const [key, pending] of [...this.pendingMap]) {
1739
+ const path = await this.agent.transcriptChildLocate(
1740
+ rootPath,
1741
+ pending.child
1742
+ );
1743
+ if (path !== null) {
1744
+ this.pendingMap.delete(key);
1745
+ this.fileOpen(
1746
+ { key, parentKey: pending.parentKey, callKey: pending.child.callKey },
1747
+ path
1748
+ );
1749
+ continue;
1750
+ }
1751
+ if (Date.now() - pending.since > CHILD_DEADLINE_MS) {
1752
+ this.pendingMap.delete(key);
1753
+ this.unresolvedList.push(key);
1754
+ }
1755
+ }
1756
+ }
1757
+ // Fires once, after the deadline, so a launch that will never find its
1758
+ // transcript stops looking identical to one whose agent is still starting.
1759
+ missingCheck() {
1760
+ if (this.isMissingReported) return;
1761
+ if (this.fileMap.size > 0 && this.isReading) return;
1762
+ if (Date.now() - this.startedMs < MISSING_DEADLINE_MS) return;
1763
+ this.isMissingReported = true;
1764
+ this.onMissing();
1765
+ }
1766
+ // One line's failure is that line's. Without this, a sink that throws would
1767
+ // abandon every line already framed in the same read, from a cursor that has
1768
+ // already moved past them.
1769
+ emit(key, text, seq, offset) {
1770
+ try {
1771
+ this.sink?.line(key, text, seq, offset);
1772
+ } catch (e) {
1773
+ this.isReadErrorReported = true;
1774
+ }
1775
+ this.childrenNote(key, text);
1776
+ }
1777
+ childrenNote(parentKey, text) {
1778
+ for (const child of this.agent.transcriptChildren(text)) {
1779
+ if (this.fileMap.has(child.key) || this.pendingMap.has(child.key))
1780
+ continue;
1781
+ this.pendingMap.set(child.key, { child, parentKey, since: Date.now() });
1782
+ }
1783
+ }
1784
+ };
1785
+ var FLUSH_INTERVAL_MS2 = 250;
1786
+ var FLUSH_BYTES_MAX2 = 512 * 1024;
1787
+ var FLUSH_LINES_MAX = 100;
1788
+ var TranscriptStream = class {
1789
+ constructor(session, reader) {
1790
+ this.session = session;
1791
+ this.reader = reader;
1792
+ reader.sinkAttach(this);
1793
+ }
1794
+ session;
1795
+ reader;
1796
+ bufferMap = /* @__PURE__ */ new Map();
1797
+ timer = null;
1798
+ unnamedCount = 0;
1799
+ outbox = new Outbox("transcript", {
1800
+ send: (batch) => this.session.transcriptAppend(batch),
1801
+ bytesOf: (batch) => batch.body.length,
1802
+ lossOf: (batch) => batch.lineCount
1803
+ });
1804
+ // Lines this session produced that the hub never took. Zero means the hub has
1805
+ // the whole transcript; anything else is a hole no derived view can see.
1806
+ get lastError() {
1807
+ return this.outbox.lastError;
1808
+ }
1809
+ get unsentCount() {
1810
+ return this.outbox.unsentCount + this.unnamedCount;
1811
+ }
1812
+ fileOpen(ref) {
1813
+ if (ref.parentKey) this.flushOne(ref.parentKey);
1814
+ const nextSeq = ref.key === this.session.resumeKey ? this.session.resumeSeq : 0;
1815
+ this.bufferMap.set(ref.key, {
1816
+ ref,
1817
+ lines: [],
1818
+ startSeq: 0,
1819
+ startOffset: 0,
1820
+ bytes: 0,
1821
+ nextSeq
1822
+ });
1823
+ this.outbox.add({
1824
+ fileKey: ref.key,
1825
+ parentKey: ref.parentKey,
1826
+ callKey: ref.callKey,
1827
+ startSeq: nextSeq,
1828
+ startOffset: 0,
1829
+ body: "",
1830
+ lineCount: 0
1831
+ });
1832
+ }
1833
+ line(key, text, seq, offset) {
1834
+ const buffer = this.bufferMap.get(key);
1835
+ if (buffer === void 0) {
1836
+ this.unnamedCount += 1;
1837
+ return;
1838
+ }
1839
+ if (seq < buffer.nextSeq) return;
1840
+ if (buffer.lines.length === 0) {
1841
+ buffer.startSeq = seq;
1842
+ buffer.startOffset = offset;
1843
+ }
1844
+ buffer.lines.push(text);
1845
+ buffer.bytes += Buffer.byteLength(text, "utf8") + 1;
1846
+ if (buffer.bytes >= FLUSH_BYTES_MAX2 || buffer.lines.length >= FLUSH_LINES_MAX) {
1847
+ this.flushOne(key);
668
1848
  return;
669
1849
  }
670
1850
  if (!this.timer) {
671
- this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS);
1851
+ this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS2);
1852
+ this.timer.unref();
672
1853
  }
673
1854
  }
1855
+ async stop(deadline) {
1856
+ this.reader.sinkDetach();
1857
+ this.flush();
1858
+ await this.outbox.flush(deadline);
1859
+ }
674
1860
  flush() {
675
1861
  if (this.timer) {
676
1862
  clearTimeout(this.timer);
677
1863
  this.timer = null;
678
1864
  }
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
- });
1865
+ for (const key of this.bufferMap.keys()) this.flushOne(key);
1866
+ }
1867
+ flushOne(key) {
1868
+ const buffer = this.bufferMap.get(key);
1869
+ if (buffer === void 0 || buffer.lines.length === 0) return;
1870
+ this.outbox.add({
1871
+ fileKey: key,
1872
+ parentKey: buffer.ref.parentKey,
1873
+ callKey: buffer.ref.callKey,
1874
+ startSeq: buffer.startSeq,
1875
+ startOffset: buffer.startOffset,
1876
+ // The trailing newline matters: the body is a slice of the file, and every
1877
+ // line in the file is terminated.
1878
+ body: `${buffer.lines.join("\n")}
1879
+ `,
1880
+ lineCount: buffer.lines.length
706
1881
  });
1882
+ buffer.lines = [];
1883
+ buffer.bytes = 0;
707
1884
  }
708
1885
  };
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;
1886
+ async function sizeOrNull(path) {
1887
+ try {
1888
+ return (await stat3(path)).size;
1889
+ } catch (e) {
1890
+ if (e.code === "ENOENT") return null;
1891
+ throw e;
1892
+ }
740
1893
  }
1894
+
1895
+ // src/steering.ts
741
1896
  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;
1897
+ var OPEN_TIMEOUT_MS = 15e3;
1898
+ var PING_INTERVAL_MS = 1e4;
1899
+ var PONG_STALE_MS = 25e3;
1900
+ var Steering = class {
1901
+ constructor(session, auth) {
1902
+ this.session = session;
1903
+ this.auth = auth;
749
1904
  }
750
- hubUrl;
751
- tokenRefresher;
752
- chatId;
753
- agentActorId;
754
- sinceMs;
1905
+ session;
1906
+ auth;
755
1907
  ws = null;
756
- stopped = false;
757
- handler = null;
1908
+ isStopped = false;
1909
+ inject = null;
758
1910
  reconnectTimer = null;
1911
+ openTimer = null;
1912
+ pingTimer = null;
1913
+ pongAt = 0;
759
1914
  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();
1915
+ isOpened = false;
1916
+ lastFailure = "";
1917
+ // Steers already typed into the pane, and what happened to each -- null while
1918
+ // one is still being typed. A reconnect replays what the hub still considers
1919
+ // undelivered, so the same steer arrives more than once: typing it again would
1920
+ // put the person's words into the agent twice, and staying silent would leave
1921
+ // the hub replaying it forever. The outcome is kept so a repeat is answered
1922
+ // with the same one.
1923
+ deliveredMap = /* @__PURE__ */ new Map();
763
1924
  start(inject) {
764
- this.handler = inject;
1925
+ this.inject = inject;
765
1926
  this.connect();
766
1927
  }
1928
+ // Empty when steering worked. Otherwise why the chat could not reach this
1929
+ // pane, which is invisible from the chat's side: with no socket, no steer ever
1930
+ // arrives, so there is no undelivered row to look at.
1931
+ get failure() {
1932
+ if (this.isOpened && !this.lastFailure) return "";
1933
+ if (!this.lastFailure) return "";
1934
+ return `chat steers could not reach this terminal: ${this.lastFailure}`;
1935
+ }
767
1936
  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;
1937
+ this.isStopped = true;
1938
+ this.timersClear();
1939
+ this.socketDrop();
775
1940
  }
776
1941
  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
- );
1942
+ if (this.isStopped) return;
1943
+ const ctor = globalThis.WebSocket;
1944
+ if (typeof ctor !== "function") {
1945
+ this.lastFailure = "this Node has no WebSocket";
783
1946
  return;
784
1947
  }
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();
1948
+ this.auth.token().then((token) => this.socketOpen(ctor, token)).catch((e) => {
1949
+ this.lastFailure = `could not authenticate the socket (${String(e)})`;
1950
+ this.reconnectSchedule();
789
1951
  });
790
1952
  }
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]);
1953
+ socketOpen(ctor, token) {
1954
+ if (this.isStopped) return;
1955
+ this.openTimerClear();
1956
+ this.pingTimerClear();
1957
+ this.socketDrop();
1958
+ const ws = new ctor(this.session.socketUrl, ["jwt", token]);
795
1959
  this.ws = ws;
1960
+ this.openTimer = setTimeout(() => {
1961
+ this.socketLost(ws, "the socket did not open");
1962
+ }, OPEN_TIMEOUT_MS);
796
1963
  ws.onopen = () => {
797
1964
  this.attempts = 0;
1965
+ this.isOpened = true;
1966
+ this.lastFailure = "";
1967
+ this.openTimerClear();
1968
+ this.pongAt = Date.now();
1969
+ this.pingSend();
1970
+ this.pingTimer = setInterval(() => this.pingSend(), PING_INTERVAL_MS);
1971
+ this.pingTimer.unref();
798
1972
  };
799
1973
  ws.onmessage = (ev) => this.onFrame(ev.data);
800
- ws.onerror = () => {
1974
+ ws.onerror = (ev) => {
1975
+ this.socketLost(ws, `the control socket errored (${String(ev)})`);
801
1976
  };
802
1977
  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
- );
1978
+ if (this.isStopped || this.ws !== ws) return;
1979
+ if (ev.code === 4004) {
1980
+ this.socketRetire(ws);
1981
+ this.lastFailure = "the hub says this session has ended";
1982
+ this.session.endedReceive();
810
1983
  return;
811
1984
  }
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
- });
1985
+ if (ev.code === 4001 || ev.code === 4002 || ev.code === 4003) {
1986
+ this.socketRetire(ws);
1987
+ this.auth.refresh().catch((e) => {
1988
+ this.lastFailure = `could not refresh the socket token (${String(e)})`;
1989
+ }).finally(() => this.reconnectSchedule());
818
1990
  return;
819
1991
  }
820
- this.scheduleReconnect();
1992
+ this.socketLost(ws, `the hub closed the control socket (${ev.code})`);
821
1993
  };
822
1994
  }
823
- scheduleReconnect() {
824
- if (this.stopped || this.reconnectTimer) return;
1995
+ // Every non-terminal transport failure comes through here. Error and close
1996
+ // commonly arrive for the same failure; socket identity makes the second a
1997
+ // no-op, and reconnectSchedule makes the first exactly one backed-off retry.
1998
+ socketLost(ws, failure) {
1999
+ if (this.isStopped || !this.socketRetire(ws)) return;
2000
+ this.lastFailure = failure;
2001
+ this.reconnectSchedule();
2002
+ }
2003
+ socketRetire(ws) {
2004
+ if (this.ws !== ws) return false;
2005
+ this.isOpened = false;
2006
+ this.openTimerClear();
2007
+ this.pingTimerClear();
2008
+ this.socketDrop();
2009
+ return true;
2010
+ }
2011
+ reconnectSchedule() {
2012
+ if (this.isStopped || this.reconnectTimer) return;
825
2013
  const delay = Math.min(1e3 * 2 ** this.attempts, RECONNECT_MAX_MS);
826
2014
  this.attempts += 1;
827
2015
  this.reconnectTimer = setTimeout(() => {
@@ -831,1096 +2019,1136 @@ var HubMentions = class {
831
2019
  }
832
2020
  onFrame(raw) {
833
2021
  if (typeof raw !== "string") return;
834
- let env;
2022
+ let frame;
835
2023
  try {
836
- env = JSON.parse(raw);
2024
+ frame = JSON.parse(raw);
837
2025
  } catch {
838
2026
  return;
839
2027
  }
840
- if (env.kind !== "message_upsert") return;
841
- const m = env.data;
842
- if (!m || typeof m.public_id !== "string" || this.seen.has(m.public_id)) return;
843
- if (m.actor?.public_id === this.agentActorId) return;
844
- const createdMs = m.created_at ? Date.parse(m.created_at) : NaN;
845
- if (Number.isFinite(createdMs) && createdMs <= this.sinceMs) return;
846
- const text = mentionPrompt(m.blocks ?? [], this.agentActorId);
847
- if (text === null) return;
848
- this.seen.add(m.public_id);
849
- this.handler?.({
850
- text,
851
- author: m.actor?.display_name ?? "someone",
852
- seed: m.actor?.public_id ?? m.public_id,
853
- ts: Number.isFinite(createdMs) ? createdMs : Date.now()
854
- });
855
- }
856
- };
857
-
858
- // src/echo-guard.ts
859
- var ECHO_TTL_MS = 3e4;
860
- function normalize(text) {
861
- return text.trim();
862
- }
863
- var EchoGuard = class {
864
- pending = [];
865
- record(text) {
866
- const norm = normalize(text);
867
- if (!norm) return;
868
- this.pending.push({ text: norm, at: Date.now() });
869
- }
870
- // True (and removes the entry) if `text` matches a still-live injected mention.
871
- // Prunes expired entries on the way so they never suppress a real message.
872
- consume(text) {
873
- const now = Date.now();
874
- this.pending = this.pending.filter((p) => now - p.at < ECHO_TTL_MS);
875
- const norm = normalize(text);
876
- const idx = this.pending.findIndex((p) => p.text === norm);
877
- if (idx === -1) return false;
878
- this.pending.splice(idx, 1);
879
- return true;
880
- }
881
- };
882
-
883
- // src/transcript/adapter.ts
884
- import { basename as basename3 } from "path";
885
-
886
- // src/transcript/claude.ts
887
- import { readdir, stat as stat2 } from "fs/promises";
888
- import { homedir as homedir2 } from "os";
889
- import { basename, join as join4 } from "path";
890
- function encodeProjectDir(cwd) {
891
- return cwd.replace(/[/.]/g, "-");
892
- }
893
- var SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
894
- var ClaudeAdapter = class {
895
- async locate(cwd, sinceMs, _processId) {
896
- const newest = await newestTranscript(projectDir(cwd));
897
- if (!newest || newest.mtimeMs < sinceMs) return null;
898
- return newest.path;
899
- }
900
- refFromPath(path) {
901
- const name = basename(path);
902
- if (!name.endsWith(".jsonl")) return null;
903
- return name.slice(0, -".jsonl".length);
904
- }
905
- // Claude Code appends to the same <sessionId>.jsonl on --resume/--continue,
906
- // so the resumed session's id is knowable before launch: from the flag's own
907
- // argument, or from the newest transcript on disk for --continue. A bare
908
- // --resume opens claude's interactive picker -- the target is unknowable, so
909
- // it reads as a fresh session. --fork-session branches into a new id.
910
- async resumeRef(command, cwd) {
911
- const args = command.slice(1);
912
- if (args.includes("--fork-session")) return null;
913
- for (let i = 0; i < args.length; i++) {
914
- const [flag, inline] = flagSplit(args[i]);
915
- if (flag === "--resume" || flag === "-r" || flag === "--session-id") {
916
- const candidate = inline ?? args[i + 1];
917
- if (candidate !== void 0 && SESSION_ID_RE.test(candidate)) return candidate;
918
- } else if (flag === "--continue" || flag === "-c") {
919
- const newest = await newestTranscript(projectDir(cwd));
920
- return newest ? this.refFromPath(newest.path) : null;
921
- }
2028
+ if (frame.kind === "pong") {
2029
+ this.pongAt = Date.now();
2030
+ return;
922
2031
  }
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 });
2032
+ if (frame.kind !== "steer") return;
2033
+ const steer = steerParse(frame.data);
2034
+ if (steer === null) return;
2035
+ const done = this.deliveredMap.get(steer.eventId);
2036
+ if (done !== void 0) {
2037
+ if (done !== null)
2038
+ void this.session.steerAck(steer.eventId, done).catch(() => {
2039
+ });
2040
+ return;
944
2041
  }
945
- if (parts.length === 0) return null;
946
- return { id: r.uuid, role: "assistant", ts: Date.parse(r.timestamp), parts };
2042
+ this.deliveredMap.set(steer.eventId, null);
2043
+ void this.deliver(steer);
947
2044
  }
948
- };
949
- function projectDir(cwd) {
950
- return join4(homedir2(), ".claude/projects", encodeProjectDir(cwd));
951
- }
952
- function flagSplit(arg) {
953
- const at = arg.indexOf("=");
954
- if (at === -1) return [arg, void 0];
955
- return [arg.slice(0, at), arg.slice(at + 1)];
956
- }
957
- async function newestTranscript(dir) {
958
- let entries;
959
- try {
960
- entries = await readdir(dir);
961
- } catch {
962
- return null;
2045
+ // Detaches the current socket's handlers before closing it, so its own close
2046
+ // event cannot clear a socket that has already replaced it.
2047
+ socketDrop() {
2048
+ const ws = this.ws;
2049
+ this.ws = null;
2050
+ if (ws === null) return;
2051
+ ws.onopen = null;
2052
+ ws.onclose = null;
2053
+ ws.onmessage = null;
2054
+ ws.onerror = null;
2055
+ ws.close(1e3);
2056
+ }
2057
+ // The control path proves itself over the control path. A durable "opened"
2058
+ // timestamp cannot notice a hub process disappearing before disconnect runs.
2059
+ pingSend() {
2060
+ const ws = this.ws;
2061
+ if (ws === null) return;
2062
+ if (Date.now() - this.pongAt > PONG_STALE_MS) {
2063
+ this.socketLost(ws, "the control socket stopped answering");
2064
+ return;
2065
+ }
2066
+ try {
2067
+ ws.send(JSON.stringify({ kind: "ping" }));
2068
+ } catch (e) {
2069
+ this.socketLost(
2070
+ ws,
2071
+ `could not ping the control socket (${String(e)})`
2072
+ );
2073
+ }
963
2074
  }
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 };
2075
+ // The hub hears what happened either way. A steer the pane could not type is
2076
+ // the failure that used to be invisible: the sender saw their own message in
2077
+ // the chat and had no way to know it never arrived.
2078
+ async deliver(steer) {
2079
+ let outcome = "failed";
2080
+ try {
2081
+ outcome = await this.inject?.(steer) ?? "failed";
2082
+ } catch (e) {
2083
+ this.lastFailure = `could not type a steer into the pane (${String(e)})`;
2084
+ }
2085
+ this.deliveredMap.set(steer.eventId, outcome);
2086
+ try {
2087
+ await this.session.steerAck(steer.eventId, outcome);
2088
+ } catch (e) {
2089
+ this.lastFailure = `could not acknowledge a steer (${String(e)})`;
2090
+ }
970
2091
  }
971
- return newest;
972
- }
973
-
974
- // src/transcript/codex.ts
975
- import { execFile } from "child_process";
976
- import { open as open2, readdir as readdir2, readlink } from "fs/promises";
977
- import { homedir as homedir3, platform } from "os";
978
- import { basename as basename2, join as join5 } from "path";
979
- var SESSION_ID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
980
- var ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
981
- var SESSION_META_BYTES = 1024 * 1024;
982
- var CodexAdapter = class {
983
- constructor(processRolloutPaths = rolloutPathsForProcess) {
984
- this.processRolloutPaths = processRolloutPaths;
2092
+ timersClear() {
2093
+ if (this.reconnectTimer) {
2094
+ clearTimeout(this.reconnectTimer);
2095
+ this.reconnectTimer = null;
2096
+ }
2097
+ this.openTimerClear();
2098
+ this.pingTimerClear();
985
2099
  }
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);
2100
+ openTimerClear() {
2101
+ if (this.openTimer) {
2102
+ clearTimeout(this.openTimer);
2103
+ this.openTimer = null;
992
2104
  }
993
- return primaryPaths.length === 1 ? primaryPaths[0] : null;
994
2105
  }
995
- refFromPath(path) {
996
- const match = ROLLOUT_RE.exec(basename2(path));
997
- return match ? match[1] : null;
2106
+ pingTimerClear() {
2107
+ if (this.pingTimer) {
2108
+ clearInterval(this.pingTimer);
2109
+ this.pingTimer = null;
2110
+ }
998
2111
  }
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;
2112
+ };
2113
+ function steerParse(data) {
2114
+ if (!data) return null;
2115
+ const { eventId, messageId, text, author } = data;
2116
+ if (typeof eventId !== "string" || !eventId) return null;
2117
+ if (typeof text !== "string" || !text.trim()) return null;
2118
+ return {
2119
+ eventId,
2120
+ messageId: typeof messageId === "string" ? messageId : "",
2121
+ text,
2122
+ author: typeof author === "string" && author ? author : "someone"
2123
+ };
2124
+ }
2125
+
2126
+ // src/capture.ts
2127
+ var exec = promisify2(execFile);
2128
+ var HEARTBEAT_INTERVAL_MS = 3e4;
2129
+ var SHUTDOWN_MS = 8e3;
2130
+ var GIT_TIMEOUT_MS = 5e3;
2131
+ var Capture = class _Capture {
2132
+ constructor(note, session, reader, recordingStream, transcriptStream, steering, chatUrl, reattached, skippedMemberList) {
2133
+ this.note = note;
2134
+ this.session = session;
2135
+ this.reader = reader;
2136
+ this.recordingStream = recordingStream;
2137
+ this.transcriptStream = transcriptStream;
2138
+ this.steering = steering;
2139
+ this.chatUrl = chatUrl;
2140
+ this.reattached = reattached;
2141
+ this.skippedMemberList = skippedMemberList;
2142
+ }
2143
+ note;
2144
+ session;
2145
+ reader;
2146
+ recordingStream;
2147
+ transcriptStream;
2148
+ steering;
2149
+ chatUrl;
2150
+ reattached;
2151
+ skippedMemberList;
2152
+ stopWork = null;
2153
+ get chatPublicId() {
2154
+ return this.session.chatPublicId;
2155
+ }
2156
+ // Opens the chat and attaches to what the pane is already producing. Returns
2157
+ // null for every reason capture cannot run -- not signed in, hub unreachable,
2158
+ // a command with no transcript -- because none of them is the pane's problem.
2159
+ static async start(hub, auth, recording, reader, options, note) {
2160
+ let session;
2161
+ try {
2162
+ session = await HubSession.open(hub, options);
2163
+ } catch (e) {
2164
+ if (e instanceof TerminalHeldError) throw e;
2165
+ note(
2166
+ `could not open the chat (${e.message}); this session stays local.`
2167
+ );
2168
+ return null;
1013
2169
  }
1014
- return null;
2170
+ const recordingStream = new RecordingStream(session, recording);
2171
+ const transcriptStream = new TranscriptStream(session, reader);
2172
+ const steering = new Steering(session, auth);
2173
+ const capture = new _Capture(
2174
+ note,
2175
+ session,
2176
+ reader,
2177
+ recordingStream,
2178
+ transcriptStream,
2179
+ steering,
2180
+ session.chatUrl,
2181
+ session.reattached,
2182
+ session.skippedMemberList
2183
+ );
2184
+ session.endedWatch(() => void capture.remoteStop());
2185
+ return capture;
2186
+ }
2187
+ steerListen(inject) {
2188
+ this.steering.start(inject);
2189
+ }
2190
+ // Mint the chat's join link, for an email invite.
2191
+ linkCreate() {
2192
+ return this.session.linkCreate();
2193
+ }
2194
+ // One deadline for both streams: stop the producers, drain in parallel with
2195
+ // the time that remains, then post the end state. `isDetached` means the agent
2196
+ // ran on unwatched, which no loss count can show, so the session says so.
2197
+ async stop(isDetached) {
2198
+ if (this.stopWork === null) this.stopWork = this.localStop(isDetached);
2199
+ await this.stopWork;
2200
+ }
2201
+ async localStop(isDetached) {
2202
+ const gitEnd = await gitSnapshot(process.cwd());
2203
+ const deadline = Date.now() + SHUTDOWN_MS;
2204
+ await this.steering.stop();
2205
+ await Promise.all([
2206
+ this.recordingStream.stop(deadline),
2207
+ this.transcriptStream.stop(deadline)
2208
+ ]);
2209
+ this.lossReport();
2210
+ await this.session.close({
2211
+ transcriptLost: this.transcriptStream.unsentCount,
2212
+ recordingLost: this.recordingStream.unsentCount,
2213
+ isDetached,
2214
+ gitEnd
2215
+ });
1015
2216
  }
1016
- parse(record) {
1017
- const r = record;
1018
- if (r.type !== "response_item" || !r.payload) return null;
1019
- const p = r.payload;
1020
- const ts = Date.parse(r.timestamp);
1021
- const id = p.id ?? `codex-${ts}`;
1022
- if (p.type === "message") {
1023
- const role = p.role === "assistant" ? "assistant" : p.role === "user" ? "user" : null;
1024
- if (role === null) return null;
1025
- const parts = [];
1026
- for (const b of p.content ?? []) {
1027
- if ((b.type === "input_text" || b.type === "output_text") && typeof b.text === "string") {
1028
- parts.push({ kind: "text", text: b.text });
1029
- }
1030
- }
1031
- if (parts.length === 0) return null;
1032
- return { id, role, ts, parts };
2217
+ // Ownership can end while the pane is still running. Stop only collaboration:
2218
+ // the local tmux session and coding agent remain entirely outside this path.
2219
+ async remoteStop() {
2220
+ if (this.stopWork === null) this.stopWork = this.remoteStopWrite();
2221
+ await this.stopWork;
2222
+ }
2223
+ async remoteStopWrite() {
2224
+ await this.steering.stop();
2225
+ await Promise.all([
2226
+ this.recordingStream.stop(Date.now()),
2227
+ this.transcriptStream.stop(Date.now())
2228
+ ]);
2229
+ await this.session.close({
2230
+ transcriptLost: this.transcriptStream.unsentCount,
2231
+ recordingLost: this.recordingStream.unsentCount,
2232
+ isDetached: true,
2233
+ gitEnd: null
2234
+ });
2235
+ this.note("this chat connection ended; the local agent is still running");
2236
+ }
2237
+ // Said once, after the streams have drained and the pane has already given the
2238
+ // terminal back. The counts are the session's integrity state; the last error
2239
+ // is the only part a person can act on.
2240
+ lossReport() {
2241
+ const lostList = [
2242
+ [
2243
+ "transcript lines",
2244
+ this.transcriptStream.unsentCount,
2245
+ this.transcriptStream.lastError
2246
+ ],
2247
+ [
2248
+ "recording chunks",
2249
+ this.recordingStream.unsentCount,
2250
+ this.recordingStream.lastError
2251
+ ]
2252
+ ];
2253
+ for (const [unit, count, why] of lostList) {
2254
+ if (count === 0) continue;
2255
+ this.note(
2256
+ `${count} ${unit} never reached the hub${why ? ` (${why})` : ""}`
2257
+ );
1033
2258
  }
1034
- if (p.type === "function_call" && typeof p.name === "string") {
1035
- return { id, role: "assistant", ts, parts: [{ kind: "tool", name: p.name }] };
2259
+ const unresolved = this.reader.unresolvedChildList;
2260
+ if (unresolved.length > 0) {
2261
+ this.note(
2262
+ `${unresolved.length} subagent transcript(s) never appeared, so their work is not in the chat`
2263
+ );
1036
2264
  }
1037
- return null;
2265
+ const failure = this.steering.failure;
2266
+ if (failure) this.note(failure);
1038
2267
  }
1039
2268
  };
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;
2269
+ var HubSession = class _HubSession {
2270
+ constructor(hub, publicId, chatPublicId2, agentActorPublicId, chatUrl, reattached, skippedMemberList, resumeKey, resumeSeq) {
2271
+ this.hub = hub;
2272
+ this.publicId = publicId;
2273
+ this.chatPublicId = chatPublicId2;
2274
+ this.agentActorPublicId = agentActorPublicId;
2275
+ this.chatUrl = chatUrl;
2276
+ this.reattached = reattached;
2277
+ this.skippedMemberList = skippedMemberList;
2278
+ this.resumeKey = resumeKey;
2279
+ this.resumeSeq = resumeSeq;
1047
2280
  }
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;
2281
+ hub;
2282
+ publicId;
2283
+ chatPublicId;
2284
+ agentActorPublicId;
2285
+ chatUrl;
2286
+ reattached;
2287
+ skippedMemberList;
2288
+ resumeKey;
2289
+ resumeSeq;
2290
+ heartbeatTimer = null;
2291
+ endedListener = null;
2292
+ isEnded = false;
2293
+ static async open(hub, options) {
2294
+ const started = await hub.terminalCreate({
2295
+ source: options.source,
2296
+ title: options.title,
2297
+ resumeKey: options.resumeKey ?? void 0,
2298
+ chatPublicId: options.chatPublicId ?? void 0,
2299
+ memberList: options.memberList,
2300
+ gitStart: options.gitStart
2301
+ });
2302
+ const session = new _HubSession(
2303
+ hub,
2304
+ started.terminalSessionPublicId,
2305
+ started.chatPublicId,
2306
+ started.agentActorPublicId,
2307
+ started.chatUrl,
2308
+ started.reattached,
2309
+ started.skippedMemberList,
2310
+ options.resumeKey ?? "",
2311
+ started.resumeSeq
2312
+ );
2313
+ session.heartbeatTimer = setInterval(
2314
+ () => void session.heartbeat(),
2315
+ HEARTBEAT_INTERVAL_MS
2316
+ );
2317
+ session.heartbeatTimer.unref();
2318
+ return session;
1057
2319
  }
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
- }
2320
+ async recordingAppend(chunk) {
2321
+ await this.liveRequest(
2322
+ () => this.hub.recordingAppend(this.publicId, chunk)
1084
2323
  );
1085
- });
1086
- }
1087
- async function linuxRolloutPaths(processId) {
1088
- const fdDir = `/proc/${processId}/fd`;
1089
- let entries;
1090
- try {
1091
- entries = await readdir2(fdDir);
1092
- } catch {
1093
- return [];
1094
2324
  }
1095
- const paths = [];
1096
- for (const entry of entries) {
1097
- let path;
2325
+ // A batch names its own file, and a subagent's names the parent that launched
2326
+ // it, so lineage arrives with the data it describes and the outbox retries
2327
+ // both together.
2328
+ async transcriptAppend(batch) {
2329
+ await this.liveRequest(
2330
+ () => this.hub.transcriptAppend(this.publicId, batch)
2331
+ );
2332
+ }
2333
+ async linkCreate() {
2334
+ return this.hub.chatLinkCreate(this.chatPublicId);
2335
+ }
2336
+ get socketUrl() {
2337
+ return this.hub.socketUrl(`/ws/terminal-sessions/${this.publicId}/`);
2338
+ }
2339
+ async steerAck(eventId, outcome) {
2340
+ await this.liveRequest(
2341
+ () => this.hub.steerAck(this.publicId, eventId, outcome)
2342
+ );
2343
+ }
2344
+ // Best-effort, like the heartbeat: the pane is already gone by the time this
2345
+ // runs, and a failed close is recovered by the hub's own staleness window.
2346
+ async close(state) {
2347
+ this.isEnded = true;
2348
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
2349
+ this.heartbeatTimer = null;
2350
+ await this.hub.terminalEnd(this.publicId, state).catch(() => {
2351
+ });
2352
+ }
2353
+ endedWatch(listener) {
2354
+ this.endedListener = listener;
2355
+ if (this.isEnded) listener();
2356
+ }
2357
+ endedReceive() {
2358
+ if (this.isEnded) return;
2359
+ this.isEnded = true;
2360
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
2361
+ this.heartbeatTimer = null;
2362
+ this.endedListener?.();
2363
+ }
2364
+ async liveRequest(request) {
2365
+ try {
2366
+ await request();
2367
+ } catch (e) {
2368
+ if (e instanceof HubError && e.status === 409) this.endedReceive();
2369
+ throw e;
2370
+ }
2371
+ }
2372
+ // A missed beat is recovered by the next one, and a hub that is unreachable
2373
+ // must never interrupt the local terminal.
2374
+ async heartbeat() {
2375
+ let state;
1098
2376
  try {
1099
- path = await readlink(join5(fdDir, entry));
2377
+ state = await this.hub.terminalHeartbeat(this.publicId);
1100
2378
  } catch {
1101
- continue;
2379
+ return;
2380
+ }
2381
+ if (state.isEnded) {
2382
+ this.endedReceive();
1102
2383
  }
1103
- if (isRolloutPath(path)) paths.push(path);
1104
2384
  }
1105
- return [...new Set(paths)];
1106
- }
1107
- function isRolloutPath(path) {
1108
- return ROLLOUT_RE.test(basename2(path));
2385
+ };
2386
+ async function gitSnapshot(cwd) {
2387
+ const sha = await git(["rev-parse", "HEAD"], cwd);
2388
+ if (sha === null) return null;
2389
+ const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
2390
+ const root = await git(["rev-parse", "--show-toplevel"], cwd);
2391
+ const status = await git(["status", "--porcelain", "-z"], cwd);
2392
+ return {
2393
+ sha: sha.trim(),
2394
+ branch: (branch ?? "").trim(),
2395
+ root: (root ?? "").trim(),
2396
+ dirtyPaths: dirtyPaths(status ?? "")
2397
+ };
1109
2398
  }
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;
2399
+ function dirtyPaths(status) {
2400
+ const entries = status.split("\0");
2401
+ const paths = [];
2402
+ for (let i = 0; i < entries.length; i += 1) {
2403
+ const entry = entries[i];
2404
+ if (entry.length < 4) continue;
2405
+ paths.push(entry.slice(3));
2406
+ if (entry[0] === "R" || entry[1] === "R") i += 1;
1118
2407
  }
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;
2408
+ return paths;
1123
2409
  }
1124
- async function firstLine(path) {
1125
- let handle;
2410
+ async function git(args, cwd) {
1126
2411
  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");
2412
+ const { stdout } = await exec("git", args, {
2413
+ cwd,
2414
+ timeout: GIT_TIMEOUT_MS,
2415
+ maxBuffer: 8 * 1024 * 1024
2416
+ });
2417
+ return stdout;
1133
2418
  } catch {
1134
2419
  return null;
1135
- } finally {
1136
- await handle?.close();
1137
2420
  }
1138
2421
  }
1139
- function isRecord(value) {
1140
- return typeof value === "object" && value !== null;
1141
- }
1142
2422
 
1143
- // src/transcript/adapter.ts
1144
- function adapterFor(command) {
1145
- const program = command[0];
1146
- if (program === void 0) return null;
1147
- const name = basename3(program);
1148
- if (name === "claude") return new ClaudeAdapter();
1149
- if (name === "codex") return new CodexAdapter();
1150
- return null;
1151
- }
1152
- function sourceFor(command) {
1153
- const program = command[0];
1154
- if (program === void 0) return null;
1155
- const name = basename3(program);
1156
- if (name === "claude") return "claude_code";
1157
- if (name === "codex") return "codex";
1158
- return null;
1159
- }
2423
+ // src/pane.ts
2424
+ import { spawn } from "child_process";
2425
+ import { rmSync as rmSync3 } from "fs";
2426
+ import { join as join5 } from "path";
2427
+ import { tmpdir as tmpdir2 } from "os";
2428
+ import { StringDecoder } from "string_decoder";
2429
+ var BANNER = `
2430
+ \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591
2431
+ \u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591
2432
+ \u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591
2433
+ \u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
2434
+ \u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
2435
+ \u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
2436
+ \u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591
2437
+ \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
2438
+ \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
2439
+ \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
2440
+ \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
2441
+ \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
2442
+ \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
2443
+ \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
2444
+ \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
2445
+ \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
2446
+ \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
2447
+ \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
2448
+ \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
2449
+ \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
2450
+ \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
2451
+ \u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591 \u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591
2452
+ \u2591\u2591\u2591\u2591\u2591\u2591\u2591 \u2591\u2591\u2591\u2591\u2591\u2591
1160
2453
 
1161
- // src/auth/hub.ts
1162
- var HubClient = class {
1163
- constructor(hubUrl, fetchImpl = fetch) {
1164
- this.hubUrl = hubUrl;
1165
- this.fetchImpl = fetchImpl;
1166
- }
1167
- hubUrl;
1168
- fetchImpl;
1169
- get url() {
1170
- return this.hubUrl;
1171
- }
1172
- startUrl(redirectUri, challenge) {
1173
- const params = `redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${encodeURIComponent(challenge)}&code_challenge_method=S256`;
1174
- return `${this.hubUrl}/users/social/auth/mobile/start/?${params}`;
1175
- }
1176
- async exchange(code, verifier) {
1177
- const body = await this.postJson("/api/auth/mobile/exchange/", {
1178
- code,
1179
- code_verifier: verifier
1180
- });
1181
- return tokenPairFrom(body);
1182
- }
1183
- async refresh(refreshToken) {
1184
- const body = await this.postJson("/api/auth/mobile/refresh/", {
1185
- refresh_token: refreshToken
1186
- });
1187
- return tokenPairFrom(body);
2454
+ Directed helps you collaborate with AI.
2455
+ `;
2456
+ var BIND_DEADLINE_MS = 2e4;
2457
+ var READER_DRAIN_MS = 1e3;
2458
+ var HOOK_SETUP_MS = 2e3;
2459
+ var TRANSCRIPT_MISSING_NOTE = "no transcript found for this session; the terminal is recording but no messages are posting";
2460
+ function bannerWrap(command, cols, rows) {
2461
+ const lines = BANNER.replace(/^\n+|\n+$/g, "").split("\n");
2462
+ const width = Math.max(...lines.map((l) => l.length));
2463
+ const left = " ".repeat(Math.max(0, Math.floor((cols - width) / 2)));
2464
+ const top = "\n".repeat(Math.max(0, Math.floor((rows - lines.length) / 2)));
2465
+ const centered = top + lines.map((l) => l.length > 0 ? left + l : l).join("\n");
2466
+ return [
2467
+ "sh",
2468
+ "-c",
2469
+ `clear; printf '%s
2470
+ ' '${centered}'; exec "$@"`,
2471
+ "sh",
2472
+ ...command
2473
+ ];
2474
+ }
2475
+ var PaneSession = class {
2476
+ constructor(options, recording, tmux, agent, isCapturing = true) {
2477
+ this.options = options;
2478
+ this.recording = recording;
2479
+ this.tmux = tmux;
2480
+ this.agent = agent;
2481
+ this.isCapturing = isCapturing;
1188
2482
  }
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}`);
2483
+ options;
2484
+ recording;
2485
+ tmux;
2486
+ agent;
2487
+ isCapturing;
2488
+ fifo = join5(tmpdir2(), `directed-${process.pid}.fifo`);
2489
+ decoder = new StringDecoder("utf8");
2490
+ tap = null;
2491
+ sizeTimer = null;
2492
+ bindTimer = null;
2493
+ hook = null;
2494
+ reader = null;
2495
+ isTapped = false;
2496
+ // The transcript this pane is reading, for capture to attach a stream to.
2497
+ // Null when the command is not an agent directed can follow.
2498
+ get transcript() {
2499
+ return this.reader;
2500
+ }
2501
+ // Brings the pane up. Only tmux failing throws; everything else warns and the
2502
+ // session runs with less capture than it hoped for.
2503
+ //
2504
+ // `running` names a conversation already going in a tmux session this launch
2505
+ // is joining. Then nothing is started: the agent is where it was left, still
2506
+ // writing the transcript it names, and starting a second one on the same
2507
+ // conversation is what the vendor refuses anyway.
2508
+ async start(running = null) {
2509
+ const cols = process.stdout.columns || 80;
2510
+ const rows = process.stdout.rows || 24;
2511
+ const sinceMs = Date.now();
2512
+ let command = this.options.command;
2513
+ let isHooked = false;
2514
+ if (this.agent && running === null) {
2515
+ const agent = this.agent;
2516
+ try {
2517
+ this.hook = await TranscriptHook.open();
2518
+ const hook = this.hook;
2519
+ const hookArgs = await withDeadline(
2520
+ agent.hookArgs(hook),
2521
+ HOOK_SETUP_MS
2522
+ );
2523
+ if (hookArgs) {
2524
+ command = [command[0], ...hookArgs, ...command.slice(1)];
2525
+ isHooked = true;
2526
+ }
2527
+ } catch (e) {
2528
+ process.stderr.write(
2529
+ `[directed] transcript hook unavailable (${String(e)}); falling back to search.
2530
+ `
2531
+ );
2532
+ }
1195
2533
  }
1196
- const body = await res.json();
1197
- return {
1198
- publicId: body.public_id,
1199
- email: body.email,
1200
- username: body.username,
1201
- fullName: body.full_name
1202
- };
1203
- }
1204
- async sessionStart(accessToken, input2) {
1205
- const res = await this.fetchImpl(`${this.hubUrl}/api/terminal-sessions/`, {
1206
- method: "POST",
1207
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
1208
- body: JSON.stringify({
1209
- source: input2.source,
1210
- title: input2.title ?? "",
1211
- external_ref: input2.externalRef ?? "",
1212
- destination_chat_public_id: input2.destinationChatPublicId ?? "",
1213
- member_identifiers: input2.memberIdentifiers ?? []
1214
- })
1215
- });
1216
- if (!res.ok) {
1217
- const detail = await errorDetail(res);
1218
- throw new Error(`POST /api/terminal-sessions/ failed: ${res.status}${detail ? ` (${detail})` : ""}`);
2534
+ if (running === null) {
2535
+ await this.tmux.newSession(bannerWrap(command, cols, rows), cols, rows);
1219
2536
  }
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
2537
+ if (this.isCapturing && running === null) await this.captureStart();
2538
+ if (this.agent) {
2539
+ const agent = this.agent;
2540
+ this.reader = new TranscriptReader(
2541
+ agent,
2542
+ process.cwd(),
2543
+ sinceMs,
2544
+ () => this.recording.mark(TRANSCRIPT_MISSING_NOTE)
2545
+ );
2546
+ this.reader.onRootOpen((key, path) => {
2547
+ void this.tmux.conversationMark(agent.kind, key, path);
1257
2548
  });
2549
+ if (running !== null) {
2550
+ this.reader.fileAdd(running.path, running.key);
2551
+ } else if (isHooked) {
2552
+ this.reader.locateDefer();
2553
+ }
2554
+ this.reader.start();
2555
+ if (running === null) this.announceBind();
1258
2556
  }
1259
- return rows;
2557
+ this.sizeTimer = this.sizePoll();
1260
2558
  }
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}`);
2559
+ // Install the terminal tap only after this wrapper owns hub capture. An
2560
+ // attach-only wrapper must not replace or compete with the pipe feeding the
2561
+ // wrapper that already owns the conversation.
2562
+ async captureStart() {
2563
+ if (this.isTapped) return;
2564
+ try {
2565
+ rmSync3(this.fifo, { force: true });
2566
+ await exited(spawn("mkfifo", [this.fifo]));
2567
+ this.tap = spawn("cat", [this.fifo]);
2568
+ this.tap.stdout?.on(
2569
+ "data",
2570
+ (chunk) => this.recording.write(this.decoder.write(chunk))
2571
+ );
2572
+ await this.tmux.pipePane(this.fifo);
2573
+ this.isTapped = true;
2574
+ } catch (e) {
2575
+ this.tap?.kill();
2576
+ this.tap = null;
2577
+ rmSync3(this.fifo, { force: true });
2578
+ process.stderr.write(
2579
+ `[directed] terminal recording unavailable (${String(e)}).
2580
+ `
2581
+ );
2582
+ }
1268
2583
  }
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 })
2584
+ // Hands the terminal to the pane and waits. Returns the child's exit code.
2585
+ async attach() {
2586
+ return new Promise((resolve) => {
2587
+ this.tmux.attach().on("exit", (code) => resolve(code ?? 0));
1277
2588
  });
1278
- if (!res.ok) throw new Error(`POST .../external-ref/ failed: ${res.status}`);
1279
2589
  }
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 };
2590
+ // Whether the tmux session outlived the attach -- the person detached rather
2591
+ // than the agent exiting. Their work is still running, so it must not be killed.
2592
+ async isDetached() {
2593
+ return this.tmux.hasSession();
1289
2594
  }
1290
- async chunkAppend(accessToken, sessionId, chunk) {
1291
- const res = await this.fetchImpl(`${this.hubUrl}/api/terminal-sessions/${sessionId}/chunks/`, {
1292
- method: "POST",
1293
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
1294
- body: JSON.stringify({
1295
- seq: chunk.seq,
1296
- offset: chunk.offset,
1297
- events: chunk.events,
1298
- width: chunk.width,
1299
- height: chunk.height,
1300
- started_epoch: chunk.startedEpoch
1301
- })
1302
- });
1303
- if (!res.ok) throw new Error(`POST .../chunks/ failed: ${res.status}`);
2595
+ async steer(text, who) {
2596
+ await this.tmux.sendText(text, this.options.steerMode === "send");
2597
+ this.recording.mark(`${who}: ${text}`);
2598
+ }
2599
+ // Unwinds everything acquired in start(), in reverse. `isDetached` decides
2600
+ // whether the agent keeps running: killing a session someone detached from
2601
+ // destroys work they expected to come back to.
2602
+ async stop(isDetached) {
2603
+ if (this.sizeTimer) clearInterval(this.sizeTimer);
2604
+ if (this.bindTimer) clearTimeout(this.bindTimer);
2605
+ await this.reader?.stop();
2606
+ if (!isDetached) await this.tmux.kill();
2607
+ if (this.tap) await tapDrain(this.tap);
2608
+ const tail = this.decoder.end();
2609
+ if (tail) this.recording.write(tail);
2610
+ this.tap?.kill();
2611
+ rmSync3(this.fifo, { force: true });
2612
+ this.hook?.close();
2613
+ this.recording.stop();
2614
+ if (this.reader && !this.reader.isReading) {
2615
+ process.stderr.write(`[directed] ${TRANSCRIPT_MISSING_NOTE}
2616
+ `);
2617
+ }
2618
+ if (isDetached) {
2619
+ process.stdout.write(
2620
+ `[directed] detached; your agent is still running. Reattach with: tmux attach -t ${this.tmux.session}
2621
+ `
2622
+ );
2623
+ }
1304
2624
  }
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)
2625
+ announceBind() {
2626
+ const hook = this.hook;
2627
+ const reader = this.reader;
2628
+ if (!hook || !reader) return;
2629
+ let isBound = false;
2630
+ hook.onAnnounce((announced) => {
2631
+ isBound = true;
2632
+ reader.fileAdd(announced.transcriptPath, announced.sessionId);
1310
2633
  });
1311
- if (!res.ok) {
1312
- throw new Error(`POST ${path} failed: ${res.status}`);
1313
- }
1314
- return await res.json();
2634
+ this.bindTimer = setTimeout(() => {
2635
+ if (!isBound) reader.locateAllow();
2636
+ }, BIND_DEADLINE_MS);
2637
+ }
2638
+ sizePoll() {
2639
+ let last = "";
2640
+ return setInterval(async () => {
2641
+ const { cols, rows } = await this.tmux.paneSize().catch(() => ({ cols: 0, rows: 0 }));
2642
+ if (!cols) return;
2643
+ const key = `${cols}x${rows}`;
2644
+ if (key !== last) {
2645
+ last = key;
2646
+ this.recording.resize(cols, rows);
2647
+ }
2648
+ }, 1e3);
1315
2649
  }
1316
2650
  };
1317
- async function errorDetail(res) {
2651
+ function tapDrain(tap) {
2652
+ return new Promise((resolve) => {
2653
+ const done = () => {
2654
+ clearTimeout(timer);
2655
+ resolve();
2656
+ };
2657
+ const timer = setTimeout(done, READER_DRAIN_MS);
2658
+ tap.stdout?.once("end", done);
2659
+ tap.once("exit", done);
2660
+ });
2661
+ }
2662
+ async function withDeadline(work, ms) {
2663
+ let timer;
1318
2664
  try {
1319
- const body = await res.json();
1320
- return typeof body.detail === "string" ? body.detail : "";
1321
- } catch {
1322
- return "";
2665
+ return await Promise.race([
2666
+ work,
2667
+ new Promise((_, reject) => {
2668
+ timer = setTimeout(
2669
+ () => reject(new Error(`timed out after ${ms}ms`)),
2670
+ ms
2671
+ );
2672
+ })
2673
+ ]);
2674
+ } finally {
2675
+ clearTimeout(timer);
1323
2676
  }
1324
2677
  }
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
- };
2678
+ function exited(child) {
2679
+ return new Promise((resolve, reject) => {
2680
+ child.on("error", reject);
2681
+ child.on(
2682
+ "exit",
2683
+ (c) => c === 0 ? resolve() : reject(new Error(`exit ${c}`))
2684
+ );
2685
+ });
1331
2686
  }
1332
2687
 
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) {
2688
+ // src/tmux.ts
2689
+ import { spawn as spawn2 } from "child_process";
2690
+ var SESSION_OPTIONS = [
2691
+ ["status", "off"],
2692
+ ["mouse", "on"],
2693
+ ["history-limit", "50000"]
2694
+ ];
2695
+ var TAG_SOURCE = "@directed-source";
2696
+ var TAG_KEY = "@directed-key";
2697
+ var TAG_PATH = "@directed-path";
2698
+ async function conversationSession(bin, source, key, runner) {
2699
+ const run = runner ?? defaultRunner(bin);
2700
+ let listed;
1342
2701
  try {
1343
- const raw = readFileSync2(authPath(), "utf8");
1344
- const stored = JSON.parse(raw);
1345
- if (stored.hubUrl !== hubUrl) return null;
1346
- return stored;
2702
+ listed = await run([
2703
+ "list-sessions",
2704
+ "-F",
2705
+ `#{session_name} #{${TAG_SOURCE}} #{${TAG_KEY}} #{${TAG_PATH}}`
2706
+ ]);
1347
2707
  } catch {
1348
2708
  return null;
1349
2709
  }
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);
2710
+ for (const line of listed.split("\n")) {
2711
+ const [name, taggedSource, taggedKey, taggedPath] = line.split(" ");
2712
+ if (taggedSource === source && taggedKey === key && name) {
2713
+ return { session: name, path: taggedPath || null };
2714
+ }
1360
2715
  }
2716
+ return null;
1361
2717
  }
1362
-
1363
- // src/auth/login.ts
1364
- import http from "http";
1365
-
1366
- // src/auth/pkce.ts
1367
- import { createHash, randomBytes } from "crypto";
1368
- function pkce() {
1369
- const verifier = randomBytes(32).toString("base64url");
1370
- const challenge = createHash("sha256").update(verifier).digest("base64url");
1371
- return { verifier, challenge };
1372
- }
1373
-
1374
- // src/auth/login.ts
1375
- var LOGIN_TIMEOUT_MS = 3e5;
1376
- var SUCCESS_BODY = `<!doctype html>
1377
- <html lang="en">
1378
- <head>
1379
- <meta charset="utf-8" />
1380
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1381
- <meta name="color-scheme" content="light dark" />
1382
- <title>Signed in - Directed AI</title>
1383
- <style>
1384
- :root {
1385
- color-scheme: light dark;
1386
- }
1387
- body {
1388
- margin: 0;
1389
- min-height: 100vh;
1390
- display: flex;
1391
- align-items: center;
1392
- justify-content: center;
1393
- font-family: system-ui, sans-serif;
1394
- -webkit-font-smoothing: antialiased;
1395
- -moz-osx-font-smoothing: grayscale;
1396
- background: #fff;
1397
- color: #1a1a1a;
1398
- }
1399
- main {
1400
- text-align: center;
1401
- padding: 0 24px;
1402
- }
1403
- img {
1404
- display: block;
1405
- margin: 0 auto 12px;
1406
- }
1407
- h1 {
1408
- font-size: 24px;
1409
- font-weight: 600;
1410
- letter-spacing: -0.02em;
1411
- margin: 0 0 8px;
1412
- }
1413
- p {
1414
- margin: 0;
1415
- color: #6b6b6b;
1416
- }
1417
- @media (prefers-color-scheme: dark) {
1418
- body {
1419
- background: #1a1a1a;
1420
- color: #f5f5f5;
1421
- }
1422
- p {
1423
- color: #a0a0a0;
1424
- }
1425
- }
1426
- </style>
1427
- </head>
1428
- <body>
1429
- <main>
1430
- <img
1431
- src="https://res.cloudinary.com/dnriuttuy/image/upload/v1761205887/dino-black-with-white-outline_nk3vhs.svg"
1432
- alt="Dino"
1433
- width="40"
1434
- height="40"
1435
- />
1436
- <h1>You are signed in</h1>
1437
- <p>You can close this tab and return to your terminal.</p>
1438
- </main>
1439
- </body>
1440
- </html>`;
1441
- function codeFromRequestUrl(url) {
1442
- const parsed = new URL(url ?? "/", "http://127.0.0.1");
1443
- return parsed.searchParams.get("code");
1444
- }
1445
- function loginFlow(hub, open3) {
1446
- const { verifier, challenge } = pkce();
1447
- return new Promise((resolve, reject) => {
1448
- let settled = false;
1449
- const server = http.createServer((req, res) => {
1450
- const code = codeFromRequestUrl(req.url);
1451
- if (!code) {
1452
- res.writeHead(400, { "Content-Type": "text/plain" });
1453
- res.end("Missing code");
1454
- return;
1455
- }
1456
- res.writeHead(200, { "Content-Type": "text/html" });
1457
- res.end(SUCCESS_BODY);
1458
- onCode(code);
1459
- });
1460
- const timer = setTimeout(() => {
1461
- settle(() => reject(new Error("Login timed out after 300s")));
1462
- }, LOGIN_TIMEOUT_MS);
1463
- function settle(run) {
1464
- if (settled) {
1465
- return;
1466
- }
1467
- settled = true;
1468
- clearTimeout(timer);
1469
- server.close();
1470
- run();
1471
- }
1472
- async function onCode(code) {
1473
- try {
1474
- const tok = await hub.exchange(code, verifier);
1475
- const identity = await hub.me(tok.access);
1476
- const stored = {
1477
- access: tok.access,
1478
- refresh: tok.refresh,
1479
- expiresAt: tok.expiresAt,
1480
- identity,
1481
- hubUrl: hub.url
1482
- };
1483
- authSave(stored);
1484
- settle(() => resolve(stored));
1485
- } catch (err) {
1486
- settle(() => reject(err instanceof Error ? err : new Error(String(err))));
1487
- }
1488
- }
1489
- server.on("error", (err) => {
1490
- settle(() => reject(err));
1491
- });
1492
- server.listen(0, "127.0.0.1", () => {
1493
- const address = server.address();
1494
- if (address === null || typeof address === "string") {
1495
- settle(() => reject(new Error("Failed to determine loopback server port")));
1496
- return;
1497
- }
1498
- const redirectUri = `http://127.0.0.1:${address.port}/cb`;
1499
- open3(hub.startUrl(redirectUri, challenge));
1500
- });
2718
+ function defaultRunner(bin) {
2719
+ return (args) => new Promise((resolve, reject) => {
2720
+ const p = spawn2(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
2721
+ let out = "", err = "";
2722
+ p.stdout.on("data", (d) => out += d);
2723
+ p.stderr.on("data", (d) => err += d);
2724
+ p.on("error", reject);
2725
+ p.on("exit", (code) => code === 0 ? resolve(out) : reject(new Error(`tmux ${args.join(" ")} failed: ${err.trim()}`)));
1501
2726
  });
1502
2727
  }
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;
2728
+ var Tmux = class {
2729
+ constructor(bin, session, runner) {
2730
+ this.bin = bin;
2731
+ this.session = session;
2732
+ this.runner = runner ?? defaultRunner(bin);
1510
2733
  }
1511
- if (stored) {
1512
- try {
1513
- const tok = await hub.refresh(stored.refresh);
1514
- const identity = await hub.me(tok.access);
1515
- authSave({
1516
- access: tok.access,
1517
- refresh: tok.refresh,
1518
- expiresAt: tok.expiresAt,
1519
- identity,
1520
- hubUrl: hub.url
1521
- });
1522
- return identity;
1523
- } catch {
2734
+ bin;
2735
+ session;
2736
+ runner;
2737
+ inputBufferNumber = 0;
2738
+ // Web and hub steers arrive independently; keep each paste and its submit
2739
+ // key together so simultaneous messages cannot interleave in the pane.
2740
+ inputWrite = Promise.resolve();
2741
+ // Detached sessions default to 80x24 until a client attaches; sizing the
2742
+ // pane up front means everything the child draws before attach (a resumed
2743
+ // agent repaints its whole conversation immediately) wraps at the real
2744
+ // terminal width, matching the recording header.
2745
+ async newSession(command, cols, rows) {
2746
+ await this.runner([
2747
+ "new-session",
2748
+ "-d",
2749
+ "-s",
2750
+ this.session,
2751
+ "-x",
2752
+ String(cols),
2753
+ "-y",
2754
+ String(rows),
2755
+ "--",
2756
+ ...command
2757
+ ]);
2758
+ for (const [name, value] of SESSION_OPTIONS) {
2759
+ await this.runner(["set-option", "-t", this.session, name, value]).catch(() => "");
1524
2760
  }
1525
2761
  }
1526
- if (opts.autoLogin) {
1527
- const fresh = await loginFlow(hub, opts.open);
1528
- return fresh.identity;
2762
+ // Names this pane's conversation on the tmux session itself, so a later launch
2763
+ // can find it. tmux outlives directed -- closing the terminal kills the
2764
+ // wrapper and leaves the agent running -- so the pane is the only place this
2765
+ // can be written where it will still be there to read.
2766
+ async conversationMark(source, key, path) {
2767
+ await this.runner(["set-option", "-t", this.session, TAG_SOURCE, source]).catch(() => "");
2768
+ await this.runner(["set-option", "-t", this.session, TAG_KEY, key]).catch(() => "");
2769
+ await this.runner(["set-option", "-t", this.session, TAG_PATH, path]).catch(() => "");
1529
2770
  }
1530
- throw new Error("Not signed in. Run `directed login` first.");
1531
- }
1532
-
1533
- // src/auth/browser.ts
1534
- import { spawn as spawn3 } from "child_process";
1535
- var OPENERS = {
1536
- darwin: (url) => ["open", [url]],
1537
- linux: (url) => ["xdg-open", [url]],
1538
- win32: (url) => ["cmd", ["/c", "start", "", url]]
1539
- };
1540
- function openBrowser(url) {
1541
- const opener = OPENERS[process.platform];
1542
- if (!opener) {
1543
- return;
2771
+ async paneSize() {
2772
+ const out = (await this.runner(["display-message", "-p", "-t", this.session, "#{pane_width}x#{pane_height}"])).trim();
2773
+ const [cols, rows] = out.split("x").map(Number);
2774
+ return { cols, rows };
1544
2775
  }
1545
- const [command, args] = opener(url);
1546
- try {
1547
- const child = spawn3(command, args, { detached: true, stdio: "ignore" });
1548
- child.on("error", () => {
2776
+ pipePane(fifo) {
2777
+ return this.runner(["pipe-pane", "-t", this.session, `cat >> ${fifo}`]).then(() => {
1549
2778
  });
1550
- child.unref();
1551
- } catch {
1552
2779
  }
1553
- }
1554
-
1555
- // src/auth/token-refresher.ts
1556
- var EXPIRY_SKEW_MS2 = 6e4;
1557
- var TokenRefresher = class {
1558
- constructor(hub, stored) {
1559
- this.hub = hub;
1560
- this.stored = stored;
2780
+ sendText(text, submit) {
2781
+ const write = this.inputWrite.then(() => this.textWrite(text, submit));
2782
+ this.inputWrite = write.catch(() => {
2783
+ });
2784
+ return write;
1561
2785
  }
1562
- hub;
1563
- stored;
1564
- refreshing = null;
1565
- async current() {
1566
- if (this.stored.expiresAt - EXPIRY_SKEW_MS2 > Date.now()) {
1567
- return this.stored.access;
1568
- }
1569
- return this.refreshNow();
2786
+ // A person who scrolled is in copy-mode, and a pane in copy-mode swallows the
2787
+ // paste and keeps the Enter: the steer never reaches the agent, and the next
2788
+ // one runs into it as one garbled line. Leaving copy-mode costs a scroll
2789
+ // position; staying in it costs the message.
2790
+ async modeExit() {
2791
+ const mode = await this.runner([
2792
+ "display-message",
2793
+ "-p",
2794
+ "-t",
2795
+ this.session,
2796
+ "#{pane_in_mode}"
2797
+ ]);
2798
+ if (mode.trim() !== "1") return;
2799
+ await this.runner(["send-keys", "-X", "-t", this.session, "cancel"]);
1570
2800
  }
1571
- // Unconditionally refreshes (deduped against a concurrent call), for when
1572
- // the server has already said the token is bad -- a 401, or a chat-socket
1573
- // close code 4002 -- regardless of what our local clock thinks.
1574
- async refreshNow() {
1575
- if (!this.refreshing) {
1576
- this.refreshing = this.refresh();
1577
- }
1578
- try {
1579
- return await this.refreshing;
1580
- } finally {
1581
- this.refreshing = null;
1582
- }
2801
+ async textWrite(text, submit) {
2802
+ await this.modeExit().catch(() => {
2803
+ });
2804
+ const buffer = `${this.session}-input-${this.inputBufferNumber}`;
2805
+ this.inputBufferNumber += 1;
2806
+ await this.runner(["set-buffer", "-b", buffer, "--", text]);
2807
+ await this.runner(["paste-buffer", "-dpr", "-b", buffer, "-t", this.session]);
2808
+ if (submit) await this.runner(["send-keys", "-t", this.session, "Enter"]);
2809
+ }
2810
+ async hasSession() {
2811
+ return this.runner(["has-session", "-t", this.session]).then(() => true).catch(() => false);
1583
2812
  }
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;
2813
+ attach() {
2814
+ return spawn2(this.bin, ["attach", "-t", this.session], { stdio: "inherit" });
2815
+ }
2816
+ kill() {
2817
+ return this.runner(["kill-session", "-t", this.session]).then(() => {
2818
+ }).catch(() => {
2819
+ });
1589
2820
  }
1590
2821
  };
1591
2822
 
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;
2823
+ // src/update.ts
2824
+ import { mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync2, readFileSync as readFileSync2, rmSync as rmSync4, writeFileSync as writeFileSync2 } from "fs";
2825
+ import { spawnSync } from "child_process";
2826
+ import { homedir as homedir4, tmpdir as tmpdir3 } from "os";
2827
+ import { dirname as dirname3, join as join6 } from "path";
2828
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
2829
+ function statePath() {
2830
+ const home = process.env.DIRECTED_HOME ?? homedir4();
2831
+ return join6(home, ".directed", "state.json");
1600
2832
  }
1601
- function chatTargetPublicId(value) {
1602
- const trimmed = value.trim();
2833
+ function stateLoad() {
1603
2834
  try {
1604
- const url = new URL(trimmed);
1605
- const match = url.pathname.match(/\/chat\/([^/]+)\/?$/);
1606
- return match ? decodeURIComponent(match[1]) : trimmed;
2835
+ return JSON.parse(readFileSync2(statePath(), "utf8"));
1607
2836
  } catch {
1608
- return trimmed;
1609
- }
1610
- }
1611
- function chatTargetExact(query, chatList) {
1612
- const publicId = chatTargetPublicId(query);
1613
- return chatList.some((chat) => chat.publicId === publicId) ? publicId : null;
1614
- }
1615
- function chatTargetMenu(chatList) {
1616
- const lines = ["", "Recent Directed chats:"];
1617
- for (const [index, chat] of chatList.entries()) {
1618
- lines.push(`${index + 1}. ${chat.title}`);
1619
- lines.push(` ${chat.url}`);
2837
+ return null;
1620
2838
  }
1621
- return `${lines.join("\n")}
1622
- `;
1623
2839
  }
1624
- async function chatTargetPick(hub, accessToken, query, terminal = DEFAULT_TERMINAL) {
1625
- let prompt = null;
2840
+ function stateSave(state) {
1626
2841
  try {
1627
- const search = query.trim();
1628
- if (search) {
1629
- const chats2 = await hub.chatLookup(accessToken, search);
1630
- const exactPublicId = chatTargetExact(search, chats2);
1631
- if (exactPublicId) return exactPublicId;
1632
- throw new Error("--into=<value> must resolve to an exact Directed chat URL or public id");
1633
- }
1634
- if (!terminal.input.isTTY || !terminal.output.isTTY) {
1635
- throw new Error("--into needs an interactive terminal; use --into=<Directed chat URL or exact public id>");
1636
- }
1637
- const chats = await hub.chatLookup(accessToken, "");
1638
- if (chats.length === 0) {
1639
- throw new Error("No recent chats available; run without --into to start a new chat");
1640
- }
1641
- prompt = createInterface({ input: terminal.input, output: terminal.output });
1642
- terminal.output.write(chatTargetMenu(chats));
1643
- const answer = await prompt.question(`Choose a chat [1-${chats.length}]: `);
1644
- const choice = chatTargetChoice(answer, chats.length);
1645
- if (choice === null) throw new Error("Choose a listed chat number");
1646
- return chats[choice - 1].publicId;
1647
- } finally {
1648
- prompt?.close();
2842
+ const path = statePath();
2843
+ mkdirSync2(dirname3(path), { recursive: true });
2844
+ writeFileSync2(path, JSON.stringify(state));
2845
+ } catch {
1649
2846
  }
1650
2847
  }
1651
-
1652
- // src/cli.ts
1653
- var require2 = createRequire(import.meta.url);
1654
- var CLI_VERSION = require2("../package.json").version;
1655
- function helpText() {
1656
- return `directed - run an interactive coding agent in a Directed chat
1657
-
1658
- Usage:
1659
- directed [options] <command> [args...]
1660
- directed login
1661
- directed logout
1662
- directed upgrade
1663
-
1664
- Options:
1665
- --accept-mode <send|stage> Submit chat steers immediately or stage them
1666
- --chat Stream the session to a Directed chat
1667
- --no-chat Do not stream the session to a Directed chat
1668
- --into[=<URL or public id>] Choose a recent chat or give an exact target
1669
- --with <people> Add @usernames or emails to the session chat
1670
- --invite <emails> Open an email draft with the chat join link
1671
- --open Open the session chat in a browser
1672
- --no-open Do not open the session chat in a browser
1673
- --name <label> Set the session label
1674
- --tmux-bin <path> Use a specific tmux executable
1675
- -h, --help Show this help
1676
-
1677
- Everything after <command> is passed to that command unchanged.
1678
-
1679
- Examples:
1680
- directed codex
1681
- directed --with @jane claude
1682
- directed codex --help`;
1683
- }
1684
- function isHelpRequest(argv) {
1685
- return argv[0] === "--help" || argv[0] === "-h";
1686
- }
1687
- function asMode(v) {
1688
- if (v !== "send" && v !== "stage") throw new Error(`--accept-mode must be 'send' or 'stage', got '${v}'`);
1689
- return v;
2848
+ function versionIsNewer(latest, current) {
2849
+ const a = latest.split(".").map(Number);
2850
+ const b = current.split(".").map(Number);
2851
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
2852
+ const x = a[i] ?? 0;
2853
+ const y = b[i] ?? 0;
2854
+ if (x !== y) return x > y;
2855
+ }
2856
+ return false;
1690
2857
  }
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
- }
2858
+ async function updateCheck(currentVersion, hubUrl, note, fetchImpl = fetch) {
2859
+ const cached = stateLoad();
2860
+ const now = Date.now();
2861
+ let latest = cached?.latestKnownVersion;
2862
+ if (!cached || now - cached.lastCheckedAt > CHECK_INTERVAL_MS) {
2863
+ try {
2864
+ const res = await fetchImpl(`${hubUrl}/cli/version`, { signal: AbortSignal.timeout(1500) });
2865
+ if (res.ok) {
2866
+ latest = (await res.json()).version;
2867
+ stateSave({ lastCheckedAt: now, latestKnownVersion: latest });
1735
2868
  }
1736
- i += 2;
1737
- } else if (a.startsWith("-")) {
1738
- throw new Error(`unknown directed option '${a}'; run 'directed --help'`);
1739
- } else {
1740
- break;
2869
+ } catch {
1741
2870
  }
1742
2871
  }
1743
- const command = argv.slice(i);
1744
- if (command.length === 0) throw new Error("usage: directed [flags] <command> [args...]");
1745
- const toChat = toChatFlag ?? sourceFor(command) !== null;
1746
- const chatTitle = sessionName;
1747
- if (!sessionName) sessionName = command.join(" ");
1748
- return {
1749
- command,
1750
- acceptMode,
1751
- sessionName,
1752
- chatTitle,
1753
- tmuxBin,
1754
- hubUrl: DEFAULTS.hubUrl,
1755
- toChat,
1756
- chatTargetQuery,
1757
- openOnStart,
1758
- invitees,
1759
- inviteEmails
1760
- };
1761
- }
1762
- function inviteMailto(emails, joinUrl, hostName) {
1763
- const subject = `${hostName} invited you to a live Directed session`;
1764
- const body = [
1765
- "Hi,",
1766
- "",
1767
- "I'm running a live coding session and want you in the chat -- follow along and jump in here:",
1768
- "",
1769
- joinUrl,
1770
- "",
1771
- "See you there,",
1772
- hostName
1773
- ].join("\n");
1774
- return `mailto:${emails.join(",")}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
1775
- }
1776
- async function inviteDraftOpen(hub, hubChat, emails, hostName) {
1777
- try {
1778
- const token = await hubChat.tokenRefresher.current();
1779
- const joinUrl = await hub.chatLinkCreate(token, hubChat.chatId);
1780
- process.stdout.write(`[directed] join link: ${joinUrl}
1781
- [directed] opening mail draft to: ${emails.join(", ")}
1782
- `);
1783
- openBrowser(inviteMailto(emails, joinUrl, hostName));
1784
- } catch (err) {
1785
- process.stderr.write(`[directed] could not prepare the email invite (${err.message}); invite them from the chat instead.
1786
- `);
2872
+ if (latest && versionIsNewer(latest, currentVersion)) {
2873
+ note(`a new version is available (${currentVersion} -> ${latest}). Run 'directed upgrade' to update.`);
1787
2874
  }
1788
2875
  }
1789
- async function hubChatStart(hub, config, conversation, echoGuard, externalRef, destinationChatPublicId, recorder) {
1790
- const source = sourceFor(config.command);
1791
- if (!source) {
1792
- process.stderr.write("[directed] chat streaming only works for claude and codex sessions; this tool has no transcript, skipping the chat.\n");
1793
- return null;
1794
- }
1795
- const stored = authLoad(config.hubUrl);
1796
- if (!stored) {
1797
- process.stderr.write("[directed] no signed-in session; run 'directed login' to stream this session to a chat. Continuing without one.\n");
1798
- return null;
2876
+ async function updateInstall(hubUrl, fetchImpl = fetch) {
2877
+ const scriptUrl = `${hubUrl}/cli/install.sh`;
2878
+ process.stdout.write(`[directed] fetching installer from ${scriptUrl}
2879
+ `);
2880
+ const res = await fetchImpl(scriptUrl);
2881
+ if (!res.ok) {
2882
+ console.error(`[directed] could not fetch installer: ${res.status} ${res.statusText}`);
2883
+ return 1;
1799
2884
  }
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
- );
2885
+ const script = await res.text();
2886
+ const dir = mkdtempSync2(join6(tmpdir3(), "directed-upgrade-"));
1814
2887
  try {
1815
- const url = await chat.start();
1816
- const sessionId = chat.sessionId;
1817
- const chatId = chat.chatId;
1818
- const agentActorId = chat.agentActorId;
1819
- if (sessionId === null || chatId === null || agentActorId === null) {
1820
- throw new Error("terminal session ids missing after start");
1821
- }
1822
- const verb = chat.reattached ? "re-attached this session to its chat" : "streaming this session to a chat";
1823
- process.stdout.write(`[directed] ${verb}: ${url}
1824
- `);
1825
- if (chat.skippedInvitees.length > 0) {
1826
- process.stderr.write(`[directed] not added (not your active connections): ${chat.skippedInvitees.join(", ")}
1827
- `);
1828
- }
1829
- if (config.openOnStart) setTimeout(() => openBrowser(url), SPLASH_MS);
1830
- return { transport: chat, sessionId, chatId, agentActorId, tokenRefresher };
1831
- } catch (err) {
1832
- process.stderr.write(`[directed] could not open the chat (${err.message}); continuing locally.
1833
- `);
1834
- return null;
2888
+ const scriptPath = join6(dir, "install.sh");
2889
+ writeFileSync2(scriptPath, script, { mode: 493 });
2890
+ const result = spawnSync("bash", [scriptPath], {
2891
+ stdio: "inherit",
2892
+ env: { ...process.env, DIRECTED_CLI_BASE_URL: hubUrl }
2893
+ });
2894
+ return result.status ?? 1;
2895
+ } finally {
2896
+ rmSync4(dir, { recursive: true, force: true });
1835
2897
  }
1836
2898
  }
2899
+
2900
+ // src/cli.ts
2901
+ var require2 = createRequire(import.meta.url);
2902
+ var CLI_VERSION = require2("../package.json").version;
1837
2903
  async function main(argv = process.argv.slice(2)) {
1838
2904
  if (isHelpRequest(argv)) {
1839
2905
  process.stdout.write(`${helpText()}
1840
2906
  `);
1841
2907
  return 0;
1842
2908
  }
1843
- if (argv[0] === "upgrade") {
1844
- return upgradeRun(DEFAULTS.hubUrl);
1845
- }
1846
- await updateCheckRun(CLI_VERSION, DEFAULTS.hubUrl);
2909
+ if (argv[0] === "__hook") return hookRun();
2910
+ if (argv[0] === "upgrade") return updateInstall(HUB_URL);
1847
2911
  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
- `);
2912
+ const state = await authLogin(new Hub(), browserOpen);
2913
+ process.stdout.write(
2914
+ `[directed] signed in as ${state.identity.email} (${HUB_URL})
2915
+ `
2916
+ );
1852
2917
  return 0;
1853
2918
  }
1854
2919
  if (argv[0] === "logout") {
1855
- authClear();
1856
- process.stdout.write(`[directed] signed out
2920
+ authClear(HUB_URL);
2921
+ process.stdout.write(`[directed] signed out of ${HUB_URL}
2922
+ `);
2923
+ return 0;
2924
+ }
2925
+ if (argv[0] === "whoami") {
2926
+ const state = authLoad(HUB_URL);
2927
+ if (state === null) {
2928
+ process.stdout.write(
2929
+ `[directed] signed out (${HUB_URL}); run '${binName()} login'
2930
+ `
2931
+ );
2932
+ return 1;
2933
+ }
2934
+ process.stdout.write(`[directed] ${state.identity.email} (${HUB_URL})
1857
2935
  `);
1858
2936
  return 0;
1859
2937
  }
1860
- const config = commandSplit(argv);
1861
- const hub = new HubClient(config.hubUrl);
1862
- let identity;
2938
+ const options = optionsParse(argv);
1863
2939
  try {
1864
- identity = await ensureAuth(hub, { autoLogin: true, open: openBrowser });
1865
- } catch (err) {
1866
- console.error(`[directed] ${err.message}`);
2940
+ execFileSync(options.tmuxBin, ["-V"], { stdio: "ignore" });
2941
+ } catch {
2942
+ console.error(
2943
+ "directed needs tmux on PATH (or pass --tmux-bin <path>). Install it, e.g. 'brew install tmux'."
2944
+ );
1867
2945
  return 1;
1868
2946
  }
1869
- process.stdout.write(`[directed] signed in as ${identity.email}
2947
+ return sessionRun(options);
2948
+ }
2949
+ async function sessionRun(options) {
2950
+ const hub = new Hub();
2951
+ const agent = agentResolve(options.command);
2952
+ const auth = authSessionLoad(hub);
2953
+ const isCapturing = options.chatEnabled && agent !== null && auth !== null;
2954
+ let localOnly = null;
2955
+ if (options.chatEnabled && agent === null) {
2956
+ localOnly = "chat streaming only works for claude and codex sessions; this tool has no transcript.";
2957
+ } else if (options.chatEnabled && auth === null) {
2958
+ localOnly = `not signed in to ${HUB_URL}; run '${binName()} login' to share a session.`;
2959
+ }
2960
+ if (localOnly !== null)
2961
+ process.stderr.write(`[directed] ${localOnly} Running locally.
1870
2962
  `);
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
- }
2963
+ let chatPublicId2 = null;
2964
+ if (isCapturing && options.chatTarget.kind !== "new") {
1878
2965
  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}`);
2966
+ hub.authAttach(auth);
2967
+ chatPublicId2 = await chatPick(hub, options.chatTarget);
2968
+ } catch (e) {
2969
+ console.error(`[directed] ${e.message}`);
1883
2970
  return 1;
1884
2971
  }
1885
2972
  }
1886
- try {
1887
- execFileSync(config.tmuxBin, ["-V"], { stdio: "ignore" });
1888
- } catch {
1889
- console.error(`directed needs tmux on PATH (or pass --tmux-bin <path>). Install it, e.g. 'brew install tmux'.`);
1890
- return 1;
1891
- }
1892
2973
  const cols = process.stdout.columns || 80;
1893
2974
  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");
2975
+ const recording = new Recording(cols, rows);
2976
+ const running = agent === null ? null : await runningFind(agent, options);
2977
+ const tmux = new Tmux(
2978
+ options.tmuxBin,
2979
+ running?.session ?? `directed-${randomBytes3(4).toString("hex")}`
2980
+ );
2981
+ const pane = new PaneSession(
2982
+ options,
2983
+ recording,
2984
+ tmux,
2985
+ isCapturing ? agent : null,
2986
+ isCapturing
2987
+ );
2988
+ await pane.start(running?.pane ?? null);
2989
+ const held = [];
2990
+ let isTerminalFree = false;
2991
+ const note = (text) => {
2992
+ recording.mark(`directed: ${text}`);
2993
+ if (isTerminalFree) process.stderr.write(`[directed] ${text}
2994
+ `);
2995
+ else held.push(text);
2996
+ };
2997
+ void updateCheck(CLI_VERSION, HUB_URL, note);
2998
+ const captureAbort = new AbortController();
2999
+ const capturing = isCapturing && auth !== null && agent !== null ? captureStart(
3000
+ hub,
3001
+ auth,
3002
+ agent,
3003
+ options,
3004
+ recording,
3005
+ pane,
3006
+ chatPublicId2,
3007
+ note,
3008
+ running !== null,
3009
+ captureAbort.signal
3010
+ ) : Promise.resolve(null);
3011
+ try {
3012
+ return await pane.attach();
3013
+ } finally {
3014
+ const isDetached = await pane.isDetached();
3015
+ captureAbort.abort();
3016
+ const capture = await capturing;
3017
+ await pane.stop(isDetached);
3018
+ isTerminalFree = true;
3019
+ for (const text of held) process.stderr.write(`[directed] ${text}
3020
+ `);
3021
+ held.length = 0;
3022
+ if (capture) await capture.stop(isDetached);
3023
+ if (localOnly !== null) process.stderr.write(`[directed] ${localOnly}
3024
+ `);
3025
+ }
3026
+ }
3027
+ async function runningFind(agent, options) {
3028
+ const key = await agent.resumeKey(options.command, process.cwd());
3029
+ if (key === null) return null;
3030
+ const found = await conversationSession(options.tmuxBin, agent.kind, key);
3031
+ if (found === null) return null;
3032
+ const path = found.path ?? await agent.transcriptPath(key);
3033
+ if (path === null) return null;
3034
+ return { session: found.session, pane: { key, path } };
3035
+ }
3036
+ function binName() {
3037
+ return basename4(process.argv[1] ?? "directed");
3038
+ }
3039
+ async function captureStart(hub, auth, agent, options, recording, pane, chatPublicId2, note, shouldRetryHeld, signal) {
3040
+ const reader = pane.transcript;
3041
+ if (reader === null) return null;
3042
+ const gitStart = await gitSnapshot(process.cwd());
3043
+ const resumeKey = await agent.resumeKey(options.command, process.cwd());
3044
+ let isHeldReported = false;
3045
+ while (!signal.aborted) {
3046
+ try {
3047
+ hub.authAttach(auth);
3048
+ const capture = await Capture.start(
3049
+ hub,
3050
+ auth,
3051
+ recording,
3052
+ reader,
3053
+ {
3054
+ source: agent.kind,
3055
+ title: options.title,
3056
+ resumeKey,
3057
+ chatPublicId: chatPublicId2,
3058
+ memberList: options.chatMembers,
3059
+ gitStart
3060
+ },
3061
+ note
3062
+ );
3063
+ if (capture === null) return null;
3064
+ await pane.captureStart();
3065
+ note(
3066
+ capture.reattached ? `re-attached to its chat: ${capture.chatUrl}` : `streaming to a chat: ${capture.chatUrl}`
3067
+ );
3068
+ if (capture.skippedMemberList.length > 0) {
3069
+ note(
3070
+ `not added (not your active connections): ${capture.skippedMemberList.join(", ")}`
3071
+ );
3072
+ }
3073
+ if (options.chatOpen) browserOpen(capture.chatUrl);
3074
+ if (options.chatInviteEmails.length > 0) {
3075
+ const mailto = await chatInvite(
3076
+ () => capture.linkCreate(),
3077
+ options.chatInviteEmails,
3078
+ auth.identity.fullName || auth.identity.username,
3079
+ note
3080
+ );
3081
+ if (mailto) browserOpen(mailto);
3082
+ }
3083
+ capture.steerListen(async (steer) => {
3084
+ await pane.steer(steer.text, steer.author);
3085
+ return options.steerMode === "send" ? "sent" : "staged";
3086
+ });
3087
+ return capture;
3088
+ } catch (e) {
3089
+ if (e instanceof TerminalHeldError && shouldRetryHeld) {
3090
+ if (!isHeldReported) {
3091
+ isHeldReported = true;
3092
+ note(
3093
+ `this conversation is already connected: ${e.chatUrl}. This terminal is attached without replacing its capture.`
3094
+ );
3095
+ }
3096
+ if (!await retryWait(signal)) return null;
3097
+ continue;
3098
+ }
3099
+ if (e instanceof TerminalHeldError) {
3100
+ note(
3101
+ `this conversation is already connected: ${e.chatUrl}. Messages there reach it through the run that has it.`
3102
+ );
3103
+ return null;
3104
+ }
3105
+ note(
3106
+ `could not start capture (${e.message}); this session stays local.`
3107
+ );
3108
+ return null;
1906
3109
  }
1907
3110
  }
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
- `);
3111
+ return null;
3112
+ }
3113
+ function retryWait(signal) {
3114
+ return new Promise((resolve) => {
3115
+ if (signal.aborted) {
3116
+ resolve(false);
3117
+ return;
3118
+ }
3119
+ const timer = setTimeout(() => finish(true), 5e3);
3120
+ const abort = () => finish(false);
3121
+ signal.addEventListener("abort", abort, { once: true });
3122
+ function finish(shouldRetry) {
3123
+ clearTimeout(timer);
3124
+ signal.removeEventListener("abort", abort);
3125
+ resolve(shouldRetry);
3126
+ }
3127
+ });
3128
+ }
3129
+ function authSessionLoad(hub) {
3130
+ const state = authLoad(hub.url);
3131
+ return state === null ? null : new AuthSession(hub, state);
3132
+ }
3133
+ var OPENERS = {
3134
+ darwin: (url) => ["open", [url]],
3135
+ linux: (url) => ["xdg-open", [url]],
3136
+ win32: (url) => ["cmd", ["/c", "start", "", url]]
3137
+ };
3138
+ function browserOpen(url) {
3139
+ const opener = OPENERS[process.platform];
3140
+ if (!opener) {
3141
+ return;
3142
+ }
3143
+ const [command, args] = opener(url);
3144
+ try {
3145
+ const child = spawn3(command, args, { detached: true, stdio: "ignore" });
3146
+ child.on("error", () => {
1915
3147
  });
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;
3148
+ child.unref();
3149
+ } catch {
3150
+ return;
3151
+ }
1924
3152
  }
1925
3153
  var argv1 = process.argv[1] ? realpathSync(process.argv[1]) : "";
1926
3154
  if (import.meta.url === pathToFileURL(argv1).href) {
@@ -1930,9 +3158,5 @@ if (import.meta.url === pathToFileURL(argv1).href) {
1930
3158
  });
1931
3159
  }
1932
3160
  export {
1933
- commandSplit,
1934
- helpText,
1935
- inviteMailto,
1936
- isHelpRequest,
1937
3161
  main
1938
3162
  };