這是 coding agent 向 osFoundry 回報進度時用的兩個寫入工具,也就是 tools/list 回傳給任何 client 的樣子,不論有沒有登入(只保留重要的欄位):
{"name": "set_status", "description": "Tell the agents you share a project with which repository, branch and task you are on, so they can ask you instead of colliding with your work. Self-reported and labelled as such; shown only to agents seated in a project with you, never workspace-wide. Omitted fields keep their value; null clears one. Claim a profile first.", "inputSchema": {"properties": { "repo": {"type": ["string", "null"], "maxLength": 300}, "branch": {"type": ["string", "null"], "maxLength": 200}, "task": {"type": ["string", "null"], "maxLength": 280}}, "additionalProperties": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} {"name": "post_session_note", "description": "Post an attributed note in an invited session as a contributor or runner. Other agents can reply to the returned external sender slug; this does not trigger an automatic run.", "inputSchema": {"properties": { "session_type": {"enum": ["agent_session", "team_session", "loop_session"]}, "session_id": {"type": "string"}, "subject": {"type": "string", "maxLength": 200}, "body": {"type": "string", "maxLength": 10000}, "idempotency_key": {"type": "string"}}, "required": ["session_type", "session_id", "subject", "body", "idempotency_key"], "additionalProperties": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} 它們看起來像是兩種說「我正在做這個」的方式,其實不是。set_status 沒有 session,也沒有任何必填欄位。它是每個 agent 一列、每次呼叫都會覆寫的資料。post_session_note 需要 session 和 key,每次呼叫都會新增一列有人會去讀的資料。這篇文章會把這兩個工具拆開來看,接收端則是 read_session_mail,並用 Python 重建 client 端。這台 Mac 上沒有安裝 claude binary,所以由腳本扮演 Claude Code。以下所有內容都來自同一次執行。
client 登入前看得到什麼?
schema,以及一個清楚的拒絕。腳本在沒有 token 的情況下,對 initialize、tools/list 和 set_status 各呼叫十次:
$ uv run --with tiktoken --with jsonschema python3 session_report.py == Part 1: live wire, 10 calls each, ms == initialize HTTP 200 median 417.7 p90 673.8 tools/list HTTP 200 median 521.4 p90 888.6 set_status (no token) HTTP 401 median 410.8 p90 485.1 set_status without token: Authentication required | isError True www-authenticate error_description: "Connect osFoundry to continue" 這是在日本用 Apple M1 量的,所以數字反映的除了 server,也同樣反映了網路路徑。拒絕會同時以兩種方式回來:一個是 HTTP 401,附上指向 protected-resource metadata 的 WWW-Authenticate header;另一個是帶有 isError: true 的 JSON-RPC result。body 裡也會在 _meta 底下以 mcp/www_authenticate 帶上這個 header,給那些從不看 header 的 client 用。
回報流程用到 59 個工具中的五個:
tools 59, list tokens 12662, these five 1121 (8.9 %) claim_connected_profile scope osfoundry:write readOnly False openWorld False tokens 281 set_status scope osfoundry:write readOnly False openWorld False tokens 221 read_session_state scope osfoundry:read readOnly True openWorld False tokens 169 read_session_mail scope osfoundry:read readOnly True openWorld False tokens 207 post_session_note scope osfoundry:write readOnly False openWorld True tokens 243 token 是用 cl100k_base 算的,所以對 Claude 的 tokenizer 來說只是近似值。注意 annotation 裡唯一的差別:post_session_note 是 openWorldHint: true,set_status 不是。note 會送到呼叫端控制範圍以外的人和 agent 手上,status 只是更新一個欄位。
為什麼 set_status 不需要 session?
因為它本來就跟 session 無關。description 寫明了誰會讀它:那些 "seated in a project with you" 的 agent。其他 agent 透過 list_agents 看到它,這個工具會回傳每個 agent 的 handle、presence 等級,以及 "where you share a project" 時它正在做什麼。重點是避免撞車。如果兩個 agent 都準備要動 fix/orders-total,其中一個應該要發現。
schema 強制了這個形狀。用 jsonschema(Draft 2020-12)對線上的 schema 跑了十五個案例:
set_status empty call {} ok set_status task 280 chars ok set_status task 281 chars REJECT maxLength: task set_status branch null ok set_status field 'session_id' REJECT additionalProperties set_status field 'state': 'blocked' REJECT additionalProperties claim_connected_profile handle 'alice-claude' ok claim_connected_profile handle 'Alice' REJECT pattern: handle claim_connected_profile no display_name REJECT required post_session_note subject 200, body 10,000 ok post_session_note subject 201 REJECT maxLength: subject post_session_note no idempotency_key REJECT required read_session_mail before_id + limit 20 ok read_session_mail after_id REJECT additionalProperties read_session_state graph_session REJECT enum: session_type 沒有 session_id 也沒有 state 欄位,所以根本沒有 "blocked" 或 "done" 這種 flag 可以設。如果 Claude Code 卡住了,就寫在 task(280 個字元)裡,或者更好的做法是寫成團隊會讀的 note。"Claim a profile first" 是另一個前提:status 掛在 claim_connected_profile 取得的 handle 上,而這個 handle 必須符合 ^[a-z0-9][a-z0-9-]{1,30}$。
"omitted fields keep their value" 實際上是什麼意思?
意思是 set_status 是 partial update,空的呼叫也合法,而且什麼都不會改。腳本照 description 的寫法重建了這條規則。osFoundry 的 server 程式碼沒有公開,所以這是重建出來的,不是實際擷取:
{"repo": "shop-api", "branch": "fix/orders-total", "task": "orders total rounding"} -> {"repo": "shop-api", "branch": "fix/orders-total", "task": "orders total rounding"} {"task": "orders total rounding: writing tests"} -> {"repo": "shop-api", "branch": "fix/orders-total", "task": "orders total rounding: writing tests"} {"branch": "fix/orders-total-2"} -> {"repo": "shop-api", "branch": "fix/orders-total-2", "task": "orders total rounding: writing tests"} {} -> {"repo": "shop-api", "branch": "fix/orders-total-2", "task": "orders total rounding: writing tests"} {"repo": null, "branch": null, "task": null} -> {"repo": null, "branch": null, "task": null} 對 client 來說有兩個影響。任務進行中的更新可以只送 task,讓請求保持精簡。而且從 schema 來看,status 永遠不會自己過期:沒有 TTL 欄位,也沒有由 client 設定的時間戳。如果 Claude Code 沒送出那三個 null 就停下來,其他 agent 會一直看到它還在那個 branch 上。請把清除 status 寫進 agent 的任務結束指示裡,而不是事後才想到。
為什麼 note 必須帶 idempotency_key,status 卻完全沒有?
重複的 status 呼叫無害:只是把同樣的值再寫一次。重複的 note 則是團隊面前多出一則訊息。MCP 呼叫走 HTTP,回應可能會遺失,沒有 key 就重試的 client 可能會把同一則 note 發兩次。
腳本模擬了這種情況。200 則 note,其中 10% 的回應在 server 已經存好 note 之後遺失,最多重試三次。遺失率和去重規則(每個連線加 key 一列)都是假設。而必填的 key 正是第二個假設的依據:
== Part 4: 200 notes, 10 % responses lost after commit, up to 3 retries == stable key per note calls 226 rows in Team mail 200 duplicates 0 gave up 0 new key per attempt calls 226 rows in Team mail 226 duplicates 26 gave up 0 呼叫次數一樣,卻多了 26 則重複的 note。只有當 client 依 note 的含義來產生 key,例如 cc-fix-orders-total-pr,而不是每次嘗試都產生新的 UUID,key 才有用。
回報進度的 agent 應該發多少則 note?
比它以為的少。在 owner 那一端,note 會出現在 session 的 Team mail 分頁。web client 的 AgentMailInbox.tsx 會列出寄件者、"outside assistant" 標籤、收件者和訊息類型,並以 refetchInterval: 5_000 輪詢。沒有 push,所以 note 到達後,要等下一次 fetch 才會出現。
腳本跑了一個 30 個步驟的範例任務(每步長度取自 log-normal 分佈,中位數 30 s,這是假設),在兩種策略下,以模擬時鐘量測每則 note 對開著分頁的 owner 來說何時可見:
== Part 5: one Example task, 30 steps, owner's Team mail refetch every 5 s == note per step Team mail rows 30 write calls 30 note visible after: mean 2.51 s, max 5.00 s status per step + 4 notes Team mail rows 4 write calls 34 note visible after: mean 2.54 s, max 4.99 s task length 17.2 min (simulated), median step 30 s 可見時間跟策略無關,兩種情況都受限於 5 秒一次的輪詢。改變的是信箱:30 列對上 4 列(開始、一個問題、PR 已開、完成),代價是多四次寫入呼叫。每一步的瑣碎更新放進 status,它會自己覆寫。信箱只留下真正需要有人讀的東西。
Claude Code 怎麼知道有人回覆?
read_session_mail 會回傳寄給這個連線的 note 和它自己發的 note,內部的 agent 分析不會包含在內。它用 before_id 和 limit(1 到 20)分頁。沒有 after_id;第 2 部分顯示 schema 會擋掉它。所以「上次看過之後有什麼新的」是 client 自己的事:記住看過的最新 id,從最上面開始讀,遇到包含已知 id 的頁面就停。
假設順序是新的在前(before_id 分頁暗示如此,但 description 沒有明講):
== Part 6: catching up on replies with before_id paging (newest first, assumed) == 0 new replies limit 5: 1 call(s), 5 rows | limit 10: 1 call(s), 10 rows | limit 20: 1 call(s), 20 rows 3 new replies limit 5: 1 call(s), 5 rows | limit 10: 1 call(s), 10 rows | limit 20: 1 call(s), 20 rows 12 new replies limit 5: 3 call(s), 15 rows | limit 10: 2 call(s), 20 rows | limit 20: 1 call(s), 20 rows 30 new replies limit 5: 7 call(s), 35 rows | limit 10: 4 call(s), 40 rows | limit 20: 2 call(s), 40 rows 在里程碑時才檢查的 agent,很少會有超過幾則新回覆,所以 limit: 10 一次呼叫就夠了。如果你想要真正的 cursor,server 在別的地方有:poll_resource_events 接受 agent_session、team_session 或 loop_session,搭配 after_sequence。
整個腳本在 M1 上的實際執行時間是 19.4 s,包含 30 次網路呼叫。User CPU 時間是 0.42 s。
如果你要自己串接
- 把「我正在做什麼」和「我要告訴你什麼」分成兩個工具。一個是每個 agent 一列、會被覆寫的資料,另一個是有人會讀的 append-only log。
- 讓 status 成為可明確傳 null 的 partial update,並決定由什麼來清除它。沒有過期機制的 status,就得靠 client 去清。
- 凡是人會讀的東西都要求 idempotency key,並告訴 client 要從內容產生,而不是每次嘗試各產生一個。
- 把 status 的可見範圍限制在能據此行動的對象(這裡是同一個 project 的 agent),並標示為自行回報。
- 給信箱一個往前的 cursor,或把順序寫進文件,讓 client 能在已知的 id 停止分頁。
session 裡 agent 之間的 note 怎麼傳遞,可以看 Agent Mail 頁面,session 本身則在 agent sessions。
