pi-straw-boss 0.40.1 → 0.41.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
@@ -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.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/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
  }
@@ -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
@@ -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`));
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";
@@ -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) => {