@staff0rd/assist 0.759.2 → 0.761.0

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.759.2",
9
+ version: "0.761.0",
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);
@@ -40652,7 +41002,8 @@ function toSessionInfo(session) {
40652
41002
  pendingPrPreview,
40653
41003
  undurable,
40654
41004
  lastUserMessage,
40655
- activeViewer
41005
+ activeViewer,
41006
+ activeViewerNode
40656
41007
  } = session;
40657
41008
  return {
40658
41009
  ...toSessionRunInfo(session),
@@ -40679,6 +41030,7 @@ function toSessionInfo(session) {
40679
41030
  undurable,
40680
41031
  lastUserMessage,
40681
41032
  activeViewer,
41033
+ activeViewerNode,
40682
41034
  joinable: joinRefusal(session) === void 0
40683
41035
  };
40684
41036
  }
@@ -40697,11 +41049,29 @@ function broadcastSessions(sessions, clients, linkedSessions = [], active) {
40697
41049
  broadcast(peers, { type: "sessions", sessions: local, active: {} });
40698
41050
  }
40699
41051
 
41052
+ // src/commands/sessions/daemon/releaseViewer.ts
41053
+ function releaseViewer(sessions, viewerId) {
41054
+ let released = false;
41055
+ for (const s of sessions.values()) {
41056
+ if (s.activeViewer !== viewerId) continue;
41057
+ daemonLog(
41058
+ `session ${s.id} active viewer ${viewerId} on ${s.activeViewerNode ?? "unknown node"} left`
41059
+ );
41060
+ s.activeViewer = void 0;
41061
+ s.activeViewerNode = void 0;
41062
+ released = true;
41063
+ }
41064
+ return released;
41065
+ }
41066
+
40700
41067
  // src/commands/sessions/daemon/claimViewer.ts
40701
- function claimViewer(session, { viewerId, onClaim }, via) {
41068
+ function claimViewer(session, { viewerId, viewerNode, onClaim }, via) {
40702
41069
  if (!viewerId || session.activeViewer === viewerId) return;
40703
41070
  session.activeViewer = viewerId;
40704
- daemonLog(`session ${session.id} active viewer ${viewerId} (${via})`);
41071
+ session.activeViewerNode = viewerNode;
41072
+ daemonLog(
41073
+ `session ${session.id} active viewer ${viewerId} on ${viewerNode ?? "unknown node"} (${via})`
41074
+ );
40705
41075
  onClaim?.();
40706
41076
  }
40707
41077
 
@@ -40743,6 +41113,29 @@ function resizePty(s, cols, rows) {
40743
41113
  }
40744
41114
  }
40745
41115
 
41116
+ // src/commands/sessions/daemon/ViewerConnections.ts
41117
+ var ViewerConnections = class {
41118
+ byClient = /* @__PURE__ */ new Map();
41119
+ track(client, viewerId) {
41120
+ const ids = this.byClient.get(client) ?? /* @__PURE__ */ new Set();
41121
+ ids.add(viewerId);
41122
+ this.byClient.set(client, ids);
41123
+ }
41124
+ releaseUnheld(client, viewerId) {
41125
+ const ids = this.byClient.get(client);
41126
+ if (!ids) return [];
41127
+ const dropped = viewerId === void 0 ? [...ids] : [viewerId];
41128
+ for (const id of dropped) ids.delete(id);
41129
+ if (ids.size === 0) this.byClient.delete(client);
41130
+ return dropped.filter((id) => !this.isHeld(id));
41131
+ }
41132
+ isHeld(viewerId) {
41133
+ for (const ids of this.byClient.values())
41134
+ if (ids.has(viewerId)) return true;
41135
+ return false;
41136
+ }
41137
+ };
41138
+
40746
41139
  // src/commands/sessions/daemon/watchPromptSubmit.ts
40747
41140
  var CARRIAGE_RETURN = "\r";
40748
41141
  function isPromptSubmit(data) {
@@ -40793,14 +41186,29 @@ function setAutoAdvance(sessions, id, enabled) {
40793
41186
 
40794
41187
  // src/commands/sessions/daemon/SessionIo.ts
40795
41188
  var SessionIo = class {
40796
- constructor(sessions, onStatusChange, notify2) {
41189
+ constructor(sessions, onStatusChange, notify2, links2) {
40797
41190
  this.sessions = sessions;
40798
41191
  this.onStatusChange = onStatusChange;
40799
41192
  this.notify = notify2;
41193
+ this.links = links2;
41194
+ }
41195
+ viewers = new ViewerConnections();
41196
+ trackViewer(client, d) {
41197
+ if (typeof d.viewerId !== "string") return;
41198
+ this.viewers.track(client, d.viewerId);
41199
+ d.viewerNode ??= this.links.localNode();
40800
41200
  }
40801
- write(id, data, viewerId) {
41201
+ viewerLeft(client, viewerId) {
41202
+ let released = false;
41203
+ for (const id of this.viewers.releaseUnheld(client, viewerId)) {
41204
+ released = releaseViewer(this.sessions, id) || released;
41205
+ this.links.viewerLeft(client, id);
41206
+ }
41207
+ if (released) this.notify();
41208
+ }
41209
+ write(id, data, viewer) {
40802
41210
  writeToSession(this.sessions, id, data, this.onStatusChange(), {
40803
- viewerId,
41211
+ ...viewer,
40804
41212
  onClaim: this.notify
40805
41213
  });
40806
41214
  }
@@ -43246,6 +43654,7 @@ function shouldAutoDismiss(session, exitCode) {
43246
43654
  }
43247
43655
 
43248
43656
  // src/commands/sessions/daemon/finishStatusChange.ts
43657
+ var DIVERGED_EXIT_CODE = 3;
43249
43658
  function finishStatusChange(session, status2, exitCode, deps2) {
43250
43659
  daemonLog(`session ${session.id} status: ${session.status} -> ${status2}`);
43251
43660
  void flushPhaseActiveMs(session);
@@ -43253,6 +43662,8 @@ function finishStatusChange(session, status2, exitCode, deps2) {
43253
43662
  if (status2 === "waiting")
43254
43663
  startTranscriptTitleGeneration(session, deps2.notify);
43255
43664
  if (status2 !== "stopped") session.undurable = void 0;
43665
+ if (session.watcher === true && exitCode === DIVERGED_EXIT_CODE)
43666
+ deps2.escalateDivergence?.(session);
43256
43667
  const autoRun = shouldAutoRun(session);
43257
43668
  if (autoRun.run) {
43258
43669
  deps2.reuseForRun(session, autoRun.itemId);
@@ -43269,19 +43680,18 @@ function finishStatusChange(session, status2, exitCode, deps2) {
43269
43680
  }
43270
43681
 
43271
43682
  // src/commands/sessions/daemon/applyStatusChange.ts
43272
- function applyStatusChange(session, status2, exitCode, dismiss, notify2, reuseForRun, sharedWithOtherAgents = () => false) {
43683
+ function applyStatusChange(session, status2, exitCode, deps2, sharedWithOtherAgents = () => false) {
43273
43684
  if (session.status === status2) return;
43274
- const deps2 = { dismiss, notify: notify2, reuseForRun };
43275
43685
  if (endsStreamLife(session, status2, sharedWithOtherAgents)) {
43276
43686
  session.closing = true;
43277
43687
  daemonLog(
43278
43688
  `session ${session.id} closing: checking durability of ${session.worktree?.path} before reap`
43279
43689
  );
43280
- notify2();
43690
+ deps2.notify();
43281
43691
  void resolveCloseDurability(
43282
43692
  session,
43283
43693
  () => finishStatusChange(session, "done", exitCode, deps2),
43284
- notify2
43694
+ deps2.notify
43285
43695
  );
43286
43696
  return;
43287
43697
  }
@@ -43289,18 +43699,378 @@ function applyStatusChange(session, status2, exitCode, dismiss, notify2, reuseFo
43289
43699
  }
43290
43700
 
43291
43701
  // src/commands/sessions/daemon/makeStatusChangeHandler.ts
43292
- function makeStatusChangeHandler(sessions, dismiss, notify2, reuseForRun) {
43702
+ function makeStatusChangeHandler(sessions, deps2) {
43293
43703
  return (s, status2, exitCode) => applyStatusChange(
43294
43704
  s,
43295
43705
  status2,
43296
43706
  exitCode,
43297
- dismiss,
43298
- notify2,
43299
- reuseForRun,
43707
+ deps2,
43300
43708
  (session) => otherTreeHolders(sessions, session).length > 0
43301
43709
  );
43302
43710
  }
43303
43711
 
43712
+ // src/commands/sessions/daemon/worktree/detectExistingWorktree.ts
43713
+ function detectExistingWorktree(cwd) {
43714
+ const repoRoot2 = findRepoRoot(cwd) ?? cwd;
43715
+ if (!worktreeConfigFor(repoRoot2).enabled) return void 0;
43716
+ const clone = mainWorktree(repoRoot2);
43717
+ if (clone && clone !== repoRoot2) return { path: repoRoot2, clone };
43718
+ return void 0;
43719
+ }
43720
+
43721
+ // src/commands/sessions/daemon/worktree/bindNewWorktree.ts
43722
+ function bindNewWorktree(session, alloc, notify2, startHeld = () => {
43723
+ }) {
43724
+ if (alloc.kind !== "worktree" || !alloc.cwd || !alloc.clone || !session)
43725
+ return;
43726
+ session.worktree = { path: alloc.cwd, clone: alloc.clone };
43727
+ if (alloc.created)
43728
+ seedWorktree(alloc.cwd, alloc.clone, () => startHeld(session));
43729
+ notify2();
43730
+ }
43731
+ function bindRestoredWorktrees(sessions) {
43732
+ for (const session of sessions.values()) {
43733
+ if (session.worktree || !session.cwd) continue;
43734
+ const worktree = detectExistingWorktree(session.cwd);
43735
+ if (worktree) session.worktree = worktree;
43736
+ }
43737
+ }
43738
+ function bindResumedWorktree(session, cwd, notify2) {
43739
+ if (!session) return;
43740
+ const worktree = detectExistingWorktree(cwd);
43741
+ if (!worktree) return;
43742
+ session.worktree = worktree;
43743
+ notify2();
43744
+ }
43745
+
43746
+ // src/commands/sessions/daemon/attachReusedRun.ts
43747
+ function attachReusedRun(session, alloc, clients, onStatusChange, tree) {
43748
+ broadcast(clients, { type: "clear", sessionId: session.id });
43749
+ if (alloc)
43750
+ bindNewWorktree(
43751
+ session,
43752
+ alloc,
43753
+ tree?.notify ?? (() => {
43754
+ }),
43755
+ tree?.startHeld
43756
+ );
43757
+ else wirePtyEvents(session, clients, onStatusChange);
43758
+ }
43759
+
43760
+ // src/commands/sessions/daemon/resetCardForRun.ts
43761
+ function resetCardForRun(session, assistArgs) {
43762
+ session.pty?.kill();
43763
+ session.gitWatcher?.close();
43764
+ session.gitWatcher = void 0;
43765
+ session.undurable = void 0;
43766
+ session.assistArgs = assistArgs;
43767
+ session.name = `assist ${assistArgs.join(" ")}`;
43768
+ session.commandType = "assist";
43769
+ session.activity = void 0;
43770
+ removeActivity(session.id);
43771
+ session.scrollback = "";
43772
+ session.startedAt = Date.now();
43773
+ session.runningMs = 0;
43774
+ session.runningSince = null;
43775
+ session.usageSeeded = false;
43776
+ setStatus2(session, "running");
43777
+ session.restored = void 0;
43778
+ session.launchedFrom = void 0;
43779
+ }
43780
+
43781
+ // src/commands/sessions/daemon/refuseSpawn.ts
43782
+ function refuseSpawn(session, error, clients, onStatusChange, stage = "respawned") {
43783
+ const reason4 = error instanceof Error ? error.message : String(error);
43784
+ session.pty = null;
43785
+ session.pendingStart = void 0;
43786
+ session.error = reason4;
43787
+ daemonLog(
43788
+ `session ${session.id} ("${session.name}") not ${stage}: ${reason4}`
43789
+ );
43790
+ broadcast(clients, { type: "clear", sessionId: session.id });
43791
+ emitSessionOutput(
43792
+ session,
43793
+ clients,
43794
+ `\r
43795
+ \x1B[31mCannot start this session: ${reason4}\x1B[0m\r
43796
+ `
43797
+ );
43798
+ onStatusChange(session, "error");
43799
+ }
43800
+
43801
+ // src/commands/sessions/daemon/startReusedRunPty.ts
43802
+ function startReusedRunPty(session, assistArgs, itemId2, hold, clients, onStatusChange) {
43803
+ try {
43804
+ Object.assign(
43805
+ session,
43806
+ startOrHoldPty(
43807
+ () => spawnPty(["assist", ...assistArgs], session.cwd, session.id),
43808
+ hold
43809
+ )
43810
+ );
43811
+ } catch (error) {
43812
+ refuseSpawn(
43813
+ session,
43814
+ error,
43815
+ clients,
43816
+ onStatusChange,
43817
+ `reused for backlog run ${itemId2}`
43818
+ );
43819
+ return false;
43820
+ }
43821
+ return true;
43822
+ }
43823
+
43824
+ // src/commands/sessions/daemon/createAssistSession.ts
43825
+ function createAssistSession(id, assistArgs, cwd, meta, holdPty) {
43826
+ const startedAt = Date.now();
43827
+ return {
43828
+ id,
43829
+ name: `assist ${assistArgs.join(" ")}`,
43830
+ title: meta?.title,
43831
+ subtitle: meta?.subtitle,
43832
+ commandType: "assist",
43833
+ status: "running",
43834
+ startedAt,
43835
+ runningMs: 0,
43836
+ runningSince: startedAt,
43837
+ waitingSince: null,
43838
+ ...startOrHoldPty(
43839
+ () => spawnPty(["assist", ...assistArgs], cwd, id),
43840
+ holdPty
43841
+ ),
43842
+ scrollback: "",
43843
+ assistArgs,
43844
+ cwd
43845
+ };
43846
+ }
43847
+
43848
+ // src/commands/sessions/daemon/createWatcherSession.ts
43849
+ var WATCHER_ARGS = ["watch", "loop"];
43850
+ function createWatcherSession(id, cwd) {
43851
+ return {
43852
+ ...createAssistSession(id, WATCHER_ARGS, cwd),
43853
+ watcher: true
43854
+ };
43855
+ }
43856
+
43857
+ // src/commands/sessions/daemon/worktree/boundTreeRoots.ts
43858
+ function boundTreeRoots(sessions, exclude) {
43859
+ const roots = /* @__PURE__ */ new Set();
43860
+ for (const session of sessions.values()) {
43861
+ if (session === exclude) continue;
43862
+ const root2 = treeRootOf(session);
43863
+ if (root2 && !roots.has(root2) && holdsTree(session, root2)) roots.add(root2);
43864
+ }
43865
+ return roots;
43866
+ }
43867
+ function treeRootOf(session) {
43868
+ if (!session.cwd) return void 0;
43869
+ return canonicalTreePath(findRepoRoot(session.cwd) ?? session.cwd);
43870
+ }
43871
+ function holdsTree(session, root2) {
43872
+ if (session.closing) return true;
43873
+ if (session.status !== "done" && session.status !== "error") return true;
43874
+ if (!worktreeConfigFor(root2).enabled) return true;
43875
+ const durability = checkDurabilitySync(root2);
43876
+ daemonLog(`tree ${root2} ${releaseReason(durability, session)}`);
43877
+ return !durability.durable;
43878
+ }
43879
+ function releaseReason(durability, session) {
43880
+ if (!durability.durable)
43881
+ return `still held by finished session ${session.id}: ${durability.reason}`;
43882
+ if (durability.gone)
43883
+ return `free for allocation: session ${session.id}'s directory is gone from disk \u2014 nothing to land, not landed work`;
43884
+ return `free for allocation: session ${session.id} finished and its work is landed`;
43885
+ }
43886
+
43887
+ // src/commands/sessions/daemon/worktree/allocateAndBind.ts
43888
+ function allocateAndBind(ctx, cwd, create, options2 = {}, context) {
43889
+ const alloc = allocateTree(cwd, boundTreeRoots(ctx.sessions), options2);
43890
+ const needsSeeding = alloc.kind === "worktree" && alloc.created === true;
43891
+ const id = ctx.spawnWith(
43892
+ (sid) => create(sid, alloc.cwd, needsSeeding),
43893
+ context
43894
+ );
43895
+ bindNewWorktree(ctx.sessions.get(id), alloc, ctx.notify, ctx.startHeld);
43896
+ return id;
43897
+ }
43898
+
43899
+ // src/commands/sessions/daemon/worktree/ensureWatcher.ts
43900
+ function ensureWatcher(ctx, cwd) {
43901
+ if (!cwd) return void 0;
43902
+ const repoRoot2 = findRepoRoot(cwd) ?? cwd;
43903
+ const cfg = worktreeConfigFor(repoRoot2);
43904
+ if (!cfg.enabled || cfg.watcher !== true) return void 0;
43905
+ const clone = resolveClone(repoRoot2);
43906
+ const live = liveWatcherFor(ctx.sessions, clone);
43907
+ if (live) {
43908
+ daemonLog(
43909
+ `no watcher spawned for the clone ${clone}: session ${live.id} is already watching it (${live.status})`
43910
+ );
43911
+ return void 0;
43912
+ }
43913
+ const id = allocateAndBind(
43914
+ ctx,
43915
+ clone,
43916
+ (sid, resolvedCwd) => createWatcherSession(sid, resolvedCwd ?? clone),
43917
+ { inPlace: true }
43918
+ );
43919
+ daemonLog(
43920
+ `spawned watcher session ${id} running assist ${WATCHER_ARGS.join(" ")} in the clone ${clone}`
43921
+ );
43922
+ return id;
43923
+ }
43924
+
43925
+ // src/commands/sessions/daemon/worktree/isBacklogRunArgs.ts
43926
+ var ITEM_ID = /^a?\d+$/;
43927
+ function isBacklogRunArgs(args) {
43928
+ const [command, subcommand, ...rest] = args;
43929
+ if (command !== "backlog" || subcommand !== "run") return false;
43930
+ return rest.some((arg) => ITEM_ID.test(arg));
43931
+ }
43932
+
43933
+ // src/commands/sessions/daemon/worktree/isPrCheckoutArgs.ts
43934
+ var CHECKOUT_COMMANDS = /* @__PURE__ */ new Set([
43935
+ "review",
43936
+ "review-pr-comments",
43937
+ "fix-conflict"
43938
+ ]);
43939
+ function isPrCheckoutArgs(args) {
43940
+ const [command, ...rest] = args;
43941
+ if (!command || !CHECKOUT_COMMANDS.has(command)) return false;
43942
+ return rest.some((arg) => /^[1-9]\d*$/.test(arg));
43943
+ }
43944
+
43945
+ // src/commands/sessions/daemon/worktree/isCommittingArgs.ts
43946
+ function isCommittingArgs(args) {
43947
+ return isPrCheckoutArgs(args) || isBacklogRunArgs(args);
43948
+ }
43949
+
43950
+ // src/commands/sessions/daemon/worktree/planReuseTree.ts
43951
+ function planReuseTree(session, ctx, options2 = {}) {
43952
+ if (!ctx || !session.cwd || session.worktree) return void 0;
43953
+ const alloc = allocateTree(
43954
+ session.cwd,
43955
+ boundTreeRoots(ctx.sessions, session),
43956
+ options2
43957
+ );
43958
+ if (alloc.kind !== "worktree" || !alloc.cwd) return void 0;
43959
+ daemonLog(
43960
+ `session ${session.id} moved from ${session.cwd} to ${alloc.cwd} for its chained run`
43961
+ );
43962
+ return alloc;
43963
+ }
43964
+
43965
+ // src/commands/sessions/daemon/failChainedRun.ts
43966
+ function failChainedRun(session, itemId2, error, clients, tree) {
43967
+ const reason4 = error instanceof Error ? error.message : String(error);
43968
+ session.pty = null;
43969
+ session.error = `backlog run ${itemId2} could not start: ${reason4}`;
43970
+ setStatus2(session, "error");
43971
+ daemonLog(
43972
+ `session ${session.id} not reused for backlog run ${itemId2}: workspace allocation failed: ${reason4}`
43973
+ );
43974
+ broadcast(clients, { type: "clear", sessionId: session.id });
43975
+ emitSessionOutput(
43976
+ session,
43977
+ clients,
43978
+ `\r
43979
+ \x1B[31m${session.error}\x1B[0m\r
43980
+ `
43981
+ );
43982
+ tree?.notify();
43983
+ }
43984
+
43985
+ // src/commands/sessions/daemon/reuseSessionForRun.ts
43986
+ function reuseSessionForRun(session, itemId2, clients, onStatusChange, tree) {
43987
+ const assistArgs = ["backlog", "run", String(itemId2)];
43988
+ const originCwd = session.cwd;
43989
+ resetCardForRun(session, assistArgs);
43990
+ let alloc;
43991
+ try {
43992
+ alloc = planReuseTree(session, tree, {
43993
+ commits: isCommittingArgs(assistArgs),
43994
+ backlogRun: true
43995
+ });
43996
+ } catch (error) {
43997
+ failChainedRun(session, itemId2, error, clients, tree);
43998
+ return;
43999
+ }
44000
+ if (alloc) session.cwd = alloc.cwd;
44001
+ const started = startReusedRunPty(
44002
+ session,
44003
+ assistArgs,
44004
+ itemId2,
44005
+ alloc !== void 0,
44006
+ clients,
44007
+ onStatusChange
44008
+ );
44009
+ if (!started) return;
44010
+ if (tree) ensureWatcher(tree, originCwd);
44011
+ attachReusedRun(session, alloc, clients, onStatusChange, tree);
44012
+ daemonLog(
44013
+ `session ${session.id} reused for backlog run ${itemId2}: ${session.name}`
44014
+ );
44015
+ }
44016
+
44017
+ // src/commands/sessions/daemon/worktree/divergencePrompt.ts
44018
+ var MAX_REASON_LINES = 20;
44019
+ var MAX_REASON_CHARS = 2e3;
44020
+ function divergencePrompt(clone, scrollback) {
44021
+ 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);
44022
+ return [
44023
+ `The watcher in ${clone} stopped because \`assist watch wait --pull --build\` could not fast-forward the branch (exit 3). The tail of its output:`,
44024
+ "",
44025
+ "```",
44026
+ reason4 || "(no output captured)",
44027
+ "```",
44028
+ "",
44029
+ "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."
44030
+ ].join("\n");
44031
+ }
44032
+
44033
+ // src/commands/sessions/daemon/worktree/escalateDivergence.ts
44034
+ var ENDED = ["done", "error", "stopped"];
44035
+ function escalateDivergence(ctx, watcher) {
44036
+ if (!watcher.cwd) return void 0;
44037
+ const clone = canonicalTreePath(watcher.cwd);
44038
+ const live = [...ctx.sessions.values()].find(
44039
+ (s) => s.divergenceEscalation === true && s.cwd !== void 0 && canonicalTreePath(s.cwd) === clone && !ENDED.includes(s.status)
44040
+ );
44041
+ if (live) {
44042
+ daemonLog(
44043
+ `watcher session ${watcher.id} exited 3 (divergence) in the clone ${clone}: no escalation spawned, session ${live.id} is already diagnosing it (${live.status})`
44044
+ );
44045
+ return void 0;
44046
+ }
44047
+ const prompt = divergencePrompt(clone, watcher.scrollback);
44048
+ const id = allocateAndBind(
44049
+ ctx,
44050
+ clone,
44051
+ (sid, resolvedCwd) => ({
44052
+ ...createSession(sid, { prompt, cwd: resolvedCwd ?? clone }),
44053
+ divergenceEscalation: true
44054
+ }),
44055
+ { inPlace: true }
44056
+ );
44057
+ daemonLog(
44058
+ `watcher session ${watcher.id} exited 3 (divergence) in the clone ${clone}: spawned escalation session ${id} to diagnose it`
44059
+ );
44060
+ return id;
44061
+ }
44062
+
44063
+ // src/commands/sessions/daemon/managerStatusChangeHandler.ts
44064
+ function managerStatusChangeHandler(sessions, clients, dismiss, notify2, treeCtx) {
44065
+ const handler = makeStatusChangeHandler(sessions, {
44066
+ dismiss,
44067
+ notify: notify2,
44068
+ reuseForRun: (session, itemId2) => reuseSessionForRun(session, itemId2, clients, handler, treeCtx()),
44069
+ escalateDivergence: (watcher) => escalateDivergence(treeCtx(), watcher)
44070
+ });
44071
+ return handler;
44072
+ }
44073
+
43304
44074
  // src/commands/sessions/daemon/applySetStatus.ts
43305
44075
  function applySetStatus(sessions, id, status2, source, onStatusChange, rebind) {
43306
44076
  const session = sessions.get(id);
@@ -43350,26 +44120,6 @@ function setStatusFromHook(sessions, report2, notify2, onStatusChange) {
43350
44120
  );
43351
44121
  }
43352
44122
 
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
44123
  // src/commands/sessions/daemon/respawnSession.ts
43374
44124
  function respawnSession(session, respawn, status2, clients, onStatusChange) {
43375
44125
  session.gitWatcher?.close();
@@ -43566,6 +44316,13 @@ function restartManagedSession(sessions, id, clients, onStatusChange) {
43566
44316
  return { ok: true };
43567
44317
  }
43568
44318
 
44319
+ // src/commands/sessions/daemon/restartAndNotify.ts
44320
+ function restartAndNotify(sessions, id, clients, onStatusChange, notify2) {
44321
+ const result = restartManagedSession(sessions, id, clients, onStatusChange);
44322
+ if (result.ok) notify2();
44323
+ return result;
44324
+ }
44325
+
43569
44326
  // src/commands/sessions/daemon/releaseClient.ts
43570
44327
  function releaseClient(client, clients, prPreview, verify) {
43571
44328
  clients.delete(client);
@@ -43602,6 +44359,7 @@ function restoreBase(id, persisted) {
43602
44359
  initialPrompt: persisted.initialPrompt,
43603
44360
  starred: persisted.watcher ? false : persisted.starred,
43604
44361
  watcher: persisted.watcher,
44362
+ divergenceEscalation: persisted.divergenceEscalation,
43605
44363
  design: persisted.design,
43606
44364
  auto: persisted.auto,
43607
44365
  autoRun: persisted.autoRun,
@@ -43990,6 +44748,25 @@ function isUpdate(persisted) {
43990
44748
  return persisted.commandType === "assist" && persisted.assistArgs?.[0] === "update";
43991
44749
  }
43992
44750
 
44751
+ // src/commands/sessions/daemon/migrateClaudeWatcher.ts
44752
+ function migrateClaudeWatcher(id, persisted) {
44753
+ if (!persisted.watcher || persisted.commandType !== "claude")
44754
+ return persisted;
44755
+ daemonLog(
44756
+ `migrating claude /watch watcher session ${id} to assist ${WATCHER_ARGS.join(" ")}`
44757
+ );
44758
+ return {
44759
+ ...persisted,
44760
+ name: `assist ${WATCHER_ARGS.join(" ")}`,
44761
+ commandType: "assist",
44762
+ assistArgs: WATCHER_ARGS,
44763
+ initialPrompt: void 0,
44764
+ claudeSessionId: void 0,
44765
+ harness: void 0,
44766
+ harnessSessionId: void 0
44767
+ };
44768
+ }
44769
+
43993
44770
  // src/commands/sessions/daemon/needsWrapperRelaunch.ts
43994
44771
  var LAUNCH_COMMANDS = ["draft", "feat", "bug", "refine"];
43995
44772
  function needsWrapperRelaunch(persisted) {
@@ -44015,7 +44792,8 @@ function relaunchConsoleWatcher(id, persisted, base) {
44015
44792
  }
44016
44793
 
44017
44794
  // src/commands/sessions/daemon/restoreSession.ts
44018
- function restoreSession(id, persisted) {
44795
+ function restoreSession(id, stored) {
44796
+ const persisted = migrateClaudeWatcher(id, stored);
44019
44797
  const base = restoreBase(id, persisted);
44020
44798
  if (persisted.status === "stopped") return stoppedSession(base, persisted);
44021
44799
  if (isUpdate(persisted)) return updatedSession(id, persisted);
@@ -44136,40 +44914,6 @@ function accountedTrees(sessions) {
44136
44914
  return accounted;
44137
44915
  }
44138
44916
 
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
44917
  // src/commands/sessions/daemon/worktree/reclaimVanishedWorktrees.ts
44174
44918
  import { existsSync as existsSync91 } from "fs";
44175
44919
  async function reclaimVanishedWorktrees(clone, paths) {
@@ -44453,257 +45197,6 @@ function runRetry(sessions, id, replace, deps2) {
44453
45197
  return null;
44454
45198
  }
44455
45199
 
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
45200
  // src/commands/sessions/daemon/setSessionTitle.ts
44708
45201
  function setSessionTitle(sessions, id, title) {
44709
45202
  const normalised = singleLineTitle(title);
@@ -44888,43 +45381,6 @@ async function collectHistory(linked) {
44888
45381
  return withRepoGroups(local).concat(remote);
44889
45382
  }
44890
45383
 
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
45384
  // src/commands/sessions/daemon/links/guardLinkCallback.ts
44929
45385
  function guardLinkCallback(label2, callback) {
44930
45386
  return (...args) => {
@@ -45304,11 +45760,6 @@ var LinkHealer = class {
45304
45760
  }
45305
45761
  };
45306
45762
 
45307
- // src/commands/sessions/daemon/links/linkVersionCheck.ts
45308
- function linkVersionCheck() {
45309
- return loadConfig().sessions?.linkVersionCheck ?? "block";
45310
- }
45311
-
45312
45763
  // src/commands/sessions/daemon/links/acceptPeerHello.ts
45313
45764
  function acceptPeerHello(linkName, msg) {
45314
45765
  if (!isHello(msg)) return { kind: "reject", reason: "malformed hello" };
@@ -45535,6 +45986,11 @@ var NodeLink = class {
45535
45986
  route(client, data) {
45536
45987
  forwardToLink(this.ctx, client, data);
45537
45988
  }
45989
+ viewerLeft(viewerId) {
45990
+ if (!this.ctx.greeted) return;
45991
+ if (sendToLink(this.ctx, { type: "viewer-left", viewerId }))
45992
+ daemonLog(`link ${this.spec.name} ws: viewer ${viewerId} left`);
45993
+ }
45538
45994
  replayScrollback(client) {
45539
45995
  replayLinkScrollback(this.ctx.relay, client);
45540
45996
  }
@@ -45627,6 +46083,10 @@ var NodeLinks = class {
45627
46083
  if (this.clients.isPeer(client)) return false;
45628
46084
  return routeToLink(this.links, this.localNode(), client, data);
45629
46085
  }
46086
+ viewerLeft(client, viewerId) {
46087
+ if (this.clients.isPeer(client)) return;
46088
+ for (const link3 of this.all()) link3.viewerLeft(viewerId);
46089
+ }
45630
46090
  dispose() {
45631
46091
  for (const link3 of this.all()) link3.dispose();
45632
46092
  this.links.clear();
@@ -45852,6 +46312,7 @@ var SessionManager = class {
45852
46312
  }
45853
46313
  replayLocal = (client) => replayScrollback(this.sessions, client);
45854
46314
  removeClient(client) {
46315
+ this.io.viewerLeft(client);
45855
46316
  releaseClient(client, this.clients, this.prPreview, this.verify);
45856
46317
  this.onIdleChange?.(this.isIdle());
45857
46318
  }
@@ -45904,22 +46365,18 @@ var SessionManager = class {
45904
46365
  resume(sessionId, cwd, name, harness) {
45905
46366
  return resumeInTree(this.treeCtx(), sessionId, cwd, name, harness);
45906
46367
  }
45907
- onStatusChange = makeStatusChangeHandler(
46368
+ onStatusChange = managerStatusChangeHandler(
45908
46369
  this.sessions,
46370
+ this.clients,
45909
46371
  (id) => this.dismissSession(id),
45910
46372
  () => this.notify(),
45911
- (session, itemId2) => reuseSessionForRun(
45912
- session,
45913
- itemId2,
45914
- this.clients,
45915
- this.onStatusChange,
45916
- this.treeCtx()
45917
- )
46373
+ () => this.treeCtx()
45918
46374
  );
45919
46375
  io = new SessionIo(
45920
46376
  this.sessions,
45921
46377
  () => this.onStatusChange,
45922
- () => this.notify()
46378
+ () => this.notify(),
46379
+ this.links
45923
46380
  );
45924
46381
  retrySession(id, replace = false) {
45925
46382
  return runRetry(this.sessions, id, replace, {
@@ -45930,14 +46387,13 @@ var SessionManager = class {
45930
46387
  });
45931
46388
  }
45932
46389
  restart(id) {
45933
- const result = restartManagedSession(
46390
+ return restartAndNotify(
45934
46391
  this.sessions,
45935
46392
  id,
45936
46393
  this.clients,
45937
- this.onStatusChange
46394
+ this.onStatusChange,
46395
+ this.notify
45938
46396
  );
45939
- if (result.ok) this.notify();
45940
- return result;
45941
46397
  }
45942
46398
  dismissSession = (id) => {
45943
46399
  dismissSessionGated(this.sessions, id, this.notify);
@@ -46416,6 +46872,38 @@ var lifecycleHandlers = {
46416
46872
  shutdown: handleShutdown
46417
46873
  };
46418
46874
 
46875
+ // src/commands/sessions/daemon/viewed.ts
46876
+ function viewed(handler) {
46877
+ return (client, m, d) => {
46878
+ m.io.trackViewer(client, d);
46879
+ handler(client, m, d);
46880
+ };
46881
+ }
46882
+
46883
+ // src/commands/sessions/daemon/viewerHandlers.ts
46884
+ var viewerOf = (d) => ({
46885
+ viewerId: d.viewerId,
46886
+ viewerNode: d.viewerNode
46887
+ });
46888
+ var viewerHandlers = {
46889
+ input: viewed(
46890
+ routed(
46891
+ (_client, m, d) => m.io.write(d.sessionId, d.data, viewerOf(d))
46892
+ )
46893
+ ),
46894
+ resize: viewed(
46895
+ routed(
46896
+ (_client, m, d) => m.io.resize(d.sessionId, d.cols, d.rows, {
46897
+ ...viewerOf(d),
46898
+ claim: d.claim === true
46899
+ })
46900
+ )
46901
+ ),
46902
+ "viewer-left": (client, m, d) => {
46903
+ if (typeof d.viewerId === "string") m.io.viewerLeft(client, d.viewerId);
46904
+ }
46905
+ };
46906
+
46419
46907
  // src/commands/sessions/daemon/setSessionCwd.ts
46420
46908
  function setSessionCwd(sessions, id, cwd) {
46421
46909
  const s = sessions.get(id);
@@ -46537,19 +47025,7 @@ var messageHandlers = {
46537
47025
  d.usedPct
46538
47026
  ),
46539
47027
  "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
- ),
47028
+ ...viewerHandlers,
46553
47029
  retry: routed((client, m, d) => {
46554
47030
  const conflict = m.retrySession(d.sessionId, d.replace === true);
46555
47031
  if (conflict)
@@ -46788,236 +47264,6 @@ async function closeSession() {
46788
47264
  }
46789
47265
  }
46790
47266
 
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
47267
  // src/commands/sessions/nodes/findUnlinkedWindowsNode.ts
47022
47268
  var WINDOWS_NODE_URL = "http://127.0.0.1:3101";
47023
47269
  async function findUnlinkedWindowsNode(health2, platform = detectPlatform()) {
@@ -47035,36 +47281,6 @@ async function findUnlinkedWindowsNode(health2, platform = detectPlatform()) {
47035
47281
  }
47036
47282
  }
47037
47283
 
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
47284
  // src/commands/sessions/nodes/printReport.ts
47069
47285
  import chalk254 from "chalk";
47070
47286
  function printReport(report2) {
@@ -47089,22 +47305,11 @@ function printDiagnosis(diagnosis) {
47089
47305
  }
47090
47306
 
47091
47307
  // 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
47308
  async function doctorNodes(name, options2) {
47104
47309
  const specs = loadLinkSpecs().filter((spec) => !name || spec.name === name);
47105
47310
  if (name && specs.length === 0)
47106
47311
  throw new Error(`No link named ${name}; see assist sessions nodes`);
47107
- const probes = doctorProbes(await queryNodes());
47312
+ const probes = buildDoctorProbes(await queryNodes());
47108
47313
  const report2 = { local: resolveNodeName(), links: [] };
47109
47314
  for (const spec of specs) report2.links.push(await diagnoseLink(spec, probes));
47110
47315
  if (specs.length === 0)