thumbgate 1.1.0 โ†’ 1.3.0

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 (63) hide show
  1. package/.claude-plugin/README.md +4 -4
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +48 -16
  6. package/adapters/README.md +1 -1
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/codex/config.toml +2 -2
  9. package/adapters/mcp/server-stdio.js +11 -8
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bin/cli.js +20 -11
  12. package/config/github-about.json +1 -1
  13. package/config/model-tiers.json +11 -0
  14. package/package.json +22 -11
  15. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  16. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  17. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  18. package/plugins/codex-profile/.mcp.json +1 -1
  19. package/plugins/codex-profile/INSTALL.md +1 -1
  20. package/plugins/codex-profile/README.md +1 -1
  21. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  22. package/plugins/cursor-marketplace/README.md +2 -2
  23. package/plugins/cursor-marketplace/commands/capture-feedback.md +2 -2
  24. package/plugins/cursor-marketplace/rules/feedback-capture.mdc +3 -3
  25. package/plugins/cursor-marketplace/skills/capture-feedback/SKILL.md +3 -2
  26. package/plugins/opencode-profile/INSTALL.md +1 -1
  27. package/public/compare.html +302 -0
  28. package/public/guide.html +4 -4
  29. package/public/index.html +77 -38
  30. package/public/learn/ai-agent-persistent-memory.html +1 -0
  31. package/public/lessons.html +325 -17
  32. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  33. package/scripts/ai-search-visibility.js +142 -0
  34. package/scripts/audit-trail.js +6 -0
  35. package/scripts/capture-railway-diagnostics.sh +97 -0
  36. package/scripts/changeset-check.js +372 -0
  37. package/scripts/check-congruence.js +8 -5
  38. package/scripts/claude-feedback-sync.js +320 -0
  39. package/scripts/cli-telemetry.js +4 -1
  40. package/scripts/computer-use-firewall.js +45 -15
  41. package/scripts/contextfs.js +32 -23
  42. package/scripts/dashboard.js +84 -0
  43. package/scripts/docker-sandbox-planner.js +208 -0
  44. package/scripts/feedback-loop.js +16 -0
  45. package/scripts/github-about.js +56 -0
  46. package/scripts/intervention-policy.js +696 -0
  47. package/scripts/local-model-profile.js +18 -2
  48. package/scripts/model-tier-router.js +10 -1
  49. package/scripts/operational-integrity.js +361 -32
  50. package/scripts/prove-adapters.js +1 -0
  51. package/scripts/prove-automation.js +2 -2
  52. package/scripts/prove-packaged-runtime.js +260 -0
  53. package/scripts/prove-runtime.js +13 -0
  54. package/scripts/published-cli.js +10 -1
  55. package/scripts/rate-limiter.js +3 -3
  56. package/scripts/statusline-links.js +238 -0
  57. package/scripts/statusline-local-stats.js +2 -0
  58. package/scripts/statusline.sh +200 -10
  59. package/scripts/sync-github-about.js +7 -4
  60. package/scripts/tool-registry.js +2 -2
  61. package/scripts/workflow-sentinel.js +197 -39
  62. package/skills/thumbgate/SKILL.md +1 -1
  63. package/src/api/server.js +12 -1
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const http = require('http');
8
+ const net = require('net');
9
+ const { execFileSync } = require('child_process');
10
+
11
+ const ROOT = path.join(__dirname, '..');
12
+ const DEFAULT_TIMEOUT_MS = 15000;
13
+ const STATUSLINE_INPUT = JSON.stringify({ context_window: { used_percentage: 12 } });
14
+
15
+ function parseArgs(argv = process.argv.slice(2)) {
16
+ const parsed = {};
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const token = argv[index];
19
+ if (!token.startsWith('--')) continue;
20
+ const [rawKey, inlineValue] = token.slice(2).split('=');
21
+ const key = rawKey.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
22
+ const value = inlineValue !== undefined ? inlineValue : argv[index + 1];
23
+ parsed[key] = value;
24
+ if (inlineValue === undefined) index += 1;
25
+ }
26
+ return parsed;
27
+ }
28
+
29
+ function pkgVersion() {
30
+ return require(path.join(ROOT, 'package.json')).version;
31
+ }
32
+
33
+ function packCurrentRepo(packDir) {
34
+ fs.mkdirSync(packDir, { recursive: true });
35
+ const output = execFileSync('npm', ['pack', '--json', '--pack-destination', packDir], {
36
+ cwd: ROOT,
37
+ encoding: 'utf8',
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ });
40
+ const parsed = JSON.parse(output);
41
+ const fileName = parsed && parsed[0] && parsed[0].filename;
42
+ if (!fileName) {
43
+ throw new Error('npm pack did not return a tarball filename');
44
+ }
45
+ return path.join(packDir, fileName);
46
+ }
47
+
48
+ function installPackage(prefixDir, packageSpec) {
49
+ fs.mkdirSync(prefixDir, { recursive: true });
50
+ execFileSync('npm', ['install', '--prefix', prefixDir, '--no-fund', '--no-audit', packageSpec], {
51
+ cwd: ROOT,
52
+ encoding: 'utf8',
53
+ stdio: ['ignore', 'pipe', 'pipe'],
54
+ });
55
+ return path.join(prefixDir, 'node_modules', '.bin', 'thumbgate');
56
+ }
57
+
58
+ function request(url, timeoutMs = 2000) {
59
+ return new Promise((resolve, reject) => {
60
+ const req = http.get(url, (res) => {
61
+ let body = '';
62
+ res.setEncoding('utf8');
63
+ res.on('data', (chunk) => { body += chunk; });
64
+ res.on('end', () => resolve({ statusCode: res.statusCode, body }));
65
+ });
66
+ req.on('error', reject);
67
+ req.setTimeout(timeoutMs, () => {
68
+ req.destroy(new Error(`Timed out requesting ${url}`));
69
+ });
70
+ });
71
+ }
72
+
73
+ function sleep(ms) {
74
+ return new Promise((resolve) => setTimeout(resolve, ms));
75
+ }
76
+
77
+ function getAvailablePort() {
78
+ return new Promise((resolve, reject) => {
79
+ const server = net.createServer();
80
+ server.listen(0, '127.0.0.1', () => {
81
+ const address = server.address();
82
+ const port = address && address.port;
83
+ server.close((error) => {
84
+ if (error) {
85
+ reject(error);
86
+ return;
87
+ }
88
+ resolve(port);
89
+ });
90
+ });
91
+ server.on('error', reject);
92
+ });
93
+ }
94
+
95
+ async function waitForHealthy(origin, expectedVersion, timeoutMs = DEFAULT_TIMEOUT_MS) {
96
+ const startedAt = Date.now();
97
+ while (Date.now() - startedAt < timeoutMs) {
98
+ try {
99
+ const response = await request(`${origin}/health`);
100
+ if (response.statusCode === 200) {
101
+ const body = JSON.parse(response.body);
102
+ if (body.version === expectedVersion) {
103
+ return body;
104
+ }
105
+ }
106
+ } catch {
107
+ // Keep polling until the detached API server comes online.
108
+ }
109
+ await sleep(250);
110
+ }
111
+ throw new Error(`Timed out waiting for packaged runtime health at ${origin}`);
112
+ }
113
+
114
+ function renderStatusline(runtimeBin, projectDir, env) {
115
+ return execFileSync(runtimeBin, ['statusline-render'], {
116
+ cwd: projectDir,
117
+ env,
118
+ encoding: 'utf8',
119
+ input: STATUSLINE_INPUT,
120
+ stdio: ['pipe', 'pipe', 'pipe'],
121
+ timeout: 10000,
122
+ });
123
+ }
124
+
125
+ function runtimeStatePath(homeDir) {
126
+ return path.join(homeDir, '.thumbgate', 'runtime', 'statusline-api.json');
127
+ }
128
+
129
+ async function stopDetachedRuntime(homeDir) {
130
+ try {
131
+ const state = JSON.parse(fs.readFileSync(runtimeStatePath(homeDir), 'utf8'));
132
+ const pid = Number(state && state.pid);
133
+ if (!Number.isInteger(pid) || pid <= 0) return;
134
+ try {
135
+ process.kill(pid, 'SIGTERM');
136
+ } catch {
137
+ return;
138
+ }
139
+ for (let attempt = 0; attempt < 10; attempt += 1) {
140
+ await sleep(100);
141
+ try {
142
+ process.kill(pid, 0);
143
+ } catch {
144
+ return;
145
+ }
146
+ }
147
+ try {
148
+ process.kill(pid, 'SIGKILL');
149
+ } catch {
150
+ // Ignore cleanup races.
151
+ }
152
+ } catch {
153
+ // No runtime state means nothing to clean up.
154
+ }
155
+ }
156
+
157
+ async function runPackagedRuntimeSmoke(options = {}) {
158
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'thumbgate-packaged-runtime-'));
159
+ const homeDir = path.join(tempRoot, 'home');
160
+ const projectDir = path.join(tempRoot, 'project');
161
+ const packDir = path.join(tempRoot, 'pack');
162
+ const runtimeDir = path.join(tempRoot, 'runtime');
163
+ const expectedVersion = options.expectedVersion || pkgVersion();
164
+ const feedbackDir = path.join(projectDir, '.thumbgate');
165
+ fs.mkdirSync(homeDir, { recursive: true });
166
+ fs.mkdirSync(projectDir, { recursive: true });
167
+ fs.mkdirSync(feedbackDir, { recursive: true });
168
+ fs.writeFileSync(
169
+ path.join(feedbackDir, 'feedback-log.jsonl'),
170
+ [
171
+ JSON.stringify({ signal: 'positive', timestamp: '2026-04-08T20:00:00.000Z', context: 'packaged runtime smoke pass' }),
172
+ JSON.stringify({ signal: 'negative', timestamp: '2026-04-08T20:01:00.000Z', context: 'packaged runtime smoke fail path' }),
173
+ ].join('\n') + '\n'
174
+ );
175
+
176
+ try {
177
+ const packageSpec = options.packageSpec || packCurrentRepo(packDir);
178
+ const runtimeBin = installPackage(runtimeDir, packageSpec);
179
+ if (!fs.existsSync(runtimeBin)) {
180
+ throw new Error(`Installed runtime binary is missing: ${runtimeBin}`);
181
+ }
182
+
183
+ const port = await getAvailablePort();
184
+ const origin = `http://127.0.0.1:${port}`;
185
+ const env = {
186
+ ...process.env,
187
+ HOME: homeDir,
188
+ THUMBGATE_PROJECT_DIR: projectDir,
189
+ THUMBGATE_LOCAL_API_ORIGIN: origin,
190
+ THUMBGATE_API_KEY: 'tg_packaged_runtime_smoke',
191
+ NO_COLOR: '1',
192
+ };
193
+
194
+ const initialStatusline = renderStatusline(runtimeBin, projectDir, env);
195
+ if (!initialStatusline.includes(`ThumbGate v${expectedVersion}`)) {
196
+ throw new Error(`Statusline version mismatch before boot: ${initialStatusline.trim()}`);
197
+ }
198
+ if (!/(Dashboard|Dashboardโ€ฆ)/.test(initialStatusline) || !/(Lessons|Lessonsโ€ฆ)/.test(initialStatusline)) {
199
+ throw new Error(`Statusline missing dashboard affordances before boot: ${initialStatusline.trim()}`);
200
+ }
201
+
202
+ const health = await waitForHealthy(origin, expectedVersion, Number(options.timeoutMs || DEFAULT_TIMEOUT_MS));
203
+ const dashboard = await request(`${origin}/dashboard`);
204
+ const lessons = await request(`${origin}/lessons`);
205
+ if (dashboard.statusCode !== 200) {
206
+ throw new Error(`Packaged dashboard returned ${dashboard.statusCode}`);
207
+ }
208
+ if (lessons.statusCode !== 200) {
209
+ throw new Error(`Packaged lessons returned ${lessons.statusCode}`);
210
+ }
211
+
212
+ const readyStatusline = renderStatusline(runtimeBin, projectDir, env);
213
+ if (!readyStatusline.includes(`${origin}/dashboard`)) {
214
+ throw new Error(`Ready statusline missing dashboard URL: ${readyStatusline.trim()}`);
215
+ }
216
+ if (!readyStatusline.includes(`${origin}/lessons`)) {
217
+ throw new Error(`Ready statusline missing lessons URL: ${readyStatusline.trim()}`);
218
+ }
219
+ if (!readyStatusline.includes(`${origin}/feedback/quick?signal=up`)) {
220
+ throw new Error(`Ready statusline missing thumbs-up URL: ${readyStatusline.trim()}`);
221
+ }
222
+ if (!readyStatusline.includes(`${origin}/feedback/quick?signal=down`)) {
223
+ throw new Error(`Ready statusline missing thumbs-down URL: ${readyStatusline.trim()}`);
224
+ }
225
+
226
+ return {
227
+ packageSpec,
228
+ expectedVersion,
229
+ origin,
230
+ health,
231
+ };
232
+ } finally {
233
+ await stopDetachedRuntime(homeDir);
234
+ fs.rmSync(tempRoot, { recursive: true, force: true });
235
+ }
236
+ }
237
+
238
+ async function main() {
239
+ const args = parseArgs();
240
+ const result = await runPackagedRuntimeSmoke({
241
+ packageSpec: args.packageSpec,
242
+ expectedVersion: args.expectedVersion,
243
+ timeoutMs: args.timeoutMs,
244
+ });
245
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
246
+ }
247
+
248
+ if (require.main === module) {
249
+ main().catch((error) => {
250
+ process.stderr.write(`${error.message || String(error)}\n`);
251
+ process.exit(1);
252
+ });
253
+ }
254
+
255
+ module.exports = {
256
+ getAvailablePort,
257
+ packCurrentRepo,
258
+ runPackagedRuntimeSmoke,
259
+ waitForHealthy,
260
+ };
@@ -5,6 +5,7 @@ const os = require('os');
5
5
  const path = require('path');
6
6
 
7
7
  const { buildManagedScheduleCommand } = require('./schedule-manager');
8
+ const { runPackagedRuntimeSmoke } = require('./prove-packaged-runtime');
8
9
 
9
10
  const ROOT = path.join(__dirname, '..');
10
11
  const RUNNER_PATH = require.resolve('./async-job-runner');
@@ -275,6 +276,18 @@ async function run() {
275
276
  }
276
277
  },
277
278
  },
279
+ {
280
+ id: 'RUNTIME-07',
281
+ desc: 'packaged thumbgate runtime boots local API and serves dashboard affordances',
282
+ fn: async () => {
283
+ const result = await runPackagedRuntimeSmoke({
284
+ expectedVersion: require(path.join(ROOT, 'package.json')).version,
285
+ });
286
+ if (!result.health || result.health.version !== require(path.join(ROOT, 'package.json')).version) {
287
+ throw new Error('Packaged runtime health version did not match package.json');
288
+ }
289
+ },
290
+ },
278
291
  ];
279
292
 
280
293
  console.log('Interruptible Runtime - Proof Gate\n');
@@ -13,6 +13,10 @@ function runtimePrefixDir(prefixDir) {
13
13
  return prefixDir || path.join(os.homedir(), '.thumbgate', 'runtime');
14
14
  }
15
15
 
16
+ function installedRuntimeBin(prefixDir) {
17
+ return path.join(runtimePrefixDir(prefixDir), 'node_modules', '.bin', 'thumbgate');
18
+ }
19
+
16
20
  function publishedCliArgs(pkgVersion, commandArgs = [], options = {}) {
17
21
  return [
18
22
  'exec',
@@ -29,7 +33,11 @@ function publishedCliArgs(pkgVersion, commandArgs = [], options = {}) {
29
33
 
30
34
  function publishedCliShellCommand(pkgVersion, commandArgs = [], options = {}) {
31
35
  const prefixDir = runtimePrefixDir(options.prefixDir);
32
- return `mkdir -p ${shellQuote(prefixDir)} && exec npm ${publishedCliArgs(pkgVersion, commandArgs, { prefixDir }).map(shellQuote).join(' ')}`;
36
+ const runtimeBin = installedRuntimeBin(prefixDir);
37
+ const escapedArgs = commandArgs.map(shellQuote).join(' ');
38
+ const fastPath = `[ -x ${shellQuote(runtimeBin)} ] && exec ${shellQuote(runtimeBin)}${escapedArgs ? ` ${escapedArgs}` : ''}`;
39
+ const installPath = `mkdir -p ${shellQuote(prefixDir)} && exec npm ${publishedCliArgs(pkgVersion, commandArgs, { prefixDir }).map(shellQuote).join(' ')}`;
40
+ return `${fastPath} || ${installPath}`;
33
41
  }
34
42
 
35
43
  function runPublishedCli(pkgVersion, commandArgs = [], options = {}) {
@@ -55,6 +63,7 @@ function runPublishedCliHelp(pkgVersion, options = {}) {
55
63
  module.exports = {
56
64
  publishedCliArgs,
57
65
  publishedCliShellCommand,
66
+ installedRuntimeBin,
58
67
  runtimePrefixDir,
59
68
  runPublishedCli,
60
69
  runPublishedCliHelp,
@@ -10,9 +10,9 @@ const {
10
10
  const USAGE_FILE = path.join(process.env.HOME || '/tmp', '.thumbgate', 'usage-limits.json');
11
11
 
12
12
  const FREE_TIER_LIMITS = {
13
- capture_feedback: { daily: Infinity, label: 'feedback captures' },
13
+ capture_feedback: { daily: 3, label: 'feedback captures' },
14
14
  search_lessons: { daily: 5, label: 'lesson searches' },
15
- search_thumbgate: { daily: 10, label: 'ThumbGate searches' },
15
+ search_thumbgate: { daily: 5, label: 'ThumbGate searches' },
16
16
  commerce_recall: { daily: 5, label: 'commerce recalls' },
17
17
  export_dpo: { daily: 0, label: 'DPO exports (Pro only)' },
18
18
  export_databricks: { daily: 0, label: 'Databricks exports (Pro only)' },
@@ -83,7 +83,7 @@ function checkLimit(action, authContext) {
83
83
  const current = usage.counts[action] || 0;
84
84
 
85
85
  if (current >= dailyLimit) {
86
- return { allowed: false, message: UPGRADE_MESSAGE, used: current, limit: dailyLimit };
86
+ return { allowed: false, message: `Free tier limit reached. Upgrade to Pro for unlimited: https://thumbgate-production.up.railway.app/pro\n${UPGRADE_MESSAGE}`, used: current, limit: dailyLimit };
87
87
  }
88
88
 
89
89
  // Increment
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const http = require('http');
8
+ const { spawn } = require('child_process');
9
+
10
+ const { getHomeDir, getRuntimeDir, resolveProjectDir } = require('./feedback-paths');
11
+ const { resolveProKey } = require('./pro-local-dashboard');
12
+
13
+ const DEFAULT_ORIGIN = 'http://localhost:3456';
14
+ const DEFAULT_TIMEOUT_MS = 150;
15
+ const DEFAULT_BOOT_GRACE_MS = 5000;
16
+ const PKG_ROOT = path.join(__dirname, '..');
17
+
18
+ function parseOrigin(origin) {
19
+ const url = new URL(origin || DEFAULT_ORIGIN);
20
+ return {
21
+ origin: url.origin,
22
+ host: url.hostname,
23
+ port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
24
+ protocol: url.protocol,
25
+ };
26
+ }
27
+
28
+ function isLoopbackHost(host) {
29
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
30
+ }
31
+
32
+ function runtimeStatePath(options = {}) {
33
+ return path.join(getRuntimeDir(options), 'statusline-api.json');
34
+ }
35
+
36
+ function readRuntimeState(options = {}) {
37
+ try {
38
+ return JSON.parse(fs.readFileSync(runtimeStatePath(options), 'utf8'));
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function writeRuntimeState(payload, options = {}) {
45
+ const targetPath = runtimeStatePath(options);
46
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
47
+ fs.writeFileSync(targetPath, JSON.stringify(payload, null, 2) + '\n');
48
+ return targetPath;
49
+ }
50
+
51
+ function isPidAlive(pid) {
52
+ const numericPid = Number(pid);
53
+ if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
54
+ try {
55
+ process.kill(numericPid, 0);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function shouldReuseBootingState(state, now = Date.now()) {
63
+ if (!state || !isPidAlive(state.pid)) return false;
64
+ const startedAt = Date.parse(state.startedAt || 0);
65
+ if (!Number.isFinite(startedAt)) return true;
66
+ return now - startedAt < DEFAULT_BOOT_GRACE_MS;
67
+ }
68
+
69
+ function requestOk(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
70
+ return new Promise((resolve) => {
71
+ const req = http.get(url, (res) => {
72
+ res.resume();
73
+ resolve(res.statusCode >= 200 && res.statusCode < 500);
74
+ });
75
+ req.on('error', () => resolve(false));
76
+ req.setTimeout(timeoutMs, () => {
77
+ req.destroy();
78
+ resolve(false);
79
+ });
80
+ });
81
+ }
82
+
83
+ async function probeLocalServer(origin, options = {}) {
84
+ const timeoutMs = Number(options.timeoutMs || DEFAULT_TIMEOUT_MS);
85
+ return requestOk(`${origin}/health`, timeoutMs);
86
+ }
87
+
88
+ function launchLocalServer(options = {}) {
89
+ const env = options.env || process.env;
90
+ const origin = parseOrigin(options.origin || env.THUMBGATE_LOCAL_API_ORIGIN || DEFAULT_ORIGIN);
91
+ const homeDir = options.homeDir || getHomeDir({ env });
92
+ const resolvedKey = (options.resolveKey || resolveProKey)({ env, homeDir });
93
+ const projectDir = resolveProjectDir({ env, cwd: options.cwd || process.cwd() });
94
+ const childEnv = {
95
+ ...env,
96
+ HOST: origin.host,
97
+ PORT: String(origin.port),
98
+ THUMBGATE_LOCAL_API_ORIGIN: origin.origin,
99
+ THUMBGATE_PROJECT_DIR: projectDir,
100
+ THUMBGATE_PRO_MODE: '1',
101
+ };
102
+
103
+ if (resolvedKey && resolvedKey.key) {
104
+ childEnv.THUMBGATE_API_KEY = resolvedKey.key;
105
+ }
106
+
107
+ const child = spawn(
108
+ process.execPath,
109
+ [path.join(PKG_ROOT, 'bin', 'cli.js'), 'start-api'],
110
+ {
111
+ cwd: projectDir,
112
+ env: childEnv,
113
+ detached: true,
114
+ stdio: 'ignore',
115
+ }
116
+ );
117
+ child.unref();
118
+
119
+ const state = {
120
+ pid: child.pid,
121
+ projectDir,
122
+ origin: origin.origin,
123
+ startedAt: new Date().toISOString(),
124
+ };
125
+ writeRuntimeState(state, { env, home: homeDir });
126
+ return state;
127
+ }
128
+
129
+ function buildLinkState({
130
+ ready,
131
+ booting,
132
+ origin,
133
+ canBootstrap,
134
+ }) {
135
+ if (ready) {
136
+ return {
137
+ state: 'ready',
138
+ dashboardLabel: 'Dashboard',
139
+ lessonsLabel: 'Lessons',
140
+ upLabel: '๐Ÿ‘',
141
+ downLabel: '๐Ÿ‘Ž',
142
+ dashboardUrl: `${origin}/dashboard`,
143
+ lessonsUrl: `${origin}/lessons`,
144
+ upUrl: `${origin}/feedback/quick?signal=up`,
145
+ downUrl: `${origin}/feedback/quick?signal=down`,
146
+ };
147
+ }
148
+
149
+ if (booting) {
150
+ return {
151
+ state: 'booting',
152
+ dashboardLabel: 'Dashboardโ€ฆ',
153
+ lessonsLabel: 'Lessonsโ€ฆ',
154
+ upLabel: '๐Ÿ‘',
155
+ downLabel: '๐Ÿ‘Ž',
156
+ dashboardUrl: '',
157
+ lessonsUrl: '',
158
+ upUrl: '',
159
+ downUrl: '',
160
+ };
161
+ }
162
+
163
+ return {
164
+ state: canBootstrap ? 'offline' : 'unavailable',
165
+ dashboardLabel: canBootstrap ? 'Dash: thumbgate pro' : 'Dashboard',
166
+ lessonsLabel: 'Learn: thumbgate lessons',
167
+ upLabel: '๐Ÿ‘',
168
+ downLabel: '๐Ÿ‘Ž',
169
+ dashboardUrl: '',
170
+ lessonsUrl: '',
171
+ upUrl: '',
172
+ downUrl: '',
173
+ };
174
+ }
175
+
176
+ async function getStatuslineLinks(options = {}) {
177
+ const env = options.env || process.env;
178
+ if (env._TEST_THUMBGATE_STATUSLINE_LINKS_JSON) {
179
+ return JSON.parse(env._TEST_THUMBGATE_STATUSLINE_LINKS_JSON);
180
+ }
181
+
182
+ const homeDir = options.homeDir || getHomeDir({ env });
183
+ const parsedOrigin = parseOrigin(options.origin || env.THUMBGATE_LOCAL_API_ORIGIN || DEFAULT_ORIGIN);
184
+ const origin = parsedOrigin.origin;
185
+ const allowLocalBootstrap = isLoopbackHost(parsedOrigin.host);
186
+ const probe = options.probeLocalServer || probeLocalServer;
187
+ const resolveKey = options.resolveKey || resolveProKey;
188
+ const startServer = options.launchLocalServer || launchLocalServer;
189
+ const key = resolveKey({ env, homeDir });
190
+ const canBootstrap = allowLocalBootstrap && Boolean(key && key.key);
191
+
192
+ const ready = allowLocalBootstrap ? await probe(origin, options) : false;
193
+ if (ready) {
194
+ return buildLinkState({ ready: true, booting: false, origin, canBootstrap });
195
+ }
196
+
197
+ const state = readRuntimeState({ env, home: homeDir });
198
+ if (shouldReuseBootingState(state)) {
199
+ return buildLinkState({ ready: false, booting: true, origin, canBootstrap });
200
+ }
201
+
202
+ if (canBootstrap) {
203
+ startServer({
204
+ env,
205
+ homeDir,
206
+ origin,
207
+ cwd: options.cwd || process.cwd(),
208
+ resolveKey: () => key,
209
+ });
210
+ return buildLinkState({ ready: false, booting: true, origin, canBootstrap });
211
+ }
212
+
213
+ return buildLinkState({ ready: false, booting: false, origin, canBootstrap });
214
+ }
215
+
216
+ if (require.main === module) {
217
+ getStatuslineLinks()
218
+ .then((payload) => {
219
+ process.stdout.write(JSON.stringify(payload));
220
+ })
221
+ .catch(() => {
222
+ process.exit(0);
223
+ });
224
+ }
225
+
226
+ module.exports = {
227
+ buildLinkState,
228
+ getStatuslineLinks,
229
+ isPidAlive,
230
+ launchLocalServer,
231
+ parseOrigin,
232
+ isLoopbackHost,
233
+ probeLocalServer,
234
+ readRuntimeState,
235
+ runtimeStatePath,
236
+ shouldReuseBootingState,
237
+ writeRuntimeState,
238
+ };
@@ -3,8 +3,10 @@
3
3
 
4
4
  const { analyzeFeedback } = require('./feedback-loop');
5
5
  const { normalizeStatsPayload } = require('./hook-thumbgate-cache-updater');
6
+ const { syncClaudeHistoryFeedback } = require('./claude-feedback-sync');
6
7
 
7
8
  try {
9
+ syncClaudeHistoryFeedback();
8
10
  const stats = analyzeFeedback();
9
11
  const payload = {
10
12
  ...normalizeStatsPayload(stats),