@markjaquith/agency 3.5.1 → 3.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -331,6 +331,14 @@ prompt appears as a persisted user message. It records whether OpenCode submitte
331
331
  the prompt natively or submission followed a companion dispatch, and shows a
332
332
  bounded error with a manual recovery instruction when delivery is not observed.
333
333
 
334
+ For built-in OpenCode V2 launches, Agency submits through the selected CLI's
335
+ authenticated session API, then opens the TUI with `--session` and an empty
336
+ composer. V2's native `--prompt` only fills the composer. Agency sets session
337
+ environment before submission and passes that same environment into the TUI;
338
+ reconnecting the TUI does not replay the prompt. V1 keeps its native launch path.
339
+ See [OpenCode auto-start](docs/opencode-auto-start.md) for details and real
340
+ startup verification.
341
+
334
342
  Custom agents are direct argv commands, never shell snippets:
335
343
 
336
344
  ```json
@@ -397,8 +405,10 @@ resolve a materialized execution-unit
397
405
  checkout through `agency context`. A multi-phase
398
406
  task root has no single checkout, so launch from its phase directory when using
399
407
  plain OpenCode or Pi. Other checkout-local configuration is not composed.
400
- `--print-command` prints the exact cwd and argv plus non-secret environment keys
401
- without launching the agent.
408
+ `--print-command` prints the cwd, command template, and non-secret environment
409
+ keys without launching the agent. Built-in OpenCode auto launches also report a
410
+ `startup` note: V2 resolves the final `--session` argv during actual startup, so
411
+ print-only mode creates neither a session nor a prompt.
402
412
 
403
413
  ### Custom Chooser Command
404
414
 
@@ -0,0 +1,67 @@
1
+ # OpenCode auto-start
2
+
3
+ `agency work . --auto` submits the generated prompt and opens the interactive
4
+ OpenCode TUI. Plain `agency work .` remains promptless.
5
+
6
+ ## OpenCode V2
7
+
8
+ V2's native `opencode --prompt TEXT` populates the composer; it does not submit.
9
+ The managed Agency plugin's worker-identity hooks run after submission and are
10
+ not an auto-submit trigger.
11
+
12
+ For the built-in `opencode` and `opencode2` presets, Agency:
13
+
14
+ 1. Checks the selected executable's version.
15
+ 2. Creates a session at the canonical launch directory, or resolves the newest
16
+ root session in that exact directory for a continued launch.
17
+ 3. Sets session environment through `PUT /api/session/{id}/environment` before
18
+ admitting input. This is necessary when the service is already running.
19
+ 4. Admits one generated prompt with `resume: true`, letting OpenCode assign its ID.
20
+ 5. Replaces the launcher with `opencode --session ID`, without a composer prompt.
21
+
22
+ All API calls use `opencode api`, retaining that executable's service discovery
23
+ and authentication. No TUI keystrokes or plugin reload callbacks submit input.
24
+ Admission errors stop startup; they do not fall back to a populated composer or
25
+ blindly retry a possibly admitted request. Errors identify the API operation
26
+ without printing the environment payload. A stopped launch can leave a session
27
+ and a working Agency task for recovery.
28
+
29
+ The native exec helper explicitly passes Bun's current environment to `execve`.
30
+ Calling libc `execvp` directly loses Bun-side environment mutations. This matters
31
+ because the V2 TUI refreshes session environment from its own inherited snapshot
32
+ when it attaches or reconnects.
33
+
34
+ V1 retains its native `--prompt` path. Custom runner definitions remain responsible
35
+ for their own submission behavior. This implementation was exercised against
36
+ installed OpenCode V2.0.1; V1 compatibility has focused command regression coverage.
37
+
38
+ ## Verification
39
+
40
+ Focused regressions:
41
+
42
+ ```sh
43
+ bun test src/utils/exec.test.ts src/workbase/opencode-launch.test.ts src/commands/work.test.ts
44
+ ```
45
+
46
+ Real opt-in startup smoke (requires installed, authenticated V2 OpenCode and Git):
47
+
48
+ ```sh
49
+ bun run test:opencode-auto
50
+ ```
51
+
52
+ This creates an isolated fixture repository/workbase and launches the real
53
+ `agency work . --auto --agent opencode` under OpenCode's PTY API. It sends zero
54
+ keyboard bytes and requires:
55
+
56
+ - Exactly one submitted user message.
57
+ - A completed assistant message with a text part exactly
58
+ `AGENCY_AUTO_SMOKE_EXECUTED`, not that string inside tool input/output.
59
+ - Actual completed shell output containing nonempty Agency identity and an empty
60
+ `HERDR_ENV`, with no inherited supervisor pane identity.
61
+ - A clean fixture checkout after execution.
62
+
63
+ It closes only its own PTY and Git daemon. The fixture, `messages.json`, and
64
+ `evidence.json` remain at the printed temporary path for inspection.
65
+ `AGENCY_SMOKE_TMPDIR` overrides the fixture parent.
66
+ Set `AGENCY_SMOKE_EXECUTABLE=/absolute/path/to/agency` to exercise an installed
67
+ CLI instead of the development checkout; evidence records the resolved executable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.5.1",
3
+ "version": "3.5.2",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -32,6 +32,7 @@
32
32
  "fixtures/protocol/success.json",
33
33
  "fixtures/protocol/error.json",
34
34
  "README.md",
35
+ "docs/opencode-auto-start.md",
35
36
  "LICENSE"
36
37
  ],
37
38
  "type": "module",
@@ -92,6 +93,7 @@
92
93
  "benchmark:review": "bun scripts/benchmark-review.ts",
93
94
  "test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
94
95
  "test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
96
+ "test:opencode-auto": "bun scripts/smoke-opencode-auto.ts",
95
97
  "format": "oxfmt",
96
98
  "format:check": "oxfmt --check",
97
99
  "knip": "knip --production",
@@ -37,6 +37,7 @@ import {
37
37
  readValidationEvidence,
38
38
  } from "../workbase/execution-contract"
39
39
  import { ValidationFailedError } from "./validate"
40
+ import { prepareOpenCodeLaunch } from "../workbase/opencode-launch"
40
41
 
41
42
  export interface WorkOptions extends BaseCommandOptions {
42
43
  readonly directory?: string
@@ -386,12 +387,22 @@ export const work = (
386
387
  ...agentEnvironment(agent, variables),
387
388
  }
388
389
  if (writablePath) environment.AGENCY_WRITABLE_CHECKOUT = writablePath
390
+ const managedOpenCodeAuto =
391
+ options.auto &&
392
+ (agent === "opencode" || agent === "opencode2") &&
393
+ !config.agents?.[agent]
389
394
  if (options.printCommand) {
390
395
  log(
391
396
  JSON.stringify(
392
397
  {
393
398
  cwd: launchPath,
394
399
  argv: resolved.argv,
400
+ ...(managedOpenCodeAuto
401
+ ? {
402
+ startup:
403
+ "OpenCode V2: submit via API, then attach with --session; V1: use argv",
404
+ }
405
+ : {}),
395
406
  environment: printableEnvironment(environment),
396
407
  },
397
408
  null,
@@ -449,14 +460,17 @@ export const work = (
449
460
  )
450
461
  }
451
462
  }
463
+ const launchArgv = managedOpenCodeAuto
464
+ ? yield* prepareOpenCodeLaunch(resolved.argv, launchPath, environment)
465
+ : resolved.argv
452
466
  for (const [key, value] of Object.entries(environment)) {
453
467
  process.env[key] = value
454
468
  }
455
469
  verboseLog(
456
- `Launching command: ${formatCommand(resolved.argv)} (cwd: ${launchPath})`,
470
+ `Launching command: ${formatCommand(launchArgv)} (cwd: ${launchPath})`,
457
471
  )
458
472
  try {
459
- launch(cli, resolved.argv, launchPath, environment)
473
+ launch(cli, launchArgv, launchPath, environment)
460
474
  } finally {
461
475
  for (const key of Object.keys(environment)) {
462
476
  const previous = previousEnvironment[key]
package/src/utils/exec.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi"
2
2
 
3
3
  /**
4
- * Native exec implementation using Bun FFI to call POSIX execvp.
4
+ * PATH-resolving native exec using POSIX execve and an explicit environment.
5
5
  * This completely replaces the current process with the specified command.
6
6
  *
7
7
  * IMPORTANT: This function will never return if successful. The process
@@ -12,12 +12,18 @@ import { dlopen, FFIType, ptr } from "bun:ffi"
12
12
  * @throws Error if exec fails (e.g., command not found)
13
13
  */
14
14
  export function execvp(file: string, args: string[]): never {
15
- // Open libc to access execvp (platform-specific library paths)
15
+ const executable = Bun.which(file)
16
+ if (!executable) throw new Error(`Unable to find executable '${file}'`)
17
+ // Bun's process.env mutations are not reflected in libc's environ. Passing
18
+ // envp explicitly preserves launch identity and deletions across replacement.
19
+ const variables = Object.entries(process.env)
20
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
21
+ .map(([key, value]) => `${key}=${value}`)
16
22
  const libcPath =
17
23
  process.platform === "darwin" ? "/usr/lib/libSystem.B.dylib" : "libc.so.6"
18
24
  const libc = dlopen(libcPath, {
19
- execvp: {
20
- args: [FFIType.cstring, FFIType.ptr],
25
+ execve: {
26
+ args: [FFIType.cstring, FFIType.ptr, FFIType.ptr],
21
27
  returns: FFIType.int,
22
28
  },
23
29
  })
@@ -36,10 +42,19 @@ export function execvp(file: string, args: string[]): never {
36
42
  }
37
43
  // Null-terminate the pointer array
38
44
  ptrs[args.length] = 0n
45
+ const environmentStrings = variables.map((value) => Buffer.from(value + "\0"))
46
+ const environmentPointers = new BigUint64Array(environmentStrings.length + 1)
47
+ for (let i = 0; i < environmentStrings.length; i++) {
48
+ environmentPointers[i] = BigInt(ptr(environmentStrings[i]!))
49
+ }
39
50
 
40
51
  // Call execvp - this will replace the current process if successful
41
- const fileBuffer = Buffer.from(file + "\0")
42
- const result = libc.symbols.execvp(ptr(fileBuffer), ptr(ptrs))
52
+ const fileBuffer = Buffer.from(executable + "\0")
53
+ const result = libc.symbols.execve(
54
+ ptr(fileBuffer),
55
+ ptr(ptrs),
56
+ ptr(environmentPointers),
57
+ )
43
58
 
44
59
  // If we reach here, exec failed
45
60
  throw new Error(
@@ -0,0 +1,120 @@
1
+ import { Schema } from "@effect/schema"
2
+ import { Effect } from "effect"
3
+ import { FileSystemService } from "../services/FileSystemService"
4
+
5
+ const Session = Schema.Struct({
6
+ id: Schema.String.pipe(Schema.pattern(/^ses/)),
7
+ location: Schema.Struct({ directory: Schema.String }),
8
+ })
9
+ const Created = Schema.Struct({ data: Session })
10
+ const Listed = Schema.Struct({ data: Schema.Array(Session) })
11
+ const Admitted = Schema.Struct({
12
+ data: Schema.Struct({
13
+ sessionID: Schema.String,
14
+ type: Schema.Literal("user"),
15
+ }),
16
+ })
17
+
18
+ // V2's TUI --prompt only seeds the composer. Submit through the same CLI's
19
+ // authenticated service connection, then attach without a prompt. Reloading or
20
+ // reconnecting the TUI cannot replay the launch input. Only built-in Agency
21
+ // auto-command templates call this helper: [cli, [--continue], --prompt, text].
22
+ export const prepareOpenCodeLaunch = (
23
+ argv: readonly string[],
24
+ cwd: string,
25
+ env: Record<string, string>,
26
+ ) =>
27
+ Effect.gen(function* () {
28
+ const fs = yield* FileSystemService
29
+ cwd = yield* fs.realPath(cwd)
30
+ const cli = argv[0]!
31
+ const run = (args: readonly string[]) =>
32
+ Effect.gen(function* () {
33
+ const result = yield* fs
34
+ .runCommand([cli, ...args], {
35
+ cwd,
36
+ env,
37
+ captureOutput: true,
38
+ timeoutMs: 60_000,
39
+ })
40
+ .pipe(
41
+ Effect.mapError(
42
+ () =>
43
+ new Error(
44
+ `OpenCode auto-start command failed: ${args.slice(0, args.indexOf("--data") < 0 ? args.length : args.indexOf("--data")).join(" ")}`,
45
+ ),
46
+ ),
47
+ )
48
+ if (result.exitCode !== 0) {
49
+ return yield* Effect.fail(
50
+ new Error(
51
+ `OpenCode auto-start failed (${args.slice(0, 3).join(" ")}): ${result.stderr}`,
52
+ ),
53
+ )
54
+ }
55
+ return result.stdout
56
+ })
57
+ const version = (yield* run(["--version"])).trim()
58
+ if (/^(?:opencode\s+v?)?1\./.test(version)) return [...argv]
59
+ if (!/^(?:opencode\s+v?)?2\./.test(version)) {
60
+ return yield* Effect.fail(
61
+ new Error(`Unsupported OpenCode version for auto-start: ${version}`),
62
+ )
63
+ }
64
+ const api = (method: string, path: string, body?: unknown) =>
65
+ run([
66
+ "api",
67
+ method,
68
+ path,
69
+ ...(body === undefined ? [] : ["--data", JSON.stringify(body)]),
70
+ ])
71
+ let session: typeof Session.Type | undefined
72
+ if (argv.includes("--continue")) {
73
+ const query = new URLSearchParams({
74
+ directory: cwd,
75
+ parentID: "null",
76
+ order: "desc",
77
+ limit: "1",
78
+ })
79
+ const listed = yield* Schema.decodeUnknown(Schema.parseJson(Listed))(
80
+ yield* api("get", `/api/session?${query}`),
81
+ )
82
+ session = listed.data[0]
83
+ }
84
+ if (!session) {
85
+ const created = yield* Schema.decodeUnknown(Schema.parseJson(Created))(
86
+ yield* api("post", "/api/session", { location: { directory: cwd } }),
87
+ )
88
+ session = created.data
89
+ }
90
+ if ((yield* fs.realPath(session.location.directory)) !== cwd) {
91
+ return yield* Effect.fail(
92
+ new Error(
93
+ `OpenCode returned a session outside the launch directory: ${session.id}`,
94
+ ),
95
+ )
96
+ }
97
+ // API client environment is not session environment on the shared server.
98
+ // Replace it before admission so the first shell/tool sees the caller's
99
+ // Agency identity and (for a real Herdr launch) the correct pane identity.
100
+ const variables = Object.fromEntries(
101
+ Object.entries({ ...process.env, ...env }).filter(
102
+ (entry): entry is [string, string] => entry[1] !== undefined,
103
+ ),
104
+ )
105
+ yield* api("put", `/api/session/${session.id}/environment`, { variables })
106
+ const submitted = yield* Schema.decodeUnknown(Schema.parseJson(Admitted))(
107
+ yield* api("post", `/api/session/${session.id}/prompt`, {
108
+ text: argv.at(-1)!,
109
+ resume: true,
110
+ }),
111
+ )
112
+ if (submitted.data.sessionID !== session.id) {
113
+ return yield* Effect.fail(
114
+ new Error(
115
+ `OpenCode did not confirm the launch prompt for ${session.id}`,
116
+ ),
117
+ )
118
+ }
119
+ return [cli, "--session", session.id]
120
+ })