Skip to content

Promote develop to main (v0.6.5) - #108

Merged
its-janghoon merged 36 commits into
mainfrom
develop
Oct 15, 2026
Merged

its-janghoon merged 36 commits into
mainfrom
develop

Conversation

@its-janghoon

Copy link
Copy Markdown
Member

Brings main up to the release line: shared daemon, engine-client crate, idle-exit fix (#100), OIDC signing (#104), shared gitflow (#105), chat-completions LLM client fix (#107). main had been stuck at 2026-09-22; office v0.10.6 bundled an engine built from it.

its-janghoon and others added 30 commits September 22, 2026 13:26
Three products are written against a local engine endpoint that has never
existed. redrob-browser POSTs to /experimental/external/chat/completions and
streams the reply; no such route is served, and the string "external" does not
appear under packages/redrob/src/server. redrob-reblend ranks a LOCAL transport
its own sidecar cannot use. redrob-office compiles this engine at release time,
ships it inside its installer behind a packaging guard, and never calls it.

The products that drive the engine successfully drive its session API. The ones
that tried to give it an OpenAI face are the broken ones. So this serves that
face for real, at one stable path.

Decisions recorded here:

- POST /v1/chat/completions is the core path, with no alias. It sits on
  packages/llm and the provider registry, not on Session: a completion is
  stateless and a session is not, and local inference then falls out as just
  another resolved provider.
- Tool ownership is decided by the request, not configured. `tools` present
  means the CALLER owns them and the engine returns tool_calls; absent means the
  engine runs its own. Office edits a document held in its renderer and browser
  drives a live tab, so for them caller ownership is the only correct answer.
- Inbound auth is the server's existing Basic. Outbound is the engine's OWN
  Console credential, which is what makes one login serve every product.
- 401 carries code "engine_not_authenticated", and that code alone may trigger a
  sign-in prompt. Branching on message text instead is how a network timeout
  shipped as "please log in".
- Every /experimental route is promoted in one breaking rename. No product calls
  any of them; the only callers are this repo's generated SDKs, which
  httpapi-codegen regenerates. No compatibility window is owed to callers that
  do not exist.
- Products stop bundling the binary. One shared daemon, recorded at
  ~/.redrob/run/server.json (0600), resolved via REDROB_CODE_BIN then
  $HOME/.redrob/bin then PATH. Per-product processes were rejected: with local
  inference that loads the same weights N times.
- Nothing pins an engine version. /capabilities gains a chatCompletions entry
  and products declare a MINIMUM capability, so auto-update cannot break them
  silently. Compatibility is kept by rules plus a contract test that fails CI
  when a v1 field is removed, not by a promise.

No code changes. The route, the promotion, the contract test, and the per-product
migration follow as their own pull requests.

Co-authored-by: Janghoon Lee <44862514+savagemanage@users.noreply.github.com>
* feat(api): the /v1/chat/completions contract and its wire conversion

First half of the route from docs/LOCAL-ENGINE-API.md: the declared contract and
the translation layer. No handler yet, so nothing is served — the endpoint is not
reachable until the following commit wires it.

The group goes on the v2 protocol surface rather than the instance httpapi:
Authorization is declared once at that HttpApi level so a new group gets it
instead of having to remember .middleware(Authorization), it has the typed SSE
helper (HttpApiSchema.StreamSse), and it is the surface with a contract
changelog. Paths there are literal, so /v1/... sits alongside /api/... without a
prefix mechanism.

Tool ownership is decided by the request and nothing else: `tools` present means
the caller executes them and the turn ends with finish_reason tool_calls;
`tools` absent means a plain completion. One rule rather than three configurable
ones, and it is what Office and browser already assume — their tools mutate an
open document and a live tab, which the engine cannot do on their behalf.

Errors are one class per status sharing the OpenAI envelope. Collapsing them
would report a rejected key as a bad request, and the status is what a client's
transport sees first. 401 carries code `engine_not_authenticated`, which is the
only condition a client may turn into a sign-in prompt.

The conversion layer is pure — no Effect, no services — because it is where the
silent failures live: a mistranslation does not fail the request, it just gives
the model something other than what the caller wrote. What it refuses is the
interesting part. Malformed tool_call arguments and an unsupported content part
are rejected rather than forwarded as {} or dropped, since an image quietly
discarded looks to a user like the model ignored it. A tool message with no
tool_call_id is refused because the provider pairs results to calls by that id
and there is nothing sensible to guess.

Two asymmetries the tests pin down: the wire carries tool arguments as a JSON
string while the engine wants them parsed, so both directions convert; and
"error"/"unknown" finish reasons have no OpenAI equivalent and must not be
reported as "stop", which would tell the caller a failed turn ended cleanly.

22 tests pass. protocol and server typecheck clean.

* feat(api): serve POST /v1/chat/completions

The handler for the contract in the previous commit. The route is now reachable.

It touches no Session, Agent or tool runtime: it resolves a provider the way the
agent does (catalog entry plus the active integration credential), makes one
model call, and translates. Everything stateful stays in the session API.

handleRaw rather than handle, because the reply is JSON or text/event-stream
depending on `stream`, and only the raw form chooses its own response. The cost
is that the payload is not decoded for you, so the declared schema is applied in
the handler rather than the body being trusted.

Streaming is where the care went. Tool calls are emitted from the completed
`tool-call` event, not from `tool-input-delta`: the engine parses arguments and
the wire wants a JSON string, so re-emitting fragments would mean re-serializing
a half-parsed value. And `provider-error` arrives as a stream ELEMENT rather than
a failure — left unhandled, an upstream 500 would end the stream with a clean
[DONE] and the caller would read a truncated answer as a complete one, so it is
matched explicitly and sent as an error frame. Once headers are out, a frame is
the only way to say the turn broke.

Error mapping keeps one value load-bearing: 401 with code
engine_not_authenticated, covering both a missing credential and a rejected one —
from the caller's side those are one problem and neither is fixed by retrying.
What is NOT remapped is a content-policy classification: the executor checks the
body before the status, so an upstream 401 mentioning safety arrives as
ContentPolicy, and reclassifying it here would report a policy refusal as a login
problem.

A caller-supplied base URL is deliberately not accepted. The config layer only
admits a new openai-compatible provider at a local address (isLocalEndpoint);
honouring an arbitrary URL here would bypass that and forward the engine's own
credential to whatever host the caller named. A local model is selected by its
`provider/model` id instead.

Four APIs were guessed wrong first and corrected against the source, not
worked around: this Effect version has no Effect.either, no Effect.catchAll and
no Stream.mapConcat, LLMResponse carries finishReason rather than a finish
object, and catalog.model.get returns an Effect on the Service (only the Draft
form is synchronous).

tool_choice now travels as the engine's own shape instead of a bare string. A
bare string is ambiguous — the normalizer reads auto/none/required as modes and
anything else as a tool name — so a tool actually named `auto` would silently
become a mode. A test pins that case.

23 tests pass; protocol and server typecheck clean. Still to come: the
capabilities flag, the httpapi-exercise scenario (test:httpapi runs
--fail-on-missing, so CI will fail until it exists), the SSE OpenAPI patch in
public.ts, and a route test.

* docs: provider authentication, and what each vendor actually permits

The design of record for how a user connects their own model access, so one setup
serves code, cowork, office and whatever comes after.

The product ask was 'let people use the Claude or ChatGPT they already pay for'.
Half of that is not available, and the split is contractual rather than technical,
so the document leads with the survey and links every claim.

Not available: a sign-in button in our own apps that spends a consumer
subscription. Anthropic's compliance page forbids third parties offering Claude.ai
login or routing through Free/Pro/Max credentials, and bans accounts for spoofing
the Claude Code harness - our own upstream removed Claude support on 2026-02-19
under legal request. Google's Gemini CLI FAQ says piggybacking its OAuth may
terminate the account, and there is a documented mass-ban thread. OpenAI allows it
in practice, but only by embedding OpenAI's own Codex runtime, and no clause grants
it, so it is a revocable product decision.

Available, and it covers most of the intent: BYOK everywhere (Anthropic and Google
both name a user API key as THE supported third-party path), plus three real
sign-in buttons - GitHub Copilot through the Copilot SDK, which spends the user's
own Copilot subscription with GitHub's blessing and a documented desktop device
flow; OpenRouter OAuth PKCE, which reaches Claude and GPT models behind an account
login and is the closest legitimate thing to what was asked for; and Azure OpenAI
via Entra device code.

The design itself adds little and removes a lot. Credentials stay in this engine's
existing auth.json, and products stop having stores of their own - today Office,
Design, the extension, Query and Recall keep five stores that never read each
other, which is the whole reason a user logs in again in every app. Products never
hold a key: /v1/chat/completions already carries the engine's credential outward
and takes none from the caller, so adding a provider is one change here rather
than a release in every app, and an app that holds no key cannot leak one.

Four routes are proposed to expose the login flow that already exists in
, with three rules: the key never comes back out, the
OAuth code stays in the engine, and no caller-supplied base URL - that last one
because honouring it would forward this engine's credential to whatever host the
caller named.

Also records a compliance check to keep: this repository contains no Anthropic
OAuth client id and no claude.ai endpoint, and the only hardcoded vendor OAuth is
xAI's.

* docs: local harness backends for every product, from one place

Codex and Claude Code as an option in all ten products from one implementation in
this engine, instead of ten adapters in ten repositories.

Corrects where the first adapter was put. It was written inside redrob-cowork behind
that app's engine-spawn hook; it works and it found real bugs, but it gives Cowork a
Codex option and the other nine nothing. It belongs here, because every product
already reaches a model through this engine and the ones that still call the Console
directly are what /v1/chat/completions was built to bring in.

The mechanism turned out to already exist. config/plugin/local-provider.ts admits a
provider when the package is exactly the trusted @ai-sdk/openai-compatible and the
URL is on this machine - a door opened for Ollama, LM Studio, llama.cpp and vLLM
whose only shared trait is speaking OpenAI-compatible chat-completions locally. A
small local shim in front of codex exec or claude -p meets the same bar, so this
needs no new provider-loading machinery and does not widen the
arbitrary-npm-package refusal by a millimetre.

This recommends a shim where docs/CODEX-RUNTIME.md argues against one, and the doc
says why rather than pretending to be consistent: a Cowork shim must emulate the
OpenCode session API, which is large, ours and still changing, so falling subtly
behind produces bugs that look like model bugs. A shim here emulates
chat-completions, which is small, published, frozen and not ours. Different surface,
different answer.

What cannot be papered over: these are agent harnesses, not completion endpoints, so
support has two tiers. The chat tier - prompt in, text out - covers every product's
ordinary use and needs no per-product code. The agent tier, where the runtime owns a
real loop against a workspace, cannot go through chat-completions at all, because
that contract says tools present means the caller owns them while here the runtime
owns the loop. Cowork, code and cad need it; it must not block the chat tier.

One safety point that is easy to miss: a harness with a writable sandbox will read
and edit the user's filesystem, and a user asking Office to reword a sentence has
not consented to an agent walking their disk. The shim pins read-only, approvals
never, network off, unless an agent-tier caller asked for more.

Sequencing puts /v1/chat/completions first because nothing here ships before it, and
records two things to carry rather than rediscover: claude -p's stream-json schema
is unverified against the real binary, and Anthropic has announced then paused a
change capping third-party subscription usage, so the Claude backend should surface
usage state rather than assume it is free.

* feat(server): close the CI gates on /v1/chat/completions

The route had a handler and a conversion layer but nothing that made CI accept it.
This adds the coverage scenario, the capability flag, and route tests - and the
tests immediately found a real bug in the handler.

The bug: a body that does not decode against ChatCompletionRequest came back as
502 api_error. schemaBodyJson fails with HttpServerError | Schema.SchemaError, and
neither was recognised, so both fell through to the catch-all that reports an
upstream failure. A caller who sent a malformed payload was told our upstream had
failed, which sends them debugging the wrong system. Now classified 400
invalid_request_error. Matched on _tag rather than instanceof, because
Schema.SchemaError and the HTTP request errors are separate hierarchies and an
instanceof chain over both stops matching silently when either is re-exported
through a different module instance.

The coverage gate was mandatory and I nearly talked myself out of it. Reading
routing.ts, coverage derives from routeKeys(OpenApi.fromApi(modules.PublicApi)) --
the instance httpapi -- while this route is registered on the v2 protocol surface,
so I concluded it was out of scope. Running it said otherwise: PublicApi merges the
v2 groups, the exerciser reported MISS POST /v1/chat/completions, and
--fail-on-missing failed the run. The scenario is real and the reading was wrong.

That scenario deliberately exercises the 400 path. A real completion needs a
provider credential this harness has none of, so asserting 200 would make the gate
depend on the machine it runs on. An unknown model id is refused before any
provider resolves, which is deterministic and still covers what most likely
breaks: that the body decodes, and that the failure arrives in the OpenAI error
envelope rather than Effect's default shape. Verified in effect mode against a
real server, not just in coverage mode.

The capability flag is chatCompletions: { version: 1, callerTools: true, stream:
true } on /experimental/capabilities, with a matching struct in the group schema.
Declared as a struct rather than a version number so a caller negotiates a
capability instead of pinning an engine build - an app needing caller-owned tools
checks callerTools, and an engine without the route omits the key entirely. version
is the contract's revision, not the engine's.

Five route tests drive the real app in-process through httpapi-layer. The envelope
assertions are the point: a caller written against OpenAI reads error.message and
error.type, so the tests also assert Effect's { name, data } shape does NOT leak.
One test pins the v1 contract by sending every promised field including the
caller-owned tools path, and checks the failure is about the model rather than a
decode, so dropping a field from the schema fails it.

Two things deliberately NOT in this change.

The SDK codegen is not regenerated. bun script/generate.ts also runs a formatter
across the repo (61 files, mostly markdown table alignment) and its openapi.json
output contains routes that are not mine - /api/variant/paraphrase among them -
which means the generated SDK was already stale before this branch. Regenerating
here would sweep another session's unregenerated surface into this PR. It needs its
own commit. This repo has no CI check for stale codegen, which is why it drifted.

The SSE response is still undocumented in OpenAPI. The group declares only
ChatCompletionResponse, so the streaming path has the same gap /event has and the
same fix site in public.ts. Doing it properly means defining a ChatCompletionChunk
schema in the protocol, which is more than closing a gate, so it is recorded rather
than half-done.

Coverage 211 pass / 0 fail / 0 missing. Route tests 5/5. Wire tests 23/23.
Typecheck clean on both packages.

* fix(protocol): stop declaring endpoint errors codegen cannot discriminate

CI failed six jobs with:
  Promise error must have a literal discriminator: server.chat.chat.completions

httpapi-codegen requires every declared endpoint error to carry a _tag or name
STRING LITERAL to branch on (declaredErrorFields). The four error classes shared one
OpenAI envelope and had nothing to discriminate by, so generation threw and every
job that generates the client died with it.

Removed the error declarations rather than adding a discriminator, for two
independent reasons:

The handler never used them. It is handleRaw - one request answers with JSON and
another with text/event-stream - so it writes every response itself, failures
included, through errorResponse(). The declared classes were decoration that had
never been on the path.

And adding _tag to satisfy the generator would make the generated spec and SDK claim
a field the wire does not carry, because OpenAI's envelope has no such key. A spec
that lies about the shape is worse than one that is silent about it.

So the statuses are documented in the endpoint description instead - 400, 401 with
code engine_not_authenticated as the only sign-in trigger, 429, 502 - and the
envelope survives as an exported ChatCompletionError type so the handler and any
caller build against one shape. docs/LOCAL-ENGINE-API.md already specifies the wire
contract.

Note this was caught by a real CI gate I had earlier described as absent. The
generated client IS checked: packages/client check:generated regenerates and fails
on any diff. That is a different check from the SDK staleness I noted in the PR body,
and the generated client output is committed here for it.

Coverage 211/0/0. Route + wire tests 28/28. protocol, server and client typecheck
clean.

* test: give the fake LLM server its own router, not the engine's

CI failed with:
  Method 'POST' already declared for route '/v1/chat/completions'

taking down every test that stands up both the engine's HttpApi and the fake
provider (httpapi-sdk prompt streaming, project-skills prompt context).

TestLLMServer piped Layer.provide(HttpRouter.layer), which layer memoization
resolves to the SAME HttpRouter instance the engine's HttpApi is built on, so the
fake and the app registered into one router. That was invisible while no engine
route shared a path with the fake. Adding POST /v1/chat/completions to the engine
collided with llm-server.ts:702, which serves that exact path.

Fixed with Layer.fresh so the fake gets its own router instance.

The fake's path is deliberately NOT renamed to dodge the clash. It exists to look
like a real OpenAI-compatible endpoint, and /v1/chat/completions is where a real one
lives; moving it to a made-up path would stop the fake exercising what the tests are
actually about. The collision was a wiring accident, not a naming one, so the wiring
is what changed.

Worth noting the failure mode: the fake had been sharing the app's router all along,
which means any future engine route matching a test double's path would have broken
the same way. This makes that class of collision impossible rather than resolving one
instance of it.

All 130 tests across the 12 files that use TestLLMServer pass (1 pre-existing skip).
httpapi-sdk 18/18.

---------

Co-authored-by: Janghoon Lee <44862514+savagemanage@users.noreply.github.com>
Both harness documents told the next reader to do things that are wrong. Fixed with
the evidence, rather than quietly reworded, so the reversals are auditable.

1. dynamicTools is no longer "the better fit"

LOCAL-HARNESS-BACKENDS.md called Codex app-server's dynamicTools the better path for
host-owned tools. Withdrawn. OpenAI's stability warning got BROADER in three months:
the 2026-06-24 snapshot carried no production warning and scoped "experimental" to
the WebSocket transport, while today's page covers the app-server command itself.
app-server has no row at all in the Feature Maturity table. dynamicTools is
double-experimental - gated behind capabilities.experimentalApi AND absent from the
generated ThreadStartParams bindings, so the field must be hand-written against no
type - and it has already changed wire shape once, leaving a LegacyDynamicToolSpec
compat path where exposeToContext became the INVERTED deferLoading. There is no
protocol version, no breaking-change log, and an open regression in the -c MCP path
under app-server mode (openai/codex#39537).

The counter-signal is recorded rather than buried: Zed's ACP adapter moved ONTO
app-server and OpenAI's own IDE surfaces run on it, which is why this is "revisit in a
quarter" and not "avoid". MCP cannot give us a tool living in our own process memory;
when Office needs that, budget for re-porting dynamicTools at least once.

2. Driving the unmodified binary is not a safe harbour

The doc said it was "the documented exception". Too confident. Anthropic began
blocking third-party harnesses from Claude subscription billing on 2026-04-04 and the
restriction is described as extending to all of them; OpenClaw is researching a
--method cli path precisely because direct OAuth use has been blocked since then, and
the same issue warns that spawning claude through the Agent SDK IS the pattern being
restricted. Anthropic's June article still says subscription funds claude -p and the
metering change is paused - the two together are a trajectory, not a guarantee.

Consequences, both load-bearing: BYOK with the user's own key becomes the FIRST
deliverable rather than the fallback, and Codex comes before Claude Code for business
reasons rather than technical ones.

3. The /v1/providers routes I proposed already exist

PROVIDER-AUTH.md proposed four new routes to expose the engine's login flow. They are
already there as the server.integration group: GET /api/integration returns
Integration.Info with a methods union (OAuthMethod | KeyMethod | EnvMethod) and a
connections array, and connect/key, connect/oauth, the attempt status/complete/cancel
routes and DELETE /api/credential complete it. Building /v1/providers would have been
a duplicate surface over one credential store with two code paths to keep in
agreement. A mapping table now records which existing route replaces each proposed
one.

So the engine side of BYOK is DONE and the real work is product-side - making an app
use those routes instead of its own store. Office, Design, the extension, Query and
Recall keep five stores that never read each other, which is the whole reason a user
logs in again in every app; Cowork already does it right and is named as the pattern.

Also folded in, from research rather than guesswork: no off-the-shelf wrapper is
reusable (the only mature tools mapping is stale Rust, the popular one says in its
README that function calling is unsupported, the third is GPL-3.0, and the 52.8k-star
one never spawns the CLI - it stores the OAuth token and calls the vendor backend,
which is exactly what April blocked). Two specifics ARE worth copying: pooled
long-lived CLI processes, and launching with --tools "" --setting-sources ""
--system-prompt <caller's>, which strips ~28k tokens of the harness's own agent prompt
and is the concrete mechanism behind the chat tier.

And claude -p's stream-json schema is now mapped, so the old "schema unverified" note
is replaced by the real field locations, plus the warning that its credential
precedence puts the OAuth login LAST - ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN,
apiKeyHelper, CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_PROFILE in the environment all
silently take over billing, and --bare never reads the OAuth login at all. Same class
of bug as the Codex env trap, opposite direction.

Co-authored-by: Janghoon Lee <44862514+savagemanage@users.noreply.github.com>
* feat(protocol): document the chat-completions SSE response

The route answers with `application/json` or with `text/event-stream` depending on
`stream`, and HttpApi can only declare one success schema. So the generated spec described
only the JSON, and a client built from it looks for choices[].message.content on a
streaming response and finds nothing on every frame - because a chunk carries
choices[].delta instead. That is the same gap /event has, and the same fix site.

Adds ChatCompletionChunk, which is genuinely a different shape rather than a reuse: the
object discriminator is chat.completion.chunk, and each choice carries a partial delta
instead of a complete message. Every delta field is optional because OpenAI's stream uses
the shape sparsely - the first frame typically carries only role, middle frames only
content, a tool call arrives spread across frames, and the terminator carries an empty
delta with finish_reason set. A schema that required content would reject the terminator.

Declared for documentation and codegen only. The handler stays handleRaw and writes its own
SSE frames, because choosing between JSON and a stream per request is exactly what HttpApi
cannot express.

Both content types are declared on the one 200 rather than the SSE replacing the JSON, so
the spec says what actually happens instead of lying in the other direction.

Registered through HttpApi.AdditionalSchemas. A $ref to a schema no endpoint references
dangles otherwise, and a dangling ref generates a client with a missing type rather than
failing loudly - which is why one of the three new tests asserts the ref resolves to a
registered component rather than only that the ref is present.

Coverage 211/0/0, route and wire tests 28/28, openapi tests 21/21, protocol and redrob
typecheck clean.

The generated artifacts are NOT regenerated here. `bun script/generate.ts` also rewrites
packages/sdk/openapi.json, which is stale on clean develop by 991 lines including routes
that are not mine, so committing it would sweep another session's unregenerated surface
into this change. It needs its own regeneration commit, which is being raised separately.

* chore(sdk): regenerate the stale generated artifacts

No source change. `bun script/generate.ts` output only.

Two things land together, and the split matters for review:

The chat-completions SSE documentation from the previous commit - the 200 now declares both
application/json and text/event-stream, and ChatCompletionChunk appears as a component.

Pre-existing drift that was already in the repo before this branch. packages/sdk/openapi.json
was stale on clean develop by roughly 900 lines, including routes that are not mine
(/api/variant/paraphrase among them). Those were regenerated here because there is no way to
regenerate only part of the file, and leaving them would mean the committed spec still does
not describe the served API. This is why it is a separate commit: the source change is
reviewable on its own, and this one is verifiable by re-running the generator.

There is no CI check for this drift, which is why it accumulated. The client generator IS
checked (packages/client check:generated fails on any diff) and passes; the SDK spec is not.
Worth adding a check, but that is a change to CI rather than to artifacts and does not belong
here.

Also deliberately NOT committed: the ~54 files `generate.ts` reformats as a side effect,
mostly markdown table alignment and line wrapping in untouched sources. Those are the
generator running a formatter over the repo, not generated output, and sweeping them in would
bury the three files that actually matter.

---------

Co-authored-by: Janghoon Lee <44862514+savagemanage@users.noreply.github.com>
)

* feat(codemode): K-1 typed domain objects as code-mode globals

A skill document is cheap only because the objects it drives already exist.
Code Mode could previously only be written as `tools.<namespace>.<tool>(...)`,
so a skill had to describe a tool call instead of naming a method.

Mechanism, in @redrob-code/codemode: `globals` on ExecuteOptions names top-level
tool namespaces that are ALSO bound as bare identifiers in the interpreter's
global scope. A global is an alias for the same tool path, so there is one
implementation and one authorization point, not two. Code Mode stays host-neutral:
it never learns what a given name means. The builtin global seeding moves into
seedBuiltinGlobals(), and BUILTIN_GLOBAL_NAMES is derived from it, so a builtin
added later cannot silently become available as a host global name.
assertValidGlobals refuses a name that is not a namespace of the tool tree, one
that shadows a builtin, a duplicate, a non-identifier, and a tool rather than a
namespace. The generated instructions gain a "Domain globals" section stating the
bare and tools forms are the same call.

Objects, in packages/redrob/src/tool/domain.ts: Page and Channel as real
TypeScript interfaces with a single implementation each, injected under the names
`page` and `channel`.

- Channel is implemented: `channel.send` appends a text part to the assistant
  message that owns the execution, so every surface the session is attached to
  renders it without the engine knowing which surface that is. `channel.id`
  returns the session id.
- Page is NOT implemented, and says so. Nothing in the engine process controls a
  browser page today, so its single implementation is unavailablePage, whose every
  method fails with the capability named: "the engine process has no browser-page
  control surface". It returns no plausible value, so a skill written against the
  interface fails loudly rather than reading an empty string.

The domain namespaces are spread after the MCP catalog deliberately: a connected
MCP server named `page` must not shadow the object a skill is written against.

* feat(skill): K-2 auto-arming from frontmatter keywords and URL globs

Two optional frontmatter keys beside name/description/slash:

  icon: string                                      # a URL
  autoInject:
    keywords: [string]                              # matched against the prompt
    url: [string]                                   # globs, e.g. docs.google.com/document/**

Both lists are optional and an empty list matches nothing, so `autoInject: {}`
is a skill that still never arms rather than one that arms on everything.

The arming decision is a pure function in packages/core/src/skill/arming.ts:

  SkillArming.arm(input: Input): ReadonlyArray<Armed>
  SkillArming.armedNames(input: Input): ReadonlyArray<string>

It takes the loaded skills, the current prompt text and the current tab URL,
and returns which skills arm, with the patterns that armed each one. It reads
nothing and caches nothing, so the decision is identical in the prompt builder,
in a UI preview and in a test. Keyword and URL matching are both
case-insensitive; keywords match on word boundaries so `ai` does not match
inside `said`; `*` and `?` stop at a `/` while `**` crosses segments; a `.` in
a glob is a literal dot and not a wildcard. The scheme is ignored on both
sides, so globs are written without one.

A skill with NO autoInject block never auto-arms. It stays explicitly loadable,
which is the point: a skill that arms on everything is always in the prompt.

Loading decodes one document at a time, so a malformed frontmatter costs that
one document and not the set. The decoders are the Result-returning form rather
than the Option form, because the Option form answers only "no" and makes the
cause unfindable; every rejection is logged at warning level with the file and
the decoder's own reason. A malformed autoInject is narrower still: it is
ignored with its own warning and the skill loads without it, since that leaves
the skill exactly where a skill with no block already sits. The reasons are in
the log MESSAGE rather than in annotations, because the default logger renders
an annotation object as [object Object] and loses precisely that detail.

Tests in packages/core/test/skill/arming.test.ts: 13 pass, 0 fail. They cover
a keyword hit, a URL-glob hit, three glob near-misses that must not arm, a
skill with no block, an empty block, case-insensitivity in both directions, and
a malformed autoInject that is ignored rather than fatal while a sibling bad
document is dropped with a logged reason. Each of five restored defects
(explicit-only guard removed, `*` crossing `/`, glob compiled as a regex,
malformed autoInject made fatal, reason text dropped from the log) was confirmed
to fail the suite.

Note: packages/schema has 2 failing tests in test/event-manifest.test.ts. They
fail identically on a clean tree (git stash push -u, 13 pass / 2 fail both
ways) and are unrelated to this change.

* feat(client): K-2 regenerate the client types for the new skill keys

`packages/client` commits its generated SDK and gates it with
`check:generated` (bun run generate && git diff --exit-code), so adding
`icon` and `autoInject` to `SkillV2.Info` in packages/schema left the
committed artifact behind and that gate red on CI - the core (linux) job
passed its tests and then failed on this step, and unit (linux) is only
the aggregation job reporting it.

Regenerated with the repo's own generator (`bun run generate` in
packages/client), not hand-edited, because the gate rejects a hand edit by
construction. The whole diff is the two new optional fields on
SkillsListOutput, which is exactly the propagation of the schema change:

    readonly icon?: string
    readonly autoInject?: { readonly keywords?: ReadonlyArray<string>
                            readonly url?: ReadonlyArray<string> }

Measured after the change: packages/client typecheck passes; its suite is
15 pass / 1 fail, and that one failure ("exposes every standard HTTP API
group") also fails on a clean origin/develop worktree, so it is not from
this change.

---------

Co-authored-by: Janghoon Lee <44862514+savagemanage@users.noreply.github.com>
…ls (#63)

* feat(codemode): K-3 a real document toolchain in code mode

Four typed globals in the same shape as K-1's `page` — `docx`, `xlsx`, `pptx`
and `pdf` — each with a `read` and a `write`, bound into the Code Mode
interpreter scope and described to the model by the catalog preview.

The rule the module is built around: a write produces a real file or it fails.
Each write serialises through a format library, lands bytes on disk, then stats
the file and returns the size the filesystem reports — so the byte count is a
reading of the artefact, not of the buffer we hoped to write. All four formats
can genuinely write, PDF included; nothing here is stubbed and nothing returns
a stringified summary of a document it did not produce.

Reads use the zip reader already vendored for OOXML (`@zip.js/zip.js`) for
`docx` and `pptx`, ExcelJS for `xlsx`, and pdf-lib plus a content-stream text
scanner for `pdf`.

`pdf.read` is the one with an honest limit, and it states it per page rather
than hiding it. Text is extracted from content streams, not rendered, so a page
whose text is drawn with a composite (Type0) font carries glyph identifiers
instead of characters: those runs are SKIPPED and the page comes back with
`textComplete: false` and a reason naming the font. A scanned page has no text
operators at all and is reported the same way — there is no OCR in the engine
process. Returning glyph soup as text would be exactly the fake success this
module exists to refuse.

Writes are gated by the same external-directory check the `write` tool uses, so
a program cannot drop a document outside the instance directory without the
user being asked, and a refusal leaves no file behind.

`DomainUnavailableError` moves to its own leaf module. `domain.ts` folds the new
globals into `DOMAIN_GLOBALS` at module scope and `document.ts` needs the error
class, so importing it from `domain.ts` made the two a cycle whose evaluation
order decided whether `DOCUMENT_GLOBALS` was defined when `domain.ts` read it —
it was not, and the suite failed on the TDZ. A leaf module has no such order to
get wrong.

Dependencies, pinned exactly, all MIT, all the canonical package for their job:

- docx 9.8.1 (dolanmiu) — writes the Word package.
- exceljs 4.4.0 (exceljs) — reads and writes the Excel workbook.
- pptxgenjs 4.0.1 (gitbrent) — writes the PowerPoint deck.
- pdf-lib 1.17.1 (Hopding) — writes the PDF, and gives the object graph the
  text scanner walks on read.

The K-3 skill document ships as a builtin skill, auto-armed on the words a
request for this work actually contains and on the three Google editors plus
Office on the web. A second test holds that document to the code BOTH ways:
every `<global>.<method>` it names is a real tool, every real tool is named by
it, and the result fields it promises are compared against the keys real calls
return rather than a list retyped in the test.

Verified: 25 unit tests over real files in a temp directory, including a program
run through the real `execute` tool whose written `.docx` is then stat'd and
magic-checked from outside the sandbox; full engine suite 3432 pass / 0 fail;
core 993 pass / 0 fail; `packages/client` `check:generated` clean, as no schema
changed.

* feat(skill): K-5 a skill that writes skills

A builtin skill document whose body is the instruction for producing a new
`SKILL.md`: the two layouts the loader's `{*.md,**/SKILL.md}` glob actually
finds, every frontmatter key its decoder actually reads, what K-2's `autoInject`
block does and why an empty one still never arms, and a template to copy.

It deliberately carries no `autoInject` of its own. Writing a skill is something
a person asks for by name, and a skill that armed on the word "skill" would be
in the prompt of every session that mentions one.

The frontmatter the skill tells an agent to write is not asserted by eye, and it
is not a copy of something checked elsewhere. The test EXTRACTS the template out
of the shipped document — anchored on the document's own `<!-- template:skill -->`
marker, so adding an earlier example cannot silently repoint it at the wrong
fence — writes it where the loader globs, and asserts the decoded `SkillV2.Info`:
name, description, slash, icon, and both `autoInject` lists. So a key the loader
does not read, a shape its decoder rejects, or a typo in the document's own
template fails the test. Reverse-verified: misspelling one frontmatter key in
the document turns the suite red, and restoring it turns it green.

Three further claims the document makes are tested as behaviour rather than
prose: the loaded skill re-decodes through `Info` (the loader builds it with
`make`, which does not run the decoder, so this is the only place the whole
value meets the schema); a `SKILL.md` in a subdirectory with no `name` is
dropped, which is the most common way a new skill silently fails to appear; and
a malformed `autoInject` costs only the arming while the skill still loads.

The builtin skills become an exported `BuiltinSkills` list the plugin iterates,
rather than three inline `draft.source` calls, so a test can run the real
decoder over every skill the engine ships.

Verified: core 993 pass / 0 fail, measured on this tree in this change.

* fix(K-5): read the skill template whatever line endings it was checked out with

The test pulls K-5's template out of the skill document with a regex anchored
on \n. There is no `* text=auto` in .gitattributes, so a Windows checkout
converts the document to CRLF, the pattern then matches no fence at all, and
the helper throws before any assertion runs.

It failed only in the Windows core job, which is the single place it could
show: on Linux the document is LF and the pattern matches.

Reverse-verified rather than assumed. With the document converted to CRLF and
the normalisation removed, the suite reproduces the CI error exactly ('no
```markdown fence follows the template marker', 0 pass); with the
normalisation in place it is 6 pass against the CRLF document.

---------

Co-authored-by: Janghoon Lee <44862514+savagemanage@users.noreply.github.com>
`page` was two typed globals that both threw. The engine process controls no
browser page, and the only reverse-direction channel in the tree was the
loopback RPC server, which is off by default for a reason its own header
states: a local server that can drive the browser is the browser to every other
process on the machine.

So this adds no listener and opens no socket. The direction that already exists
is the client subscribing to `GET /api/event`, so a browser action is a pending
request published on that stream and settled by an HTTP reply — the shape
`QuestionV2` already uses to ask a person something. The client dials the
engine; the engine never dials the browser.

  schema/browser-request.ts   the action union, the typed result, the events
  core/browser-request.ts     the pending map, the deadline, reply and refuse
  protocol/groups/            list, reply, refuse — session-owned, like questions
  server/handlers/            with the same ownership check on every settle
  redrob/tool/domain.ts       bridgedPage, replacing unavailablePage at runtime

Three decisions worth stating.

The result is a tagged union, not one permissive `unknown`. The engine knows
which shape each action must produce, so a client answering `page.query` with a
string is a protocol error the engine names. Reading it as an empty node list
would tell the model "nothing matched the selector" — a claim about the page
that nobody made.

There is a deadline, and it is the reason a skill can use this at all. Nothing
tells the engine whether a client is subscribed: the event stream is a
publish/subscribe fan-out with no subscriber count, so "no browser attached" and
"the browser is slow" are indistinguishable from here. Waiting forever is the
single outcome that is certainly wrong — `page.text()` with no browser running
would hang the session instead of failing it.

Refusing is its own route rather than a silent non-reply. A tab that is not
shared is a normal answer, and routing it through the deadline would cost twenty
seconds to say so.

`unavailablePage` stays, for the catalog preview and for any harness that binds
no browser, with its message corrected to name what is actually missing now.

Verified: 998 core tests, 390 redrob tool tests, 0 failures; tsc clean across
schema, core, protocol, client, server, redrob. The eleven pre-existing
dialog-move-session errors in packages/tui were measured on a stashed clean tree
and are unrelated. Both new guards reverse-verified: removing the shape check
makes the wrong-shaped-answer test pass a program it should fail, and removing
the deadline hangs the unanswered-request test.

The generated client SDK is regenerated, which the API change requires. Both
browser endpoints end in ".list" and the client method name is the last dot
segment, so the location-wide one carries an explicit name in contract.ts.
…ee new events

CI was red on three jobs and all three were this change's own doing.

`test:httpapi` fails on `missing=4`: the exerciser requires a scenario per route
and PA-10 added four without them. Added, mirroring the V2 question scenarios
because the browser request service is deliberately the same shape -- a global
list, a per-session list, and the two settlement routes. The settlements name a
request id that does not exist, so each asserts the 404 path and needs no live
browser on the other end.

Writing those scenarios caught a real mistake in my own reading of my own route,
twice. The reply body is `{ value: { type, value } }`: `value`, not `result`, and
the inner shape is the tagged `BrowserRequest.Value`, not the command echoed
back. The extension's executor already sends exactly that -- it was written
against the same schema file -- so this was the exerciser being wrong, not the
client. Measured by grepping what `page-actions.js` actually emits rather than
assuming the two agree.

The event manifest pinned a bare count of 88 and PA-10 brings three public wire
types. Bumping the number alone would have satisfied the tripwire without saying
what arrived, so the three are asserted by name beside it: `browser.request`
asked, answered, refused. The count stays as the guard against an accidental
addition; the names say which addition was deliberate.

`unit (linux)` and `unit (windows 4/4)` were the same manifest count -- the linux
one is an aggregator reporting its shards, not a separate failure.

httpapi 215/215 in all three modes (coverage, auth, effect), missing=0.
…ough the loader

K-2 landed `SkillArming.arm` as a pure function with its own tests and nothing
called it. Measured: its only caller was that test file. So every skill that
declared keywords and URL globs behaved exactly like one that declared none.

Calling it was not enough. The skill surface the system prompt actually reads is
a second, older loader -- `packages/redrob/src/skill/index.ts` -- and its `Info`
had no `autoInject` field at all, so the data was dropped at load time and the
matcher would have had nothing to match on no matter who invoked it. The field is
now carried through, using the same schema as the other loader rather than a
second copy of it.

A malformed block costs only the arming behaviour: the skill still loads and
stays selectable by name. Refusing the whole file would lose a working skill over
an optional key, and that failure mode is tested.

`SystemPrompt.armedSkills` is separate from `SystemPrompt.skills` because the two
make different claims. `skills` is the discovery list -- names and descriptions,
for the model to pick from with the skill tool. `armedSkills` is the CONTENT of
the ones that armed themselves on what the user just typed, which the model did
not ask for and is therefore told it is already reading, with the patterns that
armed it. A skill with no `autoInject` never arms.

The armed-by attribute is escaped with the repo's own `escapeHtml`. The first
version used `JSON.stringify` to show each pattern, which put a raw quote inside
the attribute and broke the tag; a keyword is author-supplied text from a skill
file, so that is reachable, and there is a test with a keyword that contains one.

URL arming is wired and deliberately not fed. Getting the active tab means a
browser request per message, which with no extension attached spends the request
deadline before the model sees anything -- a twenty second pause before every
reply is a worse product than URL arming is a better one. Keywords arm today; the
reason is at the call site.

Also ships one document against the real surface: `page-control`, the six `page`
calls that PA-10 made resolve. Checked both ways against the bound tool
namespace, so a call the engine does not have cannot be described and a call it
has cannot be left out, and the document's own "Six calls" sentence is asserted
against the real count.

core 998 pass 0 fail. redrob tool+session 854 pass 0 fail. Skill loader 19 pass
including the two new ones. Reverse-verified both ways: removing the wiring fails
four system tests, removing the loader field fails the end-to-end arming test.

Found and NOT fixed here, because it is a seam and not a document: the core
`SkillV2` plugin registry feeds an `EmbeddedSource` list with zero consumers in
the engine, so its builtin skills are registered and unreachable and only disk
skills load. Recorded in the queue.
… what is short

The two halves that decide whether this feature is honest. The surface that
shows a running download is deliberately not here.

Measuring first shrank the item: the RUN half already exists. A local runtime is
already an allowed provider -- the one trusted OpenAI-compatible package, local
URL only -- and the model listing already asks it what it serves. And the probe
is not native: the engine is a process on the user's own machine, so memory,
cores and arch come straight from the OS. Only the GPU needs a shell out.

`local-model-fit.ts` is built on three rules.

An unseen GPU is reported as NOT MEASURED, never as absent. Telling a user with a
discrete card that their machine has none is a false statement about their own
computer, and it is the one they would push back on hardest and be right about.
So `undefined` means we did not look, which is a third answer.

It never invents a model's size. The figure comes from whoever is offering the
model. A fit check against a size this module guessed would be a guess wearing a
verdict's clothes.

A refusal carries the numbers that produced it, and distinguishes the two cases
that matter: short by an amount this machine has if something is closed, versus
short by more than it has in total. "Your machine cannot run this" is not
actionable and cannot be checked by the person it is about.

Memory is checked against AVAILABLE rather than total, and `os.freemem()`
understates on Linux because it counts reclaimable page cache as used. That
direction is deliberate: it can refuse a model that would in fact have squeezed
in, and it will not promise one that gets killed mid-generation. The opposite
error loses the user's work.

The runtime's own listing and fetch shapes are in `local-model-pull.ts`, because
the OpenAI-compatible `GET /models` the provider listing uses reports an id and
nothing else -- no size -- so it cannot answer the question this feature turns
on. We do not fetch weights ourselves: no mirror choice, no quantisation choice,
no digest we chose to trust. The runtime already resumes interrupted downloads
and shares progress between concurrent callers; re-implementing that would mean
owning model provenance, which is a much larger promise than running a model
locally.

Shapes come from the runtime's published API reference rather than from reading
our own code back, and two traps it states explicitly have a test each:
`completed` may be absent while a layer is downloading, and `total` is per-LAYER
not per-model -- a caller reading it as whole-model progress shows a bar that
resets on every layer. An in-band `error` frame arrives after a 200 is already
sent and is terminal, not progress; treated as progress the pull looks like it is
still running and never finishes. There is also a test asserting that a
whole-body JSON parse FAILS on the real stream, so nobody simplifies the
line-by-line reader back into one parse.

core 1034 tests 0 fail, 36 new. The fit judgement is pure and needs no machine.
The GPU parsers run against the reference's own recorded output, so they are
verified on a host with no GPU.

No cloud fallback exists here and none should be added. A user who asked for a
local model asked for the data to stay on the machine; answering from a hosted
model would defeat the only reason to want this, and would do it invisibly.
…a 404 it found

The fit judgement and the probes had no caller. That is the failure this queue
keeps finding -- an arming matcher nobody invoked, a model preference read and
discarded, builtin skills registered into a list with no consumer -- so the
surface is the point of this change, not an extra.

`redrob models-local` prints the machine, then per configured local endpoint
every installed model with either "runs here" or the refusal and its numbers.

DRIVING IT END TO END FOUND A REAL DEFECT, and nothing short of that run would
have. A local runtime is configured with its OpenAI base, conventionally `/v1`,
but `/api/tags` is a SIBLING of `/v1`, not a child. Joining onto the configured
base produced `/v1/api/tags`, got a 404, and the command printed "answering,
with nothing installed" against a server serving two models. A 404 and an empty
list are both "no models" to a caller that does not separate them, so the bug
read as a working feature. Fixed, with a regression test that says why, and
reverse-verified: removing the fix fails it.

Only a TRAILING version segment is stripped. A runtime served under a prefix by
a reverse proxy keeps its path, which is why `/v1/engine` is left alone.

Measured on this host (16 cores, 61.8 GiB): a 2 GiB model runs here; a 120 GiB
model is refused with "needs about 144.0 GiB of memory (120.0 GiB of weights plus
runtime headroom) and 38.9 GiB is free of 61.8 GiB total -- short by 105.1 GiB,
more than this machine has in total".

Top-level `models-local` rather than `models local`, because `models` takes a
positional provider filter and a subcommand there is ambiguous with a provider
named `local`.

The GPU probe's failure is swallowed and reported as NOT MEASURED, never as "no
GPU". On a machine with no discrete card the tool simply does not exist, which is
the normal state of the world rather than a condition to report -- and the memory
judgement, the part that actually decides whether a model runs, does not depend
on it.

HttpClient and AppProcess are provided by this command rather than added to the
CLI runtime. It is the only command that speaks HTTP to a local runtime or shells
out for a GPU, and widening the shared runtime would hand both to every other
command that has no use for them.

core 1031 tests 0 fail across 123 files. redrob cli 380 tests 0 fail. Generated
SDK clean, tsc clean.
Redrob Reblend is now Redrob Shape, and its repository was renamed to
redrob-labs/redrob-shape (redrob-labs/redrob-shape#10). These two docs list
it among the products that reach the local engine; they now use the new name.
Docs only.
Rename reblend to shape in the local engine docs
The default-model sort ranks "gemini-3-pro" highest, and that substring also
matches gemini-3-pro-image-preview. On the redrob provider that image model
became the default, so the first chat turn of every new session failed (the
upstream answers 404 for a chat request to an image model). Redrob CAD hit
this through ACP and had to pin redrob/auto itself.

- Provider.sort ranks chat models first. A model is not a chat model when its
  capabilities say it outputs images or no text, or, for bare {id} rows like
  the ACP picker, when its id has an "image" segment.
- On the redrob provider, `auto` (the console router, always seeded) is the
  default in both Provider.defaultModel and the ACP default.

Tests: two sort cases (capability records and bare ids) and defaultModel on
the redrob provider returns redrob/auto.

Co-authored-by: Janghoon Lee <44862514+savagemanage@users.noreply.github.com>
Caller for the org-wide pull request policy (redrob-labs/.github): at most 3 open pull requests per author, and a maintainer review request is removed until the author has reviewed their own pull request. Exempt: its-janghoon, dependabot[bot].
…ls (#84)

privacy.mask_pii = off | regex | model, applied once in session/runner/llm.ts for every provider: placeholders out, real values back in every text delta and tool-call input. GET/PUT /api/privacy for the app switch, written to the global redrob.json. x-redrob-app from REDROB_APP. The Console skill library is the first skill source. Client and SDK regenerated.
…tes (#85)

image_generate and speech_generate on the engine's Redrob credential, registered only when one is connected. POST /redrob/speech and /redrob/transcribe relay to the Console audio API without the app holding the key; 503 before anything is sent when there is no key, 400 for input the gateway would refuse. From the closed #69, #71, #73, #74; image editing (#70) left out.
Off (default): text goes out as written. On (regex): the provider sees [EMAIL_1], a placeholder split across two deltas is restored, the stored reply carries the real value. Privacy.layerWith is the test seam.
…ack (#88)

Redrob Launcher signs a computer in once and writes the workspace key to a shared account.json (contract: redrob-launcher docs/shared-account.md; REDROB_SHARED_DIR overrides the directory). Redrob Code reads it as the LAST fallback for the console key: read-only, read fresh on every use, the app's own key (REDROB_API_KEY, Credential store, auth.json, config apiKey) always wins, empty/missing/malformed means not signed in, never logged.

- core/shared-account.ts is the one reader; it never writes or deletes the file.
- ConsoleKey.resolve keeps its signature; ConsoleKey.resolveWithSource reports source env | credential | api | launcher.
- ModelsDev.resolveApiKey reads the shared file fourth, so a launcher-only sign-in gets the live catalogue.
- GET /provider reports the redrob entry with source "launcher" and the resolved key when only the shared file has one, and no longer sets the "public" placeholder in that case. Provider.Info.source and the generated SDK/plugin types gain the literal; existing semantics are unchanged.
- DELETE /auth/redrob still removes only the engine's auth.json entry; the next read falls back to the shared file untouched.
- Both test preloads point REDROB_SHARED_DIR at a scratch dir so a signed-in launcher on a developer machine cannot satisfy a no-credential branch.
Redrob Launcher installs an asset only when its name is
<stem>-<version>-<os>-<arch>[-portable].<ext> with its own os-arch spellings
(linux-x86_64, macos-aarch64, windows-x64). Every release so far carried
redrob-linux-x64.tar.gz, redrob-darwin-arm64.zip and friends: no version,
stem `redrob`, Bun's target names. The launcher parsed none of them, so
Redrob Code was listed but not installable, and the release workflow was
green the whole time.

The publish job now adds one launcher-named asset per platform the launcher
has a token for (linux x86_64/aarch64 tarballs, macOS and Windows portable
zips) via packages/redrob/script/launcher-assets.ts. The existing names stay
untouched: install.sh, install.ps1, `redrob upgrade`, the Homebrew and AUR
definitions and the Dockerfile all download them verbatim, so renaming would
break self-update for every existing install.

The launcher tarball is laid out as <asset>/bin/redrob-code with bin/redrob
symlinked beside it, which is what the launcher's unpacker looks for and the
same pair of names install.sh creates. The macOS and Windows zips are copied
byte-for-byte so the notarized and Authenticode-signed binaries are what
ship. test/installation/launcher-assets.test.ts mirrors the launcher's
parser and pins the six names and the tarball layout.
Every Redrob app that runs on this engine started one of its own: Cowork and Office spawned
`redrob serve`, Design, Cut, CAD and Browser spawned `redrob acp`, so a person with three apps open
ran three engines. docs/LOCAL-ENGINE-API.md section 3 described the alternative; this builds it.

`redrob serve --register` is the daemon. It draws a secret per start, writes
~/.redrob/run/server.json (0600: id, version, url, port, pid, secret), and exits once nobody has
been attached for --idle-exit minutes (default 10) or another daemon has taken the record over.
`redrob service start|status|stop|restart` drives it by hand; `start --json` prints the record
for a caller in another language. `redrob acp --attach` makes the ACP face a thin client of the
daemon instead of an engine of its own.

Attachments are counted at /api/clients (attach, heartbeat, detach, list), a new protocol group
with its generated client and SDK bindings; an app whose heartbeats stop is dropped after 90 s, so a
crashed app cannot pin the daemon. `/experimental/capabilities` grows `sharedDaemon`.

`@redrob-labs/sdk/daemon` gives JavaScript apps `connectOrStart()` (a file read and a health check
on the fast path; `redrob service start --json` only when there is no healthy daemon), `attach()`
with the heartbeat built in, and `resolveRedrobBin()` with the documented resolution order.

Separately, `POST /v1/chat/completions` now goes through the same PII masker as the agent runner.
It called the provider directly, so a downstream app on this route had no masking at all while the
switch said on. One vault per request: the caller sends real values, the provider sees
placeholders, the answer is put back, streamed or assembled; the model tier asks the configured
local model for names exactly as the runner does.

Verified by hand with a dev daemon: service start/status/stop, reuse of a running daemon, attach,
heartbeat, detach, 401 on a wrong secret, idle exit after the window with the record removed, and
`acp --attach` answering ACP initialize while the engine pid is the daemon's. Tests: redrob
724 pass (daemon registry + clients registry, chat/completions masking, httpapi exercisers 223/223
in all three modes with the four new scenarios), core 1043 pass, sdk 1 pass, turbo typecheck 18/18.
Every job checked out `develop` by name when it started. A merge landing between dispatch and
the tag step (#89 landed two minutes after today's release dispatch) meant the binaries were
built from one commit and the tag pointed at another. The version job now records the commit it
computed from, and the build and publish jobs check out that sha, so a release is one commit from
version to tag. The tag push is by explicit ref and works from the detached checkout.
)

The 0.6.0 publish failed at "NPM_TOKEN is not set": the token the 0.4.1 publish used is gone, and the
package is meant to publish through npm's trusted publishing instead. That needs `id-token: write`
on the job and npm >= 11.5.1, which the runner's default Node does not carry; with both, `npm publish`
exchanges the GitHub OIDC token for a short-lived credential and attaches provenance on its own.

NPM_TOKEN still wins when it is set, so nothing changes for a repository that keeps one. The trust
itself lives on npmjs.com (package > Settings > Trusted publisher: redrob-labs/redrob-code,
publish-sdk.yml) and is not something this file can grant.
With `--attach` the ACP face connected to the shared daemon but never registered in
`/api/clients`, so a Cut, CAD, Browser or Design session kept the daemon counting idle time and it
would have exited mid-session after ten minutes. The CLI now has `Daemon.attach` (the twin of
`attach()` in `@redrob-labs/sdk/daemon`: POST, heartbeat at a third of the TTL, re-attach after a
404 from a restarted daemon, DELETE on detach), and `acp --attach` holds one under the app named by
REDROB_APP (`acp` when unset) until its host closes stdin.

Found during Plan A verification against the 0.6.0 binary: two `acp --attach` processes on one
daemon showed `clients: []`. Checked live after the fix: `clients: [{app: "cut"}]` while the ACP
process runs, empty after stdin closes.
Every GitHub Release now carries redrob-launcher.json, exactly { "schema": 1, "sharedAccount": true }, so Redrob Launcher learns from the release itself that this build reads the launcher's shared account file (#88).

- launcher-assets.ts writes the manifest beside the launcher installers and verifies the release directory (every launcher asset, manifest present, valid JSON, schema 1, sharedAccount true); a directory without the file fails the step.
- The publish job's Checksums step lists the manifest in SHA256SUMS and fails if it is absent.
- launcher-assets.test.ts pins the bytes and drives the verifier through missing / wrong-schema / false-flag / non-JSON cases.

No release, tag or version change.
One shared daemon serves many apps, so an app can no longer hand its engine a private config through REDROB_CONFIG. The engine now also reads <state>/directories/<sha256(dir)>/redrob.json for every instance on that directory, merged where REDROB_CONFIG merged, outside the project tree. ConfigPaths.directoryKey/directoryConfigDir in the engine and the same helpers in @redrob-labs/sdk/daemon compute the path.
Trusted publishing authenticated, then npm refused the tarball: the sigstore bundle names redrob-labs/redrob-code and package.json had no repository.url to match it.
…th the calling app (#99)

The Rust crate redrob-engine-client now lives in crates/engine-client so public apps can pin it by engine tag. /v1/chat/completions forwards x-redrob-app and x-redrob-session to the provider like the runner does. Docs cover the not-installed exit code and the no-fallback rule.
Attachments alone decided when the shared daemon leaves, so the Rust apps (Shape, Canvas, Query, Recall), which call the API through redrob-engine-client without attaching, saw the daemon they started exit ten minutes later -- possibly under a request still being answered. Both authorization middlewares now mark an authorized request as activity: not idle while one is in flight, and the idle clock restarts when it ends. Status polls (/api/health, /api/clients) do not count, since the launcher's engine card asks every few seconds.

Live-checked with the dev entrypoint at --idle-exit 1: a /provider call at 45 s kept the daemon up at 75 s; gone at 120 s. Daemon tests cover the accounting and the status-path rule.

CI note: unit (windows core) failed twice in its 'Provision ripgrep' step (community.chocolatey.org feed unavailable), before any test ran; the change does not touch core.
cdn.redrob.ai/code/latest/version still said 0.0.12, the last build published to the bucket before releases moved to GitHub (#31). Every 0.0.x install reads that marker before upgrading, so they all believed they were current and redrob upgrade was a no-op; uninstall from that line is broken too, so those users were stuck (one hit it through Cowork 0.5.0, redrob-cowork#223).

Write the marker from the newest non-prerelease GitHub release on each release: published, and on demand with workflow_dispatch. It never goes backwards. The installer the old client then runs is Console's, which downloads from GitHub, so the marker is the only piece the bucket still has to carry.
…103)

The CDN key is write-only: the first run uploaded 0.6.3 and then failed on its own read-back with HeadObject 403. The upload's exit status is the proof; the CDN read stays as a notice.
Port of redrob-launcher #20: azure/login with the job's OpenID Connect token runs right before signing; the job carries environment: release and id-token: write; the signtool dlib metadata excludes every credential a runner cannot satisfy; signatures are verified with signtool verify /pa and must be Valid from CN=McKinley Rice, Inc.; detection looks only at ARTIFACT_SIGNING_ENDPOINT/ACCOUNT/CERT_PROFILE; the .pfx and client-secret paths are gone. Dry run 38578721967: redrob-code-x64-setup.exe and redrob.exe signed by CN=McKinley Rice, Inc.
Switch to the shared redrob-labs/.github gitflow and release workflows.
/v1/chat/completions calls the model directly, with no session in between, so it needs @redrob/LLMClient in the route tree itself. Both route trees listed httpClient but not llmClient, and the layer compiler only exposes group members to handlers. Every call to the OpenAI-compatible route on v0.6.4 therefore died with a 500 "Service not found: @redrob/LLMClient" before a provider was chosen, for every app on the shared daemon.

Adds the node to both groups and a route-level regression test that drives the production tree and asserts the request reaches the model layer.
@its-janghoon
its-janghoon merged commit bc92570 into main Oct 15, 2026
45 of 47 checks passed

This branch was successfully deployed

1 active deployment
release — e03b130c Deployed Oct 15, 2026 by its-janghoon via sign-windows #38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant