pi-straw-boss 0.41.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.41.0",
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",
@@ -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";
@@ -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,
package/src/herdr.ts CHANGED
@@ -118,13 +118,37 @@ function listing(args: string[]): { method: string; params: Record<string, unkno
118
118
  return undefined;
119
119
  }
120
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
+
121
144
  /**
122
- * `run` with Herdr's listings (`pane list`, `workspace list`, `tab list --workspace`) answered over the socket instead of by a spawned CLI:
123
- * the same `{result}` text, so every reader works unchanged. Every other command, the writes included, still goes to `run`.
124
- * 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.
125
149
  */
126
- export const socketReads = (run: HerdrRun, call: HerdrCall): HerdrRun => async (args, timeoutMs) => {
127
- 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);
128
152
  return request ? JSON.stringify({ result: await call(request.method, request.params, timeoutMs) }) : run(args, timeoutMs);
129
153
  };
130
154
 
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
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 }