@inneranimalmedia/agentsam-sdk 1.5.1 → 1.6.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.
package/README.md CHANGED
@@ -35,10 +35,12 @@ cd my-project
35
35
  npm install
36
36
  npm run smoke
37
37
  npx agentsam start-local # local PTY on ws://127.0.0.1:3099
38
+ npx agentsam tunnel # cloudflared → register with IAM (dashboard Local lane)
38
39
  npm run dev # http://127.0.0.1:8787
39
40
  npm run db:migrate # local D1 schema
40
41
  ```
41
42
 
43
+ `agentsam tunnel` (default `--quick`) starts a Cloudflare quick tunnel to `:3099` and POSTs the `wss://` URL to `/api/sdk/terminal/register-local` so `agentsam_terminal_local` can reach your machine. Use `--named --tunnel-name … --hostname … --zone-id …` for a stable BYOK named tunnel.
42
44
  When you're ready to ship to **your** Cloudflare account:
43
45
 
44
46
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inneranimalmedia/agentsam-sdk",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Agent Sam is a full-stack AI agent SDK for autonomous task execution — covering data management, creative workflows, design commands, and multi-step agentic pipelines.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/cli.js CHANGED
@@ -8,6 +8,7 @@ import { copyGorillaTemplate } from './lib/gorilla-template.js';
8
8
  import { printContextSummary } from './lib/detect-context.js';
9
9
  import { promptOptionalByokKeys } from './lib/prompt-byok.js';
10
10
  import { runStartLocal } from './commands/start-local.js';
11
+ import { runTunnel } from './commands/tunnel.js';
11
12
  import { runDeploy } from './commands/deploy.js';
12
13
  import { SLASH_COMMANDS, SHELL_PHASES } from './lib/slash-commands.js';
13
14
 
@@ -28,6 +29,7 @@ function printHelp() {
28
29
  Usage:
29
30
  agentsam init Local-first project scaffold (default: localhost, no accounts)
30
31
  agentsam start-local Local PTY on ws://127.0.0.1:3099 (no tunnel, no Cloudflare)
32
+ agentsam tunnel Expose local PTY to IAM (cloudflared + register)
31
33
  agentsam deploy Graduate to Cloudflare / GCP when ready
32
34
  agentsam shell Slash commands + shell UX info
33
35
  agentsam --version
@@ -36,6 +38,12 @@ function printHelp() {
36
38
  Init is completable with Node only — no IAM login, no OAuth, no Cloudflare.
37
39
  Prove locally first; deploy prompts for accounts only when you choose to ship.
38
40
 
41
+ Tunnel options:
42
+ --quick Quick tunnel (default) — trycloudflare.com URL
43
+ --named Named CF tunnel (needs --tunnel-name --hostname --zone-id)
44
+ --port <n> Local PTY port (default 3099)
45
+ --token <sdk_…> Use existing AGENTSAM_SDK_TOKEN (skip browser auth)
46
+
39
47
  Init options:
40
48
  --name <name> Project directory name
41
49
  --lane <fullstack|cms|data|crm|creative>
@@ -193,6 +201,13 @@ if (command === '--version' || command === '-v') {
193
201
  await runShellInfo();
194
202
  } else if (command === 'start-local') {
195
203
  await runStartLocal({});
204
+ } else if (command === 'tunnel') {
205
+ try {
206
+ await runTunnel(rest);
207
+ } catch (e) {
208
+ console.error(`\n ✗ ${e?.message || e}\n`);
209
+ process.exit(1);
210
+ }
196
211
  } else if (command === 'deploy') {
197
212
  try {
198
213
  await runDeploy(parseDeployArgs(rest));
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AgentSam HTTP API server for containerized deployments
4
+ * Exposes the AgentSam SDK as HTTP endpoints (compatible with Cloudflare Workers pattern)
5
+ */
6
+ import http from 'node:http';
7
+ import { AgentSam } from './AgentSam.js';
8
+
9
+ const PORT = process.env.PORT || 8080;
10
+ const HOST = process.env.HOST || '0.0.0.0';
11
+
12
+ const agentSam = new AgentSam({
13
+ env: process.env,
14
+ agent: process.env.AGENT || 'orchestrator',
15
+ lane: process.env.LANE || 'fullstack',
16
+ project: process.env.PROJECT || 'agentsam-docker',
17
+ });
18
+
19
+ /**
20
+ * Adapt Web API Request to Node HTTP
21
+ */
22
+ class NodeRequest {
23
+ constructor(req, body) {
24
+ this.url = `http://${req.headers.host || 'localhost'}${req.url}`;
25
+ this.method = req.method;
26
+ this.headers = req.headers;
27
+ this.body = body;
28
+ }
29
+
30
+ async json() {
31
+ if (!this.body) return {};
32
+ try {
33
+ return JSON.parse(this.body);
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+ }
39
+
40
+ const server = http.createServer(async (req, res) => {
41
+ let body = '';
42
+
43
+ // Read request body
44
+ req.on('data', (chunk) => {
45
+ body += chunk.toString();
46
+ });
47
+
48
+ req.on('end', async () => {
49
+ try {
50
+ const request = new NodeRequest(req, body);
51
+ const response = await agentSam.handle(request);
52
+
53
+ // Write response headers
54
+ const headers = {};
55
+ if (response.headers && response.headers.entries) {
56
+ for (const [key, value] of response.headers.entries()) {
57
+ headers[key] = value;
58
+ }
59
+ }
60
+ res.writeHead(response.status, headers);
61
+
62
+ // Write response body
63
+ const text = await response.text();
64
+ res.end(text);
65
+ } catch (error) {
66
+ console.error('Error handling request:', error);
67
+ res.writeHead(500, { 'Content-Type': 'application/json' });
68
+ res.end(JSON.stringify({ ok: false, error: error.message }));
69
+ }
70
+ });
71
+ });
72
+
73
+ server.listen(PORT, HOST, () => {
74
+ console.log(`
75
+ ✓ Agent Sam API Server listening on http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}
76
+
77
+ Routes:
78
+ GET /api/health → service health
79
+ GET /api/agentsam/info → agent capabilities
80
+ POST /api/agentsam/session → create session
81
+ GET /api/agentsam/session/:id → get session
82
+ POST /api/agentsam/message → route message
83
+
84
+ Environment: AGENT=${process.env.AGENT || 'orchestrator'} LANE=${process.env.LANE || 'fullstack'}
85
+ `);
86
+ });
87
+
88
+ // Graceful shutdown
89
+ const shutdown = async () => {
90
+ console.log('\nShutting down...');
91
+ server.close(() => {
92
+ process.exit(0);
93
+ });
94
+ setTimeout(() => process.exit(1), 5000);
95
+ };
96
+ process.on('SIGINT', shutdown);
97
+ process.on('SIGTERM', shutdown);
@@ -0,0 +1,232 @@
1
+ /**
2
+ * agentsam tunnel — expose local PTY (:3099) to the IAM platform.
3
+ *
4
+ * Default (--quick): cloudflared quick tunnel → register ws_url via SDK API.
5
+ * Named (--named): platform provisions CF named tunnel + DNS; run with --token.
6
+ */
7
+ import { spawn, spawnSync } from 'node:child_process';
8
+ import { authenticateViaBrowser } from '../lib/auth.js';
9
+ import { postJson } from '../lib/core-client.js';
10
+
11
+ const DEFAULT_PORT = 3099;
12
+
13
+ function parseArgs(argv) {
14
+ const opts = {
15
+ mode: 'quick',
16
+ port: DEFAULT_PORT,
17
+ tunnelName: '',
18
+ hostname: '',
19
+ zoneId: '',
20
+ platform: process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : 'linux',
21
+ shell: process.platform === 'win32' ? 'powershell' : process.env.SHELL || '/bin/zsh',
22
+ skipAuth: false,
23
+ };
24
+ for (let i = 0; i < argv.length; i += 1) {
25
+ const a = argv[i];
26
+ if (a === '--quick') opts.mode = 'quick';
27
+ else if (a === '--named') opts.mode = 'named';
28
+ else if (a === '--port') opts.port = Number(argv[++i]) || DEFAULT_PORT;
29
+ else if (a === '--tunnel-name') opts.tunnelName = argv[++i] || '';
30
+ else if (a === '--hostname') opts.hostname = argv[++i] || '';
31
+ else if (a === '--zone-id') opts.zoneId = argv[++i] || '';
32
+ else if (a === '--platform') opts.platform = argv[++i] || opts.platform;
33
+ else if (a === '--shell') opts.shell = argv[++i] || opts.shell;
34
+ else if (a === '--token' && argv[i + 1]) {
35
+ process.env.AGENTSAM_SDK_TOKEN = argv[++i];
36
+ }
37
+ }
38
+ return opts;
39
+ }
40
+
41
+ function ensureCloudflared() {
42
+ const which = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['cloudflared'], {
43
+ encoding: 'utf8',
44
+ });
45
+ if (which.status !== 0) {
46
+ throw new Error(
47
+ 'cloudflared not found. Install: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/',
48
+ );
49
+ }
50
+ }
51
+
52
+ async function resolveToken() {
53
+ const existing = String(process.env.AGENTSAM_SDK_TOKEN || '').trim();
54
+ if (existing.startsWith('sdk_')) return existing;
55
+ const session = await authenticateViaBrowser();
56
+ const tok = String(session?.access_token || '').trim();
57
+ if (!tok.startsWith('sdk_')) throw new Error('IAM auth did not return an sdk_ bearer token');
58
+ process.env.AGENTSAM_SDK_TOKEN = tok;
59
+ return tok;
60
+ }
61
+
62
+ async function assertLocalPty(port) {
63
+ const url = `http://127.0.0.1:${port}/health`;
64
+ try {
65
+ const res = await fetch(url, { signal: AbortSignal.timeout(2500) });
66
+ if (!res.ok) throw new Error(`health ${res.status}`);
67
+ return true;
68
+ } catch {
69
+ throw new Error(
70
+ `Local PTY not reachable at ${url}. In another terminal run: npx agentsam start-local`,
71
+ );
72
+ }
73
+ }
74
+
75
+ function httpsToWss(url) {
76
+ const u = String(url || '').trim();
77
+ if (!u) return '';
78
+ if (u.startsWith('wss://') || u.startsWith('ws://')) return u.replace(/\/$/, '');
79
+ if (u.startsWith('https://')) return `wss://${u.slice(8)}`.replace(/\/$/, '');
80
+ if (u.startsWith('http://')) return `ws://${u.slice(7)}`.replace(/\/$/, '');
81
+ return `wss://${u.replace(/^\/+/, '')}`.replace(/\/$/, '');
82
+ }
83
+
84
+ /**
85
+ * Parse trycloudflare.com URL from cloudflared stderr/stdout.
86
+ * @param {import('node:child_process').ChildProcessWithoutNullStreams} child
87
+ * @returns {Promise<string>}
88
+ */
89
+ function waitForQuickTunnelUrl(child) {
90
+ return new Promise((resolve, reject) => {
91
+ let buf = '';
92
+ const timer = setTimeout(() => {
93
+ reject(new Error('Timed out waiting for cloudflared quick tunnel URL (60s)'));
94
+ }, 60_000);
95
+
96
+ const onData = (chunk) => {
97
+ const text = chunk.toString();
98
+ buf += text;
99
+ process.stderr.write(text);
100
+ const m =
101
+ buf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i) ||
102
+ buf.match(/https:\/\/[a-z0-9.-]+\.cfargotunnel\.com/i);
103
+ if (m) {
104
+ clearTimeout(timer);
105
+ child.stdout?.off('data', onData);
106
+ child.stderr?.off('data', onData);
107
+ resolve(m[0]);
108
+ }
109
+ };
110
+
111
+ child.stdout?.on('data', onData);
112
+ child.stderr?.on('data', onData);
113
+ child.on('error', (e) => {
114
+ clearTimeout(timer);
115
+ reject(e);
116
+ });
117
+ child.on('exit', (code) => {
118
+ clearTimeout(timer);
119
+ reject(new Error(`cloudflared exited early (code ${code})`));
120
+ });
121
+ });
122
+ }
123
+
124
+ async function runQuick(opts, token) {
125
+ await assertLocalPty(opts.port);
126
+ ensureCloudflared();
127
+
128
+ console.log(`
129
+ Agent Sam — tunnel (quick)
130
+ Local PTY http://127.0.0.1:${opts.port}
131
+ Mode cloudflared quick tunnel → IAM register-local
132
+ `);
133
+
134
+ const child = spawn(
135
+ 'cloudflared',
136
+ ['tunnel', '--url', `http://127.0.0.1:${opts.port}`, '--no-autoupdate'],
137
+ { stdio: ['ignore', 'pipe', 'pipe'] },
138
+ );
139
+
140
+ const publicUrl = await waitForQuickTunnelUrl(child);
141
+ const wsUrl = httpsToWss(publicUrl);
142
+ console.log(`\n ✓ Public URL ${publicUrl}`);
143
+ console.log(` ✓ Registering ${wsUrl}\n`);
144
+
145
+ const registered = await postJson(
146
+ '/api/sdk/terminal/register-local',
147
+ {
148
+ ws_url: wsUrl,
149
+ platform: opts.platform,
150
+ shell: opts.shell,
151
+ },
152
+ token,
153
+ );
154
+
155
+ console.log(` ✓ IAM local lane active`);
156
+ if (registered?.connection?.id) {
157
+ console.log(` ✓ connection ${registered.connection.id}`);
158
+ }
159
+ console.log(`
160
+ Keep this process running. In the dashboard: Terminal → Local.
161
+
162
+ Ctrl+C stops the tunnel.
163
+ `);
164
+
165
+ await new Promise((resolve) => {
166
+ child.on('exit', resolve);
167
+ process.on('SIGINT', () => {
168
+ child.kill('SIGINT');
169
+ });
170
+ process.on('SIGTERM', () => {
171
+ child.kill('SIGTERM');
172
+ });
173
+ });
174
+ }
175
+
176
+ async function runNamed(opts, token) {
177
+ if (!opts.tunnelName || !opts.hostname || !opts.zoneId) {
178
+ throw new Error(
179
+ 'Named mode requires --tunnel-name, --hostname, and --zone-id (from your CF zone).',
180
+ );
181
+ }
182
+ await assertLocalPty(opts.port);
183
+ ensureCloudflared();
184
+
185
+ console.log(`
186
+ Agent Sam — tunnel (named)
187
+ Provisioning Cloudflare tunnel ${opts.tunnelName} → ${opts.hostname}
188
+ `);
189
+
190
+ const provisioned = await postJson(
191
+ '/api/sdk/terminal/tunnel/provision',
192
+ {
193
+ tunnel_name: opts.tunnelName,
194
+ hostname: opts.hostname,
195
+ zone_id: opts.zoneId,
196
+ port: opts.port,
197
+ platform: opts.platform,
198
+ shell: opts.shell,
199
+ },
200
+ token,
201
+ );
202
+
203
+ const runToken = String(provisioned?.run_token || '').trim();
204
+ if (!runToken) throw new Error('Platform did not return a cloudflared run_token');
205
+
206
+ console.log(` ✓ ws_url ${provisioned.ws_url || `wss://${opts.hostname}`}`);
207
+ console.log(` ✓ Starting cloudflared tunnel run --token …\n`);
208
+
209
+ const child = spawn('cloudflared', ['tunnel', 'run', '--token', runToken, '--no-autoupdate'], {
210
+ stdio: 'inherit',
211
+ });
212
+
213
+ await new Promise((resolve, reject) => {
214
+ child.on('error', reject);
215
+ child.on('exit', resolve);
216
+ process.on('SIGINT', () => child.kill('SIGINT'));
217
+ });
218
+ }
219
+
220
+ /**
221
+ * @param {string[]} [argv]
222
+ */
223
+ export async function runTunnel(argv = []) {
224
+ const opts = parseArgs(argv);
225
+ const token = await resolveToken();
226
+
227
+ if (opts.mode === 'named') {
228
+ await runNamed(opts, token);
229
+ } else {
230
+ await runQuick(opts, token);
231
+ }
232
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * @file src/ui/splash-xterm.js
3
+ * @description In-app splash renderer for xterm.js panels.
4
+ *
5
+ * Same choreography as splash.js (CLI) but writes to an xterm Terminal
6
+ * instance instead of process.stdout. Used by XTermShell.tsx.
7
+ *
8
+ * Usage in XTermShell.tsx:
9
+ * import { runXtermSplash } from '@inneranimalmedia/agentsam-sdk/ui/splash-xterm';
10
+ * import { getXtermOptions } from '@inneranimalmedia/agentsam-sdk/ui/theme';
11
+ *
12
+ * const targetType = connection?.target_type ?? 'platform_vm';
13
+ * const term = new Terminal({
14
+ * ...getXtermOptions(targetType),
15
+ * cols: dimensions.cols,
16
+ * rows: dimensions.rows,
17
+ * });
18
+ *
19
+ * // After term.open(containerRef.current):
20
+ * const stopFlicker = await runXtermSplash(term, {
21
+ * apiBase: 'https://inneranimalmedia.com',
22
+ * workspaceId: activeWorkspace?.id,
23
+ * authToken: session?.token,
24
+ * targetType,
25
+ * skipArt: !isFirstSession,
26
+ * });
27
+ *
28
+ * // On cleanup:
29
+ * return () => { stopFlicker(); term.dispose(); };
30
+ */
31
+
32
+ import { PALETTE, getLaneTheme } from './theme.js';
33
+ import { probeAll } from './splash.js';
34
+
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // xterm write helpers
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ const ESC = '\x1b';
40
+ const CSI = `${ESC}[`;
41
+
42
+ const X = {
43
+ reset: `${CSI}0m`,
44
+ bold: `${CSI}1m`,
45
+ hide: `${CSI}?25l`,
46
+ show: `${CSI}?25h`,
47
+ clearLine: `${CSI}2K\r`,
48
+ up: (n = 1) => `${CSI}${n}A`,
49
+ fg: (hex) => {
50
+ const h = hex.replace('#', '');
51
+ const r = parseInt(h.slice(0, 2), 16);
52
+ const g = parseInt(h.slice(2, 4), 16);
53
+ const b = parseInt(h.slice(4, 6), 16);
54
+ return `${CSI}38;2;${r};${g};${b}m`;
55
+ },
56
+ };
57
+
58
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
59
+
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ // COLOR BUILDER (per-lane)
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+
64
+ function buildColors() {
65
+ const R = X.reset;
66
+ const B = X.bold;
67
+ return {
68
+ R, B,
69
+ TEAL: X.fg(PALETTE.teal300),
70
+ AMBER: X.fg(PALETTE.amber300),
71
+ STONE: X.fg('#3a3530'),
72
+ STONE2: X.fg('#2a2520'),
73
+ VINE: X.fg('#1a4a2a'),
74
+ VINE2: X.fg('#0f6b3a'),
75
+ FIRE1: X.fg('#f97316'),
76
+ FIRE2: X.fg('#ef4444'),
77
+ FIREY: X.fg('#fef08a'),
78
+ GRILL: X.fg('#1e2830'),
79
+ GRILL2: X.fg('#2d3d4a'),
80
+ PELT: X.fg('#8b6f5a'),
81
+ DIM: X.fg('#444444'),
82
+ GHOST: X.fg('#888888'),
83
+ WHT: X.fg('#e2e8f0'),
84
+ };
85
+ }
86
+
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+ // SCENE DATA
89
+ // ─────────────────────────────────────────────────────────────────────────────
90
+
91
+ function gorillaRows(C) {
92
+ const { R, GRILL, GRILL2, PELT, DIM } = C;
93
+ return [
94
+ ` ${DIM}░░▒${R}${GRILL2}▓▓▓▓▓▓▓▓${R}${DIM}▒░░${R}`,
95
+ ` ${DIM}░${R}${GRILL2}▒▓▓${R}${GRILL}█████████████${R}${GRILL2}▓▓▒${R}${DIM}░${R}`,
96
+ ` ${GRILL2}▒▓${R}${GRILL}███${R}${GRILL2}▒▒${R}${GRILL}████████${R}${GRILL2}▒▒${R}${GRILL}███${R}${GRILL2}▓▒${R}`,
97
+ ` ${GRILL2}▓${R}${GRILL}████${R} ${DIM}▒▒▒▒▒▒▒▒${R} ${GRILL}████${R}${GRILL2}▓${R}`,
98
+ ` ${GRILL}▓███${R}${GRILL2}▒${R} ${PELT}██${R}${DIM}▒${R}${PELT}████${R}${DIM}▒${R}${PELT}██${R} ${GRILL2}▒${R}${GRILL}███▓${R}`,
99
+ ` ${GRILL}████${R} ${PELT}███${R}${DIM}▒▒▒▒${R}${PELT}███${R} ${GRILL}████${R}`,
100
+ ` ${GRILL}████${R}${GRILL2}▒${R} ${PELT}██████████${R} ${GRILL2}▒${R}${GRILL}████${R}`,
101
+ ` ${GRILL}████${R} ${DIM}▒▒▒▒▒▒▒▒▒▒${R} ${GRILL}████${R}`,
102
+ ` ${GRILL2}▒${R}${GRILL}████${R}${GRILL2}▓▓▓${R}${GRILL}████████████${R}${GRILL2}▓▓▓${R}${GRILL}████${R}${GRILL2}▒${R}`,
103
+ ` ${GRILL}██████████████████████████${R}`,
104
+ ` ${DIM}▒▒${R}${GRILL}████████${R}${GRILL2}▒▒▒▒▒▒▒▒▒▒▒▒${R}${GRILL}████████${R}${DIM}▒▒${R}`,
105
+ ` ${PELT}▓${R}${GRILL}██████████${R}${DIM}░░░░░░░░░░░░░░${R}${GRILL}██████████${R}${PELT}▓${R}`,
106
+ ];
107
+ }
108
+
109
+ function titleBoxLines(C) {
110
+ const { R, B, TEAL, AMBER } = C;
111
+ const wide = 26;
112
+ const h = `${TEAL}═${R}`;
113
+ return [
114
+ `${TEAL}╔${R}${h.repeat(wide)}${TEAL}╗${R}`,
115
+ `${TEAL}║${R} ${B}${AMBER}INNERANIMAL MEDIA${R} ${TEAL}║${R}`,
116
+ `${TEAL}║${R} ${TEAL}inneranimalmedia.com${R} ${TEAL}║${R}`,
117
+ `${TEAL}╚${R}${h.repeat(wide)}${TEAL}╝${R}`,
118
+ ];
119
+ }
120
+
121
+ function ladderRows(C) {
122
+ const { R, TEAL } = C;
123
+ return [
124
+ ` ${TEAL}║${R} ${TEAL}║${R}`,
125
+ ` ${TEAL}╠═══╣${R}`,
126
+ ` ${TEAL}║${R} ${TEAL}║${R}`,
127
+ ` ${TEAL}╠═══╣${R}`,
128
+ ` ${TEAL}║${R} ${TEAL}║${R}`,
129
+ ` ${TEAL}╠═══╣${R}`,
130
+ ` ${TEAL}║${R} ${TEAL}║${R}`,
131
+ ];
132
+ }
133
+
134
+ // ─────────────────────────────────────────────────────────────────────────────
135
+ // HUD RENDER
136
+ // ─────────────────────────────────────────────────────────────────────────────
137
+
138
+ const HUD_ITEMS = [
139
+ { key: 'workspace', icon: '⊞', label: 'Workspace' },
140
+ { key: 'runtime', icon: '▣', label: 'Runtime' },
141
+ { key: 'tunnel', icon: '⟁', label: 'Tunnel' },
142
+ { key: 'agent', icon: '⬡', label: 'Agent' },
143
+ ];
144
+
145
+ const READY_LABELS = {
146
+ workspace: 'active',
147
+ runtime: 'ready',
148
+ tunnel: 'connected',
149
+ agent: 'online',
150
+ };
151
+
152
+ function renderHUDLine(states, C) {
153
+ const { R, B, TEAL, GHOST, WHT, DIM } = C;
154
+ return HUD_ITEMS.map((item, i) => {
155
+ const state = states[item.key] || 'checking';
156
+ let stateStr;
157
+ if (state === 'ready') {
158
+ stateStr = `${TEAL}● ${READY_LABELS[item.key]}${R}`;
159
+ } else if (state === 'checking') {
160
+ stateStr = `${GHOST}◌ checking...${R}`;
161
+ } else if (state === 'error') {
162
+ stateStr = `${X.fg('#ef4444')}✗ error${R}`;
163
+ } else {
164
+ stateStr = `${X.fg('#666666')}○ offline${R}`;
165
+ }
166
+ const div = i < HUD_ITEMS.length - 1 ? ` ${DIM}│${R} ` : '';
167
+ return `${GHOST}${item.icon}${R} ${B}${WHT}${item.label}${R} ${stateStr}${div}`;
168
+ }).join('');
169
+ }
170
+
171
+ // ─────────────────────────────────────────────────────────────────────────────
172
+ // MAIN XTERM SPLASH
173
+ // ─────────────────────────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * @param {import('@xterm/xterm').Terminal} term
177
+ * @param {object} opts
178
+ * @param {string} opts.apiBase
179
+ * @param {string} opts.workspaceId
180
+ * @param {string} opts.authToken
181
+ * @param {'user_hosted_tunnel'|'platform_vm'|'sandbox'} opts.targetType
182
+ * @param {boolean} opts.skipArt
183
+ * @returns {Promise<() => void>} stopFlicker — call on unmount
184
+ */
185
+ export async function runXtermSplash(term, {
186
+ apiBase = '',
187
+ workspaceId = '',
188
+ authToken = '',
189
+ targetType = 'platform_vm',
190
+ skipArt = false,
191
+ } = {}) {
192
+
193
+ const lane = getLaneTheme(targetType);
194
+ const C = buildColors();
195
+ const { R, B, TEAL, AMBER, GHOST, WHT, DIM, STONE, STONE2, FIRE1, FIRE2 } = C;
196
+ const PRIMARY = X.fg(lane.colors.primary);
197
+ const W = term.cols || 80;
198
+
199
+ const w = (s) => term.write(s);
200
+ const wl = (s = '') => term.write(s + '\r\n');
201
+
202
+ w(X.hide);
203
+ w(`${ESC}c`); // full reset
204
+ await sleep(60);
205
+
206
+ // cd verb flash
207
+ w(` ${GHOST}$ ${R}${AMBER}cd ~/inneranimalmedia${R}`);
208
+ await sleep(340);
209
+ w(`\r${X.clearLine}`);
210
+ await sleep(60);
211
+
212
+ if (!skipArt) {
213
+ wl();
214
+
215
+ const rows = gorillaRows(C);
216
+ const title = titleBoxLines(C);
217
+ const TITLE_START = 3;
218
+
219
+ for (let i = 0; i < rows.length; i++) {
220
+ const titleIdx = i - TITLE_START;
221
+ const titlePart = (titleIdx >= 0 && titleIdx < title.length)
222
+ ? ' ' + title[titleIdx]
223
+ : '';
224
+ wl(` ${rows[i]}${titlePart}`);
225
+ await sleep(24);
226
+ }
227
+
228
+ wl();
229
+
230
+ // Torches
231
+ w(` ${FIRE1} ▓▓▓${R}`);
232
+ await sleep(90);
233
+ wl(`${' '.repeat(Math.max(0, W - 14))}${FIRE2}▓▓▓${R}`);
234
+ await sleep(30);
235
+
236
+ // Ledge
237
+ w(' ');
238
+ for (let i = 0; i < W - 4; i++) {
239
+ w((i % 2 === 0) ? `${STONE}█${R}` : `${STONE2}▓${R}`);
240
+ await sleep(4);
241
+ }
242
+ wl();
243
+ wl(` ${DIM}${'╌'.repeat(W - 4)}${R}`);
244
+ await sleep(50);
245
+
246
+ // Ladder
247
+ for (const rung of ladderRows(C)) {
248
+ wl(rung);
249
+ await sleep(30);
250
+ }
251
+ }
252
+
253
+ wl();
254
+
255
+ // Start prompt
256
+ await sleep(skipArt ? 0 : 70);
257
+ wl(` ${B}${AMBER}Start ${PRIMARY}▸${R}`);
258
+ await sleep(40);
259
+ wl(` ${GHOST}Type a command to begin.${R}`);
260
+ await sleep(skipArt ? 0 : 70);
261
+ wl();
262
+
263
+ // HUD — fire probes in parallel
264
+ const probePromise = probeAll({ apiBase, workspaceId, authToken });
265
+ const initStates = { workspace: 'checking', runtime: 'checking', tunnel: 'checking', agent: 'checking' };
266
+
267
+ wl(` ${DIM}${'─'.repeat(W - 4)}${R}`);
268
+ wl();
269
+ w(` ${renderHUDLine(initStates, C)}`);
270
+ wl(); wl();
271
+ wl(` ${DIM}${'─'.repeat(W - 4)}${R}`);
272
+ wl();
273
+
274
+ // Update HUD in-place when probes resolve
275
+ const LINES_BELOW = 4;
276
+ probePromise.then(resolved => {
277
+ w(X.up(LINES_BELOW));
278
+ w(`\r${X.clearLine}`);
279
+ w(` ${renderHUDLine(resolved, C)}`);
280
+ w('\r\n'.repeat(LINES_BELOW));
281
+ w(X.show);
282
+ w(` ${PRIMARY}>${R} `);
283
+ });
284
+
285
+ // Torch flicker is handled via CSS animation on the panel wrapper
286
+ // (terminal-lanes.css) — xterm.js decoration API in Phase 2.
287
+ return () => {};
288
+ }
289
+
290
+ export default { runXtermSplash };