@sebastianandreasson/pi-autonomous-agents 0.5.2 → 0.6.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.
@@ -1,668 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { createInterface } from 'node:readline'
4
- import { spawn } from 'node:child_process'
5
- import path from 'node:path'
6
- import process from 'node:process'
7
- import {
8
- formatHeartbeatReason,
9
- formatHeartbeatTimeoutMessage,
10
- getHeartbeatDecision,
11
- resolveHeartbeatConfig,
12
- } from './pi-heartbeat.mjs'
13
- import {
14
- registerOwnedChildProcess,
15
- signalOwnedChildProcesses,
16
- signalProcessTree,
17
- watchParentProcess,
18
- } from './pi-repo.mjs'
19
-
20
- function createJsonlReader(stream, onLine) {
21
- const rl = createInterface({ input: stream })
22
- rl.on('line', onLine)
23
- return () => rl.close()
24
- }
25
-
26
- async function readRequest() {
27
- const chunks = []
28
- for await (const chunk of process.stdin) {
29
- chunks.push(chunk)
30
- }
31
-
32
- const raw = Buffer.concat(chunks).toString('utf8').trim()
33
- if (raw === '') {
34
- throw new Error('Expected JSON request on stdin.')
35
- }
36
-
37
- return JSON.parse(raw)
38
- }
39
-
40
- function formatValue(value) {
41
- const text = JSON.stringify(value)
42
- if (text === undefined) {
43
- return ''
44
- }
45
- if (text.length <= 160) {
46
- return text
47
- }
48
- return `${text.slice(0, 157)}...`
49
- }
50
-
51
- function extractToolTarget(toolName, args) {
52
- if (!args || typeof args !== 'object') {
53
- return ''
54
- }
55
-
56
- if ((toolName === 'read' || toolName === 'write' || toolName === 'edit') && typeof args.path === 'string') {
57
- return args.path
58
- }
59
-
60
- return ''
61
- }
62
-
63
- function extractShellCommand(args) {
64
- if (!args || typeof args !== 'object') {
65
- return ''
66
- }
67
-
68
- if (typeof args.command === 'string') {
69
- return args.command
70
- }
71
-
72
- if (typeof args.cmd === 'string') {
73
- return args.cmd
74
- }
75
-
76
- return ''
77
- }
78
-
79
- function isLargeShellRead(command) {
80
- const text = String(command ?? '').trim()
81
- if (text === '') {
82
- return false
83
- }
84
-
85
- if (/^\s*cat\s+\S+/.test(text)) {
86
- return true
87
- }
88
-
89
- const sedMatch = text.match(/sed\s+-n\s+['"]?(\d+)\s*,\s*(\d+)p['"]?/)
90
- if (sedMatch) {
91
- const start = Number.parseInt(sedMatch[1], 10)
92
- const end = Number.parseInt(sedMatch[2], 10)
93
- if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
94
- return (end - start) >= 120
95
- }
96
- }
97
-
98
- return false
99
- }
100
-
101
- function extractAssistantText(message) {
102
- if (!message || message.role !== 'assistant' || !Array.isArray(message.content)) {
103
- return ''
104
- }
105
-
106
- return message.content
107
- .filter((item) => item?.type === 'text')
108
- .map((item) => item.text)
109
- .join('')
110
- }
111
-
112
- function getLastAssistantMessageFromEvents(events) {
113
- const agentEnd = [...events].reverse().find((event) => event.type === 'agent_end')
114
- if (!agentEnd || !Array.isArray(agentEnd.messages)) {
115
- return null
116
- }
117
-
118
- const messages = [...agentEnd.messages].reverse()
119
- return messages.find((message) => message?.role === 'assistant') ?? null
120
- }
121
-
122
- async function run() {
123
- const request = await readRequest()
124
- const runtimeDir = path.resolve(request.runtimeDir ?? path.join(request.cwd, '.pi-runtime'))
125
- const sessionDir = path.join(runtimeDir, 'sessions')
126
- const cli = request.piCli || 'pi'
127
- const args = ['--mode', 'rpc', '--session-dir', sessionDir]
128
-
129
- if (request.sessionFile) {
130
- args.push('--session', request.sessionFile)
131
- } else if (request.sessionId) {
132
- args.push('--continue')
133
- }
134
-
135
- if (request.model) {
136
- args.push('--model', request.model)
137
- }
138
- if (request.tools) {
139
- args.push('--tools', request.tools)
140
- }
141
- if (request.thinking) {
142
- args.push('--thinking', request.thinking)
143
- }
144
- if (request.noExtensions) {
145
- args.push('--no-extensions')
146
- }
147
- if (request.noSkills) {
148
- args.push('--no-skills')
149
- }
150
- if (request.noPromptTemplates) {
151
- args.push('--no-prompt-templates')
152
- }
153
- if (request.noThemes ?? true) {
154
- args.push('--no-themes')
155
- }
156
-
157
- const child = spawn(cli, args, {
158
- cwd: request.cwd,
159
- env: process.env,
160
- detached: process.platform !== 'win32',
161
- stdio: ['pipe', 'pipe', 'pipe'],
162
- })
163
- registerOwnedChildProcess(child, {
164
- useProcessGroup: process.platform !== 'win32',
165
- })
166
-
167
- let stderr = ''
168
- const events = []
169
- const pending = new Map()
170
- let requestCounter = 0
171
- const streamTerminal = request.streamTerminal === true
172
- const requestedModel = typeof request.model === 'string' ? request.model : ''
173
- const loopRepeatThreshold = Number.isFinite(Number(request.loopRepeatThreshold))
174
- ? Number(request.loopRepeatThreshold)
175
- : 12
176
- const samePathRepeatThreshold = Number.isFinite(Number(request.samePathRepeatThreshold))
177
- ? Number(request.samePathRepeatThreshold)
178
- : 8
179
- let assistantLineOpen = false
180
- let streamedAssistantText = false
181
- let lastToolSignature = ''
182
- let repeatedToolCount = 0
183
- let lastToolTarget = ''
184
- let repeatedTargetCount = 0
185
- let loopDetected = false
186
- let loopSignature = ''
187
- let abortRequested = false
188
- const {
189
- continueAfterSeconds,
190
- noEventTimeoutSeconds,
191
- toolContinueAfterSeconds,
192
- toolNoEventTimeoutSeconds,
193
- } = resolveHeartbeatConfig(request)
194
- const continueMessage = typeof request.continueMessage === 'string' && request.continueMessage.trim() !== ''
195
- ? request.continueMessage.trim()
196
- : 'continue'
197
- let agentStarted = false
198
- let agentEnded = false
199
- let heartbeatTimedOut = false
200
- let heartbeatReason = ''
201
- let lastEventAt = Date.now()
202
- let activeToolName = ''
203
- let activeToolStartedAt = 0
204
- let heartbeatInterval = null
205
- let continueAttempted = false
206
- let continueAccepted = false
207
- let continueRejected = false
208
- let shutdownRequested = false
209
- let shutdownReason = ''
210
- let shutdownEscalationTimer = null
211
-
212
- const requestShutdown = (reason, signal = 'SIGTERM') => {
213
- if (shutdownRequested) {
214
- return
215
- }
216
-
217
- shutdownRequested = true
218
- shutdownReason = String(reason ?? 'adapter_shutdown')
219
- closeAssistantLine()
220
- signalOwnedChildProcesses(signal)
221
- shutdownEscalationTimer = setTimeout(() => {
222
- signalOwnedChildProcesses('SIGKILL')
223
- }, 1000)
224
- if (typeof shutdownEscalationTimer.unref === 'function') {
225
- shutdownEscalationTimer.unref()
226
- }
227
- }
228
-
229
- const stopWatchingParent = watchParentProcess(() => {
230
- requestShutdown('parent_exit', 'SIGTERM')
231
- })
232
-
233
- for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
234
- process.on(signal, () => {
235
- requestShutdown(signal, signal)
236
- })
237
- }
238
-
239
- const writeLive = (text) => {
240
- if (!streamTerminal) {
241
- return
242
- }
243
- process.stderr.write(text)
244
- }
245
-
246
- const ensureAssistantLine = () => {
247
- if (!assistantLineOpen) {
248
- writeLive('[PI assistant] ')
249
- assistantLineOpen = true
250
- }
251
- }
252
-
253
- const closeAssistantLine = () => {
254
- if (assistantLineOpen) {
255
- writeLive('\n')
256
- assistantLineOpen = false
257
- }
258
- }
259
-
260
- const requestAbortForLoop = () => {
261
- if (abortRequested) {
262
- return
263
- }
264
-
265
- abortRequested = true
266
- closeAssistantLine()
267
- writeLive(`[PI guard] repeated tool loop detected: ${loopSignature} x${repeatedToolCount}. Aborting current turn.\n`)
268
- void send({ type: 'abort' }).catch(() => {})
269
- }
270
-
271
- const requestAbortForHeartbeat = (decision) => {
272
- if (abortRequested) {
273
- return
274
- }
275
-
276
- abortRequested = true
277
- heartbeatTimedOut = true
278
- heartbeatReason = formatHeartbeatReason(decision)
279
- closeAssistantLine()
280
- writeLive(`[PI guard] ${formatHeartbeatTimeoutMessage(decision)} Aborting current turn (pid=${child.pid ?? 'unknown'}).\n`)
281
- void send({ type: 'abort' }).catch(() => {})
282
- signalProcessTree(child.pid, 'SIGTERM')
283
- setTimeout(() => {
284
- if (child.exitCode === null) {
285
- signalProcessTree(child.pid, 'SIGKILL')
286
- }
287
- }, 1000)
288
- }
289
-
290
- const requestSoftContinue = (decision) => {
291
- if (abortRequested || continueAttempted || agentEnded) {
292
- return
293
- }
294
-
295
- continueAttempted = true
296
- closeAssistantLine()
297
- const context = decision.activeToolName
298
- ? ` while tool "${decision.activeToolName}" is running`
299
- : ''
300
- writeLive(`[PI guard] no PI events for ${decision.continueAfterSeconds}s${context}. Sending soft continue prompt.\n`)
301
- void send({ type: 'prompt', message: continueMessage })
302
- .then((response) => {
303
- if (response?.success) {
304
- continueAccepted = true
305
- writeLive('[PI guard] soft continue accepted by PI.\n')
306
- } else {
307
- continueRejected = true
308
- writeLive(`[PI guard] soft continue rejected: ${String(response?.error ?? 'unknown error')}\n`)
309
- }
310
- })
311
- .catch((error) => {
312
- continueRejected = true
313
- writeLive(`[PI guard] soft continue failed: ${error instanceof Error ? error.message : String(error)}\n`)
314
- })
315
- }
316
-
317
- child.stderr.on('data', (chunk) => {
318
- stderr += chunk.toString()
319
- })
320
-
321
- const stopReading = createJsonlReader(child.stdout, (line) => {
322
- if (line.trim() === '') {
323
- return
324
- }
325
-
326
- let data
327
- try {
328
- data = JSON.parse(line)
329
- } catch {
330
- return
331
- }
332
-
333
- lastEventAt = Date.now()
334
-
335
- if (data.type === 'response' && typeof data.id === 'string' && pending.has(data.id)) {
336
- const current = pending.get(data.id)
337
- pending.delete(data.id)
338
- current.resolve(data)
339
- return
340
- }
341
-
342
- events.push(data)
343
-
344
- if (data.type === 'agent_start') {
345
- agentStarted = true
346
- writeLive('[PI] agent started\n')
347
- }
348
-
349
- if (data.type === 'message_update' && data.message?.role === 'assistant') {
350
- const assistantEvent = data.assistantMessageEvent
351
- if (assistantEvent?.type === 'text_delta') {
352
- ensureAssistantLine()
353
- writeLive(assistantEvent.delta)
354
- streamedAssistantText = true
355
- }
356
- }
357
-
358
- if (data.type === 'message_end' && data.message?.role === 'assistant') {
359
- const text = extractAssistantText(data.message)
360
- if (assistantLineOpen) {
361
- closeAssistantLine()
362
- } else if (!streamedAssistantText && text.trim() !== '') {
363
- writeLive(`[PI assistant] ${text}\n`)
364
- }
365
- streamedAssistantText = false
366
- }
367
-
368
- if (data.type === 'tool_execution_start') {
369
- closeAssistantLine()
370
- const argsText = formatValue(data.args)
371
- const suffix = argsText === '' ? '' : ` ${argsText}`
372
- const signature = `${data.toolName}${suffix}`
373
- activeToolName = String(data.toolName ?? '')
374
- activeToolStartedAt = Date.now()
375
- const target = extractToolTarget(data.toolName, data.args)
376
- const shellCommand = data.toolName === 'bash' ? extractShellCommand(data.args) : ''
377
- if (signature === lastToolSignature) {
378
- repeatedToolCount += 1
379
- } else {
380
- lastToolSignature = signature
381
- repeatedToolCount = 1
382
- }
383
-
384
- if (target !== '' && target === lastToolTarget) {
385
- repeatedTargetCount += 1
386
- } else if (target !== '') {
387
- lastToolTarget = target
388
- repeatedTargetCount = 1
389
- } else {
390
- lastToolTarget = ''
391
- repeatedTargetCount = 0
392
- }
393
-
394
- if (!loopDetected && repeatedToolCount >= loopRepeatThreshold) {
395
- loopDetected = true
396
- loopSignature = signature
397
- requestAbortForLoop()
398
- }
399
-
400
- if (!loopDetected && target !== '' && repeatedTargetCount >= samePathRepeatThreshold) {
401
- loopDetected = true
402
- loopSignature = `same_path:${target}`
403
- requestAbortForLoop()
404
- }
405
-
406
- writeLive(`[PI tool:start] ${data.toolName}${suffix}\n`)
407
- if (data.toolName === 'bash' && isLargeShellRead(shellCommand)) {
408
- writeLive('[PI warning] large bash file read detected; prefer read or a smaller exact window to avoid truncated context.\n')
409
- }
410
- }
411
-
412
- if (data.type === 'tool_execution_end') {
413
- closeAssistantLine()
414
- activeToolName = ''
415
- activeToolStartedAt = 0
416
- writeLive(`[PI tool:end] ${data.toolName} ${data.isError ? 'error' : 'ok'}\n`)
417
- }
418
-
419
- if (data.type === 'agent_end') {
420
- agentEnded = true
421
- closeAssistantLine()
422
- writeLive('[PI] agent finished\n')
423
- }
424
-
425
- if (data.type === 'extension_ui_request') {
426
- closeAssistantLine()
427
- writeLive(`[PI ui] ${data.method} requested and auto-cancelled in headless mode\n`)
428
- child.stdin.write(`${JSON.stringify({
429
- type: 'extension_ui_response',
430
- id: data.id,
431
- cancelled: true,
432
- })}\n`)
433
- }
434
- })
435
-
436
- const send = (command) => new Promise((resolve, reject) => {
437
- requestCounter += 1
438
- const id = `adapter_${requestCounter}`
439
- pending.set(id, { resolve, reject })
440
- child.stdin.write(`${JSON.stringify({ ...command, id })}\n`)
441
- })
442
-
443
- const waitForAgentEnd = () => new Promise((resolve) => {
444
- const interval = setInterval(() => {
445
- if (events.some((event) => event.type === 'agent_end') || child.exitCode !== null || heartbeatTimedOut) {
446
- clearInterval(interval)
447
- resolve()
448
- }
449
- }, 50)
450
- })
451
-
452
- heartbeatInterval = setInterval(() => {
453
- const decision = getHeartbeatDecision({
454
- now: Date.now(),
455
- agentStarted,
456
- agentEnded,
457
- heartbeatTimedOut,
458
- childExited: child.exitCode !== null,
459
- lastEventAt,
460
- continueAttempted,
461
- activeToolName,
462
- activeToolStartedAt,
463
- continueAfterSeconds,
464
- noEventTimeoutSeconds,
465
- toolContinueAfterSeconds,
466
- toolNoEventTimeoutSeconds,
467
- })
468
-
469
- if (decision.action === 'soft_continue') {
470
- requestSoftContinue(decision)
471
- return
472
- }
473
-
474
- if (decision.action === 'abort') {
475
- requestAbortForHeartbeat(decision)
476
- }
477
- }, 1000)
478
-
479
- try {
480
- const initialState = await send({ type: 'get_state' })
481
- if (!initialState.success) {
482
- throw new Error(initialState.error)
483
- }
484
-
485
- if (!request.model && !initialState.data.model) {
486
- throw new Error('No PI model configured. Set PI_MODEL or configure a default model in PI.')
487
- }
488
-
489
- await send({ type: 'set_auto_retry', enabled: false })
490
-
491
- const promptResponse = await send({
492
- type: 'prompt',
493
- message: request.prompt,
494
- })
495
- if (!promptResponse.success) {
496
- throw new Error(promptResponse.error)
497
- }
498
-
499
- await waitForAgentEnd()
500
-
501
- if (shutdownRequested) {
502
- console.log(JSON.stringify({
503
- sessionId: request.sessionId ?? '',
504
- sessionFile: request.sessionFile ?? '',
505
- status: 'failed',
506
- output: '',
507
- notes: shutdownReason,
508
- role: '',
509
- model: requestedModel,
510
- toolCalls: 0,
511
- toolErrors: 0,
512
- messageUpdates: 0,
513
- stopReason: '',
514
- loopDetected: false,
515
- loopSignature: '',
516
- terminalReason: 'adapter_shutdown',
517
- }))
518
- return
519
- }
520
-
521
- if (heartbeatTimedOut) {
522
- const toolCalls = events.filter((event) => event.type === 'tool_execution_start').length
523
- const toolErrors = events.filter((event) => event.type === 'tool_execution_end' && event.isError).length
524
- const messageUpdates = events.filter((event) => event.type === 'message_update').length
525
- console.log(JSON.stringify({
526
- sessionId: request.sessionId ?? '',
527
- sessionFile: request.sessionFile ?? '',
528
- status: 'timed_out',
529
- output: [
530
- '[heartbeat_timeout]',
531
- formatHeartbeatTimeoutMessage({
532
- noEventTimeoutSeconds,
533
- activeToolName,
534
- toolRuntimeSeconds: activeToolStartedAt > 0
535
- ? Math.max(0, Math.floor((Date.now() - activeToolStartedAt) / 1000))
536
- : 0,
537
- }),
538
- stderr.trim() !== '' ? `\n[stderr]\n${stderr.trim()}` : '',
539
- ].join('\n').trim(),
540
- notes: [
541
- `pi_pid=${child.pid ?? 'unknown'}`,
542
- heartbeatReason,
543
- continueAttempted ? `continue_attempted=${continueMessage}` : '',
544
- continueAccepted ? 'continue_accepted=true' : '',
545
- continueRejected ? 'continue_rejected=true' : '',
546
- ].join(' '),
547
- role: '',
548
- model: requestedModel,
549
- toolCalls,
550
- toolErrors,
551
- messageUpdates,
552
- stopReason: '',
553
- loopDetected: false,
554
- loopSignature: '',
555
- terminalReason: 'heartbeat_timeout',
556
- }))
557
- return
558
- }
559
-
560
- const state = await send({ type: 'get_state' })
561
- if (!state.success) {
562
- throw new Error(state.error)
563
- }
564
-
565
- const lastAssistant = await send({ type: 'get_last_assistant_text' })
566
- if (!lastAssistant.success) {
567
- throw new Error(lastAssistant.error)
568
- }
569
-
570
- const toolCalls = events.filter((event) => event.type === 'tool_execution_start').length
571
- const toolErrors = events.filter((event) => event.type === 'tool_execution_end' && event.isError).length
572
- const messageUpdates = events.filter((event) => event.type === 'message_update').length
573
- const lastAssistantMessage = getLastAssistantMessageFromEvents(events)
574
- const assistantText = String(lastAssistant.data.text ?? '').trim()
575
- const assistantError = String(lastAssistantMessage?.errorMessage ?? '').trim()
576
- const assistantStopReason = String(lastAssistantMessage?.stopReason ?? '').trim()
577
- const status = loopDetected
578
- ? 'stalled'
579
- : assistantError !== '' || (assistantText === '' && toolCalls === 0 && messageUpdates === 0)
580
- ? 'failed'
581
- : 'success'
582
- const terminalReason = loopDetected
583
- ? 'loop_detected'
584
- : assistantError !== ''
585
- ? 'assistant_error'
586
- : assistantStopReason === 'length'
587
- ? 'assistant_stop_length'
588
- : status === 'failed'
589
- ? 'empty_agent_turn'
590
- : 'agent_completed'
591
- const notes = [
592
- `PI session ${state.data.sessionId}`,
593
- `pi_pid=${child.pid ?? 'unknown'}`,
594
- `tool_calls=${toolCalls}`,
595
- `tool_errors=${toolErrors}`,
596
- `message_updates=${messageUpdates}`,
597
- activeToolName !== '' ? `active_tool=${activeToolName}` : '',
598
- continueAttempted ? `continue_attempted=${continueMessage}` : '',
599
- continueAccepted ? 'continue_accepted=true' : '',
600
- continueRejected ? 'continue_rejected=true' : '',
601
- loopDetected ? `loop_detected=${loopSignature}` : '',
602
- loopDetected ? `loop_repeats=${repeatedToolCount}` : '',
603
- assistantStopReason !== '' ? `stop_reason=${assistantStopReason}` : '',
604
- assistantError !== '' ? `assistant_error=${assistantError}` : '',
605
- status === 'failed' && assistantError === '' ? 'empty_agent_turn' : '',
606
- ].join(' ')
607
-
608
- const output = [
609
- assistantText,
610
- loopDetected ? `\n[loop_guard]\nRepeated tool loop detected: ${loopSignature} x${repeatedToolCount}` : '',
611
- assistantError !== '' ? `\n[assistant_error]\n${assistantError}` : '',
612
- stderr.trim() !== '' ? `\n[stderr]\n${stderr.trim()}` : '',
613
- ].join('').trim()
614
-
615
- console.log(JSON.stringify({
616
- sessionId: state.data.sessionId,
617
- sessionFile: state.data.sessionFile ?? '',
618
- status,
619
- output,
620
- notes,
621
- role: '',
622
- model: requestedModel,
623
- toolCalls,
624
- toolErrors,
625
- messageUpdates,
626
- stopReason: assistantStopReason,
627
- loopDetected,
628
- loopSignature,
629
- terminalReason,
630
- }))
631
- } finally {
632
- stopWatchingParent()
633
- if (heartbeatInterval) {
634
- clearInterval(heartbeatInterval)
635
- }
636
- if (shutdownEscalationTimer) {
637
- clearTimeout(shutdownEscalationTimer)
638
- }
639
- stopReading()
640
- for (const current of pending.values()) {
641
- current.reject(new Error('RPC adapter shutting down'))
642
- }
643
- pending.clear()
644
-
645
- signalProcessTree(child.pid, 'SIGTERM')
646
- await new Promise((resolve) => {
647
- const timeout = setTimeout(() => {
648
- signalProcessTree(child.pid, 'SIGKILL')
649
- resolve()
650
- }, 1000)
651
-
652
- child.on('exit', () => {
653
- clearTimeout(timeout)
654
- resolve()
655
- })
656
- })
657
- }
658
- }
659
-
660
- run().catch((error) => {
661
- console.log(JSON.stringify({
662
- sessionId: '',
663
- sessionFile: '',
664
- status: 'failed',
665
- output: '',
666
- notes: error instanceof Error ? error.message : String(error),
667
- }))
668
- })