@tduyng/pi-shell 0.0.1 → 0.0.3
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 +32 -26
- package/package.json +2 -2
- package/src/index.ts +12 -3
- package/src/process.ts +17 -6
- package/src/stall.ts +46 -0
package/README.md
CHANGED
|
@@ -1,58 +1,64 @@
|
|
|
1
1
|
# @tduyng/pi-shell
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Park a long command in the background and get woken when it matters. One `bg` tool covers start, read, kill, and list.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What it costs
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`bg` is 197 tokens a request for the whole lifecycle, where four tools would have cost four schemas.
|
|
8
|
+
|
|
9
|
+
The saving is in the wait, not the schema. A test suite, a build, or a dev server run for minutes and then return a log nobody reads whole. This tool holds the bytes and hands the model the tail it asked for, so a five minute build does not become a five minute stall in the transcript.
|
|
10
|
+
|
|
11
|
+
## The tool
|
|
12
|
+
|
|
13
|
+
One tool with an `action` field. Each task runs in the current working directory with a fresh shell, like pi's own bash. There is no persistent shell session.
|
|
8
14
|
|
|
9
15
|
### start
|
|
10
16
|
|
|
11
|
-
Spawns a detached command
|
|
17
|
+
Spawns a detached command and returns `task_id` (an 8-character UUID prefix), `pid`, and the absolute log `path`. stdout and stderr stream to `<agentDir>/shell-logs/<taskId>.log`. The task runs silently until you ask about it. An optional `timeout` caps it at 600 seconds, with no timeout by default.
|
|
12
18
|
|
|
13
19
|
### output
|
|
14
20
|
|
|
15
|
-
Reads new bytes since the last read
|
|
21
|
+
Reads new bytes since the last read, through a byte cursor.
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
| `wait` | Behaviour |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `now` | Return whatever is available |
|
|
26
|
+
| `next_write` | Park the agent until the first new byte, then wake it |
|
|
27
|
+
| `exit` | Park the agent until the process ends, then deliver the verdict and the unread tail |
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
A wake read does not advance the cursor, so a later `output` still sees every byte from the stored offset.
|
|
22
30
|
|
|
23
31
|
### kill
|
|
24
32
|
|
|
25
|
-
|
|
33
|
+
SIGTERM to the process group, polled every 50ms for up to a second, then SIGKILL. Takes a `task_id`, a raw `pid`, or a `port` resolved through `lsof -ti`.
|
|
26
34
|
|
|
27
35
|
### status
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
## Honest exits
|
|
37
|
+
Every task with id, pid, state (running, exited, killed), cursor offset, and the first 80 characters of the command.
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
## Waking only when asked
|
|
34
40
|
|
|
35
|
-
`
|
|
41
|
+
Only a task the model parked on, through `wait: "exit"` or `wait: "next_write"`, injects a message when it finishes. A burst of exits coalesces into one message on a byte budget: 8 KB total by default, at least 1 KB per task. The wake carries the exit verdict, the unread tail, and the log path, so the model gets the result without a read-back.
|
|
36
42
|
|
|
37
|
-
|
|
43
|
+
The wake fires from the `onExit` callback through `queueMicrotask` rather than polling. A wake that lands mid-turn goes to the follow-up queue and is delivered at the next `agent_settled`.
|
|
38
44
|
|
|
39
|
-
|
|
45
|
+
A task parked on `wait: "exit"` also gets a silence check. Every 30 seconds it reads the log size, and after 10 minutes with no new bytes the agent is woken once with the elapsed time and the log path. It fires again only after output resumes and stops again. No model call runs for the check.
|
|
40
46
|
|
|
41
|
-
|
|
47
|
+
## Reading a log without drowning in it
|
|
42
48
|
|
|
43
|
-
|
|
49
|
+
Large output is cut to a head and a tail, 8 KB total by default, with the exact omitted byte count between them. Each side reads up to 16 KB, split on UTF-8 sequence boundaries so a character is never cut mid-byte. The full log stays on disk at the path every result shows.
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
A `tool_result` handler annotates the two cases where an exit code cannot say what happened: exit 1 from `grep`, `rg`, or `find` means no matches, and a command killed by its timeout did not finish cleanly. Each note stops the model retrying a search that already succeeded.
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
pi already reports a signal-killed command as a failure: its bash backend returns `128 + signal` and raises when a command ends with neither an exit code nor a signal. `shell-process.test.ts` runs `kill -9` against pi's own bash tool and asserts a nonzero result in that family, so a pi release that reintroduces the silent success fails here.
|
|
48
54
|
|
|
49
|
-
## Footer
|
|
55
|
+
## Footer
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
A dot and the live count while any task runs, for example `2 bg running`. It clears when the last one exits.
|
|
52
58
|
|
|
53
|
-
##
|
|
59
|
+
## Lifecycle
|
|
54
60
|
|
|
55
|
-
Each
|
|
61
|
+
Each task is in-memory and dies with the pi process. The session records the command, log path, and start time, so the model can still read the log after a reload, even though the process handle is gone. Detached grandchildren that inherit the shell's stdout keep writing after the shell exits, and the log stream closes only when both pipes close, since ending it on the first close would drop data the second one still delivers.
|
|
56
62
|
|
|
57
63
|
## Install
|
|
58
64
|
|
|
@@ -64,4 +70,4 @@ Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-shell`
|
|
|
64
70
|
|
|
65
71
|
## Configure
|
|
66
72
|
|
|
67
|
-
|
|
73
|
+
`SHELL`, set to `0`, `false`, `no`, or `off`, disables the extension.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tduyng/pi-shell",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "One bg tool for background processes, woken on exit, with cursor reads that never re-read bytes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-package",
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
killTask,
|
|
17
17
|
middleOut,
|
|
18
18
|
benignExitNote,
|
|
19
|
+
isNoMatchCommand,
|
|
19
20
|
pidForPort,
|
|
20
21
|
readNewBytes,
|
|
21
22
|
readUnreadTail,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
spawnTask,
|
|
24
25
|
watchForWrite
|
|
25
26
|
} from './process.js'
|
|
27
|
+
import { watchStall } from './stall.js'
|
|
26
28
|
import type { TaskRecord } from './process.js'
|
|
27
29
|
const isString = new Validator(t.string()).isValidObject
|
|
28
30
|
|
|
@@ -95,7 +97,7 @@ export async function buildWakeText(records: TaskRecord[], budget = WAKE_TAIL_MA
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
export async function sendBackgroundWake(pi: Pick<ExtensionAPI, 'sendMessage'>, text: string): Promise<void> {
|
|
98
|
-
pi.sendMessage(
|
|
100
|
+
await pi.sendMessage(
|
|
99
101
|
{ customType: WAKE_TYPE, content: text, display: true },
|
|
100
102
|
{ deliverAs: 'followUp', triggerTurn: true }
|
|
101
103
|
)
|
|
@@ -116,8 +118,7 @@ async function flushWakes(pi: ExtensionAPI): Promise<boolean> {
|
|
|
116
118
|
|
|
117
119
|
function isBenignNoMatch(rec: Pick<TaskRecord, 'command' | 'exitCode' | 'signal'>): boolean {
|
|
118
120
|
if (rec.exitCode !== 1 || rec.signal !== null) return false
|
|
119
|
-
|
|
120
|
-
return /^(grep|rg|find)\b/.test(cmd) || /\b(grep|rg)\b/.test(cmd)
|
|
121
|
+
return isNoMatchCommand(rec.command)
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
interface PersistedTask {
|
|
@@ -226,6 +227,14 @@ async function bgAction(pi: ExtensionAPI, ctx: ExtensionContext, params: BgInput
|
|
|
226
227
|
)
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
if (!rec.wakeRequested) {
|
|
231
|
+
watchStall(rec, (silentMs) => {
|
|
232
|
+
void sendBackgroundWake(
|
|
233
|
+
pi,
|
|
234
|
+
`bg task ${params.task_id} has written no output for ${Math.round(silentMs / 60000)} min and is still running. Check its log, or kill it if it is stuck. ${LOG_PREFIX}${rec.logPath}`
|
|
235
|
+
).catch(() => undefined)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
229
238
|
rec.wakeRequested = true
|
|
230
239
|
return textResult(
|
|
231
240
|
`Watching task ${params.task_id} (bytes ${read.start}-${read.end} of ${read.size}). ` +
|
package/src/process.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import type { ChildProcess } from 'node:child_process'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
|
-
import { createWriteStream, mkdirSync, readdirSync, statSync, unlinkSync, watch } from 'node:fs'
|
|
4
|
+
import { appendFileSync, createWriteStream, mkdirSync, readdirSync, statSync, unlinkSync, watch } from 'node:fs'
|
|
5
5
|
import type { WriteStream } from 'node:fs'
|
|
6
6
|
import { open, stat } from 'node:fs/promises'
|
|
7
7
|
import { basename, join } from 'node:path'
|
|
@@ -131,6 +131,7 @@ export function spawnTask(options: SpawnOptions): TaskRecord {
|
|
|
131
131
|
mkdirSync(logDirPath, { recursive: true })
|
|
132
132
|
sweepTaskLogs(logDirPath, Date.now())
|
|
133
133
|
const logPath = join(logDirPath, `${taskId}.log`)
|
|
134
|
+
appendFileSync(logPath, '')
|
|
134
135
|
const stream: WriteStream = createWriteStream(logPath, { flags: 'a' })
|
|
135
136
|
|
|
136
137
|
const shell = resolveShell()
|
|
@@ -381,6 +382,7 @@ export function watchForWrite(
|
|
|
381
382
|
let pollTimer: ReturnType<typeof setInterval> | undefined
|
|
382
383
|
let settled = false
|
|
383
384
|
let checking: Promise<boolean> | undefined
|
|
385
|
+
let pending = false
|
|
384
386
|
|
|
385
387
|
const stopPolling = () => {
|
|
386
388
|
if (pollTimer) clearInterval(pollTimer)
|
|
@@ -394,7 +396,10 @@ export function watchForWrite(
|
|
|
394
396
|
}
|
|
395
397
|
|
|
396
398
|
const check = (): Promise<boolean> => {
|
|
397
|
-
if (checking)
|
|
399
|
+
if (checking) {
|
|
400
|
+
pending = true
|
|
401
|
+
return checking
|
|
402
|
+
}
|
|
398
403
|
checking = (async (): Promise<boolean> => {
|
|
399
404
|
if (settled) return false
|
|
400
405
|
try {
|
|
@@ -409,6 +414,10 @@ export function watchForWrite(
|
|
|
409
414
|
}
|
|
410
415
|
})().finally(() => {
|
|
411
416
|
checking = undefined
|
|
417
|
+
if (pending && !settled) {
|
|
418
|
+
pending = false
|
|
419
|
+
void check()
|
|
420
|
+
}
|
|
412
421
|
})
|
|
413
422
|
return checking
|
|
414
423
|
}
|
|
@@ -434,9 +443,7 @@ export function watchForWrite(
|
|
|
434
443
|
|
|
435
444
|
const exitBridge = () => {
|
|
436
445
|
if (settled) return
|
|
437
|
-
void (checking ?? Promise.resolve(false))
|
|
438
|
-
.then(() => check())
|
|
439
|
-
.then(finishWithoutOutput)
|
|
446
|
+
void (checking ?? Promise.resolve(false)).then(() => check()).then(finishWithoutOutput)
|
|
440
447
|
}
|
|
441
448
|
rec.exitWaiters.add(exitBridge)
|
|
442
449
|
|
|
@@ -472,7 +479,7 @@ export function benignExitNote(
|
|
|
472
479
|
raisedText: string
|
|
473
480
|
): string | undefined {
|
|
474
481
|
if (!isError) return undefined
|
|
475
|
-
if (exitCode === 1 &&
|
|
482
|
+
if (exitCode === 1 && isNoMatchCommand(command)) {
|
|
476
483
|
return '\n[Note: exit 1 here means no matches found, not an error.]'
|
|
477
484
|
}
|
|
478
485
|
if (exitCode === undefined && /^Command timed out after\b/.test(raisedText.trimEnd().split('\n').at(-1) ?? '')) {
|
|
@@ -481,6 +488,10 @@ export function benignExitNote(
|
|
|
481
488
|
return undefined
|
|
482
489
|
}
|
|
483
490
|
|
|
491
|
+
export function isNoMatchCommand(command: string): boolean {
|
|
492
|
+
return /^(?:grep|rg)\b[^|&;\n]*$/.test(command.trim())
|
|
493
|
+
}
|
|
494
|
+
|
|
484
495
|
export function middleOut(text: string, maxBytes: number): MiddleOutView {
|
|
485
496
|
const total = Buffer.byteLength(text, 'utf8')
|
|
486
497
|
if (total <= maxBytes) {
|
package/src/stall.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { registry } from './process.js'
|
|
4
|
+
import type { TaskRecord } from './process.js'
|
|
5
|
+
|
|
6
|
+
export const STALL_MS = 10 * 60 * 1000
|
|
7
|
+
const STALL_CHECK_MS = 30 * 1000
|
|
8
|
+
|
|
9
|
+
export interface StallState {
|
|
10
|
+
size: number
|
|
11
|
+
since: number
|
|
12
|
+
reported: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StallStep {
|
|
16
|
+
next: StallState
|
|
17
|
+
report: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function advanceStall(prev: StallState, size: number, now: number): StallStep {
|
|
21
|
+
if (size !== prev.size) return { next: { size, since: now, reported: false }, report: false }
|
|
22
|
+
if (prev.reported || now - prev.since < STALL_MS) return { next: prev, report: false }
|
|
23
|
+
return { next: { ...prev, reported: true }, report: true }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function watchStall(rec: TaskRecord, onStall: (silentMs: number) => void): void {
|
|
27
|
+
let state: StallState = { size: -1, since: Date.now(), reported: false }
|
|
28
|
+
const timer = setInterval(() => {
|
|
29
|
+
const live = registry.get(rec.taskId)
|
|
30
|
+
if (!live || live.exitCode !== null || live.signal !== null) {
|
|
31
|
+
clearInterval(timer)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
let size: number
|
|
35
|
+
try {
|
|
36
|
+
size = statSync(rec.logPath).size
|
|
37
|
+
} catch {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const now = Date.now()
|
|
41
|
+
const step = advanceStall(state, size, now)
|
|
42
|
+
state = step.next
|
|
43
|
+
if (step.report) onStall(now - state.since)
|
|
44
|
+
}, STALL_CHECK_MS)
|
|
45
|
+
timer.unref()
|
|
46
|
+
}
|