Skip to content

feat: a seam for shards, a quicker hot path, metrics, deploys without a storm, and a load harness - #17

Merged
frahlg merged 11 commits into
mainfrom
feat/scale
Oct 15, 2026
Merged

frahlg merged 11 commits into
mainfrom
feat/scale

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Paired with srcfl/ftw#1645, which adds the wire (CloseWrongShard 4421, CloseRestart 4503, StatusWrongShard 421 with Ftw-Shard, ShardHostOK, CapShard) and makes Core follow it. go.mod pins go/protocol at that branch; the pin moves to the merge commit once #1645 lands.

Why

FTW Cloud now carries every phone's session with its box, so it must stay fast as homes grow, and grow later without a client release. Three limits came first at an estimated 1k homes: one APNs worker with a queue that dropped, one SQLite connection, and reads under the home lock. A deploy also closed every socket at once, so every box redialled in the same half second.

What

1. A seam for shards that needs no client release

  • internal/shard: each home lives on one shard. The directory is 256 buckets by a hash of the home id, plus single moved homes, read from -shards. Without a file, one shard holds every home and nothing changes.
  • An instance that does not hold a home names the host that does:
    • /v1/box: 4421 after hello, before the registry is touched;
    • /v1/app: 4421 before message 1 is read;
    • /v1/app/once and /v1/now: 421 with Ftw-Shard.
  • Extension tokens now carry their home (<home>.<secret>), so any instance can route one without a lookup. No app reads /v1/now yet, so nothing breaks. A token without a home still reads.
  • Moving homes between shards needs a tool that comes with the second shard; the first version of this PR had one, and the owner's review took it out.
  • /mcp and /oauth stay on cloud.ftw.energy and forward to the home's shard once there are two, because other parties' MCP and OAuth clients follow no 4421 or 421.
  • No shared pub/sub and no shared storage, so readings stay off disk.

2. The hot path

  • Suspensions were already read from memory under the home lock (feat: carry each phone's session to its box, and survive a restart #15). The registry now has one writer and four readers in WAL with synchronous(NORMAL), and a box that reconnects writes nothing in Claim.
  • Push runs four alert workers and three widget workers.
    • Alerts are never dropped silently. They go again on 429, 5xx, a network error or a registry read error, until they expire, after a wait that doubles from 2 s to 5 min with jitter. Each retry goes only to devices that may still get it.
    • box.unreachable is recorded as told only once a phone took it, so one lost to a failure or an exit goes again after the restart.
    • A breaker holds pushes back for 30 s after ten failures at Apple in a row.
    • Widget pushes merge per home and go at 20 homes a second, so tomorrow's prices reach Apple as a stream, not a burst. A failed one goes once more.
  • A home's rate limits now live with the home, so a box that redials no longer refills its tunnel. A box that redials faster than 10 times, then once every 6 s, is turned away (4429) before it touches the registry.
  • A write that fails closes the box's socket, instead of leaving a half-dead socket open until its deadline.
  • Box pings and watch renewal are timers, not goroutines. At 2,000 homes with a phone each, goroutines fell from 10,027 to 6,027 and RSS by 13 %.
  • One slow phone or box cannot stall another home. Each socket has its own writer and bounded queues, and nothing blocks while it holds a lock shared by homes. TestAStuckBoxDoesNotHoldUpAnotherHome hangs four writes to one box while they hold its write lock, as a full socket does, and another home's tun goes through ten times, each under 200 ms. TestAStuckBoxDoesNotHoldUpTheDrain drains past such a box within a second.

3. Visibility

  • /metrics listens on 127.0.0.1:9464 inside the container; read it with docker exec … ftwcloudd metrics. It shows:
    • sockets by kind, homes, and homes with a connected box;
    • tun messages and bytes each way, and drops by reason;
    • push queue depths and Apple's answers;
    • handshake and forward latency histograms, queueing included;
    • the Go heap and goroutines.
  • One log line a minute goes out in CloudWatch's embedded metric format.
  • Nothing in either names a home, a device or an address.

4. Deploys without a storm

  • On SIGTERM the service:
    • stops accepting connections;
    • closes every socket, those still in their handshake too (Codex), with 4503 and a spread: the sockets it holds at -restart-rate 500 a second, from 15 s to 60 s. The floor is longer than the port stays closed, about 4 to 8 s, while compose starts the next container;
    • waits up to 2 s for the clients' answers, then up to 3 s for queued alerts;
    • exits. No drained box starts box.unreachable.
  • Every close the service sends (4421, 4503 and the refusals) waits for the client's answer (internal/wsclose), so the client reads the code rather than a reset. A phone that writes message 1 at once still reads 4421.
  • Every part that holds sockets is a drainer in cmd/ftwcloudd, so an agent's sessions are closed and counted in the spread once that part adds itself.
  • At start it checks the stored rosters on every core in parallel: 0.35 s for 10,000 homes on two M5 cores (BenchmarkRestore10k), about 1.2 s on the host by the estimate below.
  • compose.yml adds GOMEMLIMIT=1400MiB and a 15 s stop grace period.
  • deploy/README.md gives the drain step by step, and blue/green behind an NLB for later.
  • Core honours 4503 in #1645: it redials within the spread without growing its backoff. iOS today gives an unknown code 0 to 0.5 s of backoff; that change belongs in ftw-app.

5. A load harness: cmd/ftwload

  • It starts its own ftwcloudd and drives N boxes and M phones per box: live at 1 Hz, a tun command every second echoed by the box, reconnect storms, and a SIGTERM.
  • It reports latency as the harness sees it and as the service saw it, from /metrics, plus the service's CPU and memory.
  • It exits by itself, kills its child at -max, and leaves a watchdog in case the harness dies.

Numbers

The service ran with GOMAXPROCS=2 on an Apple M5, in a Linux container unless marked. Each run lasted under two minutes.

Run Service CPU (100 = one core) RSS Inside the service, p99 Phone to box and back, p50 / p99
4,000 boxes 34 % 289 MiB — —
3,000 homes with a phone each 35 % 518 MiB — 1.6 / 32 ms
8,000 homes with a phone each (16,000 sockets) 84 % 1.43 GiB envelope ≤ 2.5 ms, tun ≤ 0.25 ms 6 / 160 ms
2,000 homes with a phone each (macOS) 33 % 377 MiB envelope ≤ 1 ms, tun ≤ 0.1 ms 0.8 / 27 ms
Storm: 4,000 boxes redial at once peak 155 % all back in 0.9 s
SIGTERM, 3,000 homes and phones 6,000 of 6,000 got 4503 with the 15 s floor; exit in 0.08 s
  • Per 1,000 boxes: 8.4 % of a core and 69 MiB. The memory floor is about 45 MiB; below it the garbage collector never stops. The test boxes sent only live; a real home also holds prices, plan, lp, hb and health, about 10 MiB more per 1,000 homes.
  • Per 1,000 phones on top, each sending a command a second: about 3 % of a core and 100 to 110 MiB.
  • Where box CPU goes (macOS profile): two thirds reading the socket, a quarter checking each box message's signature (17 µs).
  • The end-to-end tail is the laptop and the harness. Inside the service a message waited at most a few milliseconds at the 99th percentile.
  • The drain bug the harness found: the first SIGTERM run gave 2,550 of 3,000 phones 1006 instead of 4503, because the process exited before their closes went out. Fixed: the drain now waits up to 2 s for them.

On a t4g.small (unmeasured: an estimate, not run on the host). A Graviton2 core does this work at roughly a third of an M5 core's speed, so I take 3.5× the CPU and the same memory: about 30 % of a vCPU and 70 MiB per 1,000 boxes at one message a second. The first limit is CPU:

Limit Boxes at 1 Hz
Burst baseline (20 % of each vCPU) about 1,300
Both vCPUs under 70 % about 4,500
GOMEMLIMIT 1,400 MiB about 20,000, or about 8,000 homes with a phone connected each

Add a shard when, over a busy day, any of these holds:

  • the instance averages more than 60 % of its two vCPUs;
  • more than 4,000 homes have a box connected;
  • the runtime holds more than 1.2 GiB;
  • a forward p99 inside the service passes 50 ms.

Between about 1,300 and 4,000 boxes, a non-burstable instance with two vCPUs is the cheaper first step and needs no shard.

Review

A review (Sonnet) found seven things, fixed here and in #1645:

  • The drain stalled on a stuck box. It took 14.5 s with one box that had stopped reading. Boxes now close side by side, without the write lock, within 2 s, and the phones' drain starts first.
  • Restarts during a move raised false alarms. push.Resume armed box.unreachable for homes another shard holds. It now skips them.
  • Pending retries were lost silently. Alerts waiting to go again were not counted at exit, and Flush could miss one between pop and send. Both are counted now, and the log says how many are lost.
  • synchronous(NORMAL) could undo access changes. A power cut could lose a suspension, a purge, a dropped token or a roster that removes a device. Those writes are now synced on the one writer connection; renewals and everything that heals itself stay on NORMAL.
  • Core: a stale hop count. After forgetting a dead host, the box refused the next redirect and backed off again. Fixed; the new test fails without the fix.
  • Core: a kept host from an earlier build was still dialled. It is now checked against the current endpoint.
  • Docs: what a merge cannot undo. They now say which rows a merge cannot delete, and that tokens from before this change named no home.

A timeout after Apple took an alert can repeat it. For events the phone's extension drops a repeated (boot, seq), as the protocol says.

Owner's review

Fixed in af24782:

  1. box.unreachable could be lost for good. It is now told only once a phone took it, and alerts retry until they expire.
  2. 4421 and 4503 had no closing handshake. Every close now waits for the client's answer, and a test writes message 1 right after the upgrade.
  3. The spread's floor is 15 s, documented in deploy/README step 2. A harness run over a real stop and start is still to do.
  4. Retries are filtered again before each try.
  5. Push registrations, unregistrations, tokens and the away rows are synced to disk.
  6. ftwcloudd move and the runbook are gone; the redirect seam stays.
  7. The MCP decision is written down, the drain covers every drainer, and agent sessions join once that part adds itself.
  • APNs: jitter, a breaker, retry on a registry read error, three widget workers, a widget retry.
  • The load numbers say unmeasured on t4g.small and count prices, plan and lp in the heap floor.
  • Directory hosts are checked against cloudwire.DefaultHost.
  • PruneSuspensions lifts a suspension only at the epoch it read (TestAPruneDoesNotLiftASuspensionMadeSinceItRead).
  • Codex: retries filtered again (P1), and the drain reaches boxes in their handshake (P2, TestTheDrainReachesABoxInItsHandshake). Its P1 on the merge went with the move tool.

Laptop rules

  • Before each big run I checked for heavy builds. One run waited out another session's go test and a simulator, and the numbers above come from quiet runs.
  • Load stayed at the service's 2 cores plus the harness's 6.
  • Every process exited by itself, and pgrep found nothing left.

Size

The service grows from 5,009 to 6,904 lines of Go without tests:

Part Change
internal/push +487
internal/metrics (new) +448
internal/uplink +219
internal/shard (new) +188
cmd/ftwcloudd +148
internal/registry +115
internal/wsclose (new) +109

cmd/ftwload is a tool and not part of the service (1,938 lines). CLAUDE.md has the table.

Overlap

Not here

  • iOS, Android and the web app do not follow 4421 or honour 4503 yet (a follow-up in ftw-app and ftw-webapp). Until they do, a second shard must not hold homes.
  • A cut in CPU per box: checking every box message's signature is a quarter of box CPU. The TLS session is already bound to the box's key, but the invariant that every box message must verify is the owner's call.
  • Shipping the container log to CloudWatch needs the awslogs driver and two IAM permissions, which the owner adds.

Tests

  • go vet ./... && go test -race ./..., go test -tags ftwclouddev ./..., bash scripts/check-imports.sh.
  • The three fuzzers ran for 15 s each.
  • New tests cover:
    • the directory, and a directory that would send a client off the domain;
    • each route's wrong-shard answer, and that a box of another shard is not claimed;
    • the connect limit, limits that survive a redial, and the drain of boxes and phones;
    • Restore on a shard;
    • token routing and a forged home prefix;
    • alert retries and giving up, widget merging, alerts that do not wait behind widgets, and the queue and gate;
    • closing handshakes, a drain that reaches a box in its handshake, box.unreachable told only once taken, retries to devices that may still get them, the breaker, a widget retry, an epoch-safe prune, and the spread.

🤖 Generated with Claude Code

frahlg and others added 2 commits October 15, 2026 19:18
… pooled registry, and push workers

- internal/shard: which instance holds a home. 256 buckets plus single
  moved homes, from a JSON file; with none, one shard holds every home.
- uplink: a box of another shard hears its host (4421) after hello and
  before the registry is touched. A home's rate limits live with the home,
  so a redial no longer refills its tunnel. A box that redials faster than
  10 then one per 6 s is turned away (4429). Drain closes every box with
  4503 and a spread and starts no box.unreachable. A failed write closes the
  socket. Restore checks rosters side by side and takes only this shard's
  homes.
- registry: one writer and four readers in WAL, synchronous(NORMAL); a
  box that reconnects writes nothing in Claim.
- push: four alert workers and one widget worker. Alerts are never dropped
  silently and go again on 429, 5xx or a network error, up to four tries
  within their expiry. Widget pushes merge per home and go at 20 homes a
  second, so tomorrow's prices are a stream, not a burst. Flush waits for
  queued alerts.
- internal/metrics: counters, gauges and latency histograms with no home
  in them, in Prometheus text and a minute's CloudWatch EMF line.
- go/protocol pinned at srcfl/ftw feat/cloud-shard-redirect (d74e8d2c).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on loopback, and a load harness

- /v1/app closes with 4421 and the host before message 1 is read;
  /v1/app/once and /v1/now answer 421 with Ftw-Shard. An extension token is
  now <home>.<secret>, so any instance can route it; a token whose home is
  not its row's is refused, and one without a home still reads.
- SIGTERM: stop accepting, close every box and phone with 4503 and a
  spread of sockets / -restart-rate (1 s to 60 s), flush queued alerts for
  up to 3 s, exit. A drained box starts no box.unreachable.
- Envelopes and tun carry their arrival time to the phone's writer, so the
  forward histograms include queueing; drops are counted by reason.
- -shards loads the directory; -metrics-listen serves /metrics on loopback
  inside the container, and `ftwcloudd metrics` prints it; one EMF line a
  minute goes to stdout for CloudWatch.
- cmd/ftwload: N boxes and M phones per box against a local ftwcloudd it
  starts itself, with live at 1 Hz, tun echoes, reconnect storms and a
  SIGTERM, and the child's CPU and memory. Exits by itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 15, 2026
cloud-scale's shard seam (#17) names shards by string, so
the subject carries the shard's name, not a number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t waits for the phones' closes

- A box's ping and a home's watch renewal are timers, not goroutines:
  10,027 goroutines for 2,000 homes with a phone each became 6,027, and
  RSS fell 13 %.
- Queue entries carry their arrival as a monotonic duration (8 bytes, not
  a 24-byte time.Time); write buffers come from a pool while a socket is
  idle.
- The drain waits up to 2 s for every app session to write its 4503: in a
  3,000-phone run, 2,550 phones got 1006 before.
- /metrics has the Go heap, the runtime's memory and goroutines.
- ftwload reads the service's own latencies and heap from /metrics, its
  own CPU, and a Linux child's figures from /proc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 15, 2026
cloud-scale's shard seam (#17) names shards by string, so
the subject carries the shard's name, not a number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 4 commits October 15, 2026 19:53
… and scaling rule in deploy/README.md

- `ftwcloudd move` copies the registry rows of the homes a directory gives
  one shard, and with -drop forgets them here. A merge never makes a row
  older: a roster only with a higher rosterSeq, other rows only where they
  are missing, and a tombstone deletes its home. The same command exports
  and merges.
- The spread after 4503 is at least 5 s (-restart-spread-min), about how
  long the next container takes to start.
- compose: GOMEMLIMIT 1400MiB and a 15 s stop grace period for the drain.
- deploy/README.md: Deploys (the drain, step by step, and blue/green behind
  an NLB), Watching it (/metrics, the minute's EMF line), Load (the runs,
  the t4g.small estimate, when to add a shard) and Shards (the directory and
  how to move homes).
- CLAUDE.md: the shard, counts and drain invariants, and the line budget
  per part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drain sets draining before it takes the list of boxes; a box that was
between its check and its registration then sees draining under the same
lock and is closed with the spread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four phones fill a stuck box's socket while a tun for another home goes
through ten times, each well under 200 ms; the test fails if the stuck box
took every write, so it cannot pass for the wrong reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 15, 2026
cloud-scale's shard seam (#17) names shards by string, so
the subject carries the shard's name, not a number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 2 commits October 15, 2026 20:16
…ies, durable access writes)

- Drain closes boxes side by side without the write lock, within 2 s; a
  box whose socket was stuck held the drain for 14.5 s before. The phones'
  drain starts first.
- push.Resume starts no box.unreachable for a home another shard holds, so
  a move's restarts raise no false alarms.
- Flush counts alerts waiting to go again, and a worker counts as busy
  from before its pop, so the exit log says how many alerts are lost.
- A suspension, a purge, a dropped or pruned token and a roster with a
  higher number are synced to disk on the one writer; a power cut no longer
  undoes them. Renewals and what heals itself stay on NORMAL.
- deploy/README: the move's ten-minute window, what a merge cannot delete,
  and the drain's side-by-side closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ests prove the same on Linux

Linux buffers more than the tun limits let through, so a box that stops
reading never filled its socket in CI and the test could not tell. A write
that hangs while it holds the write lock is what a full socket does, on
every platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg marked this pull request as ready for review October 15, 2026 18:21
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T18:25:43.482804Z 5acb778 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5acb77865e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/registry/move.go Outdated
}
defer func() { _ = tx.Rollback() }()
for _, t := range moveTables {
q := fmt.Sprintf(`INSERT OR IGNORE INTO dst.%s (%s) SELECT %s FROM main.%s WHERE home IN %s`, t, columns[t], columns[t], t, in)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Upsert changed child rows during shard merge

During the documented two-pass shard move, a child row can change after the first export but before the final -drop pass. This blanket INSERT OR IGNORE then preserves the stale destination row: for example, a replacement extension token conflicts on (home, device_id), so the newly issued token is discarded while the old token remains, and an updated suspension can similarly retain the wrong epoch and fail to suspend the current device. Because the final pass subsequently deletes the source rows, these updates are permanently lost; tables with mutable rows need table-specific conflict handling that retains the newer value.

Useful? React with 👍 / 👎.

Comment thread internal/push/push.go Outdated
Comment on lines +681 to +684
job.regs = again
s.retrying.Add(1)
s.after(wait, func() {
s.queueAlert(job)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate recipients before retrying an alert

When an initial APNs attempt gets a network error, 429, or 5xx, the retry preserves the original PushReg objects and sends them again without consulting the registry or suspension state. If the device is suspended, removed by a new roster, or unregisters its APNs token during the 2/4/8-second retry window, the later attempt can still deliver the alert to that revoked recipient. Re-filter the failed registrations against current registry and suspension state before every retry.

Useful? React with 👍 / 👎.

Comment thread internal/uplink/uplink.go Outdated
Comment on lines +512 to +515
if c.u.draining.Load() {
reason, _ := c.u.restartReason.Load().(string)
c.closeWith(cloudwire.CloseRestart, reason)
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close boxes that are still handshaking during drain

This drain check is reached only after the box supplies its hello, but a WebSocket can remain blocked in the preceding handshake read for up to 15 seconds. Such a connection has not yet been added to u.conns, so Drain cannot close it, and upgraded WebSockets are hijacked connections that http.Server.Shutdown does not manage; the main shutdown path can therefore exit after its short drain while this socket receives an abnormal close rather than 4503. Track and close handshaking sockets, or make the drain signal interrupt this read.

Useful? React with 👍 / 👎.

frahlg and others added 2 commits October 15, 2026 22:43
…drain, durable writes, no move tool)

- box.unreachable is recorded as told only once a phone took it; an alert
  lost to a failure or an exit goes again after the restart. Alerts go
  again until they expire, after a wait that doubles from 2 s to 5 min with
  jitter, and each retry goes only to devices that may still get it. A
  registry read error is retried too.
- A breaker holds pushes back for 30 s after ten failures at Apple in a
  row. Three widget workers share the rate; a failed widgets push goes once
  more.
- internal/wsclose: every close the service sends (4421, 4503, refusals)
  waits for the client's answer, so the client reads the code and not a
  reset; a phone that writes message 1 at once reads 4421. The drain
  reaches every socket from its upgrade on, handshakes included (Codex),
  and every part that holds sockets is a drainer in main.
- The spread's floor is 15 s, longer than the port stays closed while
  compose starts the next container.
- Push registrations, unregistrations, tokens and the away rows are synced
  to disk. A roster prune lifts a suspension only at the epoch it read, so
  a suspension made meanwhile stays.
- Directory hosts must sit under cloudwire.DefaultHost.
- `ftwcloudd move` and the move runbook are gone until a second shard
  exists. deploy/README and CLAUDE.md say that /mcp and /oauth stay on the
  default host and forward to the home's shard once there are two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit 2a5f700 into main Oct 15, 2026
2 checks passed
frahlg added a commit that referenced this pull request Oct 15, 2026
main.go: keep both the keys/MCP/OAuth flags and the shard, metrics and
restart-spread flags; push keeps its own context past the signal, auth and
agents stop with the signal. go/protocol pinned to Core master f024251.
The pacing test reads the door's drops through uplink.TunDropped, now that
#17 counts drops in the metrics set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 15, 2026
#19, #20, #21)

The MCP stack as one change: ftwkeysd, the one part that can open an agent's session (#18); OAuth for assistants with the owner's approval at home (#19); reads at /mcp (#20); control within the owner's grant, and requests that wait for the owner (#21). Reviewed in two passes; merged with main after #17 (scale), go/protocol pinned to srcfl/ftw master f024251.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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