Commit Graph

584 Commits

Author SHA1 Message Date
Michal
e6db9cac83 Merge main into feat/pi-extension (prime-agent switcher UX) 2026-08-08 18:33:31 +01:00
Michal
64503e8924 Merge pull request 'feat(prime-agent): filterable project picker + visible active-project indicator' (#97) from feat/prime-agent-switcher-ux into main
Some checks failed
CI/CD / lint (push) Successful in 1m4s
CI/CD / typecheck (push) Successful in 2m21s
CI/CD / test (push) Successful in 1m20s
CI/CD / smoke (push) Has started running
CI/CD / build (push) Has started running
CI/CD / publish (push) Has been cancelled
2026-08-08 18:33:30 +01:00
Michal
ba05b2865c feat(prime-agent): filterable project picker + a visible active-project indicator
Two gaps the pi switcher had already closed.

**The indicator was invisible.** `ctx.ui.setStatus` was the wrong channel:
prime-agent stores extension statuses (`FooterDataProvider.setExtensionStatus`)
but nothing ever reads them back — there is no `getExtensionStatuses()` call
site anywhere in the app, so the value was recorded and never rendered. pi's
`footer.js` does render them, which is why the same code showed a status line
there and nothing here. The active project is now published as a *widget*
(`extensionWidgetsBelow` → `renderWidgets()`), which prime-agent does render.
`setStatus` is still called so pi keeps its footer entry.

**No search.** With 356 projects the picker was a wall of
`smoke-mcptoken-*`. Above 20 projects it now asks for a filter first;
space-separated terms must all match, case-insensitively, against name or
description, so `home auto` finds `homeautomation`. Active project sorts
first, then alphabetical.

Deliberately no client-side truncation here, unlike the pi picker: pi's
`ExtensionSelectorComponent.updateList()` renders every option, so a long list
fills the screen and has to be capped. prime-agent's selector windows the list
itself and shows a true `(20/356)` counter — capping would replace an accurate
total with a misleading one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:33:23 +01:00
Michal
8b006263de fix(skills): write the frontmatter every Agent Skills host requires
Some checks failed
CI/CD / lint (pull_request) Successful in 1m18s
CI/CD / typecheck (pull_request) Successful in 1m15s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / build (pull_request) Successful in 2m17s
CI/CD / smoke (pull_request) Failing after 3m8s
CI/CD / publish (pull_request) Has been skipped
mcpd keeps `name` and `description` as columns, and a skill's `content` is
often just the body — `propose-learnings` starts straight at its `#` heading.
The sync wrote `full.content` verbatim, so the SKILL.md landed with no YAML
frontmatter and the host rejected it. Observed live:

  [Skill warning]
    ~/.prime/agent/skills/propose-learnings/SKILL.md
      description is required

Claude Code and pi require the same keys, so this affected every target; it
only surfaced now because prime-agent prints the warning at startup.

`ensureSkillFrontmatter` synthesises the block from the columns when it is
absent, and fills in only a missing `name`/`description` when the author
already supplied a header — an existing complete header is never rewritten.
Values are emitted as JSON strings (valid YAML double-quoted scalars) because
descriptions routinely contain `:` and `#`, which break a bare scalar.

Also: `--force` now re-fetches skills whose server content is unchanged.
Without it a skill already on disk could never be repaired by a client-side
fix — the content hash still matched, so it was skipped forever, which is
exactly what happened on the first attempt to repair the file above.

Three existing assertions compared SKILL.md to the raw server content; they
now assert the body survives rather than exact bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:16:22 +01:00
Michal
47809a8942 fix(pi): correct the model's tool context after a project switch
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m13s
CI/CD / lint (pull_request) Successful in 2m30s
CI/CD / test (pull_request) Successful in 1m25s
CI/CD / build (pull_request) Successful in 2m28s
CI/CD / smoke (pull_request) Failing after 2m59s
CI/CD / publish (pull_request) Has been skipped
Switching projects twice left the agent believing it had lost MCP access
entirely: it kept calling the previous project's tool names, got
"Tool mc_<old>_begin_session not found", and concluded no MCP tools existed.

Root cause is a pi constraint, not a bug in the switch. pi has no way to
unregister a tool — `registerTool` only ever does `extension.tools.set(name)`
— so the previous project's `mc_*` tools stay registered and merely go
inactive. `setActiveTools` correctly drops them from the live set, but the
conversation still contains the old project's tool listing, so the model
keeps calling names that now answer "not found".

Nothing was telling the model the tool set had changed. Now the switch
injects a custom message naming the active project, stating that previously
listed mcpctl tool names are dead, and listing what is actually callable.
Custom messages are converted to user-role messages by `convertToLlm`, so
they do reach the model (unlike `appendEntry`, which is explicitly excluded
from context). `display: false` keeps it out of the transcript — the
notification is what the human reads.

The gate is also called out explicitly, in both the notification and the
injected message. A freshly switched project gets a new mcp-session-id and is
therefore gated again, so "1 tool(s) ready" is correct but reads like a
failure; it now says which begin_session call unlocks the rest.

`toolChangeAnnouncement` is exported and unit-tested rather than left as
wording only reachable through a TUI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:05:33 +01:00
Michal
24a3b8cc0a feat(pi): filter the project picker instead of scrolling hundreds of rows
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m10s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / lint (pull_request) Successful in 2m45s
CI/CD / smoke (pull_request) Failing after 3m35s
CI/CD / build (pull_request) Successful in 2m7s
CI/CD / publish (pull_request) Has been skipped
pi's selector is a plain arrow-key list: `ExtensionUIDialogOptions` has no
search field and `ExtensionSelectorComponent` ignores typed characters, so
filtering has to happen before the list is handed over. With 356 projects —
most of them `smoke-proj-none-*` leftovers — arrowing to the one you want is
hopeless.

Above 20 projects the picker now asks for a filter first. Terms are
space-separated and all must match as case-insensitive substrings, so
`home auto` finds `homeautomation`. Blank shows everything, Esc cancels.

The active project sorts first (most likely pick), then alphabetical. A
result set over 50 is capped, and the title says what was dropped — a
silently truncated list reads as "that's all of them".

The ordering and matching are extracted into an exported `filterProjects` so
they are unit-tested rather than eyeballed through a TUI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:51:36 +01:00
Michal
739e679d1a fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
Some checks failed
CI/CD / lint (pull_request) Successful in 1m7s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 2m49s
CI/CD / smoke (pull_request) Failing after 1m54s
CI/CD / build (pull_request) Successful in 4m21s
CI/CD / publish (pull_request) Has been skipped
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.

Fixes:

- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
  chosen string; it was called with `{value,label}` objects, so the menu
  rendered five `[object Object]` rows and `choice === "status"` never
  matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
  `Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
  integration whose stated purpose is to not depend on ~/.claude — and said
  so in its own success message. It also ran execSync with `stdio: "inherit"`,
  painting raw output over pi's TUI, and interpolated the project name into a
  shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
  the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
  `docs-search`). The colliding tool was silently never registered but still
  reported active, so its calls were forwarded to the first tool. Names are
  now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
  parsing strips `//` comments, a no-op run destroyed them.

Guards, so this class of bug can't return:

- `src/pi-ext/tsconfig.json` checks the extension against the real published
  pi types (dev dependency, not a shim — a shim drifting from the published
  API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
  editing the sources without regenerating silently shipped old code.

Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
Michal
f1d84b0952 fix(config pi): embed extension sources so installed binary is self-contained
- add scripts/generate-pi-extension.ts to embed src/pi-ext sources as
  string constants (mirrors prime-agent's embedded switcher pattern).
- config pi now writes the embedded sources by default, so it works from
  an /usr/bin install with no source tree (--extension-dir overrides for
  dev). Verified installed binary writes files byte-identical to source.
- add installEmbeddedExtension test.
2026-08-08 17:03:03 +01:00
Michal
772c25f605 feat(cli): make the agentic-teams stack usable from the terminal
Everything so far was API-only. This adds the CLI surface and proves the whole
vertical works end to end against a real mcpd and a real Gitea.

  mcpctl create gitprovider mysources --url https://mysources.co.uk \
      --secret gitea-token --key TOKEN
  mcpctl repos discover mysources --query mcp --project demo
  mcpctl repos import   mysources --project demo michal/mcpctl
  mcpctl create epic ship-agents --project demo --content "..." \
      --criterion "cli=CLI can drive a task" --criterion "q:feel=reads at a glance"
  mcpctl create epictask foundation --epic ship-agents
  mcpctl create task wire-cli --epictask foundation --title "Wire the CLI"
  mcpctl tasks advance wire-cli in_progress
  mcpctl tasks verdict wire-cli --stage qa_review --commit 3f2ab19 --pass --summary "..."
  mcpctl tasks accept  wire-cli --note "ship it"
  mcpctl tasks history wire-cli

`repos` and `tasks` are their own verb groups rather than more create/edit
flags. Discovery is inherently two-step — look at what is there, then pick — and
accept/reject are the human gate, not field writes.

`get` gained columns for all five resources. The Epic row shows completeness as
"40% (2/5)" and CodeRepo shows SOURCE/FROM, so imported repos say where they
came from at a glance.

BREAKING: `mcpctl get tasks` now lists WORK ITEMS. The inference queue keeps
`inference-task(s)` and gains `itask(s)`; the short `task`/`tasks` aliases move
to work items. This completes the split already made in RBAC (a186d95) — having
the CLI and RBAC disagree about what "tasks" means would be worse than the
rename.

Verified end to end, not just unit tested: mcpd run locally against the test
database, admin bootstrapped, then the full flow driven through the built CLI.
Discovery returned the real repositories from https://mysources.co.uk, import
created the CodeRepo with provenance, re-discovery marked it `imported`, and the
task walked todo → in_progress → building → self_testing → qa_review with a
FAILING QA verdict correctly refusing `done`, then bounced back, passed on a
second commit, and merged through the accept gate. `tasks history` shows both
verdicts and all twelve transitions.

Two bugs the walkthrough caught that unit tests had not: `get repos` and
`get epics` crashed on `padEnd` because Prisma includes hand back `{ name }`
rather than a bare string, and the completions guard test failed because the
resource lists had changed without regenerating.

Local: build clean, workspace 2440 passed, db 128 passed (same 4 pre-existing
Secret failures), lint errors unchanged at the repo's pre-existing 794 (+2
no-console warnings, the same shape every other command registration uses).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 17:00:25 +01:00
Michal
b70300d389 feat(mcpd): CodeRepo + GitProvider — register a forge, discover and import repos
Two resources landing together, because a repo you have to hand-type is a repo
nobody adds.

CodeRepo is a git repository attached to a project. GitProvider is a configured
forge instance you register once — URL and credential in one place — and then
list and import from.

Nothing above the driver layer knows what a Gitea is. src/mcpd/src/services/git/
holds the GitProviderDriver interface, the Gitea implementation and a registry
that resolves by type, mirroring LlmAdapterRegistry.build. Adding GitHub or
GitLab is one file plus one case. The instance URL always comes from the
GitProvider row; there is no hardcoded host anywhere.

Follows the mcpservertemplates convention: a declarative resource applied via
apply -f, with instances keeping denormalized provenance that outlives the
source. CodeRepo stores sourceProvider + sourceFullName alongside the FK, so
"where did this repo come from" stays answerable after the forge is
disconnected — the same reasoning as McpServer.templateName/templateVersion.
The FK is SetNull, so disconnecting a forge never deletes the repos, or the
tasks hanging off them, that came from it.

Discovery uses /repos/search, deliberately NOT /user/repos. The latter needs the
read:user scope, which repo-scoped automation tokens do not carry — the token
in this repo's .env has write:repository and 403s on /user/repos. The driver
also passes Gitea's 403 scope message through verbatim, so an under-scoped
token says which scope it is missing instead of looking like an empty forge.

Import is partial-success by contract: one bad name must not lose the other
nineteen. Everything importable is imported; everything skipped comes back with
a reason, and a re-import is distinguished from a name collision with an
unrelated repo. Imported repos store no credential of their own and inherit the
forge's, so rotating one token covers every repo from that forge.

Credentials never leave as plaintext: reads return the reference only, and
resolveCheckouts() — the single path that produces a token — exists for the
session provisioner and is called by no route. References are verified at
definition time, so a wrong key fails with "secret has no key 'X'" rather than
as a clone that mysteriously cannot authenticate.

Clone URLs must be https/ssh/scp-style and checkout paths must be relative with
no ".." segment: both are inputs to the clone step, and neither should be able
to point it at mcpd's own filesystem or escape the workspace.

Tests: 18 CodeRepo + 22 GitProvider unit tests. Gitea driver additionally
verified live against https://mysources.co.uk (Gitea 1.25.4, 10 repos
discovered, query filter working).

Local: build clean, workspace 2440 passed (+40), db 128 passed (same 4
pre-existing Secret failures), lint unchanged at 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 16:42:40 +01:00
Michal
f90e23d000 Merge remote-tracking branch 'origin/main' into feat/pi-extension
# Conflicts:
#	README.md
#	src/cli/src/commands/config.ts
#	src/cli/src/commands/skills.ts
2026-08-08 16:35:51 +01:00
Michal
05abe3522c feat(mcpd): Task lifecycle — CAS transitions, risk-scaled done gate, accept gate
The work-item spine: repository, validation, service and routes for Task, plus
its verdicts and append-only transition log.

Transitions are compare-and-swap. The expected current state is part of the
WHERE clause and the affected-row count is the answer, so two agents racing to
advance the same task cannot both win — a read-then-write would let both
"succeed" and silently lose one. Losing the race is a 409 naming the state the
task is actually in, not a 500, because on a shared board that is a normal
outcome the caller retries. Same idiom as InferenceTaskRepository's claim.

QA / Security / SRE are stages inside the task's own loop, not post-submit
reviewers, so each can bounce straight back to in_progress — that is what
"feedback" means here. A failed build likewise goes backward, not forward.
`merged` allows only `reopened`, so a merged task cannot be quietly edited;
post-merge failure is the one way back in.

The gate on `done` scales with risk instead of running a fixed pipeline:
trivial needs nothing, normal needs a passing QA verdict, high needs QA +
Security + SRE. Forcing a one-line docs fix through security review is how a
process gets routed around, so the cost has to match the risk. A FAILING
verdict does not clear a gate — a recorded failure is evidence the gate was
tried and did not hold. The 400 names which gates are still missing.

The human accept gate is a task-level yes/no on a resolved outcome, not
per-tool approval: accept fires the merge and stores the comment, reject sends
it back to in_progress and REQUIRES a comment, because the comment is the
direction the agent works from.

GET /tasks/:id returns `legalTransitions` so no client has to hardcode the table.

Tests: 25 service tests — full happy path, every stage's bounce-back, illegal
and no-op transitions, the CAS race (including that no event is written when
the race is lost), the complete risk matrix including failing-verdict-does-not-
count, and the accept/reject gate.

Local: build clean, workspace 2400 passed (+25), db 128 passed (same 4
pre-existing Secret failures), lint unchanged at 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 16:28:26 +01:00
Michal
28f1a411fd feat(pi): add native pi integration — extension, config pi, skills --agent pi
- src/pi-ext/: self-contained pi extension (mcpctl-pi.ts + vendored
  mcp-http client) that talks JSON-RPC directly to mcplocal and registers
  project MCP tools as native pi tools. No MCP client, no ~/.claude.
- Persistent per-project session so gated projects ungate on begin_session.
- /mcpctl command: status, switch project (GUI), refresh tools, sync skills.
- mcpctl config pi: installs extension, wires pi settings, persists active
  project, syncs skills into ~/.pi/agent/skills.
- skills sync: add --agent pi (target install root).
- docs + tests.
2026-08-08 16:23:02 +01:00
Michal
3f93f0798f Merge pull request 'feat(cli): show the active mcpctl project in prime-agent's footer' (#96) from feat/mcpctl-switcher-status into main
Some checks failed
CI/CD / lint (push) Successful in 1m4s
CI/CD / typecheck (push) Successful in 2m18s
CI/CD / test (push) Successful in 1m18s
CI/CD / build (push) Successful in 2m13s
CI/CD / smoke (push) Failing after 3m24s
CI/CD / publish (push) Has been skipped
2026-08-08 12:18:22 +01:00
Michal
ce7df10e06 feat(cli): show the active mcpctl project in prime-agent's footer
`/mcpctl` could switch projects but there was no way to see which one was
active without running a command. prime-agent exposes the same status-bar
channel the model name uses (`ctx.ui.setStatus`), so the switcher now
publishes `mcpctl:<project>` there.

Wired to `session_start`, which fires on startup *and* on every reload —
including the reload the switch itself triggers — so the footer tracks
settings.json without extra bookkeeping. Cleared when no project is mounted.

Also fixes `notify(..., 'success')`: the API only accepts info|warning|error.
Not a live bug (prime-agent falls through to the same showStatus path as
'info') but it fails a typecheck of the extension against the real
ExtensionAPI, which is how it was found.

The extension ships as a JSON-escaped string and is never compiled by our
build, so it was typechecked out-of-tree against
@earendil-works/pi-coding-agent's published types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 12:18:21 +01:00
Michal
25c1bffdd0 Merge pull request 'fix(cli): harden config prime-agent sync, auth + /mcpctl switcher' (#95) from feat/config-prime-agent into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m5s
CI/CD / lint (push) Successful in 2m11s
CI/CD / test (push) Successful in 1m20s
CI/CD / smoke (push) Failing after 1m53s
CI/CD / build (push) Successful in 4m7s
CI/CD / publish (push) Has been skipped
2026-08-08 12:02:08 +01:00
Michal
170dc06496 fix(cli): close third review — token collision, migration, ownership
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m5s
CI/CD / lint (pull_request) Successful in 2m13s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / build (pull_request) Successful in 2m9s
CI/CD / smoke (pull_request) Failing after 2m44s
CI/CD / publish (pull_request) Has been skipped
Round 2 fixed the first review but introduced regressions of its own, all
of which only bite against state written by the previously installed build.

`config prime-agent`:
- Mint each credential under a unique `prime-agent-<stamp>` name again.
  `McpToken` is unique on (name, projectId) and revoke is a soft delete, so
  round 2's fixed `prime-agent` name could only ever be minted once per
  project — and the revoke-first ordering destroyed the working credential
  before discovering the mint would fail.
- Provision the credential BEFORE touching settings.json. Registering the
  new project unmounts the previously active one, so a failed mint must not
  be able to leave prime-agent with no working project at all. The command
  now aborts with settings.json untouched.
- Retire only the token this auth.json actually held, once its replacement
  is stored. Sweeping every `prime-agent*` token for the project would
  revoke the credential another install (or a custom --output run) is
  using; anything else that looks orphaned is reported, not deleted.
- Validate a pre-existing credential instead of trusting its presence: a
  revoked or expired token used to short-circuit provisioning and leave
  prime-agent broken while the command reported success. Matched by
  tokenPrefix against the project's active tokens, so the secret is never
  sent. Fails open when the API can't be consulted.
- Actually write auth.json 0600. `writeFile`'s mode is ignored for an
  existing file and prime-agent creates auth.json itself at 0644, so chmod
  after writing.
- Recognise the untagged mcpServers entries older CLIs wrote (canonical
  proxy URL + an `mcp:<name>` mcpctl PAT in auth.json) so a switch unmounts
  them instead of leaving two gateways live. Hand-configured servers have
  no such credential and are still preserved. Same rule in the `/mcpctl`
  switcher's active-project lookup.
- Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the
  extension runs from whatever directory prime-agent was started in, and
  was silently re-scoping that repo's `.mcpctl-project`.

`skills sync --agent prime-agent`:
- Record ownership from the skill's own scope, not the syncing project's.
  Globals were being pinned to whichever project happened to sync them,
  after which every other project refused to update them forever.
- Never adopt legacy, ownership-less state into the current scope. Round 2
  did, which deleted the other project's skills on the first sync after
  upgrading. Such entries are attributed to the project that last wrote the
  state file, and left alone when that isn't the project syncing now.
- Close the overwrite-guard bypass: a sync with no project, or a global
  landing on a project-owned name, could still clobber and re-own a
  tracked skill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
bench-cpa-fixed
2026-08-08 12:01:52 +01:00
Michal
da4752b90f fix(mcplocal): stop the 30s proxy timeout killing agent turns
`mcpctl chat <agent>` failed with

    HTTP 503 {"error":"service_unavailable","message":"Cannot reach mcpd daemon. Is it running?"}

while mcpd was answering /healthz in 32ms. The message was wrong in a way that
cost real debugging time: mcplocal was reaching mcpd fine and giving up after
30s. journalctl shows the signature plainly — statusCode 503 with
responseTime 30003.87 on POST /api/v1/agents/reviewer/chat.

This blocks the agentic-teams epic outright. An agent turn is a multi-turn
tool-use loop that runs for minutes by design, so a 30s ceiling on the chat path
is not a safety net, it is a guaranteed failure for every non-trivial turn.

Three defects, all in the same path:

1. One blanket budget for every forwarded route. DEFAULT_TIMEOUT_MS = 30_000 is
   right for CRUD and wrong for chat. Chat, project chat, llm infer and
   inference-task streams now get LONG_RUNNING_TIMEOUT_MS (600_000, override
   with MCPLOCAL_LONG_TIMEOUT_MS) — matching STREAM_TIMEOUT_MS, which the CLI
   already allowed. mcplocal in the middle was the binding constraint.

2. Timeouts were reported as connection failures. Split UpstreamTimeoutError
   out of ConnectionError and map it to 504 with an accurate message that says
   the daemon IS reachable. ConnectionError still means unreachable and still
   returns 503. Verified nothing else branches on ConnectionError.

3. SSE was buffered. `forward()` reads the whole body through res.text(), so
   even turns that finished in time arrived as one blob and the CLI's live
   token output never appeared. Streaming routes now use forwardStream() and
   pipe the body straight through, preserving content-type and
   x-accel-buffering (dropping the latter lets intermediaries re-buffer and
   reintroduces the stall).

Also closes the escape that produced the sibling `500 code:23` failure: the body
read in forward() was outside the try, so when mcpd had already written SSE
headers the raw DOMException reached Fastify unhandled.

Tests: 9 new proxy tests. The two that matter — "does not abort an agent chat
that outlives the CRUD budget" and "streams SSE through instead of buffering" —
were confirmed to FAIL against the pre-fix behaviour and pass after. Three
existing mcpd-client tests asserted the old taxonomy and were updated to assert
the new one deliberately.

Local: build clean, workspace 2375 passed, lint unchanged at 869.

NOT YET LIVE: mcplocal runs from the installed RPM, so this needs a package
rebuild + `systemctl --user restart mcplocal` to take effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 11:35:16 +01:00
Michal
fa7055ac5e fix(cli): close second review on prime-agent sync + switcher (auth, ownership, switching)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / typecheck (pull_request) Successful in 1m5s
CI/CD / test (pull_request) Successful in 3m7s
CI/CD / smoke (pull_request) Failing after 3m13s
CI/CD / build (pull_request) Successful in 2m12s
CI/CD / publish (pull_request) Has been skipped
Addresses the second round of `config prime-agent` review (10 findings).

auth.json (config/prime-agent.ts) — the settings.json data-loss fix had a twin:
- loadPrimeAgentAuth now fails loudly on corrupt JSON instead of swallow-and-
  rewrite, so one syntax error can no longer destroy the provider API key and
  every other project's credential. hasPrimeAgentAuth shares that guarantee.
- writePrimeAgentAuth writes 0600 (preserving an existing file's mode) instead
  of the default umask — bearer tokens are no longer world-readable on first
  creation.

state ownership (commands/skills.ts) — the ownership model edge cases:
- orphan-removal guard now normalises a canonical scope (project name, or null
  for globals; legacy undefined adopted to current scope) instead of comparing
  null against undefined, so global-only syncs and pre-PR state can no longer
  leave stale skills on disk forever.
- a same-named skill *tracked* to a different project is preserved (with a
  warning) rather than silently overwritten in the shared flat tree.
- mcpServers auto-attach is gated behind !isPrimeAgent with an explicit warning
  (prime-agent's HTTP gateway must not mutate shared mcpd project attachments);
  this also makes the earlier dropped-attach concern explicit rather than silent.

single-active project + switcher (config/prime-agent.ts, prime-agent-extension.ts):
- registerPrimeAgentMcp tags the project's entry mcpctlManaged:true and removes
  other mcpctl-managed entries, so prime-agent has one *active* mcpctl project
  while preserving untagged servers (hand-configured sre, websearch, etc).
- the /mcpctl extension now reads that tag as the single source of truth for the
  active project, fixing the false short-circuit / no-op switch.

config.ts command:
- an explicit -p now updates a differing up-tree .mcpctl-project marker (scope
  no longer silently reverts on the next sync), no-ops when it matches, and
  still never scopes $HOME.
- a project left with no usable credential now exits non-zero (the /mcpctl
  extension checks child exit status, so it no longer reports a successful
  switch after provisioning failed).
- skills sync is treated as best-effort: settings+auth determine switch success,
  so a skills error no longer falsely fails the switch.
- token minting now revokes prior active `prime-agent` tokens before creating a
  fresh one (no more never-expiring token litter / lost-credential duplication).

Tests (544 green): corrupt auth.json refusal, 0600 mode, mint-failure exit
code, single-active dedup preserving untagged sre, cross-project overwrite
preservation, and global-orphan removal on global-only sync.
bench-cpa-pre-fix
2026-08-08 11:28:15 +01:00
Michal
7f64316d59 feat(mcpd): EpicTask resource — roadmap nodes, edges, and the roadmap view
Roadmap nodes plus the edges that chain them into the dependency graph the
design doc calls the spider web. Edge direction is "from depends on to".

The database cannot enforce graph integrity here — production provisions with
`prisma db push`, so a trigger or CHECK would never exist there, and the schema
test added earlier documents that boundary explicitly. So the service rejects
the three things that would otherwise corrupt the roadmap:

- self-edges,
- edges crossing Epics, since a roadmap is one Epic's graph,
- cycles, which make the graph unschedulable and hang any topological walk.

Cycle detection is a BFS over the prerequisite direction: adding from→to closes
a loop exactly when `to` already depends transitively on `from`. The error names
the offending path, not just "cycle", so a failed apply says which nodes to look
at. The traversal carries a seen-set that also guarantees termination if the
stored graph ALREADY contains a cycle — from a hand-edited database or an
earlier bug — rather than spinning forever. There is a test for that case.

Adding an existing edge is a no-op rather than an error, so applying a manifest
twice is safe.

GET /api/v1/epics/:id/roadmap returns nodes with per-node completeness, both
edge directions, and `ready`: the nodes whose prerequisites are all satisfied,
which is the question anyone actually asks of a roadmap. A cancelled
prerequisite counts as satisfied — it is not coming. Completeness reuses
EpicService.completeness verbatim so a node and its Epic can never disagree
about what "met" means.

Tests: 19 service tests — the full cycle matrix (self, 2-cycle, long transitive,
diamond-is-fine, pre-existing-cycle termination), roadmap edge directions,
readiness including the cancelled-prerequisite case, and CRUD guards.

Local: build clean, workspace 2366 passed (+19), db 128 passed (same 4
pre-existing Secret failures), lint unchanged at 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 11:10:39 +01:00
Michal
909506317e feat(mcpd): Epic resource — the vision layer, with an honest-done gate
Full CRUD for Epic: repository, zod validation, service, routes, main.ts
wiring, and revision tracking (ResourceType gains 'epic', which is a plain
string union by design so this needed no migration).

The substance is the done rule, not the CRUD. An Epic exists to stop "every
task completed, still no car", so status is deliberately NOT settable through
PUT — it goes through POST /epics/:id/status, which enforces:

- `delivered` requires every done-criterion met. The 400 names the unmet ids
  (truncated past 5) so the caller knows what is actually missing.
- `delivered` with recorded tradeoffs is refused — if you accepted a gap, the
  status has to say so.
- `delivered_with_tradeoffs` requires at least one recorded tradeoff, otherwise
  it is `delivered` wearing a disguise.
- `delivered` with zero criteria is refused: nothing was claimed, so nothing
  can have been delivered.
- `cancelled` and `open` are always allowed — abandoning needs no ceremony, and
  reopening is how a gap gets fixed.

Completeness counts `quality` criteria alongside `outcome` ones. Grading only
the mechanically checkable half is exactly the failure the Epic exists to
prevent, so both feed the percentage, and it is computed server-side and
attached to every response rather than left for clients to re-derive.

Tradeoffs are append-only and stamped with who and when — the record is the
audit trail for why "done" was honest.

Scope changes patch-bump semver, so creep is versioned as visibly as
under-delivery.

Tests: 26 service tests (the full done-rule matrix, tradeoff append/stamping,
criterion flipping, semver bumping, and that a plain update cannot smuggle a
status through) and 11 route tests (completeness attachment, owner stamping,
400 vs 404 vs 500 behaviour, the status gate over HTTP).

Local: build clean, workspace 2347 passed (+37), lint unchanged at 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 11:05:28 +01:00
Michal
ffcd0f985b feat(orchestrator): ephemeral session pods — restart policy, volumes, init containers
An MCP server container is long-lived and restarts forever; an agent session pod
is the opposite — it does a unit of work and dies. ContainerSpec could not
express that: restartPolicy was hardcoded 'Always' in generatePodSpec, and there
were no volume, workdir, or init-container fields at all.

Adds four optional fields to ContainerSpec (restartPolicy, workdir, volumes,
initContainers) and implements them in both backends. All optional, so every
existing caller is byte-identical — generatePodSpec still emits 'Always' unless
asked otherwise, and omits volumes/initContainers keys entirely when unused.

Kubernetes gets this nearly free: initContainers run to completion in
declaration order before any app container starts, so "clone the repos, then
work them" needs no sequencing logic from us. generateDeploymentSpec
deliberately ignores spec.restartPolicy — k8s rejects anything but 'Always' on a
Deployment pod template — and says so in a comment.

Docker has no init containers, so they are emulated as sequential runs sharing
the same volumes. The important property is the failure path: a non-zero init
exit aborts BEFORE the main container is created, and the error carries the init
container's last 50 log lines. Otherwise a failed git clone would leave an empty
workspace that looks exactly like a successful one, and the agent would start
work against nothing. Named Docker volumes are namespaced by container name to
stand in for emptyDir.

Tests: 10 k8s manifest tests (defaults unchanged, each restart policy, volume
and mount emission, init ordering, name sanitization, security context still
hardened) and 9 Docker tests including the abort-before-main case and log
propagation.

Local: build clean, workspace 2310 passed (+19), lint unchanged at the repo's
pre-existing 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 10:36:21 +01:00
Michal
fceb3cfb95 feat(db): agentic-teams data model — epics, work items, repos, membership
Adds the foundation entities from docs/agentic-teams.md §5/§7: a versioned
vision layer (Epic -> EpicTask -> Task), git repositories attached to projects,
agents as many-to-many project members, and the ephemeral session that IS an
agent's workspace.

9 tables + 5 enums, purely additive — no drops and no column changes on
existing tables, since every new relation is held on the new side.

Shape notes:

- EpicTask chaining uses an explicit EpicTaskEdge table rather than a
  self-referencing array, so the roadmap graph walks in both directions and
  edges can carry a type later.
- TaskVerdict is commit-pinned and immutable: "QA verified 3f2ab" stays
  reproducible, which is what lets later coverage claims be audited rather than
  believed. TaskEvent is the append-only transition log.
- Task survives losing its repo and its assignee (both SetNull) — a work item
  outlives whoever happened to be working it — but cascades from its EpicTask.
- CodeRepo, not Repository: `repository.repository.ts` next to 24 other
  *.repository.ts data-access files would be actively confusing. CLI resource is
  `repos`, with repo/repository/repositories aliased onto it.
- AgentProject.llmId exists so the brain-ownership question
  (docs/agentic-teams.md §15 Q1) can be settled either way without a migration.
- Agent.projectId is retained and coexists with AgentProject for one release so
  findByProjectId / prepareContext keep working. Note this partially reverses
  20260223120000_remove_project_members — deliberately, and only for agents;
  human membership stays RBAC-driven.

No CHECK constraints anywhere, on purpose: deploy/entrypoint.sh provisions with
`prisma db push`, so hand-written migration SQL never runs in production. The
migration file is kept for local `migrate dev` parity and as a readable record,
and says so in its header. Invariants that cannot be expressed in the schema
(EpicTaskEdge cycle rejection) belong to the service layer; a schema test
documents that boundary rather than pretending the database enforces it.

Tests: 23 schema tests covering defaults, per-project name scoping, cascade vs
SetNull on every FK, all 12 lifecycle states, and the GC-sweep predicate.
clearAllTables extended for all 9 models.

Verified locally against postgres:16-alpine on :5433 — db suite 128 passed
(baseline was 105; the same 4 pre-existing Secret_backendId_fkey failures
remain, unrelated). Workspace 2291 passed, build clean, lint unchanged at the
repo's pre-existing 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 10:30:02 +01:00
Michal
eb1642ab1a fix(cli): harden config prime-agent sync + install /mcpctl switcher extension
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / typecheck (pull_request) Successful in 1m6s
CI/CD / test (pull_request) Successful in 3m9s
CI/CD / build (pull_request) Successful in 2m16s
CI/CD / smoke (pull_request) Failing after 3m27s
CI/CD / publish (pull_request) Has been skipped
Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.

Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
  JSON instead of swallowing it and rewriting the file (which destroyed every
  non-mcpServers setting). A project's mcpServers entry is merged (keeping
  user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
  / hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
  via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
  a thin wrapper). Under the prime-agent target it: preserves untracked
  pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
  records per-project ownership so configuring a second project never deletes
  the first project's skills, skips Claude-only hooks/postInstall, and keeps
  the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
  auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
  (b) writes the .mcpctl-project marker only when none exists up-tree and never
  from $HOME, and (c) propagates the skills sync exit code so auth failures are
  reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
  silently running the Claude sync.

New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.

Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
Michal
a186d95a85 feat(rbac): split the tasks namespace into tasks + inference-tasks
v5 gave the durable inference queue the RBAC resource `tasks`, on the
reasoning that inference was the only kind of task we had. Agentic teams
introduces work-item tasks (Epic -> epictask -> task), which need that name.

The queue moves to its own `inference-tasks` resource, matching its existing
/api/v1/inference-tasks route prefix, and `task`/`tasks` now mean work items.
Also lands the agentic-teams aliases: epic, epictask/et, repo/repository, session.

The rename is not purely a code change. ResourceBindingSchema runs
normalizeResource at parse time and stores the normalized string, so every
RbacDefinition row written before the split literally contains "tasks" meaning
inference. Shipping the alias change alone would silently stop those grants
authorizing the queue and start them authorizing work items -- a privilege
change in both directions. bootstrap/rbac-tasks-backfill.ts rewrites them once
at startup, alongside migrateAdminRole.

The run-once marker is claimed BEFORE the rewrite, not after. A failed marker
write after a successful rewrite would let the next startup rewrite again,
converting genuine post-split work-item grants into inference grants. Losing
the rewrite is recoverable by re-granting; running it twice silently is not --
so if the marker cannot be persisted we skip the rewrite and warn loudly.

mapUrlToPermission moves in lockstep: /api/v1/inference-tasks maps to
`inference-tasks`, otherwise the split would break queue authorization on the
first request.

Adds generic getBooleanSetting/setSetting helpers to bootstrap/system-settings.ts,
following the existing "global settings live in a Secret" pattern. setSetting
refuses to write when the settings secret has been migrated onto a non-plaintext
backend, where a direct `data` write would read back empty.

Out of scope, tracked separately: the CLI still routes `mcpctl get tasks` to the
inference queue (src/cli/src/commands/shared.ts). Repointing it needs the
work-item resource to exist first, or the command breaks with no replacement.

Tests: 14 unit tests cover alias resolution, the rewrite, wildcard and
operation bindings being left alone, idempotency, and both marker-failure paths.
Full workspace suite green (2291 tests). The new smoke test is RED until mcpd
is deployed with this change -- it asserts post-split storage against a live
daemon, and the currently deployed one predates the split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 10:21:56 +01:00
d56a074733 Merge pull request 'feat(cli): add mcpctl config prime-agent — proxy MCP + skills sync for prime-agent' (#93) from feat/config-prime-agent into main
Some checks failed
CI/CD / lint (push) Successful in 1m6s
CI/CD / typecheck (push) Successful in 2m9s
CI/CD / test (push) Successful in 1m21s
CI/CD / smoke (push) Failing after 1m53s
CI/CD / build (push) Successful in 4m16s
CI/CD / publish (push) Has been skipped
Reviewed-on: #93
bench-cpa-base
2026-08-08 09:19:32 +00:00
Michal
797df2299d docs(taskmaster): PRD + task graph for agentic teams foundation
Converts docs/agentic-teams.md §16 steps 1-3 into a trackable PRD and 48
appended tasks (IDs 115-162) in the master tag. Steps 4-8 land as 5 low
priority placeholders wired onto the spine, pending the §15 open questions.

The PRD is grounded in the current codebase rather than the design doc's
indicative sketches, which changed the shape of the work:

- RBAC `tasks` is already owned by the inference queue, and bindings are
  normalized at parse time, so existing RbacDefinition rows literally hold
  "tasks". Taking the name for work items needs a one-shot guarded backfill
  to `inference-tasks` (task 116).
- deploy/entrypoint.sh uses `prisma db push`, so migration SQL never runs in
  production. Invariants are service-layer, not CHECK constraints.
- Ephemeral pods do not exist: restartPolicy is hardcoded Always and
  ContainerSpec has no volume/workdir fields (tasks 140-142).
- `exec` is cheap: KubernetesOrchestrator already implements exec/attach over
  k8s.Exec/k8s.Attach; only an API + CLI surface is missing (tasks 144-145).
- No git provider client exists in product code (task 148).
- ChatService caps its tool loop at 12 iterations, so the AgentRuntime runs a
  headless coding agent in-pod rather than extending chat (tasks 146-147).

Names the git-repo entity CodeRepo (CLI `repos`) to avoid colliding with the
repository/ data-access layer; AgentProject carries an optional llmId so the
brain-ownership question can be settled either way without a migration.

Generated task_*.md files are left untracked, matching how this repo has
treated them since the first commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 09:58:20 +01:00
Michal
582f6f185b feat(cli): add mcpctl config prime-agent — proxy MCP + skills sync for prime-agent
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / smoke (pull_request) Failing after 1m53s
CI/CD / build (pull_request) Successful in 4m14s
CI/CD / publish (pull_request) Has been skipped
Mirror `mcpctl config claude` for prime-agent (which talks to the same mcpctl
proxy MCP gateway over HTTP instead of stdio):

`mcpctl config prime-agent --project X`:
- registers the proxy MCP gateway in ~/.prime/agent/settings.json as
  mcpServers.X = { type: "http", url: <gateway>/projects/X/mcp }, merging with
  any existing servers (e.g. the bundled `sre` project) and preserving all other
  settings
- writes a .mcpctl-project marker so later syncs resolve the project
- syncs the project's skills into ~/.prime/agent/skills/<name>/ as markdown
  skills (prime-agent auto-discovers them at session start)

New `mcpctl skills sync --agent prime-agent` target re-syncs the tree later.

- src/cli/src/config/prime-agent.ts: settings.json read/merge/write helpers
- src/cli/src/utils/prime-agent-skills.ts: prime-agent sync (reuses
  installSkillAtomic + skills-state; skips Claude-only hooks/postInstall)
- completes config.ts/skills.ts wiring; regenerated shell completions
- tests: commands/prime-agent.test.ts + utils/prime-agent-skills.test.ts
2026-08-08 09:34:15 +01:00
Michal
d24c30761b docs: keep superseded design-round doc history
Supporting documents folded into agentic-teams.md (the canonical unified
design record), retained as historical context:

- agentic-teams-review.md (round 2)
- vision-fidelity.md (round 3 diagnosis + research)
- epic-task-artifact-model.md (Epic/epictask/task + context philosophy)
- session-summary-vision-fidelity.md (session narrative)

All superseded by docs/agentic-teams.md, which remains the source of truth.
2026-08-08 09:03:58 +01:00
Michal
5e1c6b3681 docs: unified agentic-teams design document
Consolidates agentic-teams (rounds 1-2), vision-fidelity, Epic/artifact
model, and session summary into one canonical design record.

Supersedes: agentic-teams-review.md, vision-fidelity.md,
epic-task-artifact-model.md, session-summary-vision-fidelity.md
2026-08-08 09:03:35 +01:00
2c8419eddb Merge pull request 'fix(cli): stop the MCP stdio bridge serialising requests' (#92) from fix/mcp-bridge-head-of-line into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m4s
CI/CD / lint (push) Successful in 2m16s
CI/CD / test (push) Successful in 1m21s
CI/CD / smoke (push) Failing after 1m55s
CI/CD / build (push) Successful in 4m18s
CI/CD / publish (push) Has been skipped
One slow call stalled every later request because the stdin loop awaited each in turn; queued requests were never sent so nothing could time them out and the client saw silence. Dispatch is now concurrent. Regression test verified to fail on the old code.
2026-08-07 14:20:27 +00:00
Michal
e85250fedf fix(cli): stop the MCP stdio bridge serialising requests
Some checks failed
CI/CD / lint (pull_request) Successful in 1m7s
CI/CD / typecheck (pull_request) Successful in 2m11s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / build (pull_request) Successful in 2m24s
CI/CD / smoke (pull_request) Failing after 3m23s
CI/CD / publish (pull_request) Has been skipped
The bridge's stdin loop awaited every request before reading the next line, so
it handled exactly one at a time. A single slow call therefore stalled every
later request — and because those requests were never even sent, nothing could
time them out. The client saw silence, not an error.

Observed 2026-08-05: two gitea calls through this bridge sat completely mute
until Claude Code aborted them at its own 1800s idle limit, reporting "sent no
response or progress". The upstream was healthy the whole time — a fresh
session answered the same tools in 0.3s, and the gitea MCP server's own log
showed the calls never reached it. They died queued in the bridge.

JSON-RPC ids exist precisely so responses may return out of order, so nothing
here needed a queue. Requests now dispatch concurrently and are tracked in a
set; stdin close awaits them before the session DELETE, otherwise a concurrent
call races the teardown and dies with a 404.

We still serialise until the session id exists: it arrives on the first
response, and firing later requests without it would open a second upstream
session. A client sends `initialize` first and waits for the reply anyway, so
this costs one round trip rather than throughput.

Also makes the per-request timeout configurable via MCPCTL_MCP_TIMEOUT_MS
(default unchanged at 30s) and names it in the timeout error, so a project with
genuinely long tool calls can raise it instead of hitting a hardcoded wall.

Tests pin both halves: a fast request must overtake a slow one (this fails on
the old serial code — verified by reverting), and a failed request must still
produce a JSON-RPC error carrying the original id rather than nothing.
2026-08-07 15:19:50 +01:00
cf85e36ede Merge pull request 'feat(gate): drop reasoning field from selection (~1.5s)' (#90) from feat/gate-drop-reasoning into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m5s
CI/CD / lint (push) Successful in 2m10s
CI/CD / test (push) Successful in 1m19s
CI/CD / smoke (push) Failing after 1m50s
CI/CD / build (push) Successful in 4m7s
CI/CD / publish (push) Has been skipped
2026-07-25 02:07:30 +00:00
Michal
34e00af731 feat(gate): drop the reasoning field from selection → ~1.5s (comfortable margin)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m12s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / build (pull_request) Successful in 2m11s
CI/CD / smoke (pull_request) Failing after 2m47s
CI/CD / publish (pull_request) Has been skipped
Even "<=8 words" was ignored — the model wrote a full-sentence reasoning, pushing
begin_session to ~7s (thin under the 8s budget). The gate only needs the names, so
request just {"selectedNames":[...]}. Validated live vs vllm-current
(glm-4.6-reap-fast): 1.5s, valid JSON. (reasoning defaults to '' in extractSelection.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 03:07:28 +01:00
2f9e7be762 feat(gate): terse selection prompt (~2s) (#89)
Some checks failed
CI/CD / typecheck (push) Successful in 1m4s
CI/CD / lint (push) Successful in 2m8s
CI/CD / test (push) Successful in 1m20s
CI/CD / smoke (push) Failing after 1m51s
CI/CD / build (push) Successful in 4m7s
CI/CD / publish (push) Has been skipped
2026-07-24 20:38:32 +00:00
Michal
a432e9e19f feat(gate): terse selection prompt so a no-think model answers in ~2s
Some checks failed
CI/CD / lint (pull_request) Successful in 1m3s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m21s
CI/CD / build (pull_request) Successful in 2m14s
CI/CD / smoke (pull_request) Failing after 3m16s
CI/CD / publish (pull_request) Has been skipped
With the fast (no-think) route, gate-selection latency is now proportional to the
ANSWER length (a no-think model runs ~10-13 tok/s), not hidden thinking. A verbose
"reasoning" field pushed a selection to ~9s (over the 8s budget). Request compact
JSON with a <=8-word reasoning → ~2s (vs ~9s prose, or ~1.2s with no reasoning).
Validated live against vllm-fast (glm-4.6-reap-fast): 2.0s, valid selection JSON,
zero reasoning tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 21:38:08 +01:00
3b96b0399e feat(gate): fast no-think selection + dedicated selection Llm (#88)
Some checks failed
CI/CD / typecheck (push) Successful in 1m3s
CI/CD / lint (push) Successful in 2m7s
CI/CD / test (push) Successful in 1m17s
CI/CD / smoke (push) Failing after 1m50s
CI/CD / build (push) Successful in 4m5s
CI/CD / publish (push) Has been skipped
2026-07-24 13:29:58 +00:00
Michal
5225b54901 feat(gate): MCPCTL_GATE_SELECTION_LLM — pin a dedicated fast selection Llm
Some checks failed
CI/CD / lint (pull_request) Successful in 1m2s
CI/CD / typecheck (pull_request) Successful in 2m7s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / build (pull_request) Successful in 2m11s
CI/CD / smoke (pull_request) Failing after 2m47s
CI/CD / publish (pull_request) Has been skipped
Lets ops route gate prompt-selection at a fast (no-think) server Llm independent
of the project's chat llmProvider — so selection stays ~1-2s while chat keeps its
thinking model. Overrides the project llmProvider for the gate's server-selection
path; unset → prior behavior (use the project's llmProvider). Pairs with the
litellm qwen3-fast no-think alias (kubernetes-deployment) + a `vllm-fast` mcpd Llm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 14:28:44 +01:00
Michal
27480181bf feat(gate): ask reasoning models for a fast, no-think selection response
Prompt selection is mechanical classification — it gains nothing from chain-of-
thought, and a reasoning model (qwen3-thinking) otherwise burns its whole budget
reasoning (~40s, 6k+ chars) and blows the gate's 8s timeout. The gate's server
selection request now includes thinking-suppression hints, forwarded verbatim by
mcpd's passthrough adapter to litellm/vLLM:
  chat_template_kwargs.enable_thinking=false  (Qwen3 hard-off)
  reasoning_effort=low                         (o-series / newer vLLM)
Harmless for models that ignore them; if a backend rejects them the selector
falls back to the local provider. Override via MCPCTL_GATE_SELECT_EXTRA_BODY
(JSON), or '' to disable.

NOT yet validated live — the qwen3-thinking vLLM is crashlooping again (0/1,
HTTP 500), and whether litellm forwards chat_template_kwargs to vLLM is unconfirmed
(the /no_think prompt directive was NOT honored). Validate when the backend
recovers. mcpld gate/selector tests green (75).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:07:27 +01:00
86942b1e91 feat(gate): route prompt-selection through mcpd server Llm (#87)
Some checks failed
CI/CD / typecheck (push) Successful in 1m2s
CI/CD / lint (push) Successful in 2m7s
CI/CD / test (push) Successful in 1m18s
CI/CD / smoke (push) Failing after 1m51s
CI/CD / build (push) Successful in 4m8s
CI/CD / publish (push) Has been skipped
2026-07-24 11:52:03 +00:00
Michal
2729580974 feat(gate): route prompt-selection through mcpd's server Llm (credential tiering)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m3s
CI/CD / lint (pull_request) Successful in 2m6s
CI/CD / test (pull_request) Successful in 1m18s
CI/CD / build (pull_request) Successful in 2m10s
CI/CD / smoke (pull_request) Failing after 3m9s
CI/CD / publish (pull_request) Has been skipped
Cloud/server keys belong at the k8s/mcpd level; mcplocal handles only the user's
personal tokens. The gate's prompt-selection used mcplocal's LOCAL provider
registry (heavy=anthropic = the user's OAuth token, which is API-gated and 404s),
so selection always degraded.

Now LlmPromptSelector tries sources in priority order: (1) the project's server
Llm via mcpd's inference proxy (POST /api/v1/llms/:name/infer) — cloud/server
keys stay at k8s — then (2) the local personal-token provider as fallback. First
source that returns valid selection JSON wins. Extracted pickCompletionText
(content ?? reasoning_content) + extractSelection helpers.

Wiring: GatePluginConfig.llmProvider (from the project), threaded via
createDefaultPlugin; handleBeginSession builds serverInfer from ctx.postToMcpd.
Verified live: POST .../llms/vllm-current/infer returns valid selection JSON
(qwen3-thinking, 4000-token budget). + selector tests (server preferred, local
fallback on error, server-only). mcplocal suite green (755).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:43:48 +01:00
e75d2ba296 feat(proxy): favourite-index tool presentation + gate provider hardening (#86)
Some checks failed
CI/CD / typecheck (push) Successful in 1m5s
CI/CD / lint (push) Successful in 2m11s
CI/CD / test (push) Successful in 1m19s
CI/CD / smoke (push) Failing after 1m51s
CI/CD / build (push) Successful in 4m17s
CI/CD / publish (push) Has been skipped
2026-07-23 21:28:22 +00:00
Michal
366b505c00 fix(providers): anthropic surfaces HTTP errors too (gate parity with openai)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 1m5s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / build (pull_request) Successful in 2m14s
CI/CD / smoke (pull_request) Failing after 3m0s
CI/CD / publish (pull_request) Has been skipped
The local mcplocal gate resolves heavy=[anthropic]; its OAuth-gated key 404s the
configured opus-4 model, and anthropic.ts (like openai.ts before this series)
JSON.parsed the error body as a completion → empty content → the gate's
misleading "did not contain valid selection JSON". Reject on status >= 400 with
"Anthropic HTTP <status>: <body>" so the true reason (model-not-found / auth)
surfaces as the degradedReason. + parity test.

Note: this makes the error honest; it does not make the local gate SUCCEED — that
needs a working heavy provider (the anthropic OAuth token is fundamentally gated).
The qwen3-thinking gate path (k8s mcplocal) is addressed by the budget+reasoning
fix in the previous commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:23:37 +01:00
Michal
c9364a2f2a fix(favourite-index): route favourite/ calls even before tools/list (lazy resolver)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m4s
CI/CD / lint (pull_request) Successful in 2m12s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / smoke (pull_request) Failing after 1m54s
CI/CD / build (pull_request) Successful in 4m31s
CI/CD / publish (pull_request) Has been skipped
The presented→canonical resolver was only built during tools/list, so a
favourite/<tool> call arriving before any list this session fell through to the
namespaced router and 404'd (all/ was fine — it prefix-strips). Real MCP clients
list first, but the smoke test's initialize→call flow exposed it. Extract
buildPresentation() and lazily rebuild the resolver in onToolCallBefore when a
favourite//all/ name isn't resolved yet. + unit test (favourite/ call with no
prior list) and realistic listTools() in the smoke routing case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:19:05 +01:00
Michal
9dbd2f175d fix(gate): stop masking backend errors + rescue thinking-model prompt selection
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m14s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / smoke (pull_request) Failing after 1m54s
CI/CD / build (pull_request) Successful in 4m17s
CI/CD / publish (pull_request) Has been skipped
Two independent, live-reproduced causes of "Smart prompt-selection unavailable
(LLM response did not contain valid selection JSON)" in the gate:

- Cause B (openai.ts request): the response handler JSON.parsed the body with no
  res.statusCode check, so a LiteLLM/vLLM HTTP 500 ("Connection error") parsed as
  an empty completion and surfaced as generic "bad JSON", masking backend-down.
  Now reject on status >= 400 with "OpenAI HTTP <status>: <body>" so the selector
  reports the true degradedReason.
- Cause A (parseResponse + selector budget): a reasoning model (qwen3-thinking)
  spends its whole 1024-token budget in reasoning_content and emits content=null,
  so the {selectedNames} JSON never appears. parseResponse now falls back to
  reasoning_content (incl. provider_specific_fields) when content is empty, and
  the selector budget is raised 1024 -> 4000 (env MCPCTL_GATE_SELECT_MAX_TOKENS)
  so thinking + the JSON answer both fit.

Note: mcpd's chat path got a reasoning_content fallback in PR #67; this is the
separate mcplocal gate-selection provider, which never did. Tests: openai-provider
(500 rejects, reasoning_content capture/preference). mcplocal suite green (749).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:10:08 +01:00
Michal
f614e9bb98 feat(proxy): favourite-index tool presentation (favourite/ + all/ + prefer instruction)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 2m36s
CI/CD / smoke (pull_request) Failing after 1m53s
CI/CD / build (pull_request) Successful in 4m16s
CI/CD / publish (pull_request) Has been skipped
Measured winner from the DGX-Spark bake-off (toolsim.py, 145-tool catalog): a
curated favourite/<tool> shortlist + the full all/<server>/<tool> catalog + a
load-bearing "prefer favourite/ first" instruction nearly halved wander (37→20)
and 2.5x'd first-pick (2→5/8) vs a flat catalog. The instruction is load-bearing;
enriching descriptions did not help.

- New mcplocal plugin `favourite-index.ts`: composes AFTER gate (no-ops while
  gated), reshapes the ungated upstream catalog into favourite/ + all/, injects
  the instruction (onInitialize), and rewrites presented names back to canonical
  server/tool in onToolCallBefore so normal routing + content-pipeline still run.
  Gate/agent virtual tools pass through untouched; favourites are upstream-only.
- compose.ts: onInitialize now concatenates plugin instructions (was first-non-null)
  so favindex can contribute its banner alongside the gate's.
- Per-project config `Project.favouriteIndex` {enabled, tools[], maxFavourites};
  surfaced to the proxy via discovery; wired at project-mcp-endpoint when enabled.
- Usage derivation: mcpd tool-usage ranking over tool_call_trace events
  (normalizing presented names → canonical), GET /api/v1/audit/tool-usage, and
  `mcpctl favourites suggest|list`.
- CLI: `create project` gains --favourite/--favourite-index/--max-favourites;
  favouriteIndex round-trips through get -o yaml | apply -f. Completions regenerated.
- Tests: plugin unit (presentation, rewrite routing, gated no-op, collisions),
  compose merge, canonicalizeToolName, buildFavouriteIndex, + a live smoke test.
- Docs: docs/tool-presentation.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:20:30 +01:00
574fc63bb1 docs(reliability): fallback needs a real API key (#85)
Some checks failed
CI/CD / typecheck (push) Successful in 1m13s
CI/CD / lint (push) Successful in 2m10s
CI/CD / test (push) Successful in 1m29s
CI/CD / build (push) Successful in 2m24s
CI/CD / smoke (push) Failing after 3m15s
CI/CD / publish (push) Has been skipped
2026-07-22 22:41:37 +00:00
Michal
88025d0ef5 docs(reliability): fallback target needs a real sk-ant-api03 key, not an OAuth token
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m14s
CI/CD / lint (pull_request) Successful in 2m15s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / smoke (pull_request) Failing after 2m47s
CI/CD / build (pull_request) Successful in 2m23s
CI/CD / publish (pull_request) Has been skipped
Record the empirical finding: a Claude subscription OAuth token is gated by
Anthropic (404/429 as a raw API) and can't serve as the server-side fallback,
even though mcpd's adapter now sends it via Bearer. The anthropic-fallback is
wired-but-inert until mcpd Secret anthropic-key holds a real console API key.
Also note the chain is now Pulumi-owned so pulumi up won't wipe it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:41:30 +01:00
e95aeeb3a2 fix(mcpd): anthropic adapter OAuth support (#84)
Some checks failed
CI/CD / lint (push) Successful in 1m5s
CI/CD / typecheck (push) Successful in 2m6s
CI/CD / test (push) Successful in 1m16s
CI/CD / smoke (push) Failing after 1m52s
CI/CD / build (push) Successful in 4m15s
CI/CD / publish (push) Has been skipped
2026-07-21 22:46:26 +00:00
Michal
a1c545d21a fix(mcpd): anthropic adapter supports OAuth (sk-ant-oat) keys via Bearer
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / typecheck (pull_request) Successful in 2m11s
CI/CD / build (pull_request) Successful in 2m27s
CI/CD / smoke (pull_request) Failing after 2m48s
CI/CD / publish (pull_request) Has been skipped
Claude subscription / Claude Code keys are OAuth tokens and authenticate with
Authorization: Bearer, not x-api-key. mcpd's server adapter only sent x-api-key,
so registering an OAuth key as a fallback Llm 401'd ("invalid x-api-key").
Mirror the mcplocal client adapter's isOAuth switch so an OAuth key works as a
server-side anthropic fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:43:56 +01:00