@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 +31 -0
- package/daemon.js +221 -22
- package/package.json +1 -1
- package/skills/telepty/SKILL.md +22 -0
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
|
-
|
|
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:
|
|
236
|
-
|
|
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(
|
|
242
|
-
|
|
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
|
-
|
|
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:
|
|
280
|
-
|
|
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(
|
|
286
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
477
|
+
session.ptyProcess.write(data);
|
|
478
|
+
return true;
|
|
324
479
|
}
|
|
325
|
-
} else {
|
|
326
|
-
session.ptyProcess.write(injectData);
|
|
327
480
|
}
|
|
328
|
-
|
|
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: '
|
|
332
|
-
|
|
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
package/skills/telepty/SKILL.md
CHANGED
|
@@ -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
|