@starlove/orbit 1.0.5 → 1.0.7

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 (94) hide show
  1. package/.dockerignore +0 -0
  2. package/Dockerfile +0 -0
  3. package/LICENSE +0 -0
  4. package/README.md +8 -0
  5. package/SKILL-ORBIT.md +2 -0
  6. package/build.mjs +2 -2
  7. package/dist/full/apple-touch-icon.png +0 -0
  8. package/dist/full/cursors/live_arrow.cur +0 -0
  9. package/dist/full/cursors/live_link.cur +0 -0
  10. package/dist/full/cursors/live_move.cur +0 -0
  11. package/dist/full/cursors/live_select.cur +0 -0
  12. package/dist/full/cursors/live_unavail.cur +0 -0
  13. package/dist/full/favicon-32x32.png +0 -0
  14. package/dist/full/index.html +2 -2
  15. package/dist/full/js/app.f4fd5e94.js +604 -0
  16. package/dist/full/js/app.f4fd5e94.js.map +7 -0
  17. package/dist/full/orbits-favicon.png +0 -0
  18. package/dist/full/orbits-logo-bg.png +0 -0
  19. package/dist/full/orbits-logo.png +0 -0
  20. package/dist/full/{styles.c2e3d20a.css → styles.14e9dbbd.css} +295 -22
  21. package/dist/full/version.json +2 -2
  22. package/docs/AGENT_PROTOCOL.md +4 -0
  23. package/docs/MCP_SETUP.md +3 -1
  24. package/docs/SS-Orbit-Dark.png +0 -0
  25. package/docs/SS-Orbit-Light.png +0 -0
  26. package/docs/mcp-client.example.json +0 -0
  27. package/package.json +2 -2
  28. package/public/apple-touch-icon.png +0 -0
  29. package/public/cursors/live_arrow.cur +0 -0
  30. package/public/cursors/live_link.cur +0 -0
  31. package/public/cursors/live_move.cur +0 -0
  32. package/public/cursors/live_select.cur +0 -0
  33. package/public/cursors/live_unavail.cur +0 -0
  34. package/public/favicon-32x32.png +0 -0
  35. package/public/index.html +0 -0
  36. package/public/js/api.js +5 -1
  37. package/public/js/app.js +0 -0
  38. package/public/js/board-menu.js +0 -0
  39. package/public/js/config.js +0 -0
  40. package/public/js/create-card.js +0 -0
  41. package/public/js/dom.js +0 -0
  42. package/public/js/drawer.js +2 -0
  43. package/public/js/format.js +142 -0
  44. package/public/js/kanban.js +18 -2
  45. package/public/js/local-backend.js +29 -2
  46. package/public/js/main.js +14 -6
  47. package/public/js/modal.js +0 -0
  48. package/public/js/motion-preference.js +53 -0
  49. package/public/js/router.js +75 -56
  50. package/public/js/search.js +0 -0
  51. package/public/js/settings-tabs.js +1 -1
  52. package/public/js/settings.js +96 -3
  53. package/public/js/sse.js +0 -0
  54. package/public/js/starscape.js +25 -18
  55. package/public/js/state.js +11 -2
  56. package/public/js/theme-preference.js +88 -0
  57. package/public/js/ticket-detail.js +6 -3
  58. package/public/js/toast.js +0 -0
  59. package/public/js/transport.js +0 -0
  60. package/public/js/unread.js +0 -0
  61. package/public/js/url-routes.js +94 -0
  62. package/public/orbits-favicon.png +0 -0
  63. package/public/orbits-logo-bg.png +0 -0
  64. package/public/orbits-logo.png +0 -0
  65. package/public/styles.css +295 -22
  66. package/src/core/agent.js +10 -4
  67. package/src/core/agents-md.js +1 -0
  68. package/src/core/auth.js +0 -0
  69. package/src/core/backups.js +0 -0
  70. package/src/core/board.js +1 -1
  71. package/src/core/boards.js +0 -0
  72. package/src/core/bootstrap.js +10 -2
  73. package/src/core/db.js +0 -0
  74. package/src/core/delete-board.js +0 -0
  75. package/src/core/events.js +0 -0
  76. package/src/core/http.js +0 -0
  77. package/src/core/paths.js +0 -0
  78. package/src/core/provision-repo-board.js +0 -0
  79. package/src/core/queries.js +0 -0
  80. package/src/core/registry.js +0 -0
  81. package/src/core/relations.js +0 -0
  82. package/src/core/router.js +1 -1
  83. package/src/core/search.js +62 -23
  84. package/src/core/seed.js +0 -0
  85. package/src/core/snapshots.js +0 -0
  86. package/src/core/states.js +0 -0
  87. package/src/core/system-picker.js +0 -0
  88. package/src/core/tickets.js +28 -3
  89. package/src/core/util.js +0 -0
  90. package/src/mcp/orbit-client.js +180 -0
  91. package/src/mcp-server.js +50 -274
  92. package/src/server.js +0 -0
  93. package/dist/full/js/app.7e25f59b.js +0 -554
  94. package/dist/full/js/app.7e25f59b.js.map +0 -7
package/src/core/agent.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  requiredString
25
25
  } from "./util.js";
26
26
  import { boardManual } from "./boards.js";
27
- import { reindexTicket } from "./tickets.js";
27
+ import { bumpTicketUpdatedAt, reindexTicket } from "./tickets.js";
28
28
 
29
29
  /**
30
30
  * Claim the next AI-ready ticket on the resolved board. The router/MCP layer
@@ -71,14 +71,16 @@ export function claimNext(body, ctx) {
71
71
  if (!inProgress) throw httpError(400, "missing_in_progress_state");
72
72
 
73
73
  tx(db, () => {
74
+ const time = now();
74
75
  db.prepare("UPDATE tickets SET state_id = ?, updated_at = ? WHERE id = ?").run(
75
76
  inProgress.id,
76
- now(),
77
+ time,
77
78
  candidate.id
78
79
  );
79
80
  addComment(db, candidate.id, actor.name, "agent_note", "Agent claimed this ticket and is starting work.");
80
81
  recordEvent(db, candidate.board_id, "agent_claimed", candidate.id, actor.name, { agent_id: actor.id });
81
82
  reindexTicket(db, candidate.id);
83
+ bumpTicketUpdatedAt(db, candidate.parent_ticket_id, time);
82
84
  });
83
85
 
84
86
  return {
@@ -157,7 +159,8 @@ export function checkpointTicket(body, ctx) {
157
159
  if (!review) throw httpError(400, "missing_review_state");
158
160
  const message = requiredString(body.message, "message");
159
161
  tx(db, () => {
160
- db.prepare("UPDATE tickets SET state_id = ?, updated_at = ? WHERE id = ?").run(review.id, now(), ticket.id);
162
+ const time = now();
163
+ db.prepare("UPDATE tickets SET state_id = ?, updated_at = ? WHERE id = ?").run(review.id, time, ticket.id);
161
164
  addComment(db, ticket.id, actor.name, "checkpoint", message);
162
165
  recordEvent(db, ticket.board_id, "checkpoint_requested", ticket.id, actor.name, {
163
166
  from: ticket.state_name,
@@ -167,6 +170,7 @@ export function checkpointTicket(body, ctx) {
167
170
  actor_id: actor.id
168
171
  });
169
172
  reindexTicket(db, ticket.id);
173
+ bumpTicketUpdatedAt(db, ticket.parent_ticket_id, time);
170
174
  });
171
175
  return getContextPack(ticket.id, ctx, 1);
172
176
  }
@@ -190,10 +194,11 @@ export function completeTicket(body, ctx) {
190
194
  if (body.pr_url) lines.push(`PR: ${body.pr_url}`);
191
195
  const updates = body.updates ? appendFieldNote(ticket.implementation_updates, body.updates, actor.name) : ticket.implementation_updates;
192
196
  tx(db, () => {
197
+ const time = now();
193
198
  addComment(db, ticket.id, actor.name, "completion", lines.join("\n\n"));
194
199
  db.prepare(
195
200
  "UPDATE tickets SET state_id = ?, implementation_summary = ?, implementation_updates = ?, updated_at = ? WHERE id = ?"
196
- ).run(nextState.id, summary, updates, now(), ticket.id);
201
+ ).run(nextState.id, summary, updates, time, ticket.id);
197
202
  recordEvent(db, ticket.board_id, "agent_completed", ticket.id, actor.name, {
198
203
  from: ticket.state_name,
199
204
  next_state: nextState.name,
@@ -203,6 +208,7 @@ export function completeTicket(body, ctx) {
203
208
  actor_id: actor.id
204
209
  });
205
210
  reindexTicket(db, ticket.id);
211
+ bumpTicketUpdatedAt(db, ticket.parent_ticket_id, time);
206
212
  });
207
213
  return getContextPack(ticket.id, ctx, 1);
208
214
  }
@@ -11,6 +11,7 @@ export function orbitAgentsSection() {
11
11
  ${ORBIT_AGENTS_CANONICAL_LINE}
12
12
 
13
13
  When work mentions Orbit, kanban, board, lane, ticket, card, epic, blocker, claim, AI Ready, implementation fields, planning state, project memory, or handoff: read \`SKILL-ORBIT.md\` first and follow it.
14
+ Use Orbit API/MCP tools for tickets/cards; do not edit .orbit/board.db directly.
14
15
  ${ORBIT_AGENTS_END}\n`;
15
16
  }
16
17
 
package/src/core/auth.js CHANGED
File without changes
File without changes
package/src/core/board.js CHANGED
@@ -100,7 +100,7 @@ export {
100
100
  updateBoardEntry,
101
101
  boardManual
102
102
  } from "./boards.js";
103
- export { archiveTicket, createComment, deleteTicket, restoreTicket, updateTicket } from "./tickets.js";
103
+ export { archiveTicket, createComment, createTicket, deleteTicket, restoreTicket, updateTicket } from "./tickets.js";
104
104
  export { archivedTicketsForBoard } from "./queries.js";
105
105
  export { getTicketBlockers, getTicketRelations } from "./relations.js";
106
106
  export { checkpointTicket, claimNext, completeTicket, getContextPack, readComments, readTicket } from "./agent.js";
File without changes
@@ -33,8 +33,9 @@ function boardSummaryFromRegistry(r) {
33
33
  *
34
34
  * @param actor authenticated actor
35
35
  * @param requestedBoardId optional board id from `?board_id=` (or null)
36
+ * @param requestedBoardSlug optional board slug from `?board=` (or null)
36
37
  */
37
- export function getBootstrap(actor, requestedBoardId = null) {
38
+ export function getBootstrap(actor, requestedBoardId = null, requestedBoardSlug = null) {
38
39
  const registryRows = listBoards();
39
40
  const accessible = registryRows.filter((row) => canAccessBoard(actor, { slug: row.slug }));
40
41
 
@@ -57,6 +58,9 @@ export function getBootstrap(actor, requestedBoardId = null) {
57
58
  if (requestedBoardId) {
58
59
  chosen = accessible.find((row) => row.id === requestedBoardId) || null;
59
60
  }
61
+ if (!chosen && requestedBoardSlug) {
62
+ chosen = accessible.find((row) => row.slug === requestedBoardSlug) || null;
63
+ }
60
64
  if (!chosen) {
61
65
  const defaultRow = pickDefaultBoard();
62
66
  if (defaultRow && accessible.some((row) => row.id === defaultRow.id)) {
@@ -105,7 +109,7 @@ export function getBootstrap(actor, requestedBoardId = null) {
105
109
  JOIN states s ON s.id = t.state_id
106
110
  JOIN boards b ON b.id = t.board_id
107
111
  WHERE t.archived_at IS NULL
108
- ORDER BY t.updated_at DESC`
112
+ ORDER BY t.updated_at DESC, t.id ASC`
109
113
  )
110
114
  .all()
111
115
  .filter((ticket) => boardIds.has(ticket.board_id));
@@ -143,6 +147,10 @@ export function getBootstrap(actor, requestedBoardId = null) {
143
147
 
144
148
  return {
145
149
  actor: publicActor(actor),
150
+ // The server may choose a default board that is not first in the registry's
151
+ // alphabetic board list. Expose that choice explicitly so first-page load
152
+ // selects the same board whose lanes/tickets are included below.
153
+ active_board_id: chosenRow.id,
146
154
  meta: {
147
155
  db_path: chosenRow.db_path,
148
156
  export_dir: EXPORT_DIR
package/src/core/db.js CHANGED
File without changes
File without changes
File without changes
package/src/core/http.js CHANGED
File without changes
package/src/core/paths.js CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -107,7 +107,7 @@ export async function handleApi(req, res, url) {
107
107
 
108
108
  // Bootstrap: read-only registry + one chosen board. No global state mutated.
109
109
  if (req.method === "GET" && url.pathname === "/api/bootstrap") {
110
- sendJson(res, 200, getBootstrap(actor, url.searchParams.get("board_id")));
110
+ sendJson(res, 200, getBootstrap(actor, url.searchParams.get("board_id"), url.searchParams.get("board")));
111
111
  return;
112
112
  }
113
113
 
@@ -16,41 +16,73 @@ export function searchTickets(args, ctx) {
16
16
  if (!q) return { query: q, results: [] };
17
17
  if (!canAccessBoard(actor, board)) return { query: q, results: [] };
18
18
 
19
+ const ticketNumber = ticketNumberQuery(q);
19
20
  const fts = toFtsQuery(q);
20
21
  let rows = [];
21
- try {
22
+
23
+ if (ticketNumber !== null) {
22
24
  rows = db
23
25
  .prepare(
24
- `SELECT t.*, s.name AS state_name, b.slug AS board_slug,
25
- bm25(ticket_fts) AS rank
26
- FROM ticket_fts
27
- JOIN tickets t ON t.id = ticket_fts.ticket_id
26
+ `SELECT t.*, s.name AS state_name, b.slug AS board_slug, -1 AS rank
27
+ FROM tickets t
28
28
  JOIN states s ON s.id = t.state_id
29
29
  JOIN boards b ON b.id = t.board_id
30
- WHERE ticket_fts MATCH ? AND t.archived_at IS NULL
31
- ORDER BY rank
30
+ WHERE t.board_id = ? AND t.number = ? AND t.archived_at IS NULL
32
31
  LIMIT ?`
33
32
  )
34
- .all(fts, limit);
33
+ .all(board.id, ticketNumber, limit);
34
+ }
35
+
36
+ try {
37
+ rows = rows.concat(
38
+ db
39
+ .prepare(
40
+ `SELECT t.*, s.name AS state_name, b.slug AS board_slug,
41
+ bm25(ticket_fts) AS rank
42
+ FROM ticket_fts
43
+ JOIN tickets t ON t.id = ticket_fts.ticket_id
44
+ JOIN states s ON s.id = t.state_id
45
+ JOIN boards b ON b.id = t.board_id
46
+ WHERE ticket_fts MATCH ? AND t.board_id = ? AND t.archived_at IS NULL
47
+ ORDER BY rank
48
+ LIMIT ?`
49
+ )
50
+ .all(fts, board.id, limit)
51
+ );
35
52
  } catch {
36
53
  const like = `%${q}%`;
37
- rows = db
38
- .prepare(
39
- `SELECT DISTINCT t.*, s.name AS state_name, b.slug AS board_slug, 0 AS rank
40
- FROM tickets t
41
- JOIN states s ON s.id = t.state_id
42
- JOIN boards b ON b.id = t.board_id
43
- LEFT JOIN comments c ON c.ticket_id = t.id
44
- WHERE t.archived_at IS NULL
45
- AND (t.title LIKE ? OR t.description LIKE ? OR t.ai_plan LIKE ?
46
- OR t.implementation_summary LIKE ? OR t.implementation_updates LIKE ? OR c.body LIKE ?)
47
- ORDER BY t.updated_at DESC
48
- LIMIT ?`
49
- )
50
- .all(like, like, like, like, like, like, limit);
54
+ const numberRank = ticketNumber !== null ? "CASE WHEN t.number = ? THEN -1 ELSE 0 END" : "0";
55
+ const numberPredicate = ticketNumber !== null ? "OR t.number = ?" : "";
56
+ const fallbackArgs = ticketNumber !== null ? [ticketNumber, board.id] : [board.id];
57
+ fallbackArgs.push(like, like, like, like, like, like);
58
+ if (ticketNumber !== null) fallbackArgs.push(ticketNumber);
59
+ fallbackArgs.push(limit);
60
+
61
+ rows = rows.concat(
62
+ db
63
+ .prepare(
64
+ `SELECT DISTINCT t.*, s.name AS state_name, b.slug AS board_slug, ${numberRank} AS rank
65
+ FROM tickets t
66
+ JOIN states s ON s.id = t.state_id
67
+ JOIN boards b ON b.id = t.board_id
68
+ LEFT JOIN comments c ON c.ticket_id = t.id
69
+ WHERE t.board_id = ? AND t.archived_at IS NULL
70
+ AND (t.title LIKE ? OR t.description LIKE ? OR t.ai_plan LIKE ?
71
+ OR t.implementation_summary LIKE ? OR t.implementation_updates LIKE ? OR c.body LIKE ?
72
+ ${numberPredicate})
73
+ ORDER BY t.updated_at DESC, t.id ASC
74
+ LIMIT ?`
75
+ )
76
+ .all(...fallbackArgs)
77
+ );
51
78
  }
52
79
 
53
- rows.sort((a, b) => (a.rank !== b.rank ? a.rank - b.rank : String(b.updated_at).localeCompare(String(a.updated_at))));
80
+ rows.sort((a, b) => {
81
+ if (a.rank !== b.rank) return a.rank - b.rank;
82
+ const byTime = String(b.updated_at).localeCompare(String(a.updated_at));
83
+ if (byTime !== 0) return byTime;
84
+ return String(a.id).localeCompare(String(b.id));
85
+ });
54
86
 
55
87
  const seen = new Set();
56
88
  const deduped = [];
@@ -68,3 +100,10 @@ export function searchTickets(args, ctx) {
68
100
 
69
101
  return { query: q, results };
70
102
  }
103
+
104
+ function ticketNumberQuery(q) {
105
+ const match = String(q).trim().match(/^#?(\d+)$/);
106
+ if (!match) return null;
107
+ const number = Number(match[1]);
108
+ return Number.isSafeInteger(number) ? number : null;
109
+ }
package/src/core/seed.js CHANGED
File without changes
File without changes
File without changes
File without changes
@@ -17,6 +17,17 @@ import {
17
17
  requiredString
18
18
  } from "./util.js";
19
19
 
20
+ /**
21
+ * Bump a ticket's `updated_at` without recording an event or touching other
22
+ * fields. Used to roll child activity up to the parent epic so the column sort
23
+ * (ORDER BY updated_at DESC) keeps the epic group near recent activity even
24
+ * when only its children changed. Pass `null`/undefined to no-op.
25
+ */
26
+ export function bumpTicketUpdatedAt(db, ticketId, time) {
27
+ if (!ticketId) return;
28
+ db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(time, ticketId);
29
+ }
30
+
20
31
  export function createTicket(body, ctx) {
21
32
  const { db, board, actor } = ctx;
22
33
  // The router resolves `ctx.board` from path/body/query before we get here.
@@ -82,6 +93,7 @@ export function createTicket(body, ctx) {
82
93
  parent_ticket_id: parentTicketId
83
94
  });
84
95
  reindexTicket(db, ticketId);
96
+ bumpTicketUpdatedAt(db, parentTicketId, time);
85
97
  return ticketById(db, ticketId);
86
98
  });
87
99
  }
@@ -139,11 +151,12 @@ export function updateTicket(ticketId, body, ctx) {
139
151
  }
140
152
 
141
153
  return tx(db, () => {
154
+ const time = now();
142
155
  if (fields.length > 0) {
143
156
  const sets = fields.map((field) => `${field} = ?`);
144
157
  const values = fields.map((field) => applied[field]);
145
158
  sets.push("updated_at = ?");
146
- values.push(now(), ticketId);
159
+ values.push(time, ticketId);
147
160
  db.prepare(`UPDATE tickets SET ${sets.join(", ")} WHERE id = ?`).run(...values);
148
161
  }
149
162
 
@@ -160,7 +173,7 @@ export function updateTicket(ticketId, body, ctx) {
160
173
  db.prepare("INSERT OR IGNORE INTO ticket_labels (ticket_id, label_id) VALUES (?, ?)").run(ticketId, label.id);
161
174
  }
162
175
  if (fields.length === 0) {
163
- db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(now(), ticketId);
176
+ db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(time, ticketId);
164
177
  }
165
178
  }
166
179
 
@@ -191,6 +204,13 @@ export function updateTicket(ticketId, body, ctx) {
191
204
  }
192
205
  }
193
206
  reindexTicket(db, ticketId);
207
+ // Roll activity up to the parent epic so the column sort keeps the epic
208
+ // group near recent child activity. On reparent, bump both old and new
209
+ // parent — both views changed (one lost a child, the other gained one).
210
+ bumpTicketUpdatedAt(db, before.parent_ticket_id, time);
211
+ if (after.parent_ticket_id && after.parent_ticket_id !== before.parent_ticket_id) {
212
+ bumpTicketUpdatedAt(db, after.parent_ticket_id, time);
213
+ }
194
214
  return after;
195
215
  });
196
216
  }
@@ -208,6 +228,7 @@ export function archiveTicket(ticketId, ctx) {
208
228
  const time = now();
209
229
  db.prepare("UPDATE tickets SET archived_at = ?, updated_at = ? WHERE id = ?").run(time, time, ticketId);
210
230
  recordEvent(db, ticket.board_id, "ticket_archived", ticketId, actor.name, { title: ticket.title });
231
+ bumpTicketUpdatedAt(db, ticket.parent_ticket_id, time);
211
232
  return ticketById(db, ticketId);
212
233
  });
213
234
  }
@@ -225,6 +246,7 @@ export function restoreTicket(ticketId, ctx) {
225
246
  const time = now();
226
247
  db.prepare("UPDATE tickets SET archived_at = NULL, updated_at = ? WHERE id = ?").run(time, ticketId);
227
248
  recordEvent(db, ticket.board_id, "ticket_restored", ticketId, actor.name, { title: ticket.title });
249
+ bumpTicketUpdatedAt(db, ticket.parent_ticket_id, time);
228
250
  return ticketById(db, ticketId);
229
251
  });
230
252
  }
@@ -246,6 +268,7 @@ export function deleteTicket(ticketId, ctx) {
246
268
  });
247
269
  db.prepare("DELETE FROM ticket_fts WHERE ticket_id = ?").run(ticketId);
248
270
  db.prepare("DELETE FROM tickets WHERE id = ?").run(ticketId);
271
+ bumpTicketUpdatedAt(db, ticket.parent_ticket_id, now());
249
272
  return { ok: true, deleted_id: ticketId };
250
273
  });
251
274
  }
@@ -262,10 +285,12 @@ export function createComment(ticketId, body, ctx) {
262
285
  const kind = body.kind || (actor.type === "agent" ? "agent_note" : "human_comment");
263
286
 
264
287
  return tx(db, () => {
288
+ const time = now();
265
289
  const comment = addComment(db, ticketId, author, kind, commentBody);
266
- db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(now(), ticketId);
290
+ db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(time, ticketId);
267
291
  recordEvent(db, ticket.board_id, "comment_created", ticketId, actor.name, { kind: comment.kind });
268
292
  reindexTicket(db, ticketId);
293
+ bumpTicketUpdatedAt(db, ticket.parent_ticket_id, time);
269
294
  return comment;
270
295
  });
271
296
  }
package/src/core/util.js CHANGED
File without changes
@@ -0,0 +1,180 @@
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
+ async boardContext(args = {}) {
65
+ return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/context`, { query: { include_struck: args.include_struck ? "true" : undefined } });
66
+ },
67
+ async boardList() { return request("GET", "/api/boards"); },
68
+ 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
+ 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 } }); },
71
+ 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;
75
+ },
76
+ async readComments(args = {}) { const ctx = await this.readTicket(args); return { comments: ctx.comments || ctx.ticket?.comments || [] }; },
77
+ async getTicketRelations(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/relations`, { query: ticketQuery(args) }); },
78
+ 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 } }); },
80
+ async createTicket(args = {}) { return request("POST", "/api/tickets", { body: { ...args, board: requireBoard(args) }, ok: [201] }); },
81
+ async updateTicket({ ticket_id, ...patch }) { return request("PATCH", `/api/tickets/${encodeURIComponent(ticket_id)}`, { query: ticketQuery(patch), body: patch }); },
82
+ async addComment({ ticket_id, ...body }) { return request("POST", `/api/tickets/${encodeURIComponent(ticket_id)}/comments`, { body, ok: [201] }); },
83
+ async addBoardEntry(args = {}) { return request("POST", `/api/boards/${encodeURIComponent(requireBoard(args))}/entries`, { body: args, ok: [201] }); },
84
+ async checkpoint(args = {}) { return request("POST", "/api/agent/checkpoint", { body: args }); },
85
+ async complete(args = {}) { return request("POST", "/api/agent/complete", { body: args }); },
86
+ async archiveTicket(args = {}) { return request("POST", `/api/tickets/${encodeURIComponent(args.ticket_id)}/archive`, { query: ticketQuery(args) }); },
87
+ async restoreTicket(args = {}) { return request("POST", `/api/tickets/${encodeURIComponent(args.ticket_id)}/restore`, { query: ticketQuery(args) }); },
88
+ async deleteTicket(args = {}) { return request("DELETE", `/api/tickets/${encodeURIComponent(args.ticket_id)}`, { query: ticketQuery(args) }); },
89
+ 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`); },
91
+ async updateSettings(args = {}) { const { board_id, board_slug, board, ...patch } = args; return request("PATCH", `/api/boards/${encodeURIComponent(requireBoard(args))}`, { body: patch }); }
92
+ };
93
+ }
94
+
95
+ export async function createLocalOrbitClient(env = process.env) {
96
+ const board = await import("../core/board.js");
97
+ const dbMod = await import("../core/db.js");
98
+ const registry = await import("../core/registry.js");
99
+ const backups = await import("../core/backups.js");
100
+ const seed = await import("../core/seed.js");
101
+ const util = await import("../core/util.js");
102
+
103
+ let sessionBoardRow = null;
104
+ const setSessionBoard = (row) => { sessionBoardRow = row; if (row) registry.touchBoardActive(row.id); };
105
+ const getSessionBoard = () => sessionBoardRow;
106
+ 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
+
121
+ 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 });
135
+ }
136
+ const fresh = registry.getBoardByRepoPath(root);
137
+ if (fresh) { registry.syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
138
+ }
139
+ } else {
140
+ const gitRoot = walkUp(start, (dir) => existsSync(join(dir, ".git")));
141
+ createBoardAtRoot(util.normalizePath(gitRoot || start));
142
+ }
143
+
144
+ const ctxFor = (row, actor) => {
145
+ if (!row) throw rpcError(-32602, "No board in session; pass board_id or board_slug, or call board_set_active first.");
146
+ return { actor, board: row, db: registry.openBoardDb(row) };
147
+ };
148
+ const sessionCtx = (actor) => ctxFor(getSessionBoard(), actor);
149
+ const backup = (ctx, result) => { backups.scheduleAutomaticBoardBackup(ctx.board, ctx.db); return result; };
150
+ const explicit = (args = {}) => args.board_id ? registry.getBoardByRegistryId(args.board_id) : (args.board_slug || args.board ? registry.getBoardBySlug(args.board_slug || args.board) : null);
151
+ const rowOrSession = (args = {}) => explicit(args) || getSessionBoard();
152
+ const actor = () => board.localAgentActor();
153
+
154
+ return {
155
+ mode: "local",
156
+ sessionLabel: () => getSessionBoard()?.db_path || "(none)",
157
+ boardContext: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getBoardContext(ctx.board.id, ctx, { includeStruck: Boolean(args.include_struck) }); },
158
+ boardList: () => ({ boards: registry.listBoards().map((row) => ({ id: row.id, slug: row.slug, name: row.name, repo_path: row.repo_path })) }),
159
+ 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
+ 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); },
167
+ 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
+ updateTicket: ({ ticket_id, ...patch }) => { const ctx = sessionCtx(actor()); return backup(ctx, board.updateTicket(ticket_id, patch, ctx)); },
169
+ addComment: ({ ticket_id, ...body }) => { const ctx = sessionCtx(actor()); return backup(ctx, board.createComment(ticket_id, body, ctx)); },
170
+ addBoardEntry: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.createBoardEntry(ctx.board.id, args, ctx)); },
171
+ checkpoint: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.checkpointTicket(args, ctx)); },
172
+ complete: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.completeTicket(args, ctx)); },
173
+ archiveTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.archiveTicket(args.ticket_id, ctx)); },
174
+ restoreTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.restoreTicket(args.ticket_id, ctx)); },
175
+ deleteTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.deleteTicket(args.ticket_id, ctx)); },
176
+ 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); },
178
+ 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
+ };
180
+ }