@starlove/orbit 1.0.6 → 1.1.0

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.
Files changed (58) hide show
  1. package/README.md +22 -7
  2. package/SKILL-ORBIT.md +28 -1
  3. package/build.mjs +2 -2
  4. package/dist/full/index.html +2 -2
  5. package/dist/full/js/app.a4f045fc.js +676 -0
  6. package/dist/full/js/app.a4f045fc.js.map +7 -0
  7. package/dist/full/{styles.c2e3d20a.css → styles.836872e7.css} +1117 -47
  8. package/dist/full/version.json +2 -2
  9. package/docs/AGENT_PROTOCOL.md +27 -5
  10. package/docs/MCP_SETUP.md +7 -3
  11. package/docs/ORBIT_DISPATCH.md +185 -0
  12. package/docs/setup/orbit-init-wizard.md +174 -0
  13. package/package.json +2 -2
  14. package/public/js/actor-labels.js +65 -0
  15. package/public/js/api.js +5 -1
  16. package/public/js/app.js +8 -1
  17. package/public/js/card-actions.js +526 -0
  18. package/public/js/config.js +2 -1
  19. package/public/js/drawer.js +2 -0
  20. package/public/js/format.js +336 -0
  21. package/public/js/kanban.js +25 -2
  22. package/public/js/local-backend.js +24 -8
  23. package/public/js/main.js +14 -6
  24. package/public/js/motion-preference.js +53 -0
  25. package/public/js/router.js +78 -58
  26. package/public/js/search.js +6 -3
  27. package/public/js/settings-tabs.js +1 -1
  28. package/public/js/settings.js +192 -29
  29. package/public/js/starscape.js +25 -18
  30. package/public/js/state.js +61 -2
  31. package/public/js/theme-preference.js +88 -0
  32. package/public/js/ticket-detail.js +432 -126
  33. package/public/js/url-routes.js +94 -0
  34. package/public/styles.css +1117 -47
  35. package/src/cli/orbit.js +217 -32
  36. package/src/core/agent.js +1 -18
  37. package/src/core/agents-md.js +16 -10
  38. package/src/core/attachments.js +238 -0
  39. package/src/core/auth.js +36 -0
  40. package/src/core/backups.js +9 -3
  41. package/src/core/board.js +32 -1
  42. package/src/core/boards.js +1 -1
  43. package/src/core/bootstrap.js +9 -1
  44. package/src/core/db.js +42 -2
  45. package/src/core/delete-board.js +87 -21
  46. package/src/core/dispatch.js +532 -0
  47. package/src/core/http.js +31 -11
  48. package/src/core/migrate.js +198 -0
  49. package/src/core/paths.js +6 -0
  50. package/src/core/registry.js +12 -9
  51. package/src/core/router.js +62 -6
  52. package/src/core/search.js +56 -22
  53. package/src/core/snapshots.js +14 -2
  54. package/src/mcp/orbit-client.js +223 -0
  55. package/src/mcp-server.js +89 -129
  56. package/src/server.js +20 -0
  57. package/dist/full/js/app.aedc1bfe.js +0 -554
  58. package/dist/full/js/app.aedc1bfe.js.map +0 -7
@@ -0,0 +1,223 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ function rpcError(code, message) {
5
+ const error = new Error(message);
6
+ error.code = code;
7
+ return error;
8
+ }
9
+
10
+ export function orbitMode(env = process.env) {
11
+ const mode = String(env.ORBIT_MODE || "local").trim().toLowerCase();
12
+ if (mode === "remote" || mode === "http") return "remote";
13
+ if (mode === "local" || mode === "") return "local";
14
+ throw rpcError(-32602, `Unsupported ORBIT_MODE: ${env.ORBIT_MODE}`);
15
+ }
16
+
17
+ export async function createOrbitClient(env = process.env) {
18
+ return orbitMode(env) === "remote" ? createHttpOrbitClient(env) : createLocalOrbitClient(env);
19
+ }
20
+
21
+ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.fetch) {
22
+ const apiUrl = String(env.ORBIT_API_URL || "").trim().replace(/\/+$/, "");
23
+ const defaultBoard = String(env.ORBIT_DEFAULT_BOARD || "").trim();
24
+ if (!apiUrl) throw rpcError(-32602, "ORBIT_MODE=remote requires ORBIT_API_URL.");
25
+ if (typeof fetchImpl !== "function") throw rpcError(-32603, "ORBIT_MODE=remote requires global fetch support.");
26
+
27
+ async function request(method, path, { query = {}, body, ok = [200] } = {}) {
28
+ const url = new URL(`${apiUrl}${path}`);
29
+ for (const [key, value] of Object.entries(query)) {
30
+ if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
31
+ }
32
+ const response = await fetchImpl(url, {
33
+ method,
34
+ headers: body === undefined ? undefined : { "Content-Type": "application/json" },
35
+ body: body === undefined ? undefined : JSON.stringify(body)
36
+ });
37
+ let payload = null;
38
+ const text = await response.text();
39
+ if (text) {
40
+ try { payload = JSON.parse(text); } catch { payload = { message: text }; }
41
+ }
42
+ if (!ok.includes(response.status)) {
43
+ const message = payload?.message || payload?.error || `Orbit API ${method} ${url.pathname} failed with HTTP ${response.status}`;
44
+ throw rpcError(-32000, message);
45
+ }
46
+ return payload ?? {};
47
+ }
48
+
49
+ function boardArg(args = {}) {
50
+ return args.board_id || args.board_slug || args.board || defaultBoard;
51
+ }
52
+
53
+ function requireBoard(args = {}) {
54
+ const board = boardArg(args);
55
+ if (!board) throw rpcError(-32602, "Remote Orbit MCP requires board_id/board_slug/board or ORBIT_DEFAULT_BOARD.");
56
+ return board;
57
+ }
58
+
59
+ const ticketQuery = (args = {}) => ({ board_id: args.board_id, board: args.board_slug || args.board || defaultBoard });
60
+
61
+ return {
62
+ mode: "remote",
63
+ sessionLabel: apiUrl,
64
+ close() {},
65
+ async boardContext(args = {}) {
66
+ return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/context`, { query: { include_struck: args.include_struck ? "true" : undefined } });
67
+ },
68
+ async boardList() { return request("GET", "/api/boards"); },
69
+ async boardSetActive(args = {}) { if (!args.slug) throw rpcError(-32602, "slug is required."); env.ORBIT_DEFAULT_BOARD = String(args.slug); return { ok: true, slug: String(args.slug), mode: "remote" }; },
70
+ async claimNext(args = {}) { return request("POST", "/api/agent/claim-next", { body: { ...args, board: requireBoard(args) } }); },
71
+ async getTicketContext(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/context`, { query: { ...ticketQuery(args), depth: args.depth || 1 } }); },
72
+ async readTicket(args = {}) {
73
+ if (args.ticket_id) {
74
+ return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}`, { query: ticketQuery(args) });
75
+ }
76
+ if (args.number !== undefined || args.title) {
77
+ return request("GET", "/api/tickets/lookup", { query: { ...ticketQuery(args), number: args.number, title: args.title } });
78
+ }
79
+ throw rpcError(-32602, "ticket_id_or_number_or_title_required");
80
+ },
81
+ async readComments(args = {}) {
82
+ if (args.ticket_id) {
83
+ return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/comments`, { query: ticketQuery(args) });
84
+ }
85
+ const found = await this.readTicket(args);
86
+ const ticket = found.ticket || found.results?.find((row) =>
87
+ (args.number !== undefined && Number(row.number) === Number(args.number)) ||
88
+ (args.title && String(row.title || "").toLowerCase() === String(args.title).toLowerCase())
89
+ );
90
+ if (!ticket?.id) throw rpcError(-32004, "ticket_not_found");
91
+ return request("GET", `/api/tickets/${encodeURIComponent(ticket.id)}/comments`, { query: ticketQuery(args) });
92
+ },
93
+ async getTicketRelations(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/relations`, { query: ticketQuery(args) }); },
94
+ async getTicketBlockers(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/blockers`, { query: ticketQuery(args) }); },
95
+ async search(args = {}) { return request("GET", "/api/search", { query: { q: args.q, limit: args.limit, board_id: args.board_id, board: args.board_slug || args.board || defaultBoard } }); },
96
+ async createTicket(args = {}) { return request("POST", "/api/tickets", { body: { ...args, board: requireBoard(args) }, ok: [201] }); },
97
+ async updateTicket({ ticket_id, ...patch }) { return request("PATCH", `/api/tickets/${encodeURIComponent(ticket_id)}`, { query: ticketQuery(patch), body: patch }); },
98
+ async addComment({ ticket_id, ...body }) { return request("POST", `/api/tickets/${encodeURIComponent(ticket_id)}/comments`, { body, ok: [201] }); },
99
+ async addBoardEntry(args = {}) { return request("POST", `/api/boards/${encodeURIComponent(requireBoard(args))}/entries`, { body: args, ok: [201] }); },
100
+ async checkpoint(args = {}) { return request("POST", "/api/agent/checkpoint", { body: args }); },
101
+ async complete(args = {}) { return request("POST", "/api/agent/complete", { body: args }); },
102
+ async archiveTicket(args = {}) { return request("POST", `/api/tickets/${encodeURIComponent(args.ticket_id)}/archive`, { query: ticketQuery(args) }); },
103
+ async restoreTicket(args = {}) { return request("POST", `/api/tickets/${encodeURIComponent(args.ticket_id)}/restore`, { query: ticketQuery(args) }); },
104
+ async deleteTicket(args = {}) { return request("DELETE", `/api/tickets/${encodeURIComponent(args.ticket_id)}`, { query: ticketQuery(args) }); },
105
+ async listArchive(args = {}) { return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/archive`); },
106
+ async exportBoard(args = {}) {
107
+ return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/export`, {
108
+ query: { include_attachments: args.include_attachments || args.include_images ? "true" : undefined }
109
+ });
110
+ },
111
+ async updateSettings(args = {}) { const { board_id, board_slug, board, ...patch } = args; return request("PATCH", `/api/boards/${encodeURIComponent(requireBoard(args))}`, { body: patch }); }
112
+ };
113
+ }
114
+
115
+ export async function createLocalOrbitClient(env = process.env) {
116
+ const board = await import("../core/board.js");
117
+ const dbMod = await import("../core/db.js");
118
+ const registry = await import("../core/registry.js");
119
+ const backups = await import("../core/backups.js");
120
+ const seed = await import("../core/seed.js");
121
+ const util = await import("../core/util.js");
122
+
123
+ const provision = await import("../core/provision-repo-board.js");
124
+ let sessionBoardRow = null;
125
+ const setSessionBoard = (row) => { sessionBoardRow = row; if (row) registry.touchBoardActive(row.id); };
126
+ const getSessionBoard = () => sessionBoardRow;
127
+ const walkUp = (startDir, predicate) => { let dir = startDir; while (true) { if (predicate(dir)) return dir; const parent = dirname(dir); if (parent === dir) return null; dir = parent; } };
128
+
129
+ const start = util.normalizePath(env.PROJECT_ROOT ? resolve(env.PROJECT_ROOT) : process.cwd());
130
+ const displayStart = resolve(env.PROJECT_ROOT ? env.PROJECT_ROOT : process.cwd());
131
+
132
+ // Find repo root (walk up for .git, fall back to start).
133
+ const gitRoot = walkUp(start, (dir) => existsSync(join(dir, ".git")));
134
+ const repoRoot = util.normalizePath(gitRoot || start);
135
+
136
+ // 1. Registry-first lookup: walk upward from start so we find boards regardless
137
+ // of whether they were registered at the git root, at PROJECT_ROOT, or at any
138
+ // ancestor (handles boards originally created from a subdirectory).
139
+ let foundByRegistry = false;
140
+ {
141
+ let dir = start;
142
+ while (true) {
143
+ const row = registry.getBoardByRepoPath(dir);
144
+ if (row && existsSync(row.db_path)) { setSessionBoard(row); foundByRegistry = true; break; }
145
+ const parent = util.normalizePath(dirname(dir));
146
+ if (parent === dir) break; // filesystem root
147
+ dir = parent;
148
+ }
149
+ }
150
+ if (!foundByRegistry) {
151
+ // 2. Legacy fallback: walk up for an existing in-repo .orbit/board.db.
152
+ const boardRoot = walkUp(start, (dir) => existsSync(join(dir, ".orbit", "board.db")));
153
+ if (boardRoot) {
154
+ const root = util.normalizePath(boardRoot);
155
+ const existing = registry.getBoardByRepoPath(root);
156
+ if (existing) {
157
+ setSessionBoard(existing);
158
+ } else {
159
+ const dbPath = join(boardRoot, ".orbit", "board.db");
160
+ const db = dbMod.openConnection(dbPath);
161
+ dbMod.createBoardSchema(db);
162
+ const seeded = seed.seedIfEmpty(db, root);
163
+ if (seeded) {
164
+ const t = util.now();
165
+ registry.insertBoard({ id: seeded.id, slug: seeded.slug, name: seeded.name, repo_path: root, db_path: dbPath, repo_url: seeded.repo_url || "", default_branch: seeded.default_branch || "main", last_active_at: t, created_at: t, updated_at: t });
166
+ }
167
+ const fresh = registry.getBoardByRepoPath(root);
168
+ if (fresh) { registry.syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
169
+ }
170
+ } else {
171
+ // 3. No board found — auto-create one (goes to central DATA_DIR/boards/).
172
+ provision.provisionRepoBoard(repoRoot, { enableAi: true });
173
+ const fresh = registry.getBoardByRepoPath(repoRoot);
174
+ if (fresh) setSessionBoard(fresh);
175
+ }
176
+ }
177
+
178
+ const ctxFor = (row, actor) => {
179
+ if (!row) throw rpcError(-32602, "No board in session; pass board_id or board_slug, or call board_set_active first.");
180
+ return { actor, board: row, db: registry.openBoardDb(row) };
181
+ };
182
+ const sessionCtx = (actor) => ctxFor(getSessionBoard(), actor);
183
+ const backup = (ctx, result) => { backups.scheduleAutomaticBoardBackup(ctx.board, ctx.db); return result; };
184
+ const explicit = (args = {}) => args.board_id ? registry.getBoardByRegistryId(args.board_id) : (args.board_slug || args.board ? registry.getBoardBySlug(args.board_slug || args.board) : null);
185
+ const rowOrSession = (args = {}) => explicit(args) || getSessionBoard();
186
+ const actor = () => board.localAgentActor();
187
+
188
+ return {
189
+ mode: "local",
190
+ sessionLabel: () => {
191
+ const b = getSessionBoard();
192
+ return b ? `${displayStart} target: ${b.repo_path} db: ${b.db_path}` : "(none)";
193
+ },
194
+ close: () => dbMod.closeAllConnections(),
195
+ boardContext: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getBoardContext(ctx.board.id, ctx, { includeStruck: Boolean(args.include_struck) }); },
196
+ boardList: () => ({ boards: registry.listBoards().map((row) => ({ id: row.id, slug: row.slug, name: row.name, repo_path: row.repo_path })) }),
197
+ boardSetActive: (args = {}) => { const slug = String(args.slug || "").trim(); if (!slug) throw rpcError(-32602, "slug is required."); const row = registry.getBoardBySlug(slug); if (!row) throw rpcError(-32004, `Board slug not found: ${slug}`); setSessionBoard(row); return { ok: true, board_id: row.id, slug: row.slug, name: row.name, db_path: row.db_path }; },
198
+ claimNext: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.claimNext(args, ctx)); },
199
+ getTicketContext: (args = {}) => board.getContextPack(args.ticket_id, sessionCtx(actor()), Number(args.depth || 1)),
200
+ readTicket: (args = {}) => board.readTicket(args, sessionCtx(actor())),
201
+ readComments: (args = {}) => board.readComments(args, sessionCtx(actor())),
202
+ getTicketRelations: (args = {}) => board.getTicketRelations(args.ticket_id, sessionCtx(actor())),
203
+ getTicketBlockers: (args = {}) => board.getTicketBlockers(args.ticket_id, sessionCtx(actor())),
204
+ search: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.searchTickets({ q: args.q, limit: args.limit }, ctx); },
205
+ createTicket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); const { board_slug, board: _b, ...body } = args; body.board_id = ctx.board.id; return backup(ctx, board.createTicket(body, ctx)); },
206
+ updateTicket: ({ ticket_id, ...patch }) => { const ctx = sessionCtx(actor()); return backup(ctx, board.updateTicket(ticket_id, patch, ctx)); },
207
+ addComment: ({ ticket_id, ...body }) => { const ctx = sessionCtx(actor()); return backup(ctx, board.createComment(ticket_id, body, ctx)); },
208
+ addBoardEntry: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.createBoardEntry(ctx.board.id, args, ctx)); },
209
+ checkpoint: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.checkpointTicket(args, ctx)); },
210
+ complete: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.completeTicket(args, ctx)); },
211
+ archiveTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.archiveTicket(args.ticket_id, ctx)); },
212
+ restoreTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.restoreTicket(args.ticket_id, ctx)); },
213
+ deleteTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.deleteTicket(args.ticket_id, ctx)); },
214
+ listArchive: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return { tickets: board.archivedTicketsForBoard(ctx.db, ctx.board.id) }; },
215
+ exportBoard: (args = {}) => {
216
+ const ctx = ctxFor(rowOrSession(args), actor());
217
+ return board.exportBoard(ctx.board.id, ctx, {
218
+ includeAttachments: Boolean(args.include_attachments || args.include_images)
219
+ });
220
+ },
221
+ updateSettings: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); const { board_id, board_slug, ...patch } = args; return backup(ctx, board.updateBoard(ctx.board.id, patch, ctx)); }
222
+ };
223
+ }
package/src/mcp-server.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // the call it's about to make, so adding new tools never has to think about
5
5
  // "active board" state.
6
6
 
7
+ import { createOrbitClient } from "./mcp/orbit-client.js";
7
8
  import { existsSync, mkdirSync } from "node:fs";
8
9
  import { dirname, join, resolve } from "node:path";
9
10
  import {
@@ -14,6 +15,7 @@ import {
14
15
  completeTicket,
15
16
  createBoardEntry,
16
17
  createComment,
18
+ createTicket,
17
19
  deleteTicket,
18
20
  exportBoard,
19
21
  getBoardContext,
@@ -28,7 +30,7 @@ import {
28
30
  updateBoard,
29
31
  updateTicket
30
32
  } from "./core/board.js";
31
- import { createBoardSchema, openConnection } from "./core/db.js";
33
+ import { closeAllConnections, createBoardSchema, openConnection } from "./core/db.js";
32
34
  import {
33
35
  getBoardByRegistryId,
34
36
  getBoardBySlug,
@@ -40,6 +42,7 @@ import {
40
42
  touchBoardActive
41
43
  } from "./core/registry.js";
42
44
  import { scheduleAutomaticBoardBackup } from "./core/backups.js";
45
+ import { provisionRepoBoard } from "./core/provision-repo-board.js";
43
46
  import { seedIfEmpty } from "./core/seed.js";
44
47
  import { now, normalizePath } from "./core/util.js";
45
48
 
@@ -48,6 +51,7 @@ const SERVER_INFO = {
48
51
  version: "0.1.0"
49
52
  };
50
53
 
54
+ const orbitClient = await createOrbitClient();
51
55
  // Per-process session board. Set on init by walking up from cwd, mutable via
52
56
  // `board_set_active`. NEVER read by anything outside this file — every
53
57
  // downstream call receives an explicit ctx.
@@ -88,7 +92,16 @@ function walkUp(startDir, predicate) {
88
92
  function initMcpSessionBoard() {
89
93
  const start = normalizePath(process.env.PROJECT_ROOT ? resolve(process.env.PROJECT_ROOT) : process.cwd());
90
94
 
91
- // 1. Walk up for an existing board db.
95
+ // 1. Prefer the registry row for PROJECT_ROOT (or one of its ancestors). New
96
+ // Orbit boards live in central DATA_DIR storage, so there may be no in-repo
97
+ // .orbit/board.db to discover.
98
+ const registeredRoot = walkUp(start, (dir) => Boolean(getBoardByRepoPath(normalizePath(dir))));
99
+ if (registeredRoot) {
100
+ setSessionBoard(getBoardByRepoPath(normalizePath(registeredRoot)));
101
+ return;
102
+ }
103
+
104
+ // 2. Walk up for an existing legacy board db.
92
105
  const boardRoot = walkUp(start, (dir) => existsSync(join(dir, ".orbit", "board.db")));
93
106
  if (boardRoot) {
94
107
  const root = normalizePath(boardRoot);
@@ -108,9 +121,10 @@ function initMcpSessionBoard() {
108
121
  return;
109
122
  }
110
123
 
111
- // 2. Walk up for a git root; fall back to cwd for non-git projects.
124
+ // 3. Walk up for a git root; fall back to cwd for non-git projects.
112
125
  const gitRoot = walkUp(start, (dir) => existsSync(join(dir, ".git")));
113
- createBoardAtRoot(normalizePath(gitRoot || start));
126
+ const result = provisionRepoBoard(normalizePath(gitRoot || start));
127
+ if (result.registryRow) setSessionBoard(result.registryRow);
114
128
  }
115
129
 
116
130
  function createBoardAtRoot(root) {
@@ -128,6 +142,7 @@ function createBoardAtRoot(root) {
128
142
  }
129
143
 
130
144
  initMcpSessionBoard();
145
+ closeAllConnections();
131
146
 
132
147
  /** Build a ctx for the session board (or a board the tool argument selected). */
133
148
  function ctxFor(boardRow, actor) {
@@ -180,12 +195,7 @@ const TOOL_DEFS = [
180
195
  },
181
196
  additionalProperties: false
182
197
  },
183
- handler: (args) => {
184
- const actor = localAgentActor();
185
- const row = resolveBoardOrSession(args);
186
- const ctx = ctxFor(row, actor);
187
- return getBoardContext(ctx.board.id, ctx, { includeStruck: Boolean(args.include_struck) });
188
- }
198
+ handler: (args) => orbitClient.boardContext(args)
189
199
  },
190
200
  {
191
201
  name: "board_list",
@@ -196,16 +206,7 @@ const TOOL_DEFS = [
196
206
  properties: {},
197
207
  additionalProperties: false
198
208
  },
199
- handler: () => {
200
- return {
201
- boards: listBoards().map((row) => ({
202
- id: row.id,
203
- slug: row.slug,
204
- name: row.name,
205
- repo_path: row.repo_path
206
- }))
207
- };
208
- }
209
+ handler: () => orbitClient.boardList()
209
210
  },
210
211
  {
211
212
  name: "board_set_active",
@@ -219,14 +220,7 @@ const TOOL_DEFS = [
219
220
  required: ["slug"],
220
221
  additionalProperties: false
221
222
  },
222
- handler: (args) => {
223
- const slug = String(args.slug || "").trim();
224
- if (!slug) throw rpcError(-32602, "slug is required.");
225
- const row = getBoardBySlug(slug);
226
- if (!row) throw rpcError(-32004, `Board slug not found: ${slug}`);
227
- setSessionBoard(row);
228
- return { ok: true, board_id: row.id, slug: row.slug, name: row.name, db_path: row.db_path };
229
- }
223
+ handler: (args) => orbitClient.boardSetActive(args)
230
224
  },
231
225
  {
232
226
  name: "board_claim_next",
@@ -243,17 +237,12 @@ const TOOL_DEFS = [
243
237
  },
244
238
  additionalProperties: false
245
239
  },
246
- handler: (args) => {
247
- const actor = localAgentActor();
248
- const row = resolveBoardOrSession(args);
249
- const ctx = ctxFor(row, actor);
250
- return withAutomaticBackup(ctx, claimNext(args, ctx));
251
- }
240
+ handler: (args) => orbitClient.claimNext(args)
252
241
  },
253
242
  {
254
243
  name: "board_get_ticket_context",
255
244
  description:
256
- "Read the full context pack for a ticket, including board agent_instructions (project-level agent context), relations, blockers, comments, and child cards. Heavy — prefer board_read_ticket when you only need title/description/comments.",
245
+ "Read the default agent context pack for a ticket, including board agent_instructions (project-level agent context), relations, blockers, implementation fields, and child cards. Ticket comments are intentionally omitted; use board_read_comments for explicit comment retrieval.",
257
246
  inputSchema: {
258
247
  type: "object",
259
248
  properties: {
@@ -263,16 +252,12 @@ const TOOL_DEFS = [
263
252
  required: ["ticket_id"],
264
253
  additionalProperties: false
265
254
  },
266
- handler: (args) => {
267
- const actor = localAgentActor();
268
- const ctx = sessionCtx(actor);
269
- return getContextPack(args.ticket_id, ctx, Number(args.depth || 1));
270
- }
255
+ handler: (args) => orbitClient.getTicketContext(args)
271
256
  },
272
257
  {
273
258
  name: "board_read_ticket",
274
259
  description:
275
- "Look up a ticket by ticket_id, number, or exact title (case-insensitive) on the session's active board, and return its title, description, labels, state, and full comment thread plus the board manual (agent_instructions + journal + deployment paths).",
260
+ "Look up a ticket by ticket_id, number, or exact title (case-insensitive) on the session's active board, and return its title, description, labels, state, and the board manual (agent_instructions + journal + deployment paths). Ticket comments are intentionally omitted; use board_read_comments for explicit comment retrieval.",
276
261
  inputSchema: {
277
262
  type: "object",
278
263
  properties: {
@@ -282,11 +267,7 @@ const TOOL_DEFS = [
282
267
  },
283
268
  additionalProperties: false
284
269
  },
285
- handler: (args) => {
286
- const actor = localAgentActor();
287
- const ctx = sessionCtx(actor);
288
- return readTicket(args, ctx);
289
- }
270
+ handler: (args) => orbitClient.readTicket(args)
290
271
  },
291
272
  {
292
273
  name: "board_read_comments",
@@ -301,11 +282,7 @@ const TOOL_DEFS = [
301
282
  },
302
283
  additionalProperties: false
303
284
  },
304
- handler: (args) => {
305
- const actor = localAgentActor();
306
- const ctx = sessionCtx(actor);
307
- return readComments(args, ctx);
308
- }
285
+ handler: (args) => orbitClient.readComments(args)
309
286
  },
310
287
  {
311
288
  name: "board_get_ticket_relations",
@@ -318,10 +295,7 @@ const TOOL_DEFS = [
318
295
  required: ["ticket_id"],
319
296
  additionalProperties: false
320
297
  },
321
- handler: (args) => {
322
- const actor = localAgentActor();
323
- return getTicketRelations(args.ticket_id, sessionCtx(actor));
324
- }
298
+ handler: (args) => orbitClient.getTicketRelations(args)
325
299
  },
326
300
  {
327
301
  name: "board_get_ticket_blockers",
@@ -334,10 +308,7 @@ const TOOL_DEFS = [
334
308
  required: ["ticket_id"],
335
309
  additionalProperties: false
336
310
  },
337
- handler: (args) => {
338
- const actor = localAgentActor();
339
- return getTicketBlockers(args.ticket_id, sessionCtx(actor));
340
- }
311
+ handler: (args) => orbitClient.getTicketBlockers(args)
341
312
  },
342
313
  {
343
314
  name: "board_search",
@@ -355,12 +326,33 @@ const TOOL_DEFS = [
355
326
  required: ["q"],
356
327
  additionalProperties: false
357
328
  },
358
- handler: (args) => {
359
- const actor = localAgentActor();
360
- const row = resolveBoardOrSession(args);
361
- const ctx = ctxFor(row, actor);
362
- return searchTickets({ q: args.q, limit: args.limit }, ctx);
363
- }
329
+ handler: (args) => orbitClient.search(args)
330
+ },
331
+ {
332
+ name: "board_create_ticket",
333
+ description:
334
+ "Create a new Orbit ticket/card on the active board. Use this instead of editing .orbit/board.db directly. Same operation as POST /api/tickets.",
335
+ inputSchema: {
336
+ type: "object",
337
+ properties: {
338
+ board_id: { type: "string" },
339
+ board_slug: { type: "string" },
340
+ board: { type: "string" },
341
+ title: { type: "string" },
342
+ description: { type: "string" },
343
+ type: { type: "string", enum: ["epic", "feature", "task", "bug"] },
344
+ parent_ticket_id: { type: ["string", "null"] },
345
+ ai_plan: { type: "string" },
346
+ implementation_summary: { type: "string" },
347
+ implementation_updates: { type: "string" },
348
+ state_id: { type: "string" },
349
+ priority: { type: "integer" },
350
+ labels: { type: "array", items: { type: "string" } }
351
+ },
352
+ required: ["title"],
353
+ additionalProperties: false
354
+ },
355
+ handler: (args) => orbitClient.createTicket(args)
364
356
  },
365
357
  {
366
358
  name: "board_update_ticket",
@@ -384,11 +376,7 @@ const TOOL_DEFS = [
384
376
  required: ["ticket_id"],
385
377
  additionalProperties: false
386
378
  },
387
- handler: ({ ticket_id, ...patch }) => {
388
- const actor = localAgentActor();
389
- const ctx = sessionCtx(actor);
390
- return withAutomaticBackup(ctx, updateTicket(ticket_id, patch, ctx));
391
- }
379
+ handler: ({ ticket_id, ...patch }) => orbitClient.updateTicket({ ticket_id, ...patch })
392
380
  },
393
381
  {
394
382
  name: "board_add_comment",
@@ -403,11 +391,7 @@ const TOOL_DEFS = [
403
391
  required: ["ticket_id", "body"],
404
392
  additionalProperties: false
405
393
  },
406
- handler: ({ ticket_id, ...body }) => {
407
- const actor = localAgentActor();
408
- const ctx = sessionCtx(actor);
409
- return withAutomaticBackup(ctx, createComment(ticket_id, body, ctx));
410
- }
394
+ handler: ({ ticket_id, ...body }) => orbitClient.addComment({ ticket_id, ...body })
411
395
  },
412
396
  {
413
397
  name: "board_add_board_entry",
@@ -425,12 +409,7 @@ const TOOL_DEFS = [
425
409
  required: ["type", "title"],
426
410
  additionalProperties: false
427
411
  },
428
- handler: (args) => {
429
- const actor = localAgentActor();
430
- const row = resolveBoardOrSession(args);
431
- const ctx = ctxFor(row, actor);
432
- return withAutomaticBackup(ctx, createBoardEntry(ctx.board.id, args, ctx));
433
- }
412
+ handler: (args) => orbitClient.addBoardEntry(args)
434
413
  },
435
414
  {
436
415
  name: "board_checkpoint",
@@ -445,11 +424,7 @@ const TOOL_DEFS = [
445
424
  required: ["ticket_id", "message"],
446
425
  additionalProperties: false
447
426
  },
448
- handler: (args) => {
449
- const actor = localAgentActor();
450
- const ctx = sessionCtx(actor);
451
- return withAutomaticBackup(ctx, checkpointTicket(args, ctx));
452
- }
427
+ handler: (args) => orbitClient.checkpoint(args)
453
428
  },
454
429
  {
455
430
  name: "board_complete",
@@ -467,11 +442,7 @@ const TOOL_DEFS = [
467
442
  required: ["ticket_id", "summary"],
468
443
  additionalProperties: false
469
444
  },
470
- handler: (args) => {
471
- const actor = localAgentActor();
472
- const ctx = sessionCtx(actor);
473
- return withAutomaticBackup(ctx, completeTicket(args, ctx));
474
- }
445
+ handler: (args) => orbitClient.complete(args)
475
446
  },
476
447
  {
477
448
  name: "board_archive_ticket",
@@ -483,11 +454,7 @@ const TOOL_DEFS = [
483
454
  required: ["ticket_id"],
484
455
  additionalProperties: false
485
456
  },
486
- handler: (args) => {
487
- const actor = localAgentActor();
488
- const ctx = sessionCtx(actor);
489
- return withAutomaticBackup(ctx, archiveTicket(args.ticket_id, ctx));
490
- }
457
+ handler: (args) => orbitClient.archiveTicket(args)
491
458
  },
492
459
  {
493
460
  name: "board_restore_ticket",
@@ -498,11 +465,7 @@ const TOOL_DEFS = [
498
465
  required: ["ticket_id"],
499
466
  additionalProperties: false
500
467
  },
501
- handler: (args) => {
502
- const actor = localAgentActor();
503
- const ctx = sessionCtx(actor);
504
- return withAutomaticBackup(ctx, restoreTicket(args.ticket_id, ctx));
505
- }
468
+ handler: (args) => orbitClient.restoreTicket(args)
506
469
  },
507
470
  {
508
471
  name: "board_delete_ticket",
@@ -514,11 +477,7 @@ const TOOL_DEFS = [
514
477
  required: ["ticket_id"],
515
478
  additionalProperties: false
516
479
  },
517
- handler: (args) => {
518
- const actor = localAgentActor();
519
- const ctx = sessionCtx(actor);
520
- return withAutomaticBackup(ctx, deleteTicket(args.ticket_id, ctx));
521
- }
480
+ handler: (args) => orbitClient.deleteTicket(args)
522
481
  },
523
482
  {
524
483
  name: "board_list_archive",
@@ -531,12 +490,7 @@ const TOOL_DEFS = [
531
490
  },
532
491
  additionalProperties: false
533
492
  },
534
- handler: (args) => {
535
- const actor = localAgentActor();
536
- const row = resolveBoardOrSession(args);
537
- const ctx = ctxFor(row, actor);
538
- return { tickets: archivedTicketsForBoard(ctx.db, ctx.board.id) };
539
- }
493
+ handler: (args) => orbitClient.listArchive(args)
540
494
  },
541
495
  {
542
496
  name: "board_export_board",
@@ -545,21 +499,18 @@ const TOOL_DEFS = [
545
499
  type: "object",
546
500
  properties: {
547
501
  board_id: { type: "string" },
548
- board_slug: { type: "string" }
502
+ board_slug: { type: "string" },
503
+ include_attachments: { type: "boolean", description: "Embed attached image bytes in the JSON snapshot." },
504
+ include_images: { type: "boolean", description: "Alias for include_attachments." }
549
505
  },
550
506
  additionalProperties: false
551
507
  },
552
- handler: (args) => {
553
- const actor = localAgentActor();
554
- const row = resolveBoardOrSession(args);
555
- const ctx = ctxFor(row, actor);
556
- return exportBoard(ctx.board.id, ctx);
557
- }
508
+ handler: (args) => orbitClient.exportBoard(args)
558
509
  },
559
510
  {
560
511
  name: "board_update_settings",
561
512
  description:
562
- "PATCH-equivalent for board settings: name, repo_url, system_path, default_branch, agent_instructions (project-level agent context), and project_notes (Notes For You). Same fields as PATCH /api/boards/:id.",
513
+ "PATCH-equivalent for board settings: name (display rename only; slug/canonical URLs stay unchanged), repo_url, system_path, default_branch, agent_instructions (project-level agent context), and project_notes (Notes For You).",
563
514
  inputSchema: {
564
515
  type: "object",
565
516
  properties: {
@@ -574,13 +525,7 @@ const TOOL_DEFS = [
574
525
  },
575
526
  additionalProperties: false
576
527
  },
577
- handler: (args) => {
578
- const actor = localAgentActor();
579
- const row = resolveBoardOrSession(args);
580
- const ctx = ctxFor(row, actor);
581
- const { board_id, board_slug, ...patch } = args;
582
- return withAutomaticBackup(ctx, updateBoard(ctx.board.id, patch, ctx));
583
- }
528
+ handler: (args) => orbitClient.updateSettings(args)
584
529
  }
585
530
  ];
586
531
 
@@ -588,13 +533,21 @@ const tools = new Map(TOOL_DEFS.map((tool) => [tool.name, tool]));
588
533
 
589
534
  let inputBuffer = Buffer.alloc(0);
590
535
  let expectedBodyLength = -1;
536
+ let shuttingDown = false;
537
+
538
+ function shutdown(code = 0) {
539
+ if (shuttingDown) return;
540
+ shuttingDown = true;
541
+ orbitClient.close?.();
542
+ process.exit(code);
543
+ }
591
544
 
592
545
  process.stdin.on("data", (chunk) => {
593
546
  inputBuffer = Buffer.concat([inputBuffer, chunk]);
594
547
  drainInput();
595
548
  });
596
549
 
597
- process.stdin.on("end", () => process.exit(0));
550
+ process.stdin.on("end", () => shutdown(0));
598
551
  process.stdin.resume();
599
552
 
600
553
  function drainInput() {
@@ -673,6 +626,8 @@ async function handleMessage(body, transport = "content-length") {
673
626
  }, transport);
674
627
  } catch (error) {
675
628
  writeMessage(errorResponse(message.id, error.code || -32000, error.message || "Internal error."), transport);
629
+ } finally {
630
+ orbitClient.close?.();
676
631
  }
677
632
  }
678
633
 
@@ -768,6 +723,11 @@ process.on("unhandledRejection", (error) => {
768
723
  writeMessage(errorResponse(null, -32000, error?.message || "Unhandled rejection."));
769
724
  });
770
725
 
726
+ process.once("SIGINT", () => shutdown(0));
727
+ process.once("SIGTERM", () => shutdown(0));
728
+ process.once("exit", () => orbitClient.close?.());
729
+
771
730
  if (process.env.MAB_MCP_STDERR_LOG === "1") {
772
- console.error(`Starscape Orbit MCP ready. Session board: ${getSessionBoard()?.db_path || "(none)"}`);
731
+ const target = typeof orbitClient.sessionLabel === "function" ? orbitClient.sessionLabel() : orbitClient.sessionLabel;
732
+ console.error(`Starscape Orbit MCP ready. Mode: ${orbitClient.mode}. Target: ${target}`);
773
733
  }