@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 CHANGED
@@ -1,58 +1,64 @@
1
1
  # @tduyng/pi-shell
2
2
 
3
- Background shell execution for pi. One `bg` tool covers the full lifecycle: start, read output, kill, and list tasks.
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
- ## The bg tool
5
+ ## What it costs
6
6
 
7
- A single tool with an `action` field. Each `bg` task runs in the current working directory with a fresh shell, like pi's own bash. There is no persistent shell session.
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. Returns `task_id` (8-char UUID prefix), `pid`, and the absolute `log` path. stdout and stderr stream to a log file at `<agentDir>/shell-logs/<taskId>.log`. The task runs silently until you ask about it. Optional `timeout` caps the task (max 600 seconds). Default is no timeout.
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 using a byte cursor. The `wait` parameter controls behavior:
21
+ Reads new bytes since the last read, through a byte cursor.
16
22
 
17
- - `"now"`: returns immediately with whatever is available.
18
- - `"next_write"`: parks the agent until the first new byte appears, then wakes it with the new output.
19
- - `"exit"`: parks the agent until the process ends, then delivers the verdict plus the unread tail.
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
- The cursor never advances on wake reads, so a later `bg` output call still sees every byte from the stored offset onward.
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
- Sends SIGTERM to the process group, polls every 50ms for up to 1 second, then escalates to SIGKILL. Accepts `task_id`, raw `pid`, or `port` (resolved to a pid via `lsof -ti`).
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
- Lists all tasks with id, pid, state (running, exited, killed), cursor offset, and the first 80 characters of the command.
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
- Pi 0.99 already reports a signal-killed command as a failure. Its bash backend returns `128 + signal` for a child that died without an exit code, and raises when a command ends with neither an exit code nor a signal. This extension does not override that backend.
39
+ ## Waking only when asked
34
40
 
35
- `shell-process.test.ts` runs `kill -9` against pi's own bash tool and asserts a nonzero result in the 128+n family, so a pi release that reintroduces the silent success fails here.
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
- A `tool_result` handler annotates the two cases where the exit code alone cannot tell the model what happened. An 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 from retrying a search that already succeeded.
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
- ## Middle-out output view
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
- Large logs are truncated to a head and tail, defaulting to 8KB total, with an exact omitted-byte count in between. The read window per side is 16KB, split on UTF-8 sequence boundaries so characters are never cut mid-byte. The full log stays on disk at the path always shown in results.
47
+ ## Reading a log without drowning in it
42
48
 
43
- ## Wake on exit
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
- Only a task the model parked on (via `wait: "exit"` or `wait: "next_write"`) injects a user message when it finishes. A burst of exits coalesces into one message sharing a byte budget: default 8KB total, minimum 1KB per task. The wake carries the exit verdict, the unread tail, and the full log path per task, so the model gets the result without a read-back.
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
- The wake fires from the `onExit` callback via `queueMicrotask`, not from polling. If the agent is mid-turn, the wake lands in the follow-up queue and is delivered at the next `agent_settled` boundary.
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 indicator
55
+ ## Footer
50
56
 
51
- The footer shows a dot and the live task count (e.g. "2 bg running") while any background task is active. It clears when the last task exits.
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
- ## Task lifecycle
59
+ ## Lifecycle
54
60
 
55
- Each `bg` task is in-memory only. It dies with the pi process. Persisted session entries record the command, log path, and start time so the model can read the log after a reload. The live process handle is gone. Detached grandchildren that inherit the shell's stdout keep writing to the log after the shell exits. The log stream stays open until both the stdout and stderr pipes close. Ending it on the first close would drop data the second pipe still delivers.
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
- - `SHELL`: set to `0`, `false`, `no`, or `off` to disable the extension entirely.
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.1",
4
- "description": "Background processes the agent parks on: one bg tool with start/output/kill/status, cursor reads that never re-read bytes, and a runtime wake-up when a task exits.",
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
- const cmd = rec.command.trim()
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) return 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 && /\b(?:grep|rg)\b/.test(command)) {
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
+ }