@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/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
+ }
@@ -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
+ }