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.
@@ -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