@starlove/orbit 1.0.7 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +22 -7
  2. package/SKILL-ORBIT.md +26 -1
  3. package/dist/full/index.html +2 -2
  4. package/dist/full/js/app.a4f045fc.js +676 -0
  5. package/dist/full/js/app.a4f045fc.js.map +7 -0
  6. package/dist/full/{styles.14e9dbbd.css → styles.836872e7.css} +836 -39
  7. package/dist/full/version.json +2 -2
  8. package/docs/AGENT_PROTOCOL.md +23 -5
  9. package/docs/MCP_SETUP.md +5 -3
  10. package/docs/ORBIT_DISPATCH.md +185 -0
  11. package/docs/setup/orbit-init-wizard.md +174 -0
  12. package/package.json +2 -2
  13. package/public/js/actor-labels.js +65 -0
  14. package/public/js/app.js +8 -1
  15. package/public/js/card-actions.js +526 -0
  16. package/public/js/config.js +2 -1
  17. package/public/js/format.js +204 -10
  18. package/public/js/kanban.js +15 -0
  19. package/public/js/local-backend.js +24 -8
  20. package/public/js/router.js +3 -2
  21. package/public/js/search.js +6 -3
  22. package/public/js/settings.js +96 -26
  23. package/public/js/state.js +50 -0
  24. package/public/js/ticket-detail.js +428 -125
  25. package/public/styles.css +836 -39
  26. package/src/cli/orbit.js +217 -32
  27. package/src/core/agent.js +114 -24
  28. package/src/core/agents-md.js +15 -10
  29. package/src/core/attachments.js +238 -0
  30. package/src/core/auth.js +36 -0
  31. package/src/core/backups.js +9 -3
  32. package/src/core/board.js +32 -1
  33. package/src/core/boards.js +1 -1
  34. package/src/core/db.js +42 -2
  35. package/src/core/delete-board.js +87 -21
  36. package/src/core/dispatch.js +532 -0
  37. package/src/core/http.js +31 -11
  38. package/src/core/migrate.js +198 -0
  39. package/src/core/paths.js +6 -0
  40. package/src/core/registry.js +12 -9
  41. package/src/core/router.js +81 -7
  42. package/src/core/search.js +83 -10
  43. package/src/core/snapshots.js +14 -2
  44. package/src/mcp/orbit-client.js +109 -46
  45. package/src/mcp-server.js +238 -12
  46. package/src/server.js +20 -0
  47. package/dist/full/js/app.f4fd5e94.js +0 -604
  48. package/dist/full/js/app.f4fd5e94.js.map +0 -7
@@ -57,26 +57,61 @@ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.
57
57
  }
58
58
 
59
59
  const ticketQuery = (args = {}) => ({ board_id: args.board_id, board: args.board_slug || args.board || defaultBoard });
60
+ const searchQuery = (args = {}) => ({
61
+ q: args.q,
62
+ limit: args.limit,
63
+ board_id: args.board_id,
64
+ board: args.board_slug || args.board || defaultBoard,
65
+ mode: args.mode,
66
+ include_full: args.include_full || args.includeFull ? "true" : undefined,
67
+ fields: Array.isArray(args.fields) ? args.fields.join(",") : args.fields,
68
+ max_chars_per_field: args.max_chars_per_field ?? args.maxCharsPerField
69
+ });
70
+ const contextQuery = (args = {}) => ({
71
+ ...ticketQuery(args),
72
+ depth: args.depth,
73
+ max_chars_per_field: args.max_chars_per_field ?? args.maxCharsPerField,
74
+ comment_limit: args.comment_limit ?? args.commentLimit,
75
+ include_parent_full: args.include_parent_full || args.includeParentFull ? "true" : undefined,
76
+ include_related_full: args.include_related_full || args.includeRelatedFull ? "true" : undefined
77
+ });
60
78
 
61
79
  return {
62
80
  mode: "remote",
63
81
  sessionLabel: apiUrl,
82
+ close() {},
64
83
  async boardContext(args = {}) {
65
84
  return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/context`, { query: { include_struck: args.include_struck ? "true" : undefined } });
66
85
  },
67
86
  async boardList() { return request("GET", "/api/boards"); },
68
87
  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" }; },
69
88
  async claimNext(args = {}) { return request("POST", "/api/agent/claim-next", { body: { ...args, board: requireBoard(args) } }); },
70
- async getTicketContext(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/context`, { query: { ...ticketQuery(args), depth: args.depth || 1 } }); },
89
+ async getTicketContext(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/context`, { query: { ...contextQuery(args), depth: args.depth || 1 } }); },
90
+ async getAgentDispatchPacket(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/dispatch-packet`, { query: contextQuery(args) }); },
71
91
  async readTicket(args = {}) {
72
- if (args.ticket_id) return this.getTicketContext({ ...args, depth: 1 });
73
- const found = await this.search({ q: args.title || String(args.number || ""), limit: 10, ...args });
74
- return found;
92
+ if (args.ticket_id) {
93
+ return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}`, { query: ticketQuery(args) });
94
+ }
95
+ if (args.number !== undefined || args.title) {
96
+ return request("GET", "/api/tickets/lookup", { query: { ...ticketQuery(args), number: args.number, title: args.title } });
97
+ }
98
+ throw rpcError(-32602, "ticket_id_or_number_or_title_required");
99
+ },
100
+ async readComments(args = {}) {
101
+ if (args.ticket_id) {
102
+ return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/comments`, { query: ticketQuery(args) });
103
+ }
104
+ const found = await this.readTicket(args);
105
+ const ticket = found.ticket || found.results?.find((row) =>
106
+ (args.number !== undefined && Number(row.number) === Number(args.number)) ||
107
+ (args.title && String(row.title || "").toLowerCase() === String(args.title).toLowerCase())
108
+ );
109
+ if (!ticket?.id) throw rpcError(-32004, "ticket_not_found");
110
+ return request("GET", `/api/tickets/${encodeURIComponent(ticket.id)}/comments`, { query: ticketQuery(args) });
75
111
  },
76
- async readComments(args = {}) { const ctx = await this.readTicket(args); return { comments: ctx.comments || ctx.ticket?.comments || [] }; },
77
112
  async getTicketRelations(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/relations`, { query: ticketQuery(args) }); },
78
113
  async getTicketBlockers(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/blockers`, { query: ticketQuery(args) }); },
79
- 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 } }); },
114
+ async search(args = {}) { return request("GET", "/api/search", { query: searchQuery(args) }); },
80
115
  async createTicket(args = {}) { return request("POST", "/api/tickets", { body: { ...args, board: requireBoard(args) }, ok: [201] }); },
81
116
  async updateTicket({ ticket_id, ...patch }) { return request("PATCH", `/api/tickets/${encodeURIComponent(ticket_id)}`, { query: ticketQuery(patch), body: patch }); },
82
117
  async addComment({ ticket_id, ...body }) { return request("POST", `/api/tickets/${encodeURIComponent(ticket_id)}/comments`, { body, ok: [201] }); },
@@ -87,7 +122,11 @@ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.
87
122
  async restoreTicket(args = {}) { return request("POST", `/api/tickets/${encodeURIComponent(args.ticket_id)}/restore`, { query: ticketQuery(args) }); },
88
123
  async deleteTicket(args = {}) { return request("DELETE", `/api/tickets/${encodeURIComponent(args.ticket_id)}`, { query: ticketQuery(args) }); },
89
124
  async listArchive(args = {}) { return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/archive`); },
90
- async exportBoard(args = {}) { return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/export`); },
125
+ async exportBoard(args = {}) {
126
+ return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/export`, {
127
+ query: { include_attachments: args.include_attachments || args.include_images ? "true" : undefined }
128
+ });
129
+ },
91
130
  async updateSettings(args = {}) { const { board_id, board_slug, board, ...patch } = args; return request("PATCH", `/api/boards/${encodeURIComponent(requireBoard(args))}`, { body: patch }); }
92
131
  };
93
132
  }
@@ -100,45 +139,59 @@ export async function createLocalOrbitClient(env = process.env) {
100
139
  const seed = await import("../core/seed.js");
101
140
  const util = await import("../core/util.js");
102
141
 
142
+ const provision = await import("../core/provision-repo-board.js");
103
143
  let sessionBoardRow = null;
104
144
  const setSessionBoard = (row) => { sessionBoardRow = row; if (row) registry.touchBoardActive(row.id); };
105
145
  const getSessionBoard = () => sessionBoardRow;
106
146
  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; } };
107
- const createBoardAtRoot = (root) => {
108
- const dbPath = join(root, ".orbit", "board.db");
109
- mkdirSync(dirname(dbPath), { recursive: true });
110
- const db = dbMod.openConnection(dbPath);
111
- dbMod.createBoardSchema(db);
112
- const seeded = seed.seedIfEmpty(db, root);
113
- if (seeded) {
114
- const t = util.now();
115
- 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 });
116
- }
117
- const fresh = registry.getBoardByRepoPath(root);
118
- if (fresh) { registry.syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
119
- };
120
147
 
121
148
  const start = util.normalizePath(env.PROJECT_ROOT ? resolve(env.PROJECT_ROOT) : process.cwd());
122
- const boardRoot = walkUp(start, (dir) => existsSync(join(dir, ".orbit", "board.db")));
123
- if (boardRoot) {
124
- const root = util.normalizePath(boardRoot);
125
- const existing = registry.getBoardByRepoPath(root);
126
- if (existing) setSessionBoard(existing);
127
- else {
128
- const dbPath = join(boardRoot, ".orbit", "board.db");
129
- const db = dbMod.openConnection(dbPath);
130
- dbMod.createBoardSchema(db);
131
- const seeded = seed.seedIfEmpty(db, root);
132
- if (seeded) {
133
- const t = util.now();
134
- 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 });
149
+ const displayStart = resolve(env.PROJECT_ROOT ? env.PROJECT_ROOT : process.cwd());
150
+
151
+ // Find repo root (walk up for .git, fall back to start).
152
+ const gitRoot = walkUp(start, (dir) => existsSync(join(dir, ".git")));
153
+ const repoRoot = util.normalizePath(gitRoot || start);
154
+
155
+ // 1. Registry-first lookup: walk upward from start so we find boards regardless
156
+ // of whether they were registered at the git root, at PROJECT_ROOT, or at any
157
+ // ancestor (handles boards originally created from a subdirectory).
158
+ let foundByRegistry = false;
159
+ {
160
+ let dir = start;
161
+ while (true) {
162
+ const row = registry.getBoardByRepoPath(dir);
163
+ if (row && existsSync(row.db_path)) { setSessionBoard(row); foundByRegistry = true; break; }
164
+ const parent = util.normalizePath(dirname(dir));
165
+ if (parent === dir) break; // filesystem root
166
+ dir = parent;
167
+ }
168
+ }
169
+ if (!foundByRegistry) {
170
+ // 2. Legacy fallback: walk up for an existing in-repo .orbit/board.db.
171
+ const boardRoot = walkUp(start, (dir) => existsSync(join(dir, ".orbit", "board.db")));
172
+ if (boardRoot) {
173
+ const root = util.normalizePath(boardRoot);
174
+ const existing = registry.getBoardByRepoPath(root);
175
+ if (existing) {
176
+ setSessionBoard(existing);
177
+ } else {
178
+ const dbPath = join(boardRoot, ".orbit", "board.db");
179
+ const db = dbMod.openConnection(dbPath);
180
+ dbMod.createBoardSchema(db);
181
+ const seeded = seed.seedIfEmpty(db, root);
182
+ if (seeded) {
183
+ const t = util.now();
184
+ 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 });
185
+ }
186
+ const fresh = registry.getBoardByRepoPath(root);
187
+ if (fresh) { registry.syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
135
188
  }
136
- const fresh = registry.getBoardByRepoPath(root);
137
- if (fresh) { registry.syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
189
+ } else {
190
+ // 3. No board found — auto-create one (goes to central DATA_DIR/boards/).
191
+ provision.provisionRepoBoard(repoRoot, { enableAi: true });
192
+ const fresh = registry.getBoardByRepoPath(repoRoot);
193
+ if (fresh) setSessionBoard(fresh);
138
194
  }
139
- } else {
140
- const gitRoot = walkUp(start, (dir) => existsSync(join(dir, ".git")));
141
- createBoardAtRoot(util.normalizePath(gitRoot || start));
142
195
  }
143
196
 
144
197
  const ctxFor = (row, actor) => {
@@ -153,17 +206,22 @@ export async function createLocalOrbitClient(env = process.env) {
153
206
 
154
207
  return {
155
208
  mode: "local",
156
- sessionLabel: () => getSessionBoard()?.db_path || "(none)",
209
+ sessionLabel: () => {
210
+ const b = getSessionBoard();
211
+ return b ? `${displayStart} target: ${b.repo_path} db: ${b.db_path}` : "(none)";
212
+ },
213
+ close: () => dbMod.closeAllConnections(),
157
214
  boardContext: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getBoardContext(ctx.board.id, ctx, { includeStruck: Boolean(args.include_struck) }); },
158
215
  boardList: () => ({ boards: registry.listBoards().map((row) => ({ id: row.id, slug: row.slug, name: row.name, repo_path: row.repo_path })) }),
159
216
  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 }; },
160
217
  claimNext: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.claimNext(args, ctx)); },
161
- getTicketContext: (args = {}) => board.getContextPack(args.ticket_id, sessionCtx(actor()), Number(args.depth || 1)),
162
- readTicket: (args = {}) => board.readTicket(args, sessionCtx(actor())),
163
- readComments: (args = {}) => board.readComments(args, sessionCtx(actor())),
164
- getTicketRelations: (args = {}) => board.getTicketRelations(args.ticket_id, sessionCtx(actor())),
165
- getTicketBlockers: (args = {}) => board.getTicketBlockers(args.ticket_id, sessionCtx(actor())),
166
- search: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.searchTickets({ q: args.q, limit: args.limit }, ctx); },
218
+ getTicketContext: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getContextPack(args.ticket_id, ctx, Number(args.depth || 1), args); },
219
+ getAgentDispatchPacket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getAgentDispatchPacket(args.ticket_id, ctx, args); },
220
+ readTicket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.readTicket(args, ctx); },
221
+ readComments: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.readComments(args, ctx); },
222
+ getTicketRelations: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getTicketRelations(args.ticket_id, ctx); },
223
+ getTicketBlockers: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getTicketBlockers(args.ticket_id, ctx); },
224
+ search: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.searchTickets(args, ctx); },
167
225
  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)); },
168
226
  updateTicket: ({ ticket_id, ...patch }) => { const ctx = sessionCtx(actor()); return backup(ctx, board.updateTicket(ticket_id, patch, ctx)); },
169
227
  addComment: ({ ticket_id, ...body }) => { const ctx = sessionCtx(actor()); return backup(ctx, board.createComment(ticket_id, body, ctx)); },
@@ -174,7 +232,12 @@ export async function createLocalOrbitClient(env = process.env) {
174
232
  restoreTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.restoreTicket(args.ticket_id, ctx)); },
175
233
  deleteTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.deleteTicket(args.ticket_id, ctx)); },
176
234
  listArchive: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return { tickets: board.archivedTicketsForBoard(ctx.db, ctx.board.id) }; },
177
- exportBoard: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.exportBoard(ctx.board.id, ctx); },
235
+ exportBoard: (args = {}) => {
236
+ const ctx = ctxFor(rowOrSession(args), actor());
237
+ return board.exportBoard(ctx.board.id, ctx, {
238
+ includeAttachments: Boolean(args.include_attachments || args.include_images)
239
+ });
240
+ },
178
241
  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)); }
179
242
  };
180
243
  }
package/src/mcp-server.js CHANGED
@@ -5,6 +5,47 @@
5
5
  // "active board" state.
6
6
 
7
7
  import { createOrbitClient } from "./mcp/orbit-client.js";
8
+ import { existsSync, mkdirSync } from "node:fs";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import {
11
+ archivedTicketsForBoard,
12
+ archiveTicket,
13
+ checkpointTicket,
14
+ claimNext,
15
+ completeTicket,
16
+ createBoardEntry,
17
+ createComment,
18
+ createTicket,
19
+ deleteTicket,
20
+ exportBoard,
21
+ getBoardContext,
22
+ getAgentDispatchPacket,
23
+ getContextPack,
24
+ readComments,
25
+ readTicket,
26
+ getTicketBlockers,
27
+ getTicketRelations,
28
+ localAgentActor,
29
+ restoreTicket,
30
+ searchTickets,
31
+ updateBoard,
32
+ updateTicket
33
+ } from "./core/board.js";
34
+ import { closeAllConnections, createBoardSchema, openConnection } from "./core/db.js";
35
+ import {
36
+ getBoardByRegistryId,
37
+ getBoardBySlug,
38
+ getBoardByRepoPath,
39
+ insertBoard,
40
+ listBoards,
41
+ openBoardDb,
42
+ syncRegistryFromBoardDb,
43
+ touchBoardActive
44
+ } from "./core/registry.js";
45
+ import { scheduleAutomaticBoardBackup } from "./core/backups.js";
46
+ import { provisionRepoBoard } from "./core/provision-repo-board.js";
47
+ import { seedIfEmpty } from "./core/seed.js";
48
+ import { now, normalizePath } from "./core/util.js";
8
49
 
9
50
  const SERVER_INFO = {
10
51
  name: "minimal-agent-board",
@@ -12,6 +53,133 @@ const SERVER_INFO = {
12
53
  };
13
54
 
14
55
  const orbitClient = await createOrbitClient();
56
+ // Per-process session board. Set on init by walking up from cwd, mutable via
57
+ // `board_set_active`. NEVER read by anything outside this file — every
58
+ // downstream call receives an explicit ctx.
59
+ let sessionBoardRow = null;
60
+
61
+ function setSessionBoard(row) {
62
+ sessionBoardRow = row;
63
+ if (row) touchBoardActive(row.id);
64
+ }
65
+
66
+ function getSessionBoard() {
67
+ return sessionBoardRow;
68
+ }
69
+
70
+ /**
71
+ * Walk up from `startDir` toward the filesystem root, returning the first
72
+ * directory that satisfies `predicate`. Returns null if none found.
73
+ */
74
+ function walkUp(startDir, predicate) {
75
+ let dir = startDir;
76
+ while (true) {
77
+ if (predicate(dir)) return dir;
78
+ const parent = dirname(dir);
79
+ if (parent === dir) return null;
80
+ dir = parent;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Initialise the MCP session board. Resolution order:
86
+ * 1. Walk up from PROJECT_ROOT (cwd unless `orbit mcp --cwd` or env overrides it)
87
+ * for an existing `.orbit/board.db` — finds the board even when the agent
88
+ * is launched from a subdirectory or MCP config uses an absolute command.
89
+ * 2. Walk up from PROJECT_ROOT for a `.git/` directory — auto-creates a board at
90
+ * the git root on first launch.
91
+ * 3. Fall back to PROJECT_ROOT itself — supports non-git projects and global installs.
92
+ */
93
+ function initMcpSessionBoard() {
94
+ const start = normalizePath(process.env.PROJECT_ROOT ? resolve(process.env.PROJECT_ROOT) : process.cwd());
95
+
96
+ // 1. Prefer the registry row for PROJECT_ROOT (or one of its ancestors). New
97
+ // Orbit boards live in central DATA_DIR storage, so there may be no in-repo
98
+ // .orbit/board.db to discover.
99
+ const registeredRoot = walkUp(start, (dir) => Boolean(getBoardByRepoPath(normalizePath(dir))));
100
+ if (registeredRoot) {
101
+ setSessionBoard(getBoardByRepoPath(normalizePath(registeredRoot)));
102
+ return;
103
+ }
104
+
105
+ // 2. Walk up for an existing legacy board db.
106
+ const boardRoot = walkUp(start, (dir) => existsSync(join(dir, ".orbit", "board.db")));
107
+ if (boardRoot) {
108
+ const root = normalizePath(boardRoot);
109
+ const existing = getBoardByRepoPath(root);
110
+ if (existing) { setSessionBoard(existing); return; }
111
+ // db exists on disk but isn't in the registry yet — register it.
112
+ const dbPath = join(boardRoot, ".orbit", "board.db");
113
+ const db = openConnection(dbPath);
114
+ createBoardSchema(db);
115
+ const seeded = seedIfEmpty(db, root);
116
+ if (seeded) {
117
+ const t = now();
118
+ 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 });
119
+ }
120
+ const fresh = getBoardByRepoPath(root);
121
+ if (fresh) { syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
122
+ return;
123
+ }
124
+
125
+ // 3. Walk up for a git root; fall back to cwd for non-git projects.
126
+ const gitRoot = walkUp(start, (dir) => existsSync(join(dir, ".git")));
127
+ const result = provisionRepoBoard(normalizePath(gitRoot || start));
128
+ if (result.registryRow) setSessionBoard(result.registryRow);
129
+ }
130
+
131
+ function createBoardAtRoot(root) {
132
+ const dbPath = join(root, ".orbit", "board.db");
133
+ mkdirSync(dirname(dbPath), { recursive: true });
134
+ const db = openConnection(dbPath);
135
+ createBoardSchema(db);
136
+ const seeded = seedIfEmpty(db, root);
137
+ if (seeded) {
138
+ const t = now();
139
+ 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 });
140
+ }
141
+ const fresh = getBoardByRepoPath(root);
142
+ if (fresh) { syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
143
+ }
144
+
145
+ initMcpSessionBoard();
146
+ closeAllConnections();
147
+
148
+ /** Build a ctx for the session board (or a board the tool argument selected). */
149
+ function ctxFor(boardRow, actor) {
150
+ if (!boardRow) throw rpcError(-32602, "No board in session; pass board_id or board_slug, or call board_set_active first.");
151
+ return { actor, board: boardRow, db: openBoardDb(boardRow) };
152
+ }
153
+
154
+ function sessionCtx(actor) {
155
+ return ctxFor(getSessionBoard(), actor);
156
+ }
157
+
158
+ function withAutomaticBackup(ctx, result) {
159
+ scheduleAutomaticBoardBackup(ctx.board, ctx.db);
160
+ return result;
161
+ }
162
+
163
+ /** Resolve a registry row from explicit args (no session fallback). */
164
+ function resolveExplicitBoard(args) {
165
+ if (args.board_id) {
166
+ const row = getBoardByRegistryId(args.board_id);
167
+ if (!row) throw rpcError(-32004, "Board id not found.");
168
+ return row;
169
+ }
170
+ if (args.board_slug || args.board) {
171
+ const row = getBoardBySlug(args.board_slug || args.board);
172
+ if (!row) throw rpcError(-32004, "Board slug not found.");
173
+ return row;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /** Resolve a board for a tool that accepts board_id/board_slug AND can fall
179
+ * back to the session board. */
180
+ function resolveBoardOrSession(args) {
181
+ return resolveExplicitBoard(args) || getSessionBoard();
182
+ }
15
183
 
16
184
  const TOOL_DEFS = [
17
185
  {
@@ -75,28 +243,56 @@ const TOOL_DEFS = [
75
243
  {
76
244
  name: "board_get_ticket_context",
77
245
  description:
78
- "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.",
246
+ "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.",
79
247
  inputSchema: {
80
248
  type: "object",
81
249
  properties: {
82
250
  ticket_id: { type: "string" },
83
- depth: { type: "integer", minimum: 1, maximum: 3 }
251
+ board_id: { type: "string" },
252
+ board_slug: { type: "string" },
253
+ board: { type: "string" },
254
+ depth: { type: "integer", minimum: 1, maximum: 3 },
255
+ max_chars_per_field: { type: "integer", minimum: 1 },
256
+ include_parent_full: { type: "boolean" },
257
+ include_related_full: { type: "boolean" }
84
258
  },
85
259
  required: ["ticket_id"],
86
260
  additionalProperties: false
87
261
  },
88
262
  handler: (args) => orbitClient.getTicketContext(args)
89
263
  },
264
+ {
265
+ name: "board_get_agent_dispatch_packet",
266
+ description:
267
+ "Return a lean agent dispatch packet for one ticket: board identity/instructions, capped ticket description/acceptance/AI plan, blockers, shallow parent, relevant workflow state IDs, repo path/default branch, recent capped comments, and label names. Parent/related/implementation bodies are intentionally omitted.",
268
+ inputSchema: {
269
+ type: "object",
270
+ properties: {
271
+ ticket_id: { type: "string" },
272
+ board_id: { type: "string" },
273
+ board_slug: { type: "string" },
274
+ board: { type: "string" },
275
+ max_chars_per_field: { type: "integer", minimum: 1 },
276
+ comment_limit: { type: "integer", minimum: 0, maximum: 20 }
277
+ },
278
+ required: ["ticket_id"],
279
+ additionalProperties: false
280
+ },
281
+ handler: (args) => orbitClient.getAgentDispatchPacket(args)
282
+ },
90
283
  {
91
284
  name: "board_read_ticket",
92
285
  description:
93
- "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).",
286
+ "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.",
94
287
  inputSchema: {
95
288
  type: "object",
96
289
  properties: {
97
290
  ticket_id: { type: "string" },
98
291
  number: { type: "integer" },
99
- title: { type: "string" }
292
+ title: { type: "string" },
293
+ board_id: { type: "string" },
294
+ board_slug: { type: "string" },
295
+ board: { type: "string" }
100
296
  },
101
297
  additionalProperties: false
102
298
  },
@@ -111,7 +307,10 @@ const TOOL_DEFS = [
111
307
  properties: {
112
308
  ticket_id: { type: "string" },
113
309
  number: { type: "integer" },
114
- title: { type: "string" }
310
+ title: { type: "string" },
311
+ board_id: { type: "string" },
312
+ board_slug: { type: "string" },
313
+ board: { type: "string" }
115
314
  },
116
315
  additionalProperties: false
117
316
  },
@@ -123,7 +322,10 @@ const TOOL_DEFS = [
123
322
  inputSchema: {
124
323
  type: "object",
125
324
  properties: {
126
- ticket_id: { type: "string" }
325
+ ticket_id: { type: "string" },
326
+ board_id: { type: "string" },
327
+ board_slug: { type: "string" },
328
+ board: { type: "string" }
127
329
  },
128
330
  required: ["ticket_id"],
129
331
  additionalProperties: false
@@ -136,7 +338,10 @@ const TOOL_DEFS = [
136
338
  inputSchema: {
137
339
  type: "object",
138
340
  properties: {
139
- ticket_id: { type: "string" }
341
+ ticket_id: { type: "string" },
342
+ board_id: { type: "string" },
343
+ board_slug: { type: "string" },
344
+ board: { type: "string" }
140
345
  },
141
346
  required: ["ticket_id"],
142
347
  additionalProperties: false
@@ -154,7 +359,11 @@ const TOOL_DEFS = [
154
359
  board: { type: "string" },
155
360
  board_slug: { type: "string" },
156
361
  board_id: { type: "string" },
157
- limit: { type: "integer", minimum: 1, maximum: 50 }
362
+ limit: { type: "integer", minimum: 1, maximum: 50 },
363
+ mode: { type: "string", enum: ["ids", "summary", "full"] },
364
+ include_full: { type: "boolean" },
365
+ fields: { type: "array", items: { type: "string" } },
366
+ max_chars_per_field: { type: "integer", minimum: 1 }
158
367
  },
159
368
  required: ["q"],
160
369
  additionalProperties: false
@@ -332,7 +541,9 @@ const TOOL_DEFS = [
332
541
  type: "object",
333
542
  properties: {
334
543
  board_id: { type: "string" },
335
- board_slug: { type: "string" }
544
+ board_slug: { type: "string" },
545
+ include_attachments: { type: "boolean", description: "Embed attached image bytes in the JSON snapshot." },
546
+ include_images: { type: "boolean", description: "Alias for include_attachments." }
336
547
  },
337
548
  additionalProperties: false
338
549
  },
@@ -341,7 +552,7 @@ const TOOL_DEFS = [
341
552
  {
342
553
  name: "board_update_settings",
343
554
  description:
344
- "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.",
555
+ "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).",
345
556
  inputSchema: {
346
557
  type: "object",
347
558
  properties: {
@@ -364,13 +575,21 @@ const tools = new Map(TOOL_DEFS.map((tool) => [tool.name, tool]));
364
575
 
365
576
  let inputBuffer = Buffer.alloc(0);
366
577
  let expectedBodyLength = -1;
578
+ let shuttingDown = false;
579
+
580
+ function shutdown(code = 0) {
581
+ if (shuttingDown) return;
582
+ shuttingDown = true;
583
+ orbitClient.close?.();
584
+ process.exit(code);
585
+ }
367
586
 
368
587
  process.stdin.on("data", (chunk) => {
369
588
  inputBuffer = Buffer.concat([inputBuffer, chunk]);
370
589
  drainInput();
371
590
  });
372
591
 
373
- process.stdin.on("end", () => process.exit(0));
592
+ process.stdin.on("end", () => shutdown(0));
374
593
  process.stdin.resume();
375
594
 
376
595
  function drainInput() {
@@ -449,6 +668,8 @@ async function handleMessage(body, transport = "content-length") {
449
668
  }, transport);
450
669
  } catch (error) {
451
670
  writeMessage(errorResponse(message.id, error.code || -32000, error.message || "Internal error."), transport);
671
+ } finally {
672
+ orbitClient.close?.();
452
673
  }
453
674
  }
454
675
 
@@ -544,6 +765,11 @@ process.on("unhandledRejection", (error) => {
544
765
  writeMessage(errorResponse(null, -32000, error?.message || "Unhandled rejection."));
545
766
  });
546
767
 
768
+ process.once("SIGINT", () => shutdown(0));
769
+ process.once("SIGTERM", () => shutdown(0));
770
+ process.once("exit", () => orbitClient.close?.());
771
+
547
772
  if (process.env.MAB_MCP_STDERR_LOG === "1") {
548
- console.error(`Starscape Orbit MCP ready. Mode: ${orbitClient.mode}. Target: ${typeof orbitClient.sessionLabel === "function" ? orbitClient.sessionLabel() : orbitClient.sessionLabel}`);
773
+ const target = typeof orbitClient.sessionLabel === "function" ? orbitClient.sessionLabel() : orbitClient.sessionLabel;
774
+ console.error(`Starscape Orbit MCP ready. Mode: ${orbitClient.mode}. Target: ${target}`);
549
775
  }
package/src/server.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  startSSEStream,
9
9
  startupSummary
10
10
  } from "./core/board.js";
11
+ import { closeAllConnections } from "./core/db.js";
11
12
 
12
13
  const server = createServer(async (req, res) => {
13
14
  try {
@@ -46,3 +47,22 @@ server.listen(boardRuntime.port, () => {
46
47
  console.log(`Boards (${summary.boardCount}):`);
47
48
  for (const path of summary.boardPaths) console.log(` - ${path}`);
48
49
  });
50
+
51
+ let shuttingDown = false;
52
+
53
+ function shutdown() {
54
+ if (shuttingDown) return;
55
+ shuttingDown = true;
56
+ server.close(() => {
57
+ closeAllConnections();
58
+ process.exit(0);
59
+ });
60
+ setTimeout(() => {
61
+ closeAllConnections();
62
+ process.exit(0);
63
+ }, 1500).unref();
64
+ }
65
+
66
+ process.once("SIGINT", shutdown);
67
+ process.once("SIGTERM", shutdown);
68
+ process.once("exit", () => closeAllConnections());