instar 0.6.0 → 0.6.2

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.
@@ -32,7 +32,7 @@ Start with a brief welcome, then immediately ask HOW they want to use Instar.
32
32
 
33
33
  **Welcome to Instar!**
34
34
 
35
- Instar gives Claude Code a persistent bodya server, jobs, memory, and messaging. Two ways to use it:
35
+ Instar turns Claude Code into a persistent agent you just talk to through Telegram, not the terminal. This setup gets you there. Two ways to use it:
36
36
 
37
37
  ---
38
38
 
@@ -246,21 +246,23 @@ which claude
246
246
  - **Port** (default: 4040) — "The agent runs a small HTTP server for health checks and internal communication."
247
247
  - **Max sessions** (default: 3) — "This limits how many Claude sessions can run at once. 2-3 is usually right."
248
248
 
249
- ### 3c. Telegram Setup (Strongly Recommended Essential for General Agents)
249
+ ### 3c. Telegram Setup — The Destination
250
250
 
251
- **Frame Telegram as the primary way to interact with the agent.** Explain briefly:
251
+ **This terminal session is the on-ramp. Telegram is where the agent experience truly begins.** Frame it that way:
252
252
 
253
- > Telegram is where Instar really shines:
253
+ > Right now we're in a terminal. Telegram is where your agent comes alive:
254
+ > - **Just talk** — no commands, no terminal, just conversation
254
255
  > - **Topic threads** — organized channels for different concerns
255
- > - **Message history** — full record of every interaction
256
- > - **Mobile access** — talk to your agent from anywhere
257
- > - **Notifications** — your agent reaches you proactively
256
+ > - **Mobile access** — your agent is always reachable
257
+ > - **Proactive** — your agent reaches out when something matters
258
258
 
259
- For **General Agents**: Telegram is essential. Without it, a general agent has no natural interface — you'd have to open a terminal every time. **Strongly encourage** setting it up and explain that this IS the agent's communication channel.
259
+ The goal of this setup is to get the user onto Telegram as fast as possible. Everything else (jobs, config, technical setup) supports that destination.
260
260
 
261
- For **Project Agents**: Telegram is strongly recommended but optional. The agent can still work through CLI sessions without it.
261
+ For **General Agents**: Telegram is essential. Without it, there IS no natural interface. Be direct: "This is how you'll talk to your agent."
262
262
 
263
- If the user declines, that's their choice but make the tradeoff clear in one sentence.
263
+ For **Project Agents**: Telegram is strongly recommended. Frame it as: "Your agent can message you about builds, issues, and progress — you just reply."
264
+
265
+ If the user declines, accept it in one sentence and move on — but they should understand they're choosing the terminal-only experience.
264
266
 
265
267
  #### Browser-Automated Setup (Default)
266
268
 
@@ -445,23 +447,23 @@ Append if not present:
445
447
  .instar/logs/
446
448
  ```
447
449
 
448
- ## Phase 4: Summary & Next Steps
450
+ ## Phase 4: Summary & Launch
449
451
 
450
- Show what was created briefly, then focus on what happens next.
452
+ Show what was created briefly, then get the user to their agent.
451
453
 
452
- **Next stepsframe around Telegram if configured:**
454
+ **If Telegram was configured this is the moment:**
453
455
 
454
- If Telegram was set up, emphasize that Telegram IS the interface now:
456
+ > "That's everything. Let me start the server, and then open Telegram and say hello to your agent. That's your primary channel from here on — no terminal needed."
455
457
 
456
- > "Start the server with `instar server start`, then open Telegram. That's it. Your agent will message you when it has something to report. You can message it when you need something done. Everything else — jobs, monitoring, memory — happens automatically."
458
+ Start the server, then direct them to Telegram. The setup is complete when the user is talking to their agent in Telegram, not when config files are written.
457
459
 
458
- If Telegram was NOT set up, mention they can add it later:
460
+ **If Telegram was NOT configured:**
459
461
 
460
- > "Start the server with `instar server start`. You can interact with your agent through Claude Code sessions. For a much richer experience, run `instar add telegram` later it gives you mobile access, organized threads, and message history."
462
+ > "Start the server with `instar server start`. You can talk to your agent through Claude Code sessions. When you're ready for a richer experience, just ask your agent to help set up Telegram."
461
463
 
462
464
  Offer to start the server.
463
465
 
464
- **Important:** Do NOT present a list of CLI commands. The point of Instar is that the agent is autonomous. After starting the server, the user talks to their agent (ideally through Telegram), not to the CLI.
466
+ **Important:** Do NOT present a list of CLI commands. The setup's job is to get the user FROM the terminal TO their agent. After starting the server, the user talks to their agent (through Telegram), not to the CLI. The terminal was just the on-ramp.
465
467
 
466
468
  ## Tone
467
469
 
package/README.md CHANGED
@@ -34,54 +34,45 @@ Named after the developmental stages between molts in arthropods, where each ins
34
34
 
35
35
  The difference isn't features. It's a shift in what Claude Code *is* -- from a tool you use to an agent that works alongside you. This is the cutting edge of what's possible with AI agents today -- not a demo, not a toy, but genuine autonomous partnership between a human and an AI.
36
36
 
37
- ## Two Ways to Use Instar
37
+ ## Getting Started
38
38
 
39
- ### 🤖 General Agent A personal AI on your computer
40
-
41
- Want a persistent AI assistant you talk to through Telegram? Like OpenClaw, but ToS-compliant.
39
+ One command gets you from zero to talking with your AI partner:
42
40
 
43
41
  ```bash
44
42
  npx instar
45
- # Choose "General Agent" → set up Telegram → start the server
46
- # Now talk to your agent from your phone, anywhere
47
43
  ```
48
44
 
49
- Your agent runs in the background, handles scheduled tasks, messages you proactively, and grows through experience. Telegram is the primary interface organized topic threads, full message history, mobile access.
45
+ A guided setup handles the rest — identity, Telegram connection, server. Within minutes, you're talking to your partner from your phone, anywhere. That's the intended experience: **you talk, your partner handles everything else.**
50
46
 
51
- ### 📁 Project Agent — Add an agent to your codebase
47
+ ### Two configurations
52
48
 
53
- Want an agent that monitors, builds, and maintains a specific project?
49
+ - **General Agent** A personal AI partner on your computer. Runs in the background, handles scheduled tasks, messages you proactively, and grows through experience.
50
+ - **Project Agent** — A partner embedded in your codebase. Monitors, builds, maintains, and communicates through Telegram or terminal.
54
51
 
55
- ```bash
56
- cd my-project
57
- npx instar
58
- # Choose "Project Agent" → configure → start the server
59
- ```
60
-
61
- Your agent watches your codebase, runs health checks, handles ops tasks, and communicates through Telegram (recommended) or terminal sessions.
62
-
63
- ---
64
-
65
- The wizard walks you through everything: identity, Telegram, jobs, server. One command to go from zero to a running agent.
52
+ Once running, the infrastructure is invisible. Your partner manages its own jobs, health checks, evolution, and self-maintenance. You just talk to it.
66
53
 
67
54
  **Requirements:** Node.js 20+ · [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) · tmux · [API key](https://console.anthropic.com/) or Claude subscription
68
55
 
69
- ## CLI Reference
56
+ ## CLI Reference (Power Users)
57
+
58
+ > Most users never need these — your agent manages its own infrastructure. These commands are available for power users and for the agent itself to operate.
70
59
 
71
60
  ```bash
72
61
  # Setup
73
- instar # Interactive setup wizard (General Agent or Project Agent)
62
+ instar # Interactive setup wizard
74
63
  instar setup # Same as above
75
- instar setup --classic # Inquirer-based fallback wizard
76
64
  instar init my-agent # Create a new agent (general or project)
77
- instar init # Add agent infrastructure to current project
78
65
 
79
66
  # Server
80
67
  instar server start # Start the persistent server (background, tmux)
81
- instar server start --foreground # Start in foreground (for development)
82
68
  instar server stop # Stop the server
83
69
  instar status # Show agent infrastructure status
84
70
 
71
+ # Lifeline (persistent Telegram connection with auto-recovery)
72
+ instar lifeline start # Start lifeline (supervises server, queues messages during downtime)
73
+ instar lifeline stop # Stop lifeline and server
74
+ instar lifeline status # Check lifeline health
75
+
85
76
  # Add capabilities
86
77
  instar add telegram --token BOT_TOKEN --chat-id CHAT_ID
87
78
  instar add email --credentials-file ./credentials.json [--token-file ./token.json]
@@ -90,10 +81,8 @@ instar add sentry --dsn https://key@o0.ingest.sentry.io/0
90
81
 
91
82
  # Users and jobs
92
83
  instar user add --id alice --name "Alice" [--telegram 123] [--email a@b.com]
93
- instar user list
94
84
  instar job add --slug check-email --name "Email Check" --schedule "0 */2 * * *" \
95
85
  [--description "..."] [--priority high] [--model sonnet]
96
- instar job list
97
86
 
98
87
  # Feedback
99
88
  instar feedback --type bug --title "Session timeout" --description "Details..."
@@ -116,12 +105,14 @@ instar feedback --type bug --title "Session timeout" --description "Details..."
116
105
  ```
117
106
  You (Telegram / Terminal)
118
107
 
108
+ conversation
109
+
119
110
 
120
111
  ┌─────────────────────────┐
121
- Instar Server
122
- (Express + tmux)
123
- │ http://localhost:4040 │
112
+ Your AI Partner
113
+ (Instar Server)
124
114
  └────────┬────────────────┘
115
+ │ manages its own infrastructure
125
116
 
126
117
  ├─ Claude Code session (job: health-check)
127
118
  ├─ Claude Code session (job: email-monitor)
@@ -129,7 +120,7 @@ You (Telegram / Terminal)
129
120
  └─ Claude Code session (job: reflection)
130
121
  ```
131
122
 
132
- Each session is a **real Claude Code process** with extended thinking, native tools, sub-agents, hooks, skills, and MCP servers. Not an API wrapper -- the full development environment.
123
+ Each session is a **real Claude Code process** with extended thinking, native tools, sub-agents, hooks, skills, and MCP servers. Not an API wrapper -- the full development environment. The agent manages all of this autonomously.
133
124
 
134
125
  ## Why Instar (vs OpenClaw)
135
126
 
@@ -241,14 +232,9 @@ Two-way messaging via Telegram forum topics. Each topic maps to a Claude session
241
232
 
242
233
  ### Persistent Server
243
234
 
244
- ```bash
245
- instar server start # Background (tmux)
246
- instar server start --foreground # Foreground (dev)
247
- instar server stop
248
- instar status # Health check
249
- ```
235
+ The server runs 24/7 in the background, surviving terminal disconnects and auto-recovering from failures. The agent operates it — you don't need to manage it.
250
236
 
251
- **Endpoints:**
237
+ **API endpoints** (used by the agent internally):
252
238
 
253
239
  | Method | Path | Description |
254
240
  |--------|------|-------------|
@@ -346,7 +332,7 @@ Instar is open source. PRs and issues still work. But the *primary* feedback cha
346
332
 
347
333
  **How it works:**
348
334
 
349
- 1. **You talk to your agent** -- "The email job keeps failing" -- natural conversation, not a bug report form
335
+ 1. **You mention a problem** -- "The email job keeps failing" -- natural conversation, not a bug report form
350
336
  2. **Agent-to-agent relay** -- Your agent communicates the issue directly to Dawn, the AI that maintains Instar
351
337
  3. **Dawn evolves Instar** -- Fixes the infrastructure and publishes an update
352
338
  4. **Every agent evolves** -- Agents detect improvements, understand them, and grow -- collectively
@@ -447,12 +447,12 @@ This routes feedback to the Instar maintainers automatically. Valid types: \`bug
447
447
 
448
448
  **Feedback System** — Report bugs, request features, suggest improvements. All via \`POST /feedback\`. NOT GitHub.
449
449
 
450
- **Job Scheduler** — You can run tasks on a schedule. Jobs are defined in \`.instar/jobs.json\`.
450
+ **Job Scheduler** — Run tasks on a schedule. Jobs are defined in \`.instar/jobs.json\`.
451
451
  - View jobs: \`curl http://localhost:${port}/jobs\`
452
452
  - Trigger a job: \`curl -X POST http://localhost:${port}/jobs/SLUG/trigger\`
453
453
  - **Create new jobs**: Edit \`.instar/jobs.json\`. Each job has a slug, schedule (cron), priority, and either a prompt (Claude session), script (shell command), or skill.
454
454
 
455
- **Sessions** — You can spawn and manage Claude Code sessions.
455
+ **Sessions** — Spawn and manage Claude Code sessions.
456
456
  - List: \`curl http://localhost:${port}/sessions\`
457
457
  - Spawn: \`curl -X POST http://localhost:${port}/sessions/spawn -H 'Content-Type: application/json' -d '{"name":"task","prompt":"do something"}'\`
458
458
 
@@ -23,6 +23,8 @@ import { DispatchManager } from '../core/DispatchManager.js';
23
23
  import { UpdateChecker } from '../core/UpdateChecker.js';
24
24
  import { registerPort, unregisterPort, startHeartbeat } from '../core/PortRegistry.js';
25
25
  import { TelegraphService } from '../publishing/TelegraphService.js';
26
+ import { PrivateViewer } from '../publishing/PrivateViewer.js';
27
+ import { TunnelManager } from '../tunnel/TunnelManager.js';
26
28
  /**
27
29
  * Respawn a session for a topic, including thread history in the bootstrap.
28
30
  * This prevents "thread drift" where respawned sessions lose context.
@@ -397,11 +399,40 @@ export async function startServer(options) {
397
399
  });
398
400
  console.log(pc.green(` Publishing enabled (Telegraph)`));
399
401
  }
400
- const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, dispatches, updateChecker, publisher });
402
+ // Set up private viewer (always enabled stores rendered markdown locally)
403
+ const viewer = new PrivateViewer({
404
+ viewsDir: path.join(config.stateDir, 'views'),
405
+ });
406
+ console.log(pc.green(` Private viewer enabled`));
407
+ // Set up Cloudflare Tunnel if configured
408
+ let tunnel;
409
+ if (config.tunnel?.enabled) {
410
+ tunnel = new TunnelManager({
411
+ enabled: true,
412
+ type: config.tunnel.type || 'quick',
413
+ token: config.tunnel.token,
414
+ port: config.port,
415
+ stateDir: config.stateDir,
416
+ });
417
+ }
418
+ const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, dispatches, updateChecker, publisher, viewer, tunnel });
401
419
  await server.start();
420
+ // Start tunnel AFTER server is listening
421
+ if (tunnel) {
422
+ try {
423
+ const tunnelUrl = await tunnel.start();
424
+ console.log(pc.green(` Tunnel active: ${pc.bold(tunnelUrl)}`));
425
+ }
426
+ catch (err) {
427
+ console.error(pc.red(` Tunnel failed: ${err instanceof Error ? err.message : String(err)}`));
428
+ console.log(pc.yellow(` Server running locally without tunnel. Fix tunnel config and restart.`));
429
+ }
430
+ }
402
431
  // Graceful shutdown
403
432
  const shutdown = async () => {
404
433
  console.log('\nShutting down...');
434
+ if (tunnel)
435
+ await tunnel.stop();
405
436
  stopHeartbeat();
406
437
  unregisterPort(config.projectName);
407
438
  scheduler?.stop();
@@ -184,9 +184,10 @@ async function runClassicSetup() {
184
184
  }) ?? 3;
185
185
  // ── Step 3: Telegram (BEFORE users, so we know context) ────────
186
186
  console.log();
187
- console.log(pc.bold(' Telegram (Recommended)'));
188
- console.log(pc.dim(' Telegram is the most powerful way to interact with your agent.'));
189
- console.log(pc.dim(' You get organized topic threads, message history, and mobile access.'));
187
+ console.log(pc.bold(' Telegram — Where Your Agent Lives'));
188
+ console.log(pc.dim(' Telegram is where the real experience begins.'));
189
+ console.log(pc.dim(' Once connected, you just talk no commands, no terminal.'));
190
+ console.log(pc.dim(' Topic threads, message history, mobile access, proactive notifications.'));
190
191
  console.log();
191
192
  const telegramConfig = await promptForTelegram();
192
193
  // ── Step 4: User setup ─────────────────────────────────────────
@@ -356,14 +357,25 @@ async function runClassicSetup() {
356
357
  console.log();
357
358
  const { startServer } = await import('./server.js');
358
359
  await startServer({ foreground: false });
360
+ if (telegramConfig?.chatId) {
361
+ console.log();
362
+ console.log(pc.bold(' Now open Telegram and say hello to your agent.'));
363
+ console.log(pc.dim(' That\'s your primary channel from here on — no terminal needed.'));
364
+ }
359
365
  }
360
366
  else {
361
367
  console.log();
362
- console.log(' When you\'re ready, start the server with:');
368
+ console.log(' To start the server:');
363
369
  console.log(` ${pc.cyan('instar server start')}`);
364
370
  console.log();
365
- console.log(' Once running, your agent can handle everything else —');
366
- console.log(' just ask it to add jobs, users, or configure new integrations.');
371
+ if (telegramConfig?.chatId) {
372
+ console.log(' Then open Telegram and say hello to your agent.');
373
+ console.log(' That\'s your primary channel — no terminal needed.');
374
+ }
375
+ else {
376
+ console.log(' Once running, just talk to your agent through Claude Code sessions.');
377
+ console.log(' For a richer experience, set up Telegram later with your agent\'s help.');
378
+ }
367
379
  }
368
380
  console.log();
369
381
  }
@@ -389,11 +401,13 @@ function isInstarGlobal() {
389
401
  */
390
402
  async function promptForTelegram() {
391
403
  const enableTelegram = await confirm({
392
- message: 'Set up Telegram? (recommended gives you mobile access, organized threads, and message history)',
404
+ message: 'Set up Telegram? (this is how you\'ll talk to your agent — mobile, threaded, always available)',
393
405
  default: true,
394
406
  });
395
- if (!enableTelegram)
407
+ if (!enableTelegram) {
408
+ console.log(pc.dim(' You can set it up later — just ask your agent once it\'s running.'));
396
409
  return null;
410
+ }
397
411
  console.log();
398
412
  console.log(pc.bold(' Telegram Setup'));
399
413
  console.log(pc.dim(' We\'ll walk you through creating a Telegram bot and a group for it to live in.'));
@@ -173,6 +173,7 @@ export function loadConfig(projectDir) {
173
173
  feedbackFile: path.join(stateDir, 'feedback.json'),
174
174
  ...fileConfig.feedback,
175
175
  },
176
+ tunnel: fileConfig.tunnel,
176
177
  };
177
178
  }
178
179
  /**
@@ -185,6 +186,7 @@ export function ensureStateDir(stateDir) {
185
186
  path.join(stateDir, 'state', 'sessions'),
186
187
  path.join(stateDir, 'state', 'jobs'),
187
188
  path.join(stateDir, 'relationships'),
189
+ path.join(stateDir, 'views'),
188
190
  path.join(stateDir, 'logs'),
189
191
  ];
190
192
  for (const dir of dirs) {
@@ -348,6 +348,8 @@ export interface InstarConfig {
348
348
  updates?: UpdateConfig;
349
349
  /** Publishing (Telegraph) config */
350
350
  publishing?: PublishingConfig;
351
+ /** Cloudflare Tunnel config */
352
+ tunnel?: TunnelConfigType;
351
353
  /** Request timeout in milliseconds (default: 30000) */
352
354
  requestTimeoutMs?: number;
353
355
  /** Instar version (from package.json) */
@@ -363,6 +365,14 @@ export interface PublishingConfig {
363
365
  /** Author URL shown on published pages */
364
366
  authorUrl?: string;
365
367
  }
368
+ export interface TunnelConfigType {
369
+ /** Whether tunnel is enabled */
370
+ enabled: boolean;
371
+ /** Tunnel type: 'quick' (ephemeral, no account) or 'named' (persistent, requires token) */
372
+ type: 'quick' | 'named';
373
+ /** Cloudflare tunnel token (required for named tunnels) */
374
+ token?: string;
375
+ }
366
376
  export interface DispatchConfig {
367
377
  /** Whether dispatch polling is enabled */
368
378
  enabled: boolean;
package/dist/index.d.ts CHANGED
@@ -27,6 +27,10 @@ export { TelegramAdapter } from './messaging/TelegramAdapter.js';
27
27
  export type { TelegramConfig } from './messaging/TelegramAdapter.js';
28
28
  export { TelegraphService, markdownToNodes, parseInline } from './publishing/TelegraphService.js';
29
29
  export type { TelegraphConfig, TelegraphNode, TelegraphElement, TelegraphPage, PublishedPage } from './publishing/TelegraphService.js';
30
- export type { Session, SessionStatus, SessionManagerConfig, ModelTier, JobDefinition, JobPriority, JobExecution, JobState, JobSchedulerConfig, UserProfile, UserChannel, UserPreferences, Message, OutgoingMessage, MessagingAdapter, MessagingAdapterConfig, QuotaState, AccountQuota, HealthStatus, ComponentHealth, ActivityEvent, InstarConfig, MonitoringConfig, RelationshipRecord, RelationshipManagerConfig, InteractionSummary, FeedbackItem, FeedbackConfig, UpdateInfo, UpdateResult, DispatchConfig, UpdateConfig, PublishingConfig, } from './core/types.js';
30
+ export { PrivateViewer } from './publishing/PrivateViewer.js';
31
+ export type { PrivateView, PrivateViewerConfig } from './publishing/PrivateViewer.js';
32
+ export { TunnelManager } from './tunnel/TunnelManager.js';
33
+ export type { TunnelConfig, TunnelState } from './tunnel/TunnelManager.js';
34
+ export type { Session, SessionStatus, SessionManagerConfig, ModelTier, JobDefinition, JobPriority, JobExecution, JobState, JobSchedulerConfig, UserProfile, UserChannel, UserPreferences, Message, OutgoingMessage, MessagingAdapter, MessagingAdapterConfig, QuotaState, AccountQuota, HealthStatus, ComponentHealth, ActivityEvent, InstarConfig, MonitoringConfig, RelationshipRecord, RelationshipManagerConfig, InteractionSummary, FeedbackItem, FeedbackConfig, UpdateInfo, UpdateResult, DispatchConfig, UpdateConfig, PublishingConfig, TunnelConfigType, } from './core/types.js';
31
35
  export type { Dispatch, DispatchCheckResult, DispatchEvaluation, EvaluationDecision, DispatchFeedback, DispatchStats } from './core/DispatchManager.js';
32
36
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -29,4 +29,7 @@ export { SleepWakeDetector } from './core/SleepWakeDetector.js';
29
29
  export { TelegramAdapter } from './messaging/TelegramAdapter.js';
30
30
  // Publishing
31
31
  export { TelegraphService, markdownToNodes, parseInline } from './publishing/TelegraphService.js';
32
+ export { PrivateViewer } from './publishing/PrivateViewer.js';
33
+ // Tunnel
34
+ export { TunnelManager } from './tunnel/TunnelManager.js';
32
35
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Private content viewer for Instar agents.
3
+ *
4
+ * Stores markdown content locally and serves it as rendered HTML
5
+ * via the agent's HTTP server. When combined with a Cloudflare Tunnel,
6
+ * this provides authenticated access to rendered content from anywhere.
7
+ *
8
+ * Unlike Telegraph (public), private views are gated by the agent's
9
+ * auth token and only accessible through the tunnel URL.
10
+ */
11
+ export interface PrivateView {
12
+ id: string;
13
+ title: string;
14
+ markdown: string;
15
+ createdAt: string;
16
+ updatedAt?: string;
17
+ }
18
+ export interface PrivateViewerConfig {
19
+ /** Directory to store views */
20
+ viewsDir: string;
21
+ }
22
+ export declare class PrivateViewer {
23
+ private viewsDir;
24
+ private lastTimestamp;
25
+ constructor(config: PrivateViewerConfig);
26
+ /**
27
+ * Store markdown content for private viewing.
28
+ * Returns the view ID.
29
+ */
30
+ create(title: string, markdown: string): PrivateView;
31
+ /**
32
+ * Update an existing view.
33
+ */
34
+ update(id: string, title: string, markdown: string): PrivateView | null;
35
+ /**
36
+ * Get a view by ID.
37
+ */
38
+ get(id: string): PrivateView | null;
39
+ /**
40
+ * List all views.
41
+ */
42
+ list(): PrivateView[];
43
+ /**
44
+ * Delete a view.
45
+ */
46
+ delete(id: string): boolean;
47
+ /**
48
+ * Render a view as self-contained HTML.
49
+ */
50
+ renderHtml(view: PrivateView): string;
51
+ private save;
52
+ }
53
+ //# sourceMappingURL=PrivateViewer.d.ts.map
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Private content viewer for Instar agents.
3
+ *
4
+ * Stores markdown content locally and serves it as rendered HTML
5
+ * via the agent's HTTP server. When combined with a Cloudflare Tunnel,
6
+ * this provides authenticated access to rendered content from anywhere.
7
+ *
8
+ * Unlike Telegraph (public), private views are gated by the agent's
9
+ * auth token and only accessible through the tunnel URL.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import crypto from 'node:crypto';
14
+ import { markdownToNodes } from './TelegraphService.js';
15
+ // ── Service ────────────────────────────────────────────────────────
16
+ export class PrivateViewer {
17
+ viewsDir;
18
+ lastTimestamp = 0;
19
+ constructor(config) {
20
+ this.viewsDir = config.viewsDir;
21
+ if (!fs.existsSync(this.viewsDir)) {
22
+ fs.mkdirSync(this.viewsDir, { recursive: true });
23
+ }
24
+ }
25
+ /**
26
+ * Store markdown content for private viewing.
27
+ * Returns the view ID.
28
+ */
29
+ create(title, markdown) {
30
+ const id = crypto.randomUUID();
31
+ // Ensure monotonically increasing timestamps even within same millisecond
32
+ let now = Date.now();
33
+ if (now <= this.lastTimestamp) {
34
+ now = this.lastTimestamp + 1;
35
+ }
36
+ this.lastTimestamp = now;
37
+ const view = {
38
+ id,
39
+ title,
40
+ markdown,
41
+ createdAt: new Date(now).toISOString(),
42
+ };
43
+ this.save(view);
44
+ return view;
45
+ }
46
+ /**
47
+ * Update an existing view.
48
+ */
49
+ update(id, title, markdown) {
50
+ const existing = this.get(id);
51
+ if (!existing)
52
+ return null;
53
+ existing.title = title;
54
+ existing.markdown = markdown;
55
+ existing.updatedAt = new Date().toISOString();
56
+ this.save(existing);
57
+ return existing;
58
+ }
59
+ /**
60
+ * Get a view by ID.
61
+ */
62
+ get(id) {
63
+ const filePath = path.join(this.viewsDir, `${id}.json`);
64
+ try {
65
+ if (!fs.existsSync(filePath))
66
+ return null;
67
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ /**
74
+ * List all views.
75
+ */
76
+ list() {
77
+ try {
78
+ const files = fs.readdirSync(this.viewsDir).filter(f => f.endsWith('.json'));
79
+ return files.map(f => {
80
+ try {
81
+ return JSON.parse(fs.readFileSync(path.join(this.viewsDir, f), 'utf-8'));
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }).filter((v) => v !== null)
87
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ }
93
+ /**
94
+ * Delete a view.
95
+ */
96
+ delete(id) {
97
+ const filePath = path.join(this.viewsDir, `${id}.json`);
98
+ try {
99
+ if (!fs.existsSync(filePath))
100
+ return false;
101
+ fs.unlinkSync(filePath);
102
+ return true;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ /**
109
+ * Render a view as self-contained HTML.
110
+ */
111
+ renderHtml(view) {
112
+ const nodes = markdownToNodes(view.markdown);
113
+ const bodyHtml = nodesToHtml(nodes);
114
+ return `<!DOCTYPE html>
115
+ <html lang="en">
116
+ <head>
117
+ <meta charset="UTF-8">
118
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
119
+ <title>${escapeHtml(view.title)}</title>
120
+ <style>
121
+ * { margin: 0; padding: 0; box-sizing: border-box; }
122
+ body {
123
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
124
+ line-height: 1.6;
125
+ color: #1a1a2e;
126
+ background: #f8f9fa;
127
+ padding: 0;
128
+ }
129
+ .container {
130
+ max-width: 720px;
131
+ margin: 0 auto;
132
+ padding: 2rem 1.5rem;
133
+ background: #fff;
134
+ min-height: 100vh;
135
+ }
136
+ h1 {
137
+ font-size: 1.8rem;
138
+ margin-bottom: 0.5rem;
139
+ color: #16213e;
140
+ border-bottom: 2px solid #e8e8e8;
141
+ padding-bottom: 0.5rem;
142
+ }
143
+ .meta {
144
+ font-size: 0.85rem;
145
+ color: #888;
146
+ margin-bottom: 2rem;
147
+ }
148
+ h3 { font-size: 1.4rem; margin: 1.5rem 0 0.75rem; color: #16213e; }
149
+ h4 { font-size: 1.15rem; margin: 1.25rem 0 0.5rem; color: #16213e; }
150
+ p { margin: 0.75rem 0; }
151
+ a { color: #0f3460; text-decoration: underline; }
152
+ a:hover { color: #533483; }
153
+ strong { font-weight: 600; }
154
+ em { font-style: italic; }
155
+ s { text-decoration: line-through; color: #888; }
156
+ code {
157
+ font-family: 'SF Mono', 'Fira Code', monospace;
158
+ background: #f0f0f0;
159
+ padding: 0.15em 0.35em;
160
+ border-radius: 3px;
161
+ font-size: 0.9em;
162
+ }
163
+ pre {
164
+ background: #1a1a2e;
165
+ color: #e8e8e8;
166
+ padding: 1rem;
167
+ border-radius: 6px;
168
+ overflow-x: auto;
169
+ margin: 1rem 0;
170
+ }
171
+ pre code {
172
+ background: none;
173
+ padding: 0;
174
+ color: inherit;
175
+ font-size: 0.85rem;
176
+ }
177
+ blockquote {
178
+ border-left: 3px solid #533483;
179
+ padding: 0.5rem 1rem;
180
+ margin: 1rem 0;
181
+ color: #555;
182
+ background: #faf8ff;
183
+ border-radius: 0 4px 4px 0;
184
+ }
185
+ ul, ol { margin: 0.75rem 0; padding-left: 1.5rem; }
186
+ li { margin: 0.25rem 0; }
187
+ hr {
188
+ border: none;
189
+ border-top: 1px solid #e0e0e0;
190
+ margin: 1.5rem 0;
191
+ }
192
+ img { max-width: 100%; border-radius: 4px; }
193
+ figure { margin: 1rem 0; }
194
+ figcaption { font-size: 0.85rem; color: #888; text-align: center; margin-top: 0.25rem; }
195
+ .footer {
196
+ margin-top: 3rem;
197
+ padding-top: 1rem;
198
+ border-top: 1px solid #e8e8e8;
199
+ font-size: 0.8rem;
200
+ color: #aaa;
201
+ text-align: center;
202
+ }
203
+ </style>
204
+ </head>
205
+ <body>
206
+ <div class="container">
207
+ <h1>${escapeHtml(view.title)}</h1>
208
+ <div class="meta">${new Date(view.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}${view.updatedAt ? ' (updated)' : ''}</div>
209
+ ${bodyHtml}
210
+ <div class="footer">Served by Instar</div>
211
+ </div>
212
+ </body>
213
+ </html>`;
214
+ }
215
+ save(view) {
216
+ const filePath = path.join(this.viewsDir, `${view.id}.json`);
217
+ fs.writeFileSync(filePath, JSON.stringify(view, null, 2));
218
+ }
219
+ }
220
+ // ── HTML Rendering ─────────────────────────────────────────────────
221
+ function nodesToHtml(nodes) {
222
+ return nodes.map(nodeToHtml).join('');
223
+ }
224
+ function nodeToHtml(node) {
225
+ if (typeof node === 'string') {
226
+ return escapeHtml(node);
227
+ }
228
+ const element = node;
229
+ const tag = element.tag;
230
+ // Self-closing tags
231
+ if (tag === 'br')
232
+ return '<br>';
233
+ if (tag === 'hr')
234
+ return '<hr>';
235
+ if (tag === 'img') {
236
+ const src = element.attrs?.src ? ` src="${escapeAttr(element.attrs.src)}"` : '';
237
+ return `<img${src} alt="">`;
238
+ }
239
+ // Build attributes
240
+ let attrs = '';
241
+ if (element.attrs?.href)
242
+ attrs += ` href="${escapeAttr(element.attrs.href)}"`;
243
+ if (element.attrs?.src)
244
+ attrs += ` src="${escapeAttr(element.attrs.src)}"`;
245
+ const children = element.children ? nodesToHtml(element.children) : '';
246
+ return `<${tag}${attrs}>${children}</${tag}>`;
247
+ }
248
+ function escapeHtml(text) {
249
+ return text
250
+ .replace(/&/g, '&amp;')
251
+ .replace(/</g, '&lt;')
252
+ .replace(/>/g, '&gt;')
253
+ .replace(/"/g, '&quot;');
254
+ }
255
+ function escapeAttr(text) {
256
+ return text
257
+ .replace(/&/g, '&amp;')
258
+ .replace(/"/g, '&quot;')
259
+ .replace(/</g, '&lt;')
260
+ .replace(/>/g, '&gt;');
261
+ }
262
+ //# sourceMappingURL=PrivateViewer.js.map
@@ -49,7 +49,7 @@ export function generateUserMd(userName) {
49
49
 
50
50
  ## About
51
51
 
52
- Primary collaborator and operator of this agent.
52
+ Primary collaborator and partner.
53
53
 
54
54
  ## Communication Preferences
55
55
 
@@ -177,6 +177,22 @@ This routes feedback to the Instar maintainers automatically. Valid types: \`bug
177
177
 
178
178
  **⚠ CRITICAL: All Telegraph pages are PUBLIC.** Anyone with the URL can view the content. There is no authentication or access control. NEVER publish sensitive, private, or confidential information through Telegraph. When sharing a link, always inform the user that the page is publicly accessible.
179
179
 
180
+ **Private Viewing** — Render markdown as auth-gated HTML pages, accessible only through the agent's server (local or via tunnel).
181
+ - Create: \`curl -X POST http://localhost:${port}/view -H 'Content-Type: application/json' -d '{"title":"Report","markdown":"# Private content"}'\`
182
+ - View (HTML): Open \`http://localhost:${port}/view/VIEW_ID\` in a browser
183
+ - List: \`curl http://localhost:${port}/views\`
184
+ - Update: \`curl -X PUT http://localhost:${port}/view/VIEW_ID -H 'Content-Type: application/json' -d '{"title":"Updated","markdown":"# New content"}'\`
185
+ - Delete: \`curl -X DELETE http://localhost:${port}/view/VIEW_ID\`
186
+
187
+ **Use private views for sensitive content. Use Telegraph for public content.**
188
+
189
+ **Cloudflare Tunnel** — Expose the local server to the internet via Cloudflare. Enables remote access to private views, the API, and file serving.
190
+ - Status: \`curl http://localhost:${port}/tunnel\`
191
+ - Configure in \`.instar/config.json\`: \`{"tunnel": {"enabled": true, "type": "quick"}}\`
192
+ - Quick tunnels (default): Zero-config, ephemeral URL (*.trycloudflare.com), no account needed
193
+ - Named tunnels: Persistent custom domain, requires token from Cloudflare dashboard
194
+ - When a tunnel is running, private view responses include a \`tunnelUrl\` field for remote access
195
+
180
196
  **Scripts** — Reusable capabilities in \`.claude/scripts/\`.
181
197
 
182
198
  ### Self-Discovery (Know Before You Claim)
@@ -16,6 +16,8 @@ import type { DispatchManager } from '../core/DispatchManager.js';
16
16
  import type { UpdateChecker } from '../core/UpdateChecker.js';
17
17
  import type { QuotaTracker } from '../monitoring/QuotaTracker.js';
18
18
  import type { TelegraphService } from '../publishing/TelegraphService.js';
19
+ import type { PrivateViewer } from '../publishing/PrivateViewer.js';
20
+ import type { TunnelManager } from '../tunnel/TunnelManager.js';
19
21
  export declare class AgentServer {
20
22
  private app;
21
23
  private server;
@@ -33,6 +35,8 @@ export declare class AgentServer {
33
35
  updateChecker?: UpdateChecker;
34
36
  quotaTracker?: QuotaTracker;
35
37
  publisher?: TelegraphService;
38
+ viewer?: PrivateViewer;
39
+ tunnel?: TunnelManager;
36
40
  });
37
41
  /**
38
42
  * Start the HTTP server.
@@ -34,6 +34,8 @@ export class AgentServer {
34
34
  updateChecker: options.updateChecker ?? null,
35
35
  quotaTracker: options.quotaTracker ?? null,
36
36
  publisher: options.publisher ?? null,
37
+ viewer: options.viewer ?? null,
38
+ tunnel: options.tunnel ?? null,
37
39
  startTime: this.startTime,
38
40
  });
39
41
  this.app.use(routes);
@@ -16,6 +16,8 @@ import type { DispatchManager } from '../core/DispatchManager.js';
16
16
  import type { UpdateChecker } from '../core/UpdateChecker.js';
17
17
  import type { QuotaTracker } from '../monitoring/QuotaTracker.js';
18
18
  import type { TelegraphService } from '../publishing/TelegraphService.js';
19
+ import type { PrivateViewer } from '../publishing/PrivateViewer.js';
20
+ import type { TunnelManager } from '../tunnel/TunnelManager.js';
19
21
  export interface RouteContext {
20
22
  config: InstarConfig;
21
23
  sessionManager: SessionManager;
@@ -28,6 +30,8 @@ export interface RouteContext {
28
30
  updateChecker: UpdateChecker | null;
29
31
  quotaTracker: QuotaTracker | null;
30
32
  publisher: TelegraphService | null;
33
+ viewer: PrivateViewer | null;
34
+ tunnel: TunnelManager | null;
31
35
  startTime: Date;
32
36
  }
33
37
  export declare function createRoutes(ctx: RouteContext): Router;
@@ -155,6 +155,17 @@ export function createRoutes(ctx) {
155
155
  publishing: {
156
156
  enabled: !!ctx.publisher,
157
157
  pageCount: ctx.publisher?.listPages().length ?? 0,
158
+ warning: 'Telegraph pages are PUBLIC — anyone with the URL can view them.',
159
+ },
160
+ privateViewer: {
161
+ enabled: !!ctx.viewer,
162
+ viewCount: ctx.viewer?.list().length ?? 0,
163
+ },
164
+ tunnel: {
165
+ enabled: !!ctx.tunnel,
166
+ running: ctx.tunnel?.isRunning ?? false,
167
+ url: ctx.tunnel?.url ?? null,
168
+ type: ctx.config.tunnel?.type ?? null,
158
169
  },
159
170
  users: {
160
171
  count: userCount,
@@ -867,6 +878,129 @@ export function createRoutes(ctx) {
867
878
  res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
868
879
  }
869
880
  });
881
+ // ── Private Views (auth-gated rendered markdown) ────────────────
882
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
883
+ router.post('/view', (req, res) => {
884
+ if (!ctx.viewer) {
885
+ res.status(503).json({ error: 'Private viewer not configured' });
886
+ return;
887
+ }
888
+ const { title, markdown } = req.body;
889
+ if (!title || typeof title !== 'string' || title.length > 256) {
890
+ res.status(400).json({ error: '"title" must be a string under 256 characters' });
891
+ return;
892
+ }
893
+ if (!markdown || typeof markdown !== 'string') {
894
+ res.status(400).json({ error: '"markdown" must be a non-empty string' });
895
+ return;
896
+ }
897
+ if (markdown.length > 500_000) {
898
+ res.status(400).json({ error: '"markdown" must be under 500KB' });
899
+ return;
900
+ }
901
+ const view = ctx.viewer.create(title, markdown);
902
+ const tunnelUrl = ctx.tunnel?.getExternalUrl(`/view/${view.id}`) ?? null;
903
+ res.status(201).json({
904
+ id: view.id,
905
+ title: view.title,
906
+ localUrl: `/view/${view.id}`,
907
+ tunnelUrl,
908
+ createdAt: view.createdAt,
909
+ });
910
+ });
911
+ router.get('/view/:id', (req, res) => {
912
+ if (!ctx.viewer) {
913
+ res.status(503).json({ error: 'Private viewer not configured' });
914
+ return;
915
+ }
916
+ if (!UUID_RE.test(req.params.id)) {
917
+ res.status(400).json({ error: 'Invalid view ID' });
918
+ return;
919
+ }
920
+ const view = ctx.viewer.get(req.params.id);
921
+ if (!view) {
922
+ res.status(404).json({ error: 'View not found' });
923
+ return;
924
+ }
925
+ // Serve rendered HTML
926
+ const html = ctx.viewer.renderHtml(view);
927
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
928
+ res.send(html);
929
+ });
930
+ router.get('/views', (_req, res) => {
931
+ if (!ctx.viewer) {
932
+ res.json({ views: [] });
933
+ return;
934
+ }
935
+ const views = ctx.viewer.list().map(v => ({
936
+ id: v.id,
937
+ title: v.title,
938
+ localUrl: `/view/${v.id}`,
939
+ tunnelUrl: ctx.tunnel?.getExternalUrl(`/view/${v.id}`) ?? null,
940
+ createdAt: v.createdAt,
941
+ updatedAt: v.updatedAt,
942
+ }));
943
+ res.json({ views });
944
+ });
945
+ router.put('/view/:id', (req, res) => {
946
+ if (!ctx.viewer) {
947
+ res.status(503).json({ error: 'Private viewer not configured' });
948
+ return;
949
+ }
950
+ if (!UUID_RE.test(req.params.id)) {
951
+ res.status(400).json({ error: 'Invalid view ID' });
952
+ return;
953
+ }
954
+ const { title, markdown } = req.body;
955
+ if (!title || typeof title !== 'string' || title.length > 256) {
956
+ res.status(400).json({ error: '"title" must be a string under 256 characters' });
957
+ return;
958
+ }
959
+ if (!markdown || typeof markdown !== 'string') {
960
+ res.status(400).json({ error: '"markdown" must be a non-empty string' });
961
+ return;
962
+ }
963
+ const updated = ctx.viewer.update(req.params.id, title, markdown);
964
+ if (!updated) {
965
+ res.status(404).json({ error: 'View not found' });
966
+ return;
967
+ }
968
+ res.json({
969
+ id: updated.id,
970
+ title: updated.title,
971
+ localUrl: `/view/${updated.id}`,
972
+ tunnelUrl: ctx.tunnel?.getExternalUrl(`/view/${updated.id}`) ?? null,
973
+ updatedAt: updated.updatedAt,
974
+ });
975
+ });
976
+ router.delete('/view/:id', (req, res) => {
977
+ if (!ctx.viewer) {
978
+ res.status(503).json({ error: 'Private viewer not configured' });
979
+ return;
980
+ }
981
+ if (!UUID_RE.test(req.params.id)) {
982
+ res.status(400).json({ error: 'Invalid view ID' });
983
+ return;
984
+ }
985
+ const deleted = ctx.viewer.delete(req.params.id);
986
+ if (!deleted) {
987
+ res.status(404).json({ error: 'View not found' });
988
+ return;
989
+ }
990
+ res.json({ ok: true, deleted: req.params.id });
991
+ });
992
+ // ── Tunnel Status ──────────────────────────────────────────────
993
+ router.get('/tunnel', (_req, res) => {
994
+ if (!ctx.tunnel) {
995
+ res.json({ enabled: false, url: null });
996
+ return;
997
+ }
998
+ res.json({
999
+ enabled: true,
1000
+ running: ctx.tunnel.isRunning,
1001
+ ...ctx.tunnel.state,
1002
+ });
1003
+ });
870
1004
  // ── Events ──────────────────────────────────────────────────────
871
1005
  router.get('/events', (req, res) => {
872
1006
  const rawLimit = parseInt(req.query.limit, 10) || 50;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Cloudflare Tunnel manager for Instar agents.
3
+ *
4
+ * Manages cloudflared tunnel lifecycle — quick tunnels (zero-config,
5
+ * ephemeral) and named tunnels (persistent, custom domain).
6
+ *
7
+ * Quick tunnels require no Cloudflare account. Named tunnels require
8
+ * a tunnel token from the Cloudflare dashboard.
9
+ *
10
+ * The tunnel exposes the agent's local HTTP server to the internet,
11
+ * enabling:
12
+ * - Private content viewing (auth-gated rendered markdown)
13
+ * - Remote API access from anywhere
14
+ * - File serving (logs, reports, exports)
15
+ * - Webhook endpoints for external integrations
16
+ */
17
+ import { EventEmitter } from 'node:events';
18
+ export interface TunnelConfig {
19
+ /** Whether tunnel is enabled */
20
+ enabled: boolean;
21
+ /** Tunnel type: 'quick' (ephemeral, no account) or 'named' (persistent, requires token) */
22
+ type: 'quick' | 'named';
23
+ /** Cloudflare tunnel token (required for named tunnels) */
24
+ token?: string;
25
+ /** Local port to tunnel to */
26
+ port: number;
27
+ /** State directory for persisting tunnel info */
28
+ stateDir: string;
29
+ }
30
+ export interface TunnelState {
31
+ /** Current tunnel URL (null if not connected) */
32
+ url: string | null;
33
+ /** Tunnel type */
34
+ type: 'quick' | 'named';
35
+ /** When the tunnel was started */
36
+ startedAt: string | null;
37
+ /** Connection info from cloudflared */
38
+ connectionId?: string;
39
+ /** Connection location */
40
+ connectionLocation?: string;
41
+ }
42
+ export interface TunnelEvents {
43
+ url: (url: string) => void;
44
+ connected: (info: {
45
+ id: string;
46
+ ip: string;
47
+ location: string;
48
+ }) => void;
49
+ disconnected: () => void;
50
+ error: (error: Error) => void;
51
+ stopped: () => void;
52
+ }
53
+ export declare class TunnelManager extends EventEmitter {
54
+ private config;
55
+ private tunnel;
56
+ private stateFile;
57
+ private _state;
58
+ private _stopped;
59
+ constructor(config: TunnelConfig);
60
+ /** Current tunnel URL, or null if not connected */
61
+ get url(): string | null;
62
+ /** Whether the tunnel is currently running */
63
+ get isRunning(): boolean;
64
+ /** Current tunnel state */
65
+ get state(): TunnelState;
66
+ /**
67
+ * Start the tunnel. Ensures the cloudflared binary is installed,
68
+ * then starts the appropriate tunnel type.
69
+ */
70
+ start(): Promise<string>;
71
+ /**
72
+ * Stop the tunnel gracefully.
73
+ */
74
+ stop(): Promise<void>;
75
+ /**
76
+ * Get the full external URL for a local path.
77
+ * Returns null if tunnel is not connected.
78
+ */
79
+ getExternalUrl(localPath: string): string | null;
80
+ private ensureBinary;
81
+ private startQuickTunnel;
82
+ private startNamedTunnel;
83
+ private saveState;
84
+ }
85
+ //# sourceMappingURL=TunnelManager.d.ts.map
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Cloudflare Tunnel manager for Instar agents.
3
+ *
4
+ * Manages cloudflared tunnel lifecycle — quick tunnels (zero-config,
5
+ * ephemeral) and named tunnels (persistent, custom domain).
6
+ *
7
+ * Quick tunnels require no Cloudflare account. Named tunnels require
8
+ * a tunnel token from the Cloudflare dashboard.
9
+ *
10
+ * The tunnel exposes the agent's local HTTP server to the internet,
11
+ * enabling:
12
+ * - Private content viewing (auth-gated rendered markdown)
13
+ * - Remote API access from anywhere
14
+ * - File serving (logs, reports, exports)
15
+ * - Webhook endpoints for external integrations
16
+ */
17
+ import { EventEmitter } from 'node:events';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { bin, install, Tunnel } from 'cloudflared';
21
+ // ── Manager ────────────────────────────────────────────────────────
22
+ export class TunnelManager extends EventEmitter {
23
+ config;
24
+ tunnel = null;
25
+ stateFile;
26
+ _state;
27
+ _stopped = false;
28
+ constructor(config) {
29
+ super();
30
+ this.config = config;
31
+ this.stateFile = path.join(config.stateDir, 'tunnel.json');
32
+ this._state = {
33
+ url: null,
34
+ type: config.type,
35
+ startedAt: null,
36
+ };
37
+ }
38
+ /** Current tunnel URL, or null if not connected */
39
+ get url() {
40
+ return this._state.url;
41
+ }
42
+ /** Whether the tunnel is currently running */
43
+ get isRunning() {
44
+ return this.tunnel !== null && !this._stopped;
45
+ }
46
+ /** Current tunnel state */
47
+ get state() {
48
+ return { ...this._state };
49
+ }
50
+ /**
51
+ * Start the tunnel. Ensures the cloudflared binary is installed,
52
+ * then starts the appropriate tunnel type.
53
+ */
54
+ async start() {
55
+ if (this.tunnel) {
56
+ throw new Error('Tunnel is already running');
57
+ }
58
+ this._stopped = false;
59
+ // Ensure cloudflared binary is installed
60
+ await this.ensureBinary();
61
+ // Start the appropriate tunnel type
62
+ if (this.config.type === 'named') {
63
+ if (!this.config.token) {
64
+ throw new Error('Named tunnel requires a token. Set tunnel.token in config.');
65
+ }
66
+ return this.startNamedTunnel();
67
+ }
68
+ else {
69
+ return this.startQuickTunnel();
70
+ }
71
+ }
72
+ /**
73
+ * Stop the tunnel gracefully.
74
+ */
75
+ async stop() {
76
+ this._stopped = true;
77
+ if (this.tunnel) {
78
+ this.tunnel.stop();
79
+ this.tunnel = null;
80
+ }
81
+ this._state.url = null;
82
+ this._state.startedAt = null;
83
+ this.saveState();
84
+ this.emit('stopped');
85
+ }
86
+ /**
87
+ * Get the full external URL for a local path.
88
+ * Returns null if tunnel is not connected.
89
+ */
90
+ getExternalUrl(localPath) {
91
+ if (!this._state.url)
92
+ return null;
93
+ const base = this._state.url.replace(/\/$/, '');
94
+ const p = localPath.startsWith('/') ? localPath : `/${localPath}`;
95
+ return `${base}${p}`;
96
+ }
97
+ // ── Internal ───────────────────────────────────────────────────
98
+ async ensureBinary() {
99
+ if (!fs.existsSync(bin)) {
100
+ await install(bin);
101
+ }
102
+ }
103
+ startQuickTunnel() {
104
+ return new Promise((resolve, reject) => {
105
+ const localUrl = `http://localhost:${this.config.port}`;
106
+ try {
107
+ this.tunnel = Tunnel.quick(localUrl);
108
+ }
109
+ catch (err) {
110
+ reject(new Error(`Failed to start quick tunnel: ${err instanceof Error ? err.message : String(err)}`));
111
+ return;
112
+ }
113
+ let resolved = false;
114
+ const timeout = setTimeout(() => {
115
+ if (!resolved) {
116
+ resolved = true;
117
+ reject(new Error('Tunnel connection timed out after 30 seconds'));
118
+ }
119
+ }, 30_000);
120
+ this.tunnel.once('url', (url) => {
121
+ if (resolved)
122
+ return;
123
+ resolved = true;
124
+ clearTimeout(timeout);
125
+ this._state.url = url;
126
+ this._state.startedAt = new Date().toISOString();
127
+ this.saveState();
128
+ this.emit('url', url);
129
+ resolve(url);
130
+ });
131
+ this.tunnel.once('connected', (info) => {
132
+ this._state.connectionId = info.id;
133
+ this._state.connectionLocation = info.location;
134
+ this.saveState();
135
+ this.emit('connected', info);
136
+ });
137
+ this.tunnel.on('error', (err) => {
138
+ if (!resolved) {
139
+ resolved = true;
140
+ clearTimeout(timeout);
141
+ reject(err);
142
+ }
143
+ this.emit('error', err);
144
+ });
145
+ this.tunnel.on('exit', (code) => {
146
+ if (!this._stopped) {
147
+ this._state.url = null;
148
+ this.saveState();
149
+ this.emit('disconnected');
150
+ if (!resolved) {
151
+ resolved = true;
152
+ clearTimeout(timeout);
153
+ reject(new Error(`Tunnel process exited with code ${code}`));
154
+ }
155
+ }
156
+ });
157
+ });
158
+ }
159
+ startNamedTunnel() {
160
+ return new Promise((resolve, reject) => {
161
+ try {
162
+ this.tunnel = Tunnel.withToken(this.config.token);
163
+ }
164
+ catch (err) {
165
+ reject(new Error(`Failed to start named tunnel: ${err instanceof Error ? err.message : String(err)}`));
166
+ return;
167
+ }
168
+ let resolved = false;
169
+ const timeout = setTimeout(() => {
170
+ if (!resolved) {
171
+ resolved = true;
172
+ reject(new Error('Named tunnel connection timed out after 30 seconds'));
173
+ }
174
+ }, 30_000);
175
+ this.tunnel.once('url', (url) => {
176
+ if (resolved)
177
+ return;
178
+ resolved = true;
179
+ clearTimeout(timeout);
180
+ this._state.url = url;
181
+ this._state.startedAt = new Date().toISOString();
182
+ this.saveState();
183
+ this.emit('url', url);
184
+ resolve(url);
185
+ });
186
+ this.tunnel.once('connected', (info) => {
187
+ this._state.connectionId = info.id;
188
+ this._state.connectionLocation = info.location;
189
+ this.saveState();
190
+ this.emit('connected', info);
191
+ // For named tunnels, the URL may come from the connection info
192
+ // rather than the 'url' event, since the URL is pre-configured
193
+ if (!resolved && this._state.url) {
194
+ resolved = true;
195
+ clearTimeout(timeout);
196
+ resolve(this._state.url);
197
+ }
198
+ });
199
+ this.tunnel.on('error', (err) => {
200
+ if (!resolved) {
201
+ resolved = true;
202
+ clearTimeout(timeout);
203
+ reject(err);
204
+ }
205
+ this.emit('error', err);
206
+ });
207
+ this.tunnel.on('exit', (code) => {
208
+ if (!this._stopped) {
209
+ this._state.url = null;
210
+ this.saveState();
211
+ this.emit('disconnected');
212
+ if (!resolved) {
213
+ resolved = true;
214
+ clearTimeout(timeout);
215
+ reject(new Error(`Named tunnel process exited with code ${code}`));
216
+ }
217
+ }
218
+ });
219
+ });
220
+ }
221
+ saveState() {
222
+ try {
223
+ const dir = path.dirname(this.stateFile);
224
+ if (!fs.existsSync(dir)) {
225
+ fs.mkdirSync(dir, { recursive: true });
226
+ }
227
+ fs.writeFileSync(this.stateFile, JSON.stringify(this._state, null, 2));
228
+ }
229
+ catch {
230
+ // Non-critical — don't crash if state save fails
231
+ }
232
+ }
233
+ }
234
+ //# sourceMappingURL=TunnelManager.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@inquirer/prompts": "^8.2.1",
54
+ "cloudflared": "^0.7.1",
54
55
  "commander": "^12.0.0",
55
56
  "croner": "^8.0.0",
56
57
  "express": "^4.18.0",