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.
- package/.claude/skills/setup-wizard/skill.md +20 -18
- package/README.md +25 -39
- package/dist/commands/init.js +2 -2
- package/dist/commands/server.js +32 -1
- package/dist/commands/setup.js +22 -8
- package/dist/core/Config.js +2 -0
- package/dist/core/types.d.ts +10 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/publishing/PrivateViewer.d.ts +53 -0
- package/dist/publishing/PrivateViewer.js +262 -0
- package/dist/scaffold/templates.js +17 -1
- package/dist/server/AgentServer.d.ts +4 -0
- package/dist/server/AgentServer.js +2 -0
- package/dist/server/routes.d.ts +4 -0
- package/dist/server/routes.js +134 -0
- package/dist/tunnel/TunnelManager.d.ts +85 -0
- package/dist/tunnel/TunnelManager.js +234 -0
- package/package.json +2 -1
|
@@ -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
|
|
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
|
|
249
|
+
### 3c. Telegram Setup — The Destination
|
|
250
250
|
|
|
251
|
-
**
|
|
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
|
|
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
|
-
> - **
|
|
256
|
-
> - **
|
|
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
|
-
|
|
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 **
|
|
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
|
-
|
|
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 &
|
|
450
|
+
## Phase 4: Summary & Launch
|
|
449
451
|
|
|
450
|
-
Show what was created briefly, then
|
|
452
|
+
Show what was created briefly, then get the user to their agent.
|
|
451
453
|
|
|
452
|
-
**
|
|
454
|
+
**If Telegram was configured — this is the moment:**
|
|
453
455
|
|
|
454
|
-
|
|
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
|
-
|
|
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
|
|
460
|
+
**If Telegram was NOT configured:**
|
|
459
461
|
|
|
460
|
-
> "Start the server with `instar server start`. You can
|
|
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
|
|
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
|
-
##
|
|
37
|
+
## Getting Started
|
|
38
38
|
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
47
|
+
### Two configurations
|
|
52
48
|
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
│
|
|
122
|
-
│
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
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
|
package/dist/commands/init.js
CHANGED
|
@@ -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** —
|
|
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** —
|
|
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
|
|
package/dist/commands/server.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
|
188
|
-
console.log(pc.dim(' Telegram is the
|
|
189
|
-
console.log(pc.dim('
|
|
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('
|
|
368
|
+
console.log(' To start the server:');
|
|
363
369
|
console.log(` ${pc.cyan('instar server start')}`);
|
|
364
370
|
console.log();
|
|
365
|
-
|
|
366
|
-
|
|
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? (
|
|
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.'));
|
package/dist/core/Config.js
CHANGED
|
@@ -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) {
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
|
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, '&')
|
|
251
|
+
.replace(/</g, '<')
|
|
252
|
+
.replace(/>/g, '>')
|
|
253
|
+
.replace(/"/g, '"');
|
|
254
|
+
}
|
|
255
|
+
function escapeAttr(text) {
|
|
256
|
+
return text
|
|
257
|
+
.replace(/&/g, '&')
|
|
258
|
+
.replace(/"/g, '"')
|
|
259
|
+
.replace(/</g, '<')
|
|
260
|
+
.replace(/>/g, '>');
|
|
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
|
|
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);
|
package/dist/server/routes.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/routes.js
CHANGED
|
@@ -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.
|
|
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",
|