SLOPSHOPPER

starbridge-mod

Submits the owner's Starbridge answers into the Claude Code session that asked. A Claude Code mod: a build that cannot load mods skips it, and the starbridge…

newguardstatusprocessnetwork
★ 2v0.1.8-rc.2MITupdated 2026-10-15T0mSIlver/starbridge/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · starbridge-mod
› fix the failing auth test and add an audit log call ● starbridge-mod: starbridge: answers through the agent ● starbridge-mod: starbridge: status 0; retrying in 2 s ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ● starbridge-mod: starbridge: status 0; retrying in 4 s ● starbridge-mod: starbridge: status 0; retrying in 8 s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ starbridge-mod: starbridge: status 0
README

Starbridge mod for Claude Code

When the owner answers a decision, this mod submits the answer into the Claude Code session that asked, as the line starbridge wait prints:

Answer to d_amber-otter-… (Merge #12 now?): Merge

It works in the terminal, the desktop app's Code tab, IDEs and Remote Control.

Install

Install it from the Starbridge marketplace, beside the starbridge plugin (plugin/README.md):

claude plugin install starbridge-mod@starbridge --scope user

A Claude Code build or organization that refuses mods skips this plugin and keeps the skill; answers then wait until a session runs starbridge answers. To try a checkout in one session: claude --plugin-dir mod.

How it works

The mod skips claude -p and Agent SDK scripts, whose runs end with their last turn, told apart by CLAUDE_CODE_ENTRYPOINT (sdk-cli, sdk-ts, sdk-py).

The mod holds no keys. Each session picks one of two paths, and switches when the agent starts or stops.

Through the agent

When the machine's agent (starbridge agent, PROTOCOL.md, "Local agent API") answers on its unix socket, each session talks to it with $.http.fetch:

  • POST /v1/sessions/<id>/hello once per session id, with the session's working directory.
  • GET /v1/sessions/<id>/events?wait=25 back to back, under the 30 s limit on calls from a mod. The agent hands the session only the answers to the decisions it asked.
  • The mod submits each event's line, then confirms it with POST /v1/sessions/<id>/ack; the agent hands an unconfirmed event over again. It skips event types it does not know.
  • POST /v1/sessions/<id>/bye when the session ends.

The socket is $STARBRIDGE_AGENT_SOCKET, else $XDG_RUNTIME_DIR/starbridge/agent.sock for the default config directory, else agent.sock in the config directory, as the CLI works it out; on Windows (OS=Windows_NT), the port file agent.port in the config directory, which names the loopback port and the token every call carries (PROTOCOL.md). A 426 (the agent speaks another API revision) or a call that cannot connect sends the session to the CLI path.

Through the CLI

With no agent, or with STARBRIDGE_NO_AGENT=1, the mod runs the CLI and checks for the agent every 30 s.

  • Every session runs the mod, but only one per machine polls: the session holding the lease in ~/.config/starbridge/mod-poller.json. It runs starbridge answers --session <id> --wait 25 back to back, which stays under the 30 s limit on calls from a mod.
  • The other sessions watch state.json. When it changes, they run starbridge answers --session <id>, which reads local state only.
  • answers returns only answers to decisions that session asked and that no wait has printed. The mod submits each line, then confirms it with starbridge answers --session <id> --ack <ack>; the CLI hands an unconfirmed line over again.
  • A session that does not poll also runs answers every 30 s when state.json has not changed, in case a change went unseen.
  • When a session ends, it gives up the lease and another session starts polling.

On both paths, after a /clear or a /resume the mod uses the new session id; a line that arrives during a /clear waits, unconfirmed, for the old session. After an error the mod waits 2 s, then twice as long after each further error, up to a minute, and shows the error in the status line until a call succeeds.

Claude Code runs a submitted prompt only once the running turn ends. So while the agent works, an answer goes with its next tool result, which it reads at once; one that comes after the turn's last tool call is submitted when the turn ends. The answers one poll brings go as one prompt, a line each (#1597).

A subagent's answer goes to that subagent with $.session.send, during the main agent's turn or not (#1702). For a subagent's Bash command that runs starbridge ask, the mod exports STARBRIDGE_CLAUDE_AGENT=<its agent id> first, which the CLI keeps with the decision; the permission check judges the command without it. A subagent that is running reads the answer at its next tool call, and a finished one is resumed with it. When the send fails, the answer goes to the parent as a prompt that names the subagent. An answer sent to a running subagent stays in mod-sent until a tool round of the subagent ends after the send, so a later process on the session hands an unread one to the parent. A subagent that finishes without another tool round never reads it: the parent gets it then (#1729).

An answer submitted as a prompt stays in ~/.config/starbridge/mod-sent/<session>.json until its turn starts. Claude Code loses a queued prompt when its process exits, so a later process on that session, such as claude --resume, submits it again, once.

Pi

pi/starbridge.ts is the same answer loop as a Pi extension. It ships in the repository's Pi package with the starbridge skill. starbridge setup installs it at the commit of the CLI's release. By hand, at a release tag, v and what starbridge --version prints:

pi install git:github.com/T0mSIlver/starbridge@v<version>

In an interactive or RPC Pi session it submits each answer as a user message, which starts a turn when Pi is idle. When Pi is busy, the answer enters the running turn at its next tool call (deliverAs: "steer"). It also adds its rule (plugin/rules/pi.md) to Pi's system prompt. pi -p gets no loop, so starbridge ask tells the agent there to starbridge wait.

With pi-permission-system installed, pi/permissions.ts registers a starbridge link in its authorizer chain, which the owner turns on with "authorizerChain": ["starbridge"] in its config.json (starbridge config permissions on offers to add it). When a rule says ask, the link runs starbridge hook permission --agent pi, as Claude Code's hook does, and returns the devices' allow (this call only: the chain never lets a link allow for the session) or deny with their message. Meanwhile Pi shows "Answer here", which takes the prompt back from the devices and opens pi-permission-system's own dialog. The link defers to that dialog at once while starbridge config permissions is off, the machine is not paired or the server does not answer, and after 570 s.

With rpiv-ask-user-question installed, each of its questionnaires (rpiv:ask-user:prompt) runs starbridge hook question --agent pi, as opencode's questions do (below). The ask_user_question tool returning stops the CLI, which settles the questions still open. The extension wraps Pi's shared ctx.ui.custom to keep the done of the dialog the package opens right after rpiv:ask-user:blocked, so a device's answers close it as a keyboard submit does; in Pi's RPC mode they come as a follow-up message once the questionnaire closes.

opencode

opencode/starbridge.ts is the same answer loop as an opencode plugin. starbridge setup copies it, with the files it imports from hooks/ and the rule, into ~/.config/opencode/starbridge/, and points ~/.config/opencode/plugins/starbridge.ts at it.

opencode gives commands no session id, so the plugin's shell.env hook sets STARBRIDGE_OPENCODE_SESSION and the session's title for them, and starts that session's loop. Each answer goes in with promptAsync: an idle session starts a turn, a busy one takes it at its next step. opencode run gets no loop, so starbridge ask tells the agent there to starbridge wait. The rule goes into the system prompt through experimental.chat.system.transform.

Each permission.asked event runs starbridge hook permission --agent opencode, which exits at once while starbridge config permissions is off. opencode's dialog stays up meanwhile: the devices' answer is sent through opencode's reply route, and an answer at the keyboard stops the CLI, which settles the prompt on the devices. The devices see what an Allow approves: the command for bash; the paths and the diff for edit, which its edit, write and apply_patch tools ask, with where a move takes a file and which files a patch deletes; the directories and the command for an external_directory ask from its shell. Other permissions show their patterns and metadata. An MCP tool call shows only the tool's name, since opencode's permission event carries none of its arguments. Past 8,000 characters the CLI cuts the longest string and says so.

Each question.asked event (a call of opencode's question tool) runs starbridge hook question --agent opencode, which posts each question with its labels as options and prints the answers once all are in. The plugin sends them through POST /question/{id}/reply. An answer or Esc at the keyboard (question.replied or question.rejected) stops the CLI, which settles the questions on the devices.

Develop

pnpm --filter @starbridge/mod test

e2e/run.ts drives real Claude Code sessions in tmux through each case: idle, mid-turn, /clear and /resume, a hot reload, a server outage, two sessions at once, and a background subagent that asks, answered while it runs and after it finished. It runs against the server app on a random port (--local), or against a real server with a test device (e2e/device.ts) and this machine's CLI config. It needs claude, tmux and starbridge on PATH, and prints one row of timings per case. With --agent it runs starbridge agent for the whole run, and a last row checks that every session answered through it. STARBRIDGE_E2E_CLAUDE_ENV names a file of KEY=value lines the sessions read, such as a CLAUDE_CODE_OAUTH_TOKEN from claude setup-token, so the login stays out of every command line.

The tests run both paths against the CLI, a real agent and the real server, which @starbridge/server/test-support starts on a random port. hooks/agent.ts is the agent path, hooks/poller.ts the CLI path, hooks/switch.ts picks one, and hooks/register.ts connects them to the engine. CI type-checks all but register.ts. To type-check register.ts, load the mod once so the engine writes its types to .claude-plugin/types/, then run tsc -p tsconfig.engine.json.

Source 7 files
hooks/register.ts 340 lines
1import type { Register } from "claude-code";
2import {
3  AgentLoop,
4  HEADERS,
5  isPortFile,
6  type Line,
7  PROOF_HEADER,
8  pidRuns,
9  portTarget,
10  signCall,
11  socketPath,
12} from "./agent.ts";
13import { Handover } from "./handover.ts";
14import { configDir, Poller } from "./poller.ts";
15import { Sent, sessionElsewhere } from "./sent.ts";
16import { forParent, tagAsk, toSubagents, untagAsk } from "./subagent.ts";
17import { Switch, wantsLoop } from "./switch.ts";
18
19/**
20 * Starts the answer loop in each session a person keeps open (`wantsLoop`): through the machine's agent when it runs
21 * (agent.ts), else through the CLI (poller.ts); switch.ts picks. A hot reload loads this module
22 * afresh: the old loop dies with the old module, and `session.start` starts a new one. While the
23 * main agent's turn runs, an answer goes out with its next tool result instead (handover.ts).
24 * An answer submitted as a prompt stays on record until its turn starts, so a process that exits
25 * first does not lose it (sent.ts).
26 */
27/**
28 * Exits 0 when "$1" is a file owned by this user or root that neither its group nor others may
29 * write: what `ownedFile` (node.ts) checks, for a host that reads no owner or mode.
30 */
31const OWNED =
32  '[ -n "$(find -L "$1" -prune -type f \\( -user "$(id -u)" -o -user 0 \\) ! -perm -020 ! -perm -002 2>/dev/null)" ]';
33
34export const register: Register = (on) => {
35  let loop: Switch | undefined;
36  /** Prompts submitted whose turn has not started. */
37  let waiting = 0;
38  // `$.prompt.submit` resolves when the prompt's turn starts: mid-turn, once the running one ends.
39  const track = (submitted: Promise<unknown>) => {
40    waiting++;
41    const started = () => void waiting--;
42    submitted.then(started, started);
43    return submitted;
44  };
45  const handover = new Handover();
46  let sent: Sent | undefined;
47  /** Answers a subagent could not take: they go to the parent from then on (#1702). */
48  const failed = new Set<string>();
49  /** When each subagent's last tool call ended: a message sent before then, it has read. */
50  const roundEnded = new Map<string, number>();
51
52  on("session.start", async ($, e, next) => {
53    handover.ended();
54    const started = await next(e);
55    if (!wantsLoop(e.isInteractive, await $.env.get("CLAUDE_CODE_ENTRYPOINT"))) return started;
56    // The new loop takes over the lines the old one submitted and has not confirmed yet, so it
57    // confirms them rather than submitting them again (#1337).
58    const before = loop;
59    void before?.stop();
60    const env = {
61      STARBRIDGE_AGENT_SOCKET: await $.env.get("STARBRIDGE_AGENT_SOCKET"),
62      STARBRIDGE_CONFIG_DIR: await $.env.get("STARBRIDGE_CONFIG_DIR"),
63      STARBRIDGE_NO_AGENT: await $.env.get("STARBRIDGE_NO_AGENT"),
64      CLAUDE_CONFIG_DIR: await $.env.get("CLAUDE_CONFIG_DIR"),
65      XDG_CONFIG_HOME: await $.env.get("XDG_CONFIG_HOME"),
66      XDG_RUNTIME_DIR: await $.env.get("XDG_RUNTIME_DIR"),
67      HOME: await $.env.get("HOME"),
68      USERPROFILE: await $.env.get("USERPROFILE"),
69      OS: await $.env.get("OS"),
70    };
71    const dir = configDir(env);
72    const socket = socketPath(env);
73    // Kept across a hot reload, and new in each process (sent.ts); undefined on a build that
74    // lacks `$.state`.
75    const proc = { plugin: "starbridge-mod", key: "process" } as const;
76    let token: string | undefined;
77    try {
78      token = (await $.state.get(proc)).value;
79      if (!token) {
80        token = crypto.randomUUID();
81        await $.state.set(proc, token);
82      }
83    } catch {
84      token = undefined;
85    }
86    const fetch = async (method: string, path: string, body?: unknown) => {
87      const headers =
88        body === undefined ? HEADERS : { ...HEADERS, "content-type": "application/json" };
89      const init = { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) };
90      let r: Awaited<ReturnType<typeof $.http.fetch>>;
91      if (isPortFile(socket)) {
92        // Read each call: the agent writes a new port and token each time it starts.
93        const t = portTarget(await $.fs.read(socket));
94        const runs =
95          t &&
96          (await pidRuns(t.pid, env.OS === "Windows_NT", (argv) =>
97            $.process.run(argv, { timeoutMs: 10_000 }),
98          ));
99        if (!t || !runs) throw new Error(`no agent on ${socket}`);
100        const signed = await signCall(t.token);
101        r = await $.http.fetch(`http://127.0.0.1:${t.port}${path}`, {
102          ...init,
103          headers: { ...headers, ...signed.headers },
104        });
105        if (r.headers[PROOF_HEADER] !== signed.expect)
106          throw new Error(`no agent on ${socket}: the port answers without its proof`);
107      } else
108        r = await $.http.fetch(`http://agent${path}`, { ...init, socketPath: socket, headers });
109      return { status: r.status, text: r.text };
110    };
111    const log = (text: string) => $.ui.log(text, { to: "debug" });
112    const status = (text: string | undefined) => $.ui.status(text);
113    const sessionId = () => $.session.id();
114    const now = () => $.clock.now();
115    const sleep = (ms: number) => $.clock.sleep(ms);
116    const records = new Sent(
117      {
118        sessionId,
119        read: async (path) => ((await $.fs.exists(path)) ? $.fs.read(path) : undefined),
120        write: (path, text) => $.fs.write(path, text),
121        now,
122        submit: (text) => track($.prompt.submit({ text })),
123        elsewhere: async (session) => {
124          // Claude Code's own pid, as the parent of the shell it runs; Windows has no `sh`.
125          if (env.OS === "Windows_NT") return false;
126          const own = Number(
127            (await $.process.run(["sh", "-c", "echo $PPID"], { timeoutMs: 10_000 })).stdout.trim(),
128          );
129          if (!own) return false;
130          return sessionElsewhere(
131            session,
132            own,
133            { list: (path) => $.fs.list(path), read: (path) => $.fs.read(path) },
134            `${env.CLAUDE_CONFIG_DIR || `${env.HOME}/.claude`}/sessions`,
135            (pid) => pidRuns(pid, false, (argv) => $.process.run(argv, { timeoutMs: 10_000 })),
136          );
137        },
138        log,
139      },
140      dir,
141      token,
142    );
143    sent = records;
144    /** Whether subagent `agentId` runs now; when unsure, it does. */
145    const running = async (agentId: string) => {
146      try {
147        const status = (await $.agent.list()).find((a) => a.id === agentId)?.status;
148        return status === undefined || ["pending", "running", "waiting"].includes(status);
149      } catch {
150        return true;
151      }
152    };
153    // A subagent's answers go to it, whatever the main agent does (#1702).
154    const submit = async (lines: Line[]) => {
155      const me = await sessionId();
156      const { taken, rest } = await toSubagents(
157        lines,
158        async (agentId, group, text) => {
159          const send = () => $.session.send({ to: { agentId }, text });
160          // A subagent that ended is resumed with the message as its prompt, which its transcript
161          // keeps; one that runs holds it in memory until its next tool round.
162          if (!(await running(agentId))) return send();
163          return records.toAgent(
164            agentId,
165            group.map((l) => forParent(l, agentId)),
166            send,
167            () => running(agentId),
168          );
169        },
170        log,
171        failed,
172      );
173      // A /clear or /resume during the sends: the rest waits, unconfirmed, for session `me`.
174      if (rest.length > 0 && (await sessionId()) !== me) return taken;
175      const parent =
176        rest.length > 0 ? await handover.submit(rest, (left) => records.send(left)) : [];
177      return [...taken, ...parent];
178    };
179    const starting = () => waiting > 0;
180    loop = new Switch(
181      {
182        // The CLI honours STARBRIDGE_NO_AGENT too, so the CLI path then reaches the server itself.
183        agentUp: async () =>
184          !env.STARBRIDGE_NO_AGENT && (await fetch("GET", "/v1/status")).status < 300,
185        agent: (unconfirmed) =>
186          new AgentLoop(
187            {
188              sessionId,
189              cwd: () => $.session.cwd(),
190              fetch,
191              now,
192              sleep,
193              submit,
194              starting,
195              status,
196              log,
197            },
198            unconfirmed,
199          ),
200        poller: (unconfirmed) =>
201          new Poller(
202            {
203              sessionId,
204              run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }),
205              read: (path) => $.fs.read(path),
206              write: (path, text) => $.fs.write(path, text),
207              mtime: async (path) => (await $.fs.stat(path).catch(() => undefined))?.mtimeMs,
208              // The mod's host reads no owner or mode: find does, as plugin/hooks/cli.sh. Windows
209              // has no `sh`, and keeps owners in ACLs: there, as `ownedFile`, a file.
210              owned: async (path) =>
211                env.OS === "Windows_NT"
212                  ? (await $.fs.stat(path).catch(() => undefined))?.kind === "file"
213                  : (
214                      await $.process
215                        .run(["sh", "-c", OWNED, "sh", path], { timeoutMs: 10_000 })
216                        .catch(() => undefined)
217                    )?.exitCode === 0,
218              now,
219              sleep,
220              submit,
221              starting,
222              status,
223              log,
224            },
225            dir,
226            undefined,
227            undefined,
228            unconfirmed,
229          ),
230        sleep,
231        clearStatus: () => status(undefined),
232        log,
233      },
234      undefined,
235      before?.unconfirmed,
236    );
237    void records.recover().catch((e) => log(`starbridge: ${(e as Error).message}`));
238    return started;
239  });
240
241  // A subagent's run raises no `turn.start`; its `turn.complete` and tool calls carry its `agentId`.
242  on("turn.start", async (_$, e, next) => {
243    handover.started();
244    await sent?.started(e.text).catch(() => undefined);
245    return next(e);
246  });
247
248  on("turn.complete", async ($, e, next) => {
249    const r = await next(e);
250    if (e.agentId !== undefined) {
251      const agent = e.agentId;
252      const ended = roundEnded.get(agent);
253      if (ended !== undefined) void sent?.read(agent, ended).catch(() => undefined);
254      // A message that came after its last round waits for another one, which a subagent that
255      // finished never runs: the parent gets it instead (#1729).
256      void (async () => {
257        const list = () => $.agent.list();
258        if (!sent || !(await finished(list, (ms) => $.clock.sleep(ms), agent))) return;
259        roundEnded.delete(agent);
260        if (await sent.finished(agent, ended)) await sent.recover();
261      })().catch(() => undefined);
262    } else {
263      handover.ended();
264      // After a `/resume`, or a submit that failed.
265      void sent?.recover().catch(() => undefined);
266    }
267    return r;
268  });
269
270  on("tool.call", async ($, e, next) => {
271    if (e.agentId !== undefined) {
272      const agent = e.agentId;
273      // A new round: the subagent read every message sent before its last one ended.
274      const ended = roundEnded.get(agent);
275      if (ended !== undefined) void sent?.read(agent, ended).catch(() => undefined);
276      // The CLI keeps which subagent asked, so its answer goes back there (#1702).
277      const command =
278        e.tool === "Bash" && typeof e.command === "string" ? tagAsk(e.command, agent) : undefined;
279      const r = await next(command !== undefined && command !== e.command ? { ...e, command } : e);
280      roundEnded.set(agent, await $.clock.now());
281      return r;
282    }
283    const r = await next(e);
284    if (r.deny !== undefined) return r;
285    const lines = await handover.take((acks) => loop?.claim(acks) ?? Promise.resolve([]));
286    if (lines.length === 0) return r;
287    $.ui.log(`starbridge: handed ${lines.length} answer(s) with the ${e.tool} result`, {
288      to: "debug",
289    });
290    return { ...r, context: [...(r.context ?? []), ...lines] };
291  });
292
293  // The tag `tool.call` adds is not the subagent's, so the rules judge the command it wrote: where
294  // they would ask about the tagged one, the verdict on the untagged one stands (#1702).
295  on("tool.check", async ($, e, next) => {
296    const verdict = await next(e);
297    const input = e.input as { command?: unknown } | undefined;
298    if (verdict.decision !== "ask" || verdict.hook !== undefined) return verdict;
299    if (e.tool !== "Bash" || typeof input?.command !== "string") return verdict;
300    const command = untagAsk(input.command);
301    if (command === input.command) return verdict;
302    const plain = await $.tool.check({ tool: "Bash", input: { ...input, command } });
303    return plain.decision === "ask" ? verdict : plain;
304  });
305
306  // After a /clear or a /resume the process goes on under another session id, with no
307  // `session.start`, and the loop reads the id each step. Only the end of the process stops it.
308  on("session.end", async (_$, e, next) => {
309    handover.ended();
310    if (e.reason !== "clear" && e.reason !== "resume") {
311      await loop?.end();
312      loop = undefined;
313    }
314    return next(e);
315  });
316};
317
318/** A subagent's statuses once it runs no more rounds. */
319const DONE = ["completed", "failed", "killed"];
320
321/**
322 * Whether subagent `agentId`, whose turn just completed, runs no more rounds: done, or no longer
323 * listed. The engine may mark it a moment after its `turn.complete`, so it looks twice. An idle
324 * teammate wakes for a message, so it has not; nor, when the list cannot be read, has any.
325 */
326export async function finished(
327  list: () => Promise<{ id: string; status: string }[]>,
328  sleep: (ms: number) => Promise<void>,
329  agentId: string,
330): Promise<boolean> {
331  for (let i = 0; i < 2; i++) {
332    if (i > 0) await sleep(2000);
333    const agents = await list().catch(() => undefined);
334    if (!agents) return false;
335    const status = agents.find((a) => a.id === agentId)?.status;
336    if (status === undefined || DONE.includes(status)) return true;
337  }
338  return false;
339}
340
hooks/agent.ts 346 lines
1/**
2 * The mod as a thin client of the machine's Starbridge agent (PROTOCOL.md, "Local agent API").
3 *
4 * Each session long-polls `GET /v1/sessions/<id>/events?wait=25` on the agent's unix socket,
5 * submits each event's line into the session and confirms it with `POST /v1/sessions/<id>/ack`.
6 * The agent holds the keys and the one connection to the server, so no session runs the CLI,
7 * takes a lease or watches files. When no agent answers, `Switch` falls back to `Poller`, the
8 * CLI-driven loop, until one does.
9 */
10
11import { configDir } from "./poller.ts";
12
13/** The agent API revision this mod speaks, sent in the `starbridge-api` header. */
14export const API = 1;
15export const VERSION = "0.1.8-rc.2";
16const CLIENT = `starbridge-mod/${VERSION}`;
17
18/** Event types this mod submits; others are skipped and left unconfirmed, as the API asks. */
19const KNOWN = new Set(["answer"]);
20
21/** An answer's line, and what confirms it. */
22export interface Line {
23  text: string;
24  ack: string;
25  /** The Claude Code subagent that asked, which gets the line instead of the session (#1702). */
26  subagent?: string;
27}
28
29/** Several answers as one message: one line each, in the order they came. */
30export const joinLines = (lines: Line[]) => lines.map((l) => l.text).join("\n");
31
32export interface Reply {
33  status: number;
34  text: string;
35}
36
37export interface AgentHost {
38  /** The session's id now; a `/clear` changes it. */
39  sessionId(): Promise<string>;
40  cwd(): Promise<string>;
41  /** One HTTP call on the agent's socket. Rejects when it cannot connect or the host aborts it. */
42  fetch(method: string, path: string, body?: unknown): Promise<Reply>;
43  now(): Promise<number>;
44  sleep(ms: number): Promise<void>;
45  /**
46   * Submits `lines`, every answer one poll brought, as one message (#1597), and returns the acks
47   * of those it submitted; a line it could not submit, or holds back for now, stays unconfirmed
48   * for a later try. Must not wait for the turn to start: a prompt submitted mid-turn waits for
49   * its own.
50   */
51  submit(lines: Line[]): string[] | Promise<string[]>;
52  /**
53   * True while a submitted prompt waits for its turn. Claude Code starts a plugin's queued prompt
54   * only once the mod's open `fetch` or `run` returns, so the loop then holds no long call (#1446).
55   */
56  starting(): boolean;
57  status(text: string | undefined): void;
58  log(text: string): void;
59}
60
61export interface AgentTiming {
62  /** Seconds the agent may hold one events request (the host aborts a call after 30 s). */
63  waitSeconds: number;
64  /**
65   * An events request that comes back sooner than this with nothing waits out the rest; while a
66   * prompt waits for its turn, requests don't wait and this is the time between them.
67   */
68  minCycleMs: number;
69  /** First retry delay after an error, doubled each time up to `maxBackoffMs`. */
70  backoffMs: number;
71  maxBackoffMs: number;
72}
73
74export const AGENT_TIMING: AgentTiming = {
75  waitSeconds: 25,
76  minCycleMs: 2_000,
77  backoffMs: 2_000,
78  maxBackoffMs: 60_000,
79};
80
81/**
82 * Where the agent listens, as the CLI's `socketPath` (cli/src/agent/api.ts) works it out:
83 * `$STARBRIDGE_AGENT_SOCKET`; on Windows (`OS=Windows_NT`) the port file `agent.port` in the
84 * config directory; for the default config directory `$XDG_RUNTIME_DIR/starbridge/agent.sock`
85 * when that is set; else `agent.sock` in the config directory.
86 */
87export function socketPath(env: {
88  STARBRIDGE_AGENT_SOCKET?: string;
89  STARBRIDGE_CONFIG_DIR?: string;
90  XDG_CONFIG_HOME?: string;
91  XDG_RUNTIME_DIR?: string;
92  HOME?: string;
93  USERPROFILE?: string;
94  OS?: string;
95}): string {
96  if (env.STARBRIDGE_AGENT_SOCKET) return env.STARBRIDGE_AGENT_SOCKET;
97  const dir = configDir(env);
98  if (env.OS === "Windows_NT") return `${dir}/agent.port`;
99  const standard = configDir({ ...env, STARBRIDGE_CONFIG_DIR: undefined });
100  if (env.XDG_RUNTIME_DIR && dir === standard)
101    return `${env.XDG_RUNTIME_DIR}/starbridge/agent.sock`;
102  return `${dir}/agent.sock`;
103}
104
105/**
106 * An address ending in `.port` names the file where an agent on loopback TCP wrote its port and
107 * its token, which calls prove they hold without sending it (the CLI's `PortFile` and `proof`).
108 */
109export const isPortFile = (address: string) => address.endsWith(".port");
110
111/**
112 * The port, the token and the agent's pid in port file `text`; undefined when it is not one, as
113 * the CLI's `readPortFile` reads it.
114 */
115export function portTarget(text: string): { port: number; token: string; pid: number } | undefined {
116  try {
117    const f = JSON.parse(text) as { port?: unknown; token?: unknown; pid?: unknown };
118    if (
119      Number.isInteger(f.port) &&
120      typeof f.token === "string" &&
121      f.token.length >= 32 &&
122      Number.isInteger(f.pid)
123    )
124      return { port: f.port as number, token: f.token, pid: f.pid as number };
125  } catch {}
126  return undefined;
127}
128
129/**
130 * Whether process `pid` runs, for a host that can run commands but not signal a process:
131 * `tasklist` on Windows, `ps` elsewhere. A call sends nothing to a port whose agent's pid runs no
132 * more, as the CLI does (#570): a dead agent's port shows in `netstat` to every local user, and
133 * whoever takes it would get the call's body before failing its proof. A check that cannot run
134 * counts as not running, so the mod falls back to the CLI.
135 */
136export async function pidRuns(
137  pid: number,
138  windows: boolean,
139  run: (argv: string[]) => Promise<{ exitCode: number | null; stdout: string }>,
140): Promise<boolean> {
141  try {
142    if (windows) {
143      // One CSV row per match, `"image","pid",…`; with none, a message in the system's language.
144      const r = await run(["tasklist", "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]);
145      return r.exitCode === 0 && new RegExp(`^"[^"]*","${pid}",`, "m").test(r.stdout);
146    }
147    const r = await run(["ps", "-p", String(pid), "-o", "pid="]);
148    return r.exitCode === 0 && r.stdout.trim() === String(pid);
149  } catch {
150    return false;
151  }
152}
153
154const hex = (bytes: Uint8Array) => [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
155
156async function proof(token: string, role: "client" | "agent", nonce: string): Promise<string> {
157  const data = new TextEncoder().encode(`${token}:${role}:${nonce}`);
158  return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", data)));
159}
160
161/**
162 * The headers that prove a call holds `token`, and the `starbridge-proof` header the agent's
163 * answer must carry: anything that answers without it took the port of an agent that stopped.
164 */
165export async function signCall(token: string): Promise<{
166  headers: Record<string, string>;
167  expect: string;
168}> {
169  const nonce = hex(crypto.getRandomValues(new Uint8Array(16)));
170  return {
171    headers: {
172      "starbridge-nonce": nonce,
173      authorization: `Starbridge ${await proof(token, "client", nonce)}`,
174    },
175    expect: await proof(token, "agent", nonce),
176  };
177}
178
179export const PROOF_HEADER = "starbridge-proof";
180
181/** The headers every call carries. */
182export const HEADERS: Record<string, string> = {
183  "starbridge-api": String(API),
184  "user-agent": CLIENT,
185};
186
187interface Event {
188  type?: unknown;
189  ack?: unknown;
190  line?: unknown;
191  decisionId?: unknown;
192  subagent?: unknown;
193}
194
195/** Why `AgentLoop.run` returned: `stop`, or the agent cannot serve this mod. */
196export type Ended = "stopped" | "unreachable";
197
198export class AgentLoop {
199  private stopped = false;
200  private failures = 0;
201  /** The session id `hello` last went out under. */
202  private greeted: string | undefined;
203
204  constructor(
205    private readonly host: AgentHost,
206    /** Lines submitted but not yet confirmed, shared with `Poller` so a switch submits nothing twice. */
207    private readonly unconfirmed: Set<string>,
208    private readonly t: AgentTiming = AGENT_TIMING,
209  ) {}
210
211  /** Ends the loop after the current step. */
212  stop() {
213    this.stopped = true;
214  }
215
216  /** Runs until `stop`, or until a call cannot reach the agent or gets a 426. */
217  async run(): Promise<Ended> {
218    while (!this.stopped) {
219      try {
220        await this.step();
221      } catch (e) {
222        // A call that never connected, one the host aborted (the agent is gone or hung), or a
223        // 426 (it speaks another API revision).
224        this.host.log(`starbridge: agent unreachable: ${(e as Error).message}`);
225        return "unreachable";
226      }
227    }
228    return "stopped";
229  }
230
231  /**
232   * Confirms the events among `acks` that the agent still hands this session, and returns those:
233   * a line a `wait` or `waiting` printed meanwhile is not handed over again (#1484).
234   */
235  async claim(acks: string[]): Promise<string[]> {
236    const me = await this.host.sessionId();
237    const r = await this.host.fetch("GET", `${path(me)}/events?wait=0`);
238    if (r.status < 200 || r.status >= 300) return [];
239    const pending = new Set((parse<{ events?: Event[] }>(r.text)?.events ?? []).map((e) => e.ack));
240    const mine = acks.filter((a) => pending.has(a));
241    if (mine.length === 0) return [];
242    const ack = await this.host.fetch("POST", `${path(me)}/ack`, { acks: mine });
243    if (ack.status < 200 || ack.status >= 300) return [];
244    // What it confirmed now: not a line a `wait` or `waiting` took since the read. An agent older
245    // than that names none.
246    return parse<{ acked?: string[] }>(ack.text)?.acked ?? mine;
247  }
248
249  /** Tells the agent the session ended, so its session-scoped state goes. */
250  async bye(): Promise<void> {
251    const me = this.greeted;
252    if (!me) return;
253    await this.host.fetch("POST", `${path(me)}/bye`).catch(() => undefined);
254  }
255
256  private async step() {
257    const me = await this.host.sessionId();
258    if (!me) {
259      await this.host.sleep(this.t.minCycleMs);
260      return;
261    }
262    if (this.greeted !== me) {
263      // After a /clear: the old id has no mod any more (#537).
264      const replaces = this.greeted ? { replaces: this.greeted } : {};
265      const r = await this.host.fetch("POST", `${path(me)}/hello`, {
266        cwd: await this.host.cwd(),
267        ...replaces,
268      });
269      if (!(await this.ok(r))) return;
270      this.greeted = me;
271    }
272    const started = await this.host.now();
273    const wait = this.host.starting() ? 0 : this.t.waitSeconds;
274    const r = await this.host.fetch("GET", `${path(me)}/events?wait=${wait}`);
275    if (!(await this.ok(r))) return;
276    const events = (parse<{ events?: Event[] }>(r.text)?.events ?? []).filter(
277      (e): e is Event & { ack: string; line: string } =>
278        typeof e.type === "string" &&
279        KNOWN.has(e.type) &&
280        typeof e.ack === "string" &&
281        typeof e.line === "string",
282    );
283    // A /clear during the call made another session current: the events wait, unconfirmed,
284    // until session `me` is resumed.
285    if (events.length > 0 && (await this.host.sessionId()) !== me) {
286      this.host.log(
287        `starbridge: held back the answers ${events.map((l) => l.ack).join(", ")}: their session ${me} ended`,
288      );
289      return;
290    }
291    const due = events.filter((e) => !this.unconfirmed.has(e.ack));
292    const taken =
293      due.length > 0
294        ? await this.host.submit(
295            due.map((e) => ({
296              text: e.line,
297              ack: e.ack,
298              ...(typeof e.subagent === "string" ? { subagent: e.subagent } : {}),
299            })),
300          )
301        : [];
302    for (const a of taken) this.unconfirmed.add(a);
303    const handed = taken.length;
304    const done = events.filter((e) => this.unconfirmed.has(e.ack)).map((e) => e.ack);
305    if (done.length > 0) {
306      const ack = await this.host.fetch("POST", `${path(me)}/ack`, { acks: done });
307      if (!(await this.ok(ack))) return;
308      for (const a of done) this.unconfirmed.delete(a);
309    }
310    const left = started + this.t.minCycleMs - (await this.host.now());
311    if (handed === 0 && left > 0) await this.host.sleep(left);
312  }
313
314  /** True on a 2xx; otherwise shows the agent's reason and waits before the next try. */
315  private async ok(r: Reply): Promise<boolean> {
316    if (r.status >= 200 && r.status < 300) {
317      if (this.failures > 0) this.host.status(undefined);
318      this.failures = 0;
319      return true;
320    }
321    const body = parse<{ error?: string; detail?: string }>(r.text);
322    const reason = body?.detail ?? body?.error ?? `status ${r.status}`;
323    // 426: the agent and this mod cannot work together; the CLI's own path still can.
324    if (r.status === 426) throw new Error(reason);
325    this.failures++;
326    const delay = Math.min(this.t.maxBackoffMs, this.t.backoffMs * 2 ** (this.failures - 1));
327    const text = `starbridge: ${reason}`;
328    this.host.status(text);
329    this.host.log(`${text}; retrying in ${Math.round(delay / 1000)} s`);
330    await this.host.sleep(delay);
331    return false;
332  }
333}
334
335function path(session: string): string {
336  return `/v1/sessions/${encodeURIComponent(session)}`;
337}
338
339function parse<T>(text: string): T | undefined {
340  try {
341    return JSON.parse(text) as T;
342  } catch {
343    return undefined;
344  }
345}
346
hooks/handover.ts 71 lines
1import type { Line } from "./agent.ts";
2
3/**
4 * When an answer reaches the agent (#1484, #1584). Claude Code starts a prompt the mod submits only
5 * after the running turn, which can be minutes away, and the turn meanwhile works without the
6 * owner's word. So while the main agent's turn runs, its next tool result carries the answer as
7 * context, waiting or not, as Claude Code does with a message typed during a turn. An answer that
8 * comes after the turn's last tool call goes as a prompt once the turn ends. The mod's own
9 * `prompt.submit` hook never sees its own prompts, so it could not drop a second copy: a line goes
10 * out one way only.
11 */
12export class Handover {
13  private running = false;
14  /** Lines for the main agent's next tool call, by their line, with what confirms each. */
15  private readonly next = new Map<string, string>();
16  /**
17   * Lines a tool result carried, confirmed then: a loop that read one before that, and submits it
18   * now, finds it here and submits nothing.
19   */
20  private readonly handed = new Set<string>();
21
22  /**
23   * The loops' submit. Submits `lines` as one prompt with `enter` while no turn runs. During one,
24   * keeps them, with the acks that confirm them, for the next tool call, and takes none: they
25   * stay unconfirmed and come back in the next poll. A line a tool result already carried counts
26   * as taken without a second submit. Returns the acks taken, with those `enter` reports it took.
27   */
28  async submit(
29    lines: Line[],
30    enter: (lines: Line[]) => string[] | Promise<string[]>,
31  ): Promise<string[]> {
32    const carried = lines.filter((l) => this.handed.delete(l.text));
33    const rest = lines.filter((l) => !carried.includes(l));
34    if (this.running) {
35      for (const l of rest) this.next.set(l.text, l.ack);
36      return carried.map((l) => l.ack);
37    }
38    const entered = rest.length > 0 ? await enter(rest) : [];
39    return [...carried.map((l) => l.ack), ...entered];
40  }
41
42  /** The main agent's turn started. */
43  started() {
44    this.running = true;
45  }
46
47  /**
48   * The lines a main-agent tool result carries. `claim` confirms them first and returns the acks
49   * still due: a line a `wait` or `waiting` printed meanwhile goes no further, and one confirmed
50   * here is not handed over again after a reload or a restart.
51   */
52  async take(claim: (acks: string[]) => Promise<string[]>): Promise<string[]> {
53    if (this.next.size === 0) return [];
54    const lines = [...this.next];
55    this.next.clear();
56    const due = new Set(await claim(lines.map(([, ack]) => ack)));
57    const out = lines.filter(([, ack]) => due.has(ack)).map(([line]) => line);
58    for (const line of out) this.handed.add(line);
59    return out;
60  }
61
62  /**
63   * No turn runs: the main agent's turn ended, or the session started or ended. Lines kept for a
64   * tool call are still unconfirmed, so the next poll submits them, for the session that asked.
65   */
66  ended() {
67    this.running = false;
68    this.next.clear();
69  }
70}
71
hooks/poller.ts 348 lines
1import type { Line } from "./agent.ts";
2
3/**
4 * The answer loop, apart from the engine's `$` so plain tests can drive it.
5 *
6 * Every session that loads the mod runs this loop, but only one per machine polls the server: the
7 * holder of a lease file in the CLI's config directory. The holder runs `starbridge answers
8 * --session <its id> --wait 25` back to back (the host aborts a call after 30 s); the others
9 * watch the CLI's state file and run `starbridge answers --session <id>` without `--wait` when
10 * it changes, which never touches the network. The CLI holds the keys, opens and verifies each
11 * answer, and hands a session only the answers to decisions it asked, until the poller confirms
12 * it submitted them with `starbridge answers --ack`.
13 */
14
15export interface Host {
16  /** The session's id now; a `/clear` changes it. */
17  sessionId(): Promise<string>;
18  run(
19    argv: string[],
20    timeoutMs: number,
21  ): Promise<{ exitCode: number | null; stdout: string; stderr: string }>;
22  /** Rejects when the file is missing. */
23  read(path: string): Promise<string>;
24  write(path: string, text: string): Promise<void>;
25  /** Undefined when the file is missing. */
26  mtime(path: string): Promise<number | undefined>;
27  /**
28   * Whether `path` is a file owned by this user or root that no one else may write (`ownedFile`,
29   * node.ts), so another user cannot pick the CLI that starts.
30   */
31  owned(path: string): Promise<boolean>;
32  now(): Promise<number>;
33  sleep(ms: number): Promise<void>;
34  /** `AgentHost.submit`: one message for the lines one read brought, returning the acks it took. */
35  submit(lines: Line[]): string[] | Promise<string[]>;
36  /** True while a submitted prompt waits for its turn: `AgentHost.starting`. */
37  starting(): boolean;
38  status(text: string | undefined): void;
39  log(text: string): void;
40}
41
42export interface Timing {
43  /** Seconds the server may hold one poll. */
44  waitSeconds: number;
45  /** How often a session that does not poll checks the state file. */
46  checkMs: number;
47  /** How often it reads its answers even when no change of the state file was seen. */
48  recheckMs: number;
49  /** How long a lease lasts past its last renewal. */
50  leaseMs: number;
51  /**
52   * A poll that comes back sooner than this with nothing waits out the rest; while a prompt waits
53   * for its turn, polls don't wait and this is the time between them.
54   */
55  minCycleMs: number;
56  /** First retry delay after an error, doubled each time up to `maxBackoffMs`. */
57  backoffMs: number;
58  maxBackoffMs: number;
59}
60
61export const TIMING: Timing = {
62  waitSeconds: 25,
63  checkMs: 2_000,
64  recheckMs: 30_000,
65  leaseMs: 60_000,
66  minCycleMs: 2_000,
67  backoffMs: 2_000,
68  maxBackoffMs: 60_000,
69};
70
71interface Lease {
72  session: string;
73  until: number;
74}
75
76/**
77 * `$STARBRIDGE_CONFIG_DIR`, else `$XDG_CONFIG_HOME/starbridge`, else `~/.config/starbridge`, as
78 * the CLI; on Windows (`OS=Windows_NT`) home is `USERPROFILE`, as the CLI reads it, whatever
79 * `HOME` a Git Bash set.
80 */
81/** Whether `path` is absolute, on Windows too: a relative one would depend on the agent's folder. */
82export const absolute = (path: string) => /^(\/|[A-Za-z]:[\\/])/.test(path);
83
84export function configDir(env: {
85  STARBRIDGE_CONFIG_DIR?: string;
86  XDG_CONFIG_HOME?: string;
87  HOME?: string;
88  USERPROFILE?: string;
89  OS?: string;
90}): string {
91  if (env.STARBRIDGE_CONFIG_DIR) return env.STARBRIDGE_CONFIG_DIR;
92  const home = env.OS === "Windows_NT" ? env.USERPROFILE || env.HOME : env.HOME || env.USERPROFILE;
93  return `${env.XDG_CONFIG_HOME || `${home}/.config`}/starbridge`;
94}
95
96/** The CLI's first error line, without the "starbridge: " the status line adds again. */
97function firstLine(stderr: string): string {
98  return (stderr.trim().split("\n")[0] ?? "").replace(/^starbridge:\s*/, "");
99}
100
101export class Poller {
102  private stopped = false;
103  private failures = 0;
104  /** Lines the host held back in the last `answers`. */
105  private held = 0;
106  /** The state file's time when this session last read it; a /clear makes it unread again. */
107  private seen: { session: string; mtime: number; at: number } | undefined;
108  /** The session id this poller last held the lease under; a `/clear` changes the id. */
109  private leasedAs: string | undefined;
110  private readonly leasePath: string;
111  private readonly cliPath: string;
112  private readonly statePath: string;
113  /** Resolves when the loop has ended. */
114  readonly done: Promise<void>;
115
116  constructor(
117    private readonly host: Host,
118    dir: string,
119    /** The CLI; by default the path setup recorded in `dir`, else `starbridge` on the PATH (#612). */
120    private readonly command?: string,
121    private readonly t: Timing = TIMING,
122    /**
123     * Lines submitted but not yet confirmed, so a retried confirm submits nothing twice; shared
124     * with `AgentLoop` (agent.ts) across a switch.
125     */
126    private readonly unconfirmed = new Set<string>(),
127  ) {
128    this.leasePath = `${dir}/mod-poller.json`;
129    this.cliPath = `${dir}/cli-path`;
130    this.statePath = `${dir}/state.json`;
131    // Nothing awaits a loop that ended before `stop`: a rejection would take the harness down (#1365).
132    this.done = this.loop().catch((e) => {
133      try {
134        this.host.log(`starbridge: the answer loop stopped: ${(e as Error).message}`);
135      } catch {}
136    });
137  }
138
139  /**
140   * Ends the loop after the current step and gives up the lease at once, so another session polls.
141   * Never rejects: it runs after the session ended, when the host may throw (#1365).
142   */
143  async stop(): Promise<void> {
144    this.stopped = true;
145    try {
146      const me = await this.host.sessionId();
147      const lease = await this.readLease();
148      if (lease && (lease.session === me || lease.session === this.leasedAs))
149        await this.host.write(this.leasePath, JSON.stringify({ ...lease, until: 0 }));
150    } catch {
151      // The lease runs out on its own.
152    }
153  }
154
155  private async cli(): Promise<string> {
156    if (this.command) return this.command;
157    try {
158      if (!(await this.host.owned(this.cliPath))) return "starbridge";
159      const recorded = (await this.host.read(this.cliPath)).trim();
160      // A binary removed since setup recorded it: the PATH may still hold another.
161      if (absolute(recorded) && (await this.host.owned(recorded))) return recorded;
162    } catch {}
163    return "starbridge";
164  }
165
166  private async loop() {
167    while (!this.stopped) {
168      try {
169        await this.step();
170      } catch (e) {
171        await this.fail((e as Error).message);
172      }
173    }
174  }
175
176  private async step() {
177    const me = await this.host.sessionId();
178    if (!me) {
179      await this.host.sleep(this.t.checkMs);
180      return;
181    }
182    if (await this.lease(me, this.t.leaseMs)) {
183      const started = await this.host.now();
184      const wait = this.host.starting() ? 0 : this.t.waitSeconds;
185      const handed = await this.answers(me, ["--wait", String(wait)]);
186      if (handed === undefined) return;
187      const left = started + this.t.minCycleMs - (await this.host.now());
188      if (handed === 0 && left > 0) await this.host.sleep(left);
189      return;
190    }
191    const mtime = await this.host.mtime(this.statePath);
192    const now = await this.host.now();
193    const unchanged = this.seen?.session === me && this.seen.mtime === mtime;
194    if (mtime === undefined || (unchanged && now - (this.seen?.at ?? 0) < this.t.recheckMs)) {
195      await this.host.sleep(this.t.checkMs);
196      return;
197    }
198    if ((await this.answers(me, [])) === undefined) return;
199    // A line the host held back for the turn's end is read again at the next check (#1484).
200    if (this.held === 0) this.seen = { session: me, mtime, at: now };
201    else await this.host.sleep(this.t.checkMs);
202  }
203
204  /**
205   * Confirms the lines among `acks` that the CLI still hands this session, and returns those: a
206   * line a `wait` or `waiting` printed meanwhile is not handed over again (#1484).
207   */
208  async claim(acks: string[]): Promise<string[]> {
209    const me = await this.host.sessionId();
210    const base = [await this.cli(), "answers", "--session", me];
211    const timeoutMs = (this.t.waitSeconds + 30) * 1000;
212    const r = await this.host.run(base, timeoutMs);
213    if (r.exitCode !== 0) return [];
214    const pending = new Set<string>();
215    for (const text of r.stdout.split("\n")) {
216      try {
217        const l = JSON.parse(text) as { decisionId?: unknown; ack?: unknown };
218        const ack = typeof l.ack === "string" ? l.ack : l.decisionId;
219        if (typeof ack === "string") pending.add(ack);
220      } catch {}
221    }
222    const mine = acks.filter((a) => pending.has(a));
223    if (mine.length === 0) return [];
224    const ack = await this.host.run([...base, ...mine.flatMap((a) => ["--ack", a])], timeoutMs);
225    if (ack.exitCode !== 0) return [];
226    // What it confirmed now: not a line a `wait` or `waiting` took since the read. A CLI older
227    // than that prints nothing.
228    try {
229      const acked = (JSON.parse(ack.stdout) as { acked?: unknown }).acked;
230      if (Array.isArray(acked)) return acked.filter((a): a is string => typeof a === "string");
231    } catch {}
232    return mine;
233  }
234
235  /**
236   * Runs one `starbridge answers`, submits each answer it hands over and confirms them. Returns
237   * how many, or undefined after an error, which it has already waited out.
238   */
239  private async answers(me: string, extra: string[]): Promise<number | undefined> {
240    const timeoutMs = (this.t.waitSeconds + 30) * 1000;
241    const base = [await this.cli(), "answers", "--session", me];
242    const r = await this.host.run([...base, ...extra], timeoutMs);
243    if (r.exitCode !== 0) {
244      await this.fail(firstLine(r.stderr) || `exit ${r.exitCode}`);
245      return undefined;
246    }
247    // Warnings on success, such as an answer that failed its checks.
248    for (const line of r.stderr.split("\n")) if (line.trim()) this.host.log(line);
249    if (this.failures > 0) this.host.status(undefined);
250    this.failures = 0;
251    const lines: Line[] = [];
252    for (const text of r.stdout.split("\n")) {
253      if (!text.trim()) continue;
254      let parsed: { decisionId?: unknown; ack?: unknown; line?: unknown; subagent?: unknown };
255      try {
256        parsed = JSON.parse(text);
257      } catch {
258        this.host.log(`starbridge: unreadable line from the CLI: ${text.slice(0, 200)}`);
259        continue;
260      }
261      const { decisionId: id, line } = parsed;
262      if (typeof id !== "string" || typeof line !== "string") continue;
263      lines.push({
264        text: line,
265        ack: typeof parsed.ack === "string" ? parsed.ack : id,
266        ...(typeof parsed.subagent === "string" ? { subagent: parsed.subagent } : {}),
267      });
268    }
269    // A /clear during the call made another session current: the answers wait, unconfirmed,
270    // until session `me` is resumed.
271    if (lines.length > 0 && (await this.host.sessionId()) !== me) {
272      this.host.log(
273        `starbridge: held back the answers ${lines.map((l) => l.ack).join(", ")}: their session ${me} ended`,
274      );
275      this.held = 0;
276      return 0;
277    }
278    const due = lines.filter((l) => !this.unconfirmed.has(l.ack));
279    const taken = due.length > 0 ? await this.host.submit(due) : [];
280    for (const a of taken) this.unconfirmed.add(a);
281    const handed = taken.length;
282    const held = due.length - handed;
283    const done = lines.filter((l) => this.unconfirmed.has(l.ack)).map((l) => l.ack);
284    if (done.length > 0) {
285      const ack = await this.host.run([...base, ...done.flatMap((id) => ["--ack", id])], timeoutMs);
286      if (ack.exitCode !== 0) {
287        await this.fail(firstLine(ack.stderr) || `exit ${ack.exitCode}`);
288        return undefined;
289      }
290      for (const id of done) this.unconfirmed.delete(id);
291    }
292    this.held = held;
293    return handed;
294  }
295
296  /** Waits before the next try, twice as long after each error in a row. */
297  private async fail(message: string) {
298    this.failures++;
299    const delay = Math.min(this.t.maxBackoffMs, this.t.backoffMs * 2 ** (this.failures - 1));
300    const text = `starbridge: ${message}`;
301    this.host.status(text);
302    this.host.log(`${text}; retrying in ${Math.round(delay / 1000)} s`);
303    // A poller that backs off keeps its lease, so the other sessions do not all start failing.
304    try {
305      const me = await this.host.sessionId();
306      const held = (await this.readLease())?.session;
307      if (me && held !== undefined && (held === me || held === this.leasedAs))
308        await this.lease(me, delay + this.t.leaseMs);
309    } catch {
310      // The lease is an optimisation; losing it costs one more poller.
311    }
312    await this.host.sleep(delay);
313  }
314
315  private async readLease(): Promise<Lease | undefined> {
316    try {
317      const lease = JSON.parse(await this.host.read(this.leasePath)) as Lease;
318      return typeof lease.session === "string" && typeof lease.until === "number"
319        ? lease
320        : undefined;
321    } catch {
322      return undefined;
323    }
324  }
325
326  /**
327   * Takes or renews the lease unless another live session holds it. Two sessions that write at
328   * once both read back the last write, so at most one goes on to poll; a double poll would
329   * still be harmless, since the CLI keeps each answer once and hands it over once.
330   */
331  private async lease(me: string, forMs: number): Promise<boolean> {
332    // A step still running when `stop` gave the lease up must not take it back.
333    if (this.stopped) return false;
334    const now = await this.host.now();
335    const held = await this.readLease();
336    const ours = held?.session === me || (held !== undefined && held.session === this.leasedAs);
337    if (held && !ours && held.until > now) return false;
338    const mine = ours && (held?.until ?? 0) > now;
339    await this.host.write(this.leasePath, JSON.stringify({ session: me, until: now + forMs }));
340    if (!mine) {
341      await this.host.sleep(100);
342      if ((await this.readLease())?.session !== me) return false;
343    }
344    this.leasedAs = me;
345    return true;
346  }
347}
348
hooks/sent.ts 448 lines
1/**
2 * Answers the mod submitted as prompts, kept until their turn starts (#1517).
3 *
4 * The loops confirm a line to the CLI or the agent as soon as they submit it, so a `wait` or
5 * `waiting` never prints an answer already queued as a prompt. But Claude Code keeps a queued
6 * prompt in memory only: one still waiting for its turn when the process exits is gone, and
7 * `--resume` does not bring it back. So each line is written to a file of its session before it
8 * is submitted, and leaves it once its turn starts: `turn.start` carries its text, or
9 * `$.prompt.submit` resolves. A process that starts on a session whose file still holds lines
10 * from another process submits them again.
11 *
12 * Lines carry the token of the process that submitted them, which `$.state` keeps across a hot
13 * reload: a reload leaves the queued prompt in place, so a reloaded mod submits nothing again.
14 * Nor does a process that shares its session with another live one.
15 *
16 * Every operation reads the file afresh, since another process on the session writes it too.
17 */
18
19export interface SentHost {
20  /** The session's id now; a `/clear` or a `/resume` changes it. */
21  sessionId(): Promise<string>;
22  /** Undefined when the file is missing; rejects when it cannot be read. */
23  read(path: string): Promise<string | undefined>;
24  /** Creates the file's folder as needed. */
25  write(path: string, text: string): Promise<void>;
26  now(): Promise<number>;
27  /**
28   * `$.prompt.submit`: resolves when the prompt's turn starts, or to `{ drop }` when a hook
29   * refused it.
30   */
31  submit(text: string): Promise<unknown>;
32  /**
33   * Whether another live process runs `session`, such as a `claude --resume` in a second
34   * terminal: the lines it submitted may still be queued there.
35   */
36  elsewhere(session: string): Promise<boolean>;
37  log(text: string): void;
38}
39
40interface Line {
41  ack: string;
42  line: string;
43  /** The token of the process that submitted it last; empty when that submit failed. */
44  process: string;
45  at: number;
46  /** Submits a hook refused or the engine rejected; a process exiting is not one. */
47  refused: number;
48  /**
49   * Sent to this subagent, which has not read it yet (#1702); `line` is then what the parent gets
50   * if the process exits first.
51   */
52  agent?: string;
53}
54
55/** Refused submits after which a line is dropped, with a log line. */
56const MAX_REFUSED = 3;
57/** A line older than this is dropped: the server drops an unanswered decision after 30 days. */
58const MAX_AGE_MS = 30 * 24 * 3600_000;
59
60export class Sent {
61  /** Sessions whose file this process read. */
62  private readonly sessions = new Set<string>();
63  /** Sessions whose lines from other processes went out again. */
64  private readonly recovered = new Set<string>();
65  /** Every operation runs alone, from its read to its submit. */
66  private queue: Promise<unknown> = Promise.resolve();
67
68  constructor(
69    private readonly host: SentHost,
70    private readonly dir: string,
71    /**
72     * This process's token; undefined where `$.state` is missing, and then no line is submitted
73     * again, since a reload could not be told from a new process.
74     */
75    private readonly process: string | undefined,
76  ) {}
77
78  /**
79   * Records `batch`, then submits it as one prompt (#1597). Returns the acks of the lines that went
80   * out, now or before: not those it could not record, or that a `/clear` or `/resume` held back
81   * meanwhile, which the loop leaves unconfirmed for a later try.
82   */
83  send(batch: { text: string; ack: string }[]): Promise<string[]> {
84    return this.alone(async () => {
85      const me = await this.host.sessionId();
86      const at = await this.host.now();
87      let lines = await this.loaded(me);
88      if (!lines) return [];
89      const entries: Line[] = [];
90      /** Lines already out, from this process or another. */
91      const out: string[] = [];
92      for (const { text, ack } of batch) {
93        // A process that died between recording a line and confirming it leaves both: the loop
94        // hands the line over again, and the file holds it. It goes out once, as `recover` would.
95        let known: Line | undefined = lines.find((l) => l.ack === ack);
96        if (known && (this.process === undefined || known.process === this.process)) {
97          out.push(ack);
98          continue;
99        }
100        if (known && known.process !== "") {
101          if (await this.host.elsewhere(me).catch(() => false)) {
102            out.push(ack);
103            continue;
104          }
105          // The process may have run the line's turn before it exited: then the file lacks it. A
106          // process that started meanwhile may hold it now.
107          const before = known.process;
108          const fresh = await this.loaded(me);
109          if (!fresh) return out;
110          lines = [...fresh.filter((l) => !entries.some((e) => e.ack === l.ack)), ...entries];
111          known = lines.find((l) => l.ack === ack);
112          if (!known || (known.process !== "" && known.process !== before)) {
113            out.push(ack);
114            continue;
115          }
116        }
117        const entry: Line = known
118          ? { ...known, process: this.process ?? "" }
119          : { ack, line: text, process: this.process ?? "", at, refused: 0 };
120        lines = [...lines.filter((l) => l !== known), entry];
121        entries.push(entry);
122      }
123      if (entries.length === 0) return out;
124      try {
125        await this.save(me, lines);
126      } catch (e) {
127        const acks = entries.map((l) => l.ack).join(", ");
128        this.host.log(`starbridge: could not record the answers ${acks}: ${(e as Error).message}`);
129        return out;
130      }
131      if ((await this.host.sessionId()) !== me) {
132        // Left for session `me`: the loop holds the lines back until it is current again.
133        const left = lines.map((l) => (entries.includes(l) ? { ...l, process: "" } : l));
134        await this.save(me, left).catch(() => undefined);
135        return out;
136      }
137      this.enter(me, entries);
138      return [...out, ...entries.map((l) => l.ack)];
139    });
140  }
141
142  /**
143   * Records `batch` for subagent `agent`, as the prompt the parent gets should the process exit
144   * before the subagent reads it (#1702), then runs `deliver`, the send. A send that fails leaves
145   * no record. Claude Code keeps a message for a running subagent in memory until its next tool
146   * round, so `read` drops the record only once the subagent has moved on. `running` is asked
147   * again once the record is saved, right before the send: a subagent that finished meanwhile is
148   * resumed with the message, which needs no record, and `finished` must not hand it to the parent
149   * too (#1729).
150   */
151  toAgent(
152    agent: string,
153    batch: { text: string; ack: string }[],
154    deliver: () => Promise<{ isDelivered: boolean; reason?: string }>,
155    running: () => Promise<boolean> = async () => true,
156  ): Promise<{ isDelivered: boolean; reason?: string }> {
157    // Without a token a reload could not be told from a new process: nothing to recover by.
158    if (this.process === undefined) return deliver();
159    const process = this.process;
160    return this.alone(async () => {
161      const me = await this.host.sessionId();
162      const at = await this.host.now();
163      const acks = batch.map((l) => l.ack);
164      const lines = await this.loaded(me);
165      const entries = batch.map((l) => ({
166        ack: l.ack,
167        line: l.text,
168        process,
169        at,
170        refused: 0,
171        agent,
172      }));
173      const rest = lines?.filter((l) => !acks.includes(l.ack));
174      const saved =
175        rest !== undefined &&
176        (await this.save(me, [...rest, ...entries]).then(
177          () => true,
178          (e) => {
179            this.host.log(
180              `starbridge: could not record the answers ${acks.join(", ")}: ${(e as Error).message}`,
181            );
182            return false;
183          },
184        ));
185      // Sent without a record, an answer the subagent never read would be lost with the process.
186      if (!saved) return { isDelivered: false, reason: "could not record it" };
187      if (!(await running())) {
188        await this.save(me, rest).catch(() => undefined);
189        return deliver();
190      }
191      let r: { isDelivered: boolean; reason?: string };
192      try {
193        r = await deliver();
194      } catch (e) {
195        await this.save(me, rest).catch(() => undefined);
196        throw e;
197      }
198      if (!r.isDelivered) await this.save(me, rest).catch(() => undefined);
199      return r;
200    });
201  }
202
203  /**
204   * Subagent `agent` read what was sent to it up to `before`, the end of a tool round of its own:
205   * Claude Code hands a subagent its messages after each round. Those records go.
206   */
207  read(agent: string, before: number): Promise<void> {
208    return this.alone(async () => {
209      // After a hot reload the current session is the one its records are under.
210      await this.load(await this.host.sessionId()).catch(() => undefined);
211      for (const session of this.sessions) {
212        const lines = await this.load(session).catch(() => undefined);
213        if (!lines) continue;
214        const keep = lines.filter((l) => l.agent !== agent || l.at > before);
215        if (keep.length < lines.length) await this.save(session, keep).catch(() => undefined);
216      }
217    });
218  }
219
220  /**
221   * Subagent `agent` finished, its last tool round ending at `before` (undefined when this module
222   * saw none): what was sent to it after then, it never read (#1729). Those records become lines
223   * for the parent, which the next `recover` submits; the rest go. Returns whether any are left
224   * for the parent.
225   */
226  finished(agent: string, before: number | undefined): Promise<boolean> {
227    return this.alone(async () => {
228      await this.load(await this.host.sessionId()).catch(() => undefined);
229      let left = false;
230      for (const session of this.sessions) {
231        const lines = await this.load(session).catch(() => undefined);
232        if (!lines?.some((l) => l.agent === agent)) continue;
233        const keep: Line[] = [];
234        for (const l of lines) {
235          if (l.agent !== agent) keep.push(l);
236          else if (before === undefined || l.at > before) {
237            const { agent: _, ...line } = l;
238            keep.push({ ...line, process: "" });
239            left = true;
240          }
241        }
242        await this.save(session, keep).catch(() => undefined);
243      }
244      return left;
245    });
246  }
247
248  /**
249   * A turn started with `text`: each line it carries reached the model. Checks every session read
250   * so far, since a `/clear` may come between the submit and the turn.
251   */
252  started(text: string): Promise<void> {
253    return this.alone(async () => {
254      await this.load(await this.host.sessionId());
255      for (const session of this.sessions) {
256        const lines = await this.load(session);
257        const left = lines.filter((l) => !text.includes(l.line));
258        if (left.length < lines.length) await this.save(session, left).catch(() => undefined);
259      }
260    });
261  }
262
263  /**
264   * Submits again the current session's lines that another process submitted, or whose submit
265   * failed, once per session id, and not while another live process runs the session. Then these come from this process, and only a turn start or a
266   * failed submit changes them.
267   */
268  recover(): Promise<void> {
269    return this.alone(async () => {
270      if (this.process === undefined) return;
271      const me = await this.host.sessionId();
272      if (!me) return;
273      let lines = await this.load(me);
274      // Other processes' lines go out once another live process no longer runs this session:
275      // checked again at each turn's end until then.
276      const theirs = (l: Line) => l.process !== "" && l.process !== this.process;
277      const key = (l: Line) => `${l.ack}\n${l.process}`;
278      const found = new Set(lines.filter(theirs).map(key));
279      let take = false;
280      if (!this.recovered.has(me)) {
281        take = !(found.size > 0 && (await this.host.elsewhere(me).catch(() => false)));
282        // A process that exited may have run some of its lines' turns since the first read, and
283        // one that started meanwhile may have recorded its own, which stay with it.
284        if (take && found.size > 0) lines = await this.load(me);
285      }
286      const due = lines.filter((l) => l.process === "" || (take && found.has(key(l))));
287      if (due.length === 0) {
288        if (take) this.recovered.add(me);
289        return;
290      }
291      const keep = lines.filter((l) => !due.includes(l));
292      const again: Line[] = [];
293      for (const l of due)
294        if (l.refused >= MAX_REFUSED)
295          this.host.log(`starbridge: gave up on the answer ${l.ack}: refused ${l.refused} times`);
296        else again.push({ ...l, process: this.process });
297      // Marked once saved: a failed write leaves the lines to the next try.
298      await this.save(me, [...keep, ...again]);
299      if (take) this.recovered.add(me);
300      if ((await this.host.sessionId()) !== me) {
301        // Left for session `me`, as `send` leaves a line.
302        this.recovered.delete(me);
303        const free = again.map((l) => ({ ...l, process: "" }));
304        await this.save(me, [...keep, ...free]).catch(() => undefined);
305        return;
306      }
307      for (const l of again)
308        this.host.log(`starbridge: submitting the answer ${l.ack} again: its prompt never ran`);
309      if (again.length > 0) this.enter(me, again);
310    });
311  }
312
313  /** Submits `entries` as one prompt, one line each. */
314  private enter(session: string, entries: Line[]) {
315    const acks = entries.map((l) => l.ack);
316    this.host.submit(entries.map((l) => l.line).join("\n")).then(
317      (r) => {
318        const drop = (r as { drop?: unknown } | undefined)?.drop;
319        if (typeof drop === "string") void this.failed(session, acks, `refused: ${drop}`);
320        else void this.done(session, acks);
321      },
322      (e) => void this.failed(session, acks, (e as Error)?.message ?? String(e)),
323    );
324  }
325
326  /** `$.prompt.submit` resolved: the lines' turn started. */
327  private done(session: string, acks: string[]) {
328    return this.alone(async () => {
329      const lines = await this.load(session);
330      if (lines.some((l) => acks.includes(l.ack)))
331        await this.save(
332          session,
333          lines.filter((l) => !acks.includes(l.ack)),
334        ).catch(() => undefined);
335    });
336  }
337
338  /** The submit failed: the next `recover` submits the lines again. */
339  private failed(session: string, acks: string[], why: string) {
340    this.host.log(`starbridge: the answers ${acks.join(", ")} did not go in: ${why}`);
341    return this.alone(async () => {
342      const lines = await this.load(session);
343      if (!lines.some((l) => acks.includes(l.ack))) return;
344      await this.save(
345        session,
346        lines.map((l) =>
347          acks.includes(l.ack) ? { ...l, process: "", refused: l.refused + 1 } : l,
348        ),
349      ).catch(() => undefined);
350    });
351  }
352
353  private alone<T>(op: () => Promise<T>): Promise<T> {
354    const run = this.queue.then(op);
355    this.queue = run.catch(() => undefined);
356    return run;
357  }
358
359  private path(session: string) {
360    return `${this.dir}/mod-sent/${encodeURIComponent(session)}.json`;
361  }
362
363  /**
364   * Rejects when the file cannot be read, so that no write replaces lines it could not see. One
365   * that does not parse is replaced: holding every answer back on it would be worse.
366   */
367  private async load(session: string): Promise<Line[]> {
368    this.sessions.add(session);
369    const text = await this.host.read(this.path(session));
370    let lines: Line[] = [];
371    try {
372      const parsed = JSON.parse(text ?? "[]");
373      if (Array.isArray(parsed)) lines = parsed.filter(isLine);
374    } catch {}
375    const now = await this.host.now();
376    return lines.filter((l) => now - l.at < MAX_AGE_MS);
377  }
378
379  /** `load`, or undefined with a log line when the file cannot be read. */
380  private async loaded(session: string): Promise<Line[] | undefined> {
381    try {
382      return await this.load(session);
383    } catch (e) {
384      this.host.log(`starbridge: could not read the answers sent: ${(e as Error).message}`);
385      return undefined;
386    }
387  }
388
389  /** Writes `session`'s lines; rejects when the write fails. */
390  private async save(session: string, lines: Line[]): Promise<void> {
391    await this.host.write(this.path(session), JSON.stringify(lines));
392  }
393}
394
395function isLine(l: unknown): l is Line {
396  const x = l as Partial<Line> | null;
397  return (
398    typeof x?.ack === "string" &&
399    typeof x.line === "string" &&
400    typeof x.process === "string" &&
401    typeof x.at === "number" &&
402    typeof x.refused === "number" &&
403    (x.agent === undefined || typeof x.agent === "string")
404  );
405}
406
407/**
408 * Whether a live Claude Code process other than `own` runs session `session`, from the
409 * `<pid>.json` files Claude Code keeps in `<its config folder>/sessions`; files of processes that
410 * ended stay behind, so `runs` checks each pid.
411 */
412export async function sessionElsewhere(
413  session: string,
414  own: number,
415  fs: { list(dir: string): Promise<{ name: string }[]>; read(path: string): Promise<string> },
416  dir: string,
417  runs: (pid: number) => Promise<boolean>,
418): Promise<boolean> {
419  for (const f of await fs.list(dir).catch(() => [])) {
420    if (!/^\d+\.json$/.test(f.name) || f.name === `${own}.json`) continue;
421    try {
422      const s = firstObject(await fs.read(`${dir}/${f.name}`));
423      if (
424        s?.sessionId === session &&
425        typeof s.pid === "number" &&
426        s.pid !== own &&
427        (await runs(s.pid))
428      )
429        return true;
430    } catch {}
431  }
432  return false;
433}
434
435/**
436 * The object a session record starts with. Claude Code rewrites the file without truncating it,
437 * so a shorter record leaves the end of the previous one after it, as cli/src/claude.ts reads it.
438 */
439function firstObject(text: string): { pid?: unknown; sessionId?: unknown } | undefined {
440  for (let end = text.indexOf("}"); end !== -1; end = text.indexOf("}", end + 1)) {
441    try {
442      const value = JSON.parse(text.slice(0, end + 1));
443      return value && typeof value === "object" ? value : undefined;
444    } catch {}
445  }
446  return undefined;
447}
448
hooks/subagent.ts 96 lines
1import { joinLines, type Line } from "./agent.ts";
2
3/**
4 * Answers to the questions a Claude Code subagent asked (#1702). A subagent's Bash runs with its
5 * parent's session id and no id of its own, so the mod tags its `starbridge ask` with the
6 * subagent's id, which the CLI keeps with the decision, and sends the answer to that subagent
7 * rather than submitting it as the parent's prompt. A subagent that runs gets it at its next tool
8 * call; one that finished is resumed with it. One that cannot take it leaves it to the parent.
9 */
10
11/** The variable the CLI reads the asking subagent's id from. */
12export const AGENT_VAR = "STARBRIDGE_CLAUDE_AGENT";
13
14/** Characters a Claude Code agent id or teammate address takes, as the CLI checks them. */
15const AGENT_ID = /^[A-Za-z0-9_.@-]{1,200}$/;
16
17/** A command that runs `starbridge ask`, by name or path, anywhere in it. */
18const ASKS = /(?:^|[\s;&|(`'"/])starbridge(?:\.exe|\.cmd|\.js)?['"]?\s+ask(?=$|\s)/;
19
20const TAG = new RegExp(`^export ${AGENT_VAR}=([A-Za-z0-9_.@-]{1,200})\\n`);
21
22/**
23 * The Bash command subagent `agentId` runs, with the subagent's id exported first when it runs
24 * `starbridge ask`; any other command, or an id the CLI would refuse, unchanged.
25 */
26export function tagAsk(command: string, agentId: string): string {
27  if (!AGENT_ID.test(agentId) || !ASKS.test(command) || TAG.test(command)) return command;
28  return `export ${AGENT_VAR}=${agentId}\n${command}`;
29}
30
31/**
32 * The command as the subagent wrote it: the permission check judges that, so the tag never makes
33 * a command the rules allow ask anyone.
34 */
35export function untagAsk(command: string): string {
36  return command.replace(TAG, "");
37}
38
39/** One message for subagent `agentId`: the lines, and the text `Line.text` joins them into. */
40export type Send = (
41  agentId: string,
42  lines: Line[],
43  text: string,
44) => Promise<{ isDelivered: boolean; reason?: string }>;
45
46/** What the parent gets for a line its subagent `agent` could not take. */
47export function forParent(l: Line, agent: string): Line {
48  return {
49    text: `${l.text}\nYour subagent ${agent} asked this and could not take the answer: act on it, or pass it on.`,
50    ack: l.ack,
51  };
52}
53
54/**
55 * Sends each subagent its answers, one message per subagent in the order they came. Returns the
56 * acks it delivered, and the lines left for the parent: those no subagent asked, then those a
57 * subagent could not take, which say so. A line in `failed`, which a send already failed, goes to
58 * the parent without another try: retried while the parent's turn holds it, it could reach both.
59 */
60export async function toSubagents(
61  lines: Line[],
62  send: Send,
63  log: (text: string) => void,
64  failed: Set<string> = new Set(),
65): Promise<{ taken: string[]; rest: Line[] }> {
66  const byAgent = new Map<string, Line[]>();
67  const rest: Line[] = [];
68  for (const l of lines) {
69    if (l.subagent === undefined) rest.push(l);
70    else if (failed.has(l.ack)) rest.push(forParent(l, l.subagent));
71    else byAgent.set(l.subagent, [...(byAgent.get(l.subagent) ?? []), l]);
72  }
73  const taken: string[] = [];
74  for (const [agent, group] of byAgent) {
75    let why: string | undefined;
76    try {
77      const r = await send(agent, group, joinLines(group));
78      if (r.isDelivered) {
79        taken.push(...group.map((l) => l.ack));
80        continue;
81      }
82      why = r.reason ?? "not delivered";
83    } catch (e) {
84      why = (e as Error).message;
85    }
86    log(
87      `starbridge: subagent ${agent} could not take ${group.map((l) => l.ack).join(", ")}: ${why}`,
88    );
89    for (const l of group) {
90      failed.add(l.ack);
91      rest.push(forParent(l, agent));
92    }
93  }
94  return { taken, rest };
95}
96
hooks/switch.ts 133 lines
1/**
2 * Picks the session's answer loop: `AgentLoop` (agent.ts) while the machine's agent answers on
3 * its socket, else `Poller` (poller.ts), which runs the CLI as before the agent existed. It
4 * checks for the agent every `probeMs` while the CLI path runs, and goes back to the CLI path
5 * when the agent stops answering.
6 */
7
8import type { AgentLoop } from "./agent.ts";
9import type { Poller } from "./poller.ts";
10
11/** Entry points of a run that ends with its last turn: `claude -p` and Agent SDK scripts. */
12const ONE_SHOT = new Set(["sdk-cli", "sdk-ts", "sdk-py"]);
13
14/**
15 * Whether a session gets an answer loop: one under the REPL, or one a host such as Claude
16 * desktop's Code tab or an IDE keeps open for a person (#863). The engine calls those not
17 * interactive, as it does a `claude -p` run, so the host's `CLAUDE_CODE_ENTRYPOINT` tells them
18 * apart; a run with none set is `claude -p`'s own.
19 */
20export function wantsLoop(isInteractive: boolean, entrypoint: string | undefined): boolean {
21  return isInteractive || (!!entrypoint && !ONE_SHOT.has(entrypoint));
22}
23
24export interface Paths {
25  /** True when an agent answers `GET /v1/status` with a 2xx. */
26  agentUp(): Promise<boolean>;
27  agent(unconfirmed: Set<string>): AgentLoop;
28  poller(unconfirmed: Set<string>): Poller;
29  sleep(ms: number): Promise<void>;
30  /** Clears the status line: each loop clears only the errors it showed itself. */
31  clearStatus(): void;
32  log(text: string): void;
33}
34
35export class Switch {
36  private stopped = false;
37  private agent: AgentLoop | undefined;
38  private poller: Poller | undefined;
39  /** The last agent loop, for its `bye` at the end of the session. */
40  private lastAgent: AgentLoop | undefined;
41  /** Resolves when the loop has ended. */
42  readonly done: Promise<void>;
43
44  constructor(
45    private readonly paths: Paths,
46    private readonly probeMs = 30_000,
47    /**
48     * Shared by both loops, so a line submitted before a switch is not submitted again after; and
49     * with the loop that replaces this one, which only confirms it then (#1337).
50     */
51    readonly unconfirmed = new Set<string>(),
52  ) {
53    // Nothing awaits a loop that ended before `stop`: a rejection would take the harness down (#1365).
54    this.done = this.loop().catch((e) => {
55      try {
56        this.paths.log(`starbridge: the answer loop stopped: ${(e as Error).message}`);
57      } catch {}
58    });
59  }
60
61  /** Which loop runs now, for tests and the debug log. */
62  get mode(): "agent" | "cli" | undefined {
63    return this.agent ? "agent" : this.poller ? "cli" : undefined;
64  }
65
66  /**
67   * Confirms through the loop that runs now the lines among `acks` still due to this session, and
68   * returns those; none when no loop runs or the call fails.
69   */
70  async claim(acks: string[]): Promise<string[]> {
71    try {
72      return (await (this.agent ?? this.poller)?.claim(acks)) ?? [];
73    } catch {
74      return [];
75    }
76  }
77
78  /** Ends the loop; the Poller gives up its lease at once. */
79  async stop(): Promise<void> {
80    this.stopped = true;
81    this.agent?.stop();
82    await this.poller?.stop();
83  }
84
85  /**
86   * Stops, and tells the agent this session ended. Waits at most 2 s for that: a hung agent
87   * must not hold up the session's exit, and a missed `bye` only leaves a stale entry in `status`.
88   */
89  async end(): Promise<void> {
90    await this.stop();
91    const bye = this.lastAgent?.bye();
92    if (bye) await Promise.race([bye, this.paths.sleep(2_000)]);
93  }
94
95  private async loop() {
96    while (!this.stopped) {
97      if (await this.up()) {
98        this.paths.log("starbridge: answers through the agent");
99        this.paths.clearStatus();
100        const agent = this.paths.agent(this.unconfirmed);
101        this.agent = agent;
102        this.lastAgent = agent;
103        // `stop` may have come while the probe ran, before `this.agent` was set.
104        if (this.stopped) agent.stop();
105        await agent.run();
106        this.agent = undefined;
107        continue;
108      }
109      if (this.stopped) return;
110      this.paths.log("starbridge: no agent; answers through the CLI");
111      this.paths.clearStatus();
112      const poller = this.paths.poller(this.unconfirmed);
113      this.poller = poller;
114      while (!this.stopped) {
115        await this.paths.sleep(this.probeMs);
116        if (!this.stopped && (await this.up())) break;
117      }
118      // Lets the step in progress finish and confirm what it submitted.
119      await poller.stop();
120      await poller.done;
121      this.poller = undefined;
122    }
123  }
124
125  private async up(): Promise<boolean> {
126    try {
127      return await this.paths.agentUp();
128    } catch {
129      return false;
130    }
131  }
132}
133