thumbgate 1.1.0 → 1.2.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 (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.well-known/mcp/server-card.json +1 -1
  4. package/README.md +16 -5
  5. package/adapters/README.md +1 -1
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/codex/config.toml +2 -2
  8. package/adapters/mcp/server-stdio.js +10 -7
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/config/github-about.json +1 -1
  11. package/package.json +20 -11
  12. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  13. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  14. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  15. package/plugins/codex-profile/.mcp.json +1 -1
  16. package/plugins/codex-profile/INSTALL.md +1 -1
  17. package/plugins/codex-profile/README.md +1 -1
  18. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  19. package/plugins/opencode-profile/INSTALL.md +1 -1
  20. package/public/compare.html +302 -0
  21. package/public/index.html +36 -10
  22. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  23. package/scripts/ai-search-visibility.js +142 -0
  24. package/scripts/changeset-check.js +372 -0
  25. package/scripts/check-congruence.js +7 -4
  26. package/scripts/computer-use-firewall.js +45 -15
  27. package/scripts/docker-sandbox-planner.js +208 -0
  28. package/scripts/github-about.js +56 -0
  29. package/scripts/operational-integrity.js +7 -1
  30. package/scripts/published-cli.js +10 -1
  31. package/scripts/statusline-links.js +238 -0
  32. package/scripts/statusline.sh +39 -4
  33. package/scripts/sync-github-about.js +7 -4
  34. package/scripts/workflow-sentinel.js +83 -35
  35. package/src/api/server.js +12 -1
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('node:path');
5
+
6
+ const { classifyCommand } = require('./operational-integrity');
7
+
8
+ const HIGH_RISK_ACTION_TYPES = new Set([
9
+ 'shell.exec',
10
+ 'file.delete',
11
+ 'upload',
12
+ 'message.send',
13
+ ]);
14
+
15
+ function normalizeText(value) {
16
+ if (value === undefined || value === null) return '';
17
+ return String(value).trim();
18
+ }
19
+
20
+ function normalizeStringArray(values = []) {
21
+ if (!Array.isArray(values)) return [];
22
+ return Array.from(new Set(
23
+ values
24
+ .map((value) => normalizeText(value))
25
+ .filter(Boolean),
26
+ ));
27
+ }
28
+
29
+ function normalizeRiskBand(value) {
30
+ const normalized = normalizeText(value).toLowerCase();
31
+ if (['very_high', 'high', 'medium', 'low'].includes(normalized)) {
32
+ return normalized;
33
+ }
34
+ if (normalized === 'critical') return 'very_high';
35
+ return 'low';
36
+ }
37
+
38
+ function quoteShellArg(value) {
39
+ return `'${String(value).replaceAll('\'', String.raw`'\''`)}'`;
40
+ }
41
+
42
+ function buildNetworkPolicy(input = {}) {
43
+ const allowedHosts = normalizeStringArray(input.allowedHosts || input.egressAllowlist);
44
+ if (input.requiresNetwork !== true) {
45
+ return {
46
+ mode: 'deny_all',
47
+ allowedHosts: [],
48
+ };
49
+ }
50
+ return {
51
+ mode: allowedHosts.length > 0 ? 'allow_list' : 'egress_enabled',
52
+ allowedHosts,
53
+ };
54
+ }
55
+
56
+ function buildLaunchers(workspacePath) {
57
+ const suffix = workspacePath ? ` shell ${quoteShellArg(workspacePath)}` : ' shell';
58
+ return {
59
+ standalone: `sbx run${suffix}`,
60
+ dockerDesktop: `docker sandbox run${suffix}`,
61
+ followUp: workspacePath
62
+ ? [
63
+ 'sbx list',
64
+ 'docker sandbox ls',
65
+ ]
66
+ : [],
67
+ };
68
+ }
69
+
70
+ function buildSummary(shouldSandbox, recommendation) {
71
+ if (!shouldSandbox) {
72
+ return 'Current action can stay on the normal local execution path.';
73
+ }
74
+ if (recommendation === 'required') {
75
+ return 'Route this action into Docker Sandboxes before retrying so the run happens inside a disposable microVM instead of on the host.';
76
+ }
77
+ return 'Prefer Docker Sandboxes for this action to reduce host blast radius while keeping local autonomy.';
78
+ }
79
+
80
+ function buildWhy({
81
+ recommendation,
82
+ command,
83
+ riskBand,
84
+ actionType,
85
+ affectedFiles,
86
+ }) {
87
+ const lines = [];
88
+ if (recommendation === 'required') {
89
+ lines.push('The predicted action is destructive or release-sensitive enough to justify host isolation.');
90
+ } else if (recommendation === 'recommended') {
91
+ lines.push('The predicted action is high-risk enough that isolated execution meaningfully reduces host blast radius.');
92
+ } else {
93
+ lines.push('The current action does not need a dedicated Docker sandbox boundary.');
94
+ }
95
+
96
+ if (command && /\brm\s+-rf\b/i.test(command)) {
97
+ lines.push('Recursive delete commands are safer when the filesystem boundary lives inside a disposable microVM.');
98
+ }
99
+ if (command && /\bgit\s+push\b.*(?:--force|-f)\b/i.test(command)) {
100
+ lines.push('Force-push flows should run in an isolated lane so host credentials and unrelated state stay out of scope.');
101
+ }
102
+ if (command && /\b(?:gh\s+pr\s+(?:create|merge)|npm\s+publish|yarn\s+publish|pnpm\s+publish)\b/i.test(command)) {
103
+ lines.push('PR, merge, and publish flows are governance-sensitive and benefit from a disposable execution boundary.');
104
+ }
105
+ if (HIGH_RISK_ACTION_TYPES.has(actionType)) {
106
+ lines.push(`Action type ${actionType} is in the high-risk set for local execution.`);
107
+ }
108
+ if (riskBand === 'very_high' || riskBand === 'high') {
109
+ lines.push(`Risk band ${riskBand} predicts elevated blast radius on the local host.`);
110
+ }
111
+ if (affectedFiles.length >= 4) {
112
+ lines.push(`The change touches ${affectedFiles.length} files, so host isolation improves recovery if the run goes sideways.`);
113
+ }
114
+ return lines;
115
+ }
116
+
117
+ function buildDockerSandboxPlan(input = {}) {
118
+ const toolName = normalizeText(input.toolName);
119
+ const actionType = normalizeText(input.actionType)
120
+ || (toolName === 'Bash' ? 'shell.exec' : '');
121
+ const command = normalizeText(input.command);
122
+ const repoPath = normalizeText(input.repoPath);
123
+ const workspacePath = repoPath ? path.resolve(repoPath) : null;
124
+ const affectedFiles = normalizeStringArray(input.affectedFiles || input.changedFiles || input.files);
125
+ const riskBand = normalizeRiskBand(input.riskBand || input.band);
126
+ const riskScore = Number.isFinite(Number(input.riskScore))
127
+ ? Number(Number(input.riskScore).toFixed(4))
128
+ : null;
129
+ const commandInfo = classifyCommand(command);
130
+ const destructiveCommand = /\brm\s+-rf\b/i.test(command)
131
+ || /\bgit\s+push\b.*(?:--force|-f)\b/i.test(command)
132
+ || /\bgh\s+pr\s+merge\b.*--admin\b/i.test(command);
133
+ const governedCommand = Boolean(
134
+ commandInfo.isPrCreate
135
+ || commandInfo.isPrMerge
136
+ || commandInfo.isPublish
137
+ || commandInfo.isReleaseCreate
138
+ || commandInfo.isTagCreate
139
+ );
140
+ const highRiskAction = HIGH_RISK_ACTION_TYPES.has(actionType)
141
+ || destructiveCommand
142
+ || governedCommand
143
+ || riskBand === 'high'
144
+ || riskBand === 'very_high';
145
+
146
+ let recommendation = 'not_needed';
147
+ if (destructiveCommand || commandInfo.isPublish || commandInfo.isReleaseCreate || actionType === 'upload' || actionType === 'message.send') {
148
+ recommendation = 'required';
149
+ } else if (highRiskAction || affectedFiles.length >= 4) {
150
+ recommendation = 'recommended';
151
+ }
152
+
153
+ const shouldSandbox = recommendation !== 'not_needed';
154
+ const networkPolicy = buildNetworkPolicy({
155
+ requiresNetwork: input.requiresNetwork === true || governedCommand || commandInfo.isPublish || actionType === 'upload' || actionType === 'message.send',
156
+ allowedHosts: input.allowedHosts,
157
+ egressAllowlist: input.egressAllowlist,
158
+ });
159
+ const launchers = buildLaunchers(workspacePath);
160
+ const summary = buildSummary(shouldSandbox, recommendation);
161
+
162
+ return {
163
+ plannerVersion: 'docker-sandbox-plan-v1',
164
+ shouldSandbox,
165
+ recommendation,
166
+ summary,
167
+ sandboxKind: shouldSandbox ? 'docker_microvm' : 'host',
168
+ workspacePath,
169
+ actionType: actionType || null,
170
+ riskBand,
171
+ riskScore,
172
+ command: command || null,
173
+ affectedFiles,
174
+ networkPolicy,
175
+ launchers,
176
+ claims: shouldSandbox ? {
177
+ isolationBoundary: 'microvm',
178
+ hostAccess: 'bounded_outside_host',
179
+ dockerDaemon: 'private_inside_sandbox',
180
+ workspaceStrategy: workspacePath ? 'directory_sync' : 'ephemeral',
181
+ } : null,
182
+ why: buildWhy({
183
+ recommendation,
184
+ command,
185
+ riskBand,
186
+ actionType,
187
+ affectedFiles,
188
+ }),
189
+ };
190
+ }
191
+
192
+ module.exports = {
193
+ HIGH_RISK_ACTION_TYPES,
194
+ buildDockerSandboxPlan,
195
+ buildLaunchers,
196
+ buildNetworkPolicy,
197
+ buildSummary,
198
+ normalizeRiskBand,
199
+ };
200
+
201
+ if (require.main === module) {
202
+ const plan = buildDockerSandboxPlan({
203
+ toolName: process.argv[2] || 'Bash',
204
+ command: process.argv.slice(3).join(' '),
205
+ repoPath: process.cwd(),
206
+ });
207
+ console.log(JSON.stringify(plan, null, 2));
208
+ }
@@ -12,6 +12,8 @@ const ROOT = path.join(__dirname, '..');
12
12
  const CONFIG_RELATIVE_PATH = path.join('config', 'github-about.json');
13
13
  const LEGACY_REPOSITORY_URL = 'https://github.com/IgorGanapolsky/thumbgate';
14
14
  const GITHUB_API_BASE_URL = 'https://api.github.com';
15
+ const DEFAULT_VERIFY_ATTEMPTS = 5;
16
+ const DEFAULT_VERIFY_DELAY_MS = 2000;
15
17
 
16
18
  function readText(root, relativePath) {
17
19
  return fs.readFileSync(path.join(root, relativePath), 'utf8');
@@ -296,6 +298,57 @@ async function fetchLiveGitHubAbout(options = {}) {
296
298
  };
297
299
  }
298
300
 
301
+ function normalizePositiveInteger(value, fallback) {
302
+ const parsed = Number.parseInt(value, 10);
303
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
304
+ }
305
+
306
+ function sleep(delayMs) {
307
+ return new Promise((resolve) => {
308
+ setTimeout(resolve, delayMs);
309
+ });
310
+ }
311
+
312
+ async function verifyLiveGitHubAbout(options = {}) {
313
+ const root = options.root || ROOT;
314
+ const expected = options.expected || loadGitHubAboutConfig(root);
315
+ const repo = normalizeText(options.repo) || expected.repo;
316
+ const label = options.label || `Live GitHub About (${repo})`;
317
+ const attempts = normalizePositiveInteger(options.attempts, DEFAULT_VERIFY_ATTEMPTS);
318
+ const delayMs = normalizePositiveInteger(options.delayMs, DEFAULT_VERIFY_DELAY_MS);
319
+ const fetcher = typeof options.fetcher === 'function' ? options.fetcher : fetchLiveGitHubAbout;
320
+ const sleeper = typeof options.sleep === 'function' ? options.sleep : sleep;
321
+ let actual = null;
322
+ let errors = [];
323
+
324
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
325
+ actual = await fetcher({
326
+ root,
327
+ repo,
328
+ token: options.token,
329
+ });
330
+ errors = compareGitHubAbout(expected, actual, label);
331
+ if (errors.length === 0) {
332
+ return {
333
+ ok: true,
334
+ actual,
335
+ attemptsUsed: attempt,
336
+ errors: [],
337
+ };
338
+ }
339
+ if (attempt < attempts) {
340
+ await sleeper(delayMs * attempt);
341
+ }
342
+ }
343
+
344
+ return {
345
+ ok: false,
346
+ actual,
347
+ attemptsUsed: attempts,
348
+ errors,
349
+ };
350
+ }
351
+
299
352
  async function updateLiveGitHubAbout(options = {}) {
300
353
  const about = loadGitHubAboutConfig(options.root || ROOT);
301
354
  const repo = normalizeText(options.repo) || about.repo;
@@ -334,6 +387,8 @@ async function updateLiveGitHubAbout(options = {}) {
334
387
  }
335
388
 
336
389
  module.exports = {
390
+ DEFAULT_VERIFY_ATTEMPTS,
391
+ DEFAULT_VERIFY_DELAY_MS,
337
392
  LEGACY_REPOSITORY_URL,
338
393
  buildCanonicalRepoUrls,
339
394
  collectLocalGitHubAboutErrors,
@@ -347,4 +402,5 @@ module.exports = {
347
402
  normalizeTopics,
348
403
  normalizeUrl,
349
404
  updateLiveGitHubAbout,
405
+ verifyLiveGitHubAbout,
350
406
  };
@@ -457,12 +457,17 @@ function parseCliArgs(argv = process.argv.slice(2)) {
457
457
  return options;
458
458
  }
459
459
 
460
+ function resolveCiBranchName(env = process.env) {
461
+ const branchName = String(env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || '').trim();
462
+ return branchName || undefined;
463
+ }
464
+
460
465
  function runCli(env = process.env, argv = process.argv.slice(2)) {
461
466
  const args = parseCliArgs(argv);
462
467
  const result = evaluateOperationalIntegrity({
463
468
  repoPath: args.repoPath,
464
469
  baseBranch: args.baseBranch || env.DEFAULT_BRANCH || DEFAULT_BASE_BRANCH,
465
- currentBranch: env.GITHUB_REF_NAME || undefined,
470
+ currentBranch: resolveCiBranchName(env),
466
471
  requirePrForReleaseSensitive: args.requirePrForReleaseSensitive,
467
472
  requireVersionNotBehindBase: args.requireVersionNotBehindBase,
468
473
  fetchBase: args.fetchBase,
@@ -517,6 +522,7 @@ module.exports = {
517
522
  parseSemver,
518
523
  readPackageVersion,
519
524
  resolveBaseRef,
525
+ resolveCiBranchName,
520
526
  resolveRepoRoot,
521
527
  runCli,
522
528
  sanitizeGlobList,
@@ -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,
@@ -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
+ };
@@ -6,6 +6,7 @@
6
6
  # Resolve script directory safely (CodeQL: no uncontrolled paths)
7
7
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
8
8
  case "$SCRIPT_DIR" in *[!a-zA-Z0-9/_.-]*) echo "ThumbGate: invalid script path"; exit 1;; esac
9
+ LOCAL_API_ORIGIN="${THUMBGATE_LOCAL_API_ORIGIN:-http://localhost:3456}"
9
10
 
10
11
  # ── Parse Claude Code session JSON from stdin ─────────────────────
11
12
  eval "$(cat | jq -r '
@@ -63,7 +64,7 @@ fi
63
64
  # Background refresh from REST API when cache is stale (>120s)
64
65
  if [ $(( _NOW - ${CACHE_TS:-0} )) -gt 120 ]; then
65
66
  (
66
- _R=$(curl -s --max-time 3 "http://localhost:3456/v1/feedback/stats" -H "Authorization: Bearer ${THUMBGATE_API_KEY:-tg_creator_dev_enterprise}" 2>/dev/null)
67
+ _R=$(curl -s --max-time 3 "${LOCAL_API_ORIGIN}/v1/feedback/stats" -H "Authorization: Bearer ${THUMBGATE_API_KEY:-tg_creator_dev_enterprise}" 2>/dev/null)
67
68
  [ -z "$_R" ] && exit 0
68
69
  echo "$_R" | python3 -c "
69
70
  import json,sys,time,os
@@ -78,6 +79,23 @@ except:pass
78
79
  disown 2>/dev/null
79
80
  fi
80
81
 
82
+ # ── Clickable statusline affordances ─────────────────────────────
83
+ LINK_STATE="offline"
84
+ UP_URL=""; DOWN_URL=""; DASHBOARD_URL=""; LESSONS_URL=""
85
+ DASHBOARD_LABEL="Dashboard"; LESSONS_LABEL="Lessons"
86
+ _LINKS_JSON=$(node "${SCRIPT_DIR}/statusline-links.js" 2>/dev/null)
87
+ if [ -n "$_LINKS_JSON" ]; then
88
+ eval "$(echo "$_LINKS_JSON" | jq -r '
89
+ @sh "LINK_STATE=\(.state // "offline")",
90
+ @sh "UP_URL=\(.upUrl // "")",
91
+ @sh "DOWN_URL=\(.downUrl // "")",
92
+ @sh "DASHBOARD_URL=\(.dashboardUrl // "")",
93
+ @sh "LESSONS_URL=\(.lessonsUrl // "")",
94
+ @sh "DASHBOARD_LABEL=\(.dashboardLabel // "Dashboard")",
95
+ @sh "LESSONS_LABEL=\(.lessonsLabel // "Lessons")"
96
+ ' 2>/dev/null)"
97
+ fi
98
+
81
99
  # ── ThumbGate package metadata ────────────────────────────────────────
82
100
  TG_VERSION="unknown"; TG_TIER="Free"
83
101
  _META_JSON=$(node "${SCRIPT_DIR}/statusline-meta.js" 2>/dev/null)
@@ -107,17 +125,34 @@ case "${TREND}" in
107
125
  improving) ARROW="↗" ;; degrading) ARROW="↘" ;; stable) ARROW="→" ;; *) ARROW="?" ;;
108
126
  esac
109
127
 
128
+ osc8_link() {
129
+ local url="$1"
130
+ local label="$2"
131
+ if [ -n "$url" ]; then
132
+ printf '\033]8;;%s\a%s\033]8;;\a' "$url" "$label"
133
+ else
134
+ printf '%s' "$label"
135
+ fi
136
+ }
137
+
138
+ UP_ICON="$(osc8_link "$UP_URL" "👍")"
139
+ DOWN_ICON="$(osc8_link "$DOWN_URL" "👎")"
140
+ DASHBOARD_LINK="$(osc8_link "$DASHBOARD_URL" "$DASHBOARD_LABEL")"
141
+ LESSONS_LINK="$(osc8_link "$LESSONS_URL" "$LESSONS_LABEL")"
142
+
110
143
  # ── Output (single line) ─────────────────────────────────────────
111
144
  LINE="ThumbGate v${TG_VERSION} · ${TG_TIER}"
112
145
  if [ "$UP" = "0" ] && [ "$DOWN" = "0" ]; then
113
- echo -e "${D}${LINE} · no feedback yet${RST}"
146
+ LINE="${D}${LINE} · no feedback yet${RST} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
147
+ printf '%b\n' "$LINE"
114
148
  else
115
- LINE="${LINE} · ${G}${BD}${UP}${RST}👍 ${R}${BD}${DOWN}${RST}👎 ${ARROW}"
149
+ LINE="${LINE} · ${G}${BD}${UP}${RST}${UP_ICON} ${R}${BD}${DOWN}${RST}${DOWN_ICON} ${ARROW}"
116
150
 
117
151
  # Control Tower alerts (if any)
118
152
  [ "${SLO_V:-0}" -gt 0 ] && LINE="${LINE} ${R}${SLO_V} SLO${RST}"
119
153
  [ "${AT_RISK:-0}" -gt 0 ] && LINE="${LINE} ${R}${AT_RISK}⚠${RST}"
120
154
  [ "${ANOMALIES:-0}" -gt 0 ] && LINE="${LINE} ${R}${ANOMALIES}☠${RST}"
155
+ LINE="${LINE} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
121
156
 
122
- echo -e "$LINE"
157
+ printf '%b\n' "$LINE"
123
158
  fi
@@ -6,6 +6,7 @@ const {
6
6
  fetchLiveGitHubAbout,
7
7
  loadGitHubAboutConfig,
8
8
  updateLiveGitHubAbout,
9
+ verifyLiveGitHubAbout,
9
10
  } = require('./github-about');
10
11
 
11
12
  async function main() {
@@ -32,11 +33,13 @@ async function main() {
32
33
  console.log(`Syncing GitHub About for ${about.repo}...`);
33
34
  await updateLiveGitHubAbout({ repo: about.repo });
34
35
 
35
- const after = await fetchLiveGitHubAbout({ repo: about.repo });
36
- const remaining = compareGitHubAbout(about, after, `Live GitHub About (${about.repo})`);
37
- if (remaining.length > 0) {
36
+ const verification = await verifyLiveGitHubAbout({
37
+ expected: about,
38
+ repo: about.repo,
39
+ });
40
+ if (verification.errors.length > 0) {
38
41
  console.error(`\n❌ GitHub About sync incomplete for ${about.repo}:\n`);
39
- for (const error of remaining) {
42
+ for (const error of verification.errors) {
40
43
  console.error(` • ${error}`);
41
44
  }
42
45
  console.error('');