borgmcp 2.0.9 → 2.0.11

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 (64) hide show
  1. package/README.md +2 -2
  2. package/SECURITY.md +1 -1
  3. package/dist/agent-runtime.d.ts +2 -0
  4. package/dist/agent-runtime.d.ts.map +1 -1
  5. package/dist/agent-runtime.js +5 -1
  6. package/dist/agent-runtime.js.map +1 -1
  7. package/dist/assimilate-cmd.d.ts +2 -0
  8. package/dist/assimilate-cmd.d.ts.map +1 -1
  9. package/dist/assimilate-cmd.js +2 -0
  10. package/dist/assimilate-cmd.js.map +1 -1
  11. package/dist/assimilate-deps.d.ts.map +1 -1
  12. package/dist/assimilate-deps.js +6 -0
  13. package/dist/assimilate-deps.js.map +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +18 -17
  16. package/dist/index.js.map +1 -1
  17. package/dist/local-manage-tool-result.d.ts +10 -0
  18. package/dist/local-manage-tool-result.d.ts.map +1 -1
  19. package/dist/local-manage-tool-result.js +36 -0
  20. package/dist/local-manage-tool-result.js.map +1 -1
  21. package/dist/regen-format.d.ts.map +1 -1
  22. package/dist/regen-format.js +6 -4
  23. package/dist/regen-format.js.map +1 -1
  24. package/dist/regen.js +2 -0
  25. package/dist/regen.js.map +1 -1
  26. package/dist/remote-client.d.ts +18 -6
  27. package/dist/remote-client.d.ts.map +1 -1
  28. package/dist/remote-client.js +99 -16
  29. package/dist/remote-client.js.map +1 -1
  30. package/dist/roster-render.d.ts +11 -0
  31. package/dist/roster-render.d.ts.map +1 -1
  32. package/dist/roster-render.js +41 -8
  33. package/dist/roster-render.js.map +1 -1
  34. package/dist/runtime-metadata.d.ts +13 -0
  35. package/dist/runtime-metadata.d.ts.map +1 -0
  36. package/dist/runtime-metadata.js +52 -0
  37. package/dist/runtime-metadata.js.map +1 -0
  38. package/dist/server-handshake.d.ts +2 -1
  39. package/dist/server-handshake.d.ts.map +1 -1
  40. package/dist/server-handshake.js +3 -0
  41. package/dist/server-handshake.js.map +1 -1
  42. package/dist/tool-manifest.js +2 -2
  43. package/dist/tool-manifest.js.map +1 -1
  44. package/dist/working-repo.d.ts +5 -7
  45. package/dist/working-repo.d.ts.map +1 -1
  46. package/dist/working-repo.js +30 -40
  47. package/dist/working-repo.js.map +1 -1
  48. package/docs/EXTRACTION_PROVENANCE.md +8 -8
  49. package/docs/LOCAL_SERVER.md +1 -1
  50. package/docs/RELEASING.md +25 -13
  51. package/package.json +2 -2
  52. package/src/agent-runtime.ts +8 -1
  53. package/src/assimilate-cmd.ts +3 -1
  54. package/src/assimilate-deps.ts +10 -4
  55. package/src/index.ts +26 -21
  56. package/src/local-manage-tool-result.ts +47 -0
  57. package/src/regen-format.ts +12 -5
  58. package/src/regen.ts +2 -0
  59. package/src/remote-client.ts +124 -24
  60. package/src/roster-render.ts +58 -8
  61. package/src/runtime-metadata.ts +67 -0
  62. package/src/server-handshake.ts +7 -2
  63. package/src/tool-manifest.ts +2 -2
  64. package/src/working-repo.ts +30 -41
@@ -49,6 +49,7 @@ import type { ExpectedBinding, FinalizeServerSeatOutcome, PersistedLocalSeat } f
49
49
  import type { SeatBinding, BindPendingSeatOutcome } from './seats.js';
50
50
  import { createHash } from 'node:crypto';
51
51
  import { buildOpenCodeLaunchArgs, type LaunchApprovalDecision } from './cli-tool-approval.js';
52
+ import { resolveWorkingRepo, type WorkingRepo } from './working-repo.js';
52
53
 
53
54
  const PRIVATE_STATE_UNAVAILABLE_COPY = [
54
55
  'Borg could not safely prepare its private local state.',
@@ -259,7 +260,7 @@ export interface AssimilateDeps {
259
260
  assimilate: (
260
261
  apiUrl: string,
261
262
  token: string,
262
- params: { cube_id: string; role_id: string; hostname?: string | null; prior_drone_id?: string; remint_invalid_prior?: boolean; model?: string | null; agent_kind?: 'claude' | 'codex' | 'opencode' | null; session_operation?: ServerSessionOperation; session_expected?: ExpectedBinding; revalidate_at_prepare?: boolean },
263
+ params: { cube_id: string; role_id: string; hostname?: string | null; prior_drone_id?: string; remint_invalid_prior?: boolean; model?: string | null; agent_kind?: 'claude' | 'codex' | 'opencode' | null; working_repo?: WorkingRepo; session_operation?: ServerSessionOperation; session_expected?: ExpectedBinding; revalidate_at_prepare?: boolean },
263
264
  serverTrustIdentity?: string,
264
265
  ) => Promise<AssimilateResult>;
265
266
 
@@ -1183,6 +1184,7 @@ export async function runAssimilate(
1183
1184
  hostname: deps.getHostname(),
1184
1185
  agent_kind: cli,
1185
1186
  model: effectiveModel,
1187
+ working_repo: resolveWorkingRepo(projectRoot),
1186
1188
  ...(reattachPriorId ? { prior_drone_id: reattachPriorId } : {}),
1187
1189
  ...(remintInvalidPrior ? { remint_invalid_prior: true } : {}),
1188
1190
  session_operation: sessionOperation,
@@ -16,6 +16,7 @@ import { createInterface } from 'node:readline/promises';
16
16
  import prompts from 'prompts';
17
17
  import { readinessProbeEnv } from './readiness-probe.js';
18
18
  import { resolveMcpBinaryPath } from './self-path.js';
19
+ import { buildRuntimeMetadataReport } from './runtime-metadata.js';
19
20
 
20
21
  import type { AssimilateDeps } from './assimilate-cmd.js';
21
22
  import {
@@ -322,10 +323,15 @@ export function buildDefaultAssimilateDeps(): AssimilateDeps {
322
323
  cubeId: params.cube_id,
323
324
  roleId: params.role_id,
324
325
  operation,
325
- ...(params.prior_drone_id === undefined
326
- ? {}
327
- : { priorDroneId: params.prior_drone_id }),
328
- },
326
+ ...(params.prior_drone_id === undefined
327
+ ? {}
328
+ : { priorDroneId: params.prior_drone_id }),
329
+ runtimeMetadata: buildRuntimeMetadataReport({
330
+ agentKind: params.agent_kind,
331
+ reportedModel: params.model,
332
+ workingRepo: params.working_repo,
333
+ }),
334
+ },
329
335
  pending.credential,
330
336
  { fetchImpl: trust.fetchImpl },
331
337
  );
package/src/index.ts CHANGED
@@ -93,7 +93,11 @@ import {
93
93
  formatWakePathPrefix,
94
94
  shouldShowWakePathWarning,
95
95
  } from './stream-status.js';
96
- import { formatRoleAgentLabel, formatWorkingRepoLabel, renderRoster } from './roster-render.js';
96
+ import {
97
+ RUNTIME_METADATA_ADVISORY,
98
+ renderRoster,
99
+ renderRuntimeMetadataLines,
100
+ } from './roster-render.js';
97
101
  import { resolveWorkingRepo } from './working-repo.js';
98
102
  import {
99
103
  DroneEvictedError,
@@ -110,6 +114,7 @@ import { initConsolePrefix, consolePrefix } from './console-prefix.js';
110
114
  import {
111
115
  resolveSessionAgentKind,
112
116
  } from './codex-app-wake.js';
117
+ import { resolveReportableSessionAgentKind } from './agent-runtime.js';
113
118
  import {
114
119
  connectOpenCodeDrone,
115
120
  injectOpenCodeEntry,
@@ -134,14 +139,15 @@ import {
134
139
  /**
135
140
  * Apply a template's roles + message_taxonomy to a cube.
136
141
  *
137
- * gh#473 PR2 delegates to the NON-CLOBBERING server route. New roles
138
- * are inserted; existing template-named roles get ADD fragments (template
139
- * sections/classes the cube lacks) auto-applied, but EVOLVED (conflicting)
140
- * fragments are surfaced server-side and KEPT — never silently
141
- * overwritten. The old per-role blanket `updateRole`/whole-taxonomy
142
- * `updateCube` overwrite path is removed. Operators who want to take the
143
- * template version of a conflicting fragment use `borg_sync-roles` with a
144
- * `decisions` map. Returns `{ created, updated }` for the caller's toast.
142
+ * Client-orchestrated through the server's non-clobbering role and taxonomy
143
+ * primitives. New roles are inserted; existing template-named roles get ADD
144
+ * fragments (template sections/classes the cube lacks) auto-applied, but
145
+ * EVOLVED (conflicting) fragments are kept — never silently overwritten. The
146
+ * old per-role blanket `updateRole`/whole-taxonomy `updateCube` overwrite path
147
+ * is removed. Operators who want to take the template version of a conflicting
148
+ * fragment use `borg_sync-roles` with a `decisions` map. Primitive operations
149
+ * are sequential, so a later failure can leave earlier changes committed.
150
+ * Returns `{ created, updated }` for the caller's toast.
145
151
  */
146
152
  async function applyTemplateToCube(
147
153
  cubeId: string,
@@ -368,6 +374,7 @@ export async function main() {
368
374
  const result = await regen(active.sessionToken, active.apiUrl, {
369
375
  since,
370
376
  reportedModel,
377
+ agentKind: resolveReportableSessionAgentKind(),
371
378
  workingRepo: resolveWorkingRepo(),
372
379
  serverTrustIdentity: active.serverTrustIdentity,
373
380
  });
@@ -444,6 +451,7 @@ export async function main() {
444
451
  // — an evicted/revoked seat FAILS server-side and is surfaced).
445
452
  try {
446
453
  const result = await regen(active!.sessionToken, active!.apiUrl, {
454
+ agentKind: resolveReportableSessionAgentKind(),
447
455
  workingRepo: resolveWorkingRepo(),
448
456
  serverTrustIdentity: active!.serverTrustIdentity,
449
457
  });
@@ -1084,21 +1092,18 @@ export async function main() {
1084
1092
  return { content: [{ type: 'text', text: 'No drones in this cube yet.' }] };
1085
1093
  }
1086
1094
  const rolesById = new Map(roles.map((r: any) => [r.id, r]));
1087
- const lines = drones.map((d: any) => {
1088
- const r = rolesById.get(d.role_id) as any;
1089
- const roleLabel = formatRoleAgentLabel(r?.name ?? '?', d.agent_kind);
1090
- const wakeClass =
1095
+ const lines = drones.map((d: any) => {
1096
+ const r = rolesById.get(d.role_id) as any;
1097
+ const wakeClass =
1091
1098
  d.wake_path_alert_class && d.wake_path_alert_class !== 'independent'
1092
1099
  ? ` — wake-path-class: ${d.wake_path_alert_class}`
1093
1100
  : '';
1094
- const reportedModel = d.reported_model
1095
- ? `Reported model: ${d.reported_model}`
1096
- : ' — Reported model: not reported';
1097
- const workingRepo = formatWorkingRepoLabel(d);
1098
- const workingRepoText = workingRepo ? ` — ${workingRepo}` : '';
1099
- return `- **${d.label}** (id: ${d.id}) role: ${roleLabel} (${d.role_id}) — last seen ${d.last_seen}${wakeClass}${reportedModel}${workingRepoText}`;
1100
- });
1101
- return { content: [{ type: 'text', text: `Drones in cube ${cubeId} (${drones.length}):\n\n${lines.join('\n')}` }] };
1101
+ return [
1102
+ `- **${d.label}** (id: ${d.id}) — Role: ${r?.name ?? '?'} (${d.role_id})last seen ${d.last_seen}${wakeClass}`,
1103
+ ...renderRuntimeMetadataLines(d),
1104
+ ].join('\n');
1105
+ });
1106
+ return { content: [{ type: 'text', text: `Drones in cube ${cubeId} (${drones.length}):\n\n_${RUNTIME_METADATA_ADVISORY}_\n\n${lines.join('\n')}` }] };
1102
1107
  }
1103
1108
 
1104
1109
  case 'borg_list-roles': {
@@ -1,6 +1,53 @@
1
1
  import { LocalManageRequiredError } from './server-errors.js';
2
2
 
3
+ export interface RoleSectionConflictOperation {
4
+ roleId: string;
5
+ action: 'replace' | 'insert' | 'delete';
6
+ heading: string;
7
+ after?: string | null;
8
+ }
9
+
10
+ export class RoleSectionConflictError extends Error {
11
+ constructor(public readonly operation: RoleSectionConflictOperation) {
12
+ super('The role section patch conflicted with the current role text.');
13
+ this.name = 'RoleSectionConflictError';
14
+ }
15
+ }
16
+
17
+ const UNSAFE_OPERATION_CHARACTER =
18
+ /["\\`*_[\]()<>#!|~\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/u;
19
+
20
+ function safeOperationValue(value: string): string {
21
+ const escaped = Array.from(value, (character) =>
22
+ UNSAFE_OPERATION_CHARACTER.test(character)
23
+ ? `\\u{${character.codePointAt(0)!.toString(16)}}`
24
+ : character).join('');
25
+ return `"${escaped}"`;
26
+ }
27
+
3
28
  export function formatLocalManageToolResult(error: unknown) {
29
+ if (error instanceof RoleSectionConflictError) {
30
+ const { roleId, action, heading, after } = error.operation;
31
+ const context = [
32
+ `action=${action}`,
33
+ `heading=${safeOperationValue(heading)}`,
34
+ ...(action === 'insert'
35
+ ? [`after=${after == null ? '<end>' : safeOperationValue(after)}`]
36
+ : []),
37
+ ].join(', ');
38
+ return {
39
+ content: [{
40
+ type: 'text' as const,
41
+ text:
42
+ `[ROLE-SECTION-CONFLICT] The requested role section operation conflicts with the current role text.\n\n` +
43
+ `Requested operation: ${context}\n\n` +
44
+ 'No role text was changed by this request. ' +
45
+ `Read the current role with \`borg_role\` using role id ${safeOperationValue(roleId)}, ` +
46
+ 'then retry `borg_patch-role-section` against that current text.',
47
+ }],
48
+ isError: true as const,
49
+ };
50
+ }
4
51
  if (!(error instanceof LocalManageRequiredError)) return null;
5
52
 
6
53
  return {
@@ -12,7 +12,10 @@ import {
12
12
  } from 'borgmcp-shared/templates';
13
13
  import { parseRoleSections } from 'borgmcp-shared/role-section';
14
14
  import { formatDroneAddressToken } from 'borgmcp-shared/drone-address';
15
- import { formatRoleAgentLabel } from './roster-render.js';
15
+ import {
16
+ RUNTIME_METADATA_ADVISORY,
17
+ renderRuntimeMetadataLines,
18
+ } from './roster-render.js';
16
19
  import { shellEscape } from './shell-escape.js';
17
20
  import { resolveInboxMonitorPath } from './self-path.js';
18
21
 
@@ -488,10 +491,12 @@ export function formatRegenMarkdown(
488
491
 
489
492
  const droneOverview =
490
493
  result.drones
491
- .map((d: any) => {
492
- const role = result.roles.find((r: any) => r.id === d.role_id);
493
- const roleLabel = formatRoleAgentLabel(role?.name ?? '?', d.agent_kind);
494
- return `- **${d.label}** (${roleLabel}) — last seen ${humanAgo(new Date(d.last_seen))}`;
494
+ .map((d: any) => {
495
+ const role = result.roles.find((r: any) => r.id === d.role_id);
496
+ return [
497
+ `- **${d.label}** (Role: ${role?.name ?? '?'}) — last seen ${humanAgo(new Date(d.last_seen))}`,
498
+ ...renderRuntimeMetadataLines(d),
499
+ ].join('\n');
495
500
  })
496
501
  .join('\n') || '_(no drones connected)_';
497
502
 
@@ -611,6 +616,8 @@ export function formatRegenMarkdown(
611
616
  roleOverview,
612
617
  '',
613
618
  `## Connected drones`,
619
+ `_${RUNTIME_METADATA_ADVISORY}_`,
620
+ '',
614
621
  droneOverview,
615
622
  '',
616
623
  `## Cube log`,
package/src/regen.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  type AgentKind,
27
27
  } from './regen-format.js';
28
28
  import { resolveSessionAgentKind } from './codex-app-wake.js';
29
+ import { resolveReportableSessionAgentKind } from './agent-runtime.js';
29
30
  import { handleVersionFlag } from './version.js';
30
31
  import { gateAllowsActivation } from './launch-gate.js';
31
32
  import { resolveWorkingRepo } from './working-repo.js';
@@ -85,6 +86,7 @@ async function main(): Promise<void> {
85
86
  let result: Awaited<ReturnType<typeof regen>> | null = null;
86
87
  try {
87
88
  result = await regen(active.sessionToken, active.apiUrl, {
89
+ agentKind: resolveReportableSessionAgentKind(),
88
90
  workingRepo: resolveWorkingRepo(),
89
91
  serverTrustIdentity: active.serverTrustIdentity,
90
92
  });
@@ -16,11 +16,14 @@ import {
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import {
18
18
  createProtocolEnvelope,
19
+ decodeDroneRuntimeMetadataState,
19
20
  decodeEvictDroneResult,
20
21
  decodeProtocolEnvelope,
21
22
  decodeProtocolErrorEnvelope,
22
23
  decodeReassignDroneResult,
24
+ decodeUpdateDroneRuntimeMetadataResponse,
23
25
  ErrorCode,
26
+ type AgentKind,
24
27
  type EvictDroneResult,
25
28
  type ReassignDroneResult,
26
29
  } from 'borgmcp-shared/protocol';
@@ -33,6 +36,7 @@ import { getTemplate, type Template, type TemplateRole } from 'borgmcp-shared/te
33
36
  import { parseRoleSections } from 'borgmcp-shared/role-section';
34
37
  import type { FragmentView, NonClobberSyncResult } from './sync-roles-render.js';
35
38
  import type { WorkingRepo } from './working-repo.js';
39
+ import { buildRuntimeMetadataPatch } from './runtime-metadata.js';
36
40
  import { loadBorgServerTrust, type ServerFetch } from './server-trust.js';
37
41
  import {
38
42
  BorgServerError,
@@ -50,6 +54,7 @@ import {
50
54
  } from './local-server-cursor.js';
51
55
  import { readBoundedResponseBody } from './server-response.js';
52
56
  import { resolveLocalLogRecipients } from './local-log-routing.js';
57
+ import { RoleSectionConflictError } from './local-manage-tool-result.js';
53
58
 
54
59
  export interface RemoteConnection {
55
60
  apiUrl: string;
@@ -67,6 +72,7 @@ export const LOCAL_SERVER_RESPONSE_LIMIT_BYTES = 32 * 1024 * 1024;
67
72
  // A typed auth-error envelope is tiny; anything larger is hostile and the
68
73
  // bounded read throws → the 401 fails closed to non-destructive CREDENTIAL_REJECTED.
69
74
  const AUTH_ERROR_ENVELOPE_LIMIT_BYTES = 64 * 1024;
75
+ const ROLE_SECTION_CONFLICT_CODE = 'ROLE_SECTION_CONFLICT';
70
76
  export const LOCAL_SERVER_REQUEST_TIMEOUT_MS = 5_000;
71
77
  const LOCAL_SERVER_RESPONSE_LIMIT_MESSAGE =
72
78
  'Local Borg server response exceeded the response limit';
@@ -400,18 +406,42 @@ async function localCubeComposition(active: ActiveCube): Promise<{
400
406
  if (!cubePayload || !rolePayload || !dronePayload) {
401
407
  throw new Error('Local Borg server returned an incomplete cube response');
402
408
  }
403
- const drone = dronePayload.drones.find((candidate) => candidate.id === active.droneId);
409
+ const drones = dronePayload.drones.map(withValidatedRuntimeMetadata);
410
+ const drone = drones.find((candidate) => candidate.id === active.droneId);
404
411
  const role = rolePayload.roles.find((candidate) => candidate.id === drone?.role_id);
405
412
  if (!drone || !role) throw new Error('Local Borg server no longer recognizes this drone seat');
406
413
  return {
407
414
  cube: cubePayload.cube,
408
415
  roles: rolePayload.roles,
409
- drones: dronePayload.drones,
416
+ drones,
410
417
  role,
411
418
  drone,
412
419
  };
413
420
  }
414
421
 
422
+ function withValidatedRuntimeMetadata<T extends Record<string, unknown>>(drone: T): T {
423
+ const state = decodeDroneRuntimeMetadataState(drone);
424
+ return {
425
+ ...drone,
426
+ ...state.runtime_metadata,
427
+ runtime_metadata_reported: state.runtime_metadata_reported,
428
+ };
429
+ }
430
+
431
+ async function updateOwnRuntimeMetadata(
432
+ active: ActiveCube,
433
+ patch: ReturnType<typeof buildRuntimeMetadataPatch>,
434
+ ): Promise<void> {
435
+ const payload = await localServerRequest<Record<string, unknown>>(
436
+ active,
437
+ `/api/cubes/${active.cubeId}/drones/self/metadata`,
438
+ 'PATCH',
439
+ { ...patch },
440
+ );
441
+ if (!payload) throw new Error('Local Borg server returned an empty runtime metadata response');
442
+ decodeUpdateDroneRuntimeMetadataResponse(payload);
443
+ }
444
+
415
445
  function localCursorBinding(active: ActiveCube) {
416
446
  return {
417
447
  origin: active.apiUrl,
@@ -673,7 +703,30 @@ async function authedFetch(
673
703
  AUTH_ERROR_ENVELOPE_LIMIT_BYTES,
674
704
  'Local Borg server error response exceeded the response limit',
675
705
  );
676
- code = decodeProtocolErrorEnvelope(JSON.parse(body)).error.code;
706
+ const parsed = JSON.parse(body);
707
+ try {
708
+ code = decodeProtocolErrorEnvelope(parsed).error.code;
709
+ } catch {
710
+ if (
711
+ parsed !== null && typeof parsed === 'object' &&
712
+ parsed.error !== null && typeof parsed.error === 'object' &&
713
+ parsed.error.code === ROLE_SECTION_CONFLICT_CODE
714
+ ) {
715
+ // Shared 0.6.2 intentionally omits this server-local code. Re-validate the whole
716
+ // envelope through the strict shared decoder with only the recognized
717
+ // code substituted; no server-provided diagnostic is ever surfaced.
718
+ decodeProtocolErrorEnvelope({
719
+ ...parsed,
720
+ error: {
721
+ ...parsed.error,
722
+ code: ErrorCode.INVALID_INPUT,
723
+ message: 'Role section conflict.',
724
+ ...(Object.hasOwn(parsed.error, 'details') ? { details: 'Redacted.' } : {}),
725
+ },
726
+ });
727
+ code = ROLE_SECTION_CONFLICT_CODE as ErrorCode;
728
+ }
729
+ }
677
730
  } catch {
678
731
  code = undefined;
679
732
  }
@@ -740,7 +793,7 @@ export async function whoami(
740
793
  sessionToken: string,
741
794
  apiUrl: string,
742
795
  serverTrustIdentity?: string,
743
- ): Promise<{ cube_id: string; cube_name: string; drone_id: string; drone_label: string; role_id: string; role_name: string }> {
796
+ ): Promise<{ cube_id: string; cube_name: string; drone_id: string; drone_label: string; role_id: string; role_name: string; runtime_metadata: { agent_kind: AgentKind | null; reported_model: string | null; working_repo_name: string | null; working_repo_origin: string | null }; runtime_metadata_reported: boolean }> {
744
797
  const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
745
798
  const composed = await localCubeComposition(local);
746
799
  return {
@@ -750,6 +803,13 @@ export async function whoami(
750
803
  drone_label: composed.drone.label,
751
804
  role_id: composed.role.id,
752
805
  role_name: composed.role.name,
806
+ runtime_metadata: {
807
+ agent_kind: composed.drone.agent_kind,
808
+ reported_model: composed.drone.reported_model,
809
+ working_repo_name: composed.drone.working_repo_name,
810
+ working_repo_origin: composed.drone.working_repo_origin,
811
+ },
812
+ runtime_metadata_reported: composed.drone.runtime_metadata_reported,
753
813
  };
754
814
  }
755
815
 
@@ -786,7 +846,7 @@ export async function getRoster(
786
846
  throw new Error('Local Borg server returned an incomplete roster response');
787
847
  }
788
848
  return {
789
- drones: dronePayload.drones,
849
+ drones: dronePayload.drones.map(withValidatedRuntimeMetadata),
790
850
  roles: rolePayload.roles,
791
851
  message_taxonomy: cubePayload.cube.message_taxonomy ?? null,
792
852
  since: dronePayload.since ?? since,
@@ -944,6 +1004,8 @@ export async function regen(
944
1004
  since?: string;
945
1005
  /** Advisory self-report from the running agent; never model-routing config. */
946
1006
  reportedModel?: string;
1007
+ /** Positively identified running Agent CLI; null means explicitly unknown. */
1008
+ agentKind?: AgentKind | null;
947
1009
  /** Current cwd-derived identity; refreshed each regen to avoid stale routing data. */
948
1010
  workingRepo?: WorkingRepo;
949
1011
  /** Verified self-hosted authority from the caller's first active-state read. */
@@ -970,6 +1032,24 @@ export async function regen(
970
1032
  apiUrl,
971
1033
  opts.serverTrustIdentity,
972
1034
  );
1035
+ if (
1036
+ opts.agentKind !== undefined ||
1037
+ opts.reportedModel !== undefined ||
1038
+ opts.workingRepo !== undefined
1039
+ ) {
1040
+ const patch = buildRuntimeMetadataPatch({
1041
+ agentKind: opts.agentKind ?? null,
1042
+ reportedModel: opts.reportedModel,
1043
+ workingRepo: opts.workingRepo,
1044
+ });
1045
+ try {
1046
+ await updateOwnRuntimeMetadata(local, patch);
1047
+ } catch {
1048
+ // Metadata is advisory. Preserve the prior server value and continue with
1049
+ // the authenticated identity read; never echo rejected local input.
1050
+ console.warn('Local regen: runtime metadata update unavailable; preserving the prior safe report.');
1051
+ }
1052
+ }
973
1053
  const composed = await localCubeComposition(local);
974
1054
  const cursor = opts.since === undefined
975
1055
  ? await getLocalServerCursor(localCursorBinding(local))
@@ -1355,19 +1435,36 @@ export async function patchRoleSection(
1355
1435
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1356
1436
  const cubeId = targetCubeId ?? active.cubeId;
1357
1437
  assertUuidShape(cubeId, 'cube_id');
1358
- const result = await localManageRequest<{ role: any }>(
1359
- active,
1360
- `/api/cubes/${cubeId}/roles/${roleId}/section-patch`,
1361
- 'POST',
1362
- {
1363
- operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
1364
- cubeName: cubeId === active.cubeId ? active.name : cubeId,
1365
- noMutation: `No role section was ${op.action === 'insert' ? 'inserted' : op.action === 'replace' ? 'replaced' : 'deleted'}.`,
1366
- },
1367
- { ...op },
1368
- undefined,
1369
- connectionOverride,
1370
- );
1438
+ let result: { role: any } | null;
1439
+ try {
1440
+ result = await localManageRequest<{ role: any }>(
1441
+ active,
1442
+ `/api/cubes/${cubeId}/roles/${roleId}/section-patch`,
1443
+ 'POST',
1444
+ {
1445
+ operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
1446
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1447
+ noMutation: `No role section was ${op.action === 'insert' ? 'inserted' : op.action === 'replace' ? 'replaced' : 'deleted'}.`,
1448
+ },
1449
+ { ...op },
1450
+ undefined,
1451
+ connectionOverride,
1452
+ );
1453
+ } catch (error) {
1454
+ if (
1455
+ error instanceof BorgServerHttpError &&
1456
+ error.status === 409 &&
1457
+ String(error.code) === ROLE_SECTION_CONFLICT_CODE
1458
+ ) {
1459
+ throw new RoleSectionConflictError({
1460
+ roleId,
1461
+ action: op.action,
1462
+ heading: op.heading,
1463
+ ...(op.action === 'insert' ? { after: op.after } : {}),
1464
+ });
1465
+ }
1466
+ throw error;
1467
+ }
1371
1468
  if (!result) throw new Error('Local Borg server returned an empty role response');
1372
1469
  return result;
1373
1470
  }
@@ -1494,16 +1591,17 @@ export async function getCube(cubeId: string, connection?: RemoteConnection): Pr
1494
1591
  return {
1495
1592
  ...cubePayload.cube,
1496
1593
  roles: rolePayload.roles,
1497
- drones: dronePayload.drones,
1594
+ drones: dronePayload.drones.map(withValidatedRuntimeMetadata),
1498
1595
  };
1499
1596
  }
1500
1597
 
1501
1598
  /**
1502
- * gh#473 PR2 — apply a named template to an existing cube via the
1503
- * NON-CLOBBERING server route. New roles are inserted; existing
1599
+ * Apply a named template through client-orchestrated, non-clobbering role and
1600
+ * taxonomy primitives. New roles are inserted; existing
1504
1601
  * template-named roles get ADD fragments auto-applied (template
1505
1602
  * sections/classes the cube lacks) but their EVOLVED (conflicting)
1506
- * fragments are surfaced server-side and KEPT, never overwritten. Returns
1603
+ * fragments are kept, never overwritten. Primitive operations are sequential,
1604
+ * so earlier changes may remain committed if a later operation fails. Returns
1507
1605
  * `{ created, updated }` counts. To selectively take template versions of
1508
1606
  * conflicting fragments, use `syncRoles` with a `decisions` map instead.
1509
1607
  */
@@ -1550,7 +1648,7 @@ export async function applyTemplate(
1550
1648
  }
1551
1649
 
1552
1650
  /**
1553
- * gh#473 PR2 — NON-CLOBBERING sync of a cube's roles + message_taxonomy
1651
+ * Client-orchestrated, non-clobbering sync of a cube's roles and taxonomy
1554
1652
  * against the current built-in template. Dry-run by default classifies
1555
1653
  * each fragment (role-text SECTION / short_description / flags / taxonomy
1556
1654
  * CLASS) as ADD / UNCHANGED / CONFLICT. Pass apply=true to commit:
@@ -1558,7 +1656,9 @@ export async function applyTemplate(
1558
1656
  * ONLY when their stable key appears in `decisions` as 'accept'.
1559
1657
  * Unspecified conflicts DEFAULT TO REJECT — the cube's evolved text is
1560
1658
  * never silently overwritten. Custom roles (names not in template) are
1561
- * never touched. Returns a NonClobberSyncResult.
1659
+ * never touched. Applied primitives are sequential rather than atomic, so an
1660
+ * earlier operation may remain committed if a later operation fails. Returns a
1661
+ * NonClobberSyncResult.
1562
1662
  */
1563
1663
  export async function syncRoles(
1564
1664
  cubeId: string,
@@ -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');