@aerode/pish 0.8.0 → 0.9.2
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/README.md +239 -44
- package/dist/agent.js +40 -27
- package/dist/app.js +59 -101
- package/dist/config.js +23 -0
- package/dist/log.js +3 -4
- package/dist/main.js +7 -14
- package/dist/recorder.js +23 -9
- package/dist/render.js +23 -15
- package/dist/session.js +61 -0
- package/dist/strip.js +3 -8
- package/dist/vterm.js +1 -1
- package/package.json +14 -5
package/README.md
CHANGED
|
@@ -1,38 +1,84 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# pish
|
|
2
4
|
|
|
3
5
|
**Your shell, with AI built in.**
|
|
4
6
|
|
|
5
|
-
pish
|
|
7
|
+
[](https://github.com/dacapoday/pish/actions/workflows/ci.yml)
|
|
8
|
+
[](https://www.npmjs.com/package/@aerode/pish)
|
|
9
|
+
[](https://nodejs.org/)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
|
|
12
|
+
<br/>
|
|
6
13
|
|
|
7
|
-
<p
|
|
8
|
-
<
|
|
14
|
+
<p>
|
|
15
|
+
<a href="#-features">Features</a> ·
|
|
16
|
+
<a href="#-quick-start">Quick Start</a> ·
|
|
17
|
+
<a href="#-usage">Usage</a> ·
|
|
18
|
+
<a href="#%EF%B8%8F-configuration">Configuration</a> ·
|
|
19
|
+
<a href="#-how-it-works">How It Works</a> ·
|
|
20
|
+
<a href="#-contributing">Contributing</a>
|
|
9
21
|
</p>
|
|
10
22
|
|
|
11
|
-
|
|
23
|
+
<br/>
|
|
12
24
|
|
|
13
|
-
pish
|
|
25
|
+
<img src="pish-example.gif" alt="pish demo" width="640">
|
|
14
26
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Why pish?
|
|
32
|
+
|
|
33
|
+
You already know your shell. You've built muscle memory for `cd`, `grep`, `git`, pipes, redirections, and a hundred aliases. **Why should talking to AI mean leaving all that behind?**
|
|
34
|
+
|
|
35
|
+
pish doesn't replace your shell — it **is** your shell. Every command you know works exactly as before, with zero overhead. The moment you type something the shell doesn't recognize, an AI coding agent ([pi](https://github.com/badlogic/pi-mono)) seamlessly steps in — reading files, running commands, and editing code — all without breaking your flow.
|
|
36
|
+
|
|
37
|
+
> **Think of it as autocomplete for intent:** you describe what you want in plain English, and pish makes it happen, right where you are.
|
|
38
|
+
|
|
39
|
+
## ✨ Features
|
|
40
|
+
|
|
41
|
+
<table>
|
|
42
|
+
<tr>
|
|
43
|
+
<td width="50%">
|
|
44
|
+
|
|
45
|
+
### 🔄 Transparent Shell Wrapper
|
|
46
|
+
Every alias, function, pipe, redirection, job control, tab completion, and history feature works exactly as in your native bash/zsh.
|
|
47
|
+
|
|
48
|
+
### 🤖 Automatic AI Agent
|
|
49
|
+
Type anything the shell doesn't recognize — the AI agent activates with your recent shell context, reads files, runs commands, and edits code.
|
|
50
|
+
|
|
51
|
+
### 🚀 Zero Overhead
|
|
52
|
+
Normal commands never touch the AI. No hooks intercepting your keystrokes, no latency. The agent is on-demand only.
|
|
53
|
+
|
|
54
|
+
</td>
|
|
55
|
+
<td width="50%">
|
|
56
|
+
|
|
57
|
+
### 🧠 Context-Aware
|
|
58
|
+
The agent automatically sees your recent commands and their outputs — it understands what you've been doing and can pick up where you left off.
|
|
59
|
+
|
|
60
|
+
### 🔀 Seamless pi TUI
|
|
61
|
+
Type `pi` to switch to the full pi TUI. Your conversation carries over — the AI remembers everything. Exit pi, and you're right back in pish.
|
|
22
62
|
|
|
23
|
-
|
|
63
|
+
### ⚡ Control Commands
|
|
64
|
+
Switch models, adjust thinking levels, and compact context — all without leaving your terminal. Just type `/model`, `/think`, or `/compact`.
|
|
24
65
|
|
|
25
|
-
|
|
66
|
+
</td>
|
|
67
|
+
</tr>
|
|
68
|
+
</table>
|
|
26
69
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
70
|
+
## 📦 Quick Start
|
|
71
|
+
|
|
72
|
+
### Prerequisites
|
|
73
|
+
|
|
74
|
+
- **Node.js** ≥ 18
|
|
75
|
+
- **bash** ≥ 4.4 or **zsh** ≥ 5.0
|
|
76
|
+
- [**pi**](https://github.com/badlogic/pi-mono) installed and on PATH
|
|
31
77
|
|
|
32
78
|
### Install from npm
|
|
33
79
|
|
|
34
80
|
```bash
|
|
35
|
-
npm install -g pish
|
|
81
|
+
npm install -g @aerode/pish
|
|
36
82
|
```
|
|
37
83
|
|
|
38
84
|
### Install from source
|
|
@@ -45,7 +91,7 @@ npm run build
|
|
|
45
91
|
npm link # makes `pish` available globally
|
|
46
92
|
```
|
|
47
93
|
|
|
48
|
-
###
|
|
94
|
+
### Launch
|
|
49
95
|
|
|
50
96
|
```bash
|
|
51
97
|
pish # start with $SHELL (or bash)
|
|
@@ -54,13 +100,13 @@ pish /usr/local/bin/bash # use a specific shell binary
|
|
|
54
100
|
pish --pi /path/to/pi # use a specific pi binary
|
|
55
101
|
```
|
|
56
102
|
|
|
57
|
-
## Usage
|
|
103
|
+
## 🎯 Usage
|
|
58
104
|
|
|
59
|
-
### Normal commands
|
|
105
|
+
### Normal commands — everything just works
|
|
60
106
|
|
|
61
|
-
|
|
107
|
+
Aliases, functions, pipes, redirections, job control, history, tab completion — **all unchanged**. pish adds zero overhead to normal shell operations.
|
|
62
108
|
|
|
63
|
-
### AI agent
|
|
109
|
+
### AI agent — just describe what you want
|
|
64
110
|
|
|
65
111
|
Type anything the shell doesn't recognize. The agent sees your recent commands and their outputs as context:
|
|
66
112
|
|
|
@@ -68,35 +114,48 @@ Type anything the shell doesn't recognize. The agent sees your recent commands a
|
|
|
68
114
|
❯ find all TODO comments in src/
|
|
69
115
|
⠋ Working...
|
|
70
116
|
$ grep -rn "TODO" src/
|
|
71
|
-
✓ done (2.1s)
|
|
117
|
+
✓ done (2.1s · 1.2k tokens · $0.003 · claude-sonnet-4-20250514)
|
|
72
118
|
```
|
|
73
119
|
|
|
120
|
+
The agent can:
|
|
121
|
+
- 📖 Read files and understand project structure
|
|
122
|
+
- ⚡ Run commands to gather information
|
|
123
|
+
- ✏️ Edit code across multiple files
|
|
124
|
+
- 🔍 Debug errors using your recent shell output as context
|
|
125
|
+
|
|
74
126
|
### Reverse to pi TUI
|
|
75
127
|
|
|
76
|
-
Type `pi` with no arguments to open the full pi TUI. Your conversation carries over — the AI remembers everything from the current session. When you exit
|
|
128
|
+
Type `pi` with no arguments to open the full [pi](https://github.com/badlogic/pi-mono) TUI. Your conversation carries over — the AI remembers everything from the current session. When you exit, you're back in pish with the updated session.
|
|
129
|
+
|
|
130
|
+
> **Tip:** `pi` with any arguments (e.g. `pi --help`, `pi some-file.txt`) is passed straight through to the original pi binary — only bare `pi` activates the session handoff. You can also use `command pi` to bypass pish entirely.
|
|
77
131
|
|
|
78
132
|
### Control commands
|
|
79
133
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
134
|
+
| Command | Description | Example |
|
|
135
|
+
|---------|-------------|---------|
|
|
136
|
+
| `/compact [instructions]` | Compact agent context | `/compact focus on auth` |
|
|
137
|
+
| `/model [provider/model]` | Switch or query model | `/model anthropic/claude-sonnet-4-20250514` |
|
|
138
|
+
| `/think [level]` | Set thinking level | `/think high` |
|
|
85
139
|
|
|
86
140
|
### Keyboard shortcuts
|
|
87
141
|
|
|
88
142
|
| Key | Action |
|
|
89
143
|
|-----|--------|
|
|
90
|
-
|
|
|
91
|
-
|
|
|
144
|
+
| <kbd>Ctrl</kbd>+<kbd>C</kbd> | Abort running agent |
|
|
145
|
+
| <kbd>Ctrl</kbd>+<kbd>L</kbd> | Clear screen + reset context + reset session |
|
|
146
|
+
|
|
147
|
+
## ⚙️ Configuration
|
|
92
148
|
|
|
93
|
-
|
|
149
|
+
Configuration priority: **CLI args > Environment variables > Defaults**
|
|
94
150
|
|
|
95
|
-
|
|
151
|
+
### CLI Options
|
|
96
152
|
|
|
97
153
|
```
|
|
98
154
|
pish [options] [shell]
|
|
99
155
|
|
|
156
|
+
Arguments:
|
|
157
|
+
shell bash, zsh, or full path (default: $SHELL or bash)
|
|
158
|
+
|
|
100
159
|
Options:
|
|
101
160
|
-s, --shell <name> Shell name or path
|
|
102
161
|
--pi <path> Path to pi binary
|
|
@@ -105,25 +164,161 @@ Options:
|
|
|
105
164
|
-h, --help Show help
|
|
106
165
|
```
|
|
107
166
|
|
|
108
|
-
|
|
109
|
-
|
|
167
|
+
### Environment Variables
|
|
168
|
+
|
|
169
|
+
| Variable | Description | Default |
|
|
170
|
+
|----------|-------------|---------|
|
|
110
171
|
| `PISH_SHELL` | Shell name or path | `$SHELL` or `bash` |
|
|
111
172
|
| `PISH_PI` | Path to pi binary | `pi` |
|
|
112
173
|
| `PISH_MAX_CONTEXT` | Max history entries sent to AI | `20` |
|
|
113
|
-
| `PISH_HEAD_LINES` | Output head lines kept | `50` |
|
|
114
|
-
| `PISH_TAIL_LINES` | Output tail lines kept | `30` |
|
|
115
|
-
| `PISH_LINE_WIDTH` | Max chars per line | `512` |
|
|
116
|
-
| `PISH_TOOL_LINES` | Max tool result lines
|
|
174
|
+
| `PISH_HEAD_LINES` | Output head lines kept per command | `50` |
|
|
175
|
+
| `PISH_TAIL_LINES` | Output tail lines kept per command | `30` |
|
|
176
|
+
| `PISH_LINE_WIDTH` | Max chars per output line | `512` |
|
|
177
|
+
| `PISH_TOOL_LINES` | Max tool result lines displayed | `10` |
|
|
117
178
|
| `PISH_LOG` | Event log (`stderr` or file path) | off |
|
|
118
179
|
| `PISH_DEBUG` | Debug log file path | off |
|
|
119
180
|
| `PISH_NO_BANNER` | Hide startup banner (set to `1`) | off |
|
|
120
181
|
|
|
182
|
+
## 🏗️ How It Works
|
|
183
|
+
|
|
184
|
+
pish runs your shell inside a PTY with lightweight hooks injected via a temporary rcfile. An [OSC 9154](https://en.wikipedia.org/wiki/ANSI_escape_code#OSC_(Operating_System_Command)_sequences) signal protocol embedded in the terminal data stream lets pish detect shell events — command execution, prompts, errors — without interfering with normal operation.
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
┌─────────────────────────────────────────────────────┐
|
|
188
|
+
│ Terminal (stdin/stdout) │
|
|
189
|
+
│ ▲ │
|
|
190
|
+
│ │ │
|
|
191
|
+
│ ▼ │
|
|
192
|
+
│ ┌─────────────────────────────────────────────┐ │
|
|
193
|
+
│ │ pish (Node.js) │ │
|
|
194
|
+
│ │ │ │
|
|
195
|
+
│ │ ┌──────────┐ ┌──────────┐ │ │
|
|
196
|
+
│ │ │ Recorder │◄──│ OSC │ PTY data │ │
|
|
197
|
+
│ │ │ (context)│ │ Parser │◄──────────┐ │ │
|
|
198
|
+
│ │ └────┬─────┘ └──────────┘ │ │ │
|
|
199
|
+
│ │ │ │ │ │
|
|
200
|
+
│ │ ▼ │ │ │
|
|
201
|
+
│ │ ┌──────────┐ ┌───────┴─┐ │ │
|
|
202
|
+
│ │ │ Agent │ pi --mode rpc │ PTY │ │ │
|
|
203
|
+
│ │ │ Manager │◄────────────────►│ bash/zsh│ │ │
|
|
204
|
+
│ │ └────┬─────┘ (on demand) │ +hooks │ │ │
|
|
205
|
+
│ │ │ └────┬────┘ │ │
|
|
206
|
+
│ │ ▼ │ │ │
|
|
207
|
+
│ │ ┌──────────┐ ┌────┴────┐ │ │
|
|
208
|
+
│ │ │ Renderer │──► stderr │ FIFO │ │ │
|
|
209
|
+
│ │ │ (pi-tui) │ (AI output) │ (sync) │ │ │
|
|
210
|
+
│ │ └──────────┘ └─────────┘ │ │
|
|
211
|
+
│ └─────────────────────────────────────────────┘ │
|
|
212
|
+
└─────────────────────────────────────────────────────┘
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Key design decisions
|
|
216
|
+
|
|
217
|
+
| Decision | Rationale |
|
|
218
|
+
|----------|-----------|
|
|
219
|
+
| Agent output goes to **stderr** | Never contaminates shell stdout — pipes and redirections work perfectly |
|
|
220
|
+
| Normal commands **never touch FIFO** | Zero latency for regular shell operations |
|
|
221
|
+
| Agent spawns **on demand** | No background process until you need it — instant startup |
|
|
222
|
+
| Session persists **across agent restarts** | Your conversation context survives `kill` and `reverse` |
|
|
223
|
+
| Context is **automatically truncated** | Smart head/tail truncation keeps AI context relevant without overwhelming tokens |
|
|
224
|
+
|
|
225
|
+
### The four signal paths
|
|
226
|
+
|
|
227
|
+
| Path | Trigger | What happens |
|
|
228
|
+
|------|---------|-------------|
|
|
229
|
+
| **Normal command** | `ls`, `git status`, etc. | Flows through PTY untouched. Recorder captures output for context. |
|
|
230
|
+
| **AI agent** | Unknown command like `fix the bug` | CNF fires → FIFO blocks shell → agent runs → PROCEED unblocks |
|
|
231
|
+
| **Reverse pi** | `pi` (no args) | Switches to full pi TUI with session handoff, returns to pish on exit |
|
|
232
|
+
| **Empty line** | Just pressing Enter | No-op, no context captured |
|
|
121
233
|
|
|
122
|
-
##
|
|
234
|
+
## 🧪 Testing
|
|
123
235
|
|
|
124
|
-
|
|
236
|
+
pish has a comprehensive three-layer test suite:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
# Run everything
|
|
240
|
+
bash test/run_tests.sh
|
|
241
|
+
|
|
242
|
+
# Unit tests only (~60s, 104 tests covering osc/strip/recorder/agent/config)
|
|
243
|
+
npm run test:unit
|
|
244
|
+
|
|
245
|
+
# Fast scenario tests (~10s, no pi binary needed)
|
|
246
|
+
bash test/run_tests.sh fast
|
|
247
|
+
|
|
248
|
+
# Slow scenario tests (~2min, needs real pi + LLM)
|
|
249
|
+
bash test/run_tests.sh slow
|
|
250
|
+
|
|
251
|
+
# Single test
|
|
252
|
+
bash test/run_tests.sh bash normal_cmd
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
| Layer | Tests | What it covers | Requires pi? |
|
|
256
|
+
|-------|-------|---------------|-------------|
|
|
257
|
+
| **Unit** | 104 | OSC parsing, ANSI stripping, recorder logic, agent RPC, config loading | No |
|
|
258
|
+
| **Fast scenarios** | 10 × bash/zsh + 3 edge | Shell lifecycle, context capture, truncation, nesting, control commands | No |
|
|
259
|
+
| **Slow scenarios** | 6 × bash/zsh | Real agent interaction, abort, reverse, model switching | Yes |
|
|
260
|
+
|
|
261
|
+
Scenario tests use `expect` scripts to drive real shell sessions, produce JSONL event logs, and verify with assertion-based checks.
|
|
262
|
+
|
|
263
|
+
## 🤝 Contributing
|
|
264
|
+
|
|
265
|
+
Contributions are welcome! Here's how to get started:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
git clone https://github.com/dacapoday/pish.git
|
|
269
|
+
cd pish
|
|
270
|
+
npm install
|
|
271
|
+
npm run build
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Development workflow
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
npm run dev # Watch mode (tsc --watch)
|
|
278
|
+
npm run lint # Biome lint + format check
|
|
279
|
+
npm run test:unit # Quick unit tests
|
|
280
|
+
bash test/run_tests.sh fast # Fast scenario tests
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Project structure
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
src/
|
|
287
|
+
├── main.ts # Entry point: bootstrap + I/O wiring
|
|
288
|
+
├── app.ts # Core state machine + event dispatch
|
|
289
|
+
├── config.ts # Unified config (CLI + ENV + defaults)
|
|
290
|
+
├── recorder.ts # PTY stream → context entries
|
|
291
|
+
├── agent.ts # pi RPC process management
|
|
292
|
+
├── hooks.ts # bash/zsh rcfile generation
|
|
293
|
+
├── render.ts # Agent UI → stderr (Markdown, spinner, status)
|
|
294
|
+
├── osc.ts # OSC 9154 signal parser
|
|
295
|
+
├── session.ts # pi session file discovery
|
|
296
|
+
├── theme.ts # ANSI colors + Markdown theme
|
|
297
|
+
├── vterm.ts # xterm headless prompt replay
|
|
298
|
+
├── strip.ts # ANSI stripping + truncation
|
|
299
|
+
└── log.ts # JSON event logging
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Before submitting a PR, please:
|
|
303
|
+
1. Run `npm run lint` to ensure code style
|
|
304
|
+
2. Run `bash test/run_tests.sh fast` to verify nothing is broken
|
|
305
|
+
3. Update documentation if behavior changes
|
|
306
|
+
|
|
307
|
+
## 📋 Known Limitations
|
|
308
|
+
|
|
309
|
+
- **Bash keywords** — `do something` or `if something` triggers a bash syntax error instead of the AI agent. Rephrase as `please do something`.
|
|
125
310
|
- **CNF returns 0** — `$?` after an agent run is always 0, not 127.
|
|
311
|
+
- **Reverse context** — When switching to pi TUI, shell context history is not transferred (only the agent session carries over).
|
|
312
|
+
- **bash 4.4+ required** — macOS ships bash 3.2; install a newer version via `brew install bash`.
|
|
126
313
|
|
|
127
|
-
## License
|
|
314
|
+
## 📄 License
|
|
128
315
|
|
|
129
316
|
[MIT](LICENSE) © [dacapoday](https://github.com/dacapoday)
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
<div align="center">
|
|
321
|
+
|
|
322
|
+
**[⬆ Back to top](#pish)**
|
|
323
|
+
|
|
324
|
+
</div>
|
package/dist/agent.js
CHANGED
|
@@ -34,19 +34,19 @@ function toRpcResponse(obj) {
|
|
|
34
34
|
export class AgentManager {
|
|
35
35
|
proc = null;
|
|
36
36
|
buf = '';
|
|
37
|
-
|
|
37
|
+
_onEvent = null;
|
|
38
38
|
_running = false;
|
|
39
39
|
_submitted = false;
|
|
40
40
|
startTime = 0;
|
|
41
41
|
lastUsage = null;
|
|
42
|
-
|
|
42
|
+
config;
|
|
43
43
|
pendingRpc = new Map();
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
/** Crash info (in-memory), displayed on next enterAgentMode */
|
|
47
|
-
|
|
48
|
-
constructor(
|
|
49
|
-
this.
|
|
44
|
+
/** Session file path. Maintained by pish across agent process restarts. */
|
|
45
|
+
_sessionFile;
|
|
46
|
+
/** Crash info (in-memory), displayed on next enterAgentMode. */
|
|
47
|
+
_crashInfo;
|
|
48
|
+
constructor(config) {
|
|
49
|
+
this.config = config;
|
|
50
50
|
}
|
|
51
51
|
get running() {
|
|
52
52
|
return this._running;
|
|
@@ -56,18 +56,18 @@ export class AgentManager {
|
|
|
56
56
|
return this.proc !== null && !this.proc.killed;
|
|
57
57
|
}
|
|
58
58
|
onEvent(cb) {
|
|
59
|
-
this.
|
|
59
|
+
this._onEvent = cb;
|
|
60
60
|
}
|
|
61
61
|
/** Ensure the pi process is alive (lazy spawn). */
|
|
62
62
|
ensureRunning() {
|
|
63
63
|
if (this.proc && !this.proc.killed)
|
|
64
64
|
return;
|
|
65
65
|
const args = ['--mode', 'rpc'];
|
|
66
|
-
if (this.
|
|
67
|
-
args.push('--session', this.
|
|
66
|
+
if (this._sessionFile) {
|
|
67
|
+
args.push('--session', this._sessionFile);
|
|
68
68
|
}
|
|
69
69
|
log('agent_spawn', { args });
|
|
70
|
-
this.proc = spawn(this.piPath, args, {
|
|
70
|
+
this.proc = spawn(this.config.piPath, args, {
|
|
71
71
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
72
72
|
env: { ...process.env },
|
|
73
73
|
});
|
|
@@ -125,7 +125,7 @@ export class AgentManager {
|
|
|
125
125
|
code !== 0 &&
|
|
126
126
|
signal !== 'SIGTERM' &&
|
|
127
127
|
signal !== 'SIGKILL') {
|
|
128
|
-
this.
|
|
128
|
+
this._crashInfo = `agent process exited unexpectedly (code ${code})`;
|
|
129
129
|
}
|
|
130
130
|
});
|
|
131
131
|
}
|
|
@@ -167,6 +167,7 @@ export class AgentManager {
|
|
|
167
167
|
this.emitEvent({ type: 'turn_end' });
|
|
168
168
|
break;
|
|
169
169
|
case 'message_end': {
|
|
170
|
+
// Stash per-message usage as fallback — agent_end may lack messages[]
|
|
170
171
|
const msg = obj.message;
|
|
171
172
|
if (msg?.role === 'assistant' && msg.usage) {
|
|
172
173
|
const u = msg.usage;
|
|
@@ -262,7 +263,10 @@ export class AgentManager {
|
|
|
262
263
|
// toolcall_start/delta/end handled by tool_execution_* events
|
|
263
264
|
}
|
|
264
265
|
}
|
|
265
|
-
/**
|
|
266
|
+
/**
|
|
267
|
+
* Aggregate usage from agent_end.messages[]. Falls back to lastUsage
|
|
268
|
+
* (stashed from message_end) if messages array is absent or empty.
|
|
269
|
+
*/
|
|
266
270
|
aggregateUsage(agentEnd) {
|
|
267
271
|
const messages = agentEnd.messages;
|
|
268
272
|
if (!Array.isArray(messages)) {
|
|
@@ -309,7 +313,7 @@ export class AgentManager {
|
|
|
309
313
|
};
|
|
310
314
|
}
|
|
311
315
|
emitEvent(event) {
|
|
312
|
-
this.
|
|
316
|
+
this._onEvent?.(event);
|
|
313
317
|
}
|
|
314
318
|
/** Submit a prompt to the agent. */
|
|
315
319
|
submit(message) {
|
|
@@ -332,15 +336,9 @@ export class AgentManager {
|
|
|
332
336
|
const cmd = `${JSON.stringify({ type: 'abort' })}\n`;
|
|
333
337
|
this.proc.stdin.write(cmd);
|
|
334
338
|
}
|
|
335
|
-
/** Send an RPC command (fire-and-forget). */
|
|
336
|
-
rpc(command) {
|
|
337
|
-
this.ensureRunning();
|
|
338
|
-
if (!this.proc?.stdin?.writable)
|
|
339
|
-
return;
|
|
340
|
-
this.proc.stdin.write(`${JSON.stringify(command)}\n`);
|
|
341
|
-
}
|
|
342
339
|
/** Send an RPC command and wait for the matching response. */
|
|
343
|
-
async rpcWait(command, timeoutMs
|
|
340
|
+
async rpcWait(command, timeoutMs) {
|
|
341
|
+
const timeout = timeoutMs ?? this.config.rpcTimeout;
|
|
344
342
|
this.ensureRunning();
|
|
345
343
|
if (!this.proc?.stdin?.writable) {
|
|
346
344
|
return {
|
|
@@ -355,7 +353,7 @@ export class AgentManager {
|
|
|
355
353
|
if (this.pendingRpc.delete(id)) {
|
|
356
354
|
resolve({ type: 'response', success: false, error: 'RPC timeout' });
|
|
357
355
|
}
|
|
358
|
-
},
|
|
356
|
+
}, timeout);
|
|
359
357
|
this.pendingRpc.set(id, { resolve, timer });
|
|
360
358
|
this.proc.stdin.write(`${JSON.stringify({ ...command, id })}\n`);
|
|
361
359
|
});
|
|
@@ -375,7 +373,7 @@ export class AgentManager {
|
|
|
375
373
|
if (this.proc && !this.proc.killed) {
|
|
376
374
|
const p = this.proc;
|
|
377
375
|
p.kill('SIGTERM');
|
|
378
|
-
// Escalate to SIGKILL if process doesn't exit
|
|
376
|
+
// Escalate to SIGKILL if process doesn't exit in time
|
|
379
377
|
const forceKill = setTimeout(() => {
|
|
380
378
|
try {
|
|
381
379
|
p.kill('SIGKILL');
|
|
@@ -383,16 +381,31 @@ export class AgentManager {
|
|
|
383
381
|
catch {
|
|
384
382
|
/* already exited */
|
|
385
383
|
}
|
|
386
|
-
},
|
|
384
|
+
}, this.config.killTimeout);
|
|
387
385
|
forceKill.unref(); // Don't prevent Node from exiting
|
|
388
386
|
this.proc = null;
|
|
389
387
|
}
|
|
390
388
|
this._running = false;
|
|
391
389
|
this._submitted = false;
|
|
392
390
|
}
|
|
391
|
+
get sessionFile() {
|
|
392
|
+
return this._sessionFile;
|
|
393
|
+
}
|
|
394
|
+
set sessionFile(path) {
|
|
395
|
+
this._sessionFile = path;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Consume crash info — returns the stored message and clears it.
|
|
399
|
+
* Designed for one-time display on next enterAgentMode.
|
|
400
|
+
*/
|
|
401
|
+
consumeCrashInfo() {
|
|
402
|
+
const info = this._crashInfo;
|
|
403
|
+
this._crashInfo = undefined;
|
|
404
|
+
return info;
|
|
405
|
+
}
|
|
393
406
|
/** Kill process + clear session (full reset via Ctrl+L). */
|
|
394
407
|
reset() {
|
|
395
408
|
this.kill();
|
|
396
|
-
this.
|
|
409
|
+
this._sessionFile = undefined;
|
|
397
410
|
}
|
|
398
411
|
}
|
package/dist/app.js
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* and FIFO responses. Created by main.ts after all resources are ready.
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from 'node:fs';
|
|
9
|
-
import * as os from 'node:os';
|
|
10
9
|
import * as path from 'node:path';
|
|
11
10
|
import { closeLog, log } from './log.js';
|
|
12
11
|
import { printBanner, printControl, printControlResult, printExit, printNotice, StreamRenderer, startSpinner, } from './render.js';
|
|
12
|
+
import { findLatestSession } from './session.js';
|
|
13
13
|
// ═══════════════════════════════════════
|
|
14
14
|
// Pure helpers (module-level, no `this`)
|
|
15
15
|
// ═══════════════════════════════════════
|
|
@@ -32,55 +32,6 @@ function formatContext(entries) {
|
|
|
32
32
|
})
|
|
33
33
|
.join('\n\n');
|
|
34
34
|
}
|
|
35
|
-
/** pi agent directory, respects PI_CODING_AGENT_DIR env var. */
|
|
36
|
-
function getAgentDir() {
|
|
37
|
-
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
38
|
-
if (envDir) {
|
|
39
|
-
if (envDir === '~')
|
|
40
|
-
return os.homedir();
|
|
41
|
-
if (envDir.startsWith('~/'))
|
|
42
|
-
return os.homedir() + envDir.slice(1);
|
|
43
|
-
return envDir;
|
|
44
|
-
}
|
|
45
|
-
return path.join(os.homedir(), '.pi', 'agent');
|
|
46
|
-
}
|
|
47
|
-
/** CWD encoding rule, matching pi's getDefaultSessionDir. */
|
|
48
|
-
function cwdToSessionSubdir(cwd) {
|
|
49
|
-
return `--${cwd.replace(/^[\/\\]/, '').replace(/[\/\\:]/g, '-')}--`;
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Find the latest session file in the CWD session directory with mtime > since.
|
|
53
|
-
*/
|
|
54
|
-
async function findLatestSession(since, debug) {
|
|
55
|
-
const cwdSessionDir = path.join(getAgentDir(), 'sessions', cwdToSessionSubdir(process.cwd()));
|
|
56
|
-
let files;
|
|
57
|
-
try {
|
|
58
|
-
files = await fs.promises.readdir(cwdSessionDir);
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
return null; // directory doesn't exist
|
|
62
|
-
}
|
|
63
|
-
let latest = null;
|
|
64
|
-
for (const file of files) {
|
|
65
|
-
if (!file.endsWith('.jsonl'))
|
|
66
|
-
continue;
|
|
67
|
-
try {
|
|
68
|
-
const filePath = path.join(cwdSessionDir, file);
|
|
69
|
-
const fstat = await fs.promises.stat(filePath);
|
|
70
|
-
const mtime = fstat.mtimeMs;
|
|
71
|
-
if (mtime > since && (!latest || mtime > latest.mtime)) {
|
|
72
|
-
latest = { path: filePath, mtime };
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
debug('findLatestSession: stat error for', file);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return latest?.path ?? null;
|
|
80
|
-
}
|
|
81
|
-
// ═══════════════════════════════════════
|
|
82
|
-
// App
|
|
83
|
-
// ═══════════════════════════════════════
|
|
84
35
|
export class App {
|
|
85
36
|
// ── Injected dependencies ──
|
|
86
37
|
cfg;
|
|
@@ -95,12 +46,8 @@ export class App {
|
|
|
95
46
|
// ── FIFO ──
|
|
96
47
|
fifoFd = null;
|
|
97
48
|
cleaned = false;
|
|
98
|
-
// ── Agent mode
|
|
99
|
-
|
|
100
|
-
agentCmd = '';
|
|
101
|
-
agentStartTime = 0;
|
|
102
|
-
stdinBuffer = [];
|
|
103
|
-
renderer = null;
|
|
49
|
+
// ── Agent mode (null = normal) ──
|
|
50
|
+
agentSession = null;
|
|
104
51
|
// ── Reverse session recovery ──
|
|
105
52
|
sessionEpoch = Date.now();
|
|
106
53
|
reverseStartTime = 0;
|
|
@@ -114,8 +61,9 @@ export class App {
|
|
|
114
61
|
this.tmpDir = infra.tmpDir;
|
|
115
62
|
this.rcPath = infra.rcPath;
|
|
116
63
|
// Open debug log file (same file shell hooks append to)
|
|
117
|
-
|
|
118
|
-
|
|
64
|
+
this.debugFd = deps.cfg.debugPath
|
|
65
|
+
? fs.openSync(deps.cfg.debugPath, 'a')
|
|
66
|
+
: null;
|
|
119
67
|
// Wire internal event handlers
|
|
120
68
|
this.agent.onEvent((event) => this.onAgentEvent(event));
|
|
121
69
|
this.recorder.onEvent((evt) => this.onRecorderEvent(evt));
|
|
@@ -132,19 +80,19 @@ export class App {
|
|
|
132
80
|
onPtyExit(code) {
|
|
133
81
|
this.debugLog('PTY exited, code:', code);
|
|
134
82
|
printExit();
|
|
135
|
-
log('exit', { context_count: this.recorder.
|
|
83
|
+
log('exit', { context_count: this.recorder.contextCount, code });
|
|
136
84
|
closeLog();
|
|
137
85
|
this.cleanup();
|
|
138
86
|
process.exit(code);
|
|
139
87
|
}
|
|
140
88
|
/** Terminal stdin data → mode routing. */
|
|
141
89
|
onStdin(data) {
|
|
142
|
-
if (this.
|
|
90
|
+
if (this.agentSession) {
|
|
143
91
|
if (data.length === 1 && data[0] === 0x03) {
|
|
144
92
|
this.abortAgent();
|
|
145
93
|
}
|
|
146
94
|
else {
|
|
147
|
-
this.stdinBuffer.push(Buffer.from(data));
|
|
95
|
+
this.agentSession.stdinBuffer.push(Buffer.from(data));
|
|
148
96
|
}
|
|
149
97
|
return;
|
|
150
98
|
}
|
|
@@ -163,6 +111,7 @@ export class App {
|
|
|
163
111
|
/** Terminal resized. */
|
|
164
112
|
onResize(cols, rows) {
|
|
165
113
|
this.pty.resize(cols, rows);
|
|
114
|
+
this.recorder.updateSize(cols, rows);
|
|
166
115
|
}
|
|
167
116
|
/** Cleanup all resources. Public — called by signal handlers in main.ts. */
|
|
168
117
|
cleanup() {
|
|
@@ -212,16 +161,16 @@ export class App {
|
|
|
212
161
|
// Agent event handler
|
|
213
162
|
// ═══════════════════════════════════════
|
|
214
163
|
onAgentEvent(event) {
|
|
215
|
-
this.renderer
|
|
216
|
-
if (event.type === 'agent_done' && this.
|
|
164
|
+
this.agentSession?.renderer.handleEvent(event);
|
|
165
|
+
if (event.type === 'agent_done' && this.agentSession) {
|
|
217
166
|
log('agent_done', {
|
|
218
|
-
cmd: this.
|
|
219
|
-
duration_ms: Date.now() - this.
|
|
167
|
+
cmd: this.agentSession.cmd,
|
|
168
|
+
duration_ms: Date.now() - this.agentSession.startTime,
|
|
220
169
|
});
|
|
221
170
|
this.exitAgentMode();
|
|
222
171
|
}
|
|
223
|
-
if (event.type === 'agent_error' && this.
|
|
224
|
-
log('agent_error', { cmd: this.
|
|
172
|
+
if (event.type === 'agent_error' && this.agentSession) {
|
|
173
|
+
log('agent_error', { cmd: this.agentSession.cmd, error: event.error });
|
|
225
174
|
this.exitAgentMode();
|
|
226
175
|
}
|
|
227
176
|
}
|
|
@@ -235,14 +184,18 @@ export class App {
|
|
|
235
184
|
this.fifoFd = fs.openSync(this.fifoPath, 'w');
|
|
236
185
|
this.debugLog('FIFO write fd opened');
|
|
237
186
|
log('shell_ready', { pid: this.pty.pid });
|
|
238
|
-
|
|
187
|
+
if (!this.cfg.noBanner) {
|
|
188
|
+
printBanner(this.cfg.version, this.cfg.shell, {
|
|
189
|
+
noAgent: this.cfg.noAgent,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
239
192
|
break;
|
|
240
193
|
case 'context':
|
|
241
194
|
log('context', {
|
|
242
195
|
prompt: evt.entry.prompt,
|
|
243
196
|
output: evt.entry.output,
|
|
244
197
|
rc: evt.entry.rc,
|
|
245
|
-
kept: this.recorder.
|
|
198
|
+
kept: this.recorder.contextCount,
|
|
246
199
|
});
|
|
247
200
|
break;
|
|
248
201
|
case 'context_skip':
|
|
@@ -271,19 +224,21 @@ export class App {
|
|
|
271
224
|
// Agent mode transitions
|
|
272
225
|
// ═══════════════════════════════════════
|
|
273
226
|
enterAgentMode(cmd) {
|
|
274
|
-
this.mode = 'agent';
|
|
275
|
-
this.agentCmd = cmd;
|
|
276
|
-
this.agentStartTime = Date.now();
|
|
277
|
-
this.stdinBuffer = [];
|
|
278
227
|
this.debugLog('enterAgentMode:', cmd);
|
|
279
228
|
const entries = this.recorder.drain();
|
|
280
229
|
log('agent', { cmd, context_count: entries.length });
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
230
|
+
const renderer = new StreamRenderer(this.cfg.toolResultLines, this.cfg.spinnerInterval);
|
|
231
|
+
this.agentSession = {
|
|
232
|
+
cmd,
|
|
233
|
+
startTime: Date.now(),
|
|
234
|
+
stdinBuffer: [],
|
|
235
|
+
renderer,
|
|
236
|
+
};
|
|
237
|
+
const crashInfo = this.agent.consumeCrashInfo();
|
|
238
|
+
if (crashInfo) {
|
|
239
|
+
printNotice(crashInfo);
|
|
285
240
|
}
|
|
286
|
-
|
|
241
|
+
renderer.showSpinner();
|
|
287
242
|
let message = cmd;
|
|
288
243
|
const ctx = formatContext(entries);
|
|
289
244
|
if (ctx) {
|
|
@@ -292,15 +247,15 @@ export class App {
|
|
|
292
247
|
this.agent.submit(message);
|
|
293
248
|
}
|
|
294
249
|
exitAgentMode() {
|
|
295
|
-
|
|
296
|
-
this.
|
|
297
|
-
this.
|
|
250
|
+
const session = this.agentSession;
|
|
251
|
+
this.debugLog('exitAgentMode, stdinBuffer:', session.stdinBuffer.length, 'chunks');
|
|
252
|
+
this.agentSession = null;
|
|
298
253
|
this.fifoWrite('PROCEED');
|
|
299
254
|
// Request state to get session file (for subsequent reverse).
|
|
300
255
|
// Only if agent process is alive — don't respawn after crash.
|
|
301
256
|
if (this.agent.alive) {
|
|
302
257
|
this.agent
|
|
303
|
-
.rpcWait({ type: 'get_state' }
|
|
258
|
+
.rpcWait({ type: 'get_state' })
|
|
304
259
|
.then((response) => {
|
|
305
260
|
if (response.success && response.data?.sessionFile) {
|
|
306
261
|
this.agent.sessionFile = response.data.sessionFile;
|
|
@@ -310,28 +265,25 @@ export class App {
|
|
|
310
265
|
log('get_state_error', { error: String(err) });
|
|
311
266
|
});
|
|
312
267
|
}
|
|
313
|
-
|
|
314
|
-
this.stdinBuffer = [];
|
|
315
|
-
if (buffered.length > 0) {
|
|
268
|
+
if (session.stdinBuffer.length > 0) {
|
|
316
269
|
setTimeout(() => {
|
|
317
|
-
for (const chunk of
|
|
270
|
+
for (const chunk of session.stdinBuffer) {
|
|
318
271
|
this.pty.write(chunk.toString());
|
|
319
272
|
}
|
|
320
|
-
this.debugLog('replayed',
|
|
321
|
-
},
|
|
273
|
+
this.debugLog('replayed', session.stdinBuffer.length, 'stdin chunks');
|
|
274
|
+
}, this.cfg.stdinReplayDelay);
|
|
322
275
|
}
|
|
323
276
|
}
|
|
324
277
|
abortAgent() {
|
|
325
278
|
this.debugLog('abortAgent');
|
|
279
|
+
const session = this.agentSession;
|
|
326
280
|
this.agent.abort();
|
|
327
|
-
|
|
281
|
+
session.renderer.printInterrupted();
|
|
328
282
|
log('agent_abort', {
|
|
329
|
-
cmd:
|
|
330
|
-
duration_ms: Date.now() -
|
|
283
|
+
cmd: session.cmd,
|
|
284
|
+
duration_ms: Date.now() - session.startTime,
|
|
331
285
|
});
|
|
332
|
-
this.
|
|
333
|
-
this.renderer = null;
|
|
334
|
-
this.stdinBuffer = [];
|
|
286
|
+
this.agentSession = null;
|
|
335
287
|
this.fifoWrite('PROCEED');
|
|
336
288
|
}
|
|
337
289
|
// ═══════════════════════════════════════
|
|
@@ -373,9 +325,9 @@ export class App {
|
|
|
373
325
|
const arg = parts.slice(1).join(' ');
|
|
374
326
|
switch (name) {
|
|
375
327
|
case '/compact': {
|
|
376
|
-
const stopSpinner = startSpinner('Compacting...');
|
|
328
|
+
const stopSpinner = startSpinner('Compacting...', this.cfg.spinnerInterval);
|
|
377
329
|
try {
|
|
378
|
-
return await this.agent.rpcWait({ type: 'compact', ...(arg ? { customInstructions: arg } : {}) },
|
|
330
|
+
return await this.agent.rpcWait({ type: 'compact', ...(arg ? { customInstructions: arg } : {}) }, this.cfg.compactTimeout);
|
|
379
331
|
}
|
|
380
332
|
finally {
|
|
381
333
|
stopSpinner();
|
|
@@ -383,7 +335,9 @@ export class App {
|
|
|
383
335
|
}
|
|
384
336
|
case '/model': {
|
|
385
337
|
if (!arg) {
|
|
386
|
-
|
|
338
|
+
// Query current model via get_state, then wrap as set_model response
|
|
339
|
+
// to reuse printControlResult's existing set_model rendering.
|
|
340
|
+
const state = await this.agent.rpcWait({ type: 'get_state' });
|
|
387
341
|
if (state.success && state.data?.model) {
|
|
388
342
|
const m = state.data.model;
|
|
389
343
|
const prov = m.provider;
|
|
@@ -411,15 +365,19 @@ export class App {
|
|
|
411
365
|
type: 'set_model',
|
|
412
366
|
provider: arg.slice(0, slashIdx),
|
|
413
367
|
modelId: arg.slice(slashIdx + 1),
|
|
414
|
-
}
|
|
368
|
+
});
|
|
415
369
|
}
|
|
416
370
|
else {
|
|
417
|
-
return await this.agent.rpcWait({
|
|
371
|
+
return await this.agent.rpcWait({
|
|
372
|
+
type: 'set_model',
|
|
373
|
+
provider: '',
|
|
374
|
+
modelId: arg,
|
|
375
|
+
});
|
|
418
376
|
}
|
|
419
377
|
}
|
|
420
378
|
case '/think': {
|
|
421
379
|
const level = arg || 'medium';
|
|
422
|
-
return await this.agent.rpcWait({ type: 'set_thinking_level', level }
|
|
380
|
+
return await this.agent.rpcWait({ type: 'set_thinking_level', level });
|
|
423
381
|
}
|
|
424
382
|
default:
|
|
425
383
|
return null;
|
|
@@ -435,7 +393,7 @@ export class App {
|
|
|
435
393
|
this.reverseStartTime = Date.now();
|
|
436
394
|
this.preReverseSessionFile = sessionFile;
|
|
437
395
|
log('reverse', {
|
|
438
|
-
context_count: this.recorder.
|
|
396
|
+
context_count: this.recorder.contextCount,
|
|
439
397
|
session: sessionFile || null,
|
|
440
398
|
});
|
|
441
399
|
if (sessionFile) {
|
package/dist/config.js
CHANGED
|
@@ -11,11 +11,24 @@ const require = createRequire(import.meta.url);
|
|
|
11
11
|
// ── Defaults ──
|
|
12
12
|
export const DEFAULTS = {
|
|
13
13
|
shell: 'bash',
|
|
14
|
+
// Context truncation
|
|
14
15
|
maxContext: 20,
|
|
15
16
|
headLines: 50,
|
|
16
17
|
tailLines: 30,
|
|
17
18
|
lineWidth: 512,
|
|
19
|
+
// Rendering
|
|
18
20
|
toolResultLines: 10,
|
|
21
|
+
// Timeouts (ms)
|
|
22
|
+
rpcTimeout: 30_000, // default RPC response timeout
|
|
23
|
+
compactTimeout: 60_000, // /compact needs LLM generation — much longer
|
|
24
|
+
killTimeout: 2_000, // SIGTERM → SIGKILL escalation wait
|
|
25
|
+
stdinReplayDelay: 50, // wait for shell readline ready after agent exit
|
|
26
|
+
// Terminal defaults (PTY spawn + vterm replay fallback)
|
|
27
|
+
defaultCols: 120,
|
|
28
|
+
defaultRows: 30,
|
|
29
|
+
// Internal limits
|
|
30
|
+
compactBufferThreshold: 100_000, // recorder fullBuffer trim threshold (bytes)
|
|
31
|
+
spinnerInterval: 80, // spinner animation frame interval (ms)
|
|
19
32
|
};
|
|
20
33
|
// ── Version ──
|
|
21
34
|
function readVersion() {
|
|
@@ -182,6 +195,16 @@ export function loadConfig() {
|
|
|
182
195
|
tailLines: envInt('PISH_TAIL_LINES', DEFAULTS.tailLines),
|
|
183
196
|
lineWidth: envInt('PISH_LINE_WIDTH', DEFAULTS.lineWidth),
|
|
184
197
|
toolResultLines: envInt('PISH_TOOL_LINES', DEFAULTS.toolResultLines),
|
|
198
|
+
rpcTimeout: DEFAULTS.rpcTimeout,
|
|
199
|
+
compactTimeout: DEFAULTS.compactTimeout,
|
|
200
|
+
killTimeout: DEFAULTS.killTimeout,
|
|
201
|
+
stdinReplayDelay: DEFAULTS.stdinReplayDelay,
|
|
202
|
+
defaultCols: DEFAULTS.defaultCols,
|
|
203
|
+
defaultRows: DEFAULTS.defaultRows,
|
|
204
|
+
compactBufferThreshold: DEFAULTS.compactBufferThreshold,
|
|
205
|
+
spinnerInterval: DEFAULTS.spinnerInterval,
|
|
206
|
+
debugPath: process.env.PISH_DEBUG || null,
|
|
207
|
+
logTarget: process.env.PISH_LOG || null,
|
|
185
208
|
noBanner: process.env.PISH_NO_BANNER === '1',
|
|
186
209
|
};
|
|
187
210
|
}
|
package/dist/log.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structured JSON event log.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Target values (from Config.logTarget):
|
|
5
|
+
* null → no output
|
|
6
6
|
* "1" | "stderr" → stderr
|
|
7
7
|
* file path → append to file
|
|
8
8
|
*/
|
|
9
9
|
import * as fs from 'node:fs';
|
|
10
10
|
let logFd = null;
|
|
11
11
|
let logToStderr = false;
|
|
12
|
-
export function initLog() {
|
|
13
|
-
const target = process.env.PISH_LOG;
|
|
12
|
+
export function initLog(target) {
|
|
14
13
|
if (!target)
|
|
15
14
|
return;
|
|
16
15
|
if (target === '1' || target === 'stderr') {
|
package/dist/main.js
CHANGED
|
@@ -26,22 +26,15 @@ if (process.env.PISH_PID) {
|
|
|
26
26
|
// Bootstrap
|
|
27
27
|
// ═══════════════════════════════════════
|
|
28
28
|
const cfg = loadConfig();
|
|
29
|
-
initLog();
|
|
29
|
+
initLog(cfg.logTarget);
|
|
30
30
|
// ── Infrastructure ──
|
|
31
31
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pish-'));
|
|
32
32
|
const fifoPath = path.join(tmpDir, 'fifo');
|
|
33
33
|
execFileSync('mkfifo', [fifoPath]);
|
|
34
34
|
const rcPath = generateRcfile({ shell: cfg.shell, fifoPath, tmpDir });
|
|
35
35
|
// ── Objects ──
|
|
36
|
-
const recorder = new Recorder(
|
|
37
|
-
|
|
38
|
-
truncate: {
|
|
39
|
-
headLines: cfg.headLines,
|
|
40
|
-
tailLines: cfg.tailLines,
|
|
41
|
-
maxLineWidth: cfg.lineWidth,
|
|
42
|
-
},
|
|
43
|
-
});
|
|
44
|
-
const agent = new AgentManager(cfg.piPath);
|
|
36
|
+
const recorder = new Recorder(cfg);
|
|
37
|
+
const agent = new AgentManager(cfg);
|
|
45
38
|
// ── PTY ──
|
|
46
39
|
const shellArgs = cfg.shell === 'bash' ? ['--rcfile', rcPath, '-i'] : ['-i'];
|
|
47
40
|
const env = {
|
|
@@ -56,8 +49,8 @@ if (cfg.shell === 'zsh') {
|
|
|
56
49
|
}
|
|
57
50
|
const ptyProcess = pty.spawn(cfg.shellPath, shellArgs, {
|
|
58
51
|
name: 'xterm-256color',
|
|
59
|
-
cols: process.stdout.columns ||
|
|
60
|
-
rows: process.stdout.rows ||
|
|
52
|
+
cols: process.stdout.columns || cfg.defaultCols,
|
|
53
|
+
rows: process.stdout.rows || cfg.defaultRows,
|
|
61
54
|
cwd: process.cwd(),
|
|
62
55
|
env,
|
|
63
56
|
});
|
|
@@ -70,9 +63,9 @@ ptyProcess.onData((data) => app.onPtyData(data));
|
|
|
70
63
|
ptyProcess.onExit(({ exitCode }) => app.onPtyExit(exitCode ?? 0));
|
|
71
64
|
process.stdin.setRawMode?.(true);
|
|
72
65
|
process.stdin.resume();
|
|
73
|
-
process.stdin.on('data', (data) => app.onStdin(data));
|
|
66
|
+
process.stdin.on('data', (data) => app.onStdin(Buffer.from(data)));
|
|
74
67
|
process.stdout.on('resize', () => {
|
|
75
|
-
app.onResize(process.stdout.columns ||
|
|
68
|
+
app.onResize(process.stdout.columns || cfg.defaultCols, process.stdout.rows || cfg.defaultRows);
|
|
76
69
|
});
|
|
77
70
|
// ═══════════════════════════════════════
|
|
78
71
|
// Signals
|
package/dist/recorder.js
CHANGED
|
@@ -9,12 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { log } from './log.js';
|
|
11
11
|
import { OscParser } from './osc.js';
|
|
12
|
-
import {
|
|
12
|
+
import { isAltScreen, stripAnsi, truncateLines } from './strip.js';
|
|
13
13
|
import { vtermReplay } from './vterm.js';
|
|
14
|
-
const DEFAULT_OPTIONS = {
|
|
15
|
-
maxContext: 20,
|
|
16
|
-
truncate: DEFAULT_TRUNCATE,
|
|
17
|
-
};
|
|
18
14
|
export class Recorder {
|
|
19
15
|
/**
|
|
20
16
|
* Complete clean PTY data (OSC 9154 stripped).
|
|
@@ -29,13 +25,27 @@ export class Recorder {
|
|
|
29
25
|
reverseInProgress = false;
|
|
30
26
|
gotFirstD = false;
|
|
31
27
|
pending = Promise.resolve();
|
|
28
|
+
/** Current terminal dimensions (updated via updateSize). */
|
|
29
|
+
cols;
|
|
30
|
+
rows;
|
|
32
31
|
opts;
|
|
33
32
|
oscParser = new OscParser();
|
|
34
33
|
/** Committed context entries. */
|
|
35
34
|
context = [];
|
|
36
35
|
_onEvent = null;
|
|
37
36
|
constructor(opts) {
|
|
38
|
-
this.opts =
|
|
37
|
+
this.opts = opts;
|
|
38
|
+
this.cols = opts.defaultCols;
|
|
39
|
+
this.rows = opts.defaultRows;
|
|
40
|
+
}
|
|
41
|
+
/** Number of committed context entries. */
|
|
42
|
+
get contextCount() {
|
|
43
|
+
return this.context.length;
|
|
44
|
+
}
|
|
45
|
+
/** Update terminal dimensions (called on resize). */
|
|
46
|
+
updateSize(cols, rows) {
|
|
47
|
+
this.cols = cols;
|
|
48
|
+
this.rows = rows;
|
|
39
49
|
}
|
|
40
50
|
onEvent(cb) {
|
|
41
51
|
this._onEvent = cb;
|
|
@@ -121,12 +131,16 @@ export class Recorder {
|
|
|
121
131
|
const cRel = snap.cAbs - snap.segStart;
|
|
122
132
|
const promptRaw = segData.slice(0, cRel);
|
|
123
133
|
const outputRaw = segData.slice(cRel);
|
|
124
|
-
promptText = await vtermReplay(promptRaw);
|
|
134
|
+
promptText = await vtermReplay(promptRaw, this.cols, this.rows);
|
|
125
135
|
if (isAltScreen(outputRaw)) {
|
|
126
136
|
outputText = '[full-screen app]';
|
|
127
137
|
}
|
|
128
138
|
else {
|
|
129
|
-
outputText = truncateLines(stripAnsi(outputRaw).trim(),
|
|
139
|
+
outputText = truncateLines(stripAnsi(outputRaw).trim(), {
|
|
140
|
+
headLines: this.opts.headLines,
|
|
141
|
+
tailLines: this.opts.tailLines,
|
|
142
|
+
lineWidth: this.opts.lineWidth,
|
|
143
|
+
});
|
|
130
144
|
}
|
|
131
145
|
}
|
|
132
146
|
else {
|
|
@@ -156,7 +170,7 @@ export class Recorder {
|
|
|
156
170
|
}
|
|
157
171
|
/** Release memory periodically (fullBuffer grows indefinitely). */
|
|
158
172
|
maybeCompact() {
|
|
159
|
-
if (this.segStart >
|
|
173
|
+
if (this.segStart > this.opts.compactBufferThreshold) {
|
|
160
174
|
this.fullBuffer = this.fullBuffer.slice(this.segStart);
|
|
161
175
|
if (this.cAbs !== null)
|
|
162
176
|
this.cAbs -= this.segStart;
|
package/dist/render.js
CHANGED
|
@@ -7,11 +7,9 @@
|
|
|
7
7
|
import { Box, Markdown, Text, visibleWidth, } from '@mariozechner/pi-tui';
|
|
8
8
|
import { bold, createMarkdownTheme, dim, TAG, theme, toolBg } from './theme.js';
|
|
9
9
|
// ─── Banner / Exit / Control ───
|
|
10
|
-
export function printBanner(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const parts = [`v${cfg.version}`, dim(cfg.shell)];
|
|
14
|
-
if (cfg.noAgent)
|
|
10
|
+
export function printBanner(version, shell, flags) {
|
|
11
|
+
const parts = [`v${version}`, dim(shell)];
|
|
12
|
+
if (flags?.noAgent)
|
|
15
13
|
parts.push(theme.warning('no-agent'));
|
|
16
14
|
process.stderr.write(`${TAG} ${parts.join(dim(' │ '))}\n`);
|
|
17
15
|
}
|
|
@@ -63,7 +61,7 @@ export function printNotice(msg) {
|
|
|
63
61
|
}
|
|
64
62
|
// ─── Spinner ───
|
|
65
63
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
66
|
-
export function startSpinner(label) {
|
|
64
|
+
export function startSpinner(label, intervalMs) {
|
|
67
65
|
let frame = 0;
|
|
68
66
|
let stopped = false;
|
|
69
67
|
const maxLabelCols = termWidth() - 3;
|
|
@@ -81,7 +79,7 @@ export function startSpinner(label) {
|
|
|
81
79
|
frame++;
|
|
82
80
|
};
|
|
83
81
|
render();
|
|
84
|
-
const timer = setInterval(render,
|
|
82
|
+
const timer = setInterval(render, intervalMs);
|
|
85
83
|
return () => {
|
|
86
84
|
if (stopped)
|
|
87
85
|
return;
|
|
@@ -92,7 +90,7 @@ export function startSpinner(label) {
|
|
|
92
90
|
}
|
|
93
91
|
// ─── Helpers ───
|
|
94
92
|
function termWidth() {
|
|
95
|
-
return process.stderr.columns ||
|
|
93
|
+
return process.stderr.columns || FALLBACK_TERM_WIDTH;
|
|
96
94
|
}
|
|
97
95
|
function flush(comp) {
|
|
98
96
|
for (const line of comp.render(termWidth())) {
|
|
@@ -105,6 +103,12 @@ function shortenPath(p) {
|
|
|
105
103
|
return `~${p.slice(home.length)}`;
|
|
106
104
|
return p;
|
|
107
105
|
}
|
|
106
|
+
/** Max visible chars for generic tool-title arg truncation. */
|
|
107
|
+
const TOOL_TITLE_MAX_CHARS = 60;
|
|
108
|
+
/** Max visible chars for compaction summary. */
|
|
109
|
+
const COMPACTION_SUMMARY_MAX_CHARS = 80;
|
|
110
|
+
/** Default terminal width when stderr columns is unknown. */
|
|
111
|
+
const FALLBACK_TERM_WIDTH = 80;
|
|
108
112
|
function truncate(s, max) {
|
|
109
113
|
const flat = s.replace(/\n/g, ' ');
|
|
110
114
|
if (flat.length <= max)
|
|
@@ -136,7 +140,7 @@ function formatToolTitle(toolName, args) {
|
|
|
136
140
|
}
|
|
137
141
|
const keys = Object.keys(args);
|
|
138
142
|
if (keys.length > 0)
|
|
139
|
-
return `${toolName} ${truncate(String(args[keys[0]]),
|
|
143
|
+
return `${toolName} ${truncate(String(args[keys[0]]), TOOL_TITLE_MAX_CHARS)}`;
|
|
140
144
|
return toolName;
|
|
141
145
|
}
|
|
142
146
|
function extractResultText(result) {
|
|
@@ -240,9 +244,11 @@ export class StreamRenderer {
|
|
|
240
244
|
mdTheme;
|
|
241
245
|
pendingTools = new Map();
|
|
242
246
|
toolResultLines;
|
|
243
|
-
|
|
247
|
+
spinnerInterval;
|
|
248
|
+
constructor(toolResultLines, spinnerInterval) {
|
|
244
249
|
this.mdTheme = createMarkdownTheme();
|
|
245
250
|
this.toolResultLines = toolResultLines;
|
|
251
|
+
this.spinnerInterval = spinnerInterval;
|
|
246
252
|
}
|
|
247
253
|
handleEvent(event) {
|
|
248
254
|
switch (event.type) {
|
|
@@ -290,7 +296,7 @@ export class StreamRenderer {
|
|
|
290
296
|
}
|
|
291
297
|
showSpinner() {
|
|
292
298
|
process.stderr.write('\x1b[?25l'); // hide cursor
|
|
293
|
-
this.stopSpinner = startSpinner('Working...');
|
|
299
|
+
this.stopSpinner = startSpinner('Working...', this.spinnerInterval);
|
|
294
300
|
}
|
|
295
301
|
// ─── Thinking ───
|
|
296
302
|
onThinkingStart() {
|
|
@@ -352,7 +358,7 @@ export class StreamRenderer {
|
|
|
352
358
|
this.clearSpinner();
|
|
353
359
|
const title = formatToolTitle(toolName, args);
|
|
354
360
|
this.pendingTools.set(toolCallId, title);
|
|
355
|
-
this.stopSpinner = startSpinner(title);
|
|
361
|
+
this.stopSpinner = startSpinner(title, this.spinnerInterval);
|
|
356
362
|
}
|
|
357
363
|
onToolEnd(toolCallId, _toolName, result, isError) {
|
|
358
364
|
this.clearSpinner();
|
|
@@ -385,7 +391,7 @@ export class StreamRenderer {
|
|
|
385
391
|
const reasonText = reason === 'overflow'
|
|
386
392
|
? 'Context overflow — auto-compacting...'
|
|
387
393
|
: 'Auto-compacting...';
|
|
388
|
-
this.stopSpinner = startSpinner(reasonText);
|
|
394
|
+
this.stopSpinner = startSpinner(reasonText, this.spinnerInterval);
|
|
389
395
|
}
|
|
390
396
|
onCompactionEnd(summary, aborted, error) {
|
|
391
397
|
this.clearSpinner();
|
|
@@ -396,7 +402,9 @@ export class StreamRenderer {
|
|
|
396
402
|
process.stderr.write(`${theme.error('✗')} compaction failed: ${error}\n`);
|
|
397
403
|
}
|
|
398
404
|
else if (summary) {
|
|
399
|
-
const short = summary.length >
|
|
405
|
+
const short = summary.length > COMPACTION_SUMMARY_MAX_CHARS
|
|
406
|
+
? `${summary.slice(0, COMPACTION_SUMMARY_MAX_CHARS - 3)}...`
|
|
407
|
+
: summary;
|
|
400
408
|
process.stderr.write(`${theme.accent('●')} compacted: ${theme.muted(short)}\n`);
|
|
401
409
|
}
|
|
402
410
|
}
|
|
@@ -437,7 +445,7 @@ export class StreamRenderer {
|
|
|
437
445
|
}
|
|
438
446
|
}
|
|
439
447
|
restartSpinner() {
|
|
440
|
-
this.stopSpinner = startSpinner('Working...');
|
|
448
|
+
this.stopSpinner = startSpinner('Working...', this.spinnerInterval);
|
|
441
449
|
}
|
|
442
450
|
countCursorLinesFromStart(text) {
|
|
443
451
|
const w = termWidth();
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session file discovery.
|
|
3
|
+
*
|
|
4
|
+
* Locates pi session files on disk. Encapsulates the path encoding
|
|
5
|
+
* convention shared with pi (getDefaultSessionDir) so that changes
|
|
6
|
+
* to session layout only affect this module.
|
|
7
|
+
*
|
|
8
|
+
* All functions are pure / side-effect-free (aside from filesystem reads).
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as os from 'node:os';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
/** pi agent directory, respects PI_CODING_AGENT_DIR env var. */
|
|
14
|
+
export function getAgentDir() {
|
|
15
|
+
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
16
|
+
if (envDir) {
|
|
17
|
+
if (envDir === '~')
|
|
18
|
+
return os.homedir();
|
|
19
|
+
if (envDir.startsWith('~/'))
|
|
20
|
+
return os.homedir() + envDir.slice(1);
|
|
21
|
+
return envDir;
|
|
22
|
+
}
|
|
23
|
+
return path.join(os.homedir(), '.pi', 'agent');
|
|
24
|
+
}
|
|
25
|
+
/** CWD encoding rule, matching pi's getDefaultSessionDir. */
|
|
26
|
+
export function cwdToSessionSubdir(cwd) {
|
|
27
|
+
return `--${cwd.replace(/^[\/\\]/, '').replace(/[\/\\:]/g, '-')}--`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Find the latest session file (.jsonl) in the CWD session directory
|
|
31
|
+
* with mtime strictly greater than `since` (epoch ms).
|
|
32
|
+
*
|
|
33
|
+
* Returns the absolute path, or null if none found.
|
|
34
|
+
*/
|
|
35
|
+
export async function findLatestSession(since, debug = () => { }) {
|
|
36
|
+
const cwdSessionDir = path.join(getAgentDir(), 'sessions', cwdToSessionSubdir(process.cwd()));
|
|
37
|
+
let files;
|
|
38
|
+
try {
|
|
39
|
+
files = await fs.promises.readdir(cwdSessionDir);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null; // directory doesn't exist
|
|
43
|
+
}
|
|
44
|
+
let latest = null;
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
if (!file.endsWith('.jsonl'))
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
const filePath = path.join(cwdSessionDir, file);
|
|
50
|
+
const fstat = await fs.promises.stat(filePath);
|
|
51
|
+
const mtime = fstat.mtimeMs;
|
|
52
|
+
if (mtime > since && (!latest || mtime > latest.mtime)) {
|
|
53
|
+
latest = { path: filePath, mtime };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
debug('findLatestSession: stat error for', file);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return latest?.path ?? null;
|
|
61
|
+
}
|
package/dist/strip.js
CHANGED
|
@@ -14,11 +14,6 @@ export function isAltScreen(data) {
|
|
|
14
14
|
export function stripAnsi(data) {
|
|
15
15
|
return data.replace(ANSI_RE, '');
|
|
16
16
|
}
|
|
17
|
-
export const DEFAULT_TRUNCATE = {
|
|
18
|
-
headLines: 50,
|
|
19
|
-
tailLines: 30,
|
|
20
|
-
maxLineWidth: 512,
|
|
21
|
-
};
|
|
22
17
|
/**
|
|
23
18
|
* Line-level truncation: keep head + tail lines, truncate the middle.
|
|
24
19
|
* Also truncates individual long lines.
|
|
@@ -26,12 +21,12 @@ export const DEFAULT_TRUNCATE = {
|
|
|
26
21
|
* Head > tail ratio: command output typically starts with structured info
|
|
27
22
|
* (headers, column names, first results); tail preserves errors and final results.
|
|
28
23
|
*/
|
|
29
|
-
export function truncateLines(text, opts
|
|
24
|
+
export function truncateLines(text, opts) {
|
|
30
25
|
let lines = text.split('\n');
|
|
31
26
|
// Per-line truncation
|
|
32
27
|
lines = lines.map((line) => {
|
|
33
|
-
if (line.length > opts.
|
|
34
|
-
return `${line.slice(0, opts.
|
|
28
|
+
if (line.length > opts.lineWidth) {
|
|
29
|
+
return `${line.slice(0, opts.lineWidth)} ...`;
|
|
35
30
|
}
|
|
36
31
|
return line;
|
|
37
32
|
});
|
package/dist/vterm.js
CHANGED
|
@@ -37,7 +37,7 @@ function getTerm(cols, rows) {
|
|
|
37
37
|
* Replay raw PTY data and return the final displayed text.
|
|
38
38
|
* Digests readline editing, ANSI color, cursor operations, alt screen, etc.
|
|
39
39
|
*/
|
|
40
|
-
export function vtermReplay(data, cols
|
|
40
|
+
export function vtermReplay(data, cols, rows) {
|
|
41
41
|
return new Promise((resolve) => {
|
|
42
42
|
const term = getTerm(cols, rows);
|
|
43
43
|
term.write(data, () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aerode/pish",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "A shell-first pi coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dacapoday",
|
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
"bugs": {
|
|
13
13
|
"url": "https://github.com/dacapoday/pish/issues"
|
|
14
14
|
},
|
|
15
|
-
"keywords": [
|
|
15
|
+
"keywords": [
|
|
16
|
+
"shell",
|
|
17
|
+
"ai",
|
|
18
|
+
"coding-agent",
|
|
19
|
+
"bash",
|
|
20
|
+
"zsh",
|
|
21
|
+
"pi",
|
|
22
|
+
"cli"
|
|
23
|
+
],
|
|
16
24
|
"engines": {
|
|
17
25
|
"node": ">=18"
|
|
18
26
|
},
|
|
@@ -37,13 +45,14 @@
|
|
|
37
45
|
"postinstall.sh"
|
|
38
46
|
],
|
|
39
47
|
"dependencies": {
|
|
40
|
-
"@mariozechner/pi-tui": "^0.
|
|
48
|
+
"@mariozechner/pi-tui": "^0.65.0",
|
|
41
49
|
"@xterm/headless": "^6.0.0",
|
|
42
50
|
"node-pty": "^1.0.0"
|
|
43
51
|
},
|
|
44
52
|
"devDependencies": {
|
|
45
53
|
"@biomejs/biome": "^2.4.10",
|
|
46
|
-
"@types/node": "^
|
|
47
|
-
"
|
|
54
|
+
"@types/node": "^25.5.2",
|
|
55
|
+
"tsx": "^4.21.0",
|
|
56
|
+
"typescript": "^6.0.2"
|
|
48
57
|
}
|
|
49
58
|
}
|