@tduyng/pi-shell 0.0.1 → 0.0.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 +4 -0
- package/package.json +1 -1
- package/src/index.ts +12 -3
- package/src/process.ts +17 -6
- package/src/stall.ts +46 -0
package/README.md
CHANGED
|
@@ -46,6 +46,10 @@ Only a task the model parked on (via `wait: "exit"` or `wait: "next_write"`) inj
|
|
|
46
46
|
|
|
47
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.
|
|
48
48
|
|
|
49
|
+
## Stall check
|
|
50
|
+
|
|
51
|
+
A task the model parked on with `wait: "exit"` also gets a silence check. Every 30 seconds it reads the log size. After 10 minutes with no new bytes, the agent is woken once with the elapsed time and the log path. The wake fires again only after output resumes and stops again. No model call runs for the check.
|
|
52
|
+
|
|
49
53
|
## Footer indicator
|
|
50
54
|
|
|
51
55
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tduyng/pi-shell",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
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
|
+
}
|