@dmsdc-ai/aigentry-telepty 0.1.96 → 0.1.98

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/cli.js CHANGED
@@ -682,7 +682,9 @@ async function manageInteractive() {
682
682
  console.log('\x1b[1mAvailable Sessions:\x1b[0m');
683
683
  sessions.forEach(s => {
684
684
  const hostLabel = formatHostLabel(s.host);
685
- console.log(` - \x1b[36m${s.id}\x1b[0m (\x1b[33m${hostLabel}\x1b[0m) [${s.command}] - Status: ${s.healthStatus || 'UNKNOWN'} - Clients: ${s.active_clients}`);
685
+ const stEmoji = s.autoState ? s.autoState.emoji : '';
686
+ const stLabel = s.autoState ? s.autoState.state : '';
687
+ console.log(` - \x1b[36m${s.id}\x1b[0m (\x1b[33m${hostLabel}\x1b[0m) [${s.command}] - ${s.healthStatus || 'UNKNOWN'}${stLabel ? ` ${stEmoji} ${stLabel}` : ''} - Clients: ${s.active_clients}`);
686
688
  });
687
689
  }
688
690
  console.log('\n');
@@ -858,7 +860,9 @@ async function main() {
858
860
  console.log(` - ID: \x1b[36m${s.id}\x1b[0m`);
859
861
  console.log(` Host: ${formatHostLabel(s.host)}`);
860
862
  console.log(` Command: ${s.command}`);
861
- console.log(` Status: ${formatSessionHealth(s)}`);
863
+ const autoEmoji = s.autoState ? s.autoState.emoji : '';
864
+ const autoLabel = s.autoState ? s.autoState.state : '';
865
+ console.log(` Status: ${formatSessionHealth(s)}${autoLabel ? ` ${autoEmoji} ${autoLabel}` : ''}`);
862
866
  console.log(` Terminal: ${formatSessionTerminal(s)}`);
863
867
  console.log(` CWD: ${s.cwd}`);
864
868
  console.log(` Clients: ${s.active_clients}`);
@@ -1775,17 +1779,21 @@ async function main() {
1775
1779
 
1776
1780
  // Color-coded state display
1777
1781
  const stateColors = {
1778
- running: '\x1b[32m', // green
1779
- idle: '\x1b[33m', // yellow
1780
- thinking: '\x1b[36m', // cyan
1781
- stuck: '\x1b[31m', // red
1782
- waiting_input: '\x1b[35m', // magenta
1782
+ starting: '\x1b[33m', // yellow
1783
+ idle: '\x1b[32m', // green
1784
+ working: '\x1b[36m', // cyan
1785
+ thinking: '\x1b[35m', // magenta
1786
+ waiting: '\x1b[33m', // yellow
1787
+ error: '\x1b[31m', // red
1788
+ restarting: '\x1b[33m', // yellow
1789
+ dead: '\x1b[90m', // gray
1783
1790
  };
1784
1791
  const stateColor = stateColors[auto.state] || '\x1b[37m';
1785
1792
  const reset = '\x1b[0m';
1786
1793
 
1787
1794
  console.log(`\n Session: \x1b[36m${data.session_id}${reset}`);
1788
- console.log(` Auto State: ${stateColor}${auto.state || 'unknown'}${reset} (confidence: ${auto.confidence != null ? (auto.confidence * 100).toFixed(0) + '%' : '?'})`);
1795
+ const stateEmoji = auto.emoji || '';
1796
+ console.log(` State: ${stateColor}${stateEmoji} ${auto.state || 'unknown'}${reset} (confidence: ${auto.confidence != null ? (auto.confidence * 100).toFixed(0) + '%' : '?'})`);
1789
1797
  if (auto.since) {
1790
1798
  const durationMs = auto.duration_ms || 0;
1791
1799
  const durationStr = durationMs < 60000
package/daemon.js CHANGED
@@ -12,7 +12,7 @@ const terminalBackend = require('./terminal-backend');
12
12
  const { FileMailbox } = require('./src/mailbox/index');
13
13
  const { DeliveryEngine } = require('./src/mailbox/delivery');
14
14
  const { UnixSocketNotifier } = require('./src/mailbox/notifier');
15
- const { SessionStateManager } = require('./session-state');
15
+ const { SessionStateManager, STATE_DISPLAY } = require('./session-state');
16
16
 
17
17
  const config = getConfig();
18
18
  const EXPECTED_TOKEN = config.authToken;
@@ -27,19 +27,33 @@ const HEALTH_POLL_MS = Math.max(100, Number(process.env.TELEPTY_HEALTH_POLL_MS |
27
27
 
28
28
  // Session state machine manager — auto-detects session state from PTY output
29
29
  const sessionStateManager = new SessionStateManager({
30
- idle_timeout_ms: Number(process.env.TELEPTY_STATE_IDLE_TIMEOUT_MS || 5000),
31
- stuck_repeat_count: Number(process.env.TELEPTY_STATE_STUCK_REPEAT_COUNT || 3),
32
- stuck_window_ms: Number(process.env.TELEPTY_STATE_STUCK_WINDOW_MS || 180000),
33
- thinking_timeout_ms:Number(process.env.TELEPTY_STATE_THINKING_TIMEOUT_MS || 300000),
30
+ idle_timeout_ms: Number(process.env.TELEPTY_STATE_IDLE_TIMEOUT_MS || 5000),
31
+ error_repeat_count: Number(process.env.TELEPTY_STATE_ERROR_REPEAT_COUNT || 3),
32
+ error_window_ms: Number(process.env.TELEPTY_STATE_ERROR_WINDOW_MS || 180000),
33
+ thinking_timeout_ms: Number(process.env.TELEPTY_STATE_THINKING_TIMEOUT_MS || 300000),
34
34
  });
35
35
 
36
- // Broadcast state transitions to the bus
36
+ // Broadcast state transitions to the bus + fire auto-report on idle
37
37
  sessionStateManager.onTransition((sessionId, from, to, detail) => {
38
38
  const session = sessions[sessionId];
39
39
  if (!session) return;
40
40
  broadcastSessionEvent('session_auto_state', sessionId, session, {
41
41
  extra: { auto_state: to, auto_state_from: from, auto_detail: detail }
42
42
  });
43
+
44
+ // Auto-report: fire when session transitions to idle after inject
45
+ if (to === 'idle' && pendingReports[sessionId]) {
46
+ const pendingReport = pendingReports[sessionId];
47
+ delete pendingReports[sessionId];
48
+ const elapsed = ((Date.now() - new Date(pendingReport.injectedAt).getTime()) / 1000).toFixed(1);
49
+ const reportMsg = `TASK_COMPLETE: ${sessionId} is now idle after processing inject (${elapsed}s)`;
50
+ const srcId = resolveSessionAlias(pendingReport.source) || pendingReport.source;
51
+ const srcSession = sessions[srcId];
52
+ if (srcSession) {
53
+ deliverInjectionToSession(srcId, srcSession, reportMsg, { noEnter: false, source: 'auto_report' });
54
+ console.log(`[AUTO-REPORT] ${sessionId} → ${srcId}: idle after ${elapsed}s (via state machine)`);
55
+ }
56
+ }
43
57
  });
44
58
 
45
59
  function persistSessions() {
@@ -267,7 +281,7 @@ function getSessionHealthStatus(session, options = {}) {
267
281
 
268
282
  function getSessionHealthReason(session, healthStatus) {
269
283
  if (session.type === 'wrapped') {
270
- if (healthStatus === 'CONNECTED') return session.ready ? 'OWNER_CONNECTED' : 'OWNER_CONNECTED_NOT_READY';
284
+ if (healthStatus === 'CONNECTED') return 'OWNER_CONNECTED';
271
285
  if (healthStatus === 'STALE') return 'OWNER_DISCONNECTED_STALE';
272
286
  return 'OWNER_DISCONNECTED';
273
287
  }
@@ -554,6 +568,22 @@ async function writeDataToSession(id, session, data) {
554
568
  return { success: true };
555
569
  }
556
570
 
571
+ /**
572
+ * Submit Enter to a session using terminal-level methods.
573
+ * Used by POST /submit endpoint for explicit terminal-level submit.
574
+ * Priority: kitty send-text → cmux send-key → PTY \r fallback.
575
+ * Returns the strategy name or null on failure.
576
+ */
577
+ function terminalLevelSubmit(id, session) {
578
+ // Priority 1: kitty send-text (terminal-level, bypasses PTY raw mode quirks)
579
+ if (session.type === 'wrapped' && sendViaKitty(id, '\r')) return 'kitty';
580
+ // Priority 2: cmux send-key
581
+ if (session.backend === 'cmux' && session.cmuxWorkspaceId && submitViaCmux(id)) return 'cmux';
582
+ // Priority 3: PTY \r
583
+ if (submitViaPty(session)) return 'pty_cr';
584
+ return null;
585
+ }
586
+
557
587
  async function deliverInjectionToSession(id, session, prompt, options = {}) {
558
588
  const now = Date.now();
559
589
  const injectFailure = getInjectFailure(session, { nowMs: now });
@@ -705,7 +735,13 @@ function serializeSession(id, session, options = {}) {
705
735
  lastStateReportAt: session.lastStateReportAt || null,
706
736
  transport,
707
737
  semantic,
708
- autoState: autoState ? { state: autoState.state, since: autoState.since, confidence: autoState.confidence } : null,
738
+ autoState: autoState ? {
739
+ state: autoState.state,
740
+ emoji: (STATE_DISPLAY[autoState.state] || {}).emoji || '?',
741
+ since: autoState.since,
742
+ confidence: autoState.confidence,
743
+ detail: autoState.detail,
744
+ } : null,
709
745
  mailbox: (() => {
710
746
  try {
711
747
  const pending = mailbox.peek(id).filter(m => m.state === 'pending' || m.state === 'in_flight');
@@ -738,7 +774,7 @@ for (const [id, meta] of Object.entries(_persisted)) {
738
774
  lastDisconnectedAt: meta.lastDisconnectedAt || meta.lastActivityAt || new Date().toISOString(),
739
775
  lastStateReportAt: meta.lastStateReportAt || null,
740
776
  stateReport: meta.stateReport || null,
741
- clients: new Set(), isClosing: false, outputRing: [], ready: false, };
777
+ clients: new Set(), isClosing: false, outputRing: [], ready: true, };
742
778
  console.log(`[PERSIST] Restored session ${id} (awaiting reconnect)`);
743
779
  }
744
780
  }
@@ -883,6 +919,7 @@ app.post('/api/sessions/spawn', (req, res) => {
883
919
  ptyProcess.onExit(({ exitCode, signal }) => {
884
920
  const currentId = sessionRecord.id;
885
921
  console.log(`[EXIT] Session ${currentId} exited with code ${exitCode}`);
922
+ sessionStateManager.markDead(currentId, exitCode, signal);
886
923
  sessionRecord.isClosing = true;
887
924
  sessionRecord.clients.forEach(ws => ws.close(1000, 'Session exited'));
888
925
  if (sessions[currentId] === sessionRecord) {
@@ -953,7 +990,7 @@ app.post('/api/sessions/register', (req, res) => {
953
990
  clients: new Set(),
954
991
  isClosing: false,
955
992
  outputRing: [],
956
- ready: delivery_type === 'aterm', // aterm sessions are always ready (aterm manages readiness)
993
+ ready: true, // all sessions are injectable once registered (#150)
957
994
  };
958
995
  // Check for existing session with same base alias and emit replaced event
959
996
  const baseAlias = session_id.replace(/-\d+$/, '');
@@ -1031,7 +1068,9 @@ app.get('/api/sessions/:id/state', (req, res) => {
1031
1068
 
1032
1069
  res.json({
1033
1070
  session_id: resolvedId,
1034
- auto: autoState || { state: 'unknown', detail: 'no state machine registered' },
1071
+ auto: autoState
1072
+ ? { ...autoState, emoji: (STATE_DISPLAY[autoState.state] || {}).emoji || '?' }
1073
+ : { state: 'unknown', emoji: '?', detail: 'no state machine registered' },
1035
1074
  self_report: semantic,
1036
1075
  last_state_report_at: session.lastStateReportAt || null,
1037
1076
  });
@@ -1291,8 +1330,11 @@ function sendViaKitty(sessionId, text) {
1291
1330
  });
1292
1331
  }
1293
1332
  if (hasCr) {
1294
- // Delay before sending Return — CLI needs time to process text input
1295
- execSync('sleep 0.5', { timeout: 2000 });
1333
+ // Delay before sending Return — only when text was sent in the same call
1334
+ // (when CR-only, text was already delivered via a different path)
1335
+ if (textOnly.length > 0) {
1336
+ execSync('sleep 0.5', { timeout: 2000 });
1337
+ }
1296
1338
  execSync(`kitty @ --to unix:${socket} send-text --match id:${windowId} $'\\r'`, {
1297
1339
  timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
1298
1340
  });
@@ -1383,29 +1425,25 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
1383
1425
  const retryDelayMs = Math.min(Math.max(Number(req.body?.retry_delay_ms) || 500, 100), 2000);
1384
1426
  const preDelayMs = Math.min(Math.max(Number(req.body?.pre_delay_ms) || 0, 0), 1000);
1385
1427
 
1386
- const strategy = 'pty_cr';
1387
- console.log(`[SUBMIT] Session ${id} (${session.command}) strategy: ${strategy}${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}`);
1428
+ // Terminal-level submit: kitty → cmux → PTY fallback
1429
+ console.log(`[SUBMIT] Session ${id} (${session.command})${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}`);
1388
1430
 
1389
1431
  // Pre-delay: wait for paste rendering to complete before sending CR
1390
1432
  if (preDelayMs > 0) {
1391
1433
  await new Promise(resolve => setTimeout(resolve, preDelayMs));
1392
1434
  }
1393
1435
 
1394
- function executeSubmit() {
1395
- return submitViaPty(session);
1396
- }
1397
-
1398
- let success = executeSubmit();
1436
+ let strategy = terminalLevelSubmit(id, session);
1399
1437
  let attempts = 1;
1400
1438
 
1401
1439
  // Retry: resend CR if paste may have absorbed the first one
1402
- for (let i = 0; i < retries && success; i++) {
1440
+ for (let i = 0; i < retries && strategy; i++) {
1403
1441
  await new Promise(resolve => setTimeout(resolve, retryDelayMs));
1404
- executeSubmit();
1442
+ terminalLevelSubmit(id, session);
1405
1443
  attempts++;
1406
1444
  }
1407
1445
 
1408
- if (success) {
1446
+ if (strategy) {
1409
1447
  const busMsg = JSON.stringify({
1410
1448
  type: 'submit',
1411
1449
  sender: 'daemon',
@@ -1419,7 +1457,7 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
1419
1457
  });
1420
1458
  res.json({ success: true, strategy, attempts });
1421
1459
  } else {
1422
- res.status(503).json({ error: `Submit failed via ${strategy}`, strategy, attempts });
1460
+ res.status(503).json({ error: 'Submit failed via all strategies (kitty/cmux/pty)', strategy: 'none', attempts });
1423
1461
  }
1424
1462
  });
1425
1463
 
@@ -2043,6 +2081,11 @@ const mailboxDelivery = new DeliveryEngine(mailbox, {
2043
2081
  }
2044
2082
  },
2045
2083
  });
2084
+ // Startup sweep: break stale lock files before starting delivery
2085
+ const staleBroken = mailbox.breakStaleLocks();
2086
+ if (staleBroken > 0) {
2087
+ console.log(`[MAILBOX] Startup sweep: broke ${staleBroken} stale lock(s)`);
2088
+ }
2046
2089
  mailboxDelivery.start();
2047
2090
 
2048
2091
  const IDLE_THRESHOLD_SECONDS = 60;
@@ -2214,7 +2257,7 @@ wss.on('connection', (ws, req) => {
2214
2257
  clients: new Set([ws]),
2215
2258
  isClosing: false,
2216
2259
  outputRing: [],
2217
- ready: false,
2260
+ ready: true,
2218
2261
  };
2219
2262
  sessions[sessionId] = autoSession;
2220
2263
  console.log(`[WS] Auto-registered wrapped session ${sessionId} on reconnect`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.1.96",
3
+ "version": "0.1.98",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -9,9 +9,9 @@
9
9
  "telepty-mcp": "mcp-server/index.mjs"
10
10
  },
11
11
  "scripts": {
12
- "test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js",
13
- "test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js",
14
- "test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js"
12
+ "test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js",
13
+ "test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js",
14
+ "test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js"
15
15
  },
16
16
  "keywords": [
17
17
  "pty",
package/session-state.js CHANGED
@@ -1,16 +1,22 @@
1
1
  // session-state.js — PTY output-based session state machine for telepty.
2
2
  //
3
- // Automatically detects session state from PTY output patterns:
4
- // running — PTY output actively flowing
5
- // idle — no output for idle_timeout_ms + prompt pattern detected
6
- // thinking AI CLI spinner/progress patterns detected
7
- // stuck same error repeated stuck_repeat_count times within stuck_window_ms
8
- // waiting_inputY/n or interactive prompt pattern detected
3
+ // 8-state dynamic session control (#185):
4
+ // starting — PTY not yet spawned / initializing
5
+ // idle — prompt detected (OSC 133;B or pattern), ready for input
6
+ // working PTY output actively flowing (AI generating text)
7
+ // thinking AI spinner/progress patterns (no substantive text)
8
+ // waiting — interactive prompt detected (approve, y/n, confirm)
9
+ // error — error patterns detected in output
10
+ // restarting — CLI restart triggered
11
+ // dead — PTY process terminated
9
12
  //
10
13
  // Usage:
11
14
  // const { SessionStateMachine } = require('./session-state');
12
15
  // const sm = new SessionStateMachine(sessionId, config);
13
16
  // sm.feed(data); // call on every PTY output chunk
17
+ // sm.markStarting(); // daemon: PTY spawn initiated
18
+ // sm.markDead(exitCode); // daemon: PTY exited
19
+ // sm.markRestarting(); // daemon: auto-restart triggered
14
20
  // sm.getState(); // → { state, since, confidence, last_output_preview, detail }
15
21
  // sm.onTransition(callback); // (from, to, detail) => {}
16
22
  // sm.destroy(); // cleanup timers
@@ -22,11 +28,26 @@
22
28
  // ---------------------------------------------------------------------------
23
29
 
24
30
  const STATES = Object.freeze({
25
- RUNNING: 'running',
26
- IDLE: 'idle',
27
- THINKING: 'thinking',
28
- STUCK: 'stuck',
29
- WAITING_INPUT: 'waiting_input',
31
+ STARTING: 'starting',
32
+ IDLE: 'idle',
33
+ WORKING: 'working',
34
+ THINKING: 'thinking',
35
+ WAITING: 'waiting',
36
+ ERROR: 'error',
37
+ RESTARTING: 'restarting',
38
+ DEAD: 'dead',
39
+ });
40
+
41
+ // State display metadata
42
+ const STATE_DISPLAY = Object.freeze({
43
+ [STATES.STARTING]: { emoji: '🔄', color: '\x1b[33m' }, // yellow
44
+ [STATES.IDLE]: { emoji: '💤', color: '\x1b[32m' }, // green
45
+ [STATES.WORKING]: { emoji: '🔨', color: '\x1b[36m' }, // cyan
46
+ [STATES.THINKING]: { emoji: '🧠', color: '\x1b[35m' }, // magenta
47
+ [STATES.WAITING]: { emoji: '⏳', color: '\x1b[33m' }, // yellow
48
+ [STATES.ERROR]: { emoji: '🔴', color: '\x1b[31m' }, // red
49
+ [STATES.RESTARTING]: { emoji: '🔄', color: '\x1b[33m' }, // yellow
50
+ [STATES.DEAD]: { emoji: '☠️', color: '\x1b[90m' }, // gray
30
51
  });
31
52
 
32
53
  // ---------------------------------------------------------------------------
@@ -35,9 +56,9 @@ const STATES = Object.freeze({
35
56
 
36
57
  const DEFAULT_CONFIG = Object.freeze({
37
58
  idle_timeout_ms: 5000, // 5s silence + prompt → idle
38
- stuck_repeat_count: 3, // same error N times → stuck
39
- stuck_window_ms: 180000, // 3 min window for stuck detection
40
- thinking_timeout_ms: 300000, // 5 min thinking before → stuck
59
+ error_repeat_count: 3, // same error N times → high confidence
60
+ error_window_ms: 180000, // 3 min window for error dedup
61
+ thinking_timeout_ms: 300000, // 5 min thinking before → error
41
62
  poll_interval_ms: 1000, // state check tick interval
42
63
  output_preview_len: 200, // last N chars for preview
43
64
  error_dedup_len: 120, // error line length for dedup fingerprint
@@ -47,6 +68,11 @@ const DEFAULT_CONFIG = Object.freeze({
47
68
  // Pattern sets (all terminal-agnostic, CLI-agnostic)
48
69
  // ---------------------------------------------------------------------------
49
70
 
71
+ // OSC 133 prompt marks — high-confidence idle detection
72
+ // OSC 133;A = prompt start, OSC 133;B = command start (prompt ready)
73
+ // Both indicate the shell/CLI is at a prompt waiting for input.
74
+ const OSC_133_RE = /\x1b\]133;[AB](?:\x07|\x1b\\)/;
75
+
50
76
  // Shell prompt patterns — last line of output looks like a prompt
51
77
  const PROMPT_PATTERNS = [
52
78
  /[$#%>❯›»] *$/, // common shell prompts
@@ -74,7 +100,7 @@ const THINKING_PATTERNS = [
74
100
  ];
75
101
 
76
102
  // Interactive input prompts — session is waiting for user input
77
- const WAITING_INPUT_PATTERNS = [
103
+ const WAITING_PATTERNS = [
78
104
  /\[Y\/n\]/i, // [Y/n]
79
105
  /\(y\/N\)/i, // (y/N)
80
106
  /\[yes\/no\]/i, // [yes/no]
@@ -91,7 +117,7 @@ const WAITING_INPUT_PATTERNS = [
91
117
  /\benter .*[:\s]*$/i, // Enter something:
92
118
  ];
93
119
 
94
- // Error patterns for stuck detection
120
+ // Error patterns for error detection
95
121
  const ERROR_PATTERNS = [
96
122
  /\berror\b[:\[]/i,
97
123
  /\bError:/,
@@ -109,7 +135,7 @@ const ERROR_PATTERNS = [
109
135
  /\bECONNREFUSED\b/,
110
136
  ];
111
137
 
112
- // ANSI escape stripper
138
+ // ANSI escape stripper (preserves OSC 133 detection by running after OSC check)
113
139
  const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b\[[\?]?[0-9;]*[hlm]/g;
114
140
 
115
141
  function stripAnsi(str) {
@@ -126,9 +152,9 @@ class SessionStateMachine {
126
152
  this.config = { ...DEFAULT_CONFIG, ...config };
127
153
 
128
154
  // Current state
129
- this._state = STATES.RUNNING;
155
+ this._state = STATES.STARTING;
130
156
  this._since = Date.now();
131
- this._confidence = 0.5;
157
+ this._confidence = 1.0;
132
158
  this._detail = null;
133
159
 
134
160
  // Output tracking
@@ -137,10 +163,13 @@ class SessionStateMachine {
137
163
  this._recentLines = []; // last N stripped lines
138
164
  this._maxRecentLines = 50;
139
165
 
140
- // Stuck detection: error fingerprints with timestamps
166
+ // OSC 133 tracking
167
+ this._lastOsc133At = null;
168
+
169
+ // Error detection: error fingerprints with timestamps
141
170
  this._errorHistory = []; // [{ fingerprint, timestamp }]
142
171
 
143
- // Thinking start time (for thinking → stuck timeout)
172
+ // Thinking start time (for thinking → error timeout)
144
173
  this._thinkingStartedAt = null;
145
174
 
146
175
  // Transition listeners
@@ -162,6 +191,16 @@ class SessionStateMachine {
162
191
  const previewLen = this.config.output_preview_len;
163
192
  this._lastOutputPreview = (this._lastOutputPreview + data).slice(-previewLen);
164
193
 
194
+ // Check for OSC 133 prompt mark in RAW data (before ANSI stripping)
195
+ if (OSC_133_RE.test(data)) {
196
+ this._lastOsc133At = now;
197
+ this._transition(STATES.IDLE, 0.95, {
198
+ trigger: 'osc_133_prompt',
199
+ timestamp: new Date(now).toISOString(),
200
+ });
201
+ return;
202
+ }
203
+
165
204
  // Strip ANSI and split into lines for pattern analysis
166
205
  const cleaned = stripAnsi(data);
167
206
  const lines = cleaned.split(/\r?\n/).filter(l => l.trim().length > 0);
@@ -174,6 +213,11 @@ class SessionStateMachine {
174
213
  this._recentLines.shift();
175
214
  }
176
215
 
216
+ // Don't run detection on lifecycle states managed externally
217
+ if (this._state === STATES.DEAD || this._state === STATES.RESTARTING) {
218
+ return;
219
+ }
220
+
177
221
  // Run detection pipeline (order matters: most specific first)
178
222
  this._detect(now);
179
223
  }
@@ -207,6 +251,24 @@ class SessionStateMachine {
207
251
  this._listeners = [];
208
252
  }
209
253
 
254
+ // --- Lifecycle methods (called by daemon) ---
255
+
256
+ markStarting() {
257
+ this._transition(STATES.STARTING, 1.0, { trigger: 'lifecycle' });
258
+ }
259
+
260
+ markDead(exitCode, signal) {
261
+ this._transition(STATES.DEAD, 1.0, {
262
+ trigger: 'lifecycle',
263
+ exit_code: exitCode ?? null,
264
+ signal: signal ?? null,
265
+ });
266
+ }
267
+
268
+ markRestarting() {
269
+ this._transition(STATES.RESTARTING, 1.0, { trigger: 'lifecycle' });
270
+ }
271
+
210
272
  // --- Internal ---
211
273
 
212
274
  _transition(newState, confidence, detail) {
@@ -249,20 +311,20 @@ class SessionStateMachine {
249
311
  ? this._recentLines[this._recentLines.length - 1].text
250
312
  : '';
251
313
 
252
- // --- Priority 1: waiting_input (most specific, must act on immediately) ---
253
- if (this._matchesAny(lastLine, WAITING_INPUT_PATTERNS)) {
254
- this._transition(STATES.WAITING_INPUT, 0.9, {
314
+ // --- Priority 1: waiting (most specific, must act on immediately) ---
315
+ if (this._matchesAny(lastLine, WAITING_PATTERNS)) {
316
+ this._transition(STATES.WAITING, 0.9, {
255
317
  trigger: 'pattern',
256
318
  matched_line: lastLine.slice(0, 100),
257
319
  });
258
320
  return;
259
321
  }
260
322
 
261
- // --- Priority 2: stuck detection (repeated errors) ---
323
+ // --- Priority 2: error detection (error patterns in output) ---
262
324
  this._trackErrors(now);
263
- const stuckResult = this._checkStuck(now);
264
- if (stuckResult) {
265
- this._transition(STATES.STUCK, stuckResult.confidence, stuckResult.detail);
325
+ const errorResult = this._checkError(now);
326
+ if (errorResult) {
327
+ this._transition(STATES.ERROR, errorResult.confidence, errorResult.detail);
266
328
  return;
267
329
  }
268
330
 
@@ -275,8 +337,8 @@ class SessionStateMachine {
275
337
  return;
276
338
  }
277
339
 
278
- // --- Priority 4: running (we just received output, not matching other patterns) ---
279
- this._transition(STATES.RUNNING, 0.9, {
340
+ // --- Priority 4: working (we just received output, not matching other patterns) ---
341
+ this._transition(STATES.WORKING, 0.9, {
280
342
  trigger: 'output_received',
281
343
  });
282
344
  }
@@ -285,11 +347,16 @@ class SessionStateMachine {
285
347
  const now = Date.now();
286
348
  const silenceMs = now - this._lastOutputAt;
287
349
 
288
- // Thinking stuck after timeout
350
+ // Don't override lifecycle states
351
+ if (this._state === STATES.DEAD || this._state === STATES.RESTARTING || this._state === STATES.STARTING) {
352
+ return;
353
+ }
354
+
355
+ // Thinking → error after timeout
289
356
  if (this._state === STATES.THINKING && this._thinkingStartedAt) {
290
357
  const thinkingDuration = now - this._thinkingStartedAt;
291
358
  if (thinkingDuration > this.config.thinking_timeout_ms) {
292
- this._transition(STATES.STUCK, 0.7, {
359
+ this._transition(STATES.ERROR, 0.7, {
293
360
  trigger: 'thinking_timeout',
294
361
  thinking_duration_ms: thinkingDuration,
295
362
  });
@@ -297,10 +364,10 @@ class SessionStateMachine {
297
364
  }
298
365
  }
299
366
 
300
- // Silence → idle (only if last output looks like a prompt)
367
+ // Silence → idle (only if last output looks like a prompt or OSC 133 was recent)
301
368
  if (silenceMs > this.config.idle_timeout_ms) {
302
- // Don't override stuck or waiting_input with idle
303
- if (this._state === STATES.STUCK || this._state === STATES.WAITING_INPUT) {
369
+ // Don't override error or waiting with idle
370
+ if (this._state === STATES.ERROR || this._state === STATES.WAITING) {
304
371
  return;
305
372
  }
306
373
 
@@ -308,11 +375,12 @@ class SessionStateMachine {
308
375
  ? this._recentLines[this._recentLines.length - 1].text
309
376
  : '';
310
377
 
378
+ const hasOsc133 = this._lastOsc133At && (now - this._lastOsc133At) < this.config.idle_timeout_ms * 2;
311
379
  const hasPrompt = this._matchesAny(lastLine, PROMPT_PATTERNS);
312
- const confidence = hasPrompt ? 0.9 : 0.6;
380
+ const confidence = hasOsc133 ? 0.95 : (hasPrompt ? 0.9 : 0.6);
313
381
 
314
382
  this._transition(STATES.IDLE, confidence, {
315
- trigger: hasPrompt ? 'prompt_detected' : 'silence_timeout',
383
+ trigger: hasOsc133 ? 'osc_133_prompt' : (hasPrompt ? 'prompt_detected' : 'silence_timeout'),
316
384
  silence_ms: silenceMs,
317
385
  last_line: lastLine.slice(0, 100),
318
386
  });
@@ -320,7 +388,7 @@ class SessionStateMachine {
320
388
  }
321
389
 
322
390
  _trackErrors(now) {
323
- const cutoff = now - this.config.stuck_window_ms;
391
+ const cutoff = now - this.config.error_window_ms;
324
392
  // Expire old errors
325
393
  this._errorHistory = this._errorHistory.filter(e => e.timestamp > cutoff);
326
394
 
@@ -336,8 +404,8 @@ class SessionStateMachine {
336
404
  }
337
405
  }
338
406
 
339
- _checkStuck(now) {
340
- if (this._errorHistory.length < this.config.stuck_repeat_count) {
407
+ _checkError(now) {
408
+ if (this._errorHistory.length === 0) {
341
409
  return null;
342
410
  }
343
411
 
@@ -347,20 +415,29 @@ class SessionStateMachine {
347
415
  counts[e.fingerprint] = (counts[e.fingerprint] || 0) + 1;
348
416
  }
349
417
 
418
+ // Find the most repeated error
419
+ let maxFp = null;
420
+ let maxCount = 0;
350
421
  for (const [fp, count] of Object.entries(counts)) {
351
- if (count >= this.config.stuck_repeat_count) {
352
- return {
353
- confidence: Math.min(0.95, 0.7 + (count - this.config.stuck_repeat_count) * 0.05),
354
- detail: {
355
- trigger: 'repeated_error',
356
- error_fingerprint: fp,
357
- repeat_count: count,
358
- window_ms: this.config.stuck_window_ms,
359
- },
360
- };
422
+ if (count > maxCount) {
423
+ maxCount = count;
424
+ maxFp = fp;
361
425
  }
362
426
  }
363
427
 
428
+ // Repeated errors → high confidence error state
429
+ if (maxCount >= this.config.error_repeat_count) {
430
+ return {
431
+ confidence: Math.min(0.95, 0.7 + (maxCount - this.config.error_repeat_count) * 0.05),
432
+ detail: {
433
+ trigger: 'repeated_error',
434
+ error_fingerprint: maxFp,
435
+ repeat_count: maxCount,
436
+ window_ms: this.config.error_window_ms,
437
+ },
438
+ };
439
+ }
440
+
364
441
  return null;
365
442
  }
366
443
 
@@ -437,6 +514,30 @@ class SessionStateManager {
437
514
  return result;
438
515
  }
439
516
 
517
+ /**
518
+ * Mark a session as starting (PTY spawn initiated).
519
+ */
520
+ markStarting(sessionId) {
521
+ const sm = this._machines.get(sessionId);
522
+ if (sm) sm.markStarting();
523
+ }
524
+
525
+ /**
526
+ * Mark a session as dead (PTY exited).
527
+ */
528
+ markDead(sessionId, exitCode, signal) {
529
+ const sm = this._machines.get(sessionId);
530
+ if (sm) sm.markDead(exitCode, signal);
531
+ }
532
+
533
+ /**
534
+ * Mark a session as restarting (auto-restart triggered).
535
+ */
536
+ markRestarting(sessionId) {
537
+ const sm = this._machines.get(sessionId);
538
+ if (sm) sm.markRestarting();
539
+ }
540
+
440
541
  /**
441
542
  * Unregister and cleanup a session's state machine.
442
543
  */
@@ -484,13 +585,15 @@ class SessionStateManager {
484
585
 
485
586
  module.exports = {
486
587
  STATES,
588
+ STATE_DISPLAY,
487
589
  DEFAULT_CONFIG,
488
590
  SessionStateMachine,
489
591
  SessionStateManager,
490
592
  // Exported for testing
491
593
  PROMPT_PATTERNS,
492
594
  THINKING_PATTERNS,
493
- WAITING_INPUT_PATTERNS,
595
+ WAITING_PATTERNS,
494
596
  ERROR_PATTERNS,
597
+ OSC_133_RE,
495
598
  stripAnsi,
496
599
  };