instar 0.8.7 → 0.8.9

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.
@@ -0,0 +1,11 @@
1
+ > Why do I have a folder named ".vercel" in my project?
2
+ The ".vercel" folder is created when you link a directory to a Vercel project.
3
+
4
+ > What does the "project.json" file contain?
5
+ The "project.json" file contains:
6
+ - The ID of the Vercel project that you linked ("projectId")
7
+ - The ID of the user or team your Vercel project is owned by ("orgId")
8
+
9
+ > Should I commit the ".vercel" folder?
10
+ No, you should not share the ".vercel" folder with anyone.
11
+ Upon creation, it will be automatically added to your ".gitignore" file.
@@ -0,0 +1 @@
1
+ {"projectId":"prj_evM5LcItYL3IAmw8zNvEPGrHeaya","orgId":"team_dHctwIDcV3X9ydapQlCPHFGI","projectName":"claude-agent-kit"}
@@ -401,11 +401,149 @@
401
401
  margin-top: 8px;
402
402
  }
403
403
 
404
+ /* Mobile back button */
405
+ .back-btn {
406
+ display: none;
407
+ padding: 6px 12px;
408
+ border-radius: 6px;
409
+ border: 1px solid var(--border);
410
+ background: var(--bg);
411
+ color: var(--text);
412
+ font-size: 13px;
413
+ cursor: pointer;
414
+ margin-right: 8px;
415
+ }
416
+
417
+ .back-btn:hover { background: var(--bg-hover); }
418
+
404
419
  /* Scrollbar */
405
420
  ::-webkit-scrollbar { width: 6px; }
406
421
  ::-webkit-scrollbar-track { background: transparent; }
407
422
  ::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
408
423
  ::-webkit-scrollbar-thumb:hover { background: #444; }
424
+
425
+ /* ── Mobile responsive ─────────────────────────────────── */
426
+ @media (max-width: 768px) {
427
+ .app {
428
+ grid-template-columns: 1fr;
429
+ }
430
+
431
+ .sidebar {
432
+ border-right: none;
433
+ border-bottom: 1px solid var(--border);
434
+ }
435
+
436
+ /* In mobile, sidebar and main toggle visibility */
437
+ .app.terminal-active .sidebar {
438
+ display: none;
439
+ }
440
+
441
+ .app:not(.terminal-active) .main {
442
+ display: none;
443
+ }
444
+
445
+ .app.terminal-active .back-btn {
446
+ display: inline-block;
447
+ }
448
+
449
+ /* Larger tap targets */
450
+ .session-item {
451
+ padding: 14px 12px;
452
+ margin-bottom: 4px;
453
+ }
454
+
455
+ .session-name {
456
+ font-size: 15px;
457
+ }
458
+
459
+ .session-meta {
460
+ font-size: 12px;
461
+ margin-top: 4px;
462
+ }
463
+
464
+ .action-btn {
465
+ padding: 8px 14px;
466
+ font-size: 14px;
467
+ }
468
+
469
+ .terminal-actions {
470
+ gap: 4px;
471
+ flex-wrap: wrap;
472
+ }
473
+
474
+ .terminal-header {
475
+ padding: 8px 12px;
476
+ flex-wrap: wrap;
477
+ gap: 8px;
478
+ }
479
+
480
+ .terminal-header .session-info {
481
+ flex: 1;
482
+ min-width: 0;
483
+ }
484
+
485
+ .terminal-header .session-info h3 {
486
+ font-size: 13px;
487
+ white-space: nowrap;
488
+ overflow: hidden;
489
+ text-overflow: ellipsis;
490
+ }
491
+
492
+ /* Input bar — stack on very small screens */
493
+ .input-bar {
494
+ padding: 8px;
495
+ gap: 6px;
496
+ }
497
+
498
+ .input-bar input {
499
+ font-size: 16px; /* Prevents iOS zoom on focus */
500
+ padding: 10px 12px;
501
+ }
502
+
503
+ .input-bar button {
504
+ padding: 10px 14px;
505
+ font-size: 14px;
506
+ }
507
+
508
+ /* Terminal font smaller on mobile */
509
+ .terminal-container {
510
+ padding: 2px;
511
+ }
512
+
513
+ /* Auth box responsive */
514
+ .auth-box {
515
+ width: calc(100vw - 32px);
516
+ max-width: 360px;
517
+ padding: 24px;
518
+ }
519
+
520
+ .auth-box input {
521
+ font-size: 16px; /* Prevents iOS zoom */
522
+ padding: 12px 14px;
523
+ }
524
+
525
+ .auth-box button {
526
+ padding: 12px;
527
+ font-size: 15px;
528
+ }
529
+
530
+ /* Header compact */
531
+ .header {
532
+ padding: 10px 14px;
533
+ }
534
+
535
+ .header h1 {
536
+ font-size: 15px;
537
+ }
538
+
539
+ .header .logo {
540
+ font-size: 18px;
541
+ }
542
+
543
+ .sidebar-header {
544
+ padding: 12px 14px;
545
+ }
546
+ }
409
547
  </style>
410
548
  </head>
411
549
  <body>
@@ -413,8 +551,8 @@
413
551
  <div class="auth-overlay" id="authOverlay">
414
552
  <div class="auth-box">
415
553
  <h2>Instar Dashboard</h2>
416
- <p>Enter your auth token to connect</p>
417
- <input type="password" id="tokenInput" placeholder="Bearer token..." autofocus>
554
+ <p>Enter your PIN to connect</p>
555
+ <input type="password" id="pinInput" placeholder="PIN..." autofocus inputmode="numeric">
418
556
  <button onclick="authenticate()">Connect</button>
419
557
  <div class="auth-error" id="authError" style="display:none"></div>
420
558
  </div>
@@ -455,6 +593,7 @@
455
593
  <div id="terminalView" style="display:none">
456
594
  <div class="terminal-header">
457
595
  <div class="session-info">
596
+ <button class="back-btn" onclick="goBack()" title="Back to sessions">&larr;</button>
458
597
  <h3 id="termSessionName">—</h3>
459
598
  <span class="model-badge" id="termModelBadge" style="display:none"></span>
460
599
  </div>
@@ -488,17 +627,35 @@
488
627
 
489
628
  // ── Auth ─────────────────────────────────────────────────
490
629
  function authenticate() {
491
- token = document.getElementById('tokenInput').value.trim();
492
- if (!token) {
493
- showAuthError('Please enter a token');
630
+ const pin = document.getElementById('pinInput').value.trim();
631
+ if (!pin) {
632
+ showAuthError('Please enter a PIN');
494
633
  return;
495
634
  }
496
635
 
497
- // Test token by hitting /health with auth
498
- const port = window.location.port || location.port;
499
- fetch(`/health`, {
500
- headers: { 'Authorization': `Bearer ${token}` }
636
+ // Try PIN-based unlock first, fall back to direct token auth
637
+ fetch('/dashboard/unlock', {
638
+ method: 'POST',
639
+ headers: { 'Content-Type': 'application/json' },
640
+ body: JSON.stringify({ pin }),
501
641
  })
642
+ .then(r => {
643
+ if (r.ok) return r.json();
644
+ if (r.status === 429) throw new Error('Too many attempts. Try again later.');
645
+ if (r.status === 403) return r.json().then(d => { throw new Error(d.error || 'Incorrect PIN'); });
646
+ // No PIN endpoint (404) — try as raw token
647
+ return null;
648
+ })
649
+ .then(data => {
650
+ if (data && data.token) {
651
+ token = data.token;
652
+ } else {
653
+ // Fall back: treat input as raw Bearer token
654
+ token = pin;
655
+ }
656
+ // Verify the token works
657
+ return fetch('/health', { headers: { 'Authorization': `Bearer ${token}` } });
658
+ })
502
659
  .then(r => {
503
660
  if (r.ok) {
504
661
  localStorage.setItem('instar_token', token);
@@ -506,10 +663,10 @@
506
663
  document.getElementById('app').style.display = 'grid';
507
664
  connectWebSocket();
508
665
  } else {
509
- showAuthError('Invalid token');
666
+ showAuthError('Incorrect PIN');
510
667
  }
511
668
  })
512
- .catch(() => showAuthError('Cannot connect to server'));
669
+ .catch(err => showAuthError(err.message || 'Cannot connect to server'));
513
670
  }
514
671
 
515
672
  function showAuthError(msg) {
@@ -522,7 +679,7 @@
522
679
  const stored = localStorage.getItem('instar_token');
523
680
  if (stored) {
524
681
  token = stored;
525
- fetch(`/health`, { headers: { 'Authorization': `Bearer ${token}` } })
682
+ fetch('/health', { headers: { 'Authorization': `Bearer ${token}` } })
526
683
  .then(r => {
527
684
  if (r.ok) {
528
685
  document.getElementById('authOverlay').style.display = 'none';
@@ -533,8 +690,8 @@
533
690
  .catch(() => {});
534
691
  }
535
692
 
536
- // Enter on token input
537
- document.getElementById('tokenInput').addEventListener('keydown', e => {
693
+ // Enter on PIN input
694
+ document.getElementById('pinInput').addEventListener('keydown', e => {
538
695
  if (e.key === 'Enter') authenticate();
539
696
  });
540
697
 
@@ -674,7 +831,7 @@
674
831
  }
675
832
 
676
833
  const elapsed = formatElapsed(session.startedAt);
677
- const model = session.model || 'sonnet';
834
+ const model = session.model || 'opus';
678
835
  const isActive = session.tmuxSession === activeSession;
679
836
 
680
837
  el.className = `session-item${isActive ? ' active' : ''}`;
@@ -698,6 +855,9 @@
698
855
 
699
856
  activeSession = tmuxSession;
700
857
 
858
+ // Mobile: show terminal, hide sidebar
859
+ document.getElementById('app').classList.add('terminal-active');
860
+
701
861
  // Update UI
702
862
  document.getElementById('noSession').style.display = 'none';
703
863
  document.getElementById('terminalView').style.display = 'flex';
@@ -757,7 +917,7 @@
757
917
  brightCyan: '#99f6e4',
758
918
  brightWhite: '#eee',
759
919
  },
760
- fontSize: 13,
920
+ fontSize: window.innerWidth <= 768 ? 11 : 13,
761
921
  fontFamily: "'SF Mono', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace",
762
922
  cursorBlink: false,
763
923
  cursorStyle: 'underline',
@@ -790,6 +950,18 @@
790
950
  term.scrollToBottom();
791
951
  }
792
952
 
953
+ function goBack() {
954
+ // Mobile: go back to session list
955
+ if (activeSession) {
956
+ wsSend({ type: 'unsubscribe', session: activeSession });
957
+ }
958
+ activeSession = null;
959
+ document.getElementById('app').classList.remove('terminal-active');
960
+ document.getElementById('terminalView').style.display = 'none';
961
+ document.getElementById('noSession').style.display = 'flex';
962
+ renderSessionList();
963
+ }
964
+
793
965
  // ── Input ────────────────────────────────────────────────
794
966
  function sendInput() {
795
967
  const input = document.getElementById('termInput');
package/dist/cli.js CHANGED
@@ -443,7 +443,7 @@ jobCmd
443
443
  .requiredOption('--schedule <cron>', 'Cron expression')
444
444
  .option('--description <desc>', 'Job description')
445
445
  .option('--priority <priority>', 'Priority (critical|high|medium|low)', 'medium')
446
- .option('--model <model>', 'Model tier (opus|sonnet|haiku)', 'sonnet')
446
+ .option('--model <model>', 'Model tier (opus|sonnet|haiku)', 'opus')
447
447
  .option('--type <type>', 'Execution type (skill|prompt|script)', 'prompt')
448
448
  .option('--execute <value>', 'Execution value (skill name, prompt text, or script path)')
449
449
  .action(addJob);
@@ -970,7 +970,7 @@ function getDefaultJobs(port) {
970
970
  schedule: '0 */4 * * *',
971
971
  priority: 'medium',
972
972
  expectedDurationMinutes: 5,
973
- model: 'sonnet',
973
+ model: 'opus',
974
974
  enabled: true,
975
975
  execute: {
976
976
  type: 'prompt',
@@ -985,7 +985,7 @@ function getDefaultJobs(port) {
985
985
  schedule: '0 9 * * *',
986
986
  priority: 'low',
987
987
  expectedDurationMinutes: 3,
988
- model: 'sonnet',
988
+ model: 'opus',
989
989
  enabled: true,
990
990
  execute: {
991
991
  type: 'prompt',
@@ -1048,7 +1048,7 @@ function getDefaultJobs(port) {
1048
1048
  schedule: '0 */2 * * *',
1049
1049
  priority: 'medium',
1050
1050
  expectedDurationMinutes: 3,
1051
- model: 'sonnet',
1051
+ model: 'opus',
1052
1052
  enabled: true,
1053
1053
  gate: `curl -sf http://localhost:${port}/health >/dev/null 2>&1`,
1054
1054
  execute: {
@@ -1088,7 +1088,7 @@ If everything looks healthy, exit silently. Only report issues.`,
1088
1088
  schedule: '0 */6 * * *',
1089
1089
  priority: 'medium',
1090
1090
  expectedDurationMinutes: 5,
1091
- model: 'sonnet',
1091
+ model: 'opus',
1092
1092
  enabled: true,
1093
1093
  gate: `curl -sf http://localhost:${port}/evolution/proposals?status=proposed 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if len(d.get('proposals',[])) > 0 else 1)"`,
1094
1094
  execute: {
@@ -1117,7 +1117,7 @@ If no proposals need attention, exit silently.`,
1117
1117
  schedule: '0 */8 * * *',
1118
1118
  priority: 'low',
1119
1119
  expectedDurationMinutes: 3,
1120
- model: 'sonnet',
1120
+ model: 'opus',
1121
1121
  enabled: true,
1122
1122
  gate: `curl -sf http://localhost:${port}/evolution/learnings?applied=false 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if len(d.get('learnings',[])) > 0 else 1)"`,
1123
1123
  execute: {
@@ -26,7 +26,7 @@ export async function addJob(options) {
26
26
  schedule: options.schedule,
27
27
  priority: (options.priority || 'medium'),
28
28
  expectedDurationMinutes: 5,
29
- model: (options.model || 'sonnet'),
29
+ model: (options.model || 'opus'),
30
30
  enabled: options.enabled !== false,
31
31
  execute: {
32
32
  type: (options.type || 'prompt'),
@@ -163,6 +163,7 @@ export function loadConfig(projectDir) {
163
163
  healthCheckIntervalMs: 30000,
164
164
  },
165
165
  authToken: fileConfig.authToken,
166
+ dashboardPin: fileConfig.dashboardPin,
166
167
  relationships: fileConfig.relationships || {
167
168
  relationshipsDir: path.join(stateDir, 'relationships'),
168
169
  maxRecentInteractions: 20,
@@ -135,11 +135,6 @@ When user input starts with \`[telegram:N]\` (e.g., \`[telegram:26] hello\`), th
135
135
 
136
136
  **IMMEDIATE ACKNOWLEDGMENT (MANDATORY):** When you receive a Telegram message, your FIRST action — before reading files, searching code, or doing any work — must be sending a brief acknowledgment back. This confirms the message was received and you haven't stalled. Examples: "Got it, looking into this now." / "On it — checking the scheduler." / "Received, working on the sync." Then do the work, then send the full response.
137
137
 
138
- **Message types:**
139
- - **Text**: \`[telegram:26] hello there\` — standard text message
140
- - **Voice**: \`[telegram:26] [voice] transcribed text here\` — voice message, already transcribed
141
- - **Photo**: \`[telegram:26] [image:/path/to/file.jpg]\` or \`[telegram:26] [image:/path/to/file.jpg] caption text\` — use the Read tool to view the image at the given path
142
-
143
138
  **Response relay:** After completing your work, relay your response back:
144
139
 
145
140
  \`\`\`bash
@@ -168,17 +163,6 @@ Strip the \`[telegram:N]\` prefix before interpreting the message. Respond natur
168
163
  }
169
164
  }
170
165
  }
171
- // Upgrade existing Telegram Relay sections to document image message format
172
- if (this.config.hasTelegram && content.includes('Telegram Relay') && !content.includes('[image:')) {
173
- const imageBlock = `\n**Message types:**\n- **Text**: \`[telegram:N] hello there\` — standard text message\n- **Voice**: \`[telegram:N] [voice] transcribed text here\` — voice message, already transcribed\n- **Photo**: \`[telegram:N] [image:/path/to/file.jpg]\` or with caption — use the Read tool to view the image at the given path\n`;
174
- // Insert before the Response relay section
175
- const relayIdx = content.indexOf('**Response relay:**');
176
- if (relayIdx >= 0) {
177
- content = content.slice(0, relayIdx) + imageBlock + '\n' + content.slice(relayIdx);
178
- patched = true;
179
- result.upgraded.push('CLAUDE.md: added image/photo message format to Telegram Relay');
180
- }
181
- }
182
166
  // Private Viewer + Tunnel section
183
167
  if (!content.includes('Private Viewing') && !content.includes('POST /view')) {
184
168
  const section = `
@@ -615,6 +615,8 @@ export interface InstarConfig {
615
615
  monitoring: MonitoringConfig;
616
616
  /** Auth token for API access (generated during setup) */
617
617
  authToken?: string;
618
+ /** PIN for dashboard web access (simpler than authToken, used for mobile/remote login) */
619
+ dashboardPin?: string;
618
620
  /** Relationship tracking config */
619
621
  relationships?: RelationshipManagerConfig;
620
622
  /** Feedback loop config */
@@ -38,14 +38,6 @@ export declare class TelegramLifeline {
38
38
  start(): Promise<void>;
39
39
  private poll;
40
40
  private processUpdate;
41
- /**
42
- * Handle an incoming photo message: download it and forward/queue with [image:path] content.
43
- */
44
- private handlePhotoMessage;
45
- /**
46
- * Download a photo from Telegram and save it to the state directory.
47
- */
48
- private downloadPhoto;
49
41
  /**
50
42
  * Forward a message to the Instar server's Telegram webhook.
51
43
  */
@@ -233,14 +233,7 @@ export class TelegramLifeline {
233
233
  }
234
234
  async processUpdate(update) {
235
235
  const msg = update.message;
236
- if (!msg)
237
- return;
238
- // Handle photo messages
239
- if (msg.photo && msg.photo.length > 0 && !msg.text) {
240
- await this.handlePhotoMessage(msg);
241
- return;
242
- }
243
- if (!msg.text)
236
+ if (!msg || !msg.text)
244
237
  return;
245
238
  const topicId = msg.message_thread_id ?? 1;
246
239
  const text = msg.text;
@@ -283,73 +276,6 @@ export class TelegramLifeline {
283
276
  // Notify user that message is queued
284
277
  await this.sendToTopic(topicId, `Server is temporarily down. Your message has been queued (${this.queue.length} in queue). It will be delivered when the server recovers.`);
285
278
  }
286
- /**
287
- * Handle an incoming photo message: download it and forward/queue with [image:path] content.
288
- */
289
- async handlePhotoMessage(msg) {
290
- const topicId = msg.message_thread_id ?? 1;
291
- const photos = msg.photo;
292
- const photo = photos[photos.length - 1]; // highest resolution
293
- const caption = msg.caption ?? '';
294
- let content;
295
- let photoPath;
296
- try {
297
- photoPath = await this.downloadPhoto(photo.file_id, msg.message_id);
298
- content = caption ? `[image:${photoPath}] ${caption}` : `[image:${photoPath}]`;
299
- }
300
- catch (err) {
301
- // Download failed — forward caption or placeholder so message isn't silently dropped
302
- content = caption ? `[image:download-failed] ${caption}` : '[image:download-failed]';
303
- console.error(`[lifeline] Failed to download photo: ${err}`);
304
- }
305
- if (this.supervisor.healthy) {
306
- const forwarded = await this.forwardToServer(topicId, content, msg);
307
- if (forwarded) {
308
- await this.sendToTopic(topicId, '✓ Delivered');
309
- return;
310
- }
311
- }
312
- // Queue the photo message (server down or forward failed)
313
- this.queue.enqueue({
314
- id: `tg-${msg.message_id}`,
315
- topicId,
316
- text: content,
317
- fromUserId: msg.from.id,
318
- fromUsername: msg.from.username,
319
- fromFirstName: msg.from.first_name,
320
- timestamp: new Date(msg.date * 1000).toISOString(),
321
- photoPath,
322
- });
323
- if (this.supervisor.healthy) {
324
- await this.sendToTopic(topicId, `Server is restarting. Your photo has been queued (${this.queue.length} in queue). It will be delivered when the server recovers.`);
325
- }
326
- else {
327
- await this.sendToTopic(topicId, `Server is temporarily down. Your photo has been queued (${this.queue.length} in queue). It will be delivered when the server recovers.`);
328
- }
329
- }
330
- /**
331
- * Download a photo from Telegram and save it to the state directory.
332
- */
333
- async downloadPhoto(fileId, messageId) {
334
- // Get file path from Telegram
335
- const infoRes = await fetch(`https://api.telegram.org/bot${this.config.token}/getFile?file_id=${encodeURIComponent(fileId)}`);
336
- if (!infoRes.ok)
337
- throw new Error(`getFile failed: ${infoRes.status}`);
338
- const infoData = await infoRes.json();
339
- if (!infoData.ok || !infoData.result?.file_path)
340
- throw new Error('getFile returned no path');
341
- const filePath = infoData.result.file_path;
342
- const photoDir = path.join(this.projectConfig.stateDir, 'telegram-images');
343
- fs.mkdirSync(photoDir, { recursive: true });
344
- const filename = `photo-${Date.now()}-${messageId}.jpg`;
345
- const localPath = path.join(photoDir, filename);
346
- const fileRes = await fetch(`https://api.telegram.org/file/bot${this.config.token}/${filePath}`);
347
- if (!fileRes.ok)
348
- throw new Error(`File download failed: ${fileRes.status}`);
349
- const buf = Buffer.from(await fileRes.arrayBuffer());
350
- fs.writeFileSync(localPath, buf);
351
- return localPath;
352
- }
353
279
  /**
354
280
  * Forward a message to the Instar server's Telegram webhook.
355
281
  */
@@ -10,6 +10,7 @@
10
10
  import express from 'express';
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
+ import { createHash, timingSafeEqual } from 'node:crypto';
13
14
  import { fileURLToPath } from 'node:url';
14
15
  import { createRoutes } from './routes.js';
15
16
  import { corsMiddleware, authMiddleware, requestTimeout, errorHandler } from './middleware.js';
@@ -38,6 +39,50 @@ export class AgentServer {
38
39
  res.sendFile(path.join(dashboardDir, 'index.html'));
39
40
  });
40
41
  this.app.use('/dashboard', express.static(dashboardDir));
42
+ // PIN-based dashboard unlock — exchanges a short PIN for the auth token.
43
+ // Placed before auth middleware so the dashboard can call it without a token.
44
+ if (options.config.dashboardPin && options.config.authToken) {
45
+ const pinAttempts = new Map();
46
+ const MAX_ATTEMPTS = 5;
47
+ const WINDOW_MS = 5 * 60 * 1000; // 5-minute window
48
+ this.app.post('/dashboard/unlock', (req, res) => {
49
+ const ip = req.ip || req.socket.remoteAddress || 'unknown';
50
+ // Rate limit by IP
51
+ const now = Date.now();
52
+ let entry = pinAttempts.get(ip);
53
+ if (entry && now > entry.resetAt) {
54
+ pinAttempts.delete(ip);
55
+ entry = undefined;
56
+ }
57
+ if (entry && entry.count >= MAX_ATTEMPTS) {
58
+ res.status(429).json({ error: 'Too many attempts. Try again later.' });
59
+ return;
60
+ }
61
+ const { pin } = req.body;
62
+ if (!pin || typeof pin !== 'string') {
63
+ res.status(400).json({ error: 'Missing PIN' });
64
+ return;
65
+ }
66
+ const ha = createHash('sha256').update(pin).digest();
67
+ const hb = createHash('sha256').update(options.config.dashboardPin).digest();
68
+ if (!timingSafeEqual(ha, hb)) {
69
+ // Track failed attempt
70
+ if (!entry) {
71
+ entry = { count: 0, resetAt: now + WINDOW_MS };
72
+ pinAttempts.set(ip, entry);
73
+ }
74
+ entry.count++;
75
+ const remaining = MAX_ATTEMPTS - entry.count;
76
+ res.status(403).json({
77
+ error: 'Incorrect PIN',
78
+ attemptsRemaining: remaining,
79
+ });
80
+ return;
81
+ }
82
+ // PIN correct — return the auth token
83
+ res.json({ token: options.config.authToken });
84
+ });
85
+ }
41
86
  this.app.use(authMiddleware(options.config.authToken));
42
87
  this.app.use(requestTimeout(options.config.requestTimeoutMs));
43
88
  // Routes
@@ -97,6 +142,7 @@ export class AgentServer {
97
142
  sessionManager: this.sessionManager,
98
143
  state: this.state,
99
144
  authToken: this.config.authToken,
145
+ instarDir: this.config.stateDir,
100
146
  });
101
147
  resolve();
102
148
  });
@@ -36,11 +36,13 @@ export declare class WebSocketManager {
36
36
  private sessionManager;
37
37
  private state;
38
38
  private authToken?;
39
+ private registryPath?;
39
40
  constructor(options: {
40
41
  server: HttpServer;
41
42
  sessionManager: SessionManager;
42
43
  state: StateManager;
43
44
  authToken?: string;
45
+ instarDir?: string;
44
46
  });
45
47
  private authenticate;
46
48
  private verifyToken;
@@ -50,6 +52,12 @@ export declare class WebSocketManager {
50
52
  * Uses diff-based approach: only sends new content since last capture.
51
53
  */
52
54
  private startStreaming;
55
+ /**
56
+ * Resolve display names by cross-referencing the topic-session registry.
57
+ * Maps tmux session names to their Telegram topic names.
58
+ */
59
+ private getTopicDisplayNames;
60
+ private buildSessionList;
53
61
  private sendSessionList;
54
62
  private broadcastSessionList;
55
63
  private clientId;
@@ -25,6 +25,8 @@
25
25
  */
26
26
  import { WebSocketServer, WebSocket } from 'ws';
27
27
  import { createHash, timingSafeEqual } from 'node:crypto';
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
28
30
  export class WebSocketManager {
29
31
  wss;
30
32
  clients = new Map();
@@ -35,10 +37,14 @@ export class WebSocketManager {
35
37
  sessionManager;
36
38
  state;
37
39
  authToken;
40
+ registryPath;
38
41
  constructor(options) {
39
42
  this.sessionManager = options.sessionManager;
40
43
  this.state = options.state;
41
44
  this.authToken = options.authToken;
45
+ if (options.instarDir) {
46
+ this.registryPath = path.join(options.instarDir, 'topic-session-registry.json');
47
+ }
42
48
  this.wss = new WebSocketServer({
43
49
  noServer: true,
44
50
  });
@@ -226,32 +232,52 @@ export class WebSocketManager {
226
232
  }, 500);
227
233
  this.streamInterval.unref();
228
234
  }
229
- sendSessionList(ws) {
235
+ /**
236
+ * Resolve display names by cross-referencing the topic-session registry.
237
+ * Maps tmux session names to their Telegram topic names.
238
+ */
239
+ getTopicDisplayNames() {
240
+ const map = new Map();
241
+ if (!this.registryPath)
242
+ return map;
243
+ try {
244
+ const data = JSON.parse(fs.readFileSync(this.registryPath, 'utf-8'));
245
+ const topicToSession = data.topicToSession || {};
246
+ const topicToName = data.topicToName || {};
247
+ // Build reverse map: tmux session name → topic display name
248
+ for (const [topicId, tmuxSession] of Object.entries(topicToSession)) {
249
+ const name = topicToName[topicId];
250
+ if (name) {
251
+ map.set(tmuxSession, name);
252
+ }
253
+ }
254
+ }
255
+ catch {
256
+ // Registry missing or corrupt — skip
257
+ }
258
+ return map;
259
+ }
260
+ buildSessionList() {
230
261
  const running = this.sessionManager.listRunningSessions();
231
- const sessions = running.map(s => ({
262
+ const displayNames = this.getTopicDisplayNames();
263
+ return running.map(s => ({
232
264
  id: s.id,
233
- name: s.name,
265
+ name: displayNames.get(s.tmuxSession) || s.name,
234
266
  tmuxSession: s.tmuxSession,
235
267
  status: s.status,
236
268
  startedAt: s.startedAt,
237
269
  jobSlug: s.jobSlug,
238
270
  model: s.model,
239
271
  }));
272
+ }
273
+ sendSessionList(ws) {
274
+ const sessions = this.buildSessionList();
240
275
  this.send(ws, { type: 'sessions', sessions });
241
276
  }
242
277
  broadcastSessionList() {
243
278
  if (this.clients.size === 0)
244
279
  return;
245
- const running = this.sessionManager.listRunningSessions();
246
- const sessions = running.map(s => ({
247
- id: s.id,
248
- name: s.name,
249
- tmuxSession: s.tmuxSession,
250
- status: s.status,
251
- startedAt: s.startedAt,
252
- jobSlug: s.jobSlug,
253
- model: s.model,
254
- }));
280
+ const sessions = this.buildSessionList();
255
281
  const msg = JSON.stringify({ type: 'sessions', sessions });
256
282
  for (const client of this.clients.values()) {
257
283
  if (client.ws.readyState === WebSocket.OPEN) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",