@kaiba-cloud/orbital 0.0.5 → 0.0.6

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/dist/index.js CHANGED
@@ -15190,7 +15190,7 @@ var require_cross_spawn = __commonJS({
15190
15190
  });
15191
15191
 
15192
15192
  // src/kit/command/brand.ts
15193
- var VERSION = true ? "0.0.5" : "0.0.0-dev";
15193
+ var VERSION = true ? "0.0.6" : "0.0.0-dev";
15194
15194
  function defineBrand(overrides) {
15195
15195
  const { program } = overrides;
15196
15196
  return {
@@ -38952,12 +38952,9 @@ function readDescriptorName(dir) {
38952
38952
  if (!existsSync6(file2)) throw refuse("invalid-input", `${dir} has no ${PLUGIN_PACKAGE_FILE}, so it is not a plugin.`);
38953
38953
  return PluginPackageSchema.parse(JSON.parse(readFileSync7(file2, "utf8"))).name;
38954
38954
  }
38955
- function lastPart(value2) {
38956
- return value2.replace(/\.git$/, "").split(/[/:]/).filter(Boolean).pop() ?? value2;
38957
- }
38958
38955
  function resolvePluginRef(home, ref) {
38959
- if (ref.startsWith("npm:")) return { kind: "npm", name: lastPart(ref.slice(4).replace(/@[^/]*$/, "")), pkg: ref.slice(4) };
38960
- if (ref.startsWith("git:")) return { kind: "git", name: lastPart(ref.slice(4)), url: ref.slice(4) };
38956
+ if (ref.startsWith("npm:")) return { kind: "npm", name: null, pkg: ref.slice(4) };
38957
+ if (ref.startsWith("git:")) return { kind: "git", name: null, url: ref.slice(4) };
38961
38958
  if (isFolder(ref)) return { kind: "folder", name: readDescriptorName(resolve6(ref)), dir: resolve6(ref) };
38962
38959
  const at = ref.lastIndexOf("@");
38963
38960
  if (at <= 0) throw refuse("invalid-input", `"${ref}" is not a plugin reference.`, "Use <name>@<marketplace>, npm:<package>, git:<url>, or a folder such as ./plugins/mine.");
@@ -38989,17 +38986,17 @@ function installPlugin(home, ref, exec2 = run) {
38989
38986
  const installed = Object.keys(JSON.parse(readFileSync7(join10(scratch, "package.json"), "utf8")).dependencies ?? {});
38990
38987
  const pkgDir = join10("node_modules", ...(installed[0] ?? ref.pkg.replace(/(.)@[^/]*$/, "$1")).split("/"));
38991
38988
  const inner = PluginPackageSchema.parse(JSON.parse(readFileSync7(join10(scratch, pkgDir, PLUGIN_PACKAGE_FILE), "utf8")));
38992
- writeFileSync5(join10(scratch, PLUGIN_PACKAGE_FILE), `${JSON.stringify({ name: ref.name, command: inner.command, dir: inner.dir ? join10(pkgDir, inner.dir) : pkgDir, env: inner.env, ...Object.keys(inner.config).length > 0 && { config: inner.config }, ...inner.harnesses.length > 0 && { harnesses: inner.harnesses }, ...inner.commands.length > 0 && { commands: inner.commands } }, null, 2)}
38989
+ writeFileSync5(join10(scratch, PLUGIN_PACKAGE_FILE), `${JSON.stringify({ name: inner.name, command: inner.command, dir: inner.dir ? join10(pkgDir, inner.dir) : pkgDir, env: inner.env, ...Object.keys(inner.config).length > 0 && { config: inner.config }, ...inner.harnesses.length > 0 && { harnesses: inner.harnesses }, ...inner.commands.length > 0 && { commands: inner.commands } }, null, 2)}
38993
38990
  `);
38994
38991
  break;
38995
38992
  }
38996
38993
  }
38997
38994
  const named2 = readDescriptorName(scratch);
38998
- if (named2 !== ref.name) throw refuse("invalid-input", `The plugin calls itself ${named2}, and its reference names it ${ref.name}.`);
38999
- const dir = join10(parent, ref.name);
38995
+ if (ref.name !== null && named2 !== ref.name) throw refuse("invalid-input", `The plugin calls itself ${named2}, and its reference names it ${ref.name}.`);
38996
+ const dir = join10(parent, named2);
39000
38997
  rmSync3(dir, { recursive: true, force: true });
39001
38998
  renameSync(scratch, dir);
39002
- return dir;
38999
+ return { name: named2, dir };
39003
39000
  } finally {
39004
39001
  rmSync3(scratch, { recursive: true, force: true });
39005
39002
  }
@@ -39046,8 +39043,8 @@ function isPackageSource(source) {
39046
39043
  function ensurePluginInstalled(home, name, source, exec2 = run) {
39047
39044
  const dir = join10(pluginsDir(home), name);
39048
39045
  if (existsSync6(join10(dir, PLUGIN_PACKAGE_FILE))) return dir;
39049
- if (source.startsWith("npm:")) return installPlugin(home, { kind: "npm", name, pkg: source.slice(4) }, exec2);
39050
- if (source.startsWith("git:")) return installPlugin(home, { kind: "git", name, url: source.slice(4) }, exec2);
39046
+ if (source.startsWith("npm:")) return installPlugin(home, { kind: "npm", name, pkg: source.slice(4) }, exec2).dir;
39047
+ if (source.startsWith("git:")) return installPlugin(home, { kind: "git", name, url: source.slice(4) }, exec2).dir;
39051
39048
  throw refuse("invalid-input", `The plugin ${name} has the source ${source}, and only an npm: or git: source installs into the plugin cache.`);
39052
39049
  }
39053
39050
 
@@ -39212,8 +39209,8 @@ async function startRemotePlugin(options) {
39212
39209
  throw new Error(`The plugin ${options.name} reaches ${manifest2.permissions.network.join(", ")}, and ${ORBITAL_FILE} says offline: true.`);
39213
39210
  }
39214
39211
  await options.policy.check({ kind: "plugin.load", plugin: options.name, network: manifest2.permissions.network, secrets: manifest2.permissions.secrets, models: manifest2.permissions.models });
39215
- child.once("exit", () => {
39216
- peer.close(new Error(`The plugin ${options.name} stopped.`));
39212
+ child.once("exit", (code, signal) => {
39213
+ peer.close(new Error(`The plugin ${options.name} stopped (${signal ? `signal ${signal}` : `exit code ${code}`}).`));
39217
39214
  if (stopped || reconnecting) return;
39218
39215
  current2 = reconnect();
39219
39216
  current2.catch((error62) => options.log("error", error62 instanceof Error ? error62.message : String(error62)));
@@ -39393,7 +39390,7 @@ async function startRemotePlugin(options) {
39393
39390
  endpoint = given;
39394
39391
  void current2.then((connection) => connection.peer.notify(PLUGIN_METHODS.hostEndpoint, given));
39395
39392
  },
39396
- command: async (request) => PluginCommandResultSchema.parse(await call(PLUGIN_METHODS.commandRun, request)),
39393
+ command: async (request) => PluginCommandResultSchema.parse(await (await current2).peer.request(PLUGIN_METHODS.commandRun, request, 0)),
39397
39394
  close: async () => {
39398
39395
  stopped = true;
39399
39396
  const connection = await current2.catch(() => null);
@@ -60950,6 +60947,10 @@ async function runInPlugin(context, workspaceDir, input2, plugin, command, termi
60950
60947
  const started = await startWorkspacePlugin(context, workspaceDir, plugin, given);
60951
60948
  try {
60952
60949
  return await started.command(requestOf(command, input2, given ? { tty: context.isTty } : null));
60950
+ } catch (error62) {
60951
+ if (error62 instanceof CliError) throw error62;
60952
+ const message = error62 instanceof Error ? error62.message : String(error62);
60953
+ throw new CliError({ code: "internal", message: error62 instanceof RpcError ? `The command ${plugin} ${command.name} failed: ${message}` : `The command ${plugin} ${command.name} got no answer: ${message}`, exitCode: ExitCode.Error });
60953
60954
  } finally {
60954
60955
  await started.close?.();
60955
60956
  }
@@ -61076,29 +61077,31 @@ var add = () => ({
61076
61077
  const given = String(input2.positionals.plugin);
61077
61078
  const ref = resolvePluginRef(home, given);
61078
61079
  const copied = ref.kind !== "folder" || given.includes("@");
61079
- const installed = copied ? installPlugin(home, ref) : null;
61080
- if (installed) context.output.step(`\u2713 installed ${ref.name} in ${installed}`);
61081
- if (input2.flags["install-only"] === true) return { data: { name: ref.name, source: given, installed, enabled: false, manifest: null } };
61080
+ const install2 = copied ? installPlugin(home, ref) : null;
61081
+ const installed = install2?.dir ?? null;
61082
+ const name = install2?.name ?? ref.name ?? given;
61083
+ if (install2) context.output.step(`\u2713 installed ${name} in ${install2.dir}`);
61084
+ if (input2.flags["install-only"] === true) return { data: { name, source: given, installed, enabled: false, manifest: null } };
61082
61085
  if (input2.flags.user === true) {
61083
61086
  const source2 = ref.kind === "folder" && !given.includes("@") ? ref.dir : ref.kind === "npm" ? `npm:${ref.pkg}` : ref.kind === "git" ? `git:${ref.url}` : given;
61084
- const config3 = configOf(input2, userPlugins(context.env)[ref.name]?.config ?? {});
61085
- const manifest2 = await checkedManifest(context, workspaceOf(input2), ref.name, source2, null, config3);
61086
- context.output.step(`${ref.name} ${manifest2.version} reaches:`);
61087
+ const config3 = configOf(input2, userPlugins(context.env)[name]?.config ?? {});
61088
+ const manifest2 = await checkedManifest(context, workspaceOf(input2), name, source2, null, config3);
61089
+ context.output.step(`${name} ${manifest2.version} reaches:`);
61087
61090
  for (const line of permissionLines(manifest2)) context.output.step(line);
61088
- setUserPlugin(context.env, ref.name, { source: source2, enabled: true, config: config3, env: [] });
61089
- context.output.step(`\u2713 your user config names ${ref.name}: source ${source2}`);
61090
- return { data: { name: ref.name, source: source2, installed, enabled: true, manifest: manifest2 } };
61091
+ setUserPlugin(context.env, name, { source: source2, enabled: true, config: config3, env: [] });
61092
+ context.output.step(`\u2713 your user config names ${name}: source ${source2}`);
61093
+ return { data: { name, source: source2, installed, enabled: true, manifest: manifest2 } };
61091
61094
  }
61092
61095
  const definition = sourceOrRefuse(context, input2);
61093
61096
  const root = definition.root;
61094
61097
  const source = sourceOf(ref, given, root);
61095
- const config2 = configOf(input2, sourceConfig(definition).plugins[ref.name]?.config ?? {});
61096
- const manifest = await checkedManifest(context, workspaceOf(input2), ref.name, source, root, config2);
61097
- context.output.step(`${ref.name} ${manifest.version} reaches:`);
61098
+ const config2 = configOf(input2, sourceConfig(definition).plugins[name]?.config ?? {});
61099
+ const manifest = await checkedManifest(context, workspaceOf(input2), name, source, root, config2);
61100
+ context.output.step(`${name} ${manifest.version} reaches:`);
61098
61101
  for (const line of permissionLines(manifest)) context.output.step(line);
61099
- setPluginEntry(definition.configFile, ref.name, { source, enabled: true, config: config2, env: [] });
61100
- context.output.step(`\u2713 ${basename9(definition.configFile)} names ${ref.name}: source ${source}`);
61101
- return { data: { name: ref.name, source, installed, enabled: true, manifest } };
61102
+ setPluginEntry(definition.configFile, name, { source, enabled: true, config: config2, env: [] });
61103
+ context.output.step(`\u2713 ${basename9(definition.configFile)} names ${name}: source ${source}`);
61104
+ return { data: { name, source, installed, enabled: true, manifest } };
61102
61105
  }
61103
61106
  });
61104
61107
  var list3 = () => ({
@@ -66817,9 +66820,9 @@ function pluginRefOf(home, given) {
66817
66820
  return path3.startsWith(".") ? path3 : `./${path3}`;
66818
66821
  } };
66819
66822
  }
66820
- installPlugin(home, resolved);
66823
+ const { name } = installPlugin(home, resolved);
66821
66824
  const source = resolved.kind === "npm" ? `npm:${resolved.pkg}` : resolved.kind === "git" ? `git:${resolved.url}` : spec;
66822
- return { ref: { name: resolved.name, source, base: home }, recorded: () => source };
66825
+ return { ref: { name, source, base: home }, recorded: () => source };
66823
66826
  }
66824
66827
  function knownRef(context, known) {
66825
66828
  const home = orbitalHome(context.env);
package/dist/mod.json CHANGED
@@ -1 +1 @@
1
- {"files":{".claude-plugin/marketplace.json":"{\n \"name\": \"orbital\",\n \"owner\": { \"name\": \"orbital\" },\n \"plugins\": [{ \"name\": \"orbital\", \"source\": \"./\" }]\n}\n",".claude-plugin/plugin.json":"{\n \"name\": \"orbital\",\n \"version\": \"0.0.5\",\n \"description\": \"Joins this Claude Code session to an orbital node: the agent tools of the team, one policy chain on every tool call, messages from peers as turns, and a pane of the node.\",\n \"license\": \"UNLICENSED\",\n \"types\": \"./types/index.d.ts\",\n \"userConfig\": {\n \"orbital_bin\": {\n \"type\": \"string\",\n \"title\": \"orbital binary\",\n \"description\": \"The program that runs orbital. Empty: orbital, on the PATH. Set it to run orbital through another program.\",\n \"default\": \"\"\n },\n \"node\": {\n \"type\": \"string\",\n \"title\": \"Node\",\n \"description\": \"The node to join. Empty: ORBITAL_NODE, then the node key of the definition, then the node of the folder.\",\n \"default\": \"\"\n },\n \"team\": {\n \"type\": \"string\",\n \"title\": \"Team\",\n \"description\": \"The team this session joins. Sessions of one team are peers.\",\n \"default\": \"\"\n },\n \"governance\": {\n \"type\": \"string\",\n \"title\": \"Governance\",\n \"description\": \"enforce: a tool call is denied when the node does not answer. observe: the node only reports.\",\n \"options\": [\n \"enforce\",\n \"observe\"\n ],\n \"default\": \"enforce\"\n }\n }\n}\n",".mcp.json":"{\n \"mcpServers\": {\n \"orbital\": {\n \"command\": \"orbital\",\n \"args\": [\"mcp\", \"--attach\", \"--harness\", \"claude-code\", \"--agent\", \"claude-code\", \"--external-id-env\", \"CLAUDE_CODE_SESSION_ID\"]\n }\n }\n}\n","hooks/hooks.json":"{ \"modules\": [\"./register.tsx\"] }\n","hooks/logic.ts":"export type Attached = { sessionId: string; token: string; node: string; nodeUrl: string };\nexport type Delivery = { taskId: string; fromAgent: string | null; fromSession: string | null; mission: string | null; question: string | null; text: string };\nexport type Decision = { verdict: 'allow' } | { verdict: 'deny' | 'ask'; reason: string; policy: string };\nexport type Peer = { sessionId: string; agent: string; role: string; harness: string; status: string; self: boolean; parent: string | null };\nexport type CheckAnswer = { decision: 'allow' } | { decision: 'deny' | 'ask'; reason: string };\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction text(value: unknown): string | null {\n return typeof value === 'string' && value !== '' ? value : null;\n}\n\nexport function recordsOf(chunk: string, carry: string): { records: unknown[]; carry: string } {\n const lines = (carry + chunk).split('\\n');\n const rest = lines.pop() ?? '';\n const records = lines.filter((line) => line.trim() !== '').flatMap((line) => {\n try {\n return [JSON.parse(line) as unknown];\n } catch {\n return [];\n }\n });\n return { records, carry: rest };\n}\n\nexport function attachedOf(record: unknown): Attached | null {\n if (!isRecord(record) || record.kind !== 'attached') return null;\n const sessionId = text(record.sessionId);\n const token = text(record.token);\n const node = text(record.node);\n const nodeUrl = text(record.nodeUrl);\n return sessionId && token && node && nodeUrl ? { sessionId, token, node, nodeUrl } : null;\n}\n\nexport function detachedOf(record: unknown): string | null {\n if (!isRecord(record) || record.kind !== 'detached') return null;\n return typeof record.reason === 'string' ? record.reason : '';\n}\n\nexport const DETACHED_PROBLEM = 'The node is gone. orbital joins it again by itself when it runs.';\n\nexport function deliveryOf(record: unknown): Delivery | null {\n if (!isRecord(record) || record.kind !== 'delivery') return null;\n const taskId = text(record.taskId);\n if (!taskId || typeof record.text !== 'string') return null;\n return { taskId, fromAgent: text(record.fromAgent), fromSession: text(record.fromSession), mission: text(record.mission), question: text(record.question), text: record.text };\n}\n\nexport type Answered = { mission: string; question: string | null; by: string; text: string };\n\nexport function answeredOf(record: unknown): Answered | null {\n if (!isRecord(record) || record.kind !== 'answered') return null;\n const mission = text(record.mission);\n if (!mission || typeof record.text !== 'string') return null;\n return { mission, question: text(record.question), by: text(record.by) ?? 'another receiver', text: record.text };\n}\n\nexport function answeredText(answered: Answered): string {\n return `[orbital] ${answered.question ? `the question ${answered.question}` : 'the question'} of the mission ${answered.mission} is answered, by ${answered.by}:\\n\\n${answered.text}\\n\\nDo not answer it again, and tell the person it is answered.`;\n}\n\nexport function withoutAnswered(queued: readonly Queued[], answered: Answered): Queued[] {\n return queued.filter((one) => one.mission !== answered.mission || (answered.question !== null && one.question !== answered.question));\n}\n\nexport function decisionOf(body: unknown): Decision | null {\n if (!isRecord(body) || !isRecord(body.decision)) return null;\n const decision = body.decision;\n if (decision.verdict === 'allow') return { verdict: 'allow' };\n if ((decision.verdict === 'deny' || decision.verdict === 'ask') && typeof decision.reason === 'string' && typeof decision.policy === 'string') {\n return { verdict: decision.verdict, reason: decision.reason, policy: decision.policy };\n }\n return null;\n}\n\nexport function peersOf(body: unknown): Peer[] | null {\n if (!isRecord(body) || !Array.isArray(body.peers)) return null;\n return body.peers.flatMap((peer): Peer[] => {\n if (!isRecord(peer)) return [];\n const sessionId = text(peer.sessionId);\n const agent = text(peer.agent);\n if (!sessionId || !agent) return [];\n return [{ sessionId, agent, role: text(peer.role) ?? 'member', harness: text(peer.harness) ?? '', status: text(peer.status) ?? '', self: peer.self === true, parent: text(peer.parentSessionId) }];\n });\n}\n\n\n\nexport function checkAnswerOf(decision: Decision, hasApprover: boolean): CheckAnswer {\n if (decision.verdict === 'allow') return { decision: 'allow' };\n const reason = `${decision.policy}: ${decision.reason}`;\n if (decision.verdict === 'ask' && hasApprover) return { decision: 'ask', reason };\n return { decision: 'deny', reason };\n}\n\nexport function framed(delivery: Delivery): string {\n if (delivery.mission && delivery.question) return `[orbital] the lead of the mission ${delivery.mission} asks you the question ${delivery.question}, while it works:\\n\\n${delivery.text}\\n\\nShow the person this question and wait for their answer. Then send their answer to the lead with missionReply on ${delivery.mission} with the question ${delivery.question}, or run: orbital mission reply ${delivery.mission} \"<answer>\" --question ${delivery.question}.`;\n if (delivery.mission) return `[orbital] the mission ${delivery.mission} waits for you:\\n\\n${delivery.text}\\n\\nShow the person this question and wait for their answer. Then send their answer to the lead with missionSay on ${delivery.mission}, or run: orbital mission say ${delivery.mission} \"<answer>\".`;\n const from = delivery.fromAgent ? `${delivery.fromAgent}${delivery.fromSession ? ` (session ${delivery.fromSession})` : ''}` : 'the node';\n return `[orbital] message from ${from}:\\n\\n${delivery.text}\\n\\nAnswer it in this turn: your final answer goes back to the sender.`;\n}\n\nexport function attachArgv(bin: readonly string[], options: { cwd: string; sessionId: string; node: string; team: string; mission?: string }): string[] {\n return [\n ...bin,\n 'session',\n 'attach',\n '--harness',\n 'claude-code',\n '--agent',\n 'claude-code',\n '--workspace',\n options.cwd,\n '--external-id',\n options.sessionId,\n '--output',\n 'ndjson',\n ...(options.mission ? ['--mission', options.mission] : options.node ? ['--node', options.node] : []),\n ...(options.team ? ['--team', options.team] : []),\n ];\n}\n\nexport function binCandidates(configured: string): string[][] {\n if (configured.trim() !== '') return [configured.trim().split(/\\s+/)];\n return [['orbital']];\n}\n\nexport function versionOf(stdout: string): string | null {\n try {\n const parsed = JSON.parse(stdout) as unknown;\n if (isRecord(parsed) && isRecord(parsed.data) && typeof parsed.data.version === 'string') return parsed.data.version;\n } catch {\n return null;\n }\n return null;\n}\n\nexport type MissionState = 'open' | 'active' | 'waiting' | 'paused' | 'done' | 'failed';\nexport type Mission = {\n missionId: string;\n node: string;\n team: string | null;\n state: MissionState;\n lead: string | null;\n receivers: string[];\n question: string | null;\n questions: Array<{ id: string; question: string; at: string }>;\n result: string | null;\n brief: string | null;\n claim: string | null;\n claimedAt: string | null;\n stale: boolean;\n lastActivity: string | null;\n sessionId: string | null;\n startedAt: string | null;\n history: Array<{ state: string; at: string; by: string; note: string | null }>;\n};\n\nconst STATES: readonly MissionState[] = ['open', 'active', 'waiting', 'paused', 'done', 'failed'];\n\nexport function missionsOf(body: unknown): Mission[] | null {\n if (!isRecord(body) || !Array.isArray(body.missions)) return null;\n return body.missions.flatMap((one): Mission[] => {\n if (!isRecord(one)) return [];\n const missionId = text(one.missionId);\n const node = text(one.node);\n const state = STATES.find((known) => known === one.state);\n if (!missionId || !node || !state) return [];\n const history = Array.isArray(one.history) ? one.history.flatMap((change) => (isRecord(change) && typeof change.state === 'string' && typeof change.at === 'string' ? [{ state: change.state, at: change.at, by: typeof change.by === 'string' ? change.by : '', note: text(change.note) }] : [])) : [];\n return [{\n missionId,\n node,\n team: text(one.team),\n state,\n lead: text(one.lead),\n receivers: Array.isArray(one.receivers) ? one.receivers.filter((key): key is string => typeof key === 'string') : [],\n question: text(one.question),\n questions: Array.isArray(one.questions) ? one.questions.flatMap((asked) => (isRecord(asked) && typeof asked.id === 'string' && typeof asked.question === 'string' ? [{ id: asked.id, question: asked.question, at: typeof asked.at === 'string' ? asked.at : '' }] : [])) : [],\n result: text(one.result),\n brief: text(one.brief),\n claim: text(one.claim),\n claimedAt: text(one.claimedAt),\n stale: one.stale === true,\n lastActivity: text(one.lastActivity),\n sessionId: text(one.sessionId),\n startedAt: history.find((change) => change.state === 'active')?.at ?? text(one.claimedAt),\n history,\n }];\n });\n}\n\nexport function linesOf(body: unknown): string[] {\n if (!isRecord(body) || !Array.isArray(body.lines)) return [];\n return body.lines.flatMap((line): string[] => (isRecord(line) && typeof line.text === 'string' ? [`${typeof line.at === 'string' ? line.at.slice(11, 19) : '--:--:--'} ${line.text}`] : []));\n}\n\nconst ORDER: Record<MissionState, number> = { waiting: 0, active: 1, paused: 2, open: 3, failed: 4, done: 5 };\n\nexport function waitsOnYou(mission: Mission): boolean {\n return mission.state === 'waiting' || (mission.state === 'active' && mission.questions.length > 0);\n}\n\nexport function shownState(mission: Mission): MissionState {\n return waitsOnYou(mission) ? 'waiting' : mission.state;\n}\n\nexport function missionOrder(missions: readonly Mission[]): Mission[] {\n return [...missions].sort((a, b) => ORDER[shownState(a)] - ORDER[shownState(b)] || a.missionId.localeCompare(b.missionId));\n}\n\nconst SPIN = ['◐', '◓', '◑', '◒'];\n\nexport function stateMark(state: MissionState, tick: number): { mark: string; color: 'warning' | 'suggestion' | 'success' | 'error' | 'subtle' } {\n switch (state) {\n case 'waiting':\n return { mark: '●', color: 'warning' };\n case 'active':\n return { mark: SPIN[tick % SPIN.length] ?? '◐', color: 'suggestion' };\n case 'paused':\n return { mark: '❄', color: 'subtle' };\n case 'done':\n return { mark: '✓', color: 'success' };\n case 'failed':\n return { mark: '✗', color: 'error' };\n default:\n return { mark: '○', color: 'subtle' };\n }\n}\n\nexport function elapsed(fromIso: string | null, now: number): string {\n if (!fromIso) return '';\n const seconds = Math.max(0, Math.floor((now - Date.parse(fromIso)) / 1000));\n if (seconds < 60) return `${seconds}s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes}m`;\n return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;\n}\n\nexport function bandText(node: string, missions: readonly Mission[], sessions: number, toAnswer: number): string {\n const active = missions.filter((mission) => mission.state === 'active').length;\n const waiting = missions.filter(waitsOnYou).length;\n return [\n 'orbital',\n node,\n ...(active > 0 ? [`${active} mission${active === 1 ? '' : 's'} active`] : []),\n ...(waiting > 0 ? [`${waiting} waiting`] : []),\n `${sessions} session${sessions === 1 ? '' : 's'}`,\n ...(toAnswer > 0 ? [`${toAnswer} to answer`] : []),\n ].join(' · ');\n}\n\nexport function changedMissions(before: readonly Mission[], after: readonly Mission[]): Mission[] {\n return after.filter((mission) => (mission.state === 'waiting' || mission.state === 'done' || mission.state === 'failed') && before.some((old) => old.missionId === mission.missionId && old.node === mission.node && old.state !== mission.state));\n}\n\nexport function receiverKeys(sessionId: string, claudeSessionId: string): string[] {\n return [sessionId, `external:${claudeSessionId}`];\n}\n\nexport function waitingNotice(listed: readonly Mission[], mine: readonly string[], seen: readonly string[]): { fresh: Mission[]; seen: string[] } {\n const others = listed.filter((mission) => !mission.receivers.some((key) => mine.includes(key)));\n const keyed = [\n ...others.filter((mission) => mission.state === 'waiting').map((mission) => ({ key: `${mission.node}/${mission.missionId}`, mission })),\n ...others.flatMap((mission) => (mission.state === 'active' || mission.state === 'waiting' ? mission.questions.map((asked) => ({ key: `${mission.node}/${mission.missionId}/${asked.id}`, mission })) : [])),\n ];\n const fresh = keyed.filter((one) => !seen.includes(one.key)).map((one) => one.mission);\n return { fresh: fresh.filter((mission, index) => fresh.indexOf(mission) === index), seen: keyed.map((one) => one.key) };\n}\n\nexport function toastOf(mission: Mission): string {\n switch (mission.state) {\n case 'waiting':\n return `orbital: ${mission.missionId} waits for you: ${mission.question ?? mission.questions.at(-1)?.question ?? 'its lead stopped without a hand-off'}`.slice(0, 200);\n case 'active':\n return `orbital: ${mission.missionId} asks you: ${mission.questions.at(-1)?.question ?? ''}`.slice(0, 200);\n case 'done':\n return `orbital: ${mission.missionId} is done: ${mission.result ?? ''}`.slice(0, 200);\n default:\n return `orbital: ${mission.missionId} failed: ${mission.result ?? ''}`.slice(0, 200);\n }\n}\n\n\n\nexport function missionDetail(mission: Mission, activity: readonly string[]): string[] {\n return [\n `${mission.missionId} on ${mission.node}: ${mission.state}${mission.lead ? ` · lead ${mission.lead}` : ''}${mission.sessionId ? ` · session ${mission.sessionId.slice(0, 8)}` : ''}`,\n ...(mission.claim ? [`claim ${mission.claim}${mission.claimedAt ? ` since ${mission.claimedAt}` : ''}${mission.stale ? ' (stale)' : ''}`] : []),\n ...(mission.brief ? ['', mission.brief] : []),\n ...(mission.state === 'waiting' && mission.question ? ['', 'question:', mission.question] : []),\n ...(mission.questions.length > 0 ? ['', 'open questions of the lead:', ...mission.questions.map((asked) => ` ${asked.id}: ${asked.question}`), `answer one with missionReply on ${mission.missionId} with its question id, or run: orbital mission reply ${mission.missionId} \"<answer>\" --question <id>`] : []),\n ...(mission.state === 'paused' ? ['', ...(mission.question ? ['question (it waits again on resume):', mission.question, ''] : []), `paused: resume it with orbital mission resume ${mission.missionId}, or with missionResume`] : []),\n ...(mission.result ? ['', 'result:', mission.result] : []),\n ...(mission.history.length > 0 ? ['', 'history:', ...mission.history.map((change) => ` ${change.at.slice(0, 19).replace('T', ' ')} ${change.state.padEnd(7)} ${change.by}${change.note ? ` ${change.note}` : ''}`)] : []),\n ...(activity.length > 0 ? ['', 'activity of the lead:', ...activity.map((line) => ` ${line}`)] : []),\n ];\n}\n\nexport type BackgroundWork = { id: string; kind: string; description: string; state: string; lastTool: string | null; at: string };\nexport type TranscriptSession = { id: string; agent: string; status: string; isRunning: boolean; team: string | null; mission: string | null; since: string | null; doing: string | null; lastActivityAt: string | null; background: BackgroundWork[] };\nexport type TranscriptLine = { mark: string; tone: 'plain' | 'dim' | 'good' | 'bad' | 'busy' | 'ask'; text: string };\nexport type Transcript = { session: TranscriptSession; lines: TranscriptLine[] };\n\nfunction clipLine(value: string, length: number): string {\n const line = value.replace(/\\s+/g, ' ').trim();\n return line.length > length ? `${line.slice(0, length - 1)}…` : line;\n}\n\nfunction resultLine(value: string): string {\n const lines = value.replace(/\\s+$/, '').split('\\n').filter((line) => line.trim() !== '');\n if (lines.length === 0) return '(no output)';\n return `${clipLine(lines[0] ?? '', 90)}${lines.length > 1 ? ` … +${lines.length - 1} lines` : ''}`;\n}\n\nexport function transcriptOf(body: unknown): Transcript | null {\n if (!isRecord(body) || !isRecord(body.session) || !Array.isArray(body.entries)) return null;\n const raw = body.session;\n const id = text(raw.id);\n if (!id) return null;\n const background = Array.isArray(raw.background) ? raw.background.flatMap((one): BackgroundWork[] => (isRecord(one) && text(one.id) && text(one.at) ? [{ id: text(one.id)!, kind: text(one.kind) ?? 'task', description: text(one.description) ?? '', state: text(one.state) ?? 'running', lastTool: text(one.lastTool), at: text(one.at)! }] : [])) : [];\n const session: TranscriptSession = { id, agent: text(raw.agent) ?? 'agent', status: text(raw.status) ?? '', isRunning: raw.isRunning === true, team: text(raw.team), mission: text(raw.mission), since: text(raw.since), doing: text(raw.doing), lastActivityAt: text(raw.lastActivityAt), background };\n const lines = body.entries.flatMap((entry): TranscriptLine[] => {\n if (!isRecord(entry)) return [];\n switch (entry.kind) {\n case 'user':\n return [{ mark: '>', tone: 'dim', text: `${text(entry.from) ? `from ${text(entry.from)}: ` : ''}${clipLine(String(entry.text ?? ''), 200)}` }];\n case 'assistant':\n return String(entry.text ?? '').split('\\n').filter((line) => line.trim() !== '').slice(0, 8).map((line, index) => ({ mark: index === 0 ? '⏺' : ' ', tone: 'plain' as const, text: line }));\n case 'tool': {\n const name = text(entry.name) ?? 'tool';\n const input = text(entry.input);\n const call: TranscriptLine = { mark: '⏺', tone: isRecord(entry.result) ? (entry.result.isError === true ? 'bad' : 'good') : 'busy', text: input ? `${name}(${input})` : name };\n if (!isRecord(entry.result)) return [call];\n return [call, { mark: ' ', tone: entry.result.isError === true ? 'bad' : 'dim', text: `⎿ ${resultLine(String(entry.result.text ?? ''))}` }];\n }\n case 'end':\n if (entry.state === 'failed') return [{ mark: '✗', tone: 'bad', text: clipLine(String(entry.text ?? 'The turn failed.'), 200) }];\n if (entry.state === 'canceled') return [{ mark: ' ', tone: 'dim', text: '⎿ Stopped' }];\n if (entry.state === 'waiting') return [{ mark: '⏺', tone: 'ask', text: `Waits for an answer: ${clipLine(String(entry.text ?? ''), 200)}` }];\n return text(entry.text) ? [{ mark: '⏺', tone: 'plain', text: clipLine(String(entry.text), 300) }] : [];\n default:\n return [];\n }\n });\n return { session, lines };\n}\n\nconst SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\n\nexport function isMissionTarget(target: string, missions: readonly Mission[], peers: readonly Peer[]): boolean {\n const asked = target.trim();\n if (missions.some((one) => one.missionId === asked)) return true;\n if (SESSION_ID.test(asked) || peers.some((one) => one.sessionId === asked || (asked.length >= 6 && one.sessionId.startsWith(asked)))) return false;\n return /^[a-z0-9][a-z0-9-]{0,62}$/.test(asked);\n}\n\nexport function joinTarget(target: string, missions: readonly Mission[], peers: readonly Peer[]): { sessionId: string; label: string } | { error: string } {\n const asked = target.trim();\n if (asked === '') return { error: 'Name a mission by its id, or a session id: /orbital join <mission-or-session>.' };\n if (isMissionTarget(asked, missions, peers)) {\n const mission = missions.find((one) => one.missionId === asked && one.sessionId) ?? null;\n if (!mission?.sessionId) return { error: `The mission ${asked} has no lead session yet. /orbital missions lists them.` };\n return { sessionId: mission.sessionId, label: `${mission.missionId} · ${mission.lead ?? 'lead'}` };\n }\n const peer = peers.find((one) => one.sessionId === asked || (asked.length >= 6 && one.sessionId.startsWith(asked)));\n return peer ? { sessionId: peer.sessionId, label: peer.agent } : { sessionId: asked, label: asked.slice(0, 8) };\n}\n\nexport function sessionHeader(session: TranscriptSession, now: number): string {\n const state = session.isRunning ? 'working' : session.status === 'input_required' ? 'waiting' : session.status === 'completed' ? 'idle' : session.status;\n return [\n session.agent,\n session.id.slice(0, 8),\n ...(session.mission ? [session.mission] : []),\n state,\n ...(session.isRunning ? [`${session.doing ?? 'working'}${session.since ? ` ${elapsed(session.since, now)}` : ''}`] : []),\n ...(session.isRunning && session.lastActivityAt ? [`last activity ${elapsed(session.lastActivityAt, now)} ago`] : []),\n ].join(' · ');\n}\n\nexport function backgroundLines(session: TranscriptSession, now: number): string[] {\n const running = session.background.filter((task) => task.state === 'running').slice(-5);\n const ended = session.background.length - session.background.filter((task) => task.state === 'running').length;\n return [\n ...running.map((task) => `↳ running ${task.description ? clipLine(task.description, 60) : task.kind}${task.description ? ` · ${task.kind}` : ''}${task.lastTool ? ` · ${task.lastTool}` : ''} · ${elapsed(task.at, now)} ago`),\n ...(ended > 0 ? [`↳ ${ended} completed`] : []),\n ];\n}\n\nexport type Queued = { text: string; mission: string | null; question: string | null };\n\nexport function stillAsked(queued: readonly Queued[], missions: readonly Mission[] | null): Queued[] {\n if (!missions) return [...queued];\n const open = (one: Queued) => missions.some((mission) => mission.missionId === one.mission && (one.question === null ? mission.state === 'waiting' : mission.questions.some((asked) => asked.id === one.question)));\n const kept = queued.filter((one) => one.mission === null || open(one));\n return kept.filter((one, index) => one.mission === null || !kept.slice(index + 1).some((later) => later.mission === one.mission && later.question === one.question));\n}\n\nexport type Segment = { text: string; tone?: 'yellow' | 'gray' | 'cyan' | 'green' | 'red'; dim?: boolean; bold?: boolean };\nexport type ViewTarget = { kind: 'mission'; node: string; missionId: string; sessionId: string } | { kind: 'member'; node: string; team: string; agent: string } | { kind: 'session'; node: string; sessionId: string } | { kind: 'worker'; node: string; sessionId: string; taskId: string; label: string } | { kind: 'tasks'; node: string; sessionId: string };\nexport type Frame = { header: Segment[][]; rows: Array<{ lines: Segment[][]; target: ViewTarget | null }> };\n\nconst TONES = ['yellow', 'gray', 'cyan', 'green', 'red'] as const;\n\nfunction segmentsOf(value: unknown): Segment[][] {\n if (!Array.isArray(value)) return [];\n return value.map((line) => (Array.isArray(line) ? line.flatMap((segment): Segment[] => {\n if (!isRecord(segment) || typeof segment.text !== 'string') return [];\n const tone = TONES.find((one) => one === segment.tone);\n return [{ text: segment.text, ...(tone && { tone }), ...(segment.dim === true && { dim: true }), ...(segment.bold === true && { bold: true }) }];\n }) : []));\n}\n\nfunction targetOfView(value: unknown): ViewTarget | null {\n if (!isRecord(value)) return null;\n const node = text(value.node);\n if (!node) return null;\n switch (value.kind) {\n case 'mission': {\n const missionId = text(value.missionId);\n const sessionId = text(value.sessionId);\n return missionId && sessionId ? { kind: 'mission', node, missionId, sessionId } : null;\n }\n case 'session': {\n const sessionId = text(value.sessionId);\n return sessionId ? { kind: 'session', node, sessionId } : null;\n }\n case 'worker': {\n const sessionId = text(value.sessionId);\n const taskId = text(value.taskId);\n return sessionId && taskId ? { kind: 'worker', node, sessionId, taskId, label: text(value.label) ?? 'worker' } : null;\n }\n case 'member': {\n const team = text(value.team);\n const agent = text(value.agent);\n return team && agent ? { kind: 'member', node, team, agent } : null;\n }\n case 'tasks': {\n const sessionId = text(value.sessionId);\n return sessionId ? { kind: 'tasks', node, sessionId } : null;\n }\n default:\n return null;\n }\n}\n\nexport function frameOf(record: unknown): Frame | null {\n if (!isRecord(record) || record.kind !== 'view' || !Array.isArray(record.rows)) return null;\n return {\n header: segmentsOf(record.header),\n rows: record.rows.flatMap((row) => (isRecord(row) ? [{ lines: segmentsOf(row.lines), target: targetOfView(row.target) }] : [])),\n };\n}\n\nexport function fitLine(line: readonly Segment[], width: number): Segment[] {\n const fitted: Segment[] = [];\n let used = 0;\n for (const segment of line) {\n if (used >= width) break;\n const room = width - used;\n if (segment.text.length <= room) {\n fitted.push(segment);\n used += segment.text.length;\n continue;\n }\n fitted.push({ ...segment, text: `${segment.text.slice(0, Math.max(0, room - 1))}…` });\n used = width;\n }\n return fitted;\n}\n\nexport function opensTranscript(target: ViewTarget | null): target is Extract<ViewTarget, { kind: 'mission' | 'session' | 'worker' }> {\n return target !== null && (target.kind === 'mission' || target.kind === 'session' || target.kind === 'worker');\n}\n","hooks/register.tsx":"import { atom, read, update } from 'claude-code'\nimport type { EngineInterface, Register } from 'claude-code'\n\nimport type { OrbitalAttached, OrbitalFrame, OrbitalJoined, OrbitalMission, OrbitalPeer, OrbitalSegment, OrbitalTarget, OrbitalTranscript } from '../types'\nimport { DETACHED_PROBLEM, answeredOf, answeredText, attachArgv, attachedOf, backgroundLines, bandText, binCandidates, changedMissions, checkAnswerOf, decisionOf, deliveryOf, detachedOf, elapsed, framed, isMissionTarget, joinTarget, stillAsked, linesOf, missionDetail, missionOrder, missionsOf, shownState, fitLine, frameOf, opensTranscript, peersOf, recordsOf, sessionHeader, stateMark, toastOf, transcriptOf, versionOf, receiverKeys, waitingNotice, withoutAnswered } from './logic.ts'\nimport type { Answered, Delivery, Queued } from './logic.ts'\n\nconst PANE = 'orbital'\nconst attached = atom({ plugin: 'orbital', key: 'attached' } as const, null as OrbitalAttached | null, { shape: 'attached-1' })\nconst peers = atom({ plugin: 'orbital', key: 'peers' } as const, [] as OrbitalPeer[], { shape: 'peers-2' })\nconst missions = atom({ plugin: 'orbital', key: 'missions' } as const, [] as OrbitalMission[], { shape: 'missions-3' })\nconst frame = atom({ plugin: 'orbital', key: 'frame' } as const, null as OrbitalFrame | null, { shape: 'frame-1' })\nconst queued = atom({ plugin: 'orbital', key: 'queued' } as const, [] as Queued[], { shape: 'queued-3' })\nconst detail = atom({ plugin: 'orbital', key: 'detail' } as const, null as string[] | null, { shape: 'detail-1' })\nconst activity = atom({ plugin: 'orbital', key: 'activity' } as const, [] as string[], { shape: 'activity-1' })\nconst pending = atom({ plugin: 'orbital', key: 'pending' } as const, [] as string[], { shape: 'pending-1' })\nconst isTurnRunning = atom({ plugin: 'orbital', key: 'isTurnRunning' } as const, false, { shape: 'running-1' })\nconst seen = atom({ plugin: 'orbital', key: 'seen' } as const, [] as string[], { shape: 'seen-1' })\nconst problem = atom({ plugin: 'orbital', key: 'problem' } as const, null as string | null, { shape: 'problem-1' })\nconst joinedSession = atom({ plugin: 'orbital', key: 'joined' } as const, null as OrbitalJoined | null, { shape: 'joined-1' })\nconst transcript = atom({ plugin: 'orbital', key: 'transcript' } as const, null as OrbitalTranscript | null, { shape: 'transcript-2' })\nconst isCommandRunning = atom({ plugin: 'orbital', key: 'isCommandRunning' } as const, false, { shape: 'command-1' })\nconst said = atom({ plugin: 'orbital', key: 'said' } as const, [] as string[], { shape: 'said-1' })\nconst program = atom({ plugin: 'orbital', key: 'program' } as const, null as string[] | null, { shape: 'program-1' })\nconst link = atom({ plugin: 'orbital', key: 'link' } as const, 0, { shape: 'link-1' })\n\nconst TRANSCRIPT_LIMIT = 30\nconst RELINK_MS = 2000\nconst NO_NODE = 'No orbital node runs for this project. orbital joins it by itself when it runs. Start the nodes with: orbital up, or join a mission of another node with: /orbital join <mission>'\n\ntype Options = { orbital_bin?: string; node?: string; team?: string; governance?: string }\n\nasync function note($: EngineInterface, line: string): Promise<void> {\n await update($, activity, (list) => [...list, line].slice(-50))\n}\n\nasync function deliver($: EngineInterface, delivery: Delivery): Promise<void> {\n if (!delivery.mission) await update($, pending, (list) => [...list, delivery.taskId])\n await note($, delivery.mission ? `${delivery.mission} asks you` : `message from ${delivery.fromAgent ?? 'the node'}`)\n $.ui.toast(delivery.mission ? `orbital: ${delivery.mission} waits for you` : `orbital: message from ${delivery.fromAgent ?? 'the node'}`)\n if (await read($, isCommandRunning)) {\n await update($, queued, (list) => [...list, { text: framed(delivery), mission: delivery.mission, question: delivery.question }])\n return\n }\n if (await read($, isTurnRunning)) {\n if (delivery.mission) {\n await update($, queued, (list) => [...list, { text: framed(delivery), mission: delivery.mission, question: delivery.question }])\n return\n }\n await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: framed(delivery) }] } })\n return\n }\n await $.prompt.submit({ text: framed(delivery) })\n void refresh($)\n}\n\nasync function answered($: EngineInterface, answer: Answered): Promise<void> {\n const before = await read($, queued)\n const kept = withoutAnswered(before, answer)\n await update($, queued, () => kept)\n await note($, `${answer.mission} answered by ${answer.by}`)\n $.ui.toast(`orbital: ${answer.mission} is answered by ${answer.by}`)\n if (kept.length === before.length) await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: answeredText(answer) }] } })\n void refresh($)\n}\n\nasync function getJson($: EngineInterface, joined: OrbitalAttached, path: string): Promise<unknown> {\n const response = await $.http.fetch(`${joined.nodeUrl}${path}`, { headers: { authorization: `Bearer ${joined.token}` } })\n return response.ok ? JSON.parse(response.text) : null\n}\n\nasync function flush($: EngineInterface): Promise<void> {\n if (await read($, isTurnRunning) || await read($, isCommandRunning)) return\n const queuedNow = await read($, queued)\n if (queuedNow.length === 0) return\n const joined = await read($, attached)\n const asking = queuedNow.some((one) => one.mission !== null) && joined ? missionsOf(await getJson($, joined, '/orbital/missions')) : null\n const waiting = stillAsked(queuedNow, asking)\n await update($, queued, () => [])\n if (waiting.length === 0) return\n void $.prompt.submit({ text: waiting.map((one) => one.text).join('\\n\\n') })\n}\n\nasync function refresh($: EngineInterface): Promise<void> {\n const joined = await read($, attached)\n if (!joined) return\n const found = peersOf(await getJson($, joined, '/orbital/peers'))\n if (found && JSON.stringify(found) !== JSON.stringify(await read($, peers))) await update($, peers, () => found)\n const listed = missionsOf(await getJson($, joined, '/orbital/missions'))\n if (listed) {\n const before = await read($, missions)\n const changed = changedMissions(before, listed)\n for (const one of changed) $.ui.toast(toastOf(one))\n const notice = waitingNotice(listed, receiverKeys(joined.sessionId, await $.session.id()), await read($, seen))\n await update($, seen, () => notice.seen)\n for (const one of notice.fresh) {\n if (!changed.some((known) => known.node === one.node && known.missionId === one.missionId)) $.ui.toast(toastOf(one))\n await note($, `${one.missionId} waits for you`)\n }\n if (JSON.stringify(listed) !== JSON.stringify(before)) await update($, missions, () => listed)\n }\n await follow($, joined)\n}\n\nasync function follow($: EngineInterface, joined: OrbitalAttached): Promise<void> {\n const target = await read($, joinedSession)\n if (!target) return\n const response = await $.http.fetch(`${joined.nodeUrl}/orbital/sessions/${encodeURIComponent(target.sessionId)}/transcript?limit=${TRANSCRIPT_LIMIT}${target.worker ? `&worker=${encodeURIComponent(target.worker)}` : ''}`, { headers: { authorization: `Bearer ${joined.token}` } })\n const body: unknown = response.ok ? JSON.parse(response.text) : null\n const parsed = transcriptOf(body)\n const error = parsed ? null : (body && typeof body === 'object' && 'error' in body ? String((body as { error: unknown }).error) : `The node answered ${response.status}.`)\n const next: OrbitalTranscript = parsed ? { ...parsed, error: null } : { session: null, lines: [], error: response.ok ? 'The node sent no transcript.' : error ?? `The node answered ${response.status}.` }\n if (JSON.stringify(next) !== JSON.stringify(await read($, transcript))) await update($, transcript, () => next)\n}\n\ntype Link = { id: number; first: Promise<OrbitalAttached | null>; ended: Promise<void> }\n\nasync function attach($: EngineInterface, argv: string[]): Promise<OrbitalAttached | null> {\n return (await linked($, argv)).first\n}\n\nasync function linked($: EngineInterface, argv: string[]): Promise<Link> {\n const id = (await read($, link)) + 1\n await update($, link, () => id)\n let ready: (joined: OrbitalAttached | null) => void = () => undefined\n const first = new Promise<OrbitalAttached | null>((done) => { ready = done })\n const ended = (async () => {\n let carry = ''\n let mine: string | null = null\n for await (const piece of $.process.spawn({ argv })) {\n if (!('text' in piece) || piece.stream !== 'stdout') continue\n const parsed = recordsOf(piece.text, carry)\n carry = parsed.carry\n for (const record of parsed.records) {\n const joined = attachedOf(record)\n if (joined) {\n mine = joined.sessionId\n await update($, attached, () => joined)\n await update($, problem, () => null)\n await update($, seen, () => [])\n await note($, `joined node ${joined.node} as ${joined.sessionId}`)\n ready(joined)\n continue\n }\n if (detachedOf(record) !== null) {\n await update($, attached, () => null)\n await update($, problem, () => DETACHED_PROBLEM)\n await note($, 'the node is gone')\n continue\n }\n const answer = answeredOf(record)\n if (answer) {\n await answered($, answer)\n continue\n }\n const delivery = deliveryOf(record)\n if (delivery) await deliver($, delivery)\n }\n }\n if (mine && (await read($, attached))?.sessionId === mine) await update($, attached, () => null)\n ready(null)\n })()\n return { id, first, ended }\n}\n\nasync function stayJoined($: EngineInterface, argv: string[]): Promise<void> {\n for (;;) {\n const one = await linked($, argv)\n await one.ended.catch(() => undefined)\n if ((await read($, link)) !== one.id) return\n if (!(await read($, attached)) && (await read($, problem)) !== DETACHED_PROBLEM) await update($, problem, () => NO_NODE)\n await $.clock.sleep(RELINK_MS)\n if ((await read($, link)) !== one.id) return\n }\n}\n\nasync function controlMission($: EngineInterface, verb: 'pause' | 'resume', missionId: string): Promise<string> {\n const joined = await read($, attached)\n if (!joined) return (await read($, problem)) ?? 'Not joined to a node yet.'\n const response = await $.http.fetch(`${joined.nodeUrl}/orbital/missions/${verb}`, {\n method: 'POST',\n headers: { authorization: `Bearer ${joined.token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ missionId }),\n })\n const body: unknown = JSON.parse(response.text || '{}')\n if (!response.ok) return `Not ${verb === 'pause' ? 'paused' : 'resumed'}: ${body && typeof body === 'object' && 'error' in body ? String((body as { error: unknown }).error) : `the node answered ${response.status}`}`\n await refresh($)\n const state = body && typeof body === 'object' && 'state' in body ? String((body as { state: unknown }).state) : verb === 'pause' ? 'paused' : 'active'\n return `${missionId} is ${state}.`\n}\n\nconst VIEW_WIDTH = 60\n\nasync function watchView($: EngineInterface, bin: readonly string[]): Promise<void> {\n let carry = ''\n for await (const piece of $.process.spawn({ argv: [...bin, 'view', '--watch', '--width', String(VIEW_WIDTH), '--output', 'ndjson'] })) {\n if (!('text' in piece) || piece.stream !== 'stdout') continue\n const parsed = recordsOf(piece.text, carry)\n carry = parsed.carry\n for (const record of parsed.records) {\n const next = frameOf(record)\n if (next) await update($, frame, () => next)\n }\n }\n}\n\nasync function openTarget($: EngineInterface, target: OrbitalTarget): Promise<string> {\n if (target.kind === 'mission') return join($, target.missionId)\n if (target.kind !== 'session' && target.kind !== 'worker') return 'This row opens nothing.'\n const bin = await read($, program)\n if ((await read($, attached))?.node !== target.node) {\n if (!bin) return (await read($, problem)) ?? 'No orbital program on the PATH.'\n const moved = await attach($, attachArgv(bin, { cwd: await $.session.cwd(), sessionId: await $.session.id(), node: target.node, team: '' }))\n if (!moved) return `The node ${target.node} did not take this session.`\n }\n const label = target.kind === 'worker' ? `the worker ${target.label}` : `session ${target.sessionId.slice(0, 8)}`\n await update($, joinedSession, () => ({ sessionId: target.sessionId, label, worker: target.kind === 'worker' ? target.taskId : null }))\n await update($, transcript, () => null)\n await update($, detail, () => null)\n const joined = await read($, attached)\n if (joined) await follow($, joined)\n return `orbital follows ${label} on ${target.node}.`\n}\n\nconst TONE = { yellow: 'warning', green: 'success', red: 'error', cyan: 'suggestion', gray: undefined } as const\n\nasync function claimMission($: EngineInterface, missionId: string): Promise<string | null> {\n const joined = await read($, attached)\n const runsHere = joined && (await read($, missions)).some((mission) => mission.missionId === missionId && mission.node === joined.node && (mission.state === 'active' || mission.state === 'waiting' || mission.state === 'paused'))\n if (joined) {\n const response = await $.http.fetch(`${joined.nodeUrl}/orbital/missions/join`, {\n method: 'POST',\n headers: { authorization: `Bearer ${joined.token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ missionId }),\n })\n if (response.ok) return null\n const body: unknown = JSON.parse(response.text || '{}')\n if (runsHere) return body && typeof body === 'object' && 'error' in body ? String((body as { error: unknown }).error) : `The node answered ${response.status}.`\n }\n const bin = await read($, program)\n if (!bin) return (await read($, problem)) ?? 'No orbital program on the PATH.'\n const moved = await attach($, attachArgv(bin, { cwd: await $.session.cwd(), sessionId: await $.session.id(), node: '', team: '', mission: missionId }))\n if (!moved) return `No running node on this machine runs the mission ${missionId}, or it refused this session. orbital mission list shows the missions.`\n await refresh($)\n return null\n}\n\nasync function join($: EngineInterface, target: string): Promise<string> {\n await refresh($)\n const asked = target.trim()\n const mission = isMissionTarget(asked, await read($, missions), await read($, peers))\n const refused = mission ? await claimMission($, asked) : null\n if (refused) return refused\n const chosen = joinTarget(target, await read($, missions), await read($, peers))\n if ('error' in chosen) return chosen.error\n await update($, joinedSession, () => chosen)\n await update($, transcript, () => null)\n await update($, detail, () => null)\n const joined = await read($, attached)\n if (joined) await follow($, joined)\n return mission ? `orbital follows ${chosen.label} (session ${chosen.sessionId.slice(0, 8)}): its questions come to this session, and anything the person says to the lead goes to it at once with missionSay ${asked} or /orbital say <message>.` : `orbital follows ${chosen.label} (session ${chosen.sessionId.slice(0, 8)}): /orbital say <message> talks to it.`\n}\n\nconst DELIVERY: Record<string, string> = { steer: 'the lead takes it during its current turn', turn: 'the lead takes it as its next turn', reply: 'it answers the question of the mission' }\n\nasync function say($: EngineInterface, text: string): Promise<string> {\n const joined = await read($, attached)\n const following = await read($, joinedSession)\n if (!joined || !following) return 'Follow a lead first: /orbital join <mission>.'\n if (following.worker) return 'A worker takes no message: talk to its lead. /orbital goes back.'\n if (!text) return 'Write the message: /orbital say <message>.'\n await update($, said, (list) => [...list, text])\n const response = await $.http.fetch(`${joined.nodeUrl}/orbital/missions/say`, {\n method: 'POST',\n headers: { authorization: `Bearer ${joined.token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ target: following.sessionId, text }),\n })\n const body: unknown = JSON.parse(response.text || '{}')\n if (!response.ok) {\n await update($, said, (list) => list.filter((one) => one !== text))\n return `Not sent: ${body && typeof body === 'object' && 'error' in body ? String((body as { error: unknown }).error) : `the node answered ${response.status}`}`\n }\n const delivery = body && typeof body === 'object' && 'delivery' in body ? String((body as { delivery: unknown }).delivery) : ''\n return `Sent to ${following.label}: ${DELIVERY[delivery] ?? 'the lead has it'}.`\n}\n\nasync function leave($: EngineInterface): Promise<void> {\n await update($, joinedSession, () => null)\n await update($, transcript, () => null)\n}\n\nasync function detailOf($: EngineInterface, id: string): Promise<string[]> {\n const joined = await read($, attached)\n if (!joined) return [await read($, problem) ?? 'Not joined to a node yet.']\n await refresh($)\n const mission = (await read($, missions)).find((one) => one.missionId === id)\n if (!mission) return [`No mission ${id}. /orbital missions lists them.`]\n const activity = linesOf(await getJson($, joined, `/orbital/missions/${encodeURIComponent(id)}/activity?limit=20`))\n return missionDetail(mission, activity)\n}\n\nfunction missionList(list: readonly OrbitalMission[], now: number): string[] {\n if (list.length === 0) return ['No mission.']\n return missionOrder(list).map((mission) => `${stateMark(shownState(mission), 0).mark} ${mission.missionId}${mission.team ? ` · team ${mission.team}` : ''}${mission.lead ? ` · lead ${mission.lead}` : ''} · ${mission.state}${mission.state === 'active' ? ` ${elapsed(mission.startedAt, now)}` : ''}${mission.state === 'waiting' ? (mission.question ? ` · asks: ${mission.question}` : ' · stopped without a hand-off') : ''}${mission.questions.map((asked) => ` · asks ${asked.id}: ${asked.question}`).join('')}${mission.state === 'paused' ? ` · resume with orbital mission resume ${mission.missionId}` : ''}`)\n}\n\nexport const register: Register = (on, options) => {\n const config = (options ?? {}) as Options\n const enforce = config.governance !== 'observe'\n\n on('session.start', async ($, e, next) => {\n const started = await next(e)\n await $.command.register({ name: 'orbital', description: 'Show the missions of orbital, the sessions you reach and the lan peers, in a pane', argumentHint: '[missions | mission <id> | join <mission-or-session> | say <message> | pause <id> | resume <id>]' }).catch(() => undefined)\n void (async () => {\n let bin: string[] | null = null\n for (const candidate of binCandidates(config.orbital_bin ?? '')) {\n try {\n const ran = await $.process.run([...candidate, '--version', '--output', 'json'], { timeoutMs: 20000 })\n if (ran.exitCode === 0 && versionOf(ran.stdout) !== null) {\n bin = candidate\n break\n }\n } catch {\n continue\n }\n }\n if (!bin) {\n await update($, problem, () => 'No orbital program on the PATH. Install it with: npm i -g <scope>/orbital')\n $.ui.toast('orbital: no orbital program on the PATH. Install it, then start a new session.')\n return\n }\n await update($, program, () => bin)\n void watchView($, bin)\n await stayJoined($, attachArgv(bin, { cwd: await $.session.cwd(), sessionId: await $.session.id(), node: config.node ?? '', team: config.team ?? '' }))\n })()\n void $.clock.every(2000, () => void refresh($).then(() => flush($)))\n return started\n })\n\n on('turn.start', async ($, e, next) => {\n await update($, isTurnRunning, () => true)\n return next(e)\n })\n\n on('turn.complete', async ($, e, next) => {\n const done = await next(e)\n await update($, isTurnRunning, () => false)\n await flush($)\n const joined = await read($, attached)\n const open = await read($, pending)\n if (!joined || open.length === 0) return done\n const [taskId, ...rest] = open\n await update($, pending, () => rest)\n const answer = 'answer' in e && typeof e.answer === 'string' ? e.answer : ''\n await $.http.fetch(`${joined.nodeUrl}/orbital/sessions/${encodeURIComponent(joined.sessionId)}/settled`, {\n method: 'POST',\n headers: { authorization: `Bearer ${joined.token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ taskId, text: answer }),\n })\n await note($, `answered ${taskId}`)\n return done\n })\n\n on('tool.check', async ($, e, next) => {\n const joined = await read($, attached)\n if (!joined) return next(e)\n const response = await $.http.fetch(`${joined.nodeUrl}/orbital/policy/tool`, {\n method: 'POST',\n headers: { authorization: `Bearer ${joined.token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ toolName: e.tool, input: e.input ?? {}, ask: 'caller' }),\n })\n const decision = response.ok ? decisionOf(JSON.parse(response.text)) : null\n if (!decision) {\n if (!enforce) return next(e)\n return { decision: 'deny', reason: `orbital: the node ${joined.node} gave no decision (${response.status})` }\n }\n const answer = checkAnswerOf(decision, e.approver !== undefined)\n if (answer.decision === 'allow') return next(e)\n await note($, `${answer.decision} ${e.tool}: ${answer.reason}`)\n if (answer.decision === 'deny') $.ui.toast(`orbital denied ${e.tool}: ${answer.reason}`)\n if (!enforce) return next(e)\n return answer\n }).catch(($, e, next) => (enforce ? { decision: 'deny', reason: 'orbital: the node did not answer, and governance is enforce' } : next(e)))\n\n on('command.run', { command: 'orbital' }, async ($, e) => {\n const args = e.args.trim()\n if (args === 'missions') {\n await refresh($)\n return { text: missionList(await read($, missions), Date.now()).join('\\n') }\n }\n if (args.startsWith('join')) {\n await update($, isCommandRunning, () => true)\n const said = await join($, args.slice('join'.length)).finally(() => update($, isCommandRunning, () => false))\n const placed = await $.ui.open({ id: PANE, title: 'orbital' })\n const shown = await read($, transcript)\n return { text: placed.isPlaced || !shown ? said : [said, ...(shown.session ? [sessionHeader(shown.session, Date.now())] : []), ...shown.lines.slice(-12).map((line) => `${line.mark} ${line.text}`)].join('\\n') }\n }\n if (args === 'say' || args.startsWith('say ')) {\n const answer = await say($, args.slice('say'.length).trim())\n await $.ui.open({ id: PANE, title: 'orbital' })\n return { text: answer }\n }\n const control = /^(pause|resume)\\s+(\\S+)$/.exec(args)\n if (control) return { text: await controlMission($, control[1] as 'pause' | 'resume', control[2]!) }\n if (args.startsWith('mission ')) {\n const lines = await detailOf($, args.slice('mission '.length).trim())\n await update($, detail, () => lines)\n const placed = await $.ui.open({ id: PANE, title: 'orbital' })\n return { text: placed.isPlaced ? 'orbital pane shows the mission.' : lines.join('\\n') }\n }\n await update($, detail, () => null)\n await leave($)\n await refresh($)\n const placed = await $.ui.open({ id: PANE, title: 'orbital' })\n const joined = await read($, attached)\n return { text: placed.isPlaced ? 'orbital pane opened.' : joined ? missionList(await read($, missions), Date.now()).join('\\n') : (await read($, problem)) ?? 'Not joined to a node yet.' }\n })\n\n on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {\n const { Box, Text, Button } = $.ui.resolve(e)\n const joined = await read($, attached)\n const view = await read($, frame)\n const width = Math.max(30, Math.min(VIEW_WIDTH, (e.props as { bodyColumns?: number } | undefined)?.bodyColumns ?? VIEW_WIDTH))\n const line = (segments: readonly OrbitalSegment[]) => (\n <Box flexDirection=\"row\">\n {fitLine(segments, width).map((segment) => <Text color={segment.tone ? TONE[segment.tone] : undefined} dimColor={segment.dim === true || segment.tone === 'gray'} bold={segment.bold === true}>{segment.text}</Text>)}\n </Box>\n )\n if (!joined && !view) return <Text dimColor>{(await read($, problem)) ?? 'Not joined to a node yet.'}</Text>\n const following = await read($, joinedSession)\n if (following && joined) {\n const shown = await read($, transcript)\n const room = Math.max(4, (e.viewport?.rows ?? 30) - 5)\n const colour = { plain: undefined, dim: undefined, good: 'success', bad: 'error', busy: 'warning', ask: 'suggestion' } as const\n return (\n <Box flexDirection=\"column\">\n <Box flexDirection=\"row\">\n <Button key=\"back\" plain onPress={() => void leave($)}>missions</Button>\n <Text dimColor>{` orbital · ${joined.node}`}</Text>\n </Box>\n <Text bold>{following.worker ? following.label : shown?.session ? sessionHeader(shown.session, Date.now()) : following.label}</Text>\n {(shown?.session ? backgroundLines(shown.session, Date.now()) : []).map((line) => <Text dimColor>{` ${line}`}</Text>)}\n {!shown && <Text dimColor>Reading the transcript…</Text>}\n {shown?.error && <Text color=\"error\">{shown.error}</Text>}\n {(shown?.lines ?? []).slice(-room).map((line) => (\n <Box flexDirection=\"row\">\n <Text color={colour[line.tone]}>{`${line.mark} `}</Text>\n <Text dimColor={line.tone === 'dim'} color={line.tone === 'bad' ? 'error' : line.tone === 'ask' ? 'suggestion' : undefined}>{line.text}</Text>\n </Box>\n ))}\n {shown?.session?.isRunning && <Text color=\"warning\">{`✻ ${shown.session.doing ?? 'Working'}… (${elapsed(shown.session.since, Date.now()) || '0s'})`}</Text>}\n {(await read($, said)).filter((text) => !(shown?.lines ?? []).some((line) => line.text.includes(text.slice(0, 60)))).map((text) => (\n <Box flexDirection=\"row\">\n <Text>{'> '}</Text>\n <Text>{text}</Text>\n <Text dimColor>{' (queued)'}</Text>\n </Box>\n ))}\n <Text dimColor>{`/orbital say <message> talks to the lead · /orbital goes back`}</Text>\n </Box>\n )\n }\n const shown = await read($, detail)\n if (shown) {\n return (\n <Box flexDirection=\"column\">\n {shown.map((line) => <Text>{line}</Text>)}\n <Text dimColor>/orbital goes back to the missions.</Text>\n </Box>\n )\n }\n if (!view) return <Text dimColor>{`orbital · ${joined?.node ?? ''} · reading the nodes…`}</Text>\n return (\n <Box flexDirection=\"column\">\n {view.header.map((segments) => line(segments))}\n {view.rows.map((row, index) => {\n const target = row.target\n if (!opensTranscript(target)) return <Box flexDirection=\"column\">{row.lines.map((segments) => line(segments))}</Box>\n return (\n <Box flexDirection=\"column\">\n <Button key={`row:${index}`} plain onPress={() => void openTarget($, target)}>{fitLine(row.lines[0] ?? [], width).map((segment) => segment.text).join('')}</Button>\n {row.lines.slice(1).map((segments) => line(segments))}\n </Box>\n )\n })}\n {!joined && <Text dimColor>{(await read($, problem)) ?? 'Not joined to a node.'}</Text>}\n </Box>\n )\n })\n\n on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {\n const joined = await read($, attached)\n if (!joined) return next(e)\n const reached = (await read($, peers)).filter((peer) => !peer.self).length\n const open = await read($, pending)\n const { Text } = $.ui.resolve(e)\n return <Text dimColor>{bandText(joined.node, await read($, missions), reached, open.length)}</Text>\n })\n}\n","skills/orbital-team/SKILL.md":"---\nname: orbital-team\ndescription: Use when this session works with other agent sessions through an orbital node - to see the peers of the team, send a peer a message and wait for the answer, dispatch a job to an agent of the node, answer a message that a peer sent, or give a team a mission, such as work on a GitHub issue.\n---\n\n# Work with the team through orbital\n\nThis session is a member of a team on an orbital node.\nThe node carries the messages between the sessions of the team, on any harness.\n\n## The tools\n\n- `agentPeers` lists the sessions of the team: the agent, the session id, the role, the harness and the status.\n- `agentMessage` sends a message to a session by its id. A session that works takes it during its current turn. With `wait: true`, it returns the answer of the receiver.\n- `agentDispatch` starts an agent of the node on a task, in a session of its own, and `agentWait` waits for its result.\n- `agentList` lists the agents of the node.\n- `agentInbox` takes the messages that peers sent this session, and `agentAnswer` answers one by its `taskId`.\n- `missionNew` writes a mission for a team, `missionStart` starts it, and `missionList` and `missionShow` report its state and result.\n- `sessionTranscript` reads what a session does now: its state, its current action and its last transcript entries.\n Give it a session id, or a mission id for the lead of that mission.\n- `missionSay` gives the lead of a mission a message from the person: during its current turn when it works, as its next turn when it is idle, and as the answer when the mission waits.\n- `missionReply` answers a question of a mission: the question of a waiting mission, or an open question its lead asked with `missionAsk`, by its question id. The answer reaches the lead and closes that question for every decision receiver.\n- `missionJoin` makes this session a decision receiver of a running mission: each question of its lead comes to this session, and the questions that are open now come at once. Any session can join, also this Claude Code session. A mission can have many receivers: each question goes to every live one, and the first answer closes it for all.\n This session stays a receiver after it or the node restarts, so join once. In a terminal of this session, `orbital mission join <mission>` does the same.\n- `missionLeave` removes this session from the decision receivers of a mission.\n- `missionPause` stops the lead of an active or waiting mission and keeps its session; `missionResume` lets the lead go on in that session.\n- `missionAsk` asks the decision receivers of the mission of your session a question, as its lead, at any moment, also while your workers run. The node keeps each question open until a receiver answers it, shows it as waiting on the person, and pushes it at once to every live receiver.\n- `missionLink` adds a link to the mission of your session, as its lead: a label, a URL and a state, such as `PR #42`, its URL and `open`. A call with the same URL replaces the link.\n\n## Missions\n\nA mission is one objective of a team, from its intent to its hand-off.\nWhen the person asks a team to work on something, such as \"build, take issue 123\", give the team a mission:\n\n1. Read the source of the work, for example `gh issue view 123`.\n2. Write the mission with `missionNew`: the team, an id such as `issue-123`, the person as `given-by`, and a brief.\n The node writes `missions/<id>/MISSION.md`. One id names one mission on a node, so choose an id that no other mission has.\n The intent says what to do and links the source.\n The end state says what is true when the mission is done, for example \"a PR off main closes #123\".\n The limits say what the team must not do.\n3. Start it with `missionStart`, and tell the person the id.\n4. When the person asks, report the state and the result with `missionShow`.\n5. When the person asks what the lead does, read it with `sessionTranscript` on the mission id.\n Tell the person the current action and the last steps, in a few lines.\n6. When the person says something to the lead, send it at once with `missionSay`, in the words of the person.\n Do this whether or not the lead asked a question: a lead that works takes it during its turn, and a waiting mission takes it as the answer.\n Never hold a message back until the lead asks.\n7. When the person asks to stop a mission for now, pause it with `missionPause`, and resume it with `missionResume` when they ask.\n A paused mission takes no reply: resume it first.\n A node that stops pauses each active mission, and no lead goes on until a person resumes it.\n\nThe person follows a lead live with `/orbital join <mission>` in the orbital pane, or with `orbital session join <mission>` in a terminal. `/orbital join` of a mission works from any folder: the session attaches to the node that runs the mission and becomes one of its decision receivers, so each question of the lead comes to the session. Show the person the question, and send their answer with `missionSay`. A question that the lead asked with `missionAsk` names its question id: send the answer with `missionReply` and that question id, so that the answer closes the right question.\nWhen the person asks you to watch a mission, or to get its decisions, call `missionJoin` on it.\nA message that starts with `[orbital]` and says a question is answered means another receiver answered it: do not answer it again, and tell the person.\nWhile the session follows a lead, everything the person addresses to the lead goes to it with `missionSay`; the person can also type `/orbital say <message>`, or run `orbital mission say <mission> \"<message>\"` in a terminal.\n\n## The rules\n\n- A message from a peer arrives as a turn that starts with `[orbital] message from`.\n Answer it in that turn: the answer of the turn goes back to the sender.\n- State a message in full, because the receiver has none of your context.\n- Do not wait on a peer that may be waiting on you.\n- As the lead of a team, run long work in the background: start a worker (a member with `agentDispatch`, or a sub-agent in the background), end your turn, and answer the person.\n Do not sleep or poll for a worker: each result wakes you with a new turn, and the mission stays active while your workers run.\n A sub-agent whose type is the name of a team member shows as the work of that member.\n- As the lead of a mission, report each pull request you open with `missionLink`: its label, its URL and the state `open`.\n When its state changes, such as to merged or closed, call `missionLink` again with the same URL and the new state.\n The person sees the links of each mission in the live view.\n- As the lead of a mission, ask each decision that you need with `missionAsk`, at once, also while your workers run.\n Never put a question only in your reply text: text that ends no turn reaches no one, and the mission does not show it.\n Ask each question once, with the options you see and what each one means. Several questions stay open side by side, and each answer comes back to you as a message that names its question.\n Go on with the work that does not depend on the answer.\n- When nothing is left to do but wait for an answer, end your turn with the question: a turn that ends with no hand-off and no running work makes the mission wait.\n- A tool call that the policy of the node denies fails with the policy name in the reason. Do not try the same call another way.\n","types/index.d.ts":"export type OrbitalAttached = { sessionId: string; token: string; node: string; nodeUrl: string };\nexport type OrbitalPeer = { sessionId: string; agent: string; role: string; harness: string; status: string; self: boolean; parent: string | null };\nexport type OrbitalMission = { missionId: string; node: string; team: string | null; state: 'open' | 'active' | 'waiting' | 'paused' | 'done' | 'failed'; lead: string | null; receivers: string[]; question: string | null; questions: Array<{ id: string; question: string; at: string }>; result: string | null; brief: string | null; claim: string | null; claimedAt: string | null; stale: boolean; lastActivity: string | null; sessionId: string | null; startedAt: string | null; history: Array<{ state: string; at: string; by: string; note: string | null }> };\nexport type OrbitalBackground = { id: string; kind: string; description: string; state: string; lastTool: string | null; at: string };\nexport type OrbitalJoined = { sessionId: string; label: string; worker?: string | null };\nexport type OrbitalSegment = { text: string; tone?: 'yellow' | 'gray' | 'cyan' | 'green' | 'red'; dim?: boolean; bold?: boolean };\nexport type OrbitalTarget = { kind: 'mission'; node: string; missionId: string; sessionId: string } | { kind: 'member'; node: string; team: string; agent: string } | { kind: 'session'; node: string; sessionId: string } | { kind: 'worker'; node: string; sessionId: string; taskId: string; label: string } | { kind: 'tasks'; node: string; sessionId: string };\nexport type OrbitalFrame = { header: OrbitalSegment[][]; rows: Array<{ lines: OrbitalSegment[][]; target: OrbitalTarget | null }> };\nexport type OrbitalTranscript = {\n session: { id: string; agent: string; status: string; isRunning: boolean; team: string | null; mission: string | null; since: string | null; doing: string | null; lastActivityAt: string | null; background: OrbitalBackground[] } | null;\n lines: Array<{ mark: string; tone: 'plain' | 'dim' | 'good' | 'bad' | 'busy' | 'ask'; text: string }>;\n error: string | null;\n};\n\ndeclare module 'claude-code' {\n interface PluginState {\n orbital: {\n attached: Shaped<OrbitalAttached | null>;\n peers: Shaped<OrbitalPeer[]>;\n missions: Shaped<OrbitalMission[]>;\n frame: Shaped<OrbitalFrame | null>;\n detail: Shaped<string[] | null>;\n queued: Shaped<Array<{ text: string; mission: string | null; question: string | null }>>;\n activity: Shaped<string[]>;\n pending: Shaped<string[]>;\n isTurnRunning: Shaped<boolean>;\n problem: Shaped<string | null>;\n seen: Shaped<string[]>;\n joined: Shaped<OrbitalJoined | null>;\n transcript: Shaped<OrbitalTranscript | null>;\n program: Shaped<string[] | null>;\n link: Shaped<number>;\n said: Shaped<string[]>;\n isCommandRunning: Shaped<boolean>;\n };\n }\n}\n"}}
1
+ {"files":{".claude-plugin/marketplace.json":"{\n \"name\": \"orbital\",\n \"owner\": { \"name\": \"orbital\" },\n \"plugins\": [{ \"name\": \"orbital\", \"source\": \"./\" }]\n}\n",".claude-plugin/plugin.json":"{\n \"name\": \"orbital\",\n \"version\": \"0.0.6\",\n \"description\": \"Joins this Claude Code session to an orbital node: the agent tools of the team, one policy chain on every tool call, messages from peers as turns, and a pane of the node.\",\n \"license\": \"UNLICENSED\",\n \"types\": \"./types/index.d.ts\",\n \"userConfig\": {\n \"orbital_bin\": {\n \"type\": \"string\",\n \"title\": \"orbital binary\",\n \"description\": \"The program that runs orbital. Empty: orbital, on the PATH. Set it to run orbital through another program.\",\n \"default\": \"\"\n },\n \"node\": {\n \"type\": \"string\",\n \"title\": \"Node\",\n \"description\": \"The node to join. Empty: ORBITAL_NODE, then the node key of the definition, then the node of the folder.\",\n \"default\": \"\"\n },\n \"team\": {\n \"type\": \"string\",\n \"title\": \"Team\",\n \"description\": \"The team this session joins. Sessions of one team are peers.\",\n \"default\": \"\"\n },\n \"governance\": {\n \"type\": \"string\",\n \"title\": \"Governance\",\n \"description\": \"enforce: a tool call is denied when the node does not answer. observe: the node only reports.\",\n \"options\": [\n \"enforce\",\n \"observe\"\n ],\n \"default\": \"enforce\"\n }\n }\n}\n",".mcp.json":"{\n \"mcpServers\": {\n \"orbital\": {\n \"command\": \"orbital\",\n \"args\": [\"mcp\", \"--attach\", \"--harness\", \"claude-code\", \"--agent\", \"claude-code\", \"--external-id-env\", \"CLAUDE_CODE_SESSION_ID\"]\n }\n }\n}\n","hooks/hooks.json":"{ \"modules\": [\"./register.tsx\"] }\n","hooks/logic.ts":"export type Attached = { sessionId: string; token: string; node: string; nodeUrl: string };\nexport type Delivery = { taskId: string; fromAgent: string | null; fromSession: string | null; mission: string | null; question: string | null; text: string };\nexport type Decision = { verdict: 'allow' } | { verdict: 'deny' | 'ask'; reason: string; policy: string };\nexport type Peer = { sessionId: string; agent: string; role: string; harness: string; status: string; self: boolean; parent: string | null };\nexport type CheckAnswer = { decision: 'allow' } | { decision: 'deny' | 'ask'; reason: string };\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction text(value: unknown): string | null {\n return typeof value === 'string' && value !== '' ? value : null;\n}\n\nexport function recordsOf(chunk: string, carry: string): { records: unknown[]; carry: string } {\n const lines = (carry + chunk).split('\\n');\n const rest = lines.pop() ?? '';\n const records = lines.filter((line) => line.trim() !== '').flatMap((line) => {\n try {\n return [JSON.parse(line) as unknown];\n } catch {\n return [];\n }\n });\n return { records, carry: rest };\n}\n\nexport function attachedOf(record: unknown): Attached | null {\n if (!isRecord(record) || record.kind !== 'attached') return null;\n const sessionId = text(record.sessionId);\n const token = text(record.token);\n const node = text(record.node);\n const nodeUrl = text(record.nodeUrl);\n return sessionId && token && node && nodeUrl ? { sessionId, token, node, nodeUrl } : null;\n}\n\nexport function detachedOf(record: unknown): string | null {\n if (!isRecord(record) || record.kind !== 'detached') return null;\n return typeof record.reason === 'string' ? record.reason : '';\n}\n\nexport const DETACHED_PROBLEM = 'The node is gone. orbital joins it again by itself when it runs.';\n\nexport function deliveryOf(record: unknown): Delivery | null {\n if (!isRecord(record) || record.kind !== 'delivery') return null;\n const taskId = text(record.taskId);\n if (!taskId || typeof record.text !== 'string') return null;\n return { taskId, fromAgent: text(record.fromAgent), fromSession: text(record.fromSession), mission: text(record.mission), question: text(record.question), text: record.text };\n}\n\nexport type Answered = { mission: string; question: string | null; by: string; text: string };\n\nexport function answeredOf(record: unknown): Answered | null {\n if (!isRecord(record) || record.kind !== 'answered') return null;\n const mission = text(record.mission);\n if (!mission || typeof record.text !== 'string') return null;\n return { mission, question: text(record.question), by: text(record.by) ?? 'another receiver', text: record.text };\n}\n\nexport function answeredText(answered: Answered): string {\n return `[orbital] ${answered.question ? `the question ${answered.question}` : 'the question'} of the mission ${answered.mission} is answered, by ${answered.by}:\\n\\n${answered.text}\\n\\nDo not answer it again, and tell the person it is answered.`;\n}\n\nexport function withoutAnswered(queued: readonly Queued[], answered: Answered): Queued[] {\n return queued.filter((one) => one.mission !== answered.mission || (answered.question !== null && one.question !== answered.question));\n}\n\nexport function decisionOf(body: unknown): Decision | null {\n if (!isRecord(body) || !isRecord(body.decision)) return null;\n const decision = body.decision;\n if (decision.verdict === 'allow') return { verdict: 'allow' };\n if ((decision.verdict === 'deny' || decision.verdict === 'ask') && typeof decision.reason === 'string' && typeof decision.policy === 'string') {\n return { verdict: decision.verdict, reason: decision.reason, policy: decision.policy };\n }\n return null;\n}\n\nexport function peersOf(body: unknown): Peer[] | null {\n if (!isRecord(body) || !Array.isArray(body.peers)) return null;\n return body.peers.flatMap((peer): Peer[] => {\n if (!isRecord(peer)) return [];\n const sessionId = text(peer.sessionId);\n const agent = text(peer.agent);\n if (!sessionId || !agent) return [];\n return [{ sessionId, agent, role: text(peer.role) ?? 'member', harness: text(peer.harness) ?? '', status: text(peer.status) ?? '', self: peer.self === true, parent: text(peer.parentSessionId) }];\n });\n}\n\n\n\nexport function checkAnswerOf(decision: Decision, hasApprover: boolean): CheckAnswer {\n if (decision.verdict === 'allow') return { decision: 'allow' };\n const reason = `${decision.policy}: ${decision.reason}`;\n if (decision.verdict === 'ask' && hasApprover) return { decision: 'ask', reason };\n return { decision: 'deny', reason };\n}\n\nexport function framed(delivery: Delivery): string {\n if (delivery.mission && delivery.question) return `[orbital] the lead of the mission ${delivery.mission} asks you the question ${delivery.question}, while it works:\\n\\n${delivery.text}\\n\\nShow the person this question and wait for their answer. Then send their answer to the lead with missionReply on ${delivery.mission} with the question ${delivery.question}, or run: orbital mission reply ${delivery.mission} \"<answer>\" --question ${delivery.question}.`;\n if (delivery.mission) return `[orbital] the mission ${delivery.mission} waits for you:\\n\\n${delivery.text}\\n\\nShow the person this question and wait for their answer. Then send their answer to the lead with missionSay on ${delivery.mission}, or run: orbital mission say ${delivery.mission} \"<answer>\".`;\n const from = delivery.fromAgent ? `${delivery.fromAgent}${delivery.fromSession ? ` (session ${delivery.fromSession})` : ''}` : 'the node';\n return `[orbital] message from ${from}:\\n\\n${delivery.text}\\n\\nAnswer it in this turn: your final answer goes back to the sender.`;\n}\n\nexport function attachArgv(bin: readonly string[], options: { cwd: string; sessionId: string; node: string; team: string; mission?: string }): string[] {\n return [\n ...bin,\n 'session',\n 'attach',\n '--harness',\n 'claude-code',\n '--agent',\n 'claude-code',\n '--workspace',\n options.cwd,\n '--external-id',\n options.sessionId,\n '--output',\n 'ndjson',\n ...(options.mission ? ['--mission', options.mission] : options.node ? ['--node', options.node] : []),\n ...(options.team ? ['--team', options.team] : []),\n ];\n}\n\nexport function binCandidates(configured: string): string[][] {\n if (configured.trim() !== '') return [configured.trim().split(/\\s+/)];\n return [['orbital']];\n}\n\nexport function versionOf(stdout: string): string | null {\n try {\n const parsed = JSON.parse(stdout) as unknown;\n if (isRecord(parsed) && isRecord(parsed.data) && typeof parsed.data.version === 'string') return parsed.data.version;\n } catch {\n return null;\n }\n return null;\n}\n\nexport type MissionState = 'open' | 'active' | 'waiting' | 'paused' | 'done' | 'failed';\nexport type Mission = {\n missionId: string;\n node: string;\n team: string | null;\n state: MissionState;\n lead: string | null;\n receivers: string[];\n question: string | null;\n questions: Array<{ id: string; question: string; at: string }>;\n result: string | null;\n brief: string | null;\n claim: string | null;\n claimedAt: string | null;\n stale: boolean;\n lastActivity: string | null;\n sessionId: string | null;\n startedAt: string | null;\n history: Array<{ state: string; at: string; by: string; note: string | null }>;\n};\n\nconst STATES: readonly MissionState[] = ['open', 'active', 'waiting', 'paused', 'done', 'failed'];\n\nexport function missionsOf(body: unknown): Mission[] | null {\n if (!isRecord(body) || !Array.isArray(body.missions)) return null;\n return body.missions.flatMap((one): Mission[] => {\n if (!isRecord(one)) return [];\n const missionId = text(one.missionId);\n const node = text(one.node);\n const state = STATES.find((known) => known === one.state);\n if (!missionId || !node || !state) return [];\n const history = Array.isArray(one.history) ? one.history.flatMap((change) => (isRecord(change) && typeof change.state === 'string' && typeof change.at === 'string' ? [{ state: change.state, at: change.at, by: typeof change.by === 'string' ? change.by : '', note: text(change.note) }] : [])) : [];\n return [{\n missionId,\n node,\n team: text(one.team),\n state,\n lead: text(one.lead),\n receivers: Array.isArray(one.receivers) ? one.receivers.filter((key): key is string => typeof key === 'string') : [],\n question: text(one.question),\n questions: Array.isArray(one.questions) ? one.questions.flatMap((asked) => (isRecord(asked) && typeof asked.id === 'string' && typeof asked.question === 'string' ? [{ id: asked.id, question: asked.question, at: typeof asked.at === 'string' ? asked.at : '' }] : [])) : [],\n result: text(one.result),\n brief: text(one.brief),\n claim: text(one.claim),\n claimedAt: text(one.claimedAt),\n stale: one.stale === true,\n lastActivity: text(one.lastActivity),\n sessionId: text(one.sessionId),\n startedAt: history.find((change) => change.state === 'active')?.at ?? text(one.claimedAt),\n history,\n }];\n });\n}\n\nexport function linesOf(body: unknown): string[] {\n if (!isRecord(body) || !Array.isArray(body.lines)) return [];\n return body.lines.flatMap((line): string[] => (isRecord(line) && typeof line.text === 'string' ? [`${typeof line.at === 'string' ? line.at.slice(11, 19) : '--:--:--'} ${line.text}`] : []));\n}\n\nconst ORDER: Record<MissionState, number> = { waiting: 0, active: 1, paused: 2, open: 3, failed: 4, done: 5 };\n\nexport function waitsOnYou(mission: Mission): boolean {\n return mission.state === 'waiting' || (mission.state === 'active' && mission.questions.length > 0);\n}\n\nexport function shownState(mission: Mission): MissionState {\n return waitsOnYou(mission) ? 'waiting' : mission.state;\n}\n\nexport function missionOrder(missions: readonly Mission[]): Mission[] {\n return [...missions].sort((a, b) => ORDER[shownState(a)] - ORDER[shownState(b)] || a.missionId.localeCompare(b.missionId));\n}\n\nconst SPIN = ['◐', '◓', '◑', '◒'];\n\nexport function stateMark(state: MissionState, tick: number): { mark: string; color: 'warning' | 'suggestion' | 'success' | 'error' | 'subtle' } {\n switch (state) {\n case 'waiting':\n return { mark: '●', color: 'warning' };\n case 'active':\n return { mark: SPIN[tick % SPIN.length] ?? '◐', color: 'suggestion' };\n case 'paused':\n return { mark: '❄', color: 'subtle' };\n case 'done':\n return { mark: '✓', color: 'success' };\n case 'failed':\n return { mark: '✗', color: 'error' };\n default:\n return { mark: '○', color: 'subtle' };\n }\n}\n\nexport function elapsed(fromIso: string | null, now: number): string {\n if (!fromIso) return '';\n const seconds = Math.max(0, Math.floor((now - Date.parse(fromIso)) / 1000));\n if (seconds < 60) return `${seconds}s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes}m`;\n return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;\n}\n\nexport function bandText(node: string, missions: readonly Mission[], sessions: number, toAnswer: number): string {\n const active = missions.filter((mission) => mission.state === 'active').length;\n const waiting = missions.filter(waitsOnYou).length;\n return [\n 'orbital',\n node,\n ...(active > 0 ? [`${active} mission${active === 1 ? '' : 's'} active`] : []),\n ...(waiting > 0 ? [`${waiting} waiting`] : []),\n `${sessions} session${sessions === 1 ? '' : 's'}`,\n ...(toAnswer > 0 ? [`${toAnswer} to answer`] : []),\n ].join(' · ');\n}\n\nexport function changedMissions(before: readonly Mission[], after: readonly Mission[]): Mission[] {\n return after.filter((mission) => (mission.state === 'waiting' || mission.state === 'done' || mission.state === 'failed') && before.some((old) => old.missionId === mission.missionId && old.node === mission.node && old.state !== mission.state));\n}\n\nexport function receiverKeys(sessionId: string, claudeSessionId: string): string[] {\n return [sessionId, `external:${claudeSessionId}`];\n}\n\nexport function waitingNotice(listed: readonly Mission[], mine: readonly string[], seen: readonly string[]): { fresh: Mission[]; seen: string[] } {\n const others = listed.filter((mission) => !mission.receivers.some((key) => mine.includes(key)));\n const keyed = [\n ...others.filter((mission) => mission.state === 'waiting').map((mission) => ({ key: `${mission.node}/${mission.missionId}`, mission })),\n ...others.flatMap((mission) => (mission.state === 'active' || mission.state === 'waiting' ? mission.questions.map((asked) => ({ key: `${mission.node}/${mission.missionId}/${asked.id}`, mission })) : [])),\n ];\n const fresh = keyed.filter((one) => !seen.includes(one.key)).map((one) => one.mission);\n return { fresh: fresh.filter((mission, index) => fresh.indexOf(mission) === index), seen: keyed.map((one) => one.key) };\n}\n\nexport function toastOf(mission: Mission): string {\n switch (mission.state) {\n case 'waiting':\n return `orbital: ${mission.missionId} waits for you: ${mission.question ?? mission.questions.at(-1)?.question ?? 'its lead stopped without a hand-off'}`.slice(0, 200);\n case 'active':\n return `orbital: ${mission.missionId} asks you: ${mission.questions.at(-1)?.question ?? ''}`.slice(0, 200);\n case 'done':\n return `orbital: ${mission.missionId} is done: ${mission.result ?? ''}`.slice(0, 200);\n default:\n return `orbital: ${mission.missionId} failed: ${mission.result ?? ''}`.slice(0, 200);\n }\n}\n\n\n\nexport function missionDetail(mission: Mission, activity: readonly string[]): string[] {\n return [\n `${mission.missionId} on ${mission.node}: ${mission.state}${mission.lead ? ` · lead ${mission.lead}` : ''}${mission.sessionId ? ` · session ${mission.sessionId.slice(0, 8)}` : ''}`,\n ...(mission.claim ? [`claim ${mission.claim}${mission.claimedAt ? ` since ${mission.claimedAt}` : ''}${mission.stale ? ' (stale)' : ''}`] : []),\n ...(mission.brief ? ['', mission.brief] : []),\n ...(mission.state === 'waiting' && mission.question ? ['', 'question:', mission.question] : []),\n ...(mission.questions.length > 0 ? ['', 'open questions of the lead:', ...mission.questions.map((asked) => ` ${asked.id}: ${asked.question}`), `answer one with missionReply on ${mission.missionId} with its question id, or run: orbital mission reply ${mission.missionId} \"<answer>\" --question <id>`] : []),\n ...(mission.state === 'paused' ? ['', ...(mission.question ? ['question (it waits again on resume):', mission.question, ''] : []), `paused: resume it with orbital mission resume ${mission.missionId}, or with missionResume`] : []),\n ...(mission.result ? ['', 'result:', mission.result] : []),\n ...(mission.history.length > 0 ? ['', 'history:', ...mission.history.map((change) => ` ${change.at.slice(0, 19).replace('T', ' ')} ${change.state.padEnd(7)} ${change.by}${change.note ? ` ${change.note}` : ''}`)] : []),\n ...(activity.length > 0 ? ['', 'activity of the lead:', ...activity.map((line) => ` ${line}`)] : []),\n ];\n}\n\nexport type BackgroundWork = { id: string; kind: string; description: string; state: string; lastTool: string | null; at: string };\nexport type TranscriptSession = { id: string; agent: string; status: string; isRunning: boolean; team: string | null; mission: string | null; since: string | null; doing: string | null; lastActivityAt: string | null; background: BackgroundWork[] };\nexport type TranscriptLine = { mark: string; tone: 'plain' | 'dim' | 'good' | 'bad' | 'busy' | 'ask'; text: string };\nexport type Transcript = { session: TranscriptSession; lines: TranscriptLine[] };\n\nfunction clipLine(value: string, length: number): string {\n const line = value.replace(/\\s+/g, ' ').trim();\n return line.length > length ? `${line.slice(0, length - 1)}…` : line;\n}\n\nfunction resultLine(value: string): string {\n const lines = value.replace(/\\s+$/, '').split('\\n').filter((line) => line.trim() !== '');\n if (lines.length === 0) return '(no output)';\n return `${clipLine(lines[0] ?? '', 90)}${lines.length > 1 ? ` … +${lines.length - 1} lines` : ''}`;\n}\n\nexport function transcriptOf(body: unknown): Transcript | null {\n if (!isRecord(body) || !isRecord(body.session) || !Array.isArray(body.entries)) return null;\n const raw = body.session;\n const id = text(raw.id);\n if (!id) return null;\n const background = Array.isArray(raw.background) ? raw.background.flatMap((one): BackgroundWork[] => (isRecord(one) && text(one.id) && text(one.at) ? [{ id: text(one.id)!, kind: text(one.kind) ?? 'task', description: text(one.description) ?? '', state: text(one.state) ?? 'running', lastTool: text(one.lastTool), at: text(one.at)! }] : [])) : [];\n const session: TranscriptSession = { id, agent: text(raw.agent) ?? 'agent', status: text(raw.status) ?? '', isRunning: raw.isRunning === true, team: text(raw.team), mission: text(raw.mission), since: text(raw.since), doing: text(raw.doing), lastActivityAt: text(raw.lastActivityAt), background };\n const lines = body.entries.flatMap((entry): TranscriptLine[] => {\n if (!isRecord(entry)) return [];\n switch (entry.kind) {\n case 'user':\n return [{ mark: '>', tone: 'dim', text: `${text(entry.from) ? `from ${text(entry.from)}: ` : ''}${clipLine(String(entry.text ?? ''), 200)}` }];\n case 'assistant':\n return String(entry.text ?? '').split('\\n').filter((line) => line.trim() !== '').slice(0, 8).map((line, index) => ({ mark: index === 0 ? '⏺' : ' ', tone: 'plain' as const, text: line }));\n case 'tool': {\n const name = text(entry.name) ?? 'tool';\n const input = text(entry.input);\n const call: TranscriptLine = { mark: '⏺', tone: isRecord(entry.result) ? (entry.result.isError === true ? 'bad' : 'good') : 'busy', text: input ? `${name}(${input})` : name };\n if (!isRecord(entry.result)) return [call];\n return [call, { mark: ' ', tone: entry.result.isError === true ? 'bad' : 'dim', text: `⎿ ${resultLine(String(entry.result.text ?? ''))}` }];\n }\n case 'end':\n if (entry.state === 'failed') return [{ mark: '✗', tone: 'bad', text: clipLine(String(entry.text ?? 'The turn failed.'), 200) }];\n if (entry.state === 'canceled') return [{ mark: ' ', tone: 'dim', text: '⎿ Stopped' }];\n if (entry.state === 'waiting') return [{ mark: '⏺', tone: 'ask', text: `Waits for an answer: ${clipLine(String(entry.text ?? ''), 200)}` }];\n return text(entry.text) ? [{ mark: '⏺', tone: 'plain', text: clipLine(String(entry.text), 300) }] : [];\n default:\n return [];\n }\n });\n return { session, lines };\n}\n\nconst SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\n\nexport function isMissionTarget(target: string, missions: readonly Mission[], peers: readonly Peer[]): boolean {\n const asked = target.trim();\n if (missions.some((one) => one.missionId === asked)) return true;\n if (SESSION_ID.test(asked) || peers.some((one) => one.sessionId === asked || (asked.length >= 6 && one.sessionId.startsWith(asked)))) return false;\n return /^[a-z0-9][a-z0-9-]{0,62}$/.test(asked);\n}\n\nexport function joinTarget(target: string, missions: readonly Mission[], peers: readonly Peer[]): { sessionId: string; label: string } | { error: string } {\n const asked = target.trim();\n if (asked === '') return { error: 'Name a mission by its id, or a session id: /orbital join <mission-or-session>.' };\n if (isMissionTarget(asked, missions, peers)) {\n const mission = missions.find((one) => one.missionId === asked && one.sessionId) ?? null;\n if (!mission?.sessionId) return { error: `The mission ${asked} has no lead session yet. /orbital missions lists them.` };\n return { sessionId: mission.sessionId, label: `${mission.missionId} · ${mission.lead ?? 'lead'}` };\n }\n const peer = peers.find((one) => one.sessionId === asked || (asked.length >= 6 && one.sessionId.startsWith(asked)));\n return peer ? { sessionId: peer.sessionId, label: peer.agent } : { sessionId: asked, label: asked.slice(0, 8) };\n}\n\nexport function sessionHeader(session: TranscriptSession, now: number): string {\n const state = session.isRunning ? 'working' : session.status === 'input_required' ? 'waiting' : session.status === 'completed' ? 'idle' : session.status;\n return [\n session.agent,\n session.id.slice(0, 8),\n ...(session.mission ? [session.mission] : []),\n state,\n ...(session.isRunning ? [`${session.doing ?? 'working'}${session.since ? ` ${elapsed(session.since, now)}` : ''}`] : []),\n ...(session.isRunning && session.lastActivityAt ? [`last activity ${elapsed(session.lastActivityAt, now)} ago`] : []),\n ].join(' · ');\n}\n\nexport function backgroundLines(session: TranscriptSession, now: number): string[] {\n const running = session.background.filter((task) => task.state === 'running').slice(-5);\n const ended = session.background.length - session.background.filter((task) => task.state === 'running').length;\n return [\n ...running.map((task) => `↳ running ${task.description ? clipLine(task.description, 60) : task.kind}${task.description ? ` · ${task.kind}` : ''}${task.lastTool ? ` · ${task.lastTool}` : ''} · ${elapsed(task.at, now)} ago`),\n ...(ended > 0 ? [`↳ ${ended} completed`] : []),\n ];\n}\n\nexport type Queued = { text: string; mission: string | null; question: string | null };\n\nexport function stillAsked(queued: readonly Queued[], missions: readonly Mission[] | null): Queued[] {\n if (!missions) return [...queued];\n const open = (one: Queued) => missions.some((mission) => mission.missionId === one.mission && (one.question === null ? mission.state === 'waiting' : mission.questions.some((asked) => asked.id === one.question)));\n const kept = queued.filter((one) => one.mission === null || open(one));\n return kept.filter((one, index) => one.mission === null || !kept.slice(index + 1).some((later) => later.mission === one.mission && later.question === one.question));\n}\n\nexport type Segment = { text: string; tone?: 'yellow' | 'gray' | 'cyan' | 'green' | 'red'; dim?: boolean; bold?: boolean };\nexport type ViewTarget = { kind: 'mission'; node: string; missionId: string; sessionId: string } | { kind: 'member'; node: string; team: string; agent: string } | { kind: 'session'; node: string; sessionId: string } | { kind: 'worker'; node: string; sessionId: string; taskId: string; label: string } | { kind: 'tasks'; node: string; sessionId: string };\nexport type Frame = { header: Segment[][]; rows: Array<{ lines: Segment[][]; target: ViewTarget | null }> };\n\nconst TONES = ['yellow', 'gray', 'cyan', 'green', 'red'] as const;\n\nfunction segmentsOf(value: unknown): Segment[][] {\n if (!Array.isArray(value)) return [];\n return value.map((line) => (Array.isArray(line) ? line.flatMap((segment): Segment[] => {\n if (!isRecord(segment) || typeof segment.text !== 'string') return [];\n const tone = TONES.find((one) => one === segment.tone);\n return [{ text: segment.text, ...(tone && { tone }), ...(segment.dim === true && { dim: true }), ...(segment.bold === true && { bold: true }) }];\n }) : []));\n}\n\nfunction targetOfView(value: unknown): ViewTarget | null {\n if (!isRecord(value)) return null;\n const node = text(value.node);\n if (!node) return null;\n switch (value.kind) {\n case 'mission': {\n const missionId = text(value.missionId);\n const sessionId = text(value.sessionId);\n return missionId && sessionId ? { kind: 'mission', node, missionId, sessionId } : null;\n }\n case 'session': {\n const sessionId = text(value.sessionId);\n return sessionId ? { kind: 'session', node, sessionId } : null;\n }\n case 'worker': {\n const sessionId = text(value.sessionId);\n const taskId = text(value.taskId);\n return sessionId && taskId ? { kind: 'worker', node, sessionId, taskId, label: text(value.label) ?? 'worker' } : null;\n }\n case 'member': {\n const team = text(value.team);\n const agent = text(value.agent);\n return team && agent ? { kind: 'member', node, team, agent } : null;\n }\n case 'tasks': {\n const sessionId = text(value.sessionId);\n return sessionId ? { kind: 'tasks', node, sessionId } : null;\n }\n default:\n return null;\n }\n}\n\nexport function frameOf(record: unknown): Frame | null {\n if (!isRecord(record) || record.kind !== 'view' || !Array.isArray(record.rows)) return null;\n return {\n header: segmentsOf(record.header),\n rows: record.rows.flatMap((row) => (isRecord(row) ? [{ lines: segmentsOf(row.lines), target: targetOfView(row.target) }] : [])),\n };\n}\n\nexport function fitLine(line: readonly Segment[], width: number): Segment[] {\n const fitted: Segment[] = [];\n let used = 0;\n for (const segment of line) {\n if (used >= width) break;\n const room = width - used;\n if (segment.text.length <= room) {\n fitted.push(segment);\n used += segment.text.length;\n continue;\n }\n fitted.push({ ...segment, text: `${segment.text.slice(0, Math.max(0, room - 1))}…` });\n used = width;\n }\n return fitted;\n}\n\nexport function opensTranscript(target: ViewTarget | null): target is Extract<ViewTarget, { kind: 'mission' | 'session' | 'worker' }> {\n return target !== null && (target.kind === 'mission' || target.kind === 'session' || target.kind === 'worker');\n}\n","hooks/register.tsx":"import { atom, read, update } from 'claude-code'\nimport type { EngineInterface, Register } from 'claude-code'\n\nimport type { OrbitalAttached, OrbitalFrame, OrbitalJoined, OrbitalMission, OrbitalPeer, OrbitalSegment, OrbitalTarget, OrbitalTranscript } from '../types'\nimport { DETACHED_PROBLEM, answeredOf, answeredText, attachArgv, attachedOf, backgroundLines, bandText, binCandidates, changedMissions, checkAnswerOf, decisionOf, deliveryOf, detachedOf, elapsed, framed, isMissionTarget, joinTarget, stillAsked, linesOf, missionDetail, missionOrder, missionsOf, shownState, fitLine, frameOf, opensTranscript, peersOf, recordsOf, sessionHeader, stateMark, toastOf, transcriptOf, versionOf, receiverKeys, waitingNotice, withoutAnswered } from './logic.ts'\nimport type { Answered, Delivery, Queued } from './logic.ts'\n\nconst PANE = 'orbital'\nconst attached = atom({ plugin: 'orbital', key: 'attached' } as const, null as OrbitalAttached | null, { shape: 'attached-1' })\nconst peers = atom({ plugin: 'orbital', key: 'peers' } as const, [] as OrbitalPeer[], { shape: 'peers-2' })\nconst missions = atom({ plugin: 'orbital', key: 'missions' } as const, [] as OrbitalMission[], { shape: 'missions-3' })\nconst frame = atom({ plugin: 'orbital', key: 'frame' } as const, null as OrbitalFrame | null, { shape: 'frame-1' })\nconst queued = atom({ plugin: 'orbital', key: 'queued' } as const, [] as Queued[], { shape: 'queued-3' })\nconst detail = atom({ plugin: 'orbital', key: 'detail' } as const, null as string[] | null, { shape: 'detail-1' })\nconst activity = atom({ plugin: 'orbital', key: 'activity' } as const, [] as string[], { shape: 'activity-1' })\nconst pending = atom({ plugin: 'orbital', key: 'pending' } as const, [] as string[], { shape: 'pending-1' })\nconst isTurnRunning = atom({ plugin: 'orbital', key: 'isTurnRunning' } as const, false, { shape: 'running-1' })\nconst seen = atom({ plugin: 'orbital', key: 'seen' } as const, [] as string[], { shape: 'seen-1' })\nconst problem = atom({ plugin: 'orbital', key: 'problem' } as const, null as string | null, { shape: 'problem-1' })\nconst joinedSession = atom({ plugin: 'orbital', key: 'joined' } as const, null as OrbitalJoined | null, { shape: 'joined-1' })\nconst transcript = atom({ plugin: 'orbital', key: 'transcript' } as const, null as OrbitalTranscript | null, { shape: 'transcript-2' })\nconst isCommandRunning = atom({ plugin: 'orbital', key: 'isCommandRunning' } as const, false, { shape: 'command-1' })\nconst said = atom({ plugin: 'orbital', key: 'said' } as const, [] as string[], { shape: 'said-1' })\nconst program = atom({ plugin: 'orbital', key: 'program' } as const, null as string[] | null, { shape: 'program-1' })\nconst link = atom({ plugin: 'orbital', key: 'link' } as const, 0, { shape: 'link-1' })\n\nconst TRANSCRIPT_LIMIT = 30\nconst RELINK_MS = 2000\nconst NO_NODE = 'No orbital node runs for this project. orbital joins it by itself when it runs. Start the nodes with: orbital up, or join a mission of another node with: /orbital join <mission>'\n\ntype Options = { orbital_bin?: string; node?: string; team?: string; governance?: string }\n\nasync function note($: EngineInterface, line: string): Promise<void> {\n await update($, activity, (list) => [...list, line].slice(-50))\n}\n\nasync function deliver($: EngineInterface, delivery: Delivery): Promise<void> {\n if (!delivery.mission) await update($, pending, (list) => [...list, delivery.taskId])\n await note($, delivery.mission ? `${delivery.mission} asks you` : `message from ${delivery.fromAgent ?? 'the node'}`)\n $.ui.toast(delivery.mission ? `orbital: ${delivery.mission} waits for you` : `orbital: message from ${delivery.fromAgent ?? 'the node'}`)\n if (await read($, isCommandRunning)) {\n await update($, queued, (list) => [...list, { text: framed(delivery), mission: delivery.mission, question: delivery.question }])\n return\n }\n if (await read($, isTurnRunning)) {\n if (delivery.mission) {\n await update($, queued, (list) => [...list, { text: framed(delivery), mission: delivery.mission, question: delivery.question }])\n return\n }\n await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: framed(delivery) }] } })\n return\n }\n await $.prompt.submit({ text: framed(delivery) })\n void refresh($)\n}\n\nasync function answered($: EngineInterface, answer: Answered): Promise<void> {\n const before = await read($, queued)\n const kept = withoutAnswered(before, answer)\n await update($, queued, () => kept)\n await note($, `${answer.mission} answered by ${answer.by}`)\n $.ui.toast(`orbital: ${answer.mission} is answered by ${answer.by}`)\n if (kept.length === before.length) await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: answeredText(answer) }] } })\n void refresh($)\n}\n\nasync function getJson($: EngineInterface, joined: OrbitalAttached, path: string): Promise<unknown> {\n const response = await $.http.fetch(`${joined.nodeUrl}${path}`, { headers: { authorization: `Bearer ${joined.token}` } })\n return response.ok ? JSON.parse(response.text) : null\n}\n\nasync function flush($: EngineInterface): Promise<void> {\n if (await read($, isTurnRunning) || await read($, isCommandRunning)) return\n const queuedNow = await read($, queued)\n if (queuedNow.length === 0) return\n const joined = await read($, attached)\n const asking = queuedNow.some((one) => one.mission !== null) && joined ? missionsOf(await getJson($, joined, '/orbital/missions')) : null\n const waiting = stillAsked(queuedNow, asking)\n await update($, queued, () => [])\n if (waiting.length === 0) return\n void $.prompt.submit({ text: waiting.map((one) => one.text).join('\\n\\n') })\n}\n\nasync function refresh($: EngineInterface): Promise<void> {\n const joined = await read($, attached)\n if (!joined) return\n const found = peersOf(await getJson($, joined, '/orbital/peers'))\n if (found && JSON.stringify(found) !== JSON.stringify(await read($, peers))) await update($, peers, () => found)\n const listed = missionsOf(await getJson($, joined, '/orbital/missions'))\n if (listed) {\n const before = await read($, missions)\n const changed = changedMissions(before, listed)\n for (const one of changed) $.ui.toast(toastOf(one))\n const notice = waitingNotice(listed, receiverKeys(joined.sessionId, await $.session.id()), await read($, seen))\n await update($, seen, () => notice.seen)\n for (const one of notice.fresh) {\n if (!changed.some((known) => known.node === one.node && known.missionId === one.missionId)) $.ui.toast(toastOf(one))\n await note($, `${one.missionId} waits for you`)\n }\n if (JSON.stringify(listed) !== JSON.stringify(before)) await update($, missions, () => listed)\n }\n await follow($, joined)\n}\n\nasync function follow($: EngineInterface, joined: OrbitalAttached): Promise<void> {\n const target = await read($, joinedSession)\n if (!target) return\n const response = await $.http.fetch(`${joined.nodeUrl}/orbital/sessions/${encodeURIComponent(target.sessionId)}/transcript?limit=${TRANSCRIPT_LIMIT}${target.worker ? `&worker=${encodeURIComponent(target.worker)}` : ''}`, { headers: { authorization: `Bearer ${joined.token}` } })\n const body: unknown = response.ok ? JSON.parse(response.text) : null\n const parsed = transcriptOf(body)\n const error = parsed ? null : (body && typeof body === 'object' && 'error' in body ? String((body as { error: unknown }).error) : `The node answered ${response.status}.`)\n const next: OrbitalTranscript = parsed ? { ...parsed, error: null } : { session: null, lines: [], error: response.ok ? 'The node sent no transcript.' : error ?? `The node answered ${response.status}.` }\n if (JSON.stringify(next) !== JSON.stringify(await read($, transcript))) await update($, transcript, () => next)\n}\n\ntype Link = { id: number; first: Promise<OrbitalAttached | null>; ended: Promise<void> }\n\nasync function attach($: EngineInterface, argv: string[]): Promise<OrbitalAttached | null> {\n return (await linked($, argv)).first\n}\n\nasync function linked($: EngineInterface, argv: string[]): Promise<Link> {\n const id = (await read($, link)) + 1\n await update($, link, () => id)\n let ready: (joined: OrbitalAttached | null) => void = () => undefined\n const first = new Promise<OrbitalAttached | null>((done) => { ready = done })\n const ended = (async () => {\n let carry = ''\n let mine: string | null = null\n for await (const piece of $.process.spawn({ argv })) {\n if (!('text' in piece) || piece.stream !== 'stdout') continue\n const parsed = recordsOf(piece.text, carry)\n carry = parsed.carry\n for (const record of parsed.records) {\n const joined = attachedOf(record)\n if (joined) {\n mine = joined.sessionId\n await update($, attached, () => joined)\n await update($, problem, () => null)\n await update($, seen, () => [])\n await note($, `joined node ${joined.node} as ${joined.sessionId}`)\n ready(joined)\n continue\n }\n if (detachedOf(record) !== null) {\n await update($, attached, () => null)\n await update($, problem, () => DETACHED_PROBLEM)\n await note($, 'the node is gone')\n continue\n }\n const answer = answeredOf(record)\n if (answer) {\n await answered($, answer)\n continue\n }\n const delivery = deliveryOf(record)\n if (delivery) await deliver($, delivery)\n }\n }\n if (mine && (await read($, attached))?.sessionId === mine) await update($, attached, () => null)\n ready(null)\n })()\n return { id, first, ended }\n}\n\nasync function stayJoined($: EngineInterface, argv: string[]): Promise<void> {\n for (;;) {\n const one = await linked($, argv)\n await one.ended.catch(() => undefined)\n if ((await read($, link)) !== one.id) return\n if (!(await read($, attached)) && (await read($, problem)) !== DETACHED_PROBLEM) await update($, problem, () => NO_NODE)\n await $.clock.sleep(RELINK_MS)\n if ((await read($, link)) !== one.id) return\n }\n}\n\nasync function controlMission($: EngineInterface, verb: 'pause' | 'resume', missionId: string): Promise<string> {\n const joined = await read($, attached)\n if (!joined) return (await read($, problem)) ?? 'Not joined to a node yet.'\n const response = await $.http.fetch(`${joined.nodeUrl}/orbital/missions/${verb}`, {\n method: 'POST',\n headers: { authorization: `Bearer ${joined.token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ missionId }),\n })\n const body: unknown = JSON.parse(response.text || '{}')\n if (!response.ok) return `Not ${verb === 'pause' ? 'paused' : 'resumed'}: ${body && typeof body === 'object' && 'error' in body ? String((body as { error: unknown }).error) : `the node answered ${response.status}`}`\n await refresh($)\n const state = body && typeof body === 'object' && 'state' in body ? String((body as { state: unknown }).state) : verb === 'pause' ? 'paused' : 'active'\n return `${missionId} is ${state}.`\n}\n\nconst VIEW_WIDTH = 60\n\nasync function watchView($: EngineInterface, bin: readonly string[]): Promise<void> {\n let carry = ''\n for await (const piece of $.process.spawn({ argv: [...bin, 'view', '--watch', '--width', String(VIEW_WIDTH), '--output', 'ndjson'] })) {\n if (!('text' in piece) || piece.stream !== 'stdout') continue\n const parsed = recordsOf(piece.text, carry)\n carry = parsed.carry\n for (const record of parsed.records) {\n const next = frameOf(record)\n if (next) await update($, frame, () => next)\n }\n }\n}\n\nasync function openTarget($: EngineInterface, target: OrbitalTarget): Promise<string> {\n if (target.kind === 'mission') return join($, target.missionId)\n if (target.kind !== 'session' && target.kind !== 'worker') return 'This row opens nothing.'\n const bin = await read($, program)\n if ((await read($, attached))?.node !== target.node) {\n if (!bin) return (await read($, problem)) ?? 'No orbital program on the PATH.'\n const moved = await attach($, attachArgv(bin, { cwd: await $.session.cwd(), sessionId: await $.session.id(), node: target.node, team: '' }))\n if (!moved) return `The node ${target.node} did not take this session.`\n }\n const label = target.kind === 'worker' ? `the worker ${target.label}` : `session ${target.sessionId.slice(0, 8)}`\n await update($, joinedSession, () => ({ sessionId: target.sessionId, label, worker: target.kind === 'worker' ? target.taskId : null }))\n await update($, transcript, () => null)\n await update($, detail, () => null)\n const joined = await read($, attached)\n if (joined) await follow($, joined)\n return `orbital follows ${label} on ${target.node}.`\n}\n\nconst TONE = { yellow: 'warning', green: 'success', red: 'error', cyan: 'suggestion', gray: undefined } as const\n\nasync function claimMission($: EngineInterface, missionId: string): Promise<string | null> {\n const joined = await read($, attached)\n const runsHere = joined && (await read($, missions)).some((mission) => mission.missionId === missionId && mission.node === joined.node && (mission.state === 'active' || mission.state === 'waiting' || mission.state === 'paused'))\n if (joined) {\n const response = await $.http.fetch(`${joined.nodeUrl}/orbital/missions/join`, {\n method: 'POST',\n headers: { authorization: `Bearer ${joined.token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ missionId }),\n })\n if (response.ok) return null\n const body: unknown = JSON.parse(response.text || '{}')\n if (runsHere) return body && typeof body === 'object' && 'error' in body ? String((body as { error: unknown }).error) : `The node answered ${response.status}.`\n }\n const bin = await read($, program)\n if (!bin) return (await read($, problem)) ?? 'No orbital program on the PATH.'\n const moved = await attach($, attachArgv(bin, { cwd: await $.session.cwd(), sessionId: await $.session.id(), node: '', team: '', mission: missionId }))\n if (!moved) return `No running node on this machine runs the mission ${missionId}, or it refused this session. orbital mission list shows the missions.`\n await refresh($)\n return null\n}\n\nasync function join($: EngineInterface, target: string): Promise<string> {\n await refresh($)\n const asked = target.trim()\n const mission = isMissionTarget(asked, await read($, missions), await read($, peers))\n const refused = mission ? await claimMission($, asked) : null\n if (refused) return refused\n const chosen = joinTarget(target, await read($, missions), await read($, peers))\n if ('error' in chosen) return chosen.error\n await update($, joinedSession, () => chosen)\n await update($, transcript, () => null)\n await update($, detail, () => null)\n const joined = await read($, attached)\n if (joined) await follow($, joined)\n return mission ? `orbital follows ${chosen.label} (session ${chosen.sessionId.slice(0, 8)}): its questions come to this session, and anything the person says to the lead goes to it at once with missionSay ${asked} or /orbital say <message>.` : `orbital follows ${chosen.label} (session ${chosen.sessionId.slice(0, 8)}): /orbital say <message> talks to it.`\n}\n\nconst DELIVERY: Record<string, string> = { steer: 'the lead takes it during its current turn', turn: 'the lead takes it as its next turn', reply: 'it answers the question of the mission' }\n\nasync function say($: EngineInterface, text: string): Promise<string> {\n const joined = await read($, attached)\n const following = await read($, joinedSession)\n if (!joined || !following) return 'Follow a lead first: /orbital join <mission>.'\n if (following.worker) return 'A worker takes no message: talk to its lead. /orbital goes back.'\n if (!text) return 'Write the message: /orbital say <message>.'\n await update($, said, (list) => [...list, text])\n const response = await $.http.fetch(`${joined.nodeUrl}/orbital/missions/say`, {\n method: 'POST',\n headers: { authorization: `Bearer ${joined.token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ target: following.sessionId, text }),\n })\n const body: unknown = JSON.parse(response.text || '{}')\n if (!response.ok) {\n await update($, said, (list) => list.filter((one) => one !== text))\n return `Not sent: ${body && typeof body === 'object' && 'error' in body ? String((body as { error: unknown }).error) : `the node answered ${response.status}`}`\n }\n const delivery = body && typeof body === 'object' && 'delivery' in body ? String((body as { delivery: unknown }).delivery) : ''\n return `Sent to ${following.label}: ${DELIVERY[delivery] ?? 'the lead has it'}.`\n}\n\nasync function leave($: EngineInterface): Promise<void> {\n await update($, joinedSession, () => null)\n await update($, transcript, () => null)\n}\n\nasync function detailOf($: EngineInterface, id: string): Promise<string[]> {\n const joined = await read($, attached)\n if (!joined) return [await read($, problem) ?? 'Not joined to a node yet.']\n await refresh($)\n const mission = (await read($, missions)).find((one) => one.missionId === id)\n if (!mission) return [`No mission ${id}. /orbital missions lists them.`]\n const activity = linesOf(await getJson($, joined, `/orbital/missions/${encodeURIComponent(id)}/activity?limit=20`))\n return missionDetail(mission, activity)\n}\n\nfunction missionList(list: readonly OrbitalMission[], now: number): string[] {\n if (list.length === 0) return ['No mission.']\n return missionOrder(list).map((mission) => `${stateMark(shownState(mission), 0).mark} ${mission.missionId}${mission.team ? ` · team ${mission.team}` : ''}${mission.lead ? ` · lead ${mission.lead}` : ''} · ${mission.state}${mission.state === 'active' ? ` ${elapsed(mission.startedAt, now)}` : ''}${mission.state === 'waiting' ? (mission.question ? ` · asks: ${mission.question}` : ' · stopped without a hand-off') : ''}${mission.questions.map((asked) => ` · asks ${asked.id}: ${asked.question}`).join('')}${mission.state === 'paused' ? ` · resume with orbital mission resume ${mission.missionId}` : ''}`)\n}\n\nexport const register: Register = (on, options) => {\n const config = (options ?? {}) as Options\n const enforce = config.governance !== 'observe'\n\n on('session.start', async ($, e, next) => {\n const started = await next(e)\n await $.command.register({ name: 'orbital', description: 'Show the missions of orbital, the sessions you reach and the lan peers, in a pane', argumentHint: '[missions | mission <id> | join <mission-or-session> | say <message> | pause <id> | resume <id>]' }).catch(() => undefined)\n void (async () => {\n let bin: string[] | null = null\n for (const candidate of binCandidates(config.orbital_bin ?? '')) {\n try {\n const ran = await $.process.run([...candidate, '--version', '--output', 'json'], { timeoutMs: 20000 })\n if (ran.exitCode === 0 && versionOf(ran.stdout) !== null) {\n bin = candidate\n break\n }\n } catch {\n continue\n }\n }\n if (!bin) {\n await update($, problem, () => 'No orbital program on the PATH. Install it with: npm i -g <scope>/orbital')\n $.ui.toast('orbital: no orbital program on the PATH. Install it, then start a new session.')\n return\n }\n await update($, program, () => bin)\n void watchView($, bin)\n await stayJoined($, attachArgv(bin, { cwd: await $.session.cwd(), sessionId: await $.session.id(), node: config.node ?? '', team: config.team ?? '' }))\n })()\n void $.clock.every(2000, () => void refresh($).then(() => flush($)))\n return started\n })\n\n on('turn.start', async ($, e, next) => {\n await update($, isTurnRunning, () => true)\n return next(e)\n })\n\n on('turn.complete', async ($, e, next) => {\n const done = await next(e)\n await update($, isTurnRunning, () => false)\n await flush($)\n const joined = await read($, attached)\n const open = await read($, pending)\n if (!joined || open.length === 0) return done\n const [taskId, ...rest] = open\n await update($, pending, () => rest)\n const answer = 'answer' in e && typeof e.answer === 'string' ? e.answer : ''\n await $.http.fetch(`${joined.nodeUrl}/orbital/sessions/${encodeURIComponent(joined.sessionId)}/settled`, {\n method: 'POST',\n headers: { authorization: `Bearer ${joined.token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ taskId, text: answer }),\n })\n await note($, `answered ${taskId}`)\n return done\n })\n\n on('tool.check', async ($, e, next) => {\n const joined = await read($, attached)\n if (!joined) return next(e)\n const response = await $.http.fetch(`${joined.nodeUrl}/orbital/policy/tool`, {\n method: 'POST',\n headers: { authorization: `Bearer ${joined.token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ toolName: e.tool, input: e.input ?? {}, ask: 'caller' }),\n })\n const decision = response.ok ? decisionOf(JSON.parse(response.text)) : null\n if (!decision) {\n if (!enforce) return next(e)\n return { decision: 'deny', reason: `orbital: the node ${joined.node} gave no decision (${response.status})` }\n }\n const answer = checkAnswerOf(decision, e.approver !== undefined)\n if (answer.decision === 'allow') return next(e)\n await note($, `${answer.decision} ${e.tool}: ${answer.reason}`)\n if (answer.decision === 'deny') $.ui.toast(`orbital denied ${e.tool}: ${answer.reason}`)\n if (!enforce) return next(e)\n return answer\n }).catch(($, e, next) => (enforce ? { decision: 'deny', reason: 'orbital: the node did not answer, and governance is enforce' } : next(e)))\n\n on('command.run', { command: 'orbital' }, async ($, e) => {\n const args = e.args.trim()\n if (args === 'missions') {\n await refresh($)\n return { text: missionList(await read($, missions), Date.now()).join('\\n') }\n }\n if (args.startsWith('join')) {\n await update($, isCommandRunning, () => true)\n const said = await join($, args.slice('join'.length)).finally(() => update($, isCommandRunning, () => false))\n const placed = await $.ui.open({ id: PANE, title: 'orbital' })\n const shown = await read($, transcript)\n return { text: placed.isPlaced || !shown ? said : [said, ...(shown.session ? [sessionHeader(shown.session, Date.now())] : []), ...shown.lines.slice(-12).map((line) => `${line.mark} ${line.text}`)].join('\\n') }\n }\n if (args === 'say' || args.startsWith('say ')) {\n const answer = await say($, args.slice('say'.length).trim())\n await $.ui.open({ id: PANE, title: 'orbital' })\n return { text: answer }\n }\n const control = /^(pause|resume)\\s+(\\S+)$/.exec(args)\n if (control) return { text: await controlMission($, control[1] as 'pause' | 'resume', control[2]!) }\n if (args.startsWith('mission ')) {\n const lines = await detailOf($, args.slice('mission '.length).trim())\n await update($, detail, () => lines)\n const placed = await $.ui.open({ id: PANE, title: 'orbital' })\n return { text: placed.isPlaced ? 'orbital pane shows the mission.' : lines.join('\\n') }\n }\n await update($, detail, () => null)\n await leave($)\n await refresh($)\n const placed = await $.ui.open({ id: PANE, title: 'orbital' })\n const joined = await read($, attached)\n return { text: placed.isPlaced ? 'orbital pane opened.' : joined ? missionList(await read($, missions), Date.now()).join('\\n') : (await read($, problem)) ?? 'Not joined to a node yet.' }\n })\n\n on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {\n const { Box, Text, Button } = $.ui.resolve(e)\n const joined = await read($, attached)\n const view = await read($, frame)\n const width = Math.max(30, Math.min(VIEW_WIDTH, (e.props as { bodyColumns?: number } | undefined)?.bodyColumns ?? VIEW_WIDTH))\n const line = (segments: readonly OrbitalSegment[]) => (\n <Box flexDirection=\"row\">\n {fitLine(segments, width).map((segment) => <Text color={segment.tone ? TONE[segment.tone] : undefined} dimColor={segment.dim === true || segment.tone === 'gray'} bold={segment.bold === true}>{segment.text}</Text>)}\n </Box>\n )\n if (!joined && !view) return <Text dimColor>{(await read($, problem)) ?? 'Not joined to a node yet.'}</Text>\n const following = await read($, joinedSession)\n if (following && joined) {\n const shown = await read($, transcript)\n const room = Math.max(4, (e.viewport?.rows ?? 30) - 5)\n const colour = { plain: undefined, dim: undefined, good: 'success', bad: 'error', busy: 'warning', ask: 'suggestion' } as const\n return (\n <Box flexDirection=\"column\">\n <Box flexDirection=\"row\">\n <Button key=\"back\" plain onPress={() => void leave($)}>missions</Button>\n <Text dimColor>{` orbital · ${joined.node}`}</Text>\n </Box>\n <Text bold>{following.worker ? following.label : shown?.session ? sessionHeader(shown.session, Date.now()) : following.label}</Text>\n {(shown?.session ? backgroundLines(shown.session, Date.now()) : []).map((line) => <Text dimColor>{` ${line}`}</Text>)}\n {!shown && <Text dimColor>Reading the transcript…</Text>}\n {shown?.error && <Text color=\"error\">{shown.error}</Text>}\n {(shown?.lines ?? []).slice(-room).map((line) => (\n <Box flexDirection=\"row\">\n <Text color={colour[line.tone]}>{`${line.mark} `}</Text>\n <Text dimColor={line.tone === 'dim'} color={line.tone === 'bad' ? 'error' : line.tone === 'ask' ? 'suggestion' : undefined}>{line.text}</Text>\n </Box>\n ))}\n {shown?.session?.isRunning && <Text color=\"warning\">{`✻ ${shown.session.doing ?? 'Working'}… (${elapsed(shown.session.since, Date.now()) || '0s'})`}</Text>}\n {(await read($, said)).filter((text) => !(shown?.lines ?? []).some((line) => line.text.includes(text.slice(0, 60)))).map((text) => (\n <Box flexDirection=\"row\">\n <Text>{'> '}</Text>\n <Text>{text}</Text>\n <Text dimColor>{' (queued)'}</Text>\n </Box>\n ))}\n <Text dimColor>{`/orbital say <message> talks to the lead · /orbital goes back`}</Text>\n </Box>\n )\n }\n const shown = await read($, detail)\n if (shown) {\n return (\n <Box flexDirection=\"column\">\n {shown.map((line) => <Text>{line}</Text>)}\n <Text dimColor>/orbital goes back to the missions.</Text>\n </Box>\n )\n }\n if (!view) return <Text dimColor>{`orbital · ${joined?.node ?? ''} · reading the nodes…`}</Text>\n return (\n <Box flexDirection=\"column\">\n {view.header.map((segments) => line(segments))}\n {view.rows.map((row, index) => {\n const target = row.target\n if (!opensTranscript(target)) return <Box flexDirection=\"column\">{row.lines.map((segments) => line(segments))}</Box>\n return (\n <Box flexDirection=\"column\">\n <Button key={`row:${index}`} plain onPress={() => void openTarget($, target)}>{fitLine(row.lines[0] ?? [], width).map((segment) => segment.text).join('')}</Button>\n {row.lines.slice(1).map((segments) => line(segments))}\n </Box>\n )\n })}\n {!joined && <Text dimColor>{(await read($, problem)) ?? 'Not joined to a node.'}</Text>}\n </Box>\n )\n })\n\n on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {\n const joined = await read($, attached)\n if (!joined) return next(e)\n const reached = (await read($, peers)).filter((peer) => !peer.self).length\n const open = await read($, pending)\n const { Text } = $.ui.resolve(e)\n return <Text dimColor>{bandText(joined.node, await read($, missions), reached, open.length)}</Text>\n })\n}\n","skills/orbital-team/SKILL.md":"---\nname: orbital-team\ndescription: Use when this session works with other agent sessions through an orbital node - to see the peers of the team, send a peer a message and wait for the answer, dispatch a job to an agent of the node, answer a message that a peer sent, or give a team a mission, such as work on a GitHub issue.\n---\n\n# Work with the team through orbital\n\nThis session is a member of a team on an orbital node.\nThe node carries the messages between the sessions of the team, on any harness.\n\n## The tools\n\n- `agentPeers` lists the sessions of the team: the agent, the session id, the role, the harness and the status.\n- `agentMessage` sends a message to a session by its id. A session that works takes it during its current turn. With `wait: true`, it returns the answer of the receiver.\n- `agentDispatch` starts an agent of the node on a task, in a session of its own, and `agentWait` waits for its result.\n- `agentList` lists the agents of the node.\n- `agentInbox` takes the messages that peers sent this session, and `agentAnswer` answers one by its `taskId`.\n- `missionNew` writes a mission for a team, `missionStart` starts it, and `missionList` and `missionShow` report its state and result.\n- `sessionTranscript` reads what a session does now: its state, its current action and its last transcript entries.\n Give it a session id, or a mission id for the lead of that mission.\n- `missionSay` gives the lead of a mission a message from the person: during its current turn when it works, as its next turn when it is idle, and as the answer when the mission waits.\n- `missionReply` answers a question of a mission: the question of a waiting mission, or an open question its lead asked with `missionAsk`, by its question id. The answer reaches the lead and closes that question for every decision receiver.\n- `missionJoin` makes this session a decision receiver of a running mission: each question of its lead comes to this session, and the questions that are open now come at once. Any session can join, also this Claude Code session. A mission can have many receivers: each question goes to every live one, and the first answer closes it for all.\n This session stays a receiver after it or the node restarts, so join once. In a terminal of this session, `orbital mission join <mission>` does the same.\n- `missionLeave` removes this session from the decision receivers of a mission.\n- `missionPause` stops the lead of an active or waiting mission and keeps its session; `missionResume` lets the lead go on in that session.\n- `missionAsk` asks the decision receivers of the mission of your session a question, as its lead, at any moment, also while your workers run. The node keeps each question open until a receiver answers it, shows it as waiting on the person, and pushes it at once to every live receiver.\n- `missionLink` adds a link to the mission of your session, as its lead: a label, a URL and a state, such as `PR #42`, its URL and `open`. A call with the same URL replaces the link.\n\n## Missions\n\nA mission is one objective of a team, from its intent to its hand-off.\nWhen the person asks a team to work on something, such as \"build, take issue 123\", give the team a mission:\n\n1. Read the source of the work, for example `gh issue view 123`.\n2. Write the mission with `missionNew`: the team, an id such as `issue-123`, the person as `given-by`, and a brief.\n The node writes `missions/<id>/MISSION.md`. One id names one mission on a node, so choose an id that no other mission has.\n The intent says what to do and links the source.\n The end state says what is true when the mission is done, for example \"a PR off main closes #123\".\n The limits say what the team must not do.\n3. Start it with `missionStart`, and tell the person the id.\n4. When the person asks, report the state and the result with `missionShow`.\n5. When the person asks what the lead does, read it with `sessionTranscript` on the mission id.\n Tell the person the current action and the last steps, in a few lines.\n6. When the person says something to the lead, send it at once with `missionSay`, in the words of the person.\n Do this whether or not the lead asked a question: a lead that works takes it during its turn, and a waiting mission takes it as the answer.\n Never hold a message back until the lead asks.\n7. When the person asks to stop a mission for now, pause it with `missionPause`, and resume it with `missionResume` when they ask.\n A paused mission takes no reply: resume it first.\n A node that stops pauses each active mission, and no lead goes on until a person resumes it.\n\nThe person follows a lead live with `/orbital join <mission>` in the orbital pane, or with `orbital session join <mission>` in a terminal. `/orbital join` of a mission works from any folder: the session attaches to the node that runs the mission and becomes one of its decision receivers, so each question of the lead comes to the session. Show the person the question, and send their answer with `missionSay`. A question that the lead asked with `missionAsk` names its question id: send the answer with `missionReply` and that question id, so that the answer closes the right question.\nWhen the person asks you to watch a mission, or to get its decisions, call `missionJoin` on it.\nA message that starts with `[orbital]` and says a question is answered means another receiver answered it: do not answer it again, and tell the person.\nWhile the session follows a lead, everything the person addresses to the lead goes to it with `missionSay`; the person can also type `/orbital say <message>`, or run `orbital mission say <mission> \"<message>\"` in a terminal.\n\n## The rules\n\n- A message from a peer arrives as a turn that starts with `[orbital] message from`.\n Answer it in that turn: the answer of the turn goes back to the sender.\n- State a message in full, because the receiver has none of your context.\n- Do not wait on a peer that may be waiting on you.\n- As the lead of a team, run long work in the background: start a worker (a member with `agentDispatch`, or a sub-agent in the background), end your turn, and answer the person.\n Do not sleep or poll for a worker: each result wakes you with a new turn, and the mission stays active while your workers run.\n A sub-agent whose type is the name of a team member shows as the work of that member.\n- As the lead of a mission, report each pull request you open with `missionLink`: its label, its URL and the state `open`.\n When its state changes, such as to merged or closed, call `missionLink` again with the same URL and the new state.\n The person sees the links of each mission in the live view.\n- As the lead of a mission, ask each decision that you need with `missionAsk`, at once, also while your workers run.\n Never put a question only in your reply text: text that ends no turn reaches no one, and the mission does not show it.\n Ask each question once, with the options you see and what each one means. Several questions stay open side by side, and each answer comes back to you as a message that names its question.\n Go on with the work that does not depend on the answer.\n- When nothing is left to do but wait for an answer, end your turn with the question: a turn that ends with no hand-off and no running work makes the mission wait.\n- A tool call that the policy of the node denies fails with the policy name in the reason. Do not try the same call another way.\n","types/index.d.ts":"export type OrbitalAttached = { sessionId: string; token: string; node: string; nodeUrl: string };\nexport type OrbitalPeer = { sessionId: string; agent: string; role: string; harness: string; status: string; self: boolean; parent: string | null };\nexport type OrbitalMission = { missionId: string; node: string; team: string | null; state: 'open' | 'active' | 'waiting' | 'paused' | 'done' | 'failed'; lead: string | null; receivers: string[]; question: string | null; questions: Array<{ id: string; question: string; at: string }>; result: string | null; brief: string | null; claim: string | null; claimedAt: string | null; stale: boolean; lastActivity: string | null; sessionId: string | null; startedAt: string | null; history: Array<{ state: string; at: string; by: string; note: string | null }> };\nexport type OrbitalBackground = { id: string; kind: string; description: string; state: string; lastTool: string | null; at: string };\nexport type OrbitalJoined = { sessionId: string; label: string; worker?: string | null };\nexport type OrbitalSegment = { text: string; tone?: 'yellow' | 'gray' | 'cyan' | 'green' | 'red'; dim?: boolean; bold?: boolean };\nexport type OrbitalTarget = { kind: 'mission'; node: string; missionId: string; sessionId: string } | { kind: 'member'; node: string; team: string; agent: string } | { kind: 'session'; node: string; sessionId: string } | { kind: 'worker'; node: string; sessionId: string; taskId: string; label: string } | { kind: 'tasks'; node: string; sessionId: string };\nexport type OrbitalFrame = { header: OrbitalSegment[][]; rows: Array<{ lines: OrbitalSegment[][]; target: OrbitalTarget | null }> };\nexport type OrbitalTranscript = {\n session: { id: string; agent: string; status: string; isRunning: boolean; team: string | null; mission: string | null; since: string | null; doing: string | null; lastActivityAt: string | null; background: OrbitalBackground[] } | null;\n lines: Array<{ mark: string; tone: 'plain' | 'dim' | 'good' | 'bad' | 'busy' | 'ask'; text: string }>;\n error: string | null;\n};\n\ndeclare module 'claude-code' {\n interface PluginState {\n orbital: {\n attached: Shaped<OrbitalAttached | null>;\n peers: Shaped<OrbitalPeer[]>;\n missions: Shaped<OrbitalMission[]>;\n frame: Shaped<OrbitalFrame | null>;\n detail: Shaped<string[] | null>;\n queued: Shaped<Array<{ text: string; mission: string | null; question: string | null }>>;\n activity: Shaped<string[]>;\n pending: Shaped<string[]>;\n isTurnRunning: Shaped<boolean>;\n problem: Shaped<string | null>;\n seen: Shaped<string[]>;\n joined: Shaped<OrbitalJoined | null>;\n transcript: Shaped<OrbitalTranscript | null>;\n program: Shaped<string[] | null>;\n link: Shaped<number>;\n said: Shaped<string[]>;\n isCommandRunning: Shaped<boolean>;\n };\n }\n}\n"}}
package/dist/orbital.js CHANGED
@@ -71,7 +71,7 @@ var VERSION, current;
71
71
  var init_brand = __esm({
72
72
  "src/kit/command/brand.ts"() {
73
73
  "use strict";
74
- VERSION = true ? "0.0.5" : "0.0.0-dev";
74
+ VERSION = true ? "0.0.6" : "0.0.0-dev";
75
75
  current = defineBrand({ program: "orbital" });
76
76
  }
77
77
  });
@@ -65831,12 +65831,9 @@ function readDescriptorName(dir) {
65831
65831
  if (!existsSync6(file2)) throw refuse("invalid-input", `${dir} has no ${PLUGIN_PACKAGE_FILE}, so it is not a plugin.`);
65832
65832
  return PluginPackageSchema.parse(JSON.parse(readFileSync7(file2, "utf8"))).name;
65833
65833
  }
65834
- function lastPart(value2) {
65835
- return value2.replace(/\.git$/, "").split(/[/:]/).filter(Boolean).pop() ?? value2;
65836
- }
65837
65834
  function resolvePluginRef(home, ref) {
65838
- if (ref.startsWith("npm:")) return { kind: "npm", name: lastPart(ref.slice(4).replace(/@[^/]*$/, "")), pkg: ref.slice(4) };
65839
- if (ref.startsWith("git:")) return { kind: "git", name: lastPart(ref.slice(4)), url: ref.slice(4) };
65835
+ if (ref.startsWith("npm:")) return { kind: "npm", name: null, pkg: ref.slice(4) };
65836
+ if (ref.startsWith("git:")) return { kind: "git", name: null, url: ref.slice(4) };
65840
65837
  if (isFolder(ref)) return { kind: "folder", name: readDescriptorName(resolve6(ref)), dir: resolve6(ref) };
65841
65838
  const at = ref.lastIndexOf("@");
65842
65839
  if (at <= 0) throw refuse("invalid-input", `"${ref}" is not a plugin reference.`, "Use <name>@<marketplace>, npm:<package>, git:<url>, or a folder such as ./plugins/mine.");
@@ -65868,17 +65865,17 @@ function installPlugin(home, ref, exec4 = run) {
65868
65865
  const installed = Object.keys(JSON.parse(readFileSync7(join10(scratch, "package.json"), "utf8")).dependencies ?? {});
65869
65866
  const pkgDir = join10("node_modules", ...(installed[0] ?? ref.pkg.replace(/(.)@[^/]*$/, "$1")).split("/"));
65870
65867
  const inner = PluginPackageSchema.parse(JSON.parse(readFileSync7(join10(scratch, pkgDir, PLUGIN_PACKAGE_FILE), "utf8")));
65871
- writeFileSync5(join10(scratch, PLUGIN_PACKAGE_FILE), `${JSON.stringify({ name: ref.name, command: inner.command, dir: inner.dir ? join10(pkgDir, inner.dir) : pkgDir, env: inner.env, ...Object.keys(inner.config).length > 0 && { config: inner.config }, ...inner.harnesses.length > 0 && { harnesses: inner.harnesses }, ...inner.commands.length > 0 && { commands: inner.commands } }, null, 2)}
65868
+ writeFileSync5(join10(scratch, PLUGIN_PACKAGE_FILE), `${JSON.stringify({ name: inner.name, command: inner.command, dir: inner.dir ? join10(pkgDir, inner.dir) : pkgDir, env: inner.env, ...Object.keys(inner.config).length > 0 && { config: inner.config }, ...inner.harnesses.length > 0 && { harnesses: inner.harnesses }, ...inner.commands.length > 0 && { commands: inner.commands } }, null, 2)}
65872
65869
  `);
65873
65870
  break;
65874
65871
  }
65875
65872
  }
65876
65873
  const named2 = readDescriptorName(scratch);
65877
- if (named2 !== ref.name) throw refuse("invalid-input", `The plugin calls itself ${named2}, and its reference names it ${ref.name}.`);
65878
- const dir = join10(parent, ref.name);
65874
+ if (ref.name !== null && named2 !== ref.name) throw refuse("invalid-input", `The plugin calls itself ${named2}, and its reference names it ${ref.name}.`);
65875
+ const dir = join10(parent, named2);
65879
65876
  rmSync3(dir, { recursive: true, force: true });
65880
65877
  renameSync(scratch, dir);
65881
- return dir;
65878
+ return { name: named2, dir };
65882
65879
  } finally {
65883
65880
  rmSync3(scratch, { recursive: true, force: true });
65884
65881
  }
@@ -65925,8 +65922,8 @@ function isPackageSource(source) {
65925
65922
  function ensurePluginInstalled(home, name, source, exec4 = run) {
65926
65923
  const dir = join10(pluginsDir(home), name);
65927
65924
  if (existsSync6(join10(dir, PLUGIN_PACKAGE_FILE))) return dir;
65928
- if (source.startsWith("npm:")) return installPlugin(home, { kind: "npm", name, pkg: source.slice(4) }, exec4);
65929
- if (source.startsWith("git:")) return installPlugin(home, { kind: "git", name, url: source.slice(4) }, exec4);
65925
+ if (source.startsWith("npm:")) return installPlugin(home, { kind: "npm", name, pkg: source.slice(4) }, exec4).dir;
65926
+ if (source.startsWith("git:")) return installPlugin(home, { kind: "git", name, url: source.slice(4) }, exec4).dir;
65930
65927
  throw refuse("invalid-input", `The plugin ${name} has the source ${source}, and only an npm: or git: source installs into the plugin cache.`);
65931
65928
  }
65932
65929
 
@@ -66091,8 +66088,8 @@ async function startRemotePlugin(options) {
66091
66088
  throw new Error(`The plugin ${options.name} reaches ${manifest2.permissions.network.join(", ")}, and ${ORBITAL_FILE} says offline: true.`);
66092
66089
  }
66093
66090
  await options.policy.check({ kind: "plugin.load", plugin: options.name, network: manifest2.permissions.network, secrets: manifest2.permissions.secrets, models: manifest2.permissions.models });
66094
- child.once("exit", () => {
66095
- peer.close(new Error(`The plugin ${options.name} stopped.`));
66091
+ child.once("exit", (code, signal) => {
66092
+ peer.close(new Error(`The plugin ${options.name} stopped (${signal ? `signal ${signal}` : `exit code ${code}`}).`));
66096
66093
  if (stopped || reconnecting) return;
66097
66094
  current2 = reconnect();
66098
66095
  current2.catch((error62) => options.log("error", error62 instanceof Error ? error62.message : String(error62)));
@@ -66272,7 +66269,7 @@ async function startRemotePlugin(options) {
66272
66269
  endpoint = given;
66273
66270
  void current2.then((connection) => connection.peer.notify(PLUGIN_METHODS.hostEndpoint, given));
66274
66271
  },
66275
- command: async (request) => PluginCommandResultSchema.parse(await call(PLUGIN_METHODS.commandRun, request)),
66272
+ command: async (request) => PluginCommandResultSchema.parse(await (await current2).peer.request(PLUGIN_METHODS.commandRun, request, 0)),
66276
66273
  close: async () => {
66277
66274
  stopped = true;
66278
66275
  const connection = await current2.catch(() => null);
@@ -87915,6 +87912,10 @@ async function runInPlugin(context, workspaceDir, input2, plugin, command, termi
87915
87912
  const started = await startWorkspacePlugin(context, workspaceDir, plugin, given);
87916
87913
  try {
87917
87914
  return await started.command(requestOf(command, input2, given ? { tty: context.isTty } : null));
87915
+ } catch (error62) {
87916
+ if (error62 instanceof CliError) throw error62;
87917
+ const message = error62 instanceof Error ? error62.message : String(error62);
87918
+ throw new CliError({ code: "internal", message: error62 instanceof RpcError ? `The command ${plugin} ${command.name} failed: ${message}` : `The command ${plugin} ${command.name} got no answer: ${message}`, exitCode: ExitCode.Error });
87918
87919
  } finally {
87919
87920
  await started.close?.();
87920
87921
  }
@@ -88042,29 +88043,31 @@ var add = () => ({
88042
88043
  const given = String(input2.positionals.plugin);
88043
88044
  const ref = resolvePluginRef(home, given);
88044
88045
  const copied = ref.kind !== "folder" || given.includes("@");
88045
- const installed = copied ? installPlugin(home, ref) : null;
88046
- if (installed) context.output.step(`\u2713 installed ${ref.name} in ${installed}`);
88047
- if (input2.flags["install-only"] === true) return { data: { name: ref.name, source: given, installed, enabled: false, manifest: null } };
88046
+ const install2 = copied ? installPlugin(home, ref) : null;
88047
+ const installed = install2?.dir ?? null;
88048
+ const name = install2?.name ?? ref.name ?? given;
88049
+ if (install2) context.output.step(`\u2713 installed ${name} in ${install2.dir}`);
88050
+ if (input2.flags["install-only"] === true) return { data: { name, source: given, installed, enabled: false, manifest: null } };
88048
88051
  if (input2.flags.user === true) {
88049
88052
  const source2 = ref.kind === "folder" && !given.includes("@") ? ref.dir : ref.kind === "npm" ? `npm:${ref.pkg}` : ref.kind === "git" ? `git:${ref.url}` : given;
88050
- const config3 = configOf(input2, userPlugins(context.env)[ref.name]?.config ?? {});
88051
- const manifest2 = await checkedManifest(context, workspaceOf(input2), ref.name, source2, null, config3);
88052
- context.output.step(`${ref.name} ${manifest2.version} reaches:`);
88053
+ const config3 = configOf(input2, userPlugins(context.env)[name]?.config ?? {});
88054
+ const manifest2 = await checkedManifest(context, workspaceOf(input2), name, source2, null, config3);
88055
+ context.output.step(`${name} ${manifest2.version} reaches:`);
88053
88056
  for (const line of permissionLines(manifest2)) context.output.step(line);
88054
- setUserPlugin(context.env, ref.name, { source: source2, enabled: true, config: config3, env: [] });
88055
- context.output.step(`\u2713 your user config names ${ref.name}: source ${source2}`);
88056
- return { data: { name: ref.name, source: source2, installed, enabled: true, manifest: manifest2 } };
88057
+ setUserPlugin(context.env, name, { source: source2, enabled: true, config: config3, env: [] });
88058
+ context.output.step(`\u2713 your user config names ${name}: source ${source2}`);
88059
+ return { data: { name, source: source2, installed, enabled: true, manifest: manifest2 } };
88057
88060
  }
88058
88061
  const definition = sourceOrRefuse(context, input2);
88059
88062
  const root = definition.root;
88060
88063
  const source = sourceOf(ref, given, root);
88061
- const config2 = configOf(input2, sourceConfig(definition).plugins[ref.name]?.config ?? {});
88062
- const manifest = await checkedManifest(context, workspaceOf(input2), ref.name, source, root, config2);
88063
- context.output.step(`${ref.name} ${manifest.version} reaches:`);
88064
+ const config2 = configOf(input2, sourceConfig(definition).plugins[name]?.config ?? {});
88065
+ const manifest = await checkedManifest(context, workspaceOf(input2), name, source, root, config2);
88066
+ context.output.step(`${name} ${manifest.version} reaches:`);
88064
88067
  for (const line of permissionLines(manifest)) context.output.step(line);
88065
- setPluginEntry(definition.configFile, ref.name, { source, enabled: true, config: config2, env: [] });
88066
- context.output.step(`\u2713 ${basename9(definition.configFile)} names ${ref.name}: source ${source}`);
88067
- return { data: { name: ref.name, source, installed, enabled: true, manifest } };
88068
+ setPluginEntry(definition.configFile, name, { source, enabled: true, config: config2, env: [] });
88069
+ context.output.step(`\u2713 ${basename9(definition.configFile)} names ${name}: source ${source}`);
88070
+ return { data: { name, source, installed, enabled: true, manifest } };
88068
88071
  }
88069
88072
  });
88070
88073
  var list3 = () => ({
@@ -93395,9 +93398,9 @@ function pluginRefOf(home, given) {
93395
93398
  return path3.startsWith(".") ? path3 : `./${path3}`;
93396
93399
  } };
93397
93400
  }
93398
- installPlugin(home, resolved);
93401
+ const { name } = installPlugin(home, resolved);
93399
93402
  const source = resolved.kind === "npm" ? `npm:${resolved.pkg}` : resolved.kind === "git" ? `git:${resolved.url}` : spec;
93400
- return { ref: { name: resolved.name, source, base: home }, recorded: () => source };
93403
+ return { ref: { name, source, base: home }, recorded: () => source };
93401
93404
  }
93402
93405
  function knownRef(context, known) {
93403
93406
  const home = orbitalHome(context.env);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaiba-cloud/orbital",
3
3
  "description": "orbital: the control plane lite for agents on any harness: the node, its runner, its terminal UI, its protocol and its Claude Code mod, in one command line",
4
- "version": "0.0.5",
4
+ "version": "0.0.6",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "engines": {
@@ -151,9 +151,9 @@ how. Then `orbital node start`.
151
151
 
152
152
  ### 6. Publish
153
153
 
154
- - **npm.** Publish `orbital-plugin.json` and `dist/`. Name the package so that
155
- its last part is the plugin's name, such as `@acme/discord`.
156
- `orbital plugin add npm:@acme/discord` names the plugin by that part.
154
+ - **npm.** Publish `orbital-plugin.json` and `dist/`. The package can have any
155
+ name: `orbital plugin add npm:@acme/orbital-discord` names the plugin by the
156
+ `name` of its `orbital-plugin.json`, such as `discord`.
157
157
  - **A marketplace.** A Git repository with a `marketplace.json`. A person adds
158
158
  it with `orbital plugin marketplace add <git-url>`, then runs
159
159
  `orbital plugin add discord@<marketplace>`.