@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/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] = { id, type: s.type, command: s.command, cwd: s.cwd, backend: s.backend || null, cmuxWorkspaceId: s.cmuxWorkspaceId || null, cmuxSurfaceId: s.cmuxSurfaceId || null, createdAt: s.createdAt, lastActivityAt: s.lastActivityAt || null };
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') existing.ready = true;
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
- id: resolvedId,
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 (!prompt) return res.status(400).json({ error: 'prompt is required' });
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
- session_ids.forEach(id => {
934
+ for (const id of session_ids) {
496
935
  const session = sessions[id];
497
936
  if (session) {
498
937
  try {
499
- // cmux per-session backend (text + enter)
500
- if (session.backend === 'cmux') {
501
- const ok = terminalBackend.cmuxSendText(id, prompt);
502
- if (ok) {
503
- setTimeout(() => terminalBackend.cmuxSendEnter(id), 300);
504
- results.successful.push({ id, strategy: 'cmux_auto' });
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
- // Inject text first, then \r separately after delay
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
- const busMsg = JSON.stringify({
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 (!prompt) return res.status(400).json({ error: 'prompt is required' });
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).forEach(id => {
973
+ for (const id of Object.keys(sessions)) {
570
974
  const session = sessions[id];
571
975
  try {
572
- // cmux per-session backend (text + enter)
573
- if (session.backend === 'cmux') {
574
- const ok = terminalBackend.cmuxSendText(id, prompt);
575
- if (ok) {
576
- setTimeout(() => terminalBackend.cmuxSendEnter(id), 300);
577
- results.successful.push({ id, strategy: 'cmux_auto' });
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
- // Inject text first, then \r separately after delay
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}) using strategy: ${strategy}`);
1179
+ console.log(`[SUBMIT] Session ${id} (${session.command}) strategy: ${strategy}${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}`);
793
1180
 
794
- let success = false;
795
- // cmux per-session backend
796
- if (session.backend === 'cmux') {
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
- if (!success) {
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
- success = submitViaPty(session);
1195
+ return submitViaPty(session);
805
1196
  } else if (strategy === 'osascript_cmd_enter') {
806
- success = submitViaOsascript(id, 'cmd_enter');
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.status(404).json({ error: 'Session not found', requested: requestedId });
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 (!prompt) return res.status(400).json({ error: 'prompt is required' });
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
- // Auto-prepend [from:] [reply-to:] header if from is set and not already in prompt
879
- let finalPrompt = prompt;
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
- // Always inject text WITHOUT \r first, then send \r separately after delay
891
- // This two-step approach works for ALL CLIs (claude, codex, gemini)
892
- function writeToSession(data) {
893
- if (session.type === 'aterm' && session.deliveryEndpoint) {
894
- // Route to aterm PtyManager — fire-and-forget, aterm handles delivery
895
- try {
896
- const url = session.deliveryEndpoint;
897
- fetch(url, {
898
- method: 'POST',
899
- headers: { 'Content-Type': 'application/json' },
900
- body: JSON.stringify({ text: data, session_id: id }),
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
- let submitResult = null;
918
- if (session.type === 'aterm' && session.deliveryEndpoint) {
919
- // aterm sessions: deliver text + CR via aterm PtyManager endpoint
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
- const busMsg = JSON.stringify({
1068
- type: 'inject_written',
1069
- inject_id,
1070
- sender: 'daemon',
1071
- target_agent: id,
1072
- content: prompt,
1073
- from: from || null,
1074
- reply_to: reply_to || null,
1075
- thread_id: thread_id || null,
1076
- reply_expected: !!reply_expected,
1077
- timestamp: injectTimestamp
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: submitResult });
1351
+ res.json({ success: true, inject_id, strategy: delivery.strategy, submit: delivery.submit });
1123
1352
  } catch (err) {
1124
- const busFailMsg = JSON.stringify({
1125
- type: 'inject_write_failed',
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
- // Write to session (cmux auto-detect > kitty > session-level cmux > WS fallback)
1268
- const sock = findKittySocket();
1269
- if (!targetSession.kittyWindowId && sock) targetSession.kittyWindowId = findKittyWindowId(sock, targetId);
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
- if (targetSession.type === 'wrapped' && targetSession.ownerWs && targetSession.ownerWs.readyState === 1) {
1308
- targetSession.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
1309
- setTimeout(() => {
1310
- if (targetSession.ownerWs && targetSession.ownerWs.readyState === 1) {
1311
- targetSession.ownerWs.send(JSON.stringify({ type: 'inject', data: '\r' }));
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
- const ackMsg = JSON.stringify({
1324
- type: 'inject_written',
1325
- inject_id,
1326
- sender: 'daemon',
1327
- source_host: MACHINE_ID,
1328
- target_agent: targetId,
1329
- source_type: 'bus_auto_route',
1330
- turn_id: (msg.payload && msg.payload.turn_id) || null,
1331
- original_message_id: msg.message_id || null,
1332
- delivered,
1333
- timestamp: new Date().toISOString()
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 healthMsg = JSON.stringify({
1602
- type: 'session_health',
1603
- session_id: id,
1604
- payload: {
1605
- alive: session.type === 'wrapped' ? (session.ownerWs && session.ownerWs.readyState === 1) : (session.ptyProcess && !session.ptyProcess.killed),
1606
- pid: session.ptyProcess?.pid || null,
1607
- type: session.type,
1608
- clients: session.clients ? session.clients.size : 0,
1609
- idleSeconds
1610
- },
1611
- timestamp: new Date().toISOString()
1612
- });
1613
- busClients.forEach(client => {
1614
- if (client.readyState === 1) client.send(healthMsg);
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
- }, 10000);
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
- console.error(`[DAEMON] Port ${PORT} is already in use. Another process is blocking telepty.`);
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: new Date().toISOString(),
1683
- lastActivityAt: new Date().toISOString(),
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
- // Clean up wrapped session when owner disconnects and no other clients
1781
- if (activeSession.clients.size === 0 && !activeSession.isClosing) {
1782
- delete sessions[sessionId];
1783
- console.log(`[CLEANUP] Wrapped session ${sessionId} removed (owner disconnected)`);
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) {