@sebastianandreasson/pi-autonomous-agents 0.1.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/README.md +102 -0
- package/SETUP.md +171 -0
- package/docs/PI_SUPERVISOR.md +246 -0
- package/package.json +37 -0
- package/pi.config.json +28 -0
- package/src/cli.mjs +48 -0
- package/src/index.mjs +7 -0
- package/src/pi-client.mjs +195 -0
- package/src/pi-config.mjs +296 -0
- package/src/pi-flow.mjs +42 -0
- package/src/pi-heartbeat.mjs +152 -0
- package/src/pi-prompts.mjs +274 -0
- package/src/pi-repo.mjs +496 -0
- package/src/pi-report.mjs +55 -0
- package/src/pi-rpc-adapter.mjs +531 -0
- package/src/pi-supervisor.mjs +1156 -0
- package/src/pi-telemetry.mjs +63 -0
- package/src/pi-visual-once.mjs +86 -0
- package/src/pi-visual-review.mjs +236 -0
- package/templates/DEVELOPER.md +34 -0
- package/templates/PROJECT_SETUP.md +42 -0
- package/templates/TESTER.md +37 -0
- package/templates/gitignore.fragment +11 -0
- package/templates/pi.config.example.json +53 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
appendLog,
|
|
4
|
+
runShellCommand,
|
|
5
|
+
writeTextFile,
|
|
6
|
+
} from './pi-repo.mjs'
|
|
7
|
+
|
|
8
|
+
function truncateForNotes(text) {
|
|
9
|
+
const trimmed = text.trim()
|
|
10
|
+
if (trimmed.length <= 300) {
|
|
11
|
+
return trimmed
|
|
12
|
+
}
|
|
13
|
+
return `${trimmed.slice(0, 297)}...`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatLastAgentOutput(response) {
|
|
17
|
+
const sections = [
|
|
18
|
+
`status: ${String(response.status ?? '')}`,
|
|
19
|
+
`sessionId: ${String(response.sessionId ?? '')}`,
|
|
20
|
+
`sessionFile: ${String(response.sessionFile ?? '')}`,
|
|
21
|
+
`notes: ${String(response.notes ?? '').trim()}`,
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const output = String(response.output ?? '').trim()
|
|
25
|
+
if (output !== '') {
|
|
26
|
+
sections.push('', output)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return `${sections.join('\n')}\n`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function runMockTurn({ config, sessionId, sessionFile, prompt, reason }) {
|
|
33
|
+
const nextSessionId = sessionId || `mock-${randomUUID()}`
|
|
34
|
+
const nextSessionFile = sessionFile || `${config.piRuntimeDir}/mock-${nextSessionId}.jsonl`
|
|
35
|
+
const output = [
|
|
36
|
+
`[mock transport] ${config.agentName} session ${nextSessionId}`,
|
|
37
|
+
`reason: ${reason}`,
|
|
38
|
+
'',
|
|
39
|
+
'Prompt preview:',
|
|
40
|
+
prompt,
|
|
41
|
+
'',
|
|
42
|
+
'Mock mode does not edit files. Point PI_TRANSPORT=adapter at a real adapter to enable unattended work.',
|
|
43
|
+
].join('\n')
|
|
44
|
+
|
|
45
|
+
await writeTextFile(config.lastAgentOutputFile, `${output}\n`)
|
|
46
|
+
await appendLog(config.logFile, `Mock agent turn completed for session ${nextSessionId}`)
|
|
47
|
+
if (config.streamTerminal) {
|
|
48
|
+
process.stderr.write(`[PI mock] ${reason}\n`)
|
|
49
|
+
process.stderr.write('[PI mock] no live agent output in mock mode\n')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
sessionId: nextSessionId,
|
|
54
|
+
sessionFile: nextSessionFile,
|
|
55
|
+
status: 'success',
|
|
56
|
+
exitCode: 0,
|
|
57
|
+
timedOut: false,
|
|
58
|
+
durationSeconds: 0,
|
|
59
|
+
output,
|
|
60
|
+
notes: 'Mock transport completed without repo edits.',
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseAdapterResponse(stdout) {
|
|
65
|
+
const trimmed = stdout.trim()
|
|
66
|
+
if (trimmed === '') {
|
|
67
|
+
throw new Error('Adapter returned no JSON on stdout.')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(trimmed)
|
|
72
|
+
} catch {
|
|
73
|
+
const lines = trimmed.split('\n').map((line) => line.trim()).filter(Boolean)
|
|
74
|
+
const lastLine = lines.at(-1)
|
|
75
|
+
if (!lastLine) {
|
|
76
|
+
throw new Error('Adapter returned no parseable JSON on stdout.')
|
|
77
|
+
}
|
|
78
|
+
return JSON.parse(lastLine)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function runAdapterTurn({ config, model, sessionId, sessionFile, prompt, iteration, retryCount, reason }) {
|
|
83
|
+
if (config.adapterCommand.trim() === '') {
|
|
84
|
+
throw new Error('PI_TRANSPORT=adapter requires PI_ADAPTER_COMMAND to be set.')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const request = {
|
|
88
|
+
sessionId,
|
|
89
|
+
sessionFile,
|
|
90
|
+
prompt,
|
|
91
|
+
cwd: config.cwd,
|
|
92
|
+
taskFile: config.taskFile,
|
|
93
|
+
instructionsFile: config.instructionsFile,
|
|
94
|
+
developerInstructionsFile: config.developerInstructionsFile,
|
|
95
|
+
testerInstructionsFile: config.testerInstructionsFile,
|
|
96
|
+
runtimeDir: config.piRuntimeDir,
|
|
97
|
+
piCli: config.piCli,
|
|
98
|
+
model: model ?? config.piModel,
|
|
99
|
+
tools: config.piTools,
|
|
100
|
+
thinking: config.piThinking,
|
|
101
|
+
noExtensions: config.piNoExtensions,
|
|
102
|
+
noSkills: config.piNoSkills,
|
|
103
|
+
noPromptTemplates: config.piNoPromptTemplates,
|
|
104
|
+
noThemes: config.piNoThemes,
|
|
105
|
+
streamTerminal: config.streamTerminal,
|
|
106
|
+
loopRepeatThreshold: config.loopRepeatThreshold,
|
|
107
|
+
samePathRepeatThreshold: config.samePathRepeatThreshold,
|
|
108
|
+
continueAfterSeconds: config.continueAfterSeconds,
|
|
109
|
+
toolContinueAfterSeconds: config.toolContinueAfterSeconds,
|
|
110
|
+
continueMessage: config.continueMessage,
|
|
111
|
+
noEventTimeoutSeconds: config.noEventTimeoutSeconds,
|
|
112
|
+
toolNoEventTimeoutSeconds: config.toolNoEventTimeoutSeconds,
|
|
113
|
+
metadata: {
|
|
114
|
+
iteration,
|
|
115
|
+
retryCount,
|
|
116
|
+
reason,
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await appendLog(
|
|
121
|
+
config.logFile,
|
|
122
|
+
`Starting adapter turn via: ${config.adapterCommand} iteration=${iteration} retry=${retryCount} reason=${reason}`
|
|
123
|
+
)
|
|
124
|
+
const result = await runShellCommand({
|
|
125
|
+
cwd: config.cwd,
|
|
126
|
+
command: config.adapterCommand,
|
|
127
|
+
timeoutSeconds: config.agentTimeoutSeconds,
|
|
128
|
+
stdinText: `${JSON.stringify(request)}\n`,
|
|
129
|
+
streamStderrToParent: config.streamTerminal,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
await writeTextFile(config.lastAgentOutputFile, result.combinedOutput)
|
|
133
|
+
|
|
134
|
+
if (result.timedOut) {
|
|
135
|
+
await appendLog(config.logFile, 'Adapter turn timed out')
|
|
136
|
+
return {
|
|
137
|
+
sessionId: sessionId || '',
|
|
138
|
+
sessionFile: sessionFile || '',
|
|
139
|
+
status: 'timed_out',
|
|
140
|
+
exitCode: result.exitCode,
|
|
141
|
+
timedOut: true,
|
|
142
|
+
durationSeconds: result.durationSeconds,
|
|
143
|
+
output: result.combinedOutput,
|
|
144
|
+
notes: 'Adapter process exceeded the configured timeout.',
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (result.exitCode !== 0) {
|
|
149
|
+
await appendLog(config.logFile, `Adapter turn failed with exit code ${result.exitCode}`)
|
|
150
|
+
await writeTextFile(config.lastAgentOutputFile, result.combinedOutput)
|
|
151
|
+
return {
|
|
152
|
+
sessionId: sessionId || '',
|
|
153
|
+
sessionFile: sessionFile || '',
|
|
154
|
+
status: 'failed',
|
|
155
|
+
exitCode: result.exitCode,
|
|
156
|
+
timedOut: false,
|
|
157
|
+
durationSeconds: result.durationSeconds,
|
|
158
|
+
output: result.combinedOutput,
|
|
159
|
+
notes: truncateForNotes(result.combinedOutput) || 'Adapter exited non-zero.',
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const response = parseAdapterResponse(result.stdout)
|
|
164
|
+
await writeTextFile(config.lastAgentOutputFile, formatLastAgentOutput(response))
|
|
165
|
+
const nextSessionId = String(response.sessionId ?? sessionId ?? '')
|
|
166
|
+
const nextSessionFile = String(response.sessionFile ?? sessionFile ?? '')
|
|
167
|
+
const status = String(response.status ?? 'success')
|
|
168
|
+
const output = String(response.output ?? result.combinedOutput)
|
|
169
|
+
const notes = String(response.notes ?? truncateForNotes(output))
|
|
170
|
+
|
|
171
|
+
await appendLog(config.logFile, `Adapter turn completed with status ${status}`)
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
sessionId: nextSessionId,
|
|
175
|
+
sessionFile: nextSessionFile,
|
|
176
|
+
status,
|
|
177
|
+
exitCode: result.exitCode,
|
|
178
|
+
timedOut: false,
|
|
179
|
+
durationSeconds: result.durationSeconds,
|
|
180
|
+
output,
|
|
181
|
+
notes,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function runAgentTurn(args) {
|
|
186
|
+
if (args.config.transport === 'mock') {
|
|
187
|
+
return await runMockTurn(args)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (args.config.transport === 'adapter') {
|
|
191
|
+
return await runAdapterTurn(args)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
throw new Error(`Unsupported PI transport "${args.config.transport}". Expected "mock" or "adapter".`)
|
|
195
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const packageRoot = path.resolve(scriptDir, '..')
|
|
8
|
+
const bundledConfigFile = path.join(packageRoot, 'pi.config.json')
|
|
9
|
+
|
|
10
|
+
function hasValue(value) {
|
|
11
|
+
return value !== undefined && value !== null && value !== ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeString(value, fallback) {
|
|
15
|
+
return hasValue(value) ? String(value) : fallback
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readString(name, fileValue, fallback) {
|
|
19
|
+
const value = process.env[name]
|
|
20
|
+
if (value !== undefined) {
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
return normalizeString(fileValue, fallback)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeInt(name, raw, fallback) {
|
|
27
|
+
if (!hasValue(raw)) {
|
|
28
|
+
return fallback
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const value = Number.parseInt(String(raw), 10)
|
|
32
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
33
|
+
throw new Error(`Expected ${name} to be a non-negative integer, received "${raw}"`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readInt(name, fileValue, fallback) {
|
|
40
|
+
const raw = process.env[name]
|
|
41
|
+
if (raw !== undefined && raw !== '') {
|
|
42
|
+
return normalizeInt(name, raw, fallback)
|
|
43
|
+
}
|
|
44
|
+
return normalizeInt(name, fileValue, fallback)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeBool(name, raw, fallback) {
|
|
48
|
+
if (!hasValue(raw)) {
|
|
49
|
+
return fallback
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof raw === 'boolean') {
|
|
53
|
+
return raw
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const normalized = String(raw).toLowerCase()
|
|
57
|
+
if (normalized === '1' || normalized === 'true' || normalized === 'yes') {
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
if (normalized === '0' || normalized === 'false' || normalized === 'no') {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw new Error(`Expected ${name} to be a boolean flag, received "${raw}"`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readBool(name, fileValue, fallback) {
|
|
68
|
+
const raw = process.env[name]
|
|
69
|
+
if (raw !== undefined && raw !== '') {
|
|
70
|
+
return normalizeBool(name, raw, fallback)
|
|
71
|
+
}
|
|
72
|
+
return normalizeBool(name, fileValue, fallback)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readRepoConfig(cwd) {
|
|
76
|
+
const configFallback = fs.existsSync(bundledConfigFile) ? bundledConfigFile : 'pi.config.json'
|
|
77
|
+
const configFile = path.resolve(cwd, normalizeString(process.env.PI_CONFIG_FILE, configFallback))
|
|
78
|
+
|
|
79
|
+
if (!fs.existsSync(configFile)) {
|
|
80
|
+
return {
|
|
81
|
+
configFile,
|
|
82
|
+
values: {},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const raw = fs.readFileSync(configFile, 'utf8')
|
|
87
|
+
const parsed = JSON.parse(raw)
|
|
88
|
+
|
|
89
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
90
|
+
throw new Error(`Expected ${configFile} to contain a JSON object.`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
configFile,
|
|
95
|
+
values: parsed,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveFromCwd(cwd, name, fileValue, fallback) {
|
|
100
|
+
return path.resolve(cwd, readString(name, fileValue, fallback))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveInstructionsFile(cwd, envName, fileValue, fallback) {
|
|
104
|
+
if (!hasValue(fileValue) && process.env[envName] === undefined) {
|
|
105
|
+
return path.resolve(cwd, fallback)
|
|
106
|
+
}
|
|
107
|
+
return resolveFromCwd(cwd, envName, fileValue, fallback)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readObject(name, raw, fallback) {
|
|
111
|
+
const value = raw === undefined ? fallback : raw
|
|
112
|
+
if (value === undefined || value === null) {
|
|
113
|
+
return fallback
|
|
114
|
+
}
|
|
115
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
116
|
+
throw new Error(`Expected ${name} to be an object.`)
|
|
117
|
+
}
|
|
118
|
+
return value
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeRoleModels(raw) {
|
|
122
|
+
const value = readObject('roleModels', raw, {})
|
|
123
|
+
const normalized = {}
|
|
124
|
+
for (const [role, modelName] of Object.entries(value)) {
|
|
125
|
+
if (!hasValue(modelName)) {
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
normalized[String(role)] = String(modelName)
|
|
129
|
+
}
|
|
130
|
+
return normalized
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveModelProfile(modelProfiles, modelName) {
|
|
134
|
+
if (!modelName || typeof modelName !== 'string') {
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const profile = modelProfiles[modelName]
|
|
139
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const apiKey = hasValue(profile.apiKey)
|
|
144
|
+
? String(profile.apiKey)
|
|
145
|
+
: hasValue(profile.apiKeyEnv) && process.env[String(profile.apiKeyEnv)] !== undefined
|
|
146
|
+
? String(process.env[String(profile.apiKeyEnv)])
|
|
147
|
+
: ''
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
name: modelName,
|
|
151
|
+
baseUrl: normalizeString(profile.baseUrl, ''),
|
|
152
|
+
apiKey,
|
|
153
|
+
apiKeyEnv: normalizeString(profile.apiKeyEnv, ''),
|
|
154
|
+
vision: normalizeBool(`${modelName}.vision`, profile.vision, false),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function resolveRoleModelName(config, role) {
|
|
159
|
+
const roleName = String(role ?? '').trim()
|
|
160
|
+
if (roleName !== '' && hasValue(config?.roleModels?.[roleName])) {
|
|
161
|
+
return String(config.roleModels[roleName])
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (roleName === 'visualReview') {
|
|
165
|
+
return String(config?.visualReviewModel ?? '')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return String(config?.piModel ?? '')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function resolveRoleModel(config, role) {
|
|
172
|
+
const model = resolveRoleModelName(config, role)
|
|
173
|
+
return {
|
|
174
|
+
model,
|
|
175
|
+
modelProfile: resolveModelProfile(config?.modelProfiles ?? {}, model),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function loadConfig(mode = 'once') {
|
|
180
|
+
const cwd = process.cwd()
|
|
181
|
+
const repoConfig = readRepoConfig(cwd)
|
|
182
|
+
const file = repoConfig.values
|
|
183
|
+
const bundledAdapterCommand = 'pi-harness adapter'
|
|
184
|
+
const modelProfiles = readObject('models', file.models, {})
|
|
185
|
+
const roleModels = normalizeRoleModels(file.roleModels)
|
|
186
|
+
const piModel = readString('PI_MODEL', file.piModel, '')
|
|
187
|
+
const visualReviewModel = readString('PI_VISUAL_REVIEW_MODEL', file.visualReviewModel, '')
|
|
188
|
+
const resolvedPiModel = resolveModelProfile(modelProfiles, piModel)
|
|
189
|
+
const resolvedVisualReviewModel = resolveModelProfile(modelProfiles, visualReviewModel)
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
cwd,
|
|
193
|
+
configFile: repoConfig.configFile,
|
|
194
|
+
mode: mode === 'run' ? 'run' : 'once',
|
|
195
|
+
transport: readString('PI_TRANSPORT', file.transport, 'adapter'),
|
|
196
|
+
agentName: readString('PI_AGENT_NAME', file.agentName, 'PI'),
|
|
197
|
+
adapterCommand: readString('PI_ADAPTER_COMMAND', file.adapterCommand, bundledAdapterCommand),
|
|
198
|
+
taskFile: resolveFromCwd(cwd, 'PI_TASK_FILE', file.taskFile, 'TODOS.md'),
|
|
199
|
+
instructionsFile: resolveInstructionsFile(cwd, 'PI_INSTRUCTIONS_FILE', file.instructionsFile, path.join(packageRoot, 'templates', 'DEVELOPER.md')),
|
|
200
|
+
developerInstructionsFile: resolveInstructionsFile(
|
|
201
|
+
cwd,
|
|
202
|
+
'PI_DEVELOPER_INSTRUCTIONS_FILE',
|
|
203
|
+
file.developerInstructionsFile,
|
|
204
|
+
hasValue(file.instructionsFile)
|
|
205
|
+
? String(file.instructionsFile)
|
|
206
|
+
: path.join(packageRoot, 'templates', 'DEVELOPER.md')
|
|
207
|
+
),
|
|
208
|
+
testerInstructionsFile: resolveInstructionsFile(
|
|
209
|
+
cwd,
|
|
210
|
+
'PI_TESTER_INSTRUCTIONS_FILE',
|
|
211
|
+
file.testerInstructionsFile,
|
|
212
|
+
hasValue(file.instructionsFile)
|
|
213
|
+
? String(file.instructionsFile)
|
|
214
|
+
: path.join(packageRoot, 'templates', 'TESTER.md')
|
|
215
|
+
),
|
|
216
|
+
logFile: resolveFromCwd(cwd, 'PI_LOG_FILE', file.logFile, 'pi.log'),
|
|
217
|
+
telemetryJsonl: resolveFromCwd(cwd, 'PI_TELEMETRY_JSONL', file.telemetryJsonl, 'pi_telemetry.jsonl'),
|
|
218
|
+
telemetryCsv: resolveFromCwd(cwd, 'PI_TELEMETRY_CSV', file.telemetryCsv, 'pi_telemetry.csv'),
|
|
219
|
+
stateFile: resolveFromCwd(cwd, 'PI_STATE_FILE', file.stateFile, '.pi-state.json'),
|
|
220
|
+
sessionFile: resolveFromCwd(cwd, 'PI_SESSION_FILE', file.sessionFile, '.pi-session-id'),
|
|
221
|
+
lastAgentOutputFile: resolveFromCwd(cwd, 'PI_LAST_AGENT_OUTPUT_FILE', file.lastAgentOutputFile, '.pi-last-output.txt'),
|
|
222
|
+
lastVerificationOutputFile: resolveFromCwd(cwd, 'PI_LAST_VERIFICATION_OUTPUT_FILE', file.lastVerificationOutputFile, '.pi-last-verification.txt'),
|
|
223
|
+
changedFilesFile: resolveFromCwd(cwd, 'PI_CHANGED_FILES_FILE', file.changedFilesFile, '.pi-changed-files.txt'),
|
|
224
|
+
piRuntimeDir: resolveFromCwd(cwd, 'PI_RUNTIME_DIR', file.piRuntimeDir, '.pi-runtime'),
|
|
225
|
+
piCli: readString('PI_CLI', file.piCli, 'pi'),
|
|
226
|
+
piModel,
|
|
227
|
+
piModelProfile: resolvedPiModel,
|
|
228
|
+
modelProfiles,
|
|
229
|
+
roleModels,
|
|
230
|
+
piTools: readString('PI_TOOLS', file.piTools, 'read,bash,edit,write,grep,find,ls'),
|
|
231
|
+
piThinking: readString('PI_THINKING', file.piThinking, ''),
|
|
232
|
+
piNoExtensions: readBool('PI_NO_EXTENSIONS', file.piNoExtensions, false),
|
|
233
|
+
piNoSkills: readBool('PI_NO_SKILLS', file.piNoSkills, false),
|
|
234
|
+
piNoPromptTemplates: readBool('PI_NO_PROMPT_TEMPLATES', file.piNoPromptTemplates, false),
|
|
235
|
+
piNoThemes: readBool('PI_NO_THEMES', file.piNoThemes, true),
|
|
236
|
+
streamTerminal: readBool('PI_STREAM_TERMINAL', file.streamTerminal, false),
|
|
237
|
+
loopRepeatThreshold: readInt('PI_LOOP_REPEAT_THRESHOLD', file.loopRepeatThreshold, 12),
|
|
238
|
+
samePathRepeatThreshold: readInt('PI_SAME_PATH_REPEAT_THRESHOLD', file.samePathRepeatThreshold, 8),
|
|
239
|
+
continueAfterSeconds: readInt('PI_CONTINUE_AFTER', file.continueAfterSeconds, 300),
|
|
240
|
+
continueMessage: readString('PI_CONTINUE_MESSAGE', file.continueMessage, 'continue'),
|
|
241
|
+
noEventTimeoutSeconds: readInt('PI_NO_EVENT_TIMEOUT', file.noEventTimeoutSeconds, 900),
|
|
242
|
+
toolContinueAfterSeconds: readInt('PI_TOOL_CONTINUE_AFTER', file.toolContinueAfterSeconds, 900),
|
|
243
|
+
toolNoEventTimeoutSeconds: readInt('PI_TOOL_NO_EVENT_TIMEOUT', file.toolNoEventTimeoutSeconds, 1800),
|
|
244
|
+
testCommand: readString('PI_TEST_CMD', file.testCommand, ''),
|
|
245
|
+
agentTimeoutSeconds: readInt('PI_AGENT_TIMEOUT', file.agentTimeoutSeconds, 3600),
|
|
246
|
+
verificationTimeoutSeconds: readInt('PI_VERIFICATION_TIMEOUT', file.verificationTimeoutSeconds, 300),
|
|
247
|
+
idleRetryLimit: readInt('PI_IDLE_RETRY_LIMIT', file.idleRetryLimit, 1),
|
|
248
|
+
noChangeRetryLimit: readInt('PI_NO_CHANGE_RETRY_LIMIT', file.noChangeRetryLimit, 1),
|
|
249
|
+
visualFeedbackFile: resolveFromCwd(
|
|
250
|
+
cwd,
|
|
251
|
+
'PI_VISUAL_FEEDBACK_FILE',
|
|
252
|
+
file.visualFeedbackFile,
|
|
253
|
+
'pi-output/visual-review/FEEDBACK.md'
|
|
254
|
+
),
|
|
255
|
+
testerFeedbackFile: resolveFromCwd(
|
|
256
|
+
cwd,
|
|
257
|
+
'PI_TESTER_FEEDBACK_FILE',
|
|
258
|
+
file.testerFeedbackFile,
|
|
259
|
+
'pi-output/tester-feedback/FEEDBACK.md'
|
|
260
|
+
),
|
|
261
|
+
testerFeedbackHistoryDir: resolveFromCwd(
|
|
262
|
+
cwd,
|
|
263
|
+
'PI_TESTER_FEEDBACK_HISTORY_DIR',
|
|
264
|
+
file.testerFeedbackHistoryDir,
|
|
265
|
+
'pi-output/tester-feedback/history'
|
|
266
|
+
),
|
|
267
|
+
visualReviewHistoryDir: resolveFromCwd(
|
|
268
|
+
cwd,
|
|
269
|
+
'PI_VISUAL_REVIEW_HISTORY_DIR',
|
|
270
|
+
file.visualReviewHistoryDir,
|
|
271
|
+
'pi-output/visual-review/history'
|
|
272
|
+
),
|
|
273
|
+
visualCaptureDir: resolveFromCwd(
|
|
274
|
+
cwd,
|
|
275
|
+
'PI_VISUAL_CAPTURE_DIR',
|
|
276
|
+
file.visualCaptureDir,
|
|
277
|
+
'pi-output/visual-capture'
|
|
278
|
+
),
|
|
279
|
+
visualCaptureCommand: readString('PI_VISUAL_CAPTURE_CMD', file.visualCaptureCommand, ''),
|
|
280
|
+
visualCaptureTimeoutSeconds: readInt('PI_VISUAL_CAPTURE_TIMEOUT', file.visualCaptureTimeoutSeconds, 300),
|
|
281
|
+
visualReviewEnabled: readBool('PI_VISUAL_REVIEW_ENABLED', file.visualReviewEnabled, false),
|
|
282
|
+
visualReviewEveryNSuccesses: readInt('PI_VISUAL_REVIEW_EVERY', file.visualReviewEveryNSuccesses, 5),
|
|
283
|
+
visualReviewModel,
|
|
284
|
+
visualReviewModelProfile: resolvedVisualReviewModel,
|
|
285
|
+
visualReviewCommand: readString(
|
|
286
|
+
'PI_VISUAL_REVIEW_COMMAND',
|
|
287
|
+
file.visualReviewCommand,
|
|
288
|
+
'pi-harness visual-review-worker'
|
|
289
|
+
),
|
|
290
|
+
visualReviewMaxImages: readInt('PI_VISUAL_REVIEW_MAX_IMAGES', file.visualReviewMaxImages, 8),
|
|
291
|
+
visualReviewTimeoutSeconds: readInt('PI_VISUAL_REVIEW_TIMEOUT', file.visualReviewTimeoutSeconds, 300),
|
|
292
|
+
maxIterations: readInt('PI_MAX_ITERS', file.maxIterations, 200),
|
|
293
|
+
sleepBetweenSeconds: readInt('PI_SLEEP_BETWEEN', file.sleepBetweenSeconds, 2),
|
|
294
|
+
reportLimit: readInt('PI_REPORT_LIMIT', file.reportLimit, 20),
|
|
295
|
+
}
|
|
296
|
+
}
|
package/src/pi-flow.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function shouldPersistLatestTesterFeedback(source) {
|
|
2
|
+
return String(source ?? '').trim() !== 'tester_commit_plan'
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function deriveWorkflowStatus({
|
|
6
|
+
developerStatus,
|
|
7
|
+
testerStatus,
|
|
8
|
+
verificationStatus,
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
developerStatus === 'success'
|
|
12
|
+
&& testerStatus === 'success'
|
|
13
|
+
&& (
|
|
14
|
+
verificationStatus === 'passed'
|
|
15
|
+
|| verificationStatus === 'skipped'
|
|
16
|
+
|| verificationStatus === 'not_run'
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
? 'success'
|
|
20
|
+
: developerStatus === 'complete'
|
|
21
|
+
? 'complete'
|
|
22
|
+
: developerStatus !== 'success'
|
|
23
|
+
? developerStatus
|
|
24
|
+
: testerStatus !== 'success'
|
|
25
|
+
? testerStatus
|
|
26
|
+
: verificationStatus
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function deriveFinalStatusWithVisualReview({
|
|
30
|
+
workflowStatus,
|
|
31
|
+
visualStatus,
|
|
32
|
+
}) {
|
|
33
|
+
if (workflowStatus !== 'success') {
|
|
34
|
+
return workflowStatus
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (visualStatus === 'failed' || visualStatus === 'timed_out' || visualStatus === 'blocked') {
|
|
38
|
+
return visualStatus
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return workflowStatus
|
|
42
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
function readTimeout(raw, fallback) {
|
|
2
|
+
const value = Number(raw)
|
|
3
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function resolveHeartbeatConfig(request = {}) {
|
|
7
|
+
const continueAfterSeconds = readTimeout(request.continueAfterSeconds, 300)
|
|
8
|
+
const noEventTimeoutSeconds = readTimeout(request.noEventTimeoutSeconds, 900)
|
|
9
|
+
const toolContinueAfterSeconds = readTimeout(request.toolContinueAfterSeconds, 900)
|
|
10
|
+
const toolNoEventTimeoutSeconds = readTimeout(request.toolNoEventTimeoutSeconds, 1800)
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
continueAfterSeconds,
|
|
14
|
+
noEventTimeoutSeconds,
|
|
15
|
+
toolContinueAfterSeconds,
|
|
16
|
+
toolNoEventTimeoutSeconds,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getHeartbeatThresholds({
|
|
21
|
+
continueAfterSeconds,
|
|
22
|
+
noEventTimeoutSeconds,
|
|
23
|
+
toolContinueAfterSeconds,
|
|
24
|
+
toolNoEventTimeoutSeconds,
|
|
25
|
+
activeToolName,
|
|
26
|
+
}) {
|
|
27
|
+
if (activeToolName) {
|
|
28
|
+
return {
|
|
29
|
+
continueAfterSeconds: toolContinueAfterSeconds,
|
|
30
|
+
noEventTimeoutSeconds: toolNoEventTimeoutSeconds,
|
|
31
|
+
timeoutClass: 'tool_idle',
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
continueAfterSeconds,
|
|
37
|
+
noEventTimeoutSeconds,
|
|
38
|
+
timeoutClass: 'agent_idle',
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getActiveToolInfo(activeToolName, activeToolStartedAt, now = Date.now()) {
|
|
43
|
+
if (!activeToolName || !Number.isFinite(activeToolStartedAt)) {
|
|
44
|
+
return {
|
|
45
|
+
activeToolName: '',
|
|
46
|
+
toolRuntimeSeconds: 0,
|
|
47
|
+
isToolActive: false,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
activeToolName,
|
|
53
|
+
toolRuntimeSeconds: Math.max(0, Math.floor((now - activeToolStartedAt) / 1000)),
|
|
54
|
+
isToolActive: true,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getHeartbeatDecision({
|
|
59
|
+
now = Date.now(),
|
|
60
|
+
agentStarted,
|
|
61
|
+
agentEnded,
|
|
62
|
+
heartbeatTimedOut,
|
|
63
|
+
childExited,
|
|
64
|
+
lastEventAt,
|
|
65
|
+
continueAttempted,
|
|
66
|
+
activeToolName = '',
|
|
67
|
+
activeToolStartedAt = 0,
|
|
68
|
+
continueAfterSeconds,
|
|
69
|
+
noEventTimeoutSeconds,
|
|
70
|
+
toolContinueAfterSeconds,
|
|
71
|
+
toolNoEventTimeoutSeconds,
|
|
72
|
+
}) {
|
|
73
|
+
if (!agentStarted || agentEnded || heartbeatTimedOut || childExited) {
|
|
74
|
+
return { action: 'none' }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const idleSeconds = Math.max(0, Math.floor((now - lastEventAt) / 1000))
|
|
78
|
+
const { activeToolName: resolvedToolName, toolRuntimeSeconds, isToolActive } = getActiveToolInfo(
|
|
79
|
+
activeToolName,
|
|
80
|
+
activeToolStartedAt,
|
|
81
|
+
now
|
|
82
|
+
)
|
|
83
|
+
const thresholds = getHeartbeatThresholds({
|
|
84
|
+
continueAfterSeconds,
|
|
85
|
+
noEventTimeoutSeconds,
|
|
86
|
+
toolContinueAfterSeconds,
|
|
87
|
+
toolNoEventTimeoutSeconds,
|
|
88
|
+
activeToolName: resolvedToolName,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (!continueAttempted && thresholds.continueAfterSeconds > 0 && idleSeconds > thresholds.continueAfterSeconds) {
|
|
92
|
+
return {
|
|
93
|
+
action: 'soft_continue',
|
|
94
|
+
idleSeconds,
|
|
95
|
+
...thresholds,
|
|
96
|
+
activeToolName: resolvedToolName,
|
|
97
|
+
toolRuntimeSeconds,
|
|
98
|
+
isToolActive,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (thresholds.noEventTimeoutSeconds > 0 && idleSeconds > thresholds.noEventTimeoutSeconds) {
|
|
103
|
+
return {
|
|
104
|
+
action: 'abort',
|
|
105
|
+
idleSeconds,
|
|
106
|
+
...thresholds,
|
|
107
|
+
activeToolName: resolvedToolName,
|
|
108
|
+
toolRuntimeSeconds,
|
|
109
|
+
isToolActive,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
action: 'none',
|
|
115
|
+
idleSeconds,
|
|
116
|
+
...thresholds,
|
|
117
|
+
activeToolName: resolvedToolName,
|
|
118
|
+
toolRuntimeSeconds,
|
|
119
|
+
isToolActive,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function formatHeartbeatReason({
|
|
124
|
+
timeoutClass,
|
|
125
|
+
noEventTimeoutSeconds,
|
|
126
|
+
activeToolName,
|
|
127
|
+
toolRuntimeSeconds,
|
|
128
|
+
}) {
|
|
129
|
+
const parts = [
|
|
130
|
+
`timeout_class=${timeoutClass}`,
|
|
131
|
+
`no_pi_events_for=${noEventTimeoutSeconds}s`,
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
if (activeToolName) {
|
|
135
|
+
parts.push(`active_tool=${activeToolName}`)
|
|
136
|
+
parts.push(`tool_runtime_seconds=${toolRuntimeSeconds}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return parts.join(' ')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatHeartbeatTimeoutMessage({
|
|
143
|
+
noEventTimeoutSeconds,
|
|
144
|
+
activeToolName,
|
|
145
|
+
toolRuntimeSeconds,
|
|
146
|
+
}) {
|
|
147
|
+
if (activeToolName) {
|
|
148
|
+
return `No PI RPC events were received for ${noEventTimeoutSeconds} seconds while tool "${activeToolName}" was running (runtime ${toolRuntimeSeconds}s).`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return `No PI RPC events were received for ${noEventTimeoutSeconds} seconds.`
|
|
152
|
+
}
|