borgmcp 2.0.8 → 2.0.10

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 (61) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-runtime.d.ts +2 -0
  3. package/dist/agent-runtime.d.ts.map +1 -1
  4. package/dist/agent-runtime.js +5 -1
  5. package/dist/agent-runtime.js.map +1 -1
  6. package/dist/assimilate-cmd.d.ts +2 -0
  7. package/dist/assimilate-cmd.d.ts.map +1 -1
  8. package/dist/assimilate-cmd.js +2 -0
  9. package/dist/assimilate-cmd.js.map +1 -1
  10. package/dist/assimilate-deps.d.ts.map +1 -1
  11. package/dist/assimilate-deps.js +6 -0
  12. package/dist/assimilate-deps.js.map +1 -1
  13. package/dist/index.d.ts +13 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +38 -23
  16. package/dist/index.js.map +1 -1
  17. package/dist/regen-format.d.ts.map +1 -1
  18. package/dist/regen-format.js +6 -4
  19. package/dist/regen-format.js.map +1 -1
  20. package/dist/regen.js +2 -0
  21. package/dist/regen.js.map +1 -1
  22. package/dist/remote-client.d.ts +24 -9
  23. package/dist/remote-client.d.ts.map +1 -1
  24. package/dist/remote-client.js +329 -49
  25. package/dist/remote-client.js.map +1 -1
  26. package/dist/roster-render.d.ts +11 -0
  27. package/dist/roster-render.d.ts.map +1 -1
  28. package/dist/roster-render.js +41 -8
  29. package/dist/roster-render.js.map +1 -1
  30. package/dist/runtime-metadata.d.ts +13 -0
  31. package/dist/runtime-metadata.d.ts.map +1 -0
  32. package/dist/runtime-metadata.js +52 -0
  33. package/dist/runtime-metadata.js.map +1 -0
  34. package/dist/server-handshake.d.ts +2 -1
  35. package/dist/server-handshake.d.ts.map +1 -1
  36. package/dist/server-handshake.js +3 -0
  37. package/dist/server-handshake.js.map +1 -1
  38. package/dist/sync-roles-render.d.ts +2 -0
  39. package/dist/sync-roles-render.d.ts.map +1 -1
  40. package/dist/sync-roles-render.js +26 -7
  41. package/dist/sync-roles-render.js.map +1 -1
  42. package/dist/working-repo.d.ts +5 -7
  43. package/dist/working-repo.d.ts.map +1 -1
  44. package/dist/working-repo.js +30 -40
  45. package/dist/working-repo.js.map +1 -1
  46. package/docs/EXTRACTION_PROVENANCE.md +8 -7
  47. package/docs/LOCAL_SERVER.md +1 -1
  48. package/docs/RELEASING.md +19 -1
  49. package/package.json +2 -2
  50. package/src/agent-runtime.ts +8 -1
  51. package/src/assimilate-cmd.ts +3 -1
  52. package/src/assimilate-deps.ts +10 -4
  53. package/src/index.ts +58 -27
  54. package/src/regen-format.ts +12 -5
  55. package/src/regen.ts +2 -0
  56. package/src/remote-client.ts +382 -43
  57. package/src/roster-render.ts +58 -8
  58. package/src/runtime-metadata.ts +67 -0
  59. package/src/server-handshake.ts +7 -2
  60. package/src/sync-roles-render.ts +24 -7
  61. package/src/working-repo.ts +30 -41
@@ -16,6 +16,10 @@
16
16
  */
17
17
 
18
18
  import { formatDroneAddressToken } from 'borgmcp-shared/drone-address';
19
+ import { escapeSyncDisplay } from './sync-roles-render.js';
20
+
21
+ export const RUNTIME_METADATA_ADVISORY =
22
+ 'Agent CLI, reported model, and working repository are advisory. They do not determine authority, role, health, activity, wake behavior, or routing.';
19
23
 
20
24
  export interface RosterDrone {
21
25
  id?: string;
@@ -47,6 +51,7 @@ export interface RosterDrone {
47
51
  /** Current cwd-derived repository identity, refreshed on regen. */
48
52
  working_repo_name?: string | null;
49
53
  working_repo_origin?: string | null;
54
+ runtime_metadata_reported?: boolean;
50
55
  }
51
56
 
52
57
  export interface RosterRole {
@@ -104,6 +109,54 @@ export function formatWorkingRepoLabel(drone: Pick<RosterDrone, 'working_repo_na
104
109
  return 'Working repo: not reported';
105
110
  }
106
111
 
112
+ function metadataValue(
113
+ drone: RosterDrone,
114
+ value: string | null | undefined,
115
+ ): string {
116
+ if (drone.runtime_metadata_reported !== true) return 'not reported';
117
+ return value == null ? 'unknown' : escapeRuntimeMetadataDisplay(value);
118
+ }
119
+
120
+ /**
121
+ * Keep accepted advisory metadata readable without letting a Markdown renderer
122
+ * or link-detecting terminal turn cube-controlled text into a live target.
123
+ * The visible `[.]` / `[:]` markers preserve the reported value's differences.
124
+ */
125
+ export function escapeRuntimeMetadataDisplay(value: string): string {
126
+ const escaped = escapeSyncDisplay(value);
127
+ const defangedScheme = escaped.replace(
128
+ /\b([A-Za-z][A-Za-z0-9+.-]*)\:\/\//g,
129
+ '$1\\[:]//',
130
+ );
131
+ return defangedScheme.replace(
132
+ /\b(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,63}\b/g,
133
+ (host) => host.replaceAll('.', '\\[.\\]'),
134
+ );
135
+ }
136
+
137
+ export function renderRuntimeMetadataLines(
138
+ drone: RosterDrone,
139
+ opts: { includeOrigin?: boolean } = {},
140
+ ): string[] {
141
+ const agent = drone.agent_kind === 'claude'
142
+ ? 'Claude Code'
143
+ : drone.agent_kind === 'codex'
144
+ ? 'Codex'
145
+ : drone.agent_kind === 'opencode'
146
+ ? 'OpenCode'
147
+ : null;
148
+ const lines = [
149
+ ` - **Agent CLI:** ${metadataValue(drone, agent)}`,
150
+ ` - **Reported model:** ${metadataValue(drone, drone.reported_model)}`,
151
+ ` - **Working repo:** ${metadataValue(drone, drone.working_repo_name)}`,
152
+ ];
153
+ if (opts.includeOrigin) {
154
+ const origin = drone.working_repo_origin?.replace(/^https:\/\//i, '');
155
+ lines.push(` - **Origin:** ${metadataValue(drone, origin)}`);
156
+ }
157
+ return lines;
158
+ }
159
+
107
160
  export interface RenderRosterInputs {
108
161
  cubeName: string;
109
162
  drones: RosterDrone[];
@@ -126,6 +179,8 @@ export function renderRoster(inputs: RenderRosterInputs): string {
126
179
  const lines: string[] = [];
127
180
  lines.push(`# Drones in cube: ${cubeName}`);
128
181
  lines.push('');
182
+ lines.push(`_${RUNTIME_METADATA_ADVISORY}_`);
183
+ lines.push('');
129
184
 
130
185
  if (resolvedSince) {
131
186
  // Surface the liveness-probe context so the reader knows what the
@@ -146,15 +201,9 @@ export function renderRoster(inputs: RenderRosterInputs): string {
146
201
  for (const d of drones) {
147
202
  const role = roleById.get(d.role_id);
148
203
  const roleName = role?.name ?? 'unknown';
149
- const roleLabel = formatRoleAgentLabel(roleName, d.agent_kind);
150
204
  // gh#371: stable short-uuid address token beside the (renumber-prone) label.
151
205
  const addr = d.id ? ` ${formatDroneAddressToken(d.id)}` : '';
152
206
  const lastSeen = humanAgo(d.last_seen);
153
- const reportedModelMarker = d.reported_model
154
- ? ` · \`Reported model: ${d.reported_model}\``
155
- : ' · `Reported model: not reported`';
156
- const workingRepo = formatWorkingRepoLabel(d);
157
- const workingRepoMarker = ` · \`${workingRepo}\``;
158
207
  const wakePathMarker =
159
208
  d.wake_path && d.wake_path !== 'live'
160
209
  ? ` · \`wake-path:${WAKE_PATH_DISPLAY[d.wake_path] ?? d.wake_path}\``
@@ -183,13 +232,14 @@ export function renderRoster(inputs: RenderRosterInputs): string {
183
232
  const isAwake = d.seen_since === true;
184
233
  const marker = isAwake ? '`awake`' : '`stale`';
185
234
  lines.push(
186
- `- **${d.label}**${addr} (${roleLabel}) — last seen ${lastSeen} · ${marker}${regenCountMarker}${wakePathMarker}${wakePathClassMarker}${reportedModelMarker}${workingRepoMarker}`
235
+ `- **${d.label}**${addr} (Role: ${roleName}) — last seen ${lastSeen} · ${marker}${regenCountMarker}${wakePathMarker}${wakePathClassMarker}`
187
236
  );
188
237
  } else {
189
238
  lines.push(
190
- `- **${d.label}**${addr} (${roleLabel}) — last seen ${lastSeen}${regenCountMarker}${wakePathMarker}${wakePathClassMarker}${reportedModelMarker}${workingRepoMarker}`
239
+ `- **${d.label}**${addr} (Role: ${roleName}) — last seen ${lastSeen}${regenCountMarker}${wakePathMarker}${wakePathClassMarker}`
191
240
  );
192
241
  }
242
+ lines.push(...renderRuntimeMetadataLines(d, { includeOrigin: true }));
193
243
  }
194
244
 
195
245
  return lines.join('\n');
@@ -0,0 +1,67 @@
1
+ import type {
2
+ AgentKind,
3
+ DroneRuntimeMetadata,
4
+ DroneRuntimeMetadataPatch,
5
+ } from 'borgmcp-shared/protocol';
6
+ import {
7
+ canonicalizeRepositoryIdentity,
8
+ validateReportedModel,
9
+ validateRuntimeMetadata,
10
+ validateRuntimeMetadataPatch,
11
+ } from 'borgmcp-shared/runtime-metadata';
12
+ import type { WorkingRepo } from './working-repo.js';
13
+
14
+ function safeReportedModel(value: string | null | undefined): string | null {
15
+ if (value == null) return null;
16
+ try {
17
+ return validateReportedModel(value);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function reportableRepository(repo: WorkingRepo | undefined) {
24
+ if (!repo || repo.state === 'unavailable' || repo.state === 'rejected') return null;
25
+ if (repo.name === null && repo.origin === null) {
26
+ return { working_repo_name: null, working_repo_origin: null };
27
+ }
28
+ if (repo.name === null || repo.origin === null) return null;
29
+ try {
30
+ const canonical = canonicalizeRepositoryIdentity(repo.origin, repo.name);
31
+ return {
32
+ working_repo_name: canonical.working_repo_name,
33
+ working_repo_origin: canonical.working_repo_origin,
34
+ };
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export function buildRuntimeMetadataReport(input: {
41
+ agentKind: AgentKind | null | undefined;
42
+ reportedModel?: string | null;
43
+ workingRepo?: WorkingRepo;
44
+ }): DroneRuntimeMetadata {
45
+ const repository = reportableRepository(input.workingRepo);
46
+ return validateRuntimeMetadata({
47
+ agent_kind: input.agentKind ?? null,
48
+ reported_model: safeReportedModel(input.reportedModel),
49
+ working_repo_name: repository?.working_repo_name ?? null,
50
+ working_repo_origin: repository?.working_repo_origin ?? null,
51
+ });
52
+ }
53
+
54
+ export function buildRuntimeMetadataPatch(input: {
55
+ agentKind: AgentKind | null;
56
+ reportedModel?: string;
57
+ workingRepo?: WorkingRepo;
58
+ }): DroneRuntimeMetadataPatch {
59
+ const patch: DroneRuntimeMetadataPatch = { agent_kind: input.agentKind };
60
+ if (input.reportedModel !== undefined) {
61
+ const model = safeReportedModel(input.reportedModel);
62
+ if (model !== null) patch.reported_model = model;
63
+ }
64
+ const repository = reportableRepository(input.workingRepo);
65
+ if (repository) Object.assign(patch, repository);
66
+ return validateRuntimeMetadataPatch(patch);
67
+ }
@@ -15,6 +15,7 @@ import {
15
15
  decodeProtocolTagPreflight,
16
16
  ErrorCode,
17
17
  type CreateCubeResponse,
18
+ type DroneRuntimeMetadata,
18
19
  type ProtocolTagPreflight,
19
20
  type ServerCapability,
20
21
  } from 'borgmcp-shared/protocol';
@@ -235,6 +236,7 @@ export async function sendBorgServerAttach(
235
236
  roleId: string;
236
237
  operation: ServerSessionOperation;
237
238
  priorDroneId?: string;
239
+ runtimeMetadata?: DroneRuntimeMetadata;
238
240
  },
239
241
  pendingBearer: string,
240
242
  deps: {
@@ -274,9 +276,12 @@ export async function sendBorgServerAttach(
274
276
  cube_id: request.cubeId,
275
277
  role_id: request.roleId,
276
278
  session_credential: pending.credential,
277
- ...(request.priorDroneId === undefined
279
+ ...(request.priorDroneId === undefined
280
+ ? {}
281
+ : { prior_drone_id: request.priorDroneId }),
282
+ ...(request.runtimeMetadata === undefined
278
283
  ? {}
279
- : { prior_drone_id: request.priorDroneId }),
284
+ : { runtime_metadata: request.runtimeMetadata }),
280
285
  })),
281
286
  });
282
287
  } catch (error) {
@@ -12,6 +12,8 @@
12
12
 
13
13
  export type FragmentKind = 'add' | 'unchanged' | 'conflict';
14
14
 
15
+ const BIDI_CONTROL_RE = /\p{Bidi_Control}/u;
16
+
15
17
  export interface FragmentView {
16
18
  key: string;
17
19
  kind: FragmentKind;
@@ -35,10 +37,25 @@ export interface NonClobberSyncResult {
35
37
  unmatchedDecisions?: string[];
36
38
  }
37
39
 
40
+ /** Escape cube-controlled text before it reaches Markdown or a terminal. */
41
+ export function escapeSyncDisplay(value: string): string {
42
+ return [...value].map((char) => {
43
+ const code = char.codePointAt(0)!;
44
+ if (code === 0x0a) return '⏎';
45
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return `\\u{${code.toString(16)}}`;
46
+ if (BIDI_CONTROL_RE.test(char) || code === 0x2028 || code === 0x2029) {
47
+ return `\\u{${code.toString(16)}}`;
48
+ }
49
+ if (char === '`') return '\\u{60}';
50
+ if ('\\*_[]()<>&#|~'.includes(char)) return `\\${char}`;
51
+ return char;
52
+ }).join('');
53
+ }
54
+
38
55
  /** Truncate long fragment bodies for at-a-glance diffs. */
39
56
  function trunc(s: string | null, n = 200): string {
40
57
  if (s == null) return '(absent)';
41
- const flat = s.replace(/\n/g, '⏎');
58
+ const flat = escapeSyncDisplay(s);
42
59
  return flat.length > n ? flat.slice(0, n) + '…' : flat;
43
60
  }
44
61
 
@@ -75,7 +92,7 @@ export function renderSyncRolesResult(
75
92
  const mode = result.dryRun
76
93
  ? '**DRY RUN** (review conflicts below; re-run with `apply: true` + a `decisions` map to commit)'
77
94
  : '**APPLIED**';
78
- const lines: string[] = [`## borg_sync-roles — ${mode}`, `Template: ${templateName}`, ''];
95
+ const lines: string[] = [`## borg_sync-roles — ${mode}`, `Template: ${escapeSyncDisplay(templateName)}`, ''];
79
96
 
80
97
  // Gather all fragments across roles + taxonomy for tallying.
81
98
  const allFragments: FragmentView[] = [
@@ -109,7 +126,7 @@ export function renderSyncRolesResult(
109
126
  : applied
110
127
  ? '✓ accepted — template version applied'
111
128
  : '↩ kept your version';
112
- lines.push(`- **${f.label}** \`${f.key}\` ${status}`);
129
+ lines.push(`- **${escapeSyncDisplay(f.label)}** \`${escapeSyncDisplay(f.key)}\` ${status}`);
113
130
  lines.push(` - cube (current): "${trunc(f.cubeValue)}"`);
114
131
  lines.push(` - template (new): "${trunc(f.templateValue)}"`);
115
132
  }
@@ -127,7 +144,7 @@ export function renderSyncRolesResult(
127
144
  '(typo or stale key) — their intended accept had NO effect. Check the exact keys against the conflicts above:'
128
145
  );
129
146
  for (const k of unmatched) {
130
- lines.push(`- \`${k}\``);
147
+ lines.push(`- \`${escapeSyncDisplay(k)}\``);
131
148
  }
132
149
  lines.push('');
133
150
  }
@@ -137,11 +154,11 @@ export function renderSyncRolesResult(
137
154
  lines.push(`### Additions (safe — auto-applied, zero clobber risk)`);
138
155
  for (const r of newRoles) {
139
156
  const note = result.dryRun ? '(new role — would be created)' : '✓ created';
140
- lines.push(`- new role **${r.name}** ${note}`);
157
+ lines.push(`- new role **${escapeSyncDisplay(r.name)}** ${note}`);
141
158
  }
142
159
  for (const f of adds) {
143
160
  const note = result.dryRun ? '(would be added)' : '✓ added';
144
- lines.push(`- **${f.label}** \`${f.key}\` ${note}`);
161
+ lines.push(`- **${escapeSyncDisplay(f.label)}** \`${escapeSyncDisplay(f.key)}\` ${note}`);
145
162
  }
146
163
  lines.push('');
147
164
  }
@@ -149,7 +166,7 @@ export function renderSyncRolesResult(
149
166
  // ── Custom roles (never touched) ──
150
167
  if (customRoles.length > 0) {
151
168
  lines.push(
152
- `### Custom roles (untouched): ${customRoles.map((r) => r.name).join(', ')}`
169
+ `### Custom roles (untouched): ${customRoles.map((r) => escapeSyncDisplay(r.name)).join(', ')}`
153
170
  );
154
171
  lines.push('');
155
172
  }
@@ -7,10 +7,12 @@
7
7
  */
8
8
 
9
9
  import { spawnSync } from 'node:child_process';
10
+ import { canonicalizeRepositoryIdentity } from 'borgmcp-shared/runtime-metadata';
10
11
  export interface WorkingRepo {
11
12
  name: string | null;
12
- /** Canonical host/path identity, never a raw Git remote URL. */
13
+ /** Canonical public HTTPS identity, never a raw Git remote URL. */
13
14
  origin: string | null;
15
+ state?: 'known' | 'unknown' | 'unavailable' | 'rejected';
14
16
  }
15
17
 
16
18
  export interface WorkingRepoDeps {
@@ -30,43 +32,19 @@ function trimmed(value: string | null | undefined): string | null {
30
32
  return normalized ? normalized : null;
31
33
  }
32
34
 
33
- function nameFromIdentity(identity: string): string | null {
34
- const lastPathSegment = identity.replace(/\/$/, '').split('/').pop();
35
- const name = lastPathSegment?.replace(/\.git$/i, '').trim();
36
- return name || null;
37
- }
38
-
39
35
  /**
40
- * Convert a Git remote to a non-secret `host/org/repo` identity.
41
- *
42
- * URL userinfo, query strings, fragments, scheme, and SCP-style user prefixes
43
- * are deliberately discarded. Inputs that cannot identify a host and path are
44
- * treated as unreportable rather than forwarded verbatim.
36
+ * Convert a Git remote to the shared canonical public repository identity.
37
+ * Hostile or credential-bearing inputs are rejected rather than sanitized.
45
38
  */
46
- export function canonicalizeWorkingRepoIdentity(origin: string): string | null {
47
- const raw = origin.trim();
48
- if (!raw) return null;
49
- // Remote clients send this canonical form on subsequent lifecycle calls.
50
- const canonical = raw.match(/^([A-Za-z0-9.-]+)\/([^?#\s]+)$/);
51
- if (canonical) {
52
- const host = canonical[1].toLowerCase();
53
- const path = canonical[2].replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '');
54
- return host && path ? `${host}/${path}` : null;
55
- }
39
+ export function canonicalizeWorkingRepoIdentity(origin: string): WorkingRepo | null {
56
40
  try {
57
- const url = new URL(origin);
58
- if (!['http:', 'https:', 'ssh:', 'git:'].includes(url.protocol)) return null;
59
- const path = url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '');
60
- return url.hostname && path ? `${url.hostname.toLowerCase()}/${path}` : null;
41
+ const canonical = canonicalizeRepositoryIdentity(origin.trim());
42
+ return {
43
+ name: canonical.working_repo_name,
44
+ origin: canonical.working_repo_origin,
45
+ state: 'known',
46
+ };
61
47
  } catch {
62
- // SCP-style SSH remote: discard its optional user prefix and URL-like
63
- // query/fragment suffix before accepting only host + repository path.
64
- const match = raw.match(/^(?:[^@\s/:]+@)?([A-Za-z0-9.-]+):\/?([^?#\s]+)(?:[?#].*)?$/);
65
- if (match) {
66
- const host = match[1].toLowerCase();
67
- const path = match[2].replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '');
68
- return host && path ? `${host}/${path}` : null;
69
- }
70
48
  return null;
71
49
  }
72
50
  }
@@ -83,17 +61,28 @@ export function resolveWorkingRepo(
83
61
  deps: WorkingRepoDeps = {}
84
62
  ): WorkingRepo {
85
63
  const runGit = deps.runGit ?? defaultRunGit;
86
- const rootResult = runGit(cwd, ['rev-parse', '--show-toplevel']);
64
+ let rootResult;
65
+ try {
66
+ rootResult = runGit(cwd, ['rev-parse', '--show-toplevel']);
67
+ } catch {
68
+ return { name: null, origin: null, state: 'unavailable' };
69
+ }
87
70
  const root = rootResult.status === 0 ? trimmed(rootResult.stdout) : null;
88
71
  if (!root) {
89
- return { name: null, origin: null };
72
+ return { name: null, origin: null, state: 'unknown' };
90
73
  }
91
74
 
92
- const originResult = runGit(cwd, ['config', '--get', 'remote.origin.url']);
75
+ let originResult;
76
+ try {
77
+ originResult = runGit(cwd, ['config', '--get', 'remote.origin.url']);
78
+ } catch {
79
+ return { name: null, origin: null, state: 'unavailable' };
80
+ }
93
81
  const originRaw = originResult.status === 0 ? trimmed(originResult.stdout) : null;
94
- const origin = originRaw ? canonicalizeWorkingRepoIdentity(originRaw) : null;
95
- return {
96
- name: origin ? nameFromIdentity(origin) : null,
97
- origin,
82
+ if (!originRaw) return { name: null, origin: null, state: 'unknown' };
83
+ return canonicalizeWorkingRepoIdentity(originRaw) ?? {
84
+ name: null,
85
+ origin: null,
86
+ state: 'rejected',
98
87
  };
99
88
  }