Target /api/v1, and stop revoking a thread on the way out

bac494248859 · AtlantisPleb · · parent 3d716e9fce8f

Target /api/v1, and stop revoking a thread on the way out

The API is `/api/v1` now (openagents.com #212, eb8ea8f). The CLI targets it
through one constant rather than the twenty-odd literals it had, which is what
made the rename a seventy-file change in the first place (#214).

The server answers `/api/v3` through a transparent alias, so this build reaches
a deployment that has not shipped the rename yet and an older CLI reaches one
that has. Neither side has to move first.

"This thread was revoked. Start a new session to open another." A session
revoked its own thread as it exited. Two consequences nobody asked for:
`--resume` was impossible by construction, because every past session's thread
was already terminal before it could be resumed; and anything that touched a
thread again — a delegated child still running on the lent grant, a second
window, a resumed session — was told it had been revoked.

A thread is durable. That is the whole point of the thread model, and ending a
session is not a reason to destroy it. `DELETE /threads/{id}` stays for a
reader who means it, and the sentence it produces no longer names a lifecycle
the reader never asked about. The server drops the open-thread cap in the same
change, because a cap sized against sessions that cleaned up after themselves
would now count every session the account had ever run.

715 tests pass, including two new ones: that a session leaves its thread alone,
and that an explicit revoke still revokes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/README.md
  • modified packages/openagents-cli/contracts/repositories-v1.json
  • modified packages/openagents-cli/skills/openagents-cli/SKILL.md
  • modified packages/openagents-cli/skills/superdelegate/SKILL.md
  • modified packages/openagents-cli/src/api-contract.ts
  • modified packages/openagents-cli/src/api-passthrough.ts
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-backends.ts
  • modified packages/openagents-cli/src/coder-export.ts
  • modified packages/openagents-cli/src/coder-markdown.ts
  • modified packages/openagents-cli/src/coder-merge.ts
  • modified packages/openagents-cli/src/coder-ollama.ts
  • modified packages/openagents-cli/src/coder-plugins.ts
  • modified packages/openagents-cli/src/coder-resume.ts
  • modified packages/openagents-cli/src/coder-self-harness.ts
  • modified packages/openagents-cli/src/coder-shell.ts
  • modified packages/openagents-cli/src/coder-skills.ts
  • modified packages/openagents-cli/src/coder-thread.ts
  • modified packages/openagents-cli/src/coder-tools.ts
  • modified packages/openagents-cli/src/coder-transcript.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/src/coder-zen.ts
  • added packages/openagents-cli/src/constants.ts
  • modified packages/openagents-cli/src/device-client.ts
  • modified packages/openagents-cli/src/fleet-client.ts
  • modified packages/openagents-cli/src/forum-client.ts
  • modified packages/openagents-cli/src/index.ts
  • modified packages/openagents-cli/src/issue-client.ts
  • modified packages/openagents-cli/src/repository-client.ts
  • modified packages/openagents-cli/src/trace-command.ts
  • modified packages/openagents-cli/src/tracker-request.ts
  • modified packages/openagents-cli/test/api-command.test.ts
  • modified packages/openagents-cli/test/api-passthrough.test.ts
  • modified packages/openagents-cli/test/coder-backends.test.ts
  • modified packages/openagents-cli/test/coder-dev-server.test.ts
  • modified packages/openagents-cli/test/coder-export.test.ts
  • modified packages/openagents-cli/test/coder-ollama.test.ts
  • modified packages/openagents-cli/test/coder-paste.test.ts
  • modified packages/openagents-cli/test/coder-plugin-list-dir.test.ts
  • modified packages/openagents-cli/test/coder-plugin-mounts.test.ts
  • modified packages/openagents-cli/test/coder-resume.test.ts
  • modified packages/openagents-cli/test/coder-self-harness.test.ts
  • modified packages/openagents-cli/test/coder-shell.test.ts
  • modified packages/openagents-cli/test/coder-skills.test.ts
  • modified packages/openagents-cli/test/coder-steer.test.ts
  • modified packages/openagents-cli/test/coder-tools-openagents.test.ts
  • modified packages/openagents-cli/test/coder-transcript.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts
  • modified packages/openagents-cli/test/coder-zen.test.ts
  • modified packages/openagents-cli/test/deploy-command.test.ts
  • modified packages/openagents-cli/test/device-client.test.ts
  • modified packages/openagents-cli/test/fleet-client.test.ts
  • modified packages/openagents-cli/test/issue-client.test.ts
  • modified packages/openagents-cli/test/issue-command.test.ts
  • modified packages/openagents-cli/test/repository-client.test.ts
  • modified packages/openagents-cli/test/trace-command.test.ts

Diff

56 files changed, +443 -293

packages/openagents-cli/README.md modified +3 -3

@@ -153,8 +153,8 @@ openagents api -X PATCH -f state=closed repos/OWNER/REPO/issues/41

153 153
openagents api repos/OWNER/REPO/issues | jq '.[].title'
154 154
```
155 155
156
A path without a leading slash resolves under the API base `/api/v3/`, so
157
`repos/OWNER/REPO/issues` and `/api/v3/repos/OWNER/REPO/issues` name the same
156
A path without a leading slash resolves under the API base `/api/v1/`, so
157
`repos/OWNER/REPO/issues` and `/api/v1/repos/OWNER/REPO/issues` name the same
158 158
route. An absolute path must start with `/api/`, and a complete URL must match
159 159
the API origin you selected. The CLI refuses a path that would leave that
160 160
origin.

@@ -359,7 +359,7 @@ Projects are repository-scoped, so every project command takes the same

359 359
## Deploy the fleet (operators)
360 360
361 361
The `deploy` commands drive the operator-only fleet promotion API
362
(`/api/v3/admin/forge/targets`). They require an API token holding the
362
(`/api/v1/admin/forge/targets`). They require an API token holding the
363 363
privileged `deployments:promote` scope, and the server additionally checks
364 364
that the account is a current operator on every request. `forge:write` cannot
365 365
promote, and neither can a Git credential or a browser session.
packages/openagents-cli/contracts/repositories-v1.json modified +15 -15

@@ -9,24 +9,24 @@

9 9
  "idempotency": {
10 10
    "header": "Idempotency-Key",
11 11
    "required_for": [
12
      "POST /api/v3/user/repos",
13
      "POST /api/v3/orgs/{org}/repos",
14
      "POST /api/v3/user/repos/imports",
15
      "POST /api/v3/orgs/{org}/repos/imports"
12
      "POST /api/v1/user/repos",
13
      "POST /api/v1/orgs/{org}/repos",
14
      "POST /api/v1/user/repos/imports",
15
      "POST /api/v1/orgs/{org}/repos/imports"
16 16
    ]
17 17
  },
18 18
  "endpoints": {
19
    "get_authenticated_user": "GET /api/v3/user",
20
    "create_user_repository": "POST /api/v3/user/repos",
21
    "create_organization_repository": "POST /api/v3/orgs/{org}/repos",
22
    "import_user_repository": "POST /api/v3/user/repos/imports",
23
    "import_organization_repository": "POST /api/v3/orgs/{org}/repos/imports",
24
    "list_repositories": "GET /api/v3/user/repos",
25
    "get_repository": "GET /api/v3/repos/{owner}/{repo}",
26
    "delete_repository": "DELETE /api/v3/repos/{owner}/{repo}",
27
    "get_repository_import": "GET /api/v3/repository-imports/{id}",
28
    "create_device_authorization": "POST /api/v3/device/authorizations",
29
    "poll_device_authorization": "POST /api/v3/device/authorizations/token"
19
    "get_authenticated_user": "GET /api/v1/user",
20
    "create_user_repository": "POST /api/v1/user/repos",
21
    "create_organization_repository": "POST /api/v1/orgs/{org}/repos",
22
    "import_user_repository": "POST /api/v1/user/repos/imports",
23
    "import_organization_repository": "POST /api/v1/orgs/{org}/repos/imports",
24
    "list_repositories": "GET /api/v1/user/repos",
25
    "get_repository": "GET /api/v1/repos/{owner}/{repo}",
26
    "delete_repository": "DELETE /api/v1/repos/{owner}/{repo}",
27
    "get_repository_import": "GET /api/v1/repository-imports/{id}",
28
    "create_device_authorization": "POST /api/v1/device/authorizations",
29
    "poll_device_authorization": "POST /api/v1/device/authorizations/token"
30 30
  },
31 31
  "authenticated_user": {
32 32
    "required": ["id", "login", "namespaces", "token_expires_at"],
packages/openagents-cli/skills/openagents-cli/SKILL.md modified +1 -1

@@ -88,7 +88,7 @@ issue.

88 88
89 89
`openagents api <path>` sends an authenticated request to any API route and
90 90
writes the body as JSON. A path without a leading slash resolves under
91
`/api/v3/`. Use it when no named command covers what you need — several routes
91
`/api/v1/`. Use it when no named command covers what you need — several routes
92 92
have no command of their own.
93 93
94 94
## Two cautions
packages/openagents-cli/skills/superdelegate/SKILL.md modified +5 -5

@@ -8,11 +8,11 @@ auto: true

8 8
9 9
## Which lane
10 10
11
| The work | Use |
12
| --- | --- |
13
| One command, one answer | `shell` |
14
| The same thing to N independent parts | `delegate` |
15
| A backlog of issues | the method below |
11
| The work                              | Use              |
12
| ------------------------------------- | ---------------- |
13
| One command, one answer               | `shell`          |
14
| The same thing to N independent parts | `delegate`       |
15
| A backlog of issues                   | the method below |
16 16
17 17
Run a single command yourself. Starting an agent to run `pwd` costs minutes and
18 18
real money and hands back an answer nobody watched being produced.
packages/openagents-cli/src/api-contract.ts modified +1 -1

@@ -3,7 +3,7 @@ import { Option, Schema } from "effect";

3 3
export const REPOSITORY_CONTRACT_NAME = "openagents.repositories.v1";
4 4
export const REPOSITORY_CONTRACT_VERSION = 1;
5 5
export const REPOSITORY_CONTRACT_SHA256 =
6
  "5be86539258c38d5249887ff2628680b1f90c93909d6e83e86a836d0250ce79f";
6
  "96a71ee0a3d19eb77ffa0721cc76876e1e514bb821368c469f0a6d91f21c3870";
7 7
8 8
export const AuthenticatedNamespace = Schema.Struct({
9 9
  id: Schema.Union([Schema.Number, Schema.String]),
packages/openagents-cli/src/api-passthrough.ts modified +4 -3

@@ -1,13 +1,14 @@

1 1
import { Effect, Option } from "effect";
2 2
3 3
import type { HttpMethod } from "./api-transport.js";
4
import { API_BASE_PATH } from "./constants.js";
4 5
import { InputError } from "./errors.js";
5 6
6 7
/**
7 8
 * Every OpenAgents API route lives under this prefix. A passthrough path
8 9
 * without a leading slash resolves under it.
9 10
 */
10
export const API_BASE_PATH = "/api/v3/";
11
export { API_BASE_PATH };
11 12
12 13
/**
13 14
 * The methods `openagents api` accepts. The transport supports more, but a

@@ -30,8 +31,8 @@ const leavesOrigin = (candidate: string, origin: string) =>

30 31
/**
31 32
 * Turns a caller-supplied path into an origin-relative request path.
32 33
 *
33
 * A path without a leading slash resolves under `/api/v3/`, so
34
 * `repos/OWNER/REPO/issues` and `/api/v3/repos/OWNER/REPO/issues` name the same
34
 * A path without a leading slash resolves under `/api/v1/`, so
35
 * `repos/OWNER/REPO/issues` and `/api/v1/repos/OWNER/REPO/issues` name the same
35 36
 * route. An absolute path must stay under `/api/`, because this command talks
36 37
 * to the API rather than to the website. A complete URL is accepted only when
37 38
 * it matches the configured origin.
packages/openagents-cli/src/cli.ts modified +16 -12

@@ -740,10 +740,7 @@ const loginResumeFlag = Flag.boolean("resume").pipe(

740 740
 * and a hint that omits it sends the reader around a loop that never signs
741 741
 * them in.
742 742
 */
743
const loginCommandFor = (endpoint: {
744
  readonly origin: string;
745
  readonly profile: string;
746
}): string =>
743
const loginCommandFor = (endpoint: { readonly origin: string; readonly profile: string }): string =>
747 744
  endpoint.profile === "production"
748 745
    ? "openagents auth login"
749 746
    : endpoint.profile === "custom"

@@ -1537,7 +1534,7 @@ const coderReasoningFlag = Flag.choice("reasoning", [

1537 1534
);
1538 1535
// `--model` can name an `ollama:<model>` local model or a chat API backend.
1539 1536
// For a chat API backend a thread's grant still pins its own model and
1540
// `POST /api/v3/threads` publishes no model parameter, so naming one cannot
1537
// `POST /api/v1/threads` publishes no model parameter, so naming one cannot
1541 1538
// change which model answers. For `ollama:<model>` the local Ollama server is
1542 1539
// used directly and the named model is the one that runs.
1543 1540
const coderModelFlag = Flag.string("model").pipe(

@@ -1965,7 +1962,9 @@ const coderCommand = Command.make(

1965 1962
      // coder without asking for one.
1966 1963
      const localModel =
1967 1964
        local && named === undefined && !offline && !resume
1968
          ? yield* Effect.promise(() => discoverOllamaModel(process.env["OLLAMA_HOST"] || undefined))
1965
          ? yield* Effect.promise(() =>
1966
              discoverOllamaModel(process.env["OLLAMA_HOST"] || undefined),
1967
            )
1969 1968
          : undefined;
1970 1969
1971 1970
      if (local && named === undefined && localModel === undefined && !offline && !resume) {

@@ -1980,7 +1979,8 @@ const coderCommand = Command.make(

1980 1979
      // opencode already holds here. It is how a session runs on Ox Alpha,
1981 1980
      // which is free there and which no deployment serves.
1982 1981
      const wantsZen = named !== undefined && /^opencode:/.test(named);
1983
      const zenAsked = wantsZen && named !== undefined ? named.slice("opencode:".length) : undefined;
1982
      const zenAsked =
1983
        wantsZen && named !== undefined ? named.slice("opencode:".length) : undefined;
1984 1984
1985 1985
      const wantsOllama = named === undefined ? localModel !== undefined : isOllamaModelFlag(named);
1986 1986
      const zenKey = wantsZen ? zenCredential() : undefined;

@@ -2260,7 +2260,7 @@ const coderCommand = Command.make(

2260 2260
      if (resumed !== undefined) session.restore(resumed.entries);
2261 2261
2262 2262
      // The thread lane writes its transcript to the server as the turn loop
2263
      // runs — `POST /api/v3/threads/{id}/events`, on the account token that
2263
      // runs — `POST /api/v1/threads/{id}/events`, on the account token that
2264 2264
      // opened the thread. The server copy is the only durable copy; the
2265 2265
      // offline, Ollama, and stand-in lanes keep no record and attach nothing.
2266 2266
      // A failed post never reaches the turn loop: the writer queues, retries,

@@ -2376,7 +2376,6 @@ const coderCommand = Command.make(

2376 2376
        );
2377 2377
      }
2378 2378
2379
2380 2379
      // With --resume the positional argument named the thread, not a prompt.
2381 2380
      const oneShot = resume ? undefined : Option.getOrUndefined(prompt);
2382 2381
      const interactive = terminal.interactive && !plain && !flags.json && oneShot === undefined;

@@ -2407,8 +2406,13 @@ const coderCommand = Command.make(

2407 2406
          // Flush before revoking: revoking makes the thread terminal, and a
2408 2407
          // terminal thread refuses the events that are still queued.
2409 2408
          if (transcript !== undefined) await transcript.close();
2410
          if (thread !== undefined) await thread.revoke();
2411
          if (childThread?.kind === "opened") await childThread.thread.revoke();
2409
          // The thread outlives the session. It used to be revoked here, which
2410
          // made `--resume` impossible by construction — every past session's
2411
          // thread was already terminal — and put "This thread was revoked" in
2412
          // front of anything that touched one again: a delegated child still
2413
          // running, a second window, a resumed session. A thread is durable;
2414
          // ending a session is not a reason to destroy it. `DELETE
2415
          // /threads/{id}` is still there for a reader who means it.
2412 2416
          if (setup !== undefined) await setup.close();
2413 2417
        }
2414 2418
      });

@@ -2600,7 +2604,7 @@ const delegateCommand = Command.make(

2600 2604
          // The thread's slot goes back even on the failure path: an account
2601 2605
          // holds eight, and a script that delegates in a loop would otherwise
2602 2606
          // be refused on its ninth run.
2603
          if (thread !== undefined) await thread.revoke();
2607
          // Left open, for the same reason as above.
2604 2608
        }
2605 2609
      });
2606 2610
packages/openagents-cli/src/coder-backends.ts modified +6 -4

@@ -1,7 +1,7 @@

1 1
/**
2 2
 * The backends `openagents coder` can send a turn to.
3 3
 *
4
 * The server owns the real list and publishes it at `GET /api/v3/models`, and a
4
 * The server owns the real list and publishes it at `GET /api/v1/models`, and a
5 5
 * session that can reach the server reads it from there — see
6 6
 * `fetchServedCatalog` below. The list in this file is the fallback for a
7 7
 * session that cannot: `--offline`, or no stored credential.

@@ -18,12 +18,14 @@

18 18
 * so this list is what the flag validates against and what the status line
19 19
 * names, nothing more.
20 20
 *
21
 * The `id` is what `POST /api/v3/threads` takes as `model`. The `label` is what
21
 * The `id` is what `POST /api/v1/threads` takes as `model`. The `label` is what
22 22
 * a person reads in the status bar, where the whole line is competing for a
23 23
 * narrow terminal; a model the server serves and this file has never heard of
24 24
 * is labelled with its own id.
25 25
 */
26 26
27
import { API_VERSION_PATH } from "./constants.js";
28
27 29
export interface CoderBackend {
28 30
  /** The value the chat API takes as `model`. Matches the server's enum. */
29 31
  readonly id: string;

@@ -59,7 +61,7 @@ export const defaultBackendId = (): string =>

59 61
/** Every id, for a flag's error message and its accepted values. */
60 62
export const backendIds = (): readonly string[] => CODER_BACKENDS.map((backend) => backend.id);
61 63
62
/** One model as the server publishes it at `GET /api/v3/models`. */
64
/** One model as the server publishes it at `GET /api/v1/models`. */
63 65
export interface ServedModel {
64 66
  readonly id: string;
65 67
  /** Served here *and* its provider credential configured. */

@@ -81,7 +83,7 @@ export const fetchServedCatalog = async (

81 83
  signal?: AbortSignal,
82 84
): Promise<readonly ServedModel[] | undefined> => {
83 85
  try {
84
    const response = await fetch(new URL("/api/v3/models", api.origin), {
86
    const response = await fetch(new URL(`${API_VERSION_PATH}/models`, api.origin), {
85 87
      headers: { authorization: `Bearer ${api.token}`, accept: "application/json" },
86 88
      signal: signal ?? AbortSignal.timeout(5_000),
87 89
    });
packages/openagents-cli/src/coder-export.ts modified +4 -1

@@ -82,7 +82,10 @@ const sumMetrics = (

82 82
  const measured = entries.filter((entry) => entry.metrics !== undefined);
83 83
  if (measured.length === 0) return {};
84 84
  return {
85
    total_prompt_tokens: measured.reduce((sum, entry) => sum + (entry.metrics?.promptTokens ?? 0), 0),
85
    total_prompt_tokens: measured.reduce(
86
      (sum, entry) => sum + (entry.metrics?.promptTokens ?? 0),
87
      0,
88
    ),
86 89
    total_completion_tokens: measured.reduce(
87 90
      (sum, entry) => sum + (entry.metrics?.completionTokens ?? 0),
88 91
      0,
packages/openagents-cli/src/coder-markdown.ts modified +1 -4

@@ -212,7 +212,6 @@ function tableRows(lines: ReadonlyArray<string>, width: number): ReadonlyArray<s

212 212
  // that contains a code span too wide.
213 213
  const plain = (text: string) => visibleWidth(styled(text, ""));
214 214
215
216 215
  const widths: number[] = [];
217 216
  for (let column = 0; column < columns; column += 1) {
218 217
    const widest = cells.reduce(

@@ -243,9 +242,7 @@ function tableRows(lines: ReadonlyArray<string>, width: number): ReadonlyArray<s

243 242
      .trimEnd();
244 243
245 244
  const out = [line(headings, BOLD)];
246
  out.push(
247
    `${DIM}${widths.map((room) => "─".repeat(room)).join("─".repeat(gap))}${RESET}`,
248
  );
245
  out.push(`${DIM}${widths.map((room) => "─".repeat(room)).join("─".repeat(gap))}${RESET}`);
249 246
  for (const row of cells) out.push(line(row, ""));
250 247
  return out;
251 248
}
packages/openagents-cli/src/coder-merge.ts modified +1 -3

@@ -10,9 +10,7 @@

10 10
 * Order within one stream is preserved, because a tool call and its result are
11 11
 * a sequence. Order between streams is arrival order, which is the point.
12 12
 */
13
export async function* merge<T>(
14
  streams: ReadonlyArray<AsyncIterable<T>>,
15
): AsyncIterable<T> {
13
export async function* merge<T>(streams: ReadonlyArray<AsyncIterable<T>>): AsyncIterable<T> {
16 14
  if (streams.length === 0) return;
17 15
  if (streams.length === 1) {
18 16
    yield* streams[0] as AsyncIterable<T>;
packages/openagents-cli/src/coder-ollama.ts modified +12 -8

@@ -12,7 +12,11 @@

12 12
 */
13 13
14 14
import { Ollama } from "ollama";
15
import type { Message as OllamaMessage, Tool as OllamaTool, ToolCall as OllamaToolCall } from "ollama";
15
import type {
16
  Message as OllamaMessage,
17
  Tool as OllamaTool,
18
  ToolCall as OllamaToolCall,
19
} from "ollama";
16 20
17 21
import { declaredDescription } from "./coder-tool-families.js";
18 22
import { merge } from "./coder-merge.js";

@@ -137,10 +141,7 @@ export const discoverOllamaModel = async (

137 141
 * Shared by discovery and by resolution, so the two cannot disagree about what
138 142
 * is on the machine.
139 143
 */
140
const installedModels = async (
141
  host: string,
142
  timeoutMs: number,
143
): Promise<ReadonlyArray<string>> => {
144
const installedModels = async (host: string, timeoutMs: number): Promise<ReadonlyArray<string>> => {
144 145
  try {
145 146
    const response = await fetch(new URL("/api/tags", host), {
146 147
      signal: AbortSignal.timeout(timeoutMs),

@@ -357,7 +358,12 @@ export class OllamaReplySource implements ReplySource {

357 358
    try {
358 359
      yield* this.rounds(prompt, signal);
359 360
    } finally {
360
      yield { type: "usage", promptTokens: this.spentIn, completionTokens: this.spentOut, calls: this.calls };
361
      yield {
362
        type: "usage",
363
        promptTokens: this.spentIn,
364
        completionTokens: this.spentOut,
365
        calls: this.calls,
366
      };
361 367
    }
362 368
  }
363 369

@@ -496,8 +502,6 @@ export class OllamaReplySource implements ReplySource {

496 502
      if (signal.aborted) return;
497 503
      yield* merge(calls.map((call) => this.invoke(call, signal)));
498 504
    }
499
500
501 505
  }
502 506
503 507
  /**
packages/openagents-cli/src/coder-plugins.ts modified +5 -4

@@ -173,7 +173,10 @@ export function loadPluginFromManifest(

173 173
        return refuse("mount_invalid", `mount \`${mount.path}\` is not a directory`);
174 174
      }
175 175
    } catch {
176
      return refuse("mount_invalid", `mount \`${mount.path}\` does not resolve to a readable directory`);
176
      return refuse(
177
        "mount_invalid",
178
        `mount \`${mount.path}\` does not resolve to a readable directory`,
179
      );
177 180
    }
178 181
    mounts.push(root);
179 182
  }

@@ -202,9 +205,7 @@ export function loadPluginFromManifest(

202 205
203 206
  // Every import must be granted by a declared capability. Mounts grant
204 207
  // exactly two: the read_file and list_dir capability imports.
205
  const granted = new Set(
206
    mounts.length > 0 ? ["openagents.read_file", "openagents.list_dir"] : [],
207
  );
208
  const granted = new Set(mounts.length > 0 ? ["openagents.read_file", "openagents.list_dir"] : []);
208 209
  const undeclared = shape.imports.filter((name) => !granted.has(name));
209 210
  if (undeclared.length > 0) {
210 211
    const grantHint =
packages/openagents-cli/src/coder-resume.ts modified +6 -7

@@ -5,8 +5,8 @@

5 5
 * 2026-08-24: bare `--resume` shows a picker over recent threads filtered to
6 6
 * the current repository, `--resume <id>` names one directly, `--resume
7 7
 * --last` continues the most recent without asking, and `--all` drops the
8
 * repository filter. `GET /api/v3/threads` is the picker's list and
9
 * `GET /api/v3/threads/{id}/events` is the transcript it replays, paged
8
 * repository filter. `GET /api/v1/threads` is the picker's list and
9
 * `GET /api/v1/threads/{id}/events` is the transcript it replays, paged
10 10
 * through the `after` cursor because the listing caps at fifty and a working
11 11
 * session passes fifty events inside an hour.
12 12
 *

@@ -27,7 +27,7 @@

27 27
 * the server's own; posting them again would double the record.
28 28
 *
29 29
 * The repository filter prefers the thread's own field. Since
30
 * openagents.com #210, `POST /api/v3/threads` records a structured
30
 * openagents.com #210, `POST /api/v1/threads` records a structured
31 31
 * `repository` and every thread view returns it, so a summary takes that
32 32
 * first. Threads opened before the field existed carry none, so the objective
33 33
 * sentence this CLI composes (`openagents coder in <repo> on <branch>`) is

@@ -39,13 +39,12 @@ import { createInterface } from "node:readline";

39 39
40 40
import type { CoderEntry } from "./coder-session.js";
41 41
import { boundedResult, ThreadUnavailable, type WireMessage } from "./coder-thread.js";
42
43
const THREADS_PATH = "/api/v3/threads";
42
import { THREADS_PATH } from "./constants.js";
44 43
45 44
/** The server's listing cap. Pages are read at exactly this size. */
46 45
const PAGE_LIMIT = 50;
47 46
48
/** One thread as `GET /api/v3/threads` reports it. */
47
/** One thread as `GET /api/v1/threads` reports it. */
49 48
export interface ThreadSummary {
50 49
  readonly id: string;
51 50
  readonly status: string;

@@ -60,7 +59,7 @@ export interface ThreadSummary {

60 59
  readonly branch: string | undefined;
61 60
}
62 61
63
/** One event as `GET /api/v3/threads/{id}/events` reports it. */
62
/** One event as `GET /api/v1/threads/{id}/events` reports it. */
64 63
export interface ThreadEvent {
65 64
  /** The cursor: a client continues from the last id it read. */
66 65
  readonly id: number;
packages/openagents-cli/src/coder-self-harness.ts modified +1 -3

@@ -117,9 +117,7 @@ export class SelfHarness implements DelegateHarness {

117 117
    const tools = this.toolsFor(input.cwd);
118 118
119 119
    const resumed =
120
      input.resumeSessionId === undefined
121
        ? undefined
122
        : this.sessions.get(input.resumeSessionId);
120
      input.resumeSessionId === undefined ? undefined : this.sessions.get(input.resumeSessionId);
123 121
124 122
    const sessionId = input.resumeSessionId ?? this.mintSession();
125 123
    const transcript: WireMessage[] = resumed ?? [
packages/openagents-cli/src/coder-shell.ts modified +5 -1

@@ -123,7 +123,11 @@ export async function runShell(

123 123
    child.stderr.on("data", collect);
124 124
125 125
    child.on("error", (cause) => {
126
      finish({ output: `The command could not be started: ${cause.message}`, code: undefined, timedOut: false });
126
      finish({
127
        output: `The command could not be started: ${cause.message}`,
128
        code: undefined,
129
        timedOut: false,
130
      });
127 131
    });
128 132
    child.on("close", (code) => {
129 133
      finish({ output, code: code ?? undefined, timedOut: false });
packages/openagents-cli/src/coder-skills.ts modified +8 -10

@@ -31,19 +31,14 @@ import { fileURLToPath } from "node:url";

31 31
 * ordinary skills otherwise: they appear in the catalog, and `/skills` switches
32 32
 * them off like any other.
33 33
 */
34
const SKILL_DIRECTORIES = (
35
  cwd: string,
36
  home: string,
37
  builtIn: string,
38
): ReadonlyArray<string> => [
34
const SKILL_DIRECTORIES = (cwd: string, home: string, builtIn: string): ReadonlyArray<string> => [
39 35
  join(cwd, ".agents", "skills"),
40 36
  join(home, ".agents", "skills"),
41 37
  builtIn,
42 38
];
43 39
44 40
/** The skills packaged with this CLI, beside the compiled output. */
45
const builtInSkills = (): string =>
46
  join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
41
const builtInSkills = (): string => join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
47 42
48 43
/** How much of one skill body is handed back. */
49 44
const BODY_LIMIT = 32_000;

@@ -331,7 +326,7 @@ const openAgentsWorkspace = (cwd: string): string | undefined => {

331 326
    "most of the work, and they are easy to confuse:",
332 327
    "",
333 328
    "- **`openagents.com`** is the web application: a Phoenix and Elixir codebase serving the",
334
    "  site, the forge, and the `/api/v3` API. Its issues are the site's issues.",
329
    "  site, the forge, and the `/api/v1` API. Its issues are the site's issues.",
335 330
    "- **`openagents`** is the monorepo: the `openagents` CLI lives in",
336 331
    "  `packages/openagents-cli`, alongside the other packages. Its issues are the CLI's and the",
337 332
    "  monorepo's.",

@@ -375,9 +370,12 @@ const openAgentsWorkspace = (cwd: string): string | undefined => {

375 370
 * otherwise be told to `cd` somewhere that does not exist, which is a worse
376 371
 * instruction than none.
377 372
 */
378
const siblingCheckout = (cwd: string): { readonly name: string; readonly path: string } | undefined => {
373
const siblingCheckout = (
374
  cwd: string,
375
): { readonly name: string; readonly path: string } | undefined => {
379 376
  const here = basename(cwd);
380
  const other = here === "openagents.com" ? "openagents" : here === "openagents" ? "openagents.com" : undefined;
377
  const other =
378
    here === "openagents.com" ? "openagents" : here === "openagents" ? "openagents.com" : undefined;
381 379
  if (other === undefined) return undefined;
382 380
383 381
  const path = join(dirname(cwd), other);
packages/openagents-cli/src/coder-thread.ts modified +17 -15

@@ -2,19 +2,19 @@

2 2
 * A reply source backed by a thread of the caller's own, and the grant that
3 3
 * thread mints.
4 4
 *
5
 * `openagents coder` used to submit through `POST /api/v3/chat/turns` and poll
6
 * `GET /api/v3/chat/events`. That was the only route a user token could reach a
5
 * `openagents coder` used to submit through `POST /api/v1/chat/turns` and poll
6
 * `GET /api/v1/chat/events`. That was the only route a user token could reach a
7 7
 * model through, and the server records one conversation per account, so every
8 8
 * prompt a person typed in a terminal landed in the same conversation `/chat`
9 9
 * reads, contended for the one streaming slot that conversation admits, and
10 10
 * became provider context for the next question asked in the browser. This
11 11
 * replaces both paths.
12 12
 *
13
 * `POST /api/v3/threads` opens a thread and returns a grant. The grant is the
13
 * `POST /api/v1/threads` opens a thread and returns a grant. The grant is the
14 14
 * bearer for `POST /api/inference/proxy`, an OpenAI-compatible
15 15
 * `/chat/completions` surface that meters against the thread's own budget and
16 16
 * keeps the provider credential on the server, so the CLI still holds no
17
 * provider key. `DELETE /api/v3/threads/{id}` revokes the thread on exit, which
17
 * provider key. `DELETE /api/v1/threads/{id}` revokes the thread on exit, which
18 18
 * matters because an account may hold only eight open threads at once and a
19 19
 * closed terminal would otherwise hold a slot until its authority was spent.
20 20
 *

@@ -67,8 +67,7 @@ import type { ReplyChunk, ReplySource } from "./coder-session.js";

67 67
import type { CoderTool } from "./coder-tools.js";
68 68
import { systemPrompt, THREAD_LANE } from "./coder-system.js";
69 69
import type { TranscriptSink } from "./coder-transcript.js";
70
71
const THREADS_PATH = "/api/v3/threads";
70
import { THREADS_PATH } from "./constants.js";
72 71
73 72
/**
74 73
 * How many rounds of tool calls one turn may take before it has to answer.

@@ -247,9 +246,9 @@ export interface ResumeGrantOptions {

247 246
 * it revokes every active grant naming the thread, bumps the thread's
248 247
 * generation, and mints fresh authority against the same thread — the grant
249 248
 * lineage a resume is supposed to continue. This client asks for that at
250
 * `POST /api/v3/threads/{id}/grants`.
249
 * `POST /api/v1/threads/{id}/grants`.
251 250
 *
252
 * Today the server publishes no such route. `GET /api/v3/threads/{id}` reports
251
 * Today the server publishes no such route. `GET /api/v1/threads/{id}` reports
253 252
 * the grant's status and limits but never its token — the plaintext exists
254 253
 * exactly once, at minting — so there is no other honest way to spend an
255 254
 * existing thread. A 404 here is therefore the server saying it cannot yet

@@ -289,8 +288,8 @@ export async function remintThread(options: ResumeGrantOptions): Promise<ThreadR

289 288
    throw new ThreadUnavailable(
290 289
      "grant_unavailable",
291 290
      "This server cannot hand back authority for an existing thread: " +
292
        "GET /api/v3/threads/{id} reports the grant without its token, and " +
293
        "POST /api/v3/threads/{id}/grants is not there to re-mint one. " +
291
        "GET /api/v1/threads/{id} reports the grant without its token, and " +
292
        "POST /api/v1/threads/{id}/grants is not there to re-mint one. " +
294 293
        "The transcript is readable, but new turns cannot spend this thread " +
295 294
        "until the server can re-grant it.",
296 295
      response.status,

@@ -835,7 +834,8 @@ export class ThreadReplySource implements ReplySource {

835 834
      if (response.status >= 200 && response.status < 300) return response;
836 835
837 836
      const refusal = await proxyRefusal(response);
838
      const transient = response.status === 502 || response.status === 503 || response.status === 504;
837
      const transient =
838
        response.status === 502 || response.status === 503 || response.status === 504;
839 839
      if (!transient || attempt >= attempts) throw refusal;
840 840
841 841
      // Short and fixed. The failure is on the provider's side and a reader is

@@ -850,7 +850,6 @@ export class ThreadReplySource implements ReplySource {

850 850
    if (response === undefined || signal.aborted) return;
851 851
    if (response.body === null) return;
852 852
853
854 853
    /** Tool call fragments by their wire index, assembled as frames arrive. */
855 854
    const calls = new Map<number, { id: string; name: string; args: string }>();
856 855

@@ -1030,12 +1029,16 @@ async function proxyRefusal(response: Response): Promise<ThreadUnavailable> {

1030 1029
  const code = string(error["code"]) ?? `http_${response.status}`;
1031 1030
1032 1031
  const sentences: Record<string, string> = {
1033
    grant_revoked: "This thread was revoked. Start a new session to open another.",
1032
    // Only reachable when someone deliberately revoked this thread — a session
1033
    // no longer does it on the way out. The sentence says what to do rather
1034
    // than naming a lifecycle the reader did not ask about.
1035
    grant_revoked: "This thread is no longer live. Start a new session to open another.",
1034 1036
    // Not a sentence about a clock. A thread's authority has no deadline —
1035 1037
    // this reaches a caller only where the deployment minted one that does,
1036 1038
    // and it says what the reader can act on rather than naming an expiry a
1037 1039
    // coder session cannot have.
1038
    grant_expired: "This thread's authority is no longer live. Start a new session to open another.",
1040
    grant_expired:
1041
      "This thread's authority is no longer live. Start a new session to open another.",
1039 1042
    grant_exhausted: "This thread spent its budget. Start a new session to open another.",
1040 1043
    grant_budget_reached: "This thread reached its budget ceiling and cannot buy another call.",
1041 1044
    invalid_grant: "The inference proxy did not recognize this thread's grant.",

@@ -1127,7 +1130,6 @@ export const resolveProxyUrl = (grantUrl: string, origin: string): string => {

1127 1130
};
1128 1131
1129 1132
function record(value: unknown): Record<string, unknown> {
1130
1131 1133
  return typeof value === "object" && value !== null && !Array.isArray(value)
1132 1134
    ? (value as Record<string, unknown>)
1133 1135
    : {};
packages/openagents-cli/src/coder-tools.ts modified +9 -4

@@ -367,7 +367,9 @@ const commandTree = (entry: string): string | undefined => {

367 367
    if (block === null) continue;
368 368
    const names = [...(block[1] ?? "").matchAll(/'([a-z][a-z0-9-]*):/g)].map((found) => found[1]);
369 369
    if (names.length === 0) continue;
370
    lines.push(owner.length === 0 ? `openagents ${names.join(" | ")}` : `  ${owner} ${names.join(" | ")}`);
370
    lines.push(
371
      owner.length === 0 ? `openagents ${names.join(" | ")}` : `  ${owner} ${names.join(" | ")}`,
372
    );
371 373
  }
372 374
  return lines.length === 0 ? undefined : lines.join("\n");
373 375
};

@@ -419,7 +421,7 @@ export function openagentsTool(): CoderTool {

419 421
        ? rawArgs["args"].filter((word): word is string => typeof word === "string")
420 422
        : [];
421 423
      if (args.length === 0) {
422
        return "No command was run: `args` is required, such as [\"--help\"].";
424
        return 'No command was run: `args` is required, such as ["--help"].';
423 425
      }
424 426
425 427
      const refusal = refusalFor(args);

@@ -447,7 +449,9 @@ export function openagentsTool(): CoderTool {

447 449
448 450
        const timer = setTimeout(() => {
449 451
          child.kill("SIGKILL");
450
          finish(`The command did not finish within ${String(CLI_TIMEOUT_MS / 1000)}s.\n\n${output}`);
452
          finish(
453
            `The command did not finish within ${String(CLI_TIMEOUT_MS / 1000)}s.\n\n${output}`,
454
          );
451 455
        }, CLI_TIMEOUT_MS);
452 456
453 457
        const onAbort = () => {

@@ -552,7 +556,8 @@ export function shellTool(cwd: string): CoderTool {

552 556
      const refusal = shellRefusalFor(command);
553 557
      if (refusal !== undefined) return refusal;
554 558
555
      const asked = typeof args["timeout_seconds"] === "number" ? args["timeout_seconds"] : undefined;
559
      const asked =
560
        typeof args["timeout_seconds"] === "number" ? args["timeout_seconds"] : undefined;
556 561
      const timeoutMs = Math.min(
557 562
        asked === undefined ? DEFAULT_TIMEOUT_MS : Math.max(1, Math.trunc(asked)) * 1000,
558 563
        MAXIMUM_TIMEOUT_MS,
packages/openagents-cli/src/coder-transcript.ts modified +2 -2

@@ -1,7 +1,7 @@

1 1
/**
2 2
 * The thread's durable transcript, written as the turn loop runs.
3 3
 *
4
 * `POST /api/v3/threads/{id}/events` is append-only and the server's copy is
4
 * `POST /api/v1/threads/{id}/events` is append-only and the server's copy is
5 5
 * the only copy: this process keeps no file of its own, so what lands here is
6 6
 * what `--resume`, the export, and every other machine reading the thread will
7 7
 * ever see. The vocabulary is the one decided in the openagents.com audit of

@@ -19,7 +19,7 @@

19 19
 * throwing into the loop that called it.
20 20
 */
21 21
22
const THREADS_PATH = "/api/v3/threads";
22
import { THREADS_PATH } from "./constants.js";
23 23
24 24
/**
25 25
 * How many consecutive failed posts before the reader is told once.
packages/openagents-cli/src/coder-ui.ts modified +27 -11

@@ -454,7 +454,11 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

454 454
      return out;
455 455
    };
456 456
457
    const renderEntry = (entry: CoderEntry, width: number, tasks: ReadonlyArray<CoderTask>): ReadonlyArray<string> => {
457
    const renderEntry = (
458
      entry: CoderEntry,
459
      width: number,
460
      tasks: ReadonlyArray<CoderTask>,
461
    ): ReadonlyArray<string> => {
458 462
      const color =
459 463
        entry.role === "you"
460 464
          ? CYAN

@@ -481,7 +485,11 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

481 485
      });
482 486
    };
483 487
484
    const entryRows = (entry: CoderEntry, width: number, tasks: ReadonlyArray<CoderTask>): ReadonlyArray<string> => {
488
    const entryRows = (
489
      entry: CoderEntry,
490
      width: number,
491
      tasks: ReadonlyArray<CoderTask>,
492
    ): ReadonlyArray<string> => {
485 493
      if (entry.role === "tool" && entry.tool !== undefined) {
486 494
        return toolRows(entry.tool, width, expanded.has(entry.tool.callId), tasks);
487 495
      }

@@ -504,7 +512,12 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

504 512
      return wrapStyled(entry.text, width, entry.role === "notice" ? DIM : "");
505 513
    };
506 514
507
    const toolRows = (tool: CoderToolCall, width: number, open: boolean, tasks: ReadonlyArray<CoderTask>): ReadonlyArray<string> => {
515
    const toolRows = (
516
      tool: CoderToolCall,
517
      width: number,
518
      open: boolean,
519
      tasks: ReadonlyArray<CoderTask>,
520
    ): ReadonlyArray<string> => {
508 521
      const mark =
509 522
        tool.status === "running"
510 523
          ? `${YELLOW}◐${RESET}`

@@ -519,7 +532,8 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

519 532
        Math.max(8, width - tool.name.length - 4),
520 533
      );
521 534
      const rows = [
522
        `${mark} ${BOLD}${tool.name}${RESET}` + (summary.length === 0 ? "" : ` ${DIM}${summary}${RESET}`),
535
        `${mark} ${BOLD}${tool.name}${RESET}` +
536
          (summary.length === 0 ? "" : ` ${DIM}${summary}${RESET}`),
523 537
      ];
524 538
525 539
      if (tool.name === "delegate" && tool.status === "running") {

@@ -776,7 +790,9 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

776 790
          case "tool":
777 791
            rows.push(
778 792
              `${YELLOW}▸${RESET} ${BOLD}${entry.name}${RESET}` +
779
                (entry.target === undefined ? "" : ` ${DIM}${truncate(entry.target, body - 6)}${RESET}`),
793
                (entry.target === undefined
794
                  ? ""
795
                  : ` ${DIM}${truncate(entry.target, body - 6)}${RESET}`),
780 796
            );
781 797
            break;
782 798

@@ -978,11 +994,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

978 994
        return;
979 995
      }
980 996
981
      paint(
982
        rows,
983
        transcriptHeight + 3,
984
        4 + [...visible].length + 1,
985
      );
997
      paint(rows, transcriptHeight + 3, 4 + [...visible].length + 1);
986 998
    };
987 999
988 1000
    /**

@@ -1334,7 +1346,11 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1334 1346
          const columnOpen =
1335 1347
            children.length > 0 && (stdout.columns ?? 80) >= SIDEBAR_MINIMUM_TERMINAL;
1336 1348
1337
          if (focus === "composer" && columnOpen && (sequence === "\x1b[C" || sequence === "\x1bOC")) {
1349
          if (
1350
            focus === "composer" &&
1351
            columnOpen &&
1352
            (sequence === "\x1b[C" || sequence === "\x1bOC")
1353
          ) {
1338 1354
            focus = "sidebar";
1339 1355
            sidebarRow = Math.min(sidebarRow, children.length - 1);
1340 1356
            render();
packages/openagents-cli/src/coder-zen.ts modified +2 -1

@@ -188,7 +188,8 @@ export class ZenReplySource implements ReplySource {

188 188
        if (payload === undefined) continue;
189 189
190 190
        const usage = payload["usage"];
191
        if (typeof usage === "object" && usage !== null) this.spend(usage as Record<string, unknown>);
191
        if (typeof usage === "object" && usage !== null)
192
          this.spend(usage as Record<string, unknown>);
192 193
193 194
        const choices = payload["choices"];
194 195
        if (!Array.isArray(choices)) continue;
packages/openagents-cli/src/constants.ts added +23

@@ -0,0 +1,23 @@

1
/**
2
 * The base path for all versioned OpenAgents API routes.
3
 *
4
 * All versioned REST resources live under this prefix. A passthrough path
5
 * without a leading slash resolves under it.
6
 */
7
export const API_BASE_PATH = "/api/v1/";
8
9
/**
10
 * The versioned API prefix without a trailing slash, used to construct
11
 * route paths for specific endpoints across CLI clients.
12
 */
13
export const API_VERSION_PATH = "/api/v1";
14
15
/**
16
 * The endpoint path for coder threads.
17
 */
18
export const THREADS_PATH = `${API_VERSION_PATH}/threads`;
19
20
/**
21
 * The endpoint path for operator fleet targets.
22
 */
23
export const FLEET_TARGETS_PATH = `${API_VERSION_PATH}/admin/forge/targets`;
packages/openagents-cli/src/device-client.ts modified +3 -2

@@ -2,6 +2,7 @@ import { Duration, Effect, Layer, Option, Redacted, Schedule, Schema } from "eff

2 2
import * as Context from "effect/Context";
3 3
4 4
import { ApiTransport } from "./api-transport.js";
5
import { API_VERSION_PATH } from "./constants.js";
5 6
import { ApiError, ContractError, type CliError } from "./errors.js";
6 7
7 8
export const DeviceAuthorization = Schema.Struct({

@@ -55,7 +56,7 @@ export const deviceClientLayer = Layer.effect(

55 56
      const response = yield* transport.request({
56 57
        origin,
57 58
        method: "POST",
58
        path: "/api/v3/device/authorizations",
59
        path: `${API_VERSION_PATH}/device/authorizations`,
59 60
        // The server decides the default scope set. Asking for none keeps that
60 61
        // decision on the server; asking names exactly what the approval page
61 62
        // must show the person approving it.

@@ -89,7 +90,7 @@ export const deviceClientLayer = Layer.effect(

89 90
        .request({
90 91
          origin,
91 92
          method: "POST",
92
          path: "/api/v3/device/authorizations/token",
93
          path: `${API_VERSION_PATH}/device/authorizations/token`,
93 94
          body: { device_code: authorization.device_code },
94 95
        })
95 96
        .pipe(
packages/openagents-cli/src/fleet-client.ts modified +3 -2

@@ -2,7 +2,7 @@

2 2
 * The operator fleet promotion client.
3 3
 *
4 4
 * It speaks only the operator API from OpenAgentsInc/openagents.com#57 —
5
 * `POST/GET /api/v3/admin/forge/targets` — behind the same `/api/v3` error
5
 * `POST/GET /api/v1/admin/forge/targets` — behind the same `/api/v1` error
6 6
 * envelope every other command family reads. It never touches `/admin/forge`,
7 7
 * SSH, or any internal RPC, and it adds only what a terminal caller cannot do
8 8
 * for itself: an idempotent re-send after a failed transport, and bounded

@@ -13,12 +13,13 @@ import { Clock, Duration, Effect, Layer } from "effect";

13 13
import * as Context from "effect/Context";
14 14
15 15
import { ApiTransport } from "./api-transport.js";
16
import { FLEET_TARGETS_PATH } from "./constants.js";
16 17
import { ApiError, DeploymentWaitTimeout, type CliError } from "./errors.js";
17 18
import type { AuthenticatedApi } from "./repository-client.js";
18 19
import { asRecord, asText, makeTrackerRequest, trackerErrorDetails } from "./tracker-request.js";
19 20
20 21
/** The one route family from OpenAgentsInc/openagents.com#57. */
21
export const FLEET_TARGETS_PATH = "/api/v3/admin/forge/targets";
22
export { FLEET_TARGETS_PATH };
22 23
23 24
/** The privileged scope the server requires; `forge:write` cannot promote. */
24 25
export const OPERATOR_SCOPE = "deployments:promote";
packages/openagents-cli/src/forum-client.ts modified +16 -7

@@ -17,6 +17,7 @@ import { Effect, Layer, Redacted } from "effect";

17 17
import * as Context from "effect/Context";
18 18
19 19
import { ApiTransport, type ApiRequest } from "./api-transport.js";
20
import { API_VERSION_PATH } from "./constants.js";
20 21
import { ApiError, type CliError } from "./errors.js";
21 22
22 23
/** An origin and the token that authorizes a request against it. */

@@ -137,13 +138,17 @@ export const forumClientLayer = Layer.effect(

137 138
      request,
138 139
139 140
      boards: (input) =>
140
        request("list forum boards", { ...input, method: "GET", path: "/api/v3/forum" }),
141
        request("list forum boards", {
142
          ...input,
143
          method: "GET",
144
          path: `${API_VERSION_PATH}/forum`,
145
        }),
141 146
142 147
      topics: (input) =>
143 148
        request("list forum topics", {
144 149
          ...input,
145 150
          method: "GET",
146
          path: `/api/v3/forum/topics?forum=${encodeURIComponent(input.board)}${
151
          path: `${API_VERSION_PATH}/forum/topics?forum=${encodeURIComponent(input.board)}${
147 152
            input.page === undefined ? "" : `&page=${input.page}`
148 153
          }`,
149 154
        }),

@@ -152,7 +157,7 @@ export const forumClientLayer = Layer.effect(

152 157
        request("read a forum topic", {
153 158
          ...input,
154 159
          method: "GET",
155
          path: `/api/v3/forum/topics/${encodeURIComponent(input.id)}${
160
          path: `${API_VERSION_PATH}/forum/topics/${encodeURIComponent(input.id)}${
156 161
            input.page === undefined ? "" : `?page=${input.page}`
157 162
          }`,
158 163
        }),

@@ -161,7 +166,7 @@ export const forumClientLayer = Layer.effect(

161 166
        request("create a forum topic", {
162 167
          ...input,
163 168
          method: "POST",
164
          path: "/api/v3/forum/topics",
169
          path: `${API_VERSION_PATH}/forum/topics`,
165 170
          body: { forum: input.board, title: input.title, body_text: input.bodyText },
166 171
        }),
167 172

@@ -169,7 +174,7 @@ export const forumClientLayer = Layer.effect(

169 174
        request("reply to a forum topic", {
170 175
          ...input,
171 176
          method: "POST",
172
          path: `/api/v3/forum/topics/${encodeURIComponent(input.topicId)}/posts`,
177
          path: `${API_VERSION_PATH}/forum/topics/${encodeURIComponent(input.topicId)}/posts`,
173 178
          body: { body_text: input.bodyText },
174 179
        }),
175 180

@@ -177,12 +182,16 @@ export const forumClientLayer = Layer.effect(

177 182
        request("claim a legacy forum identity", {
178 183
          ...input,
179 184
          method: "POST",
180
          path: "/api/v3/forum/claims",
185
          path: `${API_VERSION_PATH}/forum/claims`,
181 186
          body: { actor_ref: input.actorRef },
182 187
        }),
183 188
184 189
      claims: (input) =>
185
        request("list identity claims", { ...input, method: "GET", path: "/api/v3/forum/claims" }),
190
        request("list identity claims", {
191
          ...input,
192
          method: "GET",
193
          path: `${API_VERSION_PATH}/forum/claims`,
194
        }),
186 195
    };
187 196
  }),
188 197
);
packages/openagents-cli/src/index.ts modified +1

@@ -7,6 +7,7 @@ export * from "./computer-config.js";

7 7
export * from "./computer-journal.js";
8 8
export * from "./computer-policy.js";
9 9
export * from "./computer-probe.js";
10
export * from "./constants.js";
10 11
export * from "./credential-store.js";
11 12
export * from "./device-authorization-store.js";
12 13
export * from "./endpoint.js";
packages/openagents-cli/src/issue-client.ts modified +1 -1

@@ -2,7 +2,7 @@

2 2
 * The issue API client.
3 3
 *
4 4
 * The routes it calls answer with the GitHub-compatible shapes this
5
 * repository publishes at `GET /api/v3`, so the client keeps the server's
5
 * repository publishes at `GET /api/v1`, so the client keeps the server's
6 6
 * bodies intact and adds only what a terminal caller cannot do for itself:
7 7
 * paging a list that has no `per_page` parameter, and reporting a rejected
8 8
 * write by the field the server named.
packages/openagents-cli/src/repository-client.ts modified +12 -9

@@ -13,6 +13,7 @@ import {

13 13
  repositoryFromAcceptedImport,
14 14
} from "./api-contract.js";
15 15
import { ApiTransport } from "./api-transport.js";
16
import { API_VERSION_PATH } from "./constants.js";
16 17
import {
17 18
  ApiError,
18 19
  ContractError,

@@ -271,7 +272,7 @@ export const repositoryClientLayer = Layer.effect(

271 272
      const poll = request("read repository provisioning state", {
272 273
        ...input,
273 274
        method: "GET",
274
        path: `/api/v3/repos/${encoded(input.owner)}/${encoded(input.repo)}`,
275
        path: `${API_VERSION_PATH}/repos/${encoded(input.owner)}/${encoded(input.repo)}`,
275 276
        acceptedStatuses: [200],
276 277
      }).pipe(
277 278
        Effect.flatMap((value) => decode("read repository provisioning state", Repository, value)),

@@ -326,7 +327,9 @@ export const repositoryClientLayer = Layer.effect(

326 327
        ...(input.defaultBranch === undefined ? {} : { default_branch: input.defaultBranch }),
327 328
      };
328 329
      const path =
329
        owner === undefined ? "/api/v3/user/repos" : `/api/v3/orgs/${encoded(owner)}/repos`;
330
        owner === undefined
331
          ? `${API_VERSION_PATH}/user/repos`
332
          : `${API_VERSION_PATH}/orgs/${encoded(owner)}/repos`;
330 333
      const value = yield* retryMutation(
331 334
        request("create repository", {
332 335
          ...input,

@@ -356,7 +359,7 @@ export const repositoryClientLayer = Layer.effect(

356 359
      const responseBody = yield* request("read authenticated user", {
357 360
        ...input,
358 361
        method: "GET",
359
        path: "/api/v3/user",
362
        path: `${API_VERSION_PATH}/user`,
360 363
        acceptedStatuses: [200],
361 364
      });
362 365
      return yield* decode("read authenticated user", AuthenticatedUser, responseBody);

@@ -374,7 +377,7 @@ export const repositoryClientLayer = Layer.effect(

374 377
      const responseBody = yield* request("list repositories", {
375 378
        ...input,
376 379
        method: "GET",
377
        path: `/api/v3/user/repos?${parameters.toString()}`,
380
        path: `${API_VERSION_PATH}/user/repos?${parameters.toString()}`,
378 381
        acceptedStatuses: [200],
379 382
      });
380 383
      const pageResponse = yield* decode("list repositories", RepositoryListResponse, responseBody);

@@ -392,7 +395,7 @@ export const repositoryClientLayer = Layer.effect(

392 395
      const value = yield* request("view repository", {
393 396
        ...input,
394 397
        method: "GET",
395
        path: `/api/v3/repos/${encoded(owner)}/${encoded(repo)}`,
398
        path: `${API_VERSION_PATH}/repos/${encoded(owner)}/${encoded(repo)}`,
396 399
        acceptedStatuses: [200],
397 400
      });
398 401
      return yield* decode("view repository", RepositoryResponse, value);

@@ -406,7 +409,7 @@ export const repositoryClientLayer = Layer.effect(

406 409
      yield* request("delete repository", {
407 410
        ...input,
408 411
        method: "DELETE",
409
        path: `/api/v3/repos/${encoded(owner)}/${encoded(repo)}`,
412
        path: `${API_VERSION_PATH}/repos/${encoded(owner)}/${encoded(repo)}`,
410 413
        acceptedStatuses: [204],
411 414
      });
412 415
    });

@@ -417,7 +420,7 @@ export const repositoryClientLayer = Layer.effect(

417 420
      const value = yield* request("read repository import", {
418 421
        ...input,
419 422
        method: "GET",
420
        path: `/api/v3/repository-imports/${encoded(input.importId)}`,
423
        path: `${API_VERSION_PATH}/repository-imports/${encoded(input.importId)}`,
421 424
        acceptedStatuses: [200],
422 425
      });
423 426
      return yield* decode("read repository import", RepositoryImportStatusResponse, value);

@@ -517,8 +520,8 @@ export const repositoryClientLayer = Layer.effect(

517 520
      };
518 521
      const path =
519 522
        owner === undefined
520
          ? "/api/v3/user/repos/imports"
521
          : `/api/v3/orgs/${encoded(owner)}/repos/imports`;
523
          ? `${API_VERSION_PATH}/user/repos/imports`
524
          : `${API_VERSION_PATH}/orgs/${encoded(owner)}/repos/imports`;
522 525
      const value = yield* retryMutation(
523 526
        request("import repository", {
524 527
          ...input,
packages/openagents-cli/src/trace-command.ts modified +2 -1

@@ -19,6 +19,7 @@ import { Effect } from "effect";

19 19
import { Argument, Command, Flag } from "effect/unstable/cli";
20 20
21 21
import { InputError, TraceUploadUnsupported } from "./errors.js";
22
import { API_VERSION_PATH } from "./constants.js";
22 23
import { Output, type OutputMode } from "./output.js";
23 24
import {
24 25
  defaultDiscoveryBounds,

@@ -42,7 +43,7 @@ const outputMode = (json: boolean): OutputMode => (json ? "json" : "human");

42 43
/** The server half `trace upload` is waiting for. One place, one sentence. */
43 44
export const TRACE_INGEST_ROUTE_GAP =
44 45
  "openagents.com has no trace ingest route yet. Upload needs the server half first: " +
45
  "POST /api/v3/traces accepting an ATIF v1.7 document with owner_only default visibility. " +
46
  `POST ${API_VERSION_PATH}/traces accepting an ATIF v1.7 document with owner_only default visibility. ` +
46 47
  "Until that route exists, this command refuses rather than pretending to upload.";
47 48
48 49
const listPathFlag = Flag.string("path").pipe(
packages/openagents-cli/src/tracker-request.ts modified +3 -2

@@ -1,7 +1,7 @@

1 1
/**
2 2
 * The request seam the issue and project clients share.
3 3
 *
4
 * Both talk to the same `/api/v3` routes behind the same unified error
4
 * Both talk to the same `/api/v1` routes behind the same unified error
5 5
 * envelope from issue #82, so the transport call, the accepted-status check,
6 6
 * and the failure translation live once rather than twice.
7 7
 */

@@ -9,6 +9,7 @@

9 9
import { Effect } from "effect";
10 10
11 11
import type { ApiTransportInterface, HttpMethod } from "./api-transport.js";
12
import { API_VERSION_PATH } from "./constants.js";
12 13
import { ApiError } from "./errors.js";
13 14
import type { AuthenticatedApi } from "./repository-client.js";
14 15

@@ -100,4 +101,4 @@ export type TrackerRequest = ReturnType<typeof makeTrackerRequest>;

100 101
101 102
/** The path prefix every repository-scoped tracker route shares. */
102 103
export const repositoryPath = (owner: string, repo: string): string =>
103
  `/api/v3/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
104
  `${API_VERSION_PATH}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
packages/openagents-cli/test/api-command.test.ts modified +8 -8

@@ -81,7 +81,7 @@ describe("openagents api", () => {

81 81
    expect(harnessed.requests).toHaveLength(1);
82 82
    expect(harnessed.requests[0]?.origin).toBe("http://localhost:4000");
83 83
    expect(harnessed.requests[0]?.method).toBe("GET");
84
    expect(harnessed.requests[0]?.path).toBe("/api/v3/repos/octavia/project/issues");
84
    expect(harnessed.requests[0]?.path).toBe("/api/v1/repos/octavia/project/issues");
85 85
    expect(Redacted.value(harnessed.requests[0]?.token ?? Redacted.make(""))).toBe(
86 86
      "oa_pat_fixture",
87 87
    );

@@ -113,16 +113,16 @@ describe("openagents api", () => {

113 113
  it("accepts an absolute API path and a relative path as the same route", async () => {
114 114
    const harnessed = harness({ token: "oa_pat_fixture" });
115 115
    await run(harnessed, ["api", "repos/octavia/project/issues"]);
116
    await run(harnessed, ["api", "/api/v3/repos/octavia/project/issues"]);
116
    await run(harnessed, ["api", "/api/v1/repos/octavia/project/issues"]);
117 117
    expect(harnessed.requests.map((request) => request.path)).toEqual([
118
      "/api/v3/repos/octavia/project/issues",
119
      "/api/v3/repos/octavia/project/issues",
118
      "/api/v1/repos/octavia/project/issues",
119
      "/api/v1/repos/octavia/project/issues",
120 120
    ]);
121 121
  });
122 122
123 123
  it("refuses a path that leaves the configured origin", async () => {
124 124
    const harnessed = harness({ token: "oa_pat_fixture" });
125
    const failure = await failureOf(harnessed, ["api", "https://openagents.com/api/v3/user"]);
125
    const failure = await failureOf(harnessed, ["api", "https://openagents.com/api/v1/user"]);
126 126
    expect(failure._tag).toBe("OpenAgentsCli.InputError");
127 127
    expect(failure.message).toContain("leaves the configured API origin");
128 128
    expect(harnessed.requests).toHaveLength(0);

@@ -232,7 +232,7 @@ describe("openagents api", () => {

232 232
    });
233 233
    const failure = await failureOf(harnessed, ["api", "repos/octavia/project/issues/9999"]);
234 234
    expect(failure._tag).toBe("OpenAgentsCli.ApiError");
235
    expect(failure.message).toContain("HTTP 404 for GET /api/v3/repos/octavia/project/issues/9999");
235
    expect(failure.message).toContain("HTTP 404 for GET /api/v1/repos/octavia/project/issues/9999");
236 236
    expect(failure.message).toContain("Not Found");
237 237
    if (failure._tag === "OpenAgentsCli.ApiError") {
238 238
      expect(failure.status).toBe(404);

@@ -353,7 +353,7 @@ describe("openagents api against a loopback server", () => {

353 353
      expect(result.code).toBe(0);
354 354
      expect(JSON.parse(result.stdout)).toEqual({ number: 41 });
355 355
      expect(server.received[0]?.method).toBe("POST");
356
      expect(server.received[0]?.url).toBe("/api/v3/repos/octavia/project/issues");
356
      expect(server.received[0]?.url).toBe("/api/v1/repos/octavia/project/issues");
357 357
      expect(server.received[0]?.headers.authorization).toBe("Bearer oa_pat_loopback-fixture");
358 358
      expect(JSON.parse(server.received[0]?.body ?? "")).toEqual({
359 359
        title: "From stdin",

@@ -381,7 +381,7 @@ describe("openagents api against a loopback server", () => {

381 381
      expect(result.stdout).toBe("");
382 382
      expect(result.stderr).toContain("Not Found");
383 383
      expect(result.stderr).toContain("Request id: request-loopback");
384
      expect(result.stderr).toContain("HTTP 404 for GET /api/v3/repos/octavia/project/issues/9999");
384
      expect(result.stderr).toContain("HTTP 404 for GET /api/v1/repos/octavia/project/issues/9999");
385 385
    } finally {
386 386
      await server.close();
387 387
    }
packages/openagents-cli/test/api-passthrough.test.ts modified +9 -9

@@ -28,8 +28,8 @@ describe("passthrough path resolution", () => {

28 28
29 29
  it("keeps an absolute API path unchanged", async () => {
30 30
    await expect(
31
      Effect.runPromise(resolveApiPath(origin, "/api/v3/repos/octavia/project/issues")),
32
    ).resolves.toBe("/api/v3/repos/octavia/project/issues");
31
      Effect.runPromise(resolveApiPath(origin, "/api/v1/repos/octavia/project/issues")),
32
    ).resolves.toBe("/api/v1/repos/octavia/project/issues");
33 33
  });
34 34
35 35
  it("preserves a query string in both forms", async () => {

@@ -37,20 +37,20 @@ describe("passthrough path resolution", () => {

37 37
      Effect.runPromise(resolveApiPath(origin, "repos/octavia/project/issues?state=closed")),
38 38
    ).resolves.toBe(`${API_BASE_PATH}repos/octavia/project/issues?state=closed`);
39 39
    await expect(
40
      Effect.runPromise(resolveApiPath(origin, "/api/v3/labels?per_page=5")),
41
    ).resolves.toBe("/api/v3/labels?per_page=5");
40
      Effect.runPromise(resolveApiPath(origin, "/api/v1/labels?per_page=5")),
41
    ).resolves.toBe("/api/v1/labels?per_page=5");
42 42
  });
43 43
44 44
  it("accepts a complete URL on the configured origin", async () => {
45
    await expect(Effect.runPromise(resolveApiPath(origin, `${origin}/api/v3/user`))).resolves.toBe(
46
      "/api/v3/user",
45
    await expect(Effect.runPromise(resolveApiPath(origin, `${origin}/api/v1/user`))).resolves.toBe(
46
      "/api/v1/user",
47 47
    );
48 48
  });
49 49
50 50
  it.each([
51
    "https://openagents.com/api/v3/user",
52
    "//openagents.com/api/v3/user",
53
    "http://127.0.0.1:5000/api/v3/user",
51
    "https://openagents.com/api/v1/user",
52
    "//openagents.com/api/v1/user",
53
    "http://127.0.0.1:5000/api/v1/user",
54 54
  ])("refuses %s because it leaves the configured origin", async (path) => {
55 55
    expect(await failureText(resolveApiPath(origin, path))).toContain("leaves the configured");
56 56
  });
packages/openagents-cli/test/coder-backends.test.ts modified +6 -3

@@ -29,7 +29,7 @@ describe("coder backends", () => {

29 29
  });
30 30
31 31
  it("publishes ids the chat API's own enum lists", () => {
32
    // These are the values `POST /api/v3/chat/turns` accepts as `model`, so a
32
    // These are the values `POST /api/v1/chat/turns` accepts as `model`, so a
33 33
    // change here without the matching server change is a refusal at runtime.
34 34
    expect(backendIds()).toEqual(["gemini-3.7-flash", "ox-alpha", "gpt-5.6-luna"]);
35 35
  });

@@ -97,7 +97,10 @@ describe("refusing a named backend", () => {

97 97
  });
98 98
99 99
  it("says so plainly when the deployment can run nothing", () => {
100
    const refusal = refuseBackend([{ id: "ox-alpha", available: false, isDefault: true }], "ox-alpha");
100
    const refusal = refuseBackend(
101
      [{ id: "ox-alpha", available: false, isDefault: true }],
102
      "ox-alpha",
103
    );
101 104
    expect(refusal).toContain("no model with a configured credential");
102 105
  });
103 106
});

@@ -122,7 +125,7 @@ describe("reading the published catalog", () => {

122 125
  it("reads ids and availability from the server's own shape", async () => {
123 126
    const served = await withFetch(
124 127
      (url) => {
125
        expect(url).toBe("http://localhost:4000/api/v3/models");
128
        expect(url).toBe("http://localhost:4000/api/v1/models");
126 129
        return new Response(
127 130
          JSON.stringify({
128 131
            default: "gpt-5.6-luna",
packages/openagents-cli/test/coder-dev-server.test.ts modified +13 -5

@@ -8,7 +8,10 @@ import { devServerReady, findSiteCheckout, startDevServer } from "../src/coder-d

8 8
/** A directory tree with a Phoenix `mix.exs` at its root. */
9 9
const siteCheckout = () => {
10 10
  const root = mkdtempSync(join(tmpdir(), "oa-site-"));
11
  writeFileSync(join(root, "mix.exs"), "def project do\n  [app: :openagents, version: \"0.1.0\"]\nend\n");
11
  writeFileSync(
12
    join(root, "mix.exs"),
13
    'def project do\n  [app: :openagents, version: "0.1.0"]\nend\n',
14
  );
12 15
  mkdirSync(join(root, "lib", "openagents_web"), { recursive: true });
13 16
  return root;
14 17
};

@@ -33,9 +36,9 @@ describe("finding the checkout to start a server from", () => {

33 36
    const root = siteCheckout();
34 37
    // Falls through to the search rather than starting `mix` somewhere that
35 38
    // will fail a minute later and less clearly.
36
    expect(findSiteCheckout(root, { OPENAGENTS_COM_PATH: mkdtempSync(join(tmpdir(), "oa-not-")) })).toBe(
37
      root,
38
    );
39
    expect(
40
      findSiteCheckout(root, { OPENAGENTS_COM_PATH: mkdtempSync(join(tmpdir(), "oa-not-")) }),
41
    ).toBe(root);
39 42
  });
40 43
41 44
  it("does not take a Mix project that is some other application", () => {

@@ -59,7 +62,12 @@ describe("readiness", () => {

59 62
  it("does not count a server whose database is behind as ready", async () => {
60 63
    // Phoenix answers this as its own debug page, so it is a live server that
61 64
    // cannot serve yet — neither ready nor absent.
62
    stub(() => new Response("<html>Phoenix.Ecto.PendingMigrationError at GET /healthz</html>", { status: 500 }));
65
    stub(
66
      () =>
67
        new Response("<html>Phoenix.Ecto.PendingMigrationError at GET /healthz</html>", {
68
          status: 500,
69
        }),
70
    );
63 71
    expect(await devServerReady("http://localhost:4000")).toBe(false);
64 72
  });
65 73
packages/openagents-cli/test/coder-export.test.ts modified +1 -5

@@ -181,9 +181,7 @@ describe("exporting a conversation as ATIF", () => {

181 181
      entry({ role: "you", text: "a real question" }),
182 182
    ]);
183 183
184
    expect(document["steps"]).toEqual([
185
      expect.objectContaining({ message: "a real question" }),
186
    ]);
184
    expect(document["steps"]).toEqual([expect.objectContaining({ message: "a real question" })]);
187 185
  });
188 186
189 187
  it("leaves out the empty entry the interface opens for the caret", () => {

@@ -195,7 +193,6 @@ describe("exporting a conversation as ATIF", () => {

195 193
    expect(document["steps"]).toHaveLength(1);
196 194
  });
197 195
198
199 196
  it("records what a turn cost, and how many calls that was", () => {
200 197
    const { document } = write([
201 198
      entry({ role: "you", text: "ask" }),

@@ -226,7 +223,6 @@ describe("exporting a conversation as ATIF", () => {

226 223
    expect(document["final_metrics"]).toEqual({ total_steps: 1 });
227 224
  });
228 225
229
230 226
  it("takes the clipboard only when asked to", () => {
231 227
    const directory = mkdtempSync(join(tmpdir(), "coder-export-"));
232 228
packages/openagents-cli/test/coder-ollama.test.ts modified +4 -3

@@ -134,8 +134,6 @@ describe("an ollama turn that calls a tool", () => {

134 134
    expect(messages.at(-1)).toMatchObject({ content: "child 1 said PONG", tool_name: "delegate" });
135 135
  });
136 136
137
138
139 137
  it("keeps the turn's reasoning on the transcript it sends back", async () => {
140 138
    const { source, stub } = sourceWith([
141 139
      [

@@ -346,7 +344,10 @@ describe("what a local session tells the model about itself", () => {

346 344
describe("finding a local model to default to", () => {
347 345
  const serve = (body: unknown, status = 200) =>
348 346
    vi.spyOn(globalThis, "fetch").mockResolvedValue(
349
      new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }),
347
      new Response(JSON.stringify(body), {
348
        status,
349
        headers: { "content-type": "application/json" },
350
      }),
350 351
    );
351 352
352 353
  afterEach(() => {
packages/openagents-cli/test/coder-paste.test.ts modified +61 -16

@@ -4,19 +4,42 @@ import { CoderSession, type ReplySource } from "../src/coder-session.js";

4 4
import { runCoderUi } from "../src/coder-ui.js";
5 5
6 6
class FakeOut extends EventEmitter {
7
  columns = 100; rows = 24; written = "";
8
  write(t: string) { this.written += t; return true; }
7
  columns = 100;
8
  rows = 24;
9
  written = "";
10
  write(t: string) {
11
    this.written += t;
12
    return true;
13
  }
9 14
}
10 15
class FakeIn extends EventEmitter {
11 16
  isTTY = true;
12
  setRawMode(){return this;} resume(){return this;} pause(){return this;} setEncoding(){return this;}
17
  setRawMode() {
18
    return this;
19
  }
20
  resume() {
21
    return this;
22
  }
23
  pause() {
24
    return this;
25
  }
26
  setEncoding() {
27
    return this;
28
  }
13 29
}
14 30
15 31
describe("pasting", () => {
16 32
  it("keeps a multi-line paste as one message", async () => {
17 33
    const sent: string[] = [];
18
    const src: ReplySource = { model: "m", async *reply(p: string) { sent.push(p); yield { type: "text", value: "ok" } as const; } };
19
    const stdin = new FakeIn(); const stdout = new FakeOut();
34
    const src: ReplySource = {
35
      model: "m",
36
      async *reply(p: string) {
37
        sent.push(p);
38
        yield { type: "text", value: "ok" } as const;
39
      },
40
    };
41
    const stdin = new FakeIn();
42
    const stdout = new FakeOut();
20 43
    const session = new CoderSession(src, "repo", "main");
21 44
    const running = runCoderUi(session, { stdin: stdin as never, stdout: stdout as never });
22 45

@@ -27,13 +50,21 @@ describe("pasting", () => {

27 50
    await new Promise((r) => setTimeout(r, 20));
28 51
29 52
    expect(sent).toEqual(["line one\nline two\nline three"]);
30
    stdin.emit("data", "\x04"); await running;
53
    stdin.emit("data", "\x04");
54
    await running;
31 55
  });
32 56
33 57
  it("holds a paste whose end has not arrived", async () => {
34 58
    const sent: string[] = [];
35
    const src: ReplySource = { model: "m", async *reply(p: string) { sent.push(p); yield { type: "text", value: "ok" } as const; } };
36
    const stdin = new FakeIn(); const stdout = new FakeOut();
59
    const src: ReplySource = {
60
      model: "m",
61
      async *reply(p: string) {
62
        sent.push(p);
63
        yield { type: "text", value: "ok" } as const;
64
      },
65
    };
66
    const stdin = new FakeIn();
67
    const stdout = new FakeOut();
37 68
    const session = new CoderSession(src, "repo", "main");
38 69
    const running = runCoderUi(session, { stdin: stdin as never, stdout: stdout as never });
39 70

@@ -44,12 +75,19 @@ describe("pasting", () => {

44 75
    await new Promise((r) => setTimeout(r, 20));
45 76
46 77
    expect(sent).toEqual(["first\nsecond"]);
47
    stdin.emit("data", "\x04"); await running;
78
    stdin.emit("data", "\x04");
79
    await running;
48 80
  });
49 81
50 82
  it("shows a paste as a blob rather than as its last line", async () => {
51
    const src: ReplySource = { model: "m", async *reply() { yield { type: "text", value: "ok" } as const; } };
52
    const stdin = new FakeIn(); const stdout = new FakeOut();
83
    const src: ReplySource = {
84
      model: "m",
85
      async *reply() {
86
        yield { type: "text", value: "ok" } as const;
87
      },
88
    };
89
    const stdin = new FakeIn();
90
    const stdout = new FakeOut();
53 91
    const session = new CoderSession(src, "repo", "main");
54 92
    const running = runCoderUi(session, { stdin: stdin as never, stdout: stdout as never });
55 93

@@ -65,12 +103,19 @@ describe("pasting", () => {

65 103
    // Ctrl+D quits only an empty composer, which is why escape comes first.
66 104
    stdin.emit("data", ESC);
67 105
    await new Promise((r) => setTimeout(r, 60));
68
    stdin.emit("data", "\x04"); await running;
106
    stdin.emit("data", "\x04");
107
    await running;
69 108
  });
70 109
71 110
  it("asks the terminal to bracket pastes, and stops asking on the way out", async () => {
72
    const src: ReplySource = { model: "m", async *reply() { yield { type: "text", value: "ok" } as const; } };
73
    const stdin = new FakeIn(); const stdout = new FakeOut();
111
    const src: ReplySource = {
112
      model: "m",
113
      async *reply() {
114
        yield { type: "text", value: "ok" } as const;
115
      },
116
    };
117
    const stdin = new FakeIn();
118
    const stdout = new FakeOut();
74 119
    const session = new CoderSession(src, "repo", "main");
75 120
    const running = runCoderUi(session, { stdin: stdin as never, stdout: stdout as never });
76 121

@@ -79,9 +124,9 @@ describe("pasting", () => {

79 124
    // in it is an enter.
80 125
    expect(stdout.written).toContain(`${ESC}[?2004h`);
81 126
82
    stdin.emit("data", "\x04"); await running;
127
    stdin.emit("data", "\x04");
128
    await running;
83 129
84 130
    expect(stdout.written).toContain(`${ESC}[?2004l`);
85 131
  });
86
87 132
});
packages/openagents-cli/test/coder-plugin-list-dir.test.ts modified +1 -4

@@ -42,10 +42,7 @@ const DIR_STATS_WASM = fileURLToPath(

42 42
 */
43 43
const stage = (mutateManifest?: (manifest: Record<string, unknown>) => void): string => {
44 44
  const dir = mkdtempSync(join(tmpdir(), "plugin-listdir-"));
45
  const manifest = JSON.parse(readFileSync(DIR_STATS_MANIFEST, "utf8")) as Record<
46
    string,
47
    unknown
48
  >;
45
  const manifest = JSON.parse(readFileSync(DIR_STATS_MANIFEST, "utf8")) as Record<string, unknown>;
49 46
  mutateManifest?.(manifest);
50 47
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
51 48
  copyFileSync(DIR_STATS_WASM, join(dir, "dir_stats.wasm"));
packages/openagents-cli/test/coder-plugin-mounts.test.ts modified +2 -8

@@ -45,10 +45,7 @@ const WORD_STATS_MANIFEST = fileURLToPath(

45 45
 */
46 46
const stage = (mutateManifest?: (manifest: Record<string, unknown>) => void): string => {
47 47
  const dir = mkdtempSync(join(tmpdir(), "plugin-mount-"));
48
  const manifest = JSON.parse(readFileSync(FILE_STATS_MANIFEST, "utf8")) as Record<
49
    string,
50
    unknown
51
  >;
48
  const manifest = JSON.parse(readFileSync(FILE_STATS_MANIFEST, "utf8")) as Record<string, unknown>;
52 49
  mutateManifest?.(manifest);
53 50
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
54 51
  copyFileSync(FILE_STATS_WASM, join(dir, "file_stats.wasm"));

@@ -70,10 +67,7 @@ const statPath = async (

70 67
  const packet = new TextEncoder().encode(JSON.stringify({ path }));
71 68
  const outcome = await invokePlugin(plugin, packet);
72 69
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
73
  return JSON.parse(new TextDecoder().decode(outcome)) as Record<
74
    string,
75
    Record<string, unknown>
76
  >;
70
  return JSON.parse(new TextDecoder().decode(outcome)) as Record<string, Record<string, unknown>>;
77 71
};
78 72
79 73
describe("read-only mounts", () => {
packages/openagents-cli/test/coder-resume.test.ts modified +6 -6

@@ -220,7 +220,7 @@ describe("listThreads", () => {

220 220
221 221
    const threads = await listThreads({ origin: ORIGIN, token: TOKEN, fetch: transport });
222 222
223
    expect(calls[0]?.url).toBe(`${ORIGIN}/api/v3/threads?limit=50`);
223
    expect(calls[0]?.url).toBe(`${ORIGIN}/api/v1/threads?limit=50`);
224 224
    expect(calls[0]?.authorization).toBe(`Bearer ${TOKEN}`);
225 225
    expect(threads).toHaveLength(1);
226 226
    expect(threads[0]?.repository).toBe("openagents");

@@ -301,9 +301,9 @@ describe("fetchAllEvents", () => {

301 301
    expect(events).toHaveLength(120);
302 302
    expect(events.map((event) => event.id)).toEqual(all.map((event) => event.id));
303 303
    expect(urls).toEqual([
304
      `${ORIGIN}/api/v3/threads/${THREAD_ID}/events?limit=50`,
305
      `${ORIGIN}/api/v3/threads/${THREAD_ID}/events?limit=50&after=50`,
306
      `${ORIGIN}/api/v3/threads/${THREAD_ID}/events?limit=50&after=100`,
304
      `${ORIGIN}/api/v1/threads/${THREAD_ID}/events?limit=50`,
305
      `${ORIGIN}/api/v1/threads/${THREAD_ID}/events?limit=50&after=50`,
306
      `${ORIGIN}/api/v1/threads/${THREAD_ID}/events?limit=50&after=100`,
307 307
    ]);
308 308
  });
309 309

@@ -452,7 +452,7 @@ const sse = (frames: ReadonlyArray<string>) =>

452 452
  });
453 453
454 454
describe("remintThread", () => {
455
  // The same `minted_view` shape `POST /api/v3/threads` returns for a grant at
455
  // The same `minted_view` shape `POST /api/v1/threads` returns for a grant at
456 456
  // minting: token, url, model, expires_at, limits — and no `remaining`,
457 457
  // because a freshly minted grant has spent nothing.
458 458
  const REMINTED = {

@@ -516,7 +516,7 @@ describe("remintThread", () => {

516 516
    const source = await remintThread({ origin: ORIGIN, token: TOKEN, threadId: THREAD_ID });
517 517
518 518
    expect(calls[0]?.method).toBe("POST");
519
    expect(calls[0]?.url).toBe(`${ORIGIN}/api/v3/threads/${THREAD_ID}/grants`);
519
    expect(calls[0]?.url).toBe(`${ORIGIN}/api/v1/threads/${THREAD_ID}/grants`);
520 520
    expect(source.threadId).toBe(THREAD_ID);
521 521
    expect(source.model).toBe("gpt-5.6-luna");
522 522
    // A fresh grant has spent nothing, so the budget opens at its ceilings.
packages/openagents-cli/test/coder-self-harness.test.ts modified +19 -5

@@ -94,7 +94,10 @@ describe("a child this process runs itself", () => {

94 94
    // A child that fans out is a fan-out nobody asked for, and a child that
95 95
    // stops without saying so is reported to the parent as having succeeded.
96 96
    const sent = stub([sse([`data: [DONE]`])]);
97
    const harness = new SelfHarness({ grant: GRANT, tools: (cwd) => [tool(`in-${cwd}`, async () => "")] });
97
    const harness = new SelfHarness({
98
      grant: GRANT,
99
      tools: (cwd) => [tool(`in-${cwd}`, async () => "")],
100
    });
98 101
99 102
    await drain(harness, "do it", scratch());
100 103

@@ -166,11 +169,17 @@ describe("a child this process runs itself", () => {

166 169
      sse([`data: {"choices":[{"delta":{"content":"/repo"}}]}`, `data: [DONE]`]),
167 170
    ]);
168 171
    const path = scratch();
169
    const harness = new SelfHarness({ grant: GRANT, tools: () => [tool("shell", async () => "/repo")] });
172
    const harness = new SelfHarness({
173
      grant: GRANT,
174
      tools: () => [tool("shell", async () => "/repo")],
175
    });
170 176
171 177
    await drain(harness, "where", path);
172 178
173
    const lines = readFileSync(path, "utf8").trim().split("\n").map((line) => JSON.parse(line));
179
    const lines = readFileSync(path, "utf8")
180
      .trim()
181
      .split("\n")
182
      .map((line) => JSON.parse(line));
174 183
    expect(lines.map((line: { type: string }) => line.type)).toEqual([
175 184
      "session",
176 185
      "tool",

@@ -188,7 +197,10 @@ describe("a child this process runs itself", () => {

188 197
      // The provider drops. The fleet retries with the session it was given.
189 198
      sse([`data: {"choices":[{"delta":{"content":"carried on"}}]}`, `data: [DONE]`]),
190 199
    ]);
191
    const harness = new SelfHarness({ grant: GRANT, tools: () => [tool("shell", async () => "ok")] });
200
    const harness = new SelfHarness({
201
      grant: GRANT,
202
      tools: () => [tool("shell", async () => "ok")],
203
    });
192 204
    const path = scratch();
193 205
194 206
    const first = await drain(harness, "long task", path);

@@ -200,6 +212,8 @@ describe("a child this process runs itself", () => {

200 212
    stub([new Response("no", { status: 403 })]);
201 213
    const harness = new SelfHarness({ grant: GRANT, tools: () => [] });
202 214
203
    await expect(drain(harness, "go", scratch())).rejects.toThrow(/refused the child's call \(403\)/);
215
    await expect(drain(harness, "go", scratch())).rejects.toThrow(
216
      /refused the child's call \(403\)/,
217
    );
204 218
  });
205 219
});
packages/openagents-cli/test/coder-shell.test.ts modified +1 -4

@@ -87,10 +87,7 @@ describe("running a command", () => {

87 87
    const directory = mkdtempSync(join(tmpdir(), "coder-shell-"));
88 88
    writeFileSync(join(directory, "marker.txt"), "here");
89 89
90
    const output = await shellTool(directory).run(
91
      { command: "ls" },
92
      new AbortController().signal,
93
    );
90
    const output = await shellTool(directory).run({ command: "ls" }, new AbortController().signal);
94 91
95 92
    expect(output).toContain("marker.txt");
96 93
  });
packages/openagents-cli/test/coder-skills.test.ts modified +55 -26

@@ -56,7 +56,9 @@ describe("discovering skills", () => {

56 56
    const root = workspace({ "house-style": SKILL });
57 57
    mkdirSync(join(root, ".agents", "skills", "empty"), { recursive: true });
58 58
59
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS).map((skill) => skill.name)).toEqual(["house-style"]);
59
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS).map((skill) => skill.name)).toEqual([
60
      "house-style",
61
    ]);
60 62
  });
61 63
62 64
  it("skips a skill missing a name or a description", () => {

@@ -67,10 +69,11 @@ describe("discovering skills", () => {

67 69
    });
68 70
69 71
    // One cannot be asked for and the other gives nothing to choose on.
70
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS).map((skill) => skill.name)).toEqual(["house-style"]);
72
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS).map((skill) => skill.name)).toEqual([
73
      "house-style",
74
    ]);
71 75
  });
72 76
73
74 77
  it("reads a description written as a folded block, not the block marker", () => {
75 78
    const root = workspace({
76 79
      folded: [

@@ -93,16 +96,25 @@ describe("discovering skills", () => {

93 96
94 97
  it("keeps the line breaks of a literal block", () => {
95 98
    const root = workspace({
96
      literal: ["---", "name: literal", "description: |", "  One.", "  Two.", "---", "", "Body."].join(
97
        "\n",
98
      ),
99
      literal: [
100
        "---",
101
        "name: literal",
102
        "description: |",
103
        "  One.",
104
        "  Two.",
105
        "---",
106
        "",
107
        "Body.",
108
      ].join("\n"),
99 109
    });
100 110
101 111
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS)[0]?.description).toBe("One.\nTwo.");
102 112
  });
103 113
104 114
  it("is empty for a repository with no skills directory", () => {
105
    expect(discoverSkills(mkdtempSync(join(tmpdir(), "coder-skills-")), EMPTY_HOME, NO_BUILT_INS)).toEqual([]);
115
    expect(
116
      discoverSkills(mkdtempSync(join(tmpdir(), "coder-skills-")), EMPTY_HOME, NO_BUILT_INS),
117
    ).toEqual([]);
106 118
  });
107 119
108 120
  it("strips the quotes from a quoted description", () => {

@@ -110,7 +122,9 @@ describe("discovering skills", () => {

110 122
      quoted: '---\nname: quoted\ndescription: "Quoted, with a comma."\n---\n\nBody.',
111 123
    });
112 124
113
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS)[0]?.description).toBe("Quoted, with a comma.");
125
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS)[0]?.description).toBe(
126
      "Quoted, with a comma.",
127
    );
114 128
  });
115 129
});
116 130

@@ -127,9 +141,9 @@ describe("the skill tool", () => {

127 141
  });
128 142
129 143
  it("offers only the names it found, so a model cannot ask for another", () => {
130
    expect((tool.parameters["properties"] as Record<string, { enum: string[] }>)["name"]?.enum).toEqual([
131
      "house-style",
132
    ]);
144
    expect(
145
      (tool.parameters["properties"] as Record<string, { enum: string[] }>)["name"]?.enum,
146
    ).toEqual(["house-style"]);
133 147
  });
134 148
135 149
  it("hands back the body when asked", async () => {

@@ -182,7 +196,11 @@ describe("choosing which skills the model is offered", () => {

182 196
183 197
    loadSkillSelection(root, where, NO_BUILT_INS).toggle("beta");
184 198
185
    expect(loadSkillSelection(root, where, NO_BUILT_INS).active().map((skill) => skill.name)).toEqual(["alpha"]);
199
    expect(
200
      loadSkillSelection(root, where, NO_BUILT_INS)
201
        .active()
202
        .map((skill) => skill.name),
203
    ).toEqual(["alpha"]);
186 204
  });
187 205
188 206
  it("switches one back on", () => {

@@ -192,10 +210,11 @@ describe("choosing which skills the model is offered", () => {

192 210
    loadSkillSelection(root, where, NO_BUILT_INS).toggle("beta");
193 211
    loadSkillSelection(root, where, NO_BUILT_INS).toggle("beta");
194 212
195
    expect(loadSkillSelection(root, where, NO_BUILT_INS).active().map((skill) => skill.name)).toEqual([
196
      "alpha",
197
      "beta",
198
    ]);
213
    expect(
214
      loadSkillSelection(root, where, NO_BUILT_INS)
215
        .active()
216
        .map((skill) => skill.name),
217
    ).toEqual(["alpha", "beta"]);
199 218
  });
200 219
201 220
  it("keeps the choice to the workspace it was made in", () => {

@@ -206,10 +225,11 @@ describe("choosing which skills the model is offered", () => {

206 225
    loadSkillSelection(one, where, NO_BUILT_INS).toggle("alpha");
207 226
208 227
    // A skill switched off for one repository is not switched off everywhere.
209
    expect(loadSkillSelection(other, where, NO_BUILT_INS).active().map((skill) => skill.name)).toEqual([
210
      "alpha",
211
      "beta",
212
    ]);
228
    expect(
229
      loadSkillSelection(other, where, NO_BUILT_INS)
230
        .active()
231
        .map((skill) => skill.name),
232
    ).toEqual(["alpha", "beta"]);
213 233
  });
214 234
215 235
  it("offers a skill added after the choice was made", () => {

@@ -224,10 +244,11 @@ describe("choosing which skills the model is offered", () => {

224 244
    );
225 245
226 246
    // Off is what is recorded, so something nobody has ruled on is on.
227
    expect(loadSkillSelection(root, where, NO_BUILT_INS).active().map((skill) => skill.name)).toEqual([
228
      "beta",
229
      "gamma",
230
    ]);
247
    expect(
248
      loadSkillSelection(root, where, NO_BUILT_INS)
249
        .active()
250
        .map((skill) => skill.name),
251
    ).toEqual(["beta", "gamma"]);
231 252
  });
232 253
});
233 254

@@ -268,14 +289,22 @@ describe("what a session is told without asking", () => {

268 289
  const NORMAL = "---\nname: other\ndescription: Something else.\n---\n\nRead me on request.";
269 290
270 291
  it("marks a skill that loads itself", () => {
271
    const found = discoverSkills(workspace({ method: AUTO, other: NORMAL }), EMPTY_HOME, NO_BUILT_INS);
292
    const found = discoverSkills(
293
      workspace({ method: AUTO, other: NORMAL }),
294
      EMPTY_HOME,
295
      NO_BUILT_INS,
296
    );
272 297
273 298
    expect(found.find((skill) => skill.name === "method")?.auto).toBe(true);
274 299
    expect(found.find((skill) => skill.name === "other")?.auto).toBe(false);
275 300
  });
276 301
277 302
  it("carries an auto-loaded body and leaves the rest in the catalog", () => {
278
    const found = discoverSkills(workspace({ method: AUTO, other: NORMAL }), EMPTY_HOME, NO_BUILT_INS);
303
    const found = discoverSkills(
304
      workspace({ method: AUTO, other: NORMAL }),
305
      EMPTY_HOME,
306
      NO_BUILT_INS,
307
    );
279 308
280 309
    const standing = standingContext(found, "/somewhere/else") ?? "";
281 310
    expect(standing).toContain("Work this way.");
packages/openagents-cli/test/coder-steer.test.ts modified +4 -1

@@ -18,7 +18,10 @@ describe("steering a running turn", () => {

18 18
        // Round 0 asks for a tool and waits, so a steer can arrive mid-turn.
19 19
        const pieces =
20 20
          mine === 0
21
            ? [chunk({ content: "", tool_calls: [{ function: { name: "t", arguments: {} } }] }), chunk({}, true)]
21
            ? [
22
                chunk({ content: "", tool_calls: [{ function: { name: "t", arguments: {} } }] }),
23
                chunk({}, true),
24
              ]
22 25
            : [chunk({ content: "done" }, true)];
23 26
        return Object.assign(
24 27
          (async function* () {
packages/openagents-cli/test/coder-tools-openagents.test.ts modified -1

@@ -20,7 +20,6 @@ describe("the openagents tool", () => {

20 20
    await expect(run(["--version"])).resolves.toContain("openagents v");
21 21
  });
22 22
23
24 23
  it("carries the command tree, so a session need not go looking for one", async () => {
25 24
    const { description } = openagentsTool();
26 25
packages/openagents-cli/test/coder-transcript.test.ts modified +1 -1

@@ -71,7 +71,7 @@ describe("ThreadTranscriptWriter", () => {

71 71
    expect(wire.calls).toHaveLength(1);
72 72
    const call = wire.calls[0];
73 73
    expect(call?.method).toBe("POST");
74
    expect(call?.url).toBe(`${ORIGIN}/api/v3/threads/${THREAD_ID}/events`);
74
    expect(call?.url).toBe(`${ORIGIN}/api/v1/threads/${THREAD_ID}/events`);
75 75
    expect(call?.authorization).toBe(`Bearer ${TOKEN}`);
76 76
    expect(call?.body).toEqual({
77 77
      event_type: "turn.user",
packages/openagents-cli/test/coder-ui.test.ts modified +3 -10

@@ -1171,10 +1171,7 @@ describe("inspecting a child from the column", () => {

1171 1171
  });
1172 1172
1173 1173
  it("moves the selector without moving the transcript", async () => {
1174
    const frames = await driveKeys(two, [
1175
      ["\x1b[C"],
1176
      ["\x1b[B"],
1177
    ]);
1174
    const frames = await driveKeys(two, [["\x1b[C"], ["\x1b[B"]]);
1178 1175
1179 1176
    // Down in the column selects the second child. It must not scroll the
1180 1177
    // conversation, which is what down does when the composer has the keys.

@@ -1211,14 +1208,10 @@ describe("inspecting a child from the column", () => {

1211 1208
  });
1212 1209
1213 1210
  it("ignores right when there is no column to move into", async () => {
1214
    const frames = await driveKeys(
1215
      () => undefined,
1216
      [["\x1b[C"]],
1217
    );
1211
    const frames = await driveKeys(() => undefined, [["\x1b[C"]]);
1218 1212
1219 1213
    expect((frames[0] ?? []).join("\n")).not.toContain("enter opens");
1220 1214
  });
1221 1215
1222
  const drillKeys = async () =>
1223
    driveKeys(two, [[], ["\x1b[C"], ["\x1b[D"]]);
1216
  const drillKeys = async () => driveKeys(two, [[], ["\x1b[C"], ["\x1b[D"]]);
1224 1217
});
packages/openagents-cli/test/coder-zen.test.ts modified +1 -3

@@ -101,9 +101,7 @@ describe("finding the credential", () => {

101 101
102 102
describe("answering a turn", () => {
103 103
  it("calls Zen with the slug and the borrowed key", async () => {
104
    const sent = stub([
105
      sse([`data: {"choices":[{"delta":{"content":"Hi"}}]}`, `data: [DONE]`]),
106
    ]);
104
    const sent = stub([sse([`data: {"choices":[{"delta":{"content":"Hi"}}]}`, `data: [DONE]`])]);
107 105
    const source = new ZenReplySource({ model: "ox-alpha", key: "secret-key" });
108 106
109 107
    expect(textOf(await chunks(source, "hello"))).toBe("Hi");
packages/openagents-cli/test/deploy-command.test.ts modified +6 -6

@@ -38,7 +38,7 @@ const targetBody = (status: string, overrides: Record<string, unknown> = {}) =>

38 38
  error_code: null,
39 39
  promoted_at: "2026-08-24T00:00:00Z",
40 40
  updated_at: "2026-08-24T00:00:00Z",
41
  status_url: `http://localhost:4000/api/v3/admin/forge/targets/${targetId}`,
41
  status_url: `http://localhost:4000/api/v1/admin/forge/targets/${targetId}`,
42 42
  ...overrides,
43 43
});
44 44

@@ -111,7 +111,7 @@ describe("deploy commands", () => {

111 111
112 112
    expect(requests).toHaveLength(1);
113 113
    expect(requests[0]?.method).toBe("POST");
114
    expect(requests[0]?.path).toBe("/api/v3/admin/forge/targets");
114
    expect(requests[0]?.path).toBe("/api/v1/admin/forge/targets");
115 115
    expect(requests[0]?.body).toEqual({
116 116
      repo: "openagents.com",
117 117
      sha: fullSha,

@@ -247,8 +247,8 @@ describe("deploy commands", () => {

247 247
    await run(["--json", ...promoteArgv(["--wait", "--idempotency-key", "release-key-0002"])]);
248 248
249 249
    expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([
250
      "POST /api/v3/admin/forge/targets",
251
      `GET /api/v3/admin/forge/targets/${targetId}`,
250
      "POST /api/v1/admin/forge/targets",
251
      `GET /api/v1/admin/forge/targets/${targetId}`,
252 252
    ]);
253 253
    const value = written[0]?.document.value as Record<string, unknown>;
254 254
    expect(value["outcome"]).toBe("live");

@@ -341,7 +341,7 @@ describe("deploy commands", () => {

341 341
342 342
    await run(["--json", "deploy", "view", targetId]);
343 343
344
    expect(requests[0]?.path).toBe(`/api/v3/admin/forge/targets/${targetId}`);
344
    expect(requests[0]?.path).toBe(`/api/v1/admin/forge/targets/${targetId}`);
345 345
    const value = written[0]?.document.value as Record<string, unknown>;
346 346
    expect(value["outcome"]).toBe("pending");
347 347
    expect(value["terminal"]).toBe(false);

@@ -361,7 +361,7 @@ describe("deploy commands", () => {

361 361
362 362
    await run(["--json", "deploy", "list", "--repo", "openagents.com", "--limit", "5"]);
363 363
364
    expect(requests[0]?.path).toBe("/api/v3/admin/forge/targets?repo=openagents.com&limit=5");
364
    expect(requests[0]?.path).toBe("/api/v1/admin/forge/targets?repo=openagents.com&limit=5");
365 365
    expect(written[0]?.document.value).toEqual({
366 366
      repo: "openagents.com",
367 367
      targets: [targetBody("live")],
packages/openagents-cli/test/device-client.test.ts modified +2 -2

@@ -63,11 +63,11 @@ describe("device authorization client", () => {

63 63
    const result = await Effect.runPromise(program);
64 64
    expect(Redacted.value(result.token)).toBe("oa_pat_fixture");
65 65
    expect(result.requests[0]).toEqual({
66
      path: "/api/v3/device/authorizations",
66
      path: "/api/v1/device/authorizations",
67 67
      body: {},
68 68
    });
69 69
    expect(result.requests[1]).toEqual({
70
      path: "/api/v3/device/authorizations/token",
70
      path: "/api/v1/device/authorizations/token",
71 71
      body: { device_code: "secret-device-code" },
72 72
    });
73 73
  });
packages/openagents-cli/test/fleet-client.test.ts modified +3 -3

@@ -23,7 +23,7 @@ const targetFixture = (status: string, overrides: Record<string, unknown> = {})

23 23
  promoted_at: "2026-08-24T00:00:00Z",
24 24
  updated_at: "2026-08-24T00:00:00Z",
25 25
  status_url:
26
    "http://localhost:4000/api/v3/admin/forge/targets/0d4e8a70-0000-4000-8000-000000000001",
26
    "http://localhost:4000/api/v1/admin/forge/targets/0d4e8a70-0000-4000-8000-000000000001",
27 27
  ...overrides,
28 28
});
29 29

@@ -62,7 +62,7 @@ describe("fleet client", () => {

62 62
63 63
    expect(requests).toHaveLength(1);
64 64
    expect(requests[0]?.method).toBe("POST");
65
    expect(requests[0]?.path).toBe("/api/v3/admin/forge/targets");
65
    expect(requests[0]?.path).toBe("/api/v1/admin/forge/targets");
66 66
    expect(requests[0]?.body).toEqual({
67 67
      repo: "openagents.com",
68 68
      sha: "a".repeat(40),

@@ -298,6 +298,6 @@ describe("fleet client", () => {

298 298
    );
299 299
300 300
    expect(requests[0]?.method).toBe("GET");
301
    expect(requests[0]?.path).toBe("/api/v3/admin/forge/targets?repo=openagents.com&limit=5");
301
    expect(requests[0]?.path).toBe("/api/v1/admin/forge/targets?repo=openagents.com&limit=5");
302 302
  });
303 303
});
packages/openagents-cli/test/issue-client.test.ts modified +5 -5

@@ -130,7 +130,7 @@ describe("issue client", () => {

130 130
    );
131 131
132 132
    expect(requests[0]?.method).toBe("PATCH");
133
    expect(requests[0]?.path).toBe("/api/v3/repos/octavia/project/issues/155");
133
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project/issues/155");
134 134
    expect(requests[0]?.body).toEqual({ state: "closed" });
135 135
    expect(Object.keys(requests[0]?.body as Record<string, unknown>)).not.toContain("body");
136 136
  });

@@ -155,9 +155,9 @@ describe("issue client", () => {

155 155
    );
156 156
157 157
    expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([
158
      "GET /api/v3/repos/octavia/project/issues/129/dependencies",
159
      "POST /api/v3/repos/octavia/project/issues/129/dependencies",
160
      "DELETE /api/v3/repos/octavia/project/issues/129/dependencies/81",
158
      "GET /api/v1/repos/octavia/project/issues/129/dependencies",
159
      "POST /api/v1/repos/octavia/project/issues/129/dependencies",
160
      "DELETE /api/v1/repos/octavia/project/issues/129/dependencies/81",
161 161
    ]);
162 162
    expect(requests[1]?.body).toEqual({ blocked_by: [80, 81] });
163 163
    expect(requests[2]?.body).toBeUndefined();

@@ -177,7 +177,7 @@ describe("issue client", () => {

177 177
                message: "Validation Failed",
178 178
                code: "validation_failed",
179 179
                status: 422,
180
                documentation_url: "http://localhost:4000/api/v3",
180
                documentation_url: "http://localhost:4000/api/v1",
181 181
                request_id: "request-1",
182 182
                errors: { blocked_by: ["Issue #99999 does not exist in this repository"] },
183 183
              },
packages/openagents-cli/test/issue-command.test.ts modified +8 -8

@@ -86,7 +86,7 @@ describe("issue and project commands", () => {

86 86
87 87
    await run(["--json", "issue", "list", "--limit", "40"]);
88 88
89
    expect(requests[0]?.path.startsWith("/api/v3/repos/octavia/project/issues?")).toBe(true);
89
    expect(requests[0]?.path.startsWith("/api/v1/repos/octavia/project/issues?")).toBe(true);
90 90
    expect(requests).toHaveLength(2);
91 91
    const value = written[0]?.document.value as {
92 92
      readonly pagination: Record<string, unknown>;

@@ -111,7 +111,7 @@ describe("issue and project commands", () => {

111 111
112 112
    await run(["issue", "list", "-R", "OpenAgentsInc/openagents.com"]);
113 113
114
    expect(requests[0]?.path.startsWith("/api/v3/repos/OpenAgentsInc/openagents.com/issues?")).toBe(
114
    expect(requests[0]?.path.startsWith("/api/v1/repos/OpenAgentsInc/openagents.com/issues?")).toBe(
115 115
      true,
116 116
    );
117 117
  });

@@ -130,8 +130,8 @@ describe("issue and project commands", () => {

130 130
    await run(["issue", "close", "#155", "--comment", "why"]);
131 131
132 132
    expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([
133
      "POST /api/v3/repos/octavia/project/issues/155/comments",
134
      "PATCH /api/v3/repos/octavia/project/issues/155",
133
      "POST /api/v1/repos/octavia/project/issues/155/comments",
134
      "PATCH /api/v1/repos/octavia/project/issues/155",
135 135
    ]);
136 136
    expect(requests[0]?.body).toEqual({ body: "why" });
137 137
    expect(requests[1]?.body).toEqual({ state: "closed" });

@@ -184,8 +184,8 @@ describe("issue and project commands", () => {

184 184
    await run(["--json", "issue", "deps", "129", "--add", "#80", "--remove", "81"]);
185 185
186 186
    expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([
187
      "POST /api/v3/repos/octavia/project/issues/129/dependencies",
188
      "DELETE /api/v3/repos/octavia/project/issues/129/dependencies/81",
187
      "POST /api/v1/repos/octavia/project/issues/129/dependencies",
188
      "DELETE /api/v1/repos/octavia/project/issues/129/dependencies/81",
189 189
    ]);
190 190
    expect(written[0]?.mode).toBe("json");
191 191
    expect(written[0]?.document.value).toEqual(graph);

@@ -213,7 +213,7 @@ describe("issue and project commands", () => {

213 213
214 214
    await run(["--json", "project", "list"]);
215 215
216
    expect(requests[0]?.path).toBe("/api/v3/repos/octavia/project/projectsV2");
216
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project/projectsV2");
217 217
    expect(written[0]?.document.value).toEqual({
218 218
      projects: [{ number: 1, title: "Roadmap", state: "open", archived: false }],
219 219
    });

@@ -230,7 +230,7 @@ describe("issue and project commands", () => {

230 230
231 231
    await run(["project", "item-add", "2", "--issue", "#155"]);
232 232
233
    expect(requests[0]?.path).toBe("/api/v3/repos/octavia/project/projectsV2/2/items");
233
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project/projectsV2/2/items");
234 234
    expect(requests[0]?.body).toEqual({ issue_number: 155 });
235 235
  });
236 236
});
packages/openagents-cli/test/repository-client.test.ts modified +8 -8

@@ -81,7 +81,7 @@ describe("repository client", () => {

81 81
      }).pipe(Effect.provide(layer)),
82 82
    );
83 83
    expect(user.login).toBe("octavia");
84
    expect(requests[0]?.path).toBe("/api/v3/user");
84
    expect(requests[0]?.path).toBe("/api/v1/user");
85 85
  });
86 86
87 87
  it("creates a personal repository with the Phoenix API contract", async () => {

@@ -106,7 +106,7 @@ describe("repository client", () => {

106 106
107 107
    expect(repository.full_name).toBe("octavia/project");
108 108
    expect(requests).toHaveLength(1);
109
    expect(requests[0]?.path).toBe("/api/v3/user/repos");
109
    expect(requests[0]?.path).toBe("/api/v1/user/repos");
110 110
    expect(requests[0]?.body).toEqual({ name: "project", private: true });
111 111
    expect(requests[0]?.headers?.["idempotency-key"]).toBeTypeOf("string");
112 112
  });

@@ -131,7 +131,7 @@ describe("repository client", () => {

131 131
        });
132 132
      }).pipe(Effect.provide(layer)),
133 133
    );
134
    expect(requests[0]?.path).toBe("/api/v3/orgs/acme/repos");
134
    expect(requests[0]?.path).toBe("/api/v1/orgs/acme/repos");
135 135
  });
136 136
137 137
  it("reports repository provisioning progress before completion", async () => {

@@ -225,7 +225,7 @@ describe("repository client", () => {

225 225
    const layer = layerFromHandler((input) =>
226 226
      Effect.sync(() => {
227 227
        requests.push(input);
228
        return input.path.startsWith("/api/v3/user/repos")
228
        return input.path.startsWith("/api/v1/user/repos")
229 229
          ? { status: 200, body: { repositories: [repositoryFixture()], next_cursor: "next" } }
230 230
          : { status: 200, body: repositoryFixture() };
231 231
      }),

@@ -253,7 +253,7 @@ describe("repository client", () => {

253 253
    expect(result.list.repositories).toHaveLength(1);
254 254
    expect(result.list.nextCursor).toBe("next");
255 255
    expect(requests[0]?.path).toBe(
256
      "/api/v3/user/repos?per_page=12&after=cursor+value&namespace=octavia",
256
      "/api/v1/user/repos?per_page=12&after=cursor+value&namespace=octavia",
257 257
    );
258 258
    expect(result.view.full_name).toBe("octavia/project");
259 259
  });

@@ -281,7 +281,7 @@ describe("repository client", () => {

281 281
282 282
    expect(requests).toHaveLength(1);
283 283
    expect(requests[0]?.method).toBe("DELETE");
284
    expect(requests[0]?.path).toBe("/api/v3/repos/octavia/project");
284
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project");
285 285
  });
286 286
287 287
  it("routes GitHub imports to the selected personal or organization namespace", async () => {

@@ -321,8 +321,8 @@ describe("repository client", () => {

321 321
      }).pipe(Effect.provide(layer)),
322 322
    );
323 323
    expect(requests.map((request) => request.path)).toEqual([
324
      "/api/v3/user/repos/imports",
325
      "/api/v3/orgs/acme/repos/imports",
324
      "/api/v1/user/repos/imports",
325
      "/api/v1/orgs/acme/repos/imports",
326 326
    ]);
327 327
    expect(requests[0]?.body).toEqual({
328 328
      source: { provider: "github", repository: "octavia/project" },
packages/openagents-cli/test/trace-command.test.ts modified +1 -1

@@ -179,7 +179,7 @@ describe("openagents trace", () => {

179 179
    const { fail } = harness();
180 180
    const error = await fail(["trace", "upload", path]);
181 181
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.TraceUploadUnsupported" });
182
    expect(String((error as { message: string }).message)).toContain("POST /api/v3/traces");
182
    expect(String((error as { message: string }).message)).toContain("POST /api/v1/traces");
183 183
  });
184 184
185 185
  it("still validates the local path before refusing an upload", async () => {

This page updates live while a promote is in flight · changelog