ai-agent-session-center 2.8.2 → 2.9.4
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/dist/client/assets/{AgendaView-DQAv5CVb.js → AgendaView-JNCDhyjI.js} +1 -1
- package/dist/client/assets/{CyberdromeScene-DBlv7xBi.js → CyberdromeScene-D3HcbWvm.js} +1 -1
- package/dist/client/assets/{HistoryView-DW2ffQlM.js → HistoryView-YR2swH--.js} +1 -1
- package/dist/client/assets/ProjectBrowserView-Bfeaxxic.js +1 -0
- package/dist/client/assets/{QueueView-BOYx9a7Z.js → QueueView-XKBCGUwV.js} +1 -1
- package/dist/client/assets/index-BVmeFvDN.js +195 -0
- package/dist/client/assets/index-CyAbdpIR.css +1 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/server/apiRouter.ts +72 -17
- package/server/fileIndexCache.ts +35 -3
- package/server/sessionStore.ts +48 -14
- package/server/sshManager.ts +48 -3
- package/dist/client/assets/ProjectBrowserView-DB5IJ4IC.js +0 -1
- package/dist/client/assets/index-CCOZp8gY.js +0 -193
- package/dist/client/assets/index-bOzE64NK.css +0 -1
package/server/apiRouter.ts
CHANGED
|
@@ -10,7 +10,7 @@ function str(val: unknown): string {
|
|
|
10
10
|
if (Array.isArray(val)) return String(val[0] ?? '');
|
|
11
11
|
return val != null ? String(val) : '';
|
|
12
12
|
}
|
|
13
|
-
import { findClaudeProcess, killSession, archiveSession, setSessionTitle, setSessionLabel, setSessionPinned, setSessionAccentColor, setSummary, getSession, getAllSessions, detectSessionSource, createTerminalSession, findActiveSessionByConfig, deleteSessionFromMemory, resumeSession, reconnectSessionTerminal, reconnectOpsTerminal } from './sessionStore.js';
|
|
13
|
+
import { findClaudeProcess, killSession, archiveSession, setSessionTitle, setSessionLabel, setSessionPinned, setSessionAccentColor, setSessionCharacterModel, setSummary, getSession, getAllSessions, detectSessionSource, createTerminalSession, findActiveSessionByConfig, deleteSessionFromMemory, resumeSession, reconnectSessionTerminal, reconnectOpsTerminal } from './sessionStore.js';
|
|
14
14
|
import { config as serverConfig } from './serverConfig.js';
|
|
15
15
|
import { createTerminal, closeTerminal, getTerminals, listSshKeys, listTmuxSessions, writeToTerminal, writeWhenReady, attachToTmuxPane, consumePendingLink } from './sshManager.js';
|
|
16
16
|
import { getTeam, readTeamConfig } from './teamManager.js';
|
|
@@ -20,7 +20,7 @@ import { getMqStats } from './mqReader.js';
|
|
|
20
20
|
import { execFile } from 'child_process';
|
|
21
21
|
import { createReadStream, readFileSync, writeFileSync, readdirSync, existsSync, statSync, mkdirSync, rmSync } from 'fs';
|
|
22
22
|
import { join, dirname, extname, basename, resolve, sep } from 'path';
|
|
23
|
-
import { homedir, userInfo } from 'os';
|
|
23
|
+
import { homedir, userInfo, networkInterfaces, hostname } from 'os';
|
|
24
24
|
import { fileURLToPath } from 'url';
|
|
25
25
|
import { ALL_CLAUDE_HOOK_EVENTS, DENSITY_EVENTS, SESSION_STATUS, WS_TYPES } from './constants.js';
|
|
26
26
|
import log from './logger.js';
|
|
@@ -44,8 +44,22 @@ function saveLastUsername(username: string): void {
|
|
|
44
44
|
if (username) _lastUsedUsername = username;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Build set of all addresses/hostnames that resolve to this machine.
|
|
48
|
+
// Matches sshManager.ts approach so local-session detection is consistent.
|
|
49
|
+
const LOCAL_HOSTS = new Set<string>(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
|
|
50
|
+
try {
|
|
51
|
+
const h = hostname();
|
|
52
|
+
LOCAL_HOSTS.add(h);
|
|
53
|
+
LOCAL_HOSTS.add(`${h}.local`); // macOS mDNS
|
|
54
|
+
const ifaces = networkInterfaces();
|
|
55
|
+
for (const addrs of Object.values(ifaces)) {
|
|
56
|
+
if (!addrs) continue;
|
|
57
|
+
for (const addr of addrs) LOCAL_HOSTS.add(addr.address);
|
|
58
|
+
}
|
|
59
|
+
} catch { /* ignore — hardcoded fallback is sufficient */ }
|
|
60
|
+
|
|
47
61
|
function isLocalHost(host: string): boolean {
|
|
48
|
-
return !host ||
|
|
62
|
+
return !host || LOCAL_HOSTS.has(host);
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
// ---- Zod Validation Schemas ----
|
|
@@ -80,6 +94,7 @@ const terminalCreateSchema = z.object({
|
|
|
80
94
|
sessionTitle: z.string().max(500).optional(),
|
|
81
95
|
label: z.string().optional(),
|
|
82
96
|
enableOpsTerminal: z.boolean().optional(),
|
|
97
|
+
forceNew: z.boolean().optional(),
|
|
83
98
|
});
|
|
84
99
|
|
|
85
100
|
const tmuxSessionsSchema = z.object({
|
|
@@ -115,6 +130,10 @@ const accentColorSchema = z.object({
|
|
|
115
130
|
color: z.string().min(1).max(50),
|
|
116
131
|
});
|
|
117
132
|
|
|
133
|
+
const characterModelSchema = z.object({
|
|
134
|
+
model: z.string().min(1).max(50),
|
|
135
|
+
});
|
|
136
|
+
|
|
118
137
|
const summarizeSchema = z.object({
|
|
119
138
|
context: z.string().min(1),
|
|
120
139
|
promptTemplate: z.string().optional(),
|
|
@@ -615,6 +634,14 @@ router.put('/sessions/:id/accent-color', (req: Request, res: Response) => {
|
|
|
615
634
|
res.json({ ok: true });
|
|
616
635
|
});
|
|
617
636
|
|
|
637
|
+
// Update session character model
|
|
638
|
+
router.put('/sessions/:id/character-model', (req: Request, res: Response) => {
|
|
639
|
+
const body = validateBody(characterModelSchema, req.body, res);
|
|
640
|
+
if (!body) return;
|
|
641
|
+
setSessionCharacterModel(str(req.params.id), body.model);
|
|
642
|
+
res.json({ ok: true });
|
|
643
|
+
});
|
|
644
|
+
|
|
618
645
|
/**
|
|
619
646
|
* Summarize session using Claude CLI.
|
|
620
647
|
* The frontend sends { context, promptTemplate } from IndexedDB data.
|
|
@@ -716,8 +743,10 @@ router.post('/terminals', async (req: Request, res: Response) => {
|
|
|
716
743
|
if (!body) return;
|
|
717
744
|
|
|
718
745
|
try {
|
|
719
|
-
|
|
720
|
-
|
|
746
|
+
// Normalize any local address (IP, hostname, .local) to 'localhost' so that
|
|
747
|
+
// dedup matching, PTY creation, and stored sshHost are all consistent.
|
|
748
|
+
const resolvedHost = isLocalHost(body.host || '') ? 'localhost' : (body.host || 'localhost');
|
|
749
|
+
const username = body.username || getDefaultUsername() || (resolvedHost === 'localhost' ? 'local' : null);
|
|
721
750
|
if (!username) {
|
|
722
751
|
res.status(400).json({ success: false, error: 'username required — set it once in "+ NEW SESSION" and it will be reused' });
|
|
723
752
|
return;
|
|
@@ -747,11 +776,14 @@ router.post('/terminals', async (req: Request, res: Response) => {
|
|
|
747
776
|
}
|
|
748
777
|
|
|
749
778
|
// Deduplicate: if an active session with matching config already exists, return it
|
|
750
|
-
|
|
751
|
-
if (
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
779
|
+
// Skip deduplication when forceNew is explicitly requested (e.g. Quick session modal)
|
|
780
|
+
if (!body.forceNew) {
|
|
781
|
+
const existing = findActiveSessionByConfig(config);
|
|
782
|
+
if (existing) {
|
|
783
|
+
log.info('api', `Deduplicated terminal creation — reusing session ${existing.sessionId} for ${resolvedHost}:${config.workingDir}`);
|
|
784
|
+
res.json({ ok: true, terminalId: existing.sessionId, deduplicated: true, hasTerminal: !!existing.terminalId });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
755
787
|
}
|
|
756
788
|
|
|
757
789
|
const terminalId = await createTerminal(config, null);
|
|
@@ -1058,9 +1090,9 @@ const STREAMABLE_EXTENSIONS = new Set([
|
|
|
1058
1090
|
]);
|
|
1059
1091
|
|
|
1060
1092
|
/** Directories to skip when listing. */
|
|
1093
|
+
// Always-hidden dirs (even when showHidden=true) — large/noisy dirs with no browsing value
|
|
1061
1094
|
const HIDDEN_DIRS = new Set([
|
|
1062
|
-
'node_modules', '
|
|
1063
|
-
'venv', 'dist', 'build', '.cache', '.turbo', 'coverage', '.svelte-kit',
|
|
1095
|
+
'node_modules', '__pycache__', 'venv', 'dist', 'build', 'coverage',
|
|
1064
1096
|
]);
|
|
1065
1097
|
|
|
1066
1098
|
function isTextFile(name: string): boolean {
|
|
@@ -1121,11 +1153,12 @@ router.get('/files/list', (req: Request, res: Response) => {
|
|
|
1121
1153
|
const entries = readdirSync(fullPath, { withFileTypes: true });
|
|
1122
1154
|
const items: Array<{ name: string; type: 'dir' | 'file'; size?: number; mtime?: string }> = [];
|
|
1123
1155
|
|
|
1156
|
+
const showHidden = str(req.query.showHidden) === 'true';
|
|
1124
1157
|
for (const entry of entries) {
|
|
1125
|
-
// Skip hidden dirs
|
|
1158
|
+
// Skip certain always-hidden dirs regardless of showHidden flag
|
|
1126
1159
|
if (entry.isDirectory() && HIDDEN_DIRS.has(entry.name)) continue;
|
|
1127
|
-
// Skip dot-prefixed directories
|
|
1128
|
-
if (entry.isDirectory() && entry.name.startsWith('.')) continue;
|
|
1160
|
+
// Skip dot-prefixed directories unless showHidden is set
|
|
1161
|
+
if (!showHidden && entry.isDirectory() && entry.name.startsWith('.')) continue;
|
|
1129
1162
|
|
|
1130
1163
|
if (entry.isDirectory()) {
|
|
1131
1164
|
try {
|
|
@@ -1245,11 +1278,33 @@ router.get('/files/stream', (req: Request, res: Response) => {
|
|
|
1245
1278
|
};
|
|
1246
1279
|
const contentType = mimeMap[fileExt] || 'application/octet-stream';
|
|
1247
1280
|
|
|
1248
|
-
res.setHeader('Content-Type', contentType);
|
|
1249
|
-
res.setHeader('Content-Length', stat.size);
|
|
1250
1281
|
const safeName = basename(fullPath).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
1251
1282
|
res.setHeader('Content-Disposition', `inline; filename="${safeName}"`);
|
|
1252
1283
|
|
|
1284
|
+
// Video/audio: support HTTP Range requests so browsers can seek
|
|
1285
|
+
const isMedia = contentType.startsWith('video/') || contentType.startsWith('audio/');
|
|
1286
|
+
if (isMedia) {
|
|
1287
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
1288
|
+
const rangeHeader = req.headers.range;
|
|
1289
|
+
if (rangeHeader) {
|
|
1290
|
+
const [startStr, endStr] = rangeHeader.replace(/bytes=/, '').split('-');
|
|
1291
|
+
const start = parseInt(startStr, 10);
|
|
1292
|
+
const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
|
|
1293
|
+
const chunkSize = end - start + 1;
|
|
1294
|
+
res.status(206);
|
|
1295
|
+
res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`);
|
|
1296
|
+
res.setHeader('Content-Length', chunkSize);
|
|
1297
|
+
res.setHeader('Content-Type', contentType);
|
|
1298
|
+
const rangeStream = createReadStream(fullPath, { start, end });
|
|
1299
|
+
rangeStream.pipe(res);
|
|
1300
|
+
rangeStream.on('error', () => { if (!res.headersSent) res.status(500).json({ error: 'Stream error' }); });
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
res.setHeader('Content-Type', contentType);
|
|
1306
|
+
res.setHeader('Content-Length', stat.size);
|
|
1307
|
+
|
|
1253
1308
|
const stream = createReadStream(fullPath);
|
|
1254
1309
|
stream.pipe(res);
|
|
1255
1310
|
stream.on('error', () => {
|
package/server/fileIndexCache.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// fileIndexCache.ts — Cached file index for fast fuzzy search
|
|
2
2
|
import { readdir } from 'fs/promises';
|
|
3
|
+
import { watch as fsWatch } from 'fs';
|
|
3
4
|
import { join } from 'path';
|
|
4
5
|
import log from './logger.js';
|
|
5
6
|
|
|
@@ -28,10 +29,13 @@ interface CachedIndex {
|
|
|
28
29
|
|
|
29
30
|
const cache = new Map<string, CachedIndex>();
|
|
30
31
|
const building = new Set<string>();
|
|
32
|
+
const watchers = new Map<string, ReturnType<typeof fsWatch>>();
|
|
33
|
+
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
31
34
|
|
|
32
|
-
const CACHE_TTL_MS = 30_000; // 30 seconds
|
|
35
|
+
const CACHE_TTL_MS = 30_000; // 30 seconds (watcher keeps it fresh in practice)
|
|
33
36
|
const MAX_ENTRIES = 50_000;
|
|
34
37
|
const MAX_DEPTH = 10;
|
|
38
|
+
const WATCHER_DEBOUNCE_MS = 300;
|
|
35
39
|
|
|
36
40
|
/** Build the file index for a project root (async, non-blocking). */
|
|
37
41
|
async function buildIndex(root: string): Promise<FileEntry[]> {
|
|
@@ -74,7 +78,10 @@ async function buildIndex(root: string): Promise<FileEntry[]> {
|
|
|
74
78
|
*/
|
|
75
79
|
function ensureIndex(root: string): void {
|
|
76
80
|
const cached = cache.get(root);
|
|
77
|
-
if (cached && Date.now() - cached.builtAt < CACHE_TTL_MS)
|
|
81
|
+
if (cached && Date.now() - cached.builtAt < CACHE_TTL_MS) {
|
|
82
|
+
startWatcher(root);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
78
85
|
if (building.has(root)) return;
|
|
79
86
|
|
|
80
87
|
building.add(root);
|
|
@@ -82,6 +89,7 @@ function ensureIndex(root: string): void {
|
|
|
82
89
|
.then(entries => {
|
|
83
90
|
cache.set(root, { entries, builtAt: Date.now() });
|
|
84
91
|
log.debug('file-index', `Built index for ${root}: ${entries.length} entries`);
|
|
92
|
+
startWatcher(root);
|
|
85
93
|
})
|
|
86
94
|
.catch(err => {
|
|
87
95
|
log.error('file-index', `Failed to build index for ${root}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -91,6 +99,30 @@ function ensureIndex(root: string): void {
|
|
|
91
99
|
});
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
/** Start a recursive fs.watch on root to invalidate cache on any file change. */
|
|
103
|
+
function startWatcher(root: string): void {
|
|
104
|
+
if (watchers.has(root)) return;
|
|
105
|
+
try {
|
|
106
|
+
const watcher = fsWatch(root, { recursive: true }, (_event, filename) => {
|
|
107
|
+
// Skip node_modules and hidden dirs for performance
|
|
108
|
+
if (filename && (filename.includes('node_modules') || filename.includes('/.git/'))) return;
|
|
109
|
+
const existing = debounceTimers.get(root);
|
|
110
|
+
if (existing) clearTimeout(existing);
|
|
111
|
+
debounceTimers.set(root, setTimeout(() => {
|
|
112
|
+
debounceTimers.delete(root);
|
|
113
|
+
cache.delete(root);
|
|
114
|
+
log.debug('file-index', `Cache invalidated for ${root} (file change: ${filename})`);
|
|
115
|
+
}, WATCHER_DEBOUNCE_MS));
|
|
116
|
+
});
|
|
117
|
+
watcher.on('error', () => {
|
|
118
|
+
watchers.delete(root);
|
|
119
|
+
});
|
|
120
|
+
watchers.set(root, watcher);
|
|
121
|
+
} catch {
|
|
122
|
+
// Recursive watch not supported on this platform/path — silently ignore
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
94
126
|
/** Score a fuzzy match. Higher = better. Returns -1 if no match. */
|
|
95
127
|
function fuzzyScore(query: string, nameLower: string, pathLower: string): number {
|
|
96
128
|
// Exact filename match
|
|
@@ -178,7 +210,7 @@ export function searchFiles(
|
|
|
178
210
|
return { results: scored.slice(0, maxResults), indexing: false };
|
|
179
211
|
}
|
|
180
212
|
|
|
181
|
-
/** Invalidate cache for a project root. */
|
|
213
|
+
/** Invalidate cache for a project root (watcher stays active). */
|
|
182
214
|
export function invalidateCache(root: string): void {
|
|
183
215
|
cache.delete(root);
|
|
184
216
|
}
|
package/server/sessionStore.ts
CHANGED
|
@@ -508,6 +508,37 @@ export function handleEvent(hookData: HookPayload): HandleEventResult | null {
|
|
|
508
508
|
const session = matchSession(hookData, sessions, pendingResume, pidToSession, projectSessionCounters);
|
|
509
509
|
if (!session) return null;
|
|
510
510
|
|
|
511
|
+
// When a session is re-keyed (replacesId is set), absorb any CONNECTING session for the
|
|
512
|
+
// same path that was created by workspace auto-load. After server restart:
|
|
513
|
+
// - Priority 0.5 re-keys the old ended session (keeping its stale terminalId)
|
|
514
|
+
// - Workspace auto-load created a fresh CONNECTING session for the same path
|
|
515
|
+
// - Without merging, the CONNECTING card persists as a duplicate
|
|
516
|
+
// By merging, the re-keyed session gets the fresh SSH terminal and the orphan is removed.
|
|
517
|
+
// Safe for Priority 1/2/3 re-keys too: those consume the CONNECTING session during re-key,
|
|
518
|
+
// so no orphan exists for the same path and the loop finds nothing to merge.
|
|
519
|
+
if (session.replacesId && cwd) {
|
|
520
|
+
const normalizedCwd = cwd.replace(/\/$/, '');
|
|
521
|
+
for (const [orphanKey, orphan] of sessions) {
|
|
522
|
+
if (
|
|
523
|
+
orphanKey !== session.sessionId &&
|
|
524
|
+
orphan.terminalId &&
|
|
525
|
+
orphan.status === SESSION_STATUS.CONNECTING &&
|
|
526
|
+
orphan.projectPath?.replace(/\/$/, '') === normalizedCwd
|
|
527
|
+
) {
|
|
528
|
+
session.terminalId = orphan.terminalId;
|
|
529
|
+
if (orphan.opsTerminalId) session.opsTerminalId = orphan.opsTerminalId;
|
|
530
|
+
if (orphan.sshConfig) session.sshConfig = orphan.sshConfig;
|
|
531
|
+
if (orphan.sshHost) session.sshHost = orphan.sshHost;
|
|
532
|
+
if (orphan.sshCommand) session.sshCommand = orphan.sshCommand;
|
|
533
|
+
sessions.delete(orphanKey);
|
|
534
|
+
invalidateSessionsCache();
|
|
535
|
+
broadcastAsync({ type: WS_TYPES.SESSION_REMOVED, sessionId: orphanKey });
|
|
536
|
+
log.info('session', `Merged orphan CONNECTING terminal ${orphan.terminalId?.slice(0,8)} into re-keyed session ${session.sessionId?.slice(0,8)} (path=${normalizedCwd})`);
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
511
542
|
// Auto-revive sessions that were marked ended by ServerRestart but whose Claude process survived.
|
|
512
543
|
// This happens when Claude runs in tmux/screen and keeps sending hooks after server restart.
|
|
513
544
|
const REVIVABLE_EVENTS: Set<string> = new Set([
|
|
@@ -928,21 +959,24 @@ export async function createTerminalSession(terminalId: string, config: Terminal
|
|
|
928
959
|
|
|
929
960
|
await broadcastAsync({ type: WS_TYPES.SESSION_UPDATE, session: { ...session } });
|
|
930
961
|
|
|
931
|
-
//
|
|
962
|
+
// Auto-transition from connecting to idle if hooks don't arrive in time.
|
|
963
|
+
// Non-Claude CLIs: 3s (they never send hooks).
|
|
964
|
+
// Claude: 30s fallback — hooks should arrive via SessionStart, but if the
|
|
965
|
+
// session_id or path doesn't match (e.g. SSH remote path mismatch), the card
|
|
966
|
+
// would be stuck in connecting forever without this safety net.
|
|
932
967
|
const command = config.command || 'claude';
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
}
|
|
968
|
+
const connectingTimeout = command.startsWith('claude') ? 30_000 : 3_000;
|
|
969
|
+
setTimeout(async () => {
|
|
970
|
+
const s = sessions.get(terminalId);
|
|
971
|
+
if (s && s.status === (SESSION_STATUS.CONNECTING as string)) {
|
|
972
|
+
s.status = SESSION_STATUS.IDLE;
|
|
973
|
+
s.animationState = ANIMATION_STATE.IDLE;
|
|
974
|
+
s.emote = null;
|
|
975
|
+
if (!command.startsWith('claude')) s.model = command;
|
|
976
|
+
await broadcastAsync({ type: WS_TYPES.SESSION_UPDATE, session: { ...s } });
|
|
977
|
+
log.info('session', `Auto-transitioned session ${terminalId} to idle after connecting timeout (${command})`);
|
|
978
|
+
}
|
|
979
|
+
}, connectingTimeout);
|
|
946
980
|
|
|
947
981
|
return session;
|
|
948
982
|
}
|
package/server/sshManager.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { IPty, IDisposable } from 'node-pty';
|
|
|
7
7
|
import { execFile, execSync } from 'child_process';
|
|
8
8
|
import { readdirSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
|
-
import { homedir, networkInterfaces } from 'os';
|
|
10
|
+
import { homedir, networkInterfaces, hostname as osHostname } from 'os';
|
|
11
11
|
import log from './logger.js';
|
|
12
12
|
import type { Terminal, TerminalConfig, TerminalInfo, TmuxSessionInfo, SshKeyInfo } from '../src/types/terminal.js';
|
|
13
13
|
import type { PendingLink } from '../src/types/session.js';
|
|
@@ -181,6 +181,9 @@ function resolveWorkDir(dir: string | undefined): string {
|
|
|
181
181
|
// even when the user accesses the dashboard via a LAN IP (e.g. 192.168.x.x).
|
|
182
182
|
const localAddresses = new Set<string>(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
|
|
183
183
|
try {
|
|
184
|
+
const h = osHostname();
|
|
185
|
+
localAddresses.add(h);
|
|
186
|
+
localAddresses.add(`${h}.local`); // macOS mDNS
|
|
184
187
|
const ifaces = networkInterfaces();
|
|
185
188
|
for (const addrs of Object.values(ifaces)) {
|
|
186
189
|
if (!addrs) continue;
|
|
@@ -313,6 +316,44 @@ export function createTerminal(config: TerminalConfig, wsClient: WebSocket | nul
|
|
|
313
316
|
|
|
314
317
|
log.info('pty', `Spawned ${local ? 'local' : `remote (${config.host})`} terminal ${terminalId} (pid: ${ptyProcess.pid})`);
|
|
315
318
|
|
|
319
|
+
// Auto-type SSH password when password auth is used.
|
|
320
|
+
// Watches PTY output for "password:" prompts and sends the stored password.
|
|
321
|
+
if (!local && config.password) {
|
|
322
|
+
const storedPassword = config.password;
|
|
323
|
+
let passwordBuffer = '';
|
|
324
|
+
let passwordAttempts = 0;
|
|
325
|
+
const maxAttempts = 2;
|
|
326
|
+
const passwordDisp = ptyProcess.onData((data: string) => {
|
|
327
|
+
passwordBuffer += data;
|
|
328
|
+
if (passwordBuffer.length > 4096) passwordBuffer = passwordBuffer.slice(-4096);
|
|
329
|
+
const stripped = passwordBuffer.replace(ANSI_ESC_RE, '').toLowerCase();
|
|
330
|
+
if (stripped.includes('password:') || stripped.includes('password for')) {
|
|
331
|
+
passwordAttempts++;
|
|
332
|
+
if (passwordAttempts > maxAttempts) {
|
|
333
|
+
log.error('pty', `SSH password rejected after ${maxAttempts} attempts for ${terminalId}`);
|
|
334
|
+
passwordDisp.dispose();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// Small delay to ensure the SSH process is ready for input
|
|
338
|
+
setTimeout(() => {
|
|
339
|
+
const term = terminals.get(terminalId);
|
|
340
|
+
if (term?.pty) {
|
|
341
|
+
term.pty.write(storedPassword + '\r');
|
|
342
|
+
log.info('pty', `Auto-typed SSH password for ${terminalId} (attempt ${passwordAttempts})`);
|
|
343
|
+
}
|
|
344
|
+
}, 100);
|
|
345
|
+
// Reset buffer so we don't re-trigger on the same prompt
|
|
346
|
+
passwordBuffer = '';
|
|
347
|
+
}
|
|
348
|
+
// Stop watching after successful auth (shell prompt appears)
|
|
349
|
+
if (stripped.includes('last login') || SHELL_PROMPT_RE.test(stripped.split(/[\r\n]+/).filter(l => l.trim()).pop() || '')) {
|
|
350
|
+
passwordDisp.dispose();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
// Safety cleanup — stop watching after 30s regardless
|
|
354
|
+
setTimeout(() => { passwordDisp.dispose(); }, 30000);
|
|
355
|
+
}
|
|
356
|
+
|
|
316
357
|
// Detect when the shell is ready (prompt visible) before sending commands.
|
|
317
358
|
// Local shells init in ~100-300ms; remote SSH can take seconds for key exchange.
|
|
318
359
|
const shellReady = detectShellReady(ptyProcess, terminalId, local ? 2000 : 10000);
|
|
@@ -327,8 +368,12 @@ export function createTerminal(config: TerminalConfig, wsClient: WebSocket | nul
|
|
|
327
368
|
shellReady,
|
|
328
369
|
});
|
|
329
370
|
|
|
330
|
-
// Register pending link for session matching
|
|
331
|
-
|
|
371
|
+
// Register pending link for session matching.
|
|
372
|
+
// Skip for ops terminals (command='') — they never run Claude so they
|
|
373
|
+
// must not overwrite the main terminal's pending link for the same workDir.
|
|
374
|
+
if (!skipAutoLaunch) {
|
|
375
|
+
pendingLinks.set(workDir, { terminalId, host: config.host || 'localhost', createdAt: Date.now() });
|
|
376
|
+
}
|
|
332
377
|
|
|
333
378
|
// #19: Store disposables for proper cleanup on terminal close
|
|
334
379
|
const disposables: IDisposable[] = [];
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{z as o,k as s,A as e,P as r}from"./index-CCOZp8gY.js";function l(){const[t]=o(),a=t.get("path");if(!a)return s.jsx("div",{className:e.standalone,children:s.jsxs("div",{className:e.standaloneEmpty,children:["No project path specified. Use ",s.jsx("code",{children:"?path=/your/project"})," to open a project."]})});const n=a.split("/").filter(Boolean).pop()||a;return s.jsxs("div",{className:e.standalone,children:[s.jsxs("div",{className:e.standaloneHeader,children:[s.jsx("span",{className:e.standaloneTitle,children:n}),s.jsx("span",{className:e.standalonePath,children:a})]}),s.jsx("div",{className:e.standaloneContent,children:s.jsx(r,{projectPath:a})})]})}export{l as default};
|