@staff0rd/assist 0.760.0 → 0.761.1

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/index.js CHANGED
@@ -6,7 +6,7 @@ import { Command } from "commander";
6
6
  // package.json
7
7
  var package_default = {
8
8
  name: "@staff0rd/assist",
9
- version: "0.760.0",
9
+ version: "0.761.1",
10
10
  type: "module",
11
11
  main: "dist/index.js",
12
12
  bin: {
@@ -15735,6 +15735,9 @@ async function restartWeb(req, res, deps2 = {}) {
15735
15735
  respondJson(res, 400, { error: "Invalid target" });
15736
15736
  return;
15737
15737
  }
15738
+ console.log(
15739
+ `restart ${target} from=${req.headers["x-assist-linked-from"] ?? "local"} trace=${req.headers[TRACE_HEADER] ?? "none"}`
15740
+ );
15738
15741
  await new Promise((resolve26) => {
15739
15742
  res.once("finish", resolve26);
15740
15743
  respondJson(res, 200, { ok: true });
@@ -15937,6 +15940,350 @@ function newSessionDefaults(req, res) {
15937
15940
  respondJson(res, 200, { mode });
15938
15941
  }
15939
15942
 
15943
+ // src/commands/sessions/nodes/fetchPeerJson.ts
15944
+ var PEER_TIMEOUT_MS = 3e3;
15945
+ function peerErrorCode(error) {
15946
+ if (!(error instanceof Error)) return void 0;
15947
+ const { code, cause } = error;
15948
+ if (code) return code;
15949
+ if (error.name === "TimeoutError") return "ETIMEDOUT";
15950
+ return cause instanceof Error ? peerErrorCode(cause) : void 0;
15951
+ }
15952
+ function describePeerError(error) {
15953
+ if (!(error instanceof Error)) return String(error);
15954
+ const cause = error.cause;
15955
+ return cause instanceof Error ? `${error.message}: ${describePeerError(cause)}` : error.message;
15956
+ }
15957
+ async function fetchPeerJson(baseUrl, path106, timeoutMs = PEER_TIMEOUT_MS) {
15958
+ const res = await fetch(new URL(path106, baseUrl), {
15959
+ signal: AbortSignal.timeout(timeoutMs)
15960
+ });
15961
+ if (!res.ok)
15962
+ throw Object.assign(
15963
+ new Error(`GET ${path106} returned ${res.status} ${res.statusText}`),
15964
+ { status: res.status }
15965
+ );
15966
+ return await res.json();
15967
+ }
15968
+
15969
+ // src/commands/sessions/daemon/links/connectLinkWebSocket.ts
15970
+ import WebSocket from "ws";
15971
+ var HANDSHAKE_TIMEOUT_MS = 5e3;
15972
+ function linkWebSocketUrl(url) {
15973
+ const target = new URL("/ws", url);
15974
+ target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
15975
+ return target.toString();
15976
+ }
15977
+ function connectLinkWebSocket(url, handlers) {
15978
+ return new Promise((resolve26, reject) => {
15979
+ const ws = new WebSocket(linkWebSocketUrl(url), {
15980
+ handshakeTimeout: HANDSHAKE_TIMEOUT_MS
15981
+ });
15982
+ let open = false;
15983
+ ws.on("open", () => {
15984
+ open = true;
15985
+ resolve26({
15986
+ send: (line) => {
15987
+ if (ws.readyState !== WebSocket.OPEN) return false;
15988
+ ws.send(line);
15989
+ return true;
15990
+ },
15991
+ close: () => ws.close()
15992
+ });
15993
+ });
15994
+ ws.on("message", (data) => handlers.onLine(data.toString()));
15995
+ ws.on("error", (error) => {
15996
+ if (!open) reject(error);
15997
+ });
15998
+ ws.on("close", (code, reason4) => {
15999
+ const detail = `code ${code}${reason4.length ? ` ${reason4.toString()}` : ""}`;
16000
+ if (open) handlers.onClose(detail);
16001
+ else reject(new Error(`closed before open (${detail})`));
16002
+ });
16003
+ });
16004
+ }
16005
+
16006
+ // src/commands/sessions/nodes/probePeerHello.ts
16007
+ var HELLO_TIMEOUT_MS = 5e3;
16008
+ async function probePeerHello(url) {
16009
+ let settle = () => {
16010
+ };
16011
+ const reply2 = new Promise((resolve26) => {
16012
+ settle = resolve26;
16013
+ });
16014
+ const socket = await connectLinkWebSocket(url, {
16015
+ onLine: (line) => {
16016
+ try {
16017
+ const msg = JSON.parse(line);
16018
+ if (msg.type === "hello") settle(msg);
16019
+ } catch {
16020
+ }
16021
+ },
16022
+ onClose: (reason4) => settle(new Error(`closed before hello reply (${reason4})`))
16023
+ });
16024
+ const timer = setTimeout(
16025
+ () => settle(new Error(`no hello reply within ${HELLO_TIMEOUT_MS / 1e3}s`)),
16026
+ HELLO_TIMEOUT_MS
16027
+ );
16028
+ socket.send(JSON.stringify(buildHello()));
16029
+ const result = await reply2;
16030
+ clearTimeout(timer);
16031
+ socket.close();
16032
+ if (result instanceof Error) throw result;
16033
+ return result;
16034
+ }
16035
+
16036
+ // src/commands/sessions/nodes/buildDoctorProbes.ts
16037
+ function buildDoctorProbes(live) {
16038
+ return {
16039
+ health: (url) => fetchPeerJson(url, "/api/health"),
16040
+ hello: probePeerHello,
16041
+ tailscale: tailscaleStatus,
16042
+ linkState: (name) => {
16043
+ if (!live) return "no-daemon";
16044
+ return live.links.find((l) => l.name === name) ?? "unknown-link";
16045
+ }
16046
+ };
16047
+ }
16048
+
16049
+ // src/commands/sessions/daemon/links/linkVersionCheck.ts
16050
+ function linkVersionCheck() {
16051
+ return loadConfig().sessions?.linkVersionCheck ?? "block";
16052
+ }
16053
+
16054
+ // src/commands/sessions/nodes/DoctorProbes.ts
16055
+ function passed(hop, detail) {
16056
+ return { hop, ok: true, detail };
16057
+ }
16058
+ function failed(hop, error, remediation) {
16059
+ return { hop, ok: false, error, remediation };
16060
+ }
16061
+
16062
+ // src/commands/sessions/nodes/helloHop.ts
16063
+ async function helloHop(spec, probes) {
16064
+ let msg;
16065
+ try {
16066
+ msg = await probes.hello(spec.url);
16067
+ } catch (error) {
16068
+ return failed(
16069
+ "ws",
16070
+ describePeerError(error),
16071
+ `${spec.name}'s /api/health answers but its /ws does not \u2014 restart the web server on ${spec.name}, then check \`assist sessions nodes logs ${spec.name}\``
16072
+ );
16073
+ }
16074
+ if (!isHello(msg))
16075
+ return failed(
16076
+ "ws",
16077
+ `malformed hello: ${JSON.stringify(msg)}`,
16078
+ `run \`assist update\` on ${spec.name}`
16079
+ );
16080
+ const detail = `hello ${msg.version} (protocol ${msg.protocol ?? "legacy"})`;
16081
+ if (helloCompatible(msg)) return passed("ws", detail);
16082
+ const mismatch = `${helloMismatchKind(msg)} mismatch: peer ${msg.version} (protocol ${msg.protocol ?? "legacy"}), this node ${ASSIST_VERSION} (protocol ${PROTOCOL_VERSION})`;
16083
+ const mode = linkVersionCheck();
16084
+ if (mode !== "block")
16085
+ return passed(
16086
+ "ws",
16087
+ `${mismatch}; allowed by sessions.linkVersionCheck=${mode}`
16088
+ );
16089
+ return failed(
16090
+ "ws",
16091
+ mismatch,
16092
+ `this node heals an older peer via its /api/self-update on connect; if the heal latched, run \`assist update\` on the older node, then \`assist daemon restart\` here`
16093
+ );
16094
+ }
16095
+
16096
+ // src/commands/sessions/nodes/linkStateHop.ts
16097
+ function linkStateHop(spec, probes) {
16098
+ const status2 = probes.linkState(spec.name);
16099
+ if (status2 === "no-daemon")
16100
+ return failed(
16101
+ "link",
16102
+ "this node's daemon is not running",
16103
+ "links run in the daemon \u2014 start this node's web server or run `assist daemon restart`"
16104
+ );
16105
+ if (status2 === "unknown-link")
16106
+ return failed(
16107
+ "link",
16108
+ `this node's daemon has no link named ${spec.name}`,
16109
+ "the daemon has not loaded sessions.links \u2014 run `assist daemon restart`"
16110
+ );
16111
+ if (status2.state === "connected") return passed("link", "connected");
16112
+ const error = status2.error ?? status2.state;
16113
+ if (status2.state === "version-blocked")
16114
+ return failed("link", error, `version mismatch \u2014 heal latched. ${error}`);
16115
+ return failed(
16116
+ "link",
16117
+ error,
16118
+ `every probe passed but the daemon's link is ${status2.state}; it retries behind the circuit breaker \u2014 check this node's daemon.log for \`link ${spec.name} ws:\` lines`
16119
+ );
16120
+ }
16121
+
16122
+ // src/commands/sessions/nodes/findTailscalePeer.ts
16123
+ function findTailscalePeer(status2, hostname2) {
16124
+ const nodes = [
16125
+ ...status2.Self ? [{ ...status2.Self, Online: true }] : [],
16126
+ ...Object.values(status2.Peer ?? {})
16127
+ ];
16128
+ return nodes.find(
16129
+ (node) => node.DNSName?.replace(/\.$/, "").toLowerCase() === hostname2
16130
+ );
16131
+ }
16132
+
16133
+ // src/commands/sessions/nodes/tailscaleHop.ts
16134
+ async function tailscaleHop(spec, probes) {
16135
+ const cli = tailscaleCli();
16136
+ let status2;
16137
+ try {
16138
+ status2 = await probes.tailscale();
16139
+ } catch (error) {
16140
+ return failed(
16141
+ "tailscale",
16142
+ describePeerError(error),
16143
+ `\`${cli} status --json\` failed \u2014 install Tailscale on this node and sign in with \`${cli} up\``
16144
+ );
16145
+ }
16146
+ if (status2.BackendState !== "Running")
16147
+ return failed(
16148
+ "tailscale",
16149
+ `Tailscale on this node is ${status2.BackendState ?? "not running"}`,
16150
+ `run \`${cli} up\` on this node`
16151
+ );
16152
+ const { hostname: hostname2 } = new URL(spec.url);
16153
+ const peer = findTailscalePeer(status2, hostname2);
16154
+ if (!peer)
16155
+ return failed(
16156
+ "tailscale",
16157
+ `${hostname2} is not in this node's tailnet`,
16158
+ `check the host in \`${cli} status\`, then relink with \`assist sessions nodes link ${spec.name} --tailscale <host> --port <port>\``
16159
+ );
16160
+ if (!peer.Online)
16161
+ return failed(
16162
+ "tailscale",
16163
+ `${hostname2} is offline in the tailnet`,
16164
+ `is ${spec.name}'s machine awake with Tailscale running and signed in?`
16165
+ );
16166
+ return passed(
16167
+ "tailscale",
16168
+ `${hostname2} online${peer.TailscaleIPs?.[0] ? ` (${peer.TailscaleIPs[0]})` : ""}`
16169
+ );
16170
+ }
16171
+
16172
+ // src/commands/sessions/nodes/tailscaleServeRemediation.ts
16173
+ function tailscaleServeRemediation(spec, error) {
16174
+ const status2 = error.status;
16175
+ if (status2 !== void 0 && status2 !== 502) return void 0;
16176
+ const { hostname: hostname2, port } = new URL(spec.url);
16177
+ const servePort = port || "443";
16178
+ return status2 === 502 ? `tailscale serve on ${hostname2} answered but nothing listens on 127.0.0.1:${servePort} behind it \u2014 is ${spec.name}'s web server running (project-switch webservers)?` : `nothing serves https on ${servePort} on ${hostname2} \u2014 ${spec.name}'s web server runs tailscale serve when it starts: check the \`tailscale serve:\` line in its log (project-switch "View logs") and that sessions.tailscaleServe is not false there`;
16179
+ }
16180
+
16181
+ // src/commands/sessions/nodes/isTailscaleLink.ts
16182
+ function isTailscaleLink(spec) {
16183
+ return new URL(spec.url).hostname.endsWith(".ts.net");
16184
+ }
16185
+
16186
+ // src/commands/sessions/nodes/webHop.ts
16187
+ function webRemediation(spec, error) {
16188
+ const serve = isTailscaleLink(spec) ? tailscaleServeRemediation(spec, error) : void 0;
16189
+ if (serve) return serve;
16190
+ const { hostname: hostname2, port } = new URL(spec.url);
16191
+ switch (peerErrorCode(error)) {
16192
+ case "ECONNREFUSED":
16193
+ return `nothing listening on ${port} on ${hostname2} \u2014 is ${spec.name}'s web server running (project-switch webservers)?`;
16194
+ case "ETIMEDOUT":
16195
+ return `${hostname2}:${port} did not answer \u2014 is ${spec.name} up and the port reachable from here?`;
16196
+ case "ENOTFOUND":
16197
+ return `cannot resolve ${hostname2} \u2014 fix the url with \`assist sessions nodes link ${spec.name} <url>\``;
16198
+ }
16199
+ if (error.status === 404)
16200
+ return `${spec.name}'s web server has no /api/health \u2014 run \`assist update\` on ${spec.name}`;
16201
+ return `check the link url ${spec.url} with \`assist sessions nodes\``;
16202
+ }
16203
+ async function webHop(spec, probes) {
16204
+ let health2;
16205
+ try {
16206
+ health2 = await probes.health(spec.url);
16207
+ } catch (error) {
16208
+ return {
16209
+ hop: failed("web", describePeerError(error), webRemediation(spec, error))
16210
+ };
16211
+ }
16212
+ if (health2.nodeName !== spec.name)
16213
+ return {
16214
+ hop: failed(
16215
+ "web",
16216
+ `peer reports nodeName ${health2.nodeName}, link expects ${spec.name}`,
16217
+ `relink with \`assist sessions nodes unlink ${spec.name}\` then \`assist sessions nodes link ${health2.nodeName} ${spec.url}\`, or set sessions.nodeName on the peer`
16218
+ )
16219
+ };
16220
+ return {
16221
+ hop: passed(
16222
+ "web",
16223
+ `${health2.nodeName} ${health2.version} (protocol ${health2.protocol})`
16224
+ ),
16225
+ health: health2
16226
+ };
16227
+ }
16228
+
16229
+ // src/commands/sessions/nodes/peerDaemonHop.ts
16230
+ function peerDaemonHop(spec, health2) {
16231
+ if (health2.daemon?.reachable) return passed("daemon", "reachable");
16232
+ return failed(
16233
+ "daemon",
16234
+ `${spec.name}'s web server reports its daemon unreachable`,
16235
+ `run \`assist daemon status\` on ${spec.name}; restarting its web server starts the daemon`
16236
+ );
16237
+ }
16238
+
16239
+ // src/commands/sessions/nodes/diagnoseLink.ts
16240
+ async function diagnoseLink(spec, probes) {
16241
+ const hops = [];
16242
+ const done2 = () => ({ ...spec, ok: hops.every((h) => h.ok), hops });
16243
+ if (isTailscaleLink(spec)) {
16244
+ hops.push(await tailscaleHop(spec, probes));
16245
+ if (!hops.at(-1)?.ok) return done2();
16246
+ }
16247
+ const web3 = await webHop(spec, probes);
16248
+ hops.push(web3.hop);
16249
+ if (!web3.health) return done2();
16250
+ hops.push(peerDaemonHop(spec, web3.health));
16251
+ if (!hops.at(-1)?.ok) return done2();
16252
+ hops.push(await helloHop(spec, probes));
16253
+ if (!hops.at(-1)?.ok) return done2();
16254
+ hops.push(linkStateHop(spec, probes));
16255
+ return done2();
16256
+ }
16257
+
16258
+ // src/commands/sessions/nodes/firstBrokenHop.ts
16259
+ async function firstBrokenHop(name) {
16260
+ const spec = findLinkSpec(name);
16261
+ if (!spec)
16262
+ return {
16263
+ hop: "link",
16264
+ ok: false,
16265
+ error: `no link named ${name}`,
16266
+ remediation: "see `assist sessions nodes`"
16267
+ };
16268
+ const diagnosis = await diagnoseLink(
16269
+ spec,
16270
+ buildDoctorProbes(await queryNodes())
16271
+ );
16272
+ return diagnosis.hops.find((hop) => !hop.ok);
16273
+ }
16274
+
16275
+ // src/commands/sessions/web/nodeDoctor.ts
16276
+ async function nodeDoctor(req, res) {
16277
+ const link3 = new URL(req.url ?? "/", "http://localhost").searchParams.get(
16278
+ "link"
16279
+ );
16280
+ if (!link3) {
16281
+ respondJson(res, 400, { error: "Missing link" });
16282
+ return;
16283
+ }
16284
+ respondJson(res, 200, { hop: await firstBrokenHop(link3) ?? null });
16285
+ }
16286
+
15940
16287
  // src/commands/sessions/shared/sessionViewDefaults.ts
15941
16288
  var sessionViewDefaults = {
15942
16289
  floatWaiting: true,
@@ -16840,6 +17187,7 @@ var routes2 = {
16840
17187
  "POST /api/pr-preview/upload-image": uploadPrImage,
16841
17188
  "GET /api/pr-preview/image": previewImage,
16842
17189
  "POST /api/restart": restartWeb,
17190
+ "GET /api/node-doctor": nodeDoctor,
16843
17191
  "POST /api/self-update": selfUpdate,
16844
17192
  "GET /api/github-url": githubUrl,
16845
17193
  "GET /api/git-status": gitStatus,
@@ -25305,6 +25653,7 @@ var persistedSessionSchema = z9.object({
25305
25653
  activity: activitySchema.optional(),
25306
25654
  starred: z9.boolean().optional(),
25307
25655
  watcher: z9.boolean().optional(),
25656
+ divergenceEscalation: z9.boolean().optional(),
25308
25657
  design: z9.boolean().optional(),
25309
25658
  auto: z9.boolean().optional(),
25310
25659
  autoRun: z9.boolean().optional(),
@@ -25338,6 +25687,7 @@ function toPersistedSession(session) {
25338
25687
  activity: session.activity,
25339
25688
  starred: session.starred,
25340
25689
  watcher: session.watcher,
25690
+ divergenceEscalation: session.divergenceEscalation,
25341
25691
  design: session.design,
25342
25692
  auto: session.auto,
25343
25693
  autoRun: session.autoRun,
@@ -35647,7 +35997,7 @@ function skipReason(results, failures, strict) {
35647
35997
  }
35648
35998
 
35649
35999
  // src/commands/review/runAndSynthesise.ts
35650
- function failed(results) {
36000
+ function failed2(results) {
35651
36001
  return results.filter((r) => r.exitCode !== 0);
35652
36002
  }
35653
36003
  async function runAndSynthesise(args) {
@@ -35664,7 +36014,7 @@ async function runAndSynthesise(args) {
35664
36014
  models: args.models
35665
36015
  }
35666
36016
  );
35667
- const failures = failed(results);
36017
+ const failures = failed2(results);
35668
36018
  const reason4 = skipReason(results, failures, args.strict);
35669
36019
  if (reason4) {
35670
36020
  console.error(reason4);
@@ -39548,11 +39898,19 @@ function resolveRunCwd(config, baseDir = runConfigBaseDir()) {
39548
39898
 
39549
39899
  // src/commands/run/runPreCommands.ts
39550
39900
  import { execSync as execSync66 } from "child_process";
39551
- function runPreCommands(pre, cwd) {
39901
+ function writeCapturedOutput(error) {
39902
+ for (const stream of ["stdout", "stderr"]) {
39903
+ const output = error[stream];
39904
+ if (output) process.stdout.write(output);
39905
+ }
39906
+ }
39907
+ function runPreCommands(pre, cwd, quiet) {
39552
39908
  for (const cmd of pre) {
39553
39909
  try {
39554
- execSync66(cmd, { stdio: "inherit", cwd });
39910
+ execSync66(cmd, { stdio: quiet ? "pipe" : "inherit", cwd });
39555
39911
  } catch (error) {
39912
+ if (quiet && error && typeof error === "object")
39913
+ writeCapturedOutput(error);
39556
39914
  const code = error && typeof error === "object" && "status" in error ? error.status : 1;
39557
39915
  process.exit(code);
39558
39916
  }
@@ -39578,14 +39936,14 @@ async function runWatchBuild(entry) {
39578
39936
  if (!(error instanceof MissingRunCwdError)) throw error;
39579
39937
  return { kind: "failed", exitCode: 1, output: error.message };
39580
39938
  }
39581
- if (config.pre) runPreCommands(config.pre, cwd);
39939
+ if (config.pre) runPreCommands(config.pre, cwd, true);
39582
39940
  return toBuildOutcome(
39583
39941
  await runCommandToCompletion(
39584
39942
  config.command,
39585
39943
  [...config.args ?? [], ...resolveParams(config.params, [])],
39586
39944
  config.env,
39587
39945
  cwd,
39588
- config.quiet
39946
+ true
39589
39947
  )
39590
39948
  );
39591
39949
  }
@@ -39605,7 +39963,13 @@ async function reportBuildOrExit(entry) {
39605
39963
  // src/commands/watch/runPostBuildSync.ts
39606
39964
  async function runPostBuildSync() {
39607
39965
  return toBuildOutcome(
39608
- await runCommandToCompletion("assist", ["sync", "--yes"])
39966
+ await runCommandToCompletion(
39967
+ "assist",
39968
+ ["sync", "--yes"],
39969
+ void 0,
39970
+ void 0,
39971
+ true
39972
+ )
39609
39973
  );
39610
39974
  }
39611
39975
 
@@ -40652,7 +41016,8 @@ function toSessionInfo(session) {
40652
41016
  pendingPrPreview,
40653
41017
  undurable,
40654
41018
  lastUserMessage,
40655
- activeViewer
41019
+ activeViewer,
41020
+ activeViewerNode
40656
41021
  } = session;
40657
41022
  return {
40658
41023
  ...toSessionRunInfo(session),
@@ -40679,6 +41044,7 @@ function toSessionInfo(session) {
40679
41044
  undurable,
40680
41045
  lastUserMessage,
40681
41046
  activeViewer,
41047
+ activeViewerNode,
40682
41048
  joinable: joinRefusal(session) === void 0
40683
41049
  };
40684
41050
  }
@@ -40697,11 +41063,29 @@ function broadcastSessions(sessions, clients, linkedSessions = [], active) {
40697
41063
  broadcast(peers, { type: "sessions", sessions: local, active: {} });
40698
41064
  }
40699
41065
 
41066
+ // src/commands/sessions/daemon/releaseViewer.ts
41067
+ function releaseViewer(sessions, viewerId) {
41068
+ let released = false;
41069
+ for (const s of sessions.values()) {
41070
+ if (s.activeViewer !== viewerId) continue;
41071
+ daemonLog(
41072
+ `session ${s.id} active viewer ${viewerId} on ${s.activeViewerNode ?? "unknown node"} left`
41073
+ );
41074
+ s.activeViewer = void 0;
41075
+ s.activeViewerNode = void 0;
41076
+ released = true;
41077
+ }
41078
+ return released;
41079
+ }
41080
+
40700
41081
  // src/commands/sessions/daemon/claimViewer.ts
40701
- function claimViewer(session, { viewerId, onClaim }, via) {
41082
+ function claimViewer(session, { viewerId, viewerNode, onClaim }, via) {
40702
41083
  if (!viewerId || session.activeViewer === viewerId) return;
40703
41084
  session.activeViewer = viewerId;
40704
- daemonLog(`session ${session.id} active viewer ${viewerId} (${via})`);
41085
+ session.activeViewerNode = viewerNode;
41086
+ daemonLog(
41087
+ `session ${session.id} active viewer ${viewerId} on ${viewerNode ?? "unknown node"} (${via})`
41088
+ );
40705
41089
  onClaim?.();
40706
41090
  }
40707
41091
 
@@ -40743,6 +41127,29 @@ function resizePty(s, cols, rows) {
40743
41127
  }
40744
41128
  }
40745
41129
 
41130
+ // src/commands/sessions/daemon/ViewerConnections.ts
41131
+ var ViewerConnections = class {
41132
+ byClient = /* @__PURE__ */ new Map();
41133
+ track(client, viewerId) {
41134
+ const ids = this.byClient.get(client) ?? /* @__PURE__ */ new Set();
41135
+ ids.add(viewerId);
41136
+ this.byClient.set(client, ids);
41137
+ }
41138
+ releaseUnheld(client, viewerId) {
41139
+ const ids = this.byClient.get(client);
41140
+ if (!ids) return [];
41141
+ const dropped = viewerId === void 0 ? [...ids] : [viewerId];
41142
+ for (const id of dropped) ids.delete(id);
41143
+ if (ids.size === 0) this.byClient.delete(client);
41144
+ return dropped.filter((id) => !this.isHeld(id));
41145
+ }
41146
+ isHeld(viewerId) {
41147
+ for (const ids of this.byClient.values())
41148
+ if (ids.has(viewerId)) return true;
41149
+ return false;
41150
+ }
41151
+ };
41152
+
40746
41153
  // src/commands/sessions/daemon/watchPromptSubmit.ts
40747
41154
  var CARRIAGE_RETURN = "\r";
40748
41155
  function isPromptSubmit(data) {
@@ -40793,14 +41200,29 @@ function setAutoAdvance(sessions, id, enabled) {
40793
41200
 
40794
41201
  // src/commands/sessions/daemon/SessionIo.ts
40795
41202
  var SessionIo = class {
40796
- constructor(sessions, onStatusChange, notify2) {
41203
+ constructor(sessions, onStatusChange, notify2, links2) {
40797
41204
  this.sessions = sessions;
40798
41205
  this.onStatusChange = onStatusChange;
40799
41206
  this.notify = notify2;
41207
+ this.links = links2;
41208
+ }
41209
+ viewers = new ViewerConnections();
41210
+ trackViewer(client, d) {
41211
+ if (typeof d.viewerId !== "string") return;
41212
+ this.viewers.track(client, d.viewerId);
41213
+ d.viewerNode ??= this.links.localNode();
40800
41214
  }
40801
- write(id, data, viewerId) {
41215
+ viewerLeft(client, viewerId) {
41216
+ let released = false;
41217
+ for (const id of this.viewers.releaseUnheld(client, viewerId)) {
41218
+ released = releaseViewer(this.sessions, id) || released;
41219
+ this.links.viewerLeft(client, id);
41220
+ }
41221
+ if (released) this.notify();
41222
+ }
41223
+ write(id, data, viewer) {
40802
41224
  writeToSession(this.sessions, id, data, this.onStatusChange(), {
40803
- viewerId,
41225
+ ...viewer,
40804
41226
  onClaim: this.notify
40805
41227
  });
40806
41228
  }
@@ -43246,6 +43668,7 @@ function shouldAutoDismiss(session, exitCode) {
43246
43668
  }
43247
43669
 
43248
43670
  // src/commands/sessions/daemon/finishStatusChange.ts
43671
+ var DIVERGED_EXIT_CODE = 3;
43249
43672
  function finishStatusChange(session, status2, exitCode, deps2) {
43250
43673
  daemonLog(`session ${session.id} status: ${session.status} -> ${status2}`);
43251
43674
  void flushPhaseActiveMs(session);
@@ -43253,6 +43676,8 @@ function finishStatusChange(session, status2, exitCode, deps2) {
43253
43676
  if (status2 === "waiting")
43254
43677
  startTranscriptTitleGeneration(session, deps2.notify);
43255
43678
  if (status2 !== "stopped") session.undurable = void 0;
43679
+ if (session.watcher === true && exitCode === DIVERGED_EXIT_CODE)
43680
+ deps2.escalateDivergence?.(session);
43256
43681
  const autoRun = shouldAutoRun(session);
43257
43682
  if (autoRun.run) {
43258
43683
  deps2.reuseForRun(session, autoRun.itemId);
@@ -43269,19 +43694,18 @@ function finishStatusChange(session, status2, exitCode, deps2) {
43269
43694
  }
43270
43695
 
43271
43696
  // src/commands/sessions/daemon/applyStatusChange.ts
43272
- function applyStatusChange(session, status2, exitCode, dismiss, notify2, reuseForRun, sharedWithOtherAgents = () => false) {
43697
+ function applyStatusChange(session, status2, exitCode, deps2, sharedWithOtherAgents = () => false) {
43273
43698
  if (session.status === status2) return;
43274
- const deps2 = { dismiss, notify: notify2, reuseForRun };
43275
43699
  if (endsStreamLife(session, status2, sharedWithOtherAgents)) {
43276
43700
  session.closing = true;
43277
43701
  daemonLog(
43278
43702
  `session ${session.id} closing: checking durability of ${session.worktree?.path} before reap`
43279
43703
  );
43280
- notify2();
43704
+ deps2.notify();
43281
43705
  void resolveCloseDurability(
43282
43706
  session,
43283
43707
  () => finishStatusChange(session, "done", exitCode, deps2),
43284
- notify2
43708
+ deps2.notify
43285
43709
  );
43286
43710
  return;
43287
43711
  }
@@ -43289,18 +43713,378 @@ function applyStatusChange(session, status2, exitCode, dismiss, notify2, reuseFo
43289
43713
  }
43290
43714
 
43291
43715
  // src/commands/sessions/daemon/makeStatusChangeHandler.ts
43292
- function makeStatusChangeHandler(sessions, dismiss, notify2, reuseForRun) {
43716
+ function makeStatusChangeHandler(sessions, deps2) {
43293
43717
  return (s, status2, exitCode) => applyStatusChange(
43294
43718
  s,
43295
43719
  status2,
43296
43720
  exitCode,
43297
- dismiss,
43298
- notify2,
43299
- reuseForRun,
43721
+ deps2,
43300
43722
  (session) => otherTreeHolders(sessions, session).length > 0
43301
43723
  );
43302
43724
  }
43303
43725
 
43726
+ // src/commands/sessions/daemon/worktree/detectExistingWorktree.ts
43727
+ function detectExistingWorktree(cwd) {
43728
+ const repoRoot2 = findRepoRoot(cwd) ?? cwd;
43729
+ if (!worktreeConfigFor(repoRoot2).enabled) return void 0;
43730
+ const clone = mainWorktree(repoRoot2);
43731
+ if (clone && clone !== repoRoot2) return { path: repoRoot2, clone };
43732
+ return void 0;
43733
+ }
43734
+
43735
+ // src/commands/sessions/daemon/worktree/bindNewWorktree.ts
43736
+ function bindNewWorktree(session, alloc, notify2, startHeld = () => {
43737
+ }) {
43738
+ if (alloc.kind !== "worktree" || !alloc.cwd || !alloc.clone || !session)
43739
+ return;
43740
+ session.worktree = { path: alloc.cwd, clone: alloc.clone };
43741
+ if (alloc.created)
43742
+ seedWorktree(alloc.cwd, alloc.clone, () => startHeld(session));
43743
+ notify2();
43744
+ }
43745
+ function bindRestoredWorktrees(sessions) {
43746
+ for (const session of sessions.values()) {
43747
+ if (session.worktree || !session.cwd) continue;
43748
+ const worktree = detectExistingWorktree(session.cwd);
43749
+ if (worktree) session.worktree = worktree;
43750
+ }
43751
+ }
43752
+ function bindResumedWorktree(session, cwd, notify2) {
43753
+ if (!session) return;
43754
+ const worktree = detectExistingWorktree(cwd);
43755
+ if (!worktree) return;
43756
+ session.worktree = worktree;
43757
+ notify2();
43758
+ }
43759
+
43760
+ // src/commands/sessions/daemon/attachReusedRun.ts
43761
+ function attachReusedRun(session, alloc, clients, onStatusChange, tree) {
43762
+ broadcast(clients, { type: "clear", sessionId: session.id });
43763
+ if (alloc)
43764
+ bindNewWorktree(
43765
+ session,
43766
+ alloc,
43767
+ tree?.notify ?? (() => {
43768
+ }),
43769
+ tree?.startHeld
43770
+ );
43771
+ else wirePtyEvents(session, clients, onStatusChange);
43772
+ }
43773
+
43774
+ // src/commands/sessions/daemon/resetCardForRun.ts
43775
+ function resetCardForRun(session, assistArgs) {
43776
+ session.pty?.kill();
43777
+ session.gitWatcher?.close();
43778
+ session.gitWatcher = void 0;
43779
+ session.undurable = void 0;
43780
+ session.assistArgs = assistArgs;
43781
+ session.name = `assist ${assistArgs.join(" ")}`;
43782
+ session.commandType = "assist";
43783
+ session.activity = void 0;
43784
+ removeActivity(session.id);
43785
+ session.scrollback = "";
43786
+ session.startedAt = Date.now();
43787
+ session.runningMs = 0;
43788
+ session.runningSince = null;
43789
+ session.usageSeeded = false;
43790
+ setStatus2(session, "running");
43791
+ session.restored = void 0;
43792
+ session.launchedFrom = void 0;
43793
+ }
43794
+
43795
+ // src/commands/sessions/daemon/refuseSpawn.ts
43796
+ function refuseSpawn(session, error, clients, onStatusChange, stage = "respawned") {
43797
+ const reason4 = error instanceof Error ? error.message : String(error);
43798
+ session.pty = null;
43799
+ session.pendingStart = void 0;
43800
+ session.error = reason4;
43801
+ daemonLog(
43802
+ `session ${session.id} ("${session.name}") not ${stage}: ${reason4}`
43803
+ );
43804
+ broadcast(clients, { type: "clear", sessionId: session.id });
43805
+ emitSessionOutput(
43806
+ session,
43807
+ clients,
43808
+ `\r
43809
+ \x1B[31mCannot start this session: ${reason4}\x1B[0m\r
43810
+ `
43811
+ );
43812
+ onStatusChange(session, "error");
43813
+ }
43814
+
43815
+ // src/commands/sessions/daemon/startReusedRunPty.ts
43816
+ function startReusedRunPty(session, assistArgs, itemId2, hold, clients, onStatusChange) {
43817
+ try {
43818
+ Object.assign(
43819
+ session,
43820
+ startOrHoldPty(
43821
+ () => spawnPty(["assist", ...assistArgs], session.cwd, session.id),
43822
+ hold
43823
+ )
43824
+ );
43825
+ } catch (error) {
43826
+ refuseSpawn(
43827
+ session,
43828
+ error,
43829
+ clients,
43830
+ onStatusChange,
43831
+ `reused for backlog run ${itemId2}`
43832
+ );
43833
+ return false;
43834
+ }
43835
+ return true;
43836
+ }
43837
+
43838
+ // src/commands/sessions/daemon/createAssistSession.ts
43839
+ function createAssistSession(id, assistArgs, cwd, meta, holdPty) {
43840
+ const startedAt = Date.now();
43841
+ return {
43842
+ id,
43843
+ name: `assist ${assistArgs.join(" ")}`,
43844
+ title: meta?.title,
43845
+ subtitle: meta?.subtitle,
43846
+ commandType: "assist",
43847
+ status: "running",
43848
+ startedAt,
43849
+ runningMs: 0,
43850
+ runningSince: startedAt,
43851
+ waitingSince: null,
43852
+ ...startOrHoldPty(
43853
+ () => spawnPty(["assist", ...assistArgs], cwd, id),
43854
+ holdPty
43855
+ ),
43856
+ scrollback: "",
43857
+ assistArgs,
43858
+ cwd
43859
+ };
43860
+ }
43861
+
43862
+ // src/commands/sessions/daemon/createWatcherSession.ts
43863
+ var WATCHER_ARGS = ["watch", "loop"];
43864
+ function createWatcherSession(id, cwd) {
43865
+ return {
43866
+ ...createAssistSession(id, WATCHER_ARGS, cwd),
43867
+ watcher: true
43868
+ };
43869
+ }
43870
+
43871
+ // src/commands/sessions/daemon/worktree/boundTreeRoots.ts
43872
+ function boundTreeRoots(sessions, exclude) {
43873
+ const roots = /* @__PURE__ */ new Set();
43874
+ for (const session of sessions.values()) {
43875
+ if (session === exclude) continue;
43876
+ const root2 = treeRootOf(session);
43877
+ if (root2 && !roots.has(root2) && holdsTree(session, root2)) roots.add(root2);
43878
+ }
43879
+ return roots;
43880
+ }
43881
+ function treeRootOf(session) {
43882
+ if (!session.cwd) return void 0;
43883
+ return canonicalTreePath(findRepoRoot(session.cwd) ?? session.cwd);
43884
+ }
43885
+ function holdsTree(session, root2) {
43886
+ if (session.closing) return true;
43887
+ if (session.status !== "done" && session.status !== "error") return true;
43888
+ if (!worktreeConfigFor(root2).enabled) return true;
43889
+ const durability = checkDurabilitySync(root2);
43890
+ daemonLog(`tree ${root2} ${releaseReason(durability, session)}`);
43891
+ return !durability.durable;
43892
+ }
43893
+ function releaseReason(durability, session) {
43894
+ if (!durability.durable)
43895
+ return `still held by finished session ${session.id}: ${durability.reason}`;
43896
+ if (durability.gone)
43897
+ return `free for allocation: session ${session.id}'s directory is gone from disk \u2014 nothing to land, not landed work`;
43898
+ return `free for allocation: session ${session.id} finished and its work is landed`;
43899
+ }
43900
+
43901
+ // src/commands/sessions/daemon/worktree/allocateAndBind.ts
43902
+ function allocateAndBind(ctx, cwd, create, options2 = {}, context) {
43903
+ const alloc = allocateTree(cwd, boundTreeRoots(ctx.sessions), options2);
43904
+ const needsSeeding = alloc.kind === "worktree" && alloc.created === true;
43905
+ const id = ctx.spawnWith(
43906
+ (sid) => create(sid, alloc.cwd, needsSeeding),
43907
+ context
43908
+ );
43909
+ bindNewWorktree(ctx.sessions.get(id), alloc, ctx.notify, ctx.startHeld);
43910
+ return id;
43911
+ }
43912
+
43913
+ // src/commands/sessions/daemon/worktree/ensureWatcher.ts
43914
+ function ensureWatcher(ctx, cwd) {
43915
+ if (!cwd) return void 0;
43916
+ const repoRoot2 = findRepoRoot(cwd) ?? cwd;
43917
+ const cfg = worktreeConfigFor(repoRoot2);
43918
+ if (!cfg.enabled || cfg.watcher !== true) return void 0;
43919
+ const clone = resolveClone(repoRoot2);
43920
+ const live = liveWatcherFor(ctx.sessions, clone);
43921
+ if (live) {
43922
+ daemonLog(
43923
+ `no watcher spawned for the clone ${clone}: session ${live.id} is already watching it (${live.status})`
43924
+ );
43925
+ return void 0;
43926
+ }
43927
+ const id = allocateAndBind(
43928
+ ctx,
43929
+ clone,
43930
+ (sid, resolvedCwd) => createWatcherSession(sid, resolvedCwd ?? clone),
43931
+ { inPlace: true }
43932
+ );
43933
+ daemonLog(
43934
+ `spawned watcher session ${id} running assist ${WATCHER_ARGS.join(" ")} in the clone ${clone}`
43935
+ );
43936
+ return id;
43937
+ }
43938
+
43939
+ // src/commands/sessions/daemon/worktree/isBacklogRunArgs.ts
43940
+ var ITEM_ID = /^a?\d+$/;
43941
+ function isBacklogRunArgs(args) {
43942
+ const [command, subcommand, ...rest] = args;
43943
+ if (command !== "backlog" || subcommand !== "run") return false;
43944
+ return rest.some((arg) => ITEM_ID.test(arg));
43945
+ }
43946
+
43947
+ // src/commands/sessions/daemon/worktree/isPrCheckoutArgs.ts
43948
+ var CHECKOUT_COMMANDS = /* @__PURE__ */ new Set([
43949
+ "review",
43950
+ "review-pr-comments",
43951
+ "fix-conflict"
43952
+ ]);
43953
+ function isPrCheckoutArgs(args) {
43954
+ const [command, ...rest] = args;
43955
+ if (!command || !CHECKOUT_COMMANDS.has(command)) return false;
43956
+ return rest.some((arg) => /^[1-9]\d*$/.test(arg));
43957
+ }
43958
+
43959
+ // src/commands/sessions/daemon/worktree/isCommittingArgs.ts
43960
+ function isCommittingArgs(args) {
43961
+ return isPrCheckoutArgs(args) || isBacklogRunArgs(args);
43962
+ }
43963
+
43964
+ // src/commands/sessions/daemon/worktree/planReuseTree.ts
43965
+ function planReuseTree(session, ctx, options2 = {}) {
43966
+ if (!ctx || !session.cwd || session.worktree) return void 0;
43967
+ const alloc = allocateTree(
43968
+ session.cwd,
43969
+ boundTreeRoots(ctx.sessions, session),
43970
+ options2
43971
+ );
43972
+ if (alloc.kind !== "worktree" || !alloc.cwd) return void 0;
43973
+ daemonLog(
43974
+ `session ${session.id} moved from ${session.cwd} to ${alloc.cwd} for its chained run`
43975
+ );
43976
+ return alloc;
43977
+ }
43978
+
43979
+ // src/commands/sessions/daemon/failChainedRun.ts
43980
+ function failChainedRun(session, itemId2, error, clients, tree) {
43981
+ const reason4 = error instanceof Error ? error.message : String(error);
43982
+ session.pty = null;
43983
+ session.error = `backlog run ${itemId2} could not start: ${reason4}`;
43984
+ setStatus2(session, "error");
43985
+ daemonLog(
43986
+ `session ${session.id} not reused for backlog run ${itemId2}: workspace allocation failed: ${reason4}`
43987
+ );
43988
+ broadcast(clients, { type: "clear", sessionId: session.id });
43989
+ emitSessionOutput(
43990
+ session,
43991
+ clients,
43992
+ `\r
43993
+ \x1B[31m${session.error}\x1B[0m\r
43994
+ `
43995
+ );
43996
+ tree?.notify();
43997
+ }
43998
+
43999
+ // src/commands/sessions/daemon/reuseSessionForRun.ts
44000
+ function reuseSessionForRun(session, itemId2, clients, onStatusChange, tree) {
44001
+ const assistArgs = ["backlog", "run", String(itemId2)];
44002
+ const originCwd = session.cwd;
44003
+ resetCardForRun(session, assistArgs);
44004
+ let alloc;
44005
+ try {
44006
+ alloc = planReuseTree(session, tree, {
44007
+ commits: isCommittingArgs(assistArgs),
44008
+ backlogRun: true
44009
+ });
44010
+ } catch (error) {
44011
+ failChainedRun(session, itemId2, error, clients, tree);
44012
+ return;
44013
+ }
44014
+ if (alloc) session.cwd = alloc.cwd;
44015
+ const started = startReusedRunPty(
44016
+ session,
44017
+ assistArgs,
44018
+ itemId2,
44019
+ alloc !== void 0,
44020
+ clients,
44021
+ onStatusChange
44022
+ );
44023
+ if (!started) return;
44024
+ if (tree) ensureWatcher(tree, originCwd);
44025
+ attachReusedRun(session, alloc, clients, onStatusChange, tree);
44026
+ daemonLog(
44027
+ `session ${session.id} reused for backlog run ${itemId2}: ${session.name}`
44028
+ );
44029
+ }
44030
+
44031
+ // src/commands/sessions/daemon/worktree/divergencePrompt.ts
44032
+ var MAX_REASON_LINES = 20;
44033
+ var MAX_REASON_CHARS = 2e3;
44034
+ function divergencePrompt(clone, scrollback) {
44035
+ const reason4 = stripAnsi(scrollback).split(/\r?\n|\r/).map((line) => line.trimEnd()).filter((line) => line.trim().length > 0).slice(-MAX_REASON_LINES).join("\n").slice(-MAX_REASON_CHARS);
44036
+ return [
44037
+ `The watcher in ${clone} stopped because \`assist watch wait --pull --build\` could not fast-forward the branch (exit 3). The tail of its output:`,
44038
+ "",
44039
+ "```",
44040
+ reason4 || "(no output captured)",
44041
+ "```",
44042
+ "",
44043
+ "Diagnose why the local branch has diverged from its upstream and report what you find and the options for reconciling it. Do not force-push, `git reset`, or otherwise discard commits on either side \u2014 leave the decision to the user."
44044
+ ].join("\n");
44045
+ }
44046
+
44047
+ // src/commands/sessions/daemon/worktree/escalateDivergence.ts
44048
+ var ENDED = ["done", "error", "stopped"];
44049
+ function escalateDivergence(ctx, watcher) {
44050
+ if (!watcher.cwd) return void 0;
44051
+ const clone = canonicalTreePath(watcher.cwd);
44052
+ const live = [...ctx.sessions.values()].find(
44053
+ (s) => s.divergenceEscalation === true && s.cwd !== void 0 && canonicalTreePath(s.cwd) === clone && !ENDED.includes(s.status)
44054
+ );
44055
+ if (live) {
44056
+ daemonLog(
44057
+ `watcher session ${watcher.id} exited 3 (divergence) in the clone ${clone}: no escalation spawned, session ${live.id} is already diagnosing it (${live.status})`
44058
+ );
44059
+ return void 0;
44060
+ }
44061
+ const prompt = divergencePrompt(clone, watcher.scrollback);
44062
+ const id = allocateAndBind(
44063
+ ctx,
44064
+ clone,
44065
+ (sid, resolvedCwd) => ({
44066
+ ...createSession(sid, { prompt, cwd: resolvedCwd ?? clone }),
44067
+ divergenceEscalation: true
44068
+ }),
44069
+ { inPlace: true }
44070
+ );
44071
+ daemonLog(
44072
+ `watcher session ${watcher.id} exited 3 (divergence) in the clone ${clone}: spawned escalation session ${id} to diagnose it`
44073
+ );
44074
+ return id;
44075
+ }
44076
+
44077
+ // src/commands/sessions/daemon/managerStatusChangeHandler.ts
44078
+ function managerStatusChangeHandler(sessions, clients, dismiss, notify2, treeCtx) {
44079
+ const handler = makeStatusChangeHandler(sessions, {
44080
+ dismiss,
44081
+ notify: notify2,
44082
+ reuseForRun: (session, itemId2) => reuseSessionForRun(session, itemId2, clients, handler, treeCtx()),
44083
+ escalateDivergence: (watcher) => escalateDivergence(treeCtx(), watcher)
44084
+ });
44085
+ return handler;
44086
+ }
44087
+
43304
44088
  // src/commands/sessions/daemon/applySetStatus.ts
43305
44089
  function applySetStatus(sessions, id, status2, source, onStatusChange, rebind) {
43306
44090
  const session = sessions.get(id);
@@ -43350,26 +44134,6 @@ function setStatusFromHook(sessions, report2, notify2, onStatusChange) {
43350
44134
  );
43351
44135
  }
43352
44136
 
43353
- // src/commands/sessions/daemon/refuseSpawn.ts
43354
- function refuseSpawn(session, error, clients, onStatusChange, stage = "respawned") {
43355
- const reason4 = error instanceof Error ? error.message : String(error);
43356
- session.pty = null;
43357
- session.pendingStart = void 0;
43358
- session.error = reason4;
43359
- daemonLog(
43360
- `session ${session.id} ("${session.name}") not ${stage}: ${reason4}`
43361
- );
43362
- broadcast(clients, { type: "clear", sessionId: session.id });
43363
- emitSessionOutput(
43364
- session,
43365
- clients,
43366
- `\r
43367
- \x1B[31mCannot start this session: ${reason4}\x1B[0m\r
43368
- `
43369
- );
43370
- onStatusChange(session, "error");
43371
- }
43372
-
43373
44137
  // src/commands/sessions/daemon/respawnSession.ts
43374
44138
  function respawnSession(session, respawn, status2, clients, onStatusChange) {
43375
44139
  session.gitWatcher?.close();
@@ -43566,6 +44330,13 @@ function restartManagedSession(sessions, id, clients, onStatusChange) {
43566
44330
  return { ok: true };
43567
44331
  }
43568
44332
 
44333
+ // src/commands/sessions/daemon/restartAndNotify.ts
44334
+ function restartAndNotify(sessions, id, clients, onStatusChange, notify2) {
44335
+ const result = restartManagedSession(sessions, id, clients, onStatusChange);
44336
+ if (result.ok) notify2();
44337
+ return result;
44338
+ }
44339
+
43569
44340
  // src/commands/sessions/daemon/releaseClient.ts
43570
44341
  function releaseClient(client, clients, prPreview, verify) {
43571
44342
  clients.delete(client);
@@ -43602,6 +44373,7 @@ function restoreBase(id, persisted) {
43602
44373
  initialPrompt: persisted.initialPrompt,
43603
44374
  starred: persisted.watcher ? false : persisted.starred,
43604
44375
  watcher: persisted.watcher,
44376
+ divergenceEscalation: persisted.divergenceEscalation,
43605
44377
  design: persisted.design,
43606
44378
  auto: persisted.auto,
43607
44379
  autoRun: persisted.autoRun,
@@ -43990,6 +44762,25 @@ function isUpdate(persisted) {
43990
44762
  return persisted.commandType === "assist" && persisted.assistArgs?.[0] === "update";
43991
44763
  }
43992
44764
 
44765
+ // src/commands/sessions/daemon/migrateClaudeWatcher.ts
44766
+ function migrateClaudeWatcher(id, persisted) {
44767
+ if (!persisted.watcher || persisted.commandType !== "claude")
44768
+ return persisted;
44769
+ daemonLog(
44770
+ `migrating claude /watch watcher session ${id} to assist ${WATCHER_ARGS.join(" ")}`
44771
+ );
44772
+ return {
44773
+ ...persisted,
44774
+ name: `assist ${WATCHER_ARGS.join(" ")}`,
44775
+ commandType: "assist",
44776
+ assistArgs: WATCHER_ARGS,
44777
+ initialPrompt: void 0,
44778
+ claudeSessionId: void 0,
44779
+ harness: void 0,
44780
+ harnessSessionId: void 0
44781
+ };
44782
+ }
44783
+
43993
44784
  // src/commands/sessions/daemon/needsWrapperRelaunch.ts
43994
44785
  var LAUNCH_COMMANDS = ["draft", "feat", "bug", "refine"];
43995
44786
  function needsWrapperRelaunch(persisted) {
@@ -44015,7 +44806,8 @@ function relaunchConsoleWatcher(id, persisted, base) {
44015
44806
  }
44016
44807
 
44017
44808
  // src/commands/sessions/daemon/restoreSession.ts
44018
- function restoreSession(id, persisted) {
44809
+ function restoreSession(id, stored) {
44810
+ const persisted = migrateClaudeWatcher(id, stored);
44019
44811
  const base = restoreBase(id, persisted);
44020
44812
  if (persisted.status === "stopped") return stoppedSession(base, persisted);
44021
44813
  if (isUpdate(persisted)) return updatedSession(id, persisted);
@@ -44136,40 +44928,6 @@ function accountedTrees(sessions) {
44136
44928
  return accounted;
44137
44929
  }
44138
44930
 
44139
- // src/commands/sessions/daemon/worktree/detectExistingWorktree.ts
44140
- function detectExistingWorktree(cwd) {
44141
- const repoRoot2 = findRepoRoot(cwd) ?? cwd;
44142
- if (!worktreeConfigFor(repoRoot2).enabled) return void 0;
44143
- const clone = mainWorktree(repoRoot2);
44144
- if (clone && clone !== repoRoot2) return { path: repoRoot2, clone };
44145
- return void 0;
44146
- }
44147
-
44148
- // src/commands/sessions/daemon/worktree/bindNewWorktree.ts
44149
- function bindNewWorktree(session, alloc, notify2, startHeld = () => {
44150
- }) {
44151
- if (alloc.kind !== "worktree" || !alloc.cwd || !alloc.clone || !session)
44152
- return;
44153
- session.worktree = { path: alloc.cwd, clone: alloc.clone };
44154
- if (alloc.created)
44155
- seedWorktree(alloc.cwd, alloc.clone, () => startHeld(session));
44156
- notify2();
44157
- }
44158
- function bindRestoredWorktrees(sessions) {
44159
- for (const session of sessions.values()) {
44160
- if (session.worktree || !session.cwd) continue;
44161
- const worktree = detectExistingWorktree(session.cwd);
44162
- if (worktree) session.worktree = worktree;
44163
- }
44164
- }
44165
- function bindResumedWorktree(session, cwd, notify2) {
44166
- if (!session) return;
44167
- const worktree = detectExistingWorktree(cwd);
44168
- if (!worktree) return;
44169
- session.worktree = worktree;
44170
- notify2();
44171
- }
44172
-
44173
44931
  // src/commands/sessions/daemon/worktree/reclaimVanishedWorktrees.ts
44174
44932
  import { existsSync as existsSync91 } from "fs";
44175
44933
  async function reclaimVanishedWorktrees(clone, paths) {
@@ -44453,257 +45211,6 @@ function runRetry(sessions, id, replace, deps2) {
44453
45211
  return null;
44454
45212
  }
44455
45213
 
44456
- // src/commands/sessions/daemon/attachReusedRun.ts
44457
- function attachReusedRun(session, alloc, clients, onStatusChange, tree) {
44458
- broadcast(clients, { type: "clear", sessionId: session.id });
44459
- if (alloc)
44460
- bindNewWorktree(
44461
- session,
44462
- alloc,
44463
- tree?.notify ?? (() => {
44464
- }),
44465
- tree?.startHeld
44466
- );
44467
- else wirePtyEvents(session, clients, onStatusChange);
44468
- }
44469
-
44470
- // src/commands/sessions/daemon/resetCardForRun.ts
44471
- function resetCardForRun(session, assistArgs) {
44472
- session.pty?.kill();
44473
- session.gitWatcher?.close();
44474
- session.gitWatcher = void 0;
44475
- session.undurable = void 0;
44476
- session.assistArgs = assistArgs;
44477
- session.name = `assist ${assistArgs.join(" ")}`;
44478
- session.commandType = "assist";
44479
- session.activity = void 0;
44480
- removeActivity(session.id);
44481
- session.scrollback = "";
44482
- session.startedAt = Date.now();
44483
- session.runningMs = 0;
44484
- session.runningSince = null;
44485
- session.usageSeeded = false;
44486
- setStatus2(session, "running");
44487
- session.restored = void 0;
44488
- session.launchedFrom = void 0;
44489
- }
44490
-
44491
- // src/commands/sessions/daemon/startReusedRunPty.ts
44492
- function startReusedRunPty(session, assistArgs, itemId2, hold, clients, onStatusChange) {
44493
- try {
44494
- Object.assign(
44495
- session,
44496
- startOrHoldPty(
44497
- () => spawnPty(["assist", ...assistArgs], session.cwd, session.id),
44498
- hold
44499
- )
44500
- );
44501
- } catch (error) {
44502
- refuseSpawn(
44503
- session,
44504
- error,
44505
- clients,
44506
- onStatusChange,
44507
- `reused for backlog run ${itemId2}`
44508
- );
44509
- return false;
44510
- }
44511
- return true;
44512
- }
44513
-
44514
- // src/commands/sessions/daemon/createAssistSession.ts
44515
- function createAssistSession(id, assistArgs, cwd, meta, holdPty) {
44516
- const startedAt = Date.now();
44517
- return {
44518
- id,
44519
- name: `assist ${assistArgs.join(" ")}`,
44520
- title: meta?.title,
44521
- subtitle: meta?.subtitle,
44522
- commandType: "assist",
44523
- status: "running",
44524
- startedAt,
44525
- runningMs: 0,
44526
- runningSince: startedAt,
44527
- waitingSince: null,
44528
- ...startOrHoldPty(
44529
- () => spawnPty(["assist", ...assistArgs], cwd, id),
44530
- holdPty
44531
- ),
44532
- scrollback: "",
44533
- assistArgs,
44534
- cwd
44535
- };
44536
- }
44537
-
44538
- // src/commands/sessions/daemon/createWatcherSession.ts
44539
- var WATCHER_ARGS = ["watch", "loop"];
44540
- function createWatcherSession(id, cwd) {
44541
- return {
44542
- ...createAssistSession(id, WATCHER_ARGS, cwd),
44543
- watcher: true
44544
- };
44545
- }
44546
-
44547
- // src/commands/sessions/daemon/worktree/boundTreeRoots.ts
44548
- function boundTreeRoots(sessions, exclude) {
44549
- const roots = /* @__PURE__ */ new Set();
44550
- for (const session of sessions.values()) {
44551
- if (session === exclude) continue;
44552
- const root2 = treeRootOf(session);
44553
- if (root2 && !roots.has(root2) && holdsTree(session, root2)) roots.add(root2);
44554
- }
44555
- return roots;
44556
- }
44557
- function treeRootOf(session) {
44558
- if (!session.cwd) return void 0;
44559
- return canonicalTreePath(findRepoRoot(session.cwd) ?? session.cwd);
44560
- }
44561
- function holdsTree(session, root2) {
44562
- if (session.closing) return true;
44563
- if (session.status !== "done" && session.status !== "error") return true;
44564
- if (!worktreeConfigFor(root2).enabled) return true;
44565
- const durability = checkDurabilitySync(root2);
44566
- daemonLog(`tree ${root2} ${releaseReason(durability, session)}`);
44567
- return !durability.durable;
44568
- }
44569
- function releaseReason(durability, session) {
44570
- if (!durability.durable)
44571
- return `still held by finished session ${session.id}: ${durability.reason}`;
44572
- if (durability.gone)
44573
- return `free for allocation: session ${session.id}'s directory is gone from disk \u2014 nothing to land, not landed work`;
44574
- return `free for allocation: session ${session.id} finished and its work is landed`;
44575
- }
44576
-
44577
- // src/commands/sessions/daemon/worktree/allocateAndBind.ts
44578
- function allocateAndBind(ctx, cwd, create, options2 = {}, context) {
44579
- const alloc = allocateTree(cwd, boundTreeRoots(ctx.sessions), options2);
44580
- const needsSeeding = alloc.kind === "worktree" && alloc.created === true;
44581
- const id = ctx.spawnWith(
44582
- (sid) => create(sid, alloc.cwd, needsSeeding),
44583
- context
44584
- );
44585
- bindNewWorktree(ctx.sessions.get(id), alloc, ctx.notify, ctx.startHeld);
44586
- return id;
44587
- }
44588
-
44589
- // src/commands/sessions/daemon/worktree/ensureWatcher.ts
44590
- function ensureWatcher(ctx, cwd) {
44591
- if (!cwd) return void 0;
44592
- const repoRoot2 = findRepoRoot(cwd) ?? cwd;
44593
- const cfg = worktreeConfigFor(repoRoot2);
44594
- if (!cfg.enabled || cfg.watcher !== true) return void 0;
44595
- const clone = resolveClone(repoRoot2);
44596
- const live = liveWatcherFor(ctx.sessions, clone);
44597
- if (live) {
44598
- daemonLog(
44599
- `no watcher spawned for the clone ${clone}: session ${live.id} is already watching it (${live.status})`
44600
- );
44601
- return void 0;
44602
- }
44603
- const id = allocateAndBind(
44604
- ctx,
44605
- clone,
44606
- (sid, resolvedCwd) => createWatcherSession(sid, resolvedCwd ?? clone),
44607
- { inPlace: true }
44608
- );
44609
- daemonLog(
44610
- `spawned watcher session ${id} running assist ${WATCHER_ARGS.join(" ")} in the clone ${clone}`
44611
- );
44612
- return id;
44613
- }
44614
-
44615
- // src/commands/sessions/daemon/worktree/isBacklogRunArgs.ts
44616
- var ITEM_ID = /^a?\d+$/;
44617
- function isBacklogRunArgs(args) {
44618
- const [command, subcommand, ...rest] = args;
44619
- if (command !== "backlog" || subcommand !== "run") return false;
44620
- return rest.some((arg) => ITEM_ID.test(arg));
44621
- }
44622
-
44623
- // src/commands/sessions/daemon/worktree/isPrCheckoutArgs.ts
44624
- var CHECKOUT_COMMANDS = /* @__PURE__ */ new Set([
44625
- "review",
44626
- "review-pr-comments",
44627
- "fix-conflict"
44628
- ]);
44629
- function isPrCheckoutArgs(args) {
44630
- const [command, ...rest] = args;
44631
- if (!command || !CHECKOUT_COMMANDS.has(command)) return false;
44632
- return rest.some((arg) => /^[1-9]\d*$/.test(arg));
44633
- }
44634
-
44635
- // src/commands/sessions/daemon/worktree/isCommittingArgs.ts
44636
- function isCommittingArgs(args) {
44637
- return isPrCheckoutArgs(args) || isBacklogRunArgs(args);
44638
- }
44639
-
44640
- // src/commands/sessions/daemon/worktree/planReuseTree.ts
44641
- function planReuseTree(session, ctx, options2 = {}) {
44642
- if (!ctx || !session.cwd || session.worktree) return void 0;
44643
- const alloc = allocateTree(
44644
- session.cwd,
44645
- boundTreeRoots(ctx.sessions, session),
44646
- options2
44647
- );
44648
- if (alloc.kind !== "worktree" || !alloc.cwd) return void 0;
44649
- daemonLog(
44650
- `session ${session.id} moved from ${session.cwd} to ${alloc.cwd} for its chained run`
44651
- );
44652
- return alloc;
44653
- }
44654
-
44655
- // src/commands/sessions/daemon/failChainedRun.ts
44656
- function failChainedRun(session, itemId2, error, clients, tree) {
44657
- const reason4 = error instanceof Error ? error.message : String(error);
44658
- session.pty = null;
44659
- session.error = `backlog run ${itemId2} could not start: ${reason4}`;
44660
- setStatus2(session, "error");
44661
- daemonLog(
44662
- `session ${session.id} not reused for backlog run ${itemId2}: workspace allocation failed: ${reason4}`
44663
- );
44664
- broadcast(clients, { type: "clear", sessionId: session.id });
44665
- emitSessionOutput(
44666
- session,
44667
- clients,
44668
- `\r
44669
- \x1B[31m${session.error}\x1B[0m\r
44670
- `
44671
- );
44672
- tree?.notify();
44673
- }
44674
-
44675
- // src/commands/sessions/daemon/reuseSessionForRun.ts
44676
- function reuseSessionForRun(session, itemId2, clients, onStatusChange, tree) {
44677
- const assistArgs = ["backlog", "run", String(itemId2)];
44678
- const originCwd = session.cwd;
44679
- resetCardForRun(session, assistArgs);
44680
- let alloc;
44681
- try {
44682
- alloc = planReuseTree(session, tree, {
44683
- commits: isCommittingArgs(assistArgs),
44684
- backlogRun: true
44685
- });
44686
- } catch (error) {
44687
- failChainedRun(session, itemId2, error, clients, tree);
44688
- return;
44689
- }
44690
- if (alloc) session.cwd = alloc.cwd;
44691
- const started = startReusedRunPty(
44692
- session,
44693
- assistArgs,
44694
- itemId2,
44695
- alloc !== void 0,
44696
- clients,
44697
- onStatusChange
44698
- );
44699
- if (!started) return;
44700
- if (tree) ensureWatcher(tree, originCwd);
44701
- attachReusedRun(session, alloc, clients, onStatusChange, tree);
44702
- daemonLog(
44703
- `session ${session.id} reused for backlog run ${itemId2}: ${session.name}`
44704
- );
44705
- }
44706
-
44707
45214
  // src/commands/sessions/daemon/setSessionTitle.ts
44708
45215
  function setSessionTitle(sessions, id, title) {
44709
45216
  const normalised = singleLineTitle(title);
@@ -44888,43 +45395,6 @@ async function collectHistory(linked) {
44888
45395
  return withRepoGroups(local).concat(remote);
44889
45396
  }
44890
45397
 
44891
- // src/commands/sessions/daemon/links/connectLinkWebSocket.ts
44892
- import WebSocket from "ws";
44893
- var HANDSHAKE_TIMEOUT_MS = 5e3;
44894
- function linkWebSocketUrl(url) {
44895
- const target = new URL("/ws", url);
44896
- target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
44897
- return target.toString();
44898
- }
44899
- function connectLinkWebSocket(url, handlers) {
44900
- return new Promise((resolve26, reject) => {
44901
- const ws = new WebSocket(linkWebSocketUrl(url), {
44902
- handshakeTimeout: HANDSHAKE_TIMEOUT_MS
44903
- });
44904
- let open = false;
44905
- ws.on("open", () => {
44906
- open = true;
44907
- resolve26({
44908
- send: (line) => {
44909
- if (ws.readyState !== WebSocket.OPEN) return false;
44910
- ws.send(line);
44911
- return true;
44912
- },
44913
- close: () => ws.close()
44914
- });
44915
- });
44916
- ws.on("message", (data) => handlers.onLine(data.toString()));
44917
- ws.on("error", (error) => {
44918
- if (!open) reject(error);
44919
- });
44920
- ws.on("close", (code, reason4) => {
44921
- const detail = `code ${code}${reason4.length ? ` ${reason4.toString()}` : ""}`;
44922
- if (open) handlers.onClose(detail);
44923
- else reject(new Error(`closed before open (${detail})`));
44924
- });
44925
- });
44926
- }
44927
-
44928
45398
  // src/commands/sessions/daemon/links/guardLinkCallback.ts
44929
45399
  function guardLinkCallback(label2, callback) {
44930
45400
  return (...args) => {
@@ -45304,11 +45774,6 @@ var LinkHealer = class {
45304
45774
  }
45305
45775
  };
45306
45776
 
45307
- // src/commands/sessions/daemon/links/linkVersionCheck.ts
45308
- function linkVersionCheck() {
45309
- return loadConfig().sessions?.linkVersionCheck ?? "block";
45310
- }
45311
-
45312
45777
  // src/commands/sessions/daemon/links/acceptPeerHello.ts
45313
45778
  function acceptPeerHello(linkName, msg) {
45314
45779
  if (!isHello(msg)) return { kind: "reject", reason: "malformed hello" };
@@ -45535,6 +46000,11 @@ var NodeLink = class {
45535
46000
  route(client, data) {
45536
46001
  forwardToLink(this.ctx, client, data);
45537
46002
  }
46003
+ viewerLeft(viewerId) {
46004
+ if (!this.ctx.greeted) return;
46005
+ if (sendToLink(this.ctx, { type: "viewer-left", viewerId }))
46006
+ daemonLog(`link ${this.spec.name} ws: viewer ${viewerId} left`);
46007
+ }
45538
46008
  replayScrollback(client) {
45539
46009
  replayLinkScrollback(this.ctx.relay, client);
45540
46010
  }
@@ -45627,6 +46097,10 @@ var NodeLinks = class {
45627
46097
  if (this.clients.isPeer(client)) return false;
45628
46098
  return routeToLink(this.links, this.localNode(), client, data);
45629
46099
  }
46100
+ viewerLeft(client, viewerId) {
46101
+ if (this.clients.isPeer(client)) return;
46102
+ for (const link3 of this.all()) link3.viewerLeft(viewerId);
46103
+ }
45630
46104
  dispose() {
45631
46105
  for (const link3 of this.all()) link3.dispose();
45632
46106
  this.links.clear();
@@ -45852,6 +46326,7 @@ var SessionManager = class {
45852
46326
  }
45853
46327
  replayLocal = (client) => replayScrollback(this.sessions, client);
45854
46328
  removeClient(client) {
46329
+ this.io.viewerLeft(client);
45855
46330
  releaseClient(client, this.clients, this.prPreview, this.verify);
45856
46331
  this.onIdleChange?.(this.isIdle());
45857
46332
  }
@@ -45904,22 +46379,18 @@ var SessionManager = class {
45904
46379
  resume(sessionId, cwd, name, harness) {
45905
46380
  return resumeInTree(this.treeCtx(), sessionId, cwd, name, harness);
45906
46381
  }
45907
- onStatusChange = makeStatusChangeHandler(
46382
+ onStatusChange = managerStatusChangeHandler(
45908
46383
  this.sessions,
46384
+ this.clients,
45909
46385
  (id) => this.dismissSession(id),
45910
46386
  () => this.notify(),
45911
- (session, itemId2) => reuseSessionForRun(
45912
- session,
45913
- itemId2,
45914
- this.clients,
45915
- this.onStatusChange,
45916
- this.treeCtx()
45917
- )
46387
+ () => this.treeCtx()
45918
46388
  );
45919
46389
  io = new SessionIo(
45920
46390
  this.sessions,
45921
46391
  () => this.onStatusChange,
45922
- () => this.notify()
46392
+ () => this.notify(),
46393
+ this.links
45923
46394
  );
45924
46395
  retrySession(id, replace = false) {
45925
46396
  return runRetry(this.sessions, id, replace, {
@@ -45930,14 +46401,13 @@ var SessionManager = class {
45930
46401
  });
45931
46402
  }
45932
46403
  restart(id) {
45933
- const result = restartManagedSession(
46404
+ return restartAndNotify(
45934
46405
  this.sessions,
45935
46406
  id,
45936
46407
  this.clients,
45937
- this.onStatusChange
46408
+ this.onStatusChange,
46409
+ this.notify
45938
46410
  );
45939
- if (result.ok) this.notify();
45940
- return result;
45941
46411
  }
45942
46412
  dismissSession = (id) => {
45943
46413
  dismissSessionGated(this.sessions, id, this.notify);
@@ -46416,6 +46886,38 @@ var lifecycleHandlers = {
46416
46886
  shutdown: handleShutdown
46417
46887
  };
46418
46888
 
46889
+ // src/commands/sessions/daemon/viewed.ts
46890
+ function viewed(handler) {
46891
+ return (client, m, d) => {
46892
+ m.io.trackViewer(client, d);
46893
+ handler(client, m, d);
46894
+ };
46895
+ }
46896
+
46897
+ // src/commands/sessions/daemon/viewerHandlers.ts
46898
+ var viewerOf = (d) => ({
46899
+ viewerId: d.viewerId,
46900
+ viewerNode: d.viewerNode
46901
+ });
46902
+ var viewerHandlers = {
46903
+ input: viewed(
46904
+ routed(
46905
+ (_client, m, d) => m.io.write(d.sessionId, d.data, viewerOf(d))
46906
+ )
46907
+ ),
46908
+ resize: viewed(
46909
+ routed(
46910
+ (_client, m, d) => m.io.resize(d.sessionId, d.cols, d.rows, {
46911
+ ...viewerOf(d),
46912
+ claim: d.claim === true
46913
+ })
46914
+ )
46915
+ ),
46916
+ "viewer-left": (client, m, d) => {
46917
+ if (typeof d.viewerId === "string") m.io.viewerLeft(client, d.viewerId);
46918
+ }
46919
+ };
46920
+
46419
46921
  // src/commands/sessions/daemon/setSessionCwd.ts
46420
46922
  function setSessionCwd(sessions, id, cwd) {
46421
46923
  const s = sessions.get(id);
@@ -46537,19 +47039,7 @@ var messageHandlers = {
46537
47039
  d.usedPct
46538
47040
  ),
46539
47041
  "codex-usage": handleCodexUsage,
46540
- input: routed(
46541
- (_client, m, d) => m.io.write(
46542
- d.sessionId,
46543
- d.data,
46544
- d.viewerId
46545
- )
46546
- ),
46547
- resize: routed(
46548
- (_client, m, d) => m.io.resize(d.sessionId, d.cols, d.rows, {
46549
- viewerId: d.viewerId,
46550
- claim: d.claim === true
46551
- })
46552
- ),
47042
+ ...viewerHandlers,
46553
47043
  retry: routed((client, m, d) => {
46554
47044
  const conflict = m.retrySession(d.sessionId, d.replace === true);
46555
47045
  if (conflict)
@@ -46788,236 +47278,6 @@ async function closeSession() {
46788
47278
  }
46789
47279
  }
46790
47280
 
46791
- // src/commands/sessions/nodes/DoctorProbes.ts
46792
- function passed(hop, detail) {
46793
- return { hop, ok: true, detail };
46794
- }
46795
- function failed2(hop, error, remediation) {
46796
- return { hop, ok: false, error, remediation };
46797
- }
46798
-
46799
- // src/commands/sessions/nodes/fetchPeerJson.ts
46800
- var PEER_TIMEOUT_MS = 3e3;
46801
- function peerErrorCode(error) {
46802
- if (!(error instanceof Error)) return void 0;
46803
- const { code, cause } = error;
46804
- if (code) return code;
46805
- if (error.name === "TimeoutError") return "ETIMEDOUT";
46806
- return cause instanceof Error ? peerErrorCode(cause) : void 0;
46807
- }
46808
- function describePeerError(error) {
46809
- if (!(error instanceof Error)) return String(error);
46810
- const cause = error.cause;
46811
- return cause instanceof Error ? `${error.message}: ${describePeerError(cause)}` : error.message;
46812
- }
46813
- async function fetchPeerJson(baseUrl, path106, timeoutMs = PEER_TIMEOUT_MS) {
46814
- const res = await fetch(new URL(path106, baseUrl), {
46815
- signal: AbortSignal.timeout(timeoutMs)
46816
- });
46817
- if (!res.ok)
46818
- throw Object.assign(
46819
- new Error(`GET ${path106} returned ${res.status} ${res.statusText}`),
46820
- { status: res.status }
46821
- );
46822
- return await res.json();
46823
- }
46824
-
46825
- // src/commands/sessions/nodes/helloHop.ts
46826
- async function helloHop(spec, probes) {
46827
- let msg;
46828
- try {
46829
- msg = await probes.hello(spec.url);
46830
- } catch (error) {
46831
- return failed2(
46832
- "ws",
46833
- describePeerError(error),
46834
- `${spec.name}'s /api/health answers but its /ws does not \u2014 restart the web server on ${spec.name}, then check \`assist sessions nodes logs ${spec.name}\``
46835
- );
46836
- }
46837
- if (!isHello(msg))
46838
- return failed2(
46839
- "ws",
46840
- `malformed hello: ${JSON.stringify(msg)}`,
46841
- `run \`assist update\` on ${spec.name}`
46842
- );
46843
- const detail = `hello ${msg.version} (protocol ${msg.protocol ?? "legacy"})`;
46844
- if (helloCompatible(msg)) return passed("ws", detail);
46845
- const mismatch = `${helloMismatchKind(msg)} mismatch: peer ${msg.version} (protocol ${msg.protocol ?? "legacy"}), this node ${ASSIST_VERSION} (protocol ${PROTOCOL_VERSION})`;
46846
- const mode = linkVersionCheck();
46847
- if (mode !== "block")
46848
- return passed(
46849
- "ws",
46850
- `${mismatch}; allowed by sessions.linkVersionCheck=${mode}`
46851
- );
46852
- return failed2(
46853
- "ws",
46854
- mismatch,
46855
- `this node heals an older peer via its /api/self-update on connect; if the heal latched, run \`assist update\` on the older node, then \`assist daemon restart\` here`
46856
- );
46857
- }
46858
-
46859
- // src/commands/sessions/nodes/linkStateHop.ts
46860
- function linkStateHop(spec, probes) {
46861
- const status2 = probes.linkState(spec.name);
46862
- if (status2 === "no-daemon")
46863
- return failed2(
46864
- "link",
46865
- "this node's daemon is not running",
46866
- "links run in the daemon \u2014 start this node's web server or run `assist daemon restart`"
46867
- );
46868
- if (status2 === "unknown-link")
46869
- return failed2(
46870
- "link",
46871
- `this node's daemon has no link named ${spec.name}`,
46872
- "the daemon has not loaded sessions.links \u2014 run `assist daemon restart`"
46873
- );
46874
- if (status2.state === "connected") return passed("link", "connected");
46875
- const error = status2.error ?? status2.state;
46876
- if (status2.state === "version-blocked")
46877
- return failed2("link", error, `version mismatch \u2014 heal latched. ${error}`);
46878
- return failed2(
46879
- "link",
46880
- error,
46881
- `every probe passed but the daemon's link is ${status2.state}; it retries behind the circuit breaker \u2014 check this node's daemon.log for \`link ${spec.name} ws:\` lines`
46882
- );
46883
- }
46884
-
46885
- // src/commands/sessions/nodes/findTailscalePeer.ts
46886
- function findTailscalePeer(status2, hostname2) {
46887
- const nodes = [
46888
- ...status2.Self ? [{ ...status2.Self, Online: true }] : [],
46889
- ...Object.values(status2.Peer ?? {})
46890
- ];
46891
- return nodes.find(
46892
- (node) => node.DNSName?.replace(/\.$/, "").toLowerCase() === hostname2
46893
- );
46894
- }
46895
-
46896
- // src/commands/sessions/nodes/tailscaleHop.ts
46897
- async function tailscaleHop(spec, probes) {
46898
- const cli = tailscaleCli();
46899
- let status2;
46900
- try {
46901
- status2 = await probes.tailscale();
46902
- } catch (error) {
46903
- return failed2(
46904
- "tailscale",
46905
- describePeerError(error),
46906
- `\`${cli} status --json\` failed \u2014 install Tailscale on this node and sign in with \`${cli} up\``
46907
- );
46908
- }
46909
- if (status2.BackendState !== "Running")
46910
- return failed2(
46911
- "tailscale",
46912
- `Tailscale on this node is ${status2.BackendState ?? "not running"}`,
46913
- `run \`${cli} up\` on this node`
46914
- );
46915
- const { hostname: hostname2 } = new URL(spec.url);
46916
- const peer = findTailscalePeer(status2, hostname2);
46917
- if (!peer)
46918
- return failed2(
46919
- "tailscale",
46920
- `${hostname2} is not in this node's tailnet`,
46921
- `check the host in \`${cli} status\`, then relink with \`assist sessions nodes link ${spec.name} --tailscale <host> --port <port>\``
46922
- );
46923
- if (!peer.Online)
46924
- return failed2(
46925
- "tailscale",
46926
- `${hostname2} is offline in the tailnet`,
46927
- `is ${spec.name}'s machine awake with Tailscale running and signed in?`
46928
- );
46929
- return passed(
46930
- "tailscale",
46931
- `${hostname2} online${peer.TailscaleIPs?.[0] ? ` (${peer.TailscaleIPs[0]})` : ""}`
46932
- );
46933
- }
46934
-
46935
- // src/commands/sessions/nodes/tailscaleServeRemediation.ts
46936
- function tailscaleServeRemediation(spec, error) {
46937
- const status2 = error.status;
46938
- if (status2 !== void 0 && status2 !== 502) return void 0;
46939
- const { hostname: hostname2, port } = new URL(spec.url);
46940
- const servePort = port || "443";
46941
- return status2 === 502 ? `tailscale serve on ${hostname2} answered but nothing listens on 127.0.0.1:${servePort} behind it \u2014 is ${spec.name}'s web server running (project-switch webservers)?` : `nothing serves https on ${servePort} on ${hostname2} \u2014 ${spec.name}'s web server runs tailscale serve when it starts: check the \`tailscale serve:\` line in its log (project-switch "View logs") and that sessions.tailscaleServe is not false there`;
46942
- }
46943
-
46944
- // src/commands/sessions/nodes/isTailscaleLink.ts
46945
- function isTailscaleLink(spec) {
46946
- return new URL(spec.url).hostname.endsWith(".ts.net");
46947
- }
46948
-
46949
- // src/commands/sessions/nodes/webHop.ts
46950
- function webRemediation(spec, error) {
46951
- const serve = isTailscaleLink(spec) ? tailscaleServeRemediation(spec, error) : void 0;
46952
- if (serve) return serve;
46953
- const { hostname: hostname2, port } = new URL(spec.url);
46954
- switch (peerErrorCode(error)) {
46955
- case "ECONNREFUSED":
46956
- return `nothing listening on ${port} on ${hostname2} \u2014 is ${spec.name}'s web server running (project-switch webservers)?`;
46957
- case "ETIMEDOUT":
46958
- return `${hostname2}:${port} did not answer \u2014 is ${spec.name} up and the port reachable from here?`;
46959
- case "ENOTFOUND":
46960
- return `cannot resolve ${hostname2} \u2014 fix the url with \`assist sessions nodes link ${spec.name} <url>\``;
46961
- }
46962
- if (error.status === 404)
46963
- return `${spec.name}'s web server has no /api/health \u2014 run \`assist update\` on ${spec.name}`;
46964
- return `check the link url ${spec.url} with \`assist sessions nodes\``;
46965
- }
46966
- async function webHop(spec, probes) {
46967
- let health2;
46968
- try {
46969
- health2 = await probes.health(spec.url);
46970
- } catch (error) {
46971
- return {
46972
- hop: failed2("web", describePeerError(error), webRemediation(spec, error))
46973
- };
46974
- }
46975
- if (health2.nodeName !== spec.name)
46976
- return {
46977
- hop: failed2(
46978
- "web",
46979
- `peer reports nodeName ${health2.nodeName}, link expects ${spec.name}`,
46980
- `relink with \`assist sessions nodes unlink ${spec.name}\` then \`assist sessions nodes link ${health2.nodeName} ${spec.url}\`, or set sessions.nodeName on the peer`
46981
- )
46982
- };
46983
- return {
46984
- hop: passed(
46985
- "web",
46986
- `${health2.nodeName} ${health2.version} (protocol ${health2.protocol})`
46987
- ),
46988
- health: health2
46989
- };
46990
- }
46991
-
46992
- // src/commands/sessions/nodes/peerDaemonHop.ts
46993
- function peerDaemonHop(spec, health2) {
46994
- if (health2.daemon?.reachable) return passed("daemon", "reachable");
46995
- return failed2(
46996
- "daemon",
46997
- `${spec.name}'s web server reports its daemon unreachable`,
46998
- `run \`assist daemon status\` on ${spec.name}; restarting its web server starts the daemon`
46999
- );
47000
- }
47001
-
47002
- // src/commands/sessions/nodes/diagnoseLink.ts
47003
- async function diagnoseLink(spec, probes) {
47004
- const hops = [];
47005
- const done2 = () => ({ ...spec, ok: hops.every((h) => h.ok), hops });
47006
- if (isTailscaleLink(spec)) {
47007
- hops.push(await tailscaleHop(spec, probes));
47008
- if (!hops.at(-1)?.ok) return done2();
47009
- }
47010
- const web3 = await webHop(spec, probes);
47011
- hops.push(web3.hop);
47012
- if (!web3.health) return done2();
47013
- hops.push(peerDaemonHop(spec, web3.health));
47014
- if (!hops.at(-1)?.ok) return done2();
47015
- hops.push(await helloHop(spec, probes));
47016
- if (!hops.at(-1)?.ok) return done2();
47017
- hops.push(linkStateHop(spec, probes));
47018
- return done2();
47019
- }
47020
-
47021
47281
  // src/commands/sessions/nodes/findUnlinkedWindowsNode.ts
47022
47282
  var WINDOWS_NODE_URL = "http://127.0.0.1:3101";
47023
47283
  async function findUnlinkedWindowsNode(health2, platform = detectPlatform()) {
@@ -47035,36 +47295,6 @@ async function findUnlinkedWindowsNode(health2, platform = detectPlatform()) {
47035
47295
  }
47036
47296
  }
47037
47297
 
47038
- // src/commands/sessions/nodes/probePeerHello.ts
47039
- var HELLO_TIMEOUT_MS = 5e3;
47040
- async function probePeerHello(url) {
47041
- let settle = () => {
47042
- };
47043
- const reply2 = new Promise((resolve26) => {
47044
- settle = resolve26;
47045
- });
47046
- const socket = await connectLinkWebSocket(url, {
47047
- onLine: (line) => {
47048
- try {
47049
- const msg = JSON.parse(line);
47050
- if (msg.type === "hello") settle(msg);
47051
- } catch {
47052
- }
47053
- },
47054
- onClose: (reason4) => settle(new Error(`closed before hello reply (${reason4})`))
47055
- });
47056
- const timer = setTimeout(
47057
- () => settle(new Error(`no hello reply within ${HELLO_TIMEOUT_MS / 1e3}s`)),
47058
- HELLO_TIMEOUT_MS
47059
- );
47060
- socket.send(JSON.stringify(buildHello()));
47061
- const result = await reply2;
47062
- clearTimeout(timer);
47063
- socket.close();
47064
- if (result instanceof Error) throw result;
47065
- return result;
47066
- }
47067
-
47068
47298
  // src/commands/sessions/nodes/printReport.ts
47069
47299
  import chalk254 from "chalk";
47070
47300
  function printReport(report2) {
@@ -47089,22 +47319,11 @@ function printDiagnosis(diagnosis) {
47089
47319
  }
47090
47320
 
47091
47321
  // src/commands/sessions/nodes/doctorNodes.ts
47092
- function doctorProbes(live) {
47093
- return {
47094
- health: (url) => fetchPeerJson(url, "/api/health"),
47095
- hello: probePeerHello,
47096
- tailscale: tailscaleStatus,
47097
- linkState: (name) => {
47098
- if (!live) return "no-daemon";
47099
- return live.links.find((l) => l.name === name) ?? "unknown-link";
47100
- }
47101
- };
47102
- }
47103
47322
  async function doctorNodes(name, options2) {
47104
47323
  const specs = loadLinkSpecs().filter((spec) => !name || spec.name === name);
47105
47324
  if (name && specs.length === 0)
47106
47325
  throw new Error(`No link named ${name}; see assist sessions nodes`);
47107
- const probes = doctorProbes(await queryNodes());
47326
+ const probes = buildDoctorProbes(await queryNodes());
47108
47327
  const report2 = { local: resolveNodeName(), links: [] };
47109
47328
  for (const spec of specs) report2.links.push(await diagnoseLink(spec, probes));
47110
47329
  if (specs.length === 0)