@dmsdc-ai/aigentry-telepty 0.4.3 → 0.4.5
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/CHANGELOG.md +385 -0
- package/README.md +17 -0
- package/cli.js +225 -7
- package/daemon.js +240 -3
- package/package.json +6 -4
- package/scripts/postinstall.js +94 -0
- package/src/bridge/j3-shim.js +264 -0
- package/src/bridge/supervisor-ipc.js +330 -0
- package/src/bridge/supervisor-launcher.js +193 -0
- package/src/config-file.js +86 -0
- package/src/lifecycle.js +237 -0
- package/src/prompt-symbol-registry.js +34 -4
- package/src/submit-gate.js +7 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Per-session supervisor process lifecycle. Spawns `telepty-supervisor-bin`
|
|
4
|
+
// (single-binary `telepty supervisor` mode is post-P2 per dispatch §6.6 A),
|
|
5
|
+
// waits for manifest.json to reach Status::Ready, and offers a liveness
|
|
6
|
+
// probe.
|
|
7
|
+
//
|
|
8
|
+
// Binary discovery order (dispatch §Phase-1 plan):
|
|
9
|
+
// 1. TELEPTY_SUPERVISOR_BIN env (absolute path)
|
|
10
|
+
// 2. ./target/{release,debug}/telepty-supervisor-bin relative to repo root
|
|
11
|
+
// 3. PATH lookup via which/where
|
|
12
|
+
//
|
|
13
|
+
// Stdlib only — child_process + fs (Constitution §17).
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const { spawn: childSpawn, spawnSync } = require('node:child_process');
|
|
18
|
+
|
|
19
|
+
const { manifestPath, socketPath } = require('./j3-shim');
|
|
20
|
+
|
|
21
|
+
const DEFAULT_READY_TIMEOUT_MS = 3000;
|
|
22
|
+
const DEFAULT_READY_POLL_MS = 50;
|
|
23
|
+
const READY_STATUSES = new Set(['ready', 'draining']);
|
|
24
|
+
const BIN_NAME = 'telepty-supervisor-bin';
|
|
25
|
+
|
|
26
|
+
class SupervisorLauncherError extends Error {
|
|
27
|
+
constructor(code, message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.name = 'SupervisorLauncherError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function whichSync(name) {
|
|
35
|
+
try {
|
|
36
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
37
|
+
const res = spawnSync(cmd, [name], { encoding: 'utf8' });
|
|
38
|
+
if (res.status === 0) {
|
|
39
|
+
const line = res.stdout.trim().split(/\r?\n/)[0];
|
|
40
|
+
return line || null;
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the supervisor binary path. Throws ERR_BIN_NOT_FOUND when no
|
|
48
|
+
* candidate is found.
|
|
49
|
+
* @param {{env?: NodeJS.ProcessEnv}} [options]
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function resolveBinary({ env = process.env } = {}) {
|
|
53
|
+
if (env.TELEPTY_SUPERVISOR_BIN) {
|
|
54
|
+
if (fs.existsSync(env.TELEPTY_SUPERVISOR_BIN)) {
|
|
55
|
+
return env.TELEPTY_SUPERVISOR_BIN;
|
|
56
|
+
}
|
|
57
|
+
throw new SupervisorLauncherError(
|
|
58
|
+
'ERR_BIN_NOT_FOUND',
|
|
59
|
+
`TELEPTY_SUPERVISOR_BIN points at '${env.TELEPTY_SUPERVISOR_BIN}' but the file does not exist`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
// Repo-relative (src/bridge → repo root → target/{release,debug})
|
|
63
|
+
const repoRoot = path.resolve(__dirname, '..', '..');
|
|
64
|
+
const candidates = [
|
|
65
|
+
path.join(repoRoot, 'target', 'release', BIN_NAME),
|
|
66
|
+
path.join(repoRoot, 'target', 'debug', BIN_NAME),
|
|
67
|
+
];
|
|
68
|
+
for (const p of candidates) {
|
|
69
|
+
if (fs.existsSync(p)) return p;
|
|
70
|
+
}
|
|
71
|
+
const onPath = whichSync(BIN_NAME);
|
|
72
|
+
if (onPath) return onPath;
|
|
73
|
+
throw new SupervisorLauncherError(
|
|
74
|
+
'ERR_BIN_NOT_FOUND',
|
|
75
|
+
`${BIN_NAME} not found. Set TELEPTY_SUPERVISOR_BIN, build via 'cargo build -p telepty-supervisor-bin', or install on PATH.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Spawn a supervisor for a session. Returns a handle exposing the child
|
|
81
|
+
* process and manifest/socket paths. Caller is responsible for invoking
|
|
82
|
+
* `waitReady(sid)` before issuing bridge operations and for cleaning up the
|
|
83
|
+
* child on shutdown.
|
|
84
|
+
*
|
|
85
|
+
* stdio defaults to `['ignore', 'ignore', 'pipe']` — the supervisor mirrors
|
|
86
|
+
* PTY output to its own stdout for M1/M2 smoke parity, which would mix with
|
|
87
|
+
* the parent's stdout if inherited. Stderr (tracing) is piped so callers can
|
|
88
|
+
* surface ready/error logs if needed.
|
|
89
|
+
*
|
|
90
|
+
* @param {{sid: string, argv: string[], cwd?: ?string, binary?: ?string, env?: NodeJS.ProcessEnv, stdio?: Array}} opts
|
|
91
|
+
*/
|
|
92
|
+
function spawn(opts) {
|
|
93
|
+
const {
|
|
94
|
+
sid,
|
|
95
|
+
argv,
|
|
96
|
+
cwd = null,
|
|
97
|
+
binary = null,
|
|
98
|
+
env = process.env,
|
|
99
|
+
stdio = ['ignore', 'ignore', 'pipe'],
|
|
100
|
+
} = opts || {};
|
|
101
|
+
|
|
102
|
+
if (typeof sid !== 'string' || sid.length === 0) {
|
|
103
|
+
throw new SupervisorLauncherError('ERR_BAD_ARG', 'sid required');
|
|
104
|
+
}
|
|
105
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
106
|
+
throw new SupervisorLauncherError('ERR_BAD_ARG', 'argv must be a non-empty array');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const bin = binary || resolveBinary({ env });
|
|
110
|
+
const args = ['--sid', sid];
|
|
111
|
+
if (cwd) args.push('--cwd', cwd);
|
|
112
|
+
args.push('--', ...argv);
|
|
113
|
+
|
|
114
|
+
const child = childSpawn(bin, args, { stdio, env, detached: false });
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
child,
|
|
118
|
+
pid: child.pid,
|
|
119
|
+
sid,
|
|
120
|
+
binary: bin,
|
|
121
|
+
manifestPath: manifestPath(sid),
|
|
122
|
+
socketPath: socketPath(sid),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Poll the per-session manifest until it reaches a ready status. Resolves
|
|
128
|
+
* with the manifest object; rejects with ERR_READY_TIMEOUT on deadline.
|
|
129
|
+
*
|
|
130
|
+
* Uses TELEPTY_SESSIONS_DIR / HOME (via j3-shim's lazy resolution) so tests
|
|
131
|
+
* can redirect the sessions root deterministically.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} sid
|
|
134
|
+
* @param {{timeoutMs?: number, pollMs?: number}} [options]
|
|
135
|
+
* @returns {Promise<object>}
|
|
136
|
+
*/
|
|
137
|
+
async function waitReady(sid, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, pollMs = DEFAULT_READY_POLL_MS } = {}) {
|
|
138
|
+
const deadline = Date.now() + timeoutMs;
|
|
139
|
+
const mp = manifestPath(sid);
|
|
140
|
+
while (Date.now() < deadline) {
|
|
141
|
+
try {
|
|
142
|
+
const raw = fs.readFileSync(mp, 'utf8');
|
|
143
|
+
const m = JSON.parse(raw);
|
|
144
|
+
// supervisor.rs writes the manifest with Status::Ready *before* it calls
|
|
145
|
+
// ipc::bind_socket; gate on both so callers can immediately connect.
|
|
146
|
+
if (m && m.id === sid && READY_STATUSES.has(m.status) && fs.existsSync(m.ipc.path)) {
|
|
147
|
+
return m;
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// ENOENT / mid-write race — keep polling.
|
|
151
|
+
}
|
|
152
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
153
|
+
}
|
|
154
|
+
throw new SupervisorLauncherError(
|
|
155
|
+
'ERR_READY_TIMEOUT',
|
|
156
|
+
`supervisor for '${sid}' did not become ready within ${timeoutMs}ms`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Best-effort liveness probe — manifest present with ready/draining status
|
|
162
|
+
* AND the recorded pid is alive. Used by callers to decide bridge vs daemon
|
|
163
|
+
* fallback.
|
|
164
|
+
* @param {string} sid
|
|
165
|
+
* @returns {boolean}
|
|
166
|
+
*/
|
|
167
|
+
function isAlive(sid) {
|
|
168
|
+
try {
|
|
169
|
+
const raw = fs.readFileSync(manifestPath(sid), 'utf8');
|
|
170
|
+
const m = JSON.parse(raw);
|
|
171
|
+
if (!m || m.id !== sid) return false;
|
|
172
|
+
if (!READY_STATUSES.has(m.status)) return false;
|
|
173
|
+
if (typeof m.pid !== 'number' || m.pid <= 0) return false;
|
|
174
|
+
try {
|
|
175
|
+
process.kill(m.pid, 0);
|
|
176
|
+
return true;
|
|
177
|
+
} catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
resolveBinary,
|
|
187
|
+
spawn,
|
|
188
|
+
waitReady,
|
|
189
|
+
isAlive,
|
|
190
|
+
whichSync,
|
|
191
|
+
SupervisorLauncherError,
|
|
192
|
+
BIN_NAME,
|
|
193
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
|
|
7
|
+
const { parseDuration } = require('./lifecycle');
|
|
8
|
+
|
|
9
|
+
function configDir(options = {}) {
|
|
10
|
+
return options.configDir || path.join(os.homedir(), '.telepty');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function candidateConfigPaths(options = {}) {
|
|
14
|
+
const dir = configDir(options);
|
|
15
|
+
return [
|
|
16
|
+
path.join(dir, 'config.json'),
|
|
17
|
+
path.join(dir, 'config.yaml'),
|
|
18
|
+
path.join(dir, 'config.yml')
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseScalar(value) {
|
|
23
|
+
const trimmed = String(value || '').trim();
|
|
24
|
+
if (
|
|
25
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
26
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
27
|
+
) {
|
|
28
|
+
return trimmed.slice(1, -1);
|
|
29
|
+
}
|
|
30
|
+
return trimmed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseSimpleYaml(raw, filePath) {
|
|
34
|
+
const result = {};
|
|
35
|
+
const lines = String(raw || '').split(/\r?\n/);
|
|
36
|
+
for (const [index, line] of lines.entries()) {
|
|
37
|
+
const withoutComment = line.replace(/\s+#.*$/, '').trim();
|
|
38
|
+
if (!withoutComment) continue;
|
|
39
|
+
const match = withoutComment.match(/^([A-Za-z0-9_]+)\s*:\s*(.*?)\s*$/);
|
|
40
|
+
if (!match) {
|
|
41
|
+
throw new Error(`Invalid config syntax in ${filePath}:${index + 1}`);
|
|
42
|
+
}
|
|
43
|
+
result[match[1]] = parseScalar(match[2]);
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseConfigContent(raw, filePath) {
|
|
49
|
+
if (filePath.endsWith('.json')) {
|
|
50
|
+
return JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
return parseSimpleYaml(raw, filePath);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeConfig(parsed, filePath) {
|
|
56
|
+
const input = parsed && typeof parsed === 'object' ? parsed : {};
|
|
57
|
+
const idleTtlRaw = Object.prototype.hasOwnProperty.call(input, 'idle_ttl_default')
|
|
58
|
+
? input.idle_ttl_default
|
|
59
|
+
: 'off';
|
|
60
|
+
const idleTtlDefaultMs = parseDuration(idleTtlRaw, { fieldName: 'idle_ttl_default' });
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
path: filePath || null,
|
|
64
|
+
idle_ttl_default: idleTtlRaw == null ? 'off' : String(idleTtlRaw),
|
|
65
|
+
idleTtlDefaultMs
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadTeleptyConfig(options = {}) {
|
|
70
|
+
const paths = options.paths || candidateConfigPaths(options);
|
|
71
|
+
for (const filePath of paths) {
|
|
72
|
+
if (!fs.existsSync(filePath)) continue;
|
|
73
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
74
|
+
const parsed = parseConfigContent(raw, filePath);
|
|
75
|
+
return normalizeConfig(parsed, filePath);
|
|
76
|
+
}
|
|
77
|
+
return normalizeConfig({ idle_ttl_default: 'off' }, null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
candidateConfigPaths,
|
|
82
|
+
parseSimpleYaml,
|
|
83
|
+
parseConfigContent,
|
|
84
|
+
normalizeConfig,
|
|
85
|
+
loadTeleptyConfig
|
|
86
|
+
};
|
package/src/lifecycle.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
|
|
7
|
+
const { killWindowsProcess } = require('./win-kill-process');
|
|
8
|
+
|
|
9
|
+
const DURATION_RE = /^(\d+)(ms|s|m|h|d)$/i;
|
|
10
|
+
const UNIT_MS = {
|
|
11
|
+
ms: 1,
|
|
12
|
+
s: 1000,
|
|
13
|
+
m: 60 * 1000,
|
|
14
|
+
h: 60 * 60 * 1000,
|
|
15
|
+
d: 24 * 60 * 60 * 1000
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function parseDuration(value, options = {}) {
|
|
19
|
+
const fieldName = options.fieldName || 'duration';
|
|
20
|
+
if (value == null || value === '') {
|
|
21
|
+
throw new Error(`${fieldName} is required`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof value === 'number') {
|
|
25
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
26
|
+
throw new Error(`${fieldName} must be a non-negative duration`);
|
|
27
|
+
}
|
|
28
|
+
return Math.floor(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const raw = String(value).trim().toLowerCase();
|
|
32
|
+
if (raw === 'off') {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const match = raw.match(DURATION_RE);
|
|
37
|
+
if (!match) {
|
|
38
|
+
throw new Error(`${fieldName} must be a duration like 30m, 1h, 2d, or off`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const amount = Number(match[1]);
|
|
42
|
+
const unit = match[2].toLowerCase();
|
|
43
|
+
if (!Number.isSafeInteger(amount)) {
|
|
44
|
+
throw new Error(`${fieldName} is too large`);
|
|
45
|
+
}
|
|
46
|
+
return amount * UNIT_MS[unit];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function computeIdleSeconds(lastActivityAt, nowMs = Date.now()) {
|
|
50
|
+
if (!lastActivityAt) return null;
|
|
51
|
+
const ts = new Date(lastActivityAt).getTime();
|
|
52
|
+
if (!Number.isFinite(ts)) return null;
|
|
53
|
+
return Math.max(0, Math.floor((nowMs - ts) / 1000));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatIdleDuration(seconds) {
|
|
57
|
+
const totalMinutes = Math.max(0, Math.floor(Number(seconds || 0) / 60));
|
|
58
|
+
const days = Math.floor(totalMinutes / (24 * 60));
|
|
59
|
+
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
|
|
60
|
+
const minutes = totalMinutes % 60;
|
|
61
|
+
|
|
62
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
63
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
64
|
+
return `${minutes}m`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getSessionPid(session) {
|
|
68
|
+
if (!session) return null;
|
|
69
|
+
const candidates = [
|
|
70
|
+
session.ptyProcess && session.ptyProcess.pid,
|
|
71
|
+
session.ptyPid,
|
|
72
|
+
session.pty_pid,
|
|
73
|
+
session.pid,
|
|
74
|
+
session.ownerPid,
|
|
75
|
+
session.owner_pid
|
|
76
|
+
];
|
|
77
|
+
for (const pid of candidates) {
|
|
78
|
+
if (Number.isInteger(pid) && pid > 0) return pid;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isProcessAlive(pid, processKill = process.kill) {
|
|
84
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
85
|
+
try {
|
|
86
|
+
processKill(pid, 0);
|
|
87
|
+
return true;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (err && err.code === 'EPERM') return true;
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function defaultSleep(ms) {
|
|
95
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sendSignal(pid, signal, options = {}) {
|
|
99
|
+
const platform = options.platform || process.platform;
|
|
100
|
+
if (platform === 'win32') {
|
|
101
|
+
if (signal === 'SIGKILL' || signal === 'SIGTERM') {
|
|
102
|
+
return (options.killWindowsProcess || killWindowsProcess)(pid, options.windows || {});
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const processKill = options.processKill || process.kill;
|
|
108
|
+
try {
|
|
109
|
+
processKill(pid, signal);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err && err.code === 'ESRCH') return false;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function killSessionProcess(session, options = {}) {
|
|
118
|
+
const pid = options.pid || getSessionPid(session);
|
|
119
|
+
const timeoutMs = Math.max(0, Math.floor(options.timeoutMs ?? 5000));
|
|
120
|
+
const force = options.force === true;
|
|
121
|
+
const sleep = options.sleep || defaultSleep;
|
|
122
|
+
const processKill = options.processKill || process.kill;
|
|
123
|
+
const platform = options.platform || process.platform;
|
|
124
|
+
|
|
125
|
+
if (!pid) {
|
|
126
|
+
return { pid: null, signaled: false, escalated: false, reason: 'NO_PROCESS' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (force) {
|
|
130
|
+
const killed = sendSignal(pid, 'SIGKILL', { ...options, platform, processKill });
|
|
131
|
+
return { pid, signaled: killed, signal: 'SIGKILL', escalated: false, reason: killed ? null : 'NOT_RUNNING' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const terminated = sendSignal(pid, 'SIGTERM', { ...options, platform, processKill });
|
|
135
|
+
if (!terminated) {
|
|
136
|
+
return { pid, signaled: false, signal: 'SIGTERM', escalated: false, reason: 'NOT_RUNNING' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (timeoutMs > 0) {
|
|
140
|
+
await sleep(timeoutMs);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const alive = options.isAlive ? options.isAlive(pid) : isProcessAlive(pid, processKill);
|
|
144
|
+
if (!alive) {
|
|
145
|
+
return { pid, signaled: true, signal: 'SIGTERM', escalated: false, reason: null };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const killed = sendSignal(pid, 'SIGKILL', { ...options, platform, processKill });
|
|
149
|
+
return { pid, signaled: true, signal: 'SIGTERM', escalated: killed, escalatedSignal: killed ? 'SIGKILL' : null, reason: killed ? null : 'ESCALATION_FAILED' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function cleanupSessionArtifacts(sessionId, options = {}) {
|
|
153
|
+
const sessionsRoot = options.sessionsRoot || process.env.TELEPTY_SESSIONS_DIR || path.join(os.homedir(), '.telepty', 'sessions');
|
|
154
|
+
const sessionDir = path.join(sessionsRoot, sessionId);
|
|
155
|
+
const removed = [];
|
|
156
|
+
for (const filename of ['supervisor.sock', 'session.sock']) {
|
|
157
|
+
const filePath = path.join(sessionDir, filename);
|
|
158
|
+
try {
|
|
159
|
+
fs.rmSync(filePath, { force: true });
|
|
160
|
+
removed.push(filePath);
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
return removed;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function effectiveIdleTtlMs(session, config = {}) {
|
|
167
|
+
if (session && Object.prototype.hasOwnProperty.call(session, 'idleTtlMs')) {
|
|
168
|
+
return session.idleTtlMs == null ? null : Number(session.idleTtlMs);
|
|
169
|
+
}
|
|
170
|
+
if (session && Object.prototype.hasOwnProperty.call(session, 'idle_ttl_ms')) {
|
|
171
|
+
return session.idle_ttl_ms == null ? null : Number(session.idle_ttl_ms);
|
|
172
|
+
}
|
|
173
|
+
return config.idleTtlDefaultMs == null ? null : Number(config.idleTtlDefaultMs);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function selectIdleTtlVictims(sessions, config = {}, options = {}) {
|
|
177
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
178
|
+
const entries = sessions instanceof Map ? Array.from(sessions.entries()) : Object.entries(sessions || {});
|
|
179
|
+
const victims = [];
|
|
180
|
+
|
|
181
|
+
for (const [id, session] of entries) {
|
|
182
|
+
const ttlMs = effectiveIdleTtlMs(session, config);
|
|
183
|
+
if (!Number.isFinite(ttlMs) || ttlMs < 0) continue;
|
|
184
|
+
const idleSeconds = computeIdleSeconds(session && session.lastActivityAt, nowMs);
|
|
185
|
+
if (idleSeconds == null) continue;
|
|
186
|
+
const idleMs = idleSeconds * 1000;
|
|
187
|
+
if (idleMs > ttlMs) {
|
|
188
|
+
victims.push({ id, session, idleMs, idleSeconds, ttlMs });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return victims;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function selectCleanOlderThanTargets(sessions, options = {}) {
|
|
196
|
+
const olderThanMs = options.olderThanMs;
|
|
197
|
+
if (!Number.isFinite(olderThanMs) || olderThanMs < 0) {
|
|
198
|
+
throw new Error('olderThanMs must be a non-negative duration');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
202
|
+
const useIdle = options.idle === true;
|
|
203
|
+
const entries = Array.isArray(sessions) ? sessions.map((s) => [s.id, s]) : Object.entries(sessions || {});
|
|
204
|
+
const targets = [];
|
|
205
|
+
|
|
206
|
+
for (const [id, session] of entries) {
|
|
207
|
+
const timestamp = useIdle ? session.lastActivityAt : session.createdAt;
|
|
208
|
+
if (!timestamp) continue;
|
|
209
|
+
const ts = new Date(timestamp).getTime();
|
|
210
|
+
if (!Number.isFinite(ts)) continue;
|
|
211
|
+
const ageMs = Math.max(0, nowMs - ts);
|
|
212
|
+
if (ageMs > olderThanMs) {
|
|
213
|
+
targets.push({
|
|
214
|
+
id,
|
|
215
|
+
session,
|
|
216
|
+
reference: useIdle ? 'lastActivityAt' : 'createdAt',
|
|
217
|
+
ageMs,
|
|
218
|
+
ageSeconds: Math.floor(ageMs / 1000)
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return targets;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
parseDuration,
|
|
228
|
+
computeIdleSeconds,
|
|
229
|
+
formatIdleDuration,
|
|
230
|
+
getSessionPid,
|
|
231
|
+
isProcessAlive,
|
|
232
|
+
killSessionProcess,
|
|
233
|
+
cleanupSessionArtifacts,
|
|
234
|
+
effectiveIdleTtlMs,
|
|
235
|
+
selectIdleTtlVictims,
|
|
236
|
+
selectCleanOlderThanTargets
|
|
237
|
+
};
|
|
@@ -33,19 +33,49 @@ const ENTRIES = {
|
|
|
33
33
|
return { found: false };
|
|
34
34
|
},
|
|
35
35
|
},
|
|
36
|
-
// codex
|
|
37
|
-
//
|
|
36
|
+
// #472 (0.4.5): codex previously matched on a strict line-leading "^ › "
|
|
37
|
+
// shape; on real cmux captures the '›' tail-renders on the same row as the
|
|
38
|
+
// model-status footer and DECRQM/cursor-pos fragments leak in, so that
|
|
39
|
+
// strict matcher misses. Multi-signal tolerant matcher: picker anti-pattern
|
|
40
|
+
// first (resume-picker UI must NOT be considered ready), then a tolerant
|
|
41
|
+
// (a + b) signal pair, then the legacy strict scan as a back-compat
|
|
42
|
+
// fallback. Reason field surfaces which signal fired for log-attribution.
|
|
38
43
|
codex: {
|
|
39
44
|
symbol: '›',
|
|
40
45
|
byteSeq: Buffer.from([0xE2, 0x80, 0xBA]),
|
|
41
46
|
detect(screen) {
|
|
42
|
-
const
|
|
47
|
+
const text = String(screen == null ? '' : screen);
|
|
48
|
+
|
|
49
|
+
// Step 1: modal-UI anti-pattern. Resume picker, first-run directory
|
|
50
|
+
// trust prompt, and generic "Press enter to continue" modals are all
|
|
51
|
+
// pre-prompt UIs where Enter would not submit a user message. Treat
|
|
52
|
+
// any of them as NOT ready.
|
|
53
|
+
if (
|
|
54
|
+
/Resume a previous session/.test(text) ||
|
|
55
|
+
/^Filter:/m.test(text) ||
|
|
56
|
+
/Do you trust the contents/i.test(text) ||
|
|
57
|
+
/Press enter to continue/i.test(text)
|
|
58
|
+
) {
|
|
59
|
+
return { found: false, reason: 'codex_modal_ui' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 2: multi-signal tolerant. The codex boot box contains
|
|
63
|
+
// "OpenAI Codex (v<version>)" and the status row contains
|
|
64
|
+
// "gpt-<ver> <profile> fast". Both present anywhere on the captured
|
|
65
|
+
// screen → ready, regardless of where the literal '›' rendered.
|
|
66
|
+
if (/OpenAI Codex \(v/.test(text) && /gpt-[0-9.]+\s+\w+\s+fast/.test(text)) {
|
|
67
|
+
return { found: true, reason: 'codex_multi_signal' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Step 3: legacy strict line-leading scan — preserved for back-compat
|
|
71
|
+
// on clean cmux captures where the original matcher already worked.
|
|
72
|
+
const lines = text.split('\n');
|
|
43
73
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
44
74
|
const line = lines[i];
|
|
45
75
|
if (!/^ › /.test(line)) continue;
|
|
46
76
|
const footer = (lines[i + 1] || '') + '\n' + (lines[i + 2] || '');
|
|
47
77
|
if (/gpt-\d/.test(footer)) {
|
|
48
|
-
return { found: true, line_index: i, col: 2 };
|
|
78
|
+
return { found: true, line_index: i, col: 2, reason: 'codex_strict_line' };
|
|
49
79
|
}
|
|
50
80
|
}
|
|
51
81
|
return { found: false };
|
package/src/submit-gate.js
CHANGED
|
@@ -227,7 +227,13 @@ async function awaitPromptSymbol(session, opts = {}) {
|
|
|
227
227
|
if (lastSeenAt === null) {
|
|
228
228
|
lastSeenAt = now();
|
|
229
229
|
} else if (now() - lastSeenAt >= stabilityMs) {
|
|
230
|
-
|
|
230
|
+
// #472 (0.4.5): tag the success reason for debuggability — pairs
|
|
231
|
+
// with daemon.js startup-restore optimistic-ready logging so we
|
|
232
|
+
// can attribute every bootstrap_ready flip to a concrete signal.
|
|
233
|
+
if (match.reason && typeof console !== 'undefined' && console.log) {
|
|
234
|
+
console.log(`[bootstrap] ${session.command} ready via: ${match.reason}`);
|
|
235
|
+
}
|
|
236
|
+
return { ready: true, last_seen_at: lastSeenAt, waited_ms: now() - start, reason: match.reason };
|
|
231
237
|
}
|
|
232
238
|
} else {
|
|
233
239
|
// symbol disappeared — reset the stability streak
|