pi-straw-boss 0.37.1 → 0.38.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/README.md CHANGED
@@ -36,7 +36,7 @@ Run a few agents on the same project and they start stepping on each other: two
36
36
  - **Tidy tabs.** When a pi starts in a tab or quits out of it, the tab's panes are made equal again, so workers split off one by one don't leave a chain of halves.
37
37
  - **Stranded subagents are asked to report.** A subagent that stays open after finishing, while the pi that launched it was restarted and no longer receives its result, is asked once over intercom to send its final report there ([how it works](docs/how-it-works.md#orphaned-subagents)).
38
38
  - **A net view in Herdr's sidebar.** An optional plugin turns the Agents list into the net, towers first and each subagent under its launcher, on by default and switched by one key.
39
- - **A reaper for leftover worktree panes.** A second optional plugin closes the panes agents leave behind in worktree workspaces, removes the clean checkouts, and keeps the workspaces you pin.
39
+ - **A reaper for leftover worktree panes.** A second optional plugin closes the panes agents leave behind in worktree workspaces, removes the clean checkouts nothing holds, and keeps the workspaces you pin.
40
40
 
41
41
  ## Install
42
42
 
@@ -89,7 +89,7 @@ The tower only routes and coordinates, so it can run a lighter model: set `tower
89
89
  | `/boss close all` | Quit this base's tower, mains, and members and close it with its shells, without stashing. It is checked first as a stash checks it; anything running that would be lost (a turn, a queued message, a dialog, a monitor, a subagent, a busy shell) closes nothing, and every reason is listed. Other bases are never touched. |
90
90
  | Stash, Stashed, Pop on the page | A base's Stash puts it away under its label; the HUD's Stashed list opens each one again with Pop. A base's Quit is checked first, as `/boss close all` is. |
91
91
  | `/boss stash [name]`, `/boss pop [name]` | Put this whole base away (its tower, mains, members, and idle shells) and close it; open it again later, every pi resumed from its own session file in its old role. A stash refuses, naming why, while anything a resume would lose is there: a running turn, a queued message, an open dialog, a running monitor, a subagent, or a busy shell. |
92
- | `/boss gui`, `/boss gui stop` | Open the net in the browser (any session, joins nothing); stop its server, from any session. The machine runs one server on one URL: it comes back after its session's `/reload`, and a session on a newer pi-straw-boss takes it over, so open pages reload into the new version. Type into a session from its comms view, or work its terminal with the key pad under the terminal tile, or on a desktop by clicking into it and typing; answer a waiting `robot_hand` selector, `ask_user_question` questionnaire, or pi-code hook confirmation there or in its alert (so can any extension's `select` and `confirm`, also from a paired device; other dialogs show read-only, to answer in the terminal; `secret_drop` always takes its secret in the terminal, never on a page, local or over the tailnet, because pi-intercom identities are self-reported). Documents a session shows with [pi-herdr-doc-review](https://www.npmjs.com/package/pi-herdr-doc-review) 0.4.0 or newer open beside its comms: HTML in a sandboxed frame whose form answers the agent, text read-only. |
92
+ | `/boss gui`, `/boss gui stop` | Open the net in the browser (any session, joins nothing); stop its server, from any session. The machine runs one server on one URL: it comes back after its session's `/reload`, another session takes it over within a few seconds when the session that runs it ends (a closed pane, a quit, a crash; towers first, and a session on an older pi-straw-boss than the server waits about six seconds, then takes it until the next newer session takes it back), a session that starts later brings back a server that is down, even after every session quit or the machine restarted, and a session on a newer pi-straw-boss takes it over, so open pages reload into the new version. Only `/boss gui stop` keeps it stopped, until the next `/boss gui`; `/boss stash` and `/boss close all` do not start a stopped server. Type into a session from its comms view, or work its terminal with the key pad under the terminal tile, or on a desktop by clicking into it and typing; answer a waiting `robot_hand` selector, `ask_user_question` questionnaire, or pi-code hook confirmation there or in its alert (so can any extension's `select` and `confirm`, also from a paired device; other dialogs show read-only, to answer in the terminal; `secret_drop` always takes its secret in the terminal, never on a page, local or over the tailnet, because pi-intercom identities are self-reported). Documents a session shows with [pi-herdr-doc-review](https://www.npmjs.com/package/pi-herdr-doc-review) 0.4.0 or newer open beside its comms: HTML in a sandboxed frame whose form answers the agent, text read-only. |
93
93
  | `/boss gui remote`, `/boss gui remote off` | Reach the GUI from your phone over your tailnet (`tailscale serve`, never Funnel): pair by QR code, and a phone stays paired (90 days from its last use, restarts and `remote off` included) until you unpair it in the Phone tile; remote access stays on across a stop, a `/reload` and a new server until `remote off`; off removes only its own serve entry. The serve entry points at a second loopback listener of the GUI server, so a request is remote because of the listener it arrived on, whatever headers it carries. |
94
94
 
95
95
  Subagents and sessions in Herdr worktree workspaces never join: the session that launched them speaks for them. Outside Herdr the extension does nothing.
@@ -129,6 +129,7 @@ Run `herdr server reload-config` to load the key, and `herdr plugin unlink wayne
129
129
  A second optional Herdr plugin, in [`herdr-reaper/`](herdr-reaper), cleans up after agents that ran in worktree workspaces.
130
130
  In a workspace whose worktree is a linked git worktree (whatever its name), it closes the pane of an agent that has exited once the pane's shell is idle, and never the focused pane.
131
131
  Herdr then closes the tab and the workspace, and the plugin removes the workspace's checkout if `git status` is clean and nothing else is using it (no other pane works in it, no other workspace is open on it).
132
+ A worktree that pi-herdr-agents created is kept until its parent session removes it with `worktree_remove`, for as long as the parent session is open in a Herdr pane (or, if it is not, until it has been silent for 72 hours, `retain_idle_hours`), and so is any checkout a process of yours has as its working directory.
132
133
  Git-ignored files (`.env`, `node_modules`, caches, local databases) do not make a checkout dirty, so they are deleted with it.
133
134
  The branch always stays, and so does a dirty checkout, with the reason in the plugin log.
134
135
  A checkout with initialized submodules is removed only when every submodule (nested ones too) has no uncommitted or untracked paths and its HEAD is contained in a remote-tracking ref (or in a ref of the main repository's own copy of the module), so no commit is lost; the reaper then runs `git submodule deinit --all`, deletes the worktree's `.git/worktrees/<id>/modules` (which git refuses to remove the worktree around), and runs a plain `git worktree remove`. Otherwise it stays and the log names the submodule and why; if a removal fails partway and leaves files git no longer tracks, the log says "half-removed" and names the directory for you to delete.
package/README.zh-TW.md CHANGED
@@ -36,7 +36,7 @@
36
36
  - **分頁保持整齊。** pi 在分頁裡啟動或結束時,分頁裡的 pane 會重新平均分配,一個一個切出來的 worker 就不會留下一長串對半切的窄 pane。
37
37
  - **斷線的子智慧體會被提醒回報。** 子智慧體做完後還開著,但開出它的 pi 已經重開、收不到它的結果時,它會收到一則 intercom,請它把最終報告送回去,只會送一次([運作方式](docs/how-it-works.md#orphaned-subagents))。
38
38
  - **Herdr 側邊欄的網路檢視。** 一個選用的 plugin 會把 Agents 清單排成網的樣子:塔台在前,每個子智慧體排在開出它的人底下。預設開啟,按一個鍵就能切換。
39
- - **清理閒置的 worktree pane。** 另一個選用的 plugin 會關掉智慧體在 worktree 工作區留下的 pane,移除乾淨的 checkout,並保留你釘選的工作區。
39
+ - **清理閒置的 worktree pane。** 另一個選用的 plugin 會關掉智慧體在 worktree 工作區留下的 pane,移除乾淨且沒有東西在用的 checkout,並保留你釘選的工作區。
40
40
 
41
41
  ## 安裝
42
42
 
@@ -88,7 +88,7 @@ pi install npm:pi-straw-boss
88
88
  | `/boss close all` | 讓這個基地的塔台、主智慧體和成員結束,連同 shell 一起關掉,不存 stash。會先用 stash 的同一套檢查;有會遺失的東西(正在跑的回合、排隊中的訊息、對話框、Monitor、子智慧體、前景有程式的 shell)就什麼都不關,並列出所有原因。其他基地完全不碰。 |
89
89
  | 網頁上的「收起」「已收起」「接回」 | 基地的「收起」以基地名稱收起它;上方的「已收起」列出收起的基地,按「接回」打開。基地的「關掉」會先檢查,和 `/boss close all` 一樣。 |
90
90
  | `/boss stash [名稱]`、`/boss pop [名稱]` | 把整個基地(塔台、主智慧體、成員和閒置的 shell)收起來並關掉;之後再打開,每個 pi 都從自己的 session 檔接回原本的角色。只要有接回後會遺失的東西就拒絕並說明原因:正在跑的回合、排隊中的訊息、開著的對話框、執行中的 Monitor、子智慧體,或前景有程式在跑的 shell。 |
91
- | `/boss gui`、`/boss gui stop` | 在瀏覽器開啟網(任何 session 都能開,不會加入任何東西);停止它的伺服器,在哪個 session 都可以。整台機器只跑一個伺服器、一個網址:開它的 session `/reload` 之後,會在原網址重開;版本較新的 session 會接手,開著的頁面也會自動換成新版。可以在通訊畫面直接打字給某個 session,或用終端機畫面下方的按鍵面板操作它的終端機,在桌面上也可以點進終端機直接打字;在等回覆的 `robot_hand` 選單、`ask_user_question` 問卷或 pi-code 的 hook 確認,可以在那裡或它的警示裡作答(任何 extension 的 `select` 和 `confirm` 也可以,`confirm` 也可以從配對的裝置作答;其他對話框只能看,要回到終端機回答;`secret_drop` 的機密一律在終端機輸入,任何頁面(本機或 tailnet)都不接受,因為 pi-intercom 的身分是自己申報的,無法驗證)。session 用 [pi-herdr-doc-review](https://www.npmjs.com/package/pi-herdr-doc-review) 0.4.0 以上顯示的文件,會開在它的通訊畫面旁邊:HTML 放在沙箱框架裡,表單送出的答案會傳給智慧體;純文字只能讀。 |
91
+ | `/boss gui`、`/boss gui stop` | 在瀏覽器開啟網(任何 session 都能開,不會加入任何東西);停止它的伺服器,在哪個 session 都可以。整台機器只跑一個伺服器、一個網址:開它的 session `/reload` 之後,會在原網址重開;跑它的 session 結束時(關掉 pane、離開 pi、當機),另一個還開著的 session 會在幾秒內於原網址重開它(塔台優先;pi-straw-boss 版本比伺服器舊的 session 要等約六秒才接手,直到下一個較新的 session 再接回去);之後才啟動的 session 也會把停著的伺服器重開,即使所有 session 都離開過或機器重開機;版本較新的 session 會接手,開著的頁面也會自動換成新版。只有 `/boss gui stop` 會讓它維持停止,直到下一次 `/boss gui`;`/boss stash` 和 `/boss close all` 不會把停著的伺服器重開。可以在通訊畫面直接打字給某個 session,或用終端機畫面下方的按鍵面板操作它的終端機,在桌面上也可以點進終端機直接打字;在等回覆的 `robot_hand` 選單、`ask_user_question` 問卷或 pi-code 的 hook 確認,可以在那裡或它的警示裡作答(任何 extension 的 `select` 和 `confirm` 也可以,`confirm` 也可以從配對的裝置作答;其他對話框只能看,要回到終端機回答;`secret_drop` 的機密一律在終端機輸入,任何頁面(本機或 tailnet)都不接受,因為 pi-intercom 的身分是自己申報的,無法驗證)。session 用 [pi-herdr-doc-review](https://www.npmjs.com/package/pi-herdr-doc-review) 0.4.0 以上顯示的文件,會開在它的通訊畫面旁邊:HTML 放在沙箱框架裡,表單送出的答案會傳給智慧體;純文字只能讀。 |
92
92
  | `/boss gui remote`、`/boss gui remote off` | 透過你的 tailnet 用手機連 GUI(`tailscale serve`,絕不用 Funnel):掃 QR code 配對,手機配對後會一直有效(從最後一次使用起算 90 天,重啟與 `remote off` 都不影響),直到你在「手機」視窗解除配對;遠端存取在 stop、`/reload`、換新伺服器之後仍會保持開啟,直到 `remote off`;關閉時只移除它自己的 serve 設定。serve 指向 GUI 伺服器的第二個 loopback 監聽埠,請求算不算遠端取決於它進來的監聽埠,不看它帶什麼標頭。 |
93
93
 
94
94
  子智慧體,以及在 Herdr worktree 工作區裡的 session,都不會加入網,由開出它們的 session 代表它們。不在 Herdr 裡時,這個擴充套件什麼都不做。
@@ -128,6 +128,7 @@ description = "toggle the straw net view"
128
128
  另一個選用的 Herdr plugin 放在 [`herdr-reaper/`](herdr-reaper),專門清理 worktree 工作區裡智慧體跑完留下的東西。
129
129
  只處理 worktree 是 linked git worktree 的工作區(不管它叫什麼名字):智慧體結束、pane 的 shell 閒置之後,就關掉這個 pane,但絕不關掉有焦點的 pane。
130
130
  Herdr 接著會關掉分頁和工作區,plugin 再在 `git status` 乾淨、而且沒有別的東西還在用它(沒有別的 pane 在裡面工作,也沒有別的工作區開在同一個 checkout)時移除這個工作區的 checkout。
131
+ pi-herdr-agents 建立的 worktree 會留著,直到它的父 session 用 `worktree_remove` 移除;父 session 只要還開在某個 Herdr pane 裡就一直留著,沒有開著時則等它靜默 72 小時(`retain_idle_hours`);任何有你的行程以它為工作目錄的 checkout 也一樣不動。
131
132
  git 忽略的檔案(`.env`、`node_modules`、快取、本機資料庫)不算變更,所以會跟著 checkout 一起被刪掉。
132
133
  分支一律保留;有未提交變更的 checkout 也留著,原因寫在 plugin 日誌裡。
133
134
  含已初始化 submodule 的 checkout,只有在每個 submodule(含巢狀的)都沒有未提交或未追蹤的檔案,且 HEAD 已被某個 remote-tracking ref(或主儲存庫自己那份 module 的 ref)包含、不會遺失任何 commit 時才會移除:reaper 先跑 `git submodule deinit --all`,刪掉 worktree 的 `.git/worktrees/<id>/modules`(有它在,git 不肯移除 worktree),再跑不帶 `--force` 的 `git worktree remove`。不符合就保留,日誌會寫出是哪個 submodule、為什麼;若移除做到一半失敗、留下 git 已不再追蹤的檔案,日誌會寫「half-removed」並指出該目錄,由你自己刪除。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand with a live monitoring dashboard, and boss_broadcast reaches the rest",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/bridge.ts CHANGED
@@ -29,7 +29,8 @@ import { type Monitor, trackMonitors } from "./monitors.ts";
29
29
  // into an impostor's hands): the bridge never relays a secret dialog, and refuses any answer to one.
30
30
 
31
31
  export const REGISTER = "intercom:extension-register";
32
- const VERDICT_MS = 2000, POLL_MS = 2000, KEEP_MS = 120_000, KEEP = 16, MAX_GUIS = 8, MAX_MESSAGE = 15 * 1024;
32
+ // pi-intercom has no unregister: every host the session joins stays one of its channels, and the broker takes at most 32 per session (the tab, the viewer, and the hub's own take a few).
33
+ const VERDICT_MS = 2000, POLL_MS = 2000, KEEP_MS = 120_000, KEEP = 16, MAX_GUIS = 16, MAX_MESSAGE = 15 * 1024;
33
34
  /** At most this many queued messages go to the GUI (the oldest, which are delivered first), each cut to this many characters. */
34
35
  export const MAX_QUEUED = 5, QUEUED_TEXT = 200;
35
36
  const clipQueued = (text: string) => (text.length > QUEUED_TEXT ? `${text.slice(0, QUEUED_TEXT - 1)}…` : text);
@@ -99,7 +100,7 @@ export function startBridge(pi: ExtensionAPI, { env, dir, local, close }: { env:
99
100
  } catch {
100
101
  g = undefined;
101
102
  }
102
- if (g && g !== me() && !chans.has(g)) join_(g);
103
+ if (g && g !== me() && !chans.has(g) && alive()) join_(g); // a host whose process is gone is not joined: the survivor that takes over rewrites gui.json within moments
103
104
  push();
104
105
  return true;
105
106
  };
package/src/handover.ts CHANGED
@@ -2,16 +2,29 @@ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:f
2
2
  import { request } from "node:http";
3
3
  import { join } from "node:path";
4
4
  import { type Asked, type Gui, VERSION } from "./gui.ts";
5
+ import { alive, runs, startOf } from "./remote.ts";
5
6
 
6
7
  // One server per machine. gui.json in the state directory records the running server (pid, local and remote port, token, owner session), and
7
8
  // gui.lock makes the check-and-start atomic, so a second /boss gui, from this session or another, opens the running server
8
9
  // instead of starting one. A dead server's port and token are reused, so a page left open reconnects to its successor. Any session reaches
9
10
  // the running one: it asks it to stop (requestStop), or takes it over, stopping it and starting its own on the same URL. A session whose
10
- // pi-straw-boss is newer takes it over unasked, so an update reaches every open page once one session has reloaded.
11
- /** `ownerSession` is the owner's intercom session id: bridges follow it to the server's dialog channel. `reload` marks a server its owner's /reload stopped. `remote` is only ever written by a build up to 0.32.1 (remote was on at that /reload); paired.json keeps the wish since. */
12
- export interface GuiRecord { pid: number; port: number; remotePort?: number; token: string; csrf?: string; owner: string; ownerSession?: string; version?: string; reload?: boolean; remote?: boolean }
13
- /** `stale`: the running server is older and could not be taken over. */
14
- export interface Shared { url: string; owner: string; mine?: Gui; stale?: boolean }
11
+ // pi-straw-boss is newer takes it over unasked, so an update reaches every open page once one session has reloaded. When the session that runs it ends without a
12
+ // stop, a survivor adopts it (adoptGui): the same port, token, and secret again, so a page left open reconnects by itself. The record tells the two endings apart.
13
+ /**
14
+ * `ownerSession` is the owner's intercom session id: bridges follow it to the server's dialog channel. `started` is when the owner's process started, so a pid another
15
+ * process now holds is not taken for the owner; only a build that marks its stops writes it, so a record without it is never adopted (a server such a build stopped looks
16
+ * like one it quit). `herdr` is the socket of the Herdr server the server's net is read from: a session on another Herdr does not adopt it. `reload` marks a server its owner's
17
+ * /reload stopped. `stopped` marks one stopped on purpose (a stop from any session): no session starts it again until someone runs /boss gui. `remote` is only ever written by a
18
+ * build up to 0.32.1 (remote was on at that /reload); paired.json keeps the wish since.
19
+ */
20
+ export interface GuiRecord { pid: number; started?: string; port: number; remotePort?: number; token: string; csrf?: string; owner: string; ownerSession?: string; version?: string; herdr?: string; reload?: boolean; stopped?: boolean; remote?: boolean }
21
+ /**
22
+ * `stale`: the running server is older and could not be taken over. `from`: the owner whose end a survivor took the server over from. `moved`: the recorded port was taken, so the URL changed.
23
+ * `stopped`: it was stopped on purpose and `keep.stopped` left it so. `blind`: this process's start time could not be read, so the record it wrote has no `started` and no survivor adopts that server.
24
+ */
25
+ export interface Shared { url: string; owner: string; mine?: Gui; stale?: boolean; from?: string; moved?: boolean; stopped?: boolean; blind?: boolean }
26
+ /** What a start under gui.lock leaves alone: a server stopped on purpose, a server that is down. Only `/boss gui` starts either. */
27
+ export interface Keep { stopped?: boolean; down?: boolean }
15
28
 
16
29
  /** Whether version `a` is newer than `b`; a server that recorded none predates the handover. */
17
30
  export function newer(a: string, b?: string) {
@@ -38,6 +51,35 @@ export function markReload(dir: string, pid = process.pid) {
38
51
  if (record?.pid === pid) writeFileSync(join(dir, "gui.json"), JSON.stringify({ ...record, reload: true }), { mode: 0o600 });
39
52
  }
40
53
 
54
+ /**
55
+ * Marks the record of the server `seen` names as stopped on purpose, so no survivor starts it again. A record of another server is left alone, and that includes a successor
56
+ * on the same port and token (a restart reuses both): the process and its start time must match too.
57
+ */
58
+ export function markStopped(dir: string, seen: Pick<GuiRecord, "pid" | "started" | "port" | "token">) {
59
+ const record = readRecord(dir);
60
+ if (record?.pid === seen.pid && record.started === seen.started && record.port === seen.port && record.token === seen.token) writeFileSync(join(dir, "gui.json"), JSON.stringify({ ...record, stopped: true }), { mode: 0o600 });
61
+ }
62
+
63
+ /** Whether a survivor may take over `record`'s server: its writer marks stops (`started`), and it serves the survivor's own Herdr. */
64
+ export const adoptable = (record: GuiRecord, { herdr }: { herdr: string }) => Boolean(record.started) && record.herdr === herdr;
65
+ /** Whether the server is a newer build's than the survivor: an older survivor takes it only when no newer one has, and downgrades it until the next newer session takes it back. */
66
+ export const downgrades = (record: GuiRecord, version: string) => Boolean(record.version && newer(record.version, version));
67
+
68
+ /**
69
+ * A look at whether the process that recorded a server is gone for good, for a survivor to decide on: its pid is dead, or now belongs to another process (one that
70
+ * started at another time, or after an old record was written). A process confirmed as the recorder is afterwards looked at by signal alone, not by `ps`.
71
+ */
72
+ export function ownerGone(start: (pid: number) => string | undefined = startOf): (record: GuiRecord, wrote: number) => boolean {
73
+ let confirmed: string | undefined;
74
+ return (record, wrote) => {
75
+ const key = `${record.pid}/${record.started ?? wrote}`;
76
+ if (confirmed === key) return !alive(record.pid);
77
+ if (!runs(record, wrote, start)) return true;
78
+ confirmed = key;
79
+ return false;
80
+ };
81
+ }
82
+
41
83
  /** Asks a recorded server to stop, as its own page would, and waits until it is gone; false when it refused (a server from before 0.11.0) or stayed up. */
42
84
  export async function requestStop(r: GuiRecord, asked: Asked, sleep: (ms: number) => Promise<void>) {
43
85
  const body = JSON.stringify(asked), origin = `http://127.0.0.1:${r.port}`;
@@ -80,32 +122,109 @@ export const answers = (r: GuiRecord) =>
80
122
  req.end();
81
123
  });
82
124
 
83
- /** `take` takes the running server over whatever its version; a newer `version` takes it over anyway. */
84
- export async function sharedGui(dir: string, [owner, ownerSession]: [string, string], start: (prefer: { port?: number; remotePort?: number; token?: string; csrf?: string }) => Promise<Gui>, sleep: (ms: number) => Promise<void>, { version = VERSION, take = false } = {}): Promise<Shared> {
125
+ const STALE_MS = 10_000;
126
+ const ageOf = (file: string) => Date.now() - (statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? Date.now());
127
+ /**
128
+ * Removes a gui.lock a session that died left (older than 10 s), once. Of several sessions that find it stale only the one that makes `gui.lock.stale` looks again and removes it, so none
129
+ * removes the fresh lock another has just taken. False when another session is at it (or left its mark: a guard older than 10 s was left by a session that died inside this), for the caller to wait and try again.
130
+ */
131
+ export function clearStale(lock: string) {
132
+ const guard = `${lock}.stale`;
133
+ try {
134
+ mkdirSync(guard);
135
+ } catch (error) {
136
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
137
+ if (ageOf(guard) > STALE_MS) rmSync(guard, { recursive: true, force: true });
138
+ return false;
139
+ }
140
+ try {
141
+ if (ageOf(lock) > STALE_MS) rmSync(lock, { force: true });
142
+ return true;
143
+ } finally {
144
+ rmSync(guard, { recursive: true, force: true });
145
+ }
146
+ }
147
+ /** Takes gui.lock, which makes the check-and-start of a server atomic. Patient: waits for another session's start (a lock older than 10 s was left by a session that died); otherwise false at once when it is held. */
148
+ async function lockGui(dir: string, sleep: (ms: number) => Promise<void>, patient: boolean) {
85
149
  mkdirSync(dir, { recursive: true, mode: 0o700 });
86
150
  const lock = join(dir, "gui.lock");
87
151
  for (let tries = 0; ; tries++) {
88
152
  try {
89
153
  writeFileSync(lock, String(process.pid), { flag: "wx" });
90
- break;
154
+ return true;
91
155
  } catch (error) {
92
156
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
93
- // A lock older than 10 s was left by a session that died while starting the server.
94
- const age = Date.now() - (statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now());
95
- if (age > 10_000) rmSync(lock, { force: true });
96
- else if (tries >= 50) throw new Error("Another session is starting the straw net GUI; try again");
97
- else await sleep(200);
157
+ if (ageOf(lock) > STALE_MS && clearStale(lock)) continue; // a dead session's lock is gone: take it
158
+ if (!patient) return false;
159
+ if (tries >= 50) throw new Error("Another session is starting the straw net GUI; try again");
160
+ await sleep(200);
98
161
  }
99
162
  }
163
+ }
164
+ const unlockGui = (dir: string) => rmSync(join(dir, "gui.lock"), { force: true });
165
+ /** Records the server this process started. */
166
+ const writeRecord = (dir: string, gui: Gui, [owner, ownerSession]: [string, string], version: string, herdr: string) => {
167
+ const started = startOf(process.pid);
168
+ writeFileSync(join(dir, "gui.json"), JSON.stringify({ pid: process.pid, started, port: gui.port, remotePort: gui.remotePort, token: gui.token, csrf: gui.csrf, owner, ownerSession, version, herdr }), { mode: 0o600 });
169
+ return started ? {} : { blind: true };
170
+ };
171
+ type Start = (prefer: { port?: number; remotePort?: number; token?: string; csrf?: string }) => Promise<Gui>;
172
+
173
+ /**
174
+ * A stop typed in a session that does not run the server, under gui.lock: a survivor that is starting it finishes first, so the stop reads and marks the record that start wrote and
175
+ * is not overwritten by it, and a server it started is asked to stop as it answers. `down`: nothing answered, and the record is marked so none is started later.
176
+ */
177
+ export async function stopRecorded(dir: string, by: string, sleep: (ms: number) => Promise<void>): Promise<{ state: "down" | "stopped" | "refused"; record?: GuiRecord }> {
178
+ await lockGui(dir, sleep, true);
100
179
  try {
101
180
  const record = readRecord(dir);
181
+ if (!record || !(await answers(record))) {
182
+ if (record && !record.stopped) markStopped(dir, record); // a server that is down may be about to be taken over: a stop is a stop all the same
183
+ return { state: "down", record };
184
+ }
185
+ const stopped = await requestStop(record, { by, take: false }, sleep);
186
+ if (stopped) markStopped(dir, record); // its owner marks it too, unless it is a build from before this record knew
187
+ return { state: stopped ? "stopped" : "refused", record };
188
+ } finally {
189
+ unlockGui(dir);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * `take` takes the running server over whatever its version; a newer `version` takes it over anyway. `herdr` is the socket the server's net is read from. `keep`: a server stopped on purpose, or one
195
+ * that is down, is left as it is (read under the lock, so a stop typed meanwhile counts): the restore after a /reload keeps a stop, the takeover by a newer version also keeps a down server, and
196
+ * `/boss stash` and `/boss close all` keep a stop.
197
+ */
198
+ export async function sharedGui(dir: string, who: [string, string], start: Start, sleep: (ms: number) => Promise<void>, { version = VERSION, take = false, herdr = "", keep = {} as Keep } = {}): Promise<Shared> {
199
+ await lockGui(dir, sleep, true);
200
+ try {
201
+ const record = readRecord(dir);
202
+ if (record?.stopped && keep.stopped) return { url: urlOf(record), owner: record.owner, stopped: true };
102
203
  const theirs = record && (await answers(record)) && { url: urlOf(record), owner: record.owner };
204
+ if (record && !theirs && keep.down) return { url: urlOf(record), owner: record.owner };
103
205
  if (theirs && !(take || newer(version, record.version))) return theirs;
104
- if (theirs && !(await requestStop(record, { by: owner, take: true }, sleep))) return { ...theirs, stale: true };
206
+ if (theirs && !(await requestStop(record, { by: who[0], take: true }, sleep))) return { ...theirs, stale: true };
105
207
  const gui = await start(record ? { port: record.port, remotePort: record.remotePort, token: record.token, csrf: record.csrf } : {});
106
- writeFileSync(join(dir, "gui.json"), JSON.stringify({ pid: process.pid, port: gui.port, remotePort: gui.remotePort, token: gui.token, csrf: gui.csrf, owner, ownerSession, version }), { mode: 0o600 });
107
- return { url: gui.url, owner, mine: gui };
208
+ return { url: gui.url, owner: who[0], mine: gui, ...writeRecord(dir, gui, who, version, herdr) };
209
+ } finally {
210
+ unlockGui(dir);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * A survivor's takeover of a server whose session ended: starts it again on the recorded port, token, and secret. It starts nothing when the server answers, when a deliberate stop
216
+ * ended it, when `adoptable` says no (an old build's record, another Herdr), or when the session that ran it still lives (its /reload, /new, or /resume brings it back
217
+ * in that process). Undefined then, and when another session holds gui.lock: that one is starting it, or is about to look. The lock is the arbiter, so of several survivors looking at
218
+ * once exactly one starts it. `moved` is set when the recorded port was taken and the server got another.
219
+ */
220
+ export async function adoptGui(dir: string, who: [string, string], start: Start, sleep: (ms: number) => Promise<void>, gone: (record: GuiRecord, wrote: number) => boolean, { version = VERSION, herdr = "" } = {}): Promise<Shared | undefined> {
221
+ if (!(await lockGui(dir, sleep, false))) return undefined;
222
+ try {
223
+ const record = readRecord(dir), wrote = statSync(join(dir, "gui.json"), { throwIfNoEntry: false })?.mtimeMs ?? 0;
224
+ if (!record || record.stopped || !adoptable(record, { herdr }) || !gone(record, wrote) || (await answers(record))) return undefined;
225
+ const gui = await start({ port: record.port, remotePort: record.remotePort, token: record.token, csrf: record.csrf });
226
+ return { url: gui.url, owner: who[0], mine: gui, from: record.owner, ...writeRecord(dir, gui, who, version, herdr), ...(gui.url === urlOf(record) ? {} : { moved: true }) };
108
227
  } finally {
109
- rmSync(lock, { force: true });
228
+ unlockGui(dir);
110
229
  }
111
230
  }
package/src/herdr.ts CHANGED
@@ -82,7 +82,9 @@ export async function panes(run: HerdrRun, workspace?: string): Promise<Pane[]>
82
82
  }
83
83
 
84
84
  // Herdr answers one request per connection with one newline-ended frame, which may arrive in pieces (split anywhere, even inside a character).
85
- export const herdrCall = (socketPath = process.env.HERDR_SOCKET_PATH || join(homedir(), ".config", "herdr", "herdr.sock")): HerdrCall => (method, params, timeoutMs = 5000) =>
85
+ /** The socket of the Herdr server a process runs under: the one its environment names, else the default. Two sessions are on the same Herdr when this is the same text. */
86
+ export const herdrSocket = (env: NodeJS.ProcessEnv = process.env) => env.HERDR_SOCKET_PATH || join(homedir(), ".config", "herdr", "herdr.sock");
87
+ export const herdrCall = (socketPath = herdrSocket()): HerdrCall => (method, params, timeoutMs = 5000) =>
86
88
  new Promise((resolve, reject) => {
87
89
  const socket = createConnection(socketPath).setTimeout(timeoutMs), text = new StringDecoder("utf8");
88
90
  let reply = "";
package/src/host.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { popBase, stashedList } from "./stash.ts";
2
+ import { statSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import type { Bridge } from "./bridge.ts";
4
5
  import { createCloser } from "./closer.ts";
5
6
  import { createHub } from "./dialogs.ts";
6
7
  import { type Gui, startGui } from "./gui.ts";
7
- import { answers, markReload, readRecord, requestStop, type Shared, sharedGui } from "./handover.ts";
8
+ import { adoptable, adoptGui, downgrades, type Keep, markReload, markStopped, readRecord, type Shared, sharedGui, stopRecorded } from "./handover.ts";
9
+ import { herdrSocket } from "./herdr.ts";
8
10
  import type { Membership } from "./membership.ts";
9
11
  import { netSource } from "./net.ts";
10
12
  import { pairingKeys, showLines } from "./pairing.ts";
@@ -29,11 +31,15 @@ export function createHost({ pi, cfg, s, guiDir, me, tell }: Kit, { bridge, conn
29
31
  };
30
32
  const unsaved = (gui: Gui) => ((reason) => reason && tell(reason, "warning"))(gui.access.unsaved()); // a paired.json that could not be saved: what was just changed is lost at a restart
31
33
  const label = () => `${me() || "unknown"} (pane ${cfg.env.HERDR_PANE_ID})`;
32
- // This session's server stops, its remote entry first: /boss gui stop here, another session's stop or takeover (`asked`), or the session's end.
34
+ const herdr = herdrSocket(cfg.env); // the Herdr server this session's net is read from; gui.json names it, and only a session on the same one adopts
35
+ // This session's server stops, its remote entry first: /boss gui stop here, or another session's stop or takeover (`asked`). A stop is on purpose and says so in
36
+ // gui.json, so no survivor starts the server again (adoptGui); a takeover starts its own at once, and the session's end is not a stop (release).
33
37
  const stopOwn = async (asked?: { by: string; take: boolean }) => {
34
38
  const running = s.gui;
35
39
  s.gui = undefined;
36
40
  if (!running) return false;
41
+ const record = readRecord(guiDir);
42
+ if (!asked?.take && record?.pid === process.pid && record.port === running.port && record.token === running.token) markStopped(guiDir, record); // before the server stops answering, so a session that sees it down finds the mark
37
43
  const off = await switchRemote(running, false, guiDir, ts, true).catch((error: Error) => ({ text: error.message, changed: true }));
38
44
  if (off.changed) tell(off.text);
39
45
  await running.stop();
@@ -53,36 +59,76 @@ export function createHost({ pi, cfg, s, guiDir, me, tell }: Kit, { bridge, conn
53
59
  await switchRemote(gui, false, guiDir, ts, true).catch(() => {});
54
60
  return void tell(`Remote access stays off: the Tailscale identity is now ${now.login} on ${now.host}, not ${remembered.login} on ${remembered.host} that the paired devices belong to; sign in as that identity again, or turn it on here with /boss gui remote (that unpairs the old devices)`, "warning");
55
61
  }
56
- tell((await switchRemote(gui, true, guiDir, ts)).text);
62
+ tell((await switchRemote(gui, true, guiDir, ts)).plain);
57
63
  } catch (error) {
58
64
  tell(`Remote access stays off: ${(error as Error).message}; it is tried again when the server next starts`, "warning");
59
65
  }
60
66
  };
67
+ const start = (prefer: { port?: number; remotePort?: number; token?: string; csrf?: string }, net = netSource({ run, world, sessions, stashed: () => stashedList(guiDir), hub: (s.hub = s.hub?.me === me() ? s.hub : createHub(pi, me(), bridge)) })) => startGui({ read: net.read, net, call, hub: s.hub, dir: bundle, paired: join(guiDir, "paired.json"), ...prefer, onStop: (asked) => void stopOwn(asked), onSaveFailed: (reason) => tell(reason, "warning"), onRemote: async () => tell((await switchRemote(s.gui!, true, guiDir, ts)).text), onRemoteOff: async () => tell((await switchRemote(s.gui!, false, guiDir, ts)).text), spawn: createSpawn({ run, sleep, call, live: async (id) => (await sessions()).some((x) => x.id === id), sessionAt: async (pane) => (await sessions()).find((x) => x.herdrLocation?.paneId === pane)?.id }), close: createCloser({ run, sleep, me, sessions, ask: async (key, keepPane, ifStashable) => s.hub?.close(key, keepPane, ifStashable), check: async (key) => s.hub?.stashCheck(key), stateDir: guiDir, pop: (name) => popBase({ run, sleep, stateDir: guiDir, live: async (id) => (await sessions()).some((x) => x.id === id), agentDir: cfg.env.PI_CODING_AGENT_DIR }, name) }) });
61
68
  // One server per machine: a second /boss gui, here or in another session, opens the one that runs, unless `take` (or a newer version) takes it
62
69
  // over. Two calls in flight in this session share one start. `manual`: the caller turns remote on or off itself, so a new server does not resume it.
63
- const share = (take = false, manual = false, was = false) =>
64
- (opening ??= (async () => {
70
+ let adopting: Promise<void> | undefined, leaving = false;
71
+ // A server whose start time `ps` could not tell has no `started` in its record, so no survivor adopts it: said once.
72
+ let blind = false;
73
+ const noticed = (shared?: Shared) => void (shared?.blind && !blind && ((blind = true), tell("This session could not read its own start time (ps failed), so no other session takes the straw net GUI over if this session ends; /boss gui starts it again", "warning")));
74
+ // `keep` (handover.ts): a call that leaves a stopped or down server as it is does not hand its answer to one that starts it, so a /boss gui that finds such a call in flight waits for it.
75
+ const share = async (take = false, manual = false, was = false, keep?: Keep): Promise<Shared> => {
76
+ while (opening && leaving && !keep) await opening.then(() => {}, () => {});
77
+ if (!opening) leaving = Boolean(keep);
78
+ return (opening ??= (async () => {
79
+ await adopting; // an adoption under way is this session's start
65
80
  if (s.gui) return { url: s.gui.url, owner: label(), mine: s.gui };
66
- const start = (prefer: { port?: number; remotePort?: number; token?: string; csrf?: string }, net = netSource({ run, world, sessions, stashed: () => stashedList(guiDir), hub: (s.hub = s.hub?.me === me() ? s.hub : createHub(pi, me(), bridge)) })) => startGui({ read: net.read, net, call, hub: s.hub, dir: bundle, paired: join(guiDir, "paired.json"), ...prefer, onStop: (asked) => void stopOwn(asked), onSaveFailed: (reason) => tell(reason, "warning"), onRemote: async () => tell((await switchRemote(s.gui!, true, guiDir, ts)).text), onRemoteOff: async () => tell((await switchRemote(s.gui!, false, guiDir, ts)).text), spawn: createSpawn({ run, sleep, call, live: async (id) => (await sessions()).some((x) => x.id === id), sessionAt: async (pane) => (await sessions()).find((x) => x.herdrLocation?.paneId === pane)?.id }), close: createCloser({ run, sleep, me, sessions, ask: async (key, keepPane, ifStashable) => s.hub?.close(key, keepPane, ifStashable), check: async (key) => s.hub?.stashCheck(key), stateDir: guiDir, pop: (name) => popBase({ run, sleep, stateDir: guiDir, live: async (id) => (await sessions()).some((x) => x.id === id), agentDir: cfg.env.PI_CODING_AGENT_DIR }, name) }) });
67
- const shared = await sharedGui(guiDir, [label(), me()], start, sleep, { version, take });
81
+ const shared = await sharedGui(guiDir, [label(), me()], start, sleep, { version, take, herdr, keep });
82
+ noticed(shared);
68
83
  if ((s.gui = shared.mine) && !manual) await resume(s.gui, was);
69
84
  return shared;
70
85
  })().finally(() => (opening = undefined)));
86
+ };
87
+ // The server belongs to the machine, not to the session that started it. When that session ends without a stop (a closed pane, a quit, a crash), a survivor starts the server
88
+ // again on the same URL (handover.ts adoptGui), with remote access as `resume` leaves it, no QR, and no browser. Every session looks once every `adoptMs`; gui.lock lets exactly
89
+ // one of them start it. It is left alone when the record says it was stopped on purpose, when `adoptable` (handover.ts) says no (an old build's record, another Herdr's, a newer build's), and while the session
90
+ // that ran it lives: a /reload, /new, or /resume brings it back in that process, so a survivor never takes it from a reload. Only an interactive session adopts: a pi-herdr-agents subagent never reaches this host (index.ts), as it is
91
+ // short-lived and its parent is the session to host, and neither does a print-mode run, whose end is just as near and whose screen would not show the notice.
92
+ // Towers outlive the sessions they open, so the host is biased toward them and away from the churn of a short-lived session: a tower takes the server at the first look that finds its owner
93
+ // gone, anyone else at the second, and a build older than the server at the third, by which time any newer survivor has taken it (the next newer session takes it back).
94
+ let looking: ReturnType<typeof setInterval> | undefined, refused: string | undefined, seen = { key: "", looks: 0 };
95
+ const adopt = async () => {
96
+ if (s.gui || s.stopped || s.closing || !s.hasUI || !me() || opening || adopting) return;
97
+ const wrote = statSync(join(guiDir, "gui.json"), { throwIfNoEntry: false })?.mtimeMs, record = readRecord(guiDir);
98
+ if (!wrote || !record || record.stopped || !adoptable(record, { herdr }) || !cfg.gone(record, wrote) || refused === `${record.pid}/${wrote}`) return void (seen = { key: "", looks: 0 });
99
+ const key = `${record.pid}/${wrote}`;
100
+ seen = { key, looks: seen.key === key ? seen.looks + 1 : 1 };
101
+ if (seen.looks <= (downgrades(record, version) ? 2 : s.isTower ? 0 : 1)) return;
102
+ await (adopting = (async () => {
103
+ let shared: Shared | undefined;
104
+ try {
105
+ shared = await adoptGui(guiDir, [label(), me()], start, sleep, cfg.gone, { version, herdr });
106
+ } catch (error) {
107
+ refused = `${record.pid}/${wrote}`; // told once; /boss gui tries again
108
+ return tell(`Could not take over the straw net GUI after session ${record.owner} ended: ${(error as Error).message}; /boss gui starts it`, "warning");
109
+ }
110
+ if (!shared?.mine) return;
111
+ noticed(shared);
112
+ s.gui = shared.mine;
113
+ tell(`This session now hosts the straw net GUI: session ${shared.from} ended, ${shared.moved ? `and its port was taken meanwhile, so the URL changed: ${shared.url}` : "and the URL is unchanged"}`);
114
+ await resume(s.gui);
115
+ })().finally(() => (adopting = undefined)));
116
+ };
71
117
  const older = (owner: string) => `The straw net GUI runs in session ${owner} on a pi-straw-boss older than 0.11.0, which cannot hand it over; /reload that session or run the command there`;
72
118
  return {
73
119
  sessions,
74
120
  share,
75
121
  /** The server again after a /reload, /new, /resume, or a takeover by a newer version; remote access comes back with it when it was wanted on, and the paired devices are still paired. */
76
- async restore(was = false) {
77
- await share(false, false, was);
122
+ async restore(was = false, keep?: Keep) {
123
+ await share(false, false, was, keep);
78
124
  },
79
125
  /** `mode` is "", "stop", "remote", or "remote off", and works from any session: remote access (remote.ts) goes with the server, so a session takes it over first. */
80
126
  async command(mode: string) {
81
127
  if (mode === "stop") {
82
128
  if (await stopOwn()) return;
83
- const record = readRecord(guiDir);
84
- if (!record || !(await answers(record))) return tell("The straw net GUI is not running");
85
- return tell((await requestStop(record, { by: label(), take: false }, sleep)) ? `Straw net GUI stopped (it ran in session ${record.owner})` : older(record.owner));
129
+ const { state, record } = await stopRecorded(guiDir, label(), sleep); // under gui.lock: a survivor that is starting the server finishes first
130
+ if (state === "down") return tell("The straw net GUI is not running");
131
+ return tell(state === "stopped" ? `Straw net GUI stopped (it ran in session ${record!.owner})` : older(record!.owner));
86
132
  }
87
133
  const { url, owner, stale } = await share(mode.startsWith("remote"), mode.startsWith("remote"));
88
134
  if (mode.startsWith("remote") && !s.gui) return tell(older(owner));
@@ -100,8 +146,15 @@ export function createHost({ pi, cfg, s, guiDir, me, tell }: Kit, { bridge, conn
100
146
  tell(`Straw net GUI: ${url}`);
101
147
  await openUrl(url).catch((error: Error) => tell(`Could not open a browser (${error.message}); open the URL above yourself`, "warning"));
102
148
  },
149
+ /** Starts looking for a server to take over (every `adoptMs`, until `release`); a session start again in this instance looks anew. */
150
+ watch() {
151
+ clearInterval(looking);
152
+ looking = setInterval(() => void adopt().catch(() => {}), cfg.adoptMs).unref();
153
+ },
103
154
  /** The session ends: a /reload, /new, or /resume (`reload`) marks the server to start again in this process, and the server stops with its remote entry. */
104
155
  async release(reload: boolean) {
156
+ clearInterval(looking);
157
+ await adopting; // a server this session was starting to adopt stops with it, instead of outliving it
105
158
  if (s.gui && reload) markReload(guiDir); // a /reload, /new, or /resume starts it again in this process
106
159
  if (s.gui) await switchRemote(s.gui, false, guiDir, ts, true).catch(() => {}); // the entry goes with the server; remote stays wanted and the devices stay paired
107
160
  await s.gui?.stop();
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { afterExit, createClosing } from "./closing.ts";
9
9
  import { createElection } from "./election.ts";
10
10
  import { createGuard, GUARD_MS } from "./guard.ts";
11
11
  import { openBrowser, VERSION } from "./gui.ts";
12
+ import { ownerGone } from "./handover.ts";
12
13
  import { copyText } from "./pairing.ts";
13
14
  import { createHost } from "./host.ts";
14
15
  import { herdrApi, herdrCall, herdrRun, herdrWorld, socketReads, worldOf } from "./herdr.ts";
@@ -27,12 +28,13 @@ export type { Deps } from "./state.ts";
27
28
 
28
29
  const NUDGE_MS = 15_000;
29
30
  const DETECT_MS = 1_000;
31
+ const ADOPT_MS = 2_000;
30
32
 
31
33
  // The composition root: the concerns live in their own modules (state.ts says what they share), and this wires them for one pi session.
32
- export function createStrawBoss({ run: given, world: reading, api = herdrApi(), call = herdrCall(), tailscale: ts = tailscale, copy = copyText, env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)), bundle, openUrl = openBrowser, watch = herdrWatch(), stateDir, autoTower = true, nudgeMs = NUDGE_MS, version = VERSION, guardMs = GUARD_MS, couple = true, events = herdrEvents(), detectMs = DETECT_MS, now = Date.now, later = afterExit }: Deps = {}) {
34
+ export function createStrawBoss({ run: given, world: reading, api = herdrApi(), call = herdrCall(), tailscale: ts = tailscale, copy = copyText, env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)), bundle, openUrl = openBrowser, watch = herdrWatch(), stateDir, autoTower = true, nudgeMs = NUDGE_MS, version = VERSION, guardMs = GUARD_MS, couple = true, events = herdrEvents(), detectMs = DETECT_MS, now = Date.now, later = afterExit, gone = ownerGone(), adoptMs = ADOPT_MS }: Deps = {}) {
33
35
  // A run that is not injected is the real CLI, with its listings over the socket; an injected one answers everything itself, listings included.
34
36
  const run = given ?? socketReads(herdrRun, call), world = reading ?? (given ? worldOf(given) : herdrWorld(call));
35
- const cfg: Config = { run, world, api, call, tailscale: ts, copy, env, sleep, promptDir, bundle, openUrl, watch, stateDir, autoTower, nudgeMs, version, guardMs, couple, events, detectMs, now, later };
37
+ const cfg: Config = { run, world, api, call, tailscale: ts, copy, env, sleep, promptDir, bundle, openUrl, watch, stateDir, autoTower, nudgeMs, version, guardMs, couple, events, detectMs, now, later, gone, adoptMs };
36
38
  return function strawBoss(pi: ExtensionAPI): void {
37
39
  // Outside Herdr there is no tab to coordinate. Every session in Herdr relays its dialogs to the GUI (bridge.ts), subagents too: they ask
38
40
  // questions as well. A pi-herdr-agents worker never joins: its parent represents it.
package/src/remote.ts CHANGED
@@ -161,7 +161,7 @@ export function createAccess(token: string, ended: (device?: string) => void = (
161
161
  };
162
162
  }
163
163
  export type Access = ReturnType<typeof createAccess>;
164
- const alive = (pid: unknown) => {
164
+ export const alive = (pid: unknown) => {
165
165
  try {
166
166
  return typeof pid === "number" && pid > 0 && process.kill(pid, 0);
167
167
  } catch (error) {
@@ -196,7 +196,7 @@ export const cookieFor = (token: string, value: string) => `sb_remote=${value};
196
196
  * remote stream ends, but the paired devices stay. `stop` is the server going away (a stop, a takeover, a /reload, the session's end): the
197
197
  * entry goes the same way, and remote stays wanted, so the next server turns it on again. `dir` holds remote.json, the entry's target while it is on.
198
198
  */
199
- export async function switchRemote(gui: { port: number; remotePort: number; token: string; access: Access }, on: boolean, dir: string, run: Run = tailscale, stop = false): Promise<{ text: string; lines?: string[]; link?: () => string; changed: boolean }> {
199
+ export async function switchRemote(gui: { port: number; remotePort: number; token: string; access: Access }, on: boolean, dir: string, run: Run = tailscale, stop = false): Promise<{ text: string; plain: string; lines?: string[]; link?: () => string; changed: boolean }> {
200
200
  // An entry from before the remote listener points at the local port; it is this server's own, so it is repointed or removed like a recorded one.
201
201
  const target = `http://127.0.0.1:${gui.remotePort}`, old = `http://127.0.0.1:${gui.port}`, file = join(dir, "remote.json");
202
202
  let stale: string | undefined;
@@ -209,10 +209,11 @@ export async function switchRemote(gui: { port: number; remotePort: number; toke
209
209
  const was = gui.access.remote;
210
210
  gui.access.disable(stop);
211
211
  const id = was ?? (stale ? await identity(run).catch(() => undefined) : undefined);
212
- if (!id) return { text: "Remote access is off", changed: false };
212
+ if (!id) return { text: "Remote access is off", plain: "Remote access is off", changed: false };
213
213
  const left = { remains: false }, removed = await serveOff(run, id.host, [was && target, stale, old], left);
214
214
  if (removed || !left.remains) rmSync(file, { force: true }); // an entry of ours under another host (the identity changed) is still to remove: its record stays
215
- return { text: removed ? `Remote access is off; the Tailscale serve entry on ${PORT} is removed` : `Remote access is off; the ${PORT} entry no longer pointed here, so it was left alone`, changed: true };
215
+ const text = removed ? `Remote access is off; the Tailscale serve entry on ${PORT} is removed` : `Remote access is off; the ${PORT} entry no longer pointed here, so it was left alone`;
216
+ return { text, plain: text, changed: true };
216
217
  }
217
218
  const id = await identity(run);
218
219
  await serveOn(run, id.host, target, [stale, old]);
@@ -221,5 +222,6 @@ export async function switchRemote(gui: { port: number; remotePort: number; toke
221
222
  // Every link is a new one-time pairing URL: the QR's own, and one per press of c or o in the overlay.
222
223
  const link = () => `https://${id.host}/${gui.token}/pair/${gui.access.pair()}`;
223
224
  const lines = [...qrLines(qr(link())), "", "Scan with a device signed in to Tailscale as", id.login, "", "c copy a new one-time link", "o open one in this computer's browser", "Esc or q close"];
224
- return { text: `Remote access is on for ${id.login}, tailnet only: https://${id.host}/${gui.token}/ (scan the QR; it works once, within ten minutes)`, lines, link, changed: true };
225
+ const plain = `Remote access is on for ${id.login}, tailnet only: https://${id.host}/${gui.token}/`; // `plain`: for a server that starts again, where no QR is shown
226
+ return { text: `${plain} (scan the QR; it works once, within ten minutes)`, plain, lines, link, changed: true };
225
227
  }
@@ -1,7 +1,6 @@
1
1
  import { createConnection, type Socket } from "node:net";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
2
  import { StringDecoder } from "node:string_decoder";
3
+ import { herdrSocket } from "./herdr.ts";
5
4
 
6
5
  /** Starts watching the Herdr server; `onTakeover` runs each time a server answers again after the one watched went away. Returns the stop function. */
7
6
  export type HerdrWatch = (onTakeover: () => void) => () => void;
@@ -16,7 +15,7 @@ const ACK_MS = 5000;
16
15
  * acknowledges the subscription it calls `onTakeover` once. The first connection of a watch, which finds the server the session started in, does not.
17
16
  * The socket and the retry timer never keep pi alive; `stop` ends both.
18
17
  */
19
- export const herdrWatch = (socketPath = process.env.HERDR_SOCKET_PATH || join(homedir(), ".config", "herdr", "herdr.sock"), retryMs = RETRY_MS): HerdrWatch => (onTakeover) => {
18
+ export const herdrWatch = (socketPath = herdrSocket(), retryMs = RETRY_MS): HerdrWatch => (onTakeover) => {
20
19
  let socket: Socket | undefined, timer: ReturnType<typeof setTimeout> | undefined, stopped = false, initial = true, retries = 0;
21
20
  const attempt = () => {
22
21
  const first = initial, text = new StringDecoder("utf8");
@@ -61,7 +60,7 @@ export const herdrWatch = (socketPath = process.env.HERDR_SOCKET_PATH || join(ho
61
60
  export type HerdrEvents = (types: string[], onEvent: (event: { event: string; data: Record<string, unknown> } | null) => void) => () => void;
62
61
 
63
62
  /** One `events.subscribe` connection that reconnects like the watch above (0.25 s, 0.5 s, 1 s, then every 2 s) and never keeps pi alive. */
64
- export const herdrEvents = (socketPath = process.env.HERDR_SOCKET_PATH || join(homedir(), ".config", "herdr", "herdr.sock"), retryMs = RETRY_MS): HerdrEvents => (types, onEvent) => {
63
+ export const herdrEvents = (socketPath = herdrSocket(), retryMs = RETRY_MS): HerdrEvents => (types, onEvent) => {
65
64
  let socket: Socket | undefined, timer: ReturnType<typeof setTimeout> | undefined, stopped = false, retries = 0;
66
65
  const attempt = () => {
67
66
  const text = new StringDecoder("utf8");
package/src/session.ts CHANGED
@@ -2,7 +2,7 @@ import type { Briefing } from "./briefing.ts";
2
2
  import type { Digest } from "./digest.ts";
3
3
  import type { Guard } from "./guard.ts";
4
4
  import { answers, newer, readRecord } from "./handover.ts";
5
- import { isSub, panes, place, report, waitFor, workspaces } from "./herdr.ts";
5
+ import { herdrSocket, isSub, panes, place, report, waitFor, workspaces } from "./herdr.ts";
6
6
  import type { createHost } from "./host.ts";
7
7
  import { CONNECT_MS, type Membership } from "./membership.ts";
8
8
  import { orphans } from "./orphan.ts";
@@ -64,6 +64,7 @@ export function registerSession(kit: Kit, { briefing, presence, membership, guar
64
64
  stopWatching();
65
65
  nudges = orphans({ run, file: () => log.getSessionFile(), entries: () => log.getEntries(), sessions: host.sessions, send: reach.send, type: ENTRY, remember: (key) => pi.appendEntry(ENTRY, { nudged: key }), me, tell });
66
66
  nudgeTimer = setInterval(() => void nudges?.poll().catch(() => {}), cfg.nudgeMs).unref();
67
+ host.watch(); // the server of a session that ended without a stop is taken over here (host.ts)
67
68
  // Starting runs in the background: pi awaits session_start handlers in turn, and pi-intercom connects in its own, after this one.
68
69
  let starting: Promise<void> | undefined;
69
70
  if (s.isTower) {
@@ -85,12 +86,13 @@ export function registerSession(kit: Kit, { briefing, presence, membership, guar
85
86
  }
86
87
  starting?.catch((error: Error) => tell(`Straw Boss: ${error.message}`, "error"));
87
88
  // The GUI this process ran before a /reload, /new, or /resume starts again on its URL, and a session newer than the running server
88
- // takes it over, once pi-intercom has published this session's id: the server's dialogs are routed by it.
89
+ // takes it over (one with a screen: a print run is no host, and one on the Herdr the server serves), once pi-intercom has published this session's id: the server's dialogs are routed by it.
90
+ // Under gui.lock the restore keeps a stop typed while it waited, and a takeover does not start a server that went down meanwhile: a survivor adopts that.
89
91
  // A /new, /resume, or /fork is another session: it waits for the new id, not the one pi-intercom may still show from before.
90
- const record = readRecord(guiDir), back = record?.reload && record.pid === process.pid && event.reason !== "startup";
92
+ const record = readRecord(guiDir), back = record?.reload && !record.stopped && record.pid === process.pid && event.reason !== "startup";
91
93
  const ready = () => Boolean(me()) && (!back || event.reason === "reload" || me() !== record!.ownerSession);
92
- if (back || (record && newer(version, record.version))) void (async () => {
93
- if ((await waitFor(cfg.sleep, ready, CONNECT_MS)) && (back || (await answers(record!)))) await host.restore(Boolean(back && record!.remote));
94
+ if (back || (record && ctx.hasUI && newer(version, record.version) && (!record.herdr || record.herdr === herdrSocket(env)))) void (async () => {
95
+ if ((await waitFor(cfg.sleep, ready, CONNECT_MS)) && (back || (await answers(record!)))) await host.restore(Boolean(back && record!.remote), back ? { stopped: true } : { stopped: true, down: true });
94
96
  })().catch(() => {});
95
97
  });
96
98
 
package/src/stashing.ts CHANGED
@@ -11,6 +11,8 @@ import type { Kit } from "./state.ts";
11
11
 
12
12
  // The checks take up to three rounds of a few seconds, then every agent has 30 s to quit.
13
13
  const STASH_MS = 120_000;
14
+ // Both need the server, and start it when none runs, unless it was stopped on purpose: only /boss gui starts that one.
15
+ const STOPPED = "The straw net GUI was stopped on purpose; /boss gui starts it, then run this again";
14
16
 
15
17
  export function createStashing({ cfg, s, guiDir, tell, me }: Kit, { host }: { host: Pick<ReturnType<typeof createHost>, "share" | "sessions"> }) {
16
18
  const { run, sleep } = cfg;
@@ -22,7 +24,8 @@ export function createStashing({ cfg, s, guiDir, tell, me }: Kit, { host }: { ho
22
24
  const ws = s.at.workspaceId, label = (await workspaces(run)).find((w) => w.workspace_id === ws)?.label ?? ws, name = given?.trim() || nameFor(label, guiDir);
23
25
  if (!nameOk(name)) throw new Error(`"${name}" cannot name a stash: at most 64 characters, no slash, backslash, or control character, not starting with a dot`);
24
26
  if (existsSync(stashFile(guiDir, name))) throw new Error(`Something is stashed as "${name}" already; pop it or pick another name`);
25
- const shared = await host.share();
27
+ const shared = await host.share(false, false, false, { stopped: true });
28
+ if (shared.stopped) throw new Error(STOPPED);
26
29
  const record = readRecord(guiDir);
27
30
  if (shared.stale || !record) throw new Error(`The straw net GUI runs in session ${shared.owner} on a version that cannot stash; /reload that session first`);
28
31
  tell(`Stashing ${label} as "${name}"…`);
@@ -33,7 +36,8 @@ export function createStashing({ cfg, s, guiDir, tell, me }: Kit, { host }: { ho
33
36
  },
34
37
  /** Quits this base's tower, mains, and members and closes it, or nothing when anything there would be lost. */
35
38
  async closeAll() {
36
- const shared = await host.share();
39
+ const shared = await host.share(false, false, false, { stopped: true });
40
+ if (shared.stopped) throw new Error(STOPPED);
37
41
  const record = readRecord(guiDir);
38
42
  if (shared.stale || !record) throw new Error(`The straw net GUI runs in session ${shared.owner} on a version that cannot close all; /reload that session first`);
39
43
  tell("Closing this base…");
package/src/state.ts CHANGED
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import type { startDashboard } from "./dashboard.ts";
5
5
  import type { Hub } from "./dialogs.ts";
6
6
  import type { Gui } from "./gui.ts";
7
+ import type { GuiRecord } from "./handover.ts";
7
8
  import type { HerdrApi, HerdrCall, HerdrRun, HerdrWorld, Place, Session, Sleep } from "./herdr.ts";
8
9
  import type { Run } from "./remote.ts";
9
10
  import type { HerdrEvents, HerdrWatch } from "./server-watch.ts";
@@ -26,8 +27,11 @@ export interface SendResult { requestId?: string; status: string; code?: string;
26
27
  export interface Saved { tower?: boolean }
27
28
  export type Role = "tower" | "main" | "member";
28
29
  export type Scope = "tab" | "workspace" | "towers" | "all";
29
- /** `run` is the CLI for every command; left out, Herdr's listings go over the socket instead (`socketReads`) and `world` is one `session.snapshot`. `world` is the dashboard's and the GUI's reading of panes and workspaces. */
30
- export interface Deps { run?: HerdrRun; world?: HerdrWorld; api?: HerdrApi; call?: HerdrCall; tailscale?: Run; copy?: (text: string) => Promise<void>; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string; bundle?: string; openUrl?: (url: string) => Promise<void>; watch?: HerdrWatch; stateDir?: string; autoTower?: boolean; nudgeMs?: number; version?: string; guardMs?: number; couple?: boolean; events?: HerdrEvents; detectMs?: number; now?: () => number; later?: (pid: number, args: string[]) => void }
30
+ /**
31
+ * `run` is the CLI for every command; left out, Herdr's listings go over the socket instead (`socketReads`) and `world` is one `session.snapshot`. `world` is the dashboard's and the GUI's reading of panes and workspaces.
32
+ * `gone` says whether the process that recorded a GUI server has ended for good (handover.ts `ownerGone`), which a survivor needs before it takes the server over; `adoptMs` is how often a session looks.
33
+ */
34
+ export interface Deps { run?: HerdrRun; world?: HerdrWorld; api?: HerdrApi; call?: HerdrCall; tailscale?: Run; copy?: (text: string) => Promise<void>; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string; bundle?: string; openUrl?: (url: string) => Promise<void>; watch?: HerdrWatch; stateDir?: string; autoTower?: boolean; nudgeMs?: number; version?: string; guardMs?: number; couple?: boolean; events?: HerdrEvents; detectMs?: number; now?: () => number; later?: (pid: number, args: string[]) => void; gone?: (record: GuiRecord, wrote: number) => boolean; adoptMs?: number }
31
35
  /** The dependencies with their defaults filled in. */
32
36
  export type Config = Required<Omit<Deps, "bundle" | "stateDir">> & Pick<Deps, "bundle" | "stateDir">;
33
37