thumbgate 1.35.0 → 1.37.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 (196) hide show
  1. package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
  2. package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
  3. package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
  4. package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
  5. package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.well-known/llms.txt +1 -0
  8. package/.well-known/mcp/server-card.json +1 -1
  9. package/CONTRIBUTING.md +95 -0
  10. package/README.md +195 -632
  11. package/THIRD_PARTY_NOTICES.md +89 -0
  12. package/adapters/claude/.mcp.json +2 -2
  13. package/adapters/forge/forge.yaml +3 -3
  14. package/adapters/future-agi/.mcp.json +8 -0
  15. package/adapters/future-agi/FUTURE_AGI.md +23 -0
  16. package/adapters/future-agi/config.toml +3 -0
  17. package/adapters/future-agi/future-agi-bridge.js +9 -0
  18. package/adapters/future-agi/opencode.json +8 -0
  19. package/adapters/herdr/herdr-plugin.toml +18 -0
  20. package/adapters/mcp/server-stdio.js +238 -25
  21. package/adapters/opencode/opencode.json +1 -1
  22. package/adapters/workos/WORKOS.md +52 -0
  23. package/bin/cli.js +336 -3
  24. package/bin/futureagi-bridge +9 -0
  25. package/config/gate-templates.json +581 -4
  26. package/config/gates/actor-critic-audit.json +34 -0
  27. package/config/gates/default.json +9 -3
  28. package/config/gates/five-walls-governance.json +34 -0
  29. package/config/gates/future-agi-guardrails.json +34 -0
  30. package/config/gates/radware-threat-defense-2026.json +61 -0
  31. package/config/gates/simatree-data-governance.json +33 -0
  32. package/config/mcp-allowlists.json +4 -0
  33. package/config/merge-quality-checks.json +6 -0
  34. package/config/model-candidates.json +312 -29
  35. package/config/model-tiers.json +18 -0
  36. package/config/post-deploy-marketing-pages.json +10 -0
  37. package/config/progressive/01-wire-only.json +11 -0
  38. package/config/progressive/02-dashboard-empty-ok.json +10 -0
  39. package/config/progressive/03-one-lesson.json +10 -0
  40. package/config/progressive/04-warn-fires.json +11 -0
  41. package/config/progressive/05-strict-optional.json +11 -0
  42. package/config/progressive/README.md +15 -0
  43. package/config/schemas/broker-execution-receipt.schema.json +139 -0
  44. package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
  45. package/conformance/provider-attestation/vectors.json +320 -0
  46. package/docs/specs/provider-execution-attestation-v1.md +69 -0
  47. package/openapi/openapi.yaml +15 -0
  48. package/package.json +390 -148
  49. package/public/about.html +2 -2
  50. package/public/ai-malpractice-prevention.html +7 -7
  51. package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
  52. package/public/blog/a-receipt-is-not-world-state.html +388 -0
  53. package/public/blog/git-at-agent-scale.html +374 -0
  54. package/public/blog/no-llm-in-the-gate.html +133 -0
  55. package/public/blog.html +80 -0
  56. package/public/case-studies.html +16 -1
  57. package/public/compare.html +28 -0
  58. package/public/diagnostic.html +216 -7
  59. package/public/docs/connectors.html +39 -0
  60. package/public/federal.html +2 -2
  61. package/public/founders.html +639 -0
  62. package/public/index.html +87 -9
  63. package/public/install.html +8 -8
  64. package/public/learn.html +39 -0
  65. package/public/numbers.html +2 -2
  66. package/public/peter.html +310 -0
  67. package/public/platform-partners.html +119 -0
  68. package/public/pricing.html +24 -3
  69. package/public/privacy.html +117 -0
  70. package/public/pro.html +17 -0
  71. package/public/support.html +62 -0
  72. package/public/terms.html +130 -0
  73. package/public/third-party-notices.html +95 -0
  74. package/public/yt.html +351 -0
  75. package/scripts/action-receipts.js +133 -3
  76. package/scripts/adaptive-governance-arena.js +349 -0
  77. package/scripts/admin-override.js +205 -0
  78. package/scripts/agent-action-inventory.js +869 -0
  79. package/scripts/agent-audit-trace.js +42 -2
  80. package/scripts/agent-egress-policy.js +1117 -0
  81. package/scripts/agent-memory-lifecycle.js +141 -2
  82. package/scripts/agent-operations-planner.js +441 -1
  83. package/scripts/agent-readiness.js +68 -0
  84. package/scripts/agent-security-central.js +647 -0
  85. package/scripts/allowlist-bridge-honesty.js +417 -0
  86. package/scripts/async-job-runner.js +102 -11
  87. package/scripts/audit-trail.js +212 -0
  88. package/scripts/billing.js +1 -1
  89. package/scripts/broker-execution-receipts.js +719 -0
  90. package/scripts/budget-aware-gates-proof.js +423 -0
  91. package/scripts/claude-feedback-sync.js +29 -3
  92. package/scripts/claw-harness-production.js +237 -0
  93. package/scripts/cli-schema.js +163 -1
  94. package/scripts/codex-runbook-flywheel.js +318 -0
  95. package/scripts/context-footprint.js +186 -0
  96. package/scripts/contextfs.js +143 -61
  97. package/scripts/dashboard.js +251 -32
  98. package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
  99. package/scripts/docker-sandbox-planner.js +18 -0
  100. package/scripts/double-blind-eval-protocol.js +252 -0
  101. package/scripts/edotenv-rl-gateway.js +259 -0
  102. package/scripts/ensure-production-search-corpus.js +162 -0
  103. package/scripts/eval-holdout.js +311 -0
  104. package/scripts/feedback-aggregate.js +21 -2
  105. package/scripts/feedback-loop.js +87 -5
  106. package/scripts/feedback-quality.js +9 -0
  107. package/scripts/file-ledger-lock.js +4 -1
  108. package/scripts/financial-control-plane.js +41 -1
  109. package/scripts/find-dormant-requires.js +118 -0
  110. package/scripts/fs-utils.js +84 -8
  111. package/scripts/gates-engine.js +810 -63
  112. package/scripts/generate-case-study-outreach.js +24 -15
  113. package/scripts/git-at-scale.js +628 -0
  114. package/scripts/governance-conflict-audit.js +1650 -0
  115. package/scripts/governance-difficulty-curriculum.js +328 -0
  116. package/scripts/graphrag-retrieval.js +275 -0
  117. package/scripts/gurobi-optimizer.js +324 -0
  118. package/scripts/gurobi_optimizer.py +485 -0
  119. package/scripts/harness-selector.js +82 -1
  120. package/scripts/hidden-entry-points.js +284 -0
  121. package/scripts/human-escalation.js +199 -1
  122. package/scripts/hybrid-feedback-context.js +152 -19
  123. package/scripts/intent-governed-execution.js +602 -0
  124. package/scripts/intervention-policy.js +123 -20
  125. package/scripts/jit-harness-compose.js +628 -0
  126. package/scripts/jsonl-watcher.js +10 -0
  127. package/scripts/lesson-embedding-index.js +95 -12
  128. package/scripts/lesson-retrieval.js +105 -19
  129. package/scripts/local-model-profile.js +19 -2
  130. package/scripts/mailer/resend-mailer.js +1 -1
  131. package/scripts/matryoshka-embedding.js +235 -0
  132. package/scripts/mcp-oauth.js +42 -4
  133. package/scripts/mcp-session-handles.js +1016 -0
  134. package/scripts/mcp-wiring-doctor.js +314 -0
  135. package/scripts/memory-firewall.js +115 -2
  136. package/scripts/memory-scope-readiness.js +299 -0
  137. package/scripts/memory-vs-rag-route.js +161 -0
  138. package/scripts/model-tier-router.js +148 -21
  139. package/scripts/nvidia-specdecode-al-doctor.js +536 -0
  140. package/scripts/openui-catalog-compose-honesty.js +593 -0
  141. package/scripts/operational-integrity.js +19 -1
  142. package/scripts/override-audit.js +213 -0
  143. package/scripts/package-manager-honesty-doctor.js +458 -0
  144. package/scripts/pr-manager.js +63 -1
  145. package/scripts/prove-herdr-adapter.js +52 -0
  146. package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
  147. package/scripts/prove-workos.js +73 -0
  148. package/scripts/provider-attestation-conformance.js +192 -0
  149. package/scripts/provider-receipt-contract.js +136 -0
  150. package/scripts/qwen38-max-cost-optimizer.js +401 -0
  151. package/scripts/radware-threat-defense.js +280 -0
  152. package/scripts/rag-embedding-identity.js +221 -0
  153. package/scripts/rag-precision-guardrails.js +112 -2
  154. package/scripts/remote-feedback-capture.js +159 -0
  155. package/scripts/research-agent-harness.js +256 -0
  156. package/scripts/rsi-safety-hillclimb.js +200 -0
  157. package/scripts/rule-sprawl.js +188 -0
  158. package/scripts/schedule-manager.js +147 -0
  159. package/scripts/self-heal.js +8 -0
  160. package/scripts/session-lease.js +415 -0
  161. package/scripts/simatree-data-governance.js +347 -0
  162. package/scripts/slo-alert-engine.js +172 -7
  163. package/scripts/solver-parity.js +539 -0
  164. package/scripts/stealth-memory-injection-gate.js +333 -0
  165. package/scripts/switchyard-router.js +366 -0
  166. package/scripts/telemetry-analytics.js +84 -27
  167. package/scripts/temporal-decay-weighting.js +138 -0
  168. package/scripts/test-all.js +165 -0
  169. package/scripts/token-savings.js +42 -0
  170. package/scripts/tool-kpi-tracker.js +108 -5
  171. package/scripts/tool-registry.js +193 -5
  172. package/scripts/universal-claim-evaluator.js +14 -2
  173. package/scripts/vector-store.js +279 -9
  174. package/scripts/workflow-notebook.js +391 -0
  175. package/scripts/workflow-sentinel.js +111 -12
  176. package/scripts/workos-production-guard.js +260 -0
  177. package/scripts/workspace-search-route.js +515 -0
  178. package/server.json +2 -2
  179. package/src/agent-identity-boundary.js +76 -0
  180. package/src/agent-retrieval-cache.js +155 -0
  181. package/src/alert-noise-ledger.js +502 -0
  182. package/src/api/server.js +724 -153
  183. package/src/git-fast-cache.js +220 -0
  184. package/src/git-wal-sync.js +156 -0
  185. package/src/hash-anchored-edit.js +82 -0
  186. package/src/hermes-platform-protocol.js +475 -0
  187. package/src/hermes-sync-plane.js +241 -0
  188. package/src/index.js +30 -1
  189. package/src/iso42001-compliance-guard.js +97 -0
  190. package/src/latency-budget.js +244 -0
  191. package/src/mcp-writeguard.js +316 -0
  192. package/src/miminions-adapter.js +106 -0
  193. package/src/pipeline-compass.js +104 -0
  194. package/src/ppl-alert-pipeline.js +284 -0
  195. package/src/rendezvous-router.js +90 -0
  196. package/src/security-questionnaire.js +195 -0
@@ -61,6 +61,142 @@ function parseCronSpec(spec) {
61
61
  return null;
62
62
  }
63
63
 
64
+ const LAPTOP_BOUND_RUNTIMES = new Set([
65
+ 'laptop', 'macos', 'darwin', 'launchd', 'launchagent',
66
+ 'lid-close', 'lid_close', 'continuity', 'mac-pair', 'phone-leash', 'local',
67
+ ]);
68
+ const ALWAYS_ON_RUNTIMES = new Set([
69
+ 'vps', 'hosted', 'always-on', 'always_on', 'railway', 'fenced-vps',
70
+ ]);
71
+
72
+ function inferScheduleRuntime(params = {}) {
73
+ const explicit = String(params.runtime || '').trim().toLowerCase();
74
+ if (explicit) return explicit;
75
+ if (process.env.THUMBGATE_ALWAYS_ON_VPS === '1') return 'vps';
76
+ if (process.env.THUMBGATE_HOSTED === '1') return 'hosted';
77
+ if (process.platform === 'darwin') return 'launchd';
78
+ return 'laptop';
79
+ }
80
+
81
+ function isTwentyFourSevenSchedule(params = {}) {
82
+ // Interval alone is not a live claim. Existing hourly LaunchAgent/cron jobs
83
+ // (slow-loop, revenue-truth) stay installable; only explicit always-on/live
84
+ // claims fail closed when the path is laptop-bound.
85
+ return params.alwaysOn === true || params.twentyFourSeven === true || params.claimLive === true;
86
+ }
87
+
88
+ function isSendSpendAction(params = {}) {
89
+ const text = [params.action, params.command, params.toolName, params.purpose]
90
+ .map((value) => String(value || ''))
91
+ .join(' ');
92
+ if (params.economicAction === true || params.autoBook === true) return true;
93
+ return /\b(?:send|spend|pay|purchase|transfer|wire|invoice|payout|book|auto-?book)\b/i.test(text);
94
+ }
95
+
96
+ function resolveEscalationOptions(input = {}, options = {}) {
97
+ const approval = input.humanApproval || {};
98
+ return {
99
+ feedbackDir: options.feedbackDir || input.feedbackDir || approval.feedbackDir,
100
+ inputPath: options.inputPath || input.escalationPath || approval.escalationPath,
101
+ approvalVerificationKey: options.approvalVerificationKey || input.approvalVerificationKey,
102
+ approvalSigningKey: options.approvalSigningKey || input.approvalSigningKey,
103
+ now: options.now || input.now,
104
+ };
105
+ }
106
+
107
+ function hasVerifiedHumanSendSpendApproval(input = {}, options = {}) {
108
+ const approval = input.humanApproval;
109
+ if (!approval || typeof approval !== 'object') return false;
110
+ const escalationId = String(approval.escalationId || approval.approval?.escalationId || '').trim();
111
+ if (!escalationId) return false;
112
+ try {
113
+ const { consumeVerifiedApproval, getVerifiedApproval } = require('./human-escalation');
114
+ const consumer = approval.consumer && typeof approval.consumer === 'object'
115
+ ? approval.consumer
116
+ : { id: 'schedule-manager', kind: 'system' };
117
+ const consumed = consumeVerifiedApproval(escalationId, { consumer }, resolveEscalationOptions(input, options));
118
+ if (consumed.consumed === true && consumed.approval?.actor?.kind === 'human') {
119
+ return true;
120
+ }
121
+ if (consumed.replayed === true) {
122
+ const verified = getVerifiedApproval(escalationId, {
123
+ ...resolveEscalationOptions(input, options),
124
+ allowConsumed: true,
125
+ });
126
+ return Boolean(verified && verified.actor?.kind === 'human');
127
+ }
128
+ return false;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ function resolveScheduleClaimLive(runtimeGate, platform = process.platform) {
135
+ if (!runtimeGate || runtimeGate.allowed !== true) {
136
+ return { claimLive: false, schedulerInstallation: 'blocked' };
137
+ }
138
+ if (platform === 'darwin') {
139
+ return { claimLive: false, schedulerInstallation: 'launchd' };
140
+ }
141
+ if (platform === 'linux') {
142
+ return { claimLive: false, schedulerInstallation: 'pending-crontab' };
143
+ }
144
+ return { claimLive: false, schedulerInstallation: 'unsupported' };
145
+ }
146
+
147
+ function evaluateAlwaysOnSchedule(input = {}, options = {}) {
148
+ const runtime = inferScheduleRuntime(input);
149
+ const alwaysOn = ALWAYS_ON_RUNTIMES.has(runtime);
150
+ const actionText = [input.action, input.command, input.toolName]
151
+ .map((value) => String(value || ''))
152
+ .join(' ');
153
+
154
+ if (input.autoBook === true || /\bauto-?book\b/i.test(actionText)) {
155
+ return {
156
+ allowed: false,
157
+ failClosed: true,
158
+ claimLive: false,
159
+ reason: 'auto_book_forbidden',
160
+ message: 'Auto-book is not stolen. Send/spend stays behind a human gate.',
161
+ runtime,
162
+ };
163
+ }
164
+
165
+ if (isTwentyFourSevenSchedule(input) && (LAPTOP_BOUND_RUNTIMES.has(runtime) || !alwaysOn)) {
166
+ return {
167
+ allowed: false,
168
+ failClosed: true,
169
+ claimLive: false,
170
+ reason: 'laptop_bound_schedule',
171
+ message: 'Scheduled/24-7 work must run on an always-on VPS. Laptop, launchd, and lid-close paths fail closed and must not claim live.',
172
+ runtime,
173
+ };
174
+ }
175
+
176
+ if (isSendSpendAction(input) && !hasVerifiedHumanSendSpendApproval(input, options)) {
177
+ return {
178
+ allowed: false,
179
+ failClosed: true,
180
+ claimLive: false,
181
+ reason: 'human_gate_required',
182
+ message: 'Send/spend requires a consumed human approval from the escalation ledger.',
183
+ runtime,
184
+ };
185
+ }
186
+
187
+ return {
188
+ allowed: true,
189
+ failClosed: true,
190
+ claimLive: alwaysOn,
191
+ runtime,
192
+ boundTo: alwaysOn ? 'always-on-vps' : null,
193
+ };
194
+ }
195
+
196
+ function shouldEnforceAlwaysOnGate(params = {}) {
197
+ return isTwentyFourSevenSchedule(params) || isSendSpendAction(params) || params.autoBook === true;
198
+ }
199
+
64
200
  function generatePlist(schedule) {
65
201
  const label = escapePlistString(`${PLIST_PREFIX}.${schedule.id}`);
66
202
  const interval = schedule.calendarInterval;
@@ -151,6 +287,11 @@ function buildAgenticDataPipelineSchedule(params = {}) {
151
287
  }
152
288
 
153
289
  function createSchedule(params) {
290
+ const runtimeGate = evaluateAlwaysOnSchedule(params);
291
+ if (shouldEnforceAlwaysOnGate(params) && !runtimeGate.allowed) {
292
+ return { success: false, error: runtimeGate.message || runtimeGate.reason, gate: runtimeGate };
293
+ }
294
+
154
295
  ensureDir(SCHEDULES_DIR);
155
296
 
156
297
  const id = params.id || params.name || `sched_${Date.now()}`;
@@ -180,6 +321,8 @@ function createSchedule(params) {
180
321
  workingDirectory: params.workingDirectory || (jobFile ? path.dirname(jobFile) : process.cwd()),
181
322
  calendarInterval,
182
323
  createdAt: new Date().toISOString(),
324
+ runtimeGate,
325
+ ...resolveScheduleClaimLive(runtimeGate),
183
326
  };
184
327
 
185
328
  // Save schedule metadata
@@ -246,4 +389,8 @@ module.exports = {
246
389
  parseCronSpec,
247
390
  buildManagedScheduleCommand,
248
391
  buildAgenticDataPipelineSchedule,
392
+ evaluateAlwaysOnSchedule,
393
+ inferScheduleRuntime,
394
+ hasVerifiedHumanSendSpendApproval,
395
+ resolveScheduleClaimLive,
249
396
  };
@@ -108,6 +108,13 @@ function runSelfHeal({ reason = 'unknown', cwd = PROJECT_ROOT } = {}) {
108
108
  });
109
109
  const reasoning = aggregateTraces(traces);
110
110
 
111
+ let gitScale = null;
112
+ try {
113
+ gitScale = require('./git-at-scale').applyScaleHeal(cwd);
114
+ } catch (error) {
115
+ gitScale = { applied: false, error: error.message };
116
+ }
117
+
111
118
  return {
112
119
  timestamp: new Date().toISOString(),
113
120
  reason,
@@ -119,6 +126,7 @@ function runSelfHeal({ reason = 'unknown', cwd = PROJECT_ROOT } = {}) {
119
126
  healthy: execution.failed === 0,
120
127
  reasoning,
121
128
  traces,
129
+ gitScale,
122
130
  };
123
131
  }
124
132
 
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Checkout session lease — single-writer ownership guard for a shared git checkout.
5
+ //
6
+ // Why this exists (incident 2026-08-11): two agents ran concurrently in the SAME
7
+ // checkout. Agent A force-checked-out another branch and ran `git clean`, wiping
8
+ // Agent B's untracked work; Agent B's `git add -A` swept Agent A's untracked file
9
+ // into the wrong branch. No mechanism marked "this checkout belongs to one live
10
+ // session." Task-scope leases (gates-engine) cap authority per capability, but
11
+ // nothing prevented two processes from mutating the same working tree.
12
+ //
13
+ // This module adds a checkout-level lease:
14
+ // - `claim` — write .git/thumbgate-session-lease.json. Fails if a DIFFERENT
15
+ // live session already holds it (same-session re-claim is
16
+ // idempotent). Uses exclusive-create so concurrent claimers
17
+ // cannot both succeed.
18
+ // - `check` — exit 0 if this session holds the lease or no live lease exists;
19
+ // exit 1 if a different live agent holds it.
20
+ // - `release` — remove the lease (only the holder may release; a stale holder
21
+ // may force-release after liveness is false).
22
+ // - `guard` — run a shell command only while holding the lease.
23
+ //
24
+ // Liveness:
25
+ // - Prefer a durable PID: THUMBGATE_SESSION_PID (agent/shell parent), else the
26
+ // CLI's process.ppid when the claim command itself is short-lived, else
27
+ // process.pid for in-process API use.
28
+ // - Explicit session tokens (THUMBGATE_SESSION_AGENT) also get a TTL
29
+ // (default 8h, override THUMBGATE_SESSION_LEASE_TTL_MS) so short-lived claim
30
+ // subprocesses do not immediately look "stale" after exit.
31
+ //
32
+ // The lease file lives in .git/ so `git clean` cannot delete it.
33
+
34
+ const fs = require('node:fs');
35
+ const os = require('node:os');
36
+ const path = require('node:path');
37
+ const { spawnSync } = require('node:child_process');
38
+
39
+ const LEASE_FILENAME = 'thumbgate-session-lease.json';
40
+ const DEFAULT_SESSION_TTL_MS = 8 * 60 * 60 * 1000;
41
+ const MAX_CLAIM_ATTEMPTS = 5;
42
+
43
+ function findRepoRoot(start = process.cwd()) {
44
+ let current = path.resolve(start);
45
+ for (;;) {
46
+ if (fs.existsSync(path.join(current, '.git'))) {
47
+ return current;
48
+ }
49
+ const parent = path.dirname(current);
50
+ if (parent === current) {
51
+ return null;
52
+ }
53
+ current = parent;
54
+ }
55
+ }
56
+
57
+ function leasePath(repoRoot) {
58
+ // .git may be a file (worktree/submodule) — resolve its real dir first.
59
+ const gitPath = path.join(repoRoot, '.git');
60
+ let gitDir = gitPath;
61
+ if (fs.existsSync(gitPath) && fs.statSync(gitPath).isFile()) {
62
+ const contents = fs.readFileSync(gitPath, 'utf8').trim();
63
+ const match = contents.match(/^gitdir:\s*(.+)$/);
64
+ if (match) {
65
+ gitDir = path.resolve(repoRoot, match[1]);
66
+ }
67
+ }
68
+ return path.join(gitDir, LEASE_FILENAME);
69
+ }
70
+
71
+ function pidAlive(pid) {
72
+ if (!Number.isInteger(pid) || pid <= 0) {
73
+ return false;
74
+ }
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ } catch (error) {
79
+ // ESRCH = no such process. EPERM = exists but not ours — still alive.
80
+ return error.code === 'EPERM';
81
+ }
82
+ }
83
+
84
+ function readLease(repoRoot) {
85
+ const file = leasePath(repoRoot);
86
+ if (!fs.existsSync(file)) {
87
+ return null;
88
+ }
89
+ try {
90
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
91
+ } catch (error) {
92
+ return { invalid: true, file };
93
+ }
94
+ }
95
+
96
+ function sessionTtlMs() {
97
+ const fromEnv = Number(process.env.THUMBGATE_SESSION_LEASE_TTL_MS);
98
+ if (Number.isFinite(fromEnv) && fromEnv > 0) {
99
+ return Math.floor(fromEnv);
100
+ }
101
+ return DEFAULT_SESSION_TTL_MS;
102
+ }
103
+
104
+ function hasExplicitSessionAgent() {
105
+ return Boolean(process.env.THUMBGATE_SESSION_AGENT && String(process.env.THUMBGATE_SESSION_AGENT).trim());
106
+ }
107
+
108
+ /**
109
+ * Resolve the PID that should represent lease liveness.
110
+ * Short-lived `node scripts/session-lease.js claim` must not record itself as
111
+ * the only liveness signal — prefer the durable agent/shell parent.
112
+ */
113
+ function resolveLeasePid() {
114
+ const fromEnv = Number(process.env.THUMBGATE_SESSION_PID);
115
+ if (Number.isInteger(fromEnv) && fromEnv > 0 && pidAlive(fromEnv)) {
116
+ return fromEnv;
117
+ }
118
+
119
+ const invokedAsCli =
120
+ process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename);
121
+ if (invokedAsCli) {
122
+ const parent = Number(process.ppid);
123
+ if (Number.isInteger(parent) && parent > 1 && pidAlive(parent)) {
124
+ return parent;
125
+ }
126
+ }
127
+
128
+ return process.pid;
129
+ }
130
+
131
+ function isLeaseLive(lease) {
132
+ if (!lease || typeof lease !== 'object' || lease.invalid) {
133
+ return false;
134
+ }
135
+ if (Boolean(lease.pid) && pidAlive(Number(lease.pid))) {
136
+ return true;
137
+ }
138
+ if (lease.expiresAt) {
139
+ const expiresMs = Date.parse(lease.expiresAt);
140
+ if (Number.isFinite(expiresMs) && Date.now() < expiresMs) {
141
+ return true;
142
+ }
143
+ }
144
+ return false;
145
+ }
146
+
147
+ function agentId() {
148
+ // A session identity, not a pid identity: one agent session (Hermes, a CI
149
+ // runner, a CLI tool) may span many short-lived subprocesses, each with a
150
+ // different pid. The lease must survive across those subprocesses, so the
151
+ // owning identity is an explicit session token (env override) falling back
152
+ // to host:pid for in-process API use.
153
+ return process.env.THUMBGATE_SESSION_AGENT || `${os.hostname()}:${process.pid}`;
154
+ }
155
+
156
+ function buildLeaseRecord(details = {}) {
157
+ const explicitSession = hasExplicitSessionAgent();
158
+ const ttlMs = details.ttlMs != null ? Number(details.ttlMs) : sessionTtlMs();
159
+ const lease = {
160
+ agent: details.agent || agentId(),
161
+ pid: details.pid != null ? Number(details.pid) : resolveLeasePid(),
162
+ host: os.hostname(),
163
+ startedAt: details.startedAt || new Date().toISOString(),
164
+ cwd: process.cwd(),
165
+ user: process.env.USER || os.userInfo().username || null,
166
+ command: (process.argv[1] || '').split(path.sep).pop() || null,
167
+ };
168
+ // Explicit session tokens get a TTL so claim CLI exit does not free the lease.
169
+ if (explicitSession || details.expiresAt) {
170
+ lease.expiresAt =
171
+ details.expiresAt || new Date(Date.now() + (Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : DEFAULT_SESSION_TTL_MS)).toISOString();
172
+ }
173
+ return lease;
174
+ }
175
+
176
+ function writeLease(repoRoot, details = {}, options = {}) {
177
+ const file = leasePath(repoRoot);
178
+ const lease = buildLeaseRecord(details);
179
+ fs.mkdirSync(path.dirname(file), { recursive: true });
180
+ const payload = JSON.stringify(lease, null, 2) + '\n';
181
+ if (options.exclusive) {
182
+ fs.writeFileSync(file, payload, { flag: 'wx' });
183
+ } else {
184
+ fs.writeFileSync(file, payload);
185
+ }
186
+ return lease;
187
+ }
188
+
189
+ function removeLease(repoRoot) {
190
+ const file = leasePath(repoRoot);
191
+ if (fs.existsSync(file)) {
192
+ fs.rmSync(file, { force: true });
193
+ }
194
+ }
195
+
196
+ function claim(repoRoot, options = {}) {
197
+ for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt += 1) {
198
+ const existing = readLease(repoRoot);
199
+ const sameSession = existing && !existing.invalid && existing.agent === agentId();
200
+ if (sameSession) {
201
+ // Same session token — idempotent refresh (overwrite allowed).
202
+ const lease = writeLease(repoRoot, {
203
+ ...options,
204
+ agent: existing.agent,
205
+ startedAt: existing.startedAt,
206
+ });
207
+ return { ok: true, alreadyHeld: true, lease };
208
+ }
209
+ if (existing && !existing.invalid && isLeaseLive(existing)) {
210
+ return {
211
+ ok: false,
212
+ code: 'LEASED',
213
+ holder: existing,
214
+ message: `Checkout lease held by live agent ${existing.agent} (pid ${existing.pid}) since ${existing.startedAt}. Another session owns this checkout. Use a separate worktree instead of writing to a claimed checkout.`,
215
+ };
216
+ }
217
+
218
+ // Stale or missing: exclusive create so two concurrent claimers cannot both win.
219
+ if (existing && !existing.invalid) {
220
+ try {
221
+ removeLease(repoRoot);
222
+ } catch (error) {
223
+ // retry loop will re-read
224
+ }
225
+ }
226
+
227
+ try {
228
+ const lease = writeLease(repoRoot, options, { exclusive: true });
229
+ return { ok: true, alreadyHeld: false, lease };
230
+ } catch (error) {
231
+ if (error && error.code === 'EEXIST') {
232
+ // Lost the race — loop and re-evaluate the winner.
233
+ continue;
234
+ }
235
+ throw error;
236
+ }
237
+ }
238
+
239
+ const existing = readLease(repoRoot);
240
+ if (existing && !existing.invalid && isLeaseLive(existing) && existing.agent !== agentId()) {
241
+ return {
242
+ ok: false,
243
+ code: 'LEASED',
244
+ holder: existing,
245
+ message: `Checkout lease held by live agent ${existing.agent} (pid ${existing.pid}) since ${existing.startedAt}. Another session owns this checkout. Use a separate worktree instead of writing to a claimed checkout.`,
246
+ };
247
+ }
248
+ return {
249
+ ok: false,
250
+ code: 'LEASE_RACE',
251
+ message: 'Failed to claim checkout lease after concurrent attempts. Retry in a moment or use a separate worktree.',
252
+ };
253
+ }
254
+
255
+ function check(repoRoot) {
256
+ const existing = readLease(repoRoot);
257
+ if (!existing || existing.invalid) {
258
+ return { ok: true, held: false, lease: null };
259
+ }
260
+ if (existing.agent === agentId()) {
261
+ return { ok: true, held: true, lease: existing, mine: true };
262
+ }
263
+ if (!isLeaseLive(existing)) {
264
+ return { ok: true, held: false, lease: existing, stale: true };
265
+ }
266
+ return {
267
+ ok: false,
268
+ code: 'LEASED',
269
+ held: true,
270
+ lease: existing,
271
+ holder: existing,
272
+ message: `Checkout lease held by live agent ${existing.agent} (pid ${existing.pid}) since ${existing.startedAt}.`,
273
+ };
274
+ }
275
+
276
+ function release(repoRoot, options = {}) {
277
+ const existing = readLease(repoRoot);
278
+ if (!existing || existing.invalid) {
279
+ return { ok: true, released: false, reason: 'no-lease' };
280
+ }
281
+ if (existing.agent === agentId() || options.force) {
282
+ removeLease(repoRoot);
283
+ return { ok: true, released: true, force: Boolean(options.force) };
284
+ }
285
+ if (!isLeaseLive(existing)) {
286
+ // Stale holder is provably dead / expired — cleaning up is safe and keeps the
287
+ // checkout usable after a crashed session.
288
+ removeLease(repoRoot);
289
+ return { ok: true, released: true, stale: true };
290
+ }
291
+ return {
292
+ ok: false,
293
+ code: 'LEASED',
294
+ message: `Lease held by live agent ${existing.agent} (pid ${existing.pid}); only the holder may release.`,
295
+ };
296
+ }
297
+
298
+ function guard(repoRoot, commandArgs, options = {}) {
299
+ const leaseCheck = check(repoRoot);
300
+ if (!leaseCheck.ok) {
301
+ return { ok: false, code: leaseCheck.code, message: leaseCheck.message };
302
+ }
303
+ if (!leaseCheck.held) {
304
+ if (options.autoClaim) {
305
+ const claimResult = claim(repoRoot);
306
+ if (!claimResult.ok) {
307
+ return { ok: false, code: claimResult.code, message: claimResult.message };
308
+ }
309
+ } else {
310
+ return {
311
+ ok: false,
312
+ code: 'UNCLAIMED',
313
+ message: 'No session lease held. Run `node scripts/session-lease.js claim` (or pass --auto-claim) before mutating this checkout.',
314
+ };
315
+ }
316
+ }
317
+ const result = spawnSync(commandArgs[0], commandArgs.slice(1), {
318
+ stdio: 'inherit',
319
+ cwd: repoRoot,
320
+ });
321
+ return { ok: result.status === 0, status: result.status };
322
+ }
323
+
324
+ function main() {
325
+ const args = process.argv.slice(2);
326
+ const command = args[0];
327
+ const repoRoot = findRepoRoot();
328
+ if (!repoRoot) {
329
+ console.error('error: not inside a git checkout');
330
+ process.exit(1);
331
+ }
332
+
333
+ try {
334
+ switch (command) {
335
+ case 'claim': {
336
+ const result = claim(repoRoot);
337
+ if (!result.ok) {
338
+ console.error(result.message);
339
+ process.exit(1);
340
+ }
341
+ console.log(
342
+ result.alreadyHeld
343
+ ? `lease already held by this session (pid ${result.lease.pid})`
344
+ : `lease claimed: ${result.lease.agent} since ${result.lease.startedAt}`
345
+ );
346
+ process.exit(0);
347
+ }
348
+ case 'check': {
349
+ const result = check(repoRoot);
350
+ if (!result.ok) {
351
+ console.error(result.message);
352
+ process.exit(1);
353
+ }
354
+ console.log(result.held ? `lease held by this session (pid ${result.lease.pid})` : 'lease free');
355
+ process.exit(0);
356
+ }
357
+ case 'release': {
358
+ const result = release(repoRoot, { force: args.includes('--force') });
359
+ if (!result.ok) {
360
+ console.error(result.message);
361
+ process.exit(1);
362
+ }
363
+ console.log(
364
+ result.released
365
+ ? `lease released${result.stale ? ' (stale holder)' : ''}`
366
+ : 'no lease to release'
367
+ );
368
+ process.exit(0);
369
+ }
370
+ case 'guard': {
371
+ const autoClaim = args.includes('--auto-claim');
372
+ const commandArgs = args.slice(1).filter((arg) => arg !== '--auto-claim');
373
+ const result = guard(repoRoot, commandArgs, { autoClaim });
374
+ if (!result.ok) {
375
+ console.error(result.message || `command failed with status ${result.status}`);
376
+ process.exit(result.code === 'LEASED' || result.code === 'UNCLAIMED' ? 1 : 2);
377
+ }
378
+ process.exit(0);
379
+ }
380
+ case 'lease-path': {
381
+ console.log(leasePath(repoRoot));
382
+ process.exit(0);
383
+ }
384
+ default: {
385
+ console.error('usage: node scripts/session-lease.js <claim|check|release|guard|lease-path>');
386
+ process.exit(1);
387
+ }
388
+ }
389
+ } catch (error) {
390
+ console.error(`error: ${error.message}`);
391
+ process.exit(1);
392
+ }
393
+ }
394
+
395
+ module.exports = {
396
+ DEFAULT_SESSION_TTL_MS,
397
+ LEASE_FILENAME,
398
+ agentId,
399
+ check,
400
+ claim,
401
+ findRepoRoot,
402
+ guard,
403
+ isLeaseLive,
404
+ leasePath,
405
+ pidAlive,
406
+ readLease,
407
+ release,
408
+ removeLease,
409
+ resolveLeasePid,
410
+ writeLease,
411
+ };
412
+
413
+ if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
414
+ main();
415
+ }