@dmsdc-ai/aigentry-telepty 0.1.75 → 0.1.77
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/commands/telepty-inject.md +45 -7
- package/cli.js +559 -59
- package/cross-machine.js +68 -8
- package/daemon.js +697 -441
- package/package.json +1 -1
- package/session-routing.js +7 -5
- package/shared-context.js +147 -0
package/daemon.js
CHANGED
|
@@ -15,12 +15,32 @@ const EXPECTED_TOKEN = config.authToken;
|
|
|
15
15
|
const MACHINE_ID = process.env.TELEPTY_MACHINE_ID || os.hostname();
|
|
16
16
|
const fs = require('fs');
|
|
17
17
|
const SESSION_PERSIST_PATH = require('path').join(os.homedir(), '.config', 'aigentry-telepty', 'sessions.json');
|
|
18
|
+
const SESSION_STALE_SECONDS = Math.max(1, Number(process.env.TELEPTY_SESSION_STALE_SECONDS || 60));
|
|
19
|
+
const SESSION_CLEANUP_SECONDS = Math.max(SESSION_STALE_SECONDS, Number(process.env.TELEPTY_SESSION_CLEANUP_SECONDS || 300));
|
|
20
|
+
const DELIVERY_TIMEOUT_MS = Math.max(100, Number(process.env.TELEPTY_DELIVERY_TIMEOUT_MS || 5000));
|
|
21
|
+
const HEALTH_POLL_MS = Math.max(100, Number(process.env.TELEPTY_HEALTH_POLL_MS || 10000));
|
|
18
22
|
|
|
19
23
|
function persistSessions() {
|
|
20
24
|
try {
|
|
21
25
|
const data = {};
|
|
22
26
|
for (const [id, s] of Object.entries(sessions)) {
|
|
23
|
-
data[id] = {
|
|
27
|
+
data[id] = {
|
|
28
|
+
id,
|
|
29
|
+
type: s.type,
|
|
30
|
+
command: s.command,
|
|
31
|
+
cwd: s.cwd,
|
|
32
|
+
backend: s.backend || null,
|
|
33
|
+
cmuxWorkspaceId: s.cmuxWorkspaceId || null,
|
|
34
|
+
cmuxSurfaceId: s.cmuxSurfaceId || null,
|
|
35
|
+
termProgram: s.termProgram || null,
|
|
36
|
+
term: s.term || null,
|
|
37
|
+
createdAt: s.createdAt,
|
|
38
|
+
lastActivityAt: s.lastActivityAt || null,
|
|
39
|
+
lastConnectedAt: s.lastConnectedAt || null,
|
|
40
|
+
lastDisconnectedAt: s.lastDisconnectedAt || null,
|
|
41
|
+
lastStateReportAt: s.lastStateReportAt || null,
|
|
42
|
+
stateReport: s.stateReport || null
|
|
43
|
+
};
|
|
24
44
|
}
|
|
25
45
|
fs.mkdirSync(require('path').dirname(SESSION_PERSIST_PATH), { recursive: true });
|
|
26
46
|
fs.writeFileSync(SESSION_PERSIST_PATH, JSON.stringify(data, null, 2));
|
|
@@ -100,6 +120,11 @@ function isAllowedPeer(ip) {
|
|
|
100
120
|
return true;
|
|
101
121
|
}
|
|
102
122
|
|
|
123
|
+
// Health check – no auth required
|
|
124
|
+
app.get('/api/health', (req, res) => {
|
|
125
|
+
res.json({ status: 'ok', version: pkg.version });
|
|
126
|
+
});
|
|
127
|
+
|
|
103
128
|
// Authentication Middleware
|
|
104
129
|
app.use((req, res, next) => {
|
|
105
130
|
const clientIp = req.ip;
|
|
@@ -120,7 +145,7 @@ app.use((req, res, next) => {
|
|
|
120
145
|
}
|
|
121
146
|
|
|
122
147
|
console.warn(`[AUTH] Rejected unauthorized request from ${clientIp}`);
|
|
123
|
-
res.status(401).json({ error: 'Unauthorized: Invalid or missing token.' });
|
|
148
|
+
res.status(401).json({ error: 'Unauthorized: Invalid or missing token.', code: 'PERMISSION_DENIED' });
|
|
124
149
|
});
|
|
125
150
|
|
|
126
151
|
const PORT = process.env.PORT || 3848;
|
|
@@ -139,6 +164,349 @@ const sessions = {};
|
|
|
139
164
|
const handoffs = {};
|
|
140
165
|
const threads = {};
|
|
141
166
|
|
|
167
|
+
function broadcastBusEvent(event) {
|
|
168
|
+
const serialized = JSON.stringify(event);
|
|
169
|
+
busClients.forEach((client) => {
|
|
170
|
+
if (client.readyState === 1) client.send(serialized);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildErrorBody(code, error, extra = {}) {
|
|
175
|
+
return { success: false, code, error, ...extra };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function respondWithError(res, httpStatus, code, error, extra = {}) {
|
|
179
|
+
return res.status(httpStatus).json(buildErrorBody(code, error, extra));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isOpenWebSocket(ws) {
|
|
183
|
+
return Boolean(ws && ws.readyState === 1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeNullableText(value) {
|
|
187
|
+
if (value === undefined || value === null) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const normalized = String(value).trim();
|
|
192
|
+
return normalized.length > 0 ? normalized : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getSessionDisconnectedMs(session, nowMs = Date.now()) {
|
|
196
|
+
if (!session.lastDisconnectedAt) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return Math.max(0, nowMs - new Date(session.lastDisconnectedAt).getTime());
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getSessionHealthStatus(session, options = {}) {
|
|
204
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
205
|
+
const staleMs = (options.staleSeconds ?? SESSION_STALE_SECONDS) * 1000;
|
|
206
|
+
const disconnectedMs = getSessionDisconnectedMs(session, nowMs);
|
|
207
|
+
|
|
208
|
+
if (session.type === 'wrapped') {
|
|
209
|
+
if (isOpenWebSocket(session.ownerWs)) {
|
|
210
|
+
return 'CONNECTED';
|
|
211
|
+
}
|
|
212
|
+
if (disconnectedMs !== null && disconnectedMs >= staleMs) {
|
|
213
|
+
return 'STALE';
|
|
214
|
+
}
|
|
215
|
+
return 'DISCONNECTED';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (session.type === 'aterm') {
|
|
219
|
+
if (session.deliveryEndpoint) {
|
|
220
|
+
return 'CONNECTED';
|
|
221
|
+
}
|
|
222
|
+
if (disconnectedMs !== null && disconnectedMs >= staleMs) {
|
|
223
|
+
return 'STALE';
|
|
224
|
+
}
|
|
225
|
+
return 'DISCONNECTED';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return session.ptyProcess && !session.ptyProcess.killed ? 'CONNECTED' : 'DISCONNECTED';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getSessionHealthReason(session, healthStatus) {
|
|
232
|
+
if (session.type === 'wrapped') {
|
|
233
|
+
if (healthStatus === 'CONNECTED') return session.ready ? 'OWNER_CONNECTED' : 'OWNER_CONNECTED_NOT_READY';
|
|
234
|
+
if (healthStatus === 'STALE') return 'OWNER_DISCONNECTED_STALE';
|
|
235
|
+
return 'OWNER_DISCONNECTED';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (session.type === 'aterm') {
|
|
239
|
+
if (healthStatus === 'CONNECTED') return 'DELIVERY_ENDPOINT_AVAILABLE';
|
|
240
|
+
if (healthStatus === 'STALE') return 'DELIVERY_ENDPOINT_STALE';
|
|
241
|
+
return 'DELIVERY_ENDPOINT_MISSING';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return session.ptyProcess && !session.ptyProcess.killed ? 'PTY_RUNNING' : 'PTY_EXITED';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function buildSessionTransportBlock(session, options = {}) {
|
|
248
|
+
if (!session) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
253
|
+
const idleSeconds = session.lastActivityAt ? Math.floor((nowMs - new Date(session.lastActivityAt).getTime()) / 1000) : null;
|
|
254
|
+
const disconnectedMs = getSessionDisconnectedMs(session, nowMs);
|
|
255
|
+
const healthStatus = getSessionHealthStatus(session, { nowMs });
|
|
256
|
+
const healthReason = getSessionHealthReason(session, healthStatus);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
health_status: healthStatus,
|
|
260
|
+
health_reason: healthReason,
|
|
261
|
+
type: session.type || 'spawned',
|
|
262
|
+
backend: session.backend || 'kitty',
|
|
263
|
+
terminal: getSessionTerminalLabel(session),
|
|
264
|
+
active_clients: session.clients ? session.clients.size : 0,
|
|
265
|
+
ready: session.ready || false,
|
|
266
|
+
idle_seconds: idleSeconds,
|
|
267
|
+
disconnected_seconds: disconnectedMs === null ? null : Math.floor(disconnectedMs / 1000),
|
|
268
|
+
last_activity_at: session.lastActivityAt || null,
|
|
269
|
+
last_connected_at: session.lastConnectedAt || null,
|
|
270
|
+
last_disconnected_at: session.lastDisconnectedAt || null,
|
|
271
|
+
last_inject_from: session.lastInjectFrom || null,
|
|
272
|
+
last_reply_to: session.lastInjectReplyTo || null,
|
|
273
|
+
last_thread_id: session.lastThreadId || null
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function buildSessionSemanticBlock(session) {
|
|
278
|
+
if (!session || !session.stateReport) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const report = session.stateReport;
|
|
283
|
+
return {
|
|
284
|
+
phase: report.phase,
|
|
285
|
+
current_task: report.current_task,
|
|
286
|
+
blocker: report.blocker,
|
|
287
|
+
needs_input: report.needs_input,
|
|
288
|
+
thread_id: report.thread_id,
|
|
289
|
+
source: report.source,
|
|
290
|
+
seq: report.seq
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function buildSessionEvent(eventType, sessionId, session, options = {}) {
|
|
295
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
296
|
+
const timestamp = options.timestamp || new Date(nowMs).toISOString();
|
|
297
|
+
return {
|
|
298
|
+
type: eventType,
|
|
299
|
+
event_type: eventType,
|
|
300
|
+
sender: options.sender || 'daemon',
|
|
301
|
+
session_id: sessionId,
|
|
302
|
+
timestamp,
|
|
303
|
+
transport: options.includeTransport === false ? null : buildSessionTransportBlock(session, { nowMs }),
|
|
304
|
+
semantic: options.includeSemantic === false ? null : buildSessionSemanticBlock(session),
|
|
305
|
+
...(options.extra || {})
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function broadcastSessionEvent(eventType, sessionId, session, options = {}) {
|
|
310
|
+
const event = buildSessionEvent(eventType, sessionId, session, options);
|
|
311
|
+
broadcastBusEvent(event);
|
|
312
|
+
return event;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function parseSessionStateReport(session, payload = {}) {
|
|
316
|
+
if (!payload || typeof payload !== 'object') {
|
|
317
|
+
return buildErrorBody('INVALID_REQUEST', 'state report payload must be a JSON object', { httpStatus: 400 });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const phase = normalizeNullableText(payload.phase || payload.task_phase);
|
|
321
|
+
if (!phase) {
|
|
322
|
+
return buildErrorBody('INVALID_REQUEST', 'phase is required', { httpStatus: 400 });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let seq;
|
|
326
|
+
if (payload.seq === undefined || payload.seq === null || payload.seq === '') {
|
|
327
|
+
seq = ((session && session.stateReport && session.stateReport.seq) || 0) + 1;
|
|
328
|
+
} else {
|
|
329
|
+
seq = Number(payload.seq);
|
|
330
|
+
if (!Number.isInteger(seq) || seq < 0) {
|
|
331
|
+
return buildErrorBody('INVALID_REQUEST', 'seq must be a non-negative integer', { httpStatus: 400 });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (payload.needs_input !== undefined && typeof payload.needs_input !== 'boolean') {
|
|
336
|
+
return buildErrorBody('INVALID_REQUEST', 'needs_input must be a boolean', { httpStatus: 400 });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const source = normalizeNullableText(payload.source) || 'self_report';
|
|
340
|
+
const timestamp = new Date().toISOString();
|
|
341
|
+
return {
|
|
342
|
+
success: true,
|
|
343
|
+
report: {
|
|
344
|
+
phase,
|
|
345
|
+
current_task: normalizeNullableText(payload.current_task ?? payload.task),
|
|
346
|
+
blocker: normalizeNullableText(payload.blocker),
|
|
347
|
+
needs_input: payload.needs_input === true,
|
|
348
|
+
thread_id: normalizeNullableText(payload.thread_id),
|
|
349
|
+
source,
|
|
350
|
+
seq,
|
|
351
|
+
timestamp
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function applySessionStateReport(sessionId, session, payload = {}) {
|
|
357
|
+
const parsed = parseSessionStateReport(session, payload);
|
|
358
|
+
if (!parsed.success) {
|
|
359
|
+
return parsed;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
session.stateReport = parsed.report;
|
|
363
|
+
session.lastStateReportAt = parsed.report.timestamp;
|
|
364
|
+
if (parsed.report.thread_id) {
|
|
365
|
+
session.lastThreadId = parsed.report.thread_id;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const event = broadcastSessionEvent('session_state_report', sessionId, session, {
|
|
369
|
+
timestamp: parsed.report.timestamp
|
|
370
|
+
});
|
|
371
|
+
return {
|
|
372
|
+
success: true,
|
|
373
|
+
event,
|
|
374
|
+
semantic: buildSessionSemanticBlock(session),
|
|
375
|
+
transport: buildSessionTransportBlock(session, { nowMs: Date.parse(parsed.report.timestamp) })
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function getInjectFailure(session, options = {}) {
|
|
380
|
+
const healthStatus = getSessionHealthStatus(session, options);
|
|
381
|
+
if (healthStatus === 'STALE') {
|
|
382
|
+
return { httpStatus: 410, code: 'STALE', error: 'Session is stale and awaiting cleanup.' };
|
|
383
|
+
}
|
|
384
|
+
if (healthStatus === 'DISCONNECTED') {
|
|
385
|
+
return { httpStatus: 503, code: 'DISCONNECTED', error: 'Session owner is disconnected.' };
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function markSessionConnected(session, timestamp = new Date().toISOString()) {
|
|
391
|
+
session.lastConnectedAt = timestamp;
|
|
392
|
+
session.lastDisconnectedAt = null;
|
|
393
|
+
session._staleEmitted = false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function markSessionDisconnected(session, timestamp = new Date().toISOString()) {
|
|
397
|
+
session.lastDisconnectedAt = timestamp;
|
|
398
|
+
session.ready = false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function emitSessionLifecycleEvent(type, sessionId, session, extra = {}) {
|
|
402
|
+
const now = Date.now();
|
|
403
|
+
broadcastSessionEvent(type, sessionId, session, {
|
|
404
|
+
nowMs: now,
|
|
405
|
+
extra: {
|
|
406
|
+
healthStatus: getSessionHealthStatus(session, { nowMs: now }),
|
|
407
|
+
healthReason: getSessionHealthReason(session, getSessionHealthStatus(session, { nowMs: now })),
|
|
408
|
+
...extra
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function emitInjectFailureEvent(sessionId, code, error, extra = {}, session = null) {
|
|
414
|
+
broadcastSessionEvent('inject_failed', sessionId, session, {
|
|
415
|
+
extra: {
|
|
416
|
+
target_agent: sessionId,
|
|
417
|
+
code,
|
|
418
|
+
error,
|
|
419
|
+
...extra
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function writeDataToSession(id, session, data) {
|
|
425
|
+
if (session.type === 'aterm') {
|
|
426
|
+
if (!session.deliveryEndpoint) {
|
|
427
|
+
return buildErrorBody('DISCONNECTED', 'Delivery endpoint is missing.', { httpStatus: 503 });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const response = await fetch(session.deliveryEndpoint, {
|
|
432
|
+
method: 'POST',
|
|
433
|
+
headers: { 'Content-Type': 'application/json' },
|
|
434
|
+
body: JSON.stringify({ text: data, session_id: id }),
|
|
435
|
+
signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS)
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
if (!response.ok) {
|
|
439
|
+
return buildErrorBody('DELIVERY_FAILED', `Delivery endpoint returned ${response.status}.`, {
|
|
440
|
+
httpStatus: 502,
|
|
441
|
+
deliveryStatus: response.status
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return { success: true };
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
|
|
448
|
+
return buildErrorBody('TIMEOUT', 'Delivery endpoint timed out.', { httpStatus: 504 });
|
|
449
|
+
}
|
|
450
|
+
return buildErrorBody('DISCONNECTED', 'Delivery endpoint is unreachable.', {
|
|
451
|
+
httpStatus: 503,
|
|
452
|
+
detail: error.message
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (session.type === 'wrapped') {
|
|
458
|
+
if (!isOpenWebSocket(session.ownerWs)) {
|
|
459
|
+
return buildErrorBody('DISCONNECTED', 'Session owner is disconnected.', { httpStatus: 503 });
|
|
460
|
+
}
|
|
461
|
+
session.ownerWs.send(JSON.stringify({ type: 'inject', data }));
|
|
462
|
+
return { success: true };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!session.ptyProcess || session.ptyProcess.killed) {
|
|
466
|
+
return buildErrorBody('DISCONNECTED', 'PTY process is not connected.', { httpStatus: 503 });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
session.ptyProcess.write(data);
|
|
470
|
+
return { success: true };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function deliverInjectionToSession(id, session, prompt, options = {}) {
|
|
474
|
+
const now = Date.now();
|
|
475
|
+
const injectFailure = getInjectFailure(session, { nowMs: now });
|
|
476
|
+
if (injectFailure) {
|
|
477
|
+
return { success: false, ...injectFailure };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const textResult = await writeDataToSession(id, session, prompt);
|
|
481
|
+
if (!textResult.success) {
|
|
482
|
+
return textResult;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!options.noEnter) {
|
|
486
|
+
const submitDelay = session.type === 'wrapped' ? 500 : 300;
|
|
487
|
+
setTimeout(async () => {
|
|
488
|
+
const submitResult = await writeDataToSession(id, session, '\r');
|
|
489
|
+
if (!submitResult.success) {
|
|
490
|
+
emitInjectFailureEvent(id, submitResult.code, submitResult.error, {
|
|
491
|
+
phase: 'submit',
|
|
492
|
+
source: options.source || 'inject'
|
|
493
|
+
}, session);
|
|
494
|
+
}
|
|
495
|
+
}, submitDelay);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
session.lastActivityAt = new Date(now).toISOString();
|
|
499
|
+
return {
|
|
500
|
+
success: true,
|
|
501
|
+
strategy: session.type === 'wrapped'
|
|
502
|
+
? 'ws_split_cr'
|
|
503
|
+
: session.type === 'aterm'
|
|
504
|
+
? 'aterm_endpoint'
|
|
505
|
+
: 'pty_split_cr',
|
|
506
|
+
submit: options.noEnter ? 'skipped' : 'deferred'
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
142
510
|
function appendToOutputRing(session, data) {
|
|
143
511
|
if (!session.outputRing) session.outputRing = [];
|
|
144
512
|
session.outputRing.push(data);
|
|
@@ -150,6 +518,63 @@ function appendToOutputRing(session, data) {
|
|
|
150
518
|
}
|
|
151
519
|
}
|
|
152
520
|
|
|
521
|
+
function getSessionTerminalLabel(session) {
|
|
522
|
+
if (session.termProgram) {
|
|
523
|
+
return session.termProgram;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const term = String(session.term || '').toLowerCase();
|
|
527
|
+
if (term.includes('kitty')) return 'kitty';
|
|
528
|
+
if (term.includes('ghostty')) return 'ghostty';
|
|
529
|
+
if (term.includes('tmux')) return 'tmux';
|
|
530
|
+
|
|
531
|
+
if (session.type === 'aterm') return 'aterm';
|
|
532
|
+
if (session.backend === 'cmux') return 'cmux';
|
|
533
|
+
if (session.backend === 'kitty') return 'kitty';
|
|
534
|
+
if ((session.type || 'spawned') === 'spawned') return 'daemon-pty';
|
|
535
|
+
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function serializeSession(id, session, options = {}) {
|
|
540
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
541
|
+
const idleSeconds = session.lastActivityAt ? Math.floor((nowMs - new Date(session.lastActivityAt).getTime()) / 1000) : null;
|
|
542
|
+
const projectId = session.cwd ? session.cwd.split('/').pop() : null;
|
|
543
|
+
const healthStatus = getSessionHealthStatus(session, { nowMs });
|
|
544
|
+
const healthReason = getSessionHealthReason(session, healthStatus);
|
|
545
|
+
const disconnectedMs = getSessionDisconnectedMs(session, nowMs);
|
|
546
|
+
const transport = buildSessionTransportBlock(session, { nowMs });
|
|
547
|
+
const semantic = buildSessionSemanticBlock(session);
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
id,
|
|
551
|
+
locator: { machine_id: MACHINE_ID, session_id: id, project_id: projectId },
|
|
552
|
+
type: session.type || 'spawned',
|
|
553
|
+
command: session.command,
|
|
554
|
+
cwd: session.cwd,
|
|
555
|
+
backend: session.backend || 'kitty',
|
|
556
|
+
terminal: getSessionTerminalLabel(session),
|
|
557
|
+
termProgram: session.termProgram || null,
|
|
558
|
+
term: session.term || null,
|
|
559
|
+
cmuxWorkspaceId: session.cmuxWorkspaceId || null,
|
|
560
|
+
cmuxSurfaceId: session.cmuxSurfaceId || null,
|
|
561
|
+
createdAt: session.createdAt,
|
|
562
|
+
lastActivityAt: session.lastActivityAt || null,
|
|
563
|
+
lastConnectedAt: session.lastConnectedAt || null,
|
|
564
|
+
lastDisconnectedAt: session.lastDisconnectedAt || null,
|
|
565
|
+
idleSeconds,
|
|
566
|
+
active_clients: session.clients ? session.clients.size : 0,
|
|
567
|
+
ready: session.ready || false,
|
|
568
|
+
deliveryEndpoint: session.deliveryEndpoint || null,
|
|
569
|
+
healthStatus,
|
|
570
|
+
healthReason,
|
|
571
|
+
disconnectedSeconds: disconnectedMs === null ? null : Math.floor(disconnectedMs / 1000),
|
|
572
|
+
lastStateReportAt: session.lastStateReportAt || null,
|
|
573
|
+
transport,
|
|
574
|
+
semantic
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
153
578
|
// Detect terminal environment at daemon startup
|
|
154
579
|
const DETECTED_TERMINAL = terminalBackend.detectTerminal();
|
|
155
580
|
console.log(`[DAEMON] Terminal backend: ${DETECTED_TERMINAL}`);
|
|
@@ -161,8 +586,17 @@ for (const [id, meta] of Object.entries(_persisted)) {
|
|
|
161
586
|
sessions[id] = {
|
|
162
587
|
id, type: 'wrapped', ptyProcess: null, ownerWs: null,
|
|
163
588
|
command: meta.command || 'wrapped', cwd: meta.cwd || process.cwd(),
|
|
589
|
+
backend: meta.backend || 'kitty',
|
|
590
|
+
cmuxWorkspaceId: meta.cmuxWorkspaceId || null,
|
|
591
|
+
cmuxSurfaceId: meta.cmuxSurfaceId || null,
|
|
592
|
+
termProgram: meta.termProgram || null,
|
|
593
|
+
term: meta.term || null,
|
|
164
594
|
createdAt: meta.createdAt || new Date().toISOString(),
|
|
165
595
|
lastActivityAt: meta.lastActivityAt || new Date().toISOString(),
|
|
596
|
+
lastConnectedAt: meta.lastConnectedAt || null,
|
|
597
|
+
lastDisconnectedAt: meta.lastDisconnectedAt || meta.lastActivityAt || new Date().toISOString(),
|
|
598
|
+
lastStateReportAt: meta.lastStateReportAt || null,
|
|
599
|
+
stateReport: meta.stateReport || null,
|
|
166
600
|
clients: new Set(), isClosing: false, outputRing: [], ready: false, };
|
|
167
601
|
console.log(`[PERSIST] Restored session ${id} (awaiting reconnect)`);
|
|
168
602
|
}
|
|
@@ -270,6 +704,10 @@ app.post('/api/sessions/spawn', (req, res) => {
|
|
|
270
704
|
cwd,
|
|
271
705
|
createdAt: new Date().toISOString(),
|
|
272
706
|
lastActivityAt: new Date().toISOString(),
|
|
707
|
+
lastConnectedAt: new Date().toISOString(),
|
|
708
|
+
lastDisconnectedAt: null,
|
|
709
|
+
lastStateReportAt: null,
|
|
710
|
+
stateReport: null,
|
|
273
711
|
clients: new Set(),
|
|
274
712
|
isClosing: false,
|
|
275
713
|
outputRing: [],
|
|
@@ -323,7 +761,7 @@ app.post('/api/sessions/spawn', (req, res) => {
|
|
|
323
761
|
});
|
|
324
762
|
|
|
325
763
|
app.post('/api/sessions/register', (req, res) => {
|
|
326
|
-
const { session_id, command, cwd = process.cwd(), backend, cmux_workspace_id, cmux_surface_id } = req.body;
|
|
764
|
+
const { session_id, command, cwd = process.cwd(), backend, cmux_workspace_id, cmux_surface_id, term_program, term } = req.body;
|
|
327
765
|
if (!session_id) return res.status(400).json({ error: 'session_id is required' });
|
|
328
766
|
// Entitlement: check session limit for new registrations
|
|
329
767
|
if (!sessions[session_id]) {
|
|
@@ -342,9 +780,14 @@ app.post('/api/sessions/register', (req, res) => {
|
|
|
342
780
|
if (backend) existing.backend = backend;
|
|
343
781
|
if (cmux_workspace_id) existing.cmuxWorkspaceId = cmux_workspace_id;
|
|
344
782
|
if (cmux_surface_id) existing.cmuxSurfaceId = cmux_surface_id;
|
|
783
|
+
if (Object.prototype.hasOwnProperty.call(req.body, 'term_program')) existing.termProgram = term_program || null;
|
|
784
|
+
if (Object.prototype.hasOwnProperty.call(req.body, 'term')) existing.term = term || null;
|
|
345
785
|
if (req.body.delivery_type) existing.type = req.body.delivery_type;
|
|
346
786
|
if (req.body.delivery_endpoint) existing.deliveryEndpoint = req.body.delivery_endpoint;
|
|
347
|
-
if (req.body.delivery_type === 'aterm')
|
|
787
|
+
if (req.body.delivery_type === 'aterm') {
|
|
788
|
+
existing.ready = true;
|
|
789
|
+
markSessionConnected(existing);
|
|
790
|
+
}
|
|
348
791
|
console.log(`[REGISTER] Re-registered session ${session_id} (type: ${existing.type}, updated metadata)`);
|
|
349
792
|
return res.status(200).json({ session_id, type: existing.type, command: existing.command, cwd: existing.cwd, reregistered: true });
|
|
350
793
|
}
|
|
@@ -360,9 +803,15 @@ app.post('/api/sessions/register', (req, res) => {
|
|
|
360
803
|
backend: backend || 'kitty',
|
|
361
804
|
cmuxWorkspaceId: cmux_workspace_id || null,
|
|
362
805
|
cmuxSurfaceId: cmux_surface_id || null,
|
|
806
|
+
termProgram: term_program || null,
|
|
807
|
+
term: term || null,
|
|
363
808
|
deliveryEndpoint: delivery_endpoint || null,
|
|
364
809
|
createdAt: new Date().toISOString(),
|
|
365
810
|
lastActivityAt: new Date().toISOString(),
|
|
811
|
+
lastConnectedAt: delivery_type === 'aterm' ? new Date().toISOString() : null,
|
|
812
|
+
lastDisconnectedAt: delivery_type === 'aterm' ? null : new Date().toISOString(),
|
|
813
|
+
lastStateReportAt: null,
|
|
814
|
+
stateReport: null,
|
|
366
815
|
clients: new Set(),
|
|
367
816
|
isClosing: false,
|
|
368
817
|
outputRing: [],
|
|
@@ -410,26 +859,7 @@ app.post('/api/sessions/register', (req, res) => {
|
|
|
410
859
|
app.get('/api/sessions', (req, res) => {
|
|
411
860
|
const idleGt = req.query.idle_gt ? Number(req.query.idle_gt) : null;
|
|
412
861
|
const now = Date.now();
|
|
413
|
-
let list = Object.entries(sessions).map(([id, session]) => {
|
|
414
|
-
const idleSeconds = session.lastActivityAt ? Math.floor((now - new Date(session.lastActivityAt).getTime()) / 1000) : null;
|
|
415
|
-
const projectId = session.cwd ? session.cwd.split('/').pop() : null;
|
|
416
|
-
return {
|
|
417
|
-
id,
|
|
418
|
-
locator: { machine_id: MACHINE_ID, session_id: id, project_id: projectId },
|
|
419
|
-
type: session.type || 'spawned',
|
|
420
|
-
command: session.command,
|
|
421
|
-
cwd: session.cwd,
|
|
422
|
-
backend: session.backend || 'kitty',
|
|
423
|
-
cmuxWorkspaceId: session.cmuxWorkspaceId || null,
|
|
424
|
-
cmuxSurfaceId: session.cmuxSurfaceId || null,
|
|
425
|
-
createdAt: session.createdAt,
|
|
426
|
-
lastActivityAt: session.lastActivityAt || null,
|
|
427
|
-
idleSeconds,
|
|
428
|
-
active_clients: session.clients.size,
|
|
429
|
-
ready: session.ready || false,
|
|
430
|
-
deliveryEndpoint: session.deliveryEndpoint || null
|
|
431
|
-
};
|
|
432
|
-
});
|
|
862
|
+
let list = Object.entries(sessions).map(([id, session]) => serializeSession(id, session, { nowMs: now }));
|
|
433
863
|
if (idleGt !== null) {
|
|
434
864
|
list = list.filter(s => s.idleSeconds !== null && s.idleSeconds > idleGt);
|
|
435
865
|
}
|
|
@@ -441,24 +871,33 @@ app.get('/api/sessions/:id', (req, res) => {
|
|
|
441
871
|
const resolvedId = resolveSessionAlias(requestedId);
|
|
442
872
|
if (!resolvedId) return res.status(404).json({ error: 'Session not found' });
|
|
443
873
|
const session = sessions[resolvedId];
|
|
444
|
-
const idleSeconds = session.lastActivityAt ? Math.floor((Date.now() - new Date(session.lastActivityAt).getTime()) / 1000) : null;
|
|
445
|
-
const projectId = session.cwd ? session.cwd.split('/').pop() : null;
|
|
446
874
|
res.json({
|
|
447
|
-
|
|
448
|
-
locator: { machine_id: MACHINE_ID, session_id: resolvedId, project_id: projectId },
|
|
875
|
+
...serializeSession(resolvedId, session),
|
|
449
876
|
alias: requestedId !== resolvedId ? requestedId : null,
|
|
450
|
-
type: session.type || 'spawned',
|
|
451
|
-
command: session.command,
|
|
452
|
-
cwd: session.cwd,
|
|
453
|
-
createdAt: session.createdAt,
|
|
454
|
-
lastActivityAt: session.lastActivityAt || null,
|
|
455
|
-
idleSeconds,
|
|
456
|
-
active_clients: session.clients ? session.clients.size : 0,
|
|
457
877
|
lastInjectFrom: session.lastInjectFrom || null,
|
|
458
878
|
lastInjectReplyTo: session.lastInjectReplyTo || null
|
|
459
879
|
});
|
|
460
880
|
});
|
|
461
881
|
|
|
882
|
+
app.post('/api/sessions/:id/state', (req, res) => {
|
|
883
|
+
const requestedId = req.params.id;
|
|
884
|
+
const resolvedId = resolveSessionAlias(requestedId);
|
|
885
|
+
if (!resolvedId) return respondWithError(res, 404, 'SESSION_NOT_FOUND', 'Session not found', { requested: requestedId });
|
|
886
|
+
const session = sessions[resolvedId];
|
|
887
|
+
const applied = applySessionStateReport(resolvedId, session, req.body);
|
|
888
|
+
if (!applied.success) {
|
|
889
|
+
return respondWithError(res, applied.httpStatus || 400, applied.code || 'INVALID_REQUEST', applied.error);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
persistSessions();
|
|
893
|
+
res.json({
|
|
894
|
+
success: true,
|
|
895
|
+
session_id: resolvedId,
|
|
896
|
+
transport: applied.transport,
|
|
897
|
+
semantic: applied.semantic
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
|
|
462
901
|
app.get('/api/meta', (req, res) => {
|
|
463
902
|
res.json({
|
|
464
903
|
name: pkg.name,
|
|
@@ -485,124 +924,68 @@ app.get('/api/peers', (req, res) => {
|
|
|
485
924
|
}
|
|
486
925
|
});
|
|
487
926
|
|
|
488
|
-
app.post('/api/sessions/multicast/inject', (req, res) => {
|
|
927
|
+
app.post('/api/sessions/multicast/inject', async (req, res) => {
|
|
489
928
|
const { session_ids, prompt } = req.body;
|
|
490
|
-
if (
|
|
929
|
+
if (typeof prompt !== 'string' || prompt.length === 0) return respondWithError(res, 400, 'INVALID_REQUEST', 'prompt is required');
|
|
491
930
|
if (!Array.isArray(session_ids)) return res.status(400).json({ error: 'session_ids must be an array' });
|
|
492
931
|
|
|
493
932
|
const results = { successful: [], failed: [] };
|
|
494
933
|
|
|
495
|
-
|
|
934
|
+
for (const id of session_ids) {
|
|
496
935
|
const session = sessions[id];
|
|
497
936
|
if (session) {
|
|
498
937
|
try {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
// Broadcast injection to bus
|
|
506
|
-
const busMsg = JSON.stringify({
|
|
507
|
-
type: 'injection',
|
|
508
|
-
sender: 'cli',
|
|
509
|
-
target_agent: id,
|
|
510
|
-
content: prompt,
|
|
511
|
-
timestamp: new Date().toISOString()
|
|
512
|
-
});
|
|
513
|
-
busClients.forEach(client => {
|
|
514
|
-
if (client.readyState === 1) client.send(busMsg);
|
|
515
|
-
});
|
|
516
|
-
return; // skip WS path for this session
|
|
517
|
-
}
|
|
938
|
+
const delivery = await deliverInjectionToSession(id, session, prompt, {
|
|
939
|
+
source: 'multicast'
|
|
940
|
+
});
|
|
941
|
+
if (!delivery.success) {
|
|
942
|
+
results.failed.push({ id, code: delivery.code, error: delivery.error });
|
|
943
|
+
continue;
|
|
518
944
|
}
|
|
519
945
|
|
|
520
|
-
|
|
521
|
-
if (session.type === 'wrapped') {
|
|
522
|
-
if (session.ownerWs && session.ownerWs.readyState === 1) {
|
|
523
|
-
session.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
|
|
524
|
-
setTimeout(() => {
|
|
525
|
-
if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
526
|
-
submitViaCmux(id);
|
|
527
|
-
} else if (session.ownerWs && session.ownerWs.readyState === 1) {
|
|
528
|
-
session.ownerWs.send(JSON.stringify({ type: 'inject', data: '\r' }));
|
|
529
|
-
}
|
|
530
|
-
}, 300);
|
|
531
|
-
results.successful.push({ id, strategy: session.backend === 'cmux' ? 'cmux_split_cr' : 'split_cr' });
|
|
532
|
-
} else {
|
|
533
|
-
results.failed.push({ id, error: 'Wrap process not connected' });
|
|
534
|
-
}
|
|
535
|
-
} else {
|
|
536
|
-
session.ptyProcess.write(prompt);
|
|
537
|
-
setTimeout(() => session.ptyProcess.write('\r'), 300);
|
|
538
|
-
results.successful.push({ id, strategy: 'split_cr' });
|
|
539
|
-
}
|
|
946
|
+
results.successful.push({ id, strategy: delivery.strategy });
|
|
540
947
|
|
|
541
948
|
// Broadcast injection to bus
|
|
542
|
-
|
|
949
|
+
broadcastBusEvent({
|
|
543
950
|
type: 'injection',
|
|
544
951
|
sender: 'cli',
|
|
545
952
|
target_agent: id,
|
|
546
953
|
content: prompt,
|
|
547
954
|
timestamp: new Date().toISOString()
|
|
548
955
|
});
|
|
549
|
-
busClients.forEach(client => {
|
|
550
|
-
if (client.readyState === 1) client.send(busMsg);
|
|
551
|
-
});
|
|
552
956
|
} catch (err) {
|
|
553
|
-
results.failed.push({ id, error: err.message });
|
|
957
|
+
results.failed.push({ id, code: 'DELIVERY_FAILED', error: err.message });
|
|
554
958
|
}
|
|
555
959
|
} else {
|
|
556
|
-
results.failed.push({ id, error: 'Session not found' });
|
|
960
|
+
results.failed.push({ id, code: 'SESSION_NOT_FOUND', error: 'Session not found' });
|
|
557
961
|
}
|
|
558
|
-
}
|
|
962
|
+
}
|
|
559
963
|
|
|
560
964
|
res.json({ success: true, results });
|
|
561
965
|
});
|
|
562
966
|
|
|
563
|
-
app.post('/api/sessions/broadcast/inject', (req, res) => {
|
|
967
|
+
app.post('/api/sessions/broadcast/inject', async (req, res) => {
|
|
564
968
|
const { prompt } = req.body;
|
|
565
|
-
if (
|
|
969
|
+
if (typeof prompt !== 'string' || prompt.length === 0) return respondWithError(res, 400, 'INVALID_REQUEST', 'prompt is required');
|
|
566
970
|
|
|
567
971
|
const results = { successful: [], failed: [] };
|
|
568
972
|
|
|
569
|
-
Object.keys(sessions)
|
|
973
|
+
for (const id of Object.keys(sessions)) {
|
|
570
974
|
const session = sessions[id];
|
|
571
975
|
try {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
return; // skip WS path for this session
|
|
579
|
-
}
|
|
976
|
+
const delivery = await deliverInjectionToSession(id, session, prompt, {
|
|
977
|
+
source: 'broadcast'
|
|
978
|
+
});
|
|
979
|
+
if (!delivery.success) {
|
|
980
|
+
results.failed.push({ id, code: delivery.code, error: delivery.error });
|
|
981
|
+
continue;
|
|
580
982
|
}
|
|
581
983
|
|
|
582
|
-
|
|
583
|
-
if (session.type === 'wrapped') {
|
|
584
|
-
if (session.ownerWs && session.ownerWs.readyState === 1) {
|
|
585
|
-
session.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
|
|
586
|
-
setTimeout(() => {
|
|
587
|
-
if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
588
|
-
submitViaCmux(id);
|
|
589
|
-
} else if (session.ownerWs && session.ownerWs.readyState === 1) {
|
|
590
|
-
session.ownerWs.send(JSON.stringify({ type: 'inject', data: '\r' }));
|
|
591
|
-
}
|
|
592
|
-
}, 300);
|
|
593
|
-
results.successful.push({ id, strategy: session.backend === 'cmux' ? 'cmux_split_cr' : 'split_cr' });
|
|
594
|
-
} else {
|
|
595
|
-
results.failed.push({ id, error: 'Wrap process not connected' });
|
|
596
|
-
}
|
|
597
|
-
} else {
|
|
598
|
-
session.ptyProcess.write(prompt);
|
|
599
|
-
setTimeout(() => session.ptyProcess.write('\r'), 300);
|
|
600
|
-
results.successful.push({ id, strategy: 'split_cr' });
|
|
601
|
-
}
|
|
984
|
+
results.successful.push({ id, strategy: delivery.strategy });
|
|
602
985
|
} catch (err) {
|
|
603
|
-
results.failed.push({ id, error: err.message });
|
|
986
|
+
results.failed.push({ id, code: 'DELIVERY_FAILED', error: err.message });
|
|
604
987
|
}
|
|
605
|
-
}
|
|
988
|
+
}
|
|
606
989
|
|
|
607
990
|
// Send a single bus event for the entire broadcast (not per-session)
|
|
608
991
|
if (results.successful.length > 0) {
|
|
@@ -781,32 +1164,49 @@ function submitViaCmux(sessionId) {
|
|
|
781
1164
|
}
|
|
782
1165
|
|
|
783
1166
|
// POST /api/sessions/:id/submit — CLI-aware submit
|
|
784
|
-
app.post('/api/sessions/:id/submit', (req, res) => {
|
|
1167
|
+
app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
785
1168
|
const requestedId = req.params.id;
|
|
786
1169
|
const resolvedId = resolveSessionAlias(requestedId);
|
|
787
1170
|
if (!resolvedId) return res.status(404).json({ error: 'Session not found', requested: requestedId });
|
|
788
1171
|
const session = sessions[resolvedId];
|
|
789
1172
|
const id = resolvedId;
|
|
790
1173
|
|
|
1174
|
+
const retries = Math.min(Math.max(Number(req.body?.retries) || 0, 0), 3);
|
|
1175
|
+
const retryDelayMs = Math.min(Math.max(Number(req.body?.retry_delay_ms) || 500, 100), 2000);
|
|
1176
|
+
const preDelayMs = Math.min(Math.max(Number(req.body?.pre_delay_ms) || 0, 0), 1000);
|
|
1177
|
+
|
|
791
1178
|
const strategy = getSubmitStrategy(session.command);
|
|
792
|
-
console.log(`[SUBMIT] Session ${id} (${session.command})
|
|
1179
|
+
console.log(`[SUBMIT] Session ${id} (${session.command}) strategy: ${strategy}${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}`);
|
|
793
1180
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
success = terminalBackend.cmuxSendEnter(id);
|
|
798
|
-
}
|
|
799
|
-
if (!success && session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
800
|
-
success = submitViaCmux(id);
|
|
1181
|
+
// Pre-delay: wait for paste processing to complete before sending CR
|
|
1182
|
+
if (preDelayMs > 0) {
|
|
1183
|
+
await new Promise(resolve => setTimeout(resolve, preDelayMs));
|
|
801
1184
|
}
|
|
802
|
-
|
|
1185
|
+
|
|
1186
|
+
function executeSubmit() {
|
|
1187
|
+
// cmux per-session backend
|
|
1188
|
+
if (session.backend === 'cmux') {
|
|
1189
|
+
if (terminalBackend.cmuxSendEnter(id)) return true;
|
|
1190
|
+
}
|
|
1191
|
+
if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
1192
|
+
if (submitViaCmux(id)) return true;
|
|
1193
|
+
}
|
|
803
1194
|
if (strategy === 'pty_cr') {
|
|
804
|
-
|
|
1195
|
+
return submitViaPty(session);
|
|
805
1196
|
} else if (strategy === 'osascript_cmd_enter') {
|
|
806
|
-
|
|
807
|
-
} else {
|
|
808
|
-
success = submitViaPty(session); // fallback
|
|
1197
|
+
return submitViaOsascript(id, 'cmd_enter');
|
|
809
1198
|
}
|
|
1199
|
+
return submitViaPty(session); // fallback
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
let success = executeSubmit();
|
|
1203
|
+
let attempts = 1;
|
|
1204
|
+
|
|
1205
|
+
// Retry: resend CR if paste may have absorbed the first one
|
|
1206
|
+
for (let i = 0; i < retries && success; i++) {
|
|
1207
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
|
1208
|
+
executeSubmit();
|
|
1209
|
+
attempts++;
|
|
810
1210
|
}
|
|
811
1211
|
|
|
812
1212
|
if (success) {
|
|
@@ -815,14 +1215,15 @@ app.post('/api/sessions/:id/submit', (req, res) => {
|
|
|
815
1215
|
sender: 'daemon',
|
|
816
1216
|
session_id: id,
|
|
817
1217
|
strategy,
|
|
1218
|
+
attempts,
|
|
818
1219
|
timestamp: new Date().toISOString()
|
|
819
1220
|
});
|
|
820
1221
|
busClients.forEach(client => {
|
|
821
1222
|
if (client.readyState === 1) client.send(busMsg);
|
|
822
1223
|
});
|
|
823
|
-
res.json({ success: true, strategy });
|
|
1224
|
+
res.json({ success: true, strategy, attempts });
|
|
824
1225
|
} else {
|
|
825
|
-
res.status(503).json({ error: `Submit failed via ${strategy}`, strategy });
|
|
1226
|
+
res.status(503).json({ error: `Submit failed via ${strategy}`, strategy, attempts });
|
|
826
1227
|
}
|
|
827
1228
|
});
|
|
828
1229
|
|
|
@@ -859,225 +1260,53 @@ app.post('/api/sessions/submit-all', (req, res) => {
|
|
|
859
1260
|
res.json({ success: true, results });
|
|
860
1261
|
});
|
|
861
1262
|
|
|
862
|
-
app.post('/api/sessions/:id/inject', (req, res) => {
|
|
1263
|
+
app.post('/api/sessions/:id/inject', async (req, res) => {
|
|
863
1264
|
const requestedId = req.params.id;
|
|
864
1265
|
const resolvedId = resolveSessionAlias(requestedId);
|
|
865
|
-
if (!resolvedId) return res
|
|
1266
|
+
if (!resolvedId) return respondWithError(res, 404, 'SESSION_NOT_FOUND', 'Session not found', { requested: requestedId });
|
|
866
1267
|
const session = sessions[resolvedId];
|
|
867
1268
|
const id = resolvedId;
|
|
868
1269
|
const { prompt, no_enter, auto_submit, thread_id, reply_expected } = req.body;
|
|
869
1270
|
let { from, reply_to } = req.body;
|
|
870
|
-
if (
|
|
1271
|
+
if (typeof prompt !== 'string') return respondWithError(res, 400, 'INVALID_REQUEST', 'prompt is required');
|
|
871
1272
|
// reply_to defaults to from when omitted
|
|
872
1273
|
if (from && !reply_to) reply_to = from;
|
|
873
|
-
if (from) session.lastInjectFrom = from;
|
|
874
|
-
if (reply_to) session.lastInjectReplyTo = reply_to;
|
|
875
|
-
if (thread_id) session.lastThreadId = thread_id;
|
|
876
|
-
session.lastActivityAt = new Date().toISOString();
|
|
877
1274
|
|
|
878
|
-
//
|
|
879
|
-
|
|
880
|
-
if (from && !prompt.startsWith('[from:')) {
|
|
881
|
-
finalPrompt = `[from: ${from}] [reply-to: ${reply_to}] ${prompt}`;
|
|
882
|
-
}
|
|
883
|
-
// Append reply guide when reply_to is set, UNLESS message contains termination signal
|
|
884
|
-
const TERMINATION_SIGNALS = /no further reply needed|thread closed|closed on .+ side|ack received|ack-only|회신 불필요|스레드 종료/i;
|
|
885
|
-
if (reply_to && reply_to !== id && !TERMINATION_SIGNALS.test(prompt)) {
|
|
886
|
-
finalPrompt += `\n\n---\n[reply-to: ${reply_to}] 위 세션에 회신이 필요합니다. 답변 시 아래 명령을 실행하세요:\ntelepty inject --from ${id} ${reply_to} "답변 내용"\n---`;
|
|
887
|
-
}
|
|
1275
|
+
// Routing metadata stays in session/bus state, not in the visible prompt text.
|
|
1276
|
+
const finalPrompt = prompt;
|
|
888
1277
|
const inject_id = crypto.randomUUID();
|
|
889
1278
|
try {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
signal: AbortSignal.timeout(5000)
|
|
902
|
-
}).catch(() => {});
|
|
903
|
-
return true;
|
|
904
|
-
} catch { return false; }
|
|
905
|
-
} else if (session.type === 'wrapped') {
|
|
906
|
-
if (session.ownerWs && session.ownerWs.readyState === 1) {
|
|
907
|
-
session.ownerWs.send(JSON.stringify({ type: 'inject', data }));
|
|
908
|
-
return true;
|
|
909
|
-
}
|
|
910
|
-
return false;
|
|
911
|
-
} else {
|
|
912
|
-
session.ptyProcess.write(data);
|
|
913
|
-
return true;
|
|
914
|
-
}
|
|
1279
|
+
const delivery = await deliverInjectionToSession(id, session, finalPrompt, {
|
|
1280
|
+
noEnter: !!no_enter,
|
|
1281
|
+
source: 'inject'
|
|
1282
|
+
});
|
|
1283
|
+
if (!delivery.success) {
|
|
1284
|
+
emitInjectFailureEvent(id, delivery.code, delivery.error, {
|
|
1285
|
+
inject_id,
|
|
1286
|
+
from: from || null,
|
|
1287
|
+
reply_to: reply_to || null
|
|
1288
|
+
}, session);
|
|
1289
|
+
return respondWithError(res, delivery.httpStatus || 500, delivery.code || 'DELIVERY_FAILED', delivery.error);
|
|
915
1290
|
}
|
|
916
1291
|
|
|
917
|
-
|
|
918
|
-
if (session.
|
|
919
|
-
|
|
920
|
-
const wsOk = writeToSession(finalPrompt);
|
|
921
|
-
if (!wsOk) {
|
|
922
|
-
return res.status(503).json({ error: 'aterm endpoint not reachable' });
|
|
923
|
-
}
|
|
924
|
-
if (!no_enter) {
|
|
925
|
-
setTimeout(() => writeToSession('\r'), 300);
|
|
926
|
-
submitResult = { deferred: true, strategy: 'aterm_endpoint' };
|
|
927
|
-
}
|
|
928
|
-
} else if (session.type === 'wrapped') {
|
|
929
|
-
// For wrapped sessions: try cmux send (daemon-level auto-detect),
|
|
930
|
-
// then kitty send-text (bypasses allow bridge queue),
|
|
931
|
-
// then WS as fallback, then submit via consistent path for CR.
|
|
932
|
-
//
|
|
933
|
-
// When session is NOT ready (CLI hasn't shown prompt yet), skip cmux/kitty
|
|
934
|
-
// because they bypass the allow-bridge's prompt-ready queue.
|
|
935
|
-
// The WS path sends to the allow-bridge which queues until CLI is ready.
|
|
936
|
-
const sock = session.ready ? findKittySocket() : null;
|
|
937
|
-
if (sock && !session.kittyWindowId) session.kittyWindowId = findKittyWindowId(sock, id);
|
|
938
|
-
const wid = session.ready ? session.kittyWindowId : null;
|
|
939
|
-
|
|
940
|
-
let kittyOk = false;
|
|
941
|
-
let cmuxOk = false;
|
|
942
|
-
let deliveryPath = null; // 'cmux', 'kitty', 'ws'
|
|
943
|
-
|
|
944
|
-
// cmux per-session backend: send text directly to surface (only when ready)
|
|
945
|
-
if (session.ready && session.backend === 'cmux') {
|
|
946
|
-
cmuxOk = terminalBackend.cmuxSendText(id, finalPrompt);
|
|
947
|
-
if (cmuxOk) {
|
|
948
|
-
deliveryPath = 'cmux';
|
|
949
|
-
console.log(`[INJECT] cmux send for ${id}`);
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
if (!cmuxOk && wid && sock) {
|
|
954
|
-
// Kitty send-text primary (only when ready — bypasses allow bridge queue)
|
|
955
|
-
try {
|
|
956
|
-
const escaped = finalPrompt.replace(/\\/g, '\\\\').replace(/'/g, "'\\''");
|
|
957
|
-
require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} '${escaped}'`, {
|
|
958
|
-
timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
|
|
959
|
-
});
|
|
960
|
-
kittyOk = true;
|
|
961
|
-
deliveryPath = 'kitty';
|
|
962
|
-
console.log(`[INJECT] Kitty send-text for ${id} (window ${wid})`);
|
|
963
|
-
} catch {
|
|
964
|
-
// Invalidate cached window ID — window may have changed or been closed
|
|
965
|
-
session.kittyWindowId = null;
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
if (!cmuxOk && !kittyOk) {
|
|
969
|
-
// WS path: allow-bridge has its own prompt-ready queue
|
|
970
|
-
const wsOk = writeToSession(finalPrompt);
|
|
971
|
-
if (!wsOk) {
|
|
972
|
-
return res.status(503).json({ error: 'Process not connected' });
|
|
973
|
-
}
|
|
974
|
-
deliveryPath = 'ws';
|
|
975
|
-
if (!session.ready) {
|
|
976
|
-
console.log(`[INJECT] WS (not ready, allow-bridge will queue) for ${id}`);
|
|
977
|
-
} else {
|
|
978
|
-
console.log(`[INJECT] WS fallback for ${id}`);
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
if (!no_enter) {
|
|
983
|
-
setTimeout(() => {
|
|
984
|
-
let submitted = false;
|
|
985
|
-
|
|
986
|
-
// Use the SAME path that delivered text for CR to guarantee ordering
|
|
987
|
-
if (deliveryPath === 'cmux') {
|
|
988
|
-
// cmux: send-key return via same surface
|
|
989
|
-
if (session.backend === 'cmux') {
|
|
990
|
-
submitted = terminalBackend.cmuxSendEnter(id);
|
|
991
|
-
if (submitted) console.log(`[INJECT] cmux submit for ${id}`);
|
|
992
|
-
}
|
|
993
|
-
if (!submitted && session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
994
|
-
submitted = submitViaCmux(id);
|
|
995
|
-
if (submitted) console.log(`[INJECT] cmux session-level submit for ${id}`);
|
|
996
|
-
}
|
|
997
|
-
} else if (deliveryPath === 'kitty') {
|
|
998
|
-
// kitty: send-text CR via same window (not osascript!)
|
|
999
|
-
if (wid && sock) {
|
|
1000
|
-
try {
|
|
1001
|
-
require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} $'\\r'`, {
|
|
1002
|
-
timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
|
|
1003
|
-
});
|
|
1004
|
-
submitted = true;
|
|
1005
|
-
console.log(`[INJECT] kitty submit for ${id} (window ${wid})`);
|
|
1006
|
-
} catch {
|
|
1007
|
-
session.kittyWindowId = null;
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
// deliveryPath === 'ws' or any fallback:
|
|
1012
|
-
// Try terminal-level submit first (bypasses PTY ICRNL which converts CR→LF)
|
|
1013
|
-
// This matters for cmux/kitty sessions where text went via WS but
|
|
1014
|
-
// the application expects CR(13) not LF(10) from Enter.
|
|
1015
|
-
if (!submitted && session.backend === 'cmux') {
|
|
1016
|
-
submitted = terminalBackend.cmuxSendEnter(id);
|
|
1017
|
-
if (submitted) console.log(`[INJECT] cmux submit (fallback) for ${id}`);
|
|
1018
|
-
}
|
|
1019
|
-
if (!submitted && session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
1020
|
-
submitted = submitViaCmux(id);
|
|
1021
|
-
if (submitted) console.log(`[INJECT] cmux session-level submit (fallback) for ${id}`);
|
|
1022
|
-
}
|
|
1023
|
-
if (!submitted && wid && sock) {
|
|
1024
|
-
try {
|
|
1025
|
-
require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} $'\\r'`, {
|
|
1026
|
-
timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
|
|
1027
|
-
});
|
|
1028
|
-
submitted = true;
|
|
1029
|
-
console.log(`[INJECT] kitty submit (fallback) for ${id}`);
|
|
1030
|
-
} catch {
|
|
1031
|
-
session.kittyWindowId = null;
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
if (!submitted) {
|
|
1035
|
-
writeToSession('\r');
|
|
1036
|
-
console.log(`[INJECT] WS submit for ${id}`);
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
// Update tab title (kitty-specific, safe to fail)
|
|
1040
|
-
if (wid && sock) {
|
|
1041
|
-
try {
|
|
1042
|
-
require('child_process').execSync(`kitty @ --to unix:${sock} set-tab-title --match id:${wid} '⚡ telepty :: ${id}'`, {
|
|
1043
|
-
timeout: 2000, stdio: ['pipe', 'pipe', 'pipe']
|
|
1044
|
-
});
|
|
1045
|
-
} catch {}
|
|
1046
|
-
}
|
|
1047
|
-
}, 500);
|
|
1048
|
-
submitResult = { deferred: true, strategy: deliveryPath || 'ws' };
|
|
1049
|
-
}
|
|
1050
|
-
} else {
|
|
1051
|
-
// Spawned sessions: direct PTY write
|
|
1052
|
-
if (!writeToSession(finalPrompt)) {
|
|
1053
|
-
return res.status(503).json({ error: 'Process not connected' });
|
|
1054
|
-
}
|
|
1055
|
-
if (!no_enter) {
|
|
1056
|
-
setTimeout(() => {
|
|
1057
|
-
writeToSession('\r');
|
|
1058
|
-
console.log(`[INJECT+SUBMIT] PTY split_cr for ${id}`);
|
|
1059
|
-
}, 300);
|
|
1060
|
-
submitResult = { deferred: true, strategy: 'pty_split_cr' };
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1292
|
+
if (from) session.lastInjectFrom = from;
|
|
1293
|
+
if (reply_to) session.lastInjectReplyTo = reply_to;
|
|
1294
|
+
if (thread_id) session.lastThreadId = thread_id;
|
|
1063
1295
|
|
|
1064
1296
|
console.log(`[INJECT] Wrote to session ${id} (inject_id: ${inject_id})`);
|
|
1065
1297
|
|
|
1066
1298
|
const injectTimestamp = new Date().toISOString();
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
});
|
|
1079
|
-
busClients.forEach(client => {
|
|
1080
|
-
if (client.readyState === 1) client.send(busMsg);
|
|
1299
|
+
broadcastSessionEvent('inject_written', id, session, {
|
|
1300
|
+
timestamp: injectTimestamp,
|
|
1301
|
+
extra: {
|
|
1302
|
+
inject_id,
|
|
1303
|
+
target_agent: id,
|
|
1304
|
+
content: prompt,
|
|
1305
|
+
from: from || null,
|
|
1306
|
+
reply_to: reply_to || null,
|
|
1307
|
+
thread_id: thread_id || null,
|
|
1308
|
+
reply_expected: !!reply_expected
|
|
1309
|
+
}
|
|
1081
1310
|
});
|
|
1082
1311
|
|
|
1083
1312
|
// Notify all attached viewers (telepty attach clients) about the inject
|
|
@@ -1119,20 +1348,10 @@ app.post('/api/sessions/:id/inject', (req, res) => {
|
|
|
1119
1348
|
});
|
|
1120
1349
|
}
|
|
1121
1350
|
|
|
1122
|
-
res.json({ success: true, inject_id, submit:
|
|
1351
|
+
res.json({ success: true, inject_id, strategy: delivery.strategy, submit: delivery.submit });
|
|
1123
1352
|
} catch (err) {
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
inject_id,
|
|
1127
|
-
sender: 'daemon',
|
|
1128
|
-
target_agent: id,
|
|
1129
|
-
error: err.message,
|
|
1130
|
-
timestamp: new Date().toISOString()
|
|
1131
|
-
});
|
|
1132
|
-
busClients.forEach(client => {
|
|
1133
|
-
if (client.readyState === 1) client.send(busFailMsg);
|
|
1134
|
-
});
|
|
1135
|
-
res.status(500).json({ error: err.message });
|
|
1353
|
+
emitInjectFailureEvent(id, 'DELIVERY_FAILED', err.message, { inject_id }, session);
|
|
1354
|
+
res.status(500).json(buildErrorBody('DELIVERY_FAILED', err.message));
|
|
1136
1355
|
}
|
|
1137
1356
|
});
|
|
1138
1357
|
|
|
@@ -1240,7 +1459,7 @@ app.delete('/api/sessions/:id', (req, res) => {
|
|
|
1240
1459
|
});
|
|
1241
1460
|
|
|
1242
1461
|
// Shared auto-router: handles turn_request events from any source (WS or HTTP)
|
|
1243
|
-
function busAutoRoute(msg) {
|
|
1462
|
+
async function busAutoRoute(msg) {
|
|
1244
1463
|
const eventType = msg.type || msg.kind;
|
|
1245
1464
|
const isRoutable = (eventType === 'turn_request' || eventType === 'deliberation_route_turn') && (msg.target || msg.target_session_id);
|
|
1246
1465
|
if (!isRoutable) {
|
|
@@ -1258,84 +1477,42 @@ function busAutoRoute(msg) {
|
|
|
1258
1477
|
const targetSession = targetId ? sessions[targetId] : null;
|
|
1259
1478
|
if (!targetSession) {
|
|
1260
1479
|
console.log(`[BUS-ROUTE] Target ${rawTarget} not found among: ${Object.keys(sessions).join(', ')}`);
|
|
1480
|
+
emitInjectFailureEvent(rawTarget, 'SESSION_NOT_FOUND', 'Target session was not found.', {
|
|
1481
|
+
source: 'bus_auto_route',
|
|
1482
|
+
turn_id: turnId,
|
|
1483
|
+
original_message_id: msg.message_id || null
|
|
1484
|
+
});
|
|
1261
1485
|
return;
|
|
1262
1486
|
}
|
|
1263
1487
|
|
|
1264
1488
|
const prompt = (msg.payload && msg.payload.prompt) || msg.content || msg.prompt || JSON.stringify(msg);
|
|
1265
1489
|
const inject_id = crypto.randomUUID();
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
const wid = targetSession.kittyWindowId;
|
|
1271
|
-
let delivered = false;
|
|
1272
|
-
|
|
1273
|
-
// cmux per-session backend: send text + enter to surface
|
|
1274
|
-
if (!delivered && targetSession.backend === 'cmux') {
|
|
1275
|
-
const textOk = terminalBackend.cmuxSendText(targetId, prompt);
|
|
1276
|
-
if (textOk) {
|
|
1277
|
-
setTimeout(() => terminalBackend.cmuxSendEnter(targetId), 500);
|
|
1278
|
-
delivered = true;
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
if (!delivered && wid && sock && targetSession.type === 'wrapped') {
|
|
1283
|
-
try {
|
|
1284
|
-
const escaped = prompt.replace(/\\/g, '\\\\').replace(/'/g, "'\\''");
|
|
1285
|
-
require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} '${escaped}'`, {
|
|
1286
|
-
timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
|
|
1287
|
-
});
|
|
1288
|
-
setTimeout(() => {
|
|
1289
|
-
try {
|
|
1290
|
-
require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} $'\\r'`, {
|
|
1291
|
-
timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
|
|
1292
|
-
});
|
|
1293
|
-
} catch {}
|
|
1294
|
-
}, 500);
|
|
1295
|
-
delivered = true;
|
|
1296
|
-
} catch {}
|
|
1297
|
-
}
|
|
1298
|
-
// Session-level cmux backend: use WS for text, cmux send-key for enter
|
|
1299
|
-
if (!delivered && targetSession.backend === 'cmux' && targetSession.cmuxWorkspaceId) {
|
|
1300
|
-
if (targetSession.type === 'wrapped' && targetSession.ownerWs && targetSession.ownerWs.readyState === 1) {
|
|
1301
|
-
targetSession.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
|
|
1302
|
-
setTimeout(() => submitViaCmux(targetId), 500);
|
|
1303
|
-
delivered = true;
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1490
|
+
const delivery = await deliverInjectionToSession(targetId, targetSession, prompt, {
|
|
1491
|
+
source: 'bus_auto_route'
|
|
1492
|
+
});
|
|
1493
|
+
const delivered = delivery.success === true;
|
|
1306
1494
|
if (!delivered) {
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
}
|
|
1313
|
-
}, 300);
|
|
1314
|
-
delivered = true;
|
|
1315
|
-
} else if (targetSession.ptyProcess) {
|
|
1316
|
-
targetSession.ptyProcess.write(prompt);
|
|
1317
|
-
setTimeout(() => targetSession.ptyProcess.write('\r'), 300);
|
|
1318
|
-
delivered = true;
|
|
1319
|
-
}
|
|
1495
|
+
emitInjectFailureEvent(targetId, delivery.code, delivery.error, {
|
|
1496
|
+
source: 'bus_auto_route',
|
|
1497
|
+
turn_id: turnId,
|
|
1498
|
+
original_message_id: msg.message_id || null
|
|
1499
|
+
}, targetSession);
|
|
1320
1500
|
}
|
|
1321
1501
|
|
|
1322
1502
|
// Emit inject_written ack
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
busClients.forEach(client => {
|
|
1336
|
-
if (client.readyState === 1) client.send(ackMsg);
|
|
1503
|
+
broadcastSessionEvent('inject_written', targetId, targetSession, {
|
|
1504
|
+
extra: {
|
|
1505
|
+
inject_id,
|
|
1506
|
+
source_host: MACHINE_ID,
|
|
1507
|
+
target_agent: targetId,
|
|
1508
|
+
source_type: 'bus_auto_route',
|
|
1509
|
+
turn_id: (msg.payload && msg.payload.turn_id) || null,
|
|
1510
|
+
original_message_id: msg.message_id || null,
|
|
1511
|
+
delivered,
|
|
1512
|
+
code: delivered ? null : delivery.code,
|
|
1513
|
+
error: delivered ? null : delivery.error
|
|
1514
|
+
}
|
|
1337
1515
|
});
|
|
1338
|
-
targetSession.lastActivityAt = new Date().toISOString();
|
|
1339
1516
|
console.log(`[BUS-ROUTE] ${eventType} → ${targetId}: ${delivered ? 'delivered' : 'failed'}`);
|
|
1340
1517
|
}
|
|
1341
1518
|
|
|
@@ -1346,6 +1523,22 @@ app.post('/api/bus/publish', (req, res) => {
|
|
|
1346
1523
|
return res.status(400).json({ error: 'Payload must be a JSON object' });
|
|
1347
1524
|
}
|
|
1348
1525
|
|
|
1526
|
+
if (payload.type === 'session_state_report') {
|
|
1527
|
+
const resolvedId = resolveSessionAlias(payload.session_id || '');
|
|
1528
|
+
if (!resolvedId || !sessions[resolvedId]) {
|
|
1529
|
+
return respondWithError(res, 404, 'SESSION_NOT_FOUND', 'Session not found', { requested: payload.session_id || null });
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const applied = applySessionStateReport(resolvedId, sessions[resolvedId], payload);
|
|
1533
|
+
if (!applied.success) {
|
|
1534
|
+
return respondWithError(res, applied.httpStatus || 400, applied.code || 'INVALID_REQUEST', applied.error);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if (!payload._relayed_from) relayToPeers(applied.event);
|
|
1538
|
+
persistSessions();
|
|
1539
|
+
return res.json({ success: true, delivered: busClients.size, event: applied.event });
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1349
1542
|
let deliveredCount = 0;
|
|
1350
1543
|
|
|
1351
1544
|
busClients.forEach(client => {
|
|
@@ -1598,20 +1791,26 @@ setInterval(() => {
|
|
|
1598
1791
|
const now = Date.now();
|
|
1599
1792
|
for (const [id, session] of Object.entries(sessions)) {
|
|
1600
1793
|
const idleSeconds = session.lastActivityAt ? Math.floor((now - new Date(session.lastActivityAt).getTime()) / 1000) : null;
|
|
1601
|
-
const
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1794
|
+
const healthStatus = getSessionHealthStatus(session, { nowMs: now });
|
|
1795
|
+
const healthReason = getSessionHealthReason(session, healthStatus);
|
|
1796
|
+
const disconnectedSeconds = session.lastDisconnectedAt
|
|
1797
|
+
? Math.floor((now - new Date(session.lastDisconnectedAt).getTime()) / 1000)
|
|
1798
|
+
: null;
|
|
1799
|
+
|
|
1800
|
+
broadcastSessionEvent('session_health', id, session, {
|
|
1801
|
+
nowMs: now,
|
|
1802
|
+
extra: {
|
|
1803
|
+
payload: {
|
|
1804
|
+
alive: healthStatus === 'CONNECTED',
|
|
1805
|
+
pid: session.ptyProcess?.pid || null,
|
|
1806
|
+
type: session.type,
|
|
1807
|
+
clients: session.clients ? session.clients.size : 0,
|
|
1808
|
+
idleSeconds,
|
|
1809
|
+
healthStatus,
|
|
1810
|
+
healthReason,
|
|
1811
|
+
disconnectedSeconds
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1615
1814
|
});
|
|
1616
1815
|
|
|
1617
1816
|
// Emit session.idle when idle exceeds threshold
|
|
@@ -1633,14 +1832,46 @@ setInterval(() => {
|
|
|
1633
1832
|
if (idleSeconds !== null && idleSeconds < IDLE_THRESHOLD_SECONDS) {
|
|
1634
1833
|
session._idleEmitted = false;
|
|
1635
1834
|
}
|
|
1835
|
+
|
|
1836
|
+
if (healthStatus === 'STALE' && !session._staleEmitted) {
|
|
1837
|
+
session._staleEmitted = true;
|
|
1838
|
+
emitSessionLifecycleEvent('session_stale', id, session, {
|
|
1839
|
+
disconnectedSeconds
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const shouldCleanupDisconnected = (session.type === 'wrapped' || session.type === 'aterm')
|
|
1844
|
+
&& !isOpenWebSocket(session.ownerWs)
|
|
1845
|
+
&& (!session.clients || session.clients.size === 0)
|
|
1846
|
+
&& disconnectedSeconds !== null
|
|
1847
|
+
&& disconnectedSeconds >= SESSION_CLEANUP_SECONDS;
|
|
1848
|
+
|
|
1849
|
+
if (shouldCleanupDisconnected) {
|
|
1850
|
+
emitSessionLifecycleEvent('session_cleanup', id, session, {
|
|
1851
|
+
reason: 'STALE_DISCONNECTED',
|
|
1852
|
+
disconnectedSeconds
|
|
1853
|
+
});
|
|
1854
|
+
delete sessions[id];
|
|
1855
|
+
console.log(`[CLEANUP] Removed stale session ${id} after ${disconnectedSeconds}s disconnected`);
|
|
1856
|
+
persistSessions();
|
|
1857
|
+
}
|
|
1636
1858
|
}
|
|
1637
|
-
},
|
|
1859
|
+
}, HEALTH_POLL_MS);
|
|
1638
1860
|
|
|
1639
|
-
server.on('error', (error) => {
|
|
1861
|
+
server.on('error', async (error) => {
|
|
1640
1862
|
clearDaemonState(process.pid);
|
|
1641
1863
|
|
|
1642
1864
|
if (error && error.code === 'EADDRINUSE') {
|
|
1643
|
-
|
|
1865
|
+
// Probe health to determine if it's a telepty daemon on this port
|
|
1866
|
+
try {
|
|
1867
|
+
const probe = await fetch(`http://127.0.0.1:${PORT}/api/health`);
|
|
1868
|
+
const data = await probe.json();
|
|
1869
|
+
if (data && data.status === 'ok') {
|
|
1870
|
+
console.log(`[DAEMON] telepty daemon already running on port ${PORT} (v${data.version}). Exiting.`);
|
|
1871
|
+
process.exit(0);
|
|
1872
|
+
}
|
|
1873
|
+
} catch {}
|
|
1874
|
+
console.error(`[DAEMON] Port ${PORT} is already in use by another process.`);
|
|
1644
1875
|
process.exit(1);
|
|
1645
1876
|
}
|
|
1646
1877
|
|
|
@@ -1671,6 +1902,7 @@ wss.on('connection', (ws, req) => {
|
|
|
1671
1902
|
}, 30000);
|
|
1672
1903
|
|
|
1673
1904
|
if (!session) {
|
|
1905
|
+
const connectedAt = new Date().toISOString();
|
|
1674
1906
|
// Auto-register wrapped session on WS connect (supports reconnect after daemon restart)
|
|
1675
1907
|
const autoSession = {
|
|
1676
1908
|
id: sessionId,
|
|
@@ -1679,8 +1911,10 @@ wss.on('connection', (ws, req) => {
|
|
|
1679
1911
|
ownerWs: ws,
|
|
1680
1912
|
command: 'wrapped',
|
|
1681
1913
|
cwd: process.cwd(),
|
|
1682
|
-
createdAt:
|
|
1683
|
-
lastActivityAt:
|
|
1914
|
+
createdAt: connectedAt,
|
|
1915
|
+
lastActivityAt: connectedAt,
|
|
1916
|
+
lastConnectedAt: connectedAt,
|
|
1917
|
+
lastDisconnectedAt: null,
|
|
1684
1918
|
clients: new Set([ws]),
|
|
1685
1919
|
isClosing: false,
|
|
1686
1920
|
outputRing: [],
|
|
@@ -1710,13 +1944,19 @@ wss.on('connection', (ws, req) => {
|
|
|
1710
1944
|
// ?owner=1 reclaim handles the stale-ownerWs bug: allow bridge reconnects but stale TCP
|
|
1711
1945
|
// half-open connection still holds ownerWs slot → reconnect wrongly becomes a viewer.
|
|
1712
1946
|
if (activeSession.type === 'wrapped' && (!activeSession.ownerWs || isOwnerConnect)) {
|
|
1947
|
+
const hadDisconnectedOwner = !isOpenWebSocket(activeSession.ownerWs) && activeSession.lastDisconnectedAt;
|
|
1713
1948
|
if (isOwnerConnect && activeSession.ownerWs && activeSession.ownerWs !== ws) {
|
|
1714
1949
|
// Terminate the stale owner connection before claiming ownership
|
|
1715
1950
|
console.log(`[WS] Replacing stale ownerWs for session ${sessionId}`);
|
|
1716
1951
|
activeSession.ownerWs.terminate();
|
|
1717
1952
|
}
|
|
1718
1953
|
activeSession.ownerWs = ws;
|
|
1954
|
+
markSessionConnected(activeSession);
|
|
1719
1955
|
console.log(`[WS] Wrap owner ${isOwnerConnect && activeSession.clients.size > 1 ? 're-' : ''}connected for session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
1956
|
+
if (hadDisconnectedOwner) {
|
|
1957
|
+
emitSessionLifecycleEvent('session_reconnect', sessionId, activeSession);
|
|
1958
|
+
}
|
|
1959
|
+
persistSessions();
|
|
1720
1960
|
} else {
|
|
1721
1961
|
console.log(`[WS] Client attached to session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
1722
1962
|
}
|
|
@@ -1776,12 +2016,12 @@ wss.on('connection', (ws, req) => {
|
|
|
1776
2016
|
activeSession.clients.delete(ws);
|
|
1777
2017
|
if (activeSession.type === 'wrapped' && ws === activeSession.ownerWs) {
|
|
1778
2018
|
activeSession.ownerWs = null;
|
|
2019
|
+
markSessionDisconnected(activeSession);
|
|
1779
2020
|
console.log(`[WS] Wrap owner disconnected from session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
}
|
|
2021
|
+
emitSessionLifecycleEvent('session_disconnect', sessionId, activeSession, {
|
|
2022
|
+
clients: activeSession.clients.size
|
|
2023
|
+
});
|
|
2024
|
+
persistSessions();
|
|
1785
2025
|
} else {
|
|
1786
2026
|
console.log(`[WS] Client detached from session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
1787
2027
|
}
|
|
@@ -1798,6 +2038,22 @@ busWss.on('connection', (ws, req) => {
|
|
|
1798
2038
|
ws.on('message', (message) => {
|
|
1799
2039
|
try {
|
|
1800
2040
|
const msg = JSON.parse(message);
|
|
2041
|
+
if (msg.type === 'session_state_report') {
|
|
2042
|
+
const resolvedId = resolveSessionAlias(msg.session_id || '');
|
|
2043
|
+
if (!resolvedId || !sessions[resolvedId]) {
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
const applied = applySessionStateReport(resolvedId, sessions[resolvedId], msg);
|
|
2048
|
+
if (!applied.success) {
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
if (!msg._relayed_from) relayToPeers(applied.event);
|
|
2053
|
+
persistSessions();
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
1801
2057
|
// Broadcast to all other bus clients
|
|
1802
2058
|
busClients.forEach(client => {
|
|
1803
2059
|
if (client !== ws && client.readyState === 1) {
|