@markjaquith/agency 3.5.0 → 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
@@ -326,8 +326,18 @@ which open a fresh TUI session so the generated continuation prompt cannot be
326
326
  routed to an unrelated prior session. By default Agency opens the agent without
327
327
  a prompt. `--auto` uses its autonomous command and sends the generated task,
328
328
  phase, or epic prompt. OpenCode V2 receives a launch-only environment marker;
329
- Agency's managed TUI companion waits for the populated composer and dispatches
330
- its native submit command once.
329
+ Agency's managed TUI companion retries the native submit command until the exact
330
+ prompt appears as a persisted user message. It records whether OpenCode submitted
331
+ the prompt natively or submission followed a companion dispatch, and shows a
332
+ bounded error with a manual recovery instruction when delivery is not observed.
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.
331
341
 
332
342
  Custom agents are direct argv commands, never shell snippets:
333
343
 
@@ -395,8 +405,10 @@ resolve a materialized execution-unit
395
405
  checkout through `agency context`. A multi-phase
396
406
  task root has no single checkout, so launch from its phase directory when using
397
407
  plain OpenCode or Pi. Other checkout-local configuration is not composed.
398
- `--print-command` prints the exact cwd and argv plus non-secret environment keys
399
- 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.
400
412
 
401
413
  ### Custom Chooser Command
402
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.0",
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
+ })
@@ -35,16 +35,37 @@ export default plugin
35
35
  `
36
36
 
37
37
  const tuiBody = `const autosubmitTimeoutMs = 10_000
38
- const autosubmitRetryMs = 25
38
+ const autosubmitRetryMs = 100
39
+
40
+ type AutosubmitObservation = {
41
+ event: "marker" | "dispatch" | "submitted" | "timeout" | "error"
42
+ detail?: string
43
+ dispatches: number
44
+ }
45
+
46
+ type AutosubmitMessage = {
47
+ type?: string
48
+ text?: string
49
+ info?: { role?: string }
50
+ role?: string
51
+ parts?: readonly { type?: string; text?: string }[]
52
+ }
39
53
 
40
54
  type AutosubmitContext = {
41
55
  keymap: {
42
56
  commands(): readonly { id?: string }[]
43
57
  dispatch(id: string): unknown
44
58
  }
45
- renderer: { currentFocusedEditor?: unknown }
59
+ data?: {
60
+ session?: {
61
+ message?: {
62
+ sync?(sessionID: string): Promise<unknown>
63
+ list?(sessionID: string): readonly AutosubmitMessage[] | undefined
64
+ }
65
+ }
66
+ }
46
67
  ui: {
47
- router: { current(): { type: string } }
68
+ router: { current(): { type: string; sessionID?: string } }
48
69
  toast: {
49
70
  show(input: {
50
71
  variant: "error"
@@ -57,7 +78,11 @@ type AutosubmitContext = {
57
78
  }
58
79
 
59
80
  export const createAgencyAutosubmit = (
60
- options: { timeoutMs?: number; retryMs?: number } = {},
81
+ options: {
82
+ timeoutMs?: number
83
+ retryMs?: number
84
+ observe?: (observation: AutosubmitObservation) => void
85
+ } = {},
61
86
  ) => {
62
87
  let started = false
63
88
 
@@ -66,49 +91,111 @@ export const createAgencyAutosubmit = (
66
91
  if (process.env.AGENCY_TUI_AUTOSUBMIT !== "1") return () => {}
67
92
  if (!process.env.AGENCY_PROMPT) return () => {}
68
93
  started = true
69
- delete process.env.AGENCY_TUI_AUTOSUBMIT
70
94
 
71
95
  const timeoutMs = options.timeoutMs ?? autosubmitTimeoutMs
72
96
  const retryMs = options.retryMs ?? autosubmitRetryMs
73
97
  const deadline = Date.now() + timeoutMs
98
+ const prompt = process.env.AGENCY_PROMPT
74
99
  let timer: ReturnType<typeof setTimeout> | undefined
75
100
  let stopped = false
101
+ let dispatches = 0
102
+ let lastRoute = "unknown"
103
+
104
+ const observe = (event: AutosubmitObservation["event"], detail?: string) => {
105
+ const observation = { event, detail, dispatches }
106
+ options.observe?.(observation)
107
+ if (
108
+ !options.observe &&
109
+ (event !== "dispatch" || dispatches === 1 || dispatches % 10 === 0)
110
+ ) {
111
+ const suffix = detail ? ": " + detail : ""
112
+ console.info("[agency.tui] autosubmit " + event + suffix)
113
+ }
114
+ }
76
115
 
77
116
  const stop = () => {
78
117
  stopped = true
79
118
  if (timer) clearTimeout(timer)
80
119
  }
81
120
 
82
- const attempt = () => {
83
- if (stopped) return
84
- if (context.ui.router.current().type !== "home") {
85
- stop()
86
- return
121
+ const submitted = async (sessionID: string) => {
122
+ const messages = context.data?.session?.message
123
+ if (!messages?.list) return false
124
+ try {
125
+ await messages.sync?.(sessionID)
126
+ } catch (error) {
127
+ observe("error", "message sync failed: " + String(error))
128
+ return false
87
129
  }
130
+ return (messages.list(sessionID) ?? []).some((message) => {
131
+ const role = message.type === "user" ? "user" : message.info?.role ?? message.role
132
+ const text =
133
+ message.text ??
134
+ (message.parts ?? [])
135
+ .filter((part) => part.type === "text")
136
+ .map((part) => part.text ?? "")
137
+ .join("")
138
+ return role === "user" && text === prompt
139
+ })
140
+ }
88
141
 
89
- const submitReady = context.keymap
90
- .commands()
91
- .some((command) => command.id === "prompt.submit")
92
- if (context.renderer.currentFocusedEditor && submitReady) {
93
- stop()
94
- context.keymap.dispatch("prompt.submit")
95
- return
142
+ const finish = (event: "submitted" | "timeout", detail: string) => {
143
+ stop()
144
+ delete process.env.AGENCY_TUI_AUTOSUBMIT
145
+ observe(event, detail)
146
+ }
147
+
148
+ const attempt = async () => {
149
+ if (stopped) return
150
+ const route = context.ui.router.current()
151
+ lastRoute = route.type
152
+ if (route.type === "session" && route.sessionID) {
153
+ if (await submitted(route.sessionID)) {
154
+ finish(
155
+ "submitted",
156
+ dispatches === 0
157
+ ? "native OpenCode submission observed"
158
+ : "submitted message observed after companion dispatch",
159
+ )
160
+ return
161
+ }
96
162
  }
97
163
 
164
+ if (stopped) return
98
165
  if (Date.now() >= deadline) {
99
- stop()
166
+ finish(
167
+ "timeout",
168
+ "route=" + lastRoute + ", dispatches=" + String(dispatches),
169
+ )
100
170
  context.ui.toast.show({
101
171
  variant: "error",
102
172
  title: "Agency launch",
103
- message: "The task prompt is ready but could not be submitted automatically.",
173
+ message:
174
+ "The task prompt was not observed as submitted after " +
175
+ String(dispatches) +
176
+ " automatic attempt(s). Press Enter to submit it manually.",
104
177
  duration: 8_000,
105
178
  })
106
179
  return
107
180
  }
108
- timer = setTimeout(attempt, retryMs)
181
+
182
+ const submitReady = context.keymap
183
+ .commands()
184
+ .some((command) => command.id === "prompt.submit")
185
+ if (route.type === "home" && submitReady) {
186
+ dispatches += 1
187
+ observe("dispatch", "prompt.submit")
188
+ try {
189
+ await context.keymap.dispatch("prompt.submit")
190
+ } catch (error) {
191
+ observe("error", "dispatch failed: " + String(error))
192
+ }
193
+ }
194
+ if (!stopped) timer = setTimeout(() => void attempt(), retryMs)
109
195
  }
110
196
 
111
- attempt()
197
+ observe("marker", "autonomous prompt detected")
198
+ void attempt()
112
199
  return stop
113
200
  }
114
201
  }