@dmsdc-ai/aigentry-telepty 0.1.95 → 0.1.97

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,10 +27,10 @@ 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
36
  // Broadcast state transitions to the bus
@@ -267,7 +267,7 @@ function getSessionHealthStatus(session, options = {}) {
267
267
 
268
268
  function getSessionHealthReason(session, healthStatus) {
269
269
  if (session.type === 'wrapped') {
270
- if (healthStatus === 'CONNECTED') return session.ready ? 'OWNER_CONNECTED' : 'OWNER_CONNECTED_NOT_READY';
270
+ if (healthStatus === 'CONNECTED') return 'OWNER_CONNECTED';
271
271
  if (healthStatus === 'STALE') return 'OWNER_DISCONNECTED_STALE';
272
272
  return 'OWNER_DISCONNECTED';
273
273
  }
@@ -705,7 +705,13 @@ function serializeSession(id, session, options = {}) {
705
705
  lastStateReportAt: session.lastStateReportAt || null,
706
706
  transport,
707
707
  semantic,
708
- autoState: autoState ? { state: autoState.state, since: autoState.since, confidence: autoState.confidence } : null,
708
+ autoState: autoState ? {
709
+ state: autoState.state,
710
+ emoji: (STATE_DISPLAY[autoState.state] || {}).emoji || '?',
711
+ since: autoState.since,
712
+ confidence: autoState.confidence,
713
+ detail: autoState.detail,
714
+ } : null,
709
715
  mailbox: (() => {
710
716
  try {
711
717
  const pending = mailbox.peek(id).filter(m => m.state === 'pending' || m.state === 'in_flight');
@@ -738,7 +744,7 @@ for (const [id, meta] of Object.entries(_persisted)) {
738
744
  lastDisconnectedAt: meta.lastDisconnectedAt || meta.lastActivityAt || new Date().toISOString(),
739
745
  lastStateReportAt: meta.lastStateReportAt || null,
740
746
  stateReport: meta.stateReport || null,
741
- clients: new Set(), isClosing: false, outputRing: [], ready: false, };
747
+ clients: new Set(), isClosing: false, outputRing: [], ready: true, };
742
748
  console.log(`[PERSIST] Restored session ${id} (awaiting reconnect)`);
743
749
  }
744
750
  }
@@ -883,6 +889,7 @@ app.post('/api/sessions/spawn', (req, res) => {
883
889
  ptyProcess.onExit(({ exitCode, signal }) => {
884
890
  const currentId = sessionRecord.id;
885
891
  console.log(`[EXIT] Session ${currentId} exited with code ${exitCode}`);
892
+ sessionStateManager.markDead(currentId, exitCode, signal);
886
893
  sessionRecord.isClosing = true;
887
894
  sessionRecord.clients.forEach(ws => ws.close(1000, 'Session exited'));
888
895
  if (sessions[currentId] === sessionRecord) {
@@ -953,7 +960,7 @@ app.post('/api/sessions/register', (req, res) => {
953
960
  clients: new Set(),
954
961
  isClosing: false,
955
962
  outputRing: [],
956
- ready: delivery_type === 'aterm', // aterm sessions are always ready (aterm manages readiness)
963
+ ready: true, // all sessions are injectable once registered (#150)
957
964
  };
958
965
  // Check for existing session with same base alias and emit replaced event
959
966
  const baseAlias = session_id.replace(/-\d+$/, '');
@@ -1031,7 +1038,9 @@ app.get('/api/sessions/:id/state', (req, res) => {
1031
1038
 
1032
1039
  res.json({
1033
1040
  session_id: resolvedId,
1034
- auto: autoState || { state: 'unknown', detail: 'no state machine registered' },
1041
+ auto: autoState
1042
+ ? { ...autoState, emoji: (STATE_DISPLAY[autoState.state] || {}).emoji || '?' }
1043
+ : { state: 'unknown', emoji: '?', detail: 'no state machine registered' },
1035
1044
  self_report: semantic,
1036
1045
  last_state_report_at: session.lastStateReportAt || null,
1037
1046
  });
@@ -1577,15 +1586,24 @@ app.get('/api/sessions/:id/screen', (req, res) => {
1577
1586
  // Strip ANSI escape sequences for clean text
1578
1587
  function stripAnsi(str) {
1579
1588
  return str
1580
- .replace(/[[0-9;]*[a-zA-Z]/g, '') // CSI sequences
1581
- .replace(/][^]*/g, '') // OSC sequences (BEL terminated)
1582
- .replace(/][^]*\\/g, '') // OSC sequences (ST terminated)
1583
- .replace(/[()][AB012]/g, '') // Character set selection
1584
- .replace(/[>=<]/g, '') // Keypad mode
1585
- .replace(/[[?]?[0-9;]*[hlsurm]/g, '') // Mode set/reset
1586
- .replace(/[[0-9;]*[ABCDHJ]/g, '') // Cursor movement
1587
- .replace(/[[0-9;]*[KG]/g, '') // Line clearing
1588
- .replace(/\r/g, ''); // Carriage returns
1589
+ // Replace cursor-forward (ESC[NC, ESC[C) with N spaces to preserve whitespace
1590
+ .replace(/\[(\d*)C/g, (_, n) => ' '.repeat(Number(n) || 1))
1591
+ // CSI sequences: ESC [ ? (optional) params final_byte
1592
+ .replace(/\[\??[0-9;]*[a-zA-Z@`]/g, '')
1593
+ // OSC sequences: ESC ] ... BEL
1594
+ .replace(/\][^]*/g, '')
1595
+ // OSC sequences: ESC ] ... ST (ESC \)
1596
+ .replace(/\][^]*\\/g, '')
1597
+ // Character set selection: ESC ( / ) + charset
1598
+ .replace(/[()][AB012]/g, '')
1599
+ // Keypad and other 2-char ESC sequences
1600
+ .replace(/[>=<78DMEHcNOZ~}|]/g, '')
1601
+ // DCS / PM / APC sequences
1602
+ .replace(/^_][^]*\\/g, '')
1603
+ // Any remaining bare ESC + single char
1604
+ .replace(/./g, '')
1605
+ // Carriage returns
1606
+ .replace(/\r/g, '');
1589
1607
  }
1590
1608
 
1591
1609
  const cleaned = raw ? fullOutput : stripAnsi(fullOutput);
@@ -2034,6 +2052,11 @@ const mailboxDelivery = new DeliveryEngine(mailbox, {
2034
2052
  }
2035
2053
  },
2036
2054
  });
2055
+ // Startup sweep: break stale lock files before starting delivery
2056
+ const staleBroken = mailbox.breakStaleLocks();
2057
+ if (staleBroken > 0) {
2058
+ console.log(`[MAILBOX] Startup sweep: broke ${staleBroken} stale lock(s)`);
2059
+ }
2037
2060
  mailboxDelivery.start();
2038
2061
 
2039
2062
  const IDLE_THRESHOLD_SECONDS = 60;
@@ -2205,7 +2228,7 @@ wss.on('connection', (ws, req) => {
2205
2228
  clients: new Set([ws]),
2206
2229
  isClosing: false,
2207
2230
  outputRing: [],
2208
- ready: false,
2231
+ ready: true,
2209
2232
  };
2210
2233
  sessions[sessionId] = autoSession;
2211
2234
  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.95",
3
+ "version": "0.1.97",
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
  };