pi-straw-boss 0.40.1 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,7 @@ pi install npm:pi-straw-boss
50
50
 
51
51
  - pi running inside [Herdr](https://herdr.dev) 0.9.4 or newer.
52
52
  - [pi-intercom](https://www.npmjs.com/package/pi-intercom) 0.17.1 or newer: the net's channels, messages, and the GUI's dialog relay run over it.
53
+ - Windows support is experimental: the extension reaches Herdr over its named pipe there, the sidebar plugin runs with the `node` on `PATH`, and the worktree reaper is macOS and Linux only ([what differs](docs/how-it-works.md#windows)).
53
54
 
54
55
  ### Works with
55
56
 
@@ -126,6 +127,7 @@ command = "wayne930242.straw-boss.net"
126
127
  description = "toggle the straw net view"
127
128
  ```
128
129
 
130
+ On Windows the action is `wayne930242.straw-boss.net-windows` (Herdr wants an action id unique across platforms).
129
131
  Run `herdr server reload-config` to load the key, and `herdr plugin unlink wayne930242.straw-boss` to remove the plugin.
130
132
  `herdr plugin install wayne930242/pi-straw-boss/herdr-plugin` installs it from GitHub instead of a checkout.
131
133
  [How it works](docs/how-it-works.md#sidebar-net-view) has the tokens and a sidebar rows snippet that shows each member's role in Herdr's own view too.
@@ -148,6 +150,7 @@ A pinned workspace is never reaped and its checkout is never removed; that is al
148
150
  Renaming a workspace pins it at once, and the `pin` action toggles the pin of the workspace it runs in; unpinning leaves the name as it is.
149
151
  The pin is the workspace token `$pin`, a Nerd Font pin glyph, which a Space row shows once you add it.
150
152
  It needs Herdr 0.9.3 or newer, git and node 18 or newer.
153
+ It runs on macOS and Linux only: on Windows, Herdr does not report what a pane's shell is running, so the reaper could not tell an idle pane from one running a build or an editor.
151
154
 
152
155
  ```bash
153
156
  herdr plugin install wayne930242/pi-straw-boss/herdr-reaper
package/README.zh-TW.md CHANGED
@@ -50,6 +50,7 @@ pi install npm:pi-straw-boss
50
50
 
51
51
  - pi 要跑在 [Herdr](https://herdr.dev) 0.9.4 以上。
52
52
  - [pi-intercom](https://www.npmjs.com/package/pi-intercom) 0.17.1 以上:網的頻道、訊息,以及 GUI 轉送對話框,都是透過它。
53
+ - Windows 支援是實驗性的:擴充套件在 Windows 上透過 Herdr 的 named pipe 連線,側邊欄 plugin 用 `PATH` 上的 `node` 執行,worktree reaper 則只支援 macOS 和 Linux([差異在哪](docs/how-it-works.md#windows),英文)。
53
54
 
54
55
  ### 支援的套件
55
56
 
@@ -106,6 +107,7 @@ pi install npm:pi-straw-boss
106
107
  Herdr server 啟動時這個檢視就會打開,按一個鍵可以關掉,再按一次打開。
107
108
  pi-straw-boss 會寫入排序鍵(`sb_order`)、角色標記(`sb_mark`),子智慧體另外有樹狀分支(`sb_indent`)和它的名稱(`sb_name`),網路檢視和你自己的側邊欄列設定都可以用。
108
109
  需要 Herdr 0.9.3 以上和 node。
110
+ 在 Windows 上,切換動作是 `wayne930242.straw-boss.net-windows`(Herdr 要求動作 id 在各平台之間不能重複)。
109
111
 
110
112
  <p align="center"><img src="docs/sidebar-net-view.png" width="308" alt="Herdr 側邊欄的網路檢視:每個工作區的塔台排第一,前面是訊號塔圖示;每個主智慧體佔兩列,有星號和它的分頁名稱;每個子智慧體以樹狀分支排在開出它的 session 底下"></p>
111
113
 
@@ -147,6 +149,7 @@ Herdr 為了把 worktree 歸在底下而替主 checkout 開了一個純 shell
147
149
  工作區一改名就會自動釘選;`pin` 動作會切換它所在工作區的釘選,取消釘選不會動到名稱。
148
150
  釘選是工作區的 token `$pin`,內容是 Nerd Font 的圖釘字形,要加進 Space 的列設定才看得到。
149
151
  需要 Herdr 0.9.3 以上、git 和 node 18 以上。
152
+ 它只支援 macOS 和 Linux:在 Windows 上,Herdr 不會回報 pane 的 shell 底下在跑什麼,reaper 分不出閒置的 pane 和正在跑 build 或編輯器的 pane。
150
153
 
151
154
  ```bash
152
155
  herdr plugin install wayne930242/pi-straw-boss/herdr-reaper
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.40.1",
3
+ "version": "0.41.1",
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/agent/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn as spawnProcess } from "node:child_process";
2
2
  import { closeSync, mkdirSync, openSync, realpathSync, statSync, truncateSync } from "node:fs";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
- import { agentFiles, ask, findIntercom, holder, NoSidecar, type Reply, stateDirOf, tsxLoader } from "./paths.ts";
4
+ import { agentFiles, ask, findIntercom, holder, NoSidecar, readHeld, type Reply, socketOf, stateDirOf, tsxLoader } from "./paths.ts";
5
5
  import { startSidecar } from "./sidecar.ts";
6
6
 
7
7
  // `cli.ts` is what straw-boss runs: `join` at a session's start (it starts the sidecar of the pane, detached), and the commands of straw-net.py, each of which
@@ -66,7 +66,7 @@ const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
66
66
  const detached = ({ command, args, env, cwd, log }: SpawnSpec) => {
67
67
  try { if (statSync(log).size > LOG_MAX) truncateSync(log); } catch { /* no log yet */ }
68
68
  const fd = openSync(log, "a", 0o600);
69
- const child = spawnProcess(command, args, { detached: true, stdio: ["ignore", fd, fd], env, cwd });
69
+ const child = spawnProcess(command, args, { detached: true, stdio: ["ignore", fd, fd], env, cwd, windowsHide: true });
70
70
  closeSync(fd);
71
71
  child.unref();
72
72
  return { exited: new Promise<number | null>((resolve) => child.once("exit", (code) => resolve(code))) };
@@ -92,7 +92,7 @@ export async function run(argv: string[], deps: CliDeps = {}): Promise<number> {
92
92
  const { command, pane: _pane, ...request } = args;
93
93
  let reply: Reply;
94
94
  try {
95
- reply = await ask(files.socket, { cmd: command, ...Object.fromEntries(Object.entries(request).filter(([, value]) => value !== undefined)) }, deps.askMs ?? ASK_MS[command] ?? ASK_MS.default);
95
+ reply = await ask(socketOf(files, readHeld(files)), { cmd: command, ...Object.fromEntries(Object.entries(request).filter(([, value]) => value !== undefined)) }, deps.askMs ?? ASK_MS[command] ?? ASK_MS.default);
96
96
  } catch (error) {
97
97
  // Nothing listening is no sidecar. A sidecar that was reached and did not answer in time may still carry the request out: it is not "no sidecar", so a caller does not send it twice.
98
98
  if (error instanceof NoSidecar) return (err(`no net sidecar for pane ${args.pane}: it joins when the session starts, if pi-straw-boss and pi-intercom are installed\n`), 2);
@@ -109,7 +109,7 @@ export async function run(argv: string[], deps: CliDeps = {}): Promise<number> {
109
109
  if (old && old.held.session === a.session && old.state !== "leaving") return (out(`already joined as ${old.held.id}\n`), 0);
110
110
  if (old) {
111
111
  // Another session in the pane (a /clear, or a resume), or this one on its way out: the sidecar of the one before leaves first, so the pane never has two.
112
- if (old.state !== "leaving") await ask(files.socket, { cmd: "stop" }, 15_000).catch(() => {});
112
+ if (old.state !== "leaving") await ask(socketOf(files, old.held), { cmd: "stop" }, 15_000).catch(() => {});
113
113
  if (!(await until(async () => !(await holder(files)), deps.stopMs ?? 10_000))) {
114
114
  // Only a process that answered on the sidecar's own socket is signalled: a pid in a state file may belong to anything by now.
115
115
  const stuck = await holder(files);
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { createConnection } from "node:net";
4
4
  import { createRequire } from "node:module";
@@ -16,10 +16,18 @@ export function agentFiles(stateDir: string, pane: string) {
16
16
  const base = join(stateDir, "agents", createHash("sha256").update(pane).digest("hex").slice(0, 16));
17
17
  return { dir: dirname(base), state: `${base}.json`, socket: `${base}.sock`, log: `${base}.log` };
18
18
  }
19
+ /**
20
+ * Whether the control socket is the file `files.socket` (removed and made mode 600). On Windows it is a named pipe instead, in a namespace every account shares:
21
+ * each sidecar names its own at random and records it in its state file (`Held.pipe`), which other accounts cannot read, so none can take the name first.
22
+ */
23
+ export const socketIsFile = (platform: NodeJS.Platform = process.platform) => platform !== "win32";
24
+ export const newPipe = () => `\\\\.\\pipe\\straw-boss-${randomBytes(16).toString("hex")}`;
25
+ /** Where the sidecar that wrote `held` answers. */
26
+ export const socketOf = (files: AgentFiles, held: Held | undefined) => (typeof held?.pipe === "string" ? held.pipe : files.socket);
19
27
  export type AgentFiles = ReturnType<typeof agentFiles>;
20
28
 
21
29
  /** What a running sidecar writes first: who holds the pane. */
22
- export interface Held { pid: number; session: string; kind: string; pane: string; id: string; name: string; startedAt: number }
30
+ export interface Held { pid: number; session: string; kind: string; pane: string; id: string; name: string; startedAt: number; pipe?: string }
23
31
 
24
32
  /** Whether `pid` is a live process of this user: a sidecar is, so EPERM (another user's process) is not one of ours. */
25
33
  export const alive = (pid: unknown) => {
@@ -58,7 +66,7 @@ export interface Holder { held: Held; state: "up" | "starting" | "leaving" }
58
66
  */
59
67
  export async function judge(files: AgentFiles, held: Held | undefined, now: () => number = Date.now): Promise<Holder | undefined> {
60
68
  if (!held) return undefined;
61
- const reply = await ask(files.socket, { cmd: "hello" }, 3000).catch(() => undefined);
69
+ const reply = await ask(socketOf(files, held), { cmd: "hello" }, 3000).catch(() => undefined);
62
70
  if (reply?.ok && reply.pid === held.pid && reply.session === held.session) return { held, state: reply.leaving ? "leaving" : "up" };
63
71
  return alive(held.pid) && now() - held.startedAt < STARTING_MS ? { held, state: "starting" } : undefined;
64
72
  }
@@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url";
4
4
  import { createDigest } from "../digest.ts";
5
5
  import { createElection } from "../election.ts";
6
6
  import { createGuard, GUARD_MS } from "../guard.ts";
7
- import { herdrApi, herdrCall, herdrRun, herdrWorld, isSub, place, socketReads, worldOf, panes, workspaces } from "../herdr.ts";
7
+ import { herdrApi, herdrCall, herdrRun, herdrWorld, isSub, place, socketRun, worldOf, panes, workspaces } from "../herdr.ts";
8
8
  import type { Briefing } from "../briefing.ts";
9
9
  import { createMembership } from "../membership.ts";
10
10
  import { createPresence } from "../presence.ts";
@@ -13,7 +13,7 @@ import { herdrEvents, herdrWatch } from "../server-watch.ts";
13
13
  import type { Config, Deps, Scope } from "../state.ts";
14
14
  import { type Incoming, type IntercomLike, createHeadlessKit } from "./kit.ts";
15
15
  import { claim } from "./claim.ts";
16
- import { agentFiles, findIntercom, type Held, readHeld, type Reply, stateDirOf } from "./paths.ts";
16
+ import { agentFiles, findIntercom, type Held, newPipe, readHeld, type Reply, socketIsFile, stateDirOf } from "./paths.ts";
17
17
 
18
18
  // The net presence of one Claude Code or Codex pane (docs/specs/2026-10-23-claude-codex-net): it registers with the pi-intercom broker as a session, joins the tab's
19
19
  // net through the same modules a pi session uses (membership, election, guard, presence, reach, digest), types the messages sent to it into its pane, and
@@ -57,7 +57,7 @@ const piOwn = (key: string) => /^PI_(INTERCOM|SUBAGENT)_/.test(key) || key === "
57
57
 
58
58
  /** The configuration of a pi session, with what only the GUI and the tower's host use left unavailable. */
59
59
  function agentConfig(d: AgentDeps, env: NodeJS.ProcessEnv): Config {
60
- const call = d.call ?? herdrCall(), run = d.run ?? socketReads(herdrRun, call);
60
+ const call = d.call ?? herdrCall(), run = d.run ?? socketRun(herdrRun, call);
61
61
  return {
62
62
  run, world: d.world ?? (d.run ? worldOf(run) : herdrWorld(call)), api: d.api ?? herdrApi(), call, env, stateDir: d.stateDir,
63
63
  sleep: d.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))), now: d.now ?? Date.now,
@@ -89,7 +89,8 @@ export async function startSidecar({ pane, kind, session, record, cli = "straw-n
89
89
  // One sidecar per pane: the state file is claimed exclusively. A holder is a process that answers on the pane's socket (a pid alone is not one), or one that is young and has not opened it yet.
90
90
  mkdirSync(files.dir, { recursive: true, mode: 0o700 });
91
91
  chmodSync(files.dir, 0o700);
92
- const mine: Held = { pid: process.pid, session, kind, pane, id, name, startedAt: now() };
92
+ const socket = socketIsFile() ? files.socket : newPipe();
93
+ const mine: Held = { pid: process.pid, session, kind, pane, id, name, startedAt: now(), ...(socketIsFile() ? {} : { pipe: socket }) };
93
94
  await claim(files, mine, pane, now);
94
95
 
95
96
  const kit = createHeadlessKit({ client: undefined as unknown as IntercomLike, env, cfg, log: (text, level) => log(level && level !== "info" ? `${level}: ${text}` : text) });
@@ -124,7 +125,7 @@ export async function startSidecar({ pane, kind, session, record, cli = "straw-n
124
125
  if (s.joined && !(await foreign())) await presence.clear().catch(() => {});
125
126
  await client?.disconnect().catch(() => {});
126
127
  server.close();
127
- rmSync(files.socket, { force: true });
128
+ if (socketIsFile()) rmSync(socket, { force: true });
128
129
  // Last, and only this sidecar's own file: a sidecar for the pane can claim it the moment it is gone.
129
130
  const left = readHeld(files);
130
131
  if (left?.pid === process.pid && left.session === session) rmSync(files.state, { force: true });
@@ -317,9 +318,10 @@ export async function startSidecar({ pane, kind, session, record, cli = "straw-n
317
318
  });
318
319
  });
319
320
  await new Promise<void>((resolve, reject) => {
320
- if (existsSync(files.socket)) unlinkSync(files.socket);
321
+ if (socketIsFile() && existsSync(socket)) unlinkSync(socket);
321
322
  server.once("error", reject);
322
- server.listen(files.socket, () => (server.off("error", reject), chmodSync(files.socket, 0o600), resolve()));
323
+ // A named pipe's default access lets only its creator, administrators, and SYSTEM write to it or make another instance of it.
324
+ server.listen(socket, () => (server.off("error", reject), socketIsFile() && chmodSync(socket, 0o600), resolve()));
323
325
  }).catch(async (error) => (await leave("the control socket could not listen"), Promise.reject(error)));
324
326
 
325
327
  // The join: Herdr must say the pane is not in a sub workspace, then the broker, then the tab.
package/src/closing.ts CHANGED
@@ -1,11 +1,17 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import type { Kit } from "./state.ts";
3
3
 
4
- /** Closes a pane once the pi process `pid` has exited, from a process of its own, so pi's shutdown runs to the end first. */
4
+ /**
5
+ * Closes a pane once the pi process `pid` has exited (at most 60 s), from a process of its own, so pi's shutdown runs to the end first. Windows has no sh, so there
6
+ * the wait runs in node (pi runs on node there; a Bun-built pi would read `-e` as its own flag, so sh stays the waiter elsewhere).
7
+ */
5
8
  export const afterExit = (pid: number, args: string[]) => {
6
9
  const herdr = process.env.HERDR_BIN_PATH || "herdr";
7
- const script = `i=0; while kill -0 ${pid} 2>/dev/null && [ $i -lt 120 ]; do sleep 0.5; i=$((i+1)); done; exec "$0" "$@"`;
8
- spawn("sh", ["-c", script, herdr, ...args], { detached: true, stdio: "ignore" }).unref();
10
+ const sh = `i=0; while kill -0 ${pid} 2>/dev/null && [ $i -lt 120 ]; do sleep 0.5; i=$((i+1)); done; exec "$0" "$@"`;
11
+ const node = `const [bin, ...args] = process.argv.slice(1); let i = 0; const alive = () => { try { return process.kill(${pid}, 0); } catch (e) { return e.code === "EPERM"; } };
12
+ const t = setInterval(() => { if (alive() && ++i < 120) return; clearInterval(t); require("node:child_process").spawn(bin, args, { stdio: "ignore", windowsHide: true }).on("error", () => {}); }, 500);`;
13
+ const [cmd, pre] = process.platform === "win32" ? [process.execPath, ["-e", node]] : ["sh", ["-c", sh]];
14
+ spawn(cmd, [...pre, herdr, ...args], { detached: true, stdio: "ignore", windowsHide: true }).on("error", () => {}).unref();
9
15
  };
10
16
 
11
17
  /**
@@ -0,0 +1,24 @@
1
+ import { homedir, tmpdir } from "node:os";
2
+ import { posix, win32 } from "node:path";
3
+
4
+ // Where a local socket is on each platform: Herdr's, found as Herdr finds it, and the endpoint a client connects to for a socket path.
5
+
6
+ /**
7
+ * The socket of the Herdr server a process runs under, resolved as Herdr resolves it: `HERDR_SOCKET_PATH`, else `HERDR_SESSION`'s socket, else the default
8
+ * one in Herdr's config directory (`XDG_CONFIG_HOME`, else `%APPDATA%` on Windows and `~/.config` elsewhere). Two sessions are on the same Herdr when this is the same text.
9
+ */
10
+ export function herdrSocket(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string {
11
+ if (env.HERDR_SOCKET_PATH) return env.HERDR_SOCKET_PATH;
12
+ const path = platform === "win32" ? win32 : posix, home = env.HOME || homedir();
13
+ const config = env.XDG_CONFIG_HOME ? path.join(env.XDG_CONFIG_HOME, "herdr")
14
+ : platform !== "win32" ? path.join(home, ".config", "herdr")
15
+ : env.APPDATA ? path.join(env.APPDATA, "herdr") : env.USERPROFILE ? path.join(env.USERPROFILE, "AppData", "Roaming", "herdr") : env.HOME ? path.join(env.HOME, ".config", "herdr") : path.join(tmpdir(), "herdr");
16
+ // Herdr ignores a session name it would reject (and `default`, the default session).
17
+ const session = env.HERDR_SESSION && env.HERDR_SESSION !== "default" && /^[A-Za-z0-9._-]{1,64}$/.test(env.HERDR_SESSION) && ![".", ".."].includes(env.HERDR_SESSION) ? env.HERDR_SESSION : undefined;
18
+ return session ? path.join(config, "sessions", session, "herdr.sock") : path.join(config, "herdr.sock");
19
+ }
20
+ /**
21
+ * Where a client connects for Herdr's socket path. On Windows the path is a marker file, and Herdr listens on the named pipe `\\.\pipe\` + that path
22
+ * (a sidecar's pipe is a full pipe name already, and does not go through here). Elsewhere it is the Unix socket at the path.
23
+ */
24
+ export const localEndpoint = (socketPath: string, platform: NodeJS.Platform = process.platform) => (platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath);
package/src/herdr.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { createConnection } from "node:net";
4
- import { homedir } from "node:os";
5
4
  import { join } from "node:path";
6
5
  import { StringDecoder } from "node:string_decoder";
6
+ import { herdrSocket, localEndpoint } from "./endpoint.ts";
7
+ export { herdrSocket } from "./endpoint.ts"; // session.ts reads it here, with the rest of Herdr
7
8
 
8
9
  export type HerdrRun = (args: string[], timeoutMs?: number) => Promise<string>;
9
10
  /** One request on Herdr's socket API; rejects with the error Herdr answered. */
@@ -83,11 +84,9 @@ export async function panes(run: HerdrRun, workspace?: string): Promise<Pane[]>
83
84
  }
84
85
 
85
86
  // Herdr answers one request per connection with one newline-ended frame, which may arrive in pieces (split anywhere, even inside a character).
86
- /** 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. */
87
- export const herdrSocket = (env: NodeJS.ProcessEnv = process.env) => env.HERDR_SOCKET_PATH || join(homedir(), ".config", "herdr", "herdr.sock");
88
87
  export const herdrCall = (socketPath = herdrSocket()): HerdrCall => (method, params, timeoutMs = 5000) =>
89
88
  new Promise((resolve, reject) => {
90
- const socket = createConnection(socketPath).setTimeout(timeoutMs), text = new StringDecoder("utf8");
89
+ const socket = createConnection(localEndpoint(socketPath)).setTimeout(timeoutMs), text = new StringDecoder("utf8");
91
90
  let reply = "";
92
91
  const fail = (code: string, message: string) => (socket.destroy(), reject(new HerdrError(code, `herdr ${method} failed: ${code} ${message}`.trim())));
93
92
  socket.on("connect", () => socket.write(`${JSON.stringify({ id: `straw-boss-${Date.now()}`, method, params })}\n`));
@@ -119,13 +118,37 @@ function listing(args: string[]): { method: string; params: Record<string, unkno
119
118
  return undefined;
120
119
  }
121
120
 
121
+ /** `pane current` as the CLI asks it, for the pane the process runs in; without HERDR_PANE_ID the CLI's own answer is left to it. */
122
+ function current(args: string[], pane: string | undefined): { method: string; params: Record<string, unknown> } | undefined {
123
+ return args.length === 2 && args[0] === "pane" && args[1] === "current" && pane ? { method: "pane.current", params: { caller_pane_id: pane } } : undefined;
124
+ }
125
+
126
+ /** `<pane|workspace> report-metadata <id> --source S [--ttl-ms N] [--token k=v]... [--clear-token k]...` as its socket request; anything else is left to the CLI. */
127
+ function metadata(args: string[]): { method: string; params: Record<string, unknown> } | undefined {
128
+ const [kind, cmd, id, ...flags] = args;
129
+ if ((kind !== "pane" && kind !== "workspace") || cmd !== "report-metadata" || !id || flags.length % 2) return undefined;
130
+ const tokens: Record<string, string | null> = {};
131
+ let source: string | undefined, ttl: number | undefined;
132
+ for (let i = 0; i < flags.length; i += 2) {
133
+ const [flag, value] = [flags[i]!, flags[i + 1]!], at = value.indexOf("=");
134
+ if (flag === "--source") source = value;
135
+ else if (flag === "--ttl-ms" && /^\d+$/.test(value)) ttl = Number(value);
136
+ else if (flag === "--token" && at > 0) tokens[value.slice(0, at)] = value.slice(at + 1);
137
+ else if (flag === "--clear-token") tokens[value] = null;
138
+ else return undefined;
139
+ }
140
+ if (!source) return undefined;
141
+ return { method: `${kind}.report_metadata`, params: { [kind === "pane" ? "pane_id" : "workspace_id"]: id, source, tokens, ...(ttl === undefined ? {} : { ttl_ms: ttl }) } };
142
+ }
143
+
122
144
  /**
123
- * `run` with Herdr's listings (`pane list`, `workspace list`, `tab list --workspace`) answered over the socket instead of by a spawned CLI:
124
- * the same `{result}` text, so every reader works unchanged. Every other command, the writes included, still goes to `run`.
125
- * A listing that fails rejects with Herdr's own error (a HerdrError with its code); nothing falls back to the CLI.
145
+ * `run` with Herdr's listings (`pane list`, `workspace list`, `tab list --workspace`), `pane current`, and `report-metadata` answered over the socket
146
+ * instead of by a spawned CLI: the same `{result}` text, so every caller works unchanged. A spawned CLI costs a process start on every call, which a
147
+ * loaded machine turns into seconds, and these are the calls every session makes as it starts and on its timers. Every other command still goes to `run`.
148
+ * A request that fails rejects with Herdr's own error (a HerdrError with its code); nothing falls back to the CLI.
126
149
  */
127
- export const socketReads = (run: HerdrRun, call: HerdrCall): HerdrRun => async (args, timeoutMs) => {
128
- const request = listing(args);
150
+ export const socketRun = (run: HerdrRun, call: HerdrCall, pane = process.env.HERDR_PANE_ID): HerdrRun => async (args, timeoutMs) => {
151
+ const request = listing(args) ?? current(args, pane) ?? metadata(args);
129
152
  return request ? JSON.stringify({ result: await call(request.method, request.params, timeoutMs) }) : run(args, timeoutMs);
130
153
  };
131
154
 
package/src/host.ts CHANGED
@@ -6,7 +6,7 @@ import { createCloser } from "./closer.ts";
6
6
  import { createHub } from "./dialogs.ts";
7
7
  import { type Gui, startGui } from "./gui.ts";
8
8
  import { adoptable, adoptGui, downgrades, type Keep, markReload, markStopped, readRecord, type Shared, sharedGui, stopRecorded } from "./handover.ts";
9
- import { herdrSocket } from "./herdr.ts";
9
+ import { herdrSocket } from "./endpoint.ts";
10
10
  import type { Membership } from "./membership.ts";
11
11
  import { netSource } from "./net.ts";
12
12
  import { pairingKeys, showLines } from "./pairing.ts";
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ import { openBrowser, VERSION } from "./gui.ts";
13
13
  import { ownerGone } from "./handover.ts";
14
14
  import { copyText } from "./pairing.ts";
15
15
  import { createHost } from "./host.ts";
16
- import { herdrApi, herdrCall, herdrRun, herdrWorld, socketReads, worldOf } from "./herdr.ts";
16
+ import { herdrApi, herdrCall, herdrRun, herdrWorld, socketRun, worldOf } from "./herdr.ts";
17
17
  import { createMembership } from "./membership.ts";
18
18
  import { createPresence } from "./presence.ts";
19
19
  import { createReach } from "./reach.ts";
@@ -34,7 +34,7 @@ const ADOPT_MS = 2_000;
34
34
  // The composition root: the concerns live in their own modules (state.ts says what they share), and this wires them for one pi session.
35
35
  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 = {}) {
36
36
  // A run that is not injected is the real CLI, with its listings over the socket; an injected one answers everything itself, listings included.
37
- const run = given ?? socketReads(herdrRun, call), world = reading ?? (given ? worldOf(given) : herdrWorld(call));
37
+ const run = given ?? socketRun(herdrRun, call), world = reading ?? (given ? worldOf(given) : herdrWorld(call));
38
38
  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 };
39
39
  return function strawBoss(pi: ExtensionAPI): void {
40
40
  // Outside Herdr there is no tab to coordinate. Every session in Herdr relays its dialogs to the GUI (bridge.ts), subagents too: they ask
@@ -1,6 +1,6 @@
1
1
  import { createConnection, type Socket } from "node:net";
2
2
  import { StringDecoder } from "node:string_decoder";
3
- import { herdrSocket } from "./herdr.ts";
3
+ import { herdrSocket, localEndpoint } from "./endpoint.ts";
4
4
 
5
5
  /** Starts watching the Herdr server; `onTakeover` runs each time a server answers again after the one watched went away. Returns the stop function. */
6
6
  export type HerdrWatch = (onTakeover: () => void) => () => void;
@@ -27,7 +27,7 @@ export const herdrWatch = (socketPath = herdrSocket(), retryMs = RETRY_MS): Herd
27
27
  current.destroy();
28
28
  if (!stopped) timer = setTimeout(attempt, retryMs[Math.min(retries++, retryMs.length - 1)]).unref();
29
29
  };
30
- const current = (socket = createConnection(socketPath).setTimeout(ACK_MS));
30
+ const current = (socket = createConnection(localEndpoint(socketPath)).setTimeout(ACK_MS));
31
31
  current.unref();
32
32
  current.on("connect", () => current.write(`${JSON.stringify({ id: `straw-boss-watch-${Date.now()}`, method: "events.subscribe", params: { subscriptions: [{ type: "workspace.renamed" }] } })}\n`));
33
33
  current.on("data", (chunk) => {
@@ -71,7 +71,7 @@ export const herdrEvents = (socketPath = herdrSocket(), retryMs = RETRY_MS): Her
71
71
  current.destroy();
72
72
  if (!stopped) timer = setTimeout(attempt, retryMs[Math.min(retries++, retryMs.length - 1)]).unref();
73
73
  };
74
- const current = (socket = createConnection(socketPath).setTimeout(ACK_MS));
74
+ const current = (socket = createConnection(localEndpoint(socketPath)).setTimeout(ACK_MS));
75
75
  current.unref();
76
76
  current.on("connect", () => current.write(`${JSON.stringify({ id: `straw-boss-events-${Date.now()}`, method: "events.subscribe", params: { subscriptions: types.map((type) => ({ type })) } })}\n`));
77
77
  current.on("data", (chunk) => {
package/src/session.ts CHANGED
@@ -13,6 +13,7 @@ import { ENTRY, type Kit } from "./state.ts";
13
13
 
14
14
  const KID_MARK_MS = [3_000, 15_000];
15
15
  const SUBAGENT_TOOLS = ["subagent", "subagent_resume"];
16
+ const CHECKING = "Straw Boss is still asking Herdr whether this is a sub workspace; try again in a moment";
16
17
 
17
18
  /** What the session does at its start, around each run, and at its end. */
18
19
  export function registerSession(kit: Kit, { briefing, presence, membership, guard, host, reach, digest, control, startTower }: { briefing: Briefing; presence: Presence; membership: Membership; guard: Guard; host: ReturnType<typeof createHost>; reach: Reach; digest: Digest; control: Control; startTower(): Promise<void> }) {
@@ -42,6 +43,8 @@ export function registerSession(kit: Kit, { briefing, presence, membership, guar
42
43
  };
43
44
  // A tab the tower opened for a request starts its pi with this role: it promotes itself as soon as it has joined (opener.ts).
44
45
  const opensAsMain = env.STRAW_BOSS_ROLE === "main";
46
+ // The session start in progress, and its answer to whether this session may join (true) once Herdr has said what workspace it is in.
47
+ let started: object | undefined, checked: Promise<boolean> = Promise.resolve(false);
45
48
  // STRAW_BOSS_ROLE=main stays in the new tab's shell, so a later pi started there would self-promote. The tower's one-time pane token is what
46
49
  // authorises it: this pi consumes the token and keeps the opener's marker (sb_opened_by) fresh, which the tower counts to enforce its limits.
47
50
  const claimOpened = async () => {
@@ -55,6 +58,7 @@ export function registerSession(kit: Kit, { briefing, presence, membership, guar
55
58
  };
56
59
 
57
60
  pi.on("session_start", async (event, ctx) => {
61
+ const start = (started = {});
58
62
  s.stopped = false;
59
63
  s.ui = ctx.ui;
60
64
  s.hasUI = ctx.hasUI;
@@ -73,24 +77,29 @@ export function registerSession(kit: Kit, { briefing, presence, membership, guar
73
77
  unwatch = cfg.watch(presence.restore);
74
78
  starting = startTower();
75
79
  } else {
76
- const bar = await barReason();
77
- s.barred = bar?.text;
78
- if (bar) {
79
- tell(bar.text, bar.level);
80
- } else {
80
+ // Asking Herdr whether this is a sub workspace waits in the background too: on a loaded machine a Herdr call takes seconds, and pi-intercom
81
+ // connects only after this handler returns. Until the answer comes the session counts as barred, so nothing joins (membership.ts) or promotes early.
82
+ s.barred = CHECKING;
83
+ checked = barReason().then((bar) => {
84
+ if (start !== started) return false; // a /new or /resume started another session meanwhile, which asks again
85
+ s.barred = bar?.text;
86
+ if (bar) tell(bar.text, bar.level);
87
+ return !bar;
88
+ });
89
+ starting = checked.then(async (joinable) => {
90
+ if (!joinable) return;
81
91
  if (!s.stopped) unwatch = cfg.watch(presence.restore); // a shutdown that came while Herdr was asked about the workspace has stopped nothing yet
82
- starting = membership.joinNet().then(async () => {
83
- note(`${who()} joined tab ${s.info.tab}`);
84
- const claimed = await claimOpened();
85
- if (!claimed && event.reason !== "startup" && s.saved.openedBy && s.saved.openedPane === s.at.paneId) s.openedBy = s.saved.openedBy; // only the same pi in the tower's pane (/reload, /fork): `pi --session` anywhere is the boss's main
86
- if (s.openedBy) {
87
- control.register(); // the tower that opened this main may ask it to close (closemain.ts), also after a reload
88
- await presence.publish(); // sb_opened_by was cleared at shutdown
89
- }
90
- const taken = claimed && (await membership.promote(false));
91
- if (taken && !taken.promoted) tell(taken.text, "warning");
92
- });
93
- }
92
+ await membership.joinNet();
93
+ note(`${who()} joined tab ${s.info.tab}`);
94
+ const claimed = await claimOpened();
95
+ if (!claimed && event.reason !== "startup" && s.saved.openedBy && s.saved.openedPane === s.at.paneId) s.openedBy = s.saved.openedBy; // only the same pi in the tower's pane (/reload, /fork): `pi --session` anywhere is the boss's main
96
+ if (s.openedBy) {
97
+ control.register(); // the tower that opened this main may ask it to close (closemain.ts), also after a reload
98
+ await presence.publish(); // sb_opened_by was cleared at shutdown
99
+ }
100
+ const taken = claimed && (await membership.promote(false));
101
+ if (taken && !taken.promoted) tell(taken.text, "warning");
102
+ });
94
103
  }
95
104
  starting?.catch((error: Error) => tell(`Straw Boss: ${error.message}`, "error"));
96
105
  // The GUI this process ran before a /reload, /new, or /resume starts again on its URL, and a session newer than the running server
@@ -117,7 +126,9 @@ export function registerSession(kit: Kit, { briefing, presence, membership, guar
117
126
  // A member that dispatches or resumes a subagent without promoting is promoted here, before the subagent starts, unless the tab already has a main.
118
127
  // A tower only coordinates: it dispatches no subagent (one would outlive it with no one to take its result), and opens a main for the work instead.
119
128
  pi.on("tool_call", async (event) => {
120
- if (!SUBAGENT_TOOLS.includes(event.toolName) || s.barred) return;
129
+ if (!SUBAGENT_TOOLS.includes(event.toolName)) return;
130
+ await checked; // a subagent launched while session start still asks Herdr about the workspace waits for the answer
131
+ if (s.barred) return;
121
132
  if (s.isTower) return { block: true, reason: "A tower dispatches no subagents. Open a main for this work with boss_open_main, or route it to the main that owns it." };
122
133
  try {
123
134
  await membership.joinNet();
package/src/state.ts CHANGED
@@ -28,7 +28,7 @@ export interface Saved { tower?: boolean; openedBy?: string | null; openedPane?:
28
28
  export type Role = "tower" | "main" | "member";
29
29
  export type Scope = "tab" | "workspace" | "towers" | "all";
30
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.
31
+ * `run` is the CLI for every command; left out, Herdr's listings, `pane current`, and `report-metadata` go over the socket instead (`socketRun`) and `world` is one `session.snapshot`. `world` is the dashboard's and the GUI's reading of panes and workspaces.
32
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
33
  */
34
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 }