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 +1 -1
- package/src/agent/sidecar.ts +2 -2
- package/src/herdr.ts +29 -5
- package/src/index.ts +2 -2
- package/src/session.ts +29 -18
- package/src/state.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "0.41.
|
|
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/sidecar.ts
CHANGED
|
@@ -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,
|
|
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 ??
|
|
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
|
|
123
|
-
* the same `{result}` text, so every
|
|
124
|
-
*
|
|
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
|
|
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,
|
|
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 ??
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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)
|
|
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 (`
|
|
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 }
|