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