merge(dev): sync dev into main

This commit is contained in:
777genius 2026-04-29 12:54:53 +03:00
commit 9ff14a6e0b
71 changed files with 14196 additions and 486 deletions

View file

@ -308,7 +308,6 @@ pnpm dist # macOS + Windows + Linux
- [ ] Planning mode to organize agent plans before execution
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
- [ ] Support more models/providers (including local) e.g OpenCode (with many providers)
- [ ] Remote agent execution via SSH: launch and manage agent teams on remote machines over SSH (stream-json protocol over SSH channel, SFTP-based file monitoring for tasks/inboxes/config)
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
@ -322,6 +321,7 @@ pnpm dist # macOS + Windows + Linux
- [ ] Monitor agents processes/stats
- [ ] Reusable agents with SOUL.md
- [ ] Сommunicate via messenger
- [ ] SDK to programmatically launch agents
---

View file

@ -121,7 +121,9 @@ async function requestJson(baseUrl, pathname, options = {}) {
}
if (payload == null) {
throw makeRetryableControlError(`Team control API returned empty or non-JSON response at ${baseUrl}${pathname}`);
throw makeRetryableControlError(
`Team control API returned empty or non-JSON response at ${baseUrl}${pathname}`
);
}
return payload;
@ -170,9 +172,7 @@ function buildLaunchRequest(flags = {}) {
...(typeof flags.prompt === 'string' && flags.prompt.trim()
? { prompt: flags.prompt.trim() }
: {}),
...(typeof flags.model === 'string' && flags.model.trim()
? { model: flags.model.trim() }
: {}),
...(typeof flags.model === 'string' && flags.model.trim() ? { model: flags.model.trim() } : {}),
...(typeof flags.effort === 'string' && flags.effort.trim()
? { effort: flags.effort.trim() }
: {}),
@ -228,6 +228,16 @@ function compactRuntimeToolBody(context, flags = {}, fields) {
return body;
}
function compactBody(flags = {}, fields) {
const body = {};
for (const field of fields) {
if (flags[field] !== undefined) {
body[field] = flags[field];
}
}
return body;
}
async function postRuntimeTool(context, flags = {}, toolPath, body) {
const baseUrls = resolveControlBaseUrls(context, flags);
return requestJsonWithFallback(
@ -276,7 +286,9 @@ async function waitForProvisioningState(baseUrls, teamName, runId, timeoutMs) {
}
const stateLabel =
lastProgress && typeof lastProgress.state === 'string' ? ` while in state ${lastProgress.state}` : '';
lastProgress && typeof lastProgress.state === 'string'
? ` while in state ${lastProgress.state}`
: '';
throw new Error(`Timed out waiting for team ${teamName} to become ready${stateLabel}`);
}
@ -328,6 +340,48 @@ async function launchTeam(context, flags = {}) {
);
}
async function listTeams(context, flags = {}) {
const baseUrls = resolveControlBaseUrls(context, flags);
return requestJsonWithFallback(baseUrls, '/api/teams', {
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'] || 10000),
});
}
async function getTeam(context, flags = {}) {
const baseUrls = resolveControlBaseUrls(context, flags);
return requestJsonWithFallback(baseUrls, `/api/teams/${encodeURIComponent(context.teamName)}`, {
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'] || 10000),
});
}
async function createTeam(context, flags = {}) {
const baseUrls = resolveControlBaseUrls(context, flags);
return requestJsonWithFallback(baseUrls, '/api/teams', {
method: 'POST',
body: {
teamName: context.teamName,
...compactBody(flags, [
'displayName',
'description',
'color',
'members',
'cwd',
'prompt',
'providerId',
'providerBackendId',
'model',
'effort',
'fastMode',
'limitContext',
'skipPermissions',
'worktree',
'extraCliArgs',
]),
},
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'] || 10000),
});
}
async function stopTeam(context, flags = {}) {
const baseUrls = resolveControlBaseUrls(context, flags);
const stopped = await requestJsonWithFallback(
@ -351,7 +405,10 @@ async function stopTeam(context, flags = {}) {
async function getRuntimeState(context, flags = {}) {
const baseUrls = resolveControlBaseUrls(context, flags);
return requestJsonWithFallback(baseUrls, `/api/teams/${encodeURIComponent(context.teamName)}/runtime`);
return requestJsonWithFallback(
baseUrls,
`/api/teams/${encodeURIComponent(context.teamName)}/runtime`
);
}
async function runtimeBootstrapCheckin(context, flags = {}) {
@ -425,6 +482,9 @@ async function runtimeHeartbeat(context, flags = {}) {
}
module.exports = {
listTeams,
getTeam,
createTeam,
launchTeam,
stopTeam,
getRuntimeState,

View file

@ -1,3 +1,5 @@
const AGENT_TEAMS_TEAM_TOOL_NAMES = ['team_list', 'team_get', 'team_create'];
const AGENT_TEAMS_TASK_TOOL_NAMES = [
'member_briefing',
'task_add_comment',
@ -62,6 +64,11 @@ const AGENT_TEAMS_RUNTIME_TOOL_NAMES = [
];
const AGENT_TEAMS_MCP_TOOL_GROUPS = [
{
id: 'team',
teammateOperational: false,
toolNames: AGENT_TEAMS_TEAM_TOOL_NAMES,
},
{
id: 'task',
teammateOperational: true,
@ -120,10 +127,12 @@ const AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES = [
...AGENT_TEAMS_LEAD_TOOL_NAMES,
];
const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES =
AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`);
const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES = AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES.map(
(toolName) => `mcp__agent-teams__${toolName}`
);
module.exports = {
AGENT_TEAMS_TEAM_TOOL_NAMES,
AGENT_TEAMS_TASK_TOOL_NAMES,
AGENT_TEAMS_LEAD_TOOL_NAMES,
AGENT_TEAMS_REVIEW_TOOL_NAMES,

View file

@ -0,0 +1,360 @@
# Messenger Connectors - Uncertainty Pass 29
Date: 2026-04-29
Scope: official shared Telegram bot ingress, webhook ACK semantics, offline desktop behavior, and no durable backend plaintext queue
## Executive Delta
The next weakest boundary is official bot ingress:
```text
Telegram webhook update
official backend
desktop live connection
durable local turn
Telegram webhook ACK
```
The product decision was:
```text
Default official bot, no durable backend plaintext queue.
If desktop is offline, be honest and answer offline.
Encrypted queue can be added later as advanced reliability mode.
```
This creates a precise reliability contract:
```text
Backend must ACK Telegram only after either:
1. desktop durably accepted the plaintext turn locally, or
2. backend recorded a redaction-safe offline/blocked decision and attempted or skipped the offline notice by policy, or
3. this is a duplicate already completed update.
```
Do not use Telegram webhook retry as the queue. It is operationally noisy, finite, and hard to reason about.
## Source Facts Rechecked
Telegram official facts checked on 2026-04-29:
- `setWebhook` sends HTTPS POST updates to our URL.
- Telegram repeats webhook delivery after a non-2xx response and eventually gives up after a reasonable number of attempts.
- `secret_token` can be configured so Telegram includes `X-Telegram-Bot-Api-Secret-Token`.
- `max_connections` can be 1-100 and defaults to 40.
- `drop_pending_updates` can drop pending updates.
- `getWebhookInfo` exposes `pending_update_count` and last error fields.
- `getUpdates` confirms updates by calling with offset greater than the previous `update_id`.
- Telegram stores incoming updates until received, but not longer than 24 hours.
- `getUpdates` cannot be used while webhook is set.
- Bot API calls made directly in the webhook response do not return a result to us.
Sources:
- https://core.telegram.org/bots/api#setwebhook
- https://core.telegram.org/bots/api#getwebhookinfo
- https://core.telegram.org/bots/api#getting-updates
- https://core.telegram.org/bots/api#making-requests-when-getting-updates
- https://core.telegram.org/bots/faq
Local code facts:
- Existing `HttpServer` binds to localhost by default and serves local app HTTP routes through Fastify.
- Existing browser mode uses SSE from local server to renderer.
- There is no current cloud/backend persistent relay layer for messenger traffic.
- The app already has a usable local event idea, but messenger official mode needs a new outbound desktop-to-cloud control connection, not the existing local HTTP server.
## 1. The ACK Problem
If the backend returns non-2xx to Telegram because desktop is offline, Telegram retries. That sounds like a queue, but it is a bad queue:
- plaintext stays in Telegram's pending delivery mechanism, not under our product semantics;
- retries can repeat the same update while the user keeps typing;
- `pending_update_count` can grow and hide real bugs;
- retries are finite;
- update retention has an upper bound;
- backend may send duplicate offline notices unless it has its own idempotency state.
If the backend returns 2xx before desktop has durably accepted the update, the lead message can be lost forever in official mode because we intentionally do not keep plaintext.
Therefore ACK timing is the core ingress invariant.
## 2. Recommended Backend Ingress State Machine
Backend should persist only redaction-safe metadata before side effects:
```ts
type OfficialIngressReceipt = {
receiptId: string;
provider: 'telegram';
botScope: 'official';
updateId: number;
providerMessageKey: string;
routeId: string | null;
routeGeneration: number | null;
textHash: string | null;
fromUserHash: string | null;
chatIdHash: string;
messageThreadId: number | null;
status:
| 'received'
| 'route_missing'
| 'desktop_claim_started'
| 'desktop_accepted'
| 'desktop_acceptance_unknown'
| 'offline_notice_started'
| 'offline_notice_sent'
| 'offline_notice_ambiguous'
| 'acknowledged'
| 'failed_terminal';
createdAt: string;
updatedAt: string;
};
```
State rules:
```text
received -> desktop_claim_started -> desktop_accepted -> acknowledged
received -> offline_notice_started -> offline_notice_sent -> acknowledged
received -> offline_notice_started -> offline_notice_ambiguous -> acknowledged
received -> route_missing -> acknowledged
duplicate acknowledged -> acknowledged
```
Important: `offline_notice_ambiguous` still ACKs the webhook. It is better to possibly miss the offline notice than to auto-send duplicate notices or keep Telegram retrying.
## 3. Desktop Claim Protocol
Official mode needs a desktop-initiated persistent connection:
```text
desktop -> backend: connect(route subscriptions, install id, session key, capabilities)
backend -> desktop: inbound plaintext turn
desktop -> backend: accepted_local(internalTurnId, providerMessageKey, localMessageId)
backend -> Telegram: 2xx webhook ACK
```
Rules:
- Backend forwards plaintext only to an already-authenticated active desktop connection.
- Desktop must persist the turn locally before returning `accepted_local`.
- Desktop dedupes by `providerMessageKey` and returns the existing local acceptance if the backend retries delivery.
- Backend does not store plaintext after the request handler scope.
- Backend stores only hashes and receipt state.
- If no desktop session can accept within a short timeout, backend goes to offline policy.
Suggested timeout:
```text
2-4 seconds for desktop accepted_local
then offline response or offline status
```
This keeps webhook handlers bounded and avoids Telegram retry storms.
## 4. Crash Matrix
Critical cases:
- Backend crashes before persisting receipt.
- Telegram retries; safe, because no side effect happened.
- Backend persists receipt, crashes before desktop forward.
- Telegram retries; backend can process again from `received`.
- Backend forwards plaintext to desktop, crashes before desktop ACK.
- Telegram retries; desktop dedupe by `providerMessageKey` prevents duplicate local turn.
- Desktop persists local turn, ACK to backend is lost.
- Telegram retries; backend redelivers, desktop returns existing `accepted_local`.
- Backend records `desktop_accepted`, crashes before HTTP 2xx.
- Telegram retries; backend sees completed receipt and returns 2xx without redelivering.
- Desktop offline.
- Backend records offline decision and ACKs Telegram after offline policy.
- Offline notice `sendMessage` succeeds but backend crashes before marking success.
- On retry, backend must not blindly resend. Mark `offline_notice_ambiguous`, ACK, show support diagnostics.
## 5. Offline Policy
For default official MVP:
```text
No desktop live acceptance = no local delivery.
```
Then choose one of two offline UX policies:
1. Send a short Telegram offline notice.
2. ACK silently and rely on topic status / setup UI.
I recommend a short offline notice, but only through the same provider outbox ambiguity policy from pass 42.
Example behavior:
```text
Agent Teams desktop is offline. Open the app on the connected computer and send the message again.
```
Do not store the lead's plaintext for later replay.
## 6. Why Not Use Telegram As The Queue
Top risks:
- Telegram will retry on non-2xx, but the retry schedule and final give-up behavior are provider-controlled.
- `pending_update_count` becomes an operational failure queue with plaintext updates we cannot inspect safely.
- Once we finally ACK, Telegram considers the update handled, even if desktop state is not coherent.
- If webhook is reconfigured with `drop_pending_updates`, lead messages can be intentionally discarded.
- If the app is offline for more than Telegram retention, messages are lost anyway.
This conflicts with the product's "honest offline" behavior.
## 7. Official Mode Privacy Story
The honest statement:
```text
Official shared bot backend sees message plaintext transiently while handling Telegram delivery.
It does not durably store plaintext in MVP.
It stores redaction-safe delivery metadata and hashes for dedupe, abuse prevention, and diagnostics.
```
Not honest:
```text
Our backend never sees messages.
```
That statement is only true for private own-bot local polling mode, not official shared bot mode.
## 8. Own Bot Contrast
Own bot mode is much simpler for ingress:
```text
desktop getUpdates
desktop durable local turn
desktop confirms offset
```
Because Telegram `getUpdates` confirms by offset, desktop can persist locally before advancing offset. That is a better privacy and reliability story for users who want it.
But own bot mode is less convenient because the user must create/configure a bot.
## 9. Desktop To Backend Transport Options
1. Persistent WebSocket from desktop to backend - 🎯 8 🛡️ 8 🧠 7 - approx `1600-3600` changed LOC.
Best default for official mode. Full duplex, explicit ACK messages, connection leases, heartbeats, route subscriptions.
2. Server-Sent Events from backend to desktop plus HTTPS POST ACKs - 🎯 7 🛡️ 7 🧠 6 - approx `1300-3000` changed LOC.
Simpler in some networks, but ACK correlation and reconnect handling are more awkward.
3. Desktop polling backend every N seconds - 🎯 5 🛡️ 5 🧠 4 - approx `700-1800` changed LOC.
Poor fit for no plaintext queue because backend would need to hold plaintext or lead messages would be missed between polls.
Recommendation:
```text
Use WebSocket-like persistent desktop claim channel for official mode.
Do not add a package decision until package versions can be verified in an unrestricted network environment.
```
## 10. Multi-Desktop And Lease Policy
If the same user connects the same official route from multiple desktops:
```text
Only one active receiver lease may own inbound plaintext delivery.
```
Options:
1. Single primary device per route - 🎯 8 🛡️ 8 🧠 5 - approx `600-1400` changed LOC.
Recommended for MVP. Simple and prevents split-brain delivery.
2. Fan out to all active desktops and accept first durable ACK - 🎯 6 🛡️ 6 🧠 7 - approx `1200-2600` changed LOC.
Can duplicate local inboxes and confuse reply ownership.
3. Per-team device assignment - 🎯 7 🛡️ 8 🧠 7 - approx `1400-3200` changed LOC.
Useful later for power users, too much for MVP.
## 11. Security Requirements
Minimum official ingress controls:
- Verify `X-Telegram-Bot-Api-Secret-Token`.
- Use a secret webhook path as defense in depth.
- Reject updates that do not match expected bot id/account binding.
- Use `allowed_updates` to narrow update surface.
- Persist update id/provider message key dedupe.
- Rate-limit offline notices by chat/topic.
- HMAC/hash user ids and chat ids in backend logs.
- Do not log plaintext update payloads.
- Encrypt desktop-backend transport.
- Rotate desktop session tokens.
## 12. Test Matrix
Tests should simulate:
- valid webhook with active desktop accepted;
- duplicate webhook update after accepted;
- backend crash before receipt write;
- backend crash after receipt write;
- backend crash after desktop forward;
- desktop accepted locally but ACK lost;
- backend accepted desktop ACK but HTTP 2xx lost;
- desktop offline;
- offline notice success;
- offline notice timeout after request start;
- webhook secret mismatch;
- route missing;
- route disabled;
- topic deleted;
- bot permission lost;
- two desktop sessions racing;
- webhook max_connections concurrent deliveries out of order;
- `drop_pending_updates` during reconnect;
- old update after allowed_updates change;
- backend store unavailable;
- desktop reconnect while webhook is in-flight.
Pass criterion:
```text
No plaintext is durably stored by official backend.
No Telegram update is ACKed as handled before either desktop durable acceptance or an explicit offline/blocked decision.
No duplicate local turns for the same providerMessageKey.
No duplicate offline notice unless user/support explicitly chooses duplicate send.
```
## 13. Top 3 Overall Options
1. Synchronous desktop claim + redaction-safe ingress receipt + offline notice outbox - 🎯 8 🛡️ 8 🧠 8 - approx `2500-6000` changed LOC.
Recommended official MVP. It matches "no durable backend plaintext queue" and gives deterministic failure states.
2. Encrypted backend queue for later desktop replay - 🎯 7 🛡️ 9 🧠 9 - approx `3500-8000` changed LOC.
Better reliability, but bigger system. Backend still sees plaintext transiently from Telegram before encrypting.
3. Non-2xx webhook until desktop online, using Telegram retries as queue - 🎯 4 🛡️ 4 🧠 4 - approx `800-2000` changed LOC.
Not recommended. It is brittle, provider-controlled, and creates operational backlog.
## 14. Decision Update
Official shared bot MVP should implement:
```text
Telegram webhook
-> backend redaction-safe receipt
-> if desktop active: synchronous durable desktop claim
-> if accepted: ACK Telegram
-> if not accepted: offline notice policy, then ACK Telegram
```
Own bot mode remains:
```text
desktop long polling
-> local durable turn
-> advance update offset
```
This keeps the default UX simple while making the privacy/reliability tradeoff explicit instead of accidental.

View file

@ -0,0 +1,645 @@
# Messenger Connectors - Uncertainty Pass 30
Date: 2026-04-29
Scope: Telegram media and attachments for official shared bot mode, own-bot mode, inbox persistence, and no durable backend plaintext queue
## Executive Delta
The lowest-confidence boundary after webhook ACK timing is media:
```text
Telegram message with photo/document/voice
official backend receives update
backend may need bot token to fetch file bytes
desktop may be offline
local app currently persists attachments only for live lead messages
agent reply may need to reference or send files back
```
This is not just a file download problem. It changes the privacy story.
For official shared bot mode, the backend receives the update and can technically fetch Telegram files with the official bot token. Even if we do not store plaintext or media durably, the backend is in the transient data path. That is acceptable only if the product copy is precise:
```text
Default official bot:
- easiest setup
- no durable backend plaintext/media queue
- backend may transiently process messages while routing them
- if desktop is offline, we honestly say offline
```
Private own-bot mode is the clean privacy mode:
```text
Own bot:
- token stays in desktop
- desktop polls or receives webhooks directly when online
- backend does not receive lead messages or media
- offline reliability is lower unless user enables a separate relay/queue
```
⚠️ Recommendation update: launch official shared bot as text-first. Treat Telegram media as metadata-only/unsupported in the first official MVP. Add private own-bot media support before official shared bot media streaming if privacy is a core selling point.
## Source Facts Rechecked
Telegram official facts checked on 2026-04-29:
- Bot API is token-based. API calls are made to `https://api.telegram.org/bot<token>/METHOD_NAME`.
- Webhook responses can call a Bot API method inline, but Telegram does not return the method result to us in that webhook response.
- Incoming updates are stored by Telegram until received, but not longer than 24 hours.
- `Update.message` can be any kind of message, including text, photo, sticker, and more.
- `getFile` returns a `File` object and prepares a file for download.
- File download URL shape is `https://api.telegram.org/file/bot<token>/<file_path>`.
- Telegram guarantees that the file download link is valid for at least 1 hour.
- Standard cloud Bot API download limit is 20 MB.
- Local Bot API server can download without a size limit, upload up to 2000 MB, and can return a local `file_path`.
- `sendPhoto` supports `message_thread_id` and `direct_messages_topic_id`; uploaded photos are limited to 10 MB.
- `sendDocument` supports `message_thread_id` and `direct_messages_topic_id`; uploaded files are currently up to 50 MB.
- `sendMediaGroup` sends albums of 2-10 media items.
- `createForumTopic` can create a topic in a forum supergroup or a private chat with a user.
- Bot API 9.6 Managed Bots expose `getManagedBotToken`; the manager bot can fetch the managed bot token.
Sources:
- https://core.telegram.org/bots/api#making-requests
- https://core.telegram.org/bots/api#making-requests-when-getting-updates
- https://core.telegram.org/bots/api#getting-updates
- https://core.telegram.org/bots/api#file
- https://core.telegram.org/bots/api#getfile
- https://core.telegram.org/bots/api#using-a-local-bot-api-server
- https://core.telegram.org/bots/api#sendphoto
- https://core.telegram.org/bots/api#senddocument
- https://core.telegram.org/bots/api#sendmediagroup
- https://core.telegram.org/bots/api#createforumtopic
- https://core.telegram.org/bots/api#getmanagedbottoken
Local code facts:
- `AttachmentPayload` contains base64 data and metadata.
- `AttachmentMeta` is persisted on message rows and may include a local file path.
- `TeamAttachmentStore` writes files under app data `attachments/{teamName}/{messageId}` and stores `_index.json`.
- `TeamAttachmentStore` sanitizes path segments and stored filenames.
- Main-process IPC currently accepts only these message attachment MIME types: PNG, JPEG, GIF, WebP, PDF, and plain text.
- Main-process IPC currently limits message attachments to 5 files, 10 MB per file, and 20 MB total.
- `handleSendMessage` allows attachments only when sending to the live team lead.
- If stdin delivery fails after attachments were requested, the current code fails instead of silently dropping attachments.
- The inbox path is described as offline lead or regular members with no attachment support.
- OpenCode secondary runtime delivery marks attachment messages as terminal failure because attachments are not supported for secondary runtime.
- Renderer composer blocks attachments for cross-team messages, non-lead recipients, and offline teams.
Implication:
```text
Current app has a useful local attachment store,
but messenger media cannot safely reuse offline inbox delivery until we add
a durable provider-neutral media acceptance protocol.
```
## 1. Why Media Is Harder Than Text
Text flow can be bounded:
```text
backend receives plaintext text
desktop accepts locally
backend ACKs Telegram
backend forgets plaintext
```
Media flow needs at least one more side effect:
```text
backend receives file_id/file_unique_id/caption
backend calls getFile
backend downloads bytes using URL that embeds bot token
desktop writes bytes to local attachment store
desktop commits message row with attachment metadata
backend ACKs Telegram
```
Every step can fail independently.
The dangerous half-states are:
- backend ACKs Telegram, but desktop never wrote the file;
- desktop wrote the file, but message row did not commit;
- message row committed, but attachment file write failed;
- backend downloaded media but desktop disconnected;
- duplicate webhook retries download the same media multiple times;
- media group arrives as multiple updates and only some items are accepted;
- file is too large for Telegram cloud `getFile`;
- file link expires while desktop is offline;
- file_id is stored durably and becomes a capability to fetch content later with the bot token;
- provider MIME/type says one thing, actual bytes are another.
This is why media should not be part of the default official MVP unless it has its own state machine.
## 2. Privacy Reality
There are three privacy tiers.
### Tier A: official shared bot, text-only
```text
Backend transiently sees message text.
Backend stores only redaction-safe receipts and hashes.
No media bytes pass through backend because media is unsupported.
```
Privacy story:
```text
Simple default connection.
No durable backend plaintext queue.
Not end-to-end private from our backend.
```
### Tier B: official shared bot, ephemeral media streaming
```text
Backend transiently sees file metadata and file bytes.
Backend does not write bytes to disk.
Desktop must be online and must commit the attachment locally.
```
Privacy story:
```text
Convenient, but backend is a transient processor for media.
No durable backend media store.
```
### Tier C: own bot, local token
```text
Desktop holds token.
Desktop downloads Telegram files directly.
Backend never receives message text or media.
```
Privacy story:
```text
Best privacy.
More setup.
Works only while desktop app or local service is running, unless user adds their own hosting.
```
Managed Bots do not eliminate token exposure if our manager bot is the manager. Telegram added `getManagedBotToken`, and the official docs say the token can be fetched by the manager bot. Therefore, Managed Bots are a UX feature, not a clean no-token-access privacy feature for us.
## 3. Treat file_id As Sensitive
Telegram `file_id` is not the file bytes, but it is not harmless metadata.
Reason:
```text
file_id + bot token -> getFile -> download URL -> file bytes
```
Therefore:
- do not store raw `file_id` in durable official backend receipts unless encrypted;
- do not put raw `file_id` in logs;
- do not expose raw `file_id` to renderer unless the renderer needs it for an explicit action;
- prefer local desktop storage of raw provider file ids only after user acceptance;
- store `file_unique_id` only for dedupe if needed, but remember it cannot download or reuse the file;
- store HMACs for backend idempotency where possible.
Suggested receipt fields:
```ts
type ProviderMediaReceipt = {
provider: 'telegram';
scope: 'official' | 'own_bot';
updateId: number;
providerMessageKey: string;
providerMediaKeyHash: string;
providerFileUniqueIdHash: string | null;
providerFileIdEncrypted?: string;
mediaKind: 'photo' | 'document' | 'voice' | 'audio' | 'video' | 'animation' | 'sticker' | 'unknown';
declaredMimeType: string | null;
declaredSizeBytes: number | null;
captionHash: string | null;
status:
| 'received'
| 'unsupported_policy'
| 'desktop_claim_started'
| 'desktop_media_committed'
| 'desktop_text_only_committed'
| 'offline_notice_started'
| 'offline_notice_sent'
| 'offline_notice_ambiguous'
| 'acknowledged'
| 'failed_terminal';
};
```
For default official mode, omit `providerFileIdEncrypted` entirely.
## 4. Official MVP Policy
For official shared bot v1:
```text
Text, captions, commands:
- support
Photo/document/voice/audio/video/sticker:
- do not download
- route caption text if present
- include local metadata placeholder only if useful
- tell lead in Telegram that attachments are not supported yet or require desktop online
```
The system message should be explicit but not noisy:
```text
I received an attachment, but this connection currently supports text only.
Please send the key details as text, or connect a private bot for local file handling.
```
Rules:
- If a message has `caption`, deliver caption as the text turn.
- If a message has media and no caption, create a local event only if desktop is online and can persist a metadata-only placeholder.
- If desktop is offline, send one offline/unsupported notice and ACK Telegram.
- Deduplicate unsupported notices by `providerMessageKey`.
- Do not call `getFile` in official MVP.
- Do not store `file_id`.
This keeps the first version honest and avoids a half-built media pipeline.
## 5. Own-Bot Media Policy
Own-bot mode can support media earlier because the desktop has the token.
```text
desktop receives update via getUpdates or local webhook
desktop calls getFile directly
desktop downloads bytes directly
desktop writes TeamAttachmentStore
desktop writes message row
desktop sends ACK/offset after local commit
```
The exact update intake can be:
- desktop long polling with `getUpdates`;
- local webhook only if user has a reachable tunnel or local Bot API server;
- later, optional user-hosted relay.
For consumer desktop UX, long polling is simpler and more private:
```text
No inbound public port.
No server token storage.
Works while app is open.
Telegram can still be used from phone as the client UI.
```
Limitations:
- if desktop is asleep or app closed, no processing;
- Telegram retains updates only up to its limits;
- if the user also runs the same token elsewhere, `getUpdates` offset/webhook conflicts can appear;
- if webhook is set for the bot, `getUpdates` will not work until webhook is deleted.
## 6. Ephemeral Official Media Streaming
If we later support official shared bot media without durable backend media queue, use a strict active-desktop stream:
```text
Telegram webhook update
backend receipt persisted with hashes only
backend checks desktop session capability
backend calls getFile
backend streams file bytes to desktop over existing desktop connection
desktop writes temp file
desktop validates size/hash/MIME
desktop atomically moves into TeamAttachmentStore
desktop writes message row
desktop returns accepted_local_media
backend ACKs Telegram
```
Backend rules:
- stream only to an already authenticated desktop route session;
- do not write bytes to disk;
- limit file size before download using Telegram metadata when present;
- enforce hard byte counters during stream;
- abort stream if desktop disconnects;
- never log filename, file_id, or file_path;
- do not retry file downloads after request scope unless encrypted queue is enabled.
Desktop rules:
- write to a temp file outside final attachment path;
- compute SHA-256 while streaming;
- sniff magic bytes for supported types;
- verify final byte count;
- atomically move into local attachment store;
- only then commit message row;
- dedupe by `providerMessageKey + providerMediaPartKey`;
- return the existing local acceptance for duplicate delivery.
This needs a new storage API. Current `TeamAttachmentStore.saveAttachments` expects base64 payloads. Streaming media should not base64 all bytes through IPC.
Suggested extension:
```ts
interface AttachmentContentStore {
saveBase64MessageAttachments(input: SaveBase64AttachmentsInput): Promise<SavedAttachment[]>;
saveStreamedMessageAttachment(input: SaveStreamedAttachmentInput): Promise<SavedAttachment>;
getMessageAttachmentFiles(input: GetAttachmentFilesInput): Promise<AttachmentFileData[]>;
}
```
The Telegram adapter should depend on this port, not on `TeamAttachmentStore` directly.
## 7. Inbox Integration Model
Current inbox rows can carry `AttachmentMeta[]`, but the inbox path does not guarantee bytes exist. Messenger media needs a stronger invariant:
```text
An inbox/message row may reference an attachment only after local bytes are committed,
unless the attachment is explicitly marked as metadata-only/unsupported.
```
Add a provider-neutral attachment state:
```ts
type MessengerAttachmentState =
| 'available_local'
| 'metadata_only'
| 'unsupported_policy'
| 'too_large'
| 'download_failed'
| 'expired'
| 'blocked_security';
type MessengerAttachmentMeta = AttachmentMeta & {
state: MessengerAttachmentState;
provider: 'telegram' | 'whatsapp' | 'discord';
providerKind: string;
providerMessageKey: string;
providerMediaPartKey: string;
caption?: string;
checksumSha256?: string;
localCommittedAt?: string;
};
```
Do not overload `AttachmentMeta.filePath` absence as "unsupported". It already means metadata-only in comments, but messenger needs typed status for UI, retries, and support.
## 8. Media Group Edge Cases
Telegram albums arrive as multiple messages with a shared grouping concept. Do not assume one update equals one logical user turn.
Policy:
- collect album parts in a short local aggregation window, for example 800-1500 ms;
- if some parts are unsupported, deliver one consolidated turn with mixed attachment states;
- dedupe every part independently;
- do not block a text caption forever waiting for missing album parts;
- if the same album has multiple captions, preserve each caption near its part in the local model;
- if aggregation times out, commit what is available and mark late duplicates as follow-up parts.
For MVP text-only official mode:
- do not download album files;
- aggregate captions and unsupported media counts;
- send at most one notice per album.
## 9. Outbound Media From Agent To Telegram
Outbound media is easier only if the file already exists locally and the desktop is online.
Flow:
```text
agent/tool creates reply with attachment reference
desktop validates local file and policy
desktop sends request to provider adapter
official adapter uploads via backend
own-bot adapter uploads directly
provider returns message ids
local delivery ledger marks sent
```
Official shared bot outbound media privacy:
```text
If backend uploads the file to Telegram, backend transiently sees file bytes.
```
That is acceptable only under the same "transient processor, no durable media queue" contract.
MVP:
- outbound official: text only;
- outbound own-bot: optionally support local photos/documents after inbound media is solid;
- never let an agent silently send local files to Telegram without explicit policy gates.
## 10. Security Rules
Minimum rules before any media bytes are supported:
- allowlist MIME families by provider mode;
- enforce byte limits before and during download;
- store original filename only after sanitization;
- keep provider filename as untrusted display text;
- never use provider filename as path;
- sniff magic bytes for images/PDF/text where possible;
- reject archives in MVP;
- reject executable types;
- do not auto-open downloaded files;
- do not feed binary content to the model unless the app explicitly supports that type;
- captions and filenames are untrusted user input, not system instructions;
- strip or ignore path-like names;
- rate-limit media downloads per route;
- record redaction-safe diagnostics for failed media;
- design future malware scanning as an optional port, not hardcoded vendor logic.
For text extraction:
- plain text files can be included only after encoding validation and size cap;
- PDFs should be attached as model document blocks only when provider/runtime supports them;
- voice transcription should be a separate explicit feature, preferably local-first if privacy matters.
## 11. Failure Matrix
Critical cases:
- Update has media but no text.
- Official MVP: unsupported notice, metadata-only local event if desktop online.
- Update has media plus caption.
- Official MVP: deliver caption and mention unsupported attachment count.
- Duplicate webhook after unsupported notice.
- Return completed receipt, do not resend notice.
- Duplicate webhook after desktop local commit.
- Desktop returns existing local message id.
- Backend calls `getFile`, desktop disconnects before any bytes.
- Abort stream, offline/unsupported policy, ACK according to receipt state.
- Backend streams bytes, desktop crashes before commit.
- Duplicate webhook can retry only if backend has not ACKed yet.
- Desktop commits file, ACK to backend is lost.
- Duplicate webhook redelivers, desktop dedupes and returns existing acceptance.
- File exceeds Telegram cloud download limit.
- Mark too_large; suggest user resend as text or use own bot/local server mode later.
- File download URL expires.
- In official no-queue mode, do not attempt later replay. Mark expired if it happens in active stream.
- Provider MIME lies.
- Sniff bytes, reject if mismatch is dangerous.
- Filename is `../../x` or has control chars.
- Sanitize and preserve original only as escaped display text if needed.
- Media group partially arrives.
- Commit consolidated partial turn with per-part states.
- Backend crashes after downloading media but before desktop commit.
- No backend disk means media is lost; webhook retry may redownload if not ACKed.
- Backend crashes after desktop commit but before HTTP 2xx.
- Telegram retries; backend uses receipt and desktop dedupe to ACK.
## 12. Top 3 Options
### Option 1 - Official text-only MVP, media metadata/notice, own-bot media later
🎯 9 🛡️ 9 🧠 4
Approx changed LOC: 700-1800.
What it means:
- official shared bot supports text and captions;
- official shared bot does not call `getFile`;
- official shared bot does not store `file_id`;
- media-only messages get one clear unsupported notice;
- local UI can show "attachment received, not imported" metadata only when desktop is online;
- own-bot adapter is the first place where real media support can land.
Why this is best now:
- aligns with no durable backend plaintext/media queue;
- avoids token/file privacy ambiguity;
- matches current app constraints where attachments require live lead;
- minimizes risk of partial file delivery bugs;
- gives users a clean upgrade path: "connect private bot for local files".
Risk:
- less magical than users expect from Telegram;
- leads may send screenshots and expect them to work;
- product copy must be clear.
### Option 2 - Official ephemeral media streaming to active desktop
🎯 7 🛡️ 8 🧠 8
Approx changed LOC: 2500-6000.
What it means:
- backend downloads media only while desktop is connected;
- backend streams bytes to desktop and does not store them;
- desktop commits attachment bytes before message row;
- ACK waits for local acceptance or clean unsupported/offline decision.
Why it is viable:
- preserves convenience of official shared bot;
- no durable backend media store;
- can support common screenshots/documents.
Risk:
- backend still transiently sees file bytes;
- many failure states;
- requires new streaming attachment port;
- current base64 attachment path is not the right transport;
- harder to test than text.
### Option 3 - Backend encrypted media queue
🎯 6 🛡️ 8 🧠 9
Approx changed LOC: 3500-9000.
What it means:
- backend stores encrypted media or encrypted Telegram file capabilities for later desktop replay;
- desktop decrypts and commits when it comes online;
- official bot can feel reliable even while desktop is offline.
Why it is not first:
- this changes the product from "offline means offline" to "we queue sensitive content";
- encrypted media queue is still a data retention system;
- key management, replay, retention, deletion, and support diagnostics become much harder;
- it competes with a simpler premium/advanced reliability mode later.
Use only after:
- text routing is stable;
- ephemeral streaming is proven;
- user demand for offline media is strong enough.
## 13. Decision Update
Recommended sequence:
```text
1. Official shared bot:
text + captions + topics + reply routing + no durable backend plaintext queue.
2. Own-bot adapter:
local token + local polling + text first, then local media download.
3. Official shared bot media:
ephemeral active-desktop streaming only, no backend disk.
4. Advanced reliability:
encrypted backend queue for text/media only if explicitly enabled.
```
This keeps the architecture provider-neutral and honest:
```text
core messenger domain:
route identity
thread/topic mapping
local message ledger
attachment state machine
delivery ledger
provider adapters:
Telegram official adapter
Telegram own-bot adapter
future WhatsApp adapter
future Discord adapter
storage ports:
route registry
inbound receipt store
local turn ledger
attachment content store
outbound delivery ledger
```
The main design rule:
```text
No message row may claim an attachment is available unless the desktop has committed bytes locally.
```
## 14. Places Still Worth Deeper Research
Next low-confidence areas:
- exact Telegram private-chat topics UX across clients when many teams exist;
- whether `message_thread_id` behavior is consistent for private bot topics on desktop/mobile Telegram clients;
- how to represent teammate messages inside one team topic without confusing the user;
- whether captions/media groups should become one Agent Teams turn or multiple turns;
- how to prevent model/tool prompt injection through Telegram captions and filenames;
- which own-bot intake mode is best for desktop: long polling, local Bot API server, or optional tunnel.

View file

@ -0,0 +1,736 @@
# Messenger Connectors - Uncertainty Pass 31
Date: 2026-04-29
Scope: Telegram private-chat topics, one-topic-per-team topology, reply-to teammate routing, topic registry recoverability, and local inbox alignment
## Executive Delta
The next lowest-confidence area is not whether Telegram supports topics. It does.
The weak point is whether topics can be used as a stable product navigation layer without losing routing correctness:
```text
Telegram private topic id
-> app team route
-> lead or teammate recipient
-> durable local message
-> agent reply
-> Telegram message in the same topic
-> user replies to a concrete teammate message
```
The correct approach is:
```text
One Telegram topic per team.
Route the topic to the team.
Route the recipient inside the team by reply-to message ledger, explicit command, or UI buttons.
Default no-reply messages to the lead.
Never create one bot or one topic per teammate as the default.
```
⚠️ Main new finding: Telegram Bot API exposes create/edit/delete topic operations, but I do not see a Bot API method for listing all private-chat topics and recovering their ids. That means our app must treat topic ids as durable provider state and store them locally/backend-side from creation time. If the registry is lost, topic recovery is weak and may require creating replacement topics.
## Source Facts Rechecked
Telegram official facts checked on 2026-04-29:
- Bot API 9.3, dated December 31, 2025, added private-chat topic mode support.
- Bot API 9.3 added `User.has_topics_enabled`, `Message.message_thread_id`, and `Message.is_topic_message` support for private chats with topic mode enabled.
- Bot API 9.3 added `message_thread_id` support in private chats for `sendMessage`, media methods, `sendMediaGroup`, `copyMessage`, `forwardMessage`, `sendChatAction`, and topic-management methods.
- Bot API 9.4, dated February 9, 2026, allowed bots to create topics in private chats with `createForumTopic`.
- Bot API 9.4 added a BotFather setting that can prevent users from creating and deleting topics in private chats.
- `User.has_topics_enabled` is returned by `getMe` and means the bot has forum topic mode enabled in private chats.
- `User.allows_users_to_create_topics` is returned by `getMe` and indicates whether users may create/delete topics in private chats.
- `createForumTopic` can create a topic in a forum supergroup chat or a private chat with a user.
- `editForumTopic`, `deleteForumTopic`, and `unpinAllForumTopicMessages` support private chats with a user.
- `sendMessage.message_thread_id` routes a message to a forum/private-chat topic.
- Incoming `Message` includes optional `message_thread_id`, `is_topic_message`, `reply_to_message`, `media_group_id`, and text/media fields.
- `ReplyParameters` lets a bot reply to a specific message id in the current chat or a specified chat.
- `direct_messages_topic_id` is for channel direct messages chats and should not be confused with forum/private-chat `message_thread_id`.
- Telegram forum topics are conceptually message threads. Nested message threads inside topics are not supported.
- Telegram clients can have a "View as messages" setting for forums that shows messages from all topics in one stream. Treat this as a warning that visible topic grouping is a UX layer, not a routing authority.
Sources:
- https://core.telegram.org/bots/api-changelog
- https://core.telegram.org/bots/api#getme
- https://core.telegram.org/bots/api#user
- https://core.telegram.org/bots/api#message
- https://core.telegram.org/bots/api#sendmessage
- https://core.telegram.org/bots/api#replyparameters
- https://core.telegram.org/bots/api#createforumtopic
- https://core.telegram.org/bots/api#editforumtopic
- https://core.telegram.org/bots/api#deleteforumtopic
- https://core.telegram.org/api/forum
Local code facts:
- `InboxMessage` already has `from`, `to`, `messageId`, `relayOfMessageId`, `conversationId`, and `replyToConversationId`.
- `TeamDataService.sendMessage` passes `conversationId` and `replyToConversationId` into the message controller.
- `CrossTeamService` already uses `conversationId` and `replyToConversationId` for cross-team threads.
- OpenCode runtime delivery writes direct replies to either `user_sent_messages` or `member_inbox`.
- `MessagesFilterPopover` already derives participants from message `from` and `to`.
- `MessagesPanel` pending reply logic already treats `from=user -> to=member` and `from=member -> to=user` as meaningful route signals.
- Current message model is string-name based, not stable-id based. Prior passes already identified stable route identity as a required feature layer.
Implication:
```text
The app can represent the desired conversation shape,
but messenger connectors need a provider-neutral route registry
and provider message link ledger before Telegram topics are safe.
```
## 1. Topic Is Team Scope, Not Recipient Scope
One topic should map to one team:
```text
chatId + messageThreadId -> teamRouteId
```
Recipient should be resolved inside that team:
```text
incoming message in team topic
-> if it replies to a known bot message from teammate X, route to teammate X
-> else if it contains explicit recipient command/control, route to that recipient
-> else route to lead
```
Do not use topic title to route.
Topic title is display state:
```text
"Frontend - Acme"
"API - Acme"
"API - Acme (archived)"
```
Route identity must be persisted as:
```ts
type MessengerTeamTopicRoute = {
routeId: string;
provider: 'telegram';
botScope: 'official' | 'own_bot';
botId: string;
telegramChatIdHash: string;
telegramChatIdEncrypted?: string;
telegramMessageThreadId: number;
teamId: string;
teamGeneration: number;
projectId: string | null;
projectGeneration: number | null;
displayTitle: string;
status:
| 'active'
| 'create_pending'
| 'create_ambiguous'
| 'renaming'
| 'renamed'
| 'delete_seen'
| 'replaced'
| 'disabled'
| 'error';
createdAt: string;
updatedAt: string;
};
```
For official shared bot, backend needs this route registry. For own-bot local mode, desktop can own it locally.
## 2. The Recoverability Problem
Creation is straightforward:
```text
user starts bot
desktop/backend knows Telegram chat id
app creates topic for a team
Telegram returns ForumTopic
app stores message_thread_id
```
The low-confidence part is recovery:
```text
What if our route registry is lost?
What if topic creation succeeded but the process crashed before storing topic id?
What if user deletes or renames a topic?
What if app creates a duplicate topic after a timeout?
```
I do not see a Bot API method equivalent to "list my private-chat topics". Telegram's MTProto API has forum topic listing for forums, but Bot API docs expose topic creation/edit/delete operations and no simple list method. We should not build a core invariant on being able to reconstruct topic state from Telegram later.
Therefore:
```text
Topic registry is authoritative local/backend product state.
Telegram is an external projection.
```
Creation must use a two-phase state:
```text
create_pending -> active
create_pending -> create_ambiguous
create_ambiguous -> replaced
```
If creation response is lost:
- do not keep retrying blindly;
- show diagnostics in app;
- allow "Create replacement topic";
- optionally send a message in the general/default bot chat asking the user to pick the right topic if we can design a safe verification flow later.
## 3. User Topic Deletion And BotFather Settings
Bot API 9.4 added a setting to prevent users from creating and deleting topics in private chats.
Recommended official bot configuration:
```text
Private chat topics enabled.
Users cannot create/delete topics.
Bot manages team topics.
```
Why:
- fewer orphan routes;
- fewer topic id invalidation bugs;
- fewer accidental duplicates;
- cleaner support story.
But the app must still handle deletion or invalid topic errors:
```text
sendMessage(chatId, message_thread_id) fails
-> mark topic route as error or delete_seen
-> do not fallback silently to general chat
-> create replacement topic only behind an explicit repair flow
```
Silent fallback to general chat is dangerous because the user may read a message outside the intended team context.
## 4. Reply-To Teammate Routing
The desired product behavior:
```text
User opens team topic.
Bot posts messages from lead and teammates.
User replies to a concrete message.
App routes the reply to that concrete teammate.
```
This is viable if we store provider message links:
```ts
type ProviderMessageLink = {
provider: 'telegram';
routeId: string;
providerChatIdHash: string;
providerMessageThreadId: number;
providerMessageId: number;
internalMessageId: string;
internalTeamId: string;
internalFromMemberId: string;
internalToMemberId: string | null;
direction: 'telegram_to_app' | 'app_to_telegram';
createdAt: string;
};
```
Incoming reply resolution:
```text
if update.message.reply_to_message.message_id exists:
lookup ProviderMessageLink by chatId + messageThreadId + reply_to_message.message_id
if found and linked internal message came from teammate:
route to that teammate
if found and linked internal message came from lead:
route to lead
if found and linked internal message came from user:
route to lead or use explicit reply target from that internal row
else:
use explicit recipient control or default to lead
```
Important edge case:
```text
Telegram topics cannot have nested message threads.
Reply-to is only a pointer to a message, not a durable sub-thread per teammate.
```
Therefore, reply-to should be a routing hint, not the entire conversation model.
## 5. Explicit Recipient Controls
Reply-to is natural but insufficient.
Users will send plain messages into a topic without replying. For those messages, the app needs a deterministic default and optional controls:
```text
Default:
message without reply -> team lead
Explicit route:
/to teammate-name message
or inline button "Reply to Alice"
or short command menu
```
Do not rely on Telegram mentions for routing:
- teammate names may not be Telegram users;
- agents are not Telegram accounts;
- inline mention semantics depend on Telegram user privacy and previous contact conditions;
- local app member names can change.
Suggested official MVP:
```text
No global "active recipient" state at first.
Use reply-to for specific teammate replies.
Use /to for explicit direct messages.
Default to lead.
```
This is less magical but safer than hidden mutable state.
## 6. Message Text Format In Telegram
Because client topic grouping can be changed by the user and messages can appear in flattened views, every bot message should carry lightweight context.
Example:
```text
[Frontend] Alice
I pushed the fix and need review on the auth callback.
```
For lead:
```text
[Frontend] Lead
I will ask Alice to check the failing test.
```
For user-sent routed message acknowledgements:
```text
[Frontend] to Alice
Forwarded.
```
Rules:
- include team label in the first line;
- include member display name for agent replies;
- keep prefixes short;
- do not include internal ids;
- do not rely only on topic title;
- avoid markdown complexity unless using explicit Telegram entities.
This makes flattened Telegram views survivable.
## 7. Topic Lifecycle State Machine
Suggested route lifecycle:
```text
not_created
-> create_pending
-> active
-> renaming
-> active
-> disabled
create_pending
-> create_ambiguous
-> replaced
active
-> send_failed_topic_missing
-> repair_required
-> replacement_pending
-> active
active
-> archived
-> disabled
```
Do not delete topics automatically when a team is archived.
Recommended archive behavior:
- rename topic to include a compact archived marker;
- send one final "team archived" message;
- stop routing new user messages or route them to lead with a clear archived response;
- keep local route state for historical provider links.
Deletion destroys user-visible history in Telegram and makes provider message links harder to explain.
## 8. Rename And Duplicate Teams
Current app still relies heavily on `teamName`, while prior research recommended stable team ids and route generations.
Telegram topic routing should not follow only team name.
If team is renamed:
```text
teamId stays stable
topic route stays stable
displayTitle is updated
editForumTopic is best-effort
message prefix changes after local commit
```
If two projects have same team name:
```text
topic title must include a compact project discriminator
routeId must include project/team stable ids
```
Example topic title:
```text
Frontend - acme-web
Frontend - mobile-app
```
Title length is capped, so the full identity must be in the registry, not in Telegram title.
## 9. Topic Creation Timing
Three possible creation timings:
### Lazy create on first outbound/inbound use
Pros:
- fewer unused topics;
- less setup friction.
Cons:
- first message may be slower;
- creation failure blocks communication at the worst moment;
- ambiguous creation state can happen during a real user message.
### Eager create during connect wizard
Pros:
- setup verifies topic capability early;
- failures are visible before real traffic;
- topic registry is ready.
Cons:
- creates topics for teams user may never use;
- can clutter Telegram.
### Hybrid
Recommended:
```text
Create a topic for selected/active teams during connect wizard.
Lazy-create for other teams when user enables them.
```
This matches "minimum user actions" without creating too many topics.
## 10. Route Ambiguity Cases
Inbound ambiguity cases:
- message has no `message_thread_id`;
- message has a thread id not in registry;
- message has a known thread id but route is disabled;
- message replies to a provider message id not in ledger;
- reply target maps to a deleted/renamed teammate;
- reply target maps to an old team generation;
- user uses `/to` for an unknown teammate;
- topic title was manually changed;
- duplicate topic exists for the same team;
- user forwards/copies messages between topics;
- media group spans a topic but parts arrive separately;
- update contains `direct_messages_topic` from channel direct messages, not private chat topic;
- bot receives a message outside private chat if added to a group.
Resolution policy:
```text
Unknown topic -> do not deliver to agent, send repair/unknown-topic notice.
Known topic + unknown reply target -> route to lead with quoted context.
Known topic + stale teammate -> route to lead and mention stale target in internal metadata.
No topic id -> onboarding/default command handling only.
```
Never guess a team by topic title.
## 11. Local UI Implications
The current Messages panel can already show participant flows from `from` and `to`. For messenger connectors, add a feature-local projection rather than rewriting the existing panel first:
```ts
type MessengerConversationProjection = {
routeId: string;
teamId: string;
provider: 'telegram';
providerTopicTitle: string;
messages: Array<{
internalMessageId: string;
providerMessageId?: number;
fromMemberId: string;
toMemberId?: string;
replyToInternalMessageId?: string;
direction: 'inbound' | 'outbound';
deliveryState: 'pending' | 'sent' | 'failed' | 'ambiguous';
}>;
};
```
The renderer can keep using participant filters, but messenger-specific state should live in `src/features/messenger-connectors/renderer`:
```text
messenger feature hook
-> maps route/thread state into view model
-> existing MessagesPanel can show the durable local messages
-> optional connector status panel shows Telegram topic health
```
Do not put Telegram concepts directly into shared `InboxMessage` unless they are provider-neutral.
Provider-specific fields belong in a feature table/store:
```text
provider_message_links
provider_route_registry
provider_delivery_ledger
```
## 12. Architecture Fit
This feature clearly qualifies for the canonical feature architecture:
```text
src/features/messenger-connectors/
contracts/
core/
domain/
route.ts
topic.ts
recipient-resolution.ts
provider-message-link.ts
application/
ports.ts
connect-messenger.ts
receive-provider-update.ts
send-provider-reply.ts
repair-topic-route.ts
main/
composition/
adapters/
input/
ipc/
telegram-webhook/
desktop-relay/
output/
telegram/
team-messages/
local-store/
infrastructure/
preload/
renderer/
```
Core domain invariants:
```text
1. Provider topic title never determines route identity.
2. Provider thread id maps to exactly one active team route per bot/chat.
3. Recipient resolution is deterministic and auditable.
4. Unknown topic never reaches an agent as a normal user message.
5. Every outbound Telegram message that can be replied to has a ProviderMessageLink.
6. Topic repair never silently changes the user's message destination.
```
## 13. Top 3 Options
### Option 1 - One topic per team, reply-to ledger, default to lead, `/to` escape hatch
🎯 8 🛡️ 9 🧠 6
Approx changed LOC: 2500-5500.
What it means:
- each team has one Telegram private topic;
- inbound messages in that topic route to the lead by default;
- replying to a known teammate message routes to that teammate;
- `/to teammate message` provides explicit routing;
- topic id and provider message links are stored durably;
- unknown/stale topics enter repair flow.
Why this is best:
- matches the user's selected model;
- avoids topic explosion;
- works with current `from`/`to` message model;
- scales to many teams better than per-teammate topics;
- keeps routing deterministic.
Risk:
- users must learn reply-to or `/to` for teammate-specific messages;
- if provider message link ledger is missing, teammate routing falls back to lead;
- requires solid route registry.
### Option 2 - One topic per team with mutable active recipient controls
🎯 6 🛡️ 7 🧠 7
Approx changed LOC: 3500-7000.
What it means:
- each topic has controls such as "Active recipient: Alice";
- user taps inline buttons or commands to switch active recipient;
- plain messages route to current active recipient until changed.
Why it is tempting:
- fewer reply-to requirements;
- feels convenient on mobile;
- user can have a visible selected target.
Risk:
- hidden mutable state across desktop and phone is easy to misunderstand;
- two devices/users can change active recipient unexpectedly;
- stale controls can route messages incorrectly;
- callback handling and status messages add complexity.
This can be added later after Option 1, but I would not make it the first model.
### Option 3 - One topic per teammate or per internal conversation
🎯 4 🛡️ 6 🧠 8
Approx changed LOC: 4000-9000.
What it means:
- team lead has one topic;
- each teammate has a separate topic;
- or each conversation creates a topic.
Why it looks reliable:
- recipient is obvious from topic;
- fewer reply-to resolution rules.
Why it is worse:
- topic count explodes;
- Telegram UI becomes cluttered;
- team context fragments;
- archiving/renaming/recovering many topics is painful;
- cross-team/project grouping becomes harder;
- user wanted one team context, not dozens of technical threads.
Use only for a future "power mode" if users explicitly ask for per-agent topics.
## 14. Decision Update
Recommended design:
```text
Default official bot:
one private topic per team
topic id maps to team route
default route to lead
reply-to route to teammate through ProviderMessageLink
`/to` command as explicit escape hatch
no mutable active recipient in MVP
```
Required build blocks before implementation:
```text
1. Stable TeamRoute identity independent of teamName.
2. MessengerTopicRegistry with route generations and repair states.
3. ProviderMessageLink ledger for every Telegram outbound message.
4. RecipientResolver pure domain service.
5. UnknownTopicPolicy that never sends unknown messages to agents.
6. TopicRepair use case.
7. Tests for duplicate, deleted, renamed, stale, and unknown topics.
```
The most important invariant:
```text
Telegram topic/thread id chooses team.
Provider reply-to message id chooses teammate.
Plain topic message chooses lead.
```
## 15. Tests To Write First
Domain tests:
- known topic + no reply -> lead;
- known topic + reply to lead message -> lead;
- known topic + reply to teammate message -> teammate;
- known topic + reply to user message -> lead;
- known topic + unknown reply message id -> lead with ambiguity metadata;
- unknown topic -> repair/notice, not agent delivery;
- disabled topic -> archived/disabled response, not agent delivery;
- duplicate topic route -> terminal config error;
- renamed team -> same route id, updated display title;
- deleted teammate -> lead fallback with stale target metadata;
- `/to Alice hello` -> Alice;
- `/to unknown hello` -> lead or error notice by policy;
- media group in known topic -> same team route for all parts.
Adapter tests:
- `createForumTopic` success persists `message_thread_id`;
- create response lost enters `create_ambiguous`;
- `sendMessage` includes correct `message_thread_id`;
- `sendMessage` failure for topic not found marks repair-required;
- inbound update stores provider message link before local delivery ACK;
- outbound provider message id is stored before considering Telegram delivery complete;
- duplicate webhook with same provider message id returns existing local route.
Renderer tests:
- connector status panel shows topic healthy/error/repair-required;
- message row prefix includes team/member context for Telegram projection;
- participant filters still work with messenger-originated messages;
- reply-to unavailable shows lead fallback reason.
## 16. Remaining Low-Confidence Areas
Still worth deeper research next:
- exact Telegram client UX for private-chat topics on mobile and desktop after Bot API 9.3/9.4;
- whether BotFather private topic settings can be configured programmatically or only manually;
- exact error codes returned when a private topic is deleted or disabled;
- whether Telegram private topics expose enough update events to detect user rename/delete promptly;
- how long topic titles can remain readable with many projects and similar team names;
- whether `sendMessageDraft` could improve "agent is typing" UX per team topic without creating noisy messages;
- how to migrate a user from official shared bot topics to own-bot topics without losing local route history.

View file

@ -0,0 +1,796 @@
# Messenger Connectors - Uncertainty Pass 32
Date: 2026-04-29
Scope: agent reply capture, outbound Telegram delivery, message visibility policy, duplicate prevention, and provider delivery ambiguity
## Executive Delta
The next lowest-confidence boundary is the final leg:
```text
agent/team message
-> local app feed
-> outbound eligibility decision
-> Telegram sendMessage
-> provider message id
-> future reply-to route
```
This is where two severe bugs can happen:
```text
1. Privacy leak:
internal thoughts, tool summaries, teammate protocol XML, retry prompts, or slash output
get sent to Telegram as if they were user-facing replies.
2. Duplicate provider send:
Telegram receives a sendMessage request, but our process times out before seeing the result.
Automatic retry can send the same user-visible reply twice.
```
The fix is a dedicated outbound projection layer:
```text
MessengerOutboundProjectionGate
decides if a local message is eligible for external provider delivery
MessengerProviderDeliveryLedger
records provider send intent, in-flight state, success, ambiguity, and terminal failure
ProviderMessageLink
records Telegram message id after success so reply-to routing works later
```
Do not use the renderer feed or `sentMessages.json` as the outbound provider queue. They are useful inputs, but not the delivery protocol.
## Source Facts Rechecked
Telegram official facts checked on 2026-04-29:
- Bot API methods return a JSON object with `ok`; successful calls put the method result in `result`.
- `sendMessage` sends text and returns the sent `Message` on success.
- `sendMessage` supports `message_thread_id` for forum/private-chat topics.
- `sendMessage` supports `reply_parameters` for replying to a specific message.
- When using webhook inline responses to call Bot API methods, Telegram says it is not possible to know whether the method succeeded or to get its result.
- `ResponseParameters.retry_after` tells how many seconds to wait after flood control.
- Telegram FAQ recommends avoiding more than one message per second in a single chat; otherwise 429 errors can happen.
- Telegram FAQ says bots should not rely on webhook inline response if they need to know the result of the method.
- Bot API docs and FAQ do not expose a client-supplied idempotency key for `sendMessage`.
Sources:
- https://core.telegram.org/bots/api#making-requests
- https://core.telegram.org/bots/api#making-requests-when-getting-updates
- https://core.telegram.org/bots/api#sendmessage
- https://core.telegram.org/bots/api#replyparameters
- https://core.telegram.org/bots/api#responseparameters
- https://core.telegram.org/bots/faq
Local code facts:
- `TeamSentMessagesStore` persists `sentMessages.json`, but it caps history at 200 messages and is optimized as a local UI/persistence store, not a provider delivery ledger.
- `TeamSentMessagesStore` preserves message fields such as `from`, `to`, `source`, `leadSessionId`, `conversationId`, and `replyToConversationId`.
- `TeamDataService.extractLeadSessionTextsFromJsonl` creates lead-session text rows with `source: 'lead_session'` and usually no `to`.
- `leadSessionMessageExtractor` creates slash command result rows with `source: 'lead_session'` and `messageKind: 'slash_command_result'`.
- `TeamProvisioningService` captures native `SendMessage` tool calls. `recipient === 'user'` is persisted to `sentMessages.json`; other recipients are persisted to inbox.
- `relayLeadInboxMessages` captures plain lead output for inbox relay, strips agent-only blocks, then persists a `lead_process` message to user.
- `stripAgentBlocks` removes `info_for_agent`, legacy agent blocks, and OpenCode runtime delivery blocks.
- `inboxNoise` detects internal JSON noise and teammate-message XML protocol artifacts.
- `RuntimeDeliveryService` already has strong local idempotency ideas: journal begin, payload hash conflict detection, destination verification, committed state, failed retryable state, and reconciler.
- Existing runtime delivery works for local destinations because it can verify local files/stores. Telegram provider sends are different because success may be unknowable after network timeout.
Implication:
```text
The current app has good ingredients,
but messenger outbound needs a separate provider delivery ledger
with stricter "external visibility" rules than the UI feed.
```
## 1. Outbound Eligibility Is A Security Boundary
The local feed contains multiple categories:
```text
user_sent
lead_process
lead_session
runtime_delivery
inbox
system_notification
cross_team
cross_team_sent
slash_command_result
tool summaries
command output
internal protocol blocks
noise JSON
```
Only a small subset should be allowed to leave the app through Telegram.
Minimal provider-send eligibility:
```text
message.to == "user"
message.from is a known active team member or lead
message.source is user-visible by policy
message.text remains non-empty after sanitization
message is linked to a provider route or an explicit publish action
message has not already been sent to that provider route
route is active
topic is active
outbound policy allows this member/source/kind
```
Hard excludes:
```text
message.from == "user"
message.from == "system"
message.to != "user"
messageKind == "slash_command" unless explicitly mirrored as a user command echo
messageKind == "slash_command_result" unless explicitly requested
isInboxNoiseMessage(text)
isThoughtProtocolNoise(text)
stripAgentBlocks(text) is empty
only teammate-message XML blocks
tool-only rows with no human answer
debug diagnostics
runtime retry prompt text
permission_request JSON
```
The important rule:
```text
If a message is visible in the local app, that does not automatically mean it is safe to send to Telegram.
```
## 2. What Counts As A User-Facing Agent Reply
For the Telegram topic product, user-facing means:
```text
Lead or teammate intentionally answered the external user.
```
Good candidates:
- `SendMessage(to="user")` captured from lead or teammate runtime.
- Runtime delivery envelope whose destination is `user_sent_messages`.
- A visible reply proof with `relayOfMessageId` linked to a messenger inbound turn.
- A manual user action in our UI like "send this to Telegram".
Risky candidates:
- Lead session thoughts without `to`.
- Plain assistant text captured from stdout during a relay batch.
- Slash command output.
- Task/comment notifications.
- Cross-team internal coordination.
- Teammate-to-teammate messages.
Recommended MVP:
```text
Auto-send to Telegram only messages that have an explicit destination to external user.
Do not auto-send generic lead thoughts.
```
This means:
```text
lead_process with to=user -> eligible if linked to route
runtime delivery to user -> eligible if linked to route
lead_session without to -> not eligible
slash_command_result -> not eligible by default
cross_team_sent -> not eligible unless to=user and explicit external link exists
```
## 3. User Wants Teammate Messages Too
The user's desired behavior:
```text
Messages from other teammates to the user should appear in Telegram too,
signed by each teammate.
```
This is real and understandable. The safe model:
```text
If any team member sends a message to "user" in a route-linked conversation,
send it into the team topic with a member prefix.
```
Example Telegram rendering:
```text
[Frontend] Alice
I found the failing test. The auth callback returns before token refresh completes.
```
```text
[Frontend] Lead
Alice is checking the failing test. I will update you when she has a patch.
```
Do not send teammate-internal chatter:
```text
Alice -> Lead: "Can you clarify the expected API?"
Lead -> Bob: "Please review Alice's patch"
Bob -> Alice: "Approved"
```
unless the destination is explicitly `user`.
Therefore the outbound projection should key off destination, not role:
```text
to=user + route link + eligible source -> send to Telegram
to=lead/teammate/cross-team -> do not send
```
## 4. Route Link Requirement
Do not send every `to=user` message to Telegram. The user may have multiple channels:
```text
local UI only
Telegram official bot
Telegram own bot
future WhatsApp
future Discord
```
Outbound needs an explicit route link:
```ts
type MessengerOutboundContext =
| {
kind: 'reply_to_provider_turn';
routeId: string;
inboundProviderMessageKey: string;
internalInboundMessageId: string;
}
| {
kind: 'manual_publish';
routeId: string;
requestedBy: 'user';
localMessageId: string;
};
```
For auto-send MVP, require `reply_to_provider_turn`.
Manual publish can come later. Without route link, local app replies remain local app replies.
## 5. Provider Delivery Is Not Local Delivery
Existing `RuntimeDeliveryService` can retry local destinations because it can verify them:
```text
write deterministic local message id
verify file/store contains destination message id
mark committed
```
Telegram is different:
```text
POST sendMessage
network timeout before response
unknown whether Telegram created the message
no Bot API client idempotency key
cannot verify by deterministic local id
```
Therefore provider delivery states need an ambiguity state:
```ts
type MessengerProviderDeliveryStatus =
| 'pending'
| 'send_in_flight'
| 'sent'
| 'send_ambiguous'
| 'rate_limited'
| 'failed_retryable_before_send'
| 'failed_terminal'
| 'cancelled';
```
Critical rule:
```text
Never automatically retry send_in_flight after a transport timeout
unless the provider adapter can prove the previous attempt did not reach Telegram.
```
Most HTTP timeout cases cannot prove that.
## 6. Provider Delivery Ledger
Suggested ledger:
```ts
type MessengerProviderDeliveryRecord = {
idempotencyKey: string;
provider: 'telegram';
botScope: 'official' | 'own_bot';
routeId: string;
providerChatIdHash: string;
providerMessageThreadId: number | null;
internalMessageId: string;
internalPayloadHash: string;
visibilityDecisionId: string;
status: MessengerProviderDeliveryStatus;
providerMessageId: number | null;
replyToProviderMessageId: number | null;
attempts: number;
nextAttemptAt: string | null;
ambiguousSince: string | null;
lastErrorCode: string | null;
lastErrorMessageRedacted: string | null;
createdAt: string;
updatedAt: string;
sentAt: string | null;
};
```
Idempotency key should be deterministic:
```text
sha256(provider + routeId + internalMessageId + normalizedTextHash + deliveryKind)
```
Payload hash prevents accidental reuse:
```text
same idempotencyKey + different payloadHash -> conflict, terminal
```
When `sent`:
```text
create ProviderMessageLink:
providerMessageId -> internalMessageId
```
When `send_ambiguous`:
```text
do not create ProviderMessageLink
show warning in connector status
allow manual "send again anyway" or "mark as sent" if future support flow exists
```
## 7. Send State Machine
Safe provider send state machine:
```text
pending
-> send_in_flight
-> sent
pending
-> failed_retryable_before_send
-> pending
send_in_flight
-> rate_limited
-> pending at retry_after
send_in_flight
-> send_ambiguous
send_in_flight
-> failed_terminal
```
Retryable before-send examples:
- route temporarily locked;
- local rate limiter says wait;
- backend/desktop connection unavailable before calling Telegram;
- provider adapter rejected validation before network send.
Ambiguous examples:
- request body was handed to HTTP client and connection timed out;
- process crashed after starting `sendMessage`;
- backend sent inline webhook response with method payload and needs the provider result;
- connection reset after partial response;
- app received malformed response after Telegram may have accepted request.
Terminal examples:
- blocked by user;
- chat not found;
- topic missing and repair is required;
- message text empty after sanitization;
- payload too long and split policy disabled;
- route disabled;
- payload hash conflict.
## 8. Do Not Use Inline Webhook Response For Outbound Replies
Telegram allows calling a Bot API method by returning it in the webhook response. This is tempting for fast replies.
Do not use it for messenger outbound replies.
Reason:
```text
Telegram says we cannot know if the inline method succeeded or get its result.
Without the returned Message, we cannot store providerMessageId.
Without providerMessageId, reply-to teammate routing becomes weaker.
```
Use normal Bot API calls for outbound messages:
```text
POST /bot<TOKEN>/sendMessage
await result
persist provider message id
then mark sent
```
Inline webhook response is acceptable only for non-critical throwaway notices where no future reply routing is needed.
## 9. Rate Limiting
Telegram FAQ warns to avoid more than one message per second in a single chat.
For one-topic-per-team inside one private chat, the chat-level limiter matters more than topic-level limiter:
```text
same Telegram private chat
many team topics
many team replies
one chat-level provider limit
```
Add provider route limiter:
```text
global bot limiter
per chat limiter
per route/topic limiter
```
MVP values:
```text
per chat: 1 message per second steady
per route/topic: 1 message per second steady
burst: small queue, for example 3 messages
queue overflow: collapse or mark delayed
```
Avoid splitting a single long answer into many Telegram messages unless necessary. If splitting is needed because text exceeds Telegram limit, send chunks under one ledger group and be careful:
```text
part 1 sent
part 2 ambiguous
part 3 pending
```
Multi-part provider delivery needs a group ledger, so MVP should keep replies concise and reject/trim with clear policy before adding splitting.
## 10. Text Sanitization And Formatting
Outbound text pipeline:
```text
raw local message
strip agent-only blocks
strip teammate protocol blocks if present
reject JSON noise
normalize whitespace
prefix with team/member context
enforce max length
send plain text or Telegram entities
```
Avoid parse modes in MVP:
```text
send plain text
do not use MarkdownV2 until escaping is proven
```
Reason:
- model output can contain arbitrary punctuation;
- MarkdownV2 escaping is brittle;
- malformed formatting can fail provider send;
- provider failure after partial route logic increases ambiguity.
Use explicit Telegram entities later if rich formatting is necessary.
## 11. Reply-To Mapping
When sending a provider reply, use `reply_parameters` if we are replying to a known inbound provider message:
```text
reply_to_provider_message_id = inbound Telegram message id
message_thread_id = team topic id
```
But do not depend only on Telegram reply UI.
Also store:
```text
ProviderMessageLink(providerMessageId -> internalMessageId)
```
Then future user replies can route:
```text
reply_to_message.message_id
-> ProviderMessageLink
-> internal from member
-> route to that teammate
```
If provider send succeeds but link persistence fails:
```text
send was externally visible
do not retry send
mark provider link missing
schedule repair if possible
```
This should become `sent_link_missing`, or `sent` with diagnostics. It is not a send failure.
## 12. Local Store Is Not Enough
`sentMessages.json` is capped at 200 rows. This is fine for a UI feed but not for provider reply-to history.
Provider message links need their own retention policy:
```text
keep links for active route history window
minimum 90 days or until route deletion by user
prune only with route-level retention
never prune solely because local sentMessages hit 200 rows
```
If links are pruned:
- future replies to old Telegram messages route to lead;
- UI should show "old reply target not available";
- do not guess teammate from display prefix.
## 13. Deletion And Edits
MVP can ignore edits and deletions mostly, but not silently:
Inbound Telegram edited messages:
- do not mutate already delivered internal turns in MVP;
- create an edit event or ignore with diagnostics;
- if edited before desktop acceptance, process latest only if ingress design supports it.
Outbound local message edits:
- do not edit Telegram messages in MVP;
- send corrections as new messages only on explicit action.
Telegram delete:
- if provider message deleted, later reply-to links may break;
- keep link but mark stale when detected by send/reply errors.
This avoids complicated bidirectional sync in v1.
## 14. Failure Matrix
Critical cases:
- Local lead thought appears with no `to`.
- Do not send.
- Lead uses `SendMessage(to="user")` answering a Telegram-origin message.
- Eligible, send to that route.
- Teammate uses `SendMessage(to="user")` answering a Telegram-origin message.
- Eligible, send to same team topic with teammate prefix.
- Teammate sends to lead.
- Not eligible.
- Message contains only `<info_for_agent>`.
- Strip to empty, not eligible.
- Message contains teammate XML blocks.
- Strip/block by protocol-noise policy.
- Slash command output row appears.
- Not eligible by default.
- Provider route disabled after local reply was generated.
- Mark terminal or cancelled, do not send.
- Topic route repair-required.
- Do not fallback to general chat.
- Telegram returns 429 with retry_after.
- Mark rate_limited, schedule retry after given time.
- HTTP timeout after request sent.
- Mark send_ambiguous, do not auto-retry.
- HTTP timeout before request body leaves process.
- If adapter can prove no send, mark failed_retryable_before_send.
- Telegram returns success but local link write fails.
- Do not retry provider send, repair link.
- Duplicate local message event.
- Ledger idempotency key returns existing provider status.
- Same idempotency key with different text.
- Payload conflict, terminal.
- App restarts with `send_in_flight`.
- Convert to send_ambiguous unless adapter has proof.
- Provider message link pruned.
- Future reply falls back to lead with stale target metadata.
## 15. Top 3 Options
### Option 1 - Strict outbound projection gate + provider delivery ledger
🎯 9 🛡️ 9 🧠 7
Approx changed LOC: 2500-5500.
What it means:
- build `MessengerOutboundProjectionGate`;
- build `MessengerProviderDeliveryLedger`;
- auto-send only explicit `to=user` replies linked to a provider route;
- use Telegram normal API calls, not inline webhook response, for routable replies;
- mark network unknowns as `send_ambiguous`, not retryable;
- store `ProviderMessageLink` after success.
Why this is best:
- prevents internal-message leakage;
- avoids unsafe Telegram duplicates;
- supports teammate messages to user;
- gives reply-to routing a durable provider message id;
- matches the feature architecture standard.
Risk:
- more code;
- some ambiguous sends need user-visible diagnostics;
- initial behavior may feel conservative.
### Option 2 - Reuse `sentMessages.json` as outbound queue with simple dedupe
🎯 5 🛡️ 5 🧠 4
Approx changed LOC: 800-1800.
What it means:
- watch `sentMessages.json`;
- send any new `to=user` message to Telegram;
- store last sent internal message ids.
Why it is tempting:
- quick demo;
- current system already writes user-directed lead messages there;
- easy to observe from renderer.
Why it is risky:
- `sentMessages.json` is capped at 200;
- it is not route-specific;
- not all `to=user` messages should go to Telegram;
- provider send ambiguity is not represented;
- reply-to provider ids need another store anyway.
### Option 3 - Send all visible feed messages with broad filters
🎯 3 🛡️ 3 🧠 3
Approx changed LOC: 500-1400.
What it means:
- use `MessagesPanel`/feed projection;
- filter obvious noise;
- push visible items to Telegram.
Why it is bad:
- visibility in app is not external eligibility;
- feed contains lead thoughts, slash results, diagnostics, and UI-specific projections;
- dedupe is feed-oriented, not provider-send oriented;
- provider reply-to routing remains fragile.
This should not be used beyond a throwaway prototype.
## 16. Decision Update
Recommended model:
```text
Inbound Telegram turn creates route-linked internal message.
Agent/team responses become local messages as today.
MessengerOutboundProjectionGate observes durable local messages.
Only explicit user-directed, route-linked replies become provider send intents.
MessengerProviderDeliveryLedger handles Telegram send state.
ProviderMessageLink stores successful Telegram message ids.
Future reply-to routing uses ProviderMessageLink.
```
Minimal eligibility formula:
```text
eligible =
route.active
&& message.to == "user"
&& message.from is active member
&& origin/reply context links message to provider route
&& message not already delivered to provider
&& sanitized text non-empty
&& message kind/source allowed by policy
```
Important product behavior:
```text
Teammate messages to user are sent to Telegram.
Teammate messages to lead or other teammates are not sent.
Lead thoughts without explicit to=user are not sent.
```
## 17. Tests To Write First
Domain tests:
- `to=user` lead reply linked to provider route is eligible.
- `to=user` teammate reply linked to provider route is eligible.
- `to=user` local-only reply without route link is not eligible.
- `to=lead` teammate message is not eligible.
- lead session thought without `to` is not eligible.
- slash command result is not eligible by default.
- agent-only block strips to empty and is not eligible.
- JSON noise is not eligible.
- provider route disabled blocks eligibility.
- same internal message maps to same provider idempotency key.
- same idempotency key with changed payload is conflict.
Provider ledger tests:
- pending -> send_in_flight -> sent creates provider link.
- pre-send validation failure is retryable.
- 429 response stores retry_after and schedules retry.
- HTTP timeout after request started becomes send_ambiguous.
- restart with send_in_flight becomes send_ambiguous.
- duplicate local event returns existing sent/ambiguous state.
- success with provider link write failure does not retry provider send.
Adapter tests:
- Telegram send uses `message_thread_id`.
- Telegram send uses `reply_parameters` when inbound provider message id is known.
- Telegram send does not use webhook inline response for routable replies.
- long text is rejected or handled by explicit split policy.
- parse mode is omitted in MVP.
Renderer tests:
- connector status shows ambiguous provider sends.
- ambiguous send has manual resolution affordance.
- user can see why a local reply was not sent to Telegram.
- teammate prefix renders in Telegram projection preview.
## 18. Remaining Low-Confidence Areas
Still worth deeper research next:
- exact local event source for teammate `SendMessage(to="user")` across all supported runtimes, not just OpenCode;
- whether legacy Claude lead-session plain text should ever auto-send to Telegram or always require explicit SendMessage;
- how to migrate old `sentMessages.json` rows into provider delivery state without accidental sends;
- how to model manual "send again anyway" for `send_ambiguous` without hiding duplicate risk;
- whether `sendMessageDraft` can safely show typing/progress in a topic without confusing delivery state;
- exact Telegram error taxonomy for deleted private topic, blocked bot, and migrated chats in Bot API responses;
- retention policy for `ProviderMessageLink` under privacy delete/export requirements.

View file

@ -0,0 +1,901 @@
# Messenger Connectors - Uncertainty Pass 33
Date: 2026-04-29
Scope: Telegram account binding, connect wizard authorization, official shared bot vs own bot privacy, route ownership, revocation, and anti-hijack rules
## Executive Delta
The next lowest-confidence area is not the Telegram topic API.
It is the authorization boundary:
```text
desktop install
-> pending Telegram binding
-> Telegram user/chat identity
-> active team route
-> provider topic creation
-> future inbound/outbound permission
```
If this is wrong, the feature can look correct but still have severe bugs:
```text
1. A forwarded /start link binds the wrong Telegram account.
2. A stale pairing code reactivates an old route.
3. A username change breaks identity or routes to the wrong person.
4. A copied desktop config gives another OS user access to a Telegram route.
5. A backend log leaks chat ids, start payloads, or own-bot tokens.
6. A route is activated before the desktop confirms the Telegram claim.
```
The recommended shape is:
```text
Desktop creates one-time pairing challenge
-> user opens t.me/our_bot?start=<nonce>
-> backend records Telegram claim
-> desktop shows "Telegram account X wants to connect"
-> user confirms in desktop
-> route becomes active
-> team topics are created or reconciled
```
Do not treat Telegram `/start <payload>` alone as authorization. It proves that the message came from some Telegram account through Telegram, but it does not prove that the account is the same human currently controlling the desktop app.
## Source Facts Rechecked
Telegram official facts checked on 2026-04-29:
- Deep links let bots receive a `start` parameter in private chats. The parameter can use `A-Z`, `a-z`, `0-9`, `_`, `-`; Telegram recommends base64url, and the parameter can be up to 64 characters.
- Bot links have the shape `https://t.me/<bot_username>?start=<parameter>`.
- Bot API `Message` has `chat`, optional `from`, optional `message_thread_id`, and `is_topic_message` for forum supergroups or private chats with the bot.
- Bot API `User.id` is the stable identifier. It may exceed 32 bits but has at most 52 significant bits. `username` is optional and must not be the primary identity.
- Bot API `Chat.id` has the same 52-bit warning. Store it as string or signed 64-bit safe numeric representation, not as a JS lossy number in persistence boundaries.
- Bot API `setWebhook.secret_token` causes Telegram to send `X-Telegram-Bot-Api-Secret-Token` on webhook requests. This verifies the webhook was set by us, not user identity.
- Bot API 9.6, April 3, 2026, added Managed Bots. The created managed bot token can be fetched using `getManagedBotToken`. This means Managed Bots do not provide a "token hidden from manager bot/backend" privacy story if our bot/backend is the manager.
- Telegram Mini Apps/Login-style data can be validated through HMAC with the bot token, and newer third-party validation can use Telegram Ed25519 signatures. This is useful for a web identity step, but it is more product/backend complexity than the default bot chat wizard needs.
Sources:
- https://core.telegram.org/bots/features#deep-linking
- https://core.telegram.org/api/links#bot-links
- https://core.telegram.org/bots/api#message
- https://core.telegram.org/bots/api#user
- https://core.telegram.org/bots/api#setwebhook
- https://core.telegram.org/bots/api#recent-changes
- https://core.telegram.org/bots/api#managedbotcreated
- https://core.telegram.org/bots/api#keyboardbuttonrequestmanagedbot
- https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
Local code facts checked:
- `docs/FEATURE_ARCHITECTURE_STANDARD.md` says medium/large cross-process features should live in a full feature slice with `contracts`, `core/domain`, `core/application`, `main`, `preload`, and `renderer`.
- No obvious existing install-id or messenger-binding model was found in local searches.
- `ConfigManager` persists app config at `~/.claude/agent-teams-config.json`.
- `getAppDataPath()` returns app-owned data under Electron `userData` or a fallback app data directory, explicitly separate from `~/.claude`.
- `ApiKeyService` already has a useful encrypted-secret pattern: Electron `safeStorage` first, AES-256-GCM local fallback, file mode `0o600`, and masked list output. This is relevant for optional own-bot token storage.
- Current inbox architecture is based on `~/.claude/teams/{teamName}/inboxes/{memberName}.json`, with known race handling and message ids from earlier research.
Implication:
```text
Messenger connectors need their own binding/security sub-slice.
This should not be bolted onto Settings config as plain fields.
```
## Top 3 Binding Options
### 1. Desktop-originated deep link plus desktop confirmation
🎯 9 🛡️ 9 🧠 6 Approx change size: 2500-5500 LOC
Flow:
```text
1. Desktop generates an install identity and opens a connector setup session.
2. Desktop asks official backend for a one-time pairing challenge.
3. Backend stores only a challenge hash, selected capabilities, TTL, and desktop session id.
4. Desktop shows QR/link: https://t.me/our_bot?start=<nonce>
5. User opens link in Telegram.
6. Official bot receives /start <nonce>.
7. Backend validates nonce, marks challenge as telegram_claimed, records Telegram user/chat identity.
8. Backend pushes "claim received" to desktop control channel.
9. Desktop shows Telegram profile preview and asks for explicit confirm.
10. Only after confirm, backend activates binding and the desktop creates/reconciles team routes/topics.
```
Why this is best:
- The `/start` link is convenient.
- A stolen link is not enough because the desktop still must confirm the exact Telegram account claim.
- The route cannot become active while the user is away from desktop setup.
- It fits official shared bot default.
- It can reuse the same route model for own-bot later.
Main weaknesses:
- Requires a live desktop to complete binding.
- Requires a backend control channel for official bot mode.
- Backend will know Telegram chat id for official shared bot routing. This can be minimized and encrypted at rest, but not eliminated if backend sends messages through the shared bot.
Verdict:
```text
Use as default MVP wizard.
```
### 2. Bot-first short code entered into desktop
🎯 8 🛡️ 8 🧠 5 Approx change size: 1800-4000 LOC
Flow:
```text
1. User opens our bot manually or from a generic link.
2. Bot creates a short visible code for that Telegram chat.
3. User enters or pastes the code into desktop.
4. Desktop sends the code to backend through its authenticated setup session.
5. Backend matches Telegram claim with desktop session.
6. Desktop confirms and activates binding.
```
Why it is useful:
- Works when deep links are blocked, copied incorrectly, or opened on the wrong device.
- The Telegram chat is already known before desktop confirmation.
- Good fallback for enterprise environments where QR/deep link is unreliable.
Main weaknesses:
- More user effort.
- Short visible codes need strict TTL, rate limits, and replay protection.
- If the user pastes code into the wrong desktop install, desktop confirmation still protects against silent activation, but UX can be confusing.
Verdict:
```text
Keep as fallback, not the primary happy path.
```
### 3. Telegram Mini App or Login Widget based verification
🎯 7 🛡️ 8 🧠 8 Approx change size: 3500-7500 LOC
Flow:
```text
1. User opens a Telegram Mini App or Login Widget.
2. Web identity data is validated using Telegram HMAC or Ed25519 validation.
3. Backend links that verified Telegram identity to the user's app account or desktop setup session.
4. Bot chat binding is completed after confirmation.
```
Why it is attractive:
- Strong web identity story.
- Better if Agent Teams later has real cloud accounts, team membership, device management, and web admin.
- Can support "manage all connected Telegram devices" in a richer UI.
Main weaknesses:
- Too much product surface for MVP.
- Needs domain setup, web identity screens, auth expiry rules, and account/device policy.
- Still does not remove the need to bind a bot chat/topic route for messaging.
Verdict:
```text
Good later for cloud account management.
Do not use as default MVP unless Agent Teams already depends on cloud login.
```
## Explicitly Rejected Option
### `/start` link alone activates the route
🎯 4 🛡️ 4 🧠 3 Approx change size: 900-2000 LOC
This is easy, but unsafe.
Failure case:
```text
1. Desktop shows a setup QR.
2. User screenshots or forwards it.
3. Another Telegram account opens it first.
4. Backend binds that chat to the user's teams.
5. The wrong Telegram account receives team replies.
```
This option can be patched with TTL and rate limits, but it still has the wrong trust boundary.
## Recommended Binding State Machine
```text
unbound
-> desktop_pending
-> telegram_claimed
-> desktop_confirmed
-> active
-> revoked
```
Terminal or side states:
```text
expired
cancelled
suspicious
conflict
provider_unavailable
desktop_offline
```
Rules:
- `desktop_pending`: challenge exists, but no Telegram user is associated yet.
- `telegram_claimed`: Telegram user/chat has sent the nonce, but no route is active yet.
- `desktop_confirmed`: user explicitly accepted the claim in desktop.
- `active`: route may receive inbound Telegram messages and send outbound replies.
- `expired`: TTL elapsed before confirmation. The `/start` payload must become useless.
- `cancelled`: desktop cancelled setup. Later Telegram updates with that nonce get a generic expired response.
- `suspicious`: multiple different Telegram users tried the same nonce, too many attempts, or mismatch with an already active binding.
- `conflict`: same Telegram account/chat is already bound in a way that conflicts with the selected route policy.
- `revoked`: route exists historically but is not allowed to deliver.
Important invariant:
```text
No MessengerRoute can become active unless a Telegram claim and a desktop confirmation refer to the same pairing challenge id.
```
## Pairing Challenge Shape
Provider-neutral domain model:
```ts
interface MessengerPairingChallenge {
id: string;
provider: 'telegram';
mode: 'official-shared-bot' | 'own-bot';
installId: string;
desktopSessionId: string;
challengeHash: string;
challengeCreatedAt: string;
challengeExpiresAt: string;
state:
| 'desktop_pending'
| 'telegram_claimed'
| 'desktop_confirmed'
| 'active'
| 'expired'
| 'cancelled'
| 'suspicious'
| 'conflict'
| 'revoked';
claimedBy?: {
providerUserIdHash: string;
providerChatIdHash: string;
displayNameSnapshot: string;
usernameSnapshot?: string;
claimedAt: string;
};
capabilities: {
canReceiveTeamTopics: boolean;
canSendExternalUserMessages: boolean;
canIssueCommands: boolean;
};
}
```
Nonce rules:
- Generate at least 128 bits of randomness.
- Encode base64url without padding.
- Stay under Telegram's 64-character `start` limit.
- Store only a keyed hash server-side, not the raw nonce.
- TTL should be 5-10 minutes.
- Single use after `telegram_claimed`, with idempotent handling for duplicate update delivery.
- Never log raw nonce.
## Identity Model
Provider-neutral route ownership:
```ts
interface MessengerAccountBinding {
id: string;
provider: 'telegram';
mode: 'official-shared-bot' | 'own-bot';
installId: string;
providerAccountRef: {
userIdHash: string;
chatIdHash: string;
rawChatIdStorageRef?: string;
};
displaySnapshot: {
firstName?: string;
lastName?: string;
username?: string;
languageCode?: string;
};
status: 'active' | 'revoked' | 'disabled' | 'provider_blocked_bot';
createdAt: string;
confirmedAt: string;
lastSeenAt?: string;
revokedAt?: string;
}
```
Identity rules:
- Telegram `user.id` is identity.
- Telegram `chat.id` is delivery destination.
- Telegram `username` is display metadata only.
- Store ids as strings at persistence/API boundaries to avoid JS precision mistakes.
- Hash ids for logs and list views.
- For official shared bot, backend needs a usable chat id at send time. Use KMS/envelope encryption at rest and redact logs. Do not pretend the backend has zero access.
- For own-bot local mode, raw bot token and chat ids can stay local. This is the cleanest privacy story.
## Official Shared Bot Privacy Story
What is true:
```text
Our backend receives Telegram webhook updates.
Our backend sees enough Telegram identity to route the message.
Our backend needs enough delivery identity to call sendMessage through our shared bot.
```
What we can do:
```text
1. No durable plaintext message queue while desktop is offline.
2. Encrypt chat ids at rest.
3. Hash ids in logs and analytics.
4. Store minimal Telegram profile snapshots.
5. Keep message bodies out of backend durable storage in default mode.
6. If desktop is offline, send a clear offline notice instead of queueing plaintext.
```
What we cannot honestly claim:
```text
The official shared bot backend never sees Telegram metadata.
```
Recommended copy:
```text
Default bot is easiest: messages pass through Agent Teams relay while your desktop is online.
We do not store message bodies in the default relay queue.
For maximum privacy, connect your own bot locally.
```
## Managed Bots Privacy Recheck
Managed Bots are useful, but not for "token invisible to us" if our bot/backend is the manager.
Official docs say:
```text
ManagedBotCreated.bot token can be fetched using getManagedBotToken.
ManagedBotUpdated.bot token can be fetched using getManagedBotToken.
```
So the manager bot can fetch the created bot token.
This means:
```text
If our backend runs the manager bot, our backend can technically get the managed bot token.
```
Managed Bots can still be useful for convenience:
- Less copy/paste from BotFather.
- Better guided creation.
- Automatic suggested name/username.
- Token rotation through `replaceManagedBotToken`.
But the privacy label should be:
```text
Convenient customer-owned bot, managed by Agent Teams
```
not:
```text
Private token that Agent Teams cannot access
```
For the clean privacy option, user should create a bot in BotFather and paste token into desktop locally, or use a future flow where a locally running manager process receives the token directly and never sends it to our backend. That local-manager flow is probably too complex for MVP.
## Own-Bot Binding Flow
Own-bot mode still needs a Telegram account/chat binding.
Recommended own-bot flow:
```text
1. User creates bot in BotFather.
2. User pastes token into desktop.
3. Desktop validates getMe.
4. Desktop stores token using a SecretStoragePort based on ApiKeyService-style safeStorage/AES fallback.
5. Desktop checks getWebhookInfo.
6. If webhook exists, explain conflict and ask before deleteWebhook.
7. Desktop starts getUpdates long polling.
8. User sends /start to their own bot.
9. Desktop receives the update locally.
10. Desktop asks user to confirm the Telegram account/chat.
11. Desktop activates binding and creates topics/routes.
```
Edge case:
```text
getUpdates does not work while an outgoing webhook is set.
```
So never silently call `deleteWebhook` for an own bot. The bot may be used elsewhere.
## Route Activation Rules
After binding, route creation should be explicit:
```ts
interface MessengerRoute {
id: string;
bindingId: string;
provider: 'telegram';
teamId: string;
teamIdentitySnapshot: {
teamName: string;
teamPath?: string;
teamConfigHash?: string;
};
topicRef?: {
providerChatIdHash: string;
providerMessageThreadId: string;
topicNameSnapshot: string;
topicCreatedAt: string;
};
status: 'active' | 'disabled' | 'revoked' | 'needs_repair';
createdAt: string;
updatedAt: string;
}
```
Rules:
- Binding is account-level.
- Route is team-level.
- Topic is provider-level delivery state.
- One Telegram account can bind to multiple teams.
- One team route maps to one Telegram topic in that account's bot chat.
- Topic title is display metadata only. Never route by title.
- If topic id is missing or stale, mark `needs_repair` and create a new topic after user confirmation.
## Multi-Team and Multi-Account Policy
MVP policy:
```text
One Telegram account binding per desktop install.
Many team routes under that binding.
One topic per team route.
```
Later policy:
```text
Multiple Telegram accounts per install.
Each account can opt into selected teams.
Routes must include bindingId.
UI can show "Connected as @alice" per route.
```
Do not key route ownership only by `teamName`.
Use a stable team id or derived identity:
```text
teamId = persisted id if available
fallback = hash(canonical team path + creation marker)
teamName = mutable display snapshot
```
This is important because previous local research found many surfaces still use names like `teamName` and `memberName`.
## Threat Model and Required Controls
### Forwarded setup link or screenshot
Control:
```text
Desktop confirmation is mandatory.
```
The Telegram claim only moves challenge to `telegram_claimed`.
### Stale or replayed nonce
Controls:
```text
TTL 5-10 minutes
single-use challenge hash
state transition compare-and-swap
idempotent duplicate update handling
generic expired response
```
### Two Telegram users race the same nonce
Control:
```text
First claim locks the challenge.
Second distinct user marks suspicious or gets generic expired response.
Desktop must show the first claimed display name before confirm.
```
### Username changed
Control:
```text
Never use username for identity.
Update display snapshot from new messages.
```
### Wrong chat type
Control:
```text
Official MVP accepts only private chat with the bot.
Group/supergroup/channel starts are rejected unless a future group-mode route is explicitly built.
```
### Telegram user blocks bot
Control:
```text
Outbound send failure transitions binding or route to provider_blocked_bot / needs_attention.
Do not keep retrying indefinitely.
```
### Desktop offline after binding
Control:
```text
Default official mode has no durable plaintext backend queue.
Backend replies with offline notice or "desktop unavailable".
```
### Backend receives duplicate Telegram updates
Control:
```text
ProviderUpdateLedger keyed by provider + botMode + update_id.
Idempotent inbound message creation.
```
### Backend restart during claimed-but-unconfirmed pairing
Control:
```text
Persist pending challenge state with TTL.
Desktop reconnect asks for current challenge status.
```
### User reinstalls desktop
Control:
```text
Install identity is local.
If lost, existing bindings become orphaned until user reconnects.
Offer revoke from Telegram with /disconnect.
```
### Shared computer or copied config
Control:
```text
Store install secret under app data using OS secret storage where possible.
Copying JSON config alone should not authenticate a binding.
```
### Own-bot token leaked
Controls:
```text
SafeStorage/AES fallback
0o600 file permissions
masked list output
redacted logs
explicit token rotation and delete
```
### Managed bot token fetched by our backend
Control:
```text
Do not market Managed Bots as token-private.
Offer "own token locally" for maximum privacy.
```
## Security Storage Recommendation
Create feature-local ports:
```ts
interface MessengerInstallIdentityStore {
getOrCreateInstallIdentity(): Promise<MessengerInstallIdentity>;
rotateInstallSecret(reason: string): Promise<void>;
}
interface MessengerSecretStore {
saveSecret(ref: string, plaintext: string): Promise<void>;
readSecret(ref: string): Promise<string | null>;
deleteSecret(ref: string): Promise<void>;
getStatus(): Promise<SecretStorageStatus>;
}
```
Implementation:
- For local desktop, adapt the existing `ApiKeyService` encryption strategy.
- Do not import `ApiKeyService` directly into core.
- Keep plaintext secrets out of renderer contracts.
- Renderer gets masked status only.
- Main process owns token validation, storage, polling, and provider calls.
Storage location:
```text
App-owned data under getAppDataPath(), not ~/.claude/teams.
```
Reason:
```text
Messenger bindings are app integration state, not agent CLI/team project data.
```
## Backend Data Minimization for Official Bot
Backend tables should separate routing metadata from message payloads.
Minimum default mode:
```text
messenger_bindings
binding_id
provider
install_id_hash
telegram_user_id_hmac
telegram_chat_id_ciphertext
display_snapshot
status
created_at
confirmed_at
messenger_routes
route_id
binding_id
team_id_hash
provider_thread_id
status
created_at
updated_at
telegram_update_ledger
bot_mode
update_id
update_type
processed_at
result_kind
```
Avoid in default mode:
```text
durable plaintext inbound bodies
durable plaintext outbound bodies
raw Telegram ids in logs
raw start payloads in logs
own-bot tokens on backend
```
If we later add encrypted queue:
```text
desktop public key
backend stores ciphertext only
desktop decrypts when online
outbound offline queue requires explicit user opt-in
```
## Connect Wizard UX
Recommended happy path:
```text
Settings -> Messenger -> Telegram -> Connect
Step 1: Choose mode
Default: Agent Teams bot
Advanced: My own bot
Step 2: Select teams
All active teams by default, editable checklist
Step 3: Open Telegram
QR + button, expires countdown
Step 4: Confirm
"Telegram account @alice wants to connect"
show first name, username, provider user id suffix/hash
Step 5: Topics
create one topic per selected team
show per-team success/needs repair
```
Failure UI:
- Link expired: one-click regenerate.
- Wrong Telegram account claimed: cancel and regenerate.
- Desktop offline during claim: bot says "finish setup on desktop".
- Topic creation failed: binding can still be active, route is `needs_repair`.
- Bot blocked: show reconnect instructions.
No hidden auto-activation.
## Clean Architecture Placement
Feature slice:
```text
src/features/messenger-connectors/
contracts/
index.ts
messengerConnectorApi.ts
telegramDtos.ts
core/
domain/
bindingState.ts
pairingChallenge.ts
routePolicy.ts
providerIdentity.ts
visibilityPolicy.ts
application/
ports/
MessengerBindingStore.ts
MessengerSecretStore.ts
MessengerProviderGateway.ts
MessengerDesktopSessionGateway.ts
StartPairingUseCase.ts
ClaimPairingUseCase.ts
ConfirmPairingUseCase.ts
RevokeBindingUseCase.ts
RepairRoutesUseCase.ts
main/
composition/
adapters/
input/
messengerIpcHandlers.ts
telegramWebhookRoutes.ts
output/
TelegramOfficialBotGateway.ts
TelegramOwnBotGateway.ts
FileMessengerBindingStore.ts
ElectronMessengerSecretStore.ts
infrastructure/
telegram/
storage/
crypto/
preload/
renderer/
```
Important dependency rule:
```text
Telegram API specifics are adapter details.
Binding state, route state, replay prevention, and privacy policy are core/application rules.
```
## Tests To Add Before Shipping
Domain/application:
- `startPairing` creates a challenge with TTL and hashed nonce.
- `claimPairing` rejects unknown nonce.
- `claimPairing` rejects expired nonce.
- `claimPairing` is idempotent for duplicate same Telegram update.
- `claimPairing` marks suspicious for a different user racing the same nonce.
- `confirmPairing` fails if no Telegram claim exists.
- `confirmPairing` activates only the claimed binding.
- `cancelPairing` prevents later activation.
- `revokeBinding` disables routes.
- `routePolicy` never keys by username or topic title.
Adapter/integration:
- Telegram webhook verifies `secret_token`.
- Telegram update ledger dedupes `update_id`.
- `/start` in group/supergroup is rejected in MVP.
- JS persistence stores Telegram ids as strings.
- Raw nonce is not logged.
- Raw own-bot token is not sent to renderer.
- Own-bot `getWebhookInfo.url` conflict is surfaced before `deleteWebhook`.
- `safeStorage` unavailable path still encrypts with AES fallback and file mode is restrictive.
End-to-end scenarios:
- Happy path official bot connect.
- Forwarded link claimed by wrong Telegram account, desktop cancels, no route active.
- Link expires, user regenerates, old link stays dead.
- Two users race same link.
- Desktop restarts after Telegram claim and before confirm.
- User blocks bot after binding.
- User revokes binding from desktop.
- User sends `/disconnect` in Telegram.
- Team renamed after topic exists.
- Team route repair creates new topic without reusing title as identity.
## Decision Update
The best implementation decision after this pass:
```text
Default:
official shared bot
desktop-originated one-time deep link
desktop confirmation required
no durable plaintext backend message queue
one topic per team route
Fallback:
bot-first short code entry
Advanced privacy:
own bot token pasted into desktop
token stored locally
local getUpdates polling
Later:
encrypted backend queue
Telegram Mini App/Login identity layer
Managed Bots only as convenience, not as "token inaccessible to us"
```
Main open question left:
```text
Do we want one Telegram account per desktop install for MVP,
or allow multiple connected Telegram accounts immediately?
```
My recommendation:
🎯 9 🛡️ 8 🧠 4 Approx change size: +600-1200 LOC compared to account-agnostic routing
```text
Start with one account per install, but include bindingId in every route model.
That keeps MVP UX simple and leaves the data model ready for multi-account later.
```

View file

@ -0,0 +1,897 @@
# Messenger Connectors - Uncertainty Pass 34
Date: 2026-04-29
Scope: official shared bot relay transport, webhook ACK semantics, desktop online detection, no durable plaintext backend queue, and local commit guarantees
## Executive Delta
The weakest reliability boundary is:
```text
Telegram webhook
-> Agent Teams backend
-> online desktop relay session
-> durable local inbound message
-> lead/team routing
```
The core problem:
```text
If backend returns HTTP 2xx to Telegram before the desktop durably commits the message,
then a crash, reconnect, or dropped ACK can lose the user message forever.
```
Because the default product decision is "no durable plaintext backend queue", the backend cannot solve this by storing pending message bodies until desktop returns.
So the default official-bot rule should be:
```text
Return success to Telegram only after one of these is true:
1. Desktop ACKed that it durably committed the inbound message locally.
2. Backend handled the update terminally, for example no desktop is online and an offline notice was sent.
3. Backend intentionally rejects the webhook attempt so Telegram retries later.
```
This is the exact bridge that must be designed as a protocol, not as a best-effort event bus.
## Source Facts Rechecked
Telegram official facts checked on 2026-04-29:
- Bot API has two mutually exclusive update delivery modes: `getUpdates` and webhooks.
- Incoming updates are stored on Telegram servers until the bot receives them, but not longer than 24 hours.
- `getUpdates.offset` confirms updates when the offset is greater than their `update_id`.
- `Update.update_id` is useful for ignoring repeated updates or restoring sequence if webhook updates are out of order.
- `setWebhook.max_connections` controls the maximum simultaneous HTTPS connections Telegram may use for webhook delivery.
- On webhook delivery, unsuccessful requests are retried for a reasonable number of attempts.
- `WebhookInfo.pending_update_count`, `last_error_date`, and `last_error_message` expose webhook backlog/error state.
- `setWebhook.secret_token` adds `X-Telegram-Bot-Api-Secret-Token` to webhook requests.
Transport facts checked on 2026-04-29:
- Node.js v22 has a stable native WebSocket client API.
- Node.js v22 does not provide a built-in WebSocket server, so a Node backend still needs a server library.
- WebSocket is full-duplex over one connection, which fits `offer -> ACK -> control` flows.
- Server-Sent Events are one-way server-to-client. Client ACKs require a separate HTTP request.
- SSE supports `id` and reconnection behavior through EventSource, but it is still one-way.
- Existing repo already uses Fastify 5.7.4. `@fastify/websocket` 11.2.0 is the current npm package for WebSocket support and is built on `ws@8`.
- Snyk lists `ws@8.20.0` as published March 21, 2026, latest, with no direct vulnerabilities in its database at lookup time.
Sources:
- https://core.telegram.org/bots/api#getting-updates
- https://core.telegram.org/bots/api#update
- https://core.telegram.org/bots/api#setwebhook
- https://core.telegram.org/bots/api#getwebhookinfo
- https://nodejs.org/learn/getting-started/websocket
- https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
- https://www.npmjs.com/package/%40fastify/websocket
- https://security.snyk.io/package/npm/ws/8.20.0
Local code facts checked:
- Existing `HttpServer` is Fastify-based and binds to `127.0.0.1` by default for local app/browser API.
- Existing `src/main/http/events.ts` implements SSE for local UI clients, with keepalive comments every 30 seconds.
- That SSE stream has no durable event id/resume model and no client-to-server ACK path. It is fine for local UI refresh, not for Telegram relay commit.
- The repo already has good local durability patterns:
- `VersionedJsonStore.updateLocked()`
- `atomicWriteAsync`
- `withFileLock`
- runtime delivery journals with payload hash, pending/committed states, idempotency keys
- These patterns are directly relevant to the desktop-local inbound store and delivery ledger.
Implication:
```text
Do not reuse the existing local SSE event broadcaster as the official bot relay.
Build a dedicated MessengerRelay protocol.
```
## Top 3 Relay Architecture Options
### 1. Desktop outbound WebSocket with local-commit ACK
🎯 9 🛡️ 8 🧠 7 Approx change size: 4000-8500 LOC
Shape:
```text
desktop main process
opens WSS connection to Agent Teams relay backend
authenticates install/binding/session
sends route inventory hash and heartbeat
backend
receives Telegram webhook
resolves binding/route/topic
sends inbound offer over WebSocket
waits for desktop local-commit ACK
returns Telegram 2xx only after commit ACK
```
Why this is best:
- WebSocket is bidirectional, so `offer -> ack -> cancel -> repair -> heartbeat` stays on one connection.
- Desktop can use Node 22 native WebSocket client with no new desktop dependency.
- Backend can use Fastify + `@fastify/websocket` if cloud backend is Node/Fastify.
- It supports real online presence, route inventory sync, and backpressure.
- It avoids backend durable plaintext message bodies.
Weaknesses:
- Needs a real protocol, not just "send JSON over socket".
- Needs careful ACK timeout and reconnect behavior.
- Backend still holds plaintext in memory during the webhook attempt.
- Active-session ACK timeout is ambiguous: desktop might have committed but ACK was lost.
Verdict:
```text
Use this for default official shared bot.
```
### 2. SSE downlink plus HTTPS ACK uplink
🎯 7 🛡️ 7 🧠 6 Approx change size: 3500-7000 LOC
Shape:
```text
desktop opens EventSource/SSE to backend
backend pushes inbound offers over SSE
desktop POSTs /ack for local commit
desktop POSTs heartbeat/inventory separately
```
Why it is attractive:
- SSE is simple and HTTP-friendly.
- Browser/EventSource has reconnect behavior.
- This resembles the repo's local `/api/events` pattern.
Weaknesses:
- SSE is one-way, so ACKs and heartbeats need extra HTTP calls.
- Correlating SSE offer with POST ACK is more complex under reconnect.
- Existing local SSE implementation lacks durable event ids and Last-Event-ID resume.
- Browser SSE connection limits matter for renderer use. Desktop main process can avoid browser limits, but the protocol is still less direct than WebSocket.
Verdict:
```text
Acceptable fallback if WebSocket is blocked by enterprise proxies.
Not the primary implementation.
```
### 3. Desktop polling/long-polling relay
🎯 6 🛡️ 6 🧠 4 Approx change size: 2200-5000 LOC
Shape:
```text
desktop polls backend for pending inbound updates
backend returns message bodies if any
desktop commits locally and POSTs ACK
```
Why it is attractive:
- Easier to reason about than long-lived sockets.
- Works in many locked-down networks.
- Simple to implement initially.
Weaknesses:
- To avoid message loss, backend must hold pending plaintext while waiting for poll.
- If backend refuses durable plaintext queue, polling becomes either lossy or high-frequency.
- Latency is worse.
- Online/offline state becomes fuzzy.
Verdict:
```text
Not recommended for default no-plaintext-queue mode.
Can be a diagnostics fallback, not the main relay.
```
## Future Reliability Option
### Durable encrypted backend queue
🎯 8 🛡️ 9 🧠 9 Approx change size: 7000-14000 LOC
Shape:
```text
desktop publishes public encryption key during binding
backend stores only ciphertext message bodies
desktop decrypts when online
backend can survive restarts and desktop offline windows
```
This is the right advanced/premium reliability mode, but it is not the MVP default.
Why:
- Key rotation is non-trivial.
- Device loss/reinstall can make queued messages undecryptable.
- Multi-device routing becomes harder.
- Attachments need a separate encrypted blob policy.
- User copy must explain exactly who can decrypt what.
## Recommended Default Protocol
### High-level flow
```text
Telegram -> Backend webhook
1. verify webhook secret_token
2. dedupe update_id metadata
3. resolve binding/route/topic
4. check active desktop relay session
5. if no healthy session: send offline notice, return 2xx
6. if healthy session: offer update to desktop
7. desktop validates route and commits local inbound message
8. desktop ACKs local commit
9. backend returns 2xx to Telegram
```
The backend durable metadata ledger may store:
```text
provider
bot mode
update_id
route id
binding id
attempt count
status
timestamps
error class
payload hash
```
It should not store in default mode:
```text
raw message body
raw Telegram chat id in logs
raw Telegram user id in logs
attachment file bodies
bot tokens
```
### Webhook ACK invariant
```text
Telegram 2xx means:
Agent Teams either got the message durably into desktop local storage,
or intentionally terminal-handled it, for example offline notice.
```
Telegram non-2xx means:
```text
Agent Teams has not accepted responsibility for the update.
Telegram may retry the same update later.
```
This must be an explicit code invariant.
## Active Session Definition
Do not define "online" as "there is a socket object".
Define it as:
```ts
interface MessengerRelaySession {
sessionId: string;
installId: string;
bindingId: string;
authenticatedAt: string;
lastHeartbeatAt: string;
lastPongAt: string;
routeInventoryHash: string;
protocolVersion: number;
status: 'ready' | 'stale' | 'draining' | 'closed';
}
```
A session is healthy only if:
```text
status == ready
authenticated install secret is valid
binding is active
route inventory hash is current or compatible
last pong is recent
desktop protocol version is supported
no newer session has stolen the lease
```
Suggested timing:
```text
ping interval: 15s
stale after: 45s
hard close after: 75s
inbound offer ACK deadline: 3-8s
```
Use jitter for reconnect:
```text
initial reconnect: 1s
max reconnect: 30s
jitter: 20-40 percent
```
## Single Active Session Lease
MVP should allow one active relay session per binding.
Rule:
```text
New authenticated session for the same bindingId steals the lease.
Old session transitions to draining/closed and cannot ACK new offers.
```
Why:
- Prevents two desktop processes writing the same Telegram update to different local stores.
- Avoids split-brain if user launches two app instances.
- Keeps support/debugging simpler.
Later multi-device mode can use:
```text
bindingId + deviceId + route assignment
```
But do not start there.
## Inbound Offer Envelope
Provider-neutral envelope:
```ts
interface MessengerInboundOffer {
type: 'messenger.inbound.offer';
protocolVersion: 1;
deliveryId: string;
provider: 'telegram';
bindingId: string;
routeId: string;
orderingKey: string;
providerUpdateId: string;
providerMessageId?: string;
providerMessageThreadId?: string;
providerDate?: string;
receivedAt: string;
expiresAt: string;
payloadHash: string;
payload: MessengerInboundPayload;
}
```
Payload:
```ts
interface MessengerInboundPayload {
kind: 'text' | 'command' | 'unsupported';
text?: string;
replyTo?: ProviderMessageLink;
sender: {
providerUserIdHash: string;
displayNameSnapshot: string;
usernameSnapshot?: string;
};
}
```
Desktop ACK:
```ts
type MessengerInboundAck =
| {
type: 'messenger.inbound.ack';
deliveryId: string;
status: 'committed';
localMessageId: string;
localCommitHash: string;
committedAt: string;
}
| {
type: 'messenger.inbound.ack';
deliveryId: string;
status: 'duplicate_committed';
localMessageId: string;
committedAt: string;
}
| {
type: 'messenger.inbound.ack';
deliveryId: string;
status: 'rejected_terminal' | 'rejected_retryable';
reasonCode: string;
detail?: string;
};
```
## Desktop Local Commit Rules
Desktop must ACK `committed` only after:
```text
1. Provider update id was deduped locally.
2. Route binding is still active.
3. Message payload passed visibility/safety validation.
4. Message was written to a local durable inbound store.
5. Local store fsync/atomic-write equivalent completed as far as our platform layer supports.
```
Recommended stores:
```text
MessengerDesktopInboundStore
durable provider update payloads after acceptance
MessengerLocalDeliveryLedger
tracks delivery from inbound store to lead/team inbox
MessengerProviderUpdateLedger
dedupes providerUpdateId locally
```
Do not ACK based on:
```text
renderer state update
toast notification shown
in-memory queue push only
lead process prompt accepted but not persisted
```
## Backend Webhook Decision Matrix
### No active session
```text
send offline notice
return Telegram 2xx
record metadata: terminal_offline
```
This matches the current product decision:
```text
desktop offline -> no plaintext queue -> honest offline response
```
### Active session, offer ACKed committed
```text
return Telegram 2xx
record metadata: desktop_committed
```
### Active session, duplicate committed ACK
```text
return Telegram 2xx
record metadata: duplicate_desktop_committed
```
### Active session, terminal reject
Examples:
```text
route revoked
unknown topic
unsupported chat type
payload rejected by policy
```
Action:
```text
send user-facing rejection if useful
return Telegram 2xx
record metadata: terminal_rejected
```
### Active session, retryable reject
Examples:
```text
local store locked
team route repairing
desktop still loading route inventory
```
Action:
```text
return Telegram 503 for a bounded number of attempts or bounded age
then fall back to offline/degraded notice and 2xx
```
### Active session, no ACK before deadline
This is the hardest case.
Recommended:
```text
1. Mark metadata: ack_timeout_ambiguous.
2. Return Telegram 503 if within retry budget.
3. On retry, re-offer with same providerUpdateId and payloadHash.
4. Desktop must return duplicate_committed if it already wrote the message.
5. If retry budget expires, send "delivery uncertain/offline" notice and return 2xx.
```
Do not immediately send a definitive "not delivered" notice after an ACK timeout, because the desktop might have committed and the ACK may have been lost.
## Retry Budget
Use Telegram retries only for ambiguous transient failures, not as a product queue.
Suggested initial policy:
```text
max retry deferrals per update: 2
max retry window: 30-60s
if no healthy desktop by then: offline/degraded notice and 2xx
```
Why:
- Keeps the product promise: no default durable backend plaintext queue.
- Avoids indefinite webhook backlog.
- Lets short reconnects recover.
- Does not silently turn Telegram into a long-term queue.
## Ordering Rules
Telegram `update_id` is useful for duplicate detection, but do not assume every update id is contiguous forever.
Route ordering should use:
```text
orderingKey = provider + bindingId + routeId
providerOrder = update_id plus provider message date/message_id when available
```
Backend:
```text
Use an in-memory per-route serial executor while the process is alive.
Do not persist plaintext to achieve ordering in default mode.
```
Desktop:
```text
Deduplicate by providerUpdateId.
Append accepted messages in provider order when possible.
If out-of-order arrival is detected, store both and mark ordering warning.
```
Webhook setting:
```text
Do not set max_connections to 1 globally for the shared bot unless traffic is tiny.
Use route-level ordering instead.
```
Reason:
```text
max_connections=1 would serialize every customer through one webhook lane.
That is safe but does not scale.
```
## Route Inventory Handshake
When desktop connects:
```text
1. authenticate install/binding
2. send protocol version
3. send route inventory hash
4. backend responds with active routes known server-side
5. desktop responds with local route inventory
6. both sides mark compatible or needs_repair
```
If route inventory mismatches:
```text
do not deliver inbound user messages into uncertain routes
ask desktop to repair or refresh
```
This protects cases like:
- team deleted locally
- team renamed
- topic recreated
- binding revoked on another process
- local route store restored from old backup
## Desktop To Lead Delivery
The desktop local commit should not directly mean "agent saw it".
Better state split:
```text
provider update accepted locally
-> local inbound message committed
-> route to lead/team inbox scheduled
-> inbox write committed
-> agent turn started
-> response captured
-> outbound provider delivery ledger
```
If inbox write fails after ACKing Telegram:
```text
The message is not lost because it is in MessengerDesktopInboundStore.
MessengerLocalDeliveryLedger can retry delivery to lead/team inbox.
```
This is the same reliability style as existing runtime delivery journals.
## Official Bot vs Own Bot Difference
Official shared bot default:
```text
Telegram sends webhooks to our backend.
Backend must decide quickly whether desktop accepted the update.
If desktop is offline, backend sends offline notice and ACKs Telegram.
No catch-up after offline notice.
```
Own bot local mode:
```text
Desktop can use getUpdates long polling directly.
If desktop is offline, Telegram may retain updates for up to 24 hours.
When desktop returns, it can catch up, because Telegram is the queue.
```
This means own-bot mode has a surprising reliability advantage:
```text
It can support Telegram-side catch-up without our backend storing plaintext.
```
But UX must say it clearly:
```text
Own bot can catch up recent Telegram updates while your computer was asleep, subject to Telegram retention.
Default Agent Teams bot replies offline instead of queueing by default.
```
## Technology Recommendation
### Desktop main process client
Use Node 22 native WebSocket client.
🎯 9 🛡️ 8 🧠 4 Approx change size: 700-1400 LOC
Why:
- No new dependency for desktop client.
- Node docs say v22.4.0 marked WebSocket stable.
- Full-duplex fits ACK/control messages.
### Backend WebSocket server
If backend is Node/Fastify, use `@fastify/websocket` 11.2.0.
🎯 8 🛡️ 8 🧠 5 Approx change size: 900-1800 LOC backend-side
Why:
- Aligns with existing Fastify stack style.
- Built on `ws@8`.
- Has TypeScript declarations.
Note:
```text
This dependency is for cloud/backend package, not necessarily this Electron app package.
```
### Fallback transport
Keep SSE + HTTPS ACK as an optional enterprise fallback.
🎯 7 🛡️ 7 🧠 6 Approx change size: +1200-2500 LOC after WebSocket protocol exists
Why:
- Some networks/proxies break WebSocket.
- SSE is easier to pass through HTTP infrastructure.
But:
```text
Do not implement fallback until WebSocket protocol semantics are stable.
Otherwise two transports will double the bug surface.
```
## Error Copy Policy
Telegram user-facing responses should be honest and short.
No desktop session:
```text
Agent Teams desktop is offline for this team. Open the app and resend your message.
```
Route disabled:
```text
This team is no longer connected to Telegram. Reconnect it in Agent Teams.
```
Delivery uncertain after retry budget:
```text
Agent Teams could not confirm delivery to desktop. Check the app or resend.
```
Unsupported media in MVP:
```text
This Telegram connection currently supports text only. Send the details as text.
```
Avoid:
```text
"Message delivered" before desktop commit ACK.
"Queued" in default mode.
"We will process this when online" in default mode.
```
## Security Rules
Relay authentication:
```text
desktop signs session start with install secret
backend issues short-lived relay session token
WebSocket uses WSS only
old session token cannot ACK after lease is stolen
ACK includes deliveryId and sessionId
```
Frame validation:
```text
max payload size for text MVP
strict JSON object shape
protocolVersion required
unknown frame types rejected
provider ids stored as strings
raw provider ids redacted in logs
```
Replay controls:
```text
providerUpdateId dedupe on backend metadata ledger
providerUpdateId dedupe on desktop local ledger
deliveryId unique per backend offer
payloadHash conflict detection
duplicate committed ACK path
```
## Edge Cases To Test
Webhook and ACK:
- Telegram webhook with valid secret token and active desktop returns 2xx only after desktop commit ACK.
- Telegram webhook with no active desktop sends offline notice and returns 2xx.
- Active desktop socket exists but heartbeat is stale, backend treats it offline.
- Desktop commits locally but ACK response is lost, retry returns duplicate committed.
- Desktop receives offer after `expiresAt`, rejects retryable or terminal by policy.
- Backend process crashes before returning 2xx, Telegram retries.
- Backend process crashes after returning 2xx but before metadata update, metadata repair handles it.
Ordering and duplicates:
- Same `update_id` delivered twice.
- Two updates for same route arrive concurrently.
- Out-of-order updates due to parallel webhook connections.
- Update id jumps after a long quiet period.
- Payload hash conflict for same update id.
Session lifecycle:
- Second desktop instance steals lease.
- Old session tries to ACK after lease stolen.
- Desktop reconnects with old route inventory hash.
- Binding revoked while socket is open.
- Route disabled while offer is in flight.
Local delivery:
- Desktop commits inbound message, then app crashes before writing team inbox.
- Local delivery ledger retries inbox write on restart.
- Inbox path locked temporarily.
- Team deleted after local commit.
- Lead process offline after local commit.
Privacy:
- Backend durable stores contain no plaintext message bodies in default mode.
- Logs redact raw Telegram ids and message text.
- Offline notice path does not persist message body.
- Metrics count event classes without payload.
Own bot:
- Desktop has no webhook and polls with `getUpdates`.
- Existing webhook on own bot is detected and not deleted silently.
- Desktop catches up updates after restart within Telegram retention window.
- Desktop handles updates older than local route creation as ignored.
## Decision Update
The feature should introduce:
```text
MessengerRelaySessionManager
MessengerRelayProtocol
MessengerBackendUpdateMetadataLedger
MessengerDesktopInboundStore
MessengerLocalDeliveryLedger
```
Recommended default:
```text
official shared bot
WebSocket desktop relay
local-commit ACK before Telegram 2xx
offline notice when no healthy desktop session
bounded Telegram retry only for ambiguous active-session failures
no durable plaintext backend queue
```
Main open uncertainty left after this pass:
```text
Should official shared bot use limited Telegram webhook retries for active-session ACK timeouts,
or always terminal-handle ambiguous timeouts with "delivery uncertain" and 2xx?
```
My current recommendation:
🎯 8 🛡️ 8 🧠 6 Approx change size: +500-1200 LOC
```text
Use limited retry deferral for active-session ACK timeouts only.
Never use retry deferral when there is clearly no healthy desktop session.
```
Reason:
```text
This recovers short reconnects and ACK-loss cases without turning default mode into a hidden queue.
```

View file

@ -0,0 +1,965 @@
# Messenger Connectors - Uncertainty Pass 35
Date: 2026-04-29
Scope: conversation history, Telegram topic projection, teammate-visible messages, backfill policy, canonical local store, and anti-duplication rules
## Executive Delta
The next weakest area is:
```text
local app messages
-> canonical messenger conversation history
-> Telegram topic projection
-> provider message links
-> reply-to routing
```
This looks like a UX problem, but it is actually a data model problem.
If we simply mirror the existing app feed into Telegram, we risk:
```text
1. Sending internal lead thoughts or slash command output to Telegram.
2. Mixing unrelated teammate replies from inboxes/user.json into the wrong topic.
3. Duplicating the same answer because the local UI feed dedupes differently from provider delivery.
4. Losing reply-to routing because local messages have no provider message link.
5. Creating a Telegram topic that looks like history, but is missing context from before connection.
6. Backfilling old history and accidentally exposing private/internal messages.
```
The safest rule:
```text
Telegram topic is a projection, not the source of truth.
```
Canonical history must be a new provider-neutral store:
```text
MessengerConversationStore
accepted inbound provider messages
external-safe local replies
provider delivery links
route/team/member references
projection state
```
The existing `TeamMessageFeedService` is useful as an input, but it is not safe to use as the Telegram projection source directly.
## Source Facts Rechecked
Telegram official facts checked on 2026-04-29:
- Bot API exposes update delivery through `getUpdates` or webhooks. Updates are stored on Telegram servers until the bot receives them, but not longer than 24 hours.
- `Update.update_id` helps ignore repeated updates or restore order if webhook updates arrive out of order.
- `Message.message_id` is unique inside a chat. In some scheduled-message cases it can be `0` and unusable until actually sent.
- `Message.message_thread_id` identifies a message thread or forum topic for supergroups and private chats.
- `createForumTopic` can create a topic in a forum supergroup or a private chat with a user. It returns a `ForumTopic`.
- `editForumTopic` can change topic name/icon in a forum supergroup or private chat with a user.
- `copyMessages` supports `message_thread_id`, copies 1-100 known messages, and returns `MessageId[]`.
- `sendMessage` and media methods return the sent `Message` on success. This returned provider message id is required for future reply-to routing.
- `sendChatAction` supports `message_thread_id` and lasts 5 seconds or less. Telegram recommends it only when a response will take noticeable time.
- `sendMessageDraft` can stream a partial message to a user while it is being generated, with optional `message_thread_id`.
- `editMessageText` can edit messages, but it is primarily for changing existing message history and has 48-hour limits for certain business messages not sent by the bot.
- `deleteMessage` has important limits, including a 48-hour deletion window for normal messages and service-message exceptions.
- Telegram FAQ says bots can see messages sent to them, and group privacy mode changes what group messages they can see. Treat bots as third-party participants.
Sources:
- https://core.telegram.org/bots/api#getting-updates
- https://core.telegram.org/bots/api#update
- https://core.telegram.org/bots/api#message
- https://core.telegram.org/bots/api#createforumtopic
- https://core.telegram.org/bots/api#editforumtopic
- https://core.telegram.org/bots/api#copymessages
- https://core.telegram.org/bots/api#sendmessage
- https://core.telegram.org/bots/api#sendchataction
- https://core.telegram.org/bots/api#sendmessagedraft
- https://core.telegram.org/bots/api#editmessagetext
- https://core.telegram.org/bots/api#deletemessage
- https://telegram.org/faq
Inference from the Bot API docs:
```text
The Bot API is update-driven and method-driven.
It does not document a general "read arbitrary private chat history" method for bots.
Therefore Agent Teams must persist the history it needs at acceptance/projection time.
```
Local code facts checked:
- `TeamInboxReader` merges all `inboxes/*.json`, assigns `to` from the filename when absent, and creates deterministic message ids for rows without `messageId`.
- `TeamSentMessagesStore` keeps only the newest 200 messages in `sentMessages.json`. This is a UI/local persistence cap, not a long-term external conversation history.
- `TeamMessageFeedService` merges inbox messages, lead session messages, and sent messages, then dedupes, links passive summaries, attaches lead session ids, and annotates slash command responses.
- `TeamMessageFeedService` is optimized for UI display, not for provider delivery or privacy policy.
- `InboxMessage.source` already has multiple categories: `inbox`, `lead_session`, `lead_process`, `runtime_delivery`, `user_sent`, `system_notification`, `cross_team`, `cross_team_sent`.
- Existing `conversationId` and `replyToConversationId` are used for cross-team routing and can inspire messenger conversation identity, but they are not enough by themselves for Telegram provider links.
- `inboxes/user.json` can contain teammate replies to the user without stable provider thread context.
Implication:
```text
Messenger history must not be derived lazily from the renderer feed.
It must be committed as a conversation ledger when an external route is involved.
```
## Top 3 History Models
### 1. Canonical MessengerConversationStore plus Telegram projection ledger
🎯 9 🛡️ 9 🧠 7 Approx change size: 4000-9000 LOC
Shape:
```text
provider inbound committed locally
-> MessengerConversationStore append inbound
-> local delivery to lead/team
-> safe local replies appended to same conversation
-> TelegramProjectionLedger sends only eligible projection events
-> provider message ids stored as ProviderMessageLink
```
Why this is best:
- Telegram topic is a view of an external conversation, not the data source.
- Existing UI feed remains untouched for local app semantics.
- Provider delivery idempotency and reply-to mapping have a durable home.
- Future WhatsApp/Discord adapters can reuse the same core model.
- Privacy policy can be enforced before a row becomes externally projectable.
Weaknesses:
- More code.
- Needs migration/UI integration to show messenger conversations.
- Requires careful linking from existing team replies to the correct conversation.
Verdict:
```text
Use this.
```
### 2. Reuse existing TeamMessageFeedService as canonical history
🎯 5 🛡️ 4 🧠 3 Approx change size: 900-2200 LOC
Shape:
```text
watch TeamMessageFeedService
filter messages
send eligible messages to Telegram topic
store provider links separately
```
Why it is tempting:
- Much less new architecture.
- UI already displays this feed.
- Existing refresh/invalidation paths exist.
Why it is risky:
- Feed is display-oriented and merges many sources.
- It can annotate slash command responses.
- It dedupes and links passive summaries for UI purposes.
- It includes local-only concepts that should never leave the app by default.
- It has no long-term guarantee because `sentMessages.json` caps at 200 rows.
Verdict:
```text
Do not use as provider source of truth.
Can be an input to a projection gate only.
```
### 3. Telegram topic as the canonical history
🎯 4 🛡️ 5 🧠 5 Approx change size: 1800-4500 LOC
Shape:
```text
send everything important to Telegram
use Telegram topic message ids as history
local app reads/links only provider ids
```
Why it is attractive:
- User sees history in Telegram.
- Less local history UI work.
Why it fails:
- Bot API does not provide a general documented way to read arbitrary private chat history later.
- If delivery to Telegram is ambiguous, local source of truth is unclear.
- If user deletes messages or blocks bot, local product history degrades.
- Provider-specific semantics leak into core.
- WhatsApp/Discord will not match exactly.
Verdict:
```text
Reject for core architecture.
Telegram is projection only.
```
## Recommended Canonical Model
Use two related ledgers:
```text
MessengerConversationStore
what happened in the external-user conversation
MessengerProviderProjectionLedger
what was attempted/sent/linked in Telegram
```
Conversation row:
```ts
interface MessengerConversationMessage {
id: string;
conversationId: string;
routeId: string;
bindingId: string;
teamId: string;
direction: 'inbound_from_user' | 'outbound_to_user' | 'internal_note';
author: {
kind: 'external_user' | 'team_member' | 'team_lead' | 'system';
memberId?: string;
displayName: string;
};
text: string;
createdAt: string;
externalVisibility:
| 'projectable'
| 'local_only'
| 'blocked_by_policy'
| 'requires_manual_approval';
source: {
kind:
| 'telegram_update'
| 'team_inbox'
| 'lead_session'
| 'runtime_delivery'
| 'manual_ui'
| 'system';
localMessageId?: string;
providerUpdateId?: string;
providerMessageId?: string;
leadSessionId?: string;
};
replyTo?: {
conversationMessageId?: string;
providerMessageLink?: ProviderMessageLink;
localMessageId?: string;
};
policy: {
sanitized: boolean;
strippedInternalBlocks: boolean;
reasonCodes: string[];
};
}
```
Projection row:
```ts
interface MessengerProviderProjectionRecord {
id: string;
conversationMessageId: string;
provider: 'telegram';
routeId: string;
providerTarget: {
chatIdHash: string;
messageThreadId: string;
};
status:
| 'pending'
| 'sending'
| 'sent'
| 'ambiguous'
| 'failed_retryable'
| 'failed_terminal'
| 'suppressed';
payloadHash: string;
providerMessageLink?: ProviderMessageLink;
attempts: number;
createdAt: string;
updatedAt: string;
lastError?: string;
}
```
Important:
```text
The conversation store can contain local-only rows.
The projection ledger can only contain rows that passed external visibility policy.
```
## What Counts As Conversation History
For Telegram user-facing history, include:
```text
1. User inbound messages accepted from Telegram.
2. Lead replies explicitly addressed to user.
3. Teammate replies explicitly addressed to user.
4. User manual messages from local UI that are intentionally sent to the team under this route.
5. Short system status messages that are explicitly external-facing, for example "desktop offline".
```
Do not include by default:
```text
lead thoughts
tool summaries
slash command outputs
task status notifications
cross-team internal messages
teammate-to-teammate chat
permission_request JSON
idle heartbeats
bootstrap check-ins
raw XML/agent blocks
attachments until media policy is implemented
```
This must be enforced before a message is appended as `projectable`.
## How To Handle Teammate Messages To User
The user asked for this:
```text
Messages from teammates to the user should appear in Telegram,
with each teammate clearly signed.
```
Recommended rule:
```text
Any known team member message with to == "user" can be appended to the conversation
only if it is linked to an active messenger route/conversation.
```
Rendering:
```text
[Frontend] Alice
I found the failing test. The callback resolves before token refresh.
```
```text
[QA] Mark
Reproduced on the latest build. Only happens after session restore.
```
Why prefix instead of separate bots:
- One bot per team member is much harder to manage.
- Multiple bots do not solve core routing.
- Prefix keeps the topic readable.
- It works across providers later.
Routing requirement:
```text
Do not send every message to user globally.
Send only messages whose conversationId or relay link ties them to the active messenger conversation.
```
## Conversation Identity
Use one active user-facing conversation per team route in MVP:
```text
conversationId = routeId + currentConversationSeq
```
MVP can start with:
```text
one open conversation per team topic
```
Later:
```text
multiple conversations per team topic with task/thread labels
```
Why not one conversation per message:
- Too noisy.
- Hard for the lead to maintain context.
- Telegram topic already groups by team.
Why not only one global conversation for all teams:
- Reply routing becomes ambiguous.
- User needs team-level separation.
- Topics per team become mostly cosmetic.
## Backfill Policy
Backfill is risky because old local history may contain private/internal context.
Top 3 backfill options:
### A. No automatic backfill, send a compact connection marker
🎯 9 🛡️ 9 🧠 3 Approx change size: 500-1200 LOC
On topic creation:
```text
Connected to Agent Teams.
Team: Frontend
New messages will appear here.
```
Optional local-only UI shows older app history, but Telegram starts clean.
Verdict:
```text
Use for MVP.
```
### B. User-approved summary backfill
🎯 8 🛡️ 8 🧠 6 Approx change size: 1800-4000 LOC
Desktop prepares a summary:
```text
Recent context:
- Alice is debugging auth callback tests.
- Mark is checking session restore.
- Open question: should refresh happen before redirect?
```
User explicitly approves before sending.
Verdict:
```text
Good Phase 2.
```
### C. Raw transcript backfill
🎯 4 🛡️ 3 🧠 5 Approx change size: 1600-3600 LOC
Desktop sends last N messages from local feed into Telegram.
Problems:
- High privacy leak risk.
- Rate-limit/noise risk.
- Duplicates provider projection.
- Old messages may lack clean source/route links.
- Telegram message timestamps become send time, not original time.
Verdict:
```text
Reject by default.
Only allow export/manual paste workflows later.
```
## History Display In Telegram
Telegram topic should show:
```text
inbound user message
team reply with member prefix
short status markers
optional typing/draft/progress indicator
```
It should not try to reproduce the full local app timeline.
Recommended topic message examples:
```text
You
Can you check why login redirects loop?
```
```text
[Lead] Agent Teams
I routed this to Frontend. Alice is checking the auth callback.
```
```text
[Frontend] Alice
Found the loop. The callback reads a stale refresh token after restore.
```
```text
[System]
Desktop went offline. Open Agent Teams and resend if this still matters.
```
Avoid:
```text
tool call summaries
stdout chunks
agent chain-of-thought style text
raw task board mutations
every idle/status heartbeat
```
## Progress Indicators
Top 3 options:
### 1. `sendChatAction(typing)` heartbeat while a route-linked answer is pending
🎯 8 🛡️ 8 🧠 4 Approx change size: 700-1500 LOC
Pros:
- Official method.
- Supports `message_thread_id`.
- Lasts 5 seconds or less, so it naturally expires.
- Does not create message history clutter.
Cons:
- Needs throttling.
- Can imply active work even if the lead is blocked.
Verdict:
```text
Use carefully after inbound commit, while local delivery is pending or agent turn is active.
```
### 2. `sendMessageDraft`
🎯 6 🛡️ 6 🧠 7 Approx change size: 1200-3000 LOC
Pros:
- New Bot API method for partial generated messages.
- Supports `message_thread_id`.
- Could feel impressive.
Cons:
- Draft lifecycle/id semantics need real-world testing.
- It might leak partial agent output before safety/projection filtering.
- Harder to reconcile if final answer is suppressed.
Verdict:
```text
Do not use in MVP.
Only consider for final-answer generation after projection gate is mature.
```
### 3. Explicit status messages like "Alice is working"
🎯 7 🛡️ 6 🧠 3 Approx change size: 500-1200 LOC
Pros:
- Simple.
- Durable and visible.
Cons:
- Adds clutter.
- Can become spammy.
- Hard to keep accurate.
Verdict:
```text
Use only for major state changes, not continuous progress.
```
## Reply-To Routing
Incoming Telegram reply should route by priority:
```text
1. reply_to_message.message_id maps to ProviderMessageLink
2. message_thread_id maps to team route
3. slash command selects member or action
4. fallback to lead
```
Provider message link:
```ts
interface ProviderMessageLink {
provider: 'telegram';
routeId: string;
providerChatIdHash: string;
providerMessageThreadId: string;
providerMessageId: string;
conversationMessageId: string;
authorKind: 'external_user' | 'team_member' | 'team_lead' | 'system';
authorMemberId?: string;
sentAt: string;
}
```
Examples:
```text
User replies to Alice message
-> route to team topic
-> include reply target "Alice" in lead/team prompt
-> if direct teammate reply mode is enabled, deliver to Alice inbox
```
```text
User sends a new message in team topic without reply
-> route to lead by default
```
MVP decision:
```text
Do not DM arbitrary teammate automatically from reply-to.
Route to lead with reply context first.
```
Why:
- Lead can coordinate.
- Teammate may be offline or mid-turn.
- Direct teammate routing can be added after route policy is proven.
## Commands In Topic
Keep commands minimal in MVP:
```text
/teams
/status
/help
/disconnect
```
Do not overload the topic with rich command grammar early.
Team selection:
```text
Primary selection is topic.
Commands are fallback and diagnostics.
```
If message arrives outside a topic:
```text
show active teams
ask user to pick a topic
do not infer from recent activity unless exactly one team is active
```
## Projection State Machine
```text
local_message_seen
-> policy_checked
-> conversation_appended
-> projection_pending
-> provider_sending
-> provider_sent
-> linked
```
Failure states:
```text
suppressed_by_policy
requires_manual_approval
provider_ambiguous
provider_failed_retryable
provider_failed_terminal
route_disabled
topic_needs_repair
```
Important invariant:
```text
Provider projection cannot start before the message is appended to MessengerConversationStore.
```
This ensures Telegram never has a message that the local conversation store cannot explain.
## Duplicate Prevention
Use three layers:
```text
1. Conversation idempotency key
2. Projection payload hash
3. Provider message link
```
Conversation idempotency:
```text
source.kind + source.localMessageId/providerUpdateId + routeId
```
Projection idempotency:
```text
conversationMessageId + provider + routeId + payloadHash
```
Provider link:
```text
stored only after sendMessage returns Message
```
If Telegram send times out:
```text
mark projection ambiguous
do not retry automatically with the same text unless policy accepts duplicate risk
surface "delivery uncertain" in local UI
```
This matches earlier outbound delivery research.
## Edit And Delete Policy
Do not use Telegram edit/delete as the normal sync mechanism.
Reasons:
- `deleteMessage` has a 48-hour limit and service-message exceptions.
- `editMessageText` has constraints and can return different shapes.
- Edits are provider-specific and hard to reconcile across adapters.
MVP:
```text
append-only Telegram topic
append-only local conversation ledger
corrections are new messages
```
Later:
```text
support explicit "correct last bot message" for bot-authored messages only
```
## Storage And Retention
Do not rely on:
```text
sentMessages.json cap of 200
inboxes/user.json as long-term canonical external history
Telegram topic as recoverable history
```
Use:
```text
getAppDataPath()/messenger-conversations/
bindings/
routes/
conversations/
projections/
```
Retention tiers:
```text
MVP:
keep text conversation rows locally until user deletes route/binding
Later:
per-route retention setting
export/delete controls
encrypted local store option
encrypted backend queue option
```
## UI Implications
Desktop should show:
```text
Connected Telegram account
team topics/routes
last projected message status
delivery uncertain warnings
local-only vs sent-to-Telegram marker
reconnect/repair action
```
Message row badges:
```text
local only
sent to Telegram
delivery uncertain
blocked by policy
needs approval
```
This matters because the local app feed and Telegram topic will not always match exactly by design.
## Clean Architecture Placement
Core/domain:
```text
ConversationMessage
ConversationPolicy
ProjectionEligibility
ProviderMessageLink
ProjectionStateMachine
BackfillPolicy
```
Core/application:
```text
AppendInboundProviderMessageUseCase
AppendLocalReplyUseCase
EvaluateProjectionUseCase
ProjectConversationMessageUseCase
ReconcileProjectionUseCase
BuildBackfillPreviewUseCase
```
Ports:
```text
MessengerConversationStore
MessengerProjectionLedger
MessengerProviderGateway
TeamMessageSource
ExternalVisibilityPolicy
```
Adapters:
```text
TeamMessageFeedInputAdapter
TelegramProjectionAdapter
FileConversationStore
FileProjectionLedger
```
Important dependency rule:
```text
TeamMessageFeedInputAdapter may depend on existing team services.
Core policy must not depend on TeamMessageFeedService.
```
## Edge Cases To Test
History and projection:
- Topic created after team already has a long local message history.
- No automatic raw backfill occurs.
- User-approved summary backfill sends only approved summary.
- `sentMessages.json` drops old rows, but MessengerConversationStore keeps route conversation history.
- Same local message appears in both inbox and sent messages, only one conversation row is created.
- Same conversation row is not projected twice.
Teammate messages:
- Alice sends `to=user` in a route-linked conversation, Telegram gets `[Alice]`.
- Alice sends `to=user` outside a route-linked conversation, Telegram gets nothing.
- Alice sends teammate-internal message, Telegram gets nothing.
- Lead sends generic thought with no `to=user`, Telegram gets nothing.
- Slash command result is visible in UI, Telegram gets nothing by default.
Reply routing:
- User replies to Alice's Telegram message, provider link maps to Alice context.
- User replies to system offline notice, route remains lead fallback.
- User writes in topic without reply, route goes to lead.
- User writes outside topic with multiple teams connected, bot asks to choose topic.
- Unknown provider message id does not crash routing.
Provider behavior:
- `sendMessage` success stores provider message link.
- `sendMessage` timeout marks ambiguous and does not auto-duplicate.
- `deleteMessage` failure does not corrupt local conversation.
- `editForumTopic` failure does not reroute by title.
- Topic repair creates new topic and marks old projection state historical.
Privacy:
- Internal blocks stripped before projectable rows.
- Policy blocks `permission_request` JSON.
- Policy blocks tool stdout/stderr unless manually approved.
- Backfill preview redacts secrets and requires explicit approval.
## Decision Update
Add this to the implementation plan:
```text
MessengerConversationStore is mandatory for MVP.
Telegram topic is provider projection only.
No raw automatic history backfill.
One topic per team route.
One open conversation per team topic in MVP.
Teammate messages to user are projected only when route-linked and external-safe.
```
Recommended MVP behavior:
```text
Connect Telegram
-> create one topic per selected team
-> send a short connection marker
-> start projecting new inbound/outbound external-safe messages
-> show local projection status in desktop
```
Main remaining uncertainty:
```text
Should reply-to a teammate message route directly to that teammate,
or always go through lead with reply context?
```
My current recommendation:
🎯 8 🛡️ 8 🧠 5 Approx change size: +800-1800 LOC
```text
MVP routes all Telegram inbound through lead,
but includes reply-to teammate context in the prompt.
Add direct teammate routing later as an explicit per-team setting.
```
Reason:
```text
It preserves coordination, avoids surprising teammate interruptions,
and still lets the lead tell Alice "the user replied to your message".
```

View file

@ -12,41 +12,48 @@
## Документация
| Файл | Содержание |
|------|-----------|
| [research-inbox.md](./research-inbox.md) | Формат inbox-файлов, race conditions, atomic write, доставка сообщений |
| [research-tasks.md](./research-tasks.md) | Формат task-файлов, .lock, .highwatermark, конкурентный доступ |
| [research-messaging.md](./research-messaging.md) | Сравнение подходов (inbox vs SDK vs CLI), почему выбрали inbox |
| [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json |
| [implementation.md](./implementation.md) | Техплан: файлы, шаги, verification |
| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) |
| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync |
| Файл | Содержание |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| [research-inbox.md](./research-inbox.md) | Формат inbox-файлов, race conditions, atomic write, доставка сообщений |
| [research-tasks.md](./research-tasks.md) | Формат task-файлов, .lock, .highwatermark, конкурентный доступ |
| [research-messaging.md](./research-messaging.md) | Сравнение подходов (inbox vs SDK vs CLI), почему выбрали inbox |
| [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json |
| [implementation.md](./implementation.md) | Техплан: файлы, шаги, verification |
| [openclaw-agent-teams-integration.md](./openclaw-agent-teams-integration.md) | How to connect OpenClaw or another outside AI through Agent Teams MCP and REST control API |
| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) |
| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync |
## Ключевые решения
⚠️ `docs/iterations/*` - это исторические planning notes. Они полезны для контекста, но не являются source-of-truth для текущего поведения продукта. Актуальный контракт review flow описан в этом файле и в [kanban-design.md](./kanban-design.md).
### 1. Messaging: Inbox-файлы
Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md)
### 1.1 Roster source: members.meta.json + inboxes
- `config.json` не используется как полный реестр участников (он может содержать только team-lead и служебные поля CLI).
- Источник метаданных участников (role/color/agentType): `members.meta.json`.
- Источник runtime-состава и адресации сообщений: `inboxes/{member}.json`.
### 2. Kanban Storage: Собственный файл
Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`, а не в task metadata. Причина: metadata может быть перезаписан агентом при TaskUpdate. Подробности: [kanban-design.md](./kanban-design.md)
### 3. Review Flow: Approve / Request Changes
- Есть ревьюверы в команде → автоматическое назначение через inbox
- Юзер также может вручную одобрить задачу напрямую из `DONE` без отдельного захода в `REVIEW`
- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI)
- При Request Changes → юзер описывает проблему (опционально) → задача возвращается owner'у в `pending` с `needsFix`
### 4. Atomic Write
Все записи через tmp + rename для предотвращения corrupted JSON.
### 5. Sender Identity
Отправляем `from: "user"`. Fallback на `from: "team-lead"` если не работает.
## Финальные решения после ревью
@ -54,16 +61,19 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
По итогам 3 раундов ревью (13 экспертов) приняты следующие решения:
### Inbox: Atomic write + messageId verify
- Atomic write (tmp + rename) предотвращает corrupted JSON
- После записи читаем файл обратно и проверяем наличие нашего `messageId`
- Полный CAS/retry-цикл — не нужен на MVP: проверка при следующем read достаточна
- Риск race condition с агентом реален, но вероятность низкая
### Kanban: kanban-state.json с безопасным GC
- GC устаревших записей kanban-state выполняется ТОЛЬКО ПОСЛЕ полной загрузки tasks
- Иначе при startup возможна race condition: GC удаляет запись до того как task-файл прочитан
### Review Flow: Approve / Request Changes
- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error)
- Комментарий при Request Changes — опционален
- Manual UI допускает два valid path:
@ -73,10 +83,12 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
- `reviewHistory` и round-robin балансировка → Phase 2, не MVP
### Members: полный список через union
- `union(config members + inbox filenames + task owners)` — единственный способ получить полный список
- `owner` в task-файлах — опционален (агент может не иметь owner до назначения)
### Graceful Degradation
- `try/catch` везде в TeamDataService — при ошибке чтения возвращаем безопасные дефолты
- 3 состояния участника: `ACTIVE` / `IDLE` / `TERMINATED`
- `ACTIVE`: idle < 5 минут
@ -84,6 +96,7 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
- `TERMINATED`: получен `shutdown_response` с `approve: true`
### @dnd-kit and review transitions
- Переходы между review-колонками делаются через card actions в UI
- `@dnd-kit` сейчас используется в первую очередь для перестановки задач внутри колонки
- Phase 2: полноценный D&D через `@dnd-kit`
@ -113,5 +126,6 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
```
**ВАЖНО**:
- `config.json` не является source-of-truth для полного roster.
- Полный roster для UI формируется как `members.meta.json + inbox filenames (+ lead из config)`.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,20 @@ declare module 'agent-teams-controller' {
export interface ControllerTaskApi {
createTask(flags: Record<string, unknown>): unknown;
getTask(taskId: string): unknown;
getTaskComment(taskId: string, commentId: string): { comment: Record<string, unknown>; task: { id: string; displayId: string; subject: string; status: string; owner: string | null; commentCount: number } };
getTaskComment(
taskId: string,
commentId: string
): {
comment: Record<string, unknown>;
task: {
id: string;
displayId: string;
subject: string;
status: string;
owner: string | null;
commentCount: number;
};
};
listTasks(): unknown[];
listTaskInventory(filters?: Record<string, unknown>): unknown[];
listDeletedTasks(): unknown[];
@ -77,6 +90,9 @@ declare module 'agent-teams-controller' {
}
export interface ControllerRuntimeApi {
listTeams(flags?: Record<string, unknown>): Promise<unknown>;
getTeam(flags?: Record<string, unknown>): Promise<unknown>;
createTeam(flags: Record<string, unknown>): Promise<unknown>;
launchTeam(flags: Record<string, unknown>): Promise<unknown>;
stopTeam(flags?: Record<string, unknown>): Promise<unknown>;
getRuntimeState(flags?: Record<string, unknown>): Promise<unknown>;

View file

@ -13,8 +13,10 @@ import { registerProcessTools } from './processTools';
import { registerReviewTools } from './reviewTools';
import { registerRuntimeTools } from './runtimeTools';
import { registerTaskTools } from './taskTools';
import { registerTeamTools } from './teamTools';
const REGISTRATION_BY_GROUP = {
team: registerTeamTools,
task: registerTaskTools,
lead: registerLeadTools,
kanban: registerKanbanTools,

View file

@ -3,7 +3,7 @@ import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
import { assertConfiguredTeam } from '../utils/teamConfig';
import { assertConfiguredOrDraftTeam, assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@ -59,7 +59,7 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
extraCliArgs,
waitForReady,
}) => {
assertConfiguredTeam(teamName, claudeDir);
assertConfiguredOrDraftTeam(teamName, claudeDir);
return jsonTextContent(
await getController(teamName, claudeDir).runtime.launchTeam({
cwd,
@ -99,7 +99,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'runtime_bootstrap_checkin',
description: 'Confirm that an OpenCode team member runtime reached the app MCP bootstrap boundary',
description:
'Confirm that an OpenCode team member runtime reached the app MCP bootstrap boundary',
parameters: z.object({
...runtimeIdentitySchema,
observedAt: z.string().min(1).optional(),

View file

@ -0,0 +1,140 @@
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
const controlContextSchema = {
claudeDir: z.string().min(1).optional(),
controlUrl: z.string().optional(),
waitTimeoutMs: z.number().int().min(1000).max(600000).optional(),
};
const teamContextSchema = {
...controlContextSchema,
teamName: z.string().min(1),
};
const providerIdSchema = z.enum(['anthropic', 'codex', 'gemini', 'opencode']);
const effortSchema = z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
const fastModeSchema = z.enum(['inherit', 'on', 'off']);
const memberSchema = z.object({
name: z.string().min(1),
role: z.string().optional(),
workflow: z.string().optional(),
isolation: z.literal('worktree').optional(),
providerId: providerIdSchema.optional(),
providerBackendId: z.string().min(1).optional(),
model: z.string().min(1).optional(),
effort: effortSchema.optional(),
fastMode: fastModeSchema.optional(),
});
function controlFlags(args: {
controlUrl?: string;
waitTimeoutMs?: number;
}): Record<string, unknown> {
return {
...(args.controlUrl ? { controlUrl: args.controlUrl } : {}),
...(args.waitTimeoutMs ? { waitTimeoutMs: args.waitTimeoutMs } : {}),
};
}
export function registerTeamTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'team_list',
description: 'List teams through the local Agent Teams control API',
parameters: z.object({
...controlContextSchema,
}),
execute: async ({ claudeDir, controlUrl, waitTimeoutMs }) => {
return jsonTextContent(
await getController('agent-teams-control', claudeDir).runtime.listTeams(
controlFlags({ controlUrl, waitTimeoutMs })
)
);
},
});
server.addTool({
name: 'team_get',
description: 'Get a team snapshot through the local Agent Teams control API',
parameters: z.object({
...teamContextSchema,
}),
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs }) => {
return jsonTextContent(
await getController(teamName, claudeDir).runtime.getTeam(
controlFlags({ controlUrl, waitTimeoutMs })
)
);
},
});
server.addTool({
name: 'team_create',
description:
'Create a draft team configuration through the local Agent Teams control API. This does not launch the team.',
parameters: z.object({
...teamContextSchema,
displayName: z.string().min(1).optional(),
description: z.string().optional(),
color: z.string().min(1).optional(),
members: z.array(memberSchema).optional(),
cwd: z.string().min(1).optional(),
prompt: z.string().min(1).optional(),
providerId: providerIdSchema.optional(),
providerBackendId: z.string().min(1).optional(),
model: z.string().min(1).optional(),
effort: effortSchema.optional(),
fastMode: fastModeSchema.optional(),
limitContext: z.boolean().optional(),
skipPermissions: z.boolean().optional(),
worktree: z.string().min(1).optional(),
extraCliArgs: z.string().min(1).optional(),
}),
execute: async ({
teamName,
claudeDir,
controlUrl,
waitTimeoutMs,
displayName,
description,
color,
members,
cwd,
prompt,
providerId,
providerBackendId,
model,
effort,
fastMode,
limitContext,
skipPermissions,
worktree,
extraCliArgs,
}) => {
return jsonTextContent(
await getController(teamName, claudeDir).runtime.createTeam({
...controlFlags({ controlUrl, waitTimeoutMs }),
...(displayName ? { displayName } : {}),
...(description ? { description } : {}),
...(color ? { color } : {}),
...(members ? { members } : {}),
...(cwd ? { cwd } : {}),
...(prompt ? { prompt } : {}),
...(providerId ? { providerId } : {}),
...(providerBackendId ? { providerBackendId } : {}),
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(fastMode ? { fastMode } : {}),
...(limitContext !== undefined ? { limitContext } : {}),
...(skipPermissions !== undefined ? { skipPermissions } : {}),
...(worktree ? { worktree } : {}),
...(extraCliArgs ? { extraCliArgs } : {}),
})
);
},
});
}

View file

@ -3,38 +3,73 @@ import path from 'node:path';
import { getController } from '../controller';
function resolveConfigPath(teamName: string, claudeDir?: string): string {
function unknownTeamMessage(teamName: string): string {
return `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`;
}
function resolveTeamPaths(
teamName: string,
claudeDir?: string
): {
configPath: string;
metaPath: string;
} {
const controller = getController(teamName, claudeDir) as {
context?: { paths?: { teamDir?: string } };
};
const teamDir = controller.context?.paths?.teamDir;
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
throw new Error(
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
);
throw new Error(unknownTeamMessage(teamName));
}
return path.join(teamDir, 'config.json');
return {
configPath: path.join(teamDir, 'config.json'),
metaPath: path.join(teamDir, 'team.meta.json'),
};
}
function readJsonObject(filePath: string): Record<string, unknown> | null {
let raw = '';
try {
raw = fs.readFileSync(filePath, 'utf8');
} catch {
return null;
}
try {
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function isConfiguredTeamConfig(value: Record<string, unknown> | null): boolean {
return typeof value?.name === 'string' && value.name.trim().length > 0;
}
function isDraftTeamMeta(value: Record<string, unknown> | null): boolean {
return value?.version === 1 && typeof value.cwd === 'string' && value.cwd.trim().length > 0;
}
export function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
const configPath = resolveConfigPath(teamName, claudeDir);
let raw = '';
try {
raw = fs.readFileSync(configPath, 'utf8');
} catch {
throw new Error(
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
);
}
try {
const parsed = JSON.parse(raw) as { name?: unknown };
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
throw new Error('invalid');
}
} catch {
throw new Error(
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
);
const { configPath } = resolveTeamPaths(teamName, claudeDir);
const parsed = readJsonObject(configPath);
if (!isConfiguredTeamConfig(parsed)) {
throw new Error(unknownTeamMessage(teamName));
}
}
export function assertConfiguredOrDraftTeam(teamName: string, claudeDir?: string): void {
const { configPath, metaPath } = resolveTeamPaths(teamName, claudeDir);
if (isConfiguredTeamConfig(readJsonObject(configPath))) {
return;
}
if (isDraftTeamMeta(readJsonObject(metaPath))) {
return;
}
throw new Error(unknownTeamMessage(teamName));
}

View file

@ -224,6 +224,108 @@ describe('agent-teams-mcp tools', () => {
}
});
it('lists, gets, and creates teams through the local control API', async () => {
const claudeDir = makeClaudeDir();
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'GET' && url === '/api/teams') {
return {
body: [
{
teamName: 'alpha',
displayName: 'Alpha',
description: '',
memberCount: 1,
taskCount: 0,
lastActivity: null,
pendingCreate: true,
},
],
};
}
if (method === 'GET' && url === '/api/teams/alpha') {
return {
body: {
teamName: 'alpha',
members: [{ name: 'builder', role: 'Engineer' }],
tasks: [],
},
};
}
if (method === 'POST' && url === '/api/teams') {
return { statusCode: 201, body: { teamName: 'alpha' } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
const listed = parseJsonToolResult(
await getTool('team_list').execute({
claudeDir,
controlUrl: server.baseUrl,
})
);
expect(listed[0].teamName).toBe('alpha');
const fetched = parseJsonToolResult(
await getTool('team_get').execute({
claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
})
);
expect(fetched.teamName).toBe('alpha');
const created = parseJsonToolResult(
await getTool('team_create').execute({
claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
displayName: 'Alpha',
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
cwd: '/tmp/project',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
})
);
expect(created.teamName).toBe('alpha');
expect(calls).toEqual([
{
method: 'GET',
url: '/api/teams',
body: undefined,
},
{
method: 'GET',
url: '/api/teams/alpha',
body: undefined,
},
{
method: 'POST',
url: '/api/teams',
body: {
teamName: 'alpha',
displayName: 'Alpha',
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
cwd: '/tmp/project',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
},
},
]);
} finally {
await server.close();
}
});
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => {
const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'alpha', {
@ -1317,7 +1419,9 @@ describe('agent-teams-mcp tools', () => {
).rejects.toThrow('Unknown team "typo-team"');
expect(fs.existsSync(path.join(claudeDir, 'teams', 'typo-team'))).toBe(false);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe(false);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe(
false
);
});
it('exposes zod schemas that reject obviously invalid payloads', () => {

View file

@ -1,27 +1,27 @@
{
"version": "0.0.12",
"sourceRef": "v0.0.12",
"version": "0.0.13",
"sourceRef": "v0.0.13",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/claude_agent_teams_ui",
"releaseTag": "v1.2.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.12.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.13.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.12.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.13.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.12.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.13.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.12.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.13.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -67,7 +67,7 @@ function buildManualHints(platform: TmuxPlatform): TmuxInstallHint[] {
},
{
title: 'Install Ubuntu',
description: 'Recommended WSL distro for the tmux runtime path.',
description: 'Recommended WSL distro for optional tmux pane transport.',
command: 'wsl --install -d Ubuntu --no-launch',
},
{

View file

@ -24,7 +24,7 @@ export function buildTmuxEffectiveAvailability(
binaryPath: input.wsl.tmuxBinaryPath,
runtimeReady: false,
detail:
'tmux is available inside WSL, but the persistent teammate runtime still needs native Windows pane support.',
'tmux is available inside WSL. On Windows it is optional and is not required for teammate runtime startup.',
};
}
@ -36,7 +36,7 @@ export function buildTmuxEffectiveAvailability(
binaryPath: input.host.binaryPath,
runtimeReady: false,
detail:
'tmux was found on Windows, but the app currently relies on a WSL-backed tmux runtime for the most reliable teammate path.',
'tmux was found on Windows. Native process teammates do not require it; tmux remains optional for pane-based terminal transport.',
};
}
@ -49,7 +49,7 @@ export function buildTmuxEffectiveAvailability(
runtimeReady: false,
detail:
input.wsl?.statusDetail ??
'You can keep using the app, but Windows needs WSL before tmux can improve teammate reliability.',
'You can keep using the app without tmux. Install WSL only if you want optional tmux pane transport.',
};
}
@ -61,7 +61,7 @@ export function buildTmuxEffectiveAvailability(
runtimeReady: false,
detail:
input.wsl?.statusDetail ??
'WSL is available, but tmux is not ready there yet. Finish the Linux setup, install tmux, then re-check.',
'WSL is available, but tmux is not ready there yet. Install tmux only if you want optional pane transport.',
};
}
@ -72,7 +72,7 @@ export function buildTmuxEffectiveAvailability(
version: input.host.version,
binaryPath: input.host.binaryPath,
runtimeReady: input.nativeSupported,
detail: 'tmux is available for the persistent teammate runtime.',
detail: 'tmux is available as an optional pane transport for teammate sessions.',
};
}
@ -84,9 +84,9 @@ export function buildTmuxEffectiveAvailability(
runtimeReady: false,
detail:
input.platform === 'darwin'
? 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.'
? 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.'
: input.platform === 'linux'
? 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.'
: 'You can keep using the app, but tmux improves persistent teammate reliability.',
? 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.'
: 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.',
};
}

View file

@ -177,7 +177,7 @@ export class TmuxInstallerRunnerAdapter
strategy: 'wsl',
message: 'Preparing the Windows WSL tmux setup...',
detail:
'The app can keep working without tmux, but WSL-backed tmux gives the most reliable persistent teammate path on Windows.',
'The app can keep working without tmux. WSL-backed tmux is optional and only adds pane-based terminal transport on Windows.',
error: null,
canCancel: true,
acceptsInput: false,

View file

@ -171,22 +171,22 @@ export class TmuxInstallStrategyResolver {
if (input.effective.available) {
return input.effective.location === 'wsl'
? 'tmux is available inside WSL on Windows.'
: 'tmux is available for persistent teammate runtime.';
: 'tmux is available as an optional pane transport for teammate sessions.';
}
if (input.platform === 'darwin') {
return 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.';
return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.';
}
if (input.platform === 'linux') {
return 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.';
return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.';
}
if (input.platform === 'win32') {
return (
input.wsl?.statusDetail ??
'You can keep using the app, but tmux on Windows goes through WSL for the best teammate experience.'
'You can keep using the app without tmux. On Windows, tmux setup uses WSL and is only needed for optional pane transport.'
);
}
return 'You can keep using the app, but tmux improves persistent teammate reliability.';
return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.';
}
#buildCommand(
@ -329,7 +329,7 @@ export class TmuxInstallStrategyResolver {
if (status.wslInstalled && !status.distroName) {
this.#prependUniqueHint(manualHints, {
title: 'Install Ubuntu',
description: 'Recommended WSL distro for the tmux runtime path.',
description: 'Recommended WSL distro for optional tmux pane transport.',
command: 'wsl --install -d Ubuntu --no-launch',
});
}

View file

@ -83,14 +83,14 @@ export class TmuxInstallerBannerAdapter {
snapshot.message ??
status?.effective.detail ??
status?.wsl?.statusDetail ??
'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.';
'tmux is optional. Install it only if you want pane-based terminal transport for long-running teammate sessions.';
const benefitsBody =
status && !status.effective.available ? formatTmuxOptionalBenefits(status.platform) : null;
const runtimeReadyLabel = status
? status.effective.runtimeReady
? 'Ready for persistent teammates'
? 'Pane transport ready'
: status.effective.available
? 'Installed, but not active yet'
? 'Installed, optional transport inactive'
: null
: null;
const versionLabel =

View file

@ -20,7 +20,7 @@ const baseStatus: TmuxStatus = {
version: null,
binaryPath: null,
runtimeReady: false,
detail: 'tmux improves persistent teammate reliability.',
detail: 'tmux is optional. Install it only if you want pane transport.',
},
error: null,
autoInstall: {
@ -72,9 +72,9 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.progressPercent).toBeNull();
expect(result.manualHints).toHaveLength(1);
expect(result.manualHintsCollapsible).toBe(false);
expect(result.body).toContain('persistent teammate reliability');
expect(result.benefitsBody).toContain('Optional, but recommended');
expect(result.benefitsBody).toContain('multi-agent teams that mix providers');
expect(result.body).toContain('tmux is optional');
expect(result.benefitsBody).toContain('Optional');
expect(result.benefitsBody).toContain('pane-based terminal transport');
expect(result.installButtonPrimary).toBe(true);
expect(result.showRefreshButton).toBe(true);
});
@ -102,7 +102,7 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.title).toBe('Installing tmux');
expect(result.body).toBe('Renderer bridge failed');
expect(result.benefitsBody).toContain('Optional, but recommended');
expect(result.benefitsBody).toContain('Optional');
expect(result.error).toBe('Renderer bridge failed');
expect(result.installDisabled).toBe(true);
expect(result.canCancel).toBe(true);
@ -171,7 +171,7 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.primaryGuideUrl).toBe('https://learn.microsoft.com/en-us/windows/wsl/install');
expect(result.progressPercent).toBe(82);
expect(result.manualHintsCollapsible).toBe(true);
expect(result.benefitsBody).toContain('With tmux in WSL');
expect(result.benefitsBody).toContain('WSL-backed tmux');
expect(result.showRefreshButton).toBe(true);
});
@ -188,7 +188,7 @@ describe('TmuxInstallerBannerAdapter', () => {
version: 'tmux 3.4',
binaryPath: 'C:\\tmux.exe',
runtimeReady: false,
detail: 'tmux was found on Windows, but WSL-backed tmux is still preferred.',
detail: 'tmux was found on Windows. Native process teammates do not require it.',
},
},
snapshot: idleSnapshot,
@ -199,7 +199,7 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.visible).toBe(false);
expect(result.locationLabel).toBe('Host runtime');
expect(result.runtimeReadyLabel).toBe('Installed, but not active yet');
expect(result.runtimeReadyLabel).toBe('Installed, optional transport inactive');
expect(result.versionLabel).toBe('tmux 3.4');
expect(result.benefitsBody).toBeNull();
});
@ -216,7 +216,7 @@ describe('TmuxInstallerBannerAdapter', () => {
version: 'tmux 3.6a',
binaryPath: '/opt/homebrew/bin/tmux',
runtimeReady: true,
detail: 'tmux is available for persistent teammates.',
detail: 'tmux is available as an optional pane transport.',
},
},
snapshot: {

View file

@ -25,7 +25,7 @@ const baseStatus: TmuxStatus = {
version: null,
binaryPath: null,
runtimeReady: false,
detail: 'tmux improves persistent teammate reliability.',
detail: 'tmux is optional. Install it only if you want pane transport.',
},
error: null,
autoInstall: {

View file

@ -21,7 +21,7 @@ const baseViewModel: TmuxInstallerBannerViewModel = {
title: 'tmux is not installed',
body: 'WSL is available, but no Linux distribution is installed yet.',
benefitsBody:
'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable. Without tmux, creating multi-agent teams that mix providers may be blocked.',
'Optional. The app works without tmux. Install WSL-backed tmux only if you want pane-based terminal transport for long-running teammate sessions.',
error: null,
platformLabel: 'Windows',
locationLabel: null,
@ -94,8 +94,8 @@ describe('TmuxInstallerBannerView', () => {
const { host, root } = renderBanner(baseViewModel);
expect(host.textContent).toContain('tmux is not installed');
expect(host.textContent).toContain('Optional, but recommended');
expect(host.textContent).toContain('multi-agent teams that mix providers');
expect(host.textContent).toContain('Optional');
expect(host.textContent).toContain('pane-based terminal transport');
expect(host.textContent).not.toContain(
'WSL is available, but no Linux distribution is installed yet.'
);

View file

@ -67,12 +67,9 @@ export function formatTmuxOptionalBenefits(platform: TmuxPlatform | null): strin
return null;
}
const mixedProviderLimit =
'Without tmux, creating multi-agent teams that mix providers may be blocked.';
if (platform === 'win32') {
return `Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`;
return 'Optional. The app works without tmux. Install WSL-backed tmux only if you want pane-based terminal transport for long-running teammate sessions.';
}
return `Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`;
return 'Optional. The app works without tmux. Install tmux only if you want pane-based terminal transport for long-running teammate sessions.';
}

View file

@ -33,6 +33,7 @@ import type {
UpdaterService,
} from '../services';
import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager';
import type { TeamDataService } from '../services/team/TeamDataService';
import type { TeamProvisioningService } from '../services/team/TeamProvisioningService';
import type { FastifyInstance } from 'fastify';
@ -47,6 +48,7 @@ export interface HttpServices {
recentProjectsFeature?: RecentProjectsFeatureFacade;
updaterService: UpdaterService;
sshConnectionManager: SshConnectionManager;
teamDataService?: TeamDataService;
teamProvisioningService?: TeamProvisioningService;
}
@ -59,7 +61,7 @@ export function registerHttpRoutes(
registerSessionRoutes(app, services);
registerSearchRoutes(app, services);
registerSubagentRoutes(app, services);
if (services.teamProvisioningService) {
if (services.teamProvisioningService || services.teamDataService) {
registerTeamRoutes(app, services);
}
registerNotificationRoutes(app);

View file

@ -1,4 +1,6 @@
import { validateTeamName } from '@main/ipc/guards';
import { validateTeammateName, validateTeamName } from '@main/ipc/guards';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { extractUserFlags, PROTECTED_CLI_FLAGS } from '@shared/utils/cliArgsParser';
import {
formatEffortLevelListForProvider,
isTeamEffortLevelForProvider,
@ -7,26 +9,44 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isTeamProviderId } from '@shared/utils/teamProvider';
import { isAbsolute } from 'path';
import { constants as fsConstants } from 'fs';
import { access } from 'fs/promises';
import { isAbsolute, join } from 'path';
import type { HttpServices } from './index';
import type { EffortLevel, TeamFastMode, TeamLaunchRequest } from '@shared/types/team';
import type {
EffortLevel,
TeamCreateConfigRequest,
TeamCreateRequest,
TeamFastMode,
TeamLaunchRequest,
} from '@shared/types/team';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:teams');
type LaunchBody = Omit<TeamLaunchRequest, 'teamName'>;
type CreateTeamBody = TeamCreateConfigRequest;
class HttpBadRequestError extends Error {}
class HttpFeatureUnavailableError extends Error {}
function getTeamProvisioningService(services: HttpServices) {
function getTeamProvisioningService(
services: HttpServices
): NonNullable<HttpServices['teamProvisioningService']> {
if (!services.teamProvisioningService) {
throw new HttpFeatureUnavailableError('Team runtime control is not available in this mode');
}
return services.teamProvisioningService;
}
function getTeamDataService(services: HttpServices): NonNullable<HttpServices['teamDataService']> {
if (!services.teamDataService) {
throw new HttpFeatureUnavailableError('Team data control is not available in this mode');
}
return services.teamDataService;
}
function getStatusCode(error: unknown, fallback: number = 500): number {
if (error instanceof HttpBadRequestError) {
return 400;
@ -37,11 +57,35 @@ function getStatusCode(error: unknown, fallback: number = 500): number {
if (error instanceof Error && error.name === 'RuntimeStaleEvidenceError') {
return 409;
}
if (error instanceof Error && error.message.startsWith('Team not found')) {
return 404;
}
if (error instanceof Error && error.message.startsWith('Team already exists')) {
return 409;
}
return fallback;
}
function shouldLogError(error: unknown): boolean {
return !(error instanceof HttpBadRequestError) && !(error instanceof HttpFeatureUnavailableError);
const statusCode = getStatusCode(error);
return (
statusCode >= 500 &&
!(error instanceof HttpBadRequestError) &&
!(error instanceof HttpFeatureUnavailableError)
);
}
function assertProvisioningTeamName(value: unknown): string {
const validated = validateTeamName(value);
if (!validated.valid) {
throw new HttpBadRequestError(validated.error ?? 'Invalid teamName');
}
const teamName = validated.value!;
const parts = teamName.split('-');
if (teamName.length > 64 || !parts.every((part) => /^[a-z0-9]+$/.test(part))) {
throw new HttpBadRequestError('teamName must be kebab-case [a-z0-9-], max 64 chars');
}
return teamName;
}
function assertAbsoluteCwd(cwd: unknown): string {
@ -82,6 +126,40 @@ function assertOptionalBoolean(value: unknown, fieldName: string): boolean | und
return value;
}
function assertOptionalCwd(value: unknown): string | undefined {
if (value == null) {
return undefined;
}
const cwd = assertOptionalString(value, 'cwd');
if (!cwd) {
return undefined;
}
if (!isAbsolute(cwd)) {
throw new HttpBadRequestError('cwd must be an absolute path');
}
return cwd;
}
function assertOptionalExtraCliArgs(value: unknown): string | undefined {
const extraCliArgs = assertOptionalString(value, 'extraCliArgs');
if (!extraCliArgs) {
return undefined;
}
if (extraCliArgs.length > 1024) {
throw new HttpBadRequestError('extraCliArgs too long (max 1024)');
}
const protectedFlags = extractUserFlags(extraCliArgs).filter((flag) =>
PROTECTED_CLI_FLAGS.has(flag)
);
if (protectedFlags.length > 0) {
throw new HttpBadRequestError(
`extraCliArgs contains app-managed flags: ${[...new Set(protectedFlags)].join(', ')}`
);
}
return extraCliArgs;
}
function assertOptionalEffort(
value: unknown,
providerId: TeamLaunchRequest['providerId']
@ -111,33 +189,92 @@ function assertOptionalFastMode(value: unknown): TeamFastMode | undefined {
return value;
}
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
const providerId =
payload.providerId == null
? 'anthropic'
: isTeamProviderId(payload.providerId)
? payload.providerId
: (() => {
throw new HttpBadRequestError(
'providerId must be anthropic, codex, gemini, or opencode'
);
})();
const prompt = assertOptionalString(payload.prompt, 'prompt');
const rawProviderBackendId = assertOptionalString(payload.providerBackendId, 'providerBackendId');
function parseProviderId(value: unknown): TeamLaunchRequest['providerId'] {
if (value == null) {
return 'anthropic';
}
if (isTeamProviderId(value)) {
return value;
}
throw new HttpBadRequestError('providerId must be anthropic, codex, gemini, or opencode');
}
function parseProviderBackendId(
providerId: TeamLaunchRequest['providerId'],
value: unknown
): TeamLaunchRequest['providerBackendId'] | undefined {
const rawProviderBackendId = assertOptionalString(value, 'providerBackendId');
const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId);
if (rawProviderBackendId && !providerBackendId) {
throw new HttpBadRequestError(
'providerBackendId must be one of auto, adapter, api, cli-sdk, or codex-native'
);
}
return providerBackendId;
}
function parseCreateMembers(payloadMembers: unknown): TeamCreateConfigRequest['members'] {
if (payloadMembers == null) {
return [];
}
if (!Array.isArray(payloadMembers)) {
throw new HttpBadRequestError('members must be an array');
}
const seenNames = new Set<string>();
return payloadMembers.map((member) => {
if (!member || typeof member !== 'object') {
throw new HttpBadRequestError('member must be object');
}
const rawMember = member as Record<string, unknown>;
const nameValidation = validateTeammateName(rawMember.name);
if (!nameValidation.valid) {
throw new HttpBadRequestError(nameValidation.error ?? 'Invalid member name');
}
const name = nameValidation.value!;
if (seenNames.has(name)) {
throw new HttpBadRequestError('member names must be unique');
}
seenNames.add(name);
const role = assertOptionalString(rawMember.role, 'member role');
const workflow = assertOptionalString(rawMember.workflow, 'member workflow');
if (rawMember.isolation !== undefined && rawMember.isolation !== 'worktree') {
throw new HttpBadRequestError('member isolation must be "worktree" when provided');
}
const providerId =
rawMember.providerId == null ? undefined : parseProviderId(rawMember.providerId);
const providerBackendId = parseProviderBackendId(providerId, rawMember.providerBackendId);
const model = assertOptionalString(rawMember.model, 'member model');
const effort = assertOptionalEffort(rawMember.effort, providerId);
const fastMode = assertOptionalFastMode(rawMember.fastMode);
return {
name,
...(role ? { role } : {}),
...(workflow ? { workflow } : {}),
...(rawMember.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
...(providerId ? { providerId } : {}),
...(providerBackendId ? { providerBackendId } : {}),
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(fastMode ? { fastMode } : {}),
};
});
}
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
const providerId = parseProviderId(payload.providerId);
const prompt = assertOptionalString(payload.prompt, 'prompt');
const providerBackendId = parseProviderBackendId(providerId, payload.providerBackendId);
const model = assertOptionalString(payload.model, 'model');
const effort = assertOptionalEffort(payload.effort, providerId);
const fastMode = assertOptionalFastMode(payload.fastMode);
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
const worktree = assertOptionalString(payload.worktree, 'worktree');
const extraCliArgs = assertOptionalString(payload.extraCliArgs, 'extraCliArgs');
const extraCliArgs = assertOptionalExtraCliArgs(payload.extraCliArgs);
return {
teamName,
@ -173,6 +310,150 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
};
}
function parseCreateTeamRequest(body: unknown): TeamCreateConfigRequest {
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
const teamName = assertProvisioningTeamName(payload.teamName);
const providerId = payload.providerId == null ? undefined : parseProviderId(payload.providerId);
const providerBackendId = parseProviderBackendId(providerId, payload.providerBackendId);
const displayName = assertOptionalString(payload.displayName, 'displayName');
const description = assertOptionalString(payload.description, 'description');
const color = assertOptionalString(payload.color, 'color');
const cwd = assertOptionalCwd(payload.cwd);
const prompt = assertOptionalString(payload.prompt, 'prompt');
const model = assertOptionalString(payload.model, 'model');
const effort = assertOptionalEffort(payload.effort, providerId);
const fastMode = assertOptionalFastMode(payload.fastMode);
const limitContext = assertOptionalBoolean(payload.limitContext, 'limitContext');
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
const worktree = assertOptionalString(payload.worktree, 'worktree');
const extraCliArgs = assertOptionalExtraCliArgs(payload.extraCliArgs);
return {
teamName,
members: parseCreateMembers(payload.members),
...(displayName ? { displayName } : {}),
...(description ? { description } : {}),
...(color ? { color } : {}),
...(cwd ? { cwd } : {}),
...(prompt ? { prompt } : {}),
...(providerId ? { providerId } : {}),
...(providerBackendId ? { providerBackendId } : {}),
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(fastMode ? { fastMode } : {}),
...(limitContext !== undefined ? { limitContext } : {}),
...(skipPermissions !== undefined ? { skipPermissions } : {}),
...(worktree ? { worktree } : {}),
...(extraCliArgs ? { extraCliArgs } : {}),
};
}
function getObjectPayload(body: unknown): Record<string, unknown> {
return body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
}
function pickOptionalString(
payload: Record<string, unknown>,
key: string,
fallback: string | undefined,
fieldName: string
): string | undefined {
return Object.hasOwn(payload, key) ? assertOptionalString(payload[key], fieldName) : fallback;
}
function pickOptionalBoolean(
payload: Record<string, unknown>,
key: string,
fallback: boolean | undefined,
fieldName: string
): boolean | undefined {
return Object.hasOwn(payload, key) ? assertOptionalBoolean(payload[key], fieldName) : fallback;
}
function parseDraftLaunchCreateRequest(
savedRequest: TeamCreateRequest,
body: unknown
): TeamCreateRequest {
const payload = getObjectPayload(body);
const cwd = Object.hasOwn(payload, 'cwd') ? assertAbsoluteCwd(payload.cwd) : savedRequest.cwd;
if (!cwd) {
throw new HttpBadRequestError('cwd is required');
}
const providerId = Object.hasOwn(payload, 'providerId')
? parseProviderId(payload.providerId)
: (savedRequest.providerId ?? 'anthropic');
const providerBackendId = parseProviderBackendId(
providerId,
Object.hasOwn(payload, 'providerBackendId')
? payload.providerBackendId
: savedRequest.providerBackendId
);
const effort = assertOptionalEffort(
Object.hasOwn(payload, 'effort') ? payload.effort : savedRequest.effort,
providerId
);
const fastMode = Object.hasOwn(payload, 'fastMode')
? assertOptionalFastMode(payload.fastMode)
: savedRequest.fastMode;
const extraCliArgs = Object.hasOwn(payload, 'extraCliArgs')
? assertOptionalExtraCliArgs(payload.extraCliArgs)
: savedRequest.extraCliArgs;
if (extraCliArgs) {
assertOptionalExtraCliArgs(extraCliArgs);
}
return {
teamName: savedRequest.teamName,
displayName: savedRequest.displayName,
description: savedRequest.description,
color: savedRequest.color,
members: savedRequest.members,
cwd,
prompt: pickOptionalString(payload, 'prompt', savedRequest.prompt, 'prompt'),
providerId,
...(providerBackendId ? { providerBackendId } : {}),
model: pickOptionalString(payload, 'model', savedRequest.model, 'model'),
...(effort ? { effort } : {}),
...(fastMode ? { fastMode } : {}),
limitContext: pickOptionalBoolean(
payload,
'limitContext',
savedRequest.limitContext,
'limitContext'
),
skipPermissions: pickOptionalBoolean(
payload,
'skipPermissions',
savedRequest.skipPermissions,
'skipPermissions'
),
worktree: pickOptionalString(payload, 'worktree', savedRequest.worktree, 'worktree'),
...(extraCliArgs ? { extraCliArgs } : {}),
};
}
async function getDraftSavedRequest(
services: HttpServices,
teamName: string
): Promise<TeamCreateRequest | null> {
if (!services.teamDataService) {
return null;
}
const configPath = join(getTeamsBasePath(), teamName, 'config.json');
try {
await access(configPath, fsConstants.F_OK);
return null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
return getTeamDataService(services).getSavedRequest(teamName);
}
function withRuntimeTeamName(teamName: string, body: unknown): Record<string, unknown> {
const payload =
body && typeof body === 'object' && !Array.isArray(body)
@ -186,6 +467,56 @@ function withRuntimeTeamName(teamName: string, body: unknown): Record<string, un
}
export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void {
app.get('/api/teams', async (_request, reply) => {
try {
return reply.send(await getTeamDataService(services).listTeams());
} catch (error) {
if (shouldLogError(error)) {
logger.error('Error in GET /api/teams:', getErrorMessage(error));
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
});
app.post<{ Body: CreateTeamBody }>('/api/teams', async (request, reply) => {
try {
const createRequest = parseCreateTeamRequest(request.body);
await getTeamDataService(services).createTeamConfig(createRequest);
return reply.status(201).send({ teamName: createRequest.teamName });
} catch (error) {
if (shouldLogError(error)) {
logger.error('Error in POST /api/teams:', getErrorMessage(error));
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
});
app.get<{ Params: { teamName: string } }>('/api/teams/:teamName', async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
const teamName = validatedTeamName.value!;
const draftSavedRequest = await getDraftSavedRequest(services, teamName);
if (draftSavedRequest) {
return reply.send({
teamName,
pendingCreate: true,
savedRequest: draftSavedRequest,
});
}
return reply.send(await getTeamDataService(services).getTeamData(teamName));
} catch (error) {
if (shouldLogError(error)) {
logger.error(`Error in GET /api/teams/${request.params.teamName}:`, getErrorMessage(error));
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
});
app.post<{ Params: { teamName: string }; Body: LaunchBody }>(
'/api/teams/:teamName/launch',
async (request, reply) => {
@ -195,11 +526,17 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
return reply.status(400).send({ error: validatedTeamName.error });
}
const launchRequest = parseLaunchRequest(validatedTeamName.value!, request.body);
const response = await getTeamProvisioningService(services).launchTeam(
launchRequest,
() => undefined
);
const teamName = validatedTeamName.value!;
const draftSavedRequest = await getDraftSavedRequest(services, teamName);
const response = draftSavedRequest
? await getTeamProvisioningService(services).createTeam(
parseDraftLaunchCreateRequest(draftSavedRequest, request.body),
() => undefined
)
: await getTeamProvisioningService(services).launchTeam(
parseLaunchRequest(teamName, request.body),
() => undefined
);
return reply.send(response);
} catch (error) {
const statusCode = getStatusCode(error);

View file

@ -1337,6 +1337,7 @@ async function startHttpServer(
recentProjectsFeature,
updaterService,
sshConnectionManager,
teamDataService,
teamProvisioningService,
},
modeSwitchHandler,

View file

@ -1222,8 +1222,9 @@ function isValidEffort(value: unknown, providerId?: TeamProviderId | null): valu
return isTeamEffortLevelForProvider(value, providerId);
}
function parseOptionalMemberProviderId(
value: unknown
function parseOptionalProviderId(
value: unknown,
fieldName: string
): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } {
if (value === undefined || value === null || value === '') {
return { valid: true, value: undefined };
@ -1231,7 +1232,19 @@ function parseOptionalMemberProviderId(
if (isTeamProviderId(value)) {
return { valid: true, value };
}
return { valid: false, error: 'member providerId must be anthropic, codex, gemini, or opencode' };
return { valid: false, error: `${fieldName} must be anthropic, codex, gemini, or opencode` };
}
function parseOptionalMemberProviderId(
value: unknown
): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } {
return parseOptionalProviderId(value, 'member providerId');
}
function parseOptionalTeamProviderId(
value: unknown
): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } {
return parseOptionalProviderId(value, 'providerId');
}
function parseOptionalProviderBackendId(
@ -1611,6 +1624,13 @@ async function validateProvisioningRequest(
if (!providerValidation.valid) {
return { valid: false, error: providerValidation.error };
}
const providerBackendValidation = parseOptionalProviderBackendId(
(member as { providerBackendId?: unknown }).providerBackendId,
providerValidation.value
);
if (!providerBackendValidation.valid) {
return { valid: false, error: providerBackendValidation.error };
}
const model = (member as { model?: unknown }).model;
if (model !== undefined && typeof model !== 'string') {
return { valid: false, error: 'member model must be string' };
@ -1622,14 +1642,22 @@ async function validateProvisioningRequest(
if (!effortValidation.valid) {
return { valid: false, error: effortValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(
(member as { fastMode?: unknown }).fastMode
);
if (!fastModeValidation.valid) {
return { valid: false, error: fastModeValidation.error };
}
members.push({
name: memberName,
role: typeof role === 'string' ? role.trim() : undefined,
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: providerValidation.value,
providerBackendId: providerBackendValidation.value,
model: typeof model === 'string' ? model.trim() || undefined : undefined,
effort: effortValidation.value,
fastMode: fastModeValidation.value,
});
}
@ -1858,61 +1886,60 @@ async function handleLaunchTeam(
}
if (isDraft) {
const meta = await teamMetaStore.getMeta(tn);
const membersStore = new TeamMembersMetaStore();
const membersMeta = await membersStore.getMeta(tn);
const members = membersMeta?.members ?? [];
const savedRequest = await getTeamDataService().getSavedRequest(tn);
if (!savedRequest) {
return { success: false, error: `Missing saved request for draft team: ${tn}` };
}
const resolvedProviderId =
providerId === 'codex' || providerId === 'gemini'
? providerId
: meta?.providerId === 'codex'
? 'codex'
: meta?.providerId === 'gemini'
? 'gemini'
: 'anthropic';
const effortValidation = parseOptionalTeamEffort(payload.effort, resolvedProviderId);
const resolvedProviderId = explicitProviderId ?? savedRequest.providerId ?? providerId;
const effortValidation = parseOptionalTeamEffort(
payload.effort ?? savedRequest.effort,
resolvedProviderId
);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode ?? savedRequest.fastMode);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
const createRequest: TeamCreateRequest = {
teamName: tn,
displayName: meta?.displayName,
description: meta?.description,
color: meta?.color,
displayName: savedRequest.displayName,
description: savedRequest.description,
color: savedRequest.color,
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
prompt:
typeof payload.prompt === 'string'
? payload.prompt.trim() || undefined
: savedRequest.prompt,
providerId: resolvedProviderId,
providerBackendId: migrateProviderBackendId(
resolvedProviderId,
providerBackendValidation.value ?? meta?.providerBackendId ?? membersMeta?.providerBackendId
providerBackendValidation.value ?? savedRequest.providerBackendId
),
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
model:
typeof payload.model === 'string' ? payload.model.trim() || undefined : savedRequest.model,
effort: effortValidation.value,
fastMode: fastModeValidation.value ?? meta?.fastMode,
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
fastMode: fastModeValidation.value,
limitContext:
typeof payload.limitContext === 'boolean'
? payload.limitContext
: savedRequest.limitContext,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
typeof payload.skipPermissions === 'boolean'
? payload.skipPermissions
: savedRequest.skipPermissions,
worktree:
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
typeof payload.worktree === 'string'
? payload.worktree.trim() || undefined
: savedRequest.worktree,
extraCliArgs:
typeof payload.extraCliArgs === 'string'
? payload.extraCliArgs.trim() || undefined
: undefined,
members: members.map((m) => ({
name: m.name,
role: m.role,
workflow: m.workflow,
isolation: m.isolation,
providerId: m.providerId,
model: m.model,
effort: m.effort,
})),
: savedRequest.extraCliArgs,
members: savedRequest.members,
};
return wrapTeamHandler('create', () =>
@ -3147,14 +3174,69 @@ async function handleCreateConfig(
return { success: false, error: 'cwd must be an absolute path' };
}
}
const providerBackendValidation = parseOptionalProviderBackendId(payload.providerBackendId);
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
return { success: false, error: 'prompt must be a string' };
}
const providerValidation = parseOptionalTeamProviderId(payload.providerId);
if (!providerValidation.valid) {
return { success: false, error: providerValidation.error };
}
const providerBackendValidation = parseOptionalProviderBackendId(
payload.providerBackendId,
providerValidation.value
);
if (!providerBackendValidation.valid) {
return { success: false, error: providerBackendValidation.error };
}
if (payload.model !== undefined && typeof payload.model !== 'string') {
return { success: false, error: 'model must be a string' };
}
const effortValidation = parseOptionalTeamEffort(payload.effort, providerValidation.value);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
if (payload.limitContext !== undefined && typeof payload.limitContext !== 'boolean') {
return { success: false, error: 'limitContext must be a boolean' };
}
if (payload.skipPermissions !== undefined && typeof payload.skipPermissions !== 'boolean') {
return { success: false, error: 'skipPermissions must be a boolean' };
}
if (payload.worktree !== undefined) {
if (typeof payload.worktree !== 'string') {
return { success: false, error: 'worktree must be a string' };
}
const worktree = payload.worktree.trim();
if (worktree.length > 128) {
return { success: false, error: 'worktree name too long (max 128)' };
}
if (worktree && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(worktree)) {
return {
success: false,
error: 'worktree name: start with alphanumeric, use [a-zA-Z0-9._-]',
};
}
}
if (payload.extraCliArgs !== undefined) {
if (typeof payload.extraCliArgs !== 'string') {
return { success: false, error: 'extraCliArgs must be a string' };
}
if (payload.extraCliArgs.length > 1024) {
return { success: false, error: 'extraCliArgs too long (max 1024)' };
}
const protectedFlags = extractUserFlags(payload.extraCliArgs).filter((flag) =>
PROTECTED_CLI_FLAGS.has(flag)
);
if (protectedFlags.length > 0) {
return {
success: false,
error: `extraCliArgs contains app-managed flags: ${[...new Set(protectedFlags)].join(', ')}`,
};
}
}
const seenNames = new Set<string>();
const members: TeamCreateConfigRequest['members'] = [];
@ -3190,6 +3272,13 @@ async function handleCreateConfig(
if (!providerValidation.valid) {
return { success: false, error: providerValidation.error };
}
const providerBackendValidation = parseOptionalProviderBackendId(
(member as { providerBackendId?: unknown }).providerBackendId,
providerValidation.value
);
if (!providerBackendValidation.valid) {
return { success: false, error: providerBackendValidation.error };
}
const model = (member as { model?: unknown }).model;
if (model !== undefined && typeof model !== 'string') {
return { success: false, error: 'member model must be string' };
@ -3201,14 +3290,22 @@ async function handleCreateConfig(
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(
(member as { fastMode?: unknown }).fastMode
);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
members.push({
name: memberName,
role: typeof role === 'string' ? role.trim() : undefined,
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: providerValidation.value,
providerBackendId: providerBackendValidation.value,
model: typeof model === 'string' ? model.trim() || undefined : undefined,
effort: effortValidation.value,
fastMode: fastModeValidation.value,
});
}
@ -3220,8 +3317,23 @@ async function handleCreateConfig(
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
members,
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
providerId: providerValidation.value,
providerBackendId: providerBackendValidation.value,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: effortValidation.value,
fastMode: fastModeValidation.value,
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
worktree:
typeof payload.worktree === 'string' && payload.worktree.trim()
? payload.worktree.trim()
: undefined,
extraCliArgs:
typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
? payload.extraCliArgs.trim()
: undefined,
})
);
}
@ -4696,52 +4808,9 @@ async function handleGetSavedRequest(
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
const tn = validated.value!;
const meta = await teamMetaStore.getMeta(tn);
if (!meta) {
return { success: true, data: null };
}
const membersStore = new TeamMembersMetaStore();
const membersMeta = await membersStore.getMeta(tn);
const members = membersMeta?.members ?? [];
const resolvedProviderId = meta.providerId ?? 'anthropic';
return {
success: true,
data: {
teamName: tn,
displayName: meta.displayName,
description: meta.description,
color: meta.color,
cwd: meta.cwd,
prompt: meta.prompt,
providerId: resolvedProviderId,
providerBackendId: migrateProviderBackendId(
resolvedProviderId,
meta.providerBackendId ?? membersMeta?.providerBackendId
),
model: meta.model,
effort: meta.effort as TeamCreateRequest['effort'],
fastMode: meta.fastMode,
skipPermissions: meta.skipPermissions,
worktree: meta.worktree,
extraCliArgs: meta.extraCliArgs,
limitContext: meta.limitContext,
members: members.map((m) => ({
name: m.name,
role: m.role,
workflow: m.workflow,
isolation: m.isolation,
cwd: m.cwd,
providerId: m.providerId,
model: m.model,
effort: m.effort,
})),
},
};
return wrapTeamHandler('getSavedRequest', async () => {
return getTeamDataService().getSavedRequest(validated.value!);
});
}
async function handleDeleteDraft(

View file

@ -9,11 +9,12 @@ import {
} from '@shared/utils/taskChangeState';
import { createHash } from 'crypto';
import { existsSync } from 'fs';
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises';
import { chmod, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository';
import { OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION } from './opencode/bridge/OpenCodeBridgeCommandContract';
import {
getOpenCodeLaneScopedRuntimeFilePath,
getOpenCodeTeamRuntimeDirectory,
@ -46,7 +47,7 @@ import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types
const logger = createLogger('Service:ChangeExtractorService');
const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const;
const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE = 'chain-only' as const;
const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE = 'opencode-session-snapshot-v1' as const;
const OPEN_CODE_MAX_DISCOVERED_LANES = 500;
/** Кеш-запись: данные + mtime файла + время протухания */
@ -71,18 +72,29 @@ interface OpenCodeBackfillCacheEntry {
expiresAt: number;
}
interface OpenCodeBackfillAttempt {
attempted: boolean;
backfilled: boolean;
}
interface OpenCodeDeliveryContextTempFile {
filePath: string | null;
hash: string | null;
cleanup: () => Promise<void>;
}
interface OpenCodeDeliveryContextPayload {
rawContext: string;
hash: string;
}
export class ChangeExtractorService {
private cache = new Map<string, CacheEntry>();
private taskChangeSummaryCache = new Map<string, TaskChangeSummaryCacheEntry>();
private taskChangeSummaryInFlight = new Map<string, Promise<TaskChangeSetV2>>();
private taskChangeSummaryVersionByTask = new Map<string, number>();
private taskChangeSummaryValidationInFlight = new Set<string>();
private openCodeBackfillInFlight = new Map<string, Promise<boolean>>();
private openCodeBackfillInFlight = new Map<string, Promise<OpenCodeBackfillAttempt>>();
private openCodeBackfillCache = new Map<string, OpenCodeBackfillCacheEntry>();
private openCodeTeamEligibilityCache = new Map<string, { value: boolean; expiresAt: number }>();
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
@ -210,7 +222,8 @@ export class ChangeExtractorService {
return ledgerResult;
}
if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
const openCodeBackfill = await this.tryBackfillOpenCodeLedger(resolvedInput);
if (openCodeBackfill.backfilled || openCodeBackfill.attempted) {
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
if (backfilledLedgerResult) {
await this.recordTaskChangePresence(
@ -379,15 +392,17 @@ export class ChangeExtractorService {
}
}
private async tryBackfillOpenCodeLedger(input: ResolvedTaskChangeComputeInput): Promise<boolean> {
private async tryBackfillOpenCodeLedger(
input: ResolvedTaskChangeComputeInput
): Promise<OpenCodeBackfillAttempt> {
if (!this.openCodeLedgerBackfillPort) {
return false;
return { attempted: false, backfilled: false };
}
if (!(await this.isOpenCodeTeamCandidate(input.teamName))) {
return false;
return { attempted: false, backfilled: false };
}
if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') {
return false;
return { attempted: false, backfilled: false };
}
const context = await this.logsFinder
@ -401,7 +416,7 @@ export class ChangeExtractorService {
!path.isAbsolute(projectDir) ||
!path.isAbsolute(workspaceRoot)
) {
return false;
return { attempted: false, backfilled: false };
}
const sourceGeneration = this.teamLogSourceTracker
@ -414,8 +429,16 @@ export class ChangeExtractorService {
input.teamName,
input.taskId
);
const deliveryContextFingerprint =
this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords);
const deliveryContextPayload = this.buildOpenCodeDeliveryContextPayload(
input.teamName,
input.taskId,
deliveryContextRecords
);
const backfillMemberName = this.resolveOpenCodeBackfillMemberName(
input.effectiveOptions.owner,
deliveryContextRecords
);
const deliveryContextFingerprint = deliveryContextPayload.hash;
const cacheKey = this.buildOpenCodeBackfillCacheKey({
teamName: input.teamName,
@ -426,12 +449,12 @@ export class ChangeExtractorService {
sourceGeneration,
deliveryContextFingerprint,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
evidenceMode: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
});
const now = Date.now();
const cached = this.openCodeBackfillCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.backfilledAt > 0;
return { attempted: false, backfilled: cached.backfilledAt > 0 };
}
this.openCodeBackfillCache.delete(cacheKey);
@ -446,15 +469,16 @@ export class ChangeExtractorService {
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: input.effectiveOptions.owner ?? null,
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
sourceGeneration,
deliveryRecordCount: 0,
deliveryContextFingerprint,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
}).catch(() => undefined);
return false;
return { attempted: false, backfilled: false };
}
const existing = this.openCodeBackfillInFlight.get(cacheKey);
@ -468,7 +492,9 @@ export class ChangeExtractorService {
workspaceRoot,
cacheKey,
deliveryContextRecords,
sourceGeneration
deliveryContextPayload,
sourceGeneration,
backfillMemberName
).finally(() => {
this.openCodeBackfillInFlight.delete(cacheKey);
});
@ -484,12 +510,15 @@ export class ChangeExtractorService {
deliveryContextRecords: Awaited<
ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>
>,
sourceGeneration: string | null
): Promise<boolean> {
deliveryContextPayload: OpenCodeDeliveryContextPayload,
sourceGeneration: string | null,
backfillMemberName?: string
): Promise<OpenCodeBackfillAttempt> {
const deliveryContext = await this.createOpenCodeDeliveryContextTempFile(
input.teamName,
input.taskId,
deliveryContextRecords
deliveryContextRecords,
deliveryContextPayload
);
try {
const result = await this.openCodeLedgerBackfillPort!.backfillOpenCodeTaskLedger({
@ -497,26 +526,48 @@ export class ChangeExtractorService {
teamName: input.teamName,
taskId: input.taskId,
taskDisplayId: input.taskMeta?.displayId,
memberName: input.effectiveOptions.owner,
projectDir,
workspaceRoot,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
evidenceMode: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE,
...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}),
...(backfillMemberName ? { memberName: backfillMemberName } : {}),
...(deliveryContext.filePath
? {
deliveryContextPath: deliveryContext.filePath,
deliveryContextHash: deliveryContext.hash ?? undefined,
}
: {}),
});
const evidenceContractVersion =
typeof result.opencodeTaskLedgerEvidenceContractVersion === 'number' &&
Number.isInteger(result.opencodeTaskLedgerEvidenceContractVersion)
? result.opencodeTaskLedgerEvidenceContractVersion
: 0;
const hasExpectedEvidenceContract =
evidenceContractVersion >= OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION;
const diagnostics = hasExpectedEvidenceContract
? (result.diagnostics ?? [])
: [
`OpenCode task ledger evidence contract is unsupported or missing: ${evidenceContractVersion}.`,
...(result.diagnostics ?? []),
];
void appendOpenCodeTaskChangeDiag({
event: 'backfill_result',
reason: this.classifyOpenCodeBackfillResult(result),
reason:
!hasExpectedEvidenceContract && result.importedEvents <= 0
? 'unsupported-evidence-contract'
: this.classifyOpenCodeBackfillResult(result),
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: input.effectiveOptions.owner ?? null,
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
sourceGeneration,
deliveryRecordCount: deliveryContextRecords.length,
deliveryContextFingerprint: this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords),
deliveryContextFingerprint: deliveryContextPayload.hash,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
result: {
opencodeTaskLedgerEvidenceContractVersion: evidenceContractVersion,
attributionMode: result.attributionMode ?? OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
outcome: result.outcome,
dryRun: result.dryRun,
@ -526,13 +577,14 @@ export class ChangeExtractorService {
importedEvents: result.importedEvents,
skippedEvents: result.skippedEvents,
},
diagnostics: (result.diagnostics ?? []).slice(0, 25),
diagnostics: diagnostics.slice(0, 25),
notices: (result.notices ?? []).slice(0, 25),
}).catch(() => undefined);
const backfilled =
result.importedEvents > 0 ||
result.outcome === 'imported' ||
(result.outcome === 'duplicates-only' && result.candidateEvents > 0);
(hasExpectedEvidenceContract &&
(result.outcome === 'imported' ||
(result.outcome === 'duplicates-only' && result.candidateEvents > 0)));
if (result.importedEvents > 0) {
await this.invalidateTaskChangeSummaries(input.teamName, [input.taskId], {
@ -540,7 +592,7 @@ export class ChangeExtractorService {
});
}
if (backfilled || deliveryContextRecords.length === 0) {
if ((hasExpectedEvidenceContract && backfilled) || deliveryContextRecords.length === 0) {
this.openCodeBackfillCache.set(cacheKey, {
backfilledAt: backfilled ? Date.now() : 0,
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
@ -549,12 +601,12 @@ export class ChangeExtractorService {
this.openCodeBackfillCache.delete(cacheKey);
}
if (result.diagnostics.length > 0 && result.outcome !== 'no-history') {
if (diagnostics.length > 0 && result.outcome !== 'no-history') {
logger.debug(
`OpenCode ledger backfill for ${input.teamName}/${input.taskId}: ${result.outcome}; ${result.diagnostics.join('; ')}`
`OpenCode ledger backfill for ${input.teamName}/${input.taskId}: ${result.outcome}; ${diagnostics.join('; ')}`
);
}
return backfilled;
return { attempted: true, backfilled };
} catch (error) {
logger.warn(
`OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
@ -565,11 +617,12 @@ export class ChangeExtractorService {
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: input.effectiveOptions.owner ?? null,
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
deliveryRecordCount: deliveryContextRecords.length,
deliveryContextFingerprint: this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords),
deliveryContextFingerprint: deliveryContextPayload.hash,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
error: error instanceof Error ? error.message : String(error),
}).catch(() => undefined);
if (deliveryContextRecords.length === 0) {
@ -580,7 +633,7 @@ export class ChangeExtractorService {
} else {
this.openCodeBackfillCache.delete(cacheKey);
}
return false;
return { attempted: true, backfilled: false };
} finally {
await deliveryContext.cleanup();
}
@ -647,36 +700,47 @@ export class ChangeExtractorService {
private async createOpenCodeDeliveryContextTempFile(
teamName: string,
taskId: string,
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>,
payload = this.buildOpenCodeDeliveryContextPayload(teamName, taskId, records)
): Promise<OpenCodeDeliveryContextTempFile> {
if (records.length === 0) {
return { filePath: null, cleanup: async () => undefined };
return { filePath: null, hash: null, cleanup: async () => undefined };
}
const dir = await mkdtemp(path.join(os.tmpdir(), 'claude-team-opencode-ledger-context-'));
await chmod(dir, 0o700).catch(() => undefined);
const filePath = path.join(dir, 'delivery-context.json');
await writeFile(
filePath,
`${JSON.stringify(
{
schemaVersion: 1,
teamName,
taskId,
records,
},
null,
2
)}\n`,
{ encoding: 'utf8', mode: 0o600 }
);
await writeFile(filePath, payload.rawContext, { encoding: 'utf8', mode: 0o600 });
return {
filePath,
hash: payload.hash,
cleanup: async () => {
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
private buildOpenCodeDeliveryContextPayload(
teamName: string,
taskId: string,
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
): OpenCodeDeliveryContextPayload {
const rawContext = `${JSON.stringify(
{
schemaVersion: 1,
teamName,
taskId,
records,
},
null,
2
)}\n`;
return {
hash: createHash('sha256').update(rawContext).digest('hex'),
rawContext,
};
}
private async readOpenCodeDeliveryContextRecords(
teamName: string,
taskId: string
@ -748,6 +812,18 @@ export class ChangeExtractorService {
return records.slice(-200);
}
private resolveOpenCodeBackfillMemberName(
owner: string | undefined,
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
): string | undefined {
const members = [...new Set(records.map((record) => record.memberName.trim()).filter(Boolean))];
const normalizedOwner = owner?.trim();
if (normalizedOwner && members.includes(normalizedOwner)) {
return normalizedOwner;
}
return members.length === 1 ? members[0] : undefined;
}
private async readOpenCodeRuntimeLaneIdsFromDisk(
teamsBasePath: string,
teamName: string
@ -770,50 +846,6 @@ export class ChangeExtractorService {
return laneIds.sort((left, right) => left.localeCompare(right));
}
private hashOpenCodeDeliveryContextRecords(
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
): string {
const stableRecords = records
.map((record) => ({
memberName: record.memberName,
laneId: record.laneId ?? '',
runtimeSessionId: record.runtimeSessionId ?? '',
inboxMessageId: record.inboxMessageId ?? '',
deliveredUserMessageId: record.deliveredUserMessageId ?? '',
taskRefs: record.taskRefs
.map((taskRef) => ({
taskId: taskRef.taskId,
displayId: taskRef.displayId,
teamName: taskRef.teamName,
}))
.sort((left, right) =>
`${left.teamName}\0${left.taskId}\0${left.displayId}`.localeCompare(
`${right.teamName}\0${right.taskId}\0${right.displayId}`
)
),
}))
.sort((left, right) =>
[
left.laneId,
left.memberName,
left.runtimeSessionId,
left.inboxMessageId,
left.deliveredUserMessageId,
]
.join('\0')
.localeCompare(
[
right.laneId,
right.memberName,
right.runtimeSessionId,
right.inboxMessageId,
right.deliveredUserMessageId,
].join('\0')
)
);
return createHash('sha256').update(JSON.stringify(stableRecords)).digest('hex');
}
private async readOpenCodePromptDeliveryLedgerRecords(
filePath: string
): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
@ -841,7 +873,7 @@ export class ChangeExtractorService {
sourceGeneration?: string | null;
deliveryContextFingerprint: string;
attributionMode: typeof OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE;
evidenceMode: typeof OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE;
evidencePipeline: typeof OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE;
}): string {
return JSON.stringify({
teamName: input.teamName,
@ -852,7 +884,7 @@ export class ChangeExtractorService {
sourceGeneration: input.sourceGeneration ?? '',
deliveryContextFingerprint: input.deliveryContextFingerprint,
attributionMode: input.attributionMode,
evidenceMode: input.evidenceMode,
evidencePipeline: input.evidencePipeline,
});
}

View file

@ -293,6 +293,7 @@ export class ReviewApplierService {
decision.fileDecision === 'rejected',
allHunksRejected,
rejectedHunkIndices,
decision.hunkContextHashes,
fileContent.snippets
);
if (ledgerOutcome.handled) {
@ -450,6 +451,7 @@ export class ReviewApplierService {
fileRejected: boolean,
allHunksRejected: boolean,
rejectedHunkIndices: number[],
hunkContextHashes: Record<number, string> | undefined,
snippets: SnippetDiff[]
): Promise<LedgerApplyOutcome> {
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError);
@ -497,6 +499,20 @@ export class ReviewApplierService {
error: 'Ledger full text is unavailable; partial reject requires manual review.',
};
}
const strictHunks = mapRejectedHunkIndicesByHashStrict(
original,
modified,
rejectedHunkIndices,
hunkContextHashes
);
if (!strictHunks.ok) {
return {
handled: true,
status: strictHunks.code === 'conflict' ? 'conflict' : 'error',
code: strictHunks.code,
error: strictHunks.error,
};
}
const guard = await this.checkLedgerCurrentHash(
filePath,
lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
@ -504,7 +520,7 @@ export class ReviewApplierService {
if (!guard.ok) {
return guard.outcome;
}
const patchResult = this.tryHunkLevelReject(original, modified, rejectedHunkIndices);
const patchResult = this.tryStrictHunkLevelReject(original, modified, strictHunks.indices);
if (!patchResult) {
return {
handled: true,
@ -1035,6 +1051,46 @@ export class ReviewApplierService {
hadConflicts: false,
};
}
private tryStrictHunkLevelReject(
original: string,
modified: string,
hunkIndices: number[]
): RejectResult | null {
const patch = structuredPatch('file', 'file', original, modified);
if (!patch.hunks || patch.hunks.length === 0) return null;
const validIndices = hunkIndices.filter((idx) => idx >= 0 && idx < patch.hunks.length);
if (validIndices.length !== hunkIndices.length || validIndices.length === 0) return null;
const inversedHunks: StructuredPatchHunk[] = [];
for (const idx of validIndices) {
const hunk = patch.hunks[idx];
if (!hunk) return null;
inversedHunks.push(invertHunk(hunk));
}
const inversePatch = {
oldFileName: 'file',
newFileName: 'file',
oldHeader: undefined,
newHeader: undefined,
hunks: inversedHunks,
};
const result = applyPatch(modified, inversePatch, { fuzzFactor: 0 });
if (result === false) {
logger.debug('Strict ledger hunk-level inverse patch не удался');
return null;
}
return {
success: true,
newContent: result,
hadConflicts: false,
};
}
}
function buildHunkHashIndexMap(original: string, modified: string): Map<string, number[]> {
@ -1086,6 +1142,54 @@ function mapRejectedHunkIndicesByHash(
return [...out].sort((a, b) => a - b);
}
function mapRejectedHunkIndicesByHashStrict(
original: string,
modified: string,
rejectedIndices: number[],
hunkContextHashes: Record<number, string> | undefined
): { ok: true; indices: number[] } | { ok: false; code: ApplyErrorCode; error: string } {
if (rejectedIndices.length === 0) {
return { ok: true, indices: [] };
}
if (!hunkContextHashes || Object.keys(hunkContextHashes).length === 0) {
return {
ok: false,
code: 'manual-review-required',
error: 'Ledger partial reject requires stable hunk context hashes.',
};
}
const hashMap = buildHunkHashIndexMap(original, modified);
const out = new Set<number>();
for (const idx of rejectedIndices) {
const hash = hunkContextHashes[idx];
if (!hash) {
return {
ok: false,
code: 'manual-review-required',
error: 'Ledger partial reject is missing a hunk context hash.',
};
}
const candidates = hashMap.get(hash);
if (!candidates || candidates.length === 0) {
return {
ok: false,
code: 'conflict',
error: 'Ledger partial reject hunk context changed; please re-review.',
};
}
if (candidates.length > 1) {
return {
ok: false,
code: 'conflict',
error: 'Ledger partial reject hunk context is ambiguous; please re-review.',
};
}
out.add(candidates[0]!);
}
return { ok: true, indices: [...out].sort((a, b) => a - b) };
}
// ── Module-level helpers ──
/**

View file

@ -129,6 +129,13 @@ interface LedgerEvent {
linesRemoved?: number;
replaceAll?: boolean;
warnings?: string[];
sourceRuntime?: 'opencode';
sourceProvider?: 'opencode';
sourceImportKey?: string;
evidenceProof?: string;
supersedesEventId?: string;
snapshotId?: string;
snapshotSource?: string;
}
interface LedgerNotice {
@ -196,7 +203,7 @@ interface LedgerSummaryScopeV2 {
primaryAgentId?: string;
primaryMemberName?: string;
memberName: string;
agentIds: string[];
agentIds?: string[];
memberNames?: string[];
startTimestamp: string;
endTimestamp: string;
@ -428,11 +435,7 @@ export class TaskChangeLedgerReader {
return null;
}
const provenance = this.buildLedgerProvenance(
bundle.journalStamp,
bundle.integrity,
bundle.schemaVersion
);
const provenance = this.buildLedgerProvenanceFromSummaryBundle(bundle);
if (
freshness &&
@ -450,11 +453,7 @@ export class TaskChangeLedgerReader {
) {
return {
bundle,
provenance: this.buildLedgerProvenance(
journalStamp,
bundle.integrity,
bundle.schemaVersion
),
provenance: this.buildLedgerProvenanceFromSummaryBundle(bundle, journalStamp),
mode: 'validated',
};
}
@ -694,6 +693,86 @@ export class TaskChangeLedgerReader {
return this.buildLedgerProvenance(journalStamp, integrity, bundleSchemaVersion);
}
private buildLedgerProvenanceFromSummaryBundle(
bundle: LedgerSummaryBundleV2,
journalStamp: TaskChangeJournalStamp = bundle.journalStamp
): TaskChangeProvenance {
return {
sourceKind: 'ledger',
sourceFingerprint: this.hashFingerprintPayload(this.buildProjectedSummaryIdentity(bundle)),
journalStamp,
bundleSchemaVersion: bundle.schemaVersion,
integrity: bundle.integrity,
};
}
private buildProjectedSummaryIdentity(bundle: LedgerSummaryBundleV2): unknown {
return {
kind: 'ledger-summary-v2-projected-identity',
schemaVersion: bundle.schemaVersion,
bundleKind: bundle.bundleKind,
taskId: bundle.taskId,
integrity: bundle.integrity,
totalFiles: bundle.totalFiles,
totalLinesAdded: bundle.totalLinesAdded,
totalLinesRemoved: bundle.totalLinesRemoved,
diffStatCompleteness: bundle.diffStatCompleteness,
confidence: bundle.confidence,
files: [...bundle.files]
.map((file) => ({
changeKey: this.normalizeSummaryChangeKey(file),
filePath: normalizePathForComparison(file.filePath),
relativePath: normalizePathForComparison(file.relativePath),
displayPath: file.displayPath ? normalizePathForComparison(file.displayPath) : undefined,
linesAdded: file.linesAdded,
linesRemoved: file.linesRemoved,
diffStatKnown: file.diffStatKnown,
latestOperation: file.latestOperation,
createdInTask: file.createdInTask,
deletedInTask: file.deletedInTask,
baselineExists: file.baselineExists,
finalExists: file.finalExists,
latestBeforeHash: file.latestBeforeHash,
latestAfterHash: file.latestAfterHash,
latestBeforeState: this.contentStateFingerprint(file.latestBeforeState),
latestAfterState: this.contentStateFingerprint(file.latestAfterState),
contentAvailability: file.contentAvailability,
reviewability: file.reviewability,
relation: file.relation
? {
kind: file.relation.kind,
oldPath: normalizePathForComparison(file.relation.oldPath),
newPath: normalizePathForComparison(file.relation.newPath),
}
: undefined,
worktreePath: file.worktreePath
? normalizePathForComparison(file.worktreePath)
: undefined,
worktreeBranch: file.worktreeBranch,
baseWorkspaceRoot: file.baseWorkspaceRoot
? normalizePathForComparison(file.baseWorkspaceRoot)
: undefined,
}))
.sort(
(left, right) =>
left.changeKey.localeCompare(right.changeKey) ||
left.filePath.localeCompare(right.filePath)
),
};
}
private contentStateFingerprint(state: LedgerContentState | undefined): unknown {
if (!state) {
return undefined;
}
return {
exists: state.exists,
sha256: state.sha256,
sizeBytes: state.sizeBytes,
unavailableReason: state.unavailableReason,
};
}
private hashFingerprintPayload(payload: unknown): string {
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
@ -793,9 +872,10 @@ export class TaskChangeLedgerReader {
bundle?: LedgerSummaryBundleV2;
provenance: TaskChangeProvenance;
}): Promise<TaskChangeSetV2> {
const snippets = await this.buildSnippets(params.projectDir, params.journal.events);
const projectedEvents = this.projectJournalEventsForUi(params.journal.events);
const snippets = await this.buildSnippets(params.projectDir, projectedEvents);
const groupedSnippets = this.groupSnippets(snippets);
const warnings = this.collectWarnings(params.journal.events, params.journal.notices, {
const warnings = this.collectWarnings(projectedEvents, params.journal.notices, {
recovered: params.journal.recovered,
});
@ -836,15 +916,15 @@ export class TaskChangeLedgerReader {
totalLinesAdded = fallback.totalLinesAdded;
totalLinesRemoved = fallback.totalLinesRemoved;
totalFiles = fallback.files.length;
confidence = params.journal.events.some((event) => event.confidence === 'low')
confidence = projectedEvents.some((event) => event.confidence === 'low')
? 'low'
: params.journal.events.some((event) => event.confidence === 'medium')
: projectedEvents.some((event) => event.confidence === 'medium')
? 'medium'
: 'high';
scope = this.buildFallbackScope(
params.taskId,
files,
params.journal.events,
projectedEvents,
params.journal.notices
);
diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
@ -883,7 +963,8 @@ export class TaskChangeLedgerReader {
undefined,
params.journal.recovered ? 'recovered' : 'ok'
);
const snippets = params.journal.events.map((event) => this.eventToSnippet(event, null, null));
const projectedEvents = this.projectJournalEventsForUi(params.journal.events);
const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null));
const grouped = this.groupSnippets(snippets);
const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath);
return {
@ -893,20 +974,20 @@ export class TaskChangeLedgerReader {
totalLinesAdded: fallback.totalLinesAdded,
totalLinesRemoved: fallback.totalLinesRemoved,
totalFiles: fallback.files.length,
confidence: params.journal.events.some((event) => event.confidence === 'low')
confidence: projectedEvents.some((event) => event.confidence === 'low')
? 'low'
: params.journal.events.some((event) => event.confidence === 'medium')
: projectedEvents.some((event) => event.confidence === 'medium')
? 'medium'
: 'high',
computedAt: new Date().toISOString(),
scope: this.buildFallbackScope(
params.taskId,
fallback.files,
params.journal.events,
projectedEvents,
params.journal.notices
),
warnings: [
...this.collectWarnings(params.journal.events, params.journal.notices, {
...this.collectWarnings(projectedEvents, params.journal.notices, {
recovered: params.journal.recovered,
}),
'Task change summary fell back to journal reconstruction.',
@ -972,6 +1053,7 @@ export class TaskChangeLedgerReader {
private mapV2SummaryFile(file: LedgerSummaryFileV2, projectPath?: string): FileChangeSummary {
const displayPath = file.displayPath ?? file.filePath;
const filePath = this.normalizeLedgerFilePath(file.filePath);
const agentIds = Array.isArray(file.agentIds) ? file.agentIds : [];
return {
filePath,
relativePath: this.relativePath(displayPath, projectPath, file.relativePath),
@ -993,7 +1075,7 @@ export class TaskChangeLedgerReader {
...(file.latestBeforeState ? { beforeState: file.latestBeforeState } : {}),
...(file.latestAfterState ? { afterState: file.latestAfterState } : {}),
...(file.primaryActorKey ? { primaryActorKey: file.primaryActorKey } : {}),
...(file.agentIds.length > 0 ? { agentIds: file.agentIds } : {}),
...(agentIds.length > 0 ? { agentIds } : {}),
...(file.memberNames ? { memberNames: file.memberNames } : {}),
...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}),
...(file.worktreePath ? { worktreePath: file.worktreePath } : {}),
@ -1021,6 +1103,7 @@ export class TaskChangeLedgerReader {
scope: LedgerSummaryScopeV2,
files: LedgerSummaryFileV2[]
): TaskChangeScope {
const agentIds = Array.isArray(scope.agentIds) ? scope.agentIds : [];
return {
taskId,
memberName:
@ -1039,7 +1122,7 @@ export class TaskChangeLedgerReader {
...(scope.primaryActorKey ? { primaryActorKey: scope.primaryActorKey } : {}),
...(scope.primaryAgentId ? { primaryAgentId: scope.primaryAgentId } : {}),
...(scope.primaryMemberName ? { primaryMemberName: scope.primaryMemberName } : {}),
...(scope.agentIds.length > 0 ? { agentIds: scope.agentIds } : {}),
...(agentIds.length > 0 ? { agentIds } : {}),
...(scope.memberNames ? { memberNames: scope.memberNames } : {}),
...(scope.toolUseCount !== undefined ? { toolUseCount: scope.toolUseCount } : {}),
...(scope.toolUseIdsTruncated ? { toolUseIdsTruncated: true } : {}),
@ -1064,6 +1147,75 @@ export class TaskChangeLedgerReader {
);
}
private projectJournalEventsForUi(events: LedgerEvent[]): LedgerEvent[] {
const selectedBySourceImportKey = new Map<
string,
{ event: LedgerEvent; index: number; rank: number }
>();
const passthrough: Array<{ event: LedgerEvent; index: number }> = [];
events.forEach((event, index) => {
const sourceImportKey = this.sourceImportKeyForEvent(event);
if (!sourceImportKey) {
passthrough.push({ event, index });
return;
}
const rank = this.evidenceRankForEvent(event);
const existing = selectedBySourceImportKey.get(sourceImportKey);
if (!existing || rank >= existing.rank) {
selectedBySourceImportKey.set(sourceImportKey, { event, index, rank });
}
});
return [
...passthrough,
...[...selectedBySourceImportKey.values()].map(({ event, index }) => ({ event, index })),
]
.sort((left, right) => left.index - right.index)
.map(({ event }) => event);
}
private sourceImportKeyForEvent(event: LedgerEvent): string | null {
if (
event.sourceImportKey &&
(event.sourceRuntime === 'opencode' ||
event.sourceProvider === 'opencode' ||
event.source === 'opencode_toolpart_write' ||
event.source === 'opencode_toolpart_edit' ||
event.source === 'opencode_toolpart_apply_patch')
) {
return event.sourceImportKey;
}
return null;
}
private evidenceRankForEvent(event: LedgerEvent): number {
const hasFullText = this.hasFullTextEvidence(event);
switch (event.evidenceProof) {
case 'opencode-snapshot':
return hasFullText ? 50 : 35;
case 'inverse-apply-patch-chain':
case 'inverse-edit-chain':
case 'toolpart-chain':
return hasFullText ? 40 : 25;
case 'metadata-only-fallback':
return 10;
default:
return hasFullText ? 30 : 5;
}
}
private hasFullTextEvidence(event: Pick<LedgerEvent, 'before' | 'after' | 'operation'>): boolean {
if (event.operation === 'create') {
return event.after !== null;
}
if (event.operation === 'delete') {
return event.before !== null;
}
return event.before !== null && event.after !== null;
}
private async readContentRef(
projectDir: string,
ref: LedgerContentRef | null

View file

@ -85,6 +85,7 @@ import type {
TaskRef,
TeamConfig,
TeamCreateConfigRequest,
TeamCreateRequest,
TeamMember,
TeamMemberActivityMeta,
TeamMemberSnapshot,
@ -854,6 +855,50 @@ export class TeamDataService {
return this.configReader.listTeams();
}
async getSavedRequest(teamName: string): Promise<TeamCreateRequest | null> {
const meta = await this.teamMetaStore.getMeta(teamName);
if (!meta) {
return null;
}
const membersMeta = await this.membersMetaStore.getMeta(teamName);
const members = membersMeta?.members ?? [];
const resolvedProviderId = meta.providerId ?? 'anthropic';
return {
teamName,
displayName: meta.displayName,
description: meta.description,
color: meta.color,
cwd: meta.cwd,
prompt: meta.prompt,
providerId: resolvedProviderId,
providerBackendId: migrateProviderBackendId(
resolvedProviderId,
meta.providerBackendId ?? membersMeta?.providerBackendId
),
model: meta.model,
effort: meta.effort as TeamCreateRequest['effort'],
fastMode: meta.fastMode,
skipPermissions: meta.skipPermissions,
worktree: meta.worktree,
extraCliArgs: meta.extraCliArgs,
limitContext: meta.limitContext,
members: members.map((member) => ({
name: member.name,
role: member.role,
workflow: member.workflow,
isolation: member.isolation,
cwd: member.cwd,
providerId: member.providerId,
providerBackendId: member.providerBackendId,
model: member.model,
effort: member.effort,
fastMode: member.fastMode,
})),
};
}
async listAliveProcessTeams(): Promise<string[]> {
const teams = await this.listTeams();
const alive: string[] = [];
@ -2792,8 +2837,16 @@ export class TeamDataService {
description: request.description,
color: request.color,
cwd: request.cwd?.trim() || '',
prompt: request.prompt,
providerId: request.providerId,
providerBackendId: request.providerBackendId,
model: request.model,
effort: request.effort,
fastMode: request.fastMode,
skipPermissions: request.skipPermissions,
worktree: request.worktree,
extraCliArgs: request.extraCliArgs,
limitContext: request.limitContext,
createdAt: joinedAt,
});
@ -2823,8 +2876,10 @@ export class TeamDataService {
workflow: member.workflow?.trim() || undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
providerBackendId: member.providerBackendId,
model: member.model?.trim() || undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
fastMode: member.fastMode,
agentType: 'general-purpose' as const,
joinedAt,
}))

View file

@ -74,6 +74,10 @@ function resolveWorkerPath(): string | null {
return null;
}
function shouldWarnUnavailableWorker(): boolean {
return process.env.NODE_ENV !== 'test' && process.env.VITEST !== 'true';
}
export class TeamFsWorkerClient {
private worker: Worker | null = null;
private readonly workerPath: string | null = resolveWorkerPath();
@ -84,7 +88,7 @@ export class TeamFsWorkerClient {
>();
isAvailable(): boolean {
if (!this.workerPath && !this.warnedUnavailable) {
if (!this.workerPath && !this.warnedUnavailable && shouldWarnUnavailableWorker()) {
this.warnedUnavailable = true;
const baseDir =
typeof __dirname === 'string' && __dirname.length > 0

View file

@ -1,4 +1,8 @@
import { getMcpConfigsBasePath, getMcpServerBasePath } from '@main/utils/pathDecoder';
import {
getClaudeBasePath,
getMcpConfigsBasePath,
getMcpServerBasePath,
} from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { execFile } from 'child_process';
import { randomUUID } from 'crypto';
@ -13,6 +17,7 @@ export interface McpLaunchSpec {
}
const MCP_SERVER_NAME = 'agent-teams';
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
const logger = createLogger('Service:TeamMcpConfigBuilder');
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
@ -273,6 +278,9 @@ export class TeamMcpConfigBuilder {
[MCP_SERVER_NAME]: {
command: launchSpec.command,
args: launchSpec.args,
env: {
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
},
},
};

View file

@ -1,6 +1,7 @@
import { createHash } from 'crypto';
export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const;
export const OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION = 1 as const;
export type OpenCodeBridgeCommandName =
| 'opencode.handshake'
@ -239,18 +240,12 @@ export interface OpenCodeBackfillTaskLedgerCommandBody {
projectDir?: string;
workspaceRoot?: string;
deliveryContextPath?: string;
deliveryContextHash?: string;
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode;
dryRun?: boolean;
}
export type OpenCodeBackfillTaskLedgerAttributionMode = 'strict-delivery' | 'compatible';
export type OpenCodeBackfillTaskLedgerEvidenceMode =
| 'off'
| 'metadata-only'
| 'chain-only'
| 'snapshot-probe'
| 'snapshot-auto';
export type OpenCodeBackfillTaskLedgerOutcome =
| 'imported'
@ -265,13 +260,13 @@ export type OpenCodeBackfillTaskLedgerOutcome =
export interface OpenCodeBackfillTaskLedgerCommandData {
schemaVersion: 1;
providerId: 'opencode';
opencodeTaskLedgerEvidenceContractVersion?: number;
teamName: string;
taskId?: string;
projectDir?: string;
workspaceRoot?: string;
dryRun: boolean;
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode;
strictWindowCandidateCount?: number;
openCodeDbFingerprint?: string;
deliveryLedgerFingerprint?: string;
@ -369,6 +364,7 @@ export interface OpenCodeBridgePeerIdentity {
minVersion: number;
currentVersion: number;
supportedCommands: OpenCodeBridgeCommandName[];
opencodeTaskLedgerEvidenceContractVersion?: number;
};
runtime: {
providerId: 'opencode';
@ -853,7 +849,10 @@ function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity {
(bridgeProtocol.minVersion as number) < 1 ||
(bridgeProtocol.currentVersion as number) < (bridgeProtocol.minVersion as number) ||
!Array.isArray(bridgeProtocol.supportedCommands) ||
!bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName)
!bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName) ||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion !== undefined &&
(!Number.isInteger(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion) ||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion as number) < 1))
) {
return false;
}

View file

@ -308,7 +308,6 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
dryRun: input.dryRun === true,
...(input.attributionMode ? { attributionMode: input.attributionMode } : {}),
...(input.evidenceMode ? { evidenceMode: input.evidenceMode } : {}),
scannedSessions: 0,
scannedToolparts: 0,
candidateEvents: 0,

View file

@ -137,7 +137,7 @@ function resolveGeneratedBunLauncher(
}
function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
const scriptMatch = /"%_prog%"\s+"([^"]+\.(?:cjs|mjs|js))"\s+%\*/i.exec(content);
const scriptMatch = /"%_prog%"\s+"([^"]+(?:\.(?:cjs|mjs|js))?)"\s+%\*/i.exec(content);
const scriptTemplate = scriptMatch?.[1];
if (!scriptTemplate) {
return null;

View file

@ -1611,8 +1611,16 @@ export const CreateTeamDialog = ({
color: request.color,
members: request.members,
cwd: effectiveCwd || undefined,
prompt: request.prompt,
providerId: request.providerId,
providerBackendId: request.providerBackendId,
model: request.model,
effort: request.effort,
fastMode: request.fastMode,
limitContext: request.limitContext,
skipPermissions: request.skipPermissions,
worktree: request.worktree,
extraCliArgs: request.extraCliArgs,
});
onOpenTeam(request.teamName, effectiveCwd || undefined);
resetFormState();

View file

@ -1,5 +1,10 @@
import type { InlineChip } from '@renderer/types/inlineChip';
import type { EffortLevel, TeamProviderId } from '@shared/types';
import type {
EffortLevel,
TeamFastMode,
TeamProviderBackendId,
TeamProviderId,
} from '@shared/types';
export interface MemberDraft {
id: string;
@ -11,8 +16,10 @@ export interface MemberDraft {
workflowChips?: InlineChip[];
isolation?: 'worktree';
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
removedAt?: number | string | null;
}

View file

@ -10,7 +10,13 @@ import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types';
import type {
EffortLevel,
TeamFastMode,
TeamProviderBackendId,
TeamProviderId,
TeamProvisioningMemberInput,
} from '@shared/types';
export function validateMemberNameInline(name: string): string | null {
const trimmed = name.trim();
@ -34,8 +40,10 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
workflow: initial?.workflow,
isolation: initial?.isolation === 'worktree' ? 'worktree' : undefined,
providerId,
providerBackendId: initial?.providerBackendId,
model: normalizeExplicitTeamModelForUi(providerId, initial?.model ?? ''),
effort: initial?.effort,
fastMode: initial?.fastMode,
removedAt: initial?.removedAt,
};
}
@ -47,8 +55,10 @@ export function createMemberDraftsFromInputs(
role?: string;
workflow?: string;
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
isolation?: 'worktree';
removedAt?: number | string | null;
}[]
@ -67,8 +77,10 @@ export function createMemberDraftsFromInputs(
workflow: member.workflow,
isolation: member.isolation === 'worktree' ? 'worktree' : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
providerBackendId: member.providerBackendId,
model: member.model ?? '',
effort: normalizeDraftEffort(member.effort),
fastMode: member.fastMode,
removedAt: member.removedAt,
});
});
@ -84,8 +96,10 @@ export function clearMemberModelOverrides(member: MemberDraft): MemberDraft {
return {
...member,
providerId: undefined,
providerBackendId: undefined,
model: '',
effort: undefined,
fastMode: undefined,
};
}
@ -125,7 +139,9 @@ export function normalizeMemberDraftForProviderMode(
return {
...member,
providerId: normalizedProviderId,
providerBackendId: undefined,
model: '',
fastMode: undefined,
};
}
return member;
@ -253,6 +269,9 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
if (providerId) {
result.providerId = providerId;
}
if (member.providerBackendId) {
result.providerBackendId = member.providerBackendId;
}
const model = member.model?.trim();
if (model) {
result.model = normalizeExplicitTeamModelForUi(providerId, model);
@ -261,6 +280,9 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
if (effort) {
result.effort = effort;
}
if (member.fastMode) {
result.fastMode = member.fastMode;
}
return result;
})
.filter((member): member is NonNullable<typeof member> => member !== null);

View file

@ -1283,8 +1283,20 @@ export interface TeamCreateConfigRequest {
color?: string;
members: TeamProvisioningMemberInput[];
cwd?: string;
prompt?: string;
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
/** When true, context window is limited to 200K tokens instead of the default. */
limitContext?: boolean;
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
skipPermissions?: boolean;
/** Worktree name — CLI: --worktree <name>. */
worktree?: string;
/** Raw custom CLI args string, shell-split and appended to CLI command. */
extraCliArgs?: string;
}
export interface TeamCreateResponse {

View file

@ -90,6 +90,9 @@ declare module 'agent-teams-controller' {
}
export interface ControllerRuntimeApi {
listTeams(flags?: Record<string, unknown>): Promise<unknown>;
getTeam(flags?: Record<string, unknown>): Promise<unknown>;
createTeam(flags: Record<string, unknown>): Promise<unknown>;
launchTeam(flags: Record<string, unknown>): Promise<unknown>;
stopTeam(flags?: Record<string, unknown>): Promise<unknown>;
getRuntimeState(flags?: Record<string, unknown>): Promise<unknown>;

View file

@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"name": "opencode-snapshot-upgrade",
"taskId": "fixture-opencode-snapshot-upgrade",
"description": "OpenCode metadata-only import upgraded by source-driven snapshot evidence into one visible full-text row.",
"projectRootToken": "__PROJECT_ROOT__",
"expected": {
"totalFiles": 1,
"warnings": [],
"relativePaths": [
"src/snapshot-only.js"
],
"relationKinds": []
}
}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-opencode-snapshot-upgrade","updatedAt":"2026-04-26T10:00:02.000Z","journalStamp":{"events":{"bytes":3265,"mtimeMs":1777197602000,"tailSha256":"fixture-opencode-snapshot-upgrade-tail"}},"eventCount":2,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-opencode-snapshot-upgrade","generatedAt":"2026-04-26T10:00:02.000Z","journalStamp":{"events":{"bytes":3265,"mtimeMs":1777197602000,"tailSha256":"fixture-opencode-snapshot-upgrade-tail"}},"integrity":"ok","eventCount":2,"projectedEventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:bob","primaryMemberName":"bob","memberName":"bob","memberNames":["bob"],"startTimestamp":"2026-04-26T10:00:01.000Z","endTimestamp":"2026-04-26T10:00:01.000Z","toolUseIds":["bob-edit-snapshot-only"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":0,"end":0},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:bob","memberName":"bob","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":1,"firstTimestamp":"2026-04-26T10:00:01.000Z","lastTimestamp":"2026-04-26T10:00:01.000Z"}]},"files":[{"changeKey":"modify:__PROJECT_ROOT__/src/snapshot-only.js","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","linesAdded":1,"linesRemoved":1,"diffStatKnown":true,"eventCount":1,"journalEventCount":2,"firstTimestamp":"2026-04-26T10:00:01.000Z","lastTimestamp":"2026-04-26T10:00:01.000Z","latestOperation":"modify","createdInTask":false,"deletedInTask":false,"baselineExists":true,"finalExists":true,"latestBeforeHash":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","latestAfterHash":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","latestBeforeState":{"exists":true,"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27},"latestAfterState":{"exists":true,"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:bob","memberNames":["bob"],"executionSeqRange":{"start":0,"end":0}}],"totalLinesAdded":1,"totalLinesRemoved":1,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]}

View file

@ -0,0 +1,2 @@
{"schemaVersion":1,"taskId":"fixture-opencode-snapshot-upgrade","taskRef":"fixture-opencode-snapshot-upgrade","taskRefKind":"canonical","phase":"work","executionSeq":0,"sessionId":"opencode-session-fixture","memberName":"bob","toolUseId":"bob-edit-snapshot-only","source":"opencode_toolpart_edit","operation":"modify","confidence":"medium","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","timestamp":"2026-04-26T10:00:00.000Z","toolStatus":"succeeded","before":null,"after":null,"beforeState":{"exists":true,"unavailableReason":"opencode-before-content-unavailable"},"afterState":{"exists":true,"unavailableReason":"opencode-edit-final-content-unavailable"},"oldString":"snapshot = 1","newString":"snapshot = 2","linesAdded":0,"linesRemoved":0,"sourceRuntime":"opencode","sourceProvider":"opencode","sourceSessionId":"opencode-session-fixture","sourcePartId":"bob-edit-snapshot-only","sourceMessageId":"assistant-1","parentUserMessageId":"user-1","attributionMethod":"delivery-ledger-taskrefs","sourceImportKey":"opencode\u0000opencode-session-fixture\u0000bob-edit-snapshot-only\u0000src/snapshot-only.js","evidenceProof":"metadata-only-fallback","warnings":["OpenCode edit was captured without a git/snapshot baseline; apply/reject is manual-only."],"eventId":"opencode-metadata-only-event"}
{"schemaVersion":1,"taskId":"fixture-opencode-snapshot-upgrade","taskRef":"fixture-opencode-snapshot-upgrade","taskRefKind":"canonical","phase":"work","executionSeq":0,"sessionId":"opencode-session-fixture","memberName":"bob","toolUseId":"bob-edit-snapshot-only","source":"opencode_toolpart_edit","operation":"modify","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","timestamp":"2026-04-26T10:00:01.000Z","toolStatus":"succeeded","before":{"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27,"blobRef":"sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6"},"after":{"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27,"blobRef":"sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887"},"beforeState":{"exists":true,"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27},"afterState":{"exists":true,"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27},"oldString":"snapshot = 1","newString":"snapshot = 2","linesAdded":1,"linesRemoved":1,"sourceRuntime":"opencode","sourceProvider":"opencode","sourceSessionId":"opencode-session-fixture","sourcePartId":"bob-edit-snapshot-only","sourceMessageId":"assistant-1","parentUserMessageId":"user-1","attributionMethod":"delivery-ledger-taskrefs","sourceImportKey":"opencode\u0000opencode-session-fixture\u0000bob-edit-snapshot-only\u0000src/snapshot-only.js","evidenceProof":"inverse-edit-chain","snapshotId":"opencode-snapshot-window-fixture","snapshotSource":"opencode","supersedesEventId":"opencode-metadata-only-event","eventId":"opencode-snapshot-upgrade-event"}

View file

@ -0,0 +1 @@
export const snapshot = 2;

View file

@ -0,0 +1,395 @@
// @vitest-environment node
import Fastify from 'fastify';
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
import type { AddressInfo } from 'net';
import { registerTools } from '../../../mcp-server/src/tools';
import { registerTeamRoutes } from '@main/http/teams';
import { TeamDataService } from '@main/services/team/TeamDataService';
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import type { HttpServices } from '@main/http';
import type {
TeamCreateRequest,
TeamLaunchRequest,
TeamLaunchResponse,
TeamProvisioningProgress,
TeamRuntimeState,
} from '@shared/types/team';
interface RegisteredTool {
name: string;
execute: (args: Record<string, unknown>) => unknown;
}
function collectTools(): Map<string, RegisteredTool> {
const tools = new Map<string, RegisteredTool>();
registerTools({
addTool(config: RegisteredTool) {
tools.set(config.name, config);
},
} as never);
return tools;
}
function parseJsonToolResult(result: unknown): unknown {
const text = (result as { content?: { text?: string }[] }).content?.[0]?.text;
return JSON.parse(text ?? 'null');
}
async function fetchJson(
baseUrl: string,
pathname: string
): Promise<{
body: unknown;
status: number;
}> {
const response = await fetch(`${baseUrl}${pathname}`);
return {
status: response.status,
body: await response.json(),
};
}
function createServices(claudeRoot: string): {
createTeamCalls: TeamCreateRequest[];
services: HttpServices;
} {
const teamDataService = new TeamDataService();
const createTeamCalls: TeamCreateRequest[] = [];
const aliveTeams = new Set<string>();
const progressByRunId = new Map<string, TeamProvisioningProgress>();
const runIdByTeam = new Map<string, string>();
async function persistLaunchedConfig(request: TeamCreateRequest): Promise<void> {
const teamDir = path.join(claudeRoot, 'teams', request.teamName);
await mkdir(teamDir, { recursive: true });
await writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify(
{
name: request.displayName ?? request.teamName,
projectPath: request.cwd,
members: [
{
name: 'team-lead',
role: 'team-lead',
agentType: 'team-lead',
},
...request.members.map((member) => ({
name: member.name,
role: member.role,
workflow: member.workflow,
agentType: 'teammate',
providerId: member.providerId,
providerBackendId: member.providerBackendId,
model: member.model,
effort: member.effort,
fastMode: member.fastMode,
})),
],
},
null,
2
),
'utf8'
);
}
async function createTeam(
request: TeamCreateRequest,
onProgress: (progress: TeamProvisioningProgress) => void
): Promise<TeamLaunchResponse> {
createTeamCalls.push(request);
await persistLaunchedConfig(request);
const runId = `run-${request.teamName}`;
const progress: TeamProvisioningProgress = {
runId,
teamName: request.teamName,
state: 'ready',
message: 'Ready',
startedAt: '2026-04-29T00:00:00.000Z',
updatedAt: '2026-04-29T00:00:01.000Z',
};
aliveTeams.add(request.teamName);
runIdByTeam.set(request.teamName, runId);
progressByRunId.set(runId, progress);
onProgress(progress);
return { runId };
}
const teamProvisioningService = {
createTeam,
launchTeam: async (
request: TeamLaunchRequest,
onProgress: (progress: TeamProvisioningProgress) => void
): Promise<TeamLaunchResponse> => {
return createTeam(
{
teamName: request.teamName,
cwd: request.cwd,
prompt: request.prompt,
providerId: request.providerId,
providerBackendId: request.providerBackendId,
model: request.model,
effort: request.effort,
fastMode: request.fastMode,
skipPermissions: request.skipPermissions,
worktree: request.worktree,
extraCliArgs: request.extraCliArgs,
members: [],
},
onProgress
);
},
getProvisioningStatus: (runId: string): Promise<TeamProvisioningProgress> => {
const progress = progressByRunId.get(runId);
if (!progress) {
throw new Error('Unknown runId');
}
return Promise.resolve(progress);
},
getRuntimeState: (teamName: string): Promise<TeamRuntimeState> => {
const runId = runIdByTeam.get(teamName) ?? null;
return Promise.resolve({
teamName,
isAlive: aliveTeams.has(teamName),
runId,
progress: runId ? (progressByRunId.get(runId) ?? null) : null,
});
},
stopTeam: (teamName: string): Promise<void> => {
aliveTeams.delete(teamName);
return Promise.resolve();
},
getAliveTeams: (): string[] => [...aliveTeams],
} as HttpServices['teamProvisioningService'];
return {
createTeamCalls,
services: {
projectScanner: {} as HttpServices['projectScanner'],
sessionParser: {} as HttpServices['sessionParser'],
subagentResolver: {} as HttpServices['subagentResolver'],
chunkBuilder: {} as HttpServices['chunkBuilder'],
dataCache: {} as HttpServices['dataCache'],
updaterService: {} as HttpServices['updaterService'],
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
teamDataService,
teamProvisioningService,
},
};
}
describe('MCP team tools over the local REST control API', () => {
const tools = collectTools();
function getTool(name: string): RegisteredTool {
const tool = tools.get(name);
expect(tool).toBeDefined();
return tool!;
}
it('creates, gets, launches, and lists a team through MCP and REST end to end', async () => {
const claudeRoot = await mkdtemp(path.join(tmpdir(), 'agent-teams-control-e2e-'));
const projectDir = await mkdtemp(path.join(tmpdir(), 'agent-teams-project-e2e-'));
setClaudeBasePathOverride(claudeRoot);
const app = Fastify();
const { createTeamCalls, services } = createServices(claudeRoot);
registerTeamRoutes(app, services);
try {
await app.listen({ host: '127.0.0.1', port: 0 });
const address = app.server.address() as AddressInfo;
const controlUrl = `http://127.0.0.1:${address.port}`;
const created = parseJsonToolResult(
await getTool('team_create').execute({
claudeDir: claudeRoot,
controlUrl,
teamName: 'mcp-e2e-team',
displayName: 'MCP E2E Team',
description: 'Created by MCP integration test',
color: '#3366ff',
cwd: projectDir,
prompt: 'Coordinate the test task',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
limitContext: true,
skipPermissions: false,
worktree: 'feature-e2e',
extraCliArgs: '--max-turns 5',
members: [
{
name: 'builder',
role: 'Engineer',
workflow: 'Ship a focused patch',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
},
],
})
) as { teamName: string };
expect(created).toEqual({ teamName: 'mcp-e2e-team' });
const restDraft = await fetchJson(controlUrl, '/api/teams/mcp-e2e-team');
expect(restDraft.status).toBe(200);
expect(restDraft.body).toMatchObject({
teamName: 'mcp-e2e-team',
pendingCreate: true,
savedRequest: {
teamName: 'mcp-e2e-team',
displayName: 'MCP E2E Team',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
limitContext: true,
skipPermissions: false,
members: [
{
name: 'builder',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
},
],
},
});
const mcpDraft = parseJsonToolResult(
await getTool('team_get').execute({
claudeDir: claudeRoot,
controlUrl,
teamName: 'mcp-e2e-team',
})
);
expect(mcpDraft).toMatchObject({
teamName: 'mcp-e2e-team',
pendingCreate: true,
savedRequest: {
prompt: 'Coordinate the test task',
worktree: 'feature-e2e',
extraCliArgs: '--max-turns 5',
},
});
const restListBeforeLaunch = await fetchJson(controlUrl, '/api/teams');
expect(restListBeforeLaunch.status).toBe(200);
expect(restListBeforeLaunch.body).toEqual(
expect.arrayContaining([
expect.objectContaining({
teamName: 'mcp-e2e-team',
displayName: 'MCP E2E Team',
pendingCreate: true,
}),
])
);
const launched = parseJsonToolResult(
await getTool('team_launch').execute({
claudeDir: claudeRoot,
controlUrl,
teamName: 'mcp-e2e-team',
cwd: projectDir,
})
) as { isAlive: boolean; progress: TeamProvisioningProgress; runId: string };
expect(launched).toMatchObject({
isAlive: true,
runId: 'run-mcp-e2e-team',
progress: {
state: 'ready',
teamName: 'mcp-e2e-team',
},
});
expect(createTeamCalls).toHaveLength(1);
expect(createTeamCalls[0]).toMatchObject({
teamName: 'mcp-e2e-team',
displayName: 'MCP E2E Team',
cwd: projectDir,
prompt: 'Coordinate the test task',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
limitContext: true,
skipPermissions: false,
worktree: 'feature-e2e',
extraCliArgs: '--max-turns 5',
members: [
{
name: 'builder',
role: 'Engineer',
workflow: 'Ship a focused patch',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
},
],
});
const restRuntime = await fetchJson(controlUrl, '/api/teams/mcp-e2e-team/runtime');
expect(restRuntime.status).toBe(200);
expect(restRuntime.body).toMatchObject({
teamName: 'mcp-e2e-team',
isAlive: true,
runId: 'run-mcp-e2e-team',
});
const restListAfterLaunch = await fetchJson(controlUrl, '/api/teams');
expect(restListAfterLaunch.status).toBe(200);
const launchedListItem = (restListAfterLaunch.body as Record<string, unknown>[]).find(
(team) => team.teamName === 'mcp-e2e-team'
);
expect(launchedListItem).toMatchObject({
teamName: 'mcp-e2e-team',
displayName: 'MCP E2E Team',
});
expect(launchedListItem).not.toHaveProperty('pendingCreate');
const mcpLaunchedTeam = parseJsonToolResult(
await getTool('team_get').execute({
claudeDir: claudeRoot,
controlUrl,
teamName: 'mcp-e2e-team',
})
);
expect(mcpLaunchedTeam).toMatchObject({
teamName: 'mcp-e2e-team',
config: {
name: 'MCP E2E Team',
projectPath: projectDir,
},
members: expect.arrayContaining([
expect.objectContaining({
name: 'builder',
role: 'Engineer',
}),
]),
});
} finally {
await app.close();
setClaudeBasePathOverride(null);
await rm(claudeRoot, { recursive: true, force: true });
await rm(projectDir, { recursive: true, force: true });
}
});
});

View file

@ -4,22 +4,44 @@ import { describe, expect, it, vi } from 'vitest';
import { registerTeamRoutes } from '@main/http/teams';
import type { HttpServices } from '@main/http';
import type {
TeamCreateConfigRequest,
TeamCreateRequest,
TeamLaunchRequest,
TeamLaunchResponse,
TeamProvisioningProgress,
TeamRuntimeState,
TeamSummary,
TeamViewSnapshot,
} from '@shared/types/team';
describe('HTTP team runtime routes', () => {
function createServicesMock() {
const launchTeam = vi.fn<
(request: TeamLaunchRequest, onProgress: (progress: TeamProvisioningProgress) => void) => Promise<TeamLaunchResponse>
>();
const launchTeam =
vi.fn<
(
request: TeamLaunchRequest,
onProgress: (progress: TeamProvisioningProgress) => void
) => Promise<TeamLaunchResponse>
>();
const getRuntimeState = vi.fn<(teamName: string) => Promise<TeamRuntimeState>>();
const getProvisioningStatus = vi.fn<(runId: string) => Promise<TeamProvisioningProgress>>();
const stopTeam = vi.fn<(teamName: string) => Promise<void>>(() => Promise.resolve());
const getAliveTeams = vi.fn<() => string[]>();
const createTeam =
vi.fn<
(
request: TeamCreateRequest,
onProgress: (progress: TeamProvisioningProgress) => void
) => Promise<TeamLaunchResponse>
>();
const listTeams = vi.fn<() => Promise<TeamSummary[]>>();
const getTeamData = vi.fn<(teamName: string) => Promise<TeamViewSnapshot>>();
const getSavedRequest = vi.fn<(teamName: string) => Promise<TeamCreateRequest | null>>();
const createTeamConfig = vi.fn<(request: TeamCreateConfigRequest) => Promise<void>>(() =>
Promise.resolve()
);
const teamProvisioningService = {
createTeam,
launchTeam,
getRuntimeState,
getProvisioningStatus,
@ -27,8 +49,22 @@ describe('HTTP team runtime routes', () => {
getAliveTeams,
} as Pick<
NonNullable<HttpServices['teamProvisioningService']>,
'launchTeam' | 'getRuntimeState' | 'getProvisioningStatus' | 'stopTeam' | 'getAliveTeams'
| 'createTeam'
| 'launchTeam'
| 'getRuntimeState'
| 'getProvisioningStatus'
| 'stopTeam'
| 'getAliveTeams'
> as HttpServices['teamProvisioningService'];
const teamDataService = {
listTeams,
getTeamData,
getSavedRequest,
createTeamConfig,
} as Pick<
NonNullable<HttpServices['teamDataService']>,
'listTeams' | 'getTeamData' | 'getSavedRequest' | 'createTeamConfig'
> as HttpServices['teamDataService'];
const services = {
projectScanner: {} as HttpServices['projectScanner'],
@ -38,6 +74,7 @@ describe('HTTP team runtime routes', () => {
dataCache: {} as HttpServices['dataCache'],
updaterService: {} as HttpServices['updaterService'],
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
teamDataService,
teamProvisioningService,
} satisfies HttpServices;
@ -48,6 +85,11 @@ describe('HTTP team runtime routes', () => {
getProvisioningStatus,
stopTeam,
getAliveTeams,
createTeam,
listTeams,
getTeamData,
getSavedRequest,
createTeamConfig,
};
}
@ -59,6 +101,87 @@ describe('HTTP team runtime routes', () => {
return { app, ...mocks };
}
it('lists, gets, and creates draft teams through team data service', async () => {
const { app, listTeams, getTeamData, createTeamConfig } = await createApp();
listTeams.mockResolvedValue([
{
teamName: 'demo-team',
displayName: 'Demo Team',
description: 'Demo',
memberCount: 1,
taskCount: 0,
lastActivity: null,
pendingCreate: true,
},
]);
getTeamData.mockResolvedValue({
teamName: 'demo-team',
config: null,
tasks: [],
messages: [],
processes: [],
kanban: null,
} as unknown as TeamViewSnapshot);
try {
const listResponse = await app.inject({
method: 'GET',
url: '/api/teams',
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()[0]).toMatchObject({
teamName: 'demo-team',
pendingCreate: true,
});
const getResponse = await app.inject({
method: 'GET',
url: '/api/teams/demo-team',
});
expect(getResponse.statusCode).toBe(200);
expect(getTeamData).toHaveBeenCalledWith('demo-team');
const createResponse = await app.inject({
method: 'POST',
url: '/api/teams',
payload: {
teamName: 'new-team',
displayName: 'New Team',
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
cwd: '/Users/test/project',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
limitContext: true,
},
});
expect(createResponse.statusCode).toBe(201);
expect(createResponse.json()).toEqual({ teamName: 'new-team' });
expect(createTeamConfig).toHaveBeenCalledWith({
teamName: 'new-team',
displayName: 'New Team',
members: [
{
name: 'builder',
role: 'Engineer',
providerId: 'codex',
providerBackendId: 'codex-native',
},
],
cwd: '/Users/test/project',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
limitContext: true,
});
} finally {
await app.close();
}
});
it('launches a team with validated request payload', async () => {
const { app, launchTeam } = await createApp();
launchTeam.mockResolvedValue({ runId: 'run-1' });
@ -68,7 +191,7 @@ describe('HTTP team runtime routes', () => {
method: 'POST',
url: '/api/teams/demo-team/launch',
payload: {
cwd: '/tmp/project',
cwd: '/Users/test/project',
prompt: 'Resume work',
skipPermissions: false,
clearContext: true,
@ -80,7 +203,7 @@ describe('HTTP team runtime routes', () => {
expect(launchTeam).toHaveBeenCalledWith(
{
teamName: 'demo-team',
cwd: '/tmp/project',
cwd: '/Users/test/project',
prompt: 'Resume work',
providerId: 'anthropic',
skipPermissions: false,
@ -93,6 +216,97 @@ describe('HTTP team runtime routes', () => {
}
});
it('routes draft team launch through createTeam with saved metadata', async () => {
const { app, createTeam, getSavedRequest, launchTeam } = await createApp();
getSavedRequest.mockResolvedValue({
teamName: 'draft-team',
displayName: 'Draft Team',
description: 'Saved draft',
color: '#3366ff',
cwd: '/Users/test/saved-project',
prompt: 'Saved prompt',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'medium',
fastMode: 'on',
limitContext: true,
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
});
createTeam.mockResolvedValue({ runId: 'run-draft' });
try {
const response = await app.inject({
method: 'POST',
url: '/api/teams/draft-team/launch',
payload: {
cwd: '/Users/test/project',
effort: 'high',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ runId: 'run-draft' });
expect(launchTeam).not.toHaveBeenCalled();
expect(createTeam).toHaveBeenCalledWith(
{
teamName: 'draft-team',
displayName: 'Draft Team',
description: 'Saved draft',
color: '#3366ff',
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
cwd: '/Users/test/project',
prompt: 'Saved prompt',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
limitContext: true,
},
expect.any(Function)
);
} finally {
await app.close();
}
});
it('returns saved metadata for draft team get without requiring config.json', async () => {
const { app, getSavedRequest, getTeamData } = await createApp();
getSavedRequest.mockResolvedValue({
teamName: 'draft-team',
displayName: 'Draft Team',
cwd: '/Users/test/project',
providerId: 'codex',
providerBackendId: 'codex-native',
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
});
try {
const response = await app.inject({
method: 'GET',
url: '/api/teams/draft-team',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
teamName: 'draft-team',
pendingCreate: true,
savedRequest: {
teamName: 'draft-team',
displayName: 'Draft Team',
cwd: '/Users/test/project',
providerId: 'codex',
providerBackendId: 'codex-native',
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
},
});
expect(getTeamData).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it('rejects launch requests with non-absolute cwd', async () => {
const { app, launchTeam } = await createApp();
@ -114,7 +328,8 @@ describe('HTTP team runtime routes', () => {
});
it('returns runtime state, provisioning status, and stop results', async () => {
const { app, getRuntimeState, getProvisioningStatus, stopTeam, getAliveTeams } = await createApp();
const { app, getRuntimeState, getProvisioningStatus, stopTeam, getAliveTeams } =
await createApp();
getRuntimeState
.mockResolvedValueOnce({
teamName: 'demo-team',
@ -213,18 +428,15 @@ describe('HTTP team runtime routes', () => {
it('returns 501 when team runtime routes are registered without a runtime service', async () => {
const app = Fastify();
registerTeamRoutes(
app,
{
projectScanner: {} as HttpServices['projectScanner'],
sessionParser: {} as HttpServices['sessionParser'],
subagentResolver: {} as HttpServices['subagentResolver'],
chunkBuilder: {} as HttpServices['chunkBuilder'],
dataCache: {} as HttpServices['dataCache'],
updaterService: {} as HttpServices['updaterService'],
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
} satisfies HttpServices
);
registerTeamRoutes(app, {
projectScanner: {} as HttpServices['projectScanner'],
sessionParser: {} as HttpServices['sessionParser'],
subagentResolver: {} as HttpServices['subagentResolver'],
chunkBuilder: {} as HttpServices['chunkBuilder'],
dataCache: {} as HttpServices['dataCache'],
updaterService: {} as HttpServices['updaterService'],
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
} satisfies HttpServices);
await app.ready();
try {
@ -234,7 +446,9 @@ describe('HTTP team runtime routes', () => {
});
expect(response.statusCode).toBe(501);
expect(response.json()).toEqual({ error: 'Team runtime control is not available in this mode' });
expect(response.json()).toEqual({
error: 'Team runtime control is not available in this mode',
});
} finally {
await app.close();
}

View file

@ -2,6 +2,7 @@ import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import type {
BoardTaskActivityDetailResult,
BoardTaskActivityEntry,
@ -30,7 +31,9 @@ vi.mock('@preload/constants/ipcChannels', async (importOriginal) => {
// Mock NotificationManager — handleShowMessageNotification calls addTeamNotification
const { mockAddTeamNotification } = vi.hoisted(() => ({
mockAddTeamNotification: vi.fn().mockResolvedValue({ id: 'n1', isRead: false, createdAt: Date.now() }),
mockAddTeamNotification: vi
.fn()
.mockResolvedValue({ id: 'n1', isRead: false, createdAt: Date.now() }),
}));
const { mockGetMembersMeta } = vi.hoisted(() => ({
mockGetMembersMeta: vi.fn(),
@ -147,25 +150,29 @@ describe('ipc teams handlers', () => {
const service = {
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
getTeamData: vi.fn(async (): Promise<TeamViewSnapshot & { messages?: InboxMessage[] }> => ({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
})),
getTeamData: vi.fn(
async (): Promise<TeamViewSnapshot & { messages?: InboxMessage[] }> => ({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
})
),
getMessageFeed: vi.fn(async () => ({
teamName: 'my-team',
feedRevision: 'rev-1',
messages: [] as InboxMessage[],
})),
getMessagesPage: vi.fn(async (..._args: unknown[]): Promise<MessagesPage> => ({
messages: [] as InboxMessage[],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
})),
getMessagesPage: vi.fn(
async (..._args: unknown[]): Promise<MessagesPage> => ({
messages: [] as InboxMessage[],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
})
),
getMemberActivityMeta: vi.fn(async () => ({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
@ -207,6 +214,7 @@ describe('ipc teams handlers', () => {
removeTaskRelationship: vi.fn(async () => undefined),
replaceMembers: vi.fn(async () => undefined),
createTeamConfig: vi.fn(async () => undefined),
getSavedRequest: vi.fn(async (): Promise<TeamCreateRequest | null> => null),
};
const provisioningService = {
prepareForProvisioning: vi.fn(async () => ({
@ -265,24 +273,26 @@ describe('ipc teams handlers', () => {
getTaskActivity: vi.fn<() => Promise<BoardTaskActivityEntry[]>>(async () => []),
};
const boardTaskActivityDetailService = {
getTaskActivityDetail:
vi.fn<() => Promise<BoardTaskActivityDetailResult>>(async () => ({ status: 'missing' })),
getTaskActivityDetail: vi.fn<() => Promise<BoardTaskActivityDetailResult>>(async () => ({
status: 'missing',
})),
};
const boardTaskLogStreamService = {
getTaskLogStream:
vi.fn<() => Promise<BoardTaskLogStreamResponse>>(async () => ({
participants: [],
defaultFilter: 'all',
segments: [],
})),
getTaskLogStream: vi.fn<() => Promise<BoardTaskLogStreamResponse>>(async () => ({
participants: [],
defaultFilter: 'all',
segments: [],
})),
};
const boardTaskExactLogsService = {
getTaskExactLogSummaries:
vi.fn<() => Promise<BoardTaskExactLogSummariesResponse>>(async () => ({ items: [] })),
getTaskExactLogSummaries: vi.fn<() => Promise<BoardTaskExactLogSummariesResponse>>(
async () => ({ items: [] })
),
};
const boardTaskExactLogDetailService = {
getTaskExactLogDetail:
vi.fn<() => Promise<BoardTaskExactLogDetailResult>>(async () => ({ status: 'missing' })),
getTaskExactLogDetail: vi.fn<() => Promise<BoardTaskExactLogDetailResult>>(async () => ({
status: 'missing',
})),
};
beforeEach(() => {
@ -316,13 +326,14 @@ describe('ipc teams handlers', () => {
boardTaskActivityDetailService as never,
boardTaskLogStreamService as never,
boardTaskExactLogsService as never,
boardTaskExactLogDetailService as never,
boardTaskExactLogDetailService as never
);
registerTeamHandlers(ipcMain as never);
});
afterEach(() => {
vi.useRealTimers();
setClaudeBasePathOverride(null);
});
it('registers all expected handlers', () => {
@ -417,12 +428,7 @@ describe('ipc teams handlers', () => {
const taskId = 'task-js';
const attachmentId = 'att-js';
const attachmentDir = path.join(
getAppDataPath(),
'task-attachments',
'my-team',
taskId
);
const attachmentDir = path.join(getAppDataPath(), 'task-attachments', 'my-team', taskId);
await fs.promises.rm(attachmentDir, { recursive: true, force: true });
await fs.promises.mkdir(attachmentDir, { recursive: true });
await fs.promises.writeFile(
@ -778,7 +784,9 @@ describe('ipc teams handlers', () => {
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('FORBIDDEN: editing files, changing code, changing task/board state, delegating work, launching Agent/subagents'),
expect.stringContaining(
'FORBIDDEN: editing files, changing code, changing task/board state, delegating work, launching Agent/subagents'
),
undefined
);
expect(service.sendDirectToLead).toHaveBeenCalledWith(
@ -814,7 +822,9 @@ describe('ipc teams handlers', () => {
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Persistent teammates currently configured: alice (reviewer), jack (developer)'),
expect.stringContaining(
'Persistent teammates currently configured: alice (reviewer), jack (developer)'
),
undefined
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
@ -842,7 +852,9 @@ describe('ipc teams handlers', () => {
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Make the acknowledgement at least 40 characters so it is preserved in the Messages panel.'),
expect.stringContaining(
'Make the acknowledgement at least 40 characters so it is preserved in the Messages panel.'
),
undefined
);
});
@ -887,9 +899,10 @@ describe('ipc teams handlers', () => {
'/COMPACT keep kanban',
undefined
);
const compactCall = vi.mocked(provisioningService.sendMessageToTeam).mock
.calls as unknown[][];
expect(String(compactCall[0]?.[1] ?? '')).not.toContain('You received a direct message from the user');
const compactCall = vi.mocked(provisioningService.sendMessageToTeam).mock.calls as unknown[][];
expect(String(compactCall[0]?.[1] ?? '')).not.toContain(
'You received a direct message from the user'
);
expect(String(compactCall[0]?.[1] ?? '')).not.toContain('Current durable team context:');
expect(service.sendDirectToLead).toHaveBeenCalledWith(
'my-team',
@ -1933,7 +1946,9 @@ describe('ipc teams handlers', () => {
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Do NOT start work, claim tasks, or improvise workflow/task/process rules')
expect.stringContaining(
'Do NOT start work, claim tasks, or improvise workflow/task/process rules'
)
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
@ -2516,7 +2531,9 @@ describe('ipc teams handlers', () => {
})) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(result.error).toContain('Live member migration between OpenCode and the primary runtime owner');
expect(result.error).toContain(
'Live member migration between OpenCode and the primary runtime owner'
);
expect(result.error).toContain('alice');
expect(service.replaceMembers).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
@ -2562,7 +2579,9 @@ describe('ipc teams handlers', () => {
})) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(result.error).toContain('Live member migration between OpenCode and the primary runtime owner');
expect(result.error).toContain(
'Live member migration between OpenCode and the primary runtime owner'
);
expect(result.error).toContain('alice');
expect(service.replaceMembers).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
@ -2843,6 +2862,42 @@ describe('ipc teams handlers', () => {
expect(callArg.members).toEqual([]);
});
it('createTeam preserves teammate backend and fast mode metadata', async () => {
const handler = handlers.get(TEAM_CREATE)!;
const result = (await handler({ sender: { send: vi.fn() } } as never, {
teamName: 'runtime-team',
members: [
{
name: 'builder',
role: 'Engineer',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'high',
fastMode: 'on',
},
],
cwd: os.tmpdir(),
providerId: 'codex',
providerBackendId: 'codex-native',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.createTeam.mock.calls[0][0].members).toEqual([
{
name: 'builder',
role: 'Engineer',
workflow: undefined,
isolation: undefined,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'high',
fastMode: 'on',
},
]);
});
it('handleCreateConfig accepts members: []', async () => {
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
const result = (await handler({} as never, {
@ -2853,6 +2908,161 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(true);
});
it('handleCreateConfig preserves draft launch metadata', async () => {
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
const result = (await handler({} as never, {
teamName: 'draft-team',
displayName: ' Draft Team ',
description: ' Saved draft ',
color: '#3366ff',
members: [
{
name: 'builder',
role: ' Engineer ',
workflow: ' Ship focused patches ',
providerId: 'codex',
providerBackendId: 'codex-native',
model: ' gpt-5.2 ',
effort: 'high',
fastMode: 'on',
},
],
cwd: '/Users/test/project',
prompt: ' Saved prompt ',
providerId: 'codex',
providerBackendId: 'codex-native',
model: ' gpt-5.2 ',
effort: 'high',
fastMode: 'on',
limitContext: true,
skipPermissions: false,
worktree: 'feature-x',
extraCliArgs: '--max-turns 5',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(service.createTeamConfig).toHaveBeenCalledWith({
teamName: 'draft-team',
displayName: 'Draft Team',
description: 'Saved draft',
color: '#3366ff',
members: [
{
name: 'builder',
role: 'Engineer',
workflow: 'Ship focused patches',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
},
],
cwd: '/Users/test/project',
prompt: 'Saved prompt',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
limitContext: true,
skipPermissions: false,
worktree: 'feature-x',
extraCliArgs: '--max-turns 5',
});
});
it('launches draft team through saved request without dropping Electron draft metadata', async () => {
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-launch-'));
setClaudeBasePathOverride(claudeRoot);
try {
const teamDir = path.join(claudeRoot, 'teams', 'draft-team');
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'team.meta.json'),
JSON.stringify({
version: 1,
displayName: 'Draft Team',
cwd: '/Users/test/project',
createdAt: Date.now(),
})
);
service.getSavedRequest.mockResolvedValueOnce({
teamName: 'draft-team',
displayName: 'Draft Team',
description: 'Saved draft',
color: '#3366ff',
cwd: '/Users/test/project',
prompt: 'Saved prompt',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'medium',
fastMode: 'on',
limitContext: true,
skipPermissions: false,
worktree: 'feature-x',
extraCliArgs: '--max-turns 5',
members: [
{
name: 'builder',
role: 'Engineer',
workflow: 'Ship focused patches',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
},
],
});
const handler = handlers.get(TEAM_LAUNCH)!;
const result = (await handler({ sender: { send: vi.fn() } } as never, {
teamName: 'draft-team',
cwd: os.tmpdir(),
effort: 'high',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.launchTeam).not.toHaveBeenCalled();
expect(provisioningService.createTeam).toHaveBeenCalledWith(
{
teamName: 'draft-team',
displayName: 'Draft Team',
description: 'Saved draft',
color: '#3366ff',
members: [
{
name: 'builder',
role: 'Engineer',
workflow: 'Ship focused patches',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
},
],
cwd: os.tmpdir(),
prompt: 'Saved prompt',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
limitContext: true,
skipPermissions: false,
worktree: 'feature-x',
extraCliArgs: '--max-turns 5',
},
expect.any(Function)
);
} finally {
fs.rmSync(claudeRoot, { recursive: true, force: true });
}
});
it('handleReplaceMembers accepts members: []', async () => {
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
const result = (await handler({} as never, 'my-team', {

View file

@ -29,6 +29,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
let previousDisableRuntimeBootstrap: string | undefined;
let previousHome: string | undefined;
let previousUserProfile: string | undefined;
let previousNodeEnv: string | undefined;
let svc: TeamProvisioningService | null;
let teamName: string | null;
@ -45,8 +46,10 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
previousHome = process.env.HOME;
previousUserProfile = process.env.USERPROFILE;
previousNodeEnv = process.env.NODE_ENV;
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
process.env.NODE_ENV = 'production';
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
@ -67,7 +70,13 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
restoreEnv('HOME', previousHome);
restoreEnv('USERPROFILE', previousUserProfile);
await fs.rm(tempDir, { recursive: true, force: true });
restoreEnv('NODE_ENV', previousNodeEnv);
if (process.env.ANTHROPIC_RUNTIME_MEMORY_LIVE_KEEP_TEMP === '1') {
// Live-debug only: preserve process/runtime logs for failed Windows liveness triage.
process.stderr.write(`Preserving Anthropic runtime memory live temp dir: ${tempDir}\n`);
return;
}
await removeTempDirWithRetries(tempDir);
});
it('creates a real Anthropic team and reports teammate RSS in the runtime snapshot', async () => {
@ -79,6 +88,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
teamName = `anthropic-memory-live-${Date.now()}`;
const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true });
await writeTrustedClaudeConfig(tempClaudeRoot, projectPath);
await fs.writeFile(
path.join(projectPath, 'README.md'),
'# Anthropic runtime memory live e2e\n',
@ -133,7 +143,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
typeof alice.rssBytes === 'number' &&
alice.rssBytes > 0
);
}, 60_000);
}, 180_000, 1_000, () => JSON.stringify(snapshot, null, 2));
expect(snapshot!.members.alice).toMatchObject({
alive: true,
@ -158,10 +168,53 @@ async function assertExecutable(filePath: string): Promise<void> {
await fs.access(filePath, fsConstants.X_OK);
}
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
const normalizedProjectPath = path.normalize(projectPath).replace(/\\/g, '/');
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
const config: {
projects: Record<string, { hasTrustDialogAccepted: true }>;
customApiKeyResponses?: { approved: string[]; rejected: string[] };
} = {
projects: {
[normalizedProjectPath]: {
hasTrustDialogAccepted: true,
},
},
};
if (approvedApiKeySuffix) {
config.customApiKeyResponses = {
approved: [approvedApiKeySuffix],
rejected: [],
};
}
await fs.writeFile(
path.join(configDir, '.claude.json'),
`${JSON.stringify(config, null, 2)}\n`,
'utf8'
);
}
async function removeTempDirWithRetries(dirPath: string): Promise<void> {
const attempts = process.platform === 'win32' ? 20 : 1;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await fs.rm(dirPath, { recursive: true, force: true });
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if ((code !== 'EBUSY' && code !== 'EPERM') || attempt === attempts) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}
async function waitUntil(
predicate: () => Promise<boolean>,
timeoutMs: number,
pollMs = 1_000
pollMs = 1_000,
describeState?: () => string
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
@ -178,7 +231,8 @@ async function waitUntil(
}
const suffix =
lastError instanceof Error && lastError.message ? ` Last error: ${lastError.message}` : '';
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}`);
const state = describeState ? ` Last state: ${describeState()}` : '';
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}${state}`);
}
function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string {

View file

@ -1,10 +1,12 @@
import * as os from 'os';
import * as path from 'path';
import { createHash } from 'crypto';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs/promises';
import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService';
import { OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
@ -79,6 +81,7 @@ async function writeOpenCodeDeliveryLedger(
taskId: string;
displayId: string;
teamName: string;
taskRefs: { taskId: string; displayId: string; teamName: string }[];
}>
): Promise<string> {
const memberName = overrides?.memberName ?? 'bob';
@ -108,7 +111,7 @@ async function writeOpenCodeDeliveryLedger(
observedAssistantMessageId: overrides?.observedAssistantMessageId ?? null,
prePromptCursor: null,
postPromptCursor: null,
taskRefs: [
taskRefs: overrides?.taskRefs ?? [
{
taskId: overrides?.taskId ?? TASK_ID,
displayId: overrides?.displayId ?? 'abc12345',
@ -126,6 +129,70 @@ async function writeOpenCodeDeliveryLedger(
return filePath;
}
async function writeOpenCodeLedgerBundle(
projectDir: string,
projectPath: string,
taskId: string = TASK_ID
): Promise<void> {
const bundleDir = path.join(projectDir, '.board-task-changes', 'bundles');
await fs.mkdir(bundleDir, { recursive: true });
await fs.writeFile(
path.join(bundleDir, `${encodeURIComponent(taskId)}.json`),
JSON.stringify({
schemaVersion: 1,
source: 'task-change-ledger',
taskId,
generatedAt: '2026-03-01T10:00:00.000Z',
eventCount: 1,
files: [
{
filePath: path.join(projectPath, 'src/opencode.ts'),
relativePath: 'src/opencode.ts',
eventIds: ['event-1'],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
latestAfterHash: null,
},
],
totalLinesAdded: 1,
totalLinesRemoved: 0,
totalFiles: 1,
confidence: 'high',
warnings: [],
events: [
{
schemaVersion: 1,
eventId: 'event-1',
taskId,
taskRef: taskId,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 0,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-1',
source: 'opencode_toolpart_write',
operation: 'create',
confidence: 'exact',
workspaceRoot: projectPath,
filePath: path.join(projectPath, 'src/opencode.ts'),
relativePath: 'src/opencode.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: '',
newString: 'export const source = "opencode";\n',
linesAdded: 1,
linesRemoved: 0,
},
],
}),
'utf8'
);
}
function persistedEntryPath(baseDir: string): string {
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
}
@ -934,64 +1001,13 @@ describe('ChangeExtractorService', () => {
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
let deliveryContextHashVerified = false;
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles');
await fs.mkdir(bundleDir, { recursive: true });
await fs.writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 1,
source: 'task-change-ledger',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
eventCount: 1,
files: [
{
filePath: path.join(projectPath, 'src/opencode.ts'),
relativePath: 'src/opencode.ts',
eventIds: ['event-1'],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
latestAfterHash: null,
},
],
totalLinesAdded: 1,
totalLinesRemoved: 0,
totalFiles: 1,
confidence: 'high',
warnings: [],
events: [
{
schemaVersion: 1,
eventId: 'event-1',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 0,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-1',
source: 'opencode_toolpart_write',
operation: 'create',
confidence: 'exact',
workspaceRoot: projectPath,
filePath: path.join(projectPath, 'src/opencode.ts'),
relativePath: 'src/opencode.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: '',
newString: 'export const source = "opencode";\n',
linesAdded: 1,
linesRemoved: 0,
},
],
}),
'utf8'
);
deliveryContextHashVerified =
createHash('sha256')
.update(await fs.readFile(input.deliveryContextPath, 'utf8'))
.digest('hex') === input.deliveryContextHash;
await writeOpenCodeLedgerBundle(input.projectDir, projectPath);
return {
schemaVersion: 1,
providerId: 'opencode',
@ -1058,12 +1074,339 @@ describe('ChangeExtractorService', () => {
projectDir,
workspaceRoot: projectPath,
attributionMode: 'strict-delivery',
evidenceMode: 'chain-only',
})
);
const backfillInput = backfillOpenCodeTaskLedger.mock.calls[0]?.[0];
expect(backfillInput.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json')
);
expect(backfillInput.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
expect(deliveryContextHashVerified).toBe(true);
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
});
it('rereads ledger when OpenCode backfill writes artifacts and then fails', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
await writeOpenCodeLedgerBundle(input.projectDir, projectPath);
throw new Error('timeout after import');
});
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(result.files).toHaveLength(1);
expect(result.files[0]?.snippets[0]?.toolName).toBe('Write');
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
});
it('uses the OpenCode delivery member when the current task owner changed later', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'alice' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir, { memberName: 'bob' });
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 0,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome: 'no-history',
notices: [],
diagnostics: [],
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
expect.objectContaining({
memberName: 'bob',
attributionMode: 'strict-delivery',
})
);
});
it('omits member filter when multiple OpenCode delivery members match the task', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'alice' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir, { memberName: 'bob', runtimeSessionId: 'session-1' });
await writeOpenCodeDeliveryLedger(tmpDir, {
memberName: 'carol',
runtimeSessionId: 'session-2',
});
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 0,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome: 'no-history',
notices: [],
diagnostics: [],
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]).not.toHaveProperty('memberName');
});
it('ignores OpenCode delivery records that match only a recreated task display id', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir, {
taskId: 'old-task',
displayId: 'abc12345',
memberName: 'bob',
});
const backfillOpenCodeTaskLedger = vi.fn(async () => {
throw new Error('display-id-only delivery record must not backfill');
});
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(result.files).toHaveLength(0);
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
});
it('ignores OpenCode delivery records that only mention related tasks', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir, {
taskId: 'related-task',
displayId: 'def67890',
memberName: 'bob',
});
const backfillOpenCodeTaskLedger = vi.fn(async () => {
throw new Error('related-only delivery record must not backfill');
});
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(result.files).toHaveLength(0);
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
});
it('does not run OpenCode backfill for explicit non-OpenCode teams even if stale runtime files exist', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
@ -1182,12 +1525,11 @@ describe('ChangeExtractorService', () => {
projectDir,
workspaceRoot: projectPath,
deliveryContextPath: expect.stringContaining('delivery-context.json'),
deliveryContextHash: expect.stringMatching(/^[a-f0-9]{64}$/),
attributionMode: 'strict-delivery',
evidenceMode: 'chain-only',
})
);
});
expect(settled).toBe(false);
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
pendingBackfill.resolve({
@ -1289,6 +1631,7 @@ describe('ChangeExtractorService', () => {
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json')
);
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
});
it('does not cache negative OpenCode backfill while delivery context already exists', async () => {
@ -1331,24 +1674,30 @@ describe('ChangeExtractorService', () => {
'utf8'
);
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 1,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome: 'no-attribution',
notices: [],
diagnostics: [],
}));
let backfillAttempt = 0;
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
const outcome = backfillAttempt++ === 0 ? 'transient-error' : 'no-attribution';
return {
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 1,
scannedToolparts: 0,
candidateEvents: 0,
importedEvents: 0,
skippedEvents: 0,
outcome,
notices: [],
diagnostics: outcome === 'transient-error'
? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.']
: [],
};
});
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
@ -1394,8 +1743,158 @@ describe('ChangeExtractorService', () => {
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json')
);
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json')
);
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
});
it('does not cache duplicates-only OpenCode backfill from an old evidence contract', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 1,
scannedToolparts: 1,
candidateEvents: 1,
importedEvents: 0,
skippedEvents: 1,
outcome: 'duplicates-only',
notices: [],
diagnostics: [],
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(2);
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(2);
});
it('caches duplicates-only OpenCode backfill from the current evidence contract', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
const projectDir = path.join(tmpDir, 'project-dir');
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1,
providerId: 'opencode',
opencodeTaskLedgerEvidenceContractVersion:
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
teamName: input.teamName,
taskId: input.taskId,
projectDir: input.projectDir,
workspaceRoot: input.workspaceRoot,
dryRun: false,
attributionMode: input.attributionMode,
scannedSessions: 1,
scannedToolparts: 1,
candidateEvents: 1,
importedEvents: 0,
skippedEvents: 1,
outcome: 'duplicates-only',
notices: [],
diagnostics: [],
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
),
};
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir,
projectPath,
sessionIds: [],
})),
findLogFileRefsForTask: vi.fn(async () => []),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
undefined,
workerClient as any,
{ backfillOpenCodeTaskLedger } as any,
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(2);
});
});

View file

@ -6,6 +6,7 @@ import {
createOpenCodeBridgeHandshakeIdentityHash,
createOpenCodeBridgeIdempotencyKey,
isOpenCodeBridgeCommandName,
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
parseSingleBridgeJsonResult,
stableHash,
validateBridgeResultEnvelope,
@ -202,6 +203,42 @@ describe('OpenCodeBridgeCommandContract', () => {
});
});
it('accepts handshake evidence contract version and rejects invalid values', () => {
const client = peerIdentity('claude_team');
const server = peerIdentity('agent_teams_orchestrator');
server.bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion =
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION;
const validHandshake = buildHandshake({ client, server });
expect(
validateOpenCodeBridgeHandshake({
handshake: validHandshake,
expectedClient: client,
requiredCommand: 'opencode.launchTeam',
expectedCapabilitySnapshotId: 'cap-1',
expectedManifestHighWatermark: 10,
expectedRunId: 'run-1',
})
).toEqual({ ok: true });
server.bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion = 0;
const invalidHandshake = buildHandshake({ client, server });
expect(
validateOpenCodeBridgeHandshake({
handshake: invalidHandshake,
expectedClient: client,
requiredCommand: 'opencode.launchTeam',
expectedCapabilitySnapshotId: 'cap-1',
expectedManifestHighWatermark: 10,
expectedRunId: 'run-1',
})
).toEqual({
ok: false,
reason: 'Bridge handshake peer identity is invalid',
});
});
it('creates deterministic idempotency keys for equivalent JSON bodies', () => {
const first = createOpenCodeBridgeIdempotencyKey({
command: 'opencode.launchTeam',

View file

@ -170,6 +170,8 @@ describe('OpenCodeReadinessBridge', () => {
taskDisplayId: 'abc12345',
projectDir: '/claude/project',
workspaceRoot: '/repo',
deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json',
deliveryContextHash: 'a'.repeat(64),
})
).resolves.toMatchObject({
outcome: 'imported',
@ -184,6 +186,8 @@ describe('OpenCodeReadinessBridge', () => {
taskDisplayId: 'abc12345',
projectDir: '/claude/project',
workspaceRoot: '/repo',
deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json',
deliveryContextHash: 'a'.repeat(64),
},
{
cwd: '/repo',

View file

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createHash } from 'crypto';
import { structuredPatch } from 'diff';
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import type { SnippetDiff } from '@shared/types';
@ -985,7 +986,8 @@ describe('ReviewApplierService', () => {
{
filePath,
fileDecision: 'pending',
hunkDecisions: { 0: 'rejected' },
hunkDecisions: { 0: 'rejected', 1: 'pending' },
hunkContextHashes: buildHunkContextHashes(original, modified),
},
],
},
@ -1034,8 +1036,99 @@ describe('ReviewApplierService', () => {
expect(res).toMatchObject({ applied: 1, conflicts: 0 });
expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8');
});
it('ledger partial reject refuses stale hunk context instead of falling back to index', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
const filePath = '/tmp/stale-ledger.ts';
const original = 'const value = 1;\nconst keep = true;\n';
const modified = 'const value = 2;\nconst keep = true;\n';
readFile.mockResolvedValue(modified);
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
const svc = new ReviewApplierService();
const res = await svc.applyReviewDecisions(
{
teamName: 'team',
decisions: [
{
filePath,
fileDecision: 'pending',
hunkDecisions: { 0: 'rejected', 1: 'pending' },
hunkContextHashes: { 0: 'stale-context-hash' },
},
],
},
new Map([
[
filePath,
{
filePath,
relativePath: 'stale-ledger.ts',
snippets: [
{
toolUseId: 'ledger-1',
filePath,
toolName: 'Edit',
type: 'edit',
oldString: 'const value = 1;\n',
newString: 'const value = 2;\n',
replaceAll: false,
timestamp: '2026-03-01T10:00:00.000Z',
isError: false,
ledger: {
eventId: 'event-1',
source: 'ledger-exact',
confidence: 'exact',
originalFullContent: original,
modifiedFullContent: modified,
beforeHash: sha(original),
afterHash: sha(modified),
operation: 'modify',
beforeState: { exists: true, sha256: sha(original) },
afterState: { exists: true, sha256: sha(modified) },
},
},
],
linesAdded: 1,
linesRemoved: 1,
isNewFile: false,
originalFullContent: original,
modifiedFullContent: modified,
contentSource: 'ledger-exact',
},
],
])
);
expect(res.applied).toBe(0);
expect(res.conflicts).toBe(1);
expect(res.errors[0]?.code).toBe('conflict');
expect(writeFile).not.toHaveBeenCalled();
});
});
function sha(content: string): string {
return createHash('sha256').update(content).digest('hex');
}
function buildHunkContextHashes(original: string, modified: string): Record<number, string> {
const patch = structuredPatch('file', 'file', original, modified);
const out: Record<number, string> = {};
for (let i = 0; i < patch.hunks.length; i++) {
const hunk = patch.hunks[i]!;
const oldSideContent = hunk.lines
.filter((line) => !line.startsWith('+'))
.map((line) => line.slice(1))
.join('\n');
const newSideContent = hunk.lines
.filter((line) => !line.startsWith('-'))
.map((line) => line.slice(1))
.join('\n');
out[i] = computeDiffContextHash(oldSideContent, newSideContent);
}
return out;
}

View file

@ -285,6 +285,84 @@ describe('TaskChangeLedgerReader', () => {
expect(snippets[2]?.ledger?.source).toBe('ledger-snapshot');
});
it('projects partial OpenCode snapshot journal evidence to a later full-text upgrade', async () => {
tmpDir = await fsTempDir();
const eventsDir = path.join(tmpDir, '.board-task-changes', 'events');
const blobsDir = path.join(tmpDir, '.board-task-changes', 'blobs');
await mkdir(eventsDir, { recursive: true });
await mkdir(blobsDir, { recursive: true });
const beforeContent = 'export const value = 1;\n';
const afterContent = 'export const value = 2;\n';
await writeFile(path.join(blobsDir, 'before.txt'), beforeContent, 'utf8');
await writeFile(path.join(blobsDir, 'after.txt'), afterContent, 'utf8');
const sourceImportKey = 'opencode\0session-1\0part-edit\0src/file.ts';
const baseEvent = {
schemaVersion: 1,
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-edit',
source: 'opencode_toolpart_edit',
operation: 'modify',
confidence: 'high',
workspaceRoot: '/repo',
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
sourceRuntime: 'opencode',
sourceProvider: 'opencode',
sourceImportKey,
evidenceProof: 'opencode-snapshot',
beforeState: { exists: true, sha256: sha(beforeContent), sizeBytes: beforeContent.length },
afterState: { exists: true, sha256: sha(afterContent), sizeBytes: afterContent.length },
linesAdded: 1,
linesRemoved: 1,
};
await writeFile(
path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`),
[
{
...baseEvent,
eventId: 'event-partial',
before: null,
after: { sha256: sha(afterContent), sizeBytes: afterContent.length, blobRef: 'after.txt' },
},
{
...baseEvent,
eventId: 'event-full',
supersedesEventId: 'event-partial',
before: { sha256: sha(beforeContent), sizeBytes: beforeContent.length, blobRef: 'before.txt' },
after: { sha256: sha(afterContent), sizeBytes: afterContent.length, blobRef: 'after.txt' },
},
]
.map((entry) => JSON.stringify(entry))
.join('\n') + '\n',
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: true,
});
expect(result?.files).toHaveLength(1);
const snippets = result?.files[0]?.snippets ?? [];
expect(snippets).toHaveLength(1);
expect(snippets[0]?.ledger?.eventId).toBe('event-full');
expect(snippets[0]?.ledger?.originalFullContent).toBe(beforeContent);
expect(snippets[0]?.ledger?.modifiedFullContent).toBe(afterContent);
});
it('groups rename relations in summary-only bundles without losing absolute paths', async () => {
const relation = { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' };
tmpDir = await makeLedgerBundle({
@ -614,6 +692,91 @@ describe('TaskChangeLedgerReader', () => {
);
});
it('keeps v2 provenance fingerprint stable when only raw journal metadata changes', async () => {
tmpDir = await makeSummaryLedgerBundleV2({
bundle: {
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw-a' } },
eventCount: 1,
noticeCount: 0,
warningCount: 0,
warnings: [],
},
file: {
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
agentIds: ['alice@team'],
},
});
const reader = new TaskChangeLedgerReader();
const first = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
tmpDir = await makeSummaryLedgerBundleV2({
bundle: {
generatedAt: '2026-03-01T11:00:00.000Z',
journalStamp: { events: { bytes: 999, mtimeMs: 99, tailSha256: 'raw-b' } },
eventCount: 7,
noticeCount: 3,
warningCount: 1,
warnings: ['raw journal had a recovered warning'],
},
file: {
eventCount: 7,
firstTimestamp: '2026-03-01T09:00:00.000Z',
lastTimestamp: '2026-03-01T11:00:00.000Z',
agentIds: ['alice@team', 'bob@team'],
},
});
const second = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(first?.provenance?.sourceFingerprint).toBe(second?.provenance?.sourceFingerprint);
});
it('changes v2 provenance fingerprint when projected file evidence changes', async () => {
tmpDir = await makeSummaryLedgerBundleV2({
file: {
latestAfterHash: sha('after-v1'),
latestAfterState: { exists: true, sha256: sha('after-v1'), sizeBytes: 8 },
},
});
const reader = new TaskChangeLedgerReader();
const first = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
tmpDir = await makeSummaryLedgerBundleV2({
file: {
latestAfterHash: sha('after-v2'),
latestAfterState: { exists: true, sha256: sha('after-v2'), sizeBytes: 8 },
},
});
const second = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(first?.provenance?.sourceFingerprint).not.toBe(second?.provenance?.sourceFingerprint);
});
it('keeps identical relative rename relations isolated by worktree path', async () => {
tmpDir = await fsTempDir();
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
@ -969,6 +1132,74 @@ async function makeLedgerBundle(params: {
return dir;
}
async function makeSummaryLedgerBundleV2(params: {
bundle?: Record<string, unknown>;
file?: Record<string, unknown>;
} = {}): Promise<string> {
const dir = await fsTempDir();
const bundleDir = path.join(dir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
const file = {
changeKey: 'path:/repo/src/file.ts',
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
linesAdded: 1,
linesRemoved: 1,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: sha('before'),
latestAfterHash: sha('after'),
latestBeforeState: { exists: true, sha256: sha('before'), sizeBytes: 6 },
latestAfterState: { exists: true, sha256: sha('after'), sizeBytes: 5 },
contentAvailability: 'full-text',
reviewability: 'full-text',
agentIds: ['alice@team'],
...params.file,
};
await writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
bundleKind: 'summary',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw' } },
integrity: 'ok',
eventCount: 1,
noticeCount: 0,
scope: {
confidence: { tier: 1, label: 'high', reason: 'bundle' },
memberName: 'alice',
agentIds: ['alice@team'],
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:00:00.000Z',
toolUseIds: ['tool-1'],
toolUseCount: 1,
phaseSet: ['work'],
visibleFileCount: 1,
contributors: [],
},
files: [file],
totalLinesAdded: 1,
totalLinesRemoved: 1,
diffStatCompleteness: 'complete',
totalFiles: 1,
confidence: 'high',
warningCount: 0,
warnings: [],
...params.bundle,
}),
'utf8'
);
return dir;
}
async function fsTempDir(): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-'));
}

View file

@ -237,6 +237,74 @@ afterEach(async () => {
);
});
describe('TeamDataService draft metadata', () => {
it('round-trips create config metadata through getSavedRequest', async () => {
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-saved-request-'));
tempPaths.push(claudeRoot);
setClaudeBasePathOverride(claudeRoot);
const service = new TeamDataService();
await service.createTeamConfig({
teamName: 'draft-team',
displayName: 'Draft Team',
description: 'Saved draft',
color: '#3366ff',
cwd: '/Users/test/project',
prompt: 'Saved prompt',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
limitContext: true,
skipPermissions: false,
worktree: 'feature-x',
extraCliArgs: '--max-turns 5',
members: [
{
name: 'builder',
role: 'Engineer',
workflow: 'Ship focused patches',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
},
],
});
await expect(service.getSavedRequest('missing-team')).resolves.toBeNull();
await expect(service.getSavedRequest('draft-team')).resolves.toMatchObject({
teamName: 'draft-team',
displayName: 'Draft Team',
description: 'Saved draft',
color: '#3366ff',
cwd: '/Users/test/project',
prompt: 'Saved prompt',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
limitContext: true,
skipPermissions: false,
worktree: 'feature-x',
extraCliArgs: '--max-turns 5',
members: [
{
name: 'builder',
role: 'Engineer',
workflow: 'Ship focused patches',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.2',
effort: 'high',
fastMode: 'on',
},
],
});
});
});
function createForwardingJournalStore(initialEntries: Array<Record<string, unknown>> = []) {
const journalEntries = initialEntries;
const journal = {

View file

@ -45,7 +45,7 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
};
});
import { setAppDataBasePath } from '@main/utils/pathDecoder';
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder';
describe('TeamMcpConfigBuilder', () => {
@ -77,10 +77,10 @@ describe('TeamMcpConfigBuilder', () => {
function readGeneratedServer(
configPath: string
): { command?: string; args?: string[] } | undefined {
): { command?: string; args?: string[]; env?: Record<string, string> } | undefined {
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as {
mcpServers?: Record<string, { command?: string; args?: string[] }>;
mcpServers?: Record<string, { command?: string; args?: string[]; env?: Record<string, string> }>;
};
return parsed.mcpServers?.['agent-teams'];
}
@ -180,6 +180,7 @@ describe('TeamMcpConfigBuilder', () => {
afterEach(() => {
setAppDataBasePath(null);
setClaudeBasePathOverride(null);
setPackagedMode(false);
setResourcesPath(originalResourcesPath);
moduleInternal._load = originalModuleLoad;
@ -370,6 +371,20 @@ describe('TeamMcpConfigBuilder', () => {
expectTsxEntry(parsed.mcpServers['agent-teams'], sourceEntry);
});
it('passes the configured Claude root to the MCP server', async () => {
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-claude-root-'));
createdDirs.push(claudeRoot);
setClaudeBasePathOverride(claudeRoot);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expect(readGeneratedServer(configPath)?.env).toMatchObject({
AGENT_TEAMS_MCP_CLAUDE_DIR: claudeRoot,
});
});
it('ignores malformed user MCP file', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));

View file

@ -315,6 +315,50 @@ describe('task change ledger golden fixtures', () => {
expect(resolved.contentSource).toBe('ledger-snapshot');
});
it('reads OpenCode snapshot upgrade fixtures as one full-text ledger row', async () => {
const fixture = await materializeTaskChangeLedgerFixture('opencode-snapshot-upgrade');
cleanups.push(fixture.cleanup);
const reader = new TaskChangeLedgerReader();
const changeSet = await reader.readTaskChanges({
teamName: TEAM_NAME,
taskId: fixture.manifest.taskId,
projectDir: fixture.projectDir,
projectPath: fixture.projectDir,
includeDetails: true,
});
expect(changeSet?.files).toHaveLength(1);
const file = changeSet!.files[0]!;
expect(file.relativePath).toBe('src/snapshot-only.js');
expect(file.ledgerSummary).toMatchObject({
reviewability: 'full-text',
contentAvailability: 'full-text',
});
expect(file.snippets).toHaveLength(1);
const snippet = file.snippets[0]!;
expect(snippet.toolName).toBe('Edit');
expect(snippet.type).toBe('edit');
expect(snippet.ledger).toMatchObject({
source: 'ledger-snapshot',
confidence: 'high',
textAvailability: 'full-text',
operation: 'modify',
});
expect(snippet.ledger?.originalFullContent).toBe('export const snapshot = 1;\n');
expect(snippet.ledger?.modifiedFullContent).toBe('export const snapshot = 2;\n');
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
const resolved = await resolver.getFileContent(
TEAM_NAME,
'bob',
file.filePath,
file.snippets
);
expect(resolved.originalFullContent).toBe('export const snapshot = 1;\n');
expect(resolved.modifiedFullContent).toBe('export const snapshot = 2;\n');
expect(resolved.contentSource).toBe('ledger-snapshot');
});
it('rejects grouped copy fixtures by deleting only the copied path', async () => {
const fixture = await materializeTaskChangeLedgerFixture('copy');
cleanups.push(fixture.cleanup);

View file

@ -0,0 +1,179 @@
// @vitest-environment node
import { execFile } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
import { describe, expect, it } from 'vitest';
import { CodexBinaryResolver } from '@main/services/infrastructure/codexAppServer/CodexBinaryResolver';
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
const execFileAsync = promisify(execFile);
const liveDescribe = process.env.AGENT_CLI_LAUNCH_LIVE_E2E === '1' ? describe : describe.skip;
const CLI_LAUNCH_TIMEOUT_MS = 15_000;
type AgentCliProvider = 'opencode' | 'codex' | 'claude';
type AgentCliSpec = {
providerId: AgentCliProvider;
command: string;
overrideEnv: string;
versionPattern: RegExp;
resolver?: () => Promise<string | null>;
};
const AGENT_CLI_SPECS: AgentCliSpec[] = [
{
providerId: 'opencode',
command: 'opencode',
overrideEnv: 'OPENCODE_CLI_PATH',
versionPattern: /\b\d+\.\d+\.\d+\b/,
},
{
providerId: 'codex',
command: 'codex',
overrideEnv: 'CODEX_CLI_PATH',
versionPattern: /\b(?:codex-cli\s+)?\d+\.\d+\.\d+\b/i,
resolver: () => CodexBinaryResolver.resolve(),
},
{
providerId: 'claude',
command: 'claude',
overrideEnv: 'CLAUDE_CLI_PATH',
versionPattern: /\b\d+\.\d+\.\d+\b.*Claude Code/i,
},
];
liveDescribe('agent CLI launch live e2e', () => {
it.each(AGENT_CLI_SPECS)(
'resolves and executes $providerId through execCli without tmux',
async (spec) => {
const binaryPath = await resolveCliBinary(spec);
expect(binaryPath, `${spec.providerId} binary must be installed`).toBeTruthy();
const result = await execCli(binaryPath, ['--version'], {
timeout: CLI_LAUNCH_TIMEOUT_MS,
windowsHide: true,
});
const output = `${result.stdout}\n${result.stderr}`.trim();
expect(output).toMatch(spec.versionPattern);
expect(output).not.toMatch(/tmux/i);
expect(output).not.toMatch(/running scripts is disabled/i);
expect(output).not.toMatch(/not digitally signed/i);
},
CLI_LAUNCH_TIMEOUT_MS + 5_000
);
it.each(AGENT_CLI_SPECS)(
'spawns $providerId through spawnCli and exits cleanly without tmux',
async (spec) => {
const binaryPath = await resolveCliBinary(spec);
expect(binaryPath, `${spec.providerId} binary must be installed`).toBeTruthy();
const result = await spawnAndCollect(binaryPath, ['--version']);
const output = `${result.stdout}\n${result.stderr}`.trim();
expect(result.exitCode).toBe(0);
expect(output).toMatch(spec.versionPattern);
expect(output).not.toMatch(/tmux/i);
expect(output).not.toMatch(/running scripts is disabled/i);
expect(output).not.toMatch(/not digitally signed/i);
},
CLI_LAUNCH_TIMEOUT_MS + 5_000
);
});
async function resolveCliBinary(spec: AgentCliSpec): Promise<string> {
const override = process.env[spec.overrideEnv]?.trim();
if (override) {
return override;
}
if (spec.resolver) {
const resolved = await spec.resolver();
if (resolved) {
return preferWindowsCmdShim(resolved);
}
}
return preferWindowsCmdShim(await resolveCommandFromPath(spec.command));
}
async function resolveCommandFromPath(command: string): Promise<string> {
if (process.platform === 'win32') {
const { stdout } = await execFileAsync('where.exe', [command], {
timeout: CLI_LAUNCH_TIMEOUT_MS,
windowsHide: true,
});
const candidates = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const cmdCandidate = candidates.find((candidate) => /\.cmd$/i.test(candidate));
return cmdCandidate ?? candidates[0] ?? command;
}
const { stdout } = await execFileAsync('which', [command], {
timeout: CLI_LAUNCH_TIMEOUT_MS,
});
return stdout.trim().split(/\r?\n/)[0] ?? command;
}
function preferWindowsCmdShim(binaryPath: string): string {
if (process.platform !== 'win32') {
return binaryPath;
}
const extension = path.extname(binaryPath).toLowerCase();
if (extension === '.cmd') {
return binaryPath;
}
const cmdPeer = extension ? `${binaryPath.slice(0, -extension.length)}.cmd` : `${binaryPath}.cmd`;
return fs.existsSync(cmdPeer) ? cmdPeer : binaryPath;
}
function spawnAndCollect(
binaryPath: string,
args: string[]
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawnCli(binaryPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let settled = false;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
killProcessTree(child, 'SIGKILL');
reject(new Error(`Timed out launching ${binaryPath}`));
}
}, CLI_LAUNCH_TIMEOUT_MS);
child.stdout?.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr?.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.once('error', (error) => {
if (!settled) {
settled = true;
clearTimeout(timeout);
reject(error);
}
});
child.once('close', (exitCode) => {
if (!settled) {
settled = true;
clearTimeout(timeout);
resolve({
exitCode,
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
});
}
});
});
}

View file

@ -64,6 +64,41 @@ function createGeneratedBunLauncher(): { dir: string; launcher: string; target:
return { dir, launcher, target };
}
function createExtensionlessNpmNodeLauncher(): {
dir: string;
launcher: string;
target: string;
} {
const dir = mkdtempSync(path.join(tmpdir(), 'cat-cli-npm-launcher-'));
const targetDir = path.join(dir, 'node_modules', 'opencode-ai', 'bin');
mkdirSync(targetDir, { recursive: true });
const target = path.join(targetDir, 'opencode');
writeFileSync(target, 'console.log("ok")', 'utf8');
const launcher = path.join(dir, 'opencode.cmd');
writeFileSync(
launcher,
[
'@ECHO off',
'GOTO start',
':find_dp0',
'SET dp0=%~dp0',
'EXIT /b',
':start',
'SETLOCAL',
'CALL :find_dp0',
'IF EXIST "%dp0%\\node.exe" (',
' SET "_prog=%dp0%\\node.exe"',
') ELSE (',
' SET "_prog=node"',
')',
'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\opencode-ai\\bin\\opencode" %*',
'',
].join('\r\n'),
'utf8'
);
return { dir, launcher, target };
}
describe('cli child process helpers', () => {
beforeEach(() => {
vi.resetAllMocks();
@ -152,6 +187,24 @@ describe('cli child process helpers', () => {
}
});
it('runs extensionless npm node cmd launchers directly', () => {
setPlatform('win32');
const fake = {} as any;
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockReturnValue(fake);
const { dir, launcher, target } = createExtensionlessNpmNodeLauncher();
try {
const result = spawnCli(launcher, ['--model', 'test%PATH%"arg']);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toBe('node');
expect(spawnMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell');
expect(result).toBe(fake);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('uses shell directly when path contains non-ASCII on windows', () => {
setPlatform('win32');
const fake = {} as any;
@ -281,6 +334,29 @@ describe('cli child process helpers', () => {
}
});
it('executes extensionless npm node cmd launchers directly', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;
const execMock = child.exec as unknown as Mock;
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
cb(null, 'ok', '');
return {} as any;
}
);
const { dir, launcher, target } = createExtensionlessNpmNodeLauncher();
try {
const result = await execCli(launcher, ['--model', 'test%PATH%"arg']);
expect(execFileMock).toHaveBeenCalledTimes(1);
expect(execFileMock.mock.calls[0][0]).toBe('node');
expect(execFileMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
expect(execMock).not.toHaveBeenCalled();
expect(result.stdout).toBe('ok');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('skips straight to shell when path contains non-ASCII on windows', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;

View file

@ -124,8 +124,10 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
customRole?: string;
workflow?: string;
providerId?: string;
providerBackendId?: string;
model?: string;
effort?: string;
fastMode?: string;
}>
) =>
drafts.map((draft) => ({
@ -133,8 +135,10 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
role: draft.customRole || undefined,
workflow: draft.workflow,
providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | undefined,
providerBackendId: draft.providerBackendId as 'codex-native' | undefined,
model: draft.model,
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
fastMode: draft.fastMode as 'inherit' | 'on' | 'off' | undefined,
})),
clearMemberModelOverrides: (member: unknown) => member,
createMemberDraftsFromInputs: (
@ -143,8 +147,10 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
role?: string;
workflow?: string;
providerId?: string;
providerBackendId?: string;
model?: string;
effort?: string;
fastMode?: string;
isolation?: 'worktree';
}>
) =>
@ -157,8 +163,10 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
workflow: member.workflow ?? '',
isolation: member.isolation,
providerId: member.providerId,
providerBackendId: member.providerBackendId,
model: member.model ?? '',
effort: member.effort,
fastMode: member.fastMode,
})),
filterEditableMemberInputs: (members: unknown) => members,
normalizeLeadProviderForMode: (providerId: unknown) =>
@ -587,6 +595,82 @@ describe('LaunchTeamDialog', () => {
});
});
it('preserves hidden teammate backend and fast mode metadata before draft launch', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
teamName: 'team-alpha',
cwd: '/tmp/project',
providerId: 'anthropic',
model: 'opus',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
fastMode: 'on',
},
],
} as any);
const onLaunch = vi.fn(async () => {});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(LaunchTeamDialog, {
mode: 'launch',
open: true,
teamName: 'team-alpha',
members: [],
defaultProjectPath: '/tmp/project',
provisioningError: null,
clearProvisioningError: vi.fn(),
activeTeams: [],
onClose: vi.fn(),
onLaunch,
})
);
await flush();
await flush();
});
const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Launch team'
);
expect(submitButton).toBeTruthy();
await act(async () => {
submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await flush();
await flush();
});
expect(vi.mocked(api.teams.replaceMembers).mock.calls[0]?.[1]).toMatchObject({
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
fastMode: 'on',
},
],
});
expect(onLaunch).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await flush();
});
});
it('submits relaunch through onRelaunch without replacing members in-dialog', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);

View file

@ -72,6 +72,37 @@ describe('members editor editable input filtering', () => {
});
});
it('round-trips hidden teammate backend and fast mode metadata', () => {
const drafts = createMemberDraftsFromInputs(
filterEditableMemberInputs([
{
name: 'alice',
agentType: 'reviewer',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4-mini',
effort: 'medium',
fastMode: 'on',
},
] as any)
);
expect(drafts[0]).toMatchObject({
providerBackendId: 'codex-native',
fastMode: 'on',
});
expect(buildMembersFromDrafts(drafts)).toEqual([
expect.objectContaining({
name: 'alice',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4-mini',
effort: 'medium',
fastMode: 'on',
}),
]);
});
it('preserves explicit codex models when exporting member inputs', () => {
const drafts = createMemberDraftsFromInputs(
filterEditableMemberInputs([

View file

@ -1651,4 +1651,153 @@ describe('changeReviewSlice task changes', () => {
});
expect(store.getState().activeChangeSet).toEqual(current);
});
it('does not force re-review when ledger provenance stays stable despite warning changes', async () => {
const store = createSliceStore();
const current = {
...makeTaskChangeSet('task-ledger', '/repo/file.ts'),
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'projected-fp-stable',
},
warnings: [],
};
const fresh = {
...current,
computedAt: '2026-03-01T13:00:00.000Z',
warnings: ['raw journal warning changed'],
};
hoisted.getTaskChanges.mockResolvedValueOnce(fresh);
hoisted.applyDecisions.mockResolvedValueOnce({
applied: 1,
skipped: 0,
conflicts: 0,
errors: [],
});
store.setState({
activeChangeSet: current,
hunkDecisions: { '/repo/file.ts:0': 'rejected' },
fileDecisions: { '/repo/file.ts': 'rejected' },
fileChunkCounts: { '/repo/file.ts': 1 },
changeSetEpoch: 0,
fileContentVersionByPath: {},
});
await store.getState().applyReview('team-a', 'task-ledger');
expect(store.getState().applyError).toBeNull();
expect(hoisted.applyDecisions).toHaveBeenCalledTimes(1);
expect(store.getState().activeChangeSet).toEqual(current);
});
it('forces re-review when ledger projected provenance changes with the same file paths', async () => {
const store = createSliceStore();
const current = {
...makeTaskChangeSet('task-ledger', '/repo/file.ts'),
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'projected-fp-v1',
},
};
const fresh = {
...current,
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'projected-fp-v2',
},
};
hoisted.getTaskChanges.mockResolvedValueOnce(fresh);
store.setState({
activeChangeSet: current,
hunkDecisions: { '/repo/file.ts:0': 'rejected' },
fileDecisions: { '/repo/file.ts': 'rejected' },
fileChunkCounts: { '/repo/file.ts': 1 },
reviewUndoStack: [{ hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' } }],
changeSetEpoch: 2,
fileContentVersionByPath: { '/repo/file.ts': 3 },
});
await store.getState().applyReview('team-a', 'task-ledger');
expect(hoisted.applyDecisions).not.toHaveBeenCalled();
expect(store.getState().activeChangeSet).toEqual(fresh);
expect(store.getState().applyError).toBe(
'Changes have been updated since you started reviewing. Please re-review.'
);
expect(store.getState().hunkDecisions).toEqual({});
expect(store.getState().fileDecisions).toEqual({});
expect(store.getState().reviewUndoStack).toEqual([]);
expect(store.getState().fileContentVersionByPath).toEqual({});
});
it('clears metadata-only decisions when ledger evidence upgrades to full text for the same changeKey', async () => {
const store = createSliceStore();
const changeKey = 'path:/repo/file.ts';
const currentFile = {
...makeFile('/repo/file.ts'),
changeKey,
snippets: [],
ledgerSummary: {
latestOperation: 'modify',
contentAvailability: 'metadata-only',
reviewability: 'metadata-only',
},
};
const freshFile = {
...makeFile('/repo/file.ts'),
changeKey,
ledgerSummary: {
latestOperation: 'modify',
contentAvailability: 'full-text',
reviewability: 'full-text',
beforeState: { exists: true, sha256: 'before-hash', sizeBytes: 6 },
afterState: { exists: true, sha256: 'after-hash', sizeBytes: 5 },
},
};
const current = {
...makeTaskChangeSet('task-ledger', '/repo/file.ts'),
files: [currentFile],
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'metadata-only-projection',
},
};
const fresh = {
...current,
files: [freshFile],
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'snapshot-full-text-projection',
},
};
hoisted.getTaskChanges.mockResolvedValueOnce(fresh);
store.setState({
activeChangeSet: current,
hunkDecisions: { [`${changeKey}:0`]: 'rejected' },
fileDecisions: { [changeKey]: 'rejected' },
hunkContextHashesByFile: { [changeKey]: { 0: 'metadata-only-context' } },
fileChunkCounts: { [changeKey]: 1 },
reviewUndoStack: [
{
hunkDecisions: { [`${changeKey}:0`]: 'rejected' },
fileDecisions: { [changeKey]: 'rejected' },
},
],
changeSetEpoch: 4,
fileContentVersionByPath: { '/repo/file.ts': 2 },
});
await store.getState().applyReview('team-a', 'task-ledger');
expect(hoisted.applyDecisions).not.toHaveBeenCalled();
expect(store.getState().activeChangeSet).toEqual(fresh);
expect(store.getState().fileDecisions).toEqual({});
expect(store.getState().hunkDecisions).toEqual({});
expect(store.getState().hunkContextHashesByFile).toEqual({});
expect(store.getState().reviewUndoStack).toEqual([]);
expect(store.getState().fileContentVersionByPath).toEqual({});
});
});