@dmsdc-ai/aigentry-telepty 0.1.51 → 0.1.53

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.
@@ -0,0 +1,199 @@
1
+ # Telepty Bus Event Schema Standard
2
+
3
+ Version: 2.0 (2026-03-15)
4
+ Agreed by: telepty, deliberation, devkit, brain, orchestrator
5
+
6
+ ## Transport
7
+
8
+ - **HTTP**: `POST /api/bus/publish` with JSON body
9
+ - **WebSocket**: `ws://HOST:3848/api/bus` send JSON message
10
+ - Both paths trigger bus auto-router for routable events
11
+
12
+ ## Envelope Structure (All Events)
13
+
14
+ ```json
15
+ {
16
+ "version": 1,
17
+ "message_id": "string (UUID or prefixed ID)",
18
+ "kind": "string (event type)",
19
+ "source": "string (sender identifier)",
20
+ "source_host": "string (machine_id of sender, e.g. hostname or Tailscale IP)",
21
+ "target": "string | null (target session ID, optional @host suffix)",
22
+ "ts": "ISO 8601 timestamp"
23
+ }
24
+ ```
25
+
26
+ ### Canonical Field Names
27
+
28
+ | Field | Type | Description |
29
+ |-------|------|-------------|
30
+ | `version` | number | Envelope schema version (currently 1) |
31
+ | `kind` | string | Event type (NOT `type` — `kind` is canonical) |
32
+ | `target` | string | Target telepty session ID. May include `@host` suffix for remote |
33
+ | `source` | string | Sender identifier (format: `project:session_id`) |
34
+ | `source_host` | string | Machine ID of sender (hostname or TELEPTY_MACHINE_ID) |
35
+ | `message_id` | string | Unique message identifier |
36
+ | `ts` | string | ISO 8601 timestamp |
37
+
38
+ ## Cross-Machine Addressing
39
+
40
+ ### Session Locator
41
+ Every session is uniquely identified by a locator triple:
42
+ ```json
43
+ { "machine_id": "hostname", "session_id": "aigentry-devkit-001", "project_id": "aigentry-devkit" }
44
+ ```
45
+
46
+ ### Remote Target Format
47
+ `target` field supports `@host` suffix: `aigentry-devkit-001@100.100.100.5`
48
+ - Router strips suffix, resolves session on local daemon
49
+ - For cross-machine relay (P3), daemon forwards to target host
50
+
51
+ ### Machine ID
52
+ - Default: `os.hostname()`
53
+ - Override: `TELEPTY_MACHINE_ID` env var
54
+ - Exposed in: `GET /api/meta` (`machine_id` field), session `locator` object, bus event `source_host`
55
+
56
+ ### Peer Auth
57
+ - Localhost: always trusted
58
+ - Tailscale (100.x.y.z): trusted by default
59
+ - Custom peers: `TELEPTY_PEER_ALLOWLIST=ip1,ip2` env var
60
+ - All others: require `x-telepty-token` header
61
+
62
+ ## Routable Events (Auto-Router)
63
+
64
+ ### `turn_request`
65
+
66
+ Published by deliberation to request a turn from a session. Telepty daemon auto-routes to target session PTY.
67
+
68
+ ```json
69
+ {
70
+ "message_id": "turn_request-<uuid>",
71
+ "session_id": "<deliberation_session_id>",
72
+ "project": "<project_name>",
73
+ "kind": "turn_request",
74
+ "source": "deliberation:<deliberation_session_id>",
75
+ "target": "<telepty_session_id>[@<host>]",
76
+ "reply_to": "<deliberation_session_id>",
77
+ "trace": ["project:<name>", "speaker:<id>", "turn:<turn_id>"],
78
+ "payload": {
79
+ "turn_id": "string",
80
+ "round": "number",
81
+ "max_rounds": "number",
82
+ "speaker": "string (target telepty session ID)",
83
+ "role": "string | null",
84
+ "prompt": "string (full prompt text — inject as-is to PTY)",
85
+ "prompt_sha1": "string (40-char SHA1)",
86
+ "history_entries": "number",
87
+ "transport_timeout_ms": "number",
88
+ "semantic_timeout_ms": "number"
89
+ },
90
+ "ts": "ISO 8601"
91
+ }
92
+ ```
93
+
94
+ **Important Notes:**
95
+ - `session_id` is the DELIBERATION session ID, NOT the target telepty session
96
+ - `target` is the telepty session ID to inject into
97
+ - `payload.prompt` is the full text to write to PTY (no further processing needed)
98
+ - `@host` suffix on target: strip before resolving, use for remote routing
99
+
100
+ **Auto-Router Behavior:**
101
+ 1. Daemon receives turn_request via HTTP POST or WS
102
+ 2. Extracts `target` field, strips `@host` suffix
103
+ 3. Resolves session via `resolveSessionAlias()`
104
+ 4. Delivers `payload.prompt` to session PTY (kitty primary, WS fallback)
105
+ 5. Emits `inject_written` ack on bus
106
+
107
+ ### `inject_written` (ACK)
108
+
109
+ Emitted by telepty after successful auto-route delivery.
110
+
111
+ ```json
112
+ {
113
+ "type": "inject_written",
114
+ "inject_id": "UUID",
115
+ "sender": "daemon",
116
+ "target_agent": "<session_id>",
117
+ "source_type": "bus_auto_route",
118
+ "delivered": true,
119
+ "timestamp": "ISO 8601"
120
+ }
121
+ ```
122
+
123
+ ## Session Lifecycle Events
124
+
125
+ ### `session_register`
126
+ ```json
127
+ { "type": "session_register", "sender": "daemon", "session_id": "string", "command": "string", "cwd": "string", "timestamp": "ISO 8601" }
128
+ ```
129
+
130
+ ### `session.replaced`
131
+ ```json
132
+ { "type": "session.replaced", "sender": "daemon", "old_id": "string", "new_id": "string", "alias": "string", "timestamp": "ISO 8601" }
133
+ ```
134
+
135
+ ### `session.idle`
136
+ ```json
137
+ { "type": "session.idle", "session_id": "string", "idleSeconds": "number", "lastActivityAt": "ISO 8601", "timestamp": "ISO 8601" }
138
+ ```
139
+
140
+ ### `session_health` (periodic, every 10s)
141
+ ```json
142
+ { "type": "session_health", "session_id": "string", "payload": { "alive": true, "pid": "number|null", "type": "string", "clients": "number", "idleSeconds": "number|null" }, "timestamp": "ISO 8601" }
143
+ ```
144
+
145
+ ## Inject Events
146
+
147
+ ### `inject_written`
148
+ ```json
149
+ { "type": "inject_written", "inject_id": "UUID", "sender": "daemon", "target_agent": "string", "content": "string", "from": "string|null", "reply_to": "string|null", "thread_id": "string|null", "reply_expected": "boolean", "timestamp": "ISO 8601" }
150
+ ```
151
+
152
+ ### `message_routed`
153
+ ```json
154
+ { "type": "message_routed", "message_id": "UUID", "from": "string", "to": "string", "reply_to": "string", "inject_id": "UUID", "deliberation_session_id": "string|null", "thread_id": "string|null", "timestamp": "ISO 8601" }
155
+ ```
156
+
157
+ ## Handoff Events
158
+
159
+ ### `handoff.created` / `handoff.claimed` / `handoff.executing` / `handoff.completed`
160
+ ```json
161
+ { "type": "handoff.<status>", "handoff_id": "UUID", "source_session_id": "string|null", "deliberation_id": "string|null", "auto_execute": "boolean", "task_count": "number", "timestamp": "ISO 8601" }
162
+ ```
163
+
164
+ ## Thread Events
165
+
166
+ ### `thread.opened`
167
+ ```json
168
+ { "type": "thread.opened", "thread_id": "UUID", "topic": "string", "orchestrator_session_id": "string|null", "participant_session_ids": ["string"], "timestamp": "ISO 8601" }
169
+ ```
170
+
171
+ ### `thread.closed`
172
+ ```json
173
+ { "type": "thread.closed", "thread_id": "UUID", "topic": "string", "message_count": "number", "timestamp": "ISO 8601" }
174
+ ```
175
+
176
+ ## Termination Signal Detection
177
+
178
+ Messages containing these strings suppress auto-reply guide footer:
179
+ - `no further reply needed`
180
+ - `thread closed` / `closed on X side`
181
+ - `ack received` / `ack-only`
182
+ - `회신 불필요` / `스레드 종료`
183
+
184
+ ## Inject API Reference
185
+
186
+ ### `POST /api/sessions/:id/inject`
187
+
188
+ ```json
189
+ {
190
+ "prompt": "string (REQUIRED — canonical body field)",
191
+ "from": "string (sender session ID)",
192
+ "reply_to": "string (defaults to from if omitted)",
193
+ "thread_id": "string (optional)",
194
+ "reply_expected": "boolean (optional)",
195
+ "no_enter": "boolean (skip Enter after inject)"
196
+ }
197
+ ```
198
+
199
+ **Note:** The canonical body field is `prompt`, NOT `text`, `content`, or `message`.
package/daemon.js CHANGED
@@ -10,18 +10,54 @@ const { claimDaemonState, clearDaemonState } = require('./daemon-control');
10
10
 
11
11
  const config = getConfig();
12
12
  const EXPECTED_TOKEN = config.authToken;
13
+ const MACHINE_ID = process.env.TELEPTY_MACHINE_ID || os.hostname();
14
+ const fs = require('fs');
15
+ const SESSION_PERSIST_PATH = require('path').join(os.homedir(), '.config', 'aigentry-telepty', 'sessions.json');
16
+
17
+ function persistSessions() {
18
+ try {
19
+ const data = {};
20
+ for (const [id, s] of Object.entries(sessions)) {
21
+ data[id] = { id, type: s.type, command: s.command, cwd: s.cwd, createdAt: s.createdAt, lastActivityAt: s.lastActivityAt || null };
22
+ }
23
+ fs.mkdirSync(require('path').dirname(SESSION_PERSIST_PATH), { recursive: true });
24
+ fs.writeFileSync(SESSION_PERSIST_PATH, JSON.stringify(data, null, 2));
25
+ } catch {}
26
+ }
27
+
28
+ function loadPersistedSessions() {
29
+ try {
30
+ if (!fs.existsSync(SESSION_PERSIST_PATH)) return {};
31
+ return JSON.parse(fs.readFileSync(SESSION_PERSIST_PATH, 'utf8'));
32
+ } catch { return {}; }
33
+ }
13
34
 
14
35
  const app = express();
15
36
  app.use(cors());
16
37
  app.use(express.json());
17
38
 
39
+ // Peer allowlist: comma-separated IPs/CIDRs in TELEPTY_PEER_ALLOWLIST env
40
+ const PEER_ALLOWLIST = (process.env.TELEPTY_PEER_ALLOWLIST || '').split(',').map(s => s.trim()).filter(Boolean);
41
+
42
+ function isAllowedPeer(ip) {
43
+ if (!ip) return false;
44
+ const cleanIp = ip.replace('::ffff:', '');
45
+ // Localhost always allowed
46
+ if (cleanIp === '127.0.0.1' || ip === '::1') return true;
47
+ // Tailscale range (100.x.y.z)
48
+ if (cleanIp.startsWith('100.')) return true;
49
+ // Peer allowlist
50
+ if (PEER_ALLOWLIST.length > 0) return PEER_ALLOWLIST.includes(cleanIp);
51
+ // No allowlist = allow all authenticated
52
+ return false;
53
+ }
54
+
18
55
  // Authentication Middleware
19
56
  app.use((req, res, next) => {
20
- const isLocalhost = req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1';
21
- const isTailscale = req.ip && req.ip.startsWith('100.');
22
-
23
- if (isLocalhost || isTailscale) {
24
- return next(); // Trust local and Tailscale networks
57
+ const clientIp = req.ip;
58
+
59
+ if (isAllowedPeer(clientIp)) {
60
+ return next(); // Trust local, Tailscale, and allowlisted peers
25
61
  }
26
62
 
27
63
  const token = req.headers['x-telepty-token'] || req.query.token;
@@ -29,7 +65,7 @@ app.use((req, res, next) => {
29
65
  return next();
30
66
  }
31
67
 
32
- console.warn(`[AUTH] Rejected unauthorized request from ${req.ip}`);
68
+ console.warn(`[AUTH] Rejected unauthorized request from ${clientIp}`);
33
69
  res.status(401).json({ error: 'Unauthorized: Invalid or missing token.' });
34
70
  });
35
71
 
@@ -48,6 +84,21 @@ if (!daemonClaim.claimed) {
48
84
  const sessions = {};
49
85
  const handoffs = {};
50
86
  const threads = {};
87
+
88
+ // Restore persisted session metadata (wrapped sessions await reconnect)
89
+ const _persisted = loadPersistedSessions();
90
+ for (const [id, meta] of Object.entries(_persisted)) {
91
+ if (meta.type === 'wrapped') {
92
+ sessions[id] = {
93
+ id, type: 'wrapped', ptyProcess: null, ownerWs: null,
94
+ command: meta.command || 'wrapped', cwd: meta.cwd || process.cwd(),
95
+ createdAt: meta.createdAt || new Date().toISOString(),
96
+ lastActivityAt: meta.lastActivityAt || new Date().toISOString(),
97
+ clients: new Set(), isClosing: false
98
+ };
99
+ console.log(`[PERSIST] Restored session ${id} (awaiting reconnect)`);
100
+ }
101
+ }
51
102
  const STRIPPED_SESSION_ENV_KEYS = [
52
103
  'CLAUDECODE',
53
104
  'CODEX_CI',
@@ -185,6 +236,7 @@ app.post('/api/sessions/spawn', (req, res) => {
185
236
  });
186
237
 
187
238
  console.log(`[SPAWN] Created session ${session_id} (${command})`);
239
+ persistSessions();
188
240
  res.status(201).json({ session_id, command, cwd });
189
241
  } catch (err) {
190
242
  res.status(500).json({ error: err.message });
@@ -250,6 +302,7 @@ app.post('/api/sessions/register', (req, res) => {
250
302
  });
251
303
 
252
304
  console.log(`[REGISTER] Registered wrapped session ${session_id}`);
305
+ persistSessions();
253
306
  res.status(201).json({ session_id, type: 'wrapped', command: sessionRecord.command, cwd });
254
307
  });
255
308
 
@@ -258,8 +311,10 @@ app.get('/api/sessions', (req, res) => {
258
311
  const now = Date.now();
259
312
  let list = Object.entries(sessions).map(([id, session]) => {
260
313
  const idleSeconds = session.lastActivityAt ? Math.floor((now - new Date(session.lastActivityAt).getTime()) / 1000) : null;
314
+ const projectId = session.cwd ? session.cwd.split('/').pop() : null;
261
315
  return {
262
316
  id,
317
+ locator: { machine_id: MACHINE_ID, session_id: id, project_id: projectId },
263
318
  type: session.type || 'spawned',
264
319
  command: session.command,
265
320
  cwd: session.cwd,
@@ -281,8 +336,10 @@ app.get('/api/sessions/:id', (req, res) => {
281
336
  if (!resolvedId) return res.status(404).json({ error: 'Session not found' });
282
337
  const session = sessions[resolvedId];
283
338
  const idleSeconds = session.lastActivityAt ? Math.floor((Date.now() - new Date(session.lastActivityAt).getTime()) / 1000) : null;
339
+ const projectId = session.cwd ? session.cwd.split('/').pop() : null;
284
340
  res.json({
285
341
  id: resolvedId,
342
+ locator: { machine_id: MACHINE_ID, session_id: resolvedId, project_id: projectId },
286
343
  alias: requestedId !== resolvedId ? requestedId : null,
287
344
  type: session.type || 'spawned',
288
345
  command: session.command,
@@ -303,6 +360,7 @@ app.get('/api/meta', (req, res) => {
303
360
  pid: process.pid,
304
361
  host: HOST,
305
362
  port: Number(PORT),
363
+ machine_id: MACHINE_ID,
306
364
  capabilities: ['sessions', 'wrapped-sessions', 'skill-installer', 'singleton-daemon', 'handoff-inbox', 'deliberation-threads']
307
365
  });
308
366
  });
@@ -814,9 +872,11 @@ app.delete('/api/sessions/:id', (req, res) => {
814
872
  session.clients.forEach(ws => ws.close(1000, 'Session destroyed'));
815
873
  delete sessions[id];
816
874
  console.log(`[KILL] Wrapped session ${id} removed`);
875
+ persistSessions();
817
876
  } else {
818
877
  session.ptyProcess.kill();
819
878
  console.log(`[KILL] Session ${id} forcefully closed`);
879
+ persistSessions();
820
880
  }
821
881
  res.json({ success: true, status: 'closing' });
822
882
  } catch (err) {
@@ -885,6 +945,7 @@ function busAutoRoute(msg) {
885
945
  type: 'inject_written',
886
946
  inject_id,
887
947
  sender: 'daemon',
948
+ source_host: MACHINE_ID,
888
949
  target_agent: targetId,
889
950
  source_type: 'bus_auto_route',
890
951
  delivered,
@@ -1340,10 +1401,7 @@ server.on('upgrade', (req, socket, head) => {
1340
1401
  const url = new URL(req.url, 'http://' + req.headers.host);
1341
1402
  const token = url.searchParams.get('token');
1342
1403
 
1343
- const isLocalhost = req.socket.remoteAddress === '127.0.0.1' || req.socket.remoteAddress === '::1' || req.socket.remoteAddress === '::ffff:127.0.0.1';
1344
- const isTailscale = req.socket.remoteAddress && req.socket.remoteAddress.startsWith('100.');
1345
-
1346
- if (!isLocalhost && !isTailscale && token !== EXPECTED_TOKEN) {
1404
+ if (!isAllowedPeer(req.socket.remoteAddress) && token !== EXPECTED_TOKEN) {
1347
1405
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
1348
1406
  socket.destroy();
1349
1407
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",