Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 56 additions & 6 deletions cmd/waired/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
Expand All @@ -18,6 +19,7 @@ import (
"github.com/spf13/cobra"

"github.com/waired-ai/waired-agent/internal/agentconfig"
"github.com/waired-ai/waired-agent/internal/inferencemesh"
"github.com/waired-ai/waired-agent/internal/integration/claudecode"
"github.com/waired-ai/waired-agent/internal/integration/claudemanaged"
"github.com/waired-ai/waired-agent/internal/management"
Expand Down Expand Up @@ -196,11 +198,54 @@ func claudeLocalWindowFromModels(body []byte) int {
func claudeManagedWriteOptions(stateDir string) claudemanaged.WriteOptions {
c := agentconfig.Defaults()
_ = c.MergeJSON(agentconfig.JSONPathFor(stateDir))
return claudemanaged.WriteOptions{
local := claudeLocalContextWindow(stateDir)
opts := claudemanaged.WriteOptions{
ModelRouteDirectives: c.Inference.ClaudeModelRouteDirectives,
LocalContextWindow: claudeLocalContextWindow(stateDir),
LocalContextWindow: local,
ModelPeerEntries: c.Inference.ClaudeModelPeerEntries,
}
// Only when there is no engine here. A host that serves already has the
// exact number for the row most people use, and reading the mesh for a
// fallback it would not use is a round trip for nothing.
if local == 0 {
opts.PeerContextWindow = claudeReachableContextWindow(defaultMgmtAddr)
}
return opts
}

// claudeReachableContextWindow is the SMALLEST input-token window among the
// computers this one can currently reach, or 0 when it can reach none
// (waired-agent#1246).
//
// Smallest, because this number sizes one session and the rows it covers are
// several computers: declaring more than the smallest means a turn is
// compacted only after the gateway has already refused it, which is the one
// outcome the variable exists to avoid.
//
// Every failure collapses to 0 and writes nothing, the same rule
// claudeLocalWindowFromModels follows: this decides what an elevated process
// tells Claude Code about a window, so declining beats guessing.
func claudeReachableContextWindow(mgmtAddr string) int {
ctx, cancel := context.WithTimeout(context.Background(), pickerMeshTimeout)
defer cancel()
snap, err := fetchMeshSnapshotCtx(ctx, mgmtAddr)
if err != nil || snap == nil {
return 0
}
smallest := 0
for _, p := range snap.Peers {
if !inferencemesh.PeerServing(p) || p.InferenceState == nil {
continue
}
w := p.InferenceState.ContextWindow
if w <= 0 {
continue
}
if smallest == 0 || w < smallest {
smallest = w
}
}
return smallest
}

func runClaudeEnable(stateDir string, noStatusline bool) error {
Expand Down Expand Up @@ -276,10 +321,15 @@ func runClaudeDisable(stateDir string) error {
// CLAUDE_CODE_MAX_CONTEXT_TOKENS as ours (#408). Best-effort by design:
// disable frequently runs with the agent already stopped, and a 0 here
// only means one inert key may survive — see RemoveOptions.
window := claudeLocalContextWindow(stateDir)
removed, err := claudemanaged.RemoveWithOptions(claudemanaged.RemoveOptions{
LocalContextWindow: window,
})
opts := claudemanaged.RemoveOptions{LocalContextWindow: claudeLocalContextWindow(stateDir)}
if opts.LocalContextWindow == 0 {
// The engine-less host wrote a reachable window rather than none
// (waired-agent#1246), so the scrub has to be able to recognise that
// number too.
opts.PeerContextWindow = claudeReachableContextWindow(defaultMgmtAddr)
}
window := opts.DeclaredContextWindow()
removed, err := claudemanaged.RemoveWithOptions(opts)
// waired-agent#1174: with the window unknown the scrub cannot tell our
// own value from an operator's, so it keeps it — and the file is
// rewritten carrying that one key. On a machine Waired is being removed
Expand Down
4 changes: 2 additions & 2 deletions cmd/waired/claude_managed_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,11 @@ func topUpClaudeWindow(stateDir string) {
directives: opts.ModelRouteDirectives,
elevated: isElevatedFn(),
managed: claudemanaged.MaxContextTokensAt(path),
live: opts.LocalContextWindow,
live: opts.DeclaredContextWindow(),
}) {
return
}
if _, err := claudemanaged.SetMaxContextTokensAt(path, opts.LocalContextWindow); err != nil {
if _, err := claudemanaged.SetMaxContextTokensAt(path, opts.DeclaredContextWindow()); err != nil {
fmt.Fprintf(stderr, "warn: could not record the Claude Code context window (%v); %s\n",
err, elevationHintFor(runtime.GOOS, "waired claude enable"))
}
Expand Down
96 changes: 96 additions & 0 deletions cmd/waired/claude_reachable_window_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/waired-ai/waired-agent/internal/inferencemesh"
"github.com/waired-ai/waired-agent/proto/signer"
)

// A computer with no engine declares the smallest window it can reach
// (waired-agent#1246).
//
// PIN: product contract. Smallest, not largest: this number sizes ONE
// session and the rows it covers are several computers, so declaring more
// than the smallest means a turn is compacted only after the gateway has
// already refused it.
//
// Driven through the real fetch against an httptest listener rather than a
// swapped seam, so the parsing and the route are exercised too
// (CLAUDE.md §Test discipline).
func TestClaudeReachableContextWindow(t *testing.T) {
serving := func(window int) *signer.InferenceState {
return &signer.InferenceState{
Reachable: true, Models: []string{"qwen3-8b-instruct"}, ContextWindow: window,
}
}
for _, tc := range []struct {
name string
snap inferencemesh.Snapshot
want int
}{
{
name: "the smallest of several",
snap: inferencemesh.Snapshot{Peers: []inferencemesh.PeerView{
{DeviceID: "dev_a", InferenceState: serving(131072)},
{DeviceID: "dev_b", InferenceState: serving(32768)},
{DeviceID: "dev_c", InferenceState: serving(65536)},
}},
want: 32768,
},
{
// A peer that is not serving is not a row this host offers, so
// its window is not a number it may declare.
name: "peers that are not serving do not count",
snap: inferencemesh.Snapshot{Peers: []inferencemesh.PeerView{
{DeviceID: "dev_a", InferenceState: serving(8192), Stale: true},
{DeviceID: "dev_b", InferenceState: &signer.InferenceState{Reachable: false, ContextWindow: 4096}},
{DeviceID: "dev_c", InferenceState: serving(65536)},
}},
want: 65536,
},
{
// Declining beats guessing: this decides what an elevated
// process tells Claude Code about a window.
name: "a peer that declares no window is skipped",
snap: inferencemesh.Snapshot{Peers: []inferencemesh.PeerView{
{DeviceID: "dev_a", InferenceState: serving(0)},
{DeviceID: "dev_b", InferenceState: serving(16384)},
}},
want: 16384,
},
{
name: "nothing reachable declares nothing",
snap: inferencemesh.Snapshot{},
want: 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != meshSnapshotPath {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tc.snap)
}))
defer srv.Close()
if got := claudeReachableContextWindow(srv.URL); got != tc.want {
t.Errorf("claudeReachableContextWindow = %d, want %d", got, tc.want)
}
})
}

t.Run("an unreachable daemon declares nothing", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
if got := claudeReachableContextWindow(srv.URL); got != 0 {
t.Errorf("claudeReachableContextWindow = %d, want 0", got)
}
})
}
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/guides/claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,9 @@ there to serve it.
**Long sessions get summarized.** Models hold less conversation at once than the
cloud does, so Claude Code compacts older turns to fit. On a Waired row the
limit it works to is the one this computer's engine holds, passed on by
`sudo waired claude enable`; a `(1M context)` row works to 1M. If you switch
`sudo waired claude enable`; a `(1M context)` row works to 1M. On a computer
with no engine of its own, the limit passed on is the smallest one it can
reach, since every row it offers belongs to another computer. If you switch
this computer to a different model later, run `sudo waired claude enable` again
(Windows: from an administrator prompt) so the new limit is passed on —
`waired claude status` shows both numbers and says when they disagree. Another
Expand Down
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/ja/guides/claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,9 @@ Claude Code はこの一覧を 1 か所からしか読まないので、足す
**長いセッションは要約されます。** モデルが一度に保持できる会話量はクラウドより
少ないため、Claude Code が古いターンを要約して収めます。Waired の行では、
`sudo waired claude enable` が伝えたこのコンピュータのエンジンの上限に合わせて
要約します。`(1M context)` の行は 1M に合わせます。あとでこのコンピュータを別の
要約します。`(1M context)` の行は 1M に合わせます。自分のエンジンを持たない
コンピュータでは、出る行がすべてほかのコンピュータのものなので、伝えられる上限は
届く範囲でいちばん小さいものになります。あとでこのコンピュータを別の
モデルに切り替えたときは、新しい上限を伝えるために `sudo waired claude enable` を
実行し直してください(Windows は管理者プロンプトから)。
`waired claude status` が両方の数値を表示し、食い違っていればそう伝えます。
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
status: accepted
---

# 宣言する窓は、エンジンが無ければ届く窓に落とす (20260906 04:15)

## Status
Accepted

## Context

managed settings が持つ `CLAUDE_CODE_MAX_CONTEXT_TOKENS` は**セッションに 1 個**で、
`waired-agent#1185` 以降は Waired の `/model` 行すべてに効く。行ごとの窓は違う ——
`Waired local` はこのホスト、`Waired peer` / `Waired peer: <name>` /
`Waired public share` は相手のホストの窓。

Claude Code には**行ごとに窓を宣言する文書化された手段が無い**(`behavesAs` は
「この版が知らないモデルに、知っているモデルのクライアント側の扱いを当てる」もので
別物、かつ公開 settings リファレンスに無い)。

さらに、エンジンを持たないホストでは `LocalContextWindow` が 0 になり、**変数自体が
書かれない**。その結果:

- Claude Code は自分の既定 200k を仮定する
- Waired の全行に `"<id>" isn't described by this version's model catalog` の注意書きが出る
(2026-09-06 実測: この変数があると注意書きは消える)

エンジン無しホストが出す行は**すべてピア行**なので、そこでピアの窓を書くことは
「もっと良い数の近似」ではなく、**手に入る唯一の正直な数**である(waired-agent#1246)。

## Decision

1. **一般の場合は現状維持。**エンジンのあるホストは、これまでどおり自分の窓を書く。
最も使われる行(`Waired local`)にとって正確で、他の行に対する過大宣言は
ゲートウェイ自身の 400 が受け止める —— `waired-agent#1187` で
`capability_rejected: prompt_too_long` に統一済み。変数は compaction のヒントであって
実際に断る側ではない(`docs/decisions/20260714/0241-drop-static-auto-compact-window-pin.md`)。
2. **ローカルの窓が 0 のときだけ、到達可能なピアの窓に落とす。**
`WriteOptions.PeerContextWindow` / `RemoveOptions.PeerContextWindow` を足し、
両者が `DeclaredContextWindow()`(local > 0 なら local、でなければ peer)を使う。
3. **落とすときは最小値を採る。**この数は 1 セッションを決めるのに、対象は複数の
コンピュータ。最大を宣言すると、ゲートウェイに断られてから初めて compaction が
走るという、この変数が避けるための唯一の帰結を招く。
4. **1 つも分からなければ今までどおり何も書かない。**
`claudeLocalWindowFromModels` と同じ規則 —— 昇格したプロセスが Claude Code に
窓を教える判断なので、当て推量より辞退が良い。
5. **scrub は同じ数を認識する。**`waired claude disable` の所有権判定も
`DeclaredContextWindow()` を使う。でなければピア由来の値が残り、
`waired-agent#1174` が警告している「Waired を消したあともセッションを操り続ける値」に
なる。

## Consequences

- エンジン無しホストで注意書きが消え、200k の思い込みが実際に届く窓に置き換わる。
- ピア行の窓は依然として近似。過小側に倒しているので、断られてから縮むのではなく
早めに縮む方向に外れる。
- ホストの構成が変わると宣言値は次の `waired claude enable`(あるいは init の
仕上げの top-up)まで動かない。既存の挙動と同じで、変えていない。

## Refs
- https://github.com/waired-ai/waired-agent/issues/1246
- docs/decisions/20260714/0241-drop-static-auto-compact-window-pin.md
43 changes: 43 additions & 0 deletions docs/decisions/20260906/0416-two-claude-event-kinds-are-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
status: accepted
---

# 生産者のいない Claude イベント種別 2 つを廃止する (20260906 04:16)

## Status
Accepted

## Context

`internal/observability/eventring.go` の `KindClaudeNodeChange` と
`KindClaudeNodeFallback` は、`waired-agent#1198`(= `#1184`、auto ルート撤去)以降
**書き手が 1 人もいない**。

- `KindClaudeNodeFallback` の doc コメント自身が、生産者は anthropic ルートの降格
だったと書いている。その経路は撤去された。
- `KindClaudeNodeChange` はクラス別ルートの遷移を記録していた。クラス別ルートは無い。

リングは共有面なので、外に読み手がいないことを確かめてから決める必要があった
(waired-agent#1246)。

## Decision

**両方を型ごと廃止する。**確認した範囲:

- 公開リポ(`waired-agent`)の Go / スクリプト / docs: 定義そのもの以外の参照ゼロ。
- 私有リポ(`waired`): **コードの読み手ゼロ**。dev-docs が 2 か所で言及するのみ
(`infra/observability.md` の表、`claude-proxy.md` の現行動作の記述)。

`KindPinnedPeerUnreachable` は残す —— `waired-agent#325` 以降、pin ダウンの唯一の
記録はそちらで、こちらとは別物。

## Consequences

- 私有 dev-docs の該当 2 か所を追随させる。日付入りの過去記録
(`claude-proxy.md` の rc7 レビュー機の記述、`docs/decisions/20260802/0631`)は
**凍結**する —— そのとき実際に観測された事実なので。
- 別の遷移(運用者がローカル推論を切る、など)を記録したくなったら、この形を
復活させるのではなく、そのとき必要な形で起こす。

## Refs
- https://github.com/waired-ai/waired-agent/issues/1246
Loading
Loading