dlab-cli 0.1.2__py3-none-any.whl
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.
- dlab/__init__.py +6 -0
- dlab/cli.py +1075 -0
- dlab/config.py +190 -0
- dlab/create_dpack.py +1096 -0
- dlab/create_dpack_wizard.py +1471 -0
- dlab/create_parallel_agent_wizard.py +582 -0
- dlab/data/__init__.py +0 -0
- dlab/data/models.json +1793 -0
- dlab/docker.py +592 -0
- dlab/js/__init__.py +0 -0
- dlab/js/parallel-agents.ts +418 -0
- dlab/local.py +269 -0
- dlab/model_fallback.py +360 -0
- dlab/parallel_tool.py +18 -0
- dlab/session.py +389 -0
- dlab/timeline.py +684 -0
- dlab/tui/__init__.py +9 -0
- dlab/tui/app.py +664 -0
- dlab/tui/log_watcher.py +208 -0
- dlab/tui/models.py +438 -0
- dlab/tui/widgets/__init__.py +18 -0
- dlab/tui/widgets/agent_list.py +170 -0
- dlab/tui/widgets/artifacts_pane.py +618 -0
- dlab/tui/widgets/log_view.py +505 -0
- dlab/tui/widgets/search_popup.py +151 -0
- dlab/tui/widgets/status_bar.py +106 -0
- dlab_cli-0.1.2.dist-info/METADATA +237 -0
- dlab_cli-0.1.2.dist-info/RECORD +32 -0
- dlab_cli-0.1.2.dist-info/WHEEL +5 -0
- dlab_cli-0.1.2.dist-info/entry_points.txt +2 -0
- dlab_cli-0.1.2.dist-info/licenses/LICENSE +201 -0
- dlab_cli-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync, appendFileSync, readdirSync, copyFileSync, cpSync, statSync } from "fs"
|
|
3
|
+
// Use require() for CJS package — ESM imports break under Bun's strict interop
|
|
4
|
+
const yaml = require("yaml")
|
|
5
|
+
import { join, basename } from "path"
|
|
6
|
+
|
|
7
|
+
// Helper: Copy directory contents excluding certain paths
|
|
8
|
+
function copyWorkDir(src: string, dest: string, exclude: string[]) {
|
|
9
|
+
mkdirSync(dest, { recursive: true })
|
|
10
|
+
for (const item of readdirSync(src)) {
|
|
11
|
+
if (exclude.includes(item)) continue
|
|
12
|
+
const srcPath = join(src, item)
|
|
13
|
+
const destPath = join(dest, item)
|
|
14
|
+
const stat = statSync(srcPath)
|
|
15
|
+
if (stat.isDirectory()) {
|
|
16
|
+
cpSync(srcPath, destPath, { recursive: true })
|
|
17
|
+
} else {
|
|
18
|
+
copyFileSync(srcPath, destPath)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Helper: Parse YAML frontmatter from agent markdown
|
|
24
|
+
function parseAgentFrontmatter(agentPath: string): Record<string, any> {
|
|
25
|
+
const content = readFileSync(agentPath, "utf-8")
|
|
26
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
|
27
|
+
if (!match) return {}
|
|
28
|
+
return yaml.parse(match[1])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Helper: Build permission config from agent frontmatter tools
|
|
32
|
+
// Some permissions support nested objects (read, edit, glob, grep, list, bash, task, external_directory, lsp)
|
|
33
|
+
// Others only accept simple strings (todoread, todowrite, question, webfetch, websearch, codesearch, doom_loop)
|
|
34
|
+
function buildPermissionsFromFrontmatter(agentPath: string): Record<string, any> {
|
|
35
|
+
const frontmatter = parseAgentFrontmatter(agentPath)
|
|
36
|
+
const tools = frontmatter.tools || {}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
// Permissions that support nested objects - use { "*": "action" } format
|
|
40
|
+
"read": { "*": "allow" },
|
|
41
|
+
"glob": { "*": "allow" },
|
|
42
|
+
"grep": { "*": "allow" },
|
|
43
|
+
"list": { "*": "allow" },
|
|
44
|
+
"lsp": { "*": "allow" },
|
|
45
|
+
"external_directory": { "*": "allow" },
|
|
46
|
+
|
|
47
|
+
// Write/execute tools - based on frontmatter
|
|
48
|
+
"edit": { "*": tools["edit"] === true ? "allow" : "deny" },
|
|
49
|
+
"bash": { "*": tools["bash"] === true ? "allow" : "deny" },
|
|
50
|
+
|
|
51
|
+
// Subagent spawning - deny
|
|
52
|
+
"task": { "*": "deny" },
|
|
53
|
+
|
|
54
|
+
// Simple string permissions
|
|
55
|
+
"todoread": "allow",
|
|
56
|
+
"todowrite": tools["edit"] === true ? "allow" : "deny",
|
|
57
|
+
"question": "deny",
|
|
58
|
+
"webfetch": "allow",
|
|
59
|
+
"websearch": "allow",
|
|
60
|
+
"codesearch": "allow",
|
|
61
|
+
"doom_loop": "allow",
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Helper: Setup minimal consolidator environment (read-only, no custom tools)
|
|
66
|
+
function setupConsolidator(runDir: string, srcOpencode: string, summarizerPrompt: string) {
|
|
67
|
+
const destOpencode = join(runDir, ".opencode")
|
|
68
|
+
|
|
69
|
+
// Create directories
|
|
70
|
+
mkdirSync(join(destOpencode, "agents"), { recursive: true })
|
|
71
|
+
|
|
72
|
+
// Create minimal consolidator agent from summarizer_prompt
|
|
73
|
+
const consolidatorAgent = `---
|
|
74
|
+
description: Consolidator agent for comparing parallel agent results
|
|
75
|
+
mode: primary
|
|
76
|
+
tools:
|
|
77
|
+
read: true
|
|
78
|
+
edit: false
|
|
79
|
+
bash: false
|
|
80
|
+
parallel-agents: false
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
${summarizerPrompt}
|
|
84
|
+
`
|
|
85
|
+
writeFileSync(join(destOpencode, "agents", "consolidator.md"), consolidatorAgent)
|
|
86
|
+
|
|
87
|
+
// Copy and modify opencode.json
|
|
88
|
+
const opconfig = JSON.parse(readFileSync(join(srcOpencode, "opencode.json"), "utf-8"))
|
|
89
|
+
opconfig.default_agent = "consolidator"
|
|
90
|
+
|
|
91
|
+
// Hardcoded read-only permissions for consolidator
|
|
92
|
+
opconfig.permission = {
|
|
93
|
+
// Read/discovery tools - always allowed
|
|
94
|
+
"read": { "*": "allow" },
|
|
95
|
+
"glob": { "*": "allow" },
|
|
96
|
+
"grep": { "*": "allow" },
|
|
97
|
+
"list": { "*": "allow" },
|
|
98
|
+
"lsp": { "*": "allow" },
|
|
99
|
+
"external_directory": { "*": "allow" },
|
|
100
|
+
|
|
101
|
+
// Write/execute tools - always denied
|
|
102
|
+
"edit": { "*": "deny" },
|
|
103
|
+
"bash": { "*": "deny" },
|
|
104
|
+
"task": { "*": "deny" },
|
|
105
|
+
|
|
106
|
+
// Simple permissions
|
|
107
|
+
"todoread": "allow",
|
|
108
|
+
"todowrite": "deny",
|
|
109
|
+
"question": "deny",
|
|
110
|
+
"webfetch": "allow",
|
|
111
|
+
"websearch": "allow",
|
|
112
|
+
"codesearch": "allow",
|
|
113
|
+
"doom_loop": "allow",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
writeFileSync(join(destOpencode, "opencode.json"), JSON.stringify(opconfig, null, 2))
|
|
117
|
+
|
|
118
|
+
// NOTE: Do NOT copy tools/ directory - consolidator has no custom tools
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Helper: Copy only allowed tools based on agent frontmatter
|
|
122
|
+
function copyAllowedTools(srcOpencode: string, destOpencode: string, agentName: string) {
|
|
123
|
+
const agentPath = join(srcOpencode, "agents", `${agentName}.md`)
|
|
124
|
+
const frontmatter = parseAgentFrontmatter(agentPath)
|
|
125
|
+
const tools = frontmatter.tools || {}
|
|
126
|
+
|
|
127
|
+
// Create dest directories
|
|
128
|
+
mkdirSync(join(destOpencode, "agents"), { recursive: true })
|
|
129
|
+
mkdirSync(join(destOpencode, "tools"), { recursive: true })
|
|
130
|
+
|
|
131
|
+
// Copy opencode.json and package.json
|
|
132
|
+
copyFileSync(join(srcOpencode, "opencode.json"), join(destOpencode, "opencode.json"))
|
|
133
|
+
if (existsSync(join(srcOpencode, "package.json"))) {
|
|
134
|
+
copyFileSync(join(srcOpencode, "package.json"), join(destOpencode, "package.json"))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// NOTE: Do NOT copy parallel_agents/ to subagents - only orchestrator needs it
|
|
138
|
+
|
|
139
|
+
// Copy the target agent definition, converting subagent to primary mode
|
|
140
|
+
// (OpenCode doesn't allow subagents to be the default_agent)
|
|
141
|
+
let agentContent = readFileSync(agentPath, "utf-8")
|
|
142
|
+
agentContent = agentContent.replace(/^(---.*)mode:\s*subagent(.*---)$/ms, "$1mode: primary$2")
|
|
143
|
+
writeFileSync(join(destOpencode, "agents", `${agentName}.md`), agentContent)
|
|
144
|
+
|
|
145
|
+
// Copy only tools that are explicitly allowed (true in frontmatter)
|
|
146
|
+
const srcTools = join(srcOpencode, "tools")
|
|
147
|
+
if (existsSync(srcTools)) {
|
|
148
|
+
for (const file of readdirSync(srcTools)) {
|
|
149
|
+
const toolName = file.replace(/\.(ts|js)$/, "")
|
|
150
|
+
// NEVER copy parallel-agents to subagents
|
|
151
|
+
if (toolName === "parallel-agents") continue
|
|
152
|
+
// Only copy if explicitly allowed
|
|
153
|
+
if (tools[toolName] === true) {
|
|
154
|
+
copyFileSync(join(srcTools, file), join(destOpencode, "tools", file))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Copy skills declared in frontmatter
|
|
160
|
+
const skills = frontmatter.skills || []
|
|
161
|
+
if (skills.length > 0) {
|
|
162
|
+
const srcSkills = join(srcOpencode, "skills")
|
|
163
|
+
if (existsSync(srcSkills)) {
|
|
164
|
+
for (const skillName of skills) {
|
|
165
|
+
const srcSkill = join(srcSkills, skillName)
|
|
166
|
+
if (existsSync(srcSkill)) {
|
|
167
|
+
const destSkill = join(destOpencode, "skills", skillName)
|
|
168
|
+
cpSync(srcSkill, destSkill, { recursive: true })
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default tool({
|
|
176
|
+
description: "Spawn parallel subagents for a configured agent type",
|
|
177
|
+
|
|
178
|
+
args: {
|
|
179
|
+
agent: tool.schema.string().describe("Agent name (must have config in parallel_agents/)"),
|
|
180
|
+
prompts: tool.schema.array(tool.schema.string()).describe("Array of prompts, one per instance"),
|
|
181
|
+
models: tool.schema.array(tool.schema.string()).optional()
|
|
182
|
+
.describe("Optional: model per instance (defaults to config default_model)"),
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async execute(args, ctx) {
|
|
186
|
+
const cwd = process.cwd()
|
|
187
|
+
const timestamp = Date.now()
|
|
188
|
+
const runDir = join(cwd, "parallel", `run-${timestamp}`)
|
|
189
|
+
const logsDir = join(cwd, "_opencode_logs", `${args.agent}-parallel-run-${timestamp}`)
|
|
190
|
+
|
|
191
|
+
// Load parallel agent config
|
|
192
|
+
const configPath = join(cwd, ".opencode", "parallel_agents", `${args.agent}.yaml`)
|
|
193
|
+
if (!existsSync(configPath)) {
|
|
194
|
+
throw new Error(`No parallel config found: ${configPath}`)
|
|
195
|
+
}
|
|
196
|
+
const config = yaml.parse(readFileSync(configPath, "utf-8"))
|
|
197
|
+
|
|
198
|
+
// Validate instance count
|
|
199
|
+
const numInstances = args.prompts.length
|
|
200
|
+
if (numInstances < 1) {
|
|
201
|
+
throw new Error("At least 1 prompt required")
|
|
202
|
+
}
|
|
203
|
+
if (config.max_instances && numInstances > config.max_instances) {
|
|
204
|
+
throw new Error(`Max ${config.max_instances} instances allowed, got ${numInstances}`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
mkdirSync(runDir, { recursive: true })
|
|
208
|
+
mkdirSync(logsDir, { recursive: true })
|
|
209
|
+
|
|
210
|
+
// Spawn instances
|
|
211
|
+
const processes: Array<{
|
|
212
|
+
proc: ReturnType<typeof Bun.spawn>
|
|
213
|
+
dir: string
|
|
214
|
+
logFile: string
|
|
215
|
+
model: string
|
|
216
|
+
prompt: string
|
|
217
|
+
}> = []
|
|
218
|
+
|
|
219
|
+
for (let i = 0; i < numInstances; i++) {
|
|
220
|
+
const instanceDir = join(runDir, `instance-${i + 1}`)
|
|
221
|
+
mkdirSync(instanceDir, { recursive: true })
|
|
222
|
+
|
|
223
|
+
// HACK: Initialize git repo to stop OpenCode config traversal to parent directories.
|
|
224
|
+
// This is an EVIL HACK. OpenCode traverses up looking for .opencode/ configs and merges
|
|
225
|
+
// them, causing subagents to see parent agents and get confused about their role.
|
|
226
|
+
// Creating a .git dir makes OpenCode think this is a project root, stopping traversal.
|
|
227
|
+
// TODO: Replace with proper solution when OpenCode supports disabling parent traversal.
|
|
228
|
+
Bun.spawnSync(["git", "init"], { cwd: instanceDir, stdout: "ignore", stderr: "ignore" })
|
|
229
|
+
|
|
230
|
+
// Copy entire work directory to instance (excluding special dirs)
|
|
231
|
+
// This gives subagents access to all work done by the orchestrator so far
|
|
232
|
+
copyWorkDir(cwd, instanceDir, [".opencode", "_opencode_logs", "parallel", ".state.json", ".git"])
|
|
233
|
+
|
|
234
|
+
// Copy .opencode with ONLY allowed tools
|
|
235
|
+
copyAllowedTools(join(cwd, ".opencode"), join(instanceDir, ".opencode"), args.agent)
|
|
236
|
+
|
|
237
|
+
// Set default agent AND build granular permissions from frontmatter
|
|
238
|
+
const opconfigPath = join(instanceDir, ".opencode", "opencode.json")
|
|
239
|
+
const opconfig = JSON.parse(readFileSync(opconfigPath, "utf-8"))
|
|
240
|
+
opconfig.default_agent = args.agent
|
|
241
|
+
|
|
242
|
+
// Build permissions based on agent's tools frontmatter
|
|
243
|
+
const agentPath = join(cwd, ".opencode", "agents", `${args.agent}.md`)
|
|
244
|
+
opconfig.permission = buildPermissionsFromFrontmatter(agentPath)
|
|
245
|
+
|
|
246
|
+
writeFileSync(opconfigPath, JSON.stringify(opconfig, null, 2))
|
|
247
|
+
|
|
248
|
+
// Build prompt with subagent context and suffix
|
|
249
|
+
// Model priority: 1) explicit models array, 2) instance_models from config, 3) default_model
|
|
250
|
+
const instanceModels = config.instance_models || []
|
|
251
|
+
const model = args.models?.[i] || instanceModels[i] || config.default_model
|
|
252
|
+
|
|
253
|
+
// Automatic subagent context (always added to prevent hangs and enforce workspace boundaries)
|
|
254
|
+
const subagentContext = `IMPORTANT CONTEXT: You are running as a parallel subagent in non-interactive mode.
|
|
255
|
+
|
|
256
|
+
YOUR WORKING DIRECTORY: ${instanceDir}
|
|
257
|
+
|
|
258
|
+
CRITICAL OUTPUT RULES:
|
|
259
|
+
- Write ALL output files to the ROOT of your working directory
|
|
260
|
+
- Do NOT create subdirectories - write directly to: ${instanceDir}/
|
|
261
|
+
- Required outputs:
|
|
262
|
+
- summary.md -> ${instanceDir}/summary.md
|
|
263
|
+
- cleaned_data.parquet -> ${instanceDir}/cleaned_data.parquet (if applicable)
|
|
264
|
+
- You may READ files from absolute paths if mentioned in your prompt
|
|
265
|
+
- You must NEVER WRITE or EDIT files outside your working directory
|
|
266
|
+
- NEVER ask for permission or confirmation - all operations are pre-approved or pre-denied
|
|
267
|
+
- If an operation fails, report the error and continue with alternatives
|
|
268
|
+
|
|
269
|
+
`
|
|
270
|
+
const fullPrompt = subagentContext + args.prompts[i] + "\n\n" + (config.subagent_suffix_prompt || "")
|
|
271
|
+
|
|
272
|
+
// Spawn - log file starts with JSON directly (no header)
|
|
273
|
+
const logFile = join(logsDir, `instance-${i + 1}.log`)
|
|
274
|
+
|
|
275
|
+
const proc = Bun.spawn(["opencode", "run", "--format", "json", "--log-level", "DEBUG", "--model", model, fullPrompt], {
|
|
276
|
+
cwd: instanceDir,
|
|
277
|
+
stdout: "pipe",
|
|
278
|
+
stderr: "pipe",
|
|
279
|
+
env: process.env, // Inherit PYTHONPATH and other env vars
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// Stream to logs (async)
|
|
283
|
+
;(async () => {
|
|
284
|
+
const reader = proc.stdout.getReader()
|
|
285
|
+
while (true) {
|
|
286
|
+
const { done, value } = await reader.read()
|
|
287
|
+
if (done) break
|
|
288
|
+
appendFileSync(logFile, new TextDecoder().decode(value))
|
|
289
|
+
}
|
|
290
|
+
})()
|
|
291
|
+
;(async () => {
|
|
292
|
+
const reader = proc.stderr.getReader()
|
|
293
|
+
while (true) {
|
|
294
|
+
const { done, value } = await reader.read()
|
|
295
|
+
if (done) break
|
|
296
|
+
appendFileSync(logFile, "[STDERR] " + new TextDecoder().decode(value))
|
|
297
|
+
}
|
|
298
|
+
})()
|
|
299
|
+
|
|
300
|
+
processes.push({ proc, dir: instanceDir, logFile, model, prompt: args.prompts[i] })
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Wait for all
|
|
304
|
+
const results: Array<{
|
|
305
|
+
dir: string
|
|
306
|
+
summaryPath: string
|
|
307
|
+
exitCode: number | null
|
|
308
|
+
}> = []
|
|
309
|
+
|
|
310
|
+
for (const p of processes) {
|
|
311
|
+
await p.proc.exited
|
|
312
|
+
results.push({
|
|
313
|
+
dir: p.dir,
|
|
314
|
+
summaryPath: join(p.dir, "summary.md"),
|
|
315
|
+
exitCode: p.proc.exitCode,
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Run consolidator if >= 3 instances completed
|
|
320
|
+
// For n=2, the orchestrator can read both summaries directly
|
|
321
|
+
let consolidatedSummary = ""
|
|
322
|
+
if (numInstances >= 3 && config.summarizer_prompt) {
|
|
323
|
+
// Use ABSOLUTE paths so consolidator doesn't need to search
|
|
324
|
+
const summaryPaths = results
|
|
325
|
+
.filter(r => existsSync(r.summaryPath))
|
|
326
|
+
.map(r => r.summaryPath) // Already absolute paths
|
|
327
|
+
|
|
328
|
+
const consolidatedSummaryPath = join(runDir, "consolidated_summary.md")
|
|
329
|
+
|
|
330
|
+
// Build consolidator prompt with absolute paths and explicit output location
|
|
331
|
+
const consolidatorContext = `IMPORTANT CONTEXT: You are running as a consolidator subagent in non-interactive mode.
|
|
332
|
+
|
|
333
|
+
READ ONLY THESE EXACT FILES (do NOT search or glob):
|
|
334
|
+
${summaryPaths.map((p: string) => `- ${p}`).join("\n")}
|
|
335
|
+
|
|
336
|
+
WRITE YOUR OUTPUT TO: ${consolidatedSummaryPath}
|
|
337
|
+
|
|
338
|
+
RULES:
|
|
339
|
+
- NEVER ask for permission or confirmation - all operations are pre-approved or pre-denied
|
|
340
|
+
- NEVER use glob or search for files - read the exact paths listed above
|
|
341
|
+
- If an operation fails, report the error and continue with alternatives
|
|
342
|
+
|
|
343
|
+
`
|
|
344
|
+
const consolidatorPrompt = consolidatorContext + config.summarizer_prompt
|
|
345
|
+
.replace("{summary_paths}", summaryPaths.map((p: string) => `- ${p}`).join("\n"))
|
|
346
|
+
|
|
347
|
+
const consolidatorModel = config.summarizer_model || config.default_model
|
|
348
|
+
|
|
349
|
+
// HACK: Initialize git repo to stop OpenCode config traversal (same as instances)
|
|
350
|
+
// Without this, OpenCode traverses up and merges parent's .opencode/, running as orchestrator
|
|
351
|
+
Bun.spawnSync(["git", "init"], { cwd: runDir, stdout: "ignore", stderr: "ignore" })
|
|
352
|
+
|
|
353
|
+
// Setup minimal consolidator environment (read-only, no custom tools)
|
|
354
|
+
setupConsolidator(runDir, join(cwd, ".opencode"), config.summarizer_prompt)
|
|
355
|
+
|
|
356
|
+
const consLogFile = join(logsDir, "consolidator.log")
|
|
357
|
+
|
|
358
|
+
const consProc = Bun.spawn(["opencode", "run", "--format", "json", "--log-level", "DEBUG", "--model", consolidatorModel, consolidatorPrompt], {
|
|
359
|
+
cwd: runDir,
|
|
360
|
+
stdout: "pipe",
|
|
361
|
+
stderr: "pipe",
|
|
362
|
+
env: process.env, // Inherit PYTHONPATH and other env vars
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// Stream stdout to log file (don't use as summary - it's JSON logs)
|
|
366
|
+
const reader = consProc.stdout.getReader()
|
|
367
|
+
while (true) {
|
|
368
|
+
const { done, value } = await reader.read()
|
|
369
|
+
if (done) break
|
|
370
|
+
appendFileSync(consLogFile, new TextDecoder().decode(value))
|
|
371
|
+
}
|
|
372
|
+
await consProc.exited
|
|
373
|
+
|
|
374
|
+
// Read the consolidated summary from file (not stdout)
|
|
375
|
+
if (existsSync(consolidatedSummaryPath)) {
|
|
376
|
+
consolidatedSummary = readFileSync(consolidatedSummaryPath, "utf-8")
|
|
377
|
+
} else {
|
|
378
|
+
consolidatedSummary = "[Consolidator did not produce a summary file]"
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Build final output
|
|
383
|
+
let finalOutput = "## Parallel Agent Results\n\n"
|
|
384
|
+
finalOutput += `Ran ${numInstances} instances of agent: ${args.agent}\n\n`
|
|
385
|
+
|
|
386
|
+
for (const r of results) {
|
|
387
|
+
finalOutput += `### ${basename(r.dir)}\n`
|
|
388
|
+
finalOutput += `- Exit code: ${r.exitCode}\n`
|
|
389
|
+
if (existsSync(r.summaryPath)) {
|
|
390
|
+
finalOutput += `- Summary: ${r.summaryPath}\n`
|
|
391
|
+
}
|
|
392
|
+
finalOutput += "\n"
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (consolidatedSummary) {
|
|
396
|
+
finalOutput += "## Consolidated Summary\n\n"
|
|
397
|
+
finalOutput += consolidatedSummary + "\n\n"
|
|
398
|
+
finalOutput += "---\n\n"
|
|
399
|
+
finalOutput += "If you need more details, individual summaries are at:\n"
|
|
400
|
+
for (const r of results) {
|
|
401
|
+
if (existsSync(r.summaryPath)) {
|
|
402
|
+
finalOutput += `- ${r.summaryPath}\n`
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
// No consolidator (n <= 2) - tell orchestrator where to find summaries
|
|
407
|
+
finalOutput += "## Summary Locations\n\n"
|
|
408
|
+
finalOutput += "Read these summary files to compare results:\n"
|
|
409
|
+
for (const r of results) {
|
|
410
|
+
if (existsSync(r.summaryPath)) {
|
|
411
|
+
finalOutput += `- ${r.summaryPath}\n`
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return finalOutput
|
|
417
|
+
},
|
|
418
|
+
})
|
dlab/local.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local execution backend for running opencode without Docker.
|
|
3
|
+
|
|
4
|
+
Used when --no-sandboxing is passed or Docker is not available.
|
|
5
|
+
Instead of replicating the Docker environment, this copies the docker/
|
|
6
|
+
directory into the work dir as _docker/ and prepends instructions to the
|
|
7
|
+
prompt telling the agent to set up its own environment.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_docker_available() -> bool:
|
|
18
|
+
"""
|
|
19
|
+
Check if Docker CLI exists and the daemon is running.
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
-------
|
|
23
|
+
bool
|
|
24
|
+
True if docker is installed and the daemon responds.
|
|
25
|
+
"""
|
|
26
|
+
if shutil.which("docker") is None:
|
|
27
|
+
return False
|
|
28
|
+
try:
|
|
29
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
30
|
+
["docker", "info"],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
timeout=10,
|
|
33
|
+
)
|
|
34
|
+
return result.returncode == 0
|
|
35
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_package_manager(config_dir: str) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Detect package manager from docker/ contents.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
config_dir : str
|
|
46
|
+
Path to decision-pack directory.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
str
|
|
51
|
+
One of "conda", "pixi", "pip".
|
|
52
|
+
"""
|
|
53
|
+
docker_dir: Path = Path(config_dir) / "docker"
|
|
54
|
+
if (docker_dir / "environment.yml").exists():
|
|
55
|
+
return "conda"
|
|
56
|
+
if (docker_dir / "pixi.toml").exists():
|
|
57
|
+
return "pixi"
|
|
58
|
+
return "pip"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def copy_docker_dir(config_dir: str, work_dir: str) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Copy the decision-pack's docker/ directory into the work dir as _docker/.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
config_dir : str
|
|
68
|
+
Path to decision-pack directory.
|
|
69
|
+
work_dir : str
|
|
70
|
+
Session work directory.
|
|
71
|
+
"""
|
|
72
|
+
docker_src: Path = Path(config_dir) / "docker"
|
|
73
|
+
docker_dst: Path = Path(work_dir) / "_docker"
|
|
74
|
+
if docker_src.exists():
|
|
75
|
+
if docker_dst.exists():
|
|
76
|
+
shutil.rmtree(docker_dst)
|
|
77
|
+
shutil.copytree(str(docker_src), str(docker_dst))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_local_prompt(prompt: str, config: dict[str, Any]) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Prepend system instructions for unsandboxed local execution.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
prompt : str
|
|
87
|
+
Original user prompt.
|
|
88
|
+
config : dict[str, Any]
|
|
89
|
+
decision-pack configuration.
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
str
|
|
94
|
+
Prompt with system instructions prepended.
|
|
95
|
+
"""
|
|
96
|
+
pkg_mgr: str = config.get(
|
|
97
|
+
"package_manager",
|
|
98
|
+
detect_package_manager(config["config_dir"]),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
work_dir_abs: str = str(Path(config["config_dir"]).resolve().parent)
|
|
102
|
+
# Use the actual work dir if available, fall back to config dir parent
|
|
103
|
+
# (the caller should pass work_dir in config if needed)
|
|
104
|
+
|
|
105
|
+
system_instructions: str = (
|
|
106
|
+
"IMPORTANT --- SYSTEM INSTRUCTIONS (NO-SANDBOXING MODE):\n\n"
|
|
107
|
+
"You're running locally, NOT inside a Docker container. The decision-pack "
|
|
108
|
+
f"was designed for a Docker environment with python managed by {pkg_mgr}.\n\n"
|
|
109
|
+
"## Step 1: Set up the environment\n\n"
|
|
110
|
+
"Read `_docker/Dockerfile` carefully. It shows:\n"
|
|
111
|
+
"- Which base image and package manager was intended\n"
|
|
112
|
+
"- Which dependency file to install from (requirements.txt, environment.yml, pixi.toml)\n"
|
|
113
|
+
"- Which directories from _docker/ would have been COPY'd into the container "
|
|
114
|
+
"(e.g., custom Python libraries like `COPY <SUB_DIR>/ /opt/<SUB_DIR>/`)\n\n"
|
|
115
|
+
f"Use `{pkg_mgr}` to create and install a local environment from the dependency "
|
|
116
|
+
"file in `_docker/`. For example:\n"
|
|
117
|
+
"- pip: `python -m venv .venv && .venv/bin/pip install -r _docker/requirements.txt`\n"
|
|
118
|
+
"- conda: `conda create -p .conda-env --yes && conda env update -p .conda-env -f _docker/environment.yml`\n"
|
|
119
|
+
"- uv: `uv venv .venv && uv pip install --python .venv/bin/python -r _docker/requirements.txt`\n"
|
|
120
|
+
"- pixi: `cp _docker/pixi.toml . && pixi install`\n\n"
|
|
121
|
+
"If the Dockerfile COPY'd any Python libraries from _docker/ into the container, "
|
|
122
|
+
"you need to make those importable by setting the ABSOLUTE path to _docker/ in "
|
|
123
|
+
"PYTHONPATH when running scripts:\n"
|
|
124
|
+
"`PYTHONPATH=/absolute/path/to/workdir/_docker:$PYTHONPATH python my_script.py`\n\n"
|
|
125
|
+
"## Step 2: Verify the environment works\n\n"
|
|
126
|
+
"After setting up, run a small test script that imports the key packages from the "
|
|
127
|
+
"dependency file to confirm everything is installed correctly. Fix any import errors "
|
|
128
|
+
"before proceeding.\n\n"
|
|
129
|
+
"## Step 3: Read the hooks\n\n"
|
|
130
|
+
"Read `_hooks/` — these are scripts that would have run inside the container before "
|
|
131
|
+
"and after the agent session. Adapt and run pre-run hooks if they make sense locally "
|
|
132
|
+
"(e.g., skip Modal deployment if not applicable, but run data setup scripts).\n\n"
|
|
133
|
+
"## Step 4: Subagent environment instructions\n\n"
|
|
134
|
+
"When you call parallel-agents or task subagents, you MUST include in each prompt:\n"
|
|
135
|
+
"- The absolute path to the correct python binary (e.g., `/absolute/path/to/.venv/bin/python`)\n"
|
|
136
|
+
"- The correct PYTHONPATH value (e.g., `PYTHONPATH=/absolute/path/to/workdir/_docker:$PYTHONPATH`)\n"
|
|
137
|
+
"- Instructions to use this python for all script execution\n\n"
|
|
138
|
+
"Subagents do NOT inherit your environment setup. They start fresh and need explicit "
|
|
139
|
+
"instructions on which python to use.\n\n"
|
|
140
|
+
"---\n\n"
|
|
141
|
+
"Now follows the User's request:\n"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return f"{system_instructions}{prompt}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_local_env(env_file: str | None = None) -> dict[str, str]:
|
|
148
|
+
"""
|
|
149
|
+
Build environment variables dict for local execution.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
env_file : str | None
|
|
154
|
+
Optional .env file to parse and include.
|
|
155
|
+
|
|
156
|
+
Returns
|
|
157
|
+
-------
|
|
158
|
+
dict[str, str]
|
|
159
|
+
Environment variables.
|
|
160
|
+
"""
|
|
161
|
+
env: dict[str, str] = dict(os.environ)
|
|
162
|
+
|
|
163
|
+
if env_file:
|
|
164
|
+
for line in Path(env_file).read_text().splitlines():
|
|
165
|
+
line = line.strip()
|
|
166
|
+
if not line or line.startswith("#"):
|
|
167
|
+
continue
|
|
168
|
+
key, _, value = line.partition("=")
|
|
169
|
+
value = value.strip().strip("'\"")
|
|
170
|
+
env[key.strip()] = value
|
|
171
|
+
|
|
172
|
+
return env
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def run_local_command(
|
|
176
|
+
command: list[str],
|
|
177
|
+
work_dir: str,
|
|
178
|
+
env: dict[str, str],
|
|
179
|
+
timeout: int | None = None,
|
|
180
|
+
) -> tuple[int, str, str]:
|
|
181
|
+
"""
|
|
182
|
+
Run a command locally in the work directory.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
command : list[str]
|
|
187
|
+
Command and arguments.
|
|
188
|
+
work_dir : str
|
|
189
|
+
Working directory.
|
|
190
|
+
env : dict[str, str]
|
|
191
|
+
Environment variables.
|
|
192
|
+
timeout : int | None
|
|
193
|
+
Timeout in seconds.
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
tuple[int, str, str]
|
|
198
|
+
(exit_code, stdout, stderr).
|
|
199
|
+
"""
|
|
200
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
201
|
+
command,
|
|
202
|
+
capture_output=True,
|
|
203
|
+
text=True,
|
|
204
|
+
cwd=work_dir,
|
|
205
|
+
env=env,
|
|
206
|
+
timeout=timeout,
|
|
207
|
+
)
|
|
208
|
+
return result.returncode, result.stdout, result.stderr
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def run_opencode_local(
|
|
212
|
+
work_dir: str,
|
|
213
|
+
prompt: str,
|
|
214
|
+
model: str,
|
|
215
|
+
env: dict[str, str],
|
|
216
|
+
timeout: int | None = None,
|
|
217
|
+
log_prefix: str = "main",
|
|
218
|
+
) -> tuple[int, str, str]:
|
|
219
|
+
"""
|
|
220
|
+
Run opencode locally in the work directory.
|
|
221
|
+
|
|
222
|
+
Parameters
|
|
223
|
+
----------
|
|
224
|
+
work_dir : str
|
|
225
|
+
Session work directory.
|
|
226
|
+
prompt : str
|
|
227
|
+
Prompt text (already includes system instructions).
|
|
228
|
+
model : str
|
|
229
|
+
LLM model identifier.
|
|
230
|
+
env : dict[str, str]
|
|
231
|
+
Environment variables.
|
|
232
|
+
timeout : int | None
|
|
233
|
+
Timeout in seconds.
|
|
234
|
+
log_prefix : str
|
|
235
|
+
Log file prefix.
|
|
236
|
+
|
|
237
|
+
Returns
|
|
238
|
+
-------
|
|
239
|
+
tuple[int, str, str]
|
|
240
|
+
(exit_code, stdout, stderr).
|
|
241
|
+
"""
|
|
242
|
+
work_path: Path = Path(work_dir)
|
|
243
|
+
logs_dir: Path = work_path / "_opencode_logs"
|
|
244
|
+
|
|
245
|
+
# Write prompt to file (avoids shell quoting issues)
|
|
246
|
+
prompt_file: Path = work_path / ".prompt.txt"
|
|
247
|
+
prompt_file.write_text(prompt)
|
|
248
|
+
|
|
249
|
+
# Build runner script
|
|
250
|
+
log_path: str = str(logs_dir / f"{log_prefix}.log")
|
|
251
|
+
runner_script: str = f'''#!/bin/bash
|
|
252
|
+
set -o pipefail
|
|
253
|
+
prompt=$(cat "{prompt_file}")
|
|
254
|
+
opencode run --format json --log-level DEBUG --model "{model}" "$prompt" 2>&1 | tee "{log_path}"
|
|
255
|
+
'''
|
|
256
|
+
runner_file: Path = work_path / ".run_opencode.sh"
|
|
257
|
+
runner_file.write_text(runner_script)
|
|
258
|
+
runner_file.chmod(0o755)
|
|
259
|
+
|
|
260
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
261
|
+
["bash", str(runner_file)],
|
|
262
|
+
capture_output=True,
|
|
263
|
+
text=True,
|
|
264
|
+
cwd=work_dir,
|
|
265
|
+
env=env,
|
|
266
|
+
timeout=timeout,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return result.returncode, result.stdout, result.stderr
|