@aerode/pish 0.8.0
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/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/agent.js +398 -0
- package/dist/app.js +473 -0
- package/dist/config.js +187 -0
- package/dist/hooks.js +198 -0
- package/dist/log.js +49 -0
- package/dist/main.js +89 -0
- package/dist/osc.js +157 -0
- package/dist/recorder.js +177 -0
- package/dist/render.js +455 -0
- package/dist/strip.js +46 -0
- package/dist/theme.js +64 -0
- package/dist/vterm.js +59 -0
- package/package.json +49 -0
- package/postinstall.sh +7 -0
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell hook rcfile generation.
|
|
3
|
+
*
|
|
4
|
+
* Generates a temporary rcfile that sources the user's original rc
|
|
5
|
+
* and appends pish hooks (OSC signals, FIFO, CNF handler, etc.).
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as os from 'node:os';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
export function generateRcfile(config) {
|
|
11
|
+
// zsh needs ZDOTDIR/.zshrc; bash uses --rcfile.
|
|
12
|
+
// zsh ZDOTDIR must be a separate dir to avoid conflicts with user .zshrc.
|
|
13
|
+
const rcDir = config.shell === 'zsh'
|
|
14
|
+
? fs.mkdtempSync(path.join(os.tmpdir(), 'pish-rc-'))
|
|
15
|
+
: config.tmpDir;
|
|
16
|
+
const rcName = config.shell === 'zsh' ? '.zshrc' : 'rc.bash';
|
|
17
|
+
const rcPath = path.join(rcDir, rcName);
|
|
18
|
+
let content;
|
|
19
|
+
if (config.shell === 'bash') {
|
|
20
|
+
content = generateBashRc(config);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
content = generateZshRc(config);
|
|
24
|
+
}
|
|
25
|
+
fs.writeFileSync(rcPath, content, 'utf-8');
|
|
26
|
+
return rcPath;
|
|
27
|
+
}
|
|
28
|
+
function generateBashRc(c) {
|
|
29
|
+
return `
|
|
30
|
+
# === pish rcfile (bash) ===
|
|
31
|
+
|
|
32
|
+
# 1. Source user startup files — simulate login shell sourcing order
|
|
33
|
+
# bash --rcfile is non-login: it skips /etc/profile and ~/.*profile.
|
|
34
|
+
# We source them here so PATH and env from login files take effect.
|
|
35
|
+
# Order: /etc/profile → first of ~/.bash_profile, ~/.bash_login, ~/.profile
|
|
36
|
+
# Then always source ~/.bashrc (idempotent; may already be sourced by profile).
|
|
37
|
+
if [[ -z "\${PISH_NORC:-}" ]]; then
|
|
38
|
+
[[ -f /etc/profile ]] && source /etc/profile
|
|
39
|
+
if [[ -f ~/.bash_profile ]]; then
|
|
40
|
+
source ~/.bash_profile
|
|
41
|
+
elif [[ -f ~/.bash_login ]]; then
|
|
42
|
+
source ~/.bash_login
|
|
43
|
+
elif [[ -f ~/.profile ]]; then
|
|
44
|
+
source ~/.profile
|
|
45
|
+
fi
|
|
46
|
+
[[ -f ~/.bashrc ]] && source ~/.bashrc
|
|
47
|
+
else
|
|
48
|
+
PS1='PISH_READY\$ '
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# 2. Version check — requires bash 4.4+
|
|
52
|
+
if [[ "\${BASH_VERSINFO[0]}" -lt 4 || \\
|
|
53
|
+
( "\${BASH_VERSINFO[0]}" -eq 4 && "\${BASH_VERSINFO[1]}" -lt 4 ) ]]; then
|
|
54
|
+
printf '\\033]9154;E;bash %s not supported (requires 4.4+)\\007' "$BASH_VERSION"
|
|
55
|
+
return 0 2>/dev/null || exit 0
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# 3. Environment
|
|
59
|
+
export PISH_FIFO="${c.fifoPath}"
|
|
60
|
+
|
|
61
|
+
# 4. Debug
|
|
62
|
+
__pish_debug() {
|
|
63
|
+
[[ -n "\${PISH_DEBUG:-}" ]] &&
|
|
64
|
+
printf '[%s] HOOK %s\\n' "$(date +%H:%M:%S.%3N)" "$*" >> "$PISH_DEBUG"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# 5. D signal — precmd (non-blocking)
|
|
68
|
+
__pish_precmd() {
|
|
69
|
+
local rc=$?
|
|
70
|
+
__pish_debug "precmd rc=$rc"
|
|
71
|
+
printf '\\033]9154;D;%d\\007' "$rc"
|
|
72
|
+
return "$rc"
|
|
73
|
+
}
|
|
74
|
+
PROMPT_COMMAND="__pish_precmd\${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
|
|
75
|
+
|
|
76
|
+
# 6. C signal — truncation boundary (PS0)
|
|
77
|
+
PS0='$(printf "\\033]9154;C\\007")'
|
|
78
|
+
|
|
79
|
+
# 7. P signal — CNF agent entry
|
|
80
|
+
command_not_found_handle() {
|
|
81
|
+
__pish_debug "CNF: $*"
|
|
82
|
+
printf '\\033]9154;P;%s\\007' "$*"
|
|
83
|
+
read -r _ <&"\${PISH_RFD}"
|
|
84
|
+
return 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# 8. R signal — reverse / pi command
|
|
88
|
+
pi() {
|
|
89
|
+
if [[ $# -eq 0 ]]; then
|
|
90
|
+
__pish_debug "reverse"
|
|
91
|
+
printf '\\033]9154;R\\007'
|
|
92
|
+
local sig
|
|
93
|
+
read -r sig <&"\${PISH_RFD}"
|
|
94
|
+
local session="\${sig#SESSION:}"
|
|
95
|
+
if [[ -n "$session" ]]; then
|
|
96
|
+
command pi --session "$session"
|
|
97
|
+
else
|
|
98
|
+
command pi
|
|
99
|
+
fi
|
|
100
|
+
else
|
|
101
|
+
command pi "$@"
|
|
102
|
+
fi
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# 9. Control commands
|
|
106
|
+
/compact() { local a; [[ $# -gt 0 ]] && a=" $*" || a=""; printf '\\033]9154;P;/compact%s\\007' "$a"; read -r _ <&"\${PISH_RFD}"; }
|
|
107
|
+
/model() { local a; [[ $# -gt 0 ]] && a=" $*" || a=""; printf '\\033]9154;P;/model%s\\007' "$a"; read -r _ <&"\${PISH_RFD}"; }
|
|
108
|
+
/think() { local a; [[ $# -gt 0 ]] && a=" $*" || a=""; printf '\\033]9154;P;/think%s\\007' "$a"; read -r _ <&"\${PISH_RFD}"; }
|
|
109
|
+
|
|
110
|
+
# 10. Open FIFO fd + send S signal
|
|
111
|
+
exec {PISH_RFD}<>"${c.fifoPath}"
|
|
112
|
+
export PISH_RFD
|
|
113
|
+
__pish_debug "shell ready, fd=\${PISH_RFD}"
|
|
114
|
+
printf '\\033]9154;S\\007'
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
function generateZshRc(c) {
|
|
118
|
+
return `
|
|
119
|
+
# === pish rcfile (zsh) ===
|
|
120
|
+
|
|
121
|
+
# 0. Restore ZDOTDIR (we temporarily set it to load this .zshrc)
|
|
122
|
+
[[ -n "\${__PISH_ORIG_ZDOTDIR+x}" ]] && ZDOTDIR="\$__PISH_ORIG_ZDOTDIR" || unset ZDOTDIR
|
|
123
|
+
|
|
124
|
+
# 1. Source user startup files — simulate login shell sourcing order
|
|
125
|
+
# pish starts zsh -i (non-login) with ZDOTDIR override, which skips:
|
|
126
|
+
# - ~/.zshenv (ZDOTDIR changed, zsh read $ZDOTDIR/.zshenv instead)
|
|
127
|
+
# - /etc/zprofile, ~/.zprofile (login-only)
|
|
128
|
+
# - /etc/zshrc (ZDOTDIR override)
|
|
129
|
+
# On macOS this is critical: /etc/zprofile runs path_helper,
|
|
130
|
+
# and ~/.zprofile often has Homebrew PATH setup.
|
|
131
|
+
if [[ -z "\${PISH_NORC:-}" ]]; then
|
|
132
|
+
[[ -f ~/.zshenv ]] && source ~/.zshenv
|
|
133
|
+
[[ -f /etc/zprofile ]] && source /etc/zprofile
|
|
134
|
+
[[ -f /etc/zsh/zprofile ]] && source /etc/zsh/zprofile
|
|
135
|
+
[[ -f ~/.zprofile ]] && source ~/.zprofile
|
|
136
|
+
[[ -f ~/.zshrc ]] && source ~/.zshrc
|
|
137
|
+
else
|
|
138
|
+
PS1='PISH_READY%% '
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
# 2. Version check — requires zsh 5.0+
|
|
142
|
+
if [[ "\${ZSH_VERSION%%.*}" -lt 5 ]]; then
|
|
143
|
+
printf '\\033]9154;E;zsh %s not supported (requires 5.0+)\\007' "$ZSH_VERSION"
|
|
144
|
+
return 0
|
|
145
|
+
fi
|
|
146
|
+
|
|
147
|
+
# 3. Environment
|
|
148
|
+
export PISH_FIFO="${c.fifoPath}"
|
|
149
|
+
|
|
150
|
+
# 4. D signal — precmd (non-blocking)
|
|
151
|
+
__pish_precmd() {
|
|
152
|
+
local rc=$?
|
|
153
|
+
printf '\\033]9154;D;%d\\007' "$rc"
|
|
154
|
+
return "$rc"
|
|
155
|
+
}
|
|
156
|
+
precmd_functions+=(__pish_precmd)
|
|
157
|
+
|
|
158
|
+
# 5. C signal — preexec
|
|
159
|
+
__pish_preexec() {
|
|
160
|
+
printf '\\033]9154;C\\007'
|
|
161
|
+
}
|
|
162
|
+
preexec_functions+=(__pish_preexec)
|
|
163
|
+
|
|
164
|
+
# 6. P signal — CNF
|
|
165
|
+
command_not_found_handler() {
|
|
166
|
+
printf '\\033]9154;P;%s\\007' "$*"
|
|
167
|
+
read -r _ <&"\${PISH_RFD}"
|
|
168
|
+
return 0
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# 7. R signal — reverse / pi command
|
|
172
|
+
pi() {
|
|
173
|
+
if [[ $# -eq 0 ]]; then
|
|
174
|
+
printf '\\033]9154;R\\007'
|
|
175
|
+
local sig
|
|
176
|
+
read -r sig <&"\${PISH_RFD}"
|
|
177
|
+
local session="\${sig#SESSION:}"
|
|
178
|
+
if [[ -n "$session" ]]; then
|
|
179
|
+
command pi --session "$session"
|
|
180
|
+
else
|
|
181
|
+
command pi
|
|
182
|
+
fi
|
|
183
|
+
else
|
|
184
|
+
command pi "$@"
|
|
185
|
+
fi
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# 8. Control commands
|
|
189
|
+
/compact() { local a; [[ $# -gt 0 ]] && a=" $*" || a=""; printf '\\033]9154;P;/compact%s\\007' "$a"; read -r _ <&"\${PISH_RFD}"; }
|
|
190
|
+
/model() { local a; [[ $# -gt 0 ]] && a=" $*" || a=""; printf '\\033]9154;P;/model%s\\007' "$a"; read -r _ <&"\${PISH_RFD}"; }
|
|
191
|
+
/think() { local a; [[ $# -gt 0 ]] && a=" $*" || a=""; printf '\\033]9154;P;/think%s\\007' "$a"; read -r _ <&"\${PISH_RFD}"; }
|
|
192
|
+
|
|
193
|
+
# 9. Open FIFO fd + send S signal
|
|
194
|
+
exec {PISH_RFD}<>"${c.fifoPath}"
|
|
195
|
+
export PISH_RFD
|
|
196
|
+
printf '\\033]9154;S\\007'
|
|
197
|
+
`;
|
|
198
|
+
}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured JSON event log.
|
|
3
|
+
*
|
|
4
|
+
* Controlled by PISH_LOG env var:
|
|
5
|
+
* unset → no output
|
|
6
|
+
* "1" | "stderr" → stderr
|
|
7
|
+
* file path → append to file
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
let logFd = null;
|
|
11
|
+
let logToStderr = false;
|
|
12
|
+
export function initLog() {
|
|
13
|
+
const target = process.env.PISH_LOG;
|
|
14
|
+
if (!target)
|
|
15
|
+
return;
|
|
16
|
+
if (target === '1' || target === 'stderr') {
|
|
17
|
+
logToStderr = true;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
logFd = fs.openSync(target, 'a');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function closeLog() {
|
|
24
|
+
if (logFd !== null) {
|
|
25
|
+
try {
|
|
26
|
+
fs.closeSync(logFd);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* fd may already be closed */
|
|
30
|
+
}
|
|
31
|
+
logFd = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function log(event, fields) {
|
|
35
|
+
if (!logToStderr && logFd === null)
|
|
36
|
+
return;
|
|
37
|
+
const entry = {
|
|
38
|
+
ts: new Date().toISOString(),
|
|
39
|
+
event,
|
|
40
|
+
...fields,
|
|
41
|
+
};
|
|
42
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
43
|
+
if (logToStderr) {
|
|
44
|
+
process.stderr.write(line);
|
|
45
|
+
}
|
|
46
|
+
if (logFd !== null) {
|
|
47
|
+
fs.writeSync(logFd, line);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pish — Pi-Integrated Shell.
|
|
4
|
+
*
|
|
5
|
+
* Entry point: bootstrap resources, wire I/O, delegate to App.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import * as pty from 'node-pty';
|
|
12
|
+
import { AgentManager } from './agent.js';
|
|
13
|
+
import { App } from './app.js';
|
|
14
|
+
import { loadConfig } from './config.js';
|
|
15
|
+
import { generateRcfile } from './hooks.js';
|
|
16
|
+
import { initLog, log } from './log.js';
|
|
17
|
+
import { Recorder } from './recorder.js';
|
|
18
|
+
// ═══════════════════════════════════════
|
|
19
|
+
// Nesting detection
|
|
20
|
+
// ═══════════════════════════════════════
|
|
21
|
+
if (process.env.PISH_PID) {
|
|
22
|
+
process.stderr.write('\x1b[31mpish: already running (nested launch blocked)\x1b[0m\n');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
// ═══════════════════════════════════════
|
|
26
|
+
// Bootstrap
|
|
27
|
+
// ═══════════════════════════════════════
|
|
28
|
+
const cfg = loadConfig();
|
|
29
|
+
initLog();
|
|
30
|
+
// ── Infrastructure ──
|
|
31
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pish-'));
|
|
32
|
+
const fifoPath = path.join(tmpDir, 'fifo');
|
|
33
|
+
execFileSync('mkfifo', [fifoPath]);
|
|
34
|
+
const rcPath = generateRcfile({ shell: cfg.shell, fifoPath, tmpDir });
|
|
35
|
+
// ── Objects ──
|
|
36
|
+
const recorder = new Recorder({
|
|
37
|
+
maxContext: cfg.maxContext,
|
|
38
|
+
truncate: {
|
|
39
|
+
headLines: cfg.headLines,
|
|
40
|
+
tailLines: cfg.tailLines,
|
|
41
|
+
maxLineWidth: cfg.lineWidth,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const agent = new AgentManager(cfg.piPath);
|
|
45
|
+
// ── PTY ──
|
|
46
|
+
const shellArgs = cfg.shell === 'bash' ? ['--rcfile', rcPath, '-i'] : ['-i'];
|
|
47
|
+
const env = {
|
|
48
|
+
...process.env,
|
|
49
|
+
PISH_PID: String(process.pid),
|
|
50
|
+
};
|
|
51
|
+
if (cfg.shell === 'zsh') {
|
|
52
|
+
if (process.env.ZDOTDIR !== undefined) {
|
|
53
|
+
env.__PISH_ORIG_ZDOTDIR = process.env.ZDOTDIR;
|
|
54
|
+
}
|
|
55
|
+
env.ZDOTDIR = path.dirname(rcPath);
|
|
56
|
+
}
|
|
57
|
+
const ptyProcess = pty.spawn(cfg.shellPath, shellArgs, {
|
|
58
|
+
name: 'xterm-256color',
|
|
59
|
+
cols: process.stdout.columns || 120,
|
|
60
|
+
rows: process.stdout.rows || 30,
|
|
61
|
+
cwd: process.cwd(),
|
|
62
|
+
env,
|
|
63
|
+
});
|
|
64
|
+
log('start', { shell: cfg.shell, pid: ptyProcess.pid });
|
|
65
|
+
// ═══════════════════════════════════════
|
|
66
|
+
// App + wiring
|
|
67
|
+
// ═══════════════════════════════════════
|
|
68
|
+
const app = new App({ cfg, pty: ptyProcess, recorder, agent }, { fifoPath, tmpDir, rcPath });
|
|
69
|
+
ptyProcess.onData((data) => app.onPtyData(data));
|
|
70
|
+
ptyProcess.onExit(({ exitCode }) => app.onPtyExit(exitCode ?? 0));
|
|
71
|
+
process.stdin.setRawMode?.(true);
|
|
72
|
+
process.stdin.resume();
|
|
73
|
+
process.stdin.on('data', (data) => app.onStdin(data));
|
|
74
|
+
process.stdout.on('resize', () => {
|
|
75
|
+
app.onResize(process.stdout.columns || 120, process.stdout.rows || 30);
|
|
76
|
+
});
|
|
77
|
+
// ═══════════════════════════════════════
|
|
78
|
+
// Signals
|
|
79
|
+
// ═══════════════════════════════════════
|
|
80
|
+
const quit = () => {
|
|
81
|
+
app.cleanup();
|
|
82
|
+
process.exit(0);
|
|
83
|
+
};
|
|
84
|
+
process.on('SIGTERM', quit);
|
|
85
|
+
process.on('SIGHUP', quit);
|
|
86
|
+
process.on('SIGINT', quit);
|
|
87
|
+
process.on('unhandledRejection', (err) => {
|
|
88
|
+
log('unhandled_rejection', { error: String(err) });
|
|
89
|
+
});
|
package/dist/osc.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSC 9154 signal parser.
|
|
3
|
+
*
|
|
4
|
+
* Strips OSC 9154 sequences from PTY data, extracts signals,
|
|
5
|
+
* and returns clean data with signal positions.
|
|
6
|
+
*
|
|
7
|
+
* Handles cross-chunk splitting: if a partial OSC sequence is at the
|
|
8
|
+
* end of a chunk, it is buffered and completed on the next feed().
|
|
9
|
+
*
|
|
10
|
+
* Format:
|
|
11
|
+
* ESC ] 9154 ; <payload> BEL (BEL = 0x07)
|
|
12
|
+
* ESC ] 9154 ; <payload> ESC \ (ST = ESC 0x5c)
|
|
13
|
+
*/
|
|
14
|
+
// Maximum length of a buffered partial OSC before we give up and flush it.
|
|
15
|
+
// OSC 9154 payloads are short (longest: P;cmd, cmd < 4096 chars).
|
|
16
|
+
const MAX_PARTIAL = 8192;
|
|
17
|
+
// Full OSC 9154 regex — matches complete sequences.
|
|
18
|
+
const OSC_RE = /\x1b\]9154;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g;
|
|
19
|
+
// Detects a potential partial OSC 9154 at end of string.
|
|
20
|
+
// Matches any prefix of: ESC ] 9 1 5 4 ; <payload> <terminator>
|
|
21
|
+
// We check if the string ends with an incomplete ESC sequence that could
|
|
22
|
+
// become a valid OSC 9154.
|
|
23
|
+
const OSC_PREFIX = '\x1b]9154;';
|
|
24
|
+
/**
|
|
25
|
+
* Stateful OSC 9154 parser. Maintains a residual buffer for partial
|
|
26
|
+
* sequences that span chunk boundaries.
|
|
27
|
+
*/
|
|
28
|
+
export class OscParser {
|
|
29
|
+
/** Buffered partial OSC sequence from previous feed(). */
|
|
30
|
+
partial = '';
|
|
31
|
+
/**
|
|
32
|
+
* Feed raw PTY data. Returns clean data (OSC stripped) and signals.
|
|
33
|
+
*/
|
|
34
|
+
feed(data) {
|
|
35
|
+
let input;
|
|
36
|
+
if (this.partial) {
|
|
37
|
+
input = this.partial + data;
|
|
38
|
+
this.partial = '';
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
input = data;
|
|
42
|
+
}
|
|
43
|
+
// Check for partial OSC at end of input
|
|
44
|
+
const tailStart = this.findPartialTail(input);
|
|
45
|
+
let processable;
|
|
46
|
+
if (tailStart >= 0) {
|
|
47
|
+
this.partial = input.slice(tailStart);
|
|
48
|
+
processable = input.slice(0, tailStart);
|
|
49
|
+
// Safety: if partial grows too large, it's not a real OSC — flush it
|
|
50
|
+
if (this.partial.length > MAX_PARTIAL) {
|
|
51
|
+
processable += this.partial;
|
|
52
|
+
this.partial = '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
processable = input;
|
|
57
|
+
}
|
|
58
|
+
return parseOsc(processable);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Find the start of a potential partial OSC 9154 at the end of the string.
|
|
62
|
+
* Returns the index, or -1 if no partial is found.
|
|
63
|
+
*
|
|
64
|
+
* Scans backward through all ESC positions to find an unterminated
|
|
65
|
+
* OSC 9154 prefix. An ESC is "unterminated" if from that ESC to the
|
|
66
|
+
* end of string there is no BEL and no ST (ESC \\).
|
|
67
|
+
*/
|
|
68
|
+
findPartialTail(data) {
|
|
69
|
+
// Scan backward for ESC characters, find the earliest unterminated one
|
|
70
|
+
let candidate = -1;
|
|
71
|
+
for (let i = data.length - 1; i >= 0; i--) {
|
|
72
|
+
if (data[i] !== '\x1b')
|
|
73
|
+
continue;
|
|
74
|
+
const tail = data.slice(i);
|
|
75
|
+
// Check if this ESC starts a valid OSC 9154 prefix
|
|
76
|
+
if (!this.isOscPrefix(tail))
|
|
77
|
+
continue;
|
|
78
|
+
// Check if it's already terminated (BEL or ST after the opening ESC)
|
|
79
|
+
const hasBel = tail.indexOf('\x07') >= 0;
|
|
80
|
+
// ST = ESC \\ — look for \x1b\\ after position 1 (skip the leading ESC itself)
|
|
81
|
+
let hasSt = false;
|
|
82
|
+
for (let j = 1; j < tail.length - 1; j++) {
|
|
83
|
+
if (tail[j] === '\x1b' && tail[j + 1] === '\\') {
|
|
84
|
+
hasSt = true;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (hasBel || hasSt) {
|
|
89
|
+
// Terminated — regex will handle it. Stop scanning: anything
|
|
90
|
+
// before a terminated sequence cannot be a partial tail.
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
// Unterminated — record as candidate and keep scanning backward
|
|
94
|
+
// to find the earliest one (the real start of the partial).
|
|
95
|
+
candidate = i;
|
|
96
|
+
}
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Check if `s` is a valid prefix of an OSC 9154 sequence.
|
|
101
|
+
* Valid prefixes: \x1b, \x1b], \x1b]9, \x1b]91, \x1b]915, \x1b]9154,
|
|
102
|
+
* \x1b]9154;, \x1b]9154;<payload...>
|
|
103
|
+
*/
|
|
104
|
+
isOscPrefix(s) {
|
|
105
|
+
// Must start with ESC
|
|
106
|
+
if (s[0] !== '\x1b')
|
|
107
|
+
return false;
|
|
108
|
+
if (s.length === 1)
|
|
109
|
+
return true; // just ESC — could become ESC]...
|
|
110
|
+
// After ESC must be ]
|
|
111
|
+
if (s[1] !== ']')
|
|
112
|
+
return false;
|
|
113
|
+
if (s.length === 2)
|
|
114
|
+
return true;
|
|
115
|
+
// Check that chars 2..6 match "9154;" prefix
|
|
116
|
+
for (let i = 2; i < s.length && i < OSC_PREFIX.length; i++) {
|
|
117
|
+
if (s[i] !== OSC_PREFIX[i])
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
// If we've passed the full prefix, it's payload (waiting for terminator)
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Stateless parse of a complete data chunk (no partial handling). */
|
|
125
|
+
export function parseOsc(data) {
|
|
126
|
+
const signals = [];
|
|
127
|
+
let cleanOffset = 0;
|
|
128
|
+
let lastIndex = 0;
|
|
129
|
+
const clean = data.replace(OSC_RE, (match, payload, offset) => {
|
|
130
|
+
cleanOffset += offset - lastIndex;
|
|
131
|
+
lastIndex = offset + match.length;
|
|
132
|
+
const sig = parsePayload(payload);
|
|
133
|
+
if (sig)
|
|
134
|
+
signals.push({ signal: sig, cleanOffset });
|
|
135
|
+
return '';
|
|
136
|
+
});
|
|
137
|
+
return { clean, signals };
|
|
138
|
+
}
|
|
139
|
+
function parsePayload(payload) {
|
|
140
|
+
if (payload === 'S')
|
|
141
|
+
return { type: 'S' };
|
|
142
|
+
if (payload === 'C')
|
|
143
|
+
return { type: 'C' };
|
|
144
|
+
if (payload === 'R')
|
|
145
|
+
return { type: 'R' };
|
|
146
|
+
if (payload.startsWith('D;')) {
|
|
147
|
+
const rc = parseInt(payload.slice(2), 10);
|
|
148
|
+
return { type: 'D', rc: Number.isNaN(rc) ? 0 : rc };
|
|
149
|
+
}
|
|
150
|
+
if (payload.startsWith('P;')) {
|
|
151
|
+
return { type: 'P', cmd: payload.slice(2) };
|
|
152
|
+
}
|
|
153
|
+
if (payload.startsWith('E;')) {
|
|
154
|
+
return { type: 'E', msg: payload.slice(2) };
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
package/dist/recorder.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recorder — extracts context entries from the PTY stream.
|
|
3
|
+
*
|
|
4
|
+
* Core idea:
|
|
5
|
+
* - Continuously appends PTY data to a buffer.
|
|
6
|
+
* - C signal marks the prompt/output boundary.
|
|
7
|
+
* - D signal triggers commit or discard.
|
|
8
|
+
* - Buffer is not cleared on signal; instead, segment start/end offsets are tracked.
|
|
9
|
+
*/
|
|
10
|
+
import { log } from './log.js';
|
|
11
|
+
import { OscParser } from './osc.js';
|
|
12
|
+
import { DEFAULT_TRUNCATE, isAltScreen, stripAnsi, truncateLines, } from './strip.js';
|
|
13
|
+
import { vtermReplay } from './vterm.js';
|
|
14
|
+
const DEFAULT_OPTIONS = {
|
|
15
|
+
maxContext: 20,
|
|
16
|
+
truncate: DEFAULT_TRUNCATE,
|
|
17
|
+
};
|
|
18
|
+
export class Recorder {
|
|
19
|
+
/**
|
|
20
|
+
* Complete clean PTY data (OSC 9154 stripped).
|
|
21
|
+
* Append-only; segStart tracks the current segment origin.
|
|
22
|
+
*/
|
|
23
|
+
fullBuffer = '';
|
|
24
|
+
/** Start offset of the current segment (after last D). */
|
|
25
|
+
segStart = 0;
|
|
26
|
+
/** Absolute offset of C in fullBuffer (null = no C in this segment). */
|
|
27
|
+
cAbs = null;
|
|
28
|
+
discardNext = false;
|
|
29
|
+
reverseInProgress = false;
|
|
30
|
+
gotFirstD = false;
|
|
31
|
+
pending = Promise.resolve();
|
|
32
|
+
opts;
|
|
33
|
+
oscParser = new OscParser();
|
|
34
|
+
/** Committed context entries. */
|
|
35
|
+
context = [];
|
|
36
|
+
_onEvent = null;
|
|
37
|
+
constructor(opts) {
|
|
38
|
+
this.opts = { ...DEFAULT_OPTIONS, ...opts };
|
|
39
|
+
}
|
|
40
|
+
onEvent(cb) {
|
|
41
|
+
this._onEvent = cb;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Feed raw PTY data. Returns clean data with OSC sequences stripped.
|
|
45
|
+
*/
|
|
46
|
+
feed(data) {
|
|
47
|
+
const { clean, signals } = this.oscParser.feed(data);
|
|
48
|
+
const base = this.fullBuffer.length;
|
|
49
|
+
this.fullBuffer += clean;
|
|
50
|
+
for (const ps of signals) {
|
|
51
|
+
this.handleSignal(ps.signal, base + ps.cleanOffset);
|
|
52
|
+
}
|
|
53
|
+
return clean;
|
|
54
|
+
}
|
|
55
|
+
handleSignal(sig, absOffset) {
|
|
56
|
+
switch (sig.type) {
|
|
57
|
+
case 'S':
|
|
58
|
+
this.emit({ type: 'shell_ready' });
|
|
59
|
+
break;
|
|
60
|
+
case 'C':
|
|
61
|
+
this.cAbs = absOffset;
|
|
62
|
+
break;
|
|
63
|
+
case 'D': {
|
|
64
|
+
// Snapshot shared mutable state before enqueuing — C/P/R signals
|
|
65
|
+
// in the same feed() call modify these synchronously while D waits.
|
|
66
|
+
const snap = {
|
|
67
|
+
segStart: this.segStart,
|
|
68
|
+
cAbs: this.cAbs,
|
|
69
|
+
discardNext: this.discardNext,
|
|
70
|
+
reverseInProgress: this.reverseInProgress,
|
|
71
|
+
};
|
|
72
|
+
this.pending = this.pending
|
|
73
|
+
.then(() => this.handleD(sig.rc, absOffset, snap))
|
|
74
|
+
.catch((err) => {
|
|
75
|
+
log('vtermReplay_error', { error: String(err) });
|
|
76
|
+
});
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case 'P':
|
|
80
|
+
this.discardNext = true;
|
|
81
|
+
this.segStart = absOffset;
|
|
82
|
+
this.cAbs = null;
|
|
83
|
+
this.emit({ type: 'agent', cmd: sig.cmd });
|
|
84
|
+
break;
|
|
85
|
+
case 'R':
|
|
86
|
+
this.discardNext = true;
|
|
87
|
+
this.reverseInProgress = true;
|
|
88
|
+
this.segStart = absOffset;
|
|
89
|
+
this.cAbs = null;
|
|
90
|
+
this.emit({ type: 'reverse' });
|
|
91
|
+
break;
|
|
92
|
+
case 'E':
|
|
93
|
+
this.emit({ type: 'error', msg: sig.msg });
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async handleD(rc, absOffset, snap) {
|
|
98
|
+
// First D = startup garbage, skip
|
|
99
|
+
if (!this.gotFirstD) {
|
|
100
|
+
this.gotFirstD = true;
|
|
101
|
+
this.segStart = absOffset;
|
|
102
|
+
this.cAbs = null;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// D after agent or reverse — discard
|
|
106
|
+
if (snap.discardNext) {
|
|
107
|
+
this.discardNext = false;
|
|
108
|
+
if (snap.reverseInProgress) {
|
|
109
|
+
this.reverseInProgress = false;
|
|
110
|
+
this.emit({ type: 'reverse_done' });
|
|
111
|
+
}
|
|
112
|
+
this.segStart = absOffset;
|
|
113
|
+
this.cAbs = null;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Current segment: snap.segStart .. absOffset
|
|
117
|
+
const segData = this.fullBuffer.slice(snap.segStart, absOffset);
|
|
118
|
+
let promptText;
|
|
119
|
+
let outputText;
|
|
120
|
+
if (snap.cAbs !== null && snap.cAbs >= snap.segStart) {
|
|
121
|
+
const cRel = snap.cAbs - snap.segStart;
|
|
122
|
+
const promptRaw = segData.slice(0, cRel);
|
|
123
|
+
const outputRaw = segData.slice(cRel);
|
|
124
|
+
promptText = await vtermReplay(promptRaw);
|
|
125
|
+
if (isAltScreen(outputRaw)) {
|
|
126
|
+
outputText = '[full-screen app]';
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
outputText = truncateLines(stripAnsi(outputRaw).trim(), this.opts.truncate);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// No C = no command executed (empty enter, etc.)
|
|
134
|
+
this.segStart = absOffset;
|
|
135
|
+
this.cAbs = null;
|
|
136
|
+
this.emit({ type: 'context_skip', reason: 'no_c' });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Skip empty entries (C present but no output and rc=0, e.g. `true`)
|
|
140
|
+
if (!outputText && rc === 0) {
|
|
141
|
+
this.segStart = absOffset;
|
|
142
|
+
this.cAbs = null;
|
|
143
|
+
this.emit({ type: 'context_skip', reason: 'no_output' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const entry = { prompt: promptText, output: outputText, rc };
|
|
147
|
+
this.context.push(entry);
|
|
148
|
+
// Discard oldest when over limit
|
|
149
|
+
while (this.context.length > this.opts.maxContext) {
|
|
150
|
+
this.context.shift();
|
|
151
|
+
}
|
|
152
|
+
this.emit({ type: 'context', entry });
|
|
153
|
+
this.segStart = absOffset;
|
|
154
|
+
this.cAbs = null;
|
|
155
|
+
this.maybeCompact();
|
|
156
|
+
}
|
|
157
|
+
/** Release memory periodically (fullBuffer grows indefinitely). */
|
|
158
|
+
maybeCompact() {
|
|
159
|
+
if (this.segStart > 100_000) {
|
|
160
|
+
this.fullBuffer = this.fullBuffer.slice(this.segStart);
|
|
161
|
+
if (this.cAbs !== null)
|
|
162
|
+
this.cAbs -= this.segStart;
|
|
163
|
+
this.segStart = 0;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Drain context — returns all current entries and clears the list.
|
|
168
|
+
* Consumed context is not re-sent to the next agent invocation.
|
|
169
|
+
*/
|
|
170
|
+
drain() {
|
|
171
|
+
const entries = this.context.splice(0);
|
|
172
|
+
return entries;
|
|
173
|
+
}
|
|
174
|
+
emit(evt) {
|
|
175
|
+
this._onEvent?.(evt);
|
|
176
|
+
}
|
|
177
|
+
}
|