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.
- package/.vercel/README.txt +11 -0
- package/.vercel/project.json +1 -0
- package/dashboard/index.html +188 -16
- package/dist/cli.js +1 -1
- package/dist/commands/init.js +5 -5
- package/dist/commands/job.js +1 -1
- package/dist/core/Config.js +1 -0
- package/dist/core/PostUpdateMigrator.js +0 -16
- package/dist/core/types.d.ts +2 -0
- package/dist/lifeline/TelegramLifeline.d.ts +0 -8
- package/dist/lifeline/TelegramLifeline.js +1 -75
- package/dist/server/AgentServer.js +46 -0
- package/dist/server/WebSocketManager.d.ts +8 -0
- package/dist/server/WebSocketManager.js +39 -13
- package/package.json +1 -1
|
@@ -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"}
|
package/dashboard/index.html
CHANGED
|
@@ -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
|
|
417
|
-
<input type="password" id="
|
|
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">←</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
|
-
|
|
492
|
-
if (!
|
|
493
|
-
showAuthError('Please enter a
|
|
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
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
headers: { '
|
|
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('
|
|
666
|
+
showAuthError('Incorrect PIN');
|
|
510
667
|
}
|
|
511
668
|
})
|
|
512
|
-
.catch(
|
|
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(
|
|
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
|
|
537
|
-
document.getElementById('
|
|
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 || '
|
|
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)', '
|
|
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);
|
package/dist/commands/init.js
CHANGED
|
@@ -970,7 +970,7 @@ function getDefaultJobs(port) {
|
|
|
970
970
|
schedule: '0 */4 * * *',
|
|
971
971
|
priority: 'medium',
|
|
972
972
|
expectedDurationMinutes: 5,
|
|
973
|
-
model: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: {
|
package/dist/commands/job.js
CHANGED
|
@@ -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 || '
|
|
29
|
+
model: (options.model || 'opus'),
|
|
30
30
|
enabled: options.enabled !== false,
|
|
31
31
|
execute: {
|
|
32
32
|
type: (options.type || 'prompt'),
|
package/dist/core/Config.js
CHANGED
|
@@ -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 = `
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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) {
|