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.
- package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
- package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
- package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
- package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
- package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +1 -0
- package/.well-known/mcp/server-card.json +1 -1
- package/CONTRIBUTING.md +95 -0
- package/README.md +195 -632
- package/THIRD_PARTY_NOTICES.md +89 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/future-agi/.mcp.json +8 -0
- package/adapters/future-agi/FUTURE_AGI.md +23 -0
- package/adapters/future-agi/config.toml +3 -0
- package/adapters/future-agi/future-agi-bridge.js +9 -0
- package/adapters/future-agi/opencode.json +8 -0
- package/adapters/herdr/herdr-plugin.toml +18 -0
- package/adapters/mcp/server-stdio.js +238 -25
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/workos/WORKOS.md +52 -0
- package/bin/cli.js +336 -3
- package/bin/futureagi-bridge +9 -0
- package/config/gate-templates.json +581 -4
- package/config/gates/actor-critic-audit.json +34 -0
- package/config/gates/default.json +9 -3
- package/config/gates/five-walls-governance.json +34 -0
- package/config/gates/future-agi-guardrails.json +34 -0
- package/config/gates/radware-threat-defense-2026.json +61 -0
- package/config/gates/simatree-data-governance.json +33 -0
- package/config/mcp-allowlists.json +4 -0
- package/config/merge-quality-checks.json +6 -0
- package/config/model-candidates.json +312 -29
- package/config/model-tiers.json +18 -0
- package/config/post-deploy-marketing-pages.json +10 -0
- package/config/progressive/01-wire-only.json +11 -0
- package/config/progressive/02-dashboard-empty-ok.json +10 -0
- package/config/progressive/03-one-lesson.json +10 -0
- package/config/progressive/04-warn-fires.json +11 -0
- package/config/progressive/05-strict-optional.json +11 -0
- package/config/progressive/README.md +15 -0
- package/config/schemas/broker-execution-receipt.schema.json +139 -0
- package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
- package/conformance/provider-attestation/vectors.json +320 -0
- package/docs/specs/provider-execution-attestation-v1.md +69 -0
- package/openapi/openapi.yaml +15 -0
- package/package.json +390 -148
- package/public/about.html +2 -2
- package/public/ai-malpractice-prevention.html +7 -7
- package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
- package/public/blog/a-receipt-is-not-world-state.html +388 -0
- package/public/blog/git-at-agent-scale.html +374 -0
- package/public/blog/no-llm-in-the-gate.html +133 -0
- package/public/blog.html +80 -0
- package/public/case-studies.html +16 -1
- package/public/compare.html +28 -0
- package/public/diagnostic.html +216 -7
- package/public/docs/connectors.html +39 -0
- package/public/federal.html +2 -2
- package/public/founders.html +639 -0
- package/public/index.html +87 -9
- package/public/install.html +8 -8
- package/public/learn.html +39 -0
- package/public/numbers.html +2 -2
- package/public/peter.html +310 -0
- package/public/platform-partners.html +119 -0
- package/public/pricing.html +24 -3
- package/public/privacy.html +117 -0
- package/public/pro.html +17 -0
- package/public/support.html +62 -0
- package/public/terms.html +130 -0
- package/public/third-party-notices.html +95 -0
- package/public/yt.html +351 -0
- package/scripts/action-receipts.js +133 -3
- package/scripts/adaptive-governance-arena.js +349 -0
- package/scripts/admin-override.js +205 -0
- package/scripts/agent-action-inventory.js +869 -0
- package/scripts/agent-audit-trace.js +42 -2
- package/scripts/agent-egress-policy.js +1117 -0
- package/scripts/agent-memory-lifecycle.js +141 -2
- package/scripts/agent-operations-planner.js +441 -1
- package/scripts/agent-readiness.js +68 -0
- package/scripts/agent-security-central.js +647 -0
- package/scripts/allowlist-bridge-honesty.js +417 -0
- package/scripts/async-job-runner.js +102 -11
- package/scripts/audit-trail.js +212 -0
- package/scripts/billing.js +1 -1
- package/scripts/broker-execution-receipts.js +719 -0
- package/scripts/budget-aware-gates-proof.js +423 -0
- package/scripts/claude-feedback-sync.js +29 -3
- package/scripts/claw-harness-production.js +237 -0
- package/scripts/cli-schema.js +163 -1
- package/scripts/codex-runbook-flywheel.js +318 -0
- package/scripts/context-footprint.js +186 -0
- package/scripts/contextfs.js +143 -61
- package/scripts/dashboard.js +251 -32
- package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
- package/scripts/docker-sandbox-planner.js +18 -0
- package/scripts/double-blind-eval-protocol.js +252 -0
- package/scripts/edotenv-rl-gateway.js +259 -0
- package/scripts/ensure-production-search-corpus.js +162 -0
- package/scripts/eval-holdout.js +311 -0
- package/scripts/feedback-aggregate.js +21 -2
- package/scripts/feedback-loop.js +87 -5
- package/scripts/feedback-quality.js +9 -0
- package/scripts/file-ledger-lock.js +4 -1
- package/scripts/financial-control-plane.js +41 -1
- package/scripts/find-dormant-requires.js +118 -0
- package/scripts/fs-utils.js +84 -8
- package/scripts/gates-engine.js +810 -63
- package/scripts/generate-case-study-outreach.js +24 -15
- package/scripts/git-at-scale.js +628 -0
- package/scripts/governance-conflict-audit.js +1650 -0
- package/scripts/governance-difficulty-curriculum.js +328 -0
- package/scripts/graphrag-retrieval.js +275 -0
- package/scripts/gurobi-optimizer.js +324 -0
- package/scripts/gurobi_optimizer.py +485 -0
- package/scripts/harness-selector.js +82 -1
- package/scripts/hidden-entry-points.js +284 -0
- package/scripts/human-escalation.js +199 -1
- package/scripts/hybrid-feedback-context.js +152 -19
- package/scripts/intent-governed-execution.js +602 -0
- package/scripts/intervention-policy.js +123 -20
- package/scripts/jit-harness-compose.js +628 -0
- package/scripts/jsonl-watcher.js +10 -0
- package/scripts/lesson-embedding-index.js +95 -12
- package/scripts/lesson-retrieval.js +105 -19
- package/scripts/local-model-profile.js +19 -2
- package/scripts/mailer/resend-mailer.js +1 -1
- package/scripts/matryoshka-embedding.js +235 -0
- package/scripts/mcp-oauth.js +42 -4
- package/scripts/mcp-session-handles.js +1016 -0
- package/scripts/mcp-wiring-doctor.js +314 -0
- package/scripts/memory-firewall.js +115 -2
- package/scripts/memory-scope-readiness.js +299 -0
- package/scripts/memory-vs-rag-route.js +161 -0
- package/scripts/model-tier-router.js +148 -21
- package/scripts/nvidia-specdecode-al-doctor.js +536 -0
- package/scripts/openui-catalog-compose-honesty.js +593 -0
- package/scripts/operational-integrity.js +19 -1
- package/scripts/override-audit.js +213 -0
- package/scripts/package-manager-honesty-doctor.js +458 -0
- package/scripts/pr-manager.js +63 -1
- package/scripts/prove-herdr-adapter.js +52 -0
- package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
- package/scripts/prove-workos.js +73 -0
- package/scripts/provider-attestation-conformance.js +192 -0
- package/scripts/provider-receipt-contract.js +136 -0
- package/scripts/qwen38-max-cost-optimizer.js +401 -0
- package/scripts/radware-threat-defense.js +280 -0
- package/scripts/rag-embedding-identity.js +221 -0
- package/scripts/rag-precision-guardrails.js +112 -2
- package/scripts/remote-feedback-capture.js +159 -0
- package/scripts/research-agent-harness.js +256 -0
- package/scripts/rsi-safety-hillclimb.js +200 -0
- package/scripts/rule-sprawl.js +188 -0
- package/scripts/schedule-manager.js +147 -0
- package/scripts/self-heal.js +8 -0
- package/scripts/session-lease.js +415 -0
- package/scripts/simatree-data-governance.js +347 -0
- package/scripts/slo-alert-engine.js +172 -7
- package/scripts/solver-parity.js +539 -0
- package/scripts/stealth-memory-injection-gate.js +333 -0
- package/scripts/switchyard-router.js +366 -0
- package/scripts/telemetry-analytics.js +84 -27
- package/scripts/temporal-decay-weighting.js +138 -0
- package/scripts/test-all.js +165 -0
- package/scripts/token-savings.js +42 -0
- package/scripts/tool-kpi-tracker.js +108 -5
- package/scripts/tool-registry.js +193 -5
- package/scripts/universal-claim-evaluator.js +14 -2
- package/scripts/vector-store.js +279 -9
- package/scripts/workflow-notebook.js +391 -0
- package/scripts/workflow-sentinel.js +111 -12
- package/scripts/workos-production-guard.js +260 -0
- package/scripts/workspace-search-route.js +515 -0
- package/server.json +2 -2
- package/src/agent-identity-boundary.js +76 -0
- package/src/agent-retrieval-cache.js +155 -0
- package/src/alert-noise-ledger.js +502 -0
- package/src/api/server.js +724 -153
- package/src/git-fast-cache.js +220 -0
- package/src/git-wal-sync.js +156 -0
- package/src/hash-anchored-edit.js +82 -0
- package/src/hermes-platform-protocol.js +475 -0
- package/src/hermes-sync-plane.js +241 -0
- package/src/index.js +30 -1
- package/src/iso42001-compliance-guard.js +97 -0
- package/src/latency-budget.js +244 -0
- package/src/mcp-writeguard.js +316 -0
- package/src/miminions-adapter.js +106 -0
- package/src/pipeline-compass.js +104 -0
- package/src/ppl-alert-pipeline.js +284 -0
- package/src/rendezvous-router.js +90 -0
- 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
|
};
|
package/scripts/self-heal.js
CHANGED
|
@@ -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
|
+
}
|