@triflux/remote 10.0.1 → 10.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +68 -7
  3. package/hub/server.mjs +2181 -701
  4. package/hub/store-adapter.mjs +101 -37
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +84 -17
  9. package/hub/team/build-worker-prompt.mjs +42 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +219 -0
  12. package/hub/team/claude-native-bridge.mjs +784 -0
  13. package/hub/team/claude-session-projection.mjs +65 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +154 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +89 -12
  24. package/hub/team/cli/commands/start/start-headless.mjs +127 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/index.mjs +2 -2
  33. package/hub/team/cli/manifest.mjs +38 -8
  34. package/hub/team/cli/render.mjs +52 -30
  35. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  36. package/hub/team/cli/services/hub-client.mjs +45 -17
  37. package/hub/team/cli/services/member-selector.mjs +37 -30
  38. package/hub/team/cli/services/native-control.mjs +144 -116
  39. package/hub/team/cli/services/runtime-mode.mjs +55 -1
  40. package/hub/team/cli/services/state-store.mjs +66 -9
  41. package/hub/team/cli/services/task-model.mjs +40 -30
  42. package/hub/team/codex-review.mjs +521 -0
  43. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  44. package/hub/team/conductor-registry.mjs +121 -0
  45. package/hub/team/conductor.mjs +1379 -671
  46. package/hub/team/consensus-meta.mjs +176 -0
  47. package/hub/team/dashboard-anchor.mjs +4 -5
  48. package/hub/team/dashboard-layout.mjs +3 -1
  49. package/hub/team/dashboard-open.mjs +21 -119
  50. package/hub/team/dashboard.mjs +76 -28
  51. package/hub/team/event-log.mjs +18 -10
  52. package/hub/team/execution-mode.mjs +241 -0
  53. package/hub/team/extract-completion-payload.mjs +110 -0
  54. package/hub/team/git-preflight.mjs +286 -0
  55. package/hub/team/handoff.mjs +49 -18
  56. package/hub/team/headless-bridge-session.mjs +17 -0
  57. package/hub/team/headless.mjs +2065 -1156
  58. package/hub/team/health-probe.mjs +232 -69
  59. package/hub/team/launcher-template.mjs +23 -13
  60. package/hub/team/lead-control.mjs +91 -4
  61. package/hub/team/mcp-selector.mjs +145 -0
  62. package/hub/team/native-supervisor.mjs +90 -26
  63. package/hub/team/native.mjs +74 -35
  64. package/hub/team/nativeProxy.mjs +184 -113
  65. package/hub/team/notify.mjs +147 -78
  66. package/hub/team/orchestrator.mjs +173 -161
  67. package/hub/team/pane.mjs +43 -15
  68. package/hub/team/process-cleanup.mjs +69 -25
  69. package/hub/team/psmux.mjs +1067 -264
  70. package/hub/team/recovery-store.mjs +149 -0
  71. package/hub/team/remote-probe.mjs +69 -53
  72. package/hub/team/remote-session.mjs +131 -59
  73. package/hub/team/remote-watcher.mjs +62 -34
  74. package/hub/team/retry-state-machine.mjs +267 -0
  75. package/hub/team/routing.mjs +51 -25
  76. package/hub/team/runtime-strategy.mjs +200 -0
  77. package/hub/team/schemas/worker-completion.json +39 -0
  78. package/hub/team/sentinel-capture.mjs +127 -0
  79. package/hub/team/session-sync.mjs +153 -6
  80. package/hub/team/session.mjs +605 -611
  81. package/hub/team/shared.mjs +13 -13
  82. package/hub/team/staleState.mjs +72 -30
  83. package/hub/team/swarm-cli.mjs +390 -0
  84. package/hub/team/swarm-hypervisor.mjs +1526 -198
  85. package/hub/team/swarm-intent.mjs +216 -0
  86. package/hub/team/swarm-locks.mjs +140 -25
  87. package/hub/team/swarm-planner.mjs +121 -21
  88. package/hub/team/swarm-preflight.mjs +317 -0
  89. package/hub/team/swarm-reconciler.mjs +128 -23
  90. package/hub/team/synapse-cli.mjs +458 -0
  91. package/hub/team/synapse-http.mjs +60 -0
  92. package/hub/team/synapse-registry.mjs +329 -0
  93. package/hub/team/terminal-opener.mjs +216 -0
  94. package/hub/team/tui-core.mjs +288 -0
  95. package/hub/team/tui-lite.mjs +234 -146
  96. package/hub/team/tui-remote-adapter.mjs +21 -37
  97. package/hub/team/tui-synapse.mjs +223 -0
  98. package/hub/team/tui-viewer.mjs +104 -45
  99. package/hub/team/tui-widgets.mjs +262 -0
  100. package/hub/team/tui.mjs +936 -449
  101. package/hub/team/worker-completion-validator.mjs +84 -0
  102. package/hub/team/worker-sandbox.mjs +93 -0
  103. package/hub/team/worker-signal.mjs +263 -0
  104. package/hub/team/worker-signal.types.d.ts +52 -0
  105. package/hub/team/worktree-lifecycle.mjs +518 -64
  106. package/hub/team/wt-manager.mjs +297 -26
  107. package/hub/tools.mjs +474 -252
  108. package/hub/tray.mjs +411 -368
  109. package/hub/workers/claude-worker.mjs +162 -118
  110. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  111. package/hub/workers/codex-mcp.mjs +724 -507
  112. package/hub/workers/delegator-mcp.mjs +663 -337
  113. package/hub/workers/factory.mjs +100 -21
  114. package/hub/workers/gemini-worker.mjs +179 -86
  115. package/hub/workers/interface.mjs +23 -2
  116. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  117. package/hub/workers/worker-utils.mjs +22 -16
  118. package/package.json +4 -3
  119. package/scripts/lib/agent-json.mjs +27 -0
  120. package/scripts/lib/async.mjs +174 -0
  121. package/scripts/lib/claudemd-scanner.mjs +223 -0
  122. package/scripts/lib/cli-agy.mjs +62 -0
  123. package/scripts/lib/cli-claude.mjs +78 -0
  124. package/scripts/lib/cli-codex.mjs +199 -0
  125. package/scripts/lib/cli-gemini.mjs +67 -0
  126. package/scripts/lib/codex-recovery.sh +50 -0
  127. package/scripts/lib/context.mjs +67 -0
  128. package/scripts/lib/cross-review-utils.mjs +59 -0
  129. package/scripts/lib/doctor-env-checks.mjs +121 -0
  130. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  131. package/scripts/lib/env-probe.mjs +269 -0
  132. package/scripts/lib/env.mjs +14 -0
  133. package/scripts/lib/gemini-profiles.mjs +119 -0
  134. package/scripts/lib/handoff.mjs +187 -0
  135. package/scripts/lib/hook-utils.mjs +16 -0
  136. package/scripts/lib/hub.mjs +48 -0
  137. package/scripts/lib/keyword-rules.mjs +195 -0
  138. package/scripts/lib/logger.mjs +105 -0
  139. package/scripts/lib/mcp-filter.mjs +883 -0
  140. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  141. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  142. package/scripts/lib/mcp-health.mjs +773 -0
  143. package/scripts/lib/mcp-manifest.mjs +89 -0
  144. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  145. package/scripts/lib/pid.mjs +63 -0
  146. package/scripts/lib/process-utils.mjs +26 -0
  147. package/scripts/lib/psmux-info.mjs +179 -0
  148. package/scripts/lib/quota.mjs +47 -0
  149. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  150. package/scripts/lib/skill-state.mjs +220 -0
  151. package/scripts/lib/skill-template.mjs +375 -0
  152. package/scripts/lib/team.mjs +75 -0
  153. package/scripts/lib/timeout.mjs +66 -0
  154. package/scripts/lib/tmp.mjs +26 -0
  155. package/scripts/lib/toml.mjs +113 -0
@@ -0,0 +1,216 @@
1
+ // hub/team/swarm-intent.mjs — X-Intent helper utilities for semantic merge awareness
2
+
3
+ const INTENT_TRAILER_REGEX = /^X-Intent:\s*(.+)$/m;
4
+
5
+ function normalizeText(value) {
6
+ return typeof value === "string" ? value.trim() : "";
7
+ }
8
+
9
+ function normalizeTouches(touches) {
10
+ if (!Array.isArray(touches)) return null;
11
+ return touches
12
+ .filter((item) => typeof item === "string")
13
+ .map((item) => item.trim().replace(/\\/g, "/"))
14
+ .filter(Boolean);
15
+ }
16
+
17
+ function normalizeIntent(intentObj) {
18
+ if (!intentObj || typeof intentObj !== "object" || Array.isArray(intentObj)) {
19
+ return null;
20
+ }
21
+
22
+ const scope = normalizeText(intentObj.scope);
23
+ const action = normalizeText(intentObj.action);
24
+ const reason = normalizeText(intentObj.reason);
25
+ const touches = normalizeTouches(intentObj.touches);
26
+
27
+ if (!scope || !action || !reason || !touches) {
28
+ return null;
29
+ }
30
+
31
+ return {
32
+ scope,
33
+ action,
34
+ reason,
35
+ touches,
36
+ invariant: normalizeText(intentObj.invariant),
37
+ conflictsWith: normalizeText(intentObj.conflictsWith),
38
+ };
39
+ }
40
+
41
+ function fallbackIntent(llmResponse) {
42
+ const responseText = String(llmResponse ?? "");
43
+ return {
44
+ scope: "unknown",
45
+ action: "unknown",
46
+ reason: responseText.slice(0, 100),
47
+ touches: [],
48
+ invariant: "",
49
+ conflictsWith: "",
50
+ };
51
+ }
52
+
53
+ function includesEitherWay(a, b) {
54
+ if (!a || !b) return false;
55
+ return a.includes(b) || b.includes(a);
56
+ }
57
+
58
+ function hasConflict(intentA, intentB) {
59
+ const conflictsWith = normalizeText(intentA?.conflictsWith).toLowerCase();
60
+ const scope = normalizeText(intentB?.scope).toLowerCase();
61
+ const action = normalizeText(intentB?.action).toLowerCase();
62
+
63
+ if (!conflictsWith) return false;
64
+ return (
65
+ includesEitherWay(conflictsWith, scope) ||
66
+ includesEitherWay(conflictsWith, action)
67
+ );
68
+ }
69
+
70
+ function getTouchesOverlap(intentA, intentB) {
71
+ const touchesA = new Set(
72
+ (normalizeTouches(intentA?.touches) ?? []).map((item) =>
73
+ item.toLowerCase(),
74
+ ),
75
+ );
76
+ const touchesB = (normalizeTouches(intentB?.touches) ?? []).map((item) =>
77
+ item.toLowerCase(),
78
+ );
79
+ return touchesB.filter((item) => touchesA.has(item));
80
+ }
81
+
82
+ /**
83
+ * Build prompts for an LLM to generate an X-Intent JSON object.
84
+ * @param {string[]} changedFiles
85
+ * @param {string} commitDiff
86
+ * @param {string} taskContext
87
+ * @returns {{ systemPrompt: string, userPrompt: string }}
88
+ */
89
+ export function generateIntentPrompt(changedFiles, commitDiff, taskContext) {
90
+ const files = Array.isArray(changedFiles)
91
+ ? changedFiles.filter((filePath) => typeof filePath === "string")
92
+ : [];
93
+ const diffSnippet = String(commitDiff ?? "").slice(0, 4000);
94
+ const context = String(taskContext ?? "").trim();
95
+
96
+ return {
97
+ systemPrompt:
98
+ "You generate commit intent metadata for semantic merge awareness. Return only valid JSON with keys: scope, action, reason, touches, invariant, conflictsWith.",
99
+ userPrompt: [
100
+ "Create an intent object from the following commit context.",
101
+ "- scope/action/reason must be concise strings.",
102
+ "- touches must be an array of touched file paths.",
103
+ "- invariant/conflictsWith should be strings (empty if unknown).",
104
+ "- Return JSON only (no markdown).",
105
+ "",
106
+ `Task context:\n${context || "(none provided)"}`,
107
+ "",
108
+ `Changed files (${files.length}):\n${files.join("\n") || "(none)"}`,
109
+ "",
110
+ "Commit diff (first 4000 chars):",
111
+ diffSnippet || "(empty)",
112
+ ].join("\n"),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Format an intent object into an X-Intent commit trailer.
118
+ * @param {object} intentObj
119
+ * @returns {string}
120
+ */
121
+ export function formatIntentTrailer(intentObj) {
122
+ return `X-Intent: ${JSON.stringify(intentObj)}`;
123
+ }
124
+
125
+ /**
126
+ * Parse an X-Intent trailer from a commit message.
127
+ * @param {string} commitMessage
128
+ * @returns {object | null}
129
+ */
130
+ export function parseIntentTrailer(commitMessage) {
131
+ const message = String(commitMessage ?? "");
132
+ const match = message.match(INTENT_TRAILER_REGEX);
133
+ if (!match) return null;
134
+
135
+ try {
136
+ return JSON.parse(match[1]);
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Classify the relation between two intents.
144
+ * @param {object} intentA
145
+ * @param {object} intentB
146
+ * @returns {{ relation: "complementary" | "complementary-risky" | "contradictory" | "independent", reason: string }}
147
+ */
148
+ export function classifyIntentPair(intentA, intentB) {
149
+ if (hasConflict(intentA, intentB) || hasConflict(intentB, intentA)) {
150
+ return {
151
+ relation: "contradictory",
152
+ reason:
153
+ "conflictsWith on one intent matches the other intent scope/action",
154
+ };
155
+ }
156
+
157
+ const scopeA = normalizeText(intentA?.scope).toLowerCase();
158
+ const scopeB = normalizeText(intentB?.scope).toLowerCase();
159
+
160
+ if (scopeA && scopeB && scopeA === scopeB) {
161
+ const overlap = getTouchesOverlap(intentA, intentB);
162
+ if (overlap.length > 0) {
163
+ return {
164
+ relation: "complementary-risky",
165
+ reason: `same scope with overlapping touched files: ${overlap.join(", ")}`,
166
+ };
167
+ }
168
+
169
+ return {
170
+ relation: "complementary",
171
+ reason:
172
+ "same scope with no conflicting intent and no touched-file overlap",
173
+ };
174
+ }
175
+
176
+ return {
177
+ relation: "independent",
178
+ reason: "different scopes and no conflictsWith match detected",
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Build a validated intent object from raw LLM response text.
184
+ * @param {string} llmResponse
185
+ * @returns {{ scope: string, action: string, reason: string, touches: string[], invariant: string, conflictsWith: string }}
186
+ */
187
+ export function buildIntentFromLLMResponse(llmResponse) {
188
+ const responseText = String(llmResponse ?? "").trim();
189
+ const candidates = [];
190
+
191
+ const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
192
+ for (const match of responseText.matchAll(codeBlockRegex)) {
193
+ if (match[1]) candidates.push(match[1].trim());
194
+ }
195
+
196
+ candidates.push(responseText);
197
+
198
+ const firstBrace = responseText.indexOf("{");
199
+ const lastBrace = responseText.lastIndexOf("}");
200
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
201
+ candidates.push(responseText.slice(firstBrace, lastBrace + 1));
202
+ }
203
+
204
+ for (const candidate of candidates) {
205
+ if (!candidate) continue;
206
+ try {
207
+ const parsed = JSON.parse(candidate);
208
+ const normalized = normalizeIntent(parsed);
209
+ if (normalized) return normalized;
210
+ } catch {
211
+ // ignore parse failures and continue trying other candidates
212
+ }
213
+ }
214
+
215
+ return fallbackIntent(responseText);
216
+ }
@@ -3,11 +3,36 @@
3
3
  // Lock state is kept in-memory (single-process hypervisor) with optional
4
4
  // JSON persistence to .triflux/swarm-locks.json for crash recovery.
5
5
 
6
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
7
- import { dirname, resolve, relative } from 'node:path';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { dirname, relative, resolve } from "node:path";
8
8
 
9
9
  const LOCK_TTL_MS = 10 * 60_000; // 10 minutes default TTL
10
10
 
11
+ // Distribution-critical paths. A swarm worker that modifies these without
12
+ // having them explicitly listed in its shard lease is almost certainly a
13
+ // recovery-patch fallout (worker exited before commit, hypervisor saved
14
+ // patch with destructive `+++ /dev/null` deletions). Block by default;
15
+ // override via validateChanges options.sensitiveDeny.
16
+ //
17
+ // Paths use POSIX separators (matches normalizePath output).
18
+ export const SENSITIVE_PATH_PREFIXES = [
19
+ ".claude-plugin/",
20
+ "bin/",
21
+ ".github/workflows/",
22
+ ];
23
+ export const SENSITIVE_PATH_FILES = [
24
+ ".gitignore",
25
+ ".npmignore",
26
+ "package.json",
27
+ "package-lock.json",
28
+ "biome.json",
29
+ ];
30
+
31
+ export const DEFAULT_SENSITIVE_DENY = Object.freeze({
32
+ prefixes: Object.freeze([...SENSITIVE_PATH_PREFIXES]),
33
+ files: Object.freeze([...SENSITIVE_PATH_FILES]),
34
+ });
35
+
11
36
  /**
12
37
  * Swarm lock manager factory.
13
38
  * @param {object} [opts]
@@ -17,11 +42,7 @@ const LOCK_TTL_MS = 10 * 60_000; // 10 minutes default TTL
17
42
  * @returns {SwarmLockManager}
18
43
  */
19
44
  export function createSwarmLocks(opts = {}) {
20
- const {
21
- repoRoot = process.cwd(),
22
- persistPath,
23
- ttlMs = LOCK_TTL_MS,
24
- } = opts;
45
+ const { repoRoot = process.cwd(), persistPath, ttlMs = LOCK_TTL_MS } = opts;
25
46
 
26
47
  /** @type {Map<string, LockEntry>} normalized relative path → lock */
27
48
  const locks = new Map();
@@ -30,13 +51,41 @@ export function createSwarmLocks(opts = {}) {
30
51
 
31
52
  function normalizePath(filePath) {
32
53
  const abs = resolve(repoRoot, filePath);
33
- return relative(repoRoot, abs).replace(/\\/g, '/');
54
+ return relative(repoRoot, abs).replace(/\\/g, "/");
34
55
  }
35
56
 
36
57
  function now() {
37
58
  return Date.now();
38
59
  }
39
60
 
61
+ function normalizeLeaseType(leaseType) {
62
+ return leaseType === "shared-read" ? "shared-read" : "exclusive";
63
+ }
64
+
65
+ function normalizeSessionMeta(sessionMeta) {
66
+ return sessionMeta ?? null;
67
+ }
68
+
69
+ function normalizeEntry(entry) {
70
+ return {
71
+ workerId: entry.workerId,
72
+ acquiredAt: entry.acquiredAt,
73
+ leaseType: normalizeLeaseType(entry.leaseType),
74
+ sessionMeta: normalizeSessionMeta(entry.sessionMeta),
75
+ };
76
+ }
77
+
78
+ function hasConflict(existing, requesterId, requestedLeaseType) {
79
+ if (!existing || existing.workerId === requesterId) return false;
80
+ if (
81
+ existing.leaseType === "shared-read" &&
82
+ requestedLeaseType === "shared-read"
83
+ ) {
84
+ return false;
85
+ }
86
+ return true;
87
+ }
88
+
40
89
  function isExpired(entry) {
41
90
  return now() - entry.acquiredAt > ttlMs;
42
91
  }
@@ -56,21 +105,25 @@ export function createSwarmLocks(opts = {}) {
56
105
  const data = Object.fromEntries(
57
106
  [...locks].map(([k, v]) => [k, { ...v }]),
58
107
  );
59
- writeFileSync(persistPath, JSON.stringify(data, null, 2), 'utf8');
60
- } catch { /* best-effort */ }
108
+ writeFileSync(persistPath, JSON.stringify(data, null, 2), "utf8");
109
+ } catch {
110
+ /* best-effort */
111
+ }
61
112
  }
62
113
 
63
114
  function restore() {
64
115
  if (!persistPath || !existsSync(persistPath)) return;
65
116
  try {
66
- const data = JSON.parse(readFileSync(persistPath, 'utf8'));
117
+ const data = JSON.parse(readFileSync(persistPath, "utf8"));
67
118
  const ts = now();
68
119
  for (const [path, entry] of Object.entries(data)) {
69
120
  if (ts - entry.acquiredAt <= ttlMs) {
70
- locks.set(path, entry);
121
+ locks.set(path, normalizeEntry(entry));
71
122
  }
72
123
  }
73
- } catch { /* corrupted file — start fresh */ }
124
+ } catch {
125
+ /* corrupted file — start fresh */
126
+ }
74
127
  }
75
128
 
76
129
  // restore on creation
@@ -82,23 +135,42 @@ export function createSwarmLocks(opts = {}) {
82
135
  * Acquire file leases for a worker.
83
136
  * @param {string} workerId — worker/shard identifier
84
137
  * @param {string[]} files — file paths to lock
138
+ * @param {{ leaseType?: "exclusive" | "shared-read", sessionMeta?: { sessionId: string, host: string, taskSummary: string } }} [opts]
85
139
  * @returns {{ ok: boolean, acquired: string[], conflicts: Array<{ file: string, holder: string }> }}
86
140
  */
87
- function acquire(workerId, files) {
141
+ function acquire(workerId, files, opts = {}) {
88
142
  pruneExpired();
89
143
 
144
+ const leaseType = normalizeLeaseType(opts.leaseType);
145
+ const sessionMeta = normalizeSessionMeta(opts.sessionMeta);
90
146
  const normalized = files.map((f) => normalizePath(f));
91
147
  const conflicts = [];
92
148
  const toAcquire = [];
149
+ const allowedSharedRead = [];
93
150
 
94
151
  for (let i = 0; i < normalized.length; i++) {
95
152
  const path = normalized[i];
96
153
  const existing = locks.get(path);
97
154
 
98
- if (existing && existing.workerId !== workerId && !isExpired(existing)) {
155
+ if (!existing || isExpired(existing)) {
156
+ toAcquire.push(path);
157
+ continue;
158
+ }
159
+
160
+ if (hasConflict(existing, workerId, leaseType)) {
99
161
  conflicts.push({ file: files[i], holder: existing.workerId });
100
162
  } else {
101
- toAcquire.push(path);
163
+ if (
164
+ !(
165
+ existing.workerId !== workerId &&
166
+ existing.leaseType === "shared-read" &&
167
+ leaseType === "shared-read"
168
+ )
169
+ ) {
170
+ toAcquire.push(path);
171
+ } else {
172
+ allowedSharedRead.push(path);
173
+ }
102
174
  }
103
175
  }
104
176
 
@@ -108,11 +180,15 @@ export function createSwarmLocks(opts = {}) {
108
180
 
109
181
  const ts = now();
110
182
  for (const path of toAcquire) {
111
- locks.set(path, { workerId, acquiredAt: ts });
183
+ locks.set(path, { workerId, acquiredAt: ts, leaseType, sessionMeta });
112
184
  }
113
185
 
114
186
  persist();
115
- return { ok: true, acquired: toAcquire, conflicts: [] };
187
+ return {
188
+ ok: true,
189
+ acquired: [...toAcquire, ...allowedSharedRead],
190
+ conflicts: [],
191
+ };
116
192
  }
117
193
 
118
194
  /**
@@ -136,35 +212,70 @@ export function createSwarmLocks(opts = {}) {
136
212
  * Check if a file write would violate any lease.
137
213
  * @param {string} workerId — the worker attempting the write
138
214
  * @param {string} filePath — the file being written
215
+ * @param {"exclusive" | "shared-read"} [leaseType="exclusive"] — requested lease type
139
216
  * @returns {{ allowed: boolean, holder?: string }}
140
217
  */
141
- function check(workerId, filePath) {
218
+ function check(workerId, filePath, leaseType = "exclusive") {
142
219
  pruneExpired();
143
220
  const path = normalizePath(filePath);
144
221
  const entry = locks.get(path);
222
+ const normalizedLeaseType = normalizeLeaseType(leaseType);
145
223
 
146
224
  if (!entry || isExpired(entry)) return { allowed: true };
147
- if (entry.workerId === workerId) return { allowed: true };
225
+ if (!hasConflict(entry, workerId, normalizedLeaseType))
226
+ return { allowed: true };
148
227
  return { allowed: false, holder: entry.workerId };
149
228
  }
150
229
 
151
230
  /**
152
231
  * Validate a set of changed files against the lease map.
153
232
  * Returns all violations found.
233
+ *
234
+ * Beyond the basic cross-lease check, when `options.ownLease` is supplied
235
+ * (the caller's known lease set), this also flags out-of-lease writes to
236
+ * distribution-critical paths — e.g. recovery-patch fallouts where a
237
+ * worker's recorded diff carries `+++ /dev/null` deletions of files like
238
+ * `.claude-plugin/marketplace.json` (regression of #115 / #34).
239
+ *
240
+ * `options.ownLease` is opt-in to preserve existing callers; when omitted,
241
+ * legacy behavior (cross-lease check only) is kept.
242
+ *
154
243
  * @param {string} workerId
155
244
  * @param {string[]} changedFiles
156
- * @returns {Array<{ file: string, holder: string }>}
245
+ * @param {{ ownLease?: string[], sensitiveDeny?: { prefixes?: string[], files?: string[] } }} [options]
246
+ * @returns {Array<{ file: string, holder?: string, kind: "other-lease" | "sensitive-out-of-lease" }>}
157
247
  */
158
- function validateChanges(workerId, changedFiles) {
248
+ function validateChanges(workerId, changedFiles, options = {}) {
159
249
  pruneExpired();
160
250
  const violations = [];
251
+ const ownLeaseSet = Array.isArray(options.ownLease)
252
+ ? new Set(options.ownLease.map((path) => normalizePath(path)))
253
+ : null;
254
+ const sensitivePrefixes =
255
+ options.sensitiveDeny?.prefixes ?? SENSITIVE_PATH_PREFIXES;
256
+ const sensitiveFiles = new Set(
257
+ options.sensitiveDeny?.files ?? SENSITIVE_PATH_FILES,
258
+ );
161
259
 
162
260
  for (const file of changedFiles) {
163
261
  const path = normalizePath(file);
164
262
  const entry = locks.get(path);
165
263
 
166
264
  if (entry && entry.workerId !== workerId && !isExpired(entry)) {
167
- violations.push({ file, holder: entry.workerId });
265
+ violations.push({ file, holder: entry.workerId, kind: "other-lease" });
266
+ continue;
267
+ }
268
+
269
+ // Sensitive-path guard only runs when caller supplied ownLease.
270
+ // We only flag when the worker did NOT explicitly lease the file —
271
+ // an explicit lease means the shard intentionally owns it.
272
+ if (ownLeaseSet && !ownLeaseSet.has(path)) {
273
+ const isSensitive =
274
+ sensitiveFiles.has(path) ||
275
+ sensitivePrefixes.some((prefix) => path.startsWith(prefix));
276
+ if (isSensitive) {
277
+ violations.push({ file, kind: "sensitive-out-of-lease" });
278
+ }
168
279
  }
169
280
  }
170
281
 
@@ -173,7 +284,7 @@ export function createSwarmLocks(opts = {}) {
173
284
 
174
285
  /**
175
286
  * Get snapshot of all active locks.
176
- * @returns {Array<{ file: string, workerId: string, acquiredAt: number }>}
287
+ * @returns {Array<{ file: string, workerId: string, acquiredAt: number, leaseType: "exclusive" | "shared-read", sessionMeta: { sessionId: string, host: string, taskSummary: string } | null }>}
177
288
  */
178
289
  function snapshot() {
179
290
  pruneExpired();
@@ -181,6 +292,8 @@ export function createSwarmLocks(opts = {}) {
181
292
  file,
182
293
  workerId: entry.workerId,
183
294
  acquiredAt: entry.acquiredAt,
295
+ leaseType: normalizeLeaseType(entry.leaseType),
296
+ sessionMeta: normalizeSessionMeta(entry.sessionMeta),
184
297
  }));
185
298
  }
186
299
 
@@ -199,6 +312,8 @@ export function createSwarmLocks(opts = {}) {
199
312
  validateChanges,
200
313
  snapshot,
201
314
  releaseAll,
202
- get size() { return locks.size; },
315
+ get size() {
316
+ return locks.size;
317
+ },
203
318
  });
204
319
  }