pi-straw-boss 0.33.3 → 0.34.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 +1 -0
- package/README.zh-TW.md +1 -0
- package/package.json +1 -1
- package/src/boss.ts +8 -4
- package/src/bridge.ts +20 -9
- package/src/closer.ts +21 -5
- package/src/control.ts +2 -1
- package/src/dialogs.ts +7 -4
- package/src/gui.ts +1 -1
- package/src/handover.ts +18 -0
- package/src/host.ts +1 -1
- package/src/index.ts +2 -1
- package/src/stash.ts +220 -0
- package/src/stashing.ts +46 -0
package/README.md
CHANGED
|
@@ -86,6 +86,7 @@ The tower only routes and coordinates, so it can run a lighter model: set `tower
|
|
|
86
86
|
| `/boss` | Take over as this tab's main agent. |
|
|
87
87
|
| `/boss status`, `/boss tower` | Show the status; open and focus the tower. |
|
|
88
88
|
| `/boss close` | Quit this session and close its pane. |
|
|
89
|
+
| `/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. |
|
|
89
90
|
| `/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. |
|
|
90
91
|
| `/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. |
|
|
91
92
|
|
package/README.zh-TW.md
CHANGED
|
@@ -85,6 +85,7 @@ pi install npm:pi-straw-boss
|
|
|
85
85
|
| `boss_rename` | 用一個短語替這個 session 命名,說明它現在在做什麼;net、GUI,以及裝了 pi-auto-tab-name 時的分頁名稱都會跟著顯示。主智慧體換工作時會呼叫它。 |
|
|
86
86
|
| `/boss` | 接手成為這個分頁的主智慧體。 |
|
|
87
87
|
| `/boss status`、`/boss tower` | 顯示狀態;開啟塔台並切換過去。 |
|
|
88
|
+
| `/boss stash [名稱]`、`/boss pop [名稱]` | 把整個基地(塔台、主智慧體、成員和閒置的 shell)收起來並關掉;之後再打開,每個 pi 都從自己的 session 檔接回原本的角色。只要有接回後會遺失的東西就拒絕並說明原因:正在跑的回合、排隊中的訊息、開著的對話框、執行中的 Monitor、子智慧體,或前景有程式在跑的 shell。 |
|
|
88
89
|
| `/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 放在沙箱框架裡,表單送出的答案會傳給智慧體;純文字只能讀。 |
|
|
89
90
|
| `/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 監聽埠,請求算不算遠端取決於它進來的監聽埠,不看它帶什麼標頭。 |
|
|
90
91
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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/boss.ts
CHANGED
|
@@ -2,17 +2,19 @@ import type { createHost } from "./host.ts";
|
|
|
2
2
|
import type { Membership } from "./membership.ts";
|
|
3
3
|
import type { Reach } from "./reach.ts";
|
|
4
4
|
import type { Kit } from "./state.ts";
|
|
5
|
+
import type { createStashing } from "./stashing.ts";
|
|
5
6
|
|
|
6
7
|
/** What Tab completes after /boss; a bare /boss (take over as the main agent) has nothing to complete. */
|
|
7
|
-
const BOSS_ARGUMENTS = Object.entries({ status: "Show the coordinators", close: "Quit this session and close its pane", tower: "Open and focus the workspace tower", gui: "Open the net in the browser", "gui stop": "Close the browser GUI", "gui remote": "Open the GUI to your phone over Tailscale", "gui remote off": "Close the Tailscale entry" }).map(([value, description]) => ({ value, label: value, description }));
|
|
8
|
+
const BOSS_ARGUMENTS = Object.entries({ status: "Show the coordinators", close: "Quit this session and close its pane", tower: "Open and focus the workspace tower", gui: "Open the net in the browser", "gui stop": "Close the browser GUI", "gui remote": "Open the GUI to your phone over Tailscale", "gui remote off": "Close the Tailscale entry", stash: "Put this base away: its tower, mains, and members", pop: "Open a stashed base again" }).map(([value, description]) => ({ value, label: value, description }));
|
|
8
9
|
|
|
9
10
|
/** The /boss command. */
|
|
10
|
-
export function registerBoss({ pi, cfg, s, tell }: Kit, { host, membership, reach, closeSelf }: { host: ReturnType<typeof createHost>; membership: Membership; reach: Reach; closeSelf(keepPane: boolean): string | undefined }) {
|
|
11
|
+
export function registerBoss({ pi, cfg, s, tell }: Kit, { host, membership, reach, closeSelf, stashing }: { host: ReturnType<typeof createHost>; membership: Membership; reach: Reach; closeSelf(keepPane: boolean): string | undefined; stashing: ReturnType<typeof createStashing> }) {
|
|
11
12
|
pi.registerCommand("boss", {
|
|
12
|
-
description: "Take over as this tab's main agent (every session already joins as a member). /boss status shows the coordinators; /boss tower focuses the workspace tower (a main opens it when there is none); /boss close quits this session and closes its pane; /boss gui opens the net in the browser (/boss gui stop closes it; /boss gui remote opens it to your phone over Tailscale, /boss gui remote off closes that).",
|
|
13
|
+
description: "Take over as this tab's main agent (every session already joins as a member). /boss status shows the coordinators; /boss tower focuses the workspace tower (a main opens it when there is none); /boss close quits this session and closes its pane; /boss stash [name] puts this base away (tower, mains, members) and /boss pop [name] opens it again; /boss gui opens the net in the browser (/boss gui stop closes it; /boss gui remote opens it to your phone over Tailscale, /boss gui remote off closes that).",
|
|
13
14
|
// pi passes the whole argument text and replaces it with the chosen value, so each value is a full argument line.
|
|
14
15
|
getArgumentCompletions: (prefix) => {
|
|
15
|
-
const
|
|
16
|
+
const stashed = stashing.names().map((name) => ({ value: `pop ${name}`, label: `pop ${name}`, description: "Open this stashed base again" }));
|
|
17
|
+
const items = [...BOSS_ARGUMENTS, ...stashed].filter((item) => item.value.startsWith(prefix.trimStart()));
|
|
16
18
|
return items.length ? items : null;
|
|
17
19
|
},
|
|
18
20
|
handler: async (args, ctx) => {
|
|
@@ -22,6 +24,8 @@ export function registerBoss({ pi, cfg, s, tell }: Kit, { host, membership, reac
|
|
|
22
24
|
const why = args.trim() === "close" ? (closeSelf(false) ?? "") : undefined;
|
|
23
25
|
if (why !== undefined) return void (why && tell(why, "error"));
|
|
24
26
|
if (args.trim() === "status") return tell(s.joined || s.isTower ? JSON.stringify(await reach.status(), null, 2) : "Not in the net: still joining, or this session cannot join.");
|
|
27
|
+
const stash = /^(stash|pop)(?:\s+(.+))?$/.exec(args.trim());
|
|
28
|
+
if (stash) return await (stash[1] === "stash" ? stashing.stash(stash[2]) : stashing.pop(stash[2]));
|
|
25
29
|
if (s.isTower) throw new Error("This session is the tower, not a main agent");
|
|
26
30
|
if (args.trim() !== "tower") return tell((await membership.promote(true)).text);
|
|
27
31
|
await membership.joinNet();
|
package/src/bridge.ts
CHANGED
|
@@ -20,7 +20,8 @@ import { type Monitor, trackMonitors } from "./monitors.ts";
|
|
|
20
20
|
// Enter guard asks it what the editor would submit (`probe`, probe.ts), and it answers with a class and at most a command name, never the draft. It relays
|
|
21
21
|
// the documents the session shows with its dialogs (documents.ts) and hands a form answer for one of them to their owner. The composer's `/` menu
|
|
22
22
|
// asks it (`commands`) for the session's commands that match what is typed, and it answers with their names, sources, and descriptions.
|
|
23
|
-
// A close button asks it (`close`) to quit its session the way /boss close does (index.ts), which answers whether it will.
|
|
23
|
+
// A close button asks it (`close`) to quit its session the way /boss close does (index.ts), which answers whether it will. A stash asks it
|
|
24
|
+
// first (`stash-check`, stash.ts) whether the session can be put away and resumed losing nothing; that question never quits anything.
|
|
24
25
|
// It relays the session's queue too: pi tells extensions only whether messages wait (hasPendingMessages), so the bridge keeps what it saw
|
|
25
26
|
// typed while the agent works (`input` with a streaming behavior) and drops one when its user message starts; a queue pi emptied another
|
|
26
27
|
// way (Esc, Alt+Up) clears at the next check. And it relays the session's background monitors (monitors.ts).
|
|
@@ -43,14 +44,16 @@ export interface ProbeResult extends Editor { version: 1; type: "probe-result";
|
|
|
43
44
|
export interface CommandsResult { version: 1; type: "commands-result"; bridgeId: string; reqId: string; commands: Cmd[] }
|
|
44
45
|
export interface FormReply { version: 1; type: "form-result"; bridgeId: string; id: string; answerId: string; accepted: boolean; reason?: string }
|
|
45
46
|
export interface CloseResult { version: 1; type: "close-result"; bridgeId: string; reqId: string; accepted: boolean; reason?: string }
|
|
46
|
-
|
|
47
|
+
/** `reason`: why the session cannot be stashed now; none when it can. */
|
|
48
|
+
export interface StashCheckResult { version: 1; type: "stash-check-result"; bridgeId: string; reqId: string; reason?: string }
|
|
49
|
+
export type ToHub = Dialogs | Ack | ProbeResult | FormReply | CommandsResult | CloseResult | StashCheckResult;
|
|
47
50
|
export interface FormMessage { version: 1; type: "form"; to: string; id: string; answerId: string; fields: Fields }
|
|
48
51
|
/** An answer on the wire: never a secret's `value`. */
|
|
49
52
|
export interface AnswerMessage extends Omit<Answer, "value"> { version: 1; type: "answer"; to: string }
|
|
50
53
|
/** The hub when it runs in this session: it takes what a remote bridge would publish, from this session's id. */
|
|
51
54
|
export interface LocalHub { receive(from: string, msg: ToHub): void }
|
|
52
55
|
/** What the hub calls on the bridge of its own session. */
|
|
53
|
-
export interface Bridge { deliver(msg: AnswerMessage, reply: (ack: Ack) => void): void; sync(): void; probe(): Editor; commands(prefix: string): Cmd[]; form(msg: FormMessage, reply: (r: FormReply) => void): void; close(keepPane: boolean): { accepted: boolean; reason?: string } }
|
|
56
|
+
export interface Bridge { deliver(msg: AnswerMessage, reply: (ack: Ack) => void): void; sync(): void; probe(): Editor; commands(prefix: string): Cmd[]; form(msg: FormMessage, reply: (r: FormReply) => void): void; close(keepPane: boolean, ifStashable?: boolean): { accepted: boolean; reason?: string }; stashCheck(): string | undefined }
|
|
54
57
|
interface Channel { snapshot(): { connected: boolean; owner?: { sessionId: string; epoch: string } }; publish(payload: unknown, options?: { audience?: "owner" | "capable" }): void }
|
|
55
58
|
interface Rec { adapter: OwnerAdapter; d: Dialog; state: "open" | "settling" | "unknown" | "closed"; answerId?: string; accepted?: boolean; by?: string; at?: number; last?: Ack; waiting: ((ack: Ack) => void)[] }
|
|
56
59
|
type Event = { type: string; fromSessionId?: string; owner?: { sessionId: string; epoch: string }; payload?: Record<string, unknown> };
|
|
@@ -61,7 +64,7 @@ export function startBridge(pi: ExtensionAPI, { env, dir, local, close }: { env:
|
|
|
61
64
|
const chans = new Map<string, Channel>(), me = () => env.PI_INTERCOM_SESSION_ID ?? "";
|
|
62
65
|
let g: string | undefined, gpid = 0, seen: number | undefined, poll: ReturnType<typeof setInterval> | undefined, offs: (() => void)[] = [], warned = false;
|
|
63
66
|
let file: () => string | undefined = () => undefined, tell = (_text: string) => {}, editor = (): Editor => ({ editor: "unknown" }), docs: Doc[] = [];
|
|
64
|
-
let queued: Queued[] = [], waits = () => false;
|
|
67
|
+
let queued: Queued[] = [], waits = () => false, idle = () => true;
|
|
65
68
|
const open = () => [...recs.values()].filter((r) => r.state !== "closed");
|
|
66
69
|
const alive = () => { try { return gpid > 0 && (process.kill(gpid, 0), true); } catch (e) { return (e as NodeJS.ErrnoException).code === "EPERM"; } }; // a server that died without removing gui.json gets no probe answers
|
|
67
70
|
|
|
@@ -111,7 +114,7 @@ export function startBridge(pi: ExtensionAPI, { env, dir, local, close }: { env:
|
|
|
111
114
|
const onEvent = (from: string, ch: Channel, e: Event) => {
|
|
112
115
|
if (e.type === "owner" || e.type === "connection") return from === g && push();
|
|
113
116
|
const owner = ch.snapshot().owner, p = e.payload;
|
|
114
|
-
if (p?.type === "probe" || p?.type === "commands" || p?.type === "close") follow(); // a probe goes only to the server gui.json names at this moment
|
|
117
|
+
if (p?.type === "probe" || p?.type === "commands" || p?.type === "close" || p?.type === "stash-check") follow(); // a probe goes only to the server gui.json names at this moment
|
|
115
118
|
// Only G's own session, as the broker's current owner of this channel and the server gui.json names now, may answer, probe, or ask for a sync.
|
|
116
119
|
if (e.type !== "message" || p?.version !== 1 || e.fromSessionId !== from || owner?.sessionId !== from || e.owner?.epoch !== owner.epoch) return;
|
|
117
120
|
if (p.type === "sync" && from === g) push();
|
|
@@ -119,11 +122,18 @@ export function startBridge(pi: ExtensionAPI, { env, dir, local, close }: { env:
|
|
|
119
122
|
if (p.type === "form" && p.to === bridgeId && from === g) form(p as unknown as FormMessage, (r) => send(from, r));
|
|
120
123
|
if (p.type === "answer" && p.to === bridgeId && from === g) deliver(p as unknown as AnswerMessage, (ack) => send(from, ack));
|
|
121
124
|
if (p.type === "probe" && p.to === bridgeId && from === g && alive() && typeof p.probeId === "string" && p.probeId.length <= 64) send(from, { version: 1, type: "probe-result", bridgeId, probeId: p.probeId, ...editor() });
|
|
122
|
-
if (p.type === "close" && p.to === bridgeId && from === g && alive() && typeof p.reqId === "string" && p.reqId.length <= 64 && typeof p.keepPane === "boolean") send(from, { version: 1, type: "close-result", bridgeId, reqId: p.reqId, ...shut(p.keepPane) });
|
|
125
|
+
if (p.type === "close" && p.to === bridgeId && from === g && alive() && typeof p.reqId === "string" && p.reqId.length <= 64 && typeof p.keepPane === "boolean") send(from, { version: 1, type: "close-result", bridgeId, reqId: p.reqId, ...shut(p.keepPane, p.ifStashable === true) });
|
|
126
|
+
if (p.type === "stash-check" && p.to === bridgeId && from === g && alive() && typeof p.reqId === "string" && p.reqId.length <= 64) send(from, { version: 1, type: "stash-check-result", bridgeId, reqId: p.reqId, ...((r) => (r ? { reason: r } : {}))(stashCheck()) });
|
|
123
127
|
if (p.type === "commands" && p.to === bridgeId && from === g && alive() && typeof p.reqId === "string" && p.reqId.length <= 64 && typeof p.prefix === "string" && p.prefix.length <= MAX_PREFIX) send(from, { version: 1, type: "commands-result", bridgeId, reqId: p.reqId, commands: listCommands(pi, p.prefix) });
|
|
124
128
|
};
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
/** Why this session cannot be stashed and resumed as it is now: a turn or a queue, a dialog, or a monitor that a resume would not bring back. */
|
|
130
|
+
const stashCheck = () => {
|
|
131
|
+
const why = [!idle() && "a turn is running", waits() && "messages wait in its queue", open().length > 0 && "a dialog is open", (monitors?.list() ?? []).some((m) => m.status === "running") && "a background monitor is running"].filter(Boolean);
|
|
132
|
+
return why.length ? why.join(", ") : undefined;
|
|
133
|
+
};
|
|
134
|
+
/** `ifStashable`: a stash's close, refused when the session stopped being stashable since its check (a turn started meanwhile). */
|
|
135
|
+
const shut = (keepPane: boolean, ifStashable = false) => {
|
|
136
|
+
const why = (ifStashable && stashCheck()) || close(keepPane);
|
|
127
137
|
return why ? { accepted: false, reason: why } : { accepted: true };
|
|
128
138
|
};
|
|
129
139
|
const changed = () => {
|
|
@@ -215,6 +225,7 @@ export function startBridge(pi: ExtensionAPI, { env, dir, local, close }: { env:
|
|
|
215
225
|
docs = [];
|
|
216
226
|
queued = [];
|
|
217
227
|
waits = () => { try { return ctx.hasPendingMessages(); } catch { return false; } }; // a replaced session's context throws
|
|
228
|
+
idle = () => { try { return ctx.isIdle(); } catch { return false; } };
|
|
218
229
|
file = () => ctx.sessionManager.getSessionFile();
|
|
219
230
|
tell = (text) => ctx.hasUI && ctx.ui.notify(text, "warning");
|
|
220
231
|
editor = () => probeEditor(ctx);
|
|
@@ -246,5 +257,5 @@ export function startBridge(pi: ExtensionAPI, { env, dir, local, close }: { env:
|
|
|
246
257
|
offs.splice(0).forEach((off) => off());
|
|
247
258
|
(clearInterval(poll), (poll = undefined));
|
|
248
259
|
});
|
|
249
|
-
return { deliver, sync: () => ((seen = -1), follow()), probe: () => editor(), commands: (prefix) => listCommands(pi, prefix), form, close: shut };
|
|
260
|
+
return { deliver, sync: () => ((seen = -1), follow()), probe: () => editor(), commands: (prefix) => listCommands(pi, prefix), form, close: shut, stashCheck };
|
|
250
261
|
}
|
package/src/closer.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Reply } from "./control.ts";
|
|
2
2
|
import { type HerdrRun, panes, type Session, type Sleep } from "./herdr.ts";
|
|
3
|
+
import { stashBase } from "./stash.ts";
|
|
3
4
|
|
|
4
5
|
// What the page's close buttons ask for. Closing is each session's own (index.ts): its bridge is asked to quit it as /boss close does, and its
|
|
5
6
|
// shutdown clears its tokens (a tower's clears the base's mains). One unit closes its own pane afterwards; for a squad or a base every agent
|
|
@@ -9,20 +10,23 @@ import { type HerdrRun, panes, type Session, type Sleep } from "./herdr.ts";
|
|
|
9
10
|
// POST /<token>/api/close/unit {key} that session quits and closes its pane
|
|
10
11
|
// POST /<token>/api/close/tab {workspace, tab} every agent in the tab quits, then the tab closes
|
|
11
12
|
// POST /<token>/api/close/base {workspace} every agent in the workspace quits, then the workspace closes
|
|
13
|
+
// POST /<token>/api/close/stash {workspace, name} the base is checked and recorded first (stash.ts), then closed as above
|
|
12
14
|
|
|
13
15
|
const EXIT_MS = 30_000, JOINED = new Set(["main", "member", "tower"]);
|
|
14
|
-
|
|
16
|
+
/** `check` and `stateDir` are a stash's: without them this closer stashes nothing. */
|
|
17
|
+
export interface CloserDeps { run: HerdrRun; sleep: Sleep; me(): string; sessions(): Promise<Session[]>; ask(key: string, keepPane: boolean, ifStashable?: boolean): Promise<{ accepted: boolean; reason?: string } | undefined>; check?(key: string): Promise<{ reason?: string } | undefined>; stateDir?: string }
|
|
15
18
|
const no = (status: number, error: string, reason: string): Reply => ({ status, body: { error, reason } });
|
|
16
19
|
|
|
17
|
-
export function createCloser({ run, sleep, me, sessions, ask }: CloserDeps) {
|
|
20
|
+
export function createCloser({ run, sleep, me, sessions, ask, check, stateDir }: CloserDeps) {
|
|
18
21
|
let busy = false;
|
|
19
22
|
const name = (s: Session) => s.name ?? s.id.slice(0, 8);
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
/** `ifStashable`: a stash's batch, where each session quits only while it can still be stashed. */
|
|
25
|
+
async function batch(workspace: string, tab?: string, ifStashable = false): Promise<Reply> {
|
|
22
26
|
const here = (await panes(run, workspace)).filter((p) => (!tab || p.tab_id === tab) && JOINED.has(p.tokens?.sb_role ?? ""));
|
|
23
27
|
if (!here.length && !(await panes(run, workspace)).some((p) => !tab || p.tab_id === tab)) return no(404, "not_found", tab ? "That squad is not open any more." : "That base is not open any more.");
|
|
24
28
|
const targets = (await sessions()).filter((s) => s.id !== me() && here.some((p) => p.pane_id === s.herdrLocation?.paneId));
|
|
25
|
-
const answers = await Promise.all(targets.map(async (s) => ({ s, r: await ask(s.id, true) })));
|
|
29
|
+
const answers = await Promise.all(targets.map(async (s) => ({ s, r: await ask(s.id, true, ifStashable) })));
|
|
26
30
|
const stuck = answers.filter(({ r }) => !r?.accepted).map(({ s, r }) => `${name(s)}${r?.reason ? ` (${r.reason})` : " (no answer)"}`);
|
|
27
31
|
if (stuck.length) return no(409, "refused", `Nothing was closed: ${stuck.join(", ")} cannot be closed from here. Close them in their terminals first.`);
|
|
28
32
|
const ids = new Set(targets.map((s) => s.id));
|
|
@@ -33,13 +37,25 @@ export function createCloser({ run, sleep, me, sessions, ask }: CloserDeps) {
|
|
|
33
37
|
return { status: 200, body: { closed: targets.length } };
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
/** `refresh` reads the net again, so the hub learns of bridges that told it about themselves since (a stash's second round). */
|
|
41
|
+
return async function handle(kind: string, json: Record<string, unknown>, refresh?: () => Promise<unknown>): Promise<Reply> {
|
|
37
42
|
const str = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 200;
|
|
38
43
|
if (kind === "unit") {
|
|
39
44
|
if (!str(json.key)) return no(400, "bad_request", "A unit is named by its key.");
|
|
40
45
|
const r = await ask(json.key, false);
|
|
41
46
|
return r?.accepted ? { status: 200, body: { closing: true } } : no(409, "refused", r?.reason ?? "The session did not answer; close it in its terminal.");
|
|
42
47
|
}
|
|
48
|
+
if (kind === "stash" && check && stateDir) {
|
|
49
|
+
if (busy) return no(429, "busy", "Another close is still on its way.");
|
|
50
|
+
busy = true;
|
|
51
|
+
try {
|
|
52
|
+
return await stashBase({ run, sleep, sessions, check, stateDir }, json, (workspace) => batch(workspace, undefined, true), refresh);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return no(502, "stash_failed", (error as Error).message);
|
|
55
|
+
} finally {
|
|
56
|
+
busy = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
43
59
|
if (kind !== "tab" && kind !== "base") return no(404, "not_found", "No such action.");
|
|
44
60
|
if (!str(json.workspace) || (kind === "tab" && !str(json.tab))) return no(400, "bad_request", "A squad is named by its workspace and tab, a base by its workspace.");
|
|
45
61
|
if (busy) return no(429, "busy", "Another close is still on its way.");
|
package/src/control.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { ENDING } from "./probe.ts";
|
|
|
23
23
|
// POST /<token>/api/remote/devices, remote/unpair {id}, remote/unpair-all the paired devices, and unpairing one or all (never remote)
|
|
24
24
|
// POST /<token>/api/dismiss {key} or {all: true} hides one finished subagent, or every finished one, from every page
|
|
25
25
|
// POST /<token>/api/close/unit, close/tab, close/base quits a session, or every agent of a squad or base, then closes it (closer.ts)
|
|
26
|
+
// POST /<token>/api/close/stash {workspace, name} checks and records a base, then closes it as close/base does (stash.ts); a paired device may, as it may close/base
|
|
26
27
|
// POST /<token>/api/dirs, open/base, open/member, open/main a folder picker, a new base with a member, a new member, a new main (spawn.ts)
|
|
27
28
|
// POST /<token>/api/server/stop {by, take} stops the server: another session's /boss gui stop or takeover (never remote)
|
|
28
29
|
// Every write needs the page's own origin (Origin, Sec-Fetch-Site), a JSON body of at most 64 KB, and the CSRF secret the server wrote into
|
|
@@ -35,7 +36,7 @@ export { ENDING };
|
|
|
35
36
|
|
|
36
37
|
export interface Reply { status: number; body: unknown }
|
|
37
38
|
/** `answers.target` reads the net afresh, so the binding and the dialog are checked again for every answer. */
|
|
38
|
-
export interface ControlDeps { lookup(key: string): Found | undefined; call?: HerdrCall; csrf: string; canonical(file?: string): string | undefined; pair?(): { url: string; rows: string[] } | undefined; remoteOn?(): Promise<void>; remoteOff?(): Promise<void>; paired?: { on(): boolean; list(): unknown; unpair(id: string): boolean; unpairAll(): number; unsaved(): string | undefined }; saveFailed?(reason: string): void; spawn?(parts: string[], json: Record<string, unknown>): Promise<Reply>; close?(kind: string, json: Record<string, unknown>): Promise<Reply>; dismiss?(key: unknown): Promise<boolean>; dismissAll?(): Promise<number>; stop?(asked: Asked): void; mirrors?: Pick<ReturnType<typeof createMirrors>, "toggle" | "fresh">; secret?(file: string): Promise<boolean>; answers?: Pick<Hub, "replay" | "ended" | "send" | "probe" | "commands"> & { target(key: string): Promise<Target | undefined> } }
|
|
39
|
+
export interface ControlDeps { lookup(key: string): Found | undefined; call?: HerdrCall; csrf: string; canonical(file?: string): string | undefined; pair?(): { url: string; rows: string[] } | undefined; remoteOn?(): Promise<void>; remoteOff?(): Promise<void>; paired?: { on(): boolean; list(): unknown; unpair(id: string): boolean; unpairAll(): number; unsaved(): string | undefined }; saveFailed?(reason: string): void; spawn?(parts: string[], json: Record<string, unknown>): Promise<Reply>; close?(kind: string, json: Record<string, unknown>, refresh?: () => Promise<unknown>): Promise<Reply>; dismiss?(key: unknown): Promise<boolean>; dismissAll?(): Promise<number>; stop?(asked: Asked): void; mirrors?: Pick<ReturnType<typeof createMirrors>, "toggle" | "fresh">; secret?(file: string): Promise<boolean>; answers?: Pick<Hub, "replay" | "ended" | "send" | "probe" | "commands"> & { target(key: string): Promise<Target | undefined> } }
|
|
39
40
|
|
|
40
41
|
const no = (status: number, error: string, reason: string): Reply => ({ status, body: { error, reason } });
|
|
41
42
|
const same = (a: string, b: string) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
package/src/dialogs.ts
CHANGED
|
@@ -72,7 +72,7 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
|
|
|
72
72
|
let channel: Channel | undefined, epoch: string | undefined, active = false, change = () => {};
|
|
73
73
|
let bound = new Map<string, { from: string; b: Dialogs }>(), targets = new Map<string, Target>(), reach = new Map<string, { from: string; bridgeId: string }>();
|
|
74
74
|
const bridges = new Map<string, Dialogs>(), waiting = new Map<string, (ack?: Ack) => void>(), done = new Map<string, { key: string; at: number; sent: Sent }>(), probes = new Map<string, (e?: Editor) => void>(), lists = new Map<string, (c?: Cmd[]) => void>();
|
|
75
|
-
const closes = new Map<string, (r?: Pick<CloseResult, "accepted" | "reason">) => void>();
|
|
75
|
+
const closes = new Map<string, (r?: Pick<CloseResult, "accepted" | "reason">) => void>(), checks = new Map<string, (r?: { reason?: string }) => void>();
|
|
76
76
|
const caps = createCaps(), forms = new Map<string, (r?: FormReply) => void>();
|
|
77
77
|
let shelves = new Map<string, Shelf>();
|
|
78
78
|
const owner = () => channel?.snapshot().owner?.sessionId === me;
|
|
@@ -92,6 +92,7 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
|
|
|
92
92
|
if (msg.type === "probe-result") probes.get([from, msg.bridgeId, msg.probeId].join("\n"))?.(checkProbe({ ...msg }));
|
|
93
93
|
if (msg.type === "commands-result" && str(msg.reqId)) lists.get([from, msg.bridgeId, msg.reqId].join("\n"))?.(checkCommands(msg.commands));
|
|
94
94
|
if (msg.type === "close-result" && str(msg.reqId) && typeof msg.accepted === "boolean" && opt(msg.reason) && (msg.reason?.length ?? 0) <= 512) closes.get([from, msg.bridgeId, msg.reqId].join("\n"))?.({ accepted: msg.accepted, ...(msg.reason ? { reason: msg.reason } : {}) });
|
|
95
|
+
if (msg.type === "stash-check-result" && str(msg.reqId) && opt(msg.reason) && (msg.reason?.length ?? 0) <= 512) checks.get([from, msg.bridgeId, msg.reqId].join("\n"))?.(msg.reason ? { reason: msg.reason } : {});
|
|
95
96
|
if (msg.type === "form-result" && typeof msg.accepted === "boolean" && opt(msg.reason) && (msg.reason?.length ?? 0) <= 512) forms.get([from, msg.bridgeId, msg.id, msg.answerId].join("\n"))?.(msg);
|
|
96
97
|
};
|
|
97
98
|
const onEvent = (e: Event) => {
|
|
@@ -136,7 +137,7 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
|
|
|
136
137
|
const hub: LocalHub & {
|
|
137
138
|
readonly running: boolean; readonly me: string; start(onChange: () => void): void; stop(): void; relayed(sessions: Session[], refs: Map<string, Ref>): Relayed; target(key: string): Target | undefined;
|
|
138
139
|
replay(answerId: string, key: string): Sent | undefined; ended(key: string, dialog: string, answerId: string): Sent | undefined; send(key: string, t: Target, a: Answer): Promise<Sent>;
|
|
139
|
-
probe(key: string): Promise<Editor | undefined>; commands(key: string, prefix: string): Promise<Cmd[] | undefined>; close(key: string, keepPane: boolean): Promise<{ accepted: boolean; reason?: string } | undefined>; doc(cap: string): { key: string; doc: Doc } | undefined; form(cap: string, answerId: string, fields: Fields): Promise<FormResult | undefined>;
|
|
140
|
+
probe(key: string): Promise<Editor | undefined>; commands(key: string, prefix: string): Promise<Cmd[] | undefined>; close(key: string, keepPane: boolean, ifStashable?: boolean): Promise<{ accepted: boolean; reason?: string } | undefined>; stashCheck(key: string): Promise<{ reason?: string } | undefined>; doc(cap: string): { key: string; doc: Doc } | undefined; form(cap: string, answerId: string, fields: Fields): Promise<FormResult | undefined>;
|
|
140
141
|
} = {
|
|
141
142
|
receive,
|
|
142
143
|
get running() { return active; },
|
|
@@ -156,7 +157,7 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
|
|
|
156
157
|
active = false;
|
|
157
158
|
[bridges, bound, targets, reach, shelves].forEach((m) => m.clear());
|
|
158
159
|
caps.clear(); // a capability lives as long as its server
|
|
159
|
-
[...waiting.values(), ...probes.values(), ...lists.values(), ...forms.values(), ...closes.values()].forEach((w) => w());
|
|
160
|
+
[...waiting.values(), ...probes.values(), ...lists.values(), ...forms.values(), ...closes.values(), ...checks.values()].forEach((w) => w());
|
|
160
161
|
},
|
|
161
162
|
/** Binds each relayed list to a unit, from this net reading's sessions and registry. */
|
|
162
163
|
relayed(sessions, refs) {
|
|
@@ -209,7 +210,9 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
|
|
|
209
210
|
probe: (key) => request(key, probes, () => checkProbe({ ...local.probe() }), (probeId) => ({ type: "probe", probeId })),
|
|
210
211
|
/** The session's commands that match `prefix`, as its bridge lists them. */
|
|
211
212
|
/** Asks the session to quit as /boss close does (keeping its pane when `keepPane`); undefined when its bridge did not answer. */
|
|
212
|
-
close: (key, keepPane) => request(key, closes, () => local.close(keepPane), (reqId) => ({ type: "close", reqId, keepPane })),
|
|
213
|
+
close: (key, keepPane, ifStashable = false) => request(key, closes, () => local.close(keepPane, ifStashable), (reqId) => ({ type: "close", reqId, keepPane, ...(ifStashable ? { ifStashable } : {}) })),
|
|
214
|
+
/** Whether the session can be stashed (stash.ts): `{}` when it can, a reason when not, undefined when its bridge did not answer. */
|
|
215
|
+
stashCheck: (key) => request(key, checks, () => ((r) => (r ? { reason: r } : {}))(local.stashCheck()), (reqId) => ({ type: "stash-check", reqId })),
|
|
213
216
|
commands: (key, prefix) => request(key, lists, () => checkCommands(local.commands(prefix)), (reqId) => ({ type: "commands", reqId, prefix })),
|
|
214
217
|
/** A listed document by its capability, as its bridge lists it now (a list that changed since the reading counts, a bridge that left does not). */
|
|
215
218
|
doc(cap) {
|
package/src/gui.ts
CHANGED
|
@@ -70,7 +70,7 @@ export async function startGui({ read, net, call, hub, dir = BUNDLE, paired, por
|
|
|
70
70
|
if (n) await tick();
|
|
71
71
|
return n;
|
|
72
72
|
};
|
|
73
|
-
const control = createControl({ lookup: (key) => net?.lookup(key), call, csrf, canonical, pair, remoteOn: onRemote, remoteOff: onRemoteOff, paired: { on: () => Boolean(access.remote), ...access.devices, unsaved: access.unsaved }, saveFailed: onSaveFailed, spawn, close: close && (async (kind, json) => (await tick(), close(kind, json))), dismiss: async (key) => typeof key === "string" && Boolean(net?.dismiss?.(key)) && (await tick(), true), dismissAll, mirrors, stop, secret: waitsForSecret, answers: hub && { replay: hub.replay, ended: hub.ended, send: hub.send, probe: hub.probe, commands: hub.commands, target: async (key) => (await tick(), hub.target(key)) } });
|
|
73
|
+
const control = createControl({ lookup: (key) => net?.lookup(key), call, csrf, canonical, pair, remoteOn: onRemote, remoteOff: onRemoteOff, paired: { on: () => Boolean(access.remote), ...access.devices, unsaved: access.unsaved }, saveFailed: onSaveFailed, spawn, close: close && (async (kind, json) => (await tick(), close(kind, json, tick))), dismiss: async (key) => typeof key === "string" && Boolean(net?.dismiss?.(key)) && (await tick(), true), dismissAll, mirrors, stop, secret: waitsForSecret, answers: hub && { replay: hub.replay, ended: hub.ended, send: hub.send, probe: hub.probe, commands: hub.commands, target: async (key) => (await tick(), hub.target(key)) } });
|
|
74
74
|
let last: string | undefined, problem: string | undefined, inflight: Promise<void> | undefined;
|
|
75
75
|
let poll: ReturnType<typeof setInterval> | undefined, beat: ReturnType<typeof setInterval> | undefined;
|
|
76
76
|
const send = (to: ServerResponse, data: string, event?: string) => to.writableEnded || to.write(`${event ? `event: ${event}\n` : ""}data: ${data}\n\n`); // a reading in flight at stop ends after the streams
|
package/src/handover.ts
CHANGED
|
@@ -51,6 +51,24 @@ export async function requestStop(r: GuiRecord, asked: Asked, sleep: (ms: number
|
|
|
51
51
|
return false;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** One POST to a recorded server's API from this machine, as its own page sends it: the status and the JSON body (status 0 when it did not answer). */
|
|
55
|
+
export function postServer(r: GuiRecord, path: string, json: Record<string, unknown>, timeoutMs: number) {
|
|
56
|
+
const body = JSON.stringify(json), origin = `http://127.0.0.1:${r.port}`;
|
|
57
|
+
return new Promise<{ status: number; body: Record<string, unknown> }>((done) => {
|
|
58
|
+
const headers = { host: `127.0.0.1:${r.port}`, origin, "content-type": "application/json", "x-sb-csrf": r.csrf ?? "", "content-length": Buffer.byteLength(body) };
|
|
59
|
+
const req = request({ host: "127.0.0.1", port: r.port, path: `/${r.token}/api/${path}`, method: "POST", headers, agent: false, timeout: timeoutMs }, (res) => {
|
|
60
|
+
let text = "";
|
|
61
|
+
res.setEncoding("utf8").on("data", (c: string) => (text += c)).on("end", () => {
|
|
62
|
+
let parsed: Record<string, unknown> = {};
|
|
63
|
+
try { parsed = JSON.parse(text) as Record<string, unknown>; } catch { /* a body that is not JSON says nothing more than its status */ }
|
|
64
|
+
done({ status: res.statusCode ?? 0, body: parsed });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
req.on("error", () => done({ status: 0, body: {} })).on("timeout", () => (req.destroy(), done({ status: 0, body: {} })));
|
|
68
|
+
req.end(body);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
/** Whether a recorded server answers its own page. */
|
|
55
73
|
export const answers = (r: GuiRecord) =>
|
|
56
74
|
new Promise<boolean>((done) => {
|
package/src/host.ts
CHANGED
|
@@ -62,7 +62,7 @@ export function createHost({ pi, cfg, s, guiDir, me, tell }: Kit, { bridge, conn
|
|
|
62
62
|
const share = (take = false, manual = false, was = false) =>
|
|
63
63
|
(opening ??= (async () => {
|
|
64
64
|
if (s.gui) return { url: s.gui.url, owner: label(), mine: s.gui };
|
|
65
|
-
const start = (prefer: { port?: number; remotePort?: number; token?: string; csrf?: string }, net = netSource({ run, world, sessions, 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) => s.hub?.close(key, keepPane) }) });
|
|
65
|
+
const start = (prefer: { port?: number; remotePort?: number; token?: string; csrf?: string }, net = netSource({ run, world, sessions, 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 }) });
|
|
66
66
|
const shared = await sharedGui(guiDir, [label(), me()], start, sleep, { version, take });
|
|
67
67
|
if ((s.gui = shared.mine) && !manual) await resume(s.gui, was);
|
|
68
68
|
return shared;
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { createReach } from "./reach.ts";
|
|
|
18
18
|
import { tailscale } from "./remote.ts";
|
|
19
19
|
import { herdrEvents, herdrWatch } from "./server-watch.ts";
|
|
20
20
|
import { registerSession } from "./session.ts";
|
|
21
|
+
import { createStashing } from "./stashing.ts";
|
|
21
22
|
import { type Config, createKit, type Deps } from "./state.ts";
|
|
22
23
|
import { createTools } from "./tools.ts";
|
|
23
24
|
import { createTower } from "./tower.ts";
|
|
@@ -50,7 +51,7 @@ export function createStrawBoss({ run: given, world: reading, api = herdrApi(),
|
|
|
50
51
|
const host = createHost(kit, { bridge, connected: membership.connected });
|
|
51
52
|
closeSelf = createClosing(kit);
|
|
52
53
|
registerSession(kit, { briefing, presence, membership, guard, host, reach, digest, startTower: createTower(kit, { membership, presence, tools }) });
|
|
53
|
-
registerBoss(kit, { host, membership, reach, closeSelf });
|
|
54
|
+
registerBoss(kit, { host, membership, reach, closeSelf, stashing: createStashing(kit, { host }) });
|
|
54
55
|
};
|
|
55
56
|
}
|
|
56
57
|
|
package/src/stash.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { Reply } from "./control.ts";
|
|
5
|
+
import { createTab, type HerdrRun, isSub, type Pane, panes, parentOf, report, result, type Session, type Sleep, startPi, tabs, waitFor, workspaces } from "./herdr.ts";
|
|
6
|
+
|
|
7
|
+
// Stash and pop (docs/specs/2026-10-22-stash/spec.md). A stash puts a whole base away: it checks that every pane can be resumed losing
|
|
8
|
+
// nothing (Herdr's view here, each session's own view through its bridge's `stash-check`), writes a manifest of what ran where, then closes
|
|
9
|
+
// the base as its close button does (closer.ts). A pop opens a new workspace from the manifest and resumes each pi from its session file:
|
|
10
|
+
// the tower first (its session marks it a tower), then per tab the main (with the one-time sb_open_main token, as spawn.ts opens a main),
|
|
11
|
+
// then the members. Anything a resume cannot bring back (a running turn, a subagent, a monitor, a busy shell) refuses the stash instead.
|
|
12
|
+
// POST /<token>/api/close/stash {workspace, name} checks, writes <state dir>/stash/<name>.json, then closes the base
|
|
13
|
+
|
|
14
|
+
export type StashRole = "tower" | "main" | "member";
|
|
15
|
+
export interface StashPane { kind: StashRole | "shell"; cwd: string; session?: string; name?: string }
|
|
16
|
+
export interface StashTab { label?: string; panes: StashPane[] }
|
|
17
|
+
export interface Stash { version: 1; name: string; label: string; cwd: string; stashedAt: string; tabs: StashTab[] }
|
|
18
|
+
|
|
19
|
+
const ROLES = new Set<string>(["tower", "main", "member"]), SETTLED = new Set(["idle", "done"]);
|
|
20
|
+
const SHELLS = new Set(["zsh", "bash", "fish", "sh", "dash", "ksh", "tcsh", "csh", "nu", "login"]);
|
|
21
|
+
const MAX_NAME = 64, CHECK_ROUNDS = 3, CHECK_GAP_MS = 2000, JOIN_MS = 60_000;
|
|
22
|
+
const no = (status: number, error: string, reason: string): Reply => ({ status, body: { error, reason } });
|
|
23
|
+
|
|
24
|
+
/** A stash name is a file name: at most 64 characters, no slash, backslash, or control character, not starting with a dot. */
|
|
25
|
+
export const nameOk = (name: unknown): name is string => typeof name === "string" && name.length > 0 && name.length <= MAX_NAME && name === name.trim() && !name.startsWith(".") && !/[/\\\p{Cc}]/u.test(name);
|
|
26
|
+
const dirOf = (stateDir: string) => join(stateDir, "stash");
|
|
27
|
+
export const stashFile = (stateDir: string, name: string) => join(dirOf(stateDir), `${name}.json`);
|
|
28
|
+
|
|
29
|
+
/** The stashes waiting to be popped, by name. */
|
|
30
|
+
export function listStashes(stateDir: string): string[] {
|
|
31
|
+
try {
|
|
32
|
+
return readdirSync(dirOf(stateDir)).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter(nameOk).sort((a, b) => a.localeCompare(b));
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const str = (v: unknown): v is string => typeof v === "string" && v.length > 0;
|
|
39
|
+
function paneOk(p: unknown): p is StashPane {
|
|
40
|
+
const x = p as Partial<StashPane> | null;
|
|
41
|
+
return x !== null && typeof x === "object" && (x.kind === "shell" || ROLES.has(x.kind as string)) && str(x.cwd) && (x.session === undefined || str(x.session)) && (x.name === undefined || typeof x.name === "string");
|
|
42
|
+
}
|
|
43
|
+
/** `session` is absent for a pi that never wrote its file (pi writes it at the first reply): it has no conversation to lose, so a pop starts it fresh in its role. */
|
|
44
|
+
/** What a pop can open: the stash checks it before it closes anything, the pop before it opens anything. */
|
|
45
|
+
const stashOk = (st: Stash | null) => st?.version === 1 && str(st.label) && str(st.cwd) && Array.isArray(st.tabs) && st.tabs.length > 0 && st.tabs.every((t) => Array.isArray(t?.panes) && t.panes.length > 0 && t.panes.every(paneOk) && (t.label === undefined || typeof t.label === "string"));
|
|
46
|
+
export function readStash(stateDir: string, name: string, file = stashFile(stateDir, name)): Stash {
|
|
47
|
+
if (!nameOk(name)) throw new Error(`"${name}" is not a stash name`);
|
|
48
|
+
let st: Stash;
|
|
49
|
+
try {
|
|
50
|
+
st = JSON.parse(readFileSync(file, "utf8")) as Stash;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error((error as NodeJS.ErrnoException).code === "ENOENT" ? `Nothing is stashed as "${name}"` : `The stash "${name}" cannot be read: ${(error as Error).message}`);
|
|
53
|
+
}
|
|
54
|
+
if (!stashOk(st)) throw new Error(`The stash "${name}" is not a manifest this version reads`);
|
|
55
|
+
return st;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What runs in a shell pane's foreground besides the shell; undefined when Herdr cannot say. */
|
|
59
|
+
async function foreground(run: HerdrRun, paneId: string): Promise<string[] | undefined> {
|
|
60
|
+
try {
|
|
61
|
+
const procs = (await result(run, ["pane", "process-info", "--pane", paneId])).process_info?.foreground_processes as { name?: string; argv0?: string }[] | undefined;
|
|
62
|
+
if (!Array.isArray(procs)) return undefined;
|
|
63
|
+
return procs.map((p) => String(p.argv0 ?? p.name ?? "").replace(/^-/, "").split("/").pop()!).filter((n) => !SHELLS.has(n));
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Herdr's side of the check, and the manifest's tabs: tower tab first, then Herdr's tab order, each tab's panes in Herdr's order. */
|
|
70
|
+
export async function survey(run: HerdrRun, workspaceId: string, live: Session[]) {
|
|
71
|
+
const all = await workspaces(run), ws = all.find((w) => w.workspace_id === workspaceId);
|
|
72
|
+
if (!ws) return undefined;
|
|
73
|
+
const reasons: string[] = [], agents: Session[] = [], out: StashTab[] = [];
|
|
74
|
+
if (isSub(ws)) reasons.push(`${ws.label} is a sub workspace; stash the base it belongs to`);
|
|
75
|
+
// A sub workspace is neither recorded nor closed: anything running in it would lose the base it reports to.
|
|
76
|
+
for (const sub of all.filter((w) => isSub(w) && parentOf(all, w)?.workspace_id === workspaceId)) {
|
|
77
|
+
const ps = await panes(run, sub.workspace_id), busy = ps.filter((p) => p.agent).length;
|
|
78
|
+
if (busy) reasons.push(`its sub workspace ${sub.label} runs ${busy} agent${busy > 1 ? "s" : ""}`);
|
|
79
|
+
for (const p of ps.filter((x) => !x.agent)) if ((await foreground(run, p.pane_id))?.length !== 0) reasons.push(`pane ${p.pane_id} in its sub workspace ${sub.label} runs something`);
|
|
80
|
+
}
|
|
81
|
+
const here = await panes(run, workspaceId), sessionAt = (p: Pane) => live.find((s) => s.herdrLocation?.paneId === p.pane_id);
|
|
82
|
+
for (const t of await tabs(run, workspaceId)) {
|
|
83
|
+
const tab: StashTab = { ...(t.label ? { label: t.label } : {}), panes: [] };
|
|
84
|
+
for (const p of here.filter((x) => x.tab_id === t.tab_id)) {
|
|
85
|
+
const cwd = p.cwd ?? ws.worktree?.checkout_path ?? "", session = sessionAt(p), who = session?.name ?? `pane ${p.pane_id}`;
|
|
86
|
+
if (!p.agent) {
|
|
87
|
+
const fg = await foreground(run, p.pane_id);
|
|
88
|
+
if (!cwd) reasons.push(`pane ${p.pane_id} has no folder Herdr knows of`);
|
|
89
|
+
if (!fg) reasons.push(`Herdr cannot say what runs in pane ${p.pane_id}`);
|
|
90
|
+
else if (fg.length) reasons.push(`pane ${p.pane_id} runs ${fg.join(", ")}`);
|
|
91
|
+
tab.panes.push({ kind: "shell", cwd });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const role = p.tokens?.sb_role ?? "";
|
|
95
|
+
if (p.agent !== "pi") reasons.push(`pane ${p.pane_id} runs ${p.agent}, which a stash cannot resume`);
|
|
96
|
+
else if (!ROLES.has(role)) reasons.push(`${who} is a subagent or outside the net`);
|
|
97
|
+
if (p.agent !== "pi" || !ROLES.has(role)) continue;
|
|
98
|
+
if (!SETTLED.has(p.agent_status ?? "")) reasons.push(`${who} is ${p.agent_status ?? "in an unknown state"}`);
|
|
99
|
+
// A path Herdr names whose file does not exist yet is a session that never replied: nothing to resume, it starts fresh at pop.
|
|
100
|
+
const named = p.agent_session?.value, file = named && existsSync(named) ? named : undefined;
|
|
101
|
+
if (!named) reasons.push(`${who} has no session file Herdr knows of`);
|
|
102
|
+
if (!cwd) reasons.push(`${who} has no folder Herdr knows of`);
|
|
103
|
+
if (session) agents.push(session);
|
|
104
|
+
else reasons.push(`${who} is not on the intercom net`);
|
|
105
|
+
tab.panes.push({ kind: role as StashRole, cwd, ...(file ? { session: file } : {}), ...(session?.name ? { name: session.name } : {}) });
|
|
106
|
+
}
|
|
107
|
+
if (tab.panes.length) out.push(tab);
|
|
108
|
+
}
|
|
109
|
+
const towered = (t: StashTab) => t.panes.some((p) => p.kind === "tower");
|
|
110
|
+
const tabsOut = [...out.filter(towered), ...out.filter((t) => !towered(t))];
|
|
111
|
+
const cwd = tabsOut.flatMap((t) => t.panes).find((p) => p.kind === "tower")?.cwd ?? ws.worktree?.checkout_path ?? tabsOut[0]?.panes[0]?.cwd ?? "";
|
|
112
|
+
if (!tabsOut.length) reasons.push(`${ws.label} has nothing in it to stash`);
|
|
113
|
+
return { label: ws.label, cwd, tabs: tabsOut, reasons, agents };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface StashDeps { run: HerdrRun; sleep: Sleep; sessions(): Promise<Session[]>; check(key: string): Promise<{ reason?: string } | undefined>; stateDir: string; now?: () => Date }
|
|
117
|
+
|
|
118
|
+
/** The server's stash: every check first (nothing closes on a refusal), then the manifest, then `closeBase` (the close-base batch). */
|
|
119
|
+
export async function stashBase(d: StashDeps, json: Record<string, unknown>, closeBase: (workspace: string) => Promise<Reply>, refresh?: () => Promise<unknown>): Promise<Reply> {
|
|
120
|
+
if (!str(json.workspace) || json.workspace.length > 200) return no(400, "bad_request", "A base is named by its workspace.");
|
|
121
|
+
if (!nameOk(json.name)) return no(400, "bad_request", `A stash name has at most ${MAX_NAME} characters, no slash, backslash, or control character, and does not start with a dot.`);
|
|
122
|
+
const name = json.name, file = stashFile(d.stateDir, name);
|
|
123
|
+
if (existsSync(file)) return no(409, "exists", `Something is stashed as "${name}" already; pop it or pick another name.`);
|
|
124
|
+
const sv = await survey(d.run, json.workspace, await d.sessions());
|
|
125
|
+
if (!sv) return no(404, "not_found", "That base is not open any more.");
|
|
126
|
+
// A bridge that has not told this server about itself yet (one just started) gets another round after the net is read again.
|
|
127
|
+
const reasons = [...sv.reasons], named = (s: Session) => s.name ?? s.id.slice(0, 8);
|
|
128
|
+
let pending = sv.agents;
|
|
129
|
+
for (let round = 0; pending.length && round < CHECK_ROUNDS; round++) {
|
|
130
|
+
if (round) (await d.sleep(CHECK_GAP_MS), await refresh?.());
|
|
131
|
+
const got = await Promise.all(pending.map(async (s) => ({ s, r: await d.check(s.id) })));
|
|
132
|
+
for (const { s, r } of got) if (r?.reason) reasons.push(`${named(s)}: ${r.reason}`);
|
|
133
|
+
pending = got.filter(({ r }) => !r).map(({ s }) => s);
|
|
134
|
+
}
|
|
135
|
+
for (const s of pending) reasons.push(`${named(s)} did not answer the stash check (an older pi-straw-boss, or a stuck session)`);
|
|
136
|
+
// Herdr's side once more, since the rounds took seconds: a shell job or a subagent started meanwhile refuses too. The sessions' own side is
|
|
137
|
+
// asked again by the close itself (`ifStashable`), so a turn started now refuses that session's quit and stops the batch.
|
|
138
|
+
if (!reasons.length) reasons.push(...((await survey(d.run, json.workspace, await d.sessions()))?.reasons ?? ["the base is not open any more"]));
|
|
139
|
+
if (reasons.length) return no(409, "refused", `Nothing was stashed: ${reasons.join("; ")}.`);
|
|
140
|
+
const stash: Stash = { version: 1, name, label: sv.label, cwd: sv.cwd, stashedAt: (d.now?.() ?? new Date()).toISOString(), tabs: sv.tabs };
|
|
141
|
+
if (!stashOk(stash)) return no(500, "unreadable", "Nothing was stashed: the manifest would not be one a pop can read.");
|
|
142
|
+
mkdirSync(dirOf(d.stateDir), { recursive: true, mode: 0o700 });
|
|
143
|
+
writeFileSync(file, JSON.stringify(stash, null, 2), { mode: 0o600, flag: "wx" });
|
|
144
|
+
const closed = await closeBase(json.workspace);
|
|
145
|
+
if (closed.status === 200) return { status: 200, body: { stashed: name, sessions: sv.agents.length } };
|
|
146
|
+
// A batch that stopped half way leaves sessions running beside ones that quit: popping would run a session file twice, so the manifest goes.
|
|
147
|
+
rmSync(file, { force: true });
|
|
148
|
+
const quit = sv.tabs.flatMap((t) => t.panes).filter((p) => p.session).map((p) => `${p.name ?? "?"}: pi --session ${p.session}`);
|
|
149
|
+
const why = (closed.body as { reason?: string }).reason ?? "the base did not close";
|
|
150
|
+
return no(closed.status, "stash_failed", `${why} Nothing is stashed; a session that did quit resumes with its own file (${quit.join("; ")}).`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `agentDir`: the popping session's PI_CODING_AGENT_DIR, when it runs under one, so each pi resumes with the same settings and extensions. */
|
|
154
|
+
export interface PopDeps { run: HerdrRun; sleep: Sleep; stateDir: string; live(sessionId: string): Promise<boolean>; agentDir?: string; now?: () => Date }
|
|
155
|
+
const RANK: Record<StashPane["kind"], number> = { tower: 0, main: 1, member: 2, shell: 3 };
|
|
156
|
+
const roleEnv = (kind: StashPane["kind"]) => (kind === "shell" ? [] : [`STRAW_BOSS_ROLE=${kind}`]), flags = (env: string[]) => env.flatMap((e) => ["--env", e]);
|
|
157
|
+
|
|
158
|
+
/** Resumes one pi in its pane and waits until it is back in its role (a member may come back as the tab's main: the guard's choice). */
|
|
159
|
+
async function resume({ run, sleep, live }: PopDeps, ws: string, paneId: string, p: StashPane) {
|
|
160
|
+
if (p.kind === "tower") await report(run, "pane", paneId, { sb_role: "tower" });
|
|
161
|
+
if (p.kind === "main") await report(run, "pane", paneId, { sb_open_main: "1" }); // authorises exactly this pi to promote itself (session.ts)
|
|
162
|
+
// Herdr agent names are unique across workspaces and lowercase only.
|
|
163
|
+
const agent = p.kind === "tower" ? `tower-${ws.toLowerCase()}` : `${p.kind}-${randomUUID().slice(0, 8)}`;
|
|
164
|
+
await startPi(run, sleep, agent, paneId, [...(p.session ? ["--session", p.session] : []), ...(p.name ? ["--name", p.name] : [])]);
|
|
165
|
+
const back = p.kind === "tower"
|
|
166
|
+
? await waitFor(sleep, async () => ((id) => id && live(id))((await workspaces(run)).find((w) => w.workspace_id === ws)?.tokens?.tower), JOIN_MS, 1000)
|
|
167
|
+
: await waitFor(sleep, async () => ((r) => (p.kind === "main" ? r === "main" : r === "member" || r === "main"))((await panes(run, ws)).find((x) => x.pane_id === paneId)?.tokens?.sb_role), JOIN_MS, 500);
|
|
168
|
+
if (!back) throw new Error(`${p.name ?? p.session ?? p.kind} did not come back as the ${p.kind} within ${JOIN_MS / 1000} s (pane ${paneId})`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Opens the stash `name` in a new workspace; on any failure the new workspace closes and the manifest goes back, so popping again starts over.
|
|
173
|
+
* The manifest is claimed first by moving it into stash/.popping/ (one rename, which only one session wins), so two pops never run one session file twice.
|
|
174
|
+
*/
|
|
175
|
+
export async function popBase(d: PopDeps, name: string): Promise<{ workspace: string; label: string; sessions: number }> {
|
|
176
|
+
if (!nameOk(name)) throw new Error(`"${name}" is not a stash name`);
|
|
177
|
+
const claimed = join(dirOf(d.stateDir), ".popping", `${name}.json`);
|
|
178
|
+
mkdirSync(dirname(claimed), { recursive: true, mode: 0o700 });
|
|
179
|
+
try {
|
|
180
|
+
renameSync(stashFile(d.stateDir, name), claimed);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw new Error((error as NodeJS.ErrnoException).code === "ENOENT" ? `Nothing is stashed as "${name}" (or another session is popping it)` : (error as Error).message);
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const r = await open(d, name, claimed);
|
|
186
|
+
mkdirSync(join(dirOf(d.stateDir), "popped"), { recursive: true, mode: 0o700 });
|
|
187
|
+
renameSync(claimed, join(dirOf(d.stateDir), "popped", `${name}-${(d.now?.() ?? new Date()).toISOString().replace(/[:.]/g, "-")}.json`));
|
|
188
|
+
return r;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
renameSync(claimed, stashFile(d.stateDir, name));
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function open(d: PopDeps, name: string, file: string) {
|
|
195
|
+
const { run } = d, st = readStash(d.stateDir, name, file), all = st.tabs.flatMap((t) => t.panes);
|
|
196
|
+
const envOf = (kind: StashPane["kind"]) => [...roleEnv(kind), ...(d.agentDir ? [`PI_CODING_AGENT_DIR=${d.agentDir}`] : [])];
|
|
197
|
+
const gone = [...new Set([...all.filter((p) => p.session && !existsSync(p.session)).map((p) => p.session!), ...all.map((p) => p.cwd).filter((c) => !statSync(c, { throwIfNoEntry: false })?.isDirectory())])];
|
|
198
|
+
if (gone.length) throw new Error(`Nothing was opened: ${gone.join(", ")} no longer exist${gone.length > 1 ? "" : "s"}`);
|
|
199
|
+
const root = st.tabs[0]!.panes[0]!;
|
|
200
|
+
const created = await result(run, ["workspace", "create", "--cwd", root.cwd, "--label", st.label, "--no-focus", ...flags(envOf(root.kind))]);
|
|
201
|
+
const ws: string = created.workspace.workspace_id;
|
|
202
|
+
try {
|
|
203
|
+
let sessions = 0;
|
|
204
|
+
for (const [i, tab] of st.tabs.entries()) {
|
|
205
|
+
const ids: string[] = [];
|
|
206
|
+
for (const [j, p] of tab.panes.entries()) {
|
|
207
|
+
if (i === 0 && j === 0) ids.push(created.root_pane.pane_id as string);
|
|
208
|
+
else if (j === 0) ids.push((await createTab(run, ws, p.cwd, envOf(p.kind), tab.label)).paneId);
|
|
209
|
+
else ids.push((await result(run, ["pane", "split", ids[j - 1]!, "--direction", "right", "--cwd", p.cwd, "--no-focus", ...flags(envOf(p.kind))])).pane.pane_id as string);
|
|
210
|
+
}
|
|
211
|
+
if (i === 0 && tab.label) await run(["tab", "rename", created.tab.tab_id as string, tab.label]);
|
|
212
|
+
const starts = tab.panes.map((p, j) => ({ p, pane: ids[j]! })).filter(({ p }) => p.kind !== "shell").sort((a, b) => RANK[a.p.kind] - RANK[b.p.kind]);
|
|
213
|
+
for (const { p, pane } of starts) (await resume(d, ws, pane, p), sessions++);
|
|
214
|
+
}
|
|
215
|
+
return { workspace: ws, label: st.label, sessions };
|
|
216
|
+
} catch (error) {
|
|
217
|
+
await run(["workspace", "close", ws]).catch(() => {});
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/stashing.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { postServer, readRecord } from "./handover.ts";
|
|
3
|
+
import { workspaces } from "./herdr.ts";
|
|
4
|
+
import type { createHost } from "./host.ts";
|
|
5
|
+
import { listStashes, nameOk, popBase, stashFile } from "./stash.ts";
|
|
6
|
+
import type { Kit } from "./state.ts";
|
|
7
|
+
|
|
8
|
+
// /boss stash and /boss pop, this session's side (stash.ts holds the checks, the manifest, and the reopening). A stash runs in the session
|
|
9
|
+
// that holds the GUI server, the only one that can ask every bridge in the base whether it may be put away and then ask it to quit; this
|
|
10
|
+
// session starts that server when none runs, with no browser. A pop needs no server: it runs here, from any session in Herdr.
|
|
11
|
+
|
|
12
|
+
// The checks take up to three rounds of a few seconds, then every agent has 30 s to quit.
|
|
13
|
+
const STASH_MS = 120_000;
|
|
14
|
+
|
|
15
|
+
export function createStashing({ cfg, s, guiDir, tell }: Kit, { host }: { host: Pick<ReturnType<typeof createHost>, "share" | "sessions"> }) {
|
|
16
|
+
const { run, sleep } = cfg;
|
|
17
|
+
return {
|
|
18
|
+
names: () => listStashes(guiDir),
|
|
19
|
+
/** Stashes this session's base under `name`, by default the workspace label. When this session is in it, it quits with the rest. */
|
|
20
|
+
async stash(given: string | undefined) {
|
|
21
|
+
if (!s.joined && !s.isTower) throw new Error("Only a session of the net stashes its base: this one has not joined");
|
|
22
|
+
const ws = s.at.workspaceId, label = (await workspaces(run)).find((w) => w.workspace_id === ws)?.label ?? ws, name = given?.trim() || label;
|
|
23
|
+
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
|
+
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();
|
|
26
|
+
const record = readRecord(guiDir);
|
|
27
|
+
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
|
+
tell(`Stashing ${label} as "${name}"…`);
|
|
29
|
+
const reply = await postServer(record, "close/stash", { workspace: ws, name }, STASH_MS);
|
|
30
|
+
if (reply.status === 200) return tell(`Stashed ${label} as "${name}" (${String(reply.body.sessions)} sessions); /boss pop ${name} brings it back`);
|
|
31
|
+
const why = typeof reply.body.reason === "string" ? reply.body.reason : reply.status ? `the server answered ${reply.status}` : "the GUI server did not answer";
|
|
32
|
+
tell(reply.status === 404 && reply.body.error === "not_found" ? `The GUI server cannot stash yet (an older pi-straw-boss in session ${shared.owner}); /reload it first` : why, "error");
|
|
33
|
+
},
|
|
34
|
+
/** Pops `given`; with no name, the only stash, or the one picked from the list. */
|
|
35
|
+
async pop(given: string | undefined) {
|
|
36
|
+
const names = listStashes(guiDir);
|
|
37
|
+
if (!names.length) return tell("Nothing is stashed");
|
|
38
|
+
const name = given?.trim() || (names.length === 1 ? names[0] : await s.ui?.select("Pop which stash?", names));
|
|
39
|
+
if (!name) return;
|
|
40
|
+
tell(`Opening "${name}"…`);
|
|
41
|
+
const live = async (id: string) => (await host.sessions()).some((x) => x.id === id);
|
|
42
|
+
const r = await popBase({ run, sleep, stateDir: guiDir, live, agentDir: cfg.env.PI_CODING_AGENT_DIR }, name);
|
|
43
|
+
tell(`Popped "${name}": ${r.sessions} sessions are back in ${r.label}`);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|