@aerode/pish 0.8.0 → 0.9.1

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
@@ -32,7 +32,7 @@ Agent output goes to **stderr**, never contaminating your shell's stdout. Your p
32
32
  ### Install from npm
33
33
 
34
34
  ```bash
35
- npm install -g pish
35
+ npm install -g @aerode/pish
36
36
  ```
37
37
 
38
38
  ### Install from source
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,16 @@ 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 = { cmd, startTime: Date.now(), stdinBuffer: [], renderer };
232
+ const crashInfo = this.agent.consumeCrashInfo();
233
+ if (crashInfo) {
234
+ printNotice(crashInfo);
285
235
  }
286
- this.renderer.showSpinner();
236
+ renderer.showSpinner();
287
237
  let message = cmd;
288
238
  const ctx = formatContext(entries);
289
239
  if (ctx) {
@@ -292,15 +242,15 @@ export class App {
292
242
  this.agent.submit(message);
293
243
  }
294
244
  exitAgentMode() {
295
- this.debugLog('exitAgentMode, stdinBuffer:', this.stdinBuffer.length, 'chunks');
296
- this.mode = 'normal';
297
- this.renderer = null;
245
+ const session = this.agentSession;
246
+ this.debugLog('exitAgentMode, stdinBuffer:', session.stdinBuffer.length, 'chunks');
247
+ this.agentSession = null;
298
248
  this.fifoWrite('PROCEED');
299
249
  // Request state to get session file (for subsequent reverse).
300
250
  // Only if agent process is alive — don't respawn after crash.
301
251
  if (this.agent.alive) {
302
252
  this.agent
303
- .rpcWait({ type: 'get_state' }, 5000)
253
+ .rpcWait({ type: 'get_state' })
304
254
  .then((response) => {
305
255
  if (response.success && response.data?.sessionFile) {
306
256
  this.agent.sessionFile = response.data.sessionFile;
@@ -310,28 +260,25 @@ export class App {
310
260
  log('get_state_error', { error: String(err) });
311
261
  });
312
262
  }
313
- const buffered = this.stdinBuffer;
314
- this.stdinBuffer = [];
315
- if (buffered.length > 0) {
263
+ if (session.stdinBuffer.length > 0) {
316
264
  setTimeout(() => {
317
- for (const chunk of buffered) {
265
+ for (const chunk of session.stdinBuffer) {
318
266
  this.pty.write(chunk.toString());
319
267
  }
320
- this.debugLog('replayed', buffered.length, 'stdin chunks');
321
- }, 50);
268
+ this.debugLog('replayed', session.stdinBuffer.length, 'stdin chunks');
269
+ }, this.cfg.stdinReplayDelay);
322
270
  }
323
271
  }
324
272
  abortAgent() {
325
273
  this.debugLog('abortAgent');
274
+ const session = this.agentSession;
326
275
  this.agent.abort();
327
- this.renderer?.printInterrupted();
276
+ session.renderer.printInterrupted();
328
277
  log('agent_abort', {
329
- cmd: this.agentCmd,
330
- duration_ms: Date.now() - this.agentStartTime,
278
+ cmd: session.cmd,
279
+ duration_ms: Date.now() - session.startTime,
331
280
  });
332
- this.mode = 'normal';
333
- this.renderer = null;
334
- this.stdinBuffer = [];
281
+ this.agentSession = null;
335
282
  this.fifoWrite('PROCEED');
336
283
  }
337
284
  // ═══════════════════════════════════════
@@ -373,9 +320,9 @@ export class App {
373
320
  const arg = parts.slice(1).join(' ');
374
321
  switch (name) {
375
322
  case '/compact': {
376
- const stopSpinner = startSpinner('Compacting...');
323
+ const stopSpinner = startSpinner('Compacting...', this.cfg.spinnerInterval);
377
324
  try {
378
- return await this.agent.rpcWait({ type: 'compact', ...(arg ? { customInstructions: arg } : {}) }, 60000);
325
+ return await this.agent.rpcWait({ type: 'compact', ...(arg ? { customInstructions: arg } : {}) }, this.cfg.compactTimeout);
379
326
  }
380
327
  finally {
381
328
  stopSpinner();
@@ -383,7 +330,9 @@ export class App {
383
330
  }
384
331
  case '/model': {
385
332
  if (!arg) {
386
- const state = await this.agent.rpcWait({ type: 'get_state' }, 5000);
333
+ // Query current model via get_state, then wrap as set_model response
334
+ // to reuse printControlResult's existing set_model rendering.
335
+ const state = await this.agent.rpcWait({ type: 'get_state' });
387
336
  if (state.success && state.data?.model) {
388
337
  const m = state.data.model;
389
338
  const prov = m.provider;
@@ -411,15 +360,19 @@ export class App {
411
360
  type: 'set_model',
412
361
  provider: arg.slice(0, slashIdx),
413
362
  modelId: arg.slice(slashIdx + 1),
414
- }, 10000);
363
+ });
415
364
  }
416
365
  else {
417
- return await this.agent.rpcWait({ type: 'set_model', provider: '', modelId: arg }, 10000);
366
+ return await this.agent.rpcWait({
367
+ type: 'set_model',
368
+ provider: '',
369
+ modelId: arg,
370
+ });
418
371
  }
419
372
  }
420
373
  case '/think': {
421
374
  const level = arg || 'medium';
422
- return await this.agent.rpcWait({ type: 'set_thinking_level', level }, 5000);
375
+ return await this.agent.rpcWait({ type: 'set_thinking_level', level });
423
376
  }
424
377
  default:
425
378
  return null;
@@ -435,7 +388,7 @@ export class App {
435
388
  this.reverseStartTime = Date.now();
436
389
  this.preReverseSessionFile = sessionFile;
437
390
  log('reverse', {
438
- context_count: this.recorder.context.length,
391
+ context_count: this.recorder.contextCount,
439
392
  session: sessionFile || null,
440
393
  });
441
394
  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
  });
@@ -72,7 +65,7 @@ process.stdin.setRawMode?.(true);
72
65
  process.stdin.resume();
73
66
  process.stdin.on('data', (data) => app.onStdin(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.1",
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
  },