@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 +35 -32
- package/dist/mod.json +1 -1
- package/dist/orbital.js +35 -32
- package/package.json +1 -1
- package/skills/orbital-plugin-builder/SKILL.md +3 -3
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.
|
|
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:
|
|
38960
|
-
if (ref.startsWith("git:")) return { kind: "git", name:
|
|
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:
|
|
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,
|
|
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
|
|
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
|
|
61080
|
-
|
|
61081
|
-
|
|
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)[
|
|
61085
|
-
const manifest2 = await checkedManifest(context, workspaceOf(input2),
|
|
61086
|
-
context.output.step(`${
|
|
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,
|
|
61089
|
-
context.output.step(`\u2713 your user config names ${
|
|
61090
|
-
return { data: { name
|
|
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[
|
|
61096
|
-
const manifest = await checkedManifest(context, workspaceOf(input2),
|
|
61097
|
-
context.output.step(`${
|
|
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,
|
|
61100
|
-
context.output.step(`\u2713 ${basename9(definition.configFile)} names ${
|
|
61101
|
-
return { data: { name
|
|
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
|
|
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.
|
|
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:
|
|
65839
|
-
if (ref.startsWith("git:")) return { kind: "git", name:
|
|
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:
|
|
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,
|
|
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
|
|
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
|
|
88046
|
-
|
|
88047
|
-
|
|
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)[
|
|
88051
|
-
const manifest2 = await checkedManifest(context, workspaceOf(input2),
|
|
88052
|
-
context.output.step(`${
|
|
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,
|
|
88055
|
-
context.output.step(`\u2713 your user config names ${
|
|
88056
|
-
return { data: { name
|
|
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[
|
|
88062
|
-
const manifest = await checkedManifest(context, workspaceOf(input2),
|
|
88063
|
-
context.output.step(`${
|
|
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,
|
|
88066
|
-
context.output.step(`\u2713 ${basename9(definition.configFile)} names ${
|
|
88067
|
-
return { data: { name
|
|
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
|
|
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.
|
|
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/`.
|
|
155
|
-
|
|
156
|
-
`
|
|
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>`.
|