borgmcp 1.0.6 → 1.0.8

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 (160) hide show
  1. package/README.md +5 -3
  2. package/dist/assimilate-cmd.js +39 -511
  3. package/dist/assimilate-deps.js +3 -177
  4. package/dist/assimilate-welcome.js +2 -24
  5. package/dist/auth-env.js +1 -107
  6. package/dist/auth.js +23 -612
  7. package/dist/claude.js +11 -281
  8. package/dist/cli-help.js +29 -50
  9. package/dist/cli-platform.js +4 -94
  10. package/dist/codex-app-server.js +4 -228
  11. package/dist/codex-app-wake.js +2 -122
  12. package/dist/codex-launch.js +1 -81
  13. package/dist/codex-remote.js +1 -250
  14. package/dist/config-utils.js +3 -385
  15. package/dist/config.js +1 -190
  16. package/dist/console-prefix.js +1 -86
  17. package/dist/cube-name.js +1 -65
  18. package/dist/cubes.js +4 -269
  19. package/dist/debug.js +1 -71
  20. package/dist/device-auth.js +1 -167
  21. package/dist/direct-log.js +1 -11
  22. package/dist/health-beat.js +1 -168
  23. package/dist/inbox-monitor.js +1 -129
  24. package/dist/index.js +26 -1378
  25. package/dist/lifecycle-log-guard.js +2 -93
  26. package/dist/list-roles-render.js +6 -39
  27. package/dist/log-audit.js +3 -186
  28. package/dist/log-stream.js +9 -848
  29. package/dist/name-validator.js +1 -22
  30. package/dist/parse-assimilate-args.js +1 -82
  31. package/dist/postinstall.js +8 -22
  32. package/dist/regen-format.js +11 -337
  33. package/dist/regen.js +5 -83
  34. package/dist/remote-client.d.ts +4 -7
  35. package/dist/remote-client.js +1 -695
  36. package/dist/role-resolver.js +1 -36
  37. package/dist/role-section.js +8 -208
  38. package/dist/roster-render.js +3 -96
  39. package/dist/setup.js +41 -251
  40. package/dist/shell-escape.js +1 -22
  41. package/dist/spawn.js +10 -29
  42. package/dist/stale-version-check.js +1 -102
  43. package/dist/stream-owner.js +2 -202
  44. package/dist/stream-status.js +3 -211
  45. package/dist/subscription-retry.js +1 -23
  46. package/dist/sync-roles-render.js +3 -118
  47. package/dist/sync.js +22 -286
  48. package/dist/templates.js +120 -626
  49. package/dist/terminal-title.js +1 -68
  50. package/dist/token-crypto.js +1 -91
  51. package/dist/token-store.js +1 -222
  52. package/dist/types.d.ts +0 -5
  53. package/dist/types.js +0 -5
  54. package/dist/version.js +2 -78
  55. package/dist/worktree-lifecycle.js +2 -173
  56. package/package.json +12 -2
  57. package/dist/assimilate-cmd.d.ts.map +0 -1
  58. package/dist/assimilate-cmd.js.map +0 -1
  59. package/dist/assimilate-deps.d.ts.map +0 -1
  60. package/dist/assimilate-deps.js.map +0 -1
  61. package/dist/assimilate-welcome.d.ts.map +0 -1
  62. package/dist/assimilate-welcome.js.map +0 -1
  63. package/dist/auth-env.d.ts.map +0 -1
  64. package/dist/auth-env.js.map +0 -1
  65. package/dist/auth.d.ts.map +0 -1
  66. package/dist/auth.js.map +0 -1
  67. package/dist/claude.d.ts.map +0 -1
  68. package/dist/claude.js.map +0 -1
  69. package/dist/cli-help.d.ts.map +0 -1
  70. package/dist/cli-help.js.map +0 -1
  71. package/dist/cli-platform.d.ts.map +0 -1
  72. package/dist/cli-platform.js.map +0 -1
  73. package/dist/codex-app-server.d.ts.map +0 -1
  74. package/dist/codex-app-server.js.map +0 -1
  75. package/dist/codex-app-wake.d.ts.map +0 -1
  76. package/dist/codex-app-wake.js.map +0 -1
  77. package/dist/codex-launch.d.ts.map +0 -1
  78. package/dist/codex-launch.js.map +0 -1
  79. package/dist/codex-remote.d.ts.map +0 -1
  80. package/dist/codex-remote.js.map +0 -1
  81. package/dist/config-utils.d.ts.map +0 -1
  82. package/dist/config-utils.js.map +0 -1
  83. package/dist/config.d.ts.map +0 -1
  84. package/dist/config.js.map +0 -1
  85. package/dist/console-prefix.d.ts.map +0 -1
  86. package/dist/console-prefix.js.map +0 -1
  87. package/dist/cube-name.d.ts.map +0 -1
  88. package/dist/cube-name.js.map +0 -1
  89. package/dist/cubes.d.ts.map +0 -1
  90. package/dist/cubes.js.map +0 -1
  91. package/dist/debug.d.ts.map +0 -1
  92. package/dist/debug.js.map +0 -1
  93. package/dist/device-auth.d.ts.map +0 -1
  94. package/dist/device-auth.js.map +0 -1
  95. package/dist/direct-log.d.ts.map +0 -1
  96. package/dist/direct-log.js.map +0 -1
  97. package/dist/health-beat.d.ts.map +0 -1
  98. package/dist/health-beat.js.map +0 -1
  99. package/dist/inbox-monitor.d.ts.map +0 -1
  100. package/dist/inbox-monitor.js.map +0 -1
  101. package/dist/index.d.ts.map +0 -1
  102. package/dist/index.js.map +0 -1
  103. package/dist/lifecycle-log-guard.d.ts.map +0 -1
  104. package/dist/lifecycle-log-guard.js.map +0 -1
  105. package/dist/list-roles-render.d.ts.map +0 -1
  106. package/dist/list-roles-render.js.map +0 -1
  107. package/dist/log-audit.d.ts.map +0 -1
  108. package/dist/log-audit.js.map +0 -1
  109. package/dist/log-stream.d.ts.map +0 -1
  110. package/dist/log-stream.js.map +0 -1
  111. package/dist/name-validator.d.ts.map +0 -1
  112. package/dist/name-validator.js.map +0 -1
  113. package/dist/parse-assimilate-args.d.ts.map +0 -1
  114. package/dist/parse-assimilate-args.js.map +0 -1
  115. package/dist/postinstall.d.ts.map +0 -1
  116. package/dist/postinstall.js.map +0 -1
  117. package/dist/regen-format.d.ts.map +0 -1
  118. package/dist/regen-format.js.map +0 -1
  119. package/dist/regen.d.ts.map +0 -1
  120. package/dist/regen.js.map +0 -1
  121. package/dist/remote-client.d.ts.map +0 -1
  122. package/dist/remote-client.js.map +0 -1
  123. package/dist/role-resolver.d.ts.map +0 -1
  124. package/dist/role-resolver.js.map +0 -1
  125. package/dist/role-section.d.ts.map +0 -1
  126. package/dist/role-section.js.map +0 -1
  127. package/dist/roster-render.d.ts.map +0 -1
  128. package/dist/roster-render.js.map +0 -1
  129. package/dist/setup.d.ts.map +0 -1
  130. package/dist/setup.js.map +0 -1
  131. package/dist/shell-escape.d.ts.map +0 -1
  132. package/dist/shell-escape.js.map +0 -1
  133. package/dist/spawn.d.ts.map +0 -1
  134. package/dist/spawn.js.map +0 -1
  135. package/dist/stale-version-check.d.ts.map +0 -1
  136. package/dist/stale-version-check.js.map +0 -1
  137. package/dist/stream-owner.d.ts.map +0 -1
  138. package/dist/stream-owner.js.map +0 -1
  139. package/dist/stream-status.d.ts.map +0 -1
  140. package/dist/stream-status.js.map +0 -1
  141. package/dist/subscription-retry.d.ts.map +0 -1
  142. package/dist/subscription-retry.js.map +0 -1
  143. package/dist/sync-roles-render.d.ts.map +0 -1
  144. package/dist/sync-roles-render.js.map +0 -1
  145. package/dist/sync.d.ts.map +0 -1
  146. package/dist/sync.js.map +0 -1
  147. package/dist/templates.d.ts.map +0 -1
  148. package/dist/templates.js.map +0 -1
  149. package/dist/terminal-title.d.ts.map +0 -1
  150. package/dist/terminal-title.js.map +0 -1
  151. package/dist/token-crypto.d.ts.map +0 -1
  152. package/dist/token-crypto.js.map +0 -1
  153. package/dist/token-store.d.ts.map +0 -1
  154. package/dist/token-store.js.map +0 -1
  155. package/dist/types.d.ts.map +0 -1
  156. package/dist/types.js.map +0 -1
  157. package/dist/version.d.ts.map +0 -1
  158. package/dist/version.js.map +0 -1
  159. package/dist/worktree-lifecycle.d.ts.map +0 -1
  160. package/dist/worktree-lifecycle.js.map +0 -1
@@ -1,202 +1,2 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { promises as fs } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import path from 'node:path';
5
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
- const STREAM_LOCKS_DIR = path.join(homedir(), '.config', 'borgmcp', 'stream-locks');
7
- const OWNER_FILE = 'owner.json';
8
- const SCHEMA_VERSION = 1;
9
- export const STREAM_OWNER_STALE_MS = 70_000;
10
- const processNonce = randomUUID();
11
- const processStartedAt = new Date().toISOString();
12
- export function streamLockPath(cubeId, droneId, locksDir = STREAM_LOCKS_DIR) {
13
- assertUuid('cubeId', cubeId);
14
- assertUuid('droneId', droneId);
15
- return path.join(locksDir, cubeId, `${droneId}.lock`);
16
- }
17
- export async function acquireStreamLease(cubeId, droneId, staleMs = STREAM_OWNER_STALE_MS, deps = {}) {
18
- const lockPath = streamLockPath(cubeId, droneId, deps.locksDir);
19
- await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
20
- const lease = await tryCreateLease(lockPath, deps);
21
- if (lease)
22
- return lease;
23
- const snapshot = await readOwnershipSnapshot(cubeId, droneId, deps);
24
- if (snapshot.state !== 'owned-by-other-process') {
25
- return null;
26
- }
27
- const stale = (snapshot.ageMs ?? 0) > staleMs;
28
- const pidDead = typeof snapshot.pid === 'number' &&
29
- deps.isPidAlive !== undefined &&
30
- !deps.isPidAlive(snapshot.pid);
31
- if (!stale && !pidDead) {
32
- return null;
33
- }
34
- if (!(await moveStaleLockAside(lockPath, snapshot, staleMs, deps))) {
35
- return null;
36
- }
37
- return tryCreateLease(lockPath, deps);
38
- }
39
- export async function readOwnershipSnapshot(cubeId, droneId, deps = {}) {
40
- const lockPath = streamLockPath(cubeId, droneId, deps.locksDir);
41
- let raw;
42
- try {
43
- raw = await fs.readFile(path.join(lockPath, OWNER_FILE), 'utf8');
44
- }
45
- catch (err) {
46
- if (err?.code === 'ENOENT')
47
- return { state: 'unowned', lockPath };
48
- throw err;
49
- }
50
- let parsed;
51
- try {
52
- parsed = JSON.parse(raw);
53
- }
54
- catch {
55
- return { state: 'owned-by-other-process', lockPath, ageMs: Number.POSITIVE_INFINITY };
56
- }
57
- if (!isRecord(parsed)) {
58
- return { state: 'owned-by-other-process', lockPath, ageMs: Number.POSITIVE_INFINITY };
59
- }
60
- const now = (deps.now ?? (() => new Date()))();
61
- const heartbeatMs = Date.parse(parsed.heartbeatAt);
62
- const ageMs = Number.isFinite(heartbeatMs) ? now.getTime() - heartbeatMs : Number.POSITIVE_INFINITY;
63
- const ownPid = deps.pid ?? process.pid;
64
- const ownNonce = deps.processNonce ?? processNonce;
65
- const state = parsed.pid === ownPid && parsed.processNonce === ownNonce
66
- ? 'owner'
67
- : 'owned-by-other-process';
68
- return {
69
- state,
70
- pid: parsed.pid,
71
- processNonce: parsed.processNonce,
72
- cwd: parsed.cwd,
73
- startedAt: parsed.startedAt,
74
- heartbeatAt: parsed.heartbeatAt,
75
- ageMs,
76
- lockPath,
77
- };
78
- }
79
- async function tryCreateLease(lockPath, deps) {
80
- try {
81
- await fs.mkdir(lockPath, { mode: 0o700 });
82
- }
83
- catch (err) {
84
- if (err?.code === 'EEXIST')
85
- return null;
86
- throw err;
87
- }
88
- const record = makeRecord(deps);
89
- await writeRecord(lockPath, record);
90
- return makeLease(lockPath, record, deps);
91
- }
92
- async function moveStaleLockAside(lockPath, snapshot, staleMs, deps) {
93
- const takeoverPath = `${lockPath}.takeover-${deps.processNonce ?? processNonce}-${Date.now()}`;
94
- try {
95
- await fs.rename(lockPath, takeoverPath);
96
- }
97
- catch (err) {
98
- if (err?.code === 'ENOENT')
99
- return false;
100
- throw err;
101
- }
102
- await deps.beforeTakeoverVerify?.(takeoverPath);
103
- const verified = await readOwnershipRecord(takeoverPath);
104
- if (!isStillReclaimable(snapshot, verified, staleMs, deps)) {
105
- try {
106
- await fs.rename(takeoverPath, lockPath);
107
- }
108
- catch (err) {
109
- if (err?.code !== 'EEXIST')
110
- throw err;
111
- await fs.rm(takeoverPath, { recursive: true, force: true });
112
- }
113
- return false;
114
- }
115
- await fs.rm(takeoverPath, { recursive: true, force: true });
116
- return true;
117
- }
118
- function isStillReclaimable(snapshot, current, staleMs, deps) {
119
- if (!current) {
120
- return snapshot.ageMs === Number.POSITIVE_INFINITY;
121
- }
122
- if (snapshot.pid !== current.pid ||
123
- snapshot.processNonce !== current.processNonce ||
124
- snapshot.heartbeatAt !== current.heartbeatAt) {
125
- return false;
126
- }
127
- const now = (deps.now ?? (() => new Date()))();
128
- const heartbeatMs = Date.parse(current.heartbeatAt);
129
- const ageMs = Number.isFinite(heartbeatMs)
130
- ? now.getTime() - heartbeatMs
131
- : Number.POSITIVE_INFINITY;
132
- const stale = ageMs > staleMs;
133
- const pidDead = deps.isPidAlive !== undefined && !deps.isPidAlive(current.pid);
134
- return stale || pidDead;
135
- }
136
- function makeLease(lockPath, record, deps) {
137
- return {
138
- lockPath,
139
- record,
140
- async refresh() {
141
- const current = await readOwnershipRecord(lockPath);
142
- if (!current || current.pid !== record.pid || current.processNonce !== record.processNonce) {
143
- return false;
144
- }
145
- const next = { ...record, heartbeatAt: (deps.now ?? (() => new Date()))().toISOString() };
146
- await writeRecord(lockPath, next);
147
- this.record = next;
148
- return true;
149
- },
150
- async release() {
151
- const current = await readOwnershipRecord(lockPath);
152
- if (current?.pid === record.pid && current.processNonce === record.processNonce) {
153
- await fs.rm(lockPath, { recursive: true, force: true });
154
- }
155
- },
156
- };
157
- }
158
- async function readOwnershipRecord(lockPath) {
159
- try {
160
- const raw = await fs.readFile(path.join(lockPath, OWNER_FILE), 'utf8');
161
- const parsed = JSON.parse(raw);
162
- return isRecord(parsed) ? parsed : null;
163
- }
164
- catch {
165
- return null;
166
- }
167
- }
168
- async function writeRecord(lockPath, record) {
169
- const ownerPath = path.join(lockPath, OWNER_FILE);
170
- const tmpPath = path.join(lockPath, `${OWNER_FILE}.${record.processNonce}.tmp`);
171
- await fs.writeFile(tmpPath, JSON.stringify(record, null, 2) + '\n', {
172
- mode: 0o600,
173
- });
174
- await fs.rename(tmpPath, ownerPath);
175
- }
176
- function makeRecord(deps) {
177
- const now = deps.now ?? (() => new Date());
178
- return {
179
- schemaVersion: SCHEMA_VERSION,
180
- pid: deps.pid ?? process.pid,
181
- processNonce: deps.processNonce ?? processNonce,
182
- cwd: deps.cwd ?? process.cwd(),
183
- startedAt: deps.processStartedAt ?? processStartedAt,
184
- heartbeatAt: now().toISOString(),
185
- };
186
- }
187
- function isRecord(value) {
188
- return (value !== null &&
189
- typeof value === 'object' &&
190
- value.schemaVersion === SCHEMA_VERSION &&
191
- typeof value.pid === 'number' &&
192
- Number.isInteger(value.pid) &&
193
- typeof value.processNonce === 'string' &&
194
- typeof value.cwd === 'string' &&
195
- typeof value.startedAt === 'string' &&
196
- typeof value.heartbeatAt === 'string');
197
- }
198
- function assertUuid(label, value) {
199
- if (!UUID_RE.test(value))
200
- throw new Error(`Invalid ${label}: ${value}`);
201
- }
202
- //# sourceMappingURL=stream-owner.js.map
1
+ import{randomUUID as E}from"node:crypto";import{promises as a}from"node:fs";import{homedir as g}from"node:os";import c from"node:path";const T=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,O=c.join(g(),".config","borgmcp","stream-locks"),w="owner.json",N=1,D=7e4,p=E(),_=new Date().toISOString();function I(e,t,n=O){return y("cubeId",e),y("droneId",t),c.join(n,e,`${t}.lock`)}async function x(e,t,n=D,r={}){const o=I(e,t,r.locksDir);await a.mkdir(c.dirname(o),{recursive:!0,mode:448});const s=await h(o,r);if(s)return s;const i=await M(e,t,r);if(i.state!=="owned-by-other-process")return null;const u=(i.ageMs??0)>n,f=typeof i.pid=="number"&&r.isPidAlive!==void 0&&!r.isPidAlive(i.pid);return!u&&!f||!await R(o,i,n,r)?null:h(o,r)}async function M(e,t,n={}){const r=I(e,t,n.locksDir);let o;try{o=await a.readFile(c.join(r,w),"utf8")}catch(m){if(m?.code==="ENOENT")return{state:"unowned",lockPath:r};throw m}let s;try{s=JSON.parse(o)}catch{return{state:"owned-by-other-process",lockPath:r,ageMs:Number.POSITIVE_INFINITY}}if(!b(s))return{state:"owned-by-other-process",lockPath:r,ageMs:Number.POSITIVE_INFINITY};const i=(n.now??(()=>new Date))(),u=Date.parse(s.heartbeatAt),f=Number.isFinite(u)?i.getTime()-u:Number.POSITIVE_INFINITY,l=n.pid??process.pid,A=n.processNonce??p;return{state:s.pid===l&&s.processNonce===A?"owner":"owned-by-other-process",pid:s.pid,processNonce:s.processNonce,cwd:s.cwd,startedAt:s.startedAt,heartbeatAt:s.heartbeatAt,ageMs:f,lockPath:r}}async function h(e,t){try{await a.mkdir(e,{mode:448})}catch(r){if(r?.code==="EEXIST")return null;throw r}const n=$(t);return await S(e,n),V(e,n,t)}async function R(e,t,n,r){const o=`${e}.takeover-${r.processNonce??p}-${Date.now()}`;try{await a.rename(e,o)}catch(i){if(i?.code==="ENOENT")return!1;throw i}await r.beforeTakeoverVerify?.(o);const s=await d(o);if(!F(t,s,n,r)){try{await a.rename(o,e)}catch(i){if(i?.code!=="EEXIST")throw i;await a.rm(o,{recursive:!0,force:!0})}return!1}return await a.rm(o,{recursive:!0,force:!0}),!0}function F(e,t,n,r){if(!t)return e.ageMs===Number.POSITIVE_INFINITY;if(e.pid!==t.pid||e.processNonce!==t.processNonce||e.heartbeatAt!==t.heartbeatAt)return!1;const o=(r.now??(()=>new Date))(),s=Date.parse(t.heartbeatAt),u=(Number.isFinite(s)?o.getTime()-s:Number.POSITIVE_INFINITY)>n,f=r.isPidAlive!==void 0&&!r.isPidAlive(t.pid);return u||f}function V(e,t,n){return{lockPath:e,record:t,async refresh(){const r=await d(e);if(!r||r.pid!==t.pid||r.processNonce!==t.processNonce)return!1;const o={...t,heartbeatAt:(n.now??(()=>new Date))().toISOString()};return await S(e,o),this.record=o,!0},async release(){const r=await d(e);r?.pid===t.pid&&r.processNonce===t.processNonce&&await a.rm(e,{recursive:!0,force:!0})}}}async function d(e){try{const t=await a.readFile(c.join(e,w),"utf8"),n=JSON.parse(t);return b(n)?n:null}catch{return null}}async function S(e,t){const n=c.join(e,w),r=c.join(e,`${w}.${t.processNonce}.tmp`);await a.writeFile(r,JSON.stringify(t,null,2)+`
2
+ `,{mode:384}),await a.rename(r,n)}function $(e){const t=e.now??(()=>new Date);return{schemaVersion:N,pid:e.pid??process.pid,processNonce:e.processNonce??p,cwd:e.cwd??process.cwd(),startedAt:e.processStartedAt??_,heartbeatAt:t().toISOString()}}function b(e){return e!==null&&typeof e=="object"&&e.schemaVersion===N&&typeof e.pid=="number"&&Number.isInteger(e.pid)&&typeof e.processNonce=="string"&&typeof e.cwd=="string"&&typeof e.startedAt=="string"&&typeof e.heartbeatAt=="string"}function y(e,t){if(!T.test(t))throw new Error(`Invalid ${e}: ${t}`)}export{D as STREAM_OWNER_STALE_MS,x as acquireStreamLease,M as readOwnershipSnapshot,I as streamLockPath};
@@ -1,211 +1,3 @@
1
- /**
2
- * Renderer + inbox-Monitor liveness probe for `borg:stream-status`.
3
- *
4
- * Split out from `index.ts` so the 5-state precedence logic and the
5
- * `pgrep`-based liveness check can be unit-tested without spinning up
6
- * the MCP server. drone-4's 18:30:51 UX contract is the spec for the
7
- * rendered output shape; this module is the implementation surface.
8
- *
9
- * Top-line states (drone-4 contract):
10
- * 1. Stream not started.
11
- * 2. Stream connected, awaiting first content event.
12
- * 3. Stream connected, last content <X> ago.
13
- * 4. Stream disconnected (reconnect attempt N).
14
- * 5. Stream connected (no inbox-Monitor — wake path broken).
15
- *
16
- * Precedence when both `disconnected` and `no inbox-Monitor` apply:
17
- * prefer (4) — wire-disconnect is the upstream cause and resolves
18
- * automatically when the wire comes back up; State 5 only matters
19
- * when the wire is healthy but the file-watch isn't.
20
- */
21
- import { spawnSync } from 'node:child_process';
22
- /**
23
- * Best-effort check: is a process tailing this inbox file?
24
- *
25
- * Returns:
26
- * - true: at least one process matches `tail.*<inboxPath>` in pgrep
27
- * - false: pgrep ran cleanly and found no match
28
- * - null: cannot determine (pgrep unavailable, spawn error, no inbox path)
29
- *
30
- * The null case is informative — it means we don't know, so the
31
- * renderer must NOT fire State 5 (which would be misleading). State 5
32
- * only fires when we positively know the wake path is broken.
33
- *
34
- * Why `pgrep` and not a more elegant check: Claude Code Monitors are
35
- * tail-based subprocesses spawned by the harness, completely opaque to
36
- * the MCP server. The MCP server has no IPC channel into the harness's
37
- * task table. The cheapest reliable signal we can get from inside the
38
- * MCP server is "is there a tail subprocess open against this path?"
39
- * — which is what `pgrep -f` answers.
40
- *
41
- * macOS + Linux ship `pgrep`. Windows doesn't (borgmcp targets Mac /
42
- * Linux per package.json `os` field; the null branch handles other
43
- * platforms gracefully).
44
- */
45
- export function checkInboxMonitorHealthy(inboxPath) {
46
- if (!inboxPath)
47
- return null;
48
- try {
49
- // `-f` matches against the full command line so we catch the
50
- // `tail -n 0 -F <inboxPath>` form. `-l` lists matches; we only
51
- // need the exit code (0 = match, 1 = no match) and a sanity check
52
- // on stdout (some pgrep variants exit 0 with empty stdout under
53
- // permission errors — treat empty stdout as "no match" for safety).
54
- const res = spawnSync('pgrep', ['-f', inboxPath], {
55
- encoding: 'utf-8',
56
- timeout: 2_000,
57
- });
58
- if (res.error)
59
- return null;
60
- if (res.status === 0 && res.stdout.trim().length > 0)
61
- return true;
62
- if (res.status === 1)
63
- return false;
64
- // pgrep exits 2 for syntax error, 3 for fatal — treat as unknown.
65
- return null;
66
- }
67
- catch {
68
- return null;
69
- }
70
- }
71
- /**
72
- * Render the `borg:stream-status` markdown body per drone-4's 18:30:51
73
- * contract. Pure function — no I/O, no clock reads. Caller assembles
74
- * the inputs.
75
- */
76
- export function renderStreamStatus(inputs) {
77
- const { status, inboxMonitorHealthy, inboxPath, droneLabel, cubeName, humanAgo } = inputs;
78
- const isNotStarted = status.reconnectAttempts === 0 &&
79
- status.lastWireActivityAt === null &&
80
- !status.connected;
81
- const ownedByOther = status.ownership?.state === 'owned-by-other-process';
82
- // Top-line verdict — 5 states + override per drone-4 contract.
83
- // Precedence: disconnected > no-inbox-Monitor (wire-down upstream
84
- // cause; State 5 only applies when wire is healthy).
85
- let summary;
86
- if (ownedByOther) {
87
- summary = '**Stream owned by another Borg MCP process.**';
88
- }
89
- else if (isNotStarted) {
90
- summary = '**Stream not started.**';
91
- }
92
- else if (!status.connected) {
93
- summary = `**Stream disconnected (reconnect attempt ${status.reconnectAttempts}).**`;
94
- }
95
- else if (inboxMonitorHealthy === false) {
96
- summary = '**Stream connected (no inbox-Monitor — wake path broken).**';
97
- }
98
- else if (status.lastContentEventAt === null) {
99
- // State 2: wire works, no content yet. Collapses two underlying
100
- // conditions per drone-4 contract — fresh connect pre-first-content
101
- // and quiet cube post-reconnect. The body's heartbeat field
102
- // distinguishes them (populated vs `_(none)_`).
103
- summary = '**Stream connected, awaiting first content event.**';
104
- }
105
- else {
106
- summary = `**Stream connected, last content ${humanAgo(new Date(status.lastContentEventAt))}.**`;
107
- }
108
- const lines = [];
109
- lines.push(summary);
110
- lines.push('');
111
- lines.push('# Log-stream status');
112
- lines.push('');
113
- if (ownedByOther) {
114
- lines.push('- **state**: _(stream owner is another local process)_');
115
- }
116
- else if (isNotStarted) {
117
- lines.push('- **state**: _(stream not started)_');
118
- }
119
- else {
120
- lines.push(`- **connected**: ${status.connected}`);
121
- }
122
- // Body shape per drone-4 contract: three timestamp lines (content,
123
- // heartbeat, wire) — looks redundant in the common case where they
124
- // coincide, but the asymmetric "content quiet, heartbeats alive" case
125
- // is exactly the diagnostic scenario this surface exists to support.
126
- lines.push(`- **last content event**: ${status.lastContentEventAt
127
- ? `${status.lastContentEventAt} (${humanAgo(new Date(status.lastContentEventAt))})`
128
- : '_(none yet)_'}`);
129
- lines.push(`- **last heartbeat at**: ${status.lastHeartbeatAt
130
- ? `${status.lastHeartbeatAt} (${humanAgo(new Date(status.lastHeartbeatAt))})`
131
- : '_(none)_'}`);
132
- lines.push(`- **last wire activity**: ${status.lastWireActivityAt
133
- ? `${status.lastWireActivityAt} (${humanAgo(new Date(status.lastWireActivityAt))})`
134
- : '_(none)_'}`);
135
- lines.push(`- **last persisted event id**: ${status.lastPersistedEventId ?? '_(none)_'}`);
136
- lines.push(`- **reconnect attempts**: ${status.reconnectAttempts}`);
137
- if (ownedByOther) {
138
- const owner = status.ownership;
139
- lines.push(`- **stream owner pid**: ${owner.pid ?? '_(unknown)_'}`);
140
- lines.push(`- **stream owner cwd**: ${owner.cwd ?? '_(unknown)_'}`);
141
- lines.push(`- **stream owner heartbeat age**: ${typeof owner.ageMs === 'number' && Number.isFinite(owner.ageMs)
142
- ? `${Math.max(0, Math.round(owner.ageMs / 1000))}s`
143
- : '_(unknown)_'}`);
144
- }
145
- // State-5 body line + self-arm instruction. Only fires when we
146
- // POSITIVELY detected wake-path breakage AND the wire is up — the
147
- // disconnected case takes precedence above and would never reach
148
- // here. When `inboxMonitorHealthy === null` (couldn't determine) we
149
- // stay silent; surfacing an uncertain failure mode is worse UX than
150
- // omitting it.
151
- if (status.connected && inboxMonitorHealthy === false) {
152
- lines.push(`- **inbox-monitor**: _(no watcher detected — wake path broken)_`);
153
- if (inboxPath && droneLabel && cubeName) {
154
- lines.push('');
155
- lines.push('## Real-time wake-up (do this now)');
156
- lines.push('No process is tailing this drone\'s inbox file — this session won\'t wake on real-time cube activity, only on its fallback timer, and will miss live coordination from other drones. Arm an inbox Monitor:');
157
- lines.push('');
158
- lines.push(`> Monitor command: \`tail -n 0 -F ${inboxPath}\` — persistent, 1h timeout, description "borg inbox for ${droneLabel} on cube ${cubeName}".`);
159
- }
160
- }
161
- return lines.join('\n');
162
- }
163
- /**
164
- * Gate predicate for the regen wake-path warning (gh#51 — extracted
165
- * from the inline ternary in `client/src/index.ts` for direct unit-test
166
- * coverage of the (connected × healthy) cross-product).
167
- *
168
- * Returns true ONLY when the wire is up AND we positively detected a
169
- * dead inbox Monitor (`=== false` strict). The `null` branch
170
- * (couldn't determine) stays silent — surfacing an uncertain failure
171
- * mode is worse UX than omitting it (mirrors the State-5 precedence
172
- * rule in `renderStreamStatus`). When disconnected, the wire-down case
173
- * is the upstream cause and takes precedence; no point warning about
174
- * the wake path when the wake-path's input has no events to deliver.
175
- */
176
- export function shouldShowWakePathWarning(streamStatus, inboxMonitorHealthy) {
177
- return streamStatus.connected && inboxMonitorHealthy === false;
178
- }
179
- /**
180
- * Wake-path-broken prefix for `borg:regen` output (gh#43).
181
- *
182
- * Pure function — caller decides whether to call (gates on
183
- * `shouldShowWakePathWarning`). Returns an empty string when called
184
- * with insufficient context to render the Monitor command (e.g., no
185
- * inbox path on a no-active-cube path), so callers can always prepend
186
- * the result unconditionally.
187
- *
188
- * Mirrors the State-5 self-arm instruction shape in
189
- * `renderStreamStatus` so a drone sees the same Monitor command via
190
- * both `borg:stream-status` and `borg:regen`. The differentiator: regen
191
- * runs on every /loop iteration, so the prefix gives passive
192
- * self-healing (worst-case latency = the /loop fallback heartbeat),
193
- * whereas stream-status only surfaces the warning when actively
194
- * called.
195
- */
196
- export function formatWakePathPrefix(inputs) {
197
- const { inboxPath, droneLabel, cubeName } = inputs;
198
- if (!inboxPath || !droneLabel || !cubeName)
199
- return '';
200
- return [
201
- `## ⚠ Wake path broken — arm Monitor NOW`,
202
- ``,
203
- `No process is tailing this drone's inbox file. SSE delivery is healthy (entries reach disk), but Claude Code has no event source to wake on. Until you arm a Monitor, this session only wakes on the /loop fallback heartbeat and will miss live coordination from other drones:`,
204
- ``,
205
- `> Monitor command: \`tail -n 0 -F ${inboxPath}\` — persistent, 1h timeout, description "borg inbox for ${droneLabel} on cube ${cubeName}".`,
206
- ``,
207
- `---`,
208
- ``,
209
- ].join('\n');
210
- }
211
- //# sourceMappingURL=stream-status.js.map
1
+ import{spawnSync as p}from"node:child_process";function m(n){if(!n)return null;try{const t=p("pgrep",["-f",n],{encoding:"utf-8",timeout:2e3});return t.error?null:t.status===0&&t.stdout.trim().length>0?!0:t.status===1?!1:null}catch{return null}}function f(n){const{status:t,inboxMonitorHealthy:s,inboxPath:r,droneLabel:l,cubeName:u,humanAgo:i}=n,h=t.reconnectAttempts===0&&t.lastWireActivityAt===null&&!t.connected,c=t.ownership?.state==="owned-by-other-process";let o;c?o="**Stream owned by another Borg MCP process.**":h?o="**Stream not started.**":t.connected?s===!1?o="**Stream connected (no inbox-Monitor \u2014 wake path broken).**":t.lastContentEventAt===null?o="**Stream connected, awaiting first content event.**":o=`**Stream connected, last content ${i(new Date(t.lastContentEventAt))}.**`:o=`**Stream disconnected (reconnect attempt ${t.reconnectAttempts}).**`;const e=[];if(e.push(o),e.push(""),e.push("# Log-stream status"),e.push(""),c?e.push("- **state**: _(stream owner is another local process)_"):h?e.push("- **state**: _(stream not started)_"):e.push(`- **connected**: ${t.connected}`),e.push(`- **last content event**: ${t.lastContentEventAt?`${t.lastContentEventAt} (${i(new Date(t.lastContentEventAt))})`:"_(none yet)_"}`),e.push(`- **last heartbeat at**: ${t.lastHeartbeatAt?`${t.lastHeartbeatAt} (${i(new Date(t.lastHeartbeatAt))})`:"_(none)_"}`),e.push(`- **last wire activity**: ${t.lastWireActivityAt?`${t.lastWireActivityAt} (${i(new Date(t.lastWireActivityAt))})`:"_(none)_"}`),e.push(`- **last persisted event id**: ${t.lastPersistedEventId??"_(none)_"}`),e.push(`- **reconnect attempts**: ${t.reconnectAttempts}`),c){const a=t.ownership;e.push(`- **stream owner pid**: ${a.pid??"_(unknown)_"}`),e.push(`- **stream owner cwd**: ${a.cwd??"_(unknown)_"}`),e.push(`- **stream owner heartbeat age**: ${typeof a.ageMs=="number"&&Number.isFinite(a.ageMs)?`${Math.max(0,Math.round(a.ageMs/1e3))}s`:"_(unknown)_"}`)}return t.connected&&s===!1&&(e.push("- **inbox-monitor**: _(no watcher detected \u2014 wake path broken)_"),r&&l&&u&&(e.push(""),e.push("## Real-time wake-up (do this now)"),e.push("No process is tailing this drone's inbox file \u2014 this session won't wake on real-time cube activity, only on its fallback timer, and will miss live coordination from other drones. Arm an inbox Monitor:"),e.push(""),e.push(`> Monitor command: \`tail -n 0 -F ${r}\` \u2014 persistent, 1h timeout, description "borg inbox for ${l} on cube ${u}".`))),e.join(`
2
+ `)}function b(n,t){return n.connected&&t===!1}function w(n){const{inboxPath:t,droneLabel:s,cubeName:r}=n;return!t||!s||!r?"":["## \u26A0 Wake path broken \u2014 arm Monitor NOW","","No process is tailing this drone's inbox file. SSE delivery is healthy (entries reach disk), but Claude Code has no event source to wake on. Until you arm a Monitor, this session only wakes on the /loop fallback heartbeat and will miss live coordination from other drones:","",`> Monitor command: \`tail -n 0 -F ${t}\` \u2014 persistent, 1h timeout, description "borg inbox for ${s} on cube ${r}".`,"","---",""].join(`
3
+ `)}export{m as checkInboxMonitorHealthy,w as formatWakePathPrefix,f as renderStreamStatus,b as shouldShowWakePathWarning};
@@ -1,23 +1 @@
1
- /**
2
- * Given the initial check result, retry up to `attempts` total times with
3
- * `backoffMs` backoff, stopping early as soon as access is granted. A transient
4
- * error on a retry is swallowed (keep the prior status and keep trying). Returns
5
- * the latest status.
6
- */
7
- export async function retrySubscriptionCheck(initial, deps) {
8
- const attempts = deps.attempts ?? 3;
9
- const backoffMs = deps.backoffMs ?? 2000;
10
- let status = initial;
11
- for (let attempt = 2; attempt <= attempts && !status.hasAccess; attempt++) {
12
- deps.onRetry?.(attempt, attempts);
13
- await deps.sleep(backoffMs);
14
- try {
15
- status = await deps.check();
16
- }
17
- catch {
18
- // Transient error on a retry — keep the prior status and continue retrying.
19
- }
20
- }
21
- return status;
22
- }
23
- //# sourceMappingURL=subscription-retry.js.map
1
+ async function r(s,t){const e=t.attempts??3,o=t.backoffMs??2e3;let c=s;for(let a=2;a<=e&&!c.hasAccess;a++){t.onRetry?.(a,e),await t.sleep(o);try{c=await t.check()}catch{}}return c}export{r as retrySubscriptionCheck};
@@ -1,118 +1,3 @@
1
- /**
2
- * gh#473 PR2 non-clobbering sync output rendering.
3
- *
4
- * The dry-run output is UX-LOAD-BEARING: it must CLEARLY communicate each
5
- * conflict (which role/section or taxonomy class, cube-current vs
6
- * template-new, and how to accept) so the operator SEES what would be
7
- * clobbered. Pure string logic (mirrors `roster-render.ts` /
8
- * `list-roles-render.ts`) so it is unit-testable without the MCP runtime.
9
- *
10
- * The shape mirrors the worker's `NonClobberSyncResult`.
11
- */
12
- /** Truncate long fragment bodies for at-a-glance diffs. */
13
- function trunc(s, n = 200) {
14
- if (s == null)
15
- return '(absent)';
16
- const flat = s.replace(/\n/g, '⏎');
17
- return flat.length > n ? flat.slice(0, n) + '…' : flat;
18
- }
19
- /**
20
- * Render a `NonClobberSyncResult` as an operator-facing markdown report.
21
- *
22
- * Conflicts are the headline: each is surfaced with both sides + its
23
- * stable accept key, and the report states explicitly that conflicts are
24
- * KEPT (the cube's version) unless accepted. ADDs are reported as safe
25
- * auto-applies. Custom roles are reported untouched.
26
- */
27
- export function renderSyncRolesResult(result, templateName) {
28
- // Defensive guard against client/worker deploy skew (gh#9 class). A
29
- // pre-#473 worker returns the legacy sync-roles shape
30
- // ({ updated, added, unchanged, skipped, dryRun }) with no `roles[]`, so
31
- // `result.roles.flatMap(...)` below would throw `undefined.flatMap`.
32
- // Detect the legacy shape and render an actionable message instead of
33
- // crashing — the skew window (0.9.47+ client + pre-#473 worker) becomes a
34
- // clean "redeploy the worker" prompt, not an exception.
35
- const maybeLegacy = result;
36
- if (maybeLegacy.roles === undefined && 'updated' in maybeLegacy) {
37
- return [
38
- `## borg:sync-roles — unavailable (server out of date)`,
39
- ``,
40
- `The borg server returned the legacy sync-roles response shape — it is running a version older than #473, which the non-clobbering sync view does not support.`,
41
- ``,
42
- `**Action:** a server (worker) deploy is pending. Retry \`borg:sync-roles\` once it lands.`,
43
- ].join('\n');
44
- }
45
- const mode = result.dryRun
46
- ? '**DRY RUN** (review conflicts below; re-run with `apply: true` + a `decisions` map to commit)'
47
- : '**APPLIED**';
48
- const lines = [`## borg:sync-roles — ${mode}`, `Template: ${templateName}`, ''];
49
- // Gather all fragments across roles + taxonomy for tallying.
50
- const allFragments = [
51
- ...result.roles.flatMap((r) => r.fragments),
52
- ...result.taxonomy,
53
- ];
54
- const conflicts = allFragments.filter((f) => f.kind === 'conflict');
55
- const adds = allFragments.filter((f) => f.kind === 'add');
56
- const newRoles = result.roles.filter((r) => r.status === 'new');
57
- const customRoles = result.roles.filter((r) => r.status === 'custom-skipped');
58
- // ── Conflicts (the headline — what would be clobbered) ──
59
- if (conflicts.length > 0) {
60
- lines.push(`### ⚠ ${conflicts.length} CONFLICT(s) — these fragments differ between your cube and the template`);
61
- if (result.dryRun) {
62
- lines.push('These differ between your cube and the template — may be because you evolved them, or because the template changed them. ' +
63
- 'Surfaced for review, never silently overwritten. Each defaults to **KEEP (reject)** — your version survives. ' +
64
- 'To take the template version of a specific fragment, pass its key in `decisions` as `"<key>": "accept"`.');
65
- }
66
- else {
67
- lines.push('Unless explicitly accepted, each conflict was KEPT (your version preserved).');
68
- }
69
- lines.push('');
70
- for (const f of conflicts) {
71
- const applied = result.applied.acceptedConflicts.includes(f.key);
72
- const status = result.dryRun
73
- ? '(would KEEP your version)'
74
- : applied
75
- ? '✓ accepted — template version applied'
76
- : '↩ kept your version';
77
- lines.push(`- **${f.label}** \`${f.key}\` ${status}`);
78
- lines.push(` - cube (current): "${trunc(f.cubeValue)}"`);
79
- lines.push(` - template (new): "${trunc(f.templateValue)}"`);
80
- }
81
- lines.push('');
82
- }
83
- // ── Unmatched decision keys (typo'd / stale — intended accept dropped) ──
84
- const unmatched = result.unmatchedDecisions ?? [];
85
- if (unmatched.length > 0) {
86
- lines.push(`### ⚠ ${unmatched.length} decision key(s) matched no conflict and were ignored`);
87
- lines.push('These keys in your `decisions` map did not correspond to any classified conflict this run ' +
88
- '(typo or stale key) — their intended accept had NO effect. Check the exact keys against the conflicts above:');
89
- for (const k of unmatched) {
90
- lines.push(`- \`${k}\``);
91
- }
92
- lines.push('');
93
- }
94
- // ── Additions (safe auto-apply, zero clobber risk) ──
95
- if (newRoles.length > 0 || adds.length > 0) {
96
- lines.push(`### Additions (safe — auto-applied, zero clobber risk)`);
97
- for (const r of newRoles) {
98
- const note = result.dryRun ? '(new role — would be created)' : '✓ created';
99
- lines.push(`- new role **${r.name}** ${note}`);
100
- }
101
- for (const f of adds) {
102
- const note = result.dryRun ? '(would be added)' : '✓ added';
103
- lines.push(`- **${f.label}** \`${f.key}\` ${note}`);
104
- }
105
- lines.push('');
106
- }
107
- // ── Custom roles (never touched) ──
108
- if (customRoles.length > 0) {
109
- lines.push(`### Custom roles (untouched): ${customRoles.map((r) => r.name).join(', ')}`);
110
- lines.push('');
111
- }
112
- // ── Clean no-op ──
113
- if (conflicts.length === 0 && adds.length === 0 && newRoles.length === 0) {
114
- lines.push('✓ Cube roles + taxonomy are **up to date** with the template (no changes).');
115
- }
116
- return lines.join('\n').trimEnd();
117
- }
118
- //# sourceMappingURL=sync-roles-render.js.map
1
+ function p(t,r=200){if(t==null)return"(absent)";const o=t.replace(/\n/g,"\u23CE");return o.length>r?o.slice(0,r)+"\u2026":o}function y(t,r){const o=t;if(o.roles===void 0&&"updated"in o)return["## borg:sync-roles \u2014 unavailable (server out of date)","","The borg server returned the legacy sync-roles response shape \u2014 it is running a version older than #473, which the non-clobbering sync view does not support.","","**Action:** a server (worker) deploy is pending. Retry `borg:sync-roles` once it lands."].join(`
2
+ `);const n=[`## borg:sync-roles \u2014 ${t.dryRun?"**DRY RUN** (review conflicts below; re-run with `apply: true` + a `decisions` map to commit)":"**APPLIED**"}`,`Template: ${r}`,""],d=[...t.roles.flatMap(e=>e.fragments),...t.taxonomy],c=d.filter(e=>e.kind==="conflict"),a=d.filter(e=>e.kind==="add"),i=t.roles.filter(e=>e.status==="new"),u=t.roles.filter(e=>e.status==="custom-skipped");if(c.length>0){n.push(`### \u26A0 ${c.length} CONFLICT(s) \u2014 these fragments differ between your cube and the template`),t.dryRun?n.push('These differ between your cube and the template \u2014 may be because you evolved them, or because the template changed them. Surfaced for review, never silently overwritten. Each defaults to **KEEP (reject)** \u2014 your version survives. To take the template version of a specific fragment, pass its key in `decisions` as `"<key>": "accept"`.'):n.push("Unless explicitly accepted, each conflict was KEPT (your version preserved)."),n.push("");for(const e of c){const s=t.applied.acceptedConflicts.includes(e.key),h=t.dryRun?"(would KEEP your version)":s?"\u2713 accepted \u2014 template version applied":"\u21A9 kept your version";n.push(`- **${e.label}** \`${e.key}\` ${h}`),n.push(` - cube (current): "${p(e.cubeValue)}"`),n.push(` - template (new): "${p(e.templateValue)}"`)}n.push("")}const l=t.unmatchedDecisions??[];if(l.length>0){n.push(`### \u26A0 ${l.length} decision key(s) matched no conflict and were ignored`),n.push("These keys in your `decisions` map did not correspond to any classified conflict this run (typo or stale key) \u2014 their intended accept had NO effect. Check the exact keys against the conflicts above:");for(const e of l)n.push(`- \`${e}\``);n.push("")}if(i.length>0||a.length>0){n.push("### Additions (safe \u2014 auto-applied, zero clobber risk)");for(const e of i){const s=t.dryRun?"(new role \u2014 would be created)":"\u2713 created";n.push(`- new role **${e.name}** ${s}`)}for(const e of a){const s=t.dryRun?"(would be added)":"\u2713 added";n.push(`- **${e.label}** \`${e.key}\` ${s}`)}n.push("")}return u.length>0&&(n.push(`### Custom roles (untouched): ${u.map(e=>e.name).join(", ")}`),n.push("")),c.length===0&&a.length===0&&i.length===0&&n.push("\u2713 Cube roles + taxonomy are **up to date** with the template (no changes)."),n.join(`
3
+ `).trimEnd()}export{y as renderSyncRolesResult};