@aerode/pish 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/agent.js +398 -0
- package/dist/app.js +473 -0
- package/dist/config.js +187 -0
- package/dist/hooks.js +198 -0
- package/dist/log.js +49 -0
- package/dist/main.js +89 -0
- package/dist/osc.js +157 -0
- package/dist/recorder.js +177 -0
- package/dist/render.js +455 -0
- package/dist/strip.js +46 -0
- package/dist/theme.js +64 -0
- package/dist/vterm.js +59 -0
- package/package.json +49 -0
- package/postinstall.sh +7 -0
package/dist/app.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App — core application object.
|
|
3
|
+
*
|
|
4
|
+
* Owns all mutable session state (mode, FIFO, reverse tracking).
|
|
5
|
+
* Receives events from Recorder and AgentManager, drives rendering
|
|
6
|
+
* and FIFO responses. Created by main.ts after all resources are ready.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { closeLog, log } from './log.js';
|
|
12
|
+
import { printBanner, printControl, printControlResult, printExit, printNotice, StreamRenderer, startSpinner, } from './render.js';
|
|
13
|
+
// ═══════════════════════════════════════
|
|
14
|
+
// Pure helpers (module-level, no `this`)
|
|
15
|
+
// ═══════════════════════════════════════
|
|
16
|
+
function formatContext(entries) {
|
|
17
|
+
return entries
|
|
18
|
+
.map((e, i) => {
|
|
19
|
+
const parts = [];
|
|
20
|
+
const num = `[${i + 1}]`;
|
|
21
|
+
if (e.prompt) {
|
|
22
|
+
parts.push(`${num} ${e.prompt}`);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
parts.push(num);
|
|
26
|
+
}
|
|
27
|
+
if (e.output)
|
|
28
|
+
parts.push(e.output);
|
|
29
|
+
if (e.rc !== 0)
|
|
30
|
+
parts.push(`[exit code: ${e.rc}]`);
|
|
31
|
+
return parts.join('\n');
|
|
32
|
+
})
|
|
33
|
+
.join('\n\n');
|
|
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
|
+
export class App {
|
|
85
|
+
// ── Injected dependencies ──
|
|
86
|
+
cfg;
|
|
87
|
+
pty;
|
|
88
|
+
recorder;
|
|
89
|
+
agent;
|
|
90
|
+
// ── Infrastructure (immutable after construction) ──
|
|
91
|
+
fifoPath;
|
|
92
|
+
tmpDir;
|
|
93
|
+
rcPath;
|
|
94
|
+
debugFd;
|
|
95
|
+
// ── FIFO ──
|
|
96
|
+
fifoFd = null;
|
|
97
|
+
cleaned = false;
|
|
98
|
+
// ── Agent mode state ──
|
|
99
|
+
mode = 'normal';
|
|
100
|
+
agentCmd = '';
|
|
101
|
+
agentStartTime = 0;
|
|
102
|
+
stdinBuffer = [];
|
|
103
|
+
renderer = null;
|
|
104
|
+
// ── Reverse session recovery ──
|
|
105
|
+
sessionEpoch = Date.now();
|
|
106
|
+
reverseStartTime = 0;
|
|
107
|
+
preReverseSessionFile;
|
|
108
|
+
constructor(deps, infra) {
|
|
109
|
+
this.cfg = deps.cfg;
|
|
110
|
+
this.pty = deps.pty;
|
|
111
|
+
this.recorder = deps.recorder;
|
|
112
|
+
this.agent = deps.agent;
|
|
113
|
+
this.fifoPath = infra.fifoPath;
|
|
114
|
+
this.tmpDir = infra.tmpDir;
|
|
115
|
+
this.rcPath = infra.rcPath;
|
|
116
|
+
// 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;
|
|
119
|
+
// Wire internal event handlers
|
|
120
|
+
this.agent.onEvent((event) => this.onAgentEvent(event));
|
|
121
|
+
this.recorder.onEvent((evt) => this.onRecorderEvent(evt));
|
|
122
|
+
}
|
|
123
|
+
// ═══════════════════════════════════════
|
|
124
|
+
// Public I/O interface (called by main.ts wiring)
|
|
125
|
+
// ═══════════════════════════════════════
|
|
126
|
+
/** PTY stdout data → recorder + terminal. */
|
|
127
|
+
onPtyData(data) {
|
|
128
|
+
const clean = this.recorder.feed(data);
|
|
129
|
+
process.stdout.write(clean);
|
|
130
|
+
}
|
|
131
|
+
/** PTY process exited. */
|
|
132
|
+
onPtyExit(code) {
|
|
133
|
+
this.debugLog('PTY exited, code:', code);
|
|
134
|
+
printExit();
|
|
135
|
+
log('exit', { context_count: this.recorder.context.length, code });
|
|
136
|
+
closeLog();
|
|
137
|
+
this.cleanup();
|
|
138
|
+
process.exit(code);
|
|
139
|
+
}
|
|
140
|
+
/** Terminal stdin data → mode routing. */
|
|
141
|
+
onStdin(data) {
|
|
142
|
+
if (this.mode === 'agent') {
|
|
143
|
+
if (data.length === 1 && data[0] === 0x03) {
|
|
144
|
+
this.abortAgent();
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
this.stdinBuffer.push(Buffer.from(data));
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Ctrl+L: clear screen + reset context + full agent reset (including session)
|
|
152
|
+
if (data.length === 1 && data[0] === 0x0c) {
|
|
153
|
+
const cleared = this.recorder.drain();
|
|
154
|
+
log('context_clear', { discarded: cleared.length });
|
|
155
|
+
this.agent.reset();
|
|
156
|
+
this.sessionEpoch = Date.now();
|
|
157
|
+
this.preReverseSessionFile = undefined;
|
|
158
|
+
this.pty.write(data.toString());
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.pty.write(data.toString());
|
|
162
|
+
}
|
|
163
|
+
/** Terminal resized. */
|
|
164
|
+
onResize(cols, rows) {
|
|
165
|
+
this.pty.resize(cols, rows);
|
|
166
|
+
}
|
|
167
|
+
/** Cleanup all resources. Public — called by signal handlers in main.ts. */
|
|
168
|
+
cleanup() {
|
|
169
|
+
if (this.cleaned)
|
|
170
|
+
return;
|
|
171
|
+
this.cleaned = true;
|
|
172
|
+
process.stderr.write('\x1b[?25h'); // Restore cursor visibility
|
|
173
|
+
this.agent.kill();
|
|
174
|
+
if (this.fifoFd !== null) {
|
|
175
|
+
try {
|
|
176
|
+
fs.closeSync(this.fifoFd);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
/* fd may already be closed */
|
|
180
|
+
}
|
|
181
|
+
this.fifoFd = null;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
fs.unlinkSync(this.fifoPath);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
/* already removed or never created */
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
fs.rmSync(this.tmpDir, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
/* non-empty or already removed */
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
fs.unlinkSync(this.rcPath);
|
|
197
|
+
fs.rmSync(path.dirname(this.rcPath), { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* zsh rcdir may not exist or already cleaned */
|
|
201
|
+
}
|
|
202
|
+
if (this.debugFd !== null) {
|
|
203
|
+
try {
|
|
204
|
+
fs.closeSync(this.debugFd);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* fd may already be closed */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// ═══════════════════════════════════════
|
|
212
|
+
// Agent event handler
|
|
213
|
+
// ═══════════════════════════════════════
|
|
214
|
+
onAgentEvent(event) {
|
|
215
|
+
this.renderer?.handleEvent(event);
|
|
216
|
+
if (event.type === 'agent_done' && this.mode === 'agent') {
|
|
217
|
+
log('agent_done', {
|
|
218
|
+
cmd: this.agentCmd,
|
|
219
|
+
duration_ms: Date.now() - this.agentStartTime,
|
|
220
|
+
});
|
|
221
|
+
this.exitAgentMode();
|
|
222
|
+
}
|
|
223
|
+
if (event.type === 'agent_error' && this.mode === 'agent') {
|
|
224
|
+
log('agent_error', { cmd: this.agentCmd, error: event.error });
|
|
225
|
+
this.exitAgentMode();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// ═══════════════════════════════════════
|
|
229
|
+
// Recorder event handler
|
|
230
|
+
// ═══════════════════════════════════════
|
|
231
|
+
onRecorderEvent(evt) {
|
|
232
|
+
switch (evt.type) {
|
|
233
|
+
case 'shell_ready':
|
|
234
|
+
this.debugLog('EVENT: shell_ready');
|
|
235
|
+
this.fifoFd = fs.openSync(this.fifoPath, 'w');
|
|
236
|
+
this.debugLog('FIFO write fd opened');
|
|
237
|
+
log('shell_ready', { pid: this.pty.pid });
|
|
238
|
+
printBanner(this.cfg);
|
|
239
|
+
break;
|
|
240
|
+
case 'context':
|
|
241
|
+
log('context', {
|
|
242
|
+
prompt: evt.entry.prompt,
|
|
243
|
+
output: evt.entry.output,
|
|
244
|
+
rc: evt.entry.rc,
|
|
245
|
+
kept: this.recorder.context.length,
|
|
246
|
+
});
|
|
247
|
+
break;
|
|
248
|
+
case 'context_skip':
|
|
249
|
+
log('context_skip', { reason: evt.reason });
|
|
250
|
+
break;
|
|
251
|
+
case 'agent':
|
|
252
|
+
this.handleAgentCmd(evt.cmd);
|
|
253
|
+
break;
|
|
254
|
+
case 'reverse':
|
|
255
|
+
this.handleReverse();
|
|
256
|
+
break;
|
|
257
|
+
case 'reverse_done':
|
|
258
|
+
this.handleReverseDone().catch((err) => {
|
|
259
|
+
log('reverse_done_error', { error: String(err) });
|
|
260
|
+
});
|
|
261
|
+
break;
|
|
262
|
+
case 'error':
|
|
263
|
+
log('error', { msg: evt.msg });
|
|
264
|
+
process.stderr.write(`\x1b[31mpish: ${evt.msg}\x1b[0m\n`);
|
|
265
|
+
this.cleanup();
|
|
266
|
+
process.exit(1);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// ═══════════════════════════════════════
|
|
271
|
+
// Agent mode transitions
|
|
272
|
+
// ═══════════════════════════════════════
|
|
273
|
+
enterAgentMode(cmd) {
|
|
274
|
+
this.mode = 'agent';
|
|
275
|
+
this.agentCmd = cmd;
|
|
276
|
+
this.agentStartTime = Date.now();
|
|
277
|
+
this.stdinBuffer = [];
|
|
278
|
+
this.debugLog('enterAgentMode:', cmd);
|
|
279
|
+
const entries = this.recorder.drain();
|
|
280
|
+
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;
|
|
285
|
+
}
|
|
286
|
+
this.renderer.showSpinner();
|
|
287
|
+
let message = cmd;
|
|
288
|
+
const ctx = formatContext(entries);
|
|
289
|
+
if (ctx) {
|
|
290
|
+
message = `Here is my recent shell activity:\n\n${ctx}\n\n${cmd}`;
|
|
291
|
+
}
|
|
292
|
+
this.agent.submit(message);
|
|
293
|
+
}
|
|
294
|
+
exitAgentMode() {
|
|
295
|
+
this.debugLog('exitAgentMode, stdinBuffer:', this.stdinBuffer.length, 'chunks');
|
|
296
|
+
this.mode = 'normal';
|
|
297
|
+
this.renderer = null;
|
|
298
|
+
this.fifoWrite('PROCEED');
|
|
299
|
+
// Request state to get session file (for subsequent reverse).
|
|
300
|
+
// Only if agent process is alive — don't respawn after crash.
|
|
301
|
+
if (this.agent.alive) {
|
|
302
|
+
this.agent
|
|
303
|
+
.rpcWait({ type: 'get_state' }, 5000)
|
|
304
|
+
.then((response) => {
|
|
305
|
+
if (response.success && response.data?.sessionFile) {
|
|
306
|
+
this.agent.sessionFile = response.data.sessionFile;
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
.catch((err) => {
|
|
310
|
+
log('get_state_error', { error: String(err) });
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const buffered = this.stdinBuffer;
|
|
314
|
+
this.stdinBuffer = [];
|
|
315
|
+
if (buffered.length > 0) {
|
|
316
|
+
setTimeout(() => {
|
|
317
|
+
for (const chunk of buffered) {
|
|
318
|
+
this.pty.write(chunk.toString());
|
|
319
|
+
}
|
|
320
|
+
this.debugLog('replayed', buffered.length, 'stdin chunks');
|
|
321
|
+
}, 50);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
abortAgent() {
|
|
325
|
+
this.debugLog('abortAgent');
|
|
326
|
+
this.agent.abort();
|
|
327
|
+
this.renderer?.printInterrupted();
|
|
328
|
+
log('agent_abort', {
|
|
329
|
+
cmd: this.agentCmd,
|
|
330
|
+
duration_ms: Date.now() - this.agentStartTime,
|
|
331
|
+
});
|
|
332
|
+
this.mode = 'normal';
|
|
333
|
+
this.renderer = null;
|
|
334
|
+
this.stdinBuffer = [];
|
|
335
|
+
this.fifoWrite('PROCEED');
|
|
336
|
+
}
|
|
337
|
+
// ═══════════════════════════════════════
|
|
338
|
+
// Agent / control command dispatch
|
|
339
|
+
// ═══════════════════════════════════════
|
|
340
|
+
handleAgentCmd(cmd) {
|
|
341
|
+
this.debugLog('EVENT: agent cmd=', cmd);
|
|
342
|
+
if (cmd.startsWith('/')) {
|
|
343
|
+
log('control', { cmd });
|
|
344
|
+
printControl(cmd);
|
|
345
|
+
if (this.cfg.noAgent) {
|
|
346
|
+
this.fifoWrite('PROCEED');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
this.handleControlAsync(cmd)
|
|
350
|
+
.then((response) => {
|
|
351
|
+
if (response)
|
|
352
|
+
printControlResult(cmd, response);
|
|
353
|
+
})
|
|
354
|
+
.catch((err) => {
|
|
355
|
+
log('control_error', { cmd, error: String(err) });
|
|
356
|
+
})
|
|
357
|
+
.finally(() => {
|
|
358
|
+
this.fifoWrite('PROCEED');
|
|
359
|
+
});
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (this.cfg.noAgent) {
|
|
363
|
+
log('agent_skip', { cmd, reason: 'no-agent' });
|
|
364
|
+
printNotice(`agent disabled, skipped: ${cmd}`);
|
|
365
|
+
this.fifoWrite('PROCEED');
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
this.enterAgentMode(cmd);
|
|
369
|
+
}
|
|
370
|
+
async handleControlAsync(cmd) {
|
|
371
|
+
const parts = cmd.trim().split(/\s+/);
|
|
372
|
+
const name = parts[0];
|
|
373
|
+
const arg = parts.slice(1).join(' ');
|
|
374
|
+
switch (name) {
|
|
375
|
+
case '/compact': {
|
|
376
|
+
const stopSpinner = startSpinner('Compacting...');
|
|
377
|
+
try {
|
|
378
|
+
return await this.agent.rpcWait({ type: 'compact', ...(arg ? { customInstructions: arg } : {}) }, 60000);
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
stopSpinner();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
case '/model': {
|
|
385
|
+
if (!arg) {
|
|
386
|
+
const state = await this.agent.rpcWait({ type: 'get_state' }, 5000);
|
|
387
|
+
if (state.success && state.data?.model) {
|
|
388
|
+
const m = state.data.model;
|
|
389
|
+
const prov = m.provider;
|
|
390
|
+
const provider = typeof prov === 'object' && prov !== null
|
|
391
|
+
? (prov.id ?? '')
|
|
392
|
+
: String(prov ?? '');
|
|
393
|
+
const modelId = m.id ?? '';
|
|
394
|
+
return {
|
|
395
|
+
type: 'response',
|
|
396
|
+
command: 'set_model',
|
|
397
|
+
success: true,
|
|
398
|
+
data: { provider: { id: provider }, id: modelId },
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
type: 'response',
|
|
403
|
+
command: 'set_model',
|
|
404
|
+
success: false,
|
|
405
|
+
error: 'no model info available',
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const slashIdx = arg.indexOf('/');
|
|
409
|
+
if (slashIdx > 0) {
|
|
410
|
+
return await this.agent.rpcWait({
|
|
411
|
+
type: 'set_model',
|
|
412
|
+
provider: arg.slice(0, slashIdx),
|
|
413
|
+
modelId: arg.slice(slashIdx + 1),
|
|
414
|
+
}, 10000);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
return await this.agent.rpcWait({ type: 'set_model', provider: '', modelId: arg }, 10000);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
case '/think': {
|
|
421
|
+
const level = arg || 'medium';
|
|
422
|
+
return await this.agent.rpcWait({ type: 'set_thinking_level', level }, 5000);
|
|
423
|
+
}
|
|
424
|
+
default:
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// ═══════════════════════════════════════
|
|
429
|
+
// Reverse session recovery
|
|
430
|
+
// ═══════════════════════════════════════
|
|
431
|
+
handleReverse() {
|
|
432
|
+
this.debugLog('EVENT: reverse');
|
|
433
|
+
const sessionFile = this.agent.sessionFile;
|
|
434
|
+
this.agent.kill();
|
|
435
|
+
this.reverseStartTime = Date.now();
|
|
436
|
+
this.preReverseSessionFile = sessionFile;
|
|
437
|
+
log('reverse', {
|
|
438
|
+
context_count: this.recorder.context.length,
|
|
439
|
+
session: sessionFile || null,
|
|
440
|
+
});
|
|
441
|
+
if (sessionFile) {
|
|
442
|
+
this.fifoWrite(`SESSION:${sessionFile}`);
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
this.fifoWrite('SESSION:');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async handleReverseDone() {
|
|
449
|
+
this.debugLog('EVENT: reverse_done');
|
|
450
|
+
const since = Math.max(this.reverseStartTime, this.sessionEpoch);
|
|
451
|
+
const recovered = await findLatestSession(since, (...a) => this.debugLog(...a));
|
|
452
|
+
this.agent.sessionFile = recovered ?? this.preReverseSessionFile;
|
|
453
|
+
log('reverse_done', { session: this.agent.sessionFile ?? null });
|
|
454
|
+
}
|
|
455
|
+
// ═══════════════════════════════════════
|
|
456
|
+
// FIFO + debug
|
|
457
|
+
// ═══════════════════════════════════════
|
|
458
|
+
fifoWrite(data) {
|
|
459
|
+
if (this.fifoFd !== null) {
|
|
460
|
+
fs.writeSync(this.fifoFd, `${data}\n`);
|
|
461
|
+
this.debugLog('FIFO wrote:', data);
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
this.debugLog('FIFO not ready, dropping:', data);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
debugLog(...args) {
|
|
468
|
+
if (this.debugFd !== null) {
|
|
469
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
470
|
+
fs.writeSync(this.debugFd, `[${ts}] PISH ${args.map(String).join(' ')}\n`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified configuration.
|
|
3
|
+
*
|
|
4
|
+
* Priority: CLI args > ENV > defaults.
|
|
5
|
+
* All tunable parameters live here; other modules access via cfg.xxx.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
// ── Defaults ──
|
|
12
|
+
export const DEFAULTS = {
|
|
13
|
+
shell: 'bash',
|
|
14
|
+
maxContext: 20,
|
|
15
|
+
headLines: 50,
|
|
16
|
+
tailLines: 30,
|
|
17
|
+
lineWidth: 512,
|
|
18
|
+
toolResultLines: 10,
|
|
19
|
+
};
|
|
20
|
+
// ── Version ──
|
|
21
|
+
function readVersion() {
|
|
22
|
+
try {
|
|
23
|
+
return require('../package.json').version || '0.0.0';
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return '0.0.0';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// ── Parse helpers ──
|
|
30
|
+
export function envInt(key, fallback) {
|
|
31
|
+
const v = process.env[key];
|
|
32
|
+
if (!v)
|
|
33
|
+
return fallback;
|
|
34
|
+
const n = parseInt(v, 10);
|
|
35
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a binary name or path to its full path.
|
|
39
|
+
* Contains '/' → validate as path; otherwise → which lookup.
|
|
40
|
+
* Returns null if not found.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveBinary(nameOrPath) {
|
|
43
|
+
if (nameOrPath.includes('/')) {
|
|
44
|
+
try {
|
|
45
|
+
fs.accessSync(nameOrPath, fs.constants.X_OK);
|
|
46
|
+
return nameOrPath;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return execFileSync('which', [nameOrPath], {
|
|
54
|
+
encoding: 'utf-8',
|
|
55
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
56
|
+
}).trim();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Infer shell type from binary path basename. */
|
|
63
|
+
export function inferShellType(shellPath) {
|
|
64
|
+
const base = shellPath.split('/').pop() ?? '';
|
|
65
|
+
if (base === 'bash' || base.startsWith('bash'))
|
|
66
|
+
return 'bash';
|
|
67
|
+
if (base === 'zsh' || base.startsWith('zsh'))
|
|
68
|
+
return 'zsh';
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
export function parseArgs(argv) {
|
|
72
|
+
const args = argv.slice(2); // skip node, script
|
|
73
|
+
const result = { noAgent: false, help: false, version: false };
|
|
74
|
+
let i = 0;
|
|
75
|
+
while (i < args.length) {
|
|
76
|
+
const a = args[i];
|
|
77
|
+
if (a === '--help' || a === '-h') {
|
|
78
|
+
result.help = true;
|
|
79
|
+
}
|
|
80
|
+
else if (a === '--version' || a === '-v') {
|
|
81
|
+
result.version = true;
|
|
82
|
+
}
|
|
83
|
+
else if (a === '--shell' || a === '-s') {
|
|
84
|
+
result.shell = args[++i];
|
|
85
|
+
}
|
|
86
|
+
else if (a.startsWith('--shell=')) {
|
|
87
|
+
result.shell = a.slice('--shell='.length);
|
|
88
|
+
}
|
|
89
|
+
else if (a === '--pi') {
|
|
90
|
+
result.pi = args[++i];
|
|
91
|
+
}
|
|
92
|
+
else if (a.startsWith('--pi=')) {
|
|
93
|
+
result.pi = a.slice('--pi='.length);
|
|
94
|
+
}
|
|
95
|
+
else if (a === '--no-agent') {
|
|
96
|
+
result.noAgent = true;
|
|
97
|
+
}
|
|
98
|
+
else if (!a.startsWith('-') && !result.shell) {
|
|
99
|
+
// Positional argument = shell
|
|
100
|
+
result.shell = a;
|
|
101
|
+
}
|
|
102
|
+
i++;
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
// ── Help ──
|
|
107
|
+
function printHelp(version) {
|
|
108
|
+
process.stderr.write(`pish v${version} — Pi-Integrated Shell
|
|
109
|
+
|
|
110
|
+
Usage: pish [options] [shell]
|
|
111
|
+
|
|
112
|
+
Arguments:
|
|
113
|
+
shell bash, zsh, or path (default: bash)
|
|
114
|
+
|
|
115
|
+
Options:
|
|
116
|
+
-s, --shell <name> Shell name or path
|
|
117
|
+
--pi <path> Path to pi binary (default: pi in PATH)
|
|
118
|
+
--no-agent Disable agent (CNF passes through, for debugging)
|
|
119
|
+
-v, --version Show version
|
|
120
|
+
-h, --help Show help
|
|
121
|
+
|
|
122
|
+
Environment variables:
|
|
123
|
+
PISH_SHELL Shell name or path (default: $SHELL or bash)
|
|
124
|
+
PISH_PI Path to pi binary
|
|
125
|
+
PISH_MAX_CONTEXT Max context entries (default: ${DEFAULTS.maxContext})
|
|
126
|
+
PISH_HEAD_LINES Output head lines (default: ${DEFAULTS.headLines})
|
|
127
|
+
PISH_TAIL_LINES Output tail lines (default: ${DEFAULTS.tailLines})
|
|
128
|
+
PISH_LINE_WIDTH Max line width (default: ${DEFAULTS.lineWidth})
|
|
129
|
+
PISH_TOOL_LINES Tool result lines (default: ${DEFAULTS.toolResultLines})
|
|
130
|
+
PISH_LOG Event log (stderr or file path)
|
|
131
|
+
PISH_DEBUG Debug log file path
|
|
132
|
+
PISH_NO_BANNER Hide startup banner (set to 1)
|
|
133
|
+
|
|
134
|
+
Priority: CLI args > ENV > defaults
|
|
135
|
+
`);
|
|
136
|
+
}
|
|
137
|
+
// ── Entry point ──
|
|
138
|
+
export function loadConfig() {
|
|
139
|
+
const cli = parseArgs(process.argv);
|
|
140
|
+
const version = readVersion();
|
|
141
|
+
if (cli.help) {
|
|
142
|
+
printHelp(version);
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
if (cli.version) {
|
|
146
|
+
process.stderr.write(`pish v${version}\n`);
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
// ── Shell resolution (CLI > ENV > default) ──
|
|
150
|
+
const shellSpec = cli.shell ?? process.env.PISH_SHELL ?? process.env.SHELL ?? DEFAULTS.shell;
|
|
151
|
+
const shellPath = resolveBinary(shellSpec);
|
|
152
|
+
if (!shellPath) {
|
|
153
|
+
process.stderr.write(`pish: shell not found: ${shellSpec}\n`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
const shell = inferShellType(shellPath);
|
|
157
|
+
if (!shell) {
|
|
158
|
+
process.stderr.write(`pish: unsupported shell: ${shellSpec} (only bash and zsh)\n`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
// ── Pi resolution (skipped in --no-agent mode) ──
|
|
162
|
+
const noAgent = cli.noAgent;
|
|
163
|
+
let piPath = '';
|
|
164
|
+
if (!noAgent) {
|
|
165
|
+
const piSpec = cli.pi ?? process.env.PISH_PI ?? 'pi';
|
|
166
|
+
const resolved = resolveBinary(piSpec);
|
|
167
|
+
if (!resolved) {
|
|
168
|
+
process.stderr.write(`pish: pi not found: ${piSpec}\n`);
|
|
169
|
+
process.stderr.write(` Install pi or set --pi <path> / PISH_PI\n`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
piPath = resolved;
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
shell,
|
|
176
|
+
shellPath,
|
|
177
|
+
piPath,
|
|
178
|
+
version,
|
|
179
|
+
noAgent,
|
|
180
|
+
maxContext: envInt('PISH_MAX_CONTEXT', DEFAULTS.maxContext),
|
|
181
|
+
headLines: envInt('PISH_HEAD_LINES', DEFAULTS.headLines),
|
|
182
|
+
tailLines: envInt('PISH_TAIL_LINES', DEFAULTS.tailLines),
|
|
183
|
+
lineWidth: envInt('PISH_LINE_WIDTH', DEFAULTS.lineWidth),
|
|
184
|
+
toolResultLines: envInt('PISH_TOOL_LINES', DEFAULTS.toolResultLines),
|
|
185
|
+
noBanner: process.env.PISH_NO_BANNER === '1',
|
|
186
|
+
};
|
|
187
|
+
}
|