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