@dmsdc-ai/aigentry-telepty 0.1.15 → 0.1.16

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/BOUNDARY.md ADDED
@@ -0,0 +1,31 @@
1
+ # telepty Responsibility Boundary
2
+
3
+ ## What telepty owns
4
+
5
+ - **PTY lifecycle**: spawn, resize, kill PTY processes; emit session_spawn / session_register / session_rename events
6
+ - **Raw stdin write**: accept inject requests and write bytes to the PTY fd (best-effort, fire-and-forget)
7
+ - **stdout streaming**: pipe PTY output to connected WebSocket clients in real time
8
+ - **Bus event broadcast**: publish structured events to all `/api/bus` subscribers
9
+ - **Session lifecycle**: track active sessions, clean up on exit or owner disconnect
10
+ - **Liveness heartbeat**: emit `session_health` every 10 seconds per active session
11
+
12
+ ## What telepty does NOT own
13
+
14
+ - **CLI state management**: the caller owns its own state machine; telepty does not know what state an agent is in
15
+ - **Inject processing confirmation**: telepty emits `inject_written` when bytes are handed to the OS; it cannot confirm the process consumed or acted on them
16
+ - **Output parsing / interpretation**: telepty streams raw bytes; callers parse meaning
17
+ - **Message guarantee / retry / ordering**: no retry logic, no queue, no ordering guarantees across multiple injects
18
+ - **Session recovery / persistence**: sessions are in-memory; a daemon restart loses all sessions
19
+ - **Cross-session routing**: routing logic (which session gets which message) belongs to the caller or an orchestration layer above telepty
20
+
21
+ ## PTY limitations
22
+
23
+ - `inject_written` is **best-effort**: it confirms the write syscall to the OS PTY fd succeeded, not that the running process read or processed the input
24
+ - The OS buffers stdin asynchronously; a process blocked, sleeping, or not reading stdin will silently queue the bytes
25
+ - There is no read-back or echo confirmation; callers must observe stdout via the WebSocket stream to infer processing
26
+
27
+ ## Design principle
28
+
29
+ > **telepty = stateless dumb pipe**
30
+
31
+ telepty moves bytes. It does not interpret, retry, sequence, or guarantee delivery beyond the OS write call. All higher-level semantics (acknowledgement, ordering, state machines, recovery) are the responsibility of the layer above.
package/daemon.js CHANGED
@@ -2,6 +2,7 @@ const express = require('express');
2
2
  const cors = require('cors');
3
3
  const pty = require('node-pty');
4
4
  const os = require('os');
5
+ const crypto = require('crypto');
5
6
  const { WebSocketServer } = require('ws');
6
7
  const { getConfig } = require('./auth');
7
8
  const pkg = require('./package.json');
@@ -229,17 +230,23 @@ app.post('/api/sessions/multicast/inject', (req, res) => {
229
230
  const session = sessions[id];
230
231
  if (session) {
231
232
  try {
232
- const injectData = `${prompt}\r`;
233
+ // Inject text first, then \r separately after delay
233
234
  if (session.type === 'wrapped') {
234
235
  if (session.ownerWs && session.ownerWs.readyState === 1) {
235
- session.ownerWs.send(JSON.stringify({ type: 'inject', data: injectData }));
236
- results.successful.push(id);
236
+ session.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
237
+ setTimeout(() => {
238
+ if (session.ownerWs && session.ownerWs.readyState === 1) {
239
+ session.ownerWs.send(JSON.stringify({ type: 'inject', data: '\r' }));
240
+ }
241
+ }, 300);
242
+ results.successful.push({ id, strategy: 'split_cr' });
237
243
  } else {
238
244
  results.failed.push({ id, error: 'Wrap process not connected' });
239
245
  }
240
246
  } else {
241
- session.ptyProcess.write(injectData);
242
- results.successful.push(id);
247
+ session.ptyProcess.write(prompt);
248
+ setTimeout(() => session.ptyProcess.write('\r'), 300);
249
+ results.successful.push({ id, strategy: 'split_cr' });
243
250
  }
244
251
 
245
252
  // Broadcast injection to bus
@@ -273,17 +280,23 @@ app.post('/api/sessions/broadcast/inject', (req, res) => {
273
280
  Object.keys(sessions).forEach(id => {
274
281
  const session = sessions[id];
275
282
  try {
276
- const injectData = `${prompt}\r`;
283
+ // Inject text first, then \r separately after delay
277
284
  if (session.type === 'wrapped') {
278
285
  if (session.ownerWs && session.ownerWs.readyState === 1) {
279
- session.ownerWs.send(JSON.stringify({ type: 'inject', data: injectData }));
280
- results.successful.push(id);
286
+ session.ownerWs.send(JSON.stringify({ type: 'inject', data: prompt }));
287
+ setTimeout(() => {
288
+ if (session.ownerWs && session.ownerWs.readyState === 1) {
289
+ session.ownerWs.send(JSON.stringify({ type: 'inject', data: '\r' }));
290
+ }
291
+ }, 300);
292
+ results.successful.push({ id, strategy: 'split_cr' });
281
293
  } else {
282
294
  results.failed.push({ id, error: 'Wrap process not connected' });
283
295
  }
284
296
  } else {
285
- session.ptyProcess.write(injectData);
286
- results.successful.push(id);
297
+ session.ptyProcess.write(prompt);
298
+ setTimeout(() => session.ptyProcess.write('\r'), 300);
299
+ results.successful.push({ id, strategy: 'split_cr' });
287
300
  }
288
301
  } catch (err) {
289
302
  results.failed.push({ id, error: err.message });
@@ -308,28 +321,184 @@ app.post('/api/sessions/broadcast/inject', (req, res) => {
308
321
  res.json({ success: true, results });
309
322
  });
310
323
 
324
+ // CLI-specific submit strategies
325
+ // All CLIs submit via PTY \r when running inside telepty allow bridge
326
+ const SUBMIT_STRATEGIES = {
327
+ claude: 'pty_cr',
328
+ gemini: 'pty_cr',
329
+ codex: 'pty_cr',
330
+ };
331
+
332
+ function getSubmitStrategy(command) {
333
+ const base = command.split('/').pop().split(' ')[0]; // extract binary name
334
+ return SUBMIT_STRATEGIES[base] || 'pty_cr'; // default to \r
335
+ }
336
+
337
+ function submitViaPty(session) {
338
+ if (session.type === 'wrapped') {
339
+ if (session.ownerWs && session.ownerWs.readyState === 1) {
340
+ session.ownerWs.send(JSON.stringify({ type: 'inject', data: '\r' }));
341
+ return true;
342
+ }
343
+ return false;
344
+ } else {
345
+ session.ptyProcess.write('\r');
346
+ return true;
347
+ }
348
+ }
349
+
350
+ function submitViaOsascript(sessionId, keyCombo) {
351
+ const { execSync } = require('child_process');
352
+ const session = sessions[sessionId];
353
+ // Build fallback search terms: session ID, project dir name, CLI-specific patterns
354
+ const searchTerms = [sessionId];
355
+ if (session) {
356
+ // Extract project name from cwd (e.g., "aigentry-deliberation" from full path)
357
+ const projectName = session.cwd.split('/').pop();
358
+ if (projectName) searchTerms.push(projectName);
359
+ // CLI-specific known window titles
360
+ if (session.command === 'codex') {
361
+ searchTerms.push('New agent conversation', 'codex');
362
+ }
363
+ }
364
+
365
+ const keyAction = keyCombo === 'cmd_enter'
366
+ ? 'key code 36 using command down'
367
+ : 'key code 36';
368
+
369
+ // Try each search term until we find a matching window
370
+ const searchTermsStr = searchTerms.map(t => `"${t}"`).join(', ');
371
+ const script = `
372
+ tell application "System Events"
373
+ tell process "stable"
374
+ set searchList to {${searchTermsStr}}
375
+ repeat with term in searchList
376
+ repeat with w in windows
377
+ if name of w contains (term as text) then
378
+ perform action "AXRaise" of w
379
+ delay 0.3
380
+ ${keyAction}
381
+ return "ok:" & (name of w)
382
+ end if
383
+ end repeat
384
+ end repeat
385
+ return "window_not_found"
386
+ end tell
387
+ end tell`;
388
+
389
+ try {
390
+ const result = execSync(`osascript -e '${script}'`, { timeout: 5000 }).toString().trim();
391
+ const ok = result.startsWith('ok:');
392
+ if (ok) console.log(`[SUBMIT] osascript matched: ${result}`);
393
+ return ok;
394
+ } catch (err) {
395
+ console.error(`[SUBMIT] osascript failed for ${sessionId}:`, err.message);
396
+ return false;
397
+ }
398
+ }
399
+
400
+ // POST /api/sessions/:id/submit — CLI-aware submit
401
+ app.post('/api/sessions/:id/submit', (req, res) => {
402
+ const { id } = req.params;
403
+ const session = sessions[id];
404
+ if (!session) return res.status(404).json({ error: 'Session not found' });
405
+
406
+ const strategy = getSubmitStrategy(session.command);
407
+ console.log(`[SUBMIT] Session ${id} (${session.command}) using strategy: ${strategy}`);
408
+
409
+ let success = false;
410
+ if (strategy === 'pty_cr') {
411
+ success = submitViaPty(session);
412
+ } else if (strategy === 'osascript_cmd_enter') {
413
+ success = submitViaOsascript(id, 'cmd_enter');
414
+ } else {
415
+ success = submitViaPty(session); // fallback
416
+ }
417
+
418
+ if (success) {
419
+ const busMsg = JSON.stringify({
420
+ type: 'submit',
421
+ sender: 'daemon',
422
+ session_id: id,
423
+ strategy,
424
+ timestamp: new Date().toISOString()
425
+ });
426
+ busClients.forEach(client => {
427
+ if (client.readyState === 1) client.send(busMsg);
428
+ });
429
+ res.json({ success: true, strategy });
430
+ } else {
431
+ res.status(503).json({ error: `Submit failed via ${strategy}`, strategy });
432
+ }
433
+ });
434
+
435
+ // POST /api/sessions/submit-all — Submit all active sessions
436
+ app.post('/api/sessions/submit-all', (req, res) => {
437
+ const results = { successful: [], failed: [] };
438
+
439
+ for (const [id, session] of Object.entries(sessions)) {
440
+ const strategy = getSubmitStrategy(session.command);
441
+ let success = false;
442
+
443
+ if (strategy === 'pty_cr') {
444
+ success = submitViaPty(session);
445
+ } else if (strategy === 'osascript_cmd_enter') {
446
+ success = submitViaOsascript(id, 'cmd_enter');
447
+ }
448
+
449
+ if (success) {
450
+ results.successful.push({ id, strategy });
451
+ } else {
452
+ results.failed.push({ id, strategy, error: 'Submit failed' });
453
+ }
454
+ }
455
+
456
+ res.json({ success: true, results });
457
+ });
458
+
311
459
  app.post('/api/sessions/:id/inject', (req, res) => {
312
460
  const { id } = req.params;
313
- const { prompt, no_enter } = req.body;
461
+ const { prompt, no_enter, auto_submit } = req.body;
314
462
  const session = sessions[id];
315
463
  if (!session) return res.status(404).json({ error: 'Session not found' });
316
464
  if (!prompt) return res.status(400).json({ error: 'prompt is required' });
465
+ const inject_id = crypto.randomUUID();
317
466
  try {
318
- const injectData = no_enter ? prompt : `${prompt}\r`;
319
- if (session.type === 'wrapped') {
320
- if (session.ownerWs && session.ownerWs.readyState === 1) {
321
- session.ownerWs.send(JSON.stringify({ type: 'inject', data: injectData }));
467
+ // Always inject text WITHOUT \r first, then send \r separately after delay
468
+ // This two-step approach works for ALL CLIs (claude, codex, gemini)
469
+ function writeToSession(data) {
470
+ if (session.type === 'wrapped') {
471
+ if (session.ownerWs && session.ownerWs.readyState === 1) {
472
+ session.ownerWs.send(JSON.stringify({ type: 'inject', data }));
473
+ return true;
474
+ }
475
+ return false;
322
476
  } else {
323
- return res.status(503).json({ error: 'Wrap process is not connected' });
477
+ session.ptyProcess.write(data);
478
+ return true;
324
479
  }
325
- } else {
326
- session.ptyProcess.write(injectData);
327
480
  }
328
- console.log(`[INJECT] Wrote to session ${id}`);
481
+
482
+ if (!writeToSession(prompt)) {
483
+ return res.status(503).json({ error: 'Wrap process is not connected' });
484
+ }
485
+
486
+ // Send \r separately after 300ms delay — works for ALL CLIs
487
+ let submitResult = null;
488
+ if (!no_enter) {
489
+ setTimeout(() => {
490
+ const ok = writeToSession('\r');
491
+ console.log(`[INJECT+SUBMIT] Split \\r for ${id}: ${ok ? 'success' : 'failed'}`);
492
+ }, 300);
493
+ submitResult = { deferred: true, strategy: 'split_cr' };
494
+ }
495
+
496
+ console.log(`[INJECT] Wrote to session ${id} (inject_id: ${inject_id})`);
329
497
 
330
498
  const busMsg = JSON.stringify({
331
- type: 'injection',
332
- sender: 'cli',
499
+ type: 'inject_written',
500
+ inject_id,
501
+ sender: 'daemon',
333
502
  target_agent: id,
334
503
  content: prompt,
335
504
  timestamp: new Date().toISOString()
@@ -338,8 +507,19 @@ app.post('/api/sessions/:id/inject', (req, res) => {
338
507
  if (client.readyState === 1) client.send(busMsg);
339
508
  });
340
509
 
341
- res.json({ success: true });
510
+ res.json({ success: true, inject_id, submit: submitResult });
342
511
  } catch (err) {
512
+ const busFailMsg = JSON.stringify({
513
+ type: 'inject_write_failed',
514
+ inject_id,
515
+ sender: 'daemon',
516
+ target_agent: id,
517
+ error: err.message,
518
+ timestamp: new Date().toISOString()
519
+ });
520
+ busClients.forEach(client => {
521
+ if (client.readyState === 1) client.send(busFailMsg);
522
+ });
343
523
  res.status(500).json({ error: err.message });
344
524
  }
345
525
  });
@@ -417,6 +597,25 @@ const server = app.listen(PORT, HOST, () => {
417
597
  console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${PORT}`);
418
598
  });
419
599
 
600
+ setInterval(() => {
601
+ for (const [id, session] of Object.entries(sessions)) {
602
+ const healthMsg = JSON.stringify({
603
+ type: 'session_health',
604
+ session_id: id,
605
+ payload: {
606
+ alive: session.type === 'wrapped' ? (session.ownerWs && session.ownerWs.readyState === 1) : (session.ptyProcess && !session.ptyProcess.killed),
607
+ pid: session.ptyProcess?.pid || null,
608
+ type: session.type,
609
+ clients: session.clients ? session.clients.size : 0
610
+ },
611
+ timestamp: new Date().toISOString()
612
+ });
613
+ busClients.forEach(client => {
614
+ if (client.readyState === 1) client.send(healthMsg);
615
+ });
616
+ }
617
+ }, 10000);
618
+
420
619
  server.on('error', (error) => {
421
620
  clearDaemonState(process.pid);
422
621
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -58,6 +58,28 @@ telepty inject <session_id> "<prompt text>"
58
58
 
59
59
  When the same session ID exists on multiple hosts, use `session_id@host`.
60
60
 
61
+ **Return address rule**: If you expect a reply from the target session, you MUST include your own session ID in the inject message so the recipient knows where to send the response:
62
+
63
+ ```bash
64
+ telepty inject <target_session_id> "your message here. 응답은 telepty inject <your_session_id> 로 보내줘."
65
+ ```
66
+
67
+ Your session ID is available via `echo $TELEPTY_SESSION_ID`.
68
+
69
+ **Reliable inject pattern (2-step)**: Some CLIs (e.g. codex) do not submit on `\r` alone. For reliable delivery, use the REST API in two steps:
70
+
71
+ ```bash
72
+ TOKEN=$(cat ~/.telepty/config.json | grep authToken | cut -d '"' -f 4)
73
+ # Step 1: send content without enter
74
+ curl -s -X POST "http://127.0.0.1:3848/api/sessions/<target_id>/inject" \
75
+ -H "Content-Type: application/json" -H "x-telepty-token: $TOKEN" \
76
+ -d '{"prompt": "<content>", "no_enter": true}'
77
+ # Step 2: send enter separately
78
+ curl -s -X POST "http://127.0.0.1:3848/api/sessions/<target_id>/inject" \
79
+ -H "Content-Type: application/json" -H "x-telepty-token: $TOKEN" \
80
+ -d '{"prompt": "\n"}'
81
+ ```
82
+
61
83
  5. Allow inject on a local CLI:
62
84
 
63
85
  ```bash