@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 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 wraps bash or zsh transparently. Every command you know works exactly as before — zero overhead. When you type something the shell doesn't recognize, an AI agent ([pi](https://github.com/badlogic/pi-mono)) kicks in automatically.
7
+ [![CI](https://github.com/dacapoday/pish/actions/workflows/ci.yml/badge.svg)](https://github.com/dacapoday/pish/actions/workflows/ci.yml)
8
+ [![npm version](https://img.shields.io/npm/v/@aerode/pish?color=cb0000&label=npm)](https://www.npmjs.com/package/@aerode/pish)
9
+ [![Node.js](https://img.shields.io/badge/node-%E2%89%A518-339933?logo=nodedotjs&logoColor=white)](https://nodejs.org/)
10
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
+
12
+ <br/>
6
13
 
7
- <p align="center">
8
- <img src="pish-example.gif" alt="pish demo" width="600">
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
- ## How it works
23
+ <br/>
12
24
 
13
- pish runs your shell inside a PTY with lightweight hooks injected via rcfile. Normal commands flow through untouched. Only when `command_not_found` fires does pish intercept — it sends your recent shell history as context to the AI agent, which can read files, run commands, and edit code.
25
+ <img src="pish-example.gif" alt="pish demo" width="640">
14
26
 
15
- ```
16
- You ←→ pish (Node.js) ←→ PTY (bash/zsh + hooks)
17
-
18
- Agent (pi --mode rpc, on demand)
19
-
20
- Renderer → stderr
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
- Agent output goes to **stderr**, never contaminating your shell's stdout. Your prompt, pipes, and redirections work exactly as expected.
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
- ## Quick start
66
+ </td>
67
+ </tr>
68
+ </table>
26
69
 
27
- **Requirements:**
28
- - Node.js ≥ 18
29
- - bash ≥ 4.4 or zsh ≥ 5.0
30
- - [`pi`](https://github.com/badlogic/pi-mono) installed and on PATH
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
- ### Run
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
- Everything works exactly like your regular shell — aliases, functions, pipes, redirections, job control, history, tab completion.
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 pi, you're back in pish.
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
- /compact [instructions] # compact agent context
82
- /model provider/model # switch model
83
- /think [level] # set thinking level (none/low/medium/high)
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
- | `Ctrl+C` | Abort running agent |
91
- | `Ctrl+L` | Clear screen + reset context + reset session |
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
- ## Configuration
149
+ Configuration priority: **CLI args > Environment variables > Defaults**
94
150
 
95
- Priority: **CLI > ENV > defaults**
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
- | Environment variable | Description | Default |
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 shown | `10` |
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
- ## Known limitations
234
+ ## 🧪 Testing
123
235
 
124
- - **Bash keywords** — `do something` or `if something` triggers a syntax error instead of the AI agent. Rephrase as `please do something`.
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
- cb = null;
37
+ _onEvent = null;
38
38
  _running = false;
39
39
  _submitted = false;
40
40
  startTime = 0;
41
41
  lastUsage = null;
42
- piPath;
42
+ config;
43
43
  pendingRpc = new Map();
44
- /** Current 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(piPath = 'pi') {
49
- this.piPath = piPath;
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.cb = cb;
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.sessionFile) {
67
- args.push('--session', this.sessionFile);
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.crashInfo = `agent process exited unexpectedly (code ${code})`;
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
- /** Aggregate usage from agent_end messages array. */
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.cb?.(event);
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 = 30000) {
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
- }, timeoutMs);
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 within 2s
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
- }, 2000);
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.sessionFile = undefined;
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 state ──
99
- mode = 'normal';
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
- const debugPath = process.env.PISH_DEBUG;
118
- this.debugFd = debugPath ? fs.openSync(debugPath, 'a') : null;
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.context.length, code });
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.mode === 'agent') {
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?.handleEvent(event);
216
- if (event.type === 'agent_done' && this.mode === 'agent') {
164
+ this.agentSession?.renderer.handleEvent(event);
165
+ if (event.type === 'agent_done' && this.agentSession) {
217
166
  log('agent_done', {
218
- cmd: this.agentCmd,
219
- duration_ms: Date.now() - this.agentStartTime,
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.mode === 'agent') {
224
- log('agent_error', { cmd: this.agentCmd, error: event.error });
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
- printBanner(this.cfg);
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.context.length,
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
- this.renderer = new StreamRenderer(this.cfg.toolResultLines);
282
- if (this.agent.crashInfo) {
283
- printNotice(this.agent.crashInfo);
284
- this.agent.crashInfo = undefined;
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
- this.renderer.showSpinner();
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
- this.debugLog('exitAgentMode, stdinBuffer:', this.stdinBuffer.length, 'chunks');
296
- this.mode = 'normal';
297
- this.renderer = null;
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' }, 5000)
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
- const buffered = this.stdinBuffer;
314
- this.stdinBuffer = [];
315
- if (buffered.length > 0) {
268
+ if (session.stdinBuffer.length > 0) {
316
269
  setTimeout(() => {
317
- for (const chunk of buffered) {
270
+ for (const chunk of session.stdinBuffer) {
318
271
  this.pty.write(chunk.toString());
319
272
  }
320
- this.debugLog('replayed', buffered.length, 'stdin chunks');
321
- }, 50);
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
- this.renderer?.printInterrupted();
281
+ session.renderer.printInterrupted();
328
282
  log('agent_abort', {
329
- cmd: this.agentCmd,
330
- duration_ms: Date.now() - this.agentStartTime,
283
+ cmd: session.cmd,
284
+ duration_ms: Date.now() - session.startTime,
331
285
  });
332
- this.mode = 'normal';
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 } : {}) }, 60000);
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
- const state = await this.agent.rpcWait({ type: 'get_state' }, 5000);
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
- }, 10000);
368
+ });
415
369
  }
416
370
  else {
417
- return await this.agent.rpcWait({ type: 'set_model', provider: '', modelId: arg }, 10000);
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 }, 5000);
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.context.length,
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
- * Controlled by PISH_LOG env var:
5
- * unset → no output
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
- 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);
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 || 120,
60
- rows: process.stdout.rows || 30,
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 || 120, process.stdout.rows || 30);
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 { DEFAULT_TRUNCATE, isAltScreen, stripAnsi, truncateLines, } from './strip.js';
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 = { ...DEFAULT_OPTIONS, ...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(), this.opts.truncate);
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 > 100_000) {
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(cfg) {
11
- if (cfg.noBanner)
12
- return;
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, 80);
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 || 80;
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]]), 60)}`;
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
- constructor(toolResultLines = 10) {
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 > 80 ? `${summary.slice(0, 77)}...` : summary;
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();
@@ -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 = DEFAULT_TRUNCATE) {
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.maxLineWidth) {
34
- return `${line.slice(0, opts.maxLineWidth)} ...`;
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 = 120, rows = 30) {
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.8.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": ["shell", "ai", "coding-agent", "bash", "zsh", "pi", "cli"],
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.64.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": "^22.0.0",
47
- "typescript": "^5.0.0"
54
+ "@types/node": "^25.5.2",
55
+ "tsx": "^4.21.0",
56
+ "typescript": "^6.0.2"
48
57
  }
49
58
  }