@directed/cli 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +12 -4
  2. package/dist/cli.js +477 -490
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { realpathSync } from "fs";
4
+ import { realpathSync as realpathSync2 } from "fs";
5
5
  import { basename as basename4 } from "path";
6
6
  import { pathToFileURL } from "url";
7
7
  import { randomBytes as randomBytes3 } from "crypto";
8
- import { execFileSync, spawn as spawn3 } from "child_process";
9
- import { createRequire } from "module";
8
+ import { execFileSync, spawn as spawn2 } from "child_process";
9
+ import { createRequire as createRequire2 } from "module";
10
10
 
11
11
  // src/agent.ts
12
12
  import { basename as basename3 } from "path";
@@ -666,12 +666,15 @@ function commaList(value) {
666
666
  }
667
667
 
668
668
  // src/hub.ts
669
+ import { createRequire } from "module";
669
670
  import { gzip } from "zlib";
670
671
  import { promisify } from "util";
671
672
  var gzipAsync = promisify(gzip);
672
673
  if (false)
673
674
  throw new Error("$HUB_URL is required, e.g. HUB_URL=https://hub.directed.ai");
674
675
  var HUB_URL = "https://hub.directed.ai";
676
+ var require2 = createRequire(import.meta.url);
677
+ var CLI_VERSION = require2("../package.json").version;
675
678
  var TIMEOUT_MS = 15e3;
676
679
  var UPLOAD_TIMEOUT_MS = 6e4;
677
680
  var HubError = class extends Error {
@@ -688,6 +691,12 @@ var HubError = class extends Error {
688
691
  return this.status >= 400 && this.status < 500 && this.status !== 408 && this.status !== 429;
689
692
  }
690
693
  };
694
+ var AuthenticationRequiredError = class extends HubError {
695
+ constructor(detail) {
696
+ super(detail, 401);
697
+ this.name = "AuthenticationRequiredError";
698
+ }
699
+ };
691
700
  var TerminalHeldError = class extends HubError {
692
701
  constructor(chatUrl) {
693
702
  super("this conversation is already connected", 409);
@@ -696,6 +705,14 @@ var TerminalHeldError = class extends HubError {
696
705
  }
697
706
  chatUrl;
698
707
  };
708
+ var UpgradeRequiredError = class extends HubError {
709
+ constructor(detail, minimumVersion) {
710
+ super(detail, 426);
711
+ this.minimumVersion = minimumVersion;
712
+ this.name = "UpgradeRequiredError";
713
+ }
714
+ minimumVersion;
715
+ };
699
716
  var Hub = class {
700
717
  constructor(url = HUB_URL, fetchImpl = fetch) {
701
718
  this.url = url;
@@ -770,20 +787,25 @@ var Hub = class {
770
787
  async terminalCreate(input2) {
771
788
  const response = await this.request("/api/terminal-sessions/", {
772
789
  method: "POST",
773
- expect: [409],
790
+ expect: [409, 426],
774
791
  json: {
775
792
  source: input2.source,
776
793
  title: input2.title ?? "",
777
794
  resume_key: input2.resumeKey ?? "",
778
795
  destination_chat_public_id: input2.chatPublicId ?? "",
779
796
  member_identifiers: input2.memberList ?? [],
780
- git_start: gitSnapshotWire(input2.gitStart)
797
+ git_start: gitSnapshotWire(input2.gitStart),
798
+ client_version: CLI_VERSION
781
799
  }
782
800
  });
783
801
  if (response.status === 409) {
784
802
  const held = await response.json();
785
803
  throw new TerminalHeldError(held.chat_url);
786
804
  }
805
+ if (response.status === 426) {
806
+ const stale = await response.json();
807
+ throw new UpgradeRequiredError(stale.detail, stale.minimum_version);
808
+ }
787
809
  const body = await response.json();
788
810
  return {
789
811
  chatPublicId: body.chat_public_id,
@@ -800,7 +822,6 @@ var Hub = class {
800
822
  method: "POST",
801
823
  json: {
802
824
  transcript_lost: state.transcriptLost,
803
- recording_lost: state.recordingLost,
804
825
  detached: state.isDetached,
805
826
  git_end: gitSnapshotWire(state.gitEnd)
806
827
  }
@@ -816,9 +837,9 @@ var Hub = class {
816
837
  return { isEnded: response.status === 409 };
817
838
  }
818
839
  // 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.
840
+ // lines. Compressed: transcripts are JSON text and shrink about 5x. A
841
+ // subagent's slice names the file that launched it, so lineage arrives with
842
+ // the data it describes.
822
843
  async transcriptAppend(sessionId, batch) {
823
844
  await this.request(`/api/terminal-sessions/${sessionId}/transcript/`, {
824
845
  method: "POST",
@@ -836,19 +857,6 @@ var Hub = class {
836
857
  timeoutMs: UPLOAD_TIMEOUT_MS
837
858
  });
838
859
  }
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
860
  // What the pane did with a steer, so the chat can show delivery rather than
853
861
  // leaving the sender to guess.
854
862
  async steerAck(sessionId, eventId, outcome) {
@@ -868,15 +876,31 @@ var Hub = class {
868
876
  // is unauthenticated; a string is an explicit token for the login path; absent
869
877
  // means the attached session's token, refreshed once if the hub says it is bad.
870
878
  async request(path, spec) {
871
- const response = await this.send(path, spec, await this.tokenFor(spec));
879
+ let token;
880
+ try {
881
+ token = await this.tokenFor(spec);
882
+ } catch (error) {
883
+ if (spec.token === void 0 && this.auth !== null) {
884
+ authenticationRequired(error);
885
+ }
886
+ throw error;
887
+ }
888
+ const response = await this.send(path, spec, token);
872
889
  if (response.status !== 401 || spec.token !== void 0 || this.auth === null) {
873
890
  return this.checked(path, response, spec.expect);
874
891
  }
875
- return this.checked(
876
- path,
877
- await this.send(path, spec, await this.auth.refresh()),
878
- spec.expect
879
- );
892
+ let refreshedToken;
893
+ try {
894
+ refreshedToken = await this.auth.refresh();
895
+ } catch (error) {
896
+ authenticationRequired(error);
897
+ }
898
+ const retried = await this.send(path, spec, refreshedToken);
899
+ try {
900
+ return await this.checked(path, retried, spec.expect);
901
+ } catch (error) {
902
+ authenticationRequired(error);
903
+ }
880
904
  }
881
905
  async tokenFor(spec) {
882
906
  if (spec.token !== void 0) return spec.token;
@@ -919,6 +943,12 @@ var Hub = class {
919
943
  );
920
944
  }
921
945
  };
946
+ function authenticationRequired(error) {
947
+ if (error instanceof HubError && error.status === 401) {
948
+ throw new AuthenticationRequiredError(error.message);
949
+ }
950
+ throw error;
951
+ }
922
952
  function gitSnapshotWire(snapshot) {
923
953
  if (!snapshot) return null;
924
954
  return {
@@ -942,6 +972,57 @@ import { existsSync, mkdirSync, readFileSync, rmSync as rmSync2, writeFileSync }
942
972
  import http from "http";
943
973
  import { homedir as homedir3 } from "os";
944
974
  import { dirname as dirname2, join as join4 } from "path";
975
+
976
+ // src/prompt.ts
977
+ import { stdin, stderr } from "process";
978
+ var PROMPT_TERMINAL = { input: stdin, output: stderr };
979
+ var PromptCancelledError = class extends Error {
980
+ constructor() {
981
+ super("cancelled");
982
+ this.name = "PromptCancelledError";
983
+ }
984
+ };
985
+ function promptIsInteractive(terminal = PROMPT_TERMINAL) {
986
+ return terminal.input.isTTY === true && terminal.output.isTTY === true && typeof terminal.input.setRawMode === "function";
987
+ }
988
+ function promptAction(action, hasColors) {
989
+ const line = `> ${action}`;
990
+ return hasColors ? `\x1B[1;36m${line}\x1B[0m` : line;
991
+ }
992
+ function promptConfirm(title, action, terminal = PROMPT_TERMINAL) {
993
+ if (!promptIsInteractive(terminal)) {
994
+ throw new Error("an interactive terminal is required to continue");
995
+ }
996
+ const input2 = terminal.input;
997
+ const wasRaw = input2.isRaw === true;
998
+ terminal.output.write(
999
+ `${title}
1000
+
1001
+ ${promptAction(action, terminal.output.hasColors?.() === true)}
1002
+ `
1003
+ );
1004
+ return new Promise((resolve, reject) => {
1005
+ function finish(run) {
1006
+ input2.removeListener("data", keyRead);
1007
+ input2.setRawMode?.(wasRaw);
1008
+ input2.pause();
1009
+ run();
1010
+ }
1011
+ function keyRead(chunk) {
1012
+ const text = chunk.toString();
1013
+ if (text.includes("")) {
1014
+ finish(() => reject(new PromptCancelledError()));
1015
+ } else if (text.includes("\r") || text.includes("\n")) {
1016
+ finish(resolve);
1017
+ }
1018
+ }
1019
+ input2.setRawMode?.(true);
1020
+ input2.resume();
1021
+ input2.on("data", keyRead);
1022
+ });
1023
+ }
1024
+
1025
+ // src/auth.ts
945
1026
  var EXPIRY_SKEW_MS = 6e4;
946
1027
  var LOGIN_TIMEOUT_MS = 3e5;
947
1028
  function authPath(hubUrl) {
@@ -968,6 +1049,14 @@ function authClear(hubUrl) {
968
1049
  const path = authPath(hubUrl);
969
1050
  if (existsSync(path)) rmSync2(path);
970
1051
  }
1052
+ async function authPrompt(hub, open3, terminal) {
1053
+ await promptConfirm(
1054
+ `[directed] Sign in to ${hub.url} to share this session.`,
1055
+ "Press Enter to sign in and continue. Ctrl-C to exit.",
1056
+ terminal
1057
+ );
1058
+ return authLogin(hub, open3);
1059
+ }
971
1060
  var AuthSession = class {
972
1061
  constructor(hub, state) {
973
1062
  this.hub = hub;
@@ -1223,6 +1312,9 @@ async function chatInvite(linkCreate, emails, hostName, note) {
1223
1312
  import { execFile } from "child_process";
1224
1313
  import { promisify as promisify2 } from "util";
1225
1314
 
1315
+ // src/transcript.ts
1316
+ import { open as open2, stat as stat3 } from "fs/promises";
1317
+
1226
1318
  // src/outbox.ts
1227
1319
  var BACKOFF_MS = [500, 1500, 4e3, 1e4];
1228
1320
  var BYTES_MAX_DEFAULT = 32 * 1024 * 1024;
@@ -1349,157 +1441,7 @@ function sleep(ms) {
1349
1441
  return new Promise((resolve) => setTimeout(resolve, ms));
1350
1442
  }
1351
1443
 
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.
1376
- width;
1377
- height;
1378
- get header() {
1379
- return { width: this.headerWidth, height: this.headerHeight, startedEpoch: this.startedEpoch };
1380
- }
1381
- // Events the pane produced that no stream ever took.
1382
- get droppedEventCount() {
1383
- return this.droppedCount;
1384
- }
1385
- write(data) {
1386
- this.append([this.elapsed(), "o", data]);
1387
- }
1388
- mark(label) {
1389
- this.append([this.elapsed(), "m", label]);
1390
- }
1391
- // Recorded as an "r" event so playback re-creates the pane geometry at the
1392
- // right point in time -- a replay against stale dims mis-wraps every line the
1393
- // app draws after the resize.
1394
- resize(cols, rows) {
1395
- if (cols === this.width && rows === this.height) return;
1396
- const event = [this.elapsed(), "r", `${cols}x${rows}`];
1397
- this.width = cols;
1398
- this.height = rows;
1399
- this.append(event);
1400
- }
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();
1406
- }
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;
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;
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
- });
1495
- }
1496
- };
1497
- function monotonicMs() {
1498
- return Number(process.hrtime.bigint() / 1000000n);
1499
- }
1500
-
1501
1444
  // src/transcript.ts
1502
- import { open as open2, stat as stat3 } from "fs/promises";
1503
1445
  var POLL_MS = 400;
1504
1446
  var LOCATE_BACKOFF_MS = [400, 800, 1600, 3200, 5e3];
1505
1447
  var MISSING_DEADLINE_MS = 15e3;
@@ -1782,8 +1724,8 @@ var TranscriptReader = class {
1782
1724
  }
1783
1725
  }
1784
1726
  };
1785
- var FLUSH_INTERVAL_MS2 = 250;
1786
- var FLUSH_BYTES_MAX2 = 512 * 1024;
1727
+ var FLUSH_INTERVAL_MS = 250;
1728
+ var FLUSH_BYTES_MAX = 512 * 1024;
1787
1729
  var FLUSH_LINES_MAX = 100;
1788
1730
  var TranscriptStream = class {
1789
1731
  constructor(session, reader) {
@@ -1843,12 +1785,12 @@ var TranscriptStream = class {
1843
1785
  }
1844
1786
  buffer.lines.push(text);
1845
1787
  buffer.bytes += Buffer.byteLength(text, "utf8") + 1;
1846
- if (buffer.bytes >= FLUSH_BYTES_MAX2 || buffer.lines.length >= FLUSH_LINES_MAX) {
1788
+ if (buffer.bytes >= FLUSH_BYTES_MAX || buffer.lines.length >= FLUSH_LINES_MAX) {
1847
1789
  this.flushOne(key);
1848
1790
  return;
1849
1791
  }
1850
1792
  if (!this.timer) {
1851
- this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS2);
1793
+ this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS);
1852
1794
  this.timer.unref();
1853
1795
  }
1854
1796
  }
@@ -2129,11 +2071,10 @@ var HEARTBEAT_INTERVAL_MS = 3e4;
2129
2071
  var SHUTDOWN_MS = 8e3;
2130
2072
  var GIT_TIMEOUT_MS = 5e3;
2131
2073
  var Capture = class _Capture {
2132
- constructor(note, session, reader, recordingStream, transcriptStream, steering, chatUrl, reattached, skippedMemberList) {
2074
+ constructor(note, session, reader, transcriptStream, steering, chatUrl, reattached, skippedMemberList) {
2133
2075
  this.note = note;
2134
2076
  this.session = session;
2135
2077
  this.reader = reader;
2136
- this.recordingStream = recordingStream;
2137
2078
  this.transcriptStream = transcriptStream;
2138
2079
  this.steering = steering;
2139
2080
  this.chatUrl = chatUrl;
@@ -2143,7 +2084,6 @@ var Capture = class _Capture {
2143
2084
  note;
2144
2085
  session;
2145
2086
  reader;
2146
- recordingStream;
2147
2087
  transcriptStream;
2148
2088
  steering;
2149
2089
  chatUrl;
@@ -2153,28 +2093,19 @@ var Capture = class _Capture {
2153
2093
  get chatPublicId() {
2154
2094
  return this.session.chatPublicId;
2155
2095
  }
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;
2169
- }
2170
- const recordingStream = new RecordingStream(session, recording);
2096
+ // Binds an already-open chat to what the pane is now producing.
2097
+ //
2098
+ // Opening is separate and happens first, before tmux owns the screen: every
2099
+ // reason a chat cannot be opened -- signed out, too old for this hub, hub
2100
+ // unreachable -- is a question for the person, and by here there is nowhere
2101
+ // left to ask it.
2102
+ static attach(session, auth, reader, note) {
2171
2103
  const transcriptStream = new TranscriptStream(session, reader);
2172
2104
  const steering = new Steering(session, auth);
2173
2105
  const capture = new _Capture(
2174
2106
  note,
2175
2107
  session,
2176
2108
  reader,
2177
- recordingStream,
2178
2109
  transcriptStream,
2179
2110
  steering,
2180
2111
  session.chatUrl,
@@ -2191,9 +2122,9 @@ var Capture = class _Capture {
2191
2122
  linkCreate() {
2192
2123
  return this.session.linkCreate();
2193
2124
  }
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.
2125
+ // Stop the producers, drain the stream with the time that remains, then post
2126
+ // the end state. `isDetached` means the agent ran on unwatched, which no loss
2127
+ // count can show, so the session says so.
2197
2128
  async stop(isDetached) {
2198
2129
  if (this.stopWork === null) this.stopWork = this.localStop(isDetached);
2199
2130
  await this.stopWork;
@@ -2202,14 +2133,10 @@ var Capture = class _Capture {
2202
2133
  const gitEnd = await gitSnapshot(process.cwd());
2203
2134
  const deadline = Date.now() + SHUTDOWN_MS;
2204
2135
  await this.steering.stop();
2205
- await Promise.all([
2206
- this.recordingStream.stop(deadline),
2207
- this.transcriptStream.stop(deadline)
2208
- ]);
2136
+ await this.transcriptStream.stop(deadline);
2209
2137
  this.lossReport();
2210
2138
  await this.session.close({
2211
2139
  transcriptLost: this.transcriptStream.unsentCount,
2212
- recordingLost: this.recordingStream.unsentCount,
2213
2140
  isDetached,
2214
2141
  gitEnd
2215
2142
  });
@@ -2222,38 +2149,23 @@ var Capture = class _Capture {
2222
2149
  }
2223
2150
  async remoteStopWrite() {
2224
2151
  await this.steering.stop();
2225
- await Promise.all([
2226
- this.recordingStream.stop(Date.now()),
2227
- this.transcriptStream.stop(Date.now())
2228
- ]);
2152
+ await this.transcriptStream.stop(Date.now());
2229
2153
  await this.session.close({
2230
2154
  transcriptLost: this.transcriptStream.unsentCount,
2231
- recordingLost: this.recordingStream.unsentCount,
2232
2155
  isDetached: true,
2233
2156
  gitEnd: null
2234
2157
  });
2235
2158
  this.note("this chat connection ended; the local agent is still running");
2236
2159
  }
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
2160
+ // Said once, after the stream has drained and the pane has already given the
2161
+ // terminal back. The count is the session's integrity state; the last error
2239
2162
  // is the only part a person can act on.
2240
2163
  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;
2164
+ const lost = this.transcriptStream.unsentCount;
2165
+ if (lost > 0) {
2166
+ const why = this.transcriptStream.lastError;
2255
2167
  this.note(
2256
- `${count} ${unit} never reached the hub${why ? ` (${why})` : ""}`
2168
+ `${lost} transcript lines never reached the hub${why ? ` (${why})` : ""}`
2257
2169
  );
2258
2170
  }
2259
2171
  const unresolved = this.reader.unresolvedChildList;
@@ -2317,11 +2229,6 @@ var HubSession = class _HubSession {
2317
2229
  session.heartbeatTimer.unref();
2318
2230
  return session;
2319
2231
  }
2320
- async recordingAppend(chunk) {
2321
- await this.liveRequest(
2322
- () => this.hub.recordingAppend(this.publicId, chunk)
2323
- );
2324
- }
2325
2232
  // A batch names its own file, and a subagent's names the parent that launched
2326
2233
  // it, so lineage arrives with the data it describes and the outbox retries
2327
2234
  // both together.
@@ -2421,11 +2328,6 @@ async function git(args, cwd) {
2421
2328
  }
2422
2329
 
2423
2330
  // 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
2331
  var BANNER = `
2430
2332
  \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591
2431
2333
  \u2591\u2591\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591
@@ -2454,9 +2356,8 @@ var BANNER = `
2454
2356
  Directed helps you collaborate with AI.
2455
2357
  `;
2456
2358
  var BIND_DEADLINE_MS = 2e4;
2457
- var READER_DRAIN_MS = 1e3;
2458
2359
  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";
2360
+ var TRANSCRIPT_MISSING_NOTE = "no transcript found; nothing reaches the chat. The agent runs normally.";
2460
2361
  function bannerWrap(command, cols, rows) {
2461
2362
  const lines = BANNER.replace(/^\n+|\n+$/g, "").split("\n");
2462
2363
  const width = Math.max(...lines.map((l) => l.length));
@@ -2473,26 +2374,20 @@ function bannerWrap(command, cols, rows) {
2473
2374
  ];
2474
2375
  }
2475
2376
  var PaneSession = class {
2476
- constructor(options, recording, tmux, agent, isCapturing = true) {
2377
+ constructor(options, tmux, agent, note) {
2477
2378
  this.options = options;
2478
- this.recording = recording;
2479
2379
  this.tmux = tmux;
2480
2380
  this.agent = agent;
2481
- this.isCapturing = isCapturing;
2381
+ this.note = note;
2482
2382
  }
2483
2383
  options;
2484
- recording;
2485
2384
  tmux;
2486
2385
  agent;
2487
- isCapturing;
2488
- fifo = join5(tmpdir2(), `directed-${process.pid}.fifo`);
2489
- decoder = new StringDecoder("utf8");
2490
- tap = null;
2491
- sizeTimer = null;
2386
+ note;
2492
2387
  bindTimer = null;
2493
2388
  hook = null;
2494
2389
  reader = null;
2495
- isTapped = false;
2390
+ isMissingNoted = false;
2496
2391
  // The transcript this pane is reading, for capture to attach a stream to.
2497
2392
  // Null when the command is not an agent directed can follow.
2498
2393
  get transcript() {
@@ -2534,14 +2429,13 @@ var PaneSession = class {
2534
2429
  if (running === null) {
2535
2430
  await this.tmux.newSession(bannerWrap(command, cols, rows), cols, rows);
2536
2431
  }
2537
- if (this.isCapturing && running === null) await this.captureStart();
2538
2432
  if (this.agent) {
2539
2433
  const agent = this.agent;
2540
2434
  this.reader = new TranscriptReader(
2541
2435
  agent,
2542
2436
  process.cwd(),
2543
2437
  sinceMs,
2544
- () => this.recording.mark(TRANSCRIPT_MISSING_NOTE)
2438
+ () => this.transcriptMissingNote()
2545
2439
  );
2546
2440
  this.reader.onRootOpen((key, path) => {
2547
2441
  void this.tmux.conversationMark(agent.kind, key, path);
@@ -2554,32 +2448,6 @@ var PaneSession = class {
2554
2448
  this.reader.start();
2555
2449
  if (running === null) this.announceBind();
2556
2450
  }
2557
- this.sizeTimer = this.sizePoll();
2558
- }
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
- }
2583
2451
  }
2584
2452
  // Hands the terminal to the pane and waits. Returns the child's exit code.
2585
2453
  async attach() {
@@ -2592,29 +2460,18 @@ var PaneSession = class {
2592
2460
  async isDetached() {
2593
2461
  return this.tmux.hasSession();
2594
2462
  }
2595
- async steer(text, who) {
2463
+ async steer(text) {
2596
2464
  await this.tmux.sendText(text, this.options.steerMode === "send");
2597
- this.recording.mark(`${who}: ${text}`);
2598
2465
  }
2599
2466
  // Unwinds everything acquired in start(), in reverse. `isDetached` decides
2600
2467
  // whether the agent keeps running: killing a session someone detached from
2601
2468
  // destroys work they expected to come back to.
2602
2469
  async stop(isDetached) {
2603
- if (this.sizeTimer) clearInterval(this.sizeTimer);
2604
2470
  if (this.bindTimer) clearTimeout(this.bindTimer);
2605
2471
  await this.reader?.stop();
2606
2472
  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
2473
  this.hook?.close();
2613
- this.recording.stop();
2614
- if (this.reader && !this.reader.isReading) {
2615
- process.stderr.write(`[directed] ${TRANSCRIPT_MISSING_NOTE}
2616
- `);
2617
- }
2474
+ if (this.reader && !this.reader.isReading) this.transcriptMissingNote();
2618
2475
  if (isDetached) {
2619
2476
  process.stdout.write(
2620
2477
  `[directed] detached; your agent is still running. Reattach with: tmux attach -t ${this.tmux.session}
@@ -2622,6 +2479,13 @@ var PaneSession = class {
2622
2479
  );
2623
2480
  }
2624
2481
  }
2482
+ // Once: the reader says it after its deadline, and stop() says it for a
2483
+ // session that ended before the deadline came.
2484
+ transcriptMissingNote() {
2485
+ if (this.isMissingNoted) return;
2486
+ this.isMissingNoted = true;
2487
+ this.note(TRANSCRIPT_MISSING_NOTE);
2488
+ }
2625
2489
  announceBind() {
2626
2490
  const hook = this.hook;
2627
2491
  const reader = this.reader;
@@ -2635,30 +2499,7 @@ var PaneSession = class {
2635
2499
  if (!isBound) reader.locateAllow();
2636
2500
  }, BIND_DEADLINE_MS);
2637
2501
  }
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);
2649
- }
2650
2502
  };
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
2503
  async function withDeadline(work, ms) {
2663
2504
  let timer;
2664
2505
  try {
@@ -2675,18 +2516,9 @@ async function withDeadline(work, ms) {
2675
2516
  clearTimeout(timer);
2676
2517
  }
2677
2518
  }
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
- });
2686
- }
2687
2519
 
2688
2520
  // src/tmux.ts
2689
- import { spawn as spawn2 } from "child_process";
2521
+ import { spawn } from "child_process";
2690
2522
  var SESSION_OPTIONS = [
2691
2523
  ["status", "off"],
2692
2524
  ["mouse", "on"],
@@ -2717,7 +2549,7 @@ async function conversationSession(bin, source, key, runner) {
2717
2549
  }
2718
2550
  function defaultRunner(bin) {
2719
2551
  return (args) => new Promise((resolve, reject) => {
2720
- const p = spawn2(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
2552
+ const p = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
2721
2553
  let out = "", err = "";
2722
2554
  p.stdout.on("data", (d) => out += d);
2723
2555
  p.stderr.on("data", (d) => err += d);
@@ -2741,7 +2573,7 @@ var Tmux = class {
2741
2573
  // Detached sessions default to 80x24 until a client attaches; sizing the
2742
2574
  // pane up front means everything the child draws before attach (a resumed
2743
2575
  // agent repaints its whole conversation immediately) wraps at the real
2744
- // terminal width, matching the recording header.
2576
+ // terminal width.
2745
2577
  async newSession(command, cols, rows) {
2746
2578
  await this.runner([
2747
2579
  "new-session",
@@ -2768,15 +2600,6 @@ var Tmux = class {
2768
2600
  await this.runner(["set-option", "-t", this.session, TAG_KEY, key]).catch(() => "");
2769
2601
  await this.runner(["set-option", "-t", this.session, TAG_PATH, path]).catch(() => "");
2770
2602
  }
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 };
2775
- }
2776
- pipePane(fifo) {
2777
- return this.runner(["pipe-pane", "-t", this.session, `cat >> ${fifo}`]).then(() => {
2778
- });
2779
- }
2780
2603
  sendText(text, submit) {
2781
2604
  const write = this.inputWrite.then(() => this.textWrite(text, submit));
2782
2605
  this.inputWrite = write.catch(() => {
@@ -2811,7 +2634,7 @@ var Tmux = class {
2811
2634
  return this.runner(["has-session", "-t", this.session]).then(() => true).catch(() => false);
2812
2635
  }
2813
2636
  attach() {
2814
- return spawn2(this.bin, ["attach", "-t", this.session], { stdio: "inherit" });
2637
+ return spawn(this.bin, ["attach", "-t", this.session], { stdio: "inherit" });
2815
2638
  }
2816
2639
  kill() {
2817
2640
  return this.runner(["kill-session", "-t", this.session]).then(() => {
@@ -2821,59 +2644,65 @@ var Tmux = class {
2821
2644
  };
2822
2645
 
2823
2646
  // src/update.ts
2824
- import { mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync2, readFileSync as readFileSync2, rmSync as rmSync4, writeFileSync as writeFileSync2 } from "fs";
2647
+ import { mkdtempSync as mkdtempSync2, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "fs";
2825
2648
  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");
2832
- }
2833
- function stateLoad() {
2649
+ import { homedir as homedir4, tmpdir as tmpdir2 } from "os";
2650
+ import { join as join5 } from "path";
2651
+ function installRoot() {
2652
+ return process.env.DIRECTED_CLI_INSTALL_ROOT ?? join5(homedir4(), ".directed");
2653
+ }
2654
+ function installChannel(entry = process.argv[1] ?? "") {
2655
+ let resolved;
2834
2656
  try {
2835
- return JSON.parse(readFileSync2(statePath(), "utf8"));
2657
+ resolved = realpathSync(entry);
2836
2658
  } catch {
2837
- return null;
2659
+ return "unknown";
2838
2660
  }
2661
+ if (resolved.startsWith(`${realpathOr(installRoot())}/`)) return "curl";
2662
+ if (resolved.includes("/node_modules/")) return "npm";
2663
+ return "unknown";
2839
2664
  }
2840
- function stateSave(state) {
2665
+ function realpathOr(path) {
2841
2666
  try {
2842
- const path = statePath();
2843
- mkdirSync2(dirname3(path), { recursive: true });
2844
- writeFileSync2(path, JSON.stringify(state));
2667
+ return realpathSync(path);
2845
2668
  } catch {
2669
+ return path;
2846
2670
  }
2847
2671
  }
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;
2672
+ async function updatePrompt(currentVersion, minimumVersion, terminal) {
2673
+ await promptConfirm(
2674
+ `[directed] Directed ${minimumVersion} or newer is required (you have ${currentVersion}).`,
2675
+ "Press Enter to upgrade and continue. Ctrl-C to exit.",
2676
+ terminal
2677
+ );
2857
2678
  }
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 });
2868
- }
2869
- } catch {
2870
- }
2871
- }
2872
- if (latest && versionIsNewer(latest, currentVersion)) {
2873
- note(`a new version is available (${currentVersion} -> ${latest}). Run 'directed upgrade' to update.`);
2679
+ function updateInstructions(channel) {
2680
+ if (channel === "npm") return "npm install -g @directed/cli@latest";
2681
+ return "curl -fsSL https://hub.directed.ai/cli/install.sh | bash";
2682
+ }
2683
+ async function updateInstall(hubUrl, channel = installChannel(), fetchImpl = fetch) {
2684
+ if (channel === "npm") return npmInstall();
2685
+ if (channel === "curl") return curlInstall(hubUrl, fetchImpl);
2686
+ process.stderr.write(
2687
+ `[directed] this build was not installed by npm or the installer, so it cannot upgrade itself.
2688
+ Install a current one with: ${updateInstructions("curl")}
2689
+ `
2690
+ );
2691
+ return 1;
2692
+ }
2693
+ function npmInstall() {
2694
+ process.stdout.write("[directed] installing @directed/cli@latest from npm\n");
2695
+ const result = spawnSync("npm", ["install", "-g", "@directed/cli@latest"], {
2696
+ stdio: "inherit"
2697
+ });
2698
+ if (result.error) {
2699
+ process.stderr.write(`[directed] npm could not run (${result.error.message}).
2700
+ `);
2701
+ return 1;
2874
2702
  }
2703
+ return result.status ?? 1;
2875
2704
  }
2876
- async function updateInstall(hubUrl, fetchImpl = fetch) {
2705
+ async function curlInstall(hubUrl, fetchImpl) {
2877
2706
  const scriptUrl = `${hubUrl}/cli/install.sh`;
2878
2707
  process.stdout.write(`[directed] fetching installer from ${scriptUrl}
2879
2708
  `);
@@ -2883,23 +2712,35 @@ async function updateInstall(hubUrl, fetchImpl = fetch) {
2883
2712
  return 1;
2884
2713
  }
2885
2714
  const script = await res.text();
2886
- const dir = mkdtempSync2(join6(tmpdir3(), "directed-upgrade-"));
2715
+ const dir = mkdtempSync2(join5(tmpdir2(), "directed-upgrade-"));
2887
2716
  try {
2888
- const scriptPath = join6(dir, "install.sh");
2717
+ const scriptPath = join5(dir, "install.sh");
2889
2718
  writeFileSync2(scriptPath, script, { mode: 493 });
2890
2719
  const result = spawnSync("bash", [scriptPath], {
2891
2720
  stdio: "inherit",
2892
- env: { ...process.env, DIRECTED_CLI_BASE_URL: hubUrl }
2721
+ env: {
2722
+ ...process.env,
2723
+ DIRECTED_CLI_BASE_URL: hubUrl,
2724
+ DIRECTED_CLI_UPGRADE: "1"
2725
+ }
2893
2726
  });
2894
2727
  return result.status ?? 1;
2895
2728
  } finally {
2896
- rmSync4(dir, { recursive: true, force: true });
2729
+ rmSync3(dir, { recursive: true, force: true });
2897
2730
  }
2898
2731
  }
2732
+ function updateLaunch(argv, channel = installChannel()) {
2733
+ const spawn3 = channel === "curl" ? { command: join5(installRoot(), "bin", "directed"), args: argv } : { command: process.execPath, args: [process.argv[1] ?? "", ...argv] };
2734
+ const result = spawnSync(spawn3.command, spawn3.args, {
2735
+ stdio: "inherit",
2736
+ env: { ...process.env, DIRECTED_CLI_RELAUNCHED: "1" }
2737
+ });
2738
+ if (result.error) throw result.error;
2739
+ return result.status ?? 1;
2740
+ }
2899
2741
 
2900
2742
  // src/cli.ts
2901
- var require2 = createRequire(import.meta.url);
2902
- var CLI_VERSION = require2("../package.json").version;
2743
+ var require3 = createRequire2(import.meta.url);
2903
2744
  async function main(argv = process.argv.slice(2)) {
2904
2745
  if (isHelpRequest(argv)) {
2905
2746
  process.stdout.write(`${helpText()}
@@ -2944,68 +2785,105 @@ async function main(argv = process.argv.slice(2)) {
2944
2785
  );
2945
2786
  return 1;
2946
2787
  }
2947
- return sessionRun(options);
2788
+ return sessionRun(options, argv);
2948
2789
  }
2949
- async function sessionRun(options) {
2790
+ async function sessionRun(options, argv) {
2950
2791
  const hub = new Hub();
2951
2792
  const agent = agentResolve(options.command);
2952
- const auth = authSessionLoad(hub);
2953
- const isCapturing = options.chatEnabled && agent !== null && auth !== null;
2793
+ const shouldShare = options.chatEnabled && agent !== null;
2794
+ let auth = shouldShare ? authSessionLoad(hub) : null;
2795
+ if (shouldShare && auth === null) {
2796
+ if (!promptIsInteractive()) {
2797
+ process.stderr.write(
2798
+ `[directed] sign-in is required to share this session; use an interactive terminal.
2799
+ `
2800
+ );
2801
+ return 1;
2802
+ }
2803
+ try {
2804
+ const state = await authPrompt(hub, browserOpen, PROMPT_TERMINAL);
2805
+ auth = new AuthSession(hub, state);
2806
+ process.stderr.write(`[directed] signed in as ${state.identity.email}
2807
+ `);
2808
+ } catch (error) {
2809
+ if (error instanceof PromptCancelledError) return 130;
2810
+ process.stderr.write(
2811
+ `[directed] sign-in failed (${error.message}); the shared session was not started.
2812
+ `
2813
+ );
2814
+ return 1;
2815
+ }
2816
+ }
2817
+ const isCapturing = shouldShare && auth !== null;
2954
2818
  let localOnly = null;
2955
2819
  if (options.chatEnabled && agent === null) {
2956
2820
  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
2821
  }
2960
- if (localOnly !== null)
2961
- process.stderr.write(`[directed] ${localOnly} Running locally.
2962
- `);
2963
2822
  let chatPublicId2 = null;
2964
- if (isCapturing && options.chatTarget.kind !== "new") {
2965
- try {
2966
- hub.authAttach(auth);
2967
- chatPublicId2 = await chatPick(hub, options.chatTarget);
2968
- } catch (e) {
2969
- console.error(`[directed] ${e.message}`);
2823
+ if (isCapturing && auth !== null && options.chatTarget.kind !== "new") {
2824
+ const selected = await authRetry(hub, auth, async (currentAuth) => {
2825
+ hub.authAttach(currentAuth);
2826
+ return chatPick(hub, options.chatTarget);
2827
+ });
2828
+ if (selected.kind === "exit") return selected.code;
2829
+ auth = selected.auth;
2830
+ if (selected.kind === "failed") {
2831
+ console.error(`[directed] ${selected.error.message}`);
2970
2832
  return 1;
2971
2833
  }
2834
+ chatPublicId2 = selected.value;
2972
2835
  }
2973
- const cols = process.stdout.columns || 80;
2974
- const rows = process.stdout.rows || 24;
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
2836
  const held = [];
2990
2837
  let isTerminalFree = false;
2991
2838
  const note = (text) => {
2992
- recording.mark(`directed: ${text}`);
2993
2839
  if (isTerminalFree) process.stderr.write(`[directed] ${text}
2994
2840
  `);
2995
2841
  else held.push(text);
2996
2842
  };
2997
- void updateCheck(CLI_VERSION, HUB_URL, note);
2843
+ const running = agent === null ? null : await runningFind(agent, options);
2844
+ let session = null;
2845
+ let isHeldRetrying = false;
2846
+ if (isCapturing && auth !== null && agent !== null) {
2847
+ const opened = await sessionOpen(
2848
+ hub,
2849
+ auth,
2850
+ agent,
2851
+ options,
2852
+ chatPublicId2,
2853
+ argv,
2854
+ running !== null
2855
+ );
2856
+ if (opened.kind === "exit") return opened.code;
2857
+ if (opened.kind === "opened") {
2858
+ auth = opened.auth;
2859
+ session = opened.session;
2860
+ }
2861
+ if (opened.kind === "held") {
2862
+ auth = opened.auth;
2863
+ isHeldRetrying = true;
2864
+ }
2865
+ if (opened.kind === "local") localOnly = opened.why;
2866
+ }
2867
+ const isSharing = session !== null || isHeldRetrying;
2868
+ if (localOnly !== null)
2869
+ process.stderr.write(`[directed] ${localOnly} Running locally.
2870
+ `);
2871
+ const tmux = new Tmux(
2872
+ options.tmuxBin,
2873
+ running?.session ?? `directed-${randomBytes3(4).toString("hex")}`
2874
+ );
2875
+ const pane = new PaneSession(options, tmux, isSharing ? agent : null, note);
2876
+ await pane.start(running?.pane ?? null);
2998
2877
  const captureAbort = new AbortController();
2999
- const capturing = isCapturing && auth !== null && agent !== null ? captureStart(
2878
+ const capturing = isSharing && auth !== null && agent !== null ? captureBind(
2879
+ session,
3000
2880
  hub,
3001
2881
  auth,
3002
2882
  agent,
3003
2883
  options,
3004
- recording,
3005
2884
  pane,
3006
2885
  chatPublicId2,
3007
2886
  note,
3008
- running !== null,
3009
2887
  captureAbort.signal
3010
2888
  ) : Promise.resolve(null);
3011
2889
  try {
@@ -3036,79 +2914,188 @@ async function runningFind(agent, options) {
3036
2914
  function binName() {
3037
2915
  return basename4(process.argv[1] ?? "directed");
3038
2916
  }
3039
- async function captureStart(hub, auth, agent, options, recording, pane, chatPublicId2, note, shouldRetryHeld, signal) {
2917
+ async function sessionOpen(hub, auth, agent, options, chatPublicId2, argv, isRejoiningPane) {
2918
+ const opened = await authRetry(
2919
+ hub,
2920
+ auth,
2921
+ (currentAuth) => sessionOpenOnce(hub, currentAuth, agent, options, chatPublicId2)
2922
+ );
2923
+ if (opened.kind === "exit") return opened;
2924
+ if (opened.kind === "succeeded") {
2925
+ return { kind: "opened", session: opened.value, auth: opened.auth };
2926
+ }
2927
+ const error = opened.error;
2928
+ if (error instanceof UpgradeRequiredError) return upgradeRun(error, argv);
2929
+ if (error instanceof TerminalHeldError) {
2930
+ if (isRejoiningPane) {
2931
+ process.stderr.write(
2932
+ `[directed] joined the session streaming to ${error.chatUrl}; the original connection keeps posting.
2933
+ `
2934
+ );
2935
+ return { kind: "held", auth: opened.auth };
2936
+ }
2937
+ return {
2938
+ kind: "local",
2939
+ why: `already streaming to ${error.chatUrl} from another terminal.`
2940
+ };
2941
+ }
2942
+ return sharingFailed(error.message);
2943
+ }
2944
+ async function authRetry(hub, auth, operation) {
2945
+ try {
2946
+ return { kind: "succeeded", value: await operation(auth), auth };
2947
+ } catch (error) {
2948
+ if (!(error instanceof AuthenticationRequiredError)) {
2949
+ return { kind: "failed", error, auth };
2950
+ }
2951
+ }
2952
+ authClear(hub.url);
2953
+ if (!promptIsInteractive()) {
2954
+ process.stderr.write(
2955
+ `[directed] the saved sign-in is no longer valid; run '${binName()} login' from an interactive terminal, or use --no-chat.
2956
+ `
2957
+ );
2958
+ return { kind: "exit", code: 1 };
2959
+ }
2960
+ let replacement;
2961
+ try {
2962
+ process.stderr.write(`[directed] the saved sign-in is no longer valid.
2963
+ `);
2964
+ const state = await authPrompt(hub, browserOpen, PROMPT_TERMINAL);
2965
+ replacement = new AuthSession(hub, state);
2966
+ process.stderr.write(`[directed] signed in as ${state.identity.email}
2967
+ `);
2968
+ } catch (error) {
2969
+ if (error instanceof PromptCancelledError) return { kind: "exit", code: 130 };
2970
+ process.stderr.write(
2971
+ `[directed] sign-in failed (${error.message}); the shared session was not started.
2972
+ `
2973
+ );
2974
+ return { kind: "exit", code: 1 };
2975
+ }
2976
+ try {
2977
+ return {
2978
+ kind: "succeeded",
2979
+ value: await operation(replacement),
2980
+ auth: replacement
2981
+ };
2982
+ } catch (error) {
2983
+ if (!(error instanceof AuthenticationRequiredError)) {
2984
+ return { kind: "failed", error, auth: replacement };
2985
+ }
2986
+ authClear(hub.url);
2987
+ process.stderr.write(
2988
+ `[directed] sign-in rejected. Try '${binName()} login', or --no-chat.
2989
+ `
2990
+ );
2991
+ return { kind: "exit", code: 1 };
2992
+ }
2993
+ }
2994
+ async function sessionOpenOnce(hub, auth, agent, options, chatPublicId2) {
2995
+ hub.authAttach(auth);
2996
+ return HubSession.open(hub, {
2997
+ source: agent.kind,
2998
+ title: options.title,
2999
+ resumeKey: await agent.resumeKey(options.command, process.cwd()),
3000
+ chatPublicId: chatPublicId2,
3001
+ memberList: options.chatMembers,
3002
+ gitStart: await gitSnapshot(process.cwd())
3003
+ });
3004
+ }
3005
+ async function sharingFailed(reason) {
3006
+ const cause = /fetch failed|abort|timeout/i.test(reason) ? "can't reach the hub" : `could not start a shared session (${reason})`;
3007
+ const why = `${cause}; nothing will be shared or steerable.`;
3008
+ if (!promptIsInteractive()) {
3009
+ process.stderr.write(`[directed] ${why} Run with --no-chat to work locally.
3010
+ `);
3011
+ return { kind: "exit", code: 1 };
3012
+ }
3013
+ try {
3014
+ await promptConfirm(
3015
+ `[directed] ${why}`,
3016
+ "Press Enter to run locally. Ctrl-C to exit.",
3017
+ PROMPT_TERMINAL
3018
+ );
3019
+ } catch (e) {
3020
+ if (e instanceof PromptCancelledError) return { kind: "exit", code: 130 };
3021
+ throw e;
3022
+ }
3023
+ return { kind: "local", why };
3024
+ }
3025
+ async function upgradeRun(refusal, argv) {
3026
+ const channel = installChannel();
3027
+ const version = CLI_VERSION.split("+")[0];
3028
+ if (process.env.DIRECTED_CLI_RELAUNCHED === "1") {
3029
+ process.stderr.write(
3030
+ `[directed] still running ${version} after upgrading, and this hub needs ${refusal.minimumVersion}.
3031
+ Install it with: ${updateInstructions(channel)}
3032
+ `
3033
+ );
3034
+ return { kind: "exit", code: 1 };
3035
+ }
3036
+ if (!promptIsInteractive() || channel === "unknown") {
3037
+ process.stderr.write(
3038
+ `[directed] ${refusal.message}
3039
+ Upgrade with: ${updateInstructions(channel)}
3040
+ `
3041
+ );
3042
+ return { kind: "exit", code: 1 };
3043
+ }
3044
+ try {
3045
+ await updatePrompt(version, refusal.minimumVersion, PROMPT_TERMINAL);
3046
+ } catch (e) {
3047
+ if (e instanceof PromptCancelledError) return { kind: "exit", code: 130 };
3048
+ throw e;
3049
+ }
3050
+ if (await updateInstall(HUB_URL, channel) !== 0) {
3051
+ process.stderr.write(
3052
+ `[directed] the upgrade did not finish; the shared session was not started.
3053
+ `
3054
+ );
3055
+ return { kind: "exit", code: 1 };
3056
+ }
3057
+ process.stderr.write(`[directed] restarting on the new version
3058
+ `);
3059
+ return { kind: "exit", code: updateLaunch(argv, channel) };
3060
+ }
3061
+ async function captureBind(opened, hub, auth, agent, options, pane, chatPublicId2, note, signal) {
3040
3062
  const reader = pane.transcript;
3041
3063
  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) {
3064
+ let session = opened;
3065
+ while (session === null) {
3066
+ if (signal.aborted || !await retryWait(signal)) return null;
3046
3067
  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;
3068
+ session = await sessionOpenOnce(hub, auth, agent, options, chatPublicId2);
3088
3069
  } 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
- );
3070
+ if (e instanceof TerminalHeldError) continue;
3071
+ note(`could not open the chat (${e.message}); this session stays local.`);
3108
3072
  return null;
3109
3073
  }
3110
3074
  }
3111
- return null;
3075
+ const capture = Capture.attach(session, auth, reader, note);
3076
+ note(
3077
+ capture.reattached ? `re-attached to its chat: ${capture.chatUrl}` : `streaming to a chat: ${capture.chatUrl}`
3078
+ );
3079
+ if (capture.skippedMemberList.length > 0) {
3080
+ note(
3081
+ `not connections yet, so not added: ${capture.skippedMemberList.join(", ")}. Use --invite instead.`
3082
+ );
3083
+ }
3084
+ if (options.chatOpen) browserOpen(capture.chatUrl);
3085
+ if (options.chatInviteEmails.length > 0) {
3086
+ const mailto = await chatInvite(
3087
+ () => capture.linkCreate(),
3088
+ options.chatInviteEmails,
3089
+ auth.identity.fullName || auth.identity.username,
3090
+ note
3091
+ );
3092
+ if (mailto) browserOpen(mailto);
3093
+ }
3094
+ capture.steerListen(async (steer) => {
3095
+ await pane.steer(steer.text);
3096
+ return options.steerMode === "send" ? "sent" : "staged";
3097
+ });
3098
+ return capture;
3112
3099
  }
3113
3100
  function retryWait(signal) {
3114
3101
  return new Promise((resolve) => {
@@ -3142,7 +3129,7 @@ function browserOpen(url) {
3142
3129
  }
3143
3130
  const [command, args] = opener(url);
3144
3131
  try {
3145
- const child = spawn3(command, args, { detached: true, stdio: "ignore" });
3132
+ const child = spawn2(command, args, { detached: true, stdio: "ignore" });
3146
3133
  child.on("error", () => {
3147
3134
  });
3148
3135
  child.unref();
@@ -3150,7 +3137,7 @@ function browserOpen(url) {
3150
3137
  return;
3151
3138
  }
3152
3139
  }
3153
- var argv1 = process.argv[1] ? realpathSync(process.argv[1]) : "";
3140
+ var argv1 = process.argv[1] ? realpathSync2(process.argv[1]) : "";
3154
3141
  if (import.meta.url === pathToFileURL(argv1).href) {
3155
3142
  main().then((code) => process.exit(code)).catch((err) => {
3156
3143
  console.error(err?.message ?? err);