merge: dev into main
This commit is contained in:
commit
8959808ecd
269 changed files with 26264 additions and 3987 deletions
63
.github/workflows/release.yml
vendored
63
.github/workflows/release.yml
vendored
|
|
@ -1,9 +1,6 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
|
|
@ -20,13 +17,13 @@ permissions:
|
|||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag || github.ref }}
|
||||
group: release-${{ inputs.release_tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
IS_RELEASE_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }}
|
||||
RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag }}
|
||||
PUBLISH_RELEASE: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }}
|
||||
IS_RELEASE_BUILD: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
PUBLISH_RELEASE: ${{ inputs.publish_release }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -81,8 +78,8 @@ jobs:
|
|||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
|
@ -102,8 +99,7 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
TAG="${RELEASE_TAG:-$TAG}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)"
|
||||
if [ -n "$existing_is_draft" ]; then
|
||||
if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then
|
||||
|
|
@ -155,8 +151,7 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
TAG="${RELEASE_TAG:-$TAG}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)"
|
||||
if [ -n "$existing_is_draft" ]; then
|
||||
if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then
|
||||
|
|
@ -184,8 +179,7 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
TAG="${RELEASE_TAG:-$TAG}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
||||
missing=0
|
||||
while IFS= read -r asset; do
|
||||
|
|
@ -278,8 +272,7 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
TAG="${RELEASE_TAG:-$TAG}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
for attempt in $(seq 1 12); do
|
||||
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
||||
all_found=1
|
||||
|
|
@ -377,8 +370,8 @@ jobs:
|
|||
- name: Build app (macOS ${{ matrix.arch }})
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
|
@ -494,8 +487,8 @@ jobs:
|
|||
- name: Build app (Windows)
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
|
@ -614,8 +607,8 @@ jobs:
|
|||
- name: Build app (Linux)
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
|
@ -642,6 +635,9 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: pnpm pack:linux --publish never
|
||||
|
||||
- name: Validate Linux installers
|
||||
run: node ./scripts/electron-builder/verifyLinuxInstallers.cjs release
|
||||
|
||||
- name: Validate packaged bundle (Linux)
|
||||
run: node ./scripts/electron-builder/verifyBundle.cjs "release/linux-unpacked" linux x64
|
||||
|
||||
|
|
@ -667,10 +663,10 @@ jobs:
|
|||
needs: [release-mac, release-win, release-linux]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
steps:
|
||||
- name: Upload compatibility aliases for older updater clients
|
||||
- name: Upload stable download aliases
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
|
|
@ -681,7 +677,7 @@ jobs:
|
|||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
declare -A COMPATIBILITY_ALIASES=(
|
||||
declare -A STABLE_ALIASES=(
|
||||
["Agent.Teams.AI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg"
|
||||
["Agent.Teams.AI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg"
|
||||
["Agent.Teams.AI.Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe"
|
||||
|
|
@ -689,18 +685,11 @@ jobs:
|
|||
["agent-teams-ai-amd64.deb"]="agent-teams-ai_${VERSION}_amd64.deb"
|
||||
["agent-teams-ai-x86_64.rpm"]="agent-teams-ai-${VERSION}.x86_64.rpm"
|
||||
["agent-teams-ai.pacman"]="agent-teams-ai-${VERSION}.pacman"
|
||||
["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg"
|
||||
["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg"
|
||||
["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe"
|
||||
["Claude-Agent-Teams-UI.AppImage"]="Agent.Teams.AI-${VERSION}.AppImage"
|
||||
["Claude-Agent-Teams-UI-amd64.deb"]="agent-teams-ai_${VERSION}_amd64.deb"
|
||||
["Claude-Agent-Teams-UI-x86_64.rpm"]="agent-teams-ai-${VERSION}.x86_64.rpm"
|
||||
["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman"
|
||||
)
|
||||
|
||||
for ALIAS_NAME in "${!COMPATIBILITY_ALIASES[@]}"; do
|
||||
VERSIONED_NAME="${COMPATIBILITY_ALIASES[$ALIAS_NAME]}"
|
||||
echo "Uploading compatibility alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}"
|
||||
for ALIAS_NAME in "${!STABLE_ALIASES[@]}"; do
|
||||
VERSIONED_NAME="${STABLE_ALIASES[$ALIAS_NAME]}"
|
||||
echo "Uploading stable alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}"
|
||||
gh release download "${TAG}" \
|
||||
--repo "$REPO" \
|
||||
--pattern "${VERSIONED_NAME}" \
|
||||
|
|
@ -798,7 +787,7 @@ jobs:
|
|||
gh release upload "${TAG}" latest.yml latest-linux.yml latest-mac.yml --repo "${REPO}" --clobber
|
||||
|
||||
- name: Publish release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }}
|
||||
if: ${{ inputs.publish_release }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ function assertKanbanColumnAllowed(context, task, column, options = {}) {
|
|||
}
|
||||
|
||||
if (column === 'approved') {
|
||||
if (transition === 'manual_approve') {
|
||||
if (reviewState !== 'none') {
|
||||
throw new Error(
|
||||
`Task ${label} is already in reviewState=${reviewState}; use review_approve instead`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (transition === 'approve_review') {
|
||||
if (reviewState !== 'review' && reviewState !== 'approved') {
|
||||
throw new Error(`Task ${label} must be in review before approval`);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
function normalizeRuntimeProvider(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'opencode' ? 'opencode' : 'native';
|
||||
if (normalized === 'opencode') return 'opencode';
|
||||
if (normalized === 'codex') return 'codex';
|
||||
return 'native';
|
||||
}
|
||||
|
||||
function createMemberMessagingProtocol(runtimeProvider) {
|
||||
const provider = normalizeRuntimeProvider(runtimeProvider);
|
||||
|
||||
if (provider === 'opencode') {
|
||||
if (provider === 'opencode' || provider === 'codex') {
|
||||
const runtimeLabel = provider === 'opencode' ? 'OpenCode' : 'Codex Native';
|
||||
return {
|
||||
runtimeProvider: 'opencode',
|
||||
runtimeProvider: provider,
|
||||
sendToolName: 'agent-teams_message_send',
|
||||
sendToolAliases: [
|
||||
'agent-teams_message_send',
|
||||
|
|
@ -25,6 +28,10 @@ function createMemberMessagingProtocol(runtimeProvider) {
|
|||
buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) {
|
||||
return `agent-teams_cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`;
|
||||
},
|
||||
visibleMessageRule:
|
||||
`${runtimeLabel} visible messaging rule: call agent-teams_message_send for normal replies to the human user, lead, or same-team teammates. Always include teamName, to, from, text, and summary. Do not use SendMessage or runtime_deliver_message for ordinary replies.`,
|
||||
taskToolHint:
|
||||
`${runtimeLabel} task tool rule: call Agent Teams task tools directly; if prefixed MCP names are exposed, use mcp__agent-teams__task_get, mcp__agent-teams__task_start, mcp__agent-teams__task_add_comment, and mcp__agent-teams__task_complete.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +47,8 @@ function createMemberMessagingProtocol(runtimeProvider) {
|
|||
buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) {
|
||||
return `cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`;
|
||||
},
|
||||
visibleMessageRule: '',
|
||||
taskToolHint: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -52,8 +61,18 @@ function isOpenCodeMember(member) {
|
|||
return model.startsWith('opencode/');
|
||||
}
|
||||
|
||||
function isCodexMember(member) {
|
||||
const provider = String((member && (member.providerId || member.provider)) || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (provider) return provider === 'codex';
|
||||
const model = String((member && member.model) || '').trim().toLowerCase();
|
||||
return model.startsWith('gpt-') || model.startsWith('openai/gpt-');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createMemberMessagingProtocol,
|
||||
isCodexMember,
|
||||
isOpenCodeMember,
|
||||
normalizeRuntimeProvider,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const { withTeamBoardLock } = require('./boardLock.js');
|
|||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||
const {
|
||||
createMemberMessagingProtocol,
|
||||
isCodexMember,
|
||||
isOpenCodeMember,
|
||||
} = require('./memberMessagingProtocol.js');
|
||||
|
||||
|
|
@ -72,6 +73,12 @@ function warnNonCritical(message, error) {
|
|||
console.warn(`${message}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
function resolveMemberRuntimeProvider(member) {
|
||||
if (isOpenCodeMember(member)) return 'opencode';
|
||||
if (isCodexMember(member)) return 'codex';
|
||||
return 'native';
|
||||
}
|
||||
|
||||
function buildAssignmentMessage(context, task, options = {}) {
|
||||
const messagingProtocol = options.messagingProtocol || createMemberMessagingProtocol('native');
|
||||
const description =
|
||||
|
|
@ -104,10 +111,12 @@ function buildAssignmentMessage(context, task, options = {}) {
|
|||
text: `#${task.displayId || task.id} done. <2-4 sentence summary>. Full details in task comment <short-commentId-from-step-4>. Moving to next task.`,
|
||||
summary: `#${task.displayId || task.id} done`,
|
||||
});
|
||||
const openCodeVisibleMessageRule =
|
||||
messagingProtocol.runtimeProvider === 'opencode'
|
||||
? '\n For normal visible replies, use agent-teams_message_send. Do not use SendMessage or runtime_deliver_message for ordinary replies.'
|
||||
: '';
|
||||
const runtimeVisibleMessageRule = messagingProtocol.visibleMessageRule
|
||||
? `\n ${messagingProtocol.visibleMessageRule}`
|
||||
: '';
|
||||
const runtimeTaskToolHint = messagingProtocol.taskToolHint
|
||||
? `\n ${messagingProtocol.taskToolHint}`
|
||||
: '';
|
||||
|
||||
lines.push(
|
||||
``,
|
||||
|
|
@ -123,7 +132,7 @@ function buildAssignmentMessage(context, task, options = {}) {
|
|||
The response contains comment.id (UUID). Take its first 8 characters as the short commentId.
|
||||
task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }
|
||||
5. After task_complete, notify your lead via ${messagingProtocol.sendLeadPhrase} with a brief summary and a pointer to the full comment (use the short commentId from step 4).
|
||||
Example: ${notifyLeadExample}${openCodeVisibleMessageRule}`)
|
||||
Example: ${notifyLeadExample}${runtimeVisibleMessageRule}${runtimeTaskToolHint}`)
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
|
|
@ -189,9 +198,7 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
|
|||
const ownerMember = (resolved.members || []).find(
|
||||
(member) => isSameMember(member && member.name, owner)
|
||||
);
|
||||
const messagingProtocol = createMemberMessagingProtocol(
|
||||
isOpenCodeMember(ownerMember) ? 'opencode' : 'native'
|
||||
);
|
||||
const messagingProtocol = createMemberMessagingProtocol(resolveMemberRuntimeProvider(ownerMember));
|
||||
|
||||
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
|
||||
try {
|
||||
|
|
@ -696,10 +703,12 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
|
|||
text: '#abcd1234 done. Found 3 competitors: two lack kanban, one went closed-source in Jan. Full details in task comment e5f6a7b8. Moving to #efgh5678 next.',
|
||||
summary: '#abcd1234 done',
|
||||
});
|
||||
const openCodeVisibleMessageRule =
|
||||
messagingProtocol.runtimeProvider === 'opencode'
|
||||
? '\n - For normal visible replies, use agent-teams_message_send. Always include teamName, to, from, text, and summary. Always set from to your teammate name. Do not use SendMessage or runtime_deliver_message for ordinary replies.'
|
||||
: '';
|
||||
const runtimeVisibleMessageRule = messagingProtocol.visibleMessageRule
|
||||
? `\n - ${messagingProtocol.visibleMessageRule}`
|
||||
: '';
|
||||
const runtimeTaskToolHint = messagingProtocol.taskToolHint
|
||||
? `\n - ${messagingProtocol.taskToolHint}`
|
||||
: '';
|
||||
return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
||||
0. IMPORTANT ID RULE:
|
||||
- If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls.
|
||||
|
|
@ -722,7 +731,7 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
|
|||
- After that, run task_complete again before your reply.
|
||||
- Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved.
|
||||
- After task_complete, send a notification to your team lead via ${messagingProtocol.sendLeadPhrase}. Use the comment.id you saved earlier (first 8 characters). Your message must include: (a) which task is done, (b) a brief summary of the outcome (2-4 sentences), (c) a pointer to the full comment so the lead can fetch it, (d) what you will do next. Do NOT duplicate the entire results.
|
||||
Example: ${notifyLeadExample}${openCodeVisibleMessageRule}
|
||||
Example: ${notifyLeadExample}${runtimeVisibleMessageRule}${runtimeTaskToolHint}
|
||||
- After task_complete, call review_request ONLY when review is explicitly expected for THIS task and a concrete reviewer is already known.
|
||||
Example:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>", reviewer: "<reviewer-name>" }
|
||||
|
|
@ -891,7 +900,7 @@ async function memberBriefing(context, memberName, options = {}) {
|
|||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
const effectiveMember = member;
|
||||
const messagingProtocol = createMemberMessagingProtocol(
|
||||
options.runtimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native')
|
||||
options.runtimeProvider || resolveMemberRuntimeProvider(effectiveMember)
|
||||
);
|
||||
|
||||
const role =
|
||||
|
|
@ -937,12 +946,13 @@ async function memberBriefing(context, memberName, options = {}) {
|
|||
`CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.`,
|
||||
`CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A direct message to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only send a direct message without a task comment, the user will never see your work.`,
|
||||
`After task_complete, notify your team lead via ${messagingProtocol.sendLeadPhrase}. Use the comment.id you saved (first 8 characters). Include: task ref, brief summary (2-4 sentences), pointer to full comment, and next step. Example: ${completionNotifyExample}`,
|
||||
...(messagingProtocol.runtimeProvider === 'opencode'
|
||||
...(messagingProtocol.runtimeProvider !== 'native'
|
||||
? [
|
||||
'OpenCode visible messaging rule: call agent-teams_message_send for normal replies to the human user, lead, or same-team teammates. Always include teamName, to, from, text, and summary. Do not use SendMessage or runtime_deliver_message for ordinary replies.',
|
||||
'OpenCode bootstrap silence rule: if this briefing was requested because the desktop app attached or reconnected you, do not send readiness, understood, idle, or no-task acknowledgements to the user, lead, or teammates.',
|
||||
messagingProtocol.visibleMessageRule,
|
||||
`${messagingProtocol.runtimeProvider === 'opencode' ? 'OpenCode' : 'Codex Native'} bootstrap silence rule: if this briefing was requested because the desktop app attached or reconnected you, do not send readiness, understood, idle, or no-task acknowledgements to the user, lead, or teammates.`,
|
||||
'This briefing already includes your current Task briefing. If it shows no actionable tasks, stop and wait silently. Do not call task_briefing again in the same bootstrap turn just to check for work.',
|
||||
'Use agent-teams_message_send only for actual app-delivered messages, actionable task coordination, blockers, or task results.',
|
||||
messagingProtocol.taskToolHint,
|
||||
'For cross-team replies or messages to another team, call agent-teams_cross_team_send with toTeam/fromMember. Do not put "cross_team_send" or a remote team name into message_send.to.',
|
||||
]
|
||||
: []),
|
||||
|
|
|
|||
|
|
@ -98,13 +98,17 @@ async function requestJson(baseUrl, pathname, options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
function isRetryableControlApiStatus(statusCode) {
|
||||
return statusCode === 404 || statusCode === 408 || statusCode === 429 || statusCode >= 500;
|
||||
}
|
||||
|
||||
async function requestJsonWithFallback(baseUrls, pathname, options = {}) {
|
||||
let lastError = null;
|
||||
for (const baseUrl of baseUrls) {
|
||||
try {
|
||||
return await requestJson(baseUrl, pathname, options);
|
||||
} catch (error) {
|
||||
if (error && error.controlApiStatus) {
|
||||
if (error && error.controlApiStatus && !isRetryableControlApiStatus(error.controlApiStatus)) {
|
||||
throw error;
|
||||
}
|
||||
lastError = error;
|
||||
|
|
|
|||
|
|
@ -19,9 +19,23 @@ function withMockedRenameSync(mockRenameSync, callback) {
|
|||
}
|
||||
|
||||
describe('atomic file writes', () => {
|
||||
const tempDirs = [];
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
['EPERM', 'EACCES', 'EBUSY'].forEach((code) => {
|
||||
it(`retries transient ${code} rename failures before publishing JSON`, () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
|
||||
const dir = makeTempDir();
|
||||
const filePath = path.join(dir, 'state.json');
|
||||
let attempts = 0;
|
||||
|
||||
|
|
@ -47,7 +61,7 @@ describe('atomic file writes', () => {
|
|||
});
|
||||
|
||||
it('does not retry ENOENT rename failures and removes the temp file', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
|
||||
const dir = makeTempDir();
|
||||
const filePath = path.join(dir, 'state.json');
|
||||
let attempts = 0;
|
||||
|
||||
|
|
@ -69,7 +83,7 @@ describe('atomic file writes', () => {
|
|||
});
|
||||
|
||||
it('removes the temp file after retryable rename failures are exhausted', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
|
||||
const dir = makeTempDir();
|
||||
const filePath = path.join(dir, 'state.json');
|
||||
let attempts = 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,17 @@ const path = require('path');
|
|||
const { createController } = require('../src/index.js');
|
||||
|
||||
describe('agent-teams-controller API', () => {
|
||||
const tempDirs = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeClaudeDir() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-controller-'));
|
||||
tempDirs.push(dir);
|
||||
fs.mkdirSync(path.join(dir, 'teams', 'my-team'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'tasks', 'my-team'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
|
|
@ -192,6 +201,28 @@ describe('agent-teams-controller API', () => {
|
|||
expect(briefing).not.toContain('notify your team lead via SendMessage');
|
||||
});
|
||||
|
||||
it('uses Codex-native visible-message and prefixed task tool wording for Codex member briefing', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
config.members = [
|
||||
{ name: 'alice', role: 'team-lead' },
|
||||
{ name: 'bob', role: 'developer', providerId: 'codex', model: 'gpt-5.4-mini' },
|
||||
];
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const briefing = await controller.tasks.memberBriefing('bob');
|
||||
|
||||
expect(briefing).toContain(
|
||||
'After task_complete, notify your team lead via MCP tool agent-teams_message_send.'
|
||||
);
|
||||
expect(briefing).toContain('Codex Native visible messaging rule');
|
||||
expect(briefing).toContain('Codex Native task tool rule');
|
||||
expect(briefing).toContain('mcp__agent-teams__task_get');
|
||||
expect(briefing).not.toContain('notify your team lead via SendMessage');
|
||||
});
|
||||
|
||||
it('rejects OpenCode idle acknowledgements without explicit delivery context', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
|
|
@ -295,7 +326,7 @@ describe('agent-teams-controller API', () => {
|
|||
expect(rows[0].summary).toBe('ready');
|
||||
});
|
||||
|
||||
it('does not infer OpenCode briefing from a generic provider-scoped model alone', async () => {
|
||||
it('infers Codex-native briefing from a generic provider-scoped GPT model', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
|
@ -308,8 +339,10 @@ describe('agent-teams-controller API', () => {
|
|||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const briefing = await controller.tasks.memberBriefing('bob');
|
||||
|
||||
expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.');
|
||||
expect(briefing).not.toContain('agent-teams_message_send');
|
||||
expect(briefing).toContain(
|
||||
'After task_complete, notify your team lead via MCP tool agent-teams_message_send.'
|
||||
);
|
||||
expect(briefing).toContain('Codex Native visible messaging rule');
|
||||
});
|
||||
|
||||
it('keeps explicit native provider metadata stronger than OpenCode-looking model labels', async () => {
|
||||
|
|
@ -318,7 +351,12 @@ describe('agent-teams-controller API', () => {
|
|||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
config.members = [
|
||||
{ name: 'alice', role: 'team-lead' },
|
||||
{ name: 'bob', role: 'developer', providerId: 'codex', model: 'opencode/minimax-m2.5-free' },
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'developer',
|
||||
providerId: 'anthropic',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
];
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
|
|
@ -974,6 +1012,34 @@ describe('agent-teams-controller API', () => {
|
|||
expect(controller.tasks.getTask(deletedTask.id).status).toBe('deleted');
|
||||
});
|
||||
|
||||
it('allows direct manual approval kanban shortcut for completed tasks outside review', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Completed manual shortcut',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
|
||||
expect(() => controller.review.approveReview(task.id, { from: 'alice' })).toThrow(
|
||||
'must be in review before approval'
|
||||
);
|
||||
expect(() => controller.kanban.setKanbanColumn(task.id, 'approved')).toThrow(
|
||||
'must already be approved'
|
||||
);
|
||||
|
||||
const state = controller.kanban.setKanbanColumn(task.id, 'approved', {
|
||||
transition: 'manual_approve',
|
||||
});
|
||||
expect(state.tasks[task.id].column).toBe('approved');
|
||||
const approvedTask = controller.tasks.getTask(task.id);
|
||||
expect(approvedTask.reviewState).toBe('approved');
|
||||
expect(
|
||||
(approvedTask.historyEvents || []).filter((event) => event.type === 'review_approved')
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects review_start outside active review and keeps owner routing intact', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -1598,6 +1664,30 @@ describe('agent-teams-controller API', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('uses Codex-native MCP wording in owner assignment notifications for Codex members', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
config.members = [
|
||||
{ name: 'alice', role: 'team-lead' },
|
||||
{ name: 'bob', role: 'developer', providerId: 'codex', model: 'gpt-5.4-mini' },
|
||||
];
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
controller.tasks.createTask({
|
||||
subject: 'Implement Codex handoff',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows[0].text).toContain('MCP tool agent-teams_message_send');
|
||||
expect(rows[0].text).toContain('Codex Native visible messaging rule');
|
||||
expect(rows[0].text).toContain('mcp__agent-teams__task_get');
|
||||
expect(rows[0].text).not.toContain('notify your lead via SendMessage');
|
||||
});
|
||||
|
||||
it('does not wake owner for self-comments and keeps user clarification sticky until explicitly cleared', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ const { createController } = require('../src/index.js');
|
|||
const { CROSS_TEAM_SOURCE, CROSS_TEAM_TAG_NAME } = require('../src/internal/crossTeamProtocol.js');
|
||||
|
||||
describe('crossTeam module', () => {
|
||||
const tempDirs = [];
|
||||
|
||||
function makeClaudeDir(teams = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossteam-test-'));
|
||||
tempDirs.push(dir);
|
||||
|
||||
for (const [teamName, config] of Object.entries(teams)) {
|
||||
const teamDir = path.join(dir, 'teams', teamName);
|
||||
|
|
@ -28,6 +31,9 @@ describe('crossTeam module', () => {
|
|||
// Reset cascade guard between tests
|
||||
const cascadeGuard = require('../src/internal/cascadeGuard.js');
|
||||
cascadeGuard.reset();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('sendCrossTeamMessage', () => {
|
||||
|
|
|
|||
|
|
@ -194,6 +194,22 @@ EOF
|
|||
|
||||
Write changelog entries from the **user's perspective**, not the developer's.
|
||||
|
||||
Release notes must stay short, concrete, and user-facing. Do not include internal
|
||||
maintenance details unless they directly change what users can do or clearly fix
|
||||
a user-visible problem.
|
||||
|
||||
Avoid entries about:
|
||||
|
||||
- CI/lint/test gates, smoke tests, or validation infrastructure.
|
||||
- README/docs cleanup, roadmap checkbox changes, or release-process polish.
|
||||
- Runtime artifact internals, bundled runtime version numbers, stable aliases,
|
||||
compatibility aliases, or updater plumbing.
|
||||
- Refactors, dependency bumps, or workflow changes without a user-visible effect.
|
||||
|
||||
If a change only made future releases, tests, packaging, or developer validation
|
||||
more reliable, keep it out of the public notes or fold it into one concise
|
||||
user-facing line only when it explains a real fix.
|
||||
|
||||
**Good:**
|
||||
|
||||
- "Add team member activity timeline with live status tracking"
|
||||
|
|
@ -205,6 +221,11 @@ Write changelog entries from the **user's perspective**, not the developer's.
|
|||
- "Refactor ChunkBuilder to use new pipeline"
|
||||
- "Update dependencies"
|
||||
- "Fix bug in useEffect cleanup"
|
||||
- "Fix CI lint gate"
|
||||
- "Stabilize provider smoke tests"
|
||||
- "Update README install guidance"
|
||||
- "Bundled runtime remains vX.Y.Z"
|
||||
- "Compatibility aliases are still included"
|
||||
|
||||
Group entries by type: `What's New` > `Improvements` > `Bug Fixes` > `Breaking Changes` (if any).
|
||||
|
||||
|
|
|
|||
|
|
@ -18,3 +18,5 @@ pnpm preview
|
|||
- Static-first (SSG) by design.
|
||||
- Locale auto-detection: cookie -> browser settings -> fallback `en`.
|
||||
- Theme auto-detection: localStorage -> system preference -> fallback `light`.
|
||||
- Hero video uses the Mux Player embed. Set `NUXT_PUBLIC_MUX_PLAYBACK_ID` to override the default playback id without changing the code.
|
||||
- Hero background can use a separate Mux asset via `NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID`; otherwise it reuses `NUXT_PUBLIC_MUX_PLAYBACK_ID`.
|
||||
|
|
|
|||
|
|
@ -34,8 +34,13 @@
|
|||
--cyber-monterey-after-bg:
|
||||
linear-gradient(180deg, rgba(2, 5, 13, 0.92) 0%, rgba(2, 5, 13, 0.62) 15%, rgba(2, 5, 13, 0.08) 44%, rgba(2, 5, 13, 0.68) 100%),
|
||||
radial-gradient(circle at 64% 42%, transparent 0 26%, rgba(2, 5, 13, 0.24) 70%, rgba(2, 5, 13, 0.54) 100%);
|
||||
--cyber-monterey-canvas-opacity: 0.42;
|
||||
--cyber-monterey-canvas-filter: blur(4px) saturate(0.78) brightness(0.62) contrast(1.08);
|
||||
--cyber-monterey-video-opacity: 1;
|
||||
--cyber-monterey-video-filter: blur(0.8px) saturate(1.12) contrast(1.04);
|
||||
--cyber-monterey-video-blend: normal;
|
||||
--cyber-monterey-video-position: 74% 50%;
|
||||
--cyber-monterey-video-scale: 1.16;
|
||||
--cyber-monterey-canvas-opacity: 0.36;
|
||||
--cyber-monterey-canvas-filter: blur(0.5px) saturate(0.92) brightness(0.78) contrast(1.08);
|
||||
--cyber-monterey-canvas-blend: normal;
|
||||
--cyber-background-bg:
|
||||
radial-gradient(circle at 72% 28%, rgba(0, 234, 255, 0.1), transparent 30%),
|
||||
|
|
@ -116,113 +121,6 @@
|
|||
--cyber-frame-cut: 18px;
|
||||
}
|
||||
|
||||
.v-theme--light .cyber-hero {
|
||||
--cyber-bg-0: #f8fcff;
|
||||
--cyber-bg-1: #eaf7fb;
|
||||
--cyber-panel-weak: rgba(255, 255, 255, 0.68);
|
||||
--cyber-panel: rgba(255, 255, 255, 0.78);
|
||||
--cyber-panel-strong: rgba(255, 255, 255, 0.92);
|
||||
--cyber-cyan: #008fb3;
|
||||
--cyber-blue: #2563eb;
|
||||
--cyber-magenta: #b832d8;
|
||||
--cyber-violet: #7c3aed;
|
||||
--cyber-text: #132238;
|
||||
--cyber-muted: #596b83;
|
||||
--cyber-border-cyan: rgba(8, 145, 178, 0.34);
|
||||
--cyber-border-magenta: rgba(184, 50, 216, 0.28);
|
||||
--cyber-panel-bg:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(237, 249, 252, 0.68));
|
||||
--cyber-panel-shadow:
|
||||
0 0 0 1px rgba(8, 145, 178, 0.1) inset,
|
||||
0 18px 48px rgba(8, 145, 178, 0.12),
|
||||
0 0 22px rgba(184, 50, 216, 0.07);
|
||||
--cyber-hero-bg:
|
||||
radial-gradient(circle at 76% 30%, rgba(0, 178, 214, 0.14), transparent 32%),
|
||||
radial-gradient(circle at 86% 70%, rgba(184, 50, 216, 0.13), transparent 36%),
|
||||
linear-gradient(180deg, #fbfdff 0%, #edf8fb 56%, #f8fcff 100%);
|
||||
--cyber-monterey-bg:
|
||||
radial-gradient(circle at 78% 24%, rgba(113, 185, 255, 0.28), transparent 31%),
|
||||
radial-gradient(circle at 22% 72%, rgba(221, 170, 255, 0.24), transparent 38%),
|
||||
radial-gradient(circle at 8% 32%, rgba(101, 218, 255, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, #f8fcff 0%, #eaf7fb 48%, #fbf7ff 100%);
|
||||
--cyber-monterey-before-bg:
|
||||
radial-gradient(circle at 18% 34%, rgba(255, 255, 255, 0.46), rgba(255, 255, 255, 0.14) 34%, transparent 62%),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.08) 42%, rgba(255, 255, 255, 0.03) 64%, rgba(237, 247, 252, 0.2) 100%);
|
||||
--cyber-monterey-after-bg:
|
||||
linear-gradient(180deg, rgba(248, 252, 255, 0.52) 0%, rgba(248, 252, 255, 0.22) 18%, rgba(248, 252, 255, 0.04) 48%, rgba(248, 252, 255, 0.58) 100%),
|
||||
radial-gradient(circle at 64% 42%, transparent 0 26%, rgba(255, 255, 255, 0.16) 70%, rgba(235, 247, 252, 0.42) 100%);
|
||||
--cyber-monterey-canvas-opacity: 1;
|
||||
--cyber-monterey-canvas-filter: blur(1px) saturate(1.34) brightness(1.12) contrast(1.16);
|
||||
--cyber-monterey-canvas-blend: multiply;
|
||||
--cyber-background-bg:
|
||||
radial-gradient(circle at 72% 28%, rgba(0, 178, 214, 0.12), transparent 31%),
|
||||
radial-gradient(circle at 88% 62%, rgba(184, 50, 216, 0.1), transparent 33%),
|
||||
linear-gradient(90deg, transparent 0 64px, rgba(8, 145, 178, 0.06) 65px 66px, transparent 67px 160px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.08) 38%, rgba(237, 247, 252, 0.64) 100%);
|
||||
--cyber-background-opacity: 0.5;
|
||||
--cyber-background-before-bg:
|
||||
linear-gradient(90deg, transparent 0 8%, rgba(8, 145, 178, 0.16) 8.1% 8.22%, transparent 8.34% 18%, rgba(184, 50, 216, 0.12) 18.1% 18.22%, transparent 18.34% 31%, rgba(8, 145, 178, 0.13) 31.1% 31.24%, transparent 31.36% 44%, rgba(37, 99, 235, 0.12) 44.1% 44.2%, transparent 44.34% 62%, rgba(184, 50, 216, 0.1) 62.1% 62.22%, transparent 62.34% 78%, rgba(8, 145, 178, 0.12) 78.1% 78.22%, transparent 78.34%),
|
||||
repeating-linear-gradient(90deg, transparent 0 78px, rgba(8, 145, 178, 0.06) 80px 82px, transparent 84px 116px),
|
||||
linear-gradient(to top, rgba(8, 145, 178, 0.12) 0%, rgba(8, 145, 178, 0.07) 13%, transparent 31%),
|
||||
linear-gradient(to top, rgba(184, 50, 216, 0.1) 0%, rgba(184, 50, 216, 0.05) 16%, transparent 38%),
|
||||
linear-gradient(to top, rgba(37, 99, 235, 0.09) 0%, rgba(37, 99, 235, 0.04) 20%, transparent 48%);
|
||||
--cyber-background-before-opacity: 0.58;
|
||||
--cyber-background-before-blend: multiply;
|
||||
--cyber-background-after-bg:
|
||||
repeating-linear-gradient(90deg, transparent 0 34px, rgba(8, 145, 178, 0.1) 35px 36px, transparent 37px 110px),
|
||||
repeating-linear-gradient(180deg, transparent 0 28px, rgba(184, 50, 216, 0.08) 29px 30px, transparent 31px 78px),
|
||||
linear-gradient(90deg, transparent, rgba(8, 145, 178, 0.07), transparent);
|
||||
--cyber-background-after-opacity: 0.18;
|
||||
--cyber-background-after-blend: multiply;
|
||||
--cyber-wash-bg:
|
||||
radial-gradient(circle at 18% 44%, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.12) 36%, transparent 60%),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.18) 0%, rgba(237, 248, 252, 0.1) 36%, rgba(255, 255, 255, 0.03) 68%, rgba(251, 247, 255, 0.16) 100%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(248, 252, 255, 0.04) 58%, rgba(248, 252, 255, 0.72));
|
||||
--cyber-gridlines-bg:
|
||||
linear-gradient(rgba(8, 145, 178, 0.055) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(8, 145, 178, 0.045) 1px, transparent 1px);
|
||||
--cyber-gridlines-opacity: 0.2;
|
||||
--cyber-scanlines-bg: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(8, 35, 50, 0.035) 0,
|
||||
rgba(8, 35, 50, 0.035) 1px,
|
||||
transparent 1px,
|
||||
transparent 4px
|
||||
);
|
||||
--cyber-scanlines-opacity: 0.08;
|
||||
--cyber-copy-aura: radial-gradient(circle at 28% 38%, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.38) 58%, transparent 78%);
|
||||
--cyber-title-color: rgba(19, 34, 56, 0.98);
|
||||
--cyber-description-color: rgba(50, 65, 88, 0.82);
|
||||
--cyber-action-primary-color: #061722;
|
||||
--cyber-action-secondary-bg: rgba(255, 255, 255, 0.62);
|
||||
--cyber-action-secondary-hover-bg: rgba(8, 145, 178, 0.08);
|
||||
--cyber-release-color: rgba(50, 65, 88, 0.68);
|
||||
--cyber-scene-floor-bg:
|
||||
radial-gradient(ellipse at 58% 84%, rgba(184, 50, 216, 0.16), transparent 18%),
|
||||
radial-gradient(ellipse at 56% 84%, rgba(8, 145, 178, 0.14), transparent 32%),
|
||||
repeating-radial-gradient(ellipse at 58% 84%, rgba(8, 145, 178, 0.08) 0 1px, transparent 1px 20px);
|
||||
--cyber-scene-floor-opacity: 0.38;
|
||||
--cyber-scene-foreground-bg:
|
||||
linear-gradient(90deg, transparent 0 4%, rgba(8, 145, 178, 0.07) 4.1%, transparent 4.4%),
|
||||
linear-gradient(180deg, transparent 0 88%, rgba(184, 50, 216, 0.06));
|
||||
--cyber-scene-foreground-opacity: 0.48;
|
||||
--cyber-video-frame-bg: rgba(255, 255, 255, 0.7);
|
||||
--cyber-video-content-bg: rgba(8, 20, 34, 0.9);
|
||||
--cyber-card-text: rgba(19, 34, 56, 0.88);
|
||||
--cyber-card-muted: rgba(67, 82, 105, 0.76);
|
||||
--cyber-card-subtle: rgba(67, 82, 105, 0.64);
|
||||
--cyber-card-inset: rgba(255, 255, 255, 0.62);
|
||||
--cyber-feature-shell-bg: transparent;
|
||||
--cyber-feature-rail-bg: transparent;
|
||||
--cyber-feature-rail-shadow:
|
||||
0 0 0 1px rgba(8, 145, 178, 0.12) inset,
|
||||
0 -1px 0 rgba(8, 145, 178, 0.18),
|
||||
0 1px 0 rgba(8, 145, 178, 0.16);
|
||||
--cyber-feature-divider: rgba(8, 145, 178, 0.18);
|
||||
--cyber-feature-title: rgba(19, 34, 56, 0.94);
|
||||
--cyber-feature-text: rgba(67, 82, 105, 0.72);
|
||||
}
|
||||
|
||||
.cyber-panel {
|
||||
position: relative;
|
||||
border: 1px solid var(--cyber-border-cyan);
|
||||
|
|
@ -294,6 +192,65 @@
|
|||
background: var(--cyber-monterey-after-bg);
|
||||
}
|
||||
|
||||
.cyber-hero__background,
|
||||
.cyber-hero__wash,
|
||||
.cyber-hero__monterey::before,
|
||||
.cyber-hero__monterey::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: var(--cyber-monterey-video-opacity);
|
||||
filter: var(--cyber-monterey-video-filter);
|
||||
mix-blend-mode: var(--cyber-monterey-video-blend);
|
||||
background:
|
||||
var(--cyber-monterey-video-poster) var(--cyber-monterey-video-position) / cover no-repeat,
|
||||
var(--cyber-monterey-bg);
|
||||
transform: scale(var(--cyber-monterey-video-scale));
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-video::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-video-player {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
object-fit: cover;
|
||||
object-position: var(--cyber-monterey-video-position);
|
||||
--media-object-fit: cover;
|
||||
--media-object-position: var(--cyber-monterey-video-position);
|
||||
transition: opacity 0.45s ease;
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-video-player::part(video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: var(--cyber-monterey-video-position);
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-video-player--ready {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
@ -490,60 +447,251 @@
|
|||
|
||||
.cyber-hero__description {
|
||||
max-width: 560px;
|
||||
margin: 0 0 30px;
|
||||
margin: 0 0 20px;
|
||||
color: var(--cyber-description-color);
|
||||
font-size: clamp(1rem, 1.08vw, 1.22rem);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.cyber-hero__providers {
|
||||
width: min(680px, 100%);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px 26px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider {
|
||||
--provider-accent: var(--cyber-cyan);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 9px;
|
||||
color: var(--cyber-text);
|
||||
text-shadow:
|
||||
0 0 18px color-mix(in srgb, var(--provider-accent) 26%, transparent),
|
||||
0 0 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.cyber-hero__provider--cyan {
|
||||
--provider-accent: var(--cyber-cyan);
|
||||
}
|
||||
|
||||
.cyber-hero__provider--amber {
|
||||
--provider-accent: #ffcf5a;
|
||||
}
|
||||
|
||||
.cyber-hero__provider--magenta {
|
||||
--provider-accent: var(--cyber-magenta);
|
||||
}
|
||||
|
||||
.cyber-hero__provider-icon {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
color: var(--provider-accent);
|
||||
filter: drop-shadow(0 0 10px color-mix(in srgb, var(--provider-accent) 44%, transparent));
|
||||
}
|
||||
|
||||
.cyber-hero__provider-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-name {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--cyber-text);
|
||||
font-size: 0.98rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.cyber-hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.cyber-hero__action.v-btn {
|
||||
min-height: 52px !important;
|
||||
min-width: 148px !important;
|
||||
border-radius: var(--cyber-radius-sm) !important;
|
||||
padding-inline: 18px !important;
|
||||
font-weight: 800 !important;
|
||||
font-size: 0.9rem !important;
|
||||
letter-spacing: 0.01em !important;
|
||||
.cyber-action-button.v-btn {
|
||||
--action-accent-a: var(--cyber-cyan);
|
||||
--action-accent-b: var(--cyber-magenta);
|
||||
--action-bg:
|
||||
linear-gradient(135deg, rgba(0, 210, 255, 0.74) 0%, rgba(43, 103, 255, 0.56) 48%, rgba(207, 34, 215, 0.76) 100%),
|
||||
linear-gradient(180deg, rgba(7, 18, 42, 0.9), rgba(4, 10, 25, 0.92));
|
||||
--action-border: rgba(0, 234, 255, 0.78);
|
||||
--action-text: #f2fbff;
|
||||
--action-subtext: rgba(231, 240, 255, 0.72);
|
||||
--action-clip: polygon(18px 0, calc(100% - 12px) 0, 100% 12px, 100% calc(100% - 18px), calc(100% - 18px) 100%, 0 100%, 0 18px);
|
||||
|
||||
position: relative;
|
||||
min-height: 76px !important;
|
||||
min-width: 312px !important;
|
||||
overflow: hidden !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 0 26px !important;
|
||||
color: var(--action-text) !important;
|
||||
background: var(--action-bg) !important;
|
||||
clip-path: var(--action-clip);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
|
||||
0 0 22px color-mix(in srgb, var(--action-accent-a) 24%, transparent),
|
||||
0 0 34px color-mix(in srgb, var(--action-accent-b) 20%, transparent) !important;
|
||||
font-weight: 900 !important;
|
||||
letter-spacing: 0 !important;
|
||||
text-transform: uppercase !important;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease !important;
|
||||
filter 0.18s ease !important;
|
||||
}
|
||||
|
||||
.cyber-hero__action.v-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
.cyber-action-button.v-btn .v-btn__content {
|
||||
position: static;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cyber-hero__action--primary.v-btn {
|
||||
color: var(--cyber-action-primary-color) !important;
|
||||
background: linear-gradient(135deg, var(--cyber-cyan), var(--cyber-magenta)) !important;
|
||||
.cyber-action-button.v-btn .v-btn__overlay,
|
||||
.cyber-action-button.v-btn .v-btn__underlay {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cyber-action-button.v-btn:hover {
|
||||
filter: brightness(1.08) saturate(1.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cyber-action-button__glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.16), transparent 28%),
|
||||
radial-gradient(ellipse at 16% 48%, color-mix(in srgb, var(--action-accent-a) 22%, transparent), transparent 38%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 52%);
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.cyber-action-button--primary .cyber-action-button__glow::after {
|
||||
position: absolute;
|
||||
inset: -42% -58%;
|
||||
content: "";
|
||||
background:
|
||||
linear-gradient(
|
||||
108deg,
|
||||
transparent 0 42%,
|
||||
rgba(255, 255, 255, 0.18) 47%,
|
||||
rgba(255, 255, 255, 0.58) 50%,
|
||||
rgba(0, 234, 255, 0.24) 53%,
|
||||
transparent 59% 100%
|
||||
);
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0;
|
||||
transform: translate3d(-64%, 0, 0) skewX(-18deg);
|
||||
animation: cyberActionButtonShine 4.8s cubic-bezier(0.25, 0.1, 0.22, 1) infinite;
|
||||
}
|
||||
|
||||
.cyber-action-button__frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
color: var(--action-border);
|
||||
pointer-events: none;
|
||||
filter:
|
||||
drop-shadow(0 0 8px color-mix(in srgb, var(--action-accent-a) 22%, transparent))
|
||||
drop-shadow(0 0 12px color-mix(in srgb, var(--action-accent-b) 12%, transparent));
|
||||
}
|
||||
|
||||
.cyber-action-button__frame path {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: square;
|
||||
stroke-linejoin: miter;
|
||||
stroke-width: 1.35;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.cyber-action-button__icon {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
color: var(--action-accent-a);
|
||||
filter:
|
||||
drop-shadow(0 0 10px color-mix(in srgb, var(--action-accent-a) 54%, transparent))
|
||||
drop-shadow(0 0 18px color-mix(in srgb, var(--action-accent-b) 30%, transparent));
|
||||
}
|
||||
|
||||
.cyber-action-button--primary .cyber-action-button__icon {
|
||||
color: #00d8ff;
|
||||
filter:
|
||||
drop-shadow(0 0 10px rgba(0, 234, 255, 0.56))
|
||||
drop-shadow(0 0 16px rgba(35, 91, 255, 0.22));
|
||||
}
|
||||
|
||||
.cyber-action-button__copy {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cyber-action-button__label {
|
||||
color: var(--action-text);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cyber-action-button__subtitle {
|
||||
color: var(--action-subtext);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
text-transform: none;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.cyber-action-button--primary.v-btn {
|
||||
--action-text: #f8fbff;
|
||||
--action-subtext: rgba(237, 247, 255, 0.74);
|
||||
}
|
||||
|
||||
.cyber-action-button--secondary.v-btn {
|
||||
--action-accent-a: #91b6ff;
|
||||
--action-accent-b: var(--cyber-cyan);
|
||||
--action-bg:
|
||||
linear-gradient(135deg, rgba(10, 18, 38, 0.96), rgba(7, 14, 31, 0.78)),
|
||||
rgba(1, 7, 18, 0.84);
|
||||
--action-border: rgba(123, 160, 231, 0.64);
|
||||
--action-text: #f3f7ff;
|
||||
--action-subtext: rgba(182, 198, 226, 0.76);
|
||||
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.16) inset,
|
||||
0 0 24px rgba(0, 234, 255, 0.34),
|
||||
0 0 34px rgba(255, 43, 255, 0.22) !important;
|
||||
}
|
||||
|
||||
.cyber-hero__action--watch.v-btn,
|
||||
.cyber-hero__action--docs.v-btn {
|
||||
color: var(--cyber-text) !important;
|
||||
border-color: rgba(0, 234, 255, 0.46) !important;
|
||||
background: var(--cyber-action-secondary-bg) !important;
|
||||
}
|
||||
|
||||
.cyber-hero__action--watch.v-btn:hover,
|
||||
.cyber-hero__action--docs.v-btn:hover {
|
||||
color: var(--cyber-cyan) !important;
|
||||
border-color: rgba(0, 234, 255, 0.74) !important;
|
||||
background: var(--cyber-action-secondary-hover-bg) !important;
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset,
|
||||
0 0 24px rgba(80, 130, 255, 0.16),
|
||||
0 0 34px rgba(0, 234, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.cyber-hero__terminal-note {
|
||||
|
|
@ -1193,31 +1341,67 @@
|
|||
.cyber-feature-rail-shell {
|
||||
position: relative;
|
||||
z-index: 7;
|
||||
margin: 28px auto 0;
|
||||
width: min(1540px, 96%);
|
||||
margin: clamp(42px, 5vw, 72px) auto 0;
|
||||
width: min(1580px, 96%);
|
||||
}
|
||||
|
||||
.cyber-feature-rail-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: -118px -3vw -96px;
|
||||
inset: -82px -4vw -76px;
|
||||
pointer-events: none;
|
||||
background: var(--cyber-feature-shell-bg);
|
||||
opacity: 0.86;
|
||||
mask-image: radial-gradient(ellipse at 50% 54%, black 0 48%, rgba(0, 0, 0, 0.5) 62%, transparent 78%);
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 42%, rgba(0, 234, 255, 0.12), transparent 48%),
|
||||
radial-gradient(ellipse at 88% 54%, rgba(255, 43, 255, 0.12), transparent 42%);
|
||||
opacity: 0.72;
|
||||
mask-image: radial-gradient(ellipse at 50% 52%, black 0 48%, rgba(0, 0, 0, 0.5) 62%, transparent 78%);
|
||||
}
|
||||
|
||||
.cyber-feature-rail {
|
||||
--feature-line-top: 118px;
|
||||
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
padding: 17px 18px;
|
||||
background: var(--cyber-feature-rail-bg);
|
||||
box-shadow: var(--cyber-feature-rail-shadow);
|
||||
min-height: 300px;
|
||||
padding: 0 22px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cyber-feature-rail::before {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: var(--feature-line-top);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
content: "";
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0 2.5%, var(--cyber-cyan) 6%, rgba(0, 234, 255, 0.92) 78%, rgba(255, 43, 255, 0.95) 92%, transparent 98%),
|
||||
linear-gradient(90deg, transparent 0 3%, rgba(255, 255, 255, 0.34) 6%, transparent 13%, transparent 86%, rgba(255, 255, 255, 0.24) 92%, transparent 98%);
|
||||
box-shadow:
|
||||
0 0 18px rgba(0, 234, 255, 0.42),
|
||||
0 0 30px rgba(255, 43, 255, 0.18);
|
||||
}
|
||||
|
||||
.cyber-feature-rail::after {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: calc(var(--feature-line-top) - 7px);
|
||||
left: 0;
|
||||
width: 86px;
|
||||
height: 17px;
|
||||
content: "";
|
||||
background:
|
||||
repeating-linear-gradient(115deg, var(--cyber-cyan) 0 4px, transparent 4px 10px),
|
||||
linear-gradient(90deg, transparent, rgba(0, 234, 255, 0.6));
|
||||
clip-path: polygon(0 42%, 44% 42%, 54% 0, 100% 0, 100% 100%, 54% 100%, 44% 58%, 0 58%);
|
||||
filter: drop-shadow(0 0 10px rgba(0, 234, 255, 0.45));
|
||||
}
|
||||
|
||||
.cyber-feature-rail__collaboration {
|
||||
|
|
@ -1388,41 +1572,127 @@
|
|||
}
|
||||
|
||||
.cyber-feature-rail__item {
|
||||
--feature-accent: var(--cyber-cyan);
|
||||
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
grid-template-rows: 82px 54px minmax(0, 1fr);
|
||||
justify-items: center;
|
||||
align-items: start;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
padding: 0 18px;
|
||||
border-right: 1px solid var(--cyber-feature-divider);
|
||||
padding: 0 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__item:last-child {
|
||||
border-right: 0;
|
||||
.cyber-feature-rail__item:nth-child(5) {
|
||||
--feature-accent: var(--cyber-magenta);
|
||||
}
|
||||
|
||||
.cyber-feature-rail__icon {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
color: var(--cyber-cyan);
|
||||
border: 1px solid rgba(0, 234, 255, 0.44);
|
||||
border-radius: var(--cyber-radius-sm);
|
||||
box-shadow: 0 0 22px rgba(0, 234, 255, 0.16);
|
||||
width: 80px;
|
||||
height: 70px;
|
||||
color: var(--feature-accent);
|
||||
filter:
|
||||
drop-shadow(0 0 16px color-mix(in srgb, var(--feature-accent) 48%, transparent))
|
||||
drop-shadow(0 0 28px color-mix(in srgb, var(--feature-accent) 18%, transparent));
|
||||
}
|
||||
|
||||
.cyber-feature-rail__icon::before,
|
||||
.cyber-feature-rail__icon::after {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
content: "";
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__icon::before {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-top: 1px solid var(--feature-accent);
|
||||
border-left: 1px solid var(--feature-accent);
|
||||
}
|
||||
|
||||
.cyber-feature-rail__icon::after {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-right: 1px solid var(--feature-accent);
|
||||
border-bottom: 1px solid var(--feature-accent);
|
||||
}
|
||||
|
||||
.cyber-feature-rail__icon .v-icon {
|
||||
font-size: 46px !important;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__node {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
color: var(--feature-accent);
|
||||
background: rgba(2, 10, 24, 0.9);
|
||||
border: 2px solid var(--feature-accent);
|
||||
clip-path: polygon(28% 0, 72% 0, 100% 28%, 100% 72%, 72% 100%, 28% 100%, 0 72%, 0 28%);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||
0 0 20px color-mix(in srgb, var(--feature-accent) 34%, transparent);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 1.14rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__node::before {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -26px;
|
||||
width: 1px;
|
||||
height: 25px;
|
||||
content: "";
|
||||
background: linear-gradient(180deg, var(--feature-accent), transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--feature-accent) 46%, transparent);
|
||||
}
|
||||
|
||||
.cyber-feature-rail__copy {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
max-width: 235px;
|
||||
padding: 18px 12px 12px;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__copy::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 2px -8px -4px;
|
||||
content: "";
|
||||
background:
|
||||
linear-gradient(180deg, rgba(3, 8, 22, 0.46), rgba(3, 8, 22, 0.2)),
|
||||
radial-gradient(ellipse at 50% 38%, rgba(0, 234, 255, 0.08), transparent 72%);
|
||||
border: 1px solid rgba(0, 234, 255, 0.08);
|
||||
border-radius: 18px;
|
||||
opacity: 0.92;
|
||||
backdrop-filter: blur(11px) saturate(1.08);
|
||||
mask-image: radial-gradient(ellipse at 50% 50%, black 0 76%, rgba(0, 0, 0, 0.62) 88%, transparent 100%);
|
||||
}
|
||||
|
||||
.cyber-feature-rail__title {
|
||||
margin-bottom: 3px;
|
||||
margin-bottom: 11px;
|
||||
color: var(--cyber-feature-title);
|
||||
font-weight: 800;
|
||||
font-size: 0.92rem;
|
||||
font-size: clamp(0.98rem, 1.12vw, 1.22rem);
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__text {
|
||||
color: var(--cyber-feature-text);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
font-size: clamp(0.82rem, 0.88vw, 1rem);
|
||||
line-height: 1.52;
|
||||
}
|
||||
|
||||
@keyframes cyberRobotBob {
|
||||
|
|
@ -1466,6 +1736,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes cyberActionButtonShine {
|
||||
0%,
|
||||
54% {
|
||||
opacity: 0;
|
||||
transform: translate3d(-64%, 0, 0) skewX(-18deg);
|
||||
}
|
||||
|
||||
61% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
76% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
84%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(64%, 0, 0) skewX(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.cyber-hero__layout {
|
||||
grid-template-columns: minmax(500px, 0.9fr) minmax(0, 1.1fr);
|
||||
|
|
@ -1537,36 +1829,38 @@
|
|||
|
||||
.cyber-feature-rail {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
min-height: auto;
|
||||
row-gap: 34px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__collaboration {
|
||||
left: 50%;
|
||||
bottom: calc(100% - 14px);
|
||||
width: 132px;
|
||||
.cyber-feature-rail::before,
|
||||
.cyber-feature-rail::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__reviewer {
|
||||
--reviewer-robot-width: 78px;
|
||||
|
||||
right: 56px;
|
||||
gap: 10px;
|
||||
.cyber-feature-rail__item {
|
||||
grid-template-rows: 72px 48px minmax(0, 1fr);
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__reviewer-card {
|
||||
width: 220px;
|
||||
font-size: 0.62rem;
|
||||
.cyber-feature-rail__icon {
|
||||
width: 72px;
|
||||
height: 62px;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__robot {
|
||||
width: var(--reviewer-robot-width);
|
||||
.cyber-feature-rail__icon .v-icon {
|
||||
font-size: 38px !important;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__item:nth-child(3) {
|
||||
border-right: 0;
|
||||
.cyber-feature-rail__node {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__item:nth-child(n + 4) {
|
||||
margin-top: 16px;
|
||||
.cyber-feature-rail__copy {
|
||||
padding-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1630,14 +1924,42 @@
|
|||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.cyber-hero__providers {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-list {
|
||||
gap: 12px 18px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-name {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.cyber-hero__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cyber-hero__action.v-btn {
|
||||
.cyber-action-button.v-btn {
|
||||
width: 100%;
|
||||
min-height: 72px !important;
|
||||
min-width: 0 !important;
|
||||
padding-inline: 20px !important;
|
||||
}
|
||||
|
||||
.cyber-hero__terminal-note {
|
||||
|
|
@ -1708,26 +2030,64 @@
|
|||
|
||||
.cyber-feature-rail {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__collaboration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__reviewer {
|
||||
display: none;
|
||||
gap: 20px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__item {
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
padding: 12px 0;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(0, 234, 255, 0.14);
|
||||
grid-template-columns: 48px 44px minmax(0, 1fr);
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
justify-items: start;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__item:last-child {
|
||||
border-bottom: 0;
|
||||
.cyber-feature-rail__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__icon::before,
|
||||
.cyber-feature-rail__icon::after {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__icon .v-icon {
|
||||
font-size: 26px !important;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__node {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__node::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__copy {
|
||||
max-width: none;
|
||||
padding: 8px 10px 9px;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__copy::before {
|
||||
inset: 0 -6px;
|
||||
border-radius: 14px;
|
||||
backdrop-filter: blur(9px) saturate(1.06);
|
||||
}
|
||||
|
||||
.cyber-feature-rail__title {
|
||||
margin-bottom: 5px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__text {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.42;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const onToggle = () => {
|
|||
:icon="mdiWeatherSunny"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Toggle theme"
|
||||
:aria-label="t('theme.light')"
|
||||
/>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
|
|
|||
49
landing/components/hero/CyberHeroActionButton.vue
Normal file
49
landing/components/hero/CyberHeroActionButton.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
href: string;
|
||||
icon: string;
|
||||
tone?: "primary" | "secondary";
|
||||
target?: string;
|
||||
subtitle?: string;
|
||||
}>(), {
|
||||
tone: "primary",
|
||||
target: undefined,
|
||||
subtitle: undefined,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
:href="href"
|
||||
:target="target"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="cyber-action-button"
|
||||
:class="`cyber-action-button--${tone}`"
|
||||
>
|
||||
<span class="cyber-action-button__glow" aria-hidden="true" />
|
||||
<svg
|
||||
class="cyber-action-button__frame"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
d="M 5 0 H 96 L 100 16 V 76 L 94 100 H 0 V 24 Z"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<span class="cyber-action-button__icon" aria-hidden="true">
|
||||
<v-icon :icon="icon" size="28" />
|
||||
</span>
|
||||
<span class="cyber-action-button__copy">
|
||||
<span class="cyber-action-button__label">
|
||||
<slot />
|
||||
</span>
|
||||
<span v-if="subtitle" class="cyber-action-button__subtitle">
|
||||
{{ subtitle }}
|
||||
</span>
|
||||
</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
|
@ -6,19 +6,10 @@ import {
|
|||
mdiShieldCheckOutline,
|
||||
mdiMonitorDashboard,
|
||||
} from "@mdi/js";
|
||||
import {
|
||||
heroCollaborationFeature,
|
||||
heroFeatureRail,
|
||||
heroReviewerFeatureCard,
|
||||
type HeroMessage,
|
||||
type HeroMessagePhase,
|
||||
} from "~/data/heroScene";
|
||||
import { getLocalizedHeroFeatureRail } from "~/data/heroScene";
|
||||
|
||||
const props = defineProps<{
|
||||
activeMessage?: HeroMessage | null;
|
||||
phase?: HeroMessagePhase;
|
||||
reducedMotion?: boolean;
|
||||
}>();
|
||||
const { locale } = useI18n();
|
||||
const localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value));
|
||||
|
||||
const icons = [
|
||||
mdiRobotOutline,
|
||||
|
|
@ -27,81 +18,22 @@ const icons = [
|
|||
mdiShieldCheckOutline,
|
||||
mdiMonitorDashboard,
|
||||
] as const;
|
||||
|
||||
const reviewerIsSender = computed(() =>
|
||||
props.activeMessage?.from === "reviewer" && props.phase !== "cooldown",
|
||||
);
|
||||
const reviewerIsReceiver = computed(() =>
|
||||
props.activeMessage?.to === "reviewer" && props.phase === "receiver",
|
||||
);
|
||||
const reviewerIsActive = computed(() => reviewerIsSender.value || reviewerIsReceiver.value);
|
||||
const reviewerBubbleText = computed(() => {
|
||||
if (!props.activeMessage || props.reducedMotion) return null;
|
||||
if (props.activeMessage.from === "reviewer" && (props.phase === "sender" || props.phase === "packet")) {
|
||||
return props.activeMessage.text;
|
||||
}
|
||||
if (props.activeMessage.to === "reviewer" && props.phase === "receiver") {
|
||||
return props.activeMessage.response;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cyber-feature-rail-shell">
|
||||
<img
|
||||
class="cyber-feature-rail__collaboration"
|
||||
:src="heroCollaborationFeature.asset"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="cyber-feature-rail__reviewer"
|
||||
:class="{
|
||||
'cyber-feature-rail__reviewer--active': reviewerIsActive,
|
||||
'cyber-feature-rail__reviewer--sending': reviewerIsSender,
|
||||
'cyber-feature-rail__reviewer--receiving': reviewerIsReceiver,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Transition name="cyber-feature-bubble">
|
||||
<RobotSpeechBubble
|
||||
v-if="reviewerBubbleText"
|
||||
class="cyber-feature-rail__reviewer-bubble"
|
||||
tail="down"
|
||||
>
|
||||
{{ reviewerBubbleText }}
|
||||
</RobotSpeechBubble>
|
||||
</Transition>
|
||||
<div class="cyber-feature-rail__reviewer-card cyber-panel">
|
||||
<div class="cyber-feature-rail__reviewer-label">{{ heroReviewerFeatureCard.label }}</div>
|
||||
<ul class="cyber-feature-rail__reviewer-tasks">
|
||||
<li v-for="task in heroReviewerFeatureCard.tasks" :key="task">{{ task }}</li>
|
||||
</ul>
|
||||
<div class="cyber-feature-rail__reviewer-status">
|
||||
<span>Status:</span>
|
||||
<strong>{{ heroReviewerFeatureCard.status }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
class="cyber-feature-rail__robot"
|
||||
:src="heroReviewerFeatureCard.asset"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
</div>
|
||||
<div class="cyber-feature-rail cyber-panel">
|
||||
<div class="cyber-feature-rail">
|
||||
<div
|
||||
v-for="(feature, index) in heroFeatureRail"
|
||||
v-for="(feature, index) in localizedHeroFeatureRail"
|
||||
:key="feature.id"
|
||||
class="cyber-feature-rail__item"
|
||||
>
|
||||
<div class="cyber-feature-rail__icon">
|
||||
<v-icon :icon="icons[index]" size="28" />
|
||||
</div>
|
||||
<div class="cyber-feature-rail__node">
|
||||
{{ (index + 1).toString().padStart(2, "0") }}
|
||||
</div>
|
||||
<div class="cyber-feature-rail__copy">
|
||||
<div class="cyber-feature-rail__title">{{ feature.title }}</div>
|
||||
<div class="cyber-feature-rail__text">{{ feature.text }}</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,22 @@ import type { NeatConfig, NeatController } from "@firecms/neat";
|
|||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const isLive = ref(false);
|
||||
const shouldMountBackgroundVideo = ref(false);
|
||||
const isBackgroundVideoReady = ref(false);
|
||||
const hasBackgroundVideoError = ref(false);
|
||||
const config = useRuntimeConfig();
|
||||
const backgroundPlaybackId = computed(() => (
|
||||
String(config.public.muxBackgroundPlaybackId || config.public.muxPlaybackId || "").trim()
|
||||
));
|
||||
const backgroundPosterUrl = computed(() => {
|
||||
if (!backgroundPlaybackId.value) return "";
|
||||
|
||||
const url = new URL(`https://image.mux.com/${encodeURIComponent(backgroundPlaybackId.value)}/thumbnail.jpg`);
|
||||
url.searchParams.set("time", "0.1");
|
||||
url.searchParams.set("width", "1600");
|
||||
url.searchParams.set("fit_mode", "preserve");
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
let gradient: NeatController | null = null;
|
||||
let heroObserver: IntersectionObserver | null = null;
|
||||
|
|
@ -12,6 +28,8 @@ let isVisible = false;
|
|||
let isInitializing = false;
|
||||
let initToken = 0;
|
||||
let revealTimer: number | null = null;
|
||||
let backgroundVideoTimer: number | null = null;
|
||||
let backgroundVideoIdleId: number | null = null;
|
||||
|
||||
const montereyConfig: NeatConfig = {
|
||||
colors: [
|
||||
|
|
@ -35,7 +53,7 @@ const montereyConfig: NeatConfig = {
|
|||
wireframe: false,
|
||||
colorBlending: 9,
|
||||
backgroundColor: "#030012",
|
||||
backgroundAlpha: 1,
|
||||
backgroundAlpha: 0,
|
||||
grainScale: 6,
|
||||
grainSparsity: 0,
|
||||
grainIntensity: 0.1,
|
||||
|
|
@ -137,28 +155,120 @@ function syncGradient() {
|
|||
destroyGradient();
|
||||
}
|
||||
|
||||
function clearBackgroundVideoSchedule() {
|
||||
if (backgroundVideoTimer !== null) {
|
||||
window.clearTimeout(backgroundVideoTimer);
|
||||
backgroundVideoTimer = null;
|
||||
}
|
||||
|
||||
if (backgroundVideoIdleId !== null) {
|
||||
const idleWindow = window as Window & { cancelIdleCallback?: (handle: number) => void };
|
||||
idleWindow.cancelIdleCallback?.(backgroundVideoIdleId);
|
||||
backgroundVideoIdleId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseBackgroundVideo() {
|
||||
return Boolean(
|
||||
backgroundPlaybackId.value &&
|
||||
isVisible &&
|
||||
!motionQuery?.matches &&
|
||||
!hasBackgroundVideoError.value,
|
||||
);
|
||||
}
|
||||
|
||||
async function mountBackgroundVideo() {
|
||||
clearBackgroundVideoSchedule();
|
||||
if (shouldMountBackgroundVideo.value || !shouldUseBackgroundVideo()) return;
|
||||
|
||||
try {
|
||||
await import("@mux/mux-video");
|
||||
if (shouldUseBackgroundVideo()) {
|
||||
shouldMountBackgroundVideo.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Mux hero background video is unavailable", error);
|
||||
hasBackgroundVideoError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBackgroundVideo() {
|
||||
clearBackgroundVideoSchedule();
|
||||
if (shouldMountBackgroundVideo.value || !shouldUseBackgroundVideo()) return;
|
||||
|
||||
const idleWindow = window as Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
};
|
||||
|
||||
if (idleWindow.requestIdleCallback) {
|
||||
backgroundVideoIdleId = idleWindow.requestIdleCallback(() => {
|
||||
backgroundVideoIdleId = null;
|
||||
void mountBackgroundVideo();
|
||||
}, { timeout: 1600 });
|
||||
return;
|
||||
}
|
||||
|
||||
backgroundVideoTimer = window.setTimeout(() => {
|
||||
backgroundVideoTimer = null;
|
||||
void mountBackgroundVideo();
|
||||
}, 450);
|
||||
}
|
||||
|
||||
function stopBackgroundVideo() {
|
||||
clearBackgroundVideoSchedule();
|
||||
shouldMountBackgroundVideo.value = false;
|
||||
isBackgroundVideoReady.value = false;
|
||||
}
|
||||
|
||||
function syncBackgroundVideo() {
|
||||
if (shouldUseBackgroundVideo()) {
|
||||
scheduleBackgroundVideo();
|
||||
return;
|
||||
}
|
||||
|
||||
stopBackgroundVideo();
|
||||
}
|
||||
|
||||
function markBackgroundVideoReady() {
|
||||
if (!shouldUseBackgroundVideo()) return;
|
||||
isBackgroundVideoReady.value = true;
|
||||
}
|
||||
|
||||
function markBackgroundVideoError() {
|
||||
hasBackgroundVideoError.value = true;
|
||||
stopBackgroundVideo();
|
||||
}
|
||||
|
||||
function syncMotionState() {
|
||||
syncGradient();
|
||||
syncBackgroundVideo();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
mobileQuery = window.matchMedia("(max-width: 700px)");
|
||||
motionQuery.addEventListener("change", syncGradient);
|
||||
motionQuery.addEventListener("change", syncMotionState);
|
||||
mobileQuery.addEventListener("change", syncGradient);
|
||||
|
||||
heroObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisible = Boolean(entry?.isIntersecting);
|
||||
syncGradient();
|
||||
syncBackgroundVideo();
|
||||
},
|
||||
{ rootMargin: "160px 0px", threshold: 0.01 },
|
||||
);
|
||||
|
||||
const target = canvasRef.value?.closest(".cyber-hero");
|
||||
if (target) heroObserver.observe(target);
|
||||
syncBackgroundVideo();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
heroObserver?.disconnect();
|
||||
motionQuery?.removeEventListener("change", syncGradient);
|
||||
motionQuery?.removeEventListener("change", syncMotionState);
|
||||
mobileQuery?.removeEventListener("change", syncGradient);
|
||||
stopBackgroundVideo();
|
||||
destroyGradient();
|
||||
});
|
||||
</script>
|
||||
|
|
@ -169,6 +279,40 @@ onBeforeUnmount(() => {
|
|||
:class="{ 'cyber-hero__monterey--live': isLive }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="cyber-hero__monterey-video"
|
||||
:class="{ 'cyber-hero__monterey-video--ready': isBackgroundVideoReady }"
|
||||
:style="{ '--cyber-monterey-video-poster': backgroundPosterUrl ? `url(${backgroundPosterUrl})` : 'none' }"
|
||||
>
|
||||
<ClientOnly>
|
||||
<mux-video
|
||||
v-if="shouldMountBackgroundVideo && backgroundPlaybackId"
|
||||
class="cyber-hero__monterey-video-player"
|
||||
:class="{ 'cyber-hero__monterey-video-player--ready': isBackgroundVideoReady }"
|
||||
:playback-id="backgroundPlaybackId"
|
||||
:poster="backgroundPosterUrl || undefined"
|
||||
stream-type="on-demand"
|
||||
autoplay="muted"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="auto"
|
||||
max-resolution="720p"
|
||||
max-auto-resolution="720p"
|
||||
cap-rendition-to-player-size
|
||||
disable-tracking
|
||||
disable-cookies
|
||||
style="--media-object-fit: cover;"
|
||||
tabindex="-1"
|
||||
metadata-video-id="agent-teams-hero-background"
|
||||
metadata-video-title="Agent Teams hero background"
|
||||
@canplay="markBackgroundVideoReady"
|
||||
@loadeddata="markBackgroundVideoReady"
|
||||
@playing="markBackgroundVideoReady"
|
||||
@error="markBackgroundVideoError"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<canvas ref="canvasRef" class="cyber-hero__monterey-canvas" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ const props = defineProps<{
|
|||
activeReceiver?: HeroAgentRole | "video" | null;
|
||||
}>();
|
||||
|
||||
const { locale } = useI18n();
|
||||
const isSender = computed(() => props.activeSender === props.agent.id);
|
||||
const isReceiver = computed(() => props.activeReceiver === props.agent.id);
|
||||
const imageLoading = computed(() => (props.agent.priority ? "eager" : "lazy"));
|
||||
const imageFetchPriority = computed(() => (props.agent.priority ? "high" : "auto"));
|
||||
const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:");
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
"--agent-x": String(props.agent.desktop.x),
|
||||
|
|
@ -62,7 +64,7 @@ const rootStyle = computed(() => ({
|
|||
<li v-for="task in agent.tasks" :key="task">{{ task }}</li>
|
||||
</ul>
|
||||
<div class="cyber-agent__status">
|
||||
<span>Status:</span>
|
||||
<span>{{ statusLabel }}</span>
|
||||
<strong>{{ agent.status }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
heroAgents,
|
||||
getLocalizedHeroAgents,
|
||||
type HeroAgentRole,
|
||||
type HeroMessage,
|
||||
type HeroMessagePhase,
|
||||
} from "~/data/heroScene";
|
||||
|
||||
const { locale } = useI18n();
|
||||
const localizedHeroAgents = computed(() => getLocalizedHeroAgents(locale.value));
|
||||
|
||||
const props = defineProps<{
|
||||
message: HeroMessage | null;
|
||||
phase: HeroMessagePhase;
|
||||
|
|
@ -26,7 +29,7 @@ const activeReceiver = computed<HeroAgentRole | "video" | null>(() => (
|
|||
|
||||
<div class="cyber-scene__robots">
|
||||
<CyberHeroRobot
|
||||
v-for="agent in heroAgents"
|
||||
v-for="agent in localizedHeroAgents"
|
||||
:key="agent.id"
|
||||
:agent="agent"
|
||||
:active-sender="activeSender"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
const { locale } = useI18n();
|
||||
const isRu = computed(() => locale.value === "ru");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
id="hero-demo"
|
||||
class="cyber-video-frame"
|
||||
role="region"
|
||||
aria-label="Watch Agent Teams demo"
|
||||
:aria-label="isRu ? 'Смотреть демо Agent Teams' : 'Watch Agent Teams demo'"
|
||||
>
|
||||
<div class="cyber-video-frame__bezel" aria-hidden="true" />
|
||||
<div class="cyber-video-frame__status" aria-hidden="true">
|
||||
<span>Team command feed</span>
|
||||
<span>Live demo</span>
|
||||
<span>{{ isRu ? 'Командная лента' : 'Team command feed' }}</span>
|
||||
<span>{{ isRu ? 'Живое демо' : 'Live demo' }}</span>
|
||||
</div>
|
||||
<div class="cyber-video-frame__content">
|
||||
<HeroDemoVideo />
|
||||
|
|
|
|||
52
landing/components/hero/CyberProviderIcon.vue
Normal file
52
landing/components/hero/CyberProviderIcon.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
provider: "codex" | "anthropic" | "opencode";
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
v-if="provider === 'codex'"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="OpenAI"
|
||||
>
|
||||
<path
|
||||
d="M9.43799 9.06943V7.09387C9.43799 6.92749 9.50347 6.80267 9.65601 6.71959L13.8206 4.43211C14.3875 4.1202 15.0635 3.9747 15.7611 3.9747C18.3775 3.9747 20.0347 5.9087 20.0347 7.96734C20.0347 8.11288 20.0347 8.27926 20.0128 8.44564L15.6956 6.03335C15.434 5.88785 15.1723 5.88785 14.9107 6.03335L9.43799 9.06943ZM19.1624 16.7637V12.0431C19.1624 11.7519 19.0315 11.544 18.7699 11.3984L13.2972 8.36234L15.0851 7.3849C15.2377 7.30182 15.3686 7.30182 15.5212 7.3849L19.6858 9.67238C20.8851 10.3379 21.6917 11.7519 21.6917 13.1243C21.6917 14.7047 20.7106 16.1604 19.1624 16.7636V16.7637ZM8.15158 12.6047L6.36369 11.6066C6.21114 11.5235 6.14566 11.3986 6.14566 11.2323V6.65735C6.14566 4.43233 7.93355 2.7478 10.3538 2.7478C11.2697 2.7478 12.1199 3.039 12.8396 3.55886L8.54424 5.92959C8.28268 6.07508 8.15181 6.28303 8.15181 6.57427V12.6049L8.15158 12.6047ZM12 14.7258L9.43799 13.3533V10.4421L12 9.06965L14.5618 10.4421V13.3533L12 14.7258ZM13.6461 21.0476C12.7303 21.0476 11.8801 20.7564 11.1604 20.2366L15.4557 17.8658C15.7173 17.7203 15.8482 17.5124 15.8482 17.2211V11.1905L17.658 12.1886C17.8105 12.2717 17.876 12.3965 17.876 12.563V17.1379C17.876 19.3629 16.0662 21.0474 13.6461 21.0474V21.0476ZM8.47863 16.4103L4.314 14.1229C3.11471 13.4573 2.30808 12.0433 2.30808 10.6709C2.30808 9.06965 3.31106 7.6348 4.85903 7.03168V11.773C4.85903 12.0642 4.98995 12.2721 5.25151 12.4177L10.7025 15.4328L8.91464 16.4103C8.76209 16.4934 8.63117 16.4934 8.47863 16.4103ZM8.23892 19.8207C5.77508 19.8207 3.96533 18.0531 3.96533 15.8696C3.96533 15.7032 3.98719 15.5368 4.00886 15.3704L8.30418 17.7412C8.56574 17.8867 8.82752 17.8867 9.08909 17.7412L14.5618 14.726V16.7015C14.5618 16.8679 14.4964 16.9927 14.3438 17.0758L10.1792 19.3633C9.61225 19.6752 8.93631 19.8207 8.23869 19.8207H8.23892ZM13.6461 22.2952C16.2844 22.2952 18.4865 20.5069 18.9882 18.1362C21.4301 17.5331 23 15.3495 23 13.1245C23 11.6688 22.346 10.2548 21.1685 9.23581C21.2775 8.79908 21.343 8.36234 21.343 7.92582C21.343 4.95215 18.8137 2.72691 15.892 2.72691C15.3034 2.72691 14.7365 2.80999 14.1695 2.99726C13.1882 2.08223 11.8364 1.5 10.3538 1.5C7.71557 1.5 5.51352 3.28829 5.01185 5.65902C2.56987 6.26214 1 8.44564 1 10.6707C1 12.1264 1.65404 13.5404 2.83147 14.5594C2.72246 14.9961 2.65702 15.4328 2.65702 15.8694C2.65702 18.8431 5.1863 21.0683 8.108 21.0683C8.69661 21.0683 9.26354 20.9852 9.83046 20.7979C10.8115 21.713 12.1634 22.2952 13.6461 22.2952Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else-if="provider === 'anthropic'"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Anthropic"
|
||||
>
|
||||
<path
|
||||
d="M13.7891 3.93164L20.2223 20.0677H23.7502L17.317 3.93164H13.7891Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.32538 13.6824L8.52662 8.01177L10.7279 13.6824H6.32538ZM6.68225 3.93164L0.25 20.0677H3.84652L5.16202 16.6791H11.8914L13.2067 20.0677H16.8033L10.371 3.93164H6.68225Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 6 24 30"
|
||||
role="img"
|
||||
aria-label="OpenCode"
|
||||
>
|
||||
<path
|
||||
d="M18 30H6V18H18V30Z"
|
||||
fill="currentColor"
|
||||
opacity="0.48"
|
||||
/>
|
||||
<path
|
||||
d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
|
@ -5,6 +5,7 @@ const { t, locale } = useI18n();
|
|||
const { repoUrl } = useGithubRepo();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
const year = new Date().getFullYear();
|
||||
const authorLabel = computed(() => locale.value === 'ru' ? 'Автор' : 'Author');
|
||||
const docsHref = computed(() => {
|
||||
const base = baseURL.replace(/\/?$/, '/');
|
||||
return `${base}${locale.value === 'ru' ? 'docs/ru/' : 'docs/'}`;
|
||||
|
|
@ -31,7 +32,7 @@ const docsHref = computed(() => {
|
|||
>{{ t('footer.copyright', { year }) }} · {{ t('footer.tagline') }}</span
|
||||
>
|
||||
<div class="app-footer__links">
|
||||
<a class="app-footer__link" href="https://github.com/777genius" target="_blank">Author</a>
|
||||
<a class="app-footer__link" href="https://github.com/777genius" target="_blank">{{ authorLabel }}</a>
|
||||
<span class="app-footer__divider" />
|
||||
<a class="app-footer__link" :href="repoUrl" target="_blank">GitHub</a>
|
||||
<span class="app-footer__divider" />
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const isRu = computed(() => locale.value === 'ru');
|
|||
|
||||
const navItems = computed(() => [
|
||||
{ href: '#screenshots', label: t('nav.screenshots'), shortLabel: isRu.value ? 'Скрины' : 'Shots' },
|
||||
{ href: docsHref.value, label: 'Docs', shortLabel: 'Docs' },
|
||||
{ href: docsHref.value, label: t('nav.docs'), shortLabel: isRu.value ? 'Док' : 'Docs' },
|
||||
{ href: '#download', label: t('nav.download'), shortLabel: isRu.value ? 'Скачать' : 'Get' },
|
||||
{ href: '#comparison', label: t('nav.comparison'), shortLabel: isRu.value ? 'Сравн.' : 'Compare' },
|
||||
{ href: '#pricing', label: t('nav.pricing'), shortLabel: isRu.value ? 'Беспл.' : 'Free' },
|
||||
|
|
@ -126,10 +126,10 @@ const navItems = computed(() => [
|
|||
:href="repoUrl"
|
||||
target="_blank"
|
||||
class="app-header__github-btn"
|
||||
aria-label="GitHub"
|
||||
:aria-label="t('nav.viewOnGithub')"
|
||||
>
|
||||
<v-icon :icon="mdiGithub" class="app-header__github-icon" />
|
||||
<span class="app-header__github-text">GitHub</span>
|
||||
<span class="app-header__github-text">{{ t('nav.viewOnGithub') }}</span>
|
||||
</v-btn>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
|
@ -186,7 +186,7 @@ const navItems = computed(() => [
|
|||
--header-height: 126px;
|
||||
--header-panel-height: 86px;
|
||||
--header-action-size: clamp(54px, 3.25vw, 66px);
|
||||
--header-github-width: clamp(150px, 9.7vw, 204px);
|
||||
--header-github-width: clamp(190px, 12vw, 236px);
|
||||
--header-brand-icon: clamp(52px, 3.7vw, 68px);
|
||||
--header-brand-text: clamp(23px, 1.42vw, 32px);
|
||||
|
||||
|
|
@ -503,8 +503,8 @@ const navItems = computed(() => [
|
|||
color: var(--header-cyan) !important;
|
||||
font-family: var(--at-font-mono);
|
||||
font-weight: 800 !important;
|
||||
font-size: clamp(14px, 1vw, 19px) !important;
|
||||
letter-spacing: 0.08em !important;
|
||||
font-size: clamp(13px, 0.86vw, 16px) !important;
|
||||
letter-spacing: 0.06em !important;
|
||||
text-transform: uppercase !important;
|
||||
background: rgba(0, 234, 255, 0.035) !important;
|
||||
box-shadow:
|
||||
|
|
@ -519,6 +519,8 @@ const navItems = computed(() => [
|
|||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header__github-icon {
|
||||
|
|
@ -527,7 +529,10 @@ const navItems = computed(() => [
|
|||
}
|
||||
|
||||
.app-header__github-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header__github-btn:hover {
|
||||
|
|
@ -551,7 +556,7 @@ const navItems = computed(() => [
|
|||
--header-height: 112px;
|
||||
--header-panel-height: 72px;
|
||||
--header-action-size: 54px;
|
||||
--header-github-width: 124px;
|
||||
--header-github-width: 158px;
|
||||
--header-brand-icon: 44px;
|
||||
--header-brand-text: 15px;
|
||||
}
|
||||
|
|
@ -591,7 +596,13 @@ const navItems = computed(() => [
|
|||
}
|
||||
|
||||
.app-header__github-btn {
|
||||
font-size: 13px !important;
|
||||
padding-inline: 8px !important;
|
||||
font-size: 12px !important;
|
||||
letter-spacing: 0.04em !important;
|
||||
}
|
||||
|
||||
.app-header__github-btn :deep(.v-btn__content) {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,162 @@
|
|||
<script setup lang="ts">
|
||||
import robotAvatarCyan from "~/assets/images/hero/robots/robot-avatar-cyan-v1.webp";
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const comparisonRobotRef = ref<HTMLElement | null>(null)
|
||||
const showComparisonRobotBubble = ref(false)
|
||||
let comparisonRobotObserver: IntersectionObserver | null = null
|
||||
|
||||
const ruNotes: Record<string, string> = {
|
||||
'Messages between separate teams': 'Сообщения между отдельными командами',
|
||||
'Coordination across groups': 'Координация между группами',
|
||||
'Company-scoped org work': 'Оргработа на уровне компании',
|
||||
'Native real-time mailbox': 'Нативный mailbox в реальном времени',
|
||||
'Mailboxes + handoffs': 'Mailbox и handoff',
|
||||
'Comments + @mentions': 'Комментарии и @mentions',
|
||||
'Team mailbox, no UI': 'Командный mailbox, без UI',
|
||||
'Tasks can link to and block each other': 'Задачи могут связываться и блокировать друг друга',
|
||||
'Task deps + grouped work': 'Зависимости задач и группировка работ',
|
||||
'Goals, parent tasks, blockers': 'Цели, родительские задачи, блокеры',
|
||||
'Shared task list': 'Общий список задач',
|
||||
'Task logs + token usage': 'Логи задач и расход токенов',
|
||||
'Session recall, feed, metrics': 'Память сессий, лента, метрики',
|
||||
'Run transcripts + cost audit': 'Транскрипты запусков и аудит стоимости',
|
||||
'Run transcripts + audit log': 'Транскрипты запусков и журнал аудита',
|
||||
'Usage command, no UI': 'Команда usage, без UI',
|
||||
'Auto-attach, agents read & attach': 'Автоприкрепление, агенты читают и добавляют вложения',
|
||||
'Not task-level': 'Не на уровне задач',
|
||||
'Docs, attachments, work products': 'Документы, вложения, рабочие артефакты',
|
||||
'Chat session only': 'Только сессия чата',
|
||||
'Chat images only': 'Только изображения в чате',
|
||||
'Accept / reject individual hunks': 'Принятие или отклонение отдельных фрагментов',
|
||||
'Bring your own review': 'Ревью нужно делать отдельно',
|
||||
'With Git support': 'С поддержкой Git',
|
||||
'Control plane, not editor': 'Панель управления, не редактор',
|
||||
'Full IDE': 'Полная IDE',
|
||||
'Plan, assign, work, and review': 'Планирует, назначает, работает и ревьюит',
|
||||
'Coordinator, grouped work, recovery': 'Координатор, группировка работ, восстановление',
|
||||
'Wake-up runs + governance': 'Отложенные запуски и управление',
|
||||
'Background agents, not teams': 'Фоновые агенты, не команды',
|
||||
'Experimental CLI teams': 'Экспериментальные CLI-команды',
|
||||
'Tasks wait for blockers automatically': 'Задачи автоматически ждут блокеры',
|
||||
'Dependency waves': 'Волны зависимостей',
|
||||
'Blockers + execution locks': 'Блокеры и execution locks',
|
||||
'Team task deps, no UI': 'Зависимости командных задач, без UI',
|
||||
'Agents review each other': 'Агенты ревьюят друг друга',
|
||||
'Merge queue': 'Merge queue',
|
||||
'Merge queue, no diff UI': 'Merge queue, без diff UI',
|
||||
'Approvals + governance': 'Подтверждения и управление',
|
||||
'PR/BugBot only': 'Только PR/BugBot',
|
||||
'Team review, no UI': 'Командное ревью, без UI',
|
||||
'Guided runtime setup': 'Пошаговая настройка runtime',
|
||||
'Manual CLI stack': 'Ручной CLI-стек',
|
||||
'npx + local database': 'npx и локальная база',
|
||||
'CLI + env flag': 'CLI и env-флаг',
|
||||
'5 columns, real-time': '5 колонок, в реальном времени',
|
||||
'Dashboard, not Kanban': 'Панель, не канбан',
|
||||
'7 columns, drag-and-drop': '7 колонок, перетаскивание',
|
||||
'Вызовы tools, reasoning, timeline': 'Вызовы tools, reasoning, timeline',
|
||||
'Feed, metrics, dashboard': 'Лента, метрики, панель',
|
||||
'Agent chat + terminal': 'Чат агента и терминал',
|
||||
'View, stop, open URLs': 'Просмотр, остановка, открытие URL',
|
||||
'Agent health dashboard': 'Dashboard здоровья агентов',
|
||||
'Manual services + previews': 'Ручные сервисы и previews',
|
||||
'Native terminal only': 'Только нативный терминал',
|
||||
'CPU/RAM history for each live teammate': 'История CPU/RAM для каждого живого участника',
|
||||
'Activity/health, not CPU/RAM': 'Активность/здоровье, не CPU/RAM',
|
||||
'Run status/cost, not CPU/RAM': 'Статус/стоимость запуска, не CPU/RAM',
|
||||
'Remote agent/terminal only': 'Только remote agent/terminal',
|
||||
'Accept / reject / comment': 'Принять, отклонить или прокомментировать',
|
||||
'PR/work products, no diff UI': 'PR/рабочие артефакты, без diff UI',
|
||||
'BugBot on PRs': 'BugBot для PR',
|
||||
'Per-action approvals + notifications': 'Подтверждения и уведомления для каждого действия',
|
||||
'Проверки, эскалация, восстановление': 'Проверки, эскалация, восстановление',
|
||||
'Подтверждения на доске, пауза, остановка': 'Подтверждения на доске, пауза, остановка',
|
||||
'Background agents auto-run commands': 'Фоновые агенты сами запускают команды',
|
||||
'Permissions + hooks': 'Permissions и hooks',
|
||||
'Optional': 'Опционально',
|
||||
'Core primitive': 'Ключевая примитивная модель',
|
||||
'Worktrees / branches': 'Worktrees / branches',
|
||||
'Background branches/VMs': 'Фоновые branches/VMs',
|
||||
'Manual worktrees': 'Ручные worktrees',
|
||||
'Claude, Codex, and OpenCode in one team': 'Claude, Codex и OpenCode в одной команде',
|
||||
'Many providers, terminal-first': 'Много провайдеров, terminal-first',
|
||||
'Bring your own agents/runtimes': 'Подключайте своих агентов и runtimes',
|
||||
'Multi-model agents, no shared team': 'Мультимодельные агенты, без общей команды',
|
||||
'Claude-only experimental teams': 'Экспериментальные команды только для Claude',
|
||||
'Teammates, tasks, blockers, handoffs, activity, logs': 'Участники, задачи, блокеры, передачи, активность, логи',
|
||||
'Agent tree + feed panels': 'Дерево агентов и панели ленты',
|
||||
'Org chart/status, not a task/log map': 'Оргструктура/статус, не карта задач и логов',
|
||||
'Watch teammates work and message them directly': 'Смотрите работу участников и пишите им напрямую',
|
||||
'Terminal-based agent sessions': 'Терминальные сессии агентов',
|
||||
'Agents wake up for runs, then sleep': 'Агенты просыпаются для запусков, потом засыпают',
|
||||
'Background agents per task': 'Фоновые агенты на задачу',
|
||||
'CLI teams, no desktop view': 'CLI-команды, без desktop-экрана',
|
||||
'Tasks, logs, Kanban, review, and teammates in one app': 'Задачи, логи, канбан, ревью и участники в одном приложении',
|
||||
'Mail/feed/dashboard across tools': 'Почта, лента и панель между tools',
|
||||
'Board + transcripts, less live teammate view': 'Доска и транскрипты, меньше live-вида участников',
|
||||
'IDE chats/tasks, not team view': 'IDE-чаты/задачи, не командный вид',
|
||||
'No desktop UI': 'Нет desktop UI',
|
||||
'Know who started, who is stuck, and who replied': 'Видно кто стартовал, кто застрял и кто ответил',
|
||||
'Session health, less clear message status': 'Здоровье сессии, менее ясный статус сообщений',
|
||||
'Run status, not live teammate status': 'Статус запуска, не live-статус участника',
|
||||
'CLI mailbox, no visual status': 'CLI mailbox, без визуального статуса',
|
||||
'Roles + approvals, no org chart': 'Роли и подтверждения, без оргструктуры',
|
||||
'Roles + escalation': 'Роли и эскалация',
|
||||
'Org chart + board governance': 'Оргструктура и управление доской',
|
||||
'Team admin only': 'Только администрирование команды',
|
||||
'Cost/token visibility, no hard caps': 'Видимость стоимости/токенов, без жёстких лимитов',
|
||||
'Cost tiers + digest, no hard caps': 'Тарифные уровни и дайджест, без жёстких лимитов',
|
||||
'Per-agent budgets + hard stops': 'Бюджеты на агента и жёсткие остановки',
|
||||
'Usage + BG spend limits': 'Usage и лимиты фоновых расходов',
|
||||
'/usage + workspace limits': '/usage и лимиты workspace',
|
||||
'OSS + free model with no auth, paid providers optional': 'OSS и бесплатная модель без авторизации, платные провайдеры опциональны',
|
||||
'OSS, runtime plans needed': 'OSS, нужны тарифы runtime',
|
||||
'OSS, self-hosted + infra': 'OSS, self-hosted и инфраструктура',
|
||||
'Free + paid usage': 'Бесплатно плюс платное использование',
|
||||
'Claude plan or API usage': 'Claude plan или API usage',
|
||||
}
|
||||
|
||||
function note(text: string): string {
|
||||
return locale.value === 'ru' ? (ruNotes[text] ?? text) : text
|
||||
}
|
||||
|
||||
const sourcesPrefix = computed(() => (
|
||||
locale.value === 'ru'
|
||||
? 'Источники фактов проверены 18 мая 2026:'
|
||||
: 'Fact sources checked on May 18, 2026:'
|
||||
))
|
||||
|
||||
const ruSourceLabels: Record<string, string> = {
|
||||
'detailed research notes': 'подробные заметки исследования',
|
||||
'Gastown provider guide': 'гайд по провайдерам Gastown',
|
||||
'Gastown scheduler': 'планировщик Gastown',
|
||||
'Gastown dashboard source': 'исходники dashboard Gastown',
|
||||
'Gastown release': 'релиз Gastown',
|
||||
'Paperclip adapters': 'адаптеры Paperclip',
|
||||
'Paperclip heartbeat protocol': 'heartbeat-протокол Paperclip',
|
||||
'Paperclip org chart': 'оргструктура Paperclip',
|
||||
'Paperclip OrgChart source': 'исходники OrgChart Paperclip',
|
||||
'Paperclip budgets': 'бюджеты Paperclip',
|
||||
'Paperclip runtime services': 'runtime services Paperclip',
|
||||
'Paperclip Kanban source': 'исходники Kanban Paperclip',
|
||||
'Paperclip work products': 'work products Paperclip',
|
||||
'Paperclip release': 'релиз Paperclip',
|
||||
'Cursor Background Agents': 'фоновые агенты Cursor',
|
||||
'Cursor Diffs & Review': 'diffs и review Cursor',
|
||||
'Cursor pricing': 'цены Cursor',
|
||||
'Claude Code agent teams': 'команды агентов Claude Code',
|
||||
'Claude Code subagents': 'сабагенты Claude Code',
|
||||
'Claude Code workflows': 'workflows Claude Code',
|
||||
'Claude Code costs': 'стоимость Claude Code',
|
||||
'Claude pricing': 'цены Claude',
|
||||
}
|
||||
|
||||
function sourceLabel(label: string): string {
|
||||
return locale.value === 'ru' ? (ruSourceLabels[label] ?? label) : label
|
||||
}
|
||||
|
||||
|
||||
interface CellValue {
|
||||
status: string
|
||||
note?: string
|
||||
|
|
@ -24,211 +175,211 @@ interface ComparisonRow {
|
|||
const rows = computed<ComparisonRow[]>(() => [
|
||||
{
|
||||
feature: t('comparison.features.crossTeam'),
|
||||
us: { status: 'yes', note: 'Messages between separate teams' },
|
||||
gastown: { status: 'partial', note: 'Coordination across groups' },
|
||||
paperclip: { status: 'partial', note: 'Company-scoped org work' },
|
||||
us: { status: 'yes', note: note('Messages between separate teams') },
|
||||
gastown: { status: 'partial', note: note('Coordination across groups') },
|
||||
paperclip: { status: 'partial', note: note('Company-scoped org work') },
|
||||
cursor: { status: 'na' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.agentMessaging'),
|
||||
us: { status: 'yes', note: 'Native real-time mailbox' },
|
||||
gastown: { status: 'yes', note: 'Mailboxes + handoffs' },
|
||||
paperclip: { status: 'partial', note: 'Comments + @mentions' },
|
||||
us: { status: 'yes', note: note('Native real-time mailbox') },
|
||||
gastown: { status: 'yes', note: note('Mailboxes + handoffs') },
|
||||
paperclip: { status: 'partial', note: note('Comments + @mentions') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'yes', note: 'Team mailbox, no UI' },
|
||||
claudeCli: { status: 'yes', note: note('Team mailbox, no UI') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.linkedTasks'),
|
||||
us: { status: 'yes', note: 'Tasks can link to and block each other' },
|
||||
gastown: { status: 'partial', note: 'Task deps + grouped work' },
|
||||
paperclip: { status: 'yes', note: 'Goals, parent tasks, blockers' },
|
||||
us: { status: 'yes', note: note('Tasks can link to and block each other') },
|
||||
gastown: { status: 'partial', note: note('Task deps + grouped work') },
|
||||
paperclip: { status: 'yes', note: note('Goals, parent tasks, blockers') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'yes', note: 'Shared task list' },
|
||||
claudeCli: { status: 'yes', note: note('Shared task list') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.sessionAnalysis'),
|
||||
us: { status: 'yes', note: 'Task logs + token usage' },
|
||||
gastown: { status: 'partial', note: 'Session recall, feed, metrics' },
|
||||
paperclip: { status: 'partial', note: 'Run transcripts + cost audit' },
|
||||
us: { status: 'yes', note: note('Task logs + token usage') },
|
||||
gastown: { status: 'partial', note: note('Session recall, feed, metrics') },
|
||||
paperclip: { status: 'partial', note: note('Run transcripts + cost audit') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'partial', note: 'Usage command, no UI' },
|
||||
claudeCli: { status: 'partial', note: note('Usage command, no UI') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.taskAttachments'),
|
||||
us: { status: 'yes', note: 'Auto-attach, agents read & attach' },
|
||||
gastown: { status: 'no', note: 'Not task-level' },
|
||||
paperclip: { status: 'yes', note: 'Docs, attachments, work products' },
|
||||
cursor: { status: 'partial', note: 'Chat session only' },
|
||||
claudeCli: { status: 'partial', note: 'Chat images only' },
|
||||
us: { status: 'yes', note: note('Auto-attach, agents read & attach') },
|
||||
gastown: { status: 'no', note: note('Not task-level') },
|
||||
paperclip: { status: 'yes', note: note('Docs, attachments, work products') },
|
||||
cursor: { status: 'partial', note: note('Chat session only') },
|
||||
claudeCli: { status: 'partial', note: note('Chat images only') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.hunkReview'),
|
||||
us: { status: 'yes', note: 'Accept / reject individual hunks' },
|
||||
us: { status: 'yes', note: note('Accept / reject individual hunks') },
|
||||
gastown: { status: 'no' },
|
||||
paperclip: { status: 'no', note: 'Bring your own review' },
|
||||
paperclip: { status: 'no', note: note('Bring your own review') },
|
||||
cursor: { status: 'yes' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.codeEditor'),
|
||||
us: { status: 'yes', note: 'With Git support' },
|
||||
us: { status: 'yes', note: note('With Git support') },
|
||||
gastown: { status: 'no' },
|
||||
paperclip: { status: 'no', note: 'Control plane, not editor' },
|
||||
cursor: { status: 'yes', note: 'Full IDE' },
|
||||
paperclip: { status: 'no', note: note('Control plane, not editor') },
|
||||
cursor: { status: 'yes', note: note('Full IDE') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.fullAutonomy'),
|
||||
us: { status: 'yes', note: 'Plan, assign, work, and review' },
|
||||
gastown: { status: 'yes', note: 'Coordinator, grouped work, recovery' },
|
||||
paperclip: { status: 'yes', note: 'Wake-up runs + governance' },
|
||||
cursor: { status: 'partial', note: 'Background agents, not teams' },
|
||||
claudeCli: { status: 'yes', note: 'Experimental CLI teams' },
|
||||
us: { status: 'yes', note: note('Plan, assign, work, and review') },
|
||||
gastown: { status: 'yes', note: note('Coordinator, grouped work, recovery') },
|
||||
paperclip: { status: 'yes', note: note('Wake-up runs + governance') },
|
||||
cursor: { status: 'partial', note: note('Background agents, not teams') },
|
||||
claudeCli: { status: 'yes', note: note('Experimental CLI teams') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.taskDeps'),
|
||||
us: { status: 'yes', note: 'Tasks wait for blockers automatically' },
|
||||
gastown: { status: 'yes', note: 'Dependency waves' },
|
||||
paperclip: { status: 'yes', note: 'Blockers + execution locks' },
|
||||
us: { status: 'yes', note: note('Tasks wait for blockers automatically') },
|
||||
gastown: { status: 'yes', note: note('Dependency waves') },
|
||||
paperclip: { status: 'yes', note: note('Blockers + execution locks') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'yes', note: 'Team task deps, no UI' },
|
||||
claudeCli: { status: 'yes', note: note('Team task deps, no UI') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.reviewWorkflow'),
|
||||
us: { status: 'yes', note: 'Agents review each other' },
|
||||
gastown: { status: 'partial', note: 'Merge queue' },
|
||||
paperclip: { status: 'yes', note: 'Approvals + governance' },
|
||||
cursor: { status: 'partial', note: 'PR/BugBot only' },
|
||||
claudeCli: { status: 'yes', note: 'Team review, no UI' },
|
||||
us: { status: 'yes', note: note('Agents review each other') },
|
||||
gastown: { status: 'partial', note: note('Merge queue') },
|
||||
paperclip: { status: 'yes', note: note('Approvals + governance') },
|
||||
cursor: { status: 'partial', note: note('PR/BugBot only') },
|
||||
claudeCli: { status: 'yes', note: note('Team review, no UI') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.zeroSetup'),
|
||||
us: { status: 'yes', note: 'Guided runtime setup' },
|
||||
gastown: { status: 'no', note: 'Manual CLI stack' },
|
||||
paperclip: { status: 'partial', note: 'npx + local database' },
|
||||
us: { status: 'yes', note: note('Guided runtime setup') },
|
||||
gastown: { status: 'no', note: note('Manual CLI stack') },
|
||||
paperclip: { status: 'partial', note: note('npx + local database') },
|
||||
cursor: { status: 'yes' },
|
||||
claudeCli: { status: 'partial', note: 'CLI + env flag' },
|
||||
claudeCli: { status: 'partial', note: note('CLI + env flag') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.kanban'),
|
||||
us: { status: 'yes', note: '5 columns, real-time' },
|
||||
gastown: { status: 'no', note: 'Dashboard, not Kanban' },
|
||||
paperclip: { status: 'yes', note: '7 columns, drag-and-drop' },
|
||||
us: { status: 'yes', note: note('5 columns, real-time') },
|
||||
gastown: { status: 'no', note: note('Dashboard, not Kanban') },
|
||||
paperclip: { status: 'yes', note: note('7 columns, drag-and-drop') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.execLog'),
|
||||
us: { status: 'yes', note: 'Tool calls, reasoning, timeline' },
|
||||
gastown: { status: 'partial', note: 'Feed, metrics, dashboard' },
|
||||
paperclip: { status: 'yes', note: 'Run transcripts + audit log' },
|
||||
cursor: { status: 'partial', note: 'Agent chat + terminal' },
|
||||
us: { status: 'yes', note: note('Вызовы tools, reasoning, timeline') },
|
||||
gastown: { status: 'partial', note: note('Feed, metrics, dashboard') },
|
||||
paperclip: { status: 'yes', note: note('Run transcripts + audit log') },
|
||||
cursor: { status: 'partial', note: note('Agent chat + terminal') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.liveProcesses'),
|
||||
us: { status: 'yes', note: 'View, stop, open URLs' },
|
||||
gastown: { status: 'partial', note: 'Agent health dashboard' },
|
||||
paperclip: { status: 'partial', note: 'Manual services + previews' },
|
||||
cursor: { status: 'partial', note: 'Native terminal only' },
|
||||
us: { status: 'yes', note: note('View, stop, open URLs') },
|
||||
gastown: { status: 'partial', note: note('Agent health dashboard') },
|
||||
paperclip: { status: 'partial', note: note('Manual services + previews') },
|
||||
cursor: { status: 'partial', note: note('Native terminal only') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.runtimeLoad'),
|
||||
us: { status: 'yes', note: 'CPU/RAM history for each live teammate' },
|
||||
gastown: { status: 'partial', note: 'Activity/health, not CPU/RAM' },
|
||||
paperclip: { status: 'partial', note: 'Run status/cost, not CPU/RAM' },
|
||||
cursor: { status: 'no', note: 'Remote agent/terminal only' },
|
||||
us: { status: 'yes', note: note('CPU/RAM history for each live teammate') },
|
||||
gastown: { status: 'partial', note: note('Activity/health, not CPU/RAM') },
|
||||
paperclip: { status: 'partial', note: note('Run status/cost, not CPU/RAM') },
|
||||
cursor: { status: 'no', note: note('Remote agent/terminal only') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.perTaskReview'),
|
||||
us: { status: 'yes', note: 'Accept / reject / comment' },
|
||||
gastown: { status: 'partial', note: 'Merge queue, no diff UI' },
|
||||
paperclip: { status: 'partial', note: 'PR/work products, no diff UI' },
|
||||
cursor: { status: 'yes', note: 'BugBot on PRs' },
|
||||
us: { status: 'yes', note: note('Accept / reject / comment') },
|
||||
gastown: { status: 'partial', note: note('Merge queue, no diff UI') },
|
||||
paperclip: { status: 'partial', note: note('PR/work products, no diff UI') },
|
||||
cursor: { status: 'yes', note: note('BugBot on PRs') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.flexAutonomy'),
|
||||
us: { status: 'yes', note: 'Per-action approvals + notifications' },
|
||||
gastown: { status: 'yes', note: 'Gates, escalation, recovery' },
|
||||
paperclip: { status: 'yes', note: 'Board approvals, pause, terminate' },
|
||||
cursor: { status: 'partial', note: 'Background agents auto-run commands' },
|
||||
claudeCli: { status: 'yes', note: 'Permissions + hooks' },
|
||||
us: { status: 'yes', note: note('Per-action approvals + notifications') },
|
||||
gastown: { status: 'yes', note: note('Проверки, эскалация, восстановление') },
|
||||
paperclip: { status: 'yes', note: note('Подтверждения на доске, пауза, остановка') },
|
||||
cursor: { status: 'partial', note: note('Background agents auto-run commands') },
|
||||
claudeCli: { status: 'yes', note: note('Permissions + hooks') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.worktree'),
|
||||
us: { status: 'yes', note: 'Optional' },
|
||||
gastown: { status: 'yes', note: 'Core primitive' },
|
||||
paperclip: { status: 'yes', note: 'Worktrees / branches' },
|
||||
cursor: { status: 'partial', note: 'Background branches/VMs' },
|
||||
claudeCli: { status: 'partial', note: 'Manual worktrees' },
|
||||
us: { status: 'yes', note: note('Optional') },
|
||||
gastown: { status: 'yes', note: note('Core primitive') },
|
||||
paperclip: { status: 'yes', note: note('Worktrees / branches') },
|
||||
cursor: { status: 'partial', note: note('Background branches/VMs') },
|
||||
claudeCli: { status: 'partial', note: note('Manual worktrees') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.multiAgent'),
|
||||
us: { status: 'yes', note: 'Claude, Codex, and OpenCode in one team' },
|
||||
gastown: { status: 'yes', note: 'Many providers, terminal-first' },
|
||||
paperclip: { status: 'yes', note: 'Bring your own agents/runtimes' },
|
||||
cursor: { status: 'partial', note: 'Multi-model agents, no shared team' },
|
||||
claudeCli: { status: 'partial', note: 'Claude-only experimental teams' },
|
||||
us: { status: 'yes', note: note('Claude, Codex, and OpenCode in one team') },
|
||||
gastown: { status: 'yes', note: note('Many providers, terminal-first') },
|
||||
paperclip: { status: 'yes', note: note('Bring your own agents/runtimes') },
|
||||
cursor: { status: 'partial', note: note('Multi-model agents, no shared team') },
|
||||
claudeCli: { status: 'partial', note: note('Claude-only experimental teams') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.liveWorkGraph'),
|
||||
us: { status: 'yes', note: 'Teammates, tasks, blockers, handoffs, activity, logs' },
|
||||
gastown: { status: 'partial', note: 'Agent tree + feed panels' },
|
||||
paperclip: { status: 'partial', note: 'Org chart/status, not a task/log map' },
|
||||
us: { status: 'yes', note: note('Teammates, tasks, blockers, handoffs, activity, logs') },
|
||||
gastown: { status: 'partial', note: note('Agent tree + feed panels') },
|
||||
paperclip: { status: 'partial', note: note('Org chart/status, not a task/log map') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.liveTeam'),
|
||||
us: { status: 'yes', note: 'Watch teammates work and message them directly' },
|
||||
gastown: { status: 'partial', note: 'Terminal-based agent sessions' },
|
||||
paperclip: { status: 'partial', note: 'Agents wake up for runs, then sleep' },
|
||||
cursor: { status: 'partial', note: 'Background agents per task' },
|
||||
claudeCli: { status: 'partial', note: 'CLI teams, no desktop view' },
|
||||
us: { status: 'yes', note: note('Watch teammates work and message them directly') },
|
||||
gastown: { status: 'partial', note: note('Terminal-based agent sessions') },
|
||||
paperclip: { status: 'partial', note: note('Agents wake up for runs, then sleep') },
|
||||
cursor: { status: 'partial', note: note('Background agents per task') },
|
||||
claudeCli: { status: 'partial', note: note('CLI teams, no desktop view') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.teamWorkspace'),
|
||||
us: { status: 'yes', note: 'Tasks, logs, Kanban, review, and teammates in one app' },
|
||||
gastown: { status: 'partial', note: 'Mail/feed/dashboard across tools' },
|
||||
paperclip: { status: 'partial', note: 'Board + transcripts, less live teammate view' },
|
||||
cursor: { status: 'partial', note: 'IDE chats/tasks, not team view' },
|
||||
claudeCli: { status: 'no', note: 'No desktop UI' },
|
||||
us: { status: 'yes', note: note('Tasks, logs, Kanban, review, and teammates in one app') },
|
||||
gastown: { status: 'partial', note: note('Mail/feed/dashboard across tools') },
|
||||
paperclip: { status: 'partial', note: note('Board + transcripts, less live teammate view') },
|
||||
cursor: { status: 'partial', note: note('IDE chats/tasks, not team view') },
|
||||
claudeCli: { status: 'no', note: note('No desktop UI') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.launchProof'),
|
||||
us: { status: 'yes', note: 'Know who started, who is stuck, and who replied' },
|
||||
gastown: { status: 'partial', note: 'Session health, less clear message status' },
|
||||
paperclip: { status: 'partial', note: 'Run status, not live teammate status' },
|
||||
us: { status: 'yes', note: note('Know who started, who is stuck, and who replied') },
|
||||
gastown: { status: 'partial', note: note('Session health, less clear message status') },
|
||||
paperclip: { status: 'partial', note: note('Run status, not live teammate status') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'partial', note: 'CLI mailbox, no visual status' },
|
||||
claudeCli: { status: 'partial', note: note('CLI mailbox, no visual status') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.orgGovernance'),
|
||||
us: { status: 'partial', note: 'Roles + approvals, no org chart' },
|
||||
gastown: { status: 'partial', note: 'Roles + escalation' },
|
||||
paperclip: { status: 'yes', note: 'Org chart + board governance' },
|
||||
cursor: { status: 'partial', note: 'Team admin only' },
|
||||
us: { status: 'partial', note: note('Roles + approvals, no org chart') },
|
||||
gastown: { status: 'partial', note: note('Roles + escalation') },
|
||||
paperclip: { status: 'yes', note: note('Org chart + board governance') },
|
||||
cursor: { status: 'partial', note: note('Team admin only') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.budgetControls'),
|
||||
us: { status: 'partial', note: 'Cost/token visibility, no hard caps' },
|
||||
gastown: { status: 'partial', note: 'Cost tiers + digest, no hard caps' },
|
||||
paperclip: { status: 'yes', note: 'Per-agent budgets + hard stops' },
|
||||
cursor: { status: 'partial', note: 'Usage + BG spend limits' },
|
||||
claudeCli: { status: 'partial', note: '/usage + workspace limits' },
|
||||
us: { status: 'partial', note: note('Cost/token visibility, no hard caps') },
|
||||
gastown: { status: 'partial', note: note('Cost tiers + digest, no hard caps') },
|
||||
paperclip: { status: 'yes', note: note('Per-agent budgets + hard stops') },
|
||||
cursor: { status: 'partial', note: note('Usage + BG spend limits') },
|
||||
claudeCli: { status: 'partial', note: note('/usage + workspace limits') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.price'),
|
||||
us: { status: 'free', note: 'OSS + free model with no auth, paid providers optional' },
|
||||
gastown: { status: 'free', note: 'OSS, runtime plans needed' },
|
||||
paperclip: { status: 'free', note: 'OSS, self-hosted + infra' },
|
||||
cursor: { status: 'text', note: 'Free + paid usage' },
|
||||
claudeCli: { status: 'text', note: 'Claude plan or API usage' },
|
||||
us: { status: 'free', note: note('OSS + free model with no auth, paid providers optional') },
|
||||
gastown: { status: 'free', note: note('OSS, runtime plans needed') },
|
||||
paperclip: { status: 'free', note: note('OSS, self-hosted + infra') },
|
||||
cursor: { status: 'text', note: note('Free + paid usage') },
|
||||
claudeCli: { status: 'text', note: note('Claude plan or API usage') },
|
||||
},
|
||||
])
|
||||
|
||||
|
|
@ -343,8 +494,8 @@ function getStatusIcon(status: string): string {
|
|||
case 'yes': return '\u2713'
|
||||
case 'no': return '\u2717'
|
||||
case 'partial': return '\u25D2'
|
||||
case 'na': return 'N/A'
|
||||
case 'free': return 'Free'
|
||||
case 'na': return locale.value === 'ru' ? 'Н/Д' : 'N/A'
|
||||
case 'free': return locale.value === 'ru' ? 'Бесплатно' : 'Free'
|
||||
case 'soon': return '\uD83D\uDCC5'
|
||||
default: return ''
|
||||
}
|
||||
|
|
@ -453,10 +604,10 @@ function getStatusIcon(status: string): string {
|
|||
</div>
|
||||
|
||||
<p class="comparison-section__sources">
|
||||
Fact sources checked on May 18, 2026:
|
||||
{{ sourcesPrefix }}
|
||||
<template v-for="(source, index) in sourceLinks" :key="source.href">
|
||||
<a :href="source.href" target="_blank" rel="noopener noreferrer">
|
||||
{{ source.label }}
|
||||
{{ sourceLabel(source.label) }}
|
||||
</a><span v-if="index < sourceLinks.length - 1">, </span>
|
||||
</template>.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiApple, mdiMicrosoftWindows, mdiPenguin, mdiDownload, mdiCheckCircle } from '@mdi/js';
|
||||
import robotAvatarSeatedMagenta from '~/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp';
|
||||
import { downloadAssets } from '~/data/downloads';
|
||||
import type { DownloadOs, DownloadArch } from '~/data/downloads';
|
||||
|
||||
const { content } = useLandingContent();
|
||||
|
|
@ -10,6 +9,7 @@ const downloadStore = useDownloadStore();
|
|||
const { data: releaseData, resolve } = useReleaseDownloads();
|
||||
const { trackDownloadClick } = useAnalytics();
|
||||
const { releaseDownloadUrl } = useGithubRepo();
|
||||
const { getDownloadArch, visibleDownloadAssets: visibleAssets } = useDownloadAssetPresentation();
|
||||
const isMounted = ref(false);
|
||||
const showLinuxRobotMessage = ref(false);
|
||||
const showFallingLinuxRobot = ref(false);
|
||||
|
|
@ -236,34 +236,10 @@ const platformColors: Record<string, string> = {
|
|||
linux: '#ffd700',
|
||||
};
|
||||
|
||||
const visibleAssets = computed(() => {
|
||||
const enriched = downloadAssets.map((asset) => {
|
||||
if (asset.os !== 'macos') return { ...asset };
|
||||
if (!downloadStore.isMacOs) return { ...asset };
|
||||
return {
|
||||
...asset,
|
||||
archLabel: downloadStore.macArch === 'arm64' ? 'Apple Silicon' : 'Intel',
|
||||
};
|
||||
});
|
||||
|
||||
// Reorder so detected OS is always in the center (index 1)
|
||||
const detectedIdx = enriched.findIndex((a) => a.id === downloadStore.selectedId);
|
||||
if (detectedIdx === -1 || detectedIdx === 1) return enriched;
|
||||
|
||||
const result = [...enriched];
|
||||
const [detected] = result.splice(detectedIdx, 1);
|
||||
const [first, ...rest] = result;
|
||||
return [first, detected, ...rest];
|
||||
});
|
||||
|
||||
const getDownloadUrl = (asset: { os: string; arch: string; fileName: string }) => {
|
||||
const getDownloadUrl = (asset: { os: DownloadOs; arch: DownloadArch; fileName: string }) => {
|
||||
if (!isMounted.value) return releaseDownloadUrl(asset.fileName);
|
||||
const arch = (asset.os === 'macos' ? downloadStore.macArch : asset.arch) as DownloadArch;
|
||||
return resolve(asset.os as DownloadOs, arch)?.url || releaseDownloadUrl(asset.fileName);
|
||||
};
|
||||
|
||||
const getDownloadArch = (asset: { os: string; arch: string }) => {
|
||||
return asset.os === 'macos' ? downloadStore.macArch : asset.arch;
|
||||
const arch = getDownloadArch(asset);
|
||||
return resolve(asset.os, arch)?.url || releaseDownloadUrl(asset.fileName);
|
||||
};
|
||||
|
||||
const releaseVersion = computed(() => releaseData.value?.version || null);
|
||||
|
|
@ -275,6 +251,7 @@ const releaseDate = computed(() => {
|
|||
day: 'numeric',
|
||||
});
|
||||
});
|
||||
const linuxRobotBubble = computed(() => locale.value === 'ru' ? 'Готов начать!' : 'Ready to start!');
|
||||
|
||||
</script>
|
||||
|
||||
|
|
@ -319,7 +296,7 @@ const releaseDate = computed(() => {
|
|||
class="download-section__card-robot-bubble"
|
||||
tail="right"
|
||||
>
|
||||
Готов начать!
|
||||
{{ linuxRobotBubble }}
|
||||
</RobotSpeechBubble>
|
||||
</Transition>
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
mdiBookOpenPageVariantOutline,
|
||||
mdiDownload,
|
||||
} from "@mdi/js";
|
||||
import { heroMessages, type HeroMessagePhase } from "~/data/heroScene";
|
||||
import { getLocalizedHeroMessages, type HeroMessagePhase } from "~/data/heroScene";
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t, locale } = useI18n();
|
||||
|
|
@ -20,6 +20,7 @@ let heroMotionQuery: MediaQueryList | null = null;
|
|||
const downloadStore = useDownloadStore();
|
||||
const { resolve, data: releaseData } = useReleaseDownloads();
|
||||
const { latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
const { selectedDownloadAsset } = useDownloadAssetPresentation();
|
||||
const withBase = (path: string) => `${baseURL.replace(/\/?$/, "/")}${path.replace(/^\/+/, "")}`;
|
||||
|
||||
useCyberHeroParallax(heroRef);
|
||||
|
|
@ -33,7 +34,35 @@ const releaseDate = computed(() => {
|
|||
day: "numeric",
|
||||
});
|
||||
});
|
||||
const activeHeroMessage = computed(() => heroMessages[activeHeroMessageIndex.value] ?? null);
|
||||
const localizedHeroMessages = computed(() => getLocalizedHeroMessages(locale.value));
|
||||
const activeHeroMessage = computed(() => localizedHeroMessages.value[activeHeroMessageIndex.value] ?? null);
|
||||
const supportedProviders = [
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
accent: "cyan",
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
accent: "amber",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
accent: "magenta",
|
||||
},
|
||||
] as const;
|
||||
const supportedProvidersLabel = computed(() => (
|
||||
locale.value === "ru"
|
||||
? "Поддерживаем AI-провайдеры"
|
||||
: "Supported AI providers"
|
||||
));
|
||||
const heroSlogan = computed(() => (
|
||||
locale.value === "ru"
|
||||
? "Делайте много, почти ничего не делая"
|
||||
: "Get a lot done by doing very little"
|
||||
));
|
||||
|
||||
const heroDownloadUrl = computed(() => {
|
||||
const asset = downloadStore.selectedAsset;
|
||||
|
|
@ -43,6 +72,20 @@ const heroDownloadUrl = computed(() => {
|
|||
});
|
||||
|
||||
const docsHref = computed(() => withBase(locale.value === "ru" ? "docs/ru/" : "docs/"));
|
||||
const downloadActionSubtitle = computed(() => {
|
||||
if (!selectedDownloadAsset.value) {
|
||||
return locale.value === "ru"
|
||||
? "Для вашей платформы"
|
||||
: "For your platform";
|
||||
}
|
||||
|
||||
return selectedDownloadAsset.value.actionSubtitle;
|
||||
});
|
||||
const docsActionSubtitle = computed(() => (
|
||||
locale.value === "ru"
|
||||
? "Гайды и настройка"
|
||||
: "Guides and setup"
|
||||
));
|
||||
|
||||
function clearHeroMessageTimers() {
|
||||
heroMessageTimers.forEach(window.clearTimeout);
|
||||
|
|
@ -57,7 +100,7 @@ function setHeroMessageTimer(callback: () => void, delay: number) {
|
|||
function runHeroMessageCycle() {
|
||||
clearHeroMessageTimers();
|
||||
|
||||
if (!isHeroVisible.value || heroReducedMotion.value || heroMessages.length === 0) {
|
||||
if (!isHeroVisible.value || heroReducedMotion.value || localizedHeroMessages.value.length === 0) {
|
||||
heroMessagePhase.value = "cooldown";
|
||||
return;
|
||||
}
|
||||
|
|
@ -73,7 +116,7 @@ function runHeroMessageCycle() {
|
|||
heroMessagePhase.value = "cooldown";
|
||||
}, 3900);
|
||||
setHeroMessageTimer(() => {
|
||||
activeHeroMessageIndex.value = (activeHeroMessageIndex.value + 1) % heroMessages.length;
|
||||
activeHeroMessageIndex.value = (activeHeroMessageIndex.value + 1) % localizedHeroMessages.value.length;
|
||||
runHeroMessageCycle();
|
||||
}, 4700);
|
||||
}
|
||||
|
|
@ -138,33 +181,52 @@ onUnmounted(() => {
|
|||
</h1>
|
||||
|
||||
<p class="cyber-hero__slogan cyber-panel">
|
||||
Get a lot done by doing very little
|
||||
{{ heroSlogan }}
|
||||
</p>
|
||||
|
||||
<p class="cyber-hero__description">
|
||||
{{ content.hero.subtitle }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="cyber-hero__providers"
|
||||
:aria-label="supportedProvidersLabel"
|
||||
>
|
||||
<div class="cyber-hero__provider-list">
|
||||
<div
|
||||
v-for="provider in supportedProviders"
|
||||
:key="provider.id"
|
||||
class="cyber-hero__provider"
|
||||
:class="`cyber-hero__provider--${provider.accent}`"
|
||||
>
|
||||
<span class="cyber-hero__provider-icon" aria-hidden="true">
|
||||
<CyberProviderIcon :provider="provider.id" />
|
||||
</span>
|
||||
<span class="cyber-hero__provider-name">
|
||||
{{ provider.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cyber-hero__actions">
|
||||
<v-btn
|
||||
variant="flat"
|
||||
size="large"
|
||||
<CyberHeroActionButton
|
||||
:href="heroDownloadUrl"
|
||||
target="_blank"
|
||||
class="cyber-hero__action cyber-hero__action--primary"
|
||||
:prepend-icon="mdiDownload"
|
||||
tone="primary"
|
||||
:icon="mdiDownload"
|
||||
:subtitle="downloadActionSubtitle"
|
||||
>
|
||||
{{ t("hero.downloadNow") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="large"
|
||||
</CyberHeroActionButton>
|
||||
<CyberHeroActionButton
|
||||
:href="docsHref"
|
||||
class="cyber-hero__action cyber-hero__action--docs"
|
||||
:prepend-icon="mdiBookOpenPageVariantOutline"
|
||||
tone="secondary"
|
||||
:icon="mdiBookOpenPageVariantOutline"
|
||||
:subtitle="docsActionSubtitle"
|
||||
>
|
||||
{{ t("hero.ctaDocs") }}
|
||||
</v-btn>
|
||||
</CyberHeroActionButton>
|
||||
</div>
|
||||
|
||||
<p
|
||||
|
|
@ -190,9 +252,6 @@ onUnmounted(() => {
|
|||
|
||||
<CyberHeroFeatureStrip
|
||||
class="cyber-hero__feature-strip"
|
||||
:active-message="activeHeroMessage"
|
||||
:phase="heroMessagePhase"
|
||||
:reduced-motion="heroReducedMotion"
|
||||
/>
|
||||
</v-container>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { register } from 'swiper/element/bundle';
|
|||
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiArrowExpand } from '@mdi/js';
|
||||
import { screenshots as screenshotData } from '~/data/screenshots';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
|
||||
register();
|
||||
|
|
@ -21,12 +21,14 @@ type SwiperContainerElement = HTMLElement & {
|
|||
swiper?: SwiperApi;
|
||||
};
|
||||
|
||||
const screenshots = screenshotData.map((s) => ({
|
||||
const screenshots = computed(() => screenshotData.map((s) => ({
|
||||
src: publicPath(s.path),
|
||||
alt: s.alt,
|
||||
alt: locale.value === 'ru' ? (s.ruAlt ?? s.alt) : s.alt,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
}));
|
||||
})));
|
||||
const prevLabel = computed(() => locale.value === 'ru' ? 'Предыдущий' : 'Previous');
|
||||
const nextLabel = computed(() => locale.value === 'ru' ? 'Следующий' : 'Next');
|
||||
|
||||
const swiperRef = ref<SwiperContainerElement | null>(null);
|
||||
const swiperReady = ref(false);
|
||||
|
|
@ -45,11 +47,11 @@ function closeLightbox() {
|
|||
}
|
||||
|
||||
function lightboxPrev() {
|
||||
lightboxIndex.value = (lightboxIndex.value - 1 + screenshots.length) % screenshots.length;
|
||||
lightboxIndex.value = (lightboxIndex.value - 1 + screenshots.value.length) % screenshots.value.length;
|
||||
}
|
||||
|
||||
function lightboxNext() {
|
||||
lightboxIndex.value = (lightboxIndex.value + 1) % screenshots.length;
|
||||
lightboxIndex.value = (lightboxIndex.value + 1) % screenshots.value.length;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
|
|
@ -182,14 +184,14 @@ function slideNext() {
|
|||
<!-- Nav buttons -->
|
||||
<button
|
||||
class="screenshots-section__nav screenshots-section__nav--prev"
|
||||
aria-label="Previous"
|
||||
:aria-label="prevLabel"
|
||||
@click="slidePrev"
|
||||
>
|
||||
<v-icon :icon="mdiChevronLeft" size="28" />
|
||||
</button>
|
||||
<button
|
||||
class="screenshots-section__nav screenshots-section__nav--next"
|
||||
aria-label="Next"
|
||||
:aria-label="nextLabel"
|
||||
@click="slideNext"
|
||||
>
|
||||
<v-icon :icon="mdiChevronRight" size="28" />
|
||||
|
|
|
|||
|
|
@ -1,21 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
import { nextTick, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { mdiPlay, mdiPause, mdiVolumeHigh, mdiVolumeOff, mdiFullscreen } from '@mdi/js';
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { mdiPlay } from "@mdi/js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const videoSrc = 'https://github.com/user-attachments/assets/9cae73cd-7f42-46e5-a8fb-ad6d41737ff8';
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const isPlaying = ref(false);
|
||||
const isMuted = ref(true);
|
||||
const showControls = ref(true);
|
||||
const isLoaded = ref(true);
|
||||
const { t, locale } = useI18n();
|
||||
const config = useRuntimeConfig();
|
||||
const muxAccentColor = "#00f0ff";
|
||||
const muxPrimaryColor = "#e6fbff";
|
||||
const muxSecondaryColor = "#020617";
|
||||
|
||||
const muxPlaybackId = computed(() => String(config.public.muxPlaybackId || "").trim());
|
||||
const videoTitle = computed(() => (
|
||||
locale.value === "ru" ? "Демо-видео Agent Teams" : "Agent Teams demo video"
|
||||
));
|
||||
const muxVideoTitle = computed(() => (
|
||||
locale.value === "ru" ? "Демо Agent Teams" : "Agent Teams demo"
|
||||
));
|
||||
const muxPlayerUrl = computed(() => {
|
||||
if (!muxPlaybackId.value) return "";
|
||||
|
||||
const url = new URL(`https://player.mux.com/${encodeURIComponent(muxPlaybackId.value)}`);
|
||||
url.searchParams.set("accent-color", muxAccentColor);
|
||||
url.searchParams.set("primary-color", muxPrimaryColor);
|
||||
url.searchParams.set("secondary-color", muxSecondaryColor);
|
||||
url.searchParams.set("metadata-video-id", "agent-teams-demo");
|
||||
url.searchParams.set("metadata-video-title", muxVideoTitle.value);
|
||||
url.searchParams.set("metadata-player-name", "Landing hero");
|
||||
url.searchParams.set("title", muxVideoTitle.value);
|
||||
url.searchParams.set("video-title", muxVideoTitle.value);
|
||||
return url.toString();
|
||||
});
|
||||
const isLoaded = ref(false);
|
||||
const hasError = ref(false);
|
||||
const progress = ref(0);
|
||||
const loadProgress = ref(0);
|
||||
const hideTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
let intObserver: IntersectionObserver | null = null;
|
||||
let loadFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearLoadFallback() {
|
||||
|
|
@ -28,7 +44,6 @@ function markLoaded() {
|
|||
if (hasError.value) return;
|
||||
isLoaded.value = true;
|
||||
clearLoadFallback();
|
||||
updateLoadProgress();
|
||||
}
|
||||
|
||||
function markError() {
|
||||
|
|
@ -36,270 +51,220 @@ function markError() {
|
|||
clearLoadFallback();
|
||||
}
|
||||
|
||||
function onVideoEnded() {
|
||||
const video = videoRef.value;
|
||||
isPlaying.value = false;
|
||||
showControls.value = true;
|
||||
progress.value = 0;
|
||||
if (video) video.currentTime = 0;
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const video = videoRef.value;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
markLoaded();
|
||||
video.play()
|
||||
.then(() => {
|
||||
isPlaying.value = true;
|
||||
})
|
||||
.catch(markError);
|
||||
} else {
|
||||
video.pause();
|
||||
isPlaying.value = false;
|
||||
}
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
const video = videoRef.value;
|
||||
if (!video) return;
|
||||
video.muted = !video.muted;
|
||||
isMuted.value = video.muted;
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
container.requestFullscreen();
|
||||
}
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
const video = videoRef.value;
|
||||
if (!video || !video.duration) return;
|
||||
progress.value = (video.currentTime / video.duration) * 100;
|
||||
}
|
||||
|
||||
function onSeek(e: MouseEvent) {
|
||||
const video = videoRef.value;
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
if (!video || !target) return;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
video.currentTime = ratio * video.duration;
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function updateLoadProgress() {
|
||||
const video = videoRef.value;
|
||||
if (!video || !video.duration || !video.buffered.length) return;
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
loadProgress.value = Math.round((bufferedEnd / video.duration) * 100);
|
||||
}
|
||||
|
||||
function showControlsBriefly() {
|
||||
showControls.value = true;
|
||||
if (hideTimer.value) clearTimeout(hideTimer.value);
|
||||
hideTimer.value = setTimeout(() => {
|
||||
if (isPlaying.value) showControls.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
showControls.value = true;
|
||||
if (hideTimer.value) clearTimeout(hideTimer.value);
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (isPlaying.value) {
|
||||
hideTimer.value = setTimeout(() => {
|
||||
showControls.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
const video = videoRef.value;
|
||||
if (video) {
|
||||
isMuted.value = video.muted;
|
||||
video.addEventListener('loadedmetadata', markLoaded, { once: true });
|
||||
video.addEventListener('loadeddata', markLoaded, { once: true });
|
||||
video.addEventListener('canplay', markLoaded, { once: true });
|
||||
video.addEventListener('canplaythrough', markLoaded, { once: true });
|
||||
video.addEventListener('error', markError);
|
||||
video.addEventListener('progress', updateLoadProgress);
|
||||
video.addEventListener('ended', onVideoEnded);
|
||||
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||
markLoaded();
|
||||
} else {
|
||||
video.load();
|
||||
loadFallbackTimer = setTimeout(markLoaded, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
intObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting && videoRef.value && !videoRef.value.paused) {
|
||||
videoRef.value.pause();
|
||||
isPlaying.value = false;
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 },
|
||||
);
|
||||
if (containerRef.value) intObserver.observe(containerRef.value);
|
||||
onMounted(() => {
|
||||
loadFallbackTimer = setTimeout(markLoaded, 2500);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (hideTimer.value) clearTimeout(hideTimer.value);
|
||||
clearLoadFallback();
|
||||
if (intObserver) { intObserver.disconnect(); intObserver = null; }
|
||||
videoRef.value?.removeEventListener('loadedmetadata', markLoaded);
|
||||
videoRef.value?.removeEventListener('loadeddata', markLoaded);
|
||||
videoRef.value?.removeEventListener('canplay', markLoaded);
|
||||
videoRef.value?.removeEventListener('canplaythrough', markLoaded);
|
||||
videoRef.value?.removeEventListener('error', markError);
|
||||
videoRef.value?.removeEventListener('progress', updateLoadProgress);
|
||||
videoRef.value?.removeEventListener('ended', onVideoEnded);
|
||||
});
|
||||
onUnmounted(clearLoadFallback);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="hero-video"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="!isLoaded && !hasError" class="hero-video__skeleton">
|
||||
<div class="hero-video">
|
||||
<div class="hero-video__ambient" aria-hidden="true" />
|
||||
<div class="hero-video__edge hero-video__edge--top" aria-hidden="true" />
|
||||
<div class="hero-video__edge hero-video__edge--bottom" aria-hidden="true" />
|
||||
<div class="hero-video__corner hero-video__corner--tl" aria-hidden="true" />
|
||||
<div class="hero-video__corner hero-video__corner--tr" aria-hidden="true" />
|
||||
<div class="hero-video__corner hero-video__corner--bl" aria-hidden="true" />
|
||||
<div class="hero-video__corner hero-video__corner--br" aria-hidden="true" />
|
||||
|
||||
<ClientOnly>
|
||||
<iframe
|
||||
v-if="muxPlayerUrl && !hasError"
|
||||
class="hero-video__player"
|
||||
:class="{ 'hero-video__player--loaded': isLoaded }"
|
||||
:src="muxPlayerUrl"
|
||||
:title="videoTitle"
|
||||
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture"
|
||||
allowfullscreen
|
||||
loading="lazy"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
@load="markLoaded"
|
||||
@error="markError"
|
||||
/>
|
||||
|
||||
<template #fallback>
|
||||
<div class="hero-video__skeleton">
|
||||
<div class="hero-video__skeleton-pulse" />
|
||||
<div class="hero-video__skeleton-content">
|
||||
<div class="hero-video__skeleton-spinner" />
|
||||
<span class="hero-video__skeleton-label">{{ t("hero.watchDemo") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<div v-if="!isLoaded && !hasError && muxPlayerUrl" class="hero-video__skeleton">
|
||||
<div class="hero-video__skeleton-pulse" />
|
||||
<div class="hero-video__skeleton-content">
|
||||
<div class="hero-video__skeleton-spinner" />
|
||||
<span class="hero-video__skeleton-label">
|
||||
{{ loadProgress > 0 ? `${loadProgress}%` : t('hero.watchDemo') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-video__skeleton-bar">
|
||||
<div
|
||||
class="hero-video__skeleton-bar-fill"
|
||||
:style="{ width: `${loadProgress}%` }"
|
||||
/>
|
||||
<span class="hero-video__skeleton-label">{{ t("hero.watchDemo") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error fallback -->
|
||||
<div v-if="hasError" class="hero-video__error">
|
||||
<div v-if="hasError || !muxPlayerUrl" class="hero-video__error">
|
||||
<v-icon :icon="mdiPlay" size="36" class="hero-video__error-icon" />
|
||||
<span class="hero-video__error-text">{{ t('hero.videoUnavailable') }}</span>
|
||||
<span class="hero-video__error-text">{{ t("hero.videoUnavailable") }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Video element -->
|
||||
<video
|
||||
v-show="!hasError"
|
||||
ref="videoRef"
|
||||
class="hero-video__player"
|
||||
:class="{ 'hero-video__player--loaded': isLoaded }"
|
||||
preload="metadata"
|
||||
poster="/screenshots/2.jpg"
|
||||
muted
|
||||
playsinline
|
||||
@timeupdate="onTimeUpdate"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<source :src="videoSrc" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Play overlay (when paused) -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="!isPlaying && isLoaded"
|
||||
class="hero-video__play-overlay"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<div class="hero-video__play-btn">
|
||||
<v-icon :icon="mdiPlay" size="36" color="white" />
|
||||
</div>
|
||||
<span class="hero-video__play-label">{{ t('hero.watchDemo') }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Controls bar -->
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="isLoaded && showControls"
|
||||
class="hero-video__controls"
|
||||
>
|
||||
<!-- Progress bar -->
|
||||
<div class="hero-video__progress" @click="onSeek">
|
||||
<div class="hero-video__progress-track">
|
||||
<div
|
||||
class="hero-video__progress-fill"
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-video__controls-row">
|
||||
<button class="hero-video__control-btn" :aria-label="isPlaying ? 'Pause' : 'Play'" @click.stop="togglePlay">
|
||||
<v-icon :icon="isPlaying ? mdiPause : mdiPlay" size="18" />
|
||||
</button>
|
||||
|
||||
<button class="hero-video__control-btn" :aria-label="isMuted ? 'Unmute' : 'Mute'" @click.stop="toggleMute">
|
||||
<v-icon :icon="isMuted ? mdiVolumeOff : mdiVolumeHigh" size="18" />
|
||||
</button>
|
||||
|
||||
<div class="hero-video__spacer" />
|
||||
|
||||
<button class="hero-video__control-btn" aria-label="Fullscreen" @click.stop="toggleFullscreen">
|
||||
<v-icon :icon="mdiFullscreen" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="hero-video__scan" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-video {
|
||||
--hero-video-cyan: #00f0ff;
|
||||
--hero-video-cyan-soft: rgba(0, 240, 255, 0.22);
|
||||
--hero-video-magenta: #ff2bff;
|
||||
--hero-video-magenta-soft: rgba(255, 43, 255, 0.18);
|
||||
--hero-video-dark: rgba(2, 6, 23, 0.96);
|
||||
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 10, 15, 0.95);
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, var(--hero-video-cyan-soft), transparent 42%),
|
||||
radial-gradient(circle at 88% 100%, var(--hero-video-magenta-soft), transparent 38%),
|
||||
var(--hero-video-dark);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 240, 255, 0.15);
|
||||
border: 1px solid rgba(0, 240, 255, 0.34);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.6),
|
||||
0 0 30px rgba(0, 240, 255, 0.05),
|
||||
inset 0 1px 0 rgba(0, 240, 255, 0.1);
|
||||
0 0 34px rgba(0, 240, 255, 0.18),
|
||||
0 0 52px rgba(255, 43, 255, 0.1),
|
||||
inset 0 1px 0 rgba(230, 251, 255, 0.18);
|
||||
}
|
||||
|
||||
.hero-video::before,
|
||||
.hero-video::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.hero-video::before {
|
||||
inset: 0;
|
||||
border: 1px solid rgba(230, 251, 255, 0.16);
|
||||
border-radius: inherit;
|
||||
box-shadow:
|
||||
inset 0 0 28px rgba(0, 240, 255, 0.08),
|
||||
inset 0 -26px 42px rgba(2, 6, 23, 0.44);
|
||||
}
|
||||
|
||||
.hero-video::after {
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0 7%, rgba(0, 240, 255, 0.1) 7.2% 7.55%, transparent 7.8%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 18%, transparent 78%, rgba(0, 240, 255, 0.1));
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.hero-video__ambient,
|
||||
.hero-video__scan,
|
||||
.hero-video__edge,
|
||||
.hero-video__corner {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-video__ambient {
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 240, 255, 0.14), transparent 28%, transparent 68%, rgba(255, 43, 255, 0.16)),
|
||||
radial-gradient(circle at 50% 50%, transparent 58%, rgba(0, 0, 0, 0.34));
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.74;
|
||||
}
|
||||
|
||||
.hero-video__scan {
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
background:
|
||||
repeating-linear-gradient(to bottom, rgba(230, 251, 255, 0.1) 0 1px, transparent 1px 5px),
|
||||
linear-gradient(90deg, transparent, rgba(0, 240, 255, 0.12), transparent);
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.hero-video__edge {
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
z-index: 6;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--hero-video-cyan), var(--hero-video-magenta), transparent);
|
||||
box-shadow: 0 0 16px rgba(0, 240, 255, 0.45);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hero-video__edge--top {
|
||||
top: 9px;
|
||||
}
|
||||
|
||||
.hero-video__edge--bottom {
|
||||
bottom: 9px;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.hero-video__corner {
|
||||
z-index: 6;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-color: var(--hero-video-cyan);
|
||||
filter: drop-shadow(0 0 10px rgba(0, 240, 255, 0.54));
|
||||
}
|
||||
|
||||
.hero-video__corner--tl {
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
border-top: 2px solid;
|
||||
border-left: 2px solid;
|
||||
}
|
||||
|
||||
.hero-video__corner--tr {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
border-top: 2px solid;
|
||||
border-right: 2px solid;
|
||||
}
|
||||
|
||||
.hero-video__corner--bl {
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
border-bottom: 2px solid;
|
||||
border-left: 2px solid;
|
||||
}
|
||||
|
||||
.hero-video__corner--br {
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
border-right: 2px solid;
|
||||
border-bottom: 2px solid;
|
||||
}
|
||||
|
||||
/* ─── Video player ─── */
|
||||
.hero-video__player {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 16px;
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
border: none;
|
||||
background: #020617;
|
||||
}
|
||||
|
||||
.hero-video__player--loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ─── Loading skeleton ─── */
|
||||
.hero-video__skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
@ -308,7 +273,8 @@ onUnmounted(() => {
|
|||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
background: rgba(6, 10, 18, 0.96);
|
||||
z-index: 2;
|
||||
z-index: 7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-video__skeleton::before,
|
||||
|
|
@ -381,26 +347,9 @@ onUnmounted(() => {
|
|||
text-shadow: 0 0 16px rgba(0, 240, 255, 0.42);
|
||||
}
|
||||
|
||||
.hero-video__skeleton-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0 0 16px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-video__skeleton-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00f0ff, #ff00ff);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes skeletonPulse {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
0%,
|
||||
100% { opacity: 0.3; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
|
|
@ -408,15 +357,19 @@ onUnmounted(() => {
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ─── Error fallback ─── */
|
||||
.hero-video__error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-height: 280px;
|
||||
padding: 32px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 234, 255, 0.08), rgba(255, 43, 255, 0.05)),
|
||||
rgba(2, 6, 16, 0.94);
|
||||
}
|
||||
|
||||
.hero-video__error-icon {
|
||||
|
|
@ -430,137 +383,6 @@ onUnmounted(() => {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Play overlay ─── */
|
||||
.hero-video__play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 3;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-video__play-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 240, 255, 0.15);
|
||||
border: 2px solid rgba(0, 240, 255, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 0 30px rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
|
||||
.hero-video__play-btn:hover {
|
||||
background: rgba(0, 240, 255, 0.25);
|
||||
border-color: rgba(0, 240, 255, 0.6);
|
||||
box-shadow: 0 0 40px rgba(0, 240, 255, 0.35);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.hero-video__play-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ─── Controls bar ─── */
|
||||
.hero-video__controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||
padding: 16px 12px 8px;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.hero-video__controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hero-video__control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-video__control-btn:hover {
|
||||
background: rgba(0, 240, 255, 0.15);
|
||||
color: #00f0ff;
|
||||
}
|
||||
|
||||
.hero-video__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── Progress bar ─── */
|
||||
.hero-video__progress {
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.hero-video__progress-track {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-video__progress:hover .hero-video__progress-track {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.hero-video__progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00f0ff, #ff00ff);
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
/* ─── Transitions ─── */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 960px) {
|
||||
.hero-video {
|
||||
max-width: 100%;
|
||||
|
|
@ -573,16 +395,17 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.hero-video__player {
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.hero-video__play-btn {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
.hero-video__edge {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.hero-video__play-label {
|
||||
font-size: 11px;
|
||||
.hero-video__corner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
72
landing/composables/useDownloadAssetPresentation.ts
Normal file
72
landing/composables/useDownloadAssetPresentation.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { downloadAssets, type DownloadArch } from "~/data/downloads";
|
||||
|
||||
type DownloadAsset = (typeof downloadAssets)[number];
|
||||
type DownloadAssetLike = Pick<DownloadAsset, "id" | "os" | "arch" | "archLabel">;
|
||||
|
||||
export type PresentedDownloadAsset = DownloadAsset & {
|
||||
archLabel: string;
|
||||
actionSubtitle: string;
|
||||
resolvedArch: DownloadArch;
|
||||
};
|
||||
|
||||
export function useDownloadAssetPresentation() {
|
||||
const downloadStore = useDownloadStore();
|
||||
|
||||
const getDownloadArch = (asset: Pick<DownloadAsset, "os" | "arch">): DownloadArch => (
|
||||
asset.os === "macos" ? downloadStore.macArch : asset.arch
|
||||
);
|
||||
|
||||
const getDownloadArchLabel = (asset: DownloadAssetLike) => {
|
||||
if (asset.os === "macos" && downloadStore.isMacOs) {
|
||||
return downloadStore.macArch === "arm64" ? "Apple Silicon" : "Intel";
|
||||
}
|
||||
|
||||
return asset.archLabel;
|
||||
};
|
||||
|
||||
const getDownloadActionSubtitle = (asset: DownloadAssetLike) => {
|
||||
const archLabel = getDownloadArchLabel(asset);
|
||||
|
||||
if (asset.os === "macos") {
|
||||
const macArchLabel = archLabel === "Apple Silicon / Intel" ? "Apple Silicon & Intel" : archLabel;
|
||||
return `macOS 11+ · ${macArchLabel}`;
|
||||
}
|
||||
|
||||
if (asset.os === "windows") return `Windows 10+ · ${archLabel}`;
|
||||
|
||||
return `Linux · AppImage ${archLabel}`;
|
||||
};
|
||||
|
||||
const presentDownloadAsset = (asset: DownloadAsset): PresentedDownloadAsset => ({
|
||||
...asset,
|
||||
archLabel: getDownloadArchLabel(asset),
|
||||
actionSubtitle: getDownloadActionSubtitle(asset),
|
||||
resolvedArch: getDownloadArch(asset),
|
||||
});
|
||||
|
||||
const visibleDownloadAssets = computed(() => {
|
||||
const enriched = downloadAssets.map(presentDownloadAsset);
|
||||
|
||||
const detectedIdx = enriched.findIndex((asset) => asset.id === downloadStore.selectedId);
|
||||
if (detectedIdx === -1 || detectedIdx === 1) return enriched;
|
||||
|
||||
const result = [...enriched];
|
||||
const [detected] = result.splice(detectedIdx, 1);
|
||||
const [first, ...rest] = result;
|
||||
return [first, detected, ...rest];
|
||||
});
|
||||
|
||||
const selectedDownloadAsset = computed(() => {
|
||||
const asset = downloadStore.selectedAsset;
|
||||
return asset ? presentDownloadAsset(asset) : null;
|
||||
});
|
||||
|
||||
return {
|
||||
getDownloadActionSubtitle,
|
||||
getDownloadArch,
|
||||
getDownloadArchLabel,
|
||||
presentDownloadAsset,
|
||||
selectedDownloadAsset,
|
||||
visibleDownloadAssets,
|
||||
};
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
const resolvedImage = computed<PageSeoImage>(() => {
|
||||
if (options.image) return options.image;
|
||||
return {
|
||||
url: "/og-image.png",
|
||||
url: "/og-image-agent-teams-v5.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
type: "image/png",
|
||||
|
|
|
|||
|
|
@ -193,6 +193,117 @@ export const heroFeatureRail = [
|
|||
{
|
||||
id: "local",
|
||||
title: "Your Machine, Your Code",
|
||||
text: "Local-first workflow with task logs, process control, and Git visibility.",
|
||||
text: "Track activity, logs, file changes, and what every agent is doing inside each task.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ruHeroAgentCopy: Record<HeroAgentRole, Pick<HeroAgent, "label" | "status" | "tasks"> & { compactLabel?: string }> = {
|
||||
planner: {
|
||||
label: "Планировщик",
|
||||
compactLabel: "План",
|
||||
status: "Планирует",
|
||||
tasks: ["Анализ требований", "Декомпозиция задач", "Создание плана"],
|
||||
},
|
||||
lead: {
|
||||
label: "Лид",
|
||||
compactLabel: "Лид",
|
||||
status: "Координирует",
|
||||
tasks: ["Архитектура", "Приоритеты", "Координация команды"],
|
||||
},
|
||||
developer: {
|
||||
label: "Разработчик",
|
||||
compactLabel: "Код",
|
||||
status: "Пишет код",
|
||||
tasks: ["Реализация фичи", "Обновление кода", "Запуск проверок"],
|
||||
},
|
||||
reviewer: {
|
||||
label: "Ревьюер",
|
||||
status: "Ревьюит",
|
||||
tasks: ["Ревью кода", "Проверка качества", "Запрос правок"],
|
||||
},
|
||||
tester: { label: "Тестировщик", status: "Тестирует", tasks: [] },
|
||||
researcher: { label: "Ресёрчер", status: "Исследует", tasks: [] },
|
||||
docs: { label: "Документация", status: "Документирует", tasks: [] },
|
||||
ops: { label: "Операции", status: "Следит", tasks: [] },
|
||||
security: { label: "Безопасность", status: "Проверяет", tasks: [] },
|
||||
fixer: { label: "Фиксер", status: "Исправляет", tasks: [] },
|
||||
};
|
||||
|
||||
const ruHeroMessages: Record<string, Pick<HeroMessage, "text" | "response">> = {
|
||||
"plan-ready": { text: "План готов.", response: "Приоритет задан." },
|
||||
"build-ready": { text: "Скоуп задан.", response: "Кодинг начат." },
|
||||
"review-build": { text: "Проверь сборку.", response: "Проверяю качество." },
|
||||
"review-pass": { text: "Ревью пройдено.", response: "Готово к релизу." },
|
||||
};
|
||||
|
||||
const ruHeroFeatureRail: Record<string, { title: string; text: string }> = {
|
||||
autonomous: {
|
||||
title: "Дайте команде цель",
|
||||
text: "Агенты сами разобьют её на задачи и начнут двигаться без микроменеджмента.",
|
||||
},
|
||||
kanban: {
|
||||
title: "Канбан обновляется сам",
|
||||
text: "Карточки двигаются, пока агенты пишут, тестируют, ревьюят и разблокируют друг друга.",
|
||||
},
|
||||
developers: {
|
||||
title: "Подключайте свой AI-стек",
|
||||
text: "Claude, Codex и OpenCode в одном десктопном центре управления.",
|
||||
},
|
||||
secure: {
|
||||
title: "Оставайтесь в контуре",
|
||||
text: "Подключайтесь через комментарии, подтверждения, прямые сообщения и быстрые действия.",
|
||||
},
|
||||
local: {
|
||||
title: "Ваша машина, ваш код",
|
||||
text: "Легко отслеживайте активность, логи, изменения файлов и работу каждого агента внутри каждой задачи.",
|
||||
},
|
||||
};
|
||||
|
||||
const isRuLocale = (locale: string) => locale.toLowerCase().startsWith("ru");
|
||||
|
||||
export function getLocalizedHeroAgents(locale: string): readonly HeroAgent[] {
|
||||
if (!isRuLocale(locale)) return heroAgents;
|
||||
|
||||
return heroAgents.map((agent) => {
|
||||
const copy = ruHeroAgentCopy[agent.id];
|
||||
return {
|
||||
...agent,
|
||||
label: copy.label,
|
||||
status: copy.status,
|
||||
tasks: copy.tasks,
|
||||
mobile: {
|
||||
...agent.mobile,
|
||||
compactLabel: copy.compactLabel ?? agent.mobile.compactLabel,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getLocalizedHeroReviewerFeatureCard(locale: string): typeof heroReviewerFeatureCard {
|
||||
if (!isRuLocale(locale)) return heroReviewerFeatureCard;
|
||||
const copy = ruHeroAgentCopy.reviewer;
|
||||
return {
|
||||
...heroReviewerFeatureCard,
|
||||
label: copy.label,
|
||||
status: copy.status,
|
||||
tasks: copy.tasks,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalizedHeroMessages(locale: string): readonly HeroMessage[] {
|
||||
if (!isRuLocale(locale)) return heroMessages;
|
||||
|
||||
return heroMessages.map((message) => ({
|
||||
...message,
|
||||
...(ruHeroMessages[message.id] ?? {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getLocalizedHeroFeatureRail(locale: string): typeof heroFeatureRail {
|
||||
if (!isRuLocale(locale)) return heroFeatureRail;
|
||||
|
||||
return heroFeatureRail.map((feature) => ({
|
||||
...feature,
|
||||
...(ruHeroFeatureRail[feature.id] ?? {}),
|
||||
})) as typeof heroFeatureRail;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export type Screenshot = {
|
||||
src: string;
|
||||
alt: string;
|
||||
ruAlt?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
|
@ -10,16 +11,16 @@ export type Screenshot = {
|
|||
* `src` is relative to public/ — prepend baseURL at runtime.
|
||||
*/
|
||||
export const screenshots: (Omit<Screenshot, "src"> & { path: string })[] = [
|
||||
{ path: "screenshots/1.jpg", alt: "Kanban board with agent tasks", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/2.jpg", alt: "Agent team communication", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/3.png", alt: "Code review diff view", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/4.png", alt: "Team management dashboard", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/5.png", alt: "Live process monitoring", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/6.png", alt: "Session context analysis", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/7.png", alt: "Cross-team messaging", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/8.png", alt: "Task details and comments", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/9.png", alt: "Built-in code editor", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/10.png", alt: "Task details with code changes and execution logs", width: 2624, height: 1642 },
|
||||
{ path: "screenshots/11.png", alt: "Agent code review comments and task workflow", width: 2624, height: 1696 },
|
||||
{ path: "screenshots/12.png", alt: "Allow or deny agent actions with live preview", width: 2624, height: 1646 },
|
||||
{ path: "screenshots/1.jpg", alt: "Kanban board with agent tasks", ruAlt: "Канбан-доска с задачами агентов", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/2.jpg", alt: "Agent team communication", ruAlt: "Коммуникация команды агентов", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/3.png", alt: "Code review diff view", ruAlt: "Diff-просмотр для код-ревью", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/4.png", alt: "Team management dashboard", ruAlt: "Панель управления командой", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/5.png", alt: "Live process monitoring", ruAlt: "Мониторинг живых процессов", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/6.png", alt: "Session context analysis", ruAlt: "Анализ контекста сессии", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/7.png", alt: "Cross-team messaging", ruAlt: "Сообщения между командами", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/8.png", alt: "Task details and comments", ruAlt: "Детали задачи и комментарии", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/9.png", alt: "Built-in code editor", ruAlt: "Встроенный редактор кода", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/10.png", alt: "Task details with code changes and execution logs", ruAlt: "Детали задачи с изменениями кода и логами выполнения", width: 2624, height: 1642 },
|
||||
{ path: "screenshots/11.png", alt: "Agent code review comments and task workflow", ruAlt: "Комментарии агента к код-ревью и процессу задачи", width: 2624, height: 1696 },
|
||||
{ path: "screenshots/12.png", alt: "Allow or deny agent actions with live preview", ruAlt: "Разрешение или запрет действий агента с предпросмотром", width: 2624, height: 1646 },
|
||||
];
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 643 KiB |
BIN
landing/docs/references/hero-button-reference.png
Normal file
BIN
landing/docs/references/hero-button-reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
|
|
@ -7,9 +7,34 @@ const props = defineProps<{
|
|||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = ((config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai").replace(/\/+$/, "");
|
||||
const ogImage = `${siteUrl}/og-image-agent-teams-v5.png`;
|
||||
|
||||
const statusCode = computed(() => props.error?.statusCode || 404);
|
||||
const isNotFound = computed(() => statusCode.value === 404);
|
||||
const errorTitle = computed(() => (isNotFound.value ? t("error.notFoundTitle") : t("error.genericTitle")));
|
||||
const errorDescription = computed(() => (isNotFound.value ? t("error.notFoundDescription") : t("error.genericDescription")));
|
||||
|
||||
useSeoMeta({
|
||||
title: errorTitle,
|
||||
description: errorDescription,
|
||||
robots: "noindex, nofollow",
|
||||
ogTitle: errorTitle,
|
||||
ogDescription: errorDescription,
|
||||
ogType: "website",
|
||||
ogSiteName: "Agent Teams",
|
||||
ogImage,
|
||||
ogImageType: "image/png",
|
||||
ogImageWidth: "1200",
|
||||
ogImageHeight: "630",
|
||||
ogImageAlt: "Agent Teams - AI agent orchestration",
|
||||
twitterCard: "summary_large_image",
|
||||
twitterTitle: errorTitle,
|
||||
twitterDescription: errorDescription,
|
||||
twitterImage: ogImage,
|
||||
twitterImageAlt: "Agent Teams - AI agent orchestration"
|
||||
});
|
||||
|
||||
const handleGoHome = () => clearError({ redirect: "/" });
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"download": "Скачать",
|
||||
"pricing": "Бесплатно",
|
||||
"faq": "FAQ",
|
||||
"viewOnGithub": "View on GitHub"
|
||||
"viewOnGithub": "GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Agent Teams",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,13 @@ declare const process: {
|
|||
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai";
|
||||
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/agent-teams-ai";
|
||||
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
|
||||
const muxPlaybackId = process.env.NUXT_PUBLIC_MUX_PLAYBACK_ID || "qyeNuDjFqoDALK8eB02jMTOWUz006BdIhiqiAip3U00x7I";
|
||||
const muxBackgroundPlaybackId = process.env.NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID || muxPlaybackId;
|
||||
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
|
||||
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
|
||||
const defaultSeoTitle = "Agent Teams - AI Agent Orchestration for Developers";
|
||||
const defaultSeoDescription = "Free, open-source desktop app for AI agent teams. Start with a free model with no auth, then connect Claude, Codex, or OpenCode when you need more models.";
|
||||
const defaultSeoImage = `${siteUrl.replace(/\/+$/, "")}/og-image-agent-teams-v5.png`;
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2026-01-19",
|
||||
|
|
@ -18,6 +23,25 @@ export default defineNuxtConfig({
|
|||
app: {
|
||||
baseURL,
|
||||
head: {
|
||||
title: defaultSeoTitle,
|
||||
meta: [
|
||||
{ name: "description", content: defaultSeoDescription },
|
||||
{ name: "robots", content: "noindex, nofollow" },
|
||||
{ property: "og:title", content: defaultSeoTitle },
|
||||
{ property: "og:description", content: defaultSeoDescription },
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:site_name", content: "Agent Teams" },
|
||||
{ property: "og:image", content: defaultSeoImage },
|
||||
{ property: "og:image:type", content: "image/png" },
|
||||
{ property: "og:image:width", content: "1200" },
|
||||
{ property: "og:image:height", content: "630" },
|
||||
{ property: "og:image:alt", content: "Agent Teams - AI agent orchestration" },
|
||||
{ name: "twitter:card", content: "summary_large_image" },
|
||||
{ name: "twitter:title", content: defaultSeoTitle },
|
||||
{ name: "twitter:description", content: defaultSeoDescription },
|
||||
{ name: "twitter:image", content: defaultSeoImage },
|
||||
{ name: "twitter:image:alt", content: "Agent Teams - AI agent orchestration" }
|
||||
],
|
||||
link: [
|
||||
{ rel: "icon", type: "image/x-icon", href: `${baseURL}favicon.ico` },
|
||||
{ rel: "icon", type: "image/png", sizes: "32x32", href: `${baseURL}favicon-32.png` },
|
||||
|
|
@ -50,7 +74,7 @@ export default defineNuxtConfig({
|
|||
},
|
||||
vue: {
|
||||
compilerOptions: {
|
||||
isCustomElement: (tag: string) => tag.startsWith("swiper-")
|
||||
isCustomElement: (tag: string) => tag.startsWith("swiper-") || tag === "mux-video"
|
||||
}
|
||||
},
|
||||
vite: {
|
||||
|
|
@ -108,7 +132,9 @@ export default defineNuxtConfig({
|
|||
public: {
|
||||
siteUrl,
|
||||
githubRepo,
|
||||
githubReleasesUrl
|
||||
githubReleasesUrl,
|
||||
muxPlaybackId,
|
||||
muxBackgroundPlaybackId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"dependencies": {
|
||||
"@firecms/neat": "^0.8.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mux/mux-video": "^0.31.0",
|
||||
"@nuxtjs/i18n": "^9.5.6",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@vueuse/nuxt": "^10.11.1",
|
||||
|
|
|
|||
|
|
@ -4,12 +4,20 @@ export default defineNuxtPlugin({
|
|||
setup(nuxtApp) {
|
||||
const { initTheme } = useBrowserTheme();
|
||||
const { initLocale } = useLocation();
|
||||
let initialized = false;
|
||||
|
||||
initTheme();
|
||||
|
||||
nuxtApp.hook("app:mounted", () => {
|
||||
const initializeBrowserState = () => {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
initTheme();
|
||||
initLocale();
|
||||
});
|
||||
};
|
||||
|
||||
if (nuxtApp.isHydrating) {
|
||||
nuxtApp.hooks.hookOnce("app:suspense:resolve", initializeBrowserState);
|
||||
return;
|
||||
}
|
||||
|
||||
nuxtApp.hook("app:mounted", initializeBrowserState);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,13 +18,8 @@ function isThemeName(value: string | null | undefined): value is ThemeName {
|
|||
}
|
||||
|
||||
function resolveInitialTheme(cookieTheme: ThemeName | null): ThemeName {
|
||||
if (import.meta.client) {
|
||||
const saved = localStorage.getItem("theme");
|
||||
if (isThemeName(saved)) return saved;
|
||||
if (cookieTheme) return cookieTheme;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
// Keep the first client render identical to SSR. Browser-only sources
|
||||
// are applied after mount by init-theme-locale.client.ts.
|
||||
return cookieTheme ?? "light";
|
||||
}
|
||||
|
||||
|
|
|
|||
BIN
landing/public/og-image-agent-teams-v5.png
Normal file
BIN
landing/public/og-image-agent-teams-v5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 673 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 673 KiB |
4
mcp-server/src/agent-teams-controller.d.ts
vendored
4
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -43,7 +43,7 @@ declare module 'agent-teams-controller' {
|
|||
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||
memberBriefing(
|
||||
memberName: string,
|
||||
options?: { runtimeProvider?: 'native' | 'opencode'; includeActiveProcesses?: boolean }
|
||||
options?: { runtimeProvider?: 'native' | 'opencode' | 'codex'; includeActiveProcesses?: boolean }
|
||||
): Promise<string>;
|
||||
leadBriefing(): Promise<string>;
|
||||
taskBriefing(memberName: string): Promise<string>;
|
||||
|
|
@ -51,7 +51,7 @@ declare module 'agent-teams-controller' {
|
|||
|
||||
export interface ControllerKanbanApi {
|
||||
getKanbanState(): unknown;
|
||||
setKanbanColumn(taskId: string, column: string): unknown;
|
||||
setKanbanColumn(taskId: string, column: string, options?: Record<string, unknown>): unknown;
|
||||
clearKanban(taskId: string): unknown;
|
||||
listReviewers(): string[];
|
||||
addReviewer(reviewer: string): string[];
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ const HTTP_TRANSPORT = 'httpStream';
|
|||
const STDIO_TRANSPORT = 'stdio';
|
||||
const DEFAULT_HTTP_HOST = '127.0.0.1';
|
||||
const DEFAULT_HTTP_ENDPOINT = '/mcp';
|
||||
const MCP_HTTP_IDENTITY_SERVICE = 'agent-teams-mcp-http';
|
||||
const MCP_HTTP_IDENTITY_SERVICE_ENV = 'AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE';
|
||||
const MCP_HTTP_CLAUDE_DIR_HASH_ENV = 'AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH';
|
||||
const MCP_HTTP_LAUNCH_SPEC_HASH_ENV = 'AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH';
|
||||
const MCP_HTTP_OWNER_INSTANCE_ID_ENV = 'AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID';
|
||||
|
||||
export type AgentTeamsMcpStartOptions =
|
||||
| {
|
||||
|
|
@ -23,10 +28,32 @@ export type AgentTeamsMcpStartOptions =
|
|||
};
|
||||
};
|
||||
|
||||
export function createServer() {
|
||||
export interface AgentTeamsMcpHttpHealthIdentity {
|
||||
schemaVersion: 1;
|
||||
service: typeof MCP_HTTP_IDENTITY_SERVICE;
|
||||
transport: typeof HTTP_TRANSPORT;
|
||||
host: string;
|
||||
port: number;
|
||||
endpoint: `/${string}`;
|
||||
claudeDirHash: string;
|
||||
launchSpecHash: string;
|
||||
ownerInstanceId: string;
|
||||
}
|
||||
|
||||
export function createServer(input: { healthIdentity?: AgentTeamsMcpHttpHealthIdentity | null } = {}) {
|
||||
const server = new FastMCP({
|
||||
name: 'agent-teams-mcp',
|
||||
version: '1.0.0',
|
||||
...(input.healthIdentity
|
||||
? {
|
||||
health: {
|
||||
enabled: true,
|
||||
path: '/health',
|
||||
status: 200,
|
||||
message: JSON.stringify(input.healthIdentity),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
registerTools(server);
|
||||
|
|
@ -64,6 +91,45 @@ function parsePort(value: string | null | undefined): number {
|
|||
return parsed;
|
||||
}
|
||||
|
||||
function readIdentityValue(env: NodeJS.ProcessEnv, name: string): string | null {
|
||||
const value = env[name]?.trim();
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function buildHttpHealthIdentity(
|
||||
options: AgentTeamsMcpStartOptions,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): AgentTeamsMcpHttpHealthIdentity | null {
|
||||
if (options.transportType !== HTTP_TRANSPORT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const service = readIdentityValue(env, MCP_HTTP_IDENTITY_SERVICE_ENV);
|
||||
const claudeDirHash = readIdentityValue(env, MCP_HTTP_CLAUDE_DIR_HASH_ENV);
|
||||
const launchSpecHash = readIdentityValue(env, MCP_HTTP_LAUNCH_SPEC_HASH_ENV);
|
||||
const ownerInstanceId = readIdentityValue(env, MCP_HTTP_OWNER_INSTANCE_ID_ENV);
|
||||
if (
|
||||
service !== MCP_HTTP_IDENTITY_SERVICE ||
|
||||
!claudeDirHash ||
|
||||
!launchSpecHash ||
|
||||
!ownerInstanceId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
service: MCP_HTTP_IDENTITY_SERVICE,
|
||||
transport: HTTP_TRANSPORT,
|
||||
host: options.httpStream.host,
|
||||
port: options.httpStream.port,
|
||||
endpoint: options.httpStream.endpoint,
|
||||
claudeDirHash,
|
||||
launchSpecHash,
|
||||
ownerInstanceId,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveStartOptions(
|
||||
argv: string[] = process.argv,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
|
|
@ -92,6 +158,7 @@ export function resolveStartOptions(
|
|||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const server = createServer();
|
||||
void server.start(resolveStartOptions());
|
||||
const startOptions = resolveStartOptions();
|
||||
const server = createServer({ healthIdentity: buildHttpHealthIdentity(startOptions) });
|
||||
void server.start(startOptions);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -622,7 +622,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
runtimeProvider: z.enum(['native', 'opencode']).optional(),
|
||||
runtimeProvider: z.enum(['native', 'opencode', 'codex']).optional(),
|
||||
includeActiveProcesses: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({
|
||||
|
|
|
|||
|
|
@ -14,6 +14,71 @@ const controlContextSchema = {
|
|||
|
||||
const reportStateSchema = z.enum(['still_working', 'blocked', 'caught_up']);
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildRequiredReportFollowUp(input: {
|
||||
status: unknown;
|
||||
teamName: string;
|
||||
memberName?: string;
|
||||
from?: string;
|
||||
controlUrl?: string;
|
||||
}) {
|
||||
const status = asRecord(input.status);
|
||||
const agenda = asRecord(status?.agenda);
|
||||
const agendaFingerprint =
|
||||
typeof agenda?.fingerprint === 'string' && agenda.fingerprint.trim()
|
||||
? agenda.fingerprint.trim()
|
||||
: null;
|
||||
const reportToken =
|
||||
typeof status?.reportToken === 'string' && status.reportToken.trim()
|
||||
? status.reportToken.trim()
|
||||
: null;
|
||||
if (!status || !agendaFingerprint || !reportToken) {
|
||||
return input.status;
|
||||
}
|
||||
|
||||
const inputMemberName = input.memberName?.trim();
|
||||
const fromMemberName = input.from?.trim();
|
||||
let memberName = '';
|
||||
if (typeof status.memberName === 'string') {
|
||||
memberName = status.memberName.trim();
|
||||
}
|
||||
if (fromMemberName) {
|
||||
memberName = fromMemberName;
|
||||
}
|
||||
if (inputMemberName) {
|
||||
memberName = inputMemberName;
|
||||
}
|
||||
const items = Array.isArray(agenda?.items) ? agenda.items : [];
|
||||
const taskIds = items
|
||||
.map((item) => asRecord(item)?.taskId)
|
||||
.filter((taskId): taskId is string => typeof taskId === 'string' && taskId.trim().length > 0);
|
||||
const state = items.length > 0 ? 'still_working' : 'caught_up';
|
||||
|
||||
return {
|
||||
...status,
|
||||
statusOnlyIncomplete: true,
|
||||
nextRequiredAction:
|
||||
'Do not stop after member_work_sync_status. Call member_work_sync_report in this same turn using nextRequiredToolCall.arguments.',
|
||||
nextRequiredToolCall: {
|
||||
tool: 'member_work_sync_report',
|
||||
arguments: {
|
||||
teamName: input.teamName,
|
||||
...(memberName ? { memberName } : {}),
|
||||
...(input.controlUrl ? { controlUrl: input.controlUrl } : {}),
|
||||
state,
|
||||
agendaFingerprint,
|
||||
reportToken,
|
||||
...(taskIds.length ? { taskIds } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'member_work_sync_status',
|
||||
|
|
@ -26,12 +91,19 @@ export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}),
|
||||
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, memberName, from }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
const status = await getController(teamName, claudeDir).workSync.memberWorkSyncStatus({
|
||||
...(memberName ? { memberName } : {}),
|
||||
...(from ? { from } : {}),
|
||||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
});
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).workSync.memberWorkSyncStatus({
|
||||
buildRequiredReportFollowUp({
|
||||
status,
|
||||
teamName,
|
||||
...(memberName ? { memberName } : {}),
|
||||
...(from ? { from } : {}),
|
||||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
);
|
||||
},
|
||||
|
|
|
|||
133
mcp-server/test/http.e2e.test.ts
Normal file
133
mcp-server/test/http.e2e.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const serverEntry = path.join(repoRoot, 'dist', 'index.js');
|
||||
|
||||
const children: ChildProcess[] = [];
|
||||
|
||||
async function allocateLoopbackPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to allocate HTTP e2e port')));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readHealthBody(port: number): Promise<{ statusCode: number | null; body: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let body = '';
|
||||
const request = http.get(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/health',
|
||||
timeout: 1_000,
|
||||
},
|
||||
(response) => {
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk: string) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('end', () => resolve({ statusCode: response.statusCode ?? null, body }));
|
||||
}
|
||||
);
|
||||
request.once('timeout', () => {
|
||||
request.destroy();
|
||||
resolve({ statusCode: null, body: '' });
|
||||
});
|
||||
request.once('error', () => resolve({ statusCode: null, body: '' }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealthBody(port: number): Promise<{ statusCode: number | null; body: string }> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 10_000) {
|
||||
const result = await readHealthBody(port);
|
||||
if (result.statusCode === 200) {
|
||||
return result;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`HTTP MCP server did not become healthy on port ${port}`);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
children.splice(0).map(
|
||||
(child) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
child.once('exit', () => resolve());
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}, 500).unref();
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
describe('agent-teams-mcp HTTP e2e', () => {
|
||||
it('returns app-managed JSON identity from /health when identity env is present', async () => {
|
||||
const port = await allocateLoopbackPort();
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
serverEntry,
|
||||
'--transport',
|
||||
'httpStream',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
String(port),
|
||||
'--endpoint',
|
||||
'mcp',
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE: 'agent-teams-mcp-http',
|
||||
AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH: 'claude-dir-hash-e2e',
|
||||
AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH: 'launch-spec-hash-e2e',
|
||||
AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID: 'owner-e2e',
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
}
|
||||
);
|
||||
children.push(child);
|
||||
|
||||
const health = await waitForHealthBody(port);
|
||||
const parsed = JSON.parse(health.body) as Record<string, unknown>;
|
||||
|
||||
expect(health.statusCode).toBe(200);
|
||||
expect(parsed).toEqual({
|
||||
schemaVersion: 1,
|
||||
service: 'agent-teams-mcp-http',
|
||||
transport: 'httpStream',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
endpoint: '/mcp',
|
||||
claudeDirHash: 'claude-dir-hash-e2e',
|
||||
launchSpecHash: 'launch-spec-hash-e2e',
|
||||
ownerInstanceId: 'owner-e2e',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveStartOptions } from '../src/index';
|
||||
import { buildHttpHealthIdentity, resolveStartOptions } from '../src/index';
|
||||
|
||||
describe('agent-teams MCP start options', () => {
|
||||
it('defaults to stdio transport', () => {
|
||||
|
|
@ -51,4 +51,42 @@ describe('agent-teams MCP start options', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds HTTP health identity only when app identity env is present', () => {
|
||||
const options = resolveStartOptions(
|
||||
['node', 'index.js', '--transport', 'httpStream', '--port', '43125'],
|
||||
{}
|
||||
);
|
||||
|
||||
expect(buildHttpHealthIdentity(options, {})).toBeNull();
|
||||
expect(
|
||||
buildHttpHealthIdentity(options, {
|
||||
AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE: 'agent-teams-mcp-http',
|
||||
AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH: 'claude-hash',
|
||||
AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH: 'launch-hash',
|
||||
AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID: 'owner-id',
|
||||
})
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
service: 'agent-teams-mcp-http',
|
||||
transport: 'httpStream',
|
||||
host: '127.0.0.1',
|
||||
port: 43125,
|
||||
endpoint: '/mcp',
|
||||
claudeDirHash: 'claude-hash',
|
||||
launchSpecHash: 'launch-hash',
|
||||
ownerInstanceId: 'owner-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not build HTTP health identity for stdio transport', () => {
|
||||
const options = resolveStartOptions(['node', 'index.js'], {
|
||||
AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE: 'agent-teams-mcp-http',
|
||||
AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH: 'claude-hash',
|
||||
AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH: 'launch-hash',
|
||||
AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID: 'owner-id',
|
||||
});
|
||||
|
||||
expect(buildHttpHealthIdentity(options)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
function parseJsonToolResult(result: unknown) {
|
||||
|
|
@ -16,7 +17,17 @@ function parseJsonToolResult(result: unknown) {
|
|||
return JSON.parse(text ?? 'null');
|
||||
}
|
||||
|
||||
async function writeTeamConfig(claudeDir: string, teamName: string) {
|
||||
type TestTeamMember = Record<string, unknown>;
|
||||
|
||||
async function writeTeamConfig(
|
||||
claudeDir: string,
|
||||
teamName: string,
|
||||
members: TestTeamMember[] = [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', agentType: 'teammate', role: 'developer' },
|
||||
{ name: 'bob', agentType: 'teammate', role: 'reviewer' },
|
||||
]
|
||||
) {
|
||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||
await mkdir(teamDir, { recursive: true });
|
||||
await writeFile(
|
||||
|
|
@ -24,11 +35,7 @@ async function writeTeamConfig(claudeDir: string, teamName: string) {
|
|||
JSON.stringify(
|
||||
{
|
||||
name: teamName,
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', agentType: 'teammate', role: 'developer' },
|
||||
{ name: 'bob', agentType: 'teammate', role: 'reviewer' },
|
||||
],
|
||||
members,
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
|
@ -37,6 +44,45 @@ async function writeTeamConfig(claudeDir: string, teamName: string) {
|
|||
);
|
||||
}
|
||||
|
||||
async function startControlServer(
|
||||
handler: (request: {
|
||||
method?: string;
|
||||
url?: string;
|
||||
body?: unknown;
|
||||
}) => Promise<{ statusCode?: number; body: unknown }> | { statusCode?: number; body: unknown }
|
||||
) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const bodyText = Buffer.concat(chunks).toString('utf8');
|
||||
const body = bodyText ? JSON.parse(bodyText) : undefined;
|
||||
const result = await handler({ method: req.method, url: req.url, body });
|
||||
res.writeHead(result.statusCode ?? 200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(result.body));
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to bind control server');
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: async () =>
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function writeBulkTaskRows(claudeDir: string, teamName: string, count: number) {
|
||||
const tasksDir = path.join(claudeDir, 'tasks', teamName);
|
||||
await mkdir(tasksDir, { recursive: true });
|
||||
|
|
@ -1991,4 +2037,452 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes Codex-native briefing and owner notifications over stdio MCP', async () => {
|
||||
await writeTeamConfig(claudeDir, 'stdio-codex-team', [
|
||||
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex', model: 'gpt-5.5' },
|
||||
{
|
||||
name: 'bob',
|
||||
agentType: 'teammate',
|
||||
role: 'developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
},
|
||||
]);
|
||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
const briefingResult = await client.callTool(
|
||||
'member_briefing',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-codex-team',
|
||||
memberName: 'bob',
|
||||
runtimeProvider: 'codex',
|
||||
},
|
||||
201
|
||||
);
|
||||
const briefingText = (
|
||||
((briefingResult as { result: { content?: Array<{ text?: string }> } }).result
|
||||
?.content?.[0]?.text as string | undefined) ?? ''
|
||||
);
|
||||
expect(briefingText).toContain('Codex Native visible messaging rule');
|
||||
expect(briefingText).toContain('Codex Native task tool rule');
|
||||
expect(briefingText).toContain('agent-teams_message_send');
|
||||
expect(briefingText).toContain('mcp__agent-teams__task_get');
|
||||
expect(briefingText).not.toContain('notify your team lead via SendMessage');
|
||||
|
||||
await client.callTool(
|
||||
'task_create',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-codex-team',
|
||||
subject: 'Codex stdio assignment',
|
||||
owner: 'bob',
|
||||
description: 'Verify Codex sees Agent Teams MCP tools over stdio.',
|
||||
},
|
||||
202
|
||||
);
|
||||
|
||||
const inboxRaw = await readFile(
|
||||
path.join(claudeDir, 'teams', 'stdio-codex-team', 'inboxes', 'bob.json'),
|
||||
'utf8'
|
||||
);
|
||||
const inbox = JSON.parse(inboxRaw) as Array<{ text?: string }>;
|
||||
const assignmentText = inbox[0]?.text ?? '';
|
||||
expect(assignmentText).toContain('MCP tool agent-teams_message_send');
|
||||
expect(assignmentText).toContain('Codex Native visible messaging rule');
|
||||
expect(assignmentText).toContain('mcp__agent-teams__task_get');
|
||||
expect(assignmentText).not.toContain('notify your lead via SendMessage');
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards work-sync status and report through real stdio MCP JSON-RPC', async () => {
|
||||
await writeTeamConfig(claudeDir, 'stdio-work-sync-team', [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', agentType: 'teammate', role: 'developer' },
|
||||
]);
|
||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
const controlServer = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
if (
|
||||
method === 'POST' &&
|
||||
url === '/api/teams/stdio-work-sync-team/member-work-sync/alice/refresh'
|
||||
) {
|
||||
return {
|
||||
body: {
|
||||
teamName: 'stdio-work-sync-team',
|
||||
memberName: 'alice',
|
||||
state: 'needs_sync',
|
||||
agenda: {
|
||||
teamName: 'stdio-work-sync-team',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
fingerprint: 'agenda:v1:stdio',
|
||||
items: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
reportToken: 'wrs:v1.stdio.token',
|
||||
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
|
||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||
diagnostics: ['no_current_report'],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === 'POST' && url === '/api/teams/stdio-work-sync-team/member-work-sync/report') {
|
||||
return { body: { accepted: true, code: 'accepted', status: body } };
|
||||
}
|
||||
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||
});
|
||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
const statusResult = await client.callTool(
|
||||
'member_work_sync_status',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-work-sync-team',
|
||||
controlUrl: controlServer.baseUrl,
|
||||
from: 'alice',
|
||||
},
|
||||
203
|
||||
);
|
||||
const status = parseJsonToolResult((statusResult as { result: unknown }).result);
|
||||
expect(status.state).toBe('needs_sync');
|
||||
expect(status.agenda.fingerprint).toBe('agenda:v1:stdio');
|
||||
expect(status.statusOnlyIncomplete).toBe(true);
|
||||
expect(status.nextRequiredToolCall).toMatchObject({
|
||||
tool: 'member_work_sync_report',
|
||||
arguments: {
|
||||
teamName: 'stdio-work-sync-team',
|
||||
memberName: 'alice',
|
||||
controlUrl: controlServer.baseUrl,
|
||||
state: 'caught_up',
|
||||
agendaFingerprint: 'agenda:v1:stdio',
|
||||
reportToken: 'wrs:v1.stdio.token',
|
||||
},
|
||||
});
|
||||
|
||||
const reportResult = await client.callTool(
|
||||
'member_work_sync_report',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-work-sync-team',
|
||||
controlUrl: controlServer.baseUrl,
|
||||
memberName: 'alice',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:stdio',
|
||||
reportToken: 'wrs:v1.stdio.token',
|
||||
taskIds: ['task-1'],
|
||||
note: 'Still working',
|
||||
leaseTtlMs: 120000,
|
||||
},
|
||||
204
|
||||
);
|
||||
const report = parseJsonToolResult((reportResult as { result: unknown }).result);
|
||||
expect(report.accepted).toBe(true);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/api/teams/stdio-work-sync-team/member-work-sync/alice/refresh',
|
||||
body: {},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/api/teams/stdio-work-sync-team/member-work-sync/report',
|
||||
body: expect.objectContaining({
|
||||
memberName: 'alice',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:stdio',
|
||||
reportToken: 'wrs:v1.stdio.token',
|
||||
taskIds: ['task-1'],
|
||||
note: 'Still working',
|
||||
leaseTtlMs: 120000,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await controlServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('discovers work-sync control endpoint from env in a real stdio MCP process', async () => {
|
||||
await writeTeamConfig(claudeDir, 'stdio-work-sync-env-team', [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', agentType: 'teammate', role: 'developer' },
|
||||
]);
|
||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
const controlServer = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
if (
|
||||
method === 'POST' &&
|
||||
url === '/api/teams/stdio-work-sync-env-team/member-work-sync/alice/refresh'
|
||||
) {
|
||||
return {
|
||||
body: {
|
||||
teamName: 'stdio-work-sync-env-team',
|
||||
memberName: 'alice',
|
||||
state: 'needs_sync',
|
||||
agenda: {
|
||||
teamName: 'stdio-work-sync-env-team',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
fingerprint: 'agenda:v1:stdio-env',
|
||||
items: [{ taskId: 'task-env-1' }],
|
||||
diagnostics: [],
|
||||
},
|
||||
reportToken: 'wrs:v1.stdio.env.token',
|
||||
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
|
||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||
diagnostics: ['no_current_report'],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
method === 'POST' &&
|
||||
url === '/api/teams/stdio-work-sync-env-team/member-work-sync/report'
|
||||
) {
|
||||
return { body: { accepted: true, code: 'accepted', status: body } };
|
||||
}
|
||||
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||
});
|
||||
const previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
|
||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
const statusResult = await client.callTool(
|
||||
'member_work_sync_status',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-work-sync-env-team',
|
||||
from: 'alice',
|
||||
},
|
||||
205
|
||||
);
|
||||
const status = parseJsonToolResult((statusResult as { result: unknown }).result);
|
||||
expect(status.nextRequiredToolCall).toMatchObject({
|
||||
tool: 'member_work_sync_report',
|
||||
arguments: {
|
||||
teamName: 'stdio-work-sync-env-team',
|
||||
memberName: 'alice',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:stdio-env',
|
||||
reportToken: 'wrs:v1.stdio.env.token',
|
||||
taskIds: ['task-env-1'],
|
||||
},
|
||||
});
|
||||
|
||||
const reportResult = await client.callTool(
|
||||
'member_work_sync_report',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-work-sync-env-team',
|
||||
memberName: 'alice',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:stdio-env',
|
||||
reportToken: 'wrs:v1.stdio.env.token',
|
||||
taskIds: ['task-env-1'],
|
||||
},
|
||||
206
|
||||
);
|
||||
const report = parseJsonToolResult((reportResult as { result: unknown }).result);
|
||||
expect(report.accepted).toBe(true);
|
||||
expect(calls.map((call) => call.url)).toEqual([
|
||||
'/api/teams/stdio-work-sync-env-team/member-work-sync/alice/refresh',
|
||||
'/api/teams/stdio-work-sync-env-team/member-work-sync/report',
|
||||
]);
|
||||
} finally {
|
||||
if (previousControlUrl === undefined) {
|
||||
delete process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||
} else {
|
||||
process.env.CLAUDE_TEAM_CONTROL_URL = previousControlUrl;
|
||||
}
|
||||
await client.close();
|
||||
await controlServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back from stale explicit work-sync control URL to state file in real stdio MCP', async () => {
|
||||
await writeTeamConfig(claudeDir, 'stdio-work-sync-stale-team', [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', agentType: 'teammate', role: 'developer' },
|
||||
]);
|
||||
const staleCalls: Array<{ method?: string; url?: string }> = [];
|
||||
const freshCalls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
const staleServer = await startControlServer(async ({ method, url }) => {
|
||||
staleCalls.push({ method, url });
|
||||
return { statusCode: 404, body: { error: 'stale control server' } };
|
||||
});
|
||||
const freshServer = await startControlServer(async ({ method, url, body }) => {
|
||||
freshCalls.push({ method, url, body });
|
||||
if (
|
||||
method === 'POST' &&
|
||||
url === '/api/teams/stdio-work-sync-stale-team/member-work-sync/alice/refresh'
|
||||
) {
|
||||
return {
|
||||
body: {
|
||||
teamName: 'stdio-work-sync-stale-team',
|
||||
memberName: 'alice',
|
||||
state: 'needs_sync',
|
||||
agenda: {
|
||||
teamName: 'stdio-work-sync-stale-team',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
fingerprint: 'agenda:v1:stdio-stale-fallback',
|
||||
items: [{ taskId: 'task-stale-1' }],
|
||||
diagnostics: [],
|
||||
},
|
||||
reportToken: 'wrs:v1.stdio.stale.token',
|
||||
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
|
||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||
diagnostics: ['no_current_report'],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
method === 'POST' &&
|
||||
url === '/api/teams/stdio-work-sync-stale-team/member-work-sync/report'
|
||||
) {
|
||||
return { body: { accepted: true, code: 'accepted', status: body } };
|
||||
}
|
||||
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||
});
|
||||
await writeFile(
|
||||
path.join(claudeDir, 'team-control-api.json'),
|
||||
JSON.stringify({ baseUrl: freshServer.baseUrl, updatedAt: new Date().toISOString() }),
|
||||
'utf8'
|
||||
);
|
||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
const statusResult = await client.callTool(
|
||||
'member_work_sync_status',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-work-sync-stale-team',
|
||||
controlUrl: staleServer.baseUrl,
|
||||
from: 'alice',
|
||||
},
|
||||
207
|
||||
);
|
||||
const status = parseJsonToolResult((statusResult as { result: unknown }).result);
|
||||
expect(status.agenda.fingerprint).toBe('agenda:v1:stdio-stale-fallback');
|
||||
|
||||
const reportResult = await client.callTool(
|
||||
'member_work_sync_report',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-work-sync-stale-team',
|
||||
controlUrl: staleServer.baseUrl,
|
||||
memberName: 'alice',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:stdio-stale-fallback',
|
||||
reportToken: 'wrs:v1.stdio.stale.token',
|
||||
taskIds: ['task-stale-1'],
|
||||
},
|
||||
208
|
||||
);
|
||||
const report = parseJsonToolResult((reportResult as { result: unknown }).result);
|
||||
expect(report.accepted).toBe(true);
|
||||
expect(staleCalls.map((call) => call.url)).toEqual([
|
||||
'/api/teams/stdio-work-sync-stale-team/member-work-sync/alice/refresh',
|
||||
'/api/teams/stdio-work-sync-stale-team/member-work-sync/report',
|
||||
]);
|
||||
expect(freshCalls.map((call) => call.url)).toEqual([
|
||||
'/api/teams/stdio-work-sync-stale-team/member-work-sync/alice/refresh',
|
||||
'/api/teams/stdio-work-sync-stale-team/member-work-sync/report',
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await staleServer.close();
|
||||
await freshServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('records a pending work-sync report when control API is unavailable in real stdio MCP', async () => {
|
||||
await writeTeamConfig(claudeDir, 'stdio-work-sync-pending-team', [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', agentType: 'teammate', role: 'developer' },
|
||||
]);
|
||||
const previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||
delete process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
const reportResult = await client.callTool(
|
||||
'member_work_sync_report',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-work-sync-pending-team',
|
||||
memberName: 'alice',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:offline',
|
||||
reportToken: 'wrs:v1.offline.token',
|
||||
taskIds: ['task-offline-1'],
|
||||
},
|
||||
209
|
||||
);
|
||||
const report = parseJsonToolResult((reportResult as { result: unknown }).result);
|
||||
expect(report).toMatchObject({
|
||||
accepted: false,
|
||||
pendingValidation: true,
|
||||
code: 'pending_validation',
|
||||
});
|
||||
|
||||
const pendingFile = JSON.parse(
|
||||
await readFile(
|
||||
path.join(
|
||||
claudeDir,
|
||||
'teams',
|
||||
'stdio-work-sync-pending-team',
|
||||
'.member-work-sync',
|
||||
'pending-reports.json'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
) as {
|
||||
intents?: Record<
|
||||
string,
|
||||
{
|
||||
reason?: string;
|
||||
status?: string;
|
||||
request?: { memberName?: string; agendaFingerprint?: string; taskIds?: string[] };
|
||||
}
|
||||
>;
|
||||
};
|
||||
const intents = Object.values(pendingFile.intents ?? {});
|
||||
expect(intents).toHaveLength(1);
|
||||
expect(intents[0]).toMatchObject({
|
||||
reason: 'control_api_unavailable',
|
||||
status: 'pending',
|
||||
request: {
|
||||
memberName: 'alice',
|
||||
agendaFingerprint: 'agenda:v1:offline',
|
||||
taskIds: ['task-offline-1'],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (previousControlUrl === undefined) {
|
||||
delete process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||
} else {
|
||||
process.env.CLAUDE_TEAM_CONTROL_URL = previousControlUrl;
|
||||
}
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ function parseJsonToolResult(result: unknown) {
|
|||
|
||||
describe('agent-teams-mcp tools', () => {
|
||||
const tools = collectTools();
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function getTool(name: string) {
|
||||
const tool = tools.get(name);
|
||||
|
|
@ -39,7 +46,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
}
|
||||
|
||||
function makeClaudeDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeTeamConfig(
|
||||
|
|
@ -448,6 +457,18 @@ describe('agent-teams-mcp tools', () => {
|
|||
})
|
||||
);
|
||||
expect(status.state).toBe('needs_sync');
|
||||
expect(status.statusOnlyIncomplete).toBe(true);
|
||||
expect(status.nextRequiredToolCall).toMatchObject({
|
||||
tool: 'member_work_sync_report',
|
||||
arguments: {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
controlUrl: server.baseUrl,
|
||||
state: 'caught_up',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
},
|
||||
});
|
||||
|
||||
const report = parseJsonToolResult(
|
||||
await getTool('member_work_sync_report').execute({
|
||||
|
|
@ -538,6 +559,180 @@ describe('agent-teams-mcp tools', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('discovers the work-sync control endpoint from the published state file', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
],
|
||||
});
|
||||
const statePath = path.join(claudeDir, 'team-control-api.json');
|
||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/alice/refresh') {
|
||||
return {
|
||||
body: {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
state: 'needs_sync',
|
||||
agenda: {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
fingerprint: 'agenda:v1:state-file',
|
||||
items: [{ taskId: 'task-1' }],
|
||||
diagnostics: [],
|
||||
},
|
||||
reportToken: 'wrs:v1.state.file.token',
|
||||
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
|
||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||
diagnostics: ['no_current_report'],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/report') {
|
||||
return { body: { accepted: true, code: 'accepted', status: body } };
|
||||
}
|
||||
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||
});
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({ baseUrl: server.baseUrl, updatedAt: new Date().toISOString() }, null, 2)
|
||||
);
|
||||
|
||||
const status = parseJsonToolResult(
|
||||
await getTool('member_work_sync_status').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
from: 'alice',
|
||||
})
|
||||
);
|
||||
expect(status.nextRequiredToolCall).toMatchObject({
|
||||
tool: 'member_work_sync_report',
|
||||
arguments: {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:state-file',
|
||||
reportToken: 'wrs:v1.state.file.token',
|
||||
taskIds: ['task-1'],
|
||||
},
|
||||
});
|
||||
|
||||
const report = parseJsonToolResult(
|
||||
await getTool('member_work_sync_report').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:state-file',
|
||||
reportToken: 'wrs:v1.state.file.token',
|
||||
taskIds: ['task-1'],
|
||||
})
|
||||
);
|
||||
expect(report.accepted).toBe(true);
|
||||
expect(calls.map((call) => call.url)).toEqual([
|
||||
'/api/teams/alpha/member-work-sync/alice/refresh',
|
||||
'/api/teams/alpha/member-work-sync/report',
|
||||
]);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back from a stale explicit work-sync control URL to the published state file', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
],
|
||||
});
|
||||
const statePath = path.join(claudeDir, 'team-control-api.json');
|
||||
const staleCalls: Array<{ method?: string; url?: string }> = [];
|
||||
const freshCalls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
|
||||
const staleServer = await startControlServer(async ({ method, url }) => {
|
||||
staleCalls.push({ method, url });
|
||||
return { statusCode: 404, body: { error: 'stale control server' } };
|
||||
});
|
||||
const freshServer = await startControlServer(async ({ method, url, body }) => {
|
||||
freshCalls.push({ method, url, body });
|
||||
if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/alice/refresh') {
|
||||
return {
|
||||
body: {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
state: 'needs_sync',
|
||||
agenda: {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
fingerprint: 'agenda:v1:fresh-state-file',
|
||||
items: [{ taskId: 'task-1' }],
|
||||
diagnostics: [],
|
||||
},
|
||||
reportToken: 'wrs:v1.fresh.state.token',
|
||||
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
|
||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||
diagnostics: ['no_current_report'],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/report') {
|
||||
return { body: { accepted: true, code: 'accepted', status: body } };
|
||||
}
|
||||
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||
});
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({ baseUrl: freshServer.baseUrl, updatedAt: new Date().toISOString() })
|
||||
);
|
||||
|
||||
const status = parseJsonToolResult(
|
||||
await getTool('member_work_sync_status').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: staleServer.baseUrl,
|
||||
from: 'alice',
|
||||
})
|
||||
);
|
||||
expect(status.agenda.fingerprint).toBe('agenda:v1:fresh-state-file');
|
||||
|
||||
const report = parseJsonToolResult(
|
||||
await getTool('member_work_sync_report').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: staleServer.baseUrl,
|
||||
memberName: 'alice',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:fresh-state-file',
|
||||
reportToken: 'wrs:v1.fresh.state.token',
|
||||
taskIds: ['task-1'],
|
||||
})
|
||||
);
|
||||
expect(report.accepted).toBe(true);
|
||||
expect(staleCalls.map((call) => call.url)).toEqual([
|
||||
'/api/teams/alpha/member-work-sync/alice/refresh',
|
||||
'/api/teams/alpha/member-work-sync/report',
|
||||
]);
|
||||
expect(freshCalls.map((call) => call.url)).toEqual([
|
||||
'/api/teams/alpha/member-work-sync/alice/refresh',
|
||||
'/api/teams/alpha/member-work-sync/report',
|
||||
]);
|
||||
} finally {
|
||||
await staleServer.close();
|
||||
await freshServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'alpha';
|
||||
|
|
@ -813,6 +1008,20 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
expect(openCodeMemberBriefingText).not.toContain('task_get_comment {');
|
||||
expect(openCodeMemberBriefingText).not.toContain('notify your team lead via SendMessage');
|
||||
|
||||
const codexMemberBriefing = await getTool('member_briefing').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
memberName: 'alice',
|
||||
runtimeProvider: 'codex',
|
||||
});
|
||||
const codexMemberBriefingText = (
|
||||
codexMemberBriefing as { content: Array<{ text: string }> }
|
||||
).content[0]?.text;
|
||||
expect(codexMemberBriefingText).toContain('agent-teams_message_send');
|
||||
expect(codexMemberBriefingText).toContain('Codex Native visible messaging rule');
|
||||
expect(codexMemberBriefingText).toContain('mcp__agent-teams__task_get');
|
||||
expect(codexMemberBriefingText).not.toContain('notify your team lead via SendMessage');
|
||||
});
|
||||
|
||||
it('keeps owner-backed MCP tasks pending by default, supports explicit startImmediately, sends owner notifications, and returns compact task_briefing output', async () => {
|
||||
|
|
@ -1011,6 +1220,31 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('uses Codex-native MCP wording for task_create owner notifications', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'codex-owner';
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'bob', role: 'developer', providerId: 'codex', model: 'gpt-5.4-mini' },
|
||||
],
|
||||
});
|
||||
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Codex assigned work',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'bob.json');
|
||||
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||
expect(ownerInbox[0].text).toContain('MCP tool agent-teams_message_send');
|
||||
expect(ownerInbox[0].text).toContain('Codex Native visible messaging rule');
|
||||
expect(ownerInbox[0].text).toContain('mcp__agent-teams__task_get');
|
||||
expect(ownerInbox[0].text).not.toContain('notify your lead via SendMessage');
|
||||
});
|
||||
|
||||
it('returns compact lead_briefing output and filtered task_list inventory', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'lead-queue';
|
||||
|
|
|
|||
17
package.json
17
package.json
|
|
@ -327,7 +327,22 @@
|
|||
"artifactName": "Agent.Teams.AI-${version}.${ext}"
|
||||
},
|
||||
"deb": {
|
||||
"afterInstall": "resources/afterInstall.sh"
|
||||
"afterInstall": "resources/afterInstall.sh",
|
||||
"fpm": [
|
||||
"resources/linux/bin/agent-teams-ai=/usr/bin/agent-teams-ai"
|
||||
]
|
||||
},
|
||||
"rpm": {
|
||||
"afterInstall": "resources/afterInstall.sh",
|
||||
"fpm": [
|
||||
"resources/linux/bin/agent-teams-ai=/usr/bin/agent-teams-ai"
|
||||
]
|
||||
},
|
||||
"pacman": {
|
||||
"afterInstall": "resources/afterInstall.sh",
|
||||
"fpm": [
|
||||
"resources/linux/bin/agent-teams-ai=/usr/bin/agent-teams-ai"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "Agent.Teams.AI.Setup.${version}.${ext}",
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
* Adapted from agent-flow's draw-edges.ts (Apache 2.0).
|
||||
*/
|
||||
|
||||
import type { GraphNode, GraphEdge, GraphEdgeType } from '../ports/types';
|
||||
import { COLORS } from '../constants/colors';
|
||||
import { BEAM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants';
|
||||
import { COLORS } from '../constants/colors';
|
||||
|
||||
import type { GraphEdge, GraphEdgeType, GraphNode } from '../ports/types';
|
||||
|
||||
// ─── Edge Type → Color/Width Mapping ────────────────────────────────────────
|
||||
|
||||
|
|
@ -93,6 +94,9 @@ export function drawEdges(
|
|||
const isActive = hasActiveParticles.has(edge.id);
|
||||
const isSelected = selectedEdgeId === edge.id;
|
||||
const isHovered = !isSelected && hoveredEdgeId === edge.id;
|
||||
if (edge.type === 'message' && !isActive && !isSelected && !isHovered) {
|
||||
continue;
|
||||
}
|
||||
// Pulse alpha when particles are travelling: base 0.3 + 0.2 * sin wave
|
||||
const alpha = isActive ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) : BEAM.idleAlpha;
|
||||
const focusAlpha = focusEdgeIds && !focusEdgeIds.has(edge.id) ? 0.1 : 1;
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ const GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT = 2;
|
|||
const GRID_UNDER_LEAD_LEAD_GAP = 77.7;
|
||||
const GRID_UNDER_LEAD_ROW_GAP = 77.7;
|
||||
const ROW_ORBIT_MIN_OWNER_COUNT = 6;
|
||||
const ROW_ORBIT_MAX_OWNER_COUNT = 12;
|
||||
const ROW_ORBIT_MAX_OWNER_COUNT = 14;
|
||||
const ROW_ORBIT_HORIZONTAL_GAP = Math.max(112, STABLE_SLOT_GEOMETRY.slotHorizontalGap);
|
||||
const ROW_ORBIT_VERTICAL_GAP = Math.max(144, GRID_UNDER_LEAD_ROW_GAP);
|
||||
const ROW_ORBIT_CENTRAL_GAP = 160;
|
||||
|
|
@ -216,6 +216,7 @@ const ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT: Readonly<Record<number, readonly numb
|
|||
10: [3, 2, 2, 3],
|
||||
11: [3, 3, 2, 3],
|
||||
12: [3, 3, 3, 3],
|
||||
14: [3, 3, 2, 3, 3],
|
||||
};
|
||||
|
||||
const ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT: Readonly<
|
||||
|
|
@ -1306,7 +1307,7 @@ function buildRowOrbitSlotConfigs(
|
|||
layout?: GraphLayoutPort
|
||||
): RowOrbitSlotConfig[] | null {
|
||||
const rowCount = rowCounts.length;
|
||||
const middleRowIndex = rowCount === 3 ? 1 : -1;
|
||||
const middleRowIndex = getRowOrbitMiddleRowIndex(rowCounts);
|
||||
const configs: RowOrbitSlotConfig[] = [];
|
||||
const actualAssignments = ownerFootprints
|
||||
.map((footprint) => layout?.slotAssignments?.[footprint.ownerId])
|
||||
|
|
@ -1432,10 +1433,17 @@ function buildRowOrbitSlotFrames(
|
|||
runtimeCentralExclusion: StableRect
|
||||
): SlotFrame[] {
|
||||
const rowConfigs = groupRowOrbitSlotConfigs(slotConfigs, rowCounts.length);
|
||||
const middleRowIndex = rowCounts.length === 3 ? 1 : -1;
|
||||
const middleRowIndex = getRowOrbitMiddleRowIndex(rowCounts);
|
||||
const rowTopByIndex = resolveRowOrbitRowTops(rowConfigs, middleRowIndex, runtimeCentralExclusion);
|
||||
const framesByOwnerId = new Map<string, SlotFrame>();
|
||||
const fallbackColumnWidth = Math.max(...slotConfigs.map((config) => config.footprint.slotWidth));
|
||||
const alignedColumnCenters = resolveAlignedThreeColumnCenters(
|
||||
rowConfigs,
|
||||
rowCounts,
|
||||
middleRowIndex,
|
||||
fallbackColumnWidth,
|
||||
runtimeCentralExclusion
|
||||
);
|
||||
|
||||
for (const row of rowConfigs) {
|
||||
if (row.length === 0) {
|
||||
|
|
@ -1444,8 +1452,9 @@ function buildRowOrbitSlotFrames(
|
|||
|
||||
if (row[0]?.band === 'middle') {
|
||||
for (const config of row) {
|
||||
const ownerX =
|
||||
config.columnIndex === 0
|
||||
const ownerX = alignedColumnCenters
|
||||
? alignedColumnCenters[config.columnIndex === 0 ? 0 : 2]!
|
||||
: config.columnIndex === 0
|
||||
? runtimeCentralExclusion.left - ROW_ORBIT_CENTRAL_GAP - config.footprint.slotWidth / 2
|
||||
: runtimeCentralExclusion.right +
|
||||
ROW_ORBIT_CENTRAL_GAP +
|
||||
|
|
@ -1464,10 +1473,12 @@ function buildRowOrbitSlotFrames(
|
|||
const nextLeft = -getRowOrbitRowWidth(columnWidths) / 2;
|
||||
for (const config of row) {
|
||||
const ownerX =
|
||||
nextLeft +
|
||||
columnWidths.slice(0, config.columnIndex).reduce((sum, width) => sum + width, 0) +
|
||||
config.columnIndex * ROW_ORBIT_HORIZONTAL_GAP +
|
||||
columnWidths[config.columnIndex]! / 2;
|
||||
alignedColumnCenters && columnCount === alignedColumnCenters.length
|
||||
? alignedColumnCenters[config.columnIndex]!
|
||||
: nextLeft +
|
||||
columnWidths.slice(0, config.columnIndex).reduce((sum, width) => sum + width, 0) +
|
||||
config.columnIndex * ROW_ORBIT_HORIZONTAL_GAP +
|
||||
columnWidths[config.columnIndex]! / 2;
|
||||
const ownerY = rowTop + getOwnerAnchorTopOffset();
|
||||
framesByOwnerId.set(
|
||||
config.footprint.ownerId,
|
||||
|
|
@ -1482,6 +1493,79 @@ function buildRowOrbitSlotFrames(
|
|||
});
|
||||
}
|
||||
|
||||
function getRowOrbitMiddleRowIndex(rowCounts: readonly number[]): number {
|
||||
const candidateIndex = Math.floor(rowCounts.length / 2);
|
||||
return rowCounts.length % 2 === 1 && rowCounts[candidateIndex] === 2 ? candidateIndex : -1;
|
||||
}
|
||||
|
||||
function resolveAlignedThreeColumnCenters(
|
||||
rowConfigs: readonly (readonly RowOrbitSlotConfig[])[],
|
||||
rowCounts: readonly number[],
|
||||
middleRowIndex: number,
|
||||
fallbackColumnWidth: number,
|
||||
runtimeCentralExclusion: StableRect
|
||||
): readonly [number, number, number] | null {
|
||||
if (
|
||||
rowCounts.length !== 5 ||
|
||||
middleRowIndex < 0 ||
|
||||
rowCounts[middleRowIndex] !== 2 ||
|
||||
!rowCounts.every((columnCount, rowIndex) =>
|
||||
rowIndex === middleRowIndex ? columnCount === 2 : columnCount === 3
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const columnWidths: [number, number, number] = [
|
||||
fallbackColumnWidth,
|
||||
fallbackColumnWidth,
|
||||
fallbackColumnWidth,
|
||||
];
|
||||
|
||||
for (const row of rowConfigs) {
|
||||
for (const config of row) {
|
||||
const columnIndex =
|
||||
config.rowIndex === middleRowIndex && config.columnCount === 2
|
||||
? config.columnIndex === 0
|
||||
? 0
|
||||
: 2
|
||||
: config.columnIndex;
|
||||
columnWidths[columnIndex] = Math.max(
|
||||
columnWidths[columnIndex] ?? fallbackColumnWidth,
|
||||
config.footprint.slotWidth
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const baseCenters = resolveRowOrbitColumnCenters(columnWidths);
|
||||
const centralClearance = ROW_ORBIT_CENTRAL_GAP + SLOT_GEOMETRY.centralHorizontalGap;
|
||||
const minSideDistance = Math.max(
|
||||
Math.abs(baseCenters[0]),
|
||||
Math.abs(baseCenters[2]),
|
||||
Math.abs(runtimeCentralExclusion.left) + centralClearance + columnWidths[0] / 2,
|
||||
Math.abs(runtimeCentralExclusion.right) + centralClearance + columnWidths[2] / 2
|
||||
);
|
||||
|
||||
return [-minSideDistance, 0, minSideDistance];
|
||||
}
|
||||
|
||||
function resolveRowOrbitColumnCenters(
|
||||
columnWidths: readonly [number, number, number]
|
||||
): readonly [number, number, number] {
|
||||
const rowWidth = getRowOrbitRowWidth(columnWidths);
|
||||
const nextLeft = -rowWidth / 2;
|
||||
const leftCenter = nextLeft + columnWidths[0] / 2;
|
||||
const middleCenter = nextLeft + columnWidths[0] + ROW_ORBIT_HORIZONTAL_GAP + columnWidths[1] / 2;
|
||||
const rightCenter =
|
||||
nextLeft +
|
||||
columnWidths[0] +
|
||||
columnWidths[1] +
|
||||
ROW_ORBIT_HORIZONTAL_GAP * 2 +
|
||||
columnWidths[2] / 2;
|
||||
|
||||
return [leftCenter, middleCenter, rightCenter];
|
||||
}
|
||||
|
||||
function groupRowOrbitSlotConfigs(
|
||||
slotConfigs: readonly RowOrbitSlotConfig[],
|
||||
rowCount: number
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export interface GraphConfigPort {
|
|||
showLogs?: boolean;
|
||||
showTasks?: boolean;
|
||||
showProcesses?: boolean;
|
||||
/** Whether to show static graph edges by default */
|
||||
showEdges?: boolean;
|
||||
showCompletedTasks?: boolean;
|
||||
showEdgeLabels?: boolean;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,27 +10,10 @@
|
|||
* ALL animation state (positions, particles, effects, time) lives in refs.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
|
||||
import type { GraphDataPort } from '../ports/GraphDataPort';
|
||||
import type { GraphEventPort } from '../ports/GraphEventPort';
|
||||
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
||||
import type {
|
||||
GraphEdge,
|
||||
GraphLayoutMode,
|
||||
GraphNode,
|
||||
GraphOwnerSlotAssignment,
|
||||
} from '../ports/types';
|
||||
import type { StableRect } from '../layout/stableSlots';
|
||||
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
|
||||
import { GraphControls, type GraphFilterState } from './GraphControls';
|
||||
import { GraphOverlay } from './GraphOverlay';
|
||||
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
||||
import { buildFocusState } from './buildFocusState';
|
||||
import type { TransientHandoffCard } from './transientHandoffs';
|
||||
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
||||
import { useGraphCamera } from '../hooks/useGraphCamera';
|
||||
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
||||
|
||||
import {
|
||||
collectInteractiveEdgesInViewport,
|
||||
findEdgeAt,
|
||||
|
|
@ -38,8 +21,29 @@ import {
|
|||
getEdgeMidpoint,
|
||||
} from '../canvas/hit-detection';
|
||||
import { ANIM, ANIM_SPEED } from '../constants/canvas-constants';
|
||||
import { useGraphCamera } from '../hooks/useGraphCamera';
|
||||
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
||||
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
||||
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
||||
|
||||
import { buildFocusState } from './buildFocusState';
|
||||
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
|
||||
import { GraphControls, type GraphFilterState } from './GraphControls';
|
||||
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
||||
import { GraphOverlay } from './GraphOverlay';
|
||||
|
||||
import type { StableRect } from '../layout/stableSlots';
|
||||
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
||||
import type { GraphDataPort } from '../ports/GraphDataPort';
|
||||
import type { GraphEventPort } from '../ports/GraphEventPort';
|
||||
import type {
|
||||
GraphEdge,
|
||||
GraphLayoutMode,
|
||||
GraphNode,
|
||||
GraphOwnerSlotAssignment,
|
||||
} from '../ports/types';
|
||||
import type { TransientHandoffCard } from './transientHandoffs';
|
||||
|
||||
export interface GraphViewProps {
|
||||
data: GraphDataPort;
|
||||
events?: GraphEventPort;
|
||||
|
|
@ -96,6 +100,20 @@ export interface GraphViewProps {
|
|||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function filterVisibleGraphEdges(
|
||||
edges: GraphEdge[],
|
||||
visibleNodeIds: ReadonlySet<string>,
|
||||
showEdges: boolean,
|
||||
activeParticleEdgeIds?: ReadonlySet<string>
|
||||
): GraphEdge[] {
|
||||
return edges.filter((edge) => {
|
||||
if (!showEdges && !activeParticleEdgeIds?.has(edge.id)) {
|
||||
return false;
|
||||
}
|
||||
return visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target);
|
||||
});
|
||||
}
|
||||
|
||||
export function GraphView({
|
||||
data,
|
||||
events,
|
||||
|
|
@ -127,7 +145,7 @@ export function GraphView({
|
|||
showLogs: config?.showLogs ?? config?.showActivity ?? true,
|
||||
showTasks: config?.showTasks ?? true,
|
||||
showProcesses: config?.showProcesses ?? true,
|
||||
showEdges: true,
|
||||
showEdges: config?.showEdges ?? false,
|
||||
paused: !(config?.animationEnabled ?? true),
|
||||
});
|
||||
const effectivePaused = filters.paused || suspendAnimation;
|
||||
|
|
@ -214,13 +232,12 @@ export function GraphView({
|
|||
);
|
||||
|
||||
const getVisibleEdges = useCallback(
|
||||
(edges: GraphEdge[], visibleNodeIds: ReadonlySet<string>): GraphEdge[] =>
|
||||
edges.filter((edge) => {
|
||||
if (!filters.showEdges && edge.type !== 'parent-child') {
|
||||
return false;
|
||||
}
|
||||
return visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target);
|
||||
}),
|
||||
(
|
||||
edges: GraphEdge[],
|
||||
visibleNodeIds: ReadonlySet<string>,
|
||||
activeParticleEdgeIds?: ReadonlySet<string>
|
||||
): GraphEdge[] =>
|
||||
filterVisibleGraphEdges(edges, visibleNodeIds, filters.showEdges, activeParticleEdgeIds),
|
||||
[filters.showEdges]
|
||||
);
|
||||
|
||||
|
|
@ -376,7 +393,8 @@ export function GraphView({
|
|||
const state = simulationRef.current.stateRef.current;
|
||||
const visibleNodes = getVisibleNodes(state.nodes);
|
||||
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
|
||||
const visibleEdges = getVisibleEdges(state.edges, visibleNodeIds);
|
||||
const activeParticleEdgeIds = new Set(state.particles.map((particle) => particle.edgeId));
|
||||
const visibleEdges = getVisibleEdges(state.edges, visibleNodeIds, activeParticleEdgeIds);
|
||||
|
||||
// 4. Draw canvas imperatively (NO React re-render)
|
||||
canvasHandle.current?.draw({
|
||||
|
|
|
|||
|
|
@ -479,6 +479,9 @@ importers:
|
|||
'@mdi/js':
|
||||
specifier: ^7.4.47
|
||||
version: 7.4.47
|
||||
'@mux/mux-video':
|
||||
specifier: ^0.31.0
|
||||
version: 0.31.0
|
||||
'@nuxtjs/i18n':
|
||||
specifier: ^9.5.6
|
||||
version: 9.5.6(@vue/compiler-dom@3.5.30)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(rollup@4.60.0)(vue@3.5.30(typescript@5.9.3))
|
||||
|
|
@ -2015,6 +2018,15 @@ packages:
|
|||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@mux/mux-data-google-ima@0.3.17':
|
||||
resolution: {integrity: sha512-4wpH6dYybyZhqLn9qGn/+67Z8MZnQRAdqTFEEZw2bx61M9q01uPYYHxd8qwOnYtUGEeafsdTwVHVxKHGD3oc1A==}
|
||||
|
||||
'@mux/mux-video@0.31.0':
|
||||
resolution: {integrity: sha512-DvO2GynIJhPDc0LMuWvC144lCF+E07NI+chrg/vcRQlCf2fPdNNqCSMoaRdWu2S2v/Orsc85giT9K6GRbjbzgA==}
|
||||
|
||||
'@mux/playback-core@0.35.0':
|
||||
resolution: {integrity: sha512-7Zi1EJ9sQNIUlQVBjJCXV0CB+rUVEsU3vNRElRV4xnD7dbpoioIAhe1SjZNBifjnK5aBKLDwQHypbnL3Cw3a5A==}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
|
|
@ -5552,6 +5564,9 @@ packages:
|
|||
caniuse-lite@1.0.30001792:
|
||||
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
|
||||
|
||||
castable-video@1.1.16:
|
||||
resolution: {integrity: sha512-wBhe2dZu2afhewL3EaGgVYTyDsa9HvNhY98clMZkNzDrLelOValSrTaoMos9YX7PPBCrgpd1j6YmNyyI2Vbq3w==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
|
|
@ -5904,6 +5919,9 @@ packages:
|
|||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
custom-media-element@1.4.6:
|
||||
resolution: {integrity: sha512-/HRYqJOa1ob5ik4q7FIJVYxTJCFs/FL3+cQPAJjUf2uiqrDEzbTgB315gQ2rG8oK3w094W9m5tcB8S5Qah+caA==}
|
||||
|
||||
cytoscape-cose-bilkent@4.1.0:
|
||||
resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -7274,6 +7292,9 @@ packages:
|
|||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
hls.js@1.6.16:
|
||||
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
|
||||
|
||||
hono@4.12.5:
|
||||
resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
|
@ -8129,6 +8150,9 @@ packages:
|
|||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
media-tracks@0.3.5:
|
||||
resolution: {integrity: sha512-l54rkKXlLBt3ob3zOLWHcnjvwUmX5bNEZ70igyapOZZC9imzqBmq1oz8p2roiV04KhjblFIi2hetLPF1oYVLRA==}
|
||||
|
||||
media-typer@1.1.0:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -8406,6 +8430,9 @@ packages:
|
|||
multimath@2.0.0:
|
||||
resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==}
|
||||
|
||||
mux-embed@5.18.1:
|
||||
resolution: {integrity: sha512-ePsHjiEKY+FgrSBiMmaF+LOtTQSSBWv/1zqpREQFN96JE93xlsArT/MEi30yKOE06MgjOlL70YI750molu3y7g==}
|
||||
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
|
|
@ -12772,6 +12799,23 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@mux/mux-data-google-ima@0.3.17':
|
||||
dependencies:
|
||||
mux-embed: 5.18.1
|
||||
|
||||
'@mux/mux-video@0.31.0':
|
||||
dependencies:
|
||||
'@mux/mux-data-google-ima': 0.3.17
|
||||
'@mux/playback-core': 0.35.0
|
||||
castable-video: 1.1.16
|
||||
custom-media-element: 1.4.6
|
||||
media-tracks: 0.3.5
|
||||
|
||||
'@mux/playback-core@0.35.0':
|
||||
dependencies:
|
||||
hls.js: 1.6.16
|
||||
mux-embed: 5.18.1
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.8.1
|
||||
|
|
@ -16590,6 +16634,10 @@ snapshots:
|
|||
|
||||
caniuse-lite@1.0.30001792: {}
|
||||
|
||||
castable-video@1.1.16:
|
||||
dependencies:
|
||||
custom-media-element: 1.4.6
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chai@5.3.3:
|
||||
|
|
@ -16939,6 +16987,8 @@ snapshots:
|
|||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
custom-media-element@1.4.6: {}
|
||||
|
||||
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1):
|
||||
dependencies:
|
||||
cose-base: 1.0.3
|
||||
|
|
@ -18805,6 +18855,8 @@ snapshots:
|
|||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
hls.js@1.6.16: {}
|
||||
|
||||
hono@4.12.5: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
|
@ -19816,6 +19868,8 @@ snapshots:
|
|||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
media-tracks@0.3.5: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
medium-zoom@1.1.0: {}
|
||||
|
|
@ -20189,6 +20243,8 @@ snapshots:
|
|||
glur: 1.1.2
|
||||
object-assign: 4.1.1
|
||||
|
||||
mux-embed@5.18.1: {}
|
||||
|
||||
mz@2.7.0:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Fix chrome-sandbox permissions for SUID sandbox on Linux
|
||||
# See: https://github.com/electron/electron/issues/17972
|
||||
|
||||
SANDBOX_PATH="/opt/${productFilename}/chrome-sandbox"
|
||||
SANDBOX_PATH="/opt/${sanitizedProductName}/chrome-sandbox"
|
||||
|
||||
if [ -f "$SANDBOX_PATH" ]; then
|
||||
chown root:root "$SANDBOX_PATH"
|
||||
|
|
|
|||
18
resources/linux/bin/agent-teams-ai
Executable file
18
resources/linux/bin/agent-teams-ai
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "${AGENT_TEAMS_AI_BIN:-}" != "" ]; then
|
||||
exec "$AGENT_TEAMS_AI_BIN" "$@"
|
||||
fi
|
||||
|
||||
for executable in \
|
||||
"/opt/Agent-Teams-AI/agent-teams-ai" \
|
||||
"/opt/Agent Teams AI/agent-teams-ai"
|
||||
do
|
||||
if [ -x "$executable" ]; then
|
||||
exec "$executable" "$@"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "agent-teams-ai: installed app executable was not found under /opt/Agent-Teams-AI" >&2
|
||||
exit 127
|
||||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.44",
|
||||
"sourceRef": "v0.0.44",
|
||||
"version": "0.0.45",
|
||||
"sourceRef": "v0.0.45",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/agent-teams-ai",
|
||||
"releaseTag": "v2.0.0",
|
||||
"releaseTag": "v2.1.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.44.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.45.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.44.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.45.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.44.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.45.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.44.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.45.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
169
scripts/electron-builder/verifyLinuxInstallers.cjs
Normal file
169
scripts/electron-builder/verifyLinuxInstallers.cjs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env node
|
||||
/* global Buffer, console, module, process, require */
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[verifyLinuxInstallers] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(command, args, input) {
|
||||
const result = spawnSync(command, args, {
|
||||
input,
|
||||
encoding: input ? undefined : 'utf8',
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const stderr = Buffer.isBuffer(result.stderr)
|
||||
? result.stderr.toString('utf8')
|
||||
: result.stderr || '';
|
||||
throw new Error(`${command} ${args.join(' ')} failed: ${stderr}`);
|
||||
}
|
||||
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout || '', 'utf8');
|
||||
}
|
||||
|
||||
function readArMember(archivePath, memberName) {
|
||||
return execFileSync('ar', ['p', archivePath, memberName], {
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
function getTarCompressionFlag(memberName) {
|
||||
if (memberName.endsWith('.tar.xz')) return 'J';
|
||||
if (memberName.endsWith('.tar.gz')) return 'z';
|
||||
if (memberName.endsWith('.tar.bz2')) return 'j';
|
||||
fail(`Unsupported deb tar member compression: ${memberName}`);
|
||||
}
|
||||
|
||||
function getDebMember(archivePath, prefix) {
|
||||
const members = execFileSync('ar', ['t', archivePath], { encoding: 'utf8' })
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean);
|
||||
const member = members.find((entry) => entry.startsWith(prefix));
|
||||
if (!member) {
|
||||
fail(`Missing ${prefix} member in ${archivePath}`);
|
||||
}
|
||||
return member;
|
||||
}
|
||||
|
||||
function runDebTar(archivePath, memberName, tarMode, filePath) {
|
||||
const args = filePath
|
||||
? [
|
||||
'-c',
|
||||
'set -euo pipefail; ar p "$1" "$2" | tar "$3" - "$4"',
|
||||
'bash',
|
||||
archivePath,
|
||||
memberName,
|
||||
tarMode,
|
||||
filePath,
|
||||
]
|
||||
: [
|
||||
'-c',
|
||||
'set -euo pipefail; ar p "$1" "$2" | tar "$3" -',
|
||||
'bash',
|
||||
archivePath,
|
||||
memberName,
|
||||
tarMode,
|
||||
];
|
||||
return execFileSync('bash', args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
function listDebTar(archivePath, memberName, verbose = false) {
|
||||
const flag = getTarCompressionFlag(memberName);
|
||||
const mode = verbose ? `-tv${flag}f` : `-t${flag}f`;
|
||||
return runDebTar(archivePath, memberName, mode);
|
||||
}
|
||||
|
||||
function extractDebTarFile(archivePath, memberName, filePath) {
|
||||
const flag = getTarCompressionFlag(memberName);
|
||||
return runDebTar(archivePath, memberName, `-xO${flag}f`, filePath);
|
||||
}
|
||||
|
||||
function listTar(tarBuffer, memberName, verbose = false) {
|
||||
const flag = getTarCompressionFlag(memberName);
|
||||
const mode = verbose ? `-tv${flag}f` : `-t${flag}f`;
|
||||
return run('tar', [mode, '-'], tarBuffer).toString('utf8');
|
||||
}
|
||||
|
||||
function extractTarFile(tarBuffer, memberName, filePath) {
|
||||
const flag = getTarCompressionFlag(memberName);
|
||||
return run('tar', [`-xO${flag}f`, '-', filePath], tarBuffer).toString('utf8');
|
||||
}
|
||||
|
||||
function assertContains(haystack, needle, label) {
|
||||
if (!haystack.includes(needle)) {
|
||||
fail(`${label} does not contain ${needle}`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyDeb(debPath) {
|
||||
const dataMember = getDebMember(debPath, 'data.tar.');
|
||||
const controlMember = getDebMember(debPath, 'control.tar.');
|
||||
const control = readArMember(debPath, controlMember);
|
||||
const dataList = listDebTar(debPath, dataMember);
|
||||
const dataVerboseList = listDebTar(debPath, dataMember, true);
|
||||
|
||||
assertContains(dataList, './usr/bin/agent-teams-ai\n', path.basename(debPath));
|
||||
assertContains(dataList, './opt/Agent-Teams-AI/agent-teams-ai\n', path.basename(debPath));
|
||||
assertContains(dataList, './opt/Agent-Teams-AI/chrome-sandbox\n', path.basename(debPath));
|
||||
|
||||
const launcherLine = dataVerboseList
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.endsWith('./usr/bin/agent-teams-ai'));
|
||||
if (!launcherLine || !launcherLine.startsWith('-rwx')) {
|
||||
fail(`/usr/bin/agent-teams-ai is not executable in ${debPath}`);
|
||||
}
|
||||
|
||||
const launcher = extractDebTarFile(debPath, dataMember, './usr/bin/agent-teams-ai');
|
||||
assertContains(launcher, '/opt/Agent-Teams-AI/agent-teams-ai', 'CLI launcher');
|
||||
if (launcher.includes('--no-sandbox')) {
|
||||
fail('CLI launcher must not force --no-sandbox');
|
||||
}
|
||||
|
||||
const postinst = extractTarFile(control, controlMember, './postinst');
|
||||
assertContains(postinst, 'SANDBOX_PATH="/opt/Agent-Teams-AI/chrome-sandbox"', 'deb postinst');
|
||||
assertContains(postinst, 'chmod 4755 "$SANDBOX_PATH"', 'deb postinst');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const releaseDir = path.resolve(process.argv[2] || 'release');
|
||||
if (!fs.existsSync(releaseDir)) {
|
||||
fail(`Release directory does not exist: ${releaseDir}`);
|
||||
}
|
||||
|
||||
const debs = fs
|
||||
.readdirSync(releaseDir)
|
||||
.filter((entry) => entry.endsWith('.deb'))
|
||||
.map((entry) => path.join(releaseDir, entry));
|
||||
if (debs.length === 0) {
|
||||
fail(`No .deb packages found in ${releaseDir}`);
|
||||
}
|
||||
|
||||
for (const deb of debs) {
|
||||
verifyDeb(deb);
|
||||
console.log(`[verifyLinuxInstallers] OK ${path.basename(deb)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
_internal: {
|
||||
extractTarFile,
|
||||
getDebMember,
|
||||
listTar,
|
||||
verifyDeb,
|
||||
},
|
||||
};
|
||||
|
|
@ -999,6 +999,7 @@ export class TeamGraphAdapter {
|
|||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): void {
|
||||
const ordered = [...messages].reverse();
|
||||
TeamGraphAdapter.#ensureMessageEdges(messages, leadId, leadName, edges, memberNodeIdByAlias);
|
||||
|
||||
// First call: record all existing message IDs without creating particles.
|
||||
// This prevents old messages from spawning particles when the graph opens.
|
||||
|
|
@ -1079,24 +1080,21 @@ export class TeamGraphAdapter {
|
|||
continue;
|
||||
}
|
||||
|
||||
const edgeId = TeamGraphAdapter.#resolveMessageEdge(
|
||||
const edge = TeamGraphAdapter.#resolveMessageEdge(
|
||||
msg,
|
||||
leadId,
|
||||
leadName,
|
||||
edges,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
if (!edgeId) continue;
|
||||
if (!edge) continue;
|
||||
|
||||
// Determine direction: messages FROM a teammate TO lead should reverse
|
||||
// (edges are always lead→member, but message goes member→lead)
|
||||
const fromId = TeamGraphAdapter.#resolveParticipantId(
|
||||
msg.from ?? '',
|
||||
leadId,
|
||||
leadName,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
const isFromTeammate = fromId !== leadId;
|
||||
|
||||
const particleLabel =
|
||||
getIdleGraphLabel(msgText) ??
|
||||
|
|
@ -1104,7 +1102,7 @@ export class TeamGraphAdapter {
|
|||
|
||||
particles.push({
|
||||
id: `particle:msg:${teamName}:${msgKey}`,
|
||||
edgeId,
|
||||
edgeId: edge.id,
|
||||
progress: 0,
|
||||
kind: 'inbox_message',
|
||||
color: msg.color ?? '#66ccff',
|
||||
|
|
@ -1112,7 +1110,7 @@ export class TeamGraphAdapter {
|
|||
preview:
|
||||
getIdleGraphLabel(msgText) ??
|
||||
TeamGraphAdapter.#buildParticlePreview(msg.summary ?? msg.text),
|
||||
reverse: isFromTeammate,
|
||||
reverse: edge.source !== fromId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1128,6 +1126,26 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
static #ensureMessageEdges(
|
||||
messages: readonly InboxMessage[],
|
||||
leadId: string,
|
||||
leadName: string,
|
||||
edges: GraphEdge[],
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): void {
|
||||
for (const msg of messages) {
|
||||
if (!msg.from || !msg.to) continue;
|
||||
if (msg.summary?.startsWith('Comment on ')) continue;
|
||||
if (msg.source === 'cross_team' || msg.source === 'cross_team_sent') continue;
|
||||
|
||||
const msgText = msg.text ?? '';
|
||||
const idleSemantic = classifyIdleNotificationText(msgText);
|
||||
if (!idleSemantic && isInboxNoiseMessage(msgText)) continue;
|
||||
|
||||
TeamGraphAdapter.#resolveMessageEdge(msg, leadId, leadName, edges, memberNodeIdByAlias);
|
||||
}
|
||||
}
|
||||
|
||||
#buildCommentParticles(
|
||||
particles: GraphParticle[],
|
||||
data: TeamGraphData,
|
||||
|
|
@ -1137,6 +1155,15 @@ export class TeamGraphAdapter {
|
|||
edges: GraphEdge[],
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): void {
|
||||
TeamGraphAdapter.#ensureTaskCommentEdges(
|
||||
data,
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
edges,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
|
||||
// First call: record current comment counts without creating particles.
|
||||
// This prevents pre-existing comments from spawning particles when the graph opens.
|
||||
if (!this.#initialCommentsSeen) {
|
||||
|
|
@ -1178,35 +1205,26 @@ export class TeamGraphAdapter {
|
|||
leadName,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
const taskNodeId = `task:${teamName}:${task.id}`;
|
||||
const authorEdge =
|
||||
edges.find((e) => e.source === authorNodeId && e.target === taskNodeId) ??
|
||||
edges.find((e) => e.source === taskNodeId && e.target === authorNodeId);
|
||||
const edge = TeamGraphAdapter.#resolveTaskCommentEdge(
|
||||
task,
|
||||
newComment.author,
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
edges,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
|
||||
const edgeId =
|
||||
authorEdge?.id ??
|
||||
(() => {
|
||||
const syntheticEdgeId = `edge:msg:${authorNodeId}:${taskNodeId}`;
|
||||
if (!edges.some((edge) => edge.id === syntheticEdgeId)) {
|
||||
edges.push({
|
||||
id: syntheticEdgeId,
|
||||
source: authorNodeId,
|
||||
target: taskNodeId,
|
||||
type: 'message',
|
||||
});
|
||||
}
|
||||
return syntheticEdgeId;
|
||||
})();
|
||||
|
||||
if (authorNodeId) {
|
||||
if (edge) {
|
||||
particles.push({
|
||||
id: `particle:comment:${teamName}:${task.id}:${index + 1}`,
|
||||
edgeId,
|
||||
edgeId: edge.id,
|
||||
progress: 0,
|
||||
kind: 'task_comment',
|
||||
color: memberColors.get(newComment.author) ?? '#cc88ff',
|
||||
label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'),
|
||||
preview: TeamGraphAdapter.#buildParticlePreview(newComment.text),
|
||||
reverse: edge.source !== authorNodeId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1216,6 +1234,31 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
static #ensureTaskCommentEdges(
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
leadId: string,
|
||||
leadName: string,
|
||||
edges: GraphEdge[],
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): void {
|
||||
for (const task of data.tasks) {
|
||||
if (task.status === 'deleted') continue;
|
||||
for (const comment of task.comments ?? []) {
|
||||
if (comment.type !== 'regular') continue;
|
||||
TeamGraphAdapter.#resolveTaskCommentEdge(
|
||||
task,
|
||||
comment.author,
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
edges,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Static mappers ──────────────────────────────────────────────────────
|
||||
|
||||
static #buildBlockingEdgeId(sourceNodeId: string, targetNodeId: string): string {
|
||||
|
|
@ -1313,7 +1356,7 @@ export class TeamGraphAdapter {
|
|||
leadName: string,
|
||||
edges: GraphEdge[],
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): string | null {
|
||||
): GraphEdge | null {
|
||||
const { from, to } = msg;
|
||||
|
||||
if (from && to) {
|
||||
|
|
@ -1329,11 +1372,7 @@ export class TeamGraphAdapter {
|
|||
leadName,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
return (
|
||||
edges.find((e) => e.source === fromId && e.target === toId)?.id ??
|
||||
edges.find((e) => e.source === toId && e.target === fromId)?.id ??
|
||||
null
|
||||
);
|
||||
return TeamGraphAdapter.#resolveNodePairMessageEdge(fromId, toId, edges);
|
||||
}
|
||||
|
||||
if (from && !to) {
|
||||
|
|
@ -1348,13 +1387,83 @@ export class TeamGraphAdapter {
|
|||
(e) =>
|
||||
(e.source === leadId && e.target === fromId) ||
|
||||
(e.source === fromId && e.target === leadId)
|
||||
)?.id ?? null
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static #resolveTaskCommentEdge(
|
||||
task: TeamGraphData['tasks'][number],
|
||||
authorName: string,
|
||||
teamName: string,
|
||||
leadId: string,
|
||||
leadName: string,
|
||||
edges: GraphEdge[],
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): GraphEdge | null {
|
||||
const authorNodeId = TeamGraphAdapter.#resolveParticipantId(
|
||||
authorName,
|
||||
leadId,
|
||||
leadName,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
const ownerNodeId = TeamGraphAdapter.#resolveTaskOwnerId(
|
||||
task.owner,
|
||||
leadId,
|
||||
leadName,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
if (ownerNodeId && ownerNodeId !== authorNodeId) {
|
||||
return TeamGraphAdapter.#resolveNodePairMessageEdge(authorNodeId, ownerNodeId, edges);
|
||||
}
|
||||
|
||||
const taskNodeId = `task:${teamName}:${task.id}`;
|
||||
const authorEdge =
|
||||
edges.find((e) => e.source === authorNodeId && e.target === taskNodeId) ??
|
||||
edges.find((e) => e.source === taskNodeId && e.target === authorNodeId);
|
||||
if (authorEdge) {
|
||||
return authorEdge;
|
||||
}
|
||||
|
||||
const syntheticEdge: GraphEdge = {
|
||||
id: `edge:msg:${authorNodeId}:${taskNodeId}`,
|
||||
source: authorNodeId,
|
||||
target: taskNodeId,
|
||||
type: 'message',
|
||||
targetTaskIds: [task.id],
|
||||
};
|
||||
edges.push(syntheticEdge);
|
||||
return syntheticEdge;
|
||||
}
|
||||
|
||||
static #resolveNodePairMessageEdge(
|
||||
fromId: string,
|
||||
toId: string,
|
||||
edges: GraphEdge[]
|
||||
): GraphEdge | null {
|
||||
const existingEdge =
|
||||
edges.find((e) => e.source === fromId && e.target === toId) ??
|
||||
edges.find((e) => e.source === toId && e.target === fromId);
|
||||
if (existingEdge) {
|
||||
return existingEdge;
|
||||
}
|
||||
if (fromId === toId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [sourceId, targetId] = fromId.localeCompare(toId) <= 0 ? [fromId, toId] : [toId, fromId];
|
||||
const syntheticEdge: GraphEdge = {
|
||||
id: `edge:msg:${sourceId}:${targetId}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
type: 'message',
|
||||
};
|
||||
edges.push(syntheticEdge);
|
||||
return syntheticEdge;
|
||||
}
|
||||
|
||||
static #resolveParticipantId(
|
||||
name: string,
|
||||
leadId: string,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ interface GraphActivityHudProps {
|
|||
getViewportSize?: () => { width: number; height: number };
|
||||
focusNodeIds: ReadonlySet<string> | null;
|
||||
enabled?: boolean;
|
||||
showConnectors?: boolean;
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
onOpenMemberProfile?: (
|
||||
memberName: string,
|
||||
|
|
@ -72,6 +73,7 @@ export const GraphActivityHud = ({
|
|||
getViewportSize,
|
||||
focusNodeIds,
|
||||
enabled = true,
|
||||
showConnectors = true,
|
||||
onOpenTaskDetail,
|
||||
onOpenMemberProfile,
|
||||
}: GraphActivityHudProps): React.JSX.Element | null => {
|
||||
|
|
@ -87,8 +89,8 @@ export const GraphActivityHud = ({
|
|||
);
|
||||
const { teamData, teams } = useGraphActivityContext(teamName);
|
||||
const teamSnapshot = teamData;
|
||||
const members = teamData?.members ?? [];
|
||||
const messages = teamData?.messageFeed ?? [];
|
||||
const members = useMemo(() => teamData?.members ?? [], [teamData?.members]);
|
||||
const messages = useMemo(() => teamData?.messageFeed ?? [], [teamData?.messageFeed]);
|
||||
|
||||
const ownerNodes = useMemo(
|
||||
() =>
|
||||
|
|
@ -134,11 +136,12 @@ export const GraphActivityHud = ({
|
|||
}, [teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = highlightTimersRef.current;
|
||||
return () => {
|
||||
for (const timer of highlightTimersRef.current.values()) {
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
highlightTimersRef.current.clear();
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -515,24 +518,27 @@ export const GraphActivityHud = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
ref={(element) => {
|
||||
connectorRefs.current.set(lane.node.id, element);
|
||||
}}
|
||||
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
|
||||
>
|
||||
<path
|
||||
{showConnectors ? (
|
||||
<svg
|
||||
ref={(element) => {
|
||||
connectorPathRefs.current.set(lane.node.id, element);
|
||||
connectorRefs.current.set(lane.node.id, element);
|
||||
}}
|
||||
d=""
|
||||
fill="none"
|
||||
stroke="rgba(148, 163, 184, 0.3)"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="3 4"
|
||||
/>
|
||||
</svg>
|
||||
data-activity-connector={lane.node.id}
|
||||
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
|
||||
>
|
||||
<path
|
||||
ref={(element) => {
|
||||
connectorPathRefs.current.set(lane.node.id, element);
|
||||
}}
|
||||
d=""
|
||||
fill="none"
|
||||
stroke="rgba(148, 163, 184, 0.3)"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="3 4"
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
<div
|
||||
ref={(element) => {
|
||||
shellRefs.current.set(lane.node.id, element);
|
||||
|
|
@ -543,9 +549,6 @@ export const GraphActivityHud = ({
|
|||
maxWidth: `${laneWidth}px`,
|
||||
height: `${laneHeight}px`,
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
||||
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export const TeamGraphOverlay = ({
|
|||
getViewportSize={getViewportSize}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={filters?.showActivity ?? true}
|
||||
showConnectors={filters?.showEdges ?? false}
|
||||
onOpenTaskDetail={interactions.openTaskDetail}
|
||||
onOpenMemberProfile={interactions.openMemberProfile}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export const TeamGraphTab = ({
|
|||
getViewportSize={getViewportSize}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={isActive && (filters?.showActivity ?? true)}
|
||||
showConnectors={filters?.showEdges ?? false}
|
||||
onOpenTaskDetail={interactions.openTaskDetail}
|
||||
onOpenMemberProfile={interactions.openMemberProfile}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ const SNAPSHOT_CACHE_TTL_MS = 5_000;
|
|||
const RATE_LIMITS_CACHE_TTL_MS = 45_000;
|
||||
const LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS = 60_000;
|
||||
const CODEX_BINARY_COLD_RETRY_TIMEOUT_MS = 12_000;
|
||||
const CODEX_CLI_NOT_FOUND_MESSAGE =
|
||||
'Codex CLI not found. Install Codex to use native account management.';
|
||||
|
||||
interface CodexLastKnownAccount {
|
||||
payload: CodexAppServerGetAccountResponse;
|
||||
|
|
@ -261,6 +263,7 @@ async function resolveCodexBinaryForAccountSnapshot(): Promise<string | null> {
|
|||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: CODEX_BINARY_COLD_RETRY_TIMEOUT_MS,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
CodexBinaryResolver.clearCache();
|
||||
return CodexBinaryResolver.resolve();
|
||||
|
|
@ -487,15 +490,48 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
const now = Date.now();
|
||||
|
||||
if (!binaryPath) {
|
||||
const freshRuntimeContext = this.getFreshLastKnownRuntimeContext(now);
|
||||
if (freshRuntimeContext) {
|
||||
const freshAccountPayload = this.getFreshLastKnownAccount(now);
|
||||
const accountPayload = freshAccountPayload ?? null;
|
||||
const managedAccount = asCodexManagedAccount(accountPayload?.account ?? null);
|
||||
const readiness = evaluateCodexLaunchReadiness({
|
||||
preferredAuthMode,
|
||||
managedAccount,
|
||||
apiKey,
|
||||
appServerState: 'healthy',
|
||||
appServerStatusMessage: null,
|
||||
localActiveChatgptAccountPresent,
|
||||
});
|
||||
const snapshot = this.setSnapshot({
|
||||
preferredAuthMode,
|
||||
effectiveAuthMode: readiness.effectiveAuthMode,
|
||||
launchAllowed: readiness.launchAllowed,
|
||||
launchIssueMessage: readiness.issueMessage,
|
||||
launchReadinessState: readiness.state,
|
||||
appServerState: 'healthy',
|
||||
appServerStatusMessage: null,
|
||||
managedAccount,
|
||||
apiKey,
|
||||
requiresOpenaiAuth: accountPayload?.requiresOpenaiAuth ?? null,
|
||||
localAccountArtifactsPresent,
|
||||
localActiveChatgptAccountPresent,
|
||||
runtimeContext: freshRuntimeContext,
|
||||
login,
|
||||
rateLimits: this.snapshotCache?.rateLimits ?? null,
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const snapshot = this.setSnapshot({
|
||||
preferredAuthMode,
|
||||
effectiveAuthMode: null,
|
||||
launchAllowed: false,
|
||||
launchIssueMessage: 'Codex CLI not found. Install Codex to use native account management.',
|
||||
launchIssueMessage: CODEX_CLI_NOT_FOUND_MESSAGE,
|
||||
launchReadinessState: 'runtime_missing',
|
||||
appServerState: 'runtime-missing',
|
||||
appServerStatusMessage:
|
||||
'Codex CLI not found. Install Codex to use native account management.',
|
||||
appServerStatusMessage: CODEX_CLI_NOT_FOUND_MESSAGE,
|
||||
managedAccount: null,
|
||||
apiKey,
|
||||
requiresOpenaiAuth: null,
|
||||
|
|
@ -521,7 +557,15 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
let appServerStatusMessage: string | null = null;
|
||||
let accountPayload = this.lastKnownAccount?.payload ?? null;
|
||||
let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null;
|
||||
let runtimeContext = createRuntimeContext(binaryPath, null);
|
||||
const previousRuntimeContext = this.getFreshLastKnownRuntimeContext(now);
|
||||
let runtimeContext = createRuntimeContext(
|
||||
binaryPath,
|
||||
previousRuntimeContext?.binaryPath === binaryPath ? previousRuntimeContext.codexHome : null
|
||||
);
|
||||
this.lastKnownRuntimeContext = {
|
||||
payload: runtimeContext,
|
||||
observedAt: now,
|
||||
};
|
||||
const cachedRateLimitsAreFresh = this.hasFreshRateLimits(now);
|
||||
const shouldRequestRateLimits =
|
||||
options?.includeRateLimits === true && !cachedRateLimitsAreFresh;
|
||||
|
|
@ -706,6 +750,29 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
);
|
||||
}
|
||||
|
||||
private getFreshLastKnownRuntimeContext(now: number): CodexRuntimeContext | null {
|
||||
if (
|
||||
!this.lastKnownRuntimeContext ||
|
||||
now - this.lastKnownRuntimeContext.observedAt > LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS ||
|
||||
!this.lastKnownRuntimeContext.payload.binaryPath
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.lastKnownRuntimeContext.payload;
|
||||
}
|
||||
|
||||
private getFreshLastKnownAccount(now: number): CodexAppServerGetAccountResponse | null {
|
||||
if (
|
||||
!this.lastKnownAccount ||
|
||||
now - this.lastKnownAccount.observedAt > LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.lastKnownAccount.payload;
|
||||
}
|
||||
|
||||
private async emitCurrentSnapshot(): Promise<CodexAccountSnapshotDto> {
|
||||
if (!this.snapshotCache) {
|
||||
return this.refreshSnapshot();
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { CODEX_RUNTIME_PROGRESS } from '@features/codex-runtime-installer/contracts';
|
||||
import { execCli } from '@main/utils/childProcess';
|
||||
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
||||
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||
import {
|
||||
findFirstRuntimePathBinaryCandidate,
|
||||
isAbsoluteExistingFile,
|
||||
RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
||||
} from '@main/utils/runtimePathBinaryResolver';
|
||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { existsSync, promises as fsp, readFileSync, statSync } from 'fs';
|
||||
import { promises as fsp, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { gunzipSync } from 'zlib';
|
||||
|
||||
|
|
@ -28,7 +32,6 @@ const MAX_TARBALL_BYTES = 160 * 1024 * 1024;
|
|||
const MAX_UNPACKED_BYTES = 650 * 1024 * 1024;
|
||||
const FETCH_TIMEOUT_MS = 60_000;
|
||||
const VERSION_TIMEOUT_MS = 10_000;
|
||||
const PATH_SHELL_ENV_TIMEOUT_MS = 1_500;
|
||||
|
||||
interface NpmPackageMetadata {
|
||||
name?: string;
|
||||
|
|
@ -71,17 +74,6 @@ function getCurrentManifestPath(): string {
|
|||
return path.join(getRuntimeRootPath(), 'current.json');
|
||||
}
|
||||
|
||||
function isAbsoluteExistingFile(filePath: string | null | undefined): filePath is string {
|
||||
if (!filePath || !path.isAbsolute(filePath) || !existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseManifest(value: unknown): CodexRuntimeManifest | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
|
|
@ -141,41 +133,13 @@ function getPathExecutableNames(): string[] {
|
|||
: ['codex'];
|
||||
}
|
||||
|
||||
function splitPathEnv(pathValue: string | undefined): string[] {
|
||||
if (!pathValue) {
|
||||
return [];
|
||||
}
|
||||
return pathValue
|
||||
.split(path.delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolvePathCodexBinary(
|
||||
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
|
||||
): string | null {
|
||||
const shellEnv = getCachedShellEnv() ?? {};
|
||||
const pathEntries = [
|
||||
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
|
||||
...splitPathEnv(shellEnv.PATH),
|
||||
...splitPathEnv(buildMergedCliPath(null)),
|
||||
...splitPathEnv(process.env.PATH),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of pathEntries) {
|
||||
const normalizedEntry = path.resolve(entry);
|
||||
if (seen.has(normalizedEntry)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalizedEntry);
|
||||
for (const executableName of getPathExecutableNames()) {
|
||||
const candidate = path.join(normalizedEntry, executableName);
|
||||
if (isAbsoluteExistingFile(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return findFirstRuntimePathBinaryCandidate({
|
||||
executableNames: getPathExecutableNames(),
|
||||
additionalEnvSources,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolvePathCodexBinaryWithBestEffortEnv(
|
||||
|
|
@ -187,8 +151,9 @@ async function resolvePathCodexBinaryWithBestEffortEnv(
|
|||
}
|
||||
|
||||
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
|
||||
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
return resolvePathCodexBinary([shellEnv]);
|
||||
}
|
||||
|
|
@ -387,7 +352,11 @@ export function extractCodexRuntimePackageFilesFromTarball(
|
|||
): CodexRuntimePackageFile[] {
|
||||
const tar = gunzipSync(tarball, { maxOutputLength: MAX_UNPACKED_BYTES });
|
||||
const targetPrefix = `package/vendor/${vendorTarget}/`;
|
||||
const targetBinaryName = `${targetPrefix}codex/${executableName}`;
|
||||
const targetBinaryNames = new Set(
|
||||
getCodexRuntimeBinaryRelativePathCandidates(executableName).map(
|
||||
(relativePath) => `${targetPrefix}${relativePath}`
|
||||
)
|
||||
);
|
||||
const files: CodexRuntimePackageFile[] = [];
|
||||
let foundBinary = false;
|
||||
let offset = 0;
|
||||
|
|
@ -418,7 +387,7 @@ export function extractCodexRuntimePackageFilesFromTarball(
|
|||
data: Buffer.from(tar.subarray(dataStart, dataEnd)),
|
||||
mode,
|
||||
});
|
||||
foundBinary = foundBinary || fullName === targetBinaryName;
|
||||
foundBinary = foundBinary || targetBinaryNames.has(fullName);
|
||||
}
|
||||
} else if (
|
||||
fullName.startsWith(targetPrefix) &&
|
||||
|
|
@ -433,11 +402,31 @@ export function extractCodexRuntimePackageFilesFromTarball(
|
|||
}
|
||||
|
||||
if (!foundBinary) {
|
||||
throw new Error(`Codex package did not contain ${targetBinaryName}`);
|
||||
throw new Error(
|
||||
`Codex package did not contain one of ${Array.from(targetBinaryNames).join(', ')}`
|
||||
);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function getCodexRuntimeBinaryRelativePathCandidates(executableName: string): string[] {
|
||||
return [`bin/${executableName}`, `codex/${executableName}`];
|
||||
}
|
||||
|
||||
function resolveCodexRuntimeBinaryRelativePath(
|
||||
files: readonly CodexRuntimePackageFile[],
|
||||
executableName = getExecutableName()
|
||||
): string {
|
||||
const filePaths = new Set(files.map((file) => file.relativePath));
|
||||
const binaryPath = getCodexRuntimeBinaryRelativePathCandidates(executableName).find((candidate) =>
|
||||
filePaths.has(candidate)
|
||||
);
|
||||
if (!binaryPath) {
|
||||
throw new Error(`Extracted Codex package is missing ${executableName}`);
|
||||
}
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
async function readCurrentManifest(): Promise<CodexRuntimeManifest | null> {
|
||||
try {
|
||||
const raw = await fsp.readFile(getCurrentManifestPath(), 'utf8');
|
||||
|
|
@ -627,6 +616,7 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort {
|
|||
|
||||
this.publishProgress({ phase: 'installing', detail: 'Extracting Codex runtime...' });
|
||||
const files = extractCodexRuntimePackageFilesFromTarball(tarball, selected.vendorTarget);
|
||||
const binaryRelativePath = resolveCodexRuntimeBinaryRelativePath(files);
|
||||
const runtimeRoot = getRuntimeRootPath();
|
||||
tempDir = path.join(runtimeRoot, `installing-${process.pid}-${randomUUID()}`);
|
||||
const versionDir = path.join(
|
||||
|
|
@ -635,14 +625,14 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort {
|
|||
platformMetadata.version!,
|
||||
selected.vendorTarget
|
||||
);
|
||||
const binaryPath = path.join(versionDir, 'codex', getExecutableName());
|
||||
const binaryPath = path.join(versionDir, binaryRelativePath);
|
||||
|
||||
await fsp.rm(tempDir, { recursive: true, force: true });
|
||||
await fsp.mkdir(tempDir, { recursive: true });
|
||||
await writePackageFiles(tempDir, files);
|
||||
|
||||
this.publishProgress({ phase: 'installing', detail: 'Verifying Codex binary...' });
|
||||
const tempBinaryPath = path.join(tempDir, 'codex', getExecutableName());
|
||||
const tempBinaryPath = path.join(tempDir, binaryRelativePath);
|
||||
const { stdout } = await execCli(tempBinaryPath, ['--version'], {
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import {
|
||||
isReviewPickupAgenda,
|
||||
isStrictReviewPickupItem,
|
||||
} from './MemberWorkSyncNudgeAgendaPredicates';
|
||||
import { isStrictReviewPickupItem } from './MemberWorkSyncNudgeAgendaPredicates';
|
||||
import {
|
||||
decideMemberWorkSyncTargetedRecovery,
|
||||
type MemberWorkSyncTargetedRecoveryReason,
|
||||
|
|
@ -187,15 +184,23 @@ export function decideMemberWorkSyncNudgeActivation(input: {
|
|||
return { active: true, reason: 'review_pickup_required' };
|
||||
}
|
||||
|
||||
const targetedRecovery = decideMemberWorkSyncTargetedRecovery(input.status);
|
||||
if (targetedRecovery.active) {
|
||||
return { active: true, reason: targetedRecovery.reason };
|
||||
}
|
||||
|
||||
if (isNativeStaleInProgressCandidate(input)) {
|
||||
return { active: true, reason: 'native_stale_in_progress' };
|
||||
}
|
||||
|
||||
const targetedRecovery = decideMemberWorkSyncTargetedRecovery(input.status);
|
||||
if (targetedRecovery.active) {
|
||||
if (targetedRecovery.reason !== 'native_targeted_shadow_collecting') {
|
||||
return { active: true, reason: targetedRecovery.reason };
|
||||
}
|
||||
if (hasBlockingMetrics(input.metrics)) {
|
||||
return { active: false, reason: 'blocking_metrics' };
|
||||
}
|
||||
if (input.metrics.phase2Readiness.state !== 'shadow_ready') {
|
||||
return { active: true, reason: targetedRecovery.reason };
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBlockingMetrics(input.metrics)) {
|
||||
return { active: false, reason: 'blocking_metrics' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ function getProofMissingRecoveryOriginalMessageId(item: MemberWorkSyncOutboxItem
|
|||
return originalMessageId.length > 0 ? originalMessageId : null;
|
||||
}
|
||||
|
||||
function isStatusOnlyRecoveryOutboxItem(item: MemberWorkSyncOutboxItem): boolean {
|
||||
return item.payload.workSyncIntentKey?.startsWith('status-only:') === true;
|
||||
}
|
||||
|
||||
function getPayloadReviewRequestEventIds(item: MemberWorkSyncOutboxItem): string[] {
|
||||
return [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])]
|
||||
.filter((id) => id.length > 0)
|
||||
|
|
@ -504,7 +508,10 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
workSyncIntentKey: item.payload.workSyncIntentKey,
|
||||
taskRefs: item.payload.taskRefs,
|
||||
});
|
||||
if (busy?.busy) {
|
||||
if (
|
||||
busy?.busy &&
|
||||
!(isStatusOnlyRecoveryOutboxItem(item) && busy.reason === 'recent_tool_activity')
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `member_busy:${busy.reason ?? 'unknown'}`,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { buildMemberWorkSyncOutboxEnsureInput } from '../domain';
|
||||
import {
|
||||
buildMemberWorkSyncNudgeId,
|
||||
buildMemberWorkSyncNudgePayloadHash,
|
||||
buildMemberWorkSyncOutboxEnsureInput,
|
||||
} from '../domain';
|
||||
|
||||
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
||||
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
|
||||
|
||||
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||
import type { MemberWorkSyncOutboxEnsureInput, MemberWorkSyncStatus } from '../../contracts';
|
||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
const STATUS_ONLY_RECOVERY_INTENT_PREFIX = 'status-only';
|
||||
|
||||
function getReviewRequestEventIds(status: MemberWorkSyncStatus): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
|
|
@ -33,6 +39,26 @@ function filterReviewPickupStatusByRequestIds(
|
|||
};
|
||||
}
|
||||
|
||||
function isTurnSettledReconcile(status: MemberWorkSyncStatus): boolean {
|
||||
return status.shadow?.triggerReasons?.includes('turn_settled') === true;
|
||||
}
|
||||
|
||||
function shouldPlanStatusOnlyRecovery(input: {
|
||||
status: MemberWorkSyncStatus;
|
||||
baseInput: MemberWorkSyncOutboxEnsureInput;
|
||||
existingItemStatus: string;
|
||||
}): boolean {
|
||||
return (
|
||||
input.status.state === 'needs_sync' &&
|
||||
input.status.shadow?.wouldNudge === true &&
|
||||
isTurnSettledReconcile(input.status) &&
|
||||
input.baseInput.payload.workSyncIntent === 'agenda_sync' &&
|
||||
input.baseInput.payload.workSyncIntentKey === undefined &&
|
||||
input.existingItemStatus === 'delivered' &&
|
||||
input.status.report?.accepted !== true
|
||||
);
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncNudgeOutboxPlanResult {
|
||||
planned: boolean;
|
||||
code:
|
||||
|
|
@ -52,6 +78,34 @@ export interface MemberWorkSyncNudgeOutboxPlanResult {
|
|||
export class MemberWorkSyncNudgeOutboxPlanner {
|
||||
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
|
||||
|
||||
private buildStatusOnlyRecoveryInput(
|
||||
status: MemberWorkSyncStatus,
|
||||
baseInput: MemberWorkSyncOutboxEnsureInput
|
||||
): MemberWorkSyncOutboxEnsureInput {
|
||||
const intentKey = `${STATUS_ONLY_RECOVERY_INTENT_PREFIX}:${status.agenda.fingerprint}`;
|
||||
const payload = {
|
||||
...baseInput.payload,
|
||||
workSyncIntentKey: intentKey,
|
||||
text: [
|
||||
'Status-only recovery: the previous work-sync turn appears to have stopped after member_work_sync_status without member_work_sync_report.',
|
||||
'You must now call member_work_sync_status again, then member_work_sync_report with the returned agendaFingerprint/reportToken.',
|
||||
baseInput.payload.text,
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
return {
|
||||
...baseInput,
|
||||
id: buildMemberWorkSyncNudgeId({
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
agendaFingerprint: status.agenda.fingerprint,
|
||||
intentKey,
|
||||
}),
|
||||
payload,
|
||||
payloadHash: buildMemberWorkSyncNudgePayloadHash(this.deps.hash, payload),
|
||||
};
|
||||
}
|
||||
|
||||
async plan(status: MemberWorkSyncStatus): Promise<MemberWorkSyncNudgeOutboxPlanResult> {
|
||||
if (!this.deps.outboxStore) {
|
||||
return { planned: false, code: 'outbox_unavailable' };
|
||||
|
|
@ -159,6 +213,35 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
|||
await this.appendPlanAudit(status, { planned: false, code });
|
||||
return { planned: false, code };
|
||||
}
|
||||
if (
|
||||
shouldPlanStatusOnlyRecovery({
|
||||
status,
|
||||
baseInput: input,
|
||||
existingItemStatus: result.item.status,
|
||||
})
|
||||
) {
|
||||
const recoveryInput = this.buildStatusOnlyRecoveryInput(status, input);
|
||||
const recoveryResult = await this.deps.outboxStore.ensurePending(recoveryInput);
|
||||
if (!recoveryResult.ok) {
|
||||
this.deps.logger?.warn('member work sync status-only recovery payload conflict', {
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
outboxId: recoveryInput.id,
|
||||
existingPayloadHash: recoveryResult.existingPayloadHash,
|
||||
requestedPayloadHash: recoveryResult.requestedPayloadHash,
|
||||
});
|
||||
await this.appendPlanAudit(status, { planned: false, code: 'payload_conflict' });
|
||||
return { planned: false, code: 'payload_conflict' };
|
||||
}
|
||||
|
||||
const recoveryPlanned = recoveryResult.item.status !== 'delivered';
|
||||
const recoveryPlanResult = {
|
||||
planned: recoveryPlanned,
|
||||
code: recoveryResult.outcome,
|
||||
} as const;
|
||||
await this.appendPlanAudit(status, recoveryPlanResult);
|
||||
return recoveryPlanResult;
|
||||
}
|
||||
if (
|
||||
input.payload.workSyncIntent === 'review_pickup' &&
|
||||
result.item.status === 'failed_terminal'
|
||||
|
|
|
|||
|
|
@ -134,9 +134,7 @@ export class MemberWorkSyncReconciler {
|
|||
});
|
||||
|
||||
await this.deps.statusStore.write(status);
|
||||
if ((context.reconciledBy ?? 'request') === 'queue') {
|
||||
await this.planNudgeOutbox(status);
|
||||
}
|
||||
await this.planNudgeOutbox(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import type { MemberWorkSyncStatus } from '../../contracts';
|
|||
|
||||
export type MemberWorkSyncTargetedRecoveryReason =
|
||||
| 'opencode_targeted_shadow_collecting'
|
||||
| 'lead_targeted_shadow_collecting';
|
||||
| 'lead_targeted_shadow_collecting'
|
||||
| 'native_targeted_shadow_collecting';
|
||||
|
||||
export type MemberWorkSyncTargetedRecoveryCapability =
|
||||
| 'opencode_runtime_delivery'
|
||||
| 'lead_inbox_relay';
|
||||
| 'lead_inbox_relay'
|
||||
| 'native_inbox_watch';
|
||||
|
||||
export type MemberWorkSyncTargetedRecoveryDecision =
|
||||
| {
|
||||
|
|
@ -31,6 +33,10 @@ function isLeadLikeMemberName(memberName: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function isNativeInboxWatchProvider(providerId: MemberWorkSyncStatus['providerId']): boolean {
|
||||
return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini';
|
||||
}
|
||||
|
||||
function resolveTargetedRecoveryCapability(status: MemberWorkSyncStatus): {
|
||||
capability: MemberWorkSyncTargetedRecoveryCapability;
|
||||
reason: MemberWorkSyncTargetedRecoveryReason;
|
||||
|
|
@ -49,6 +55,13 @@ function resolveTargetedRecoveryCapability(status: MemberWorkSyncStatus): {
|
|||
};
|
||||
}
|
||||
|
||||
if (isNativeInboxWatchProvider(status.providerId)) {
|
||||
return {
|
||||
capability: 'native_inbox_watch',
|
||||
reason: 'native_targeted_shadow_collecting',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ function buildReviewPickupNudgePayload(status: MemberWorkSyncStatus): MemberWork
|
|||
preview ? `Review agenda: ${preview}.` : '',
|
||||
'Open the task, verify the current reviewState/status, then start or continue the review only if it is still assigned to you.',
|
||||
`If you cannot pick it up now, call member_work_sync_status with teamName "${status.teamName}" and memberName "${status.memberName}", then report "blocked" or "still_working" only for the real current state.`,
|
||||
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
|
||||
'If your runtime exposes prefixed Agent Teams MCP tool names, use mcp__agent-teams__member_work_sync_status and mcp__agent-teams__member_work_sync_report.',
|
||||
'Do not mark the review complete from this prompt alone, and do not reply only with acknowledgement.',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
|
@ -169,6 +171,8 @@ export function buildMemberWorkSyncNudgePayload(
|
|||
...buildProofMissingRecoveryText(status),
|
||||
preview ? `Current agenda: ${preview}.` : '',
|
||||
`Required sync action: call member_work_sync_status with teamName "${status.teamName}" and memberName "${status.memberName}", then call member_work_sync_report with the same teamName/memberName and the returned agendaFingerprint and reportToken.`,
|
||||
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
|
||||
'If your runtime exposes prefixed Agent Teams MCP tool names, use mcp__agent-teams__member_work_sync_status and mcp__agent-teams__member_work_sync_report. Do not search the filesystem or list MCP resources before the first direct tool attempt.',
|
||||
taskIds.length
|
||||
? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
|
||||
: '',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import type { MemberWorkSyncInboxNudgePort } from '../../../core/application';
|
|||
export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudgePort {
|
||||
constructor(
|
||||
private readonly inboxReader: Pick<TeamInboxReader, 'getMessagesFor'> = new TeamInboxReader(),
|
||||
private readonly inboxWriter: Pick<TeamInboxWriter, 'sendMessage'> = new TeamInboxWriter()
|
||||
private readonly inboxWriter: Pick<TeamInboxWriter, 'sendMessage'> = new TeamInboxWriter(),
|
||||
private readonly controlUrlResolver?: () => Promise<string | null> | string | null
|
||||
) {}
|
||||
|
||||
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
|
||||
|
|
@ -19,13 +20,17 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
|
|||
return { inserted: false, messageId: input.messageId };
|
||||
}
|
||||
|
||||
const controlUrl = await this.resolveControlUrl();
|
||||
const text = controlUrl
|
||||
? this.withControlUrl(input.payload.text, controlUrl)
|
||||
: input.payload.text;
|
||||
const result = await this.inboxWriter.sendMessage(input.teamName, {
|
||||
member: input.memberName,
|
||||
from: input.payload.from,
|
||||
to: input.payload.to,
|
||||
messageId: input.messageId,
|
||||
timestamp: input.timestamp,
|
||||
text: input.payload.text,
|
||||
text,
|
||||
taskRefs: input.payload.taskRefs,
|
||||
actionMode: input.payload.actionMode,
|
||||
summary: 'Work sync check',
|
||||
|
|
@ -42,4 +47,28 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
|
|||
messageId: result.messageId,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveControlUrl(): Promise<string | null> {
|
||||
if (!this.controlUrlResolver) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await this.controlUrlResolver();
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private withControlUrl(text: string, controlUrl: string): string {
|
||||
if (text.includes('controlUrl')) {
|
||||
return text;
|
||||
}
|
||||
return [
|
||||
text,
|
||||
`Required control API: pass controlUrl "${controlUrl}" in both member_work_sync_status and member_work_sync_report.`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
extraBusySignals?: MemberWorkSyncBusySignalPort[];
|
||||
proofMissingRecoveryGuard?: MemberWorkSyncProofMissingRecoveryGuardPort;
|
||||
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
|
||||
resolveControlUrl?: () => Promise<string | null> | string | null;
|
||||
reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort;
|
||||
reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
|
|
@ -229,7 +230,11 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
busySignals.length === 1
|
||||
? toolActivityBusySignal
|
||||
: new CompositeMemberWorkSyncBusySignal(busySignals, deps.logger);
|
||||
const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink();
|
||||
const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink(
|
||||
undefined,
|
||||
undefined,
|
||||
deps.resolveControlUrl
|
||||
);
|
||||
const useCaseDeps = {
|
||||
clock,
|
||||
hash,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import path from 'node:path';
|
|||
|
||||
import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState';
|
||||
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
||||
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||
|
||||
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
|
||||
|
|
@ -18,12 +19,21 @@ import type { ServiceContext } from '@main/services';
|
|||
const CODEX_SESSION_FILE_PARSE_LIMIT = 500;
|
||||
const CODEX_PROJECT_CANDIDATE_LIMIT = 40;
|
||||
const CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS = 8_000;
|
||||
const CODEX_SESSION_FILE_SOFT_BUDGET_MS = 6_500;
|
||||
const CODEX_SESSION_FILE_MAX_UNCACHED_READS_PER_RUN = 160;
|
||||
const CODEX_SESSION_FILE_READ_BATCH_SIZE = 24;
|
||||
const CODEX_SESSION_FILE_READ_TIMEOUT_MS = 700;
|
||||
const CODEX_SESSION_METADATA_READ_LIMIT_BYTES = 128 * 1024;
|
||||
const CODEX_SESSION_FILE_CACHE_SCHEMA_VERSION = 1;
|
||||
const CODEX_SESSION_FILE_CACHE_RELATIVE_PATH = path.join(
|
||||
'recent-projects',
|
||||
'codex-session-files-index.json'
|
||||
);
|
||||
|
||||
interface CodexSessionFileEntry {
|
||||
filePath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface CodexSessionEvent {
|
||||
|
|
@ -53,6 +63,50 @@ interface CodexSessionMetadata {
|
|||
branchName?: string;
|
||||
}
|
||||
|
||||
interface CodexSessionFileCacheEntry {
|
||||
filePath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
snapshot: CodexSessionProjectSnapshot | null;
|
||||
}
|
||||
|
||||
interface CodexSessionFileCacheFile {
|
||||
schemaVersion: number;
|
||||
entries: Record<string, CodexSessionFileCacheEntry>;
|
||||
}
|
||||
|
||||
interface CodexSessionSnapshotLoadResult {
|
||||
snapshots: CodexSessionProjectSnapshot[];
|
||||
degraded: boolean;
|
||||
stats: {
|
||||
files: number;
|
||||
cached: number;
|
||||
uncachedReads: number;
|
||||
timedOutReads: number;
|
||||
skippedUncached: number;
|
||||
durationMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
function emptyCache(): CodexSessionFileCacheFile {
|
||||
return {
|
||||
schemaVersion: CODEX_SESSION_FILE_CACHE_SCHEMA_VERSION,
|
||||
entries: {},
|
||||
};
|
||||
}
|
||||
|
||||
function isUsableCacheEntry(
|
||||
entry: CodexSessionFileCacheEntry | undefined,
|
||||
file: CodexSessionFileEntry
|
||||
): entry is CodexSessionFileCacheEntry {
|
||||
return (
|
||||
!!entry &&
|
||||
entry.filePath === file.filePath &&
|
||||
entry.mtimeMs === file.mtimeMs &&
|
||||
entry.size === file.size
|
||||
);
|
||||
}
|
||||
|
||||
function isInteractiveSource(source: unknown): boolean {
|
||||
return source === 'vscode' || source === 'cli';
|
||||
}
|
||||
|
|
@ -118,6 +172,45 @@ async function readFirstLine(filePath: string): Promise<string | null> {
|
|||
}
|
||||
}
|
||||
|
||||
async function readFirstLineWithTimeout(
|
||||
filePath: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ firstLine: string | null; timedOut: boolean }> {
|
||||
if (timeoutMs <= 0) {
|
||||
return {
|
||||
firstLine: null,
|
||||
timedOut: true,
|
||||
};
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const readPromise = readFirstLine(filePath)
|
||||
.then((firstLine) => ({
|
||||
firstLine,
|
||||
timedOut: false,
|
||||
}))
|
||||
.catch(() => ({
|
||||
firstLine: null,
|
||||
timedOut: false,
|
||||
}));
|
||||
const timeoutPromise = new Promise<{ firstLine: null; timedOut: true }>((resolve) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
firstLine: null,
|
||||
timedOut: true,
|
||||
}),
|
||||
timeoutMs
|
||||
);
|
||||
});
|
||||
|
||||
const result = await Promise.race([readPromise, timeoutPromise]);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function listJsonlFiles(root: string, maxDepth: number): Promise<CodexSessionFileEntry[]> {
|
||||
async function walk(directory: string, depth: number): Promise<CodexSessionFileEntry[]> {
|
||||
let entries;
|
||||
|
|
@ -144,6 +237,7 @@ async function listJsonlFiles(root: string, maxDepth: number): Promise<CodexSess
|
|||
{
|
||||
filePath: entryPath,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
|
|
@ -199,6 +293,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
readonly sourceId = 'codex-session-files';
|
||||
readonly timeoutMs = CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS;
|
||||
readonly #codexHome: string;
|
||||
readonly #cachePath: string;
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
|
|
@ -207,9 +302,14 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
identityResolver: RecentProjectIdentityResolver;
|
||||
logger: LoggerPort;
|
||||
codexHome?: string;
|
||||
appDataPath?: string;
|
||||
}
|
||||
) {
|
||||
this.#codexHome = getCodexHome(deps.codexHome);
|
||||
this.#cachePath = path.join(
|
||||
deps.appDataPath ?? getAppDataPath(),
|
||||
CODEX_SESSION_FILE_CACHE_RELATIVE_PATH
|
||||
);
|
||||
}
|
||||
|
||||
async list(): Promise<RecentProjectsSourceResult> {
|
||||
|
|
@ -224,9 +324,11 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
}
|
||||
|
||||
try {
|
||||
const snapshots = await this.#listRecentSessionSnapshots();
|
||||
const snapshotResult = await this.#listRecentSessionSnapshots();
|
||||
const candidates = await Promise.all(
|
||||
snapshots.map((snapshot) => this.#toCandidate(snapshot, activeContext.fsProvider))
|
||||
snapshotResult.snapshots.map((snapshot) =>
|
||||
this.#toCandidate(snapshot, activeContext.fsProvider)
|
||||
)
|
||||
);
|
||||
|
||||
const validCandidates = candidates.filter(
|
||||
|
|
@ -236,11 +338,13 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
this.deps.logger.info('codex session-file recent-projects source loaded', {
|
||||
count: validCandidates.length,
|
||||
codexHome: this.#codexHome,
|
||||
degraded: snapshotResult.degraded,
|
||||
...snapshotResult.stats,
|
||||
});
|
||||
|
||||
return {
|
||||
candidates: validCandidates,
|
||||
degraded: false,
|
||||
degraded: snapshotResult.degraded,
|
||||
};
|
||||
} catch (error) {
|
||||
this.deps.logger.warn('codex session-file recent-projects source failed', {
|
||||
|
|
@ -254,15 +358,23 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
}
|
||||
}
|
||||
|
||||
async #listRecentSessionSnapshots(): Promise<CodexSessionProjectSnapshot[]> {
|
||||
async #listRecentSessionSnapshots(): Promise<CodexSessionSnapshotLoadResult> {
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + CODEX_SESSION_FILE_SOFT_BUDGET_MS;
|
||||
const files = [
|
||||
...(await listJsonlFiles(path.join(this.#codexHome, 'sessions'), 4)),
|
||||
...(await listJsonlFiles(path.join(this.#codexHome, 'archived_sessions'), 1)),
|
||||
].sort((left, right) => right.mtimeMs - left.mtimeMs);
|
||||
|
||||
const snapshotsByCwd = new Map<string, CodexSessionProjectSnapshot>();
|
||||
|
||||
const candidateFiles = files.slice(0, CODEX_SESSION_FILE_PARSE_LIMIT);
|
||||
const cache = await this.#readCacheSafe();
|
||||
const nextCacheEntries = new Map<string, CodexSessionFileCacheEntry>();
|
||||
let degraded = false;
|
||||
let cached = 0;
|
||||
let uncachedReads = 0;
|
||||
let timedOutReads = 0;
|
||||
let skippedUncached = 0;
|
||||
|
||||
for (
|
||||
let offset = 0;
|
||||
|
|
@ -270,20 +382,49 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
offset += CODEX_SESSION_FILE_READ_BATCH_SIZE
|
||||
) {
|
||||
const batch = candidateFiles.slice(offset, offset + CODEX_SESSION_FILE_READ_BATCH_SIZE);
|
||||
const firstLines = await Promise.all(
|
||||
batch.map(async (file) => ({
|
||||
file,
|
||||
firstLine: await readFirstLine(file.filePath),
|
||||
}))
|
||||
const metadata = await Promise.all(
|
||||
batch.map(async (file) => {
|
||||
const cachedEntry = cache.entries[file.filePath];
|
||||
if (isUsableCacheEntry(cachedEntry, file)) {
|
||||
cached += 1;
|
||||
nextCacheEntries.set(file.filePath, cachedEntry);
|
||||
return { snapshot: cachedEntry.snapshot, processed: true };
|
||||
}
|
||||
|
||||
if (
|
||||
Date.now() >= deadline ||
|
||||
uncachedReads >= CODEX_SESSION_FILE_MAX_UNCACHED_READS_PER_RUN
|
||||
) {
|
||||
degraded = true;
|
||||
skippedUncached += 1;
|
||||
return { snapshot: null, processed: false };
|
||||
}
|
||||
|
||||
uncachedReads += 1;
|
||||
const readResult = await readFirstLineWithTimeout(
|
||||
file.filePath,
|
||||
Math.min(CODEX_SESSION_FILE_READ_TIMEOUT_MS, deadline - Date.now())
|
||||
);
|
||||
if (readResult.timedOut) {
|
||||
degraded = true;
|
||||
timedOutReads += 1;
|
||||
return { snapshot: null, processed: false };
|
||||
}
|
||||
|
||||
const firstLine = readResult.firstLine;
|
||||
const snapshot = firstLine ? parseSessionSnapshot(firstLine, file.mtimeMs) : null;
|
||||
nextCacheEntries.set(file.filePath, {
|
||||
filePath: file.filePath,
|
||||
mtimeMs: file.mtimeMs,
|
||||
size: file.size,
|
||||
snapshot,
|
||||
});
|
||||
return { snapshot, processed: true };
|
||||
})
|
||||
);
|
||||
|
||||
for (const { file, firstLine } of firstLines) {
|
||||
if (!firstLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const snapshot = parseSessionSnapshot(firstLine, file.mtimeMs);
|
||||
if (!snapshot) {
|
||||
for (const { snapshot, processed } of metadata) {
|
||||
if (!processed || !snapshot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -298,9 +439,82 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
}
|
||||
}
|
||||
|
||||
return Array.from(snapshotsByCwd.values())
|
||||
for (const file of candidateFiles) {
|
||||
const cachedEntry = cache.entries[file.filePath];
|
||||
if (isUsableCacheEntry(cachedEntry, file) && !nextCacheEntries.has(file.filePath)) {
|
||||
nextCacheEntries.set(file.filePath, cachedEntry);
|
||||
}
|
||||
}
|
||||
await this.#writeCacheSafe(nextCacheEntries);
|
||||
|
||||
const snapshots = Array.from(snapshotsByCwd.values())
|
||||
.sort((left, right) => right.lastActivityAt - left.lastActivityAt)
|
||||
.slice(0, CODEX_PROJECT_CANDIDATE_LIMIT);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (degraded) {
|
||||
this.deps.logger.warn('codex session-file recent-projects source partial', {
|
||||
files: candidateFiles.length,
|
||||
cached,
|
||||
uncachedReads,
|
||||
timedOutReads,
|
||||
skippedUncached,
|
||||
candidates: snapshots.length,
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
snapshots,
|
||||
degraded,
|
||||
stats: {
|
||||
files: candidateFiles.length,
|
||||
cached,
|
||||
uncachedReads,
|
||||
timedOutReads,
|
||||
skippedUncached,
|
||||
durationMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async #readCacheSafe(): Promise<CodexSessionFileCacheFile> {
|
||||
try {
|
||||
const raw = await fs.readFile(this.#cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<CodexSessionFileCacheFile>;
|
||||
if (
|
||||
parsed.schemaVersion !== CODEX_SESSION_FILE_CACHE_SCHEMA_VERSION ||
|
||||
!parsed.entries ||
|
||||
typeof parsed.entries !== 'object' ||
|
||||
Array.isArray(parsed.entries)
|
||||
) {
|
||||
return emptyCache();
|
||||
}
|
||||
return {
|
||||
schemaVersion: CODEX_SESSION_FILE_CACHE_SCHEMA_VERSION,
|
||||
entries: parsed.entries,
|
||||
};
|
||||
} catch {
|
||||
return emptyCache();
|
||||
}
|
||||
}
|
||||
|
||||
async #writeCacheSafe(entries: ReadonlyMap<string, CodexSessionFileCacheEntry>): Promise<void> {
|
||||
let tempPath: string | null = null;
|
||||
try {
|
||||
await fs.mkdir(path.dirname(this.#cachePath), { recursive: true });
|
||||
const cacheFile: CodexSessionFileCacheFile = {
|
||||
schemaVersion: CODEX_SESSION_FILE_CACHE_SCHEMA_VERSION,
|
||||
entries: Object.fromEntries(entries),
|
||||
};
|
||||
tempPath = `${this.#cachePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.writeFile(tempPath, JSON.stringify(cacheFile), 'utf8');
|
||||
await fs.rename(tempPath, this.#cachePath);
|
||||
} catch {
|
||||
if (tempPath) {
|
||||
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
// Cache is an optimization only; never fail recent projects because it is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
async #toCandidate(
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ export interface RuntimeProviderDirectoryEntryDto {
|
|||
hasKnownModels: boolean;
|
||||
requiresManualConfig: boolean;
|
||||
supportedInlineAuth: boolean;
|
||||
configuredAuthless: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -170,11 +171,24 @@ export interface RuntimeProviderManagementViewDto {
|
|||
title: string;
|
||||
runtime: RuntimeProviderManagementRuntimeDto;
|
||||
providers: readonly RuntimeProviderConnectionDto[];
|
||||
configuredModels?: readonly RuntimeProviderModelDto[];
|
||||
projectPath?: string | null;
|
||||
projectDefaultModel?: string | null;
|
||||
allProjectsDefaultModel?: string | null;
|
||||
defaultModelSource?: RuntimeProviderDefaultModelSourceDto | null;
|
||||
defaultModel: string | null;
|
||||
fallbackModel: string | null;
|
||||
diagnostics: readonly string[];
|
||||
}
|
||||
|
||||
export type RuntimeProviderDefaultModelSourceDto =
|
||||
| 'project'
|
||||
| 'all_projects'
|
||||
| 'opencode_config'
|
||||
| 'fallback';
|
||||
|
||||
export type RuntimeProviderDefaultScopeDto = 'project' | 'all_projects';
|
||||
|
||||
export type RuntimeProviderManagementErrorCodeDto =
|
||||
| 'unsupported-runtime'
|
||||
| 'unsupported-action'
|
||||
|
|
@ -228,6 +242,28 @@ export type RuntimeProviderModelAvailabilityDto =
|
|||
| 'unknown'
|
||||
| 'untested';
|
||||
|
||||
export type RuntimeProviderModelAccessKindDto =
|
||||
| 'no_model'
|
||||
| 'unknown_model'
|
||||
| 'credentialed'
|
||||
| 'builtin_free'
|
||||
| 'configured_authless'
|
||||
| 'verified'
|
||||
| 'not_authenticated'
|
||||
| 'execution_failed';
|
||||
|
||||
export type RuntimeProviderModelRouteKindDto =
|
||||
| 'connected_provider'
|
||||
| 'builtin_free'
|
||||
| 'configured_local'
|
||||
| 'catalog_provider';
|
||||
|
||||
export type RuntimeProviderModelProofStateDto =
|
||||
| 'not_required'
|
||||
| 'needs_probe'
|
||||
| 'verified'
|
||||
| 'failed';
|
||||
|
||||
export interface RuntimeProviderModelDto {
|
||||
modelId: string;
|
||||
providerId: string;
|
||||
|
|
@ -236,6 +272,11 @@ export interface RuntimeProviderModelDto {
|
|||
free: boolean;
|
||||
default: boolean;
|
||||
availability: RuntimeProviderModelAvailabilityDto;
|
||||
accessKind?: RuntimeProviderModelAccessKindDto;
|
||||
routeKind?: RuntimeProviderModelRouteKindDto;
|
||||
proofState?: RuntimeProviderModelProofStateDto;
|
||||
requiresExecutionProof?: boolean;
|
||||
accessReason?: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementModelsDto {
|
||||
|
|
@ -332,5 +373,6 @@ export interface RuntimeProviderManagementSetDefaultModelInput {
|
|||
providerId: string;
|
||||
modelId: string;
|
||||
probe?: boolean;
|
||||
scope?: RuntimeProviderDefaultScopeDto;
|
||||
projectPath?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv';
|
||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||
|
||||
import type {
|
||||
RuntimeProviderManagementApi,
|
||||
|
|
@ -141,7 +141,11 @@ async function resolveCliEnv(): Promise<{
|
|||
binaryPath: string | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}> {
|
||||
const shellEnv = await resolveInteractiveShellEnv();
|
||||
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
return {
|
||||
|
|
@ -638,6 +642,8 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
|||
input.providerId,
|
||||
'--model',
|
||||
input.modelId,
|
||||
'--scope',
|
||||
input.scope === 'all_projects' ? 'all-projects' : 'project',
|
||||
'--probe',
|
||||
'--compact',
|
||||
'--json',
|
||||
|
|
|
|||
|
|
@ -1,28 +1,87 @@
|
|||
import { type JSX, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
loadProjectPathProjects,
|
||||
type ProjectPathProject,
|
||||
} from '@renderer/components/team/dialogs/projectPathProjects';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useRuntimeProviderManagement } from './hooks/useRuntimeProviderManagement';
|
||||
import { RuntimeProviderManagementPanelView } from './ui/RuntimeProviderManagementPanelView';
|
||||
|
||||
import type { RuntimeProviderManagementRuntimeId } from '@features/runtime-provider-management/contracts';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
interface RuntimeProviderManagementPanelProps {
|
||||
readonly runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
readonly open: boolean;
|
||||
readonly projectPath?: string | null;
|
||||
readonly initialProviderId?: string | null;
|
||||
readonly initialProviderAction?: 'connect' | 'select' | null;
|
||||
readonly disabled?: boolean;
|
||||
readonly onProviderChanged?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function RuntimeProviderManagementPanel({
|
||||
export const RuntimeProviderManagementPanel = ({
|
||||
runtimeId,
|
||||
open,
|
||||
projectPath = null,
|
||||
initialProviderId = null,
|
||||
initialProviderAction = null,
|
||||
disabled = false,
|
||||
onProviderChanged,
|
||||
}: RuntimeProviderManagementPanelProps): JSX.Element {
|
||||
}: RuntimeProviderManagementPanelProps): JSX.Element => {
|
||||
const repositoryGroups = useStore(useShallow((state) => state.repositoryGroups));
|
||||
const initialProjectPath = useMemo(() => projectPath?.trim() || null, [projectPath]);
|
||||
const [activeProjectPath, setActiveProjectPath] = useState<string | null>(initialProjectPath);
|
||||
const [projectContextProjects, setProjectContextProjects] = useState<ProjectPathProject[]>([]);
|
||||
const [projectContextLoading, setProjectContextLoading] = useState(false);
|
||||
const [projectContextError, setProjectContextError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setActiveProjectPath(initialProjectPath);
|
||||
}, [initialProjectPath, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setProjectContextLoading(true);
|
||||
setProjectContextError(null);
|
||||
void loadProjectPathProjects({
|
||||
defaultProjectPath: activeProjectPath ?? initialProjectPath,
|
||||
repositoryGroups,
|
||||
})
|
||||
.then((projects) => {
|
||||
if (cancelled) return;
|
||||
setProjectContextProjects(projects);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setProjectContextError(
|
||||
error instanceof Error ? error.message : 'Failed to load project contexts'
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setProjectContextLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeProjectPath, initialProjectPath, open, repositoryGroups]);
|
||||
|
||||
const [state, actions] = useRuntimeProviderManagement({
|
||||
runtimeId,
|
||||
enabled: open,
|
||||
projectPath,
|
||||
projectPath: activeProjectPath,
|
||||
initialProviderId,
|
||||
initialProviderAction,
|
||||
onProviderChanged,
|
||||
});
|
||||
|
||||
|
|
@ -31,7 +90,11 @@ export function RuntimeProviderManagementPanel({
|
|||
state={state}
|
||||
actions={actions}
|
||||
disabled={disabled}
|
||||
projectPath={projectPath}
|
||||
projectPath={activeProjectPath}
|
||||
projectContextProjects={projectContextProjects}
|
||||
projectContextLoading={projectContextLoading}
|
||||
projectContextError={projectContextError}
|
||||
onProjectContextChange={setActiveProjectPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
|
||||
import type {
|
||||
RuntimeProviderConnectionDto,
|
||||
RuntimeProviderDefaultScopeDto,
|
||||
RuntimeProviderDirectoryEntryDto,
|
||||
RuntimeProviderDirectoryFilterDto,
|
||||
RuntimeProviderManagementRuntimeId,
|
||||
|
|
@ -23,6 +24,8 @@ interface UseRuntimeProviderManagementOptions {
|
|||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
enabled: boolean;
|
||||
projectPath?: string | null;
|
||||
initialProviderId?: string | null;
|
||||
initialProviderAction?: 'connect' | 'select' | null;
|
||||
onProviderChanged?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +33,11 @@ export type RuntimeProviderModelPickerMode = 'use' | 'runtime-default';
|
|||
|
||||
const DEFAULT_DIRECTORY_FILTER: RuntimeProviderDirectoryFilterDto = 'all';
|
||||
|
||||
interface ProjectContextSnapshot {
|
||||
path: string | null;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementState {
|
||||
view: RuntimeProviderManagementViewDto | null;
|
||||
providers: readonly RuntimeProviderConnectionDto[];
|
||||
|
|
@ -87,7 +95,11 @@ export interface RuntimeProviderManagementActions {
|
|||
selectModel: (modelId: string) => void;
|
||||
useModelForNewTeams: (modelId: string) => void;
|
||||
testModel: (providerId: string, modelId: string) => Promise<void>;
|
||||
setDefaultModel: (providerId: string, modelId: string) => Promise<void>;
|
||||
setDefaultModel: (
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
scope?: RuntimeProviderDefaultScopeDto
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
function replaceProvider(
|
||||
|
|
@ -123,6 +135,10 @@ function withUiTimeout<T>(promise: Promise<T>, message: string, timeoutMs = 70_0
|
|||
});
|
||||
}
|
||||
|
||||
function normalizeProjectContextPath(projectPath: string | null | undefined): string | null {
|
||||
return projectPath?.trim() || null;
|
||||
}
|
||||
|
||||
function buildFailedModelTestResult(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
|
|
@ -138,6 +154,37 @@ function buildFailedModelTestResult(
|
|||
};
|
||||
}
|
||||
|
||||
function applyModelTestResultToModel(
|
||||
model: RuntimeProviderModelDto,
|
||||
result: RuntimeProviderModelTestResultDto
|
||||
): RuntimeProviderModelDto {
|
||||
if (model.modelId !== result.modelId) {
|
||||
return model;
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
availability: result.availability,
|
||||
proofState: result.ok ? 'verified' : 'failed',
|
||||
accessKind: result.ok ? 'verified' : model.accessKind,
|
||||
requiresExecutionProof: result.ok ? false : model.requiresExecutionProof,
|
||||
};
|
||||
}
|
||||
|
||||
function applyModelTestResultToView(
|
||||
view: RuntimeProviderManagementViewDto | null,
|
||||
result: RuntimeProviderModelTestResultDto
|
||||
): RuntimeProviderManagementViewDto | null {
|
||||
if (!view?.configuredModels) {
|
||||
return view;
|
||||
}
|
||||
return {
|
||||
...view,
|
||||
configuredModels: view.configuredModels.map((model) =>
|
||||
applyModelTestResultToModel(model, result)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSavedModelForNewTeams(models: readonly RuntimeProviderModelDto[]): string | null {
|
||||
const savedModelId = getOpenCodeModelForNewTeams();
|
||||
if (!savedModelId) {
|
||||
|
|
@ -208,11 +255,35 @@ export function useRuntimeProviderManagement(
|
|||
const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const viewLoadRequestSeq = useRef(0);
|
||||
const directoryRequestSeq = useRef(0);
|
||||
const setupFormRequestSeq = useRef(0);
|
||||
const modelLoadRequestSeq = useRef(0);
|
||||
const modelProbeGenerationRef = useRef(0);
|
||||
const activeModelPickerProviderRef = useRef<string | null>(null);
|
||||
const appliedInitialProviderRef = useRef<string | null>(null);
|
||||
const currentProjectPath = normalizeProjectContextPath(options.projectPath);
|
||||
const projectContextRef = useRef<ProjectContextSnapshot>({
|
||||
path: currentProjectPath,
|
||||
generation: 0,
|
||||
});
|
||||
if (projectContextRef.current.path !== currentProjectPath) {
|
||||
projectContextRef.current = {
|
||||
path: currentProjectPath,
|
||||
generation: projectContextRef.current.generation + 1,
|
||||
};
|
||||
}
|
||||
|
||||
const getProjectContextSnapshot = useCallback(
|
||||
(): ProjectContextSnapshot => projectContextRef.current,
|
||||
[]
|
||||
);
|
||||
const isProjectContextCurrent = useCallback(
|
||||
(snapshot: ProjectContextSnapshot): boolean =>
|
||||
projectContextRef.current.path === snapshot.path &&
|
||||
projectContextRef.current.generation === snapshot.generation,
|
||||
[]
|
||||
);
|
||||
|
||||
const openModelPickerState = useCallback(
|
||||
(providerId: string, mode: RuntimeProviderModelPickerMode): void => {
|
||||
|
|
@ -247,11 +318,44 @@ export function useRuntimeProviderManagement(
|
|||
setTestingModelIds([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
directoryRequestSeq.current += 1;
|
||||
setupFormRequestSeq.current += 1;
|
||||
modelLoadRequestSeq.current += 1;
|
||||
modelProbeGenerationRef.current += 1;
|
||||
setDirectoryEntries([]);
|
||||
setDirectoryTotalCount(null);
|
||||
setDirectoryNextCursor(null);
|
||||
setDirectoryError(null);
|
||||
setDirectorySelectedProviderId(null);
|
||||
setDirectoryLoaded(false);
|
||||
setSetupForm(null);
|
||||
setSetupFormLoading(false);
|
||||
setSetupFormError(null);
|
||||
setSetupSubmitError(null);
|
||||
setActiveFormProviderId(null);
|
||||
setApiKeyValue('');
|
||||
setSetupMetadata({});
|
||||
setModels([]);
|
||||
setModelsLoading(false);
|
||||
setModelsError(null);
|
||||
setSelectedModelId(null);
|
||||
setTestingModelIds([]);
|
||||
setSavingDefaultModelId(null);
|
||||
setModelResults({});
|
||||
setSuccessMessage(null);
|
||||
}, [currentProjectPath]);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (input: { silent?: boolean } = {}): Promise<void> => {
|
||||
if (!options.enabled) {
|
||||
return;
|
||||
}
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
const requestSeq = viewLoadRequestSeq.current + 1;
|
||||
viewLoadRequestSeq.current = requestSeq;
|
||||
const requestIsCurrent = (): boolean =>
|
||||
viewLoadRequestSeq.current === requestSeq && isProjectContextCurrent(projectContext);
|
||||
const silent = input.silent === true;
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
|
|
@ -260,8 +364,11 @@ export function useRuntimeProviderManagement(
|
|||
try {
|
||||
const response = await api.runtimeProviderManagement.loadView({
|
||||
runtimeId: options.runtimeId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
});
|
||||
if (!requestIsCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
if (!silent) {
|
||||
setView(null);
|
||||
|
|
@ -278,17 +385,20 @@ export function useRuntimeProviderManagement(
|
|||
return selectInitialProviderId(nextView);
|
||||
});
|
||||
} catch (loadError) {
|
||||
if (!requestIsCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (!silent) {
|
||||
setView(null);
|
||||
}
|
||||
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers');
|
||||
} finally {
|
||||
if (!silent) {
|
||||
if (!silent && requestIsCurrent()) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[options.enabled, options.projectPath, options.runtimeId]
|
||||
[getProjectContextSnapshot, isProjectContextCurrent, options.enabled, options.runtimeId]
|
||||
);
|
||||
|
||||
const loadDirectoryPage = useCallback(
|
||||
|
|
@ -310,8 +420,11 @@ export function useRuntimeProviderManagement(
|
|||
const query = input.query ?? directoryQuery;
|
||||
const filter = input.filter ?? DEFAULT_DIRECTORY_FILTER;
|
||||
const cursor = input.cursor ?? null;
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
const requestSeq = directoryRequestSeq.current + 1;
|
||||
directoryRequestSeq.current = requestSeq;
|
||||
const requestIsCurrent = (): boolean =>
|
||||
directoryRequestSeq.current === requestSeq && isProjectContextCurrent(projectContext);
|
||||
|
||||
if (append) {
|
||||
setDirectoryRefreshing(true);
|
||||
|
|
@ -325,14 +438,14 @@ export function useRuntimeProviderManagement(
|
|||
try {
|
||||
const response = await api.runtimeProviderManagement.loadProviderDirectory({
|
||||
runtimeId: options.runtimeId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
query: query.trim() || null,
|
||||
filter,
|
||||
limit: 50,
|
||||
cursor,
|
||||
refresh: refreshDirectoryData,
|
||||
});
|
||||
if (directoryRequestSeq.current !== requestSeq) {
|
||||
if (!requestIsCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
|
|
@ -357,23 +470,32 @@ export function useRuntimeProviderManagement(
|
|||
append ? [...current, ...directory.entries] : directory.entries
|
||||
);
|
||||
} catch (loadError) {
|
||||
if (directoryRequestSeq.current === requestSeq) {
|
||||
if (requestIsCurrent()) {
|
||||
setDirectoryError(
|
||||
loadError instanceof Error ? loadError.message : 'Failed to load provider directory'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (directoryRequestSeq.current === requestSeq) {
|
||||
if (requestIsCurrent()) {
|
||||
setDirectoryLoading(false);
|
||||
setDirectoryRefreshing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[directoryQuery, directorySupported, options.enabled, options.projectPath, options.runtimeId]
|
||||
[
|
||||
directoryQuery,
|
||||
directorySupported,
|
||||
getProjectContextSnapshot,
|
||||
isProjectContextCurrent,
|
||||
options.enabled,
|
||||
options.runtimeId,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
viewLoadRequestSeq.current += 1;
|
||||
appliedInitialProviderRef.current = null;
|
||||
setProviderQuery('');
|
||||
setDirectoryLoading(false);
|
||||
setDirectoryRefreshing(false);
|
||||
|
|
@ -395,7 +517,7 @@ export function useRuntimeProviderManagement(
|
|||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [closeModelPickerState, options.enabled, refresh]);
|
||||
}, [closeModelPickerState, currentProjectPath, options.enabled, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled || !directorySupported) {
|
||||
|
|
@ -427,9 +549,11 @@ export function useRuntimeProviderManagement(
|
|||
const requestSeq = modelLoadRequestSeq.current + 1;
|
||||
modelLoadRequestSeq.current = requestSeq;
|
||||
const providerId = modelPickerProviderId;
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
const requestIsCurrent = (): boolean =>
|
||||
modelLoadRequestSeq.current === requestSeq &&
|
||||
activeModelPickerProviderRef.current === providerId;
|
||||
activeModelPickerProviderRef.current === providerId &&
|
||||
isProjectContextCurrent(projectContext);
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
|
|
@ -437,7 +561,7 @@ export function useRuntimeProviderManagement(
|
|||
api.runtimeProviderManagement.loadModels({
|
||||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
query: modelQuery.trim() || null,
|
||||
limit: 250,
|
||||
}),
|
||||
|
|
@ -480,7 +604,14 @@ export function useRuntimeProviderManagement(
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [modelPickerProviderId, modelQuery, options.enabled, options.projectPath, options.runtimeId]);
|
||||
}, [
|
||||
getProjectContextSnapshot,
|
||||
isProjectContextCurrent,
|
||||
modelPickerProviderId,
|
||||
modelQuery,
|
||||
options.enabled,
|
||||
options.runtimeId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled || activeFormProviderId) {
|
||||
|
|
@ -589,19 +720,22 @@ export function useRuntimeProviderManagement(
|
|||
setSetupFormLoading(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
const requestSeq = setupFormRequestSeq.current + 1;
|
||||
setupFormRequestSeq.current = requestSeq;
|
||||
const requestIsCurrent = (): boolean =>
|
||||
setupFormRequestSeq.current === requestSeq && isProjectContextCurrent(projectContext);
|
||||
|
||||
void withUiTimeout(
|
||||
api.runtimeProviderManagement.loadSetupForm({
|
||||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
}),
|
||||
'Provider setup form load timed out'
|
||||
)
|
||||
.then((response) => {
|
||||
if (setupFormRequestSeq.current !== requestSeq) {
|
||||
if (!requestIsCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
|
|
@ -614,7 +748,7 @@ export function useRuntimeProviderManagement(
|
|||
}
|
||||
})
|
||||
.catch((setupError) => {
|
||||
if (setupFormRequestSeq.current !== requestSeq) {
|
||||
if (!requestIsCurrent()) {
|
||||
return;
|
||||
}
|
||||
setSetupFormError(
|
||||
|
|
@ -622,12 +756,12 @@ export function useRuntimeProviderManagement(
|
|||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (setupFormRequestSeq.current === requestSeq) {
|
||||
if (requestIsCurrent()) {
|
||||
setSetupFormLoading(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[closeModelPickerState, options.projectPath, options.runtimeId]
|
||||
[closeModelPickerState, getProjectContextSnapshot, isProjectContextCurrent, options.runtimeId]
|
||||
);
|
||||
|
||||
const updateProviderQuery = useCallback(
|
||||
|
|
@ -689,6 +823,7 @@ export function useRuntimeProviderManagement(
|
|||
setError(null);
|
||||
setSetupSubmitError(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
try {
|
||||
const response = await withUiTimeout(
|
||||
api.runtimeProviderManagement.connectProvider({
|
||||
|
|
@ -697,10 +832,13 @@ export function useRuntimeProviderManagement(
|
|||
method: setupForm.method,
|
||||
apiKey: apiKey || null,
|
||||
metadata: setupMetadata,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
}),
|
||||
'Provider connect timed out'
|
||||
);
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
setSetupSubmitError(response.error.message);
|
||||
return;
|
||||
|
|
@ -717,24 +855,45 @@ export function useRuntimeProviderManagement(
|
|||
setSetupSubmitError(null);
|
||||
try {
|
||||
await options.onProviderChanged?.();
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
refresh({ silent: true }),
|
||||
loadDirectoryPage({ refresh: true, cursor: null }),
|
||||
]);
|
||||
} catch (refreshError) {
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||
);
|
||||
}
|
||||
} catch (connectError) {
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setSetupSubmitError(
|
||||
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
|
||||
);
|
||||
} finally {
|
||||
setSavingProviderId(null);
|
||||
if (isProjectContextCurrent(projectContext)) {
|
||||
setSavingProviderId(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[apiKeyValue, loadDirectoryPage, options, refresh, setupForm, setupFormError, setupMetadata]
|
||||
[
|
||||
apiKeyValue,
|
||||
getProjectContextSnapshot,
|
||||
isProjectContextCurrent,
|
||||
loadDirectoryPage,
|
||||
options,
|
||||
refresh,
|
||||
setupForm,
|
||||
setupFormError,
|
||||
setupMetadata,
|
||||
]
|
||||
);
|
||||
|
||||
const forgetProvider = useCallback(
|
||||
|
|
@ -742,15 +901,19 @@ export function useRuntimeProviderManagement(
|
|||
setSavingProviderId(providerId);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
try {
|
||||
const response = await withUiTimeout(
|
||||
api.runtimeProviderManagement.forgetCredential({
|
||||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
}),
|
||||
'Provider forget timed out'
|
||||
);
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
setError(response.error.message);
|
||||
return;
|
||||
|
|
@ -761,25 +924,39 @@ export function useRuntimeProviderManagement(
|
|||
const success = formatCredentialRemovedMessage(response.provider ?? null);
|
||||
try {
|
||||
await options.onProviderChanged?.();
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
refresh({ silent: true }),
|
||||
loadDirectoryPage({ refresh: true, cursor: null }),
|
||||
]);
|
||||
} catch (refreshError) {
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||
);
|
||||
}
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setSuccessMessage(success);
|
||||
} catch (forgetError) {
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
|
||||
);
|
||||
} finally {
|
||||
setSavingProviderId(null);
|
||||
if (isProjectContextCurrent(projectContext)) {
|
||||
setSavingProviderId(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[loadDirectoryPage, options, refresh]
|
||||
[getProjectContextSnapshot, isProjectContextCurrent, loadDirectoryPage, options, refresh]
|
||||
);
|
||||
|
||||
const openModelPicker = useCallback(
|
||||
|
|
@ -808,9 +985,11 @@ export function useRuntimeProviderManagement(
|
|||
async (providerId: string, modelId: string): Promise<void> => {
|
||||
const probeGeneration = modelProbeGenerationRef.current;
|
||||
const activeProviderAtStart = activeModelPickerProviderRef.current;
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
const shouldRecordProbeResult = (): boolean =>
|
||||
modelProbeGenerationRef.current === probeGeneration &&
|
||||
(activeProviderAtStart === null || activeModelPickerProviderRef.current === providerId);
|
||||
(activeProviderAtStart === null || activeModelPickerProviderRef.current === providerId) &&
|
||||
isProjectContextCurrent(projectContext);
|
||||
setTestingModelIds((current) =>
|
||||
current.includes(modelId) ? current : [...current, modelId]
|
||||
);
|
||||
|
|
@ -822,49 +1001,71 @@ export function useRuntimeProviderManagement(
|
|||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
modelId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
}),
|
||||
'Model test timed out',
|
||||
100_000
|
||||
);
|
||||
if (response.error) {
|
||||
if (shouldRecordProbeResult()) {
|
||||
const result = buildFailedModelTestResult(providerId, modelId, response.error.message);
|
||||
setModelResults((current) => ({
|
||||
...current,
|
||||
[modelId]: buildFailedModelTestResult(providerId, modelId, response.error!.message),
|
||||
[modelId]: result,
|
||||
}));
|
||||
setModels((current) =>
|
||||
current.map((model) => applyModelTestResultToModel(model, result))
|
||||
);
|
||||
setView((current) => applyModelTestResultToView(current, result));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (response.result && shouldRecordProbeResult()) {
|
||||
const result = response.result;
|
||||
setModelResults((current) => ({
|
||||
...current,
|
||||
[modelId]: response.result!,
|
||||
[modelId]: result,
|
||||
}));
|
||||
setModels((current) =>
|
||||
current.map((model) => applyModelTestResultToModel(model, result))
|
||||
);
|
||||
setView((current) => applyModelTestResultToView(current, result));
|
||||
}
|
||||
} catch (testError) {
|
||||
if (shouldRecordProbeResult()) {
|
||||
const result = buildFailedModelTestResult(
|
||||
providerId,
|
||||
modelId,
|
||||
testError instanceof Error ? testError.message : 'Failed to test model'
|
||||
);
|
||||
setModelResults((current) => ({
|
||||
...current,
|
||||
[modelId]: buildFailedModelTestResult(
|
||||
providerId,
|
||||
modelId,
|
||||
testError instanceof Error ? testError.message : 'Failed to test model'
|
||||
),
|
||||
[modelId]: result,
|
||||
}));
|
||||
setModels((current) =>
|
||||
current.map((model) => applyModelTestResultToModel(model, result))
|
||||
);
|
||||
setView((current) => applyModelTestResultToView(current, result));
|
||||
}
|
||||
} finally {
|
||||
setTestingModelIds((current) => current.filter((entry) => entry !== modelId));
|
||||
if (shouldRecordProbeResult()) {
|
||||
setTestingModelIds((current) => current.filter((entry) => entry !== modelId));
|
||||
}
|
||||
}
|
||||
},
|
||||
[options.projectPath, options.runtimeId]
|
||||
[getProjectContextSnapshot, isProjectContextCurrent, options.runtimeId]
|
||||
);
|
||||
|
||||
const setDefaultModel = useCallback(
|
||||
async (providerId: string, modelId: string): Promise<void> => {
|
||||
async (
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
scope: RuntimeProviderDefaultScopeDto = 'project'
|
||||
): Promise<void> => {
|
||||
setSavingDefaultModelId(modelId);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
try {
|
||||
const response = await withUiTimeout(
|
||||
api.runtimeProviderManagement.setDefaultModel({
|
||||
|
|
@ -872,36 +1073,70 @@ export function useRuntimeProviderManagement(
|
|||
providerId,
|
||||
modelId,
|
||||
probe: true,
|
||||
projectPath: options.projectPath ?? null,
|
||||
scope,
|
||||
projectPath: projectContext.path,
|
||||
}),
|
||||
'Set default model timed out',
|
||||
100_000
|
||||
);
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
setError(response.error.message);
|
||||
return;
|
||||
}
|
||||
const proofResult: RuntimeProviderModelTestResultDto = {
|
||||
providerId,
|
||||
modelId,
|
||||
ok: true,
|
||||
availability: 'available',
|
||||
message: 'Model probe passed',
|
||||
diagnostics: [],
|
||||
};
|
||||
if (response.view) {
|
||||
setView(response.view);
|
||||
setView(applyModelTestResultToView(response.view, proofResult));
|
||||
}
|
||||
setSelectedModelId(modelId);
|
||||
const effectiveDefaultModelId = response.view?.defaultModel ?? modelId;
|
||||
setModelResults((current) => ({
|
||||
...current,
|
||||
[modelId]: proofResult,
|
||||
}));
|
||||
setSelectedModelId(effectiveDefaultModelId);
|
||||
setModels((current) =>
|
||||
current.map((model) => ({
|
||||
...model,
|
||||
default: model.modelId === modelId,
|
||||
}))
|
||||
current.map((model) =>
|
||||
applyModelTestResultToModel(
|
||||
{
|
||||
...model,
|
||||
default: model.modelId === effectiveDefaultModelId,
|
||||
},
|
||||
proofResult
|
||||
)
|
||||
)
|
||||
);
|
||||
setSuccessMessage(
|
||||
scope === 'all_projects'
|
||||
? `All-projects OpenCode default set to ${modelId}`
|
||||
: `Project OpenCode default set to ${modelId}`
|
||||
);
|
||||
setSuccessMessage(`OpenCode default set to ${modelId}`);
|
||||
await options.onProviderChanged?.();
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
} catch (defaultError) {
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default'
|
||||
);
|
||||
} finally {
|
||||
setSavingDefaultModelId(null);
|
||||
if (isProjectContextCurrent(projectContext)) {
|
||||
setSavingDefaultModelId(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[options]
|
||||
[getProjectContextSnapshot, isProjectContextCurrent, options]
|
||||
);
|
||||
|
||||
const selectProvider = useCallback(
|
||||
|
|
@ -921,6 +1156,40 @@ export function useRuntimeProviderManagement(
|
|||
[closeModelPickerState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialProviderId = options.initialProviderId?.trim();
|
||||
if (!initialProviderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialAction = options.initialProviderAction ?? 'select';
|
||||
const initialKey = `${initialProviderId}:${initialAction}`;
|
||||
if (appliedInitialProviderRef.current === initialKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedInitialProviderRef.current = initialKey;
|
||||
updateProviderQuery(initialProviderId);
|
||||
|
||||
if (initialAction === 'connect') {
|
||||
startConnect(initialProviderId);
|
||||
return;
|
||||
}
|
||||
|
||||
selectProvider(initialProviderId);
|
||||
}, [
|
||||
options.enabled,
|
||||
options.initialProviderAction,
|
||||
options.initialProviderId,
|
||||
selectProvider,
|
||||
startConnect,
|
||||
updateProviderQuery,
|
||||
]);
|
||||
|
||||
const state = useMemo<RuntimeProviderManagementState>(
|
||||
() => ({
|
||||
view,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,6 @@ import { TmuxPackageManagerResolver } from '@features/tmux-installer/main/infras
|
|||
import { TmuxPlatformResolver } from '@features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver';
|
||||
import { TmuxWslService } from '@features/tmux-installer/main/infrastructure/wsl/TmuxWslService';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
|
||||
import type {
|
||||
|
|
@ -83,7 +82,6 @@ export class TmuxStatusSourceAdapter implements TmuxStatusSourcePort {
|
|||
async #probeStatus(): Promise<TmuxStatus> {
|
||||
const resolvedPlatform = await this.#platformResolver.resolve();
|
||||
const checkedAt = new Date().toISOString();
|
||||
await resolveInteractiveShellEnv();
|
||||
const env = buildEnrichedEnv();
|
||||
const plan = await this.#strategyResolver.resolve();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { buildTmuxAutoInstallCapability } from '@features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { getShellPreferredHome } from '@main/utils/shellEnv';
|
||||
|
||||
import { TmuxPackageManagerResolver } from '../platform/TmuxPackageManagerResolver';
|
||||
import { TmuxPlatformResolver } from '../platform/TmuxPlatformResolver';
|
||||
|
|
@ -51,7 +51,6 @@ export class TmuxInstallStrategyResolver {
|
|||
}
|
||||
|
||||
async resolve(): Promise<TmuxInstallPlan> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const env = buildEnrichedEnv();
|
||||
const cwd = getShellPreferredHome();
|
||||
const resolvedPlatform = await this.#platformResolver.resolve();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import * as os from 'node:os';
|
|||
import * as path from 'node:path';
|
||||
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
|
||||
import { TmuxPackageManagerResolver } from '../platform/TmuxPackageManagerResolver';
|
||||
import { TmuxWslService } from '../wsl/TmuxWslService';
|
||||
|
|
@ -71,7 +70,6 @@ export class TmuxPlatformCommandExecutor {
|
|||
return this.#wslService.execTmux(effectiveArgs, null, timeout);
|
||||
}
|
||||
|
||||
await resolveInteractiveShellEnv();
|
||||
const env = buildEnrichedEnv();
|
||||
const executable = await this.#resolveNativeTmuxExecutable(env);
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -250,13 +248,11 @@ export class TmuxPlatformCommandExecutor {
|
|||
}
|
||||
|
||||
async #execNativePs(): Promise<ExecResult> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const env = buildEnrichedEnv();
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
'ps',
|
||||
['-ax', '-o', 'pid=,ppid=,command='],
|
||||
{ env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
|
||||
{ env: process.env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => {
|
||||
const errorCode =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
|
|
|
|||
|
|
@ -1846,6 +1846,7 @@ async function initializeServices(): Promise<void> {
|
|||
isBusy: (input) => teamProvisioningService.getOpenCodeMemberDeliveryBusyStatus(input),
|
||||
},
|
||||
],
|
||||
resolveControlUrl: async () => getTeamControlApiBaseUrl(),
|
||||
proofMissingRecoveryGuard: {
|
||||
shouldDispatch: async (input) => {
|
||||
const status = await teamProvisioningService.getOpenCodeRuntimeDeliveryStatus(
|
||||
|
|
@ -2246,7 +2247,7 @@ async function shutdownServices(): Promise<void> {
|
|||
10_000
|
||||
);
|
||||
await runShutdownStep('Agent Teams MCP HTTP server cleanup', () =>
|
||||
agentTeamsMcpHttpServer.stop()
|
||||
agentTeamsMcpHttpServer.stop({ preventRestart: true })
|
||||
);
|
||||
await runShutdownStep('tracked CLI subprocess cleanup', () =>
|
||||
killTrackedCliProcesses('SIGKILL')
|
||||
|
|
|
|||
|
|
@ -233,8 +233,11 @@ async function handleVerifyProviderModels(
|
|||
providerId: CliProviderId
|
||||
): Promise<IpcResult<CliProviderStatus | null>> {
|
||||
try {
|
||||
const generation = statusCacheGeneration;
|
||||
const status = await service.verifyProviderModels(providerId);
|
||||
patchCachedProviderStatus(status);
|
||||
if (generation === statusCacheGeneration) {
|
||||
patchCachedProviderStatus(status);
|
||||
}
|
||||
return { success: true, data: status };
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const VALID_SECTIONS = new Set<ConfigSection>([
|
|||
'ssh',
|
||||
]);
|
||||
const MAX_SNOOZE_MINUTES = 24 * 60;
|
||||
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
|
@ -64,6 +65,30 @@ function isFiniteNumber(value: unknown): value is number {
|
|||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function validateAnthropicCompatibleBaseUrl(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must use http:// or https://';
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must not include credentials';
|
||||
}
|
||||
if (FIRST_PARTY_ANTHROPIC_HOSTS.has(url.hostname)) {
|
||||
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must not be a first-party Anthropic API host';
|
||||
}
|
||||
} catch {
|
||||
return 'providerConnections.anthropic.compatibleEndpoint.baseUrl must be a valid URL';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidTrigger(trigger: unknown): trigger is NotificationTrigger {
|
||||
if (!isPlainObject(trigger)) {
|
||||
return false;
|
||||
|
|
@ -496,7 +521,11 @@ function validateProviderConnectionsSection(
|
|||
const anthropicUpdate: Partial<ProviderConnectionsConfig['anthropic']> = {};
|
||||
|
||||
for (const [connectionKey, connectionValue] of Object.entries(value)) {
|
||||
if (connectionKey !== 'authMode' && connectionKey !== 'fastModeDefault') {
|
||||
if (
|
||||
connectionKey !== 'authMode' &&
|
||||
connectionKey !== 'fastModeDefault' &&
|
||||
connectionKey !== 'compatibleEndpoint'
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `providerConnections.anthropic.${connectionKey} is not a valid setting`,
|
||||
|
|
@ -519,6 +548,64 @@ function validateProviderConnectionsSection(
|
|||
continue;
|
||||
}
|
||||
|
||||
if (connectionKey === 'compatibleEndpoint') {
|
||||
if (!isPlainObject(connectionValue)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.anthropic.compatibleEndpoint must be an object',
|
||||
};
|
||||
}
|
||||
|
||||
const compatibleEndpoint: Partial<
|
||||
ProviderConnectionsConfig['anthropic']['compatibleEndpoint']
|
||||
> = {};
|
||||
for (const [endpointKey, endpointValue] of Object.entries(connectionValue)) {
|
||||
if (endpointKey !== 'enabled' && endpointKey !== 'baseUrl') {
|
||||
return {
|
||||
valid: false,
|
||||
error: `providerConnections.anthropic.compatibleEndpoint.${endpointKey} is not a valid setting`,
|
||||
};
|
||||
}
|
||||
|
||||
if (endpointKey === 'enabled') {
|
||||
if (typeof endpointValue !== 'boolean') {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'providerConnections.anthropic.compatibleEndpoint.enabled must be a boolean',
|
||||
};
|
||||
}
|
||||
compatibleEndpoint.enabled = endpointValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof endpointValue !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.anthropic.compatibleEndpoint.baseUrl must be a string',
|
||||
};
|
||||
}
|
||||
|
||||
const error = validateAnthropicCompatibleBaseUrl(endpointValue);
|
||||
if (error) {
|
||||
return { valid: false, error };
|
||||
}
|
||||
compatibleEndpoint.baseUrl = endpointValue.trim();
|
||||
}
|
||||
|
||||
if (compatibleEndpoint.enabled === true && !compatibleEndpoint.baseUrl?.trim()) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'providerConnections.anthropic.compatibleEndpoint.baseUrl is required when enabled',
|
||||
};
|
||||
}
|
||||
|
||||
anthropicUpdate.compatibleEndpoint =
|
||||
compatibleEndpoint as ProviderConnectionsConfig['anthropic']['compatibleEndpoint'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof connectionValue !== 'boolean') {
|
||||
return {
|
||||
valid: false,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ import {
|
|||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTART_MEMBER,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_MEMBER,
|
||||
TEAM_RESTORE_TASK,
|
||||
TEAM_RETRY_FAILED_OPENCODE_SECONDARY_LANES,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
|
|
@ -93,7 +94,7 @@ import {
|
|||
TEAM_VALIDATE_CLI_ARGS,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, wrapAgentBlock } from '@shared/constants/agentBlocks';
|
||||
import { wrapAgentBlock } from '@shared/constants/agentBlocks';
|
||||
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
|
||||
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
||||
|
|
@ -148,7 +149,10 @@ import { TeamConfigReader } from '../services/team/TeamConfigReader';
|
|||
import { readTeamLaunchFailureDiagnosticsBundle } from '../services/team/TeamLaunchFailureArtifactPack';
|
||||
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||
import {
|
||||
buildAddMemberSpawnMessage,
|
||||
type RuntimeBootstrapMemberMcpLaunchConfig,
|
||||
} from '../services/team/TeamProvisioningService';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
||||
|
||||
|
|
@ -745,6 +749,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember);
|
||||
ipcMain.handle(TEAM_REPLACE_MEMBERS, handleReplaceMembers);
|
||||
ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember);
|
||||
ipcMain.handle(TEAM_RESTORE_MEMBER, handleRestoreMember);
|
||||
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
||||
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
||||
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||||
|
|
@ -832,6 +837,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_ADD_MEMBER);
|
||||
ipcMain.removeHandler(TEAM_REPLACE_MEMBERS);
|
||||
ipcMain.removeHandler(TEAM_REMOVE_MEMBER);
|
||||
ipcMain.removeHandler(TEAM_RESTORE_MEMBER);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
||||
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
||||
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
||||
|
|
@ -1560,6 +1566,7 @@ interface RuntimeRosterMutationMember {
|
|||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
cwd?: string;
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
|
|
@ -1599,6 +1606,40 @@ function isOpenCodeLedRoster(members: RuntimeRosterMutationMember[]): boolean {
|
|||
return normalizeOptionalTeamProviderId(leadMember?.providerId) === 'opencode';
|
||||
}
|
||||
|
||||
async function sendLiveAddMemberSpawnPrompt(input: {
|
||||
provisioning: TeamProvisioningService;
|
||||
teamName: string;
|
||||
displayName: string;
|
||||
leadName: string;
|
||||
projectPath?: string;
|
||||
member: RuntimeRosterMutationMember;
|
||||
}): Promise<void> {
|
||||
let mcpLaunchConfig: RuntimeBootstrapMemberMcpLaunchConfig | null = null;
|
||||
try {
|
||||
mcpLaunchConfig = await input.provisioning.prepareLiveMemberMcpLaunchConfig({
|
||||
teamName: input.teamName,
|
||||
cwd: input.member.cwd?.trim() || input.projectPath,
|
||||
mcpPolicy: input.member.mcpPolicy,
|
||||
});
|
||||
const spawnMessage = buildAddMemberSpawnMessage(
|
||||
input.teamName,
|
||||
input.displayName,
|
||||
input.leadName,
|
||||
input.member,
|
||||
mcpLaunchConfig
|
||||
);
|
||||
await input.provisioning.sendMessageToTeam(input.teamName, spawnMessage);
|
||||
} catch (error) {
|
||||
await input.provisioning
|
||||
.discardLiveMemberMcpLaunchConfig({
|
||||
teamName: input.teamName,
|
||||
mcpLaunchConfig,
|
||||
})
|
||||
.catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function didOpenCodeRosterMemberChange(
|
||||
previous: RuntimeRosterMutationMember | undefined,
|
||||
next: RuntimeRosterMutationMember | undefined
|
||||
|
|
@ -2745,27 +2786,27 @@ function buildMessageDeliveryText(
|
|||
'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
|
||||
];
|
||||
hiddenBlocks.push(
|
||||
[
|
||||
AGENT_BLOCK_OPEN,
|
||||
`You received a direct message from ${senderDescriptor} via the UI.`,
|
||||
...replyInstructionLines,
|
||||
`Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`,
|
||||
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
|
||||
...(canUseAgentTeamsMessageSend
|
||||
? [
|
||||
'If neither Agent Teams MCP message_send tool name is available before any visible-message tool attempt, write exactly the concise reply text as normal assistant text so the runtime can relay it.',
|
||||
]
|
||||
: []),
|
||||
...(isUserReplyRecipient
|
||||
? [
|
||||
'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").',
|
||||
'Only after that first acknowledgement may you message the lead or another teammate.',
|
||||
'After you get the needed information, send the final answer back to "user".',
|
||||
'Do NOT stay silent while you go ask someone else.',
|
||||
]
|
||||
: []),
|
||||
AGENT_BLOCK_CLOSE,
|
||||
].join('\n')
|
||||
wrapAgentBlock(
|
||||
[
|
||||
`You received a direct message from ${senderDescriptor} via the UI.`,
|
||||
...replyInstructionLines,
|
||||
`Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`,
|
||||
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
|
||||
...(canUseAgentTeamsMessageSend
|
||||
? [
|
||||
'If neither Agent Teams MCP message_send tool name is available before any visible-message tool attempt, write exactly the concise reply text as normal assistant text so the runtime can relay it.',
|
||||
]
|
||||
: []),
|
||||
...(isUserReplyRecipient
|
||||
? [
|
||||
'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").',
|
||||
'Only after that first acknowledgement may you message the lead or another teammate.',
|
||||
'After you get the needed information, send the final answer back to "user".',
|
||||
'Do NOT stay silent while you go ask someone else.',
|
||||
]
|
||||
: []),
|
||||
].join('\n')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3021,10 +3062,12 @@ async function handleSendMessage(
|
|||
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
|
||||
...(rosterContextBlock ? [rosterContextBlock] : []),
|
||||
...(delegateAckBlock ? [delegateAckBlock] : []),
|
||||
AGENT_BLOCK_OPEN,
|
||||
`MessageId: ${preGeneratedMessageId}`,
|
||||
`When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`,
|
||||
AGENT_BLOCK_CLOSE,
|
||||
wrapAgentBlock(
|
||||
[
|
||||
`MessageId: ${preGeneratedMessageId}`,
|
||||
`When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`,
|
||||
].join('\n')
|
||||
),
|
||||
``,
|
||||
`Message from user:`,
|
||||
buildMessageDeliveryText(payload.text!, {
|
||||
|
|
@ -4343,8 +4386,8 @@ async function handleAddMember(
|
|||
const memberName = vName.value!;
|
||||
const teamDataService = getTeamDataService();
|
||||
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
|
||||
const previousMembers = (await teamDataService.getTeamData(tn))
|
||||
.members as RuntimeRosterMutationMember[];
|
||||
const previousTeamData = await teamDataService.getTeamData(tn);
|
||||
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isTeamAlive = provisioning.isTeamAlive(tn);
|
||||
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
|
||||
|
|
@ -4396,20 +4439,29 @@ async function handleAddMember(
|
|||
} catch {
|
||||
// Best-effort: fall back to default lead and team names
|
||||
}
|
||||
const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, {
|
||||
name: memberName,
|
||||
...(typeof role === 'string' ? { role } : {}),
|
||||
...(typeof workflow === 'string' ? { workflow } : {}),
|
||||
...(isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
|
||||
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
|
||||
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
|
||||
...(effortValidation.value ? { effort: effortValidation.value } : {}),
|
||||
});
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, spawnMessage);
|
||||
} catch {
|
||||
await sendLiveAddMemberSpawnPrompt({
|
||||
provisioning,
|
||||
teamName: tn,
|
||||
displayName,
|
||||
leadName,
|
||||
projectPath: previousTeamData.config?.projectPath,
|
||||
member: {
|
||||
name: memberName,
|
||||
...(typeof role === 'string' ? { role } : {}),
|
||||
...(typeof workflow === 'string' ? { workflow } : {}),
|
||||
...(isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
|
||||
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
|
||||
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
|
||||
...(effortValidation.value ? { effort: effortValidation.value } : {}),
|
||||
mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Best-effort: lead process may not be responsive
|
||||
logger.warn(`Failed to notify lead about new member "${memberName}" in ${tn}`);
|
||||
logger.warn(
|
||||
`Failed to notify lead about new member "${memberName}" in ${tn}: ${getErrorMessage(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -4517,8 +4569,8 @@ async function handleReplaceMembers(
|
|||
const tn = vTeam.value!;
|
||||
const teamDataService = getTeamDataService();
|
||||
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
|
||||
const previousMembers = (await teamDataService.getTeamData(tn))
|
||||
.members as RuntimeRosterMutationMember[];
|
||||
const previousTeamData = await teamDataService.getTeamData(tn);
|
||||
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isTeamAlive = provisioning.isTeamAlive(tn);
|
||||
const useSecondaryOpenCodeLaneRouting = isTeamAlive && !isOpenCodeLedRoster(previousMembers);
|
||||
|
|
@ -4636,11 +4688,19 @@ async function handleReplaceMembers(
|
|||
}
|
||||
|
||||
for (const addedMember of primaryDiff.added) {
|
||||
const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, addedMember);
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, spawnMessage);
|
||||
} catch {
|
||||
logger.warn(`Failed to notify lead about new member "${addedMember.name}" in ${tn}`);
|
||||
await sendLiveAddMemberSpawnPrompt({
|
||||
provisioning,
|
||||
teamName: tn,
|
||||
displayName,
|
||||
leadName,
|
||||
projectPath: previousTeamData.config?.projectPath,
|
||||
member: addedMember,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to notify lead about new member "${addedMember.name}" in ${tn}: ${getErrorMessage(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4671,8 +4731,8 @@ async function handleRemoveMember(
|
|||
const name = vMember.value!;
|
||||
const teamDataService = getTeamDataService();
|
||||
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
|
||||
const previousMembers = (await teamDataService.getTeamData(tn))
|
||||
.members as RuntimeRosterMutationMember[];
|
||||
const previousTeamData = await teamDataService.getTeamData(tn);
|
||||
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isTeamAlive = provisioning.isTeamAlive(tn);
|
||||
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
|
||||
|
|
@ -4715,6 +4775,85 @@ async function handleRemoveMember(
|
|||
});
|
||||
}
|
||||
|
||||
async function handleRestoreMember(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
memberName: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
const vMember = validateMemberName(memberName);
|
||||
if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' };
|
||||
|
||||
return wrapTeamHandler('restoreMember', async () => {
|
||||
const tn = vTeam.value!;
|
||||
const name = vMember.value!;
|
||||
const teamDataService = getTeamDataService();
|
||||
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
|
||||
const previousTeamData = await teamDataService.getTeamData(tn);
|
||||
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isTeamAlive = provisioning.isTeamAlive(tn);
|
||||
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
|
||||
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
|
||||
}
|
||||
|
||||
const restoredMember = await teamDataService.restoreMember(tn, name);
|
||||
invalidateTeamRosterSnapshotCaches(tn);
|
||||
|
||||
if (!isTeamAlive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpenCodeRosterMutationMember(restoredMember)) {
|
||||
try {
|
||||
await provisioning.reattachOpenCodeOwnedMemberLane(tn, name, {
|
||||
reason: 'member_added',
|
||||
});
|
||||
} catch (error) {
|
||||
await rollbackOpenCodeLiveRosterMutation({
|
||||
teamName: tn,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
detachOpenCodeMemberNames: [name],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let leadName = 'team-lead';
|
||||
let displayName = tn;
|
||||
try {
|
||||
const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
|
||||
teamDataService.getLeadMemberName(tn),
|
||||
teamDataService.getTeamDisplayName(tn),
|
||||
]);
|
||||
leadName = resolvedLeadName || 'team-lead';
|
||||
displayName = resolvedDisplayName || tn;
|
||||
} catch {
|
||||
// Best-effort: fall back to default lead and team names
|
||||
}
|
||||
|
||||
try {
|
||||
await sendLiveAddMemberSpawnPrompt({
|
||||
provisioning,
|
||||
teamName: tn,
|
||||
displayName,
|
||||
leadName,
|
||||
projectPath: previousTeamData.config?.projectPath,
|
||||
member: restoredMember,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to notify lead about restore of "${name}" in ${tn}: ${getErrorMessage(error)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUpdateTaskFields(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ import {
|
|||
} from '@main/types';
|
||||
import {
|
||||
analyzeSessionFileMetadata,
|
||||
extractCwd,
|
||||
extractCwdFromKnownJsonlFile,
|
||||
type SessionFileMetadata,
|
||||
} from '@main/utils/jsonl';
|
||||
import {
|
||||
|
|
@ -106,6 +106,44 @@ export interface ProjectScannerOptions {
|
|||
sessionIndexDir?: string;
|
||||
/** Test hook: set to 0 to persist index files without debounce. */
|
||||
sessionIndexPersistDelayMs?: number;
|
||||
/**
|
||||
* Bounds local stat/read work during project discovery.
|
||||
* This keeps startup scans from saturating libuv while other services initialize.
|
||||
*/
|
||||
scanFileIoConcurrency?: number;
|
||||
/**
|
||||
* Upper bound for a full project-directory scan. If exhausted, the previous
|
||||
* complete scan is reused when available; otherwise completed projects are
|
||||
* returned without caching the partial result.
|
||||
*/
|
||||
scanBudgetMs?: number;
|
||||
}
|
||||
|
||||
interface ScanInFlight {
|
||||
generation: number;
|
||||
promise: Promise<Project[]>;
|
||||
}
|
||||
|
||||
interface RepositoryGroupScanInFlight {
|
||||
generation: number;
|
||||
promise: Promise<RepositoryGroup[]>;
|
||||
}
|
||||
|
||||
interface ProjectScanWithTimeoutResult {
|
||||
projects: Project[];
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
interface ScanProjectOptions {
|
||||
shouldCommitSubprojects?: () => boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
class ScanProjectAbortedError extends Error {
|
||||
constructor() {
|
||||
super('Project scan aborted');
|
||||
this.name = 'ScanProjectAbortedError';
|
||||
}
|
||||
}
|
||||
|
||||
function splitPathSegments(value: string): string[] {
|
||||
|
|
@ -184,7 +222,12 @@ export class ProjectScanner {
|
|||
// Short-lived scan cache to prevent duplicate scans within the same request cycle.
|
||||
// Both getProjects() and getRepositoryGroups() call scan() — the cache deduplicates.
|
||||
private scanCache: { projects: Project[]; timestamp: number } | null = null;
|
||||
private scanInFlight: ScanInFlight | null = null;
|
||||
private repositoryGroupInFlight: RepositoryGroupScanInFlight | null = null;
|
||||
private scanGeneration = 0;
|
||||
private static readonly SCAN_CACHE_TTL_MS = 2000;
|
||||
private static readonly SCAN_BUDGET_MS = 24_000;
|
||||
private static readonly MIN_SCAN_BATCH_BUDGET_MS = 1000;
|
||||
|
||||
/** Cached project list for search — avoids re-scanning disk on every query */
|
||||
private searchProjectCache: { projects: Project[]; timestamp: number } | null = null;
|
||||
|
|
@ -192,6 +235,9 @@ export class ProjectScanner {
|
|||
// Platform-aware batch sizes to avoid UV thread pool saturation on Windows
|
||||
private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 16 : 64;
|
||||
private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 4 : 12;
|
||||
private static readonly LOCAL_SCAN_FILE_IO_CONCURRENCY = process.platform === 'win32' ? 8 : 16;
|
||||
private static readonly EXHAUSTIVE_CWD_SPLIT_FILE_LIMIT = 12;
|
||||
private static readonly CWD_HINT_SAMPLE_FILE_LIMIT = 1;
|
||||
|
||||
// Delegated services
|
||||
private readonly fsProvider: FileSystemProvider;
|
||||
|
|
@ -200,6 +246,10 @@ export class ProjectScanner {
|
|||
private readonly sessionSearcher: SessionSearcher;
|
||||
private readonly projectPathResolver: ProjectPathResolver;
|
||||
private readonly sessionMetadataIndex: SessionMetadataIndex | null;
|
||||
private readonly scanFileIoConcurrency: number;
|
||||
private readonly scanBudgetMs: number;
|
||||
private scanFileIoActive = 0;
|
||||
private readonly scanFileIoQueue: Array<() => void> = [];
|
||||
|
||||
constructor(
|
||||
projectsDir?: string,
|
||||
|
|
@ -223,6 +273,17 @@ export class ProjectScanner {
|
|||
persistDelayMs: options?.sessionIndexPersistDelayMs,
|
||||
})
|
||||
: null;
|
||||
const configuredScanFileIoConcurrency = options?.scanFileIoConcurrency;
|
||||
this.scanFileIoConcurrency =
|
||||
typeof configuredScanFileIoConcurrency === 'number' &&
|
||||
Number.isFinite(configuredScanFileIoConcurrency)
|
||||
? Math.max(1, Math.floor(configuredScanFileIoConcurrency))
|
||||
: ProjectScanner.LOCAL_SCAN_FILE_IO_CONCURRENCY;
|
||||
const configuredScanBudgetMs = options?.scanBudgetMs;
|
||||
this.scanBudgetMs =
|
||||
typeof configuredScanBudgetMs === 'number' && Number.isFinite(configuredScanBudgetMs)
|
||||
? Math.max(1, Math.floor(configuredScanBudgetMs))
|
||||
: ProjectScanner.SCAN_BUDGET_MS;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -243,8 +304,34 @@ export class ProjectScanner {
|
|||
return this.scanCache.projects;
|
||||
}
|
||||
|
||||
const generation = this.scanGeneration;
|
||||
const inFlight = this.scanInFlight;
|
||||
if (inFlight) {
|
||||
if (inFlight.generation === generation) {
|
||||
return inFlight.promise;
|
||||
}
|
||||
await inFlight.promise.catch(() => undefined);
|
||||
if (this.scanInFlight?.promise === inFlight.promise) {
|
||||
this.scanInFlight = null;
|
||||
}
|
||||
return this.scan();
|
||||
}
|
||||
|
||||
const promise = this.performScan(generation);
|
||||
this.scanInFlight = { generation, promise };
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (this.scanInFlight?.promise === promise) {
|
||||
this.scanInFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async performScan(generation: number): Promise<Project[]> {
|
||||
const startedAt = Date.now();
|
||||
let stage = 'start';
|
||||
let previousSubprojectRegistry: ReturnType<typeof subprojectRegistry.snapshot> | null = null;
|
||||
const slowWarnAfterMs = 10_000;
|
||||
const slowWarnTimer = setTimeout(() => {
|
||||
logger.warn(
|
||||
|
|
@ -259,11 +346,12 @@ export class ProjectScanner {
|
|||
}
|
||||
|
||||
// Clear the subproject registry on full re-scan
|
||||
previousSubprojectRegistry = subprojectRegistry.snapshot();
|
||||
subprojectRegistry.clear();
|
||||
|
||||
stage = 'readdirProjectsDir';
|
||||
const readdirStartedAt = Date.now();
|
||||
const entries = await this.fsProvider.readdir(this.projectsDir);
|
||||
const entries = await this.readdirForProjectDiscovery(this.projectsDir);
|
||||
const readdirMs = Date.now() - readdirStartedAt;
|
||||
if (readdirMs >= 2000) {
|
||||
logger.warn(`[scan] readdir slow ms=${readdirMs} entries=${entries.length}`);
|
||||
|
|
@ -276,10 +364,9 @@ export class ProjectScanner {
|
|||
|
||||
// Process each project directory (may return multiple projects per dir)
|
||||
stage = 'scanProjects';
|
||||
const projectArrays = await this.collectFulfilledInBatches(
|
||||
const { projectArrays, completedAll, slowest } = await this.scanProjectDirsWithBudget(
|
||||
projectDirs,
|
||||
this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH,
|
||||
async (dir) => this.scanProjectWithTimeout(dir.name)
|
||||
startedAt
|
||||
);
|
||||
|
||||
// Flatten and sort by most recent
|
||||
|
|
@ -295,12 +382,28 @@ export class ProjectScanner {
|
|||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 5000) {
|
||||
logger.warn(
|
||||
`[scan] completed slow ms=${ms} projectDirs=${projectDirs.length} projects=${validProjects.length}`
|
||||
`[scan] completed slow ms=${ms} projectDirs=${projectDirs.length} projects=${validProjects.length} slowest=${JSON.stringify(
|
||||
slowest.slice(0, 5)
|
||||
)}`
|
||||
);
|
||||
}
|
||||
this.scanCache = { projects: validProjects, timestamp: Date.now() };
|
||||
if (!completedAll && this.scanCache) {
|
||||
if (previousSubprojectRegistry) {
|
||||
subprojectRegistry.restore(previousSubprojectRegistry);
|
||||
}
|
||||
logger.warn(
|
||||
`[scan] returning cached complete result after budget exhaustion projects=${this.scanCache.projects.length}`
|
||||
);
|
||||
return this.scanCache.projects;
|
||||
}
|
||||
if (completedAll && this.scanGeneration === generation) {
|
||||
this.scanCache = { projects: validProjects, timestamp: Date.now() };
|
||||
}
|
||||
return validProjects;
|
||||
} catch (error) {
|
||||
if (previousSubprojectRegistry && this.scanCache) {
|
||||
subprojectRegistry.restore(previousSubprojectRegistry);
|
||||
}
|
||||
logger.error('Error scanning projects directory:', error);
|
||||
return [];
|
||||
} finally {
|
||||
|
|
@ -314,6 +417,14 @@ export class ProjectScanner {
|
|||
*/
|
||||
clearScanCache(): void {
|
||||
this.scanCache = null;
|
||||
this.scanGeneration += 1;
|
||||
}
|
||||
|
||||
private async readdirForProjectDiscovery(dirPath: string): Promise<FsDirent[]> {
|
||||
if (this.fsProvider instanceof LocalFileSystemProvider) {
|
||||
return this.fsProvider.readdir(dirPath, { prefetchEntryStats: false });
|
||||
}
|
||||
return this.fsProvider.readdir(dirPath);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -332,6 +443,31 @@ export class ProjectScanner {
|
|||
* @returns Promise resolving to RepositoryGroups sorted by most recent activity
|
||||
*/
|
||||
async scanWithWorktreeGrouping(): Promise<RepositoryGroup[]> {
|
||||
const generation = this.scanGeneration;
|
||||
const inFlight = this.repositoryGroupInFlight;
|
||||
if (inFlight) {
|
||||
if (inFlight.generation === generation) {
|
||||
return inFlight.promise;
|
||||
}
|
||||
await inFlight.promise.catch(() => undefined);
|
||||
if (this.repositoryGroupInFlight?.promise === inFlight.promise) {
|
||||
this.repositoryGroupInFlight = null;
|
||||
}
|
||||
return this.scanWithWorktreeGrouping();
|
||||
}
|
||||
|
||||
const promise = this.performScanWithWorktreeGrouping();
|
||||
this.repositoryGroupInFlight = { generation, promise };
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (this.repositoryGroupInFlight?.promise === promise) {
|
||||
this.repositoryGroupInFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async performScanWithWorktreeGrouping(): Promise<RepositoryGroup[]> {
|
||||
try {
|
||||
// 1. Scan all projects using existing logic
|
||||
const projects = await this.scan();
|
||||
|
|
@ -432,34 +568,142 @@ export class ProjectScanner {
|
|||
|
||||
/**
|
||||
* Scans a single project directory with a timeout guard.
|
||||
* Returns empty array if the scan exceeds the timeout.
|
||||
* Timeout is reported separately from an actually empty project directory so
|
||||
* partial scans cannot be mistaken for complete results.
|
||||
*/
|
||||
private async scanProjectWithTimeout(encodedName: string): Promise<Project[]> {
|
||||
private async scanProjectWithTimeout(
|
||||
encodedName: string,
|
||||
timeoutMs = ProjectScanner.SCAN_PROJECT_TIMEOUT_MS,
|
||||
options: { logTimeout?: boolean } = {}
|
||||
): Promise<ProjectScanWithTimeoutResult> {
|
||||
const abortController = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<Project[]>((resolve) => {
|
||||
let timedOut = false;
|
||||
const effectiveTimeoutMs = Math.max(1, Math.floor(timeoutMs));
|
||||
const timeout = new Promise<ProjectScanWithTimeoutResult>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
logger.warn(
|
||||
`[scanProject] timeout after ${ProjectScanner.SCAN_PROJECT_TIMEOUT_MS}ms project=${encodedName}`
|
||||
);
|
||||
resolve([]);
|
||||
}, ProjectScanner.SCAN_PROJECT_TIMEOUT_MS);
|
||||
timedOut = true;
|
||||
abortController.abort();
|
||||
if (options.logTimeout !== false) {
|
||||
logger.warn(`[scanProject] timeout after ${effectiveTimeoutMs}ms project=${encodedName}`);
|
||||
}
|
||||
resolve({ projects: [], timedOut: true });
|
||||
}, effectiveTimeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([this.scanProject(encodedName), timeout]);
|
||||
return await Promise.race([
|
||||
this.scanProject(encodedName, {
|
||||
shouldCommitSubprojects: () => !timedOut,
|
||||
signal: abortController.signal,
|
||||
}).then((projects) => ({ projects, timedOut })),
|
||||
timeout,
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private async scanProjectDirsWithBudget(
|
||||
projectDirs: FsDirent[],
|
||||
scanStartedAt: number
|
||||
): Promise<{
|
||||
projectArrays: Project[][];
|
||||
completedAll: boolean;
|
||||
slowest: Array<{ project: string; ms: number; returned: number }>;
|
||||
}> {
|
||||
const workerCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH,
|
||||
projectDirs.length
|
||||
)
|
||||
);
|
||||
const projectArrays: Project[][] = [];
|
||||
const slowest: Array<{ project: string; ms: number; returned: number }> = [];
|
||||
let nextIndex = 0;
|
||||
let startedDirs = 0;
|
||||
let completedAll = true;
|
||||
|
||||
const claimNextDir = (): { dir: FsDirent; remainingBudgetMs: number } | null => {
|
||||
if (nextIndex >= projectDirs.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - scanStartedAt;
|
||||
const remainingBudgetMs = this.scanBudgetMs - elapsedMs;
|
||||
if (remainingBudgetMs < ProjectScanner.MIN_SCAN_BATCH_BUDGET_MS) {
|
||||
completedAll = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const dir = projectDirs[nextIndex];
|
||||
nextIndex += 1;
|
||||
startedDirs += 1;
|
||||
return { dir, remainingBudgetMs };
|
||||
};
|
||||
|
||||
const workers = new Array(workerCount).fill(0).map(async () => {
|
||||
while (true) {
|
||||
const claimed = claimNextDir();
|
||||
if (!claimed) {
|
||||
return;
|
||||
}
|
||||
const { dir, remainingBudgetMs } = claimed;
|
||||
const perProjectTimeoutMs = Math.min(
|
||||
ProjectScanner.SCAN_PROJECT_TIMEOUT_MS,
|
||||
remainingBudgetMs
|
||||
);
|
||||
try {
|
||||
const projectStartedAt = Date.now();
|
||||
const result = await this.scanProjectWithTimeout(dir.name, perProjectTimeoutMs, {
|
||||
logTimeout: false,
|
||||
});
|
||||
const ms = Date.now() - projectStartedAt;
|
||||
if (ms >= 500) {
|
||||
slowest.push({ project: dir.name, ms, returned: result.projects.length });
|
||||
}
|
||||
if (result.timedOut) {
|
||||
completedAll = false;
|
||||
}
|
||||
projectArrays.push(result.projects);
|
||||
} catch {
|
||||
completedAll = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
|
||||
if (startedDirs < projectDirs.length) {
|
||||
completedAll = false;
|
||||
}
|
||||
|
||||
slowest.sort((a, b) => b.ms - a.ms);
|
||||
if (!completedAll) {
|
||||
logger.warn(
|
||||
`[scan] budget exhausted ms=${Date.now() - scanStartedAt} scannedDirs=${startedDirs}/${projectDirs.length} returnedProjectArrays=${projectArrays.length} slowest=${JSON.stringify(
|
||||
slowest.slice(0, 5)
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
return { projectArrays, completedAll, slowest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans a single project directory and returns project metadata.
|
||||
* If sessions have different cwd values, splits into multiple projects.
|
||||
*/
|
||||
private async scanProject(encodedName: string): Promise<Project[]> {
|
||||
private async scanProject(
|
||||
encodedName: string,
|
||||
options: ScanProjectOptions = {}
|
||||
): Promise<Project[]> {
|
||||
try {
|
||||
this.throwIfScanAborted(options.signal);
|
||||
const projectPath = path.join(this.projectsDir, encodedName);
|
||||
const readdirStart = Date.now();
|
||||
const entries = await this.fsProvider.readdir(projectPath);
|
||||
const entries = await this.readdirForProjectDiscovery(projectPath);
|
||||
this.throwIfScanAborted(options.signal);
|
||||
const readdirMs = Date.now() - readdirStart;
|
||||
|
||||
// Get session files (.jsonl at root level)
|
||||
|
|
@ -486,46 +730,63 @@ export class ProjectScanner {
|
|||
cwd: string | null;
|
||||
}
|
||||
|
||||
// Reading JSONL heads for cwd across hundreds/thousands of sessions can saturate I/O and
|
||||
// make the renderer appear frozen while waiting for repository groups.
|
||||
// Prefer correctness for small projects; for large ones, skip cwd splitting and fall back
|
||||
// to encoded-path decoding / limited path probing.
|
||||
const MAX_CWD_SPLIT_FILES = 80;
|
||||
// Reading JSONL heads for cwd can dominate startup when cold I/O is already contended.
|
||||
// Keep cwd splitting exact for small project dirs. For larger dirs, read only a newest-session
|
||||
// cwd hint so the project path stays accurate without building a partial subproject split.
|
||||
const shouldSplitByCwd =
|
||||
this.fsProvider.type !== 'ssh' && sessionFiles.length <= MAX_CWD_SPLIT_FILES;
|
||||
this.fsProvider.type !== 'ssh' &&
|
||||
sessionFiles.length <= ProjectScanner.EXHAUSTIVE_CWD_SPLIT_FILE_LIMIT;
|
||||
|
||||
const sessionStatStart = Date.now();
|
||||
const sessionInfos = await this.collectFulfilledInBatches(
|
||||
sessionFiles,
|
||||
this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH,
|
||||
async (file) => {
|
||||
this.throwIfScanAborted(options.signal);
|
||||
const filePath = path.join(projectPath, file.name);
|
||||
const { mtimeMs, birthtimeMs } = await this.resolveFileDetails(file, filePath);
|
||||
let cwd: string | null = null;
|
||||
|
||||
// Over SSH, avoid reading every file body during project discovery.
|
||||
if (shouldSplitByCwd) {
|
||||
try {
|
||||
cwd = await extractCwd(filePath, this.fsProvider);
|
||||
} catch {
|
||||
// Ignore unreadable files
|
||||
}
|
||||
}
|
||||
const { mtimeMs, birthtimeMs } = await this.runScanFileIo(
|
||||
() => this.resolveFileDetails(file, filePath),
|
||||
options.signal
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: extractSessionId(file.name),
|
||||
filePath,
|
||||
mtimeMs,
|
||||
birthtimeMs,
|
||||
cwd,
|
||||
cwd: null as string | null,
|
||||
} satisfies SessionInfo;
|
||||
}
|
||||
);
|
||||
this.throwIfScanAborted(options.signal);
|
||||
|
||||
if (sessionInfos.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.fsProvider.type !== 'ssh') {
|
||||
const cwdCandidates = shouldSplitByCwd
|
||||
? sessionInfos
|
||||
: [...sessionInfos]
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
.slice(0, ProjectScanner.CWD_HINT_SAMPLE_FILE_LIMIT);
|
||||
await this.collectFulfilledInBatches(cwdCandidates, 2, async (info) => {
|
||||
try {
|
||||
info.cwd = await this.runScanFileIo(
|
||||
() => extractCwdFromKnownJsonlFile(info.filePath, this.fsProvider),
|
||||
options.signal
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ScanProjectAbortedError || options.signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
// Ignore unreadable files
|
||||
}
|
||||
return null;
|
||||
});
|
||||
this.throwIfScanAborted(options.signal);
|
||||
}
|
||||
|
||||
const sessionStatMs = Date.now() - sessionStatStart;
|
||||
if (sessionFiles.length > 200 || sessionStatMs > 1000) {
|
||||
logger.debug(
|
||||
|
|
@ -565,11 +826,14 @@ export class ProjectScanner {
|
|||
}
|
||||
|
||||
const sessionPaths = sessionInfos.map((s) => s.filePath);
|
||||
this.throwIfScanAborted(options.signal);
|
||||
const actualPath = await this.projectPathResolver.resolveProjectPath(encodedName, {
|
||||
cwdHint: firstCwd ?? undefined,
|
||||
sessionPaths,
|
||||
});
|
||||
this.throwIfScanAborted(options.signal);
|
||||
const filesystemState = await resolveProjectFilesystemState(this.fsProvider, actualPath);
|
||||
this.throwIfScanAborted(options.signal);
|
||||
|
||||
// Derive name from resolved path — more reliable than decodePath for
|
||||
// paths containing dashes (e.g. "test-project" encodes lossily).
|
||||
|
|
@ -590,7 +854,11 @@ export class ProjectScanner {
|
|||
}
|
||||
|
||||
// Multiple unique cwds: split into subprojects
|
||||
const projects: Project[] = [];
|
||||
const pendingProjects: Array<{
|
||||
registryCwd: string;
|
||||
sessionIds: string[];
|
||||
project: Omit<Project, 'id'>;
|
||||
}> = [];
|
||||
|
||||
// Find the "root" cwd (shortest path, or the one matching the decoded name)
|
||||
const cwdKeys = [...cwdGroups.keys()].filter((k) => !k.startsWith('__decoded__'));
|
||||
|
|
@ -602,16 +870,11 @@ export class ProjectScanner {
|
|||
const rootName = path.basename(rootCwd) || baseName;
|
||||
|
||||
for (const [cwdKey, sessions] of cwdGroups) {
|
||||
this.throwIfScanAborted(options.signal);
|
||||
const isDecodedFallback = cwdKey.startsWith('__decoded__');
|
||||
const actualCwd = isDecodedFallback ? null : cwdKey;
|
||||
|
||||
// Register in subproject registry
|
||||
const sessionIds = sessions.map((s) => s.sessionId);
|
||||
const compositeId = subprojectRegistry.register(
|
||||
encodedName,
|
||||
actualCwd ?? decodedFallback,
|
||||
sessionIds
|
||||
);
|
||||
const exportedSessionIds = sessionIds.slice(0, MAX_SESSION_IDS_EXPORTED);
|
||||
|
||||
// Compute timestamps
|
||||
|
|
@ -635,24 +898,41 @@ export class ProjectScanner {
|
|||
const lastSegment = path.basename(actualCwd);
|
||||
displayName = `${rootName} (${lastSegment})`;
|
||||
}
|
||||
|
||||
projects.push({
|
||||
id: compositeId,
|
||||
path: actualCwd ?? decodedFallback,
|
||||
name: displayName,
|
||||
sessions: exportedSessionIds,
|
||||
totalSessions: sessionIds.length,
|
||||
createdAt: Math.floor(createdAt),
|
||||
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
||||
filesystemState: await resolveProjectFilesystemState(
|
||||
this.fsProvider,
|
||||
actualCwd ?? decodedFallback
|
||||
),
|
||||
const filesystemState = await resolveProjectFilesystemState(
|
||||
this.fsProvider,
|
||||
actualCwd ?? decodedFallback
|
||||
);
|
||||
this.throwIfScanAborted(options.signal);
|
||||
if (options.shouldCommitSubprojects?.() === false) {
|
||||
return [];
|
||||
}
|
||||
pendingProjects.push({
|
||||
registryCwd: actualCwd ?? decodedFallback,
|
||||
sessionIds,
|
||||
project: {
|
||||
path: actualCwd ?? decodedFallback,
|
||||
name: displayName,
|
||||
sessions: exportedSessionIds,
|
||||
totalSessions: sessionIds.length,
|
||||
createdAt: Math.floor(createdAt),
|
||||
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
||||
filesystemState,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return projects;
|
||||
if (options.shouldCommitSubprojects?.() === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return pendingProjects.map(({ registryCwd, sessionIds, project }) => ({
|
||||
id: subprojectRegistry.register(encodedName, registryCwd, sessionIds),
|
||||
...project,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof ScanProjectAbortedError || options.signal?.aborted) {
|
||||
return [];
|
||||
}
|
||||
logger.error(`Error scanning project ${encodedName}:`, error);
|
||||
return [];
|
||||
}
|
||||
|
|
@ -1634,6 +1914,79 @@ export class ProjectScanner {
|
|||
return results;
|
||||
}
|
||||
|
||||
private throwIfScanAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new ScanProjectAbortedError();
|
||||
}
|
||||
}
|
||||
|
||||
private async runScanFileIo<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
this.throwIfScanAborted(signal);
|
||||
if (this.fsProvider.type !== 'local') {
|
||||
const result = await operation();
|
||||
this.throwIfScanAborted(signal);
|
||||
return result;
|
||||
}
|
||||
|
||||
await this.acquireScanFileIoSlot(signal);
|
||||
try {
|
||||
this.throwIfScanAborted(signal);
|
||||
const result = await operation();
|
||||
this.throwIfScanAborted(signal);
|
||||
return result;
|
||||
} finally {
|
||||
this.releaseScanFileIoSlot();
|
||||
}
|
||||
}
|
||||
|
||||
private async acquireScanFileIoSlot(signal?: AbortSignal): Promise<void> {
|
||||
this.throwIfScanAborted(signal);
|
||||
if (this.scanFileIoActive < this.scanFileIoConcurrency) {
|
||||
this.scanFileIoActive += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let resume: (() => void) | null = null;
|
||||
const cleanup = () => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
};
|
||||
const onAbort = () => {
|
||||
const queuedResume = resume;
|
||||
if (!queuedResume) {
|
||||
return;
|
||||
}
|
||||
resume = null;
|
||||
const index = this.scanFileIoQueue.indexOf(queuedResume);
|
||||
if (index >= 0) {
|
||||
this.scanFileIoQueue.splice(index, 1);
|
||||
}
|
||||
cleanup();
|
||||
reject(new ScanProjectAbortedError());
|
||||
};
|
||||
resume = () => {
|
||||
if (!resume) {
|
||||
return;
|
||||
}
|
||||
resume = null;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
this.scanFileIoQueue.push(resume);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
this.throwIfScanAborted(signal);
|
||||
}
|
||||
|
||||
private releaseScanFileIoSlot(): void {
|
||||
const next = this.scanFileIoQueue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
this.scanFileIoActive = Math.max(0, this.scanFileIoActive - 1);
|
||||
}
|
||||
|
||||
private getErrorCode(error: unknown): string {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ interface SubprojectEntry {
|
|||
sessionIds: Set<string>;
|
||||
}
|
||||
|
||||
export interface SubprojectRegistrySnapshotEntry {
|
||||
id: string;
|
||||
baseDir: string;
|
||||
cwd: string;
|
||||
sessionIds: string[];
|
||||
}
|
||||
|
||||
class SubprojectRegistryImpl {
|
||||
private readonly entries = new Map<string, SubprojectEntry>();
|
||||
|
||||
|
|
@ -92,6 +99,33 @@ class SubprojectRegistryImpl {
|
|||
clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a deep copy of the current registry so an interrupted scan can
|
||||
* restore the previous complete project view.
|
||||
*/
|
||||
snapshot(): SubprojectRegistrySnapshotEntry[] {
|
||||
return [...this.entries.entries()].map(([id, entry]) => ({
|
||||
id,
|
||||
baseDir: entry.baseDir,
|
||||
cwd: entry.cwd,
|
||||
sessionIds: [...entry.sessionIds],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the registry contents with a previously captured snapshot.
|
||||
*/
|
||||
restore(snapshot: readonly SubprojectRegistrySnapshotEntry[]): void {
|
||||
this.entries.clear();
|
||||
for (const entry of snapshot) {
|
||||
this.entries.set(entry.id, {
|
||||
baseDir: entry.baseDir,
|
||||
cwd: entry.cwd,
|
||||
sessionIds: new Set(entry.sessionIds),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Module-level singleton */
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ const GET_STATUS_TIMEOUT_MS = 30_000;
|
|||
/** Overall timeout for the auth status check (covers both attempts + retry delay) (ms) */
|
||||
const AUTH_TOTAL_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Initial multimodel provider status budget for startup metadata (final status hydrates async). */
|
||||
const MULTIMODEL_PROVIDER_STATUS_INITIAL_TIMEOUT_MS = 1_500;
|
||||
const GET_STATUS_TIMING_LOG_THRESHOLD_MS = 2_000;
|
||||
|
||||
/** Max retries for EBUSY (antivirus scanning the new binary) */
|
||||
const EBUSY_MAX_RETRIES = 3;
|
||||
|
||||
|
|
@ -224,10 +228,16 @@ function mergeProviderStatusCatalogCache(
|
|||
): CliProviderStatus {
|
||||
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null;
|
||||
const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null;
|
||||
const shouldPreserveCurrentModels =
|
||||
incomingProvider.models.length === 0 ||
|
||||
(incomingProvider.providerId === 'opencode' &&
|
||||
incomingProvider.modelCatalog == null &&
|
||||
incomingProvider.runtimeCapabilities?.modelCatalog?.dynamic === true &&
|
||||
currentProvider.models.length > incomingProvider.models.length);
|
||||
|
||||
return {
|
||||
...incomingProvider,
|
||||
models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models,
|
||||
models: shouldPreserveCurrentModels ? currentProvider.models : incomingProvider.models,
|
||||
modelCatalog,
|
||||
modelCatalogRefreshState:
|
||||
modelCatalog && incomingRefreshState !== 'error'
|
||||
|
|
@ -391,6 +401,12 @@ interface CliInstallerStatusRunDiag {
|
|||
authStdoutTail: string;
|
||||
authTimedOut: boolean;
|
||||
gatherError: string | null;
|
||||
shellEnvMs: number | null;
|
||||
binaryResolveMs: number | null;
|
||||
versionProbeMs: number | null;
|
||||
providerInitialWaitMs: number | null;
|
||||
totalMs: number | null;
|
||||
diagWriteScheduled: boolean;
|
||||
}
|
||||
|
||||
function createCliInstallerRunDiag(): CliInstallerStatusRunDiag {
|
||||
|
|
@ -402,6 +418,12 @@ function createCliInstallerRunDiag(): CliInstallerStatusRunDiag {
|
|||
authStdoutTail: '',
|
||||
authTimedOut: false,
|
||||
gatherError: null,
|
||||
shellEnvMs: null,
|
||||
binaryResolveMs: null,
|
||||
versionProbeMs: null,
|
||||
providerInitialWaitMs: null,
|
||||
totalMs: null,
|
||||
diagWriteScheduled: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -413,6 +435,16 @@ function resetGatherDiag(diag: CliInstallerStatusRunDiag): void {
|
|||
diag.authStdoutTail = '';
|
||||
diag.authTimedOut = false;
|
||||
diag.gatherError = null;
|
||||
diag.shellEnvMs = null;
|
||||
diag.binaryResolveMs = null;
|
||||
diag.versionProbeMs = null;
|
||||
diag.providerInitialWaitMs = null;
|
||||
diag.totalMs = null;
|
||||
diag.diagWriteScheduled = false;
|
||||
}
|
||||
|
||||
function cloneCliInstallerRunDiag(diag: CliInstallerStatusRunDiag): CliInstallerStatusRunDiag {
|
||||
return { ...diag };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -431,6 +463,7 @@ export class CliInstallerService {
|
|||
private latestStatusSnapshot: CliInstallationStatus | null = null;
|
||||
private lastHealthyStatusSnapshot: CliInstallationStatus | null = null;
|
||||
private lastHealthyStatusObservedAt = 0;
|
||||
private statusGatherGeneration = 0;
|
||||
private readonly latestProviderSignatures = new Map<CliProviderId, string | null>();
|
||||
|
||||
private rememberHealthyStatus(status: CliInstallationStatus): void {
|
||||
|
|
@ -518,9 +551,53 @@ export class CliInstallerService {
|
|||
authStdoutTail: clipTailForDiag(diag.authStdoutTail, DIAG_AUTH_STDOUT_TAIL),
|
||||
authProbeTimedOut: diag.authTimedOut,
|
||||
gatherThrownError: diag.gatherError,
|
||||
shellEnvMs: diag.shellEnvMs,
|
||||
binaryResolveMs: diag.binaryResolveMs,
|
||||
versionProbeMs: diag.versionProbeMs,
|
||||
providerInitialWaitMs: diag.providerInitialWaitMs,
|
||||
totalMs: diag.totalMs,
|
||||
diagWriteScheduled: diag.diagWriteScheduled,
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleCliInstallerStatusDiag(
|
||||
r: CliInstallationStatus,
|
||||
diag: CliInstallerStatusRunDiag
|
||||
): void {
|
||||
const statusForDiag = cloneCliInstallationStatus(r);
|
||||
const diagForWrite = cloneCliInstallerRunDiag(diag);
|
||||
|
||||
queueMicrotask(() => {
|
||||
const writeStartedAt = Date.now();
|
||||
void this.writeCliInstallerStatusDiag(statusForDiag, diagForWrite)
|
||||
.then(() => {
|
||||
const diagWriteMs = Date.now() - writeStartedAt;
|
||||
if (diagWriteMs >= GET_STATUS_TIMING_LOG_THRESHOLD_MS) {
|
||||
logger.warn(`getStatus diagnostic write slow diagWriteMs=${diagWriteMs}`);
|
||||
}
|
||||
})
|
||||
.catch((diagErr) => {
|
||||
logger.error('writeCliInstallerStatusDiag failed:', getErrorMessage(diagErr));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private logGetStatusTimingIfSlow(diag: CliInstallerStatusRunDiag): void {
|
||||
const totalMs = diag.totalMs ?? 0;
|
||||
if (totalMs < GET_STATUS_TIMING_LOG_THRESHOLD_MS && !diag.authTimedOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`getStatus timing totalMs=${totalMs}` +
|
||||
` shellEnvMs=${diag.shellEnvMs ?? 'n/a'}` +
|
||||
` binaryResolveMs=${diag.binaryResolveMs ?? 'n/a'}` +
|
||||
` versionProbeMs=${diag.versionProbeMs ?? 'n/a'}` +
|
||||
` providerInitialWaitMs=${diag.providerInitialWaitMs ?? 'n/a'}` +
|
||||
` diagWriteScheduled=${diag.diagWriteScheduled}`
|
||||
);
|
||||
}
|
||||
|
||||
setMainWindow(window: BrowserWindow | null): void {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
|
|
@ -530,6 +607,7 @@ export class CliInstallerService {
|
|||
}
|
||||
|
||||
invalidateStatusCache(): void {
|
||||
this.statusGatherGeneration += 1;
|
||||
this.latestStatusSnapshot = null;
|
||||
this.latestProviderSignatures.clear();
|
||||
this.modelAvailabilityService.invalidate();
|
||||
|
|
@ -608,6 +686,14 @@ export class CliInstallerService {
|
|||
});
|
||||
}
|
||||
|
||||
private publishStatusSnapshotIfCurrent(status: CliInstallationStatus, generation: number): void {
|
||||
if (generation !== this.statusGatherGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.publishStatusSnapshot(status);
|
||||
}
|
||||
|
||||
private buildProviderModelAvailabilityContext(
|
||||
binaryPath: string,
|
||||
installedVersion: string | null,
|
||||
|
|
@ -734,6 +820,18 @@ export class CliInstallerService {
|
|||
};
|
||||
}
|
||||
|
||||
private updateLatestProviderStatusIfCurrent(
|
||||
providerStatus: CliProviderStatus,
|
||||
generation: number
|
||||
): boolean {
|
||||
if (generation !== this.statusGatherGeneration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.updateLatestProviderStatus(providerStatus);
|
||||
return true;
|
||||
}
|
||||
|
||||
private getLatestProviderStatusForModelVerification(
|
||||
providerId: CliProviderId,
|
||||
binaryPath: string,
|
||||
|
|
@ -771,6 +869,8 @@ export class CliInstallerService {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getStatus(): Promise<CliInstallationStatus> {
|
||||
const statusStartedAt = Date.now();
|
||||
const generation = ++this.statusGatherGeneration;
|
||||
const result = this.createInitialStatus();
|
||||
this.latestProviderSignatures.clear();
|
||||
this.latestStatusSnapshot = cloneCliInstallationStatus(result);
|
||||
|
|
@ -782,7 +882,7 @@ export class CliInstallerService {
|
|||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
this.gatherStatus(ref, runDiag),
|
||||
this.gatherStatus(ref, runDiag, generation),
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
logger.warn(
|
||||
|
|
@ -800,16 +900,19 @@ export class CliInstallerService {
|
|||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
try {
|
||||
await this.writeCliInstallerStatusDiag(result, runDiag);
|
||||
} catch (diagErr) {
|
||||
logger.error('writeCliInstallerStatusDiag failed:', getErrorMessage(diagErr));
|
||||
}
|
||||
runDiag.totalMs = Date.now() - statusStartedAt;
|
||||
runDiag.diagWriteScheduled = true;
|
||||
this.scheduleCliInstallerStatusDiag(result, runDiag);
|
||||
this.logGetStatusTimingIfSlow(runDiag);
|
||||
}
|
||||
}
|
||||
|
||||
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
|
|
@ -822,6 +925,7 @@ export class CliInstallerService {
|
|||
return fullStatus.providers.find((provider) => provider.providerId === providerId) ?? null;
|
||||
}
|
||||
|
||||
const generation = this.statusGatherGeneration;
|
||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||
if (!versionProbe.ok) {
|
||||
return null;
|
||||
|
|
@ -831,18 +935,24 @@ export class CliInstallerService {
|
|||
binaryPath,
|
||||
providerId,
|
||||
(hydratedProviderStatus) => {
|
||||
this.updateLatestProviderStatus(hydratedProviderStatus);
|
||||
if (!this.updateLatestProviderStatusIfCurrent(hydratedProviderStatus, generation)) {
|
||||
return;
|
||||
}
|
||||
if (this.latestStatusSnapshot) {
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
}
|
||||
}
|
||||
);
|
||||
this.updateLatestProviderStatus(providerStatus);
|
||||
this.updateLatestProviderStatusIfCurrent(providerStatus, generation);
|
||||
return providerStatus;
|
||||
}
|
||||
|
||||
async verifyProviderModels(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
|
|
@ -854,6 +964,7 @@ export class CliInstallerService {
|
|||
return this.getProviderStatus(providerId);
|
||||
}
|
||||
|
||||
const generation = this.statusGatherGeneration;
|
||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||
if (!versionProbe.ok) {
|
||||
return null;
|
||||
|
|
@ -869,8 +980,10 @@ export class CliInstallerService {
|
|||
modelVerificationState: 'idle' as const,
|
||||
modelAvailability: [],
|
||||
};
|
||||
this.updateLatestProviderStatus(nextProviderStatus);
|
||||
if (this.latestStatusSnapshot) {
|
||||
if (
|
||||
this.updateLatestProviderStatusIfCurrent(nextProviderStatus, generation) &&
|
||||
this.latestStatusSnapshot
|
||||
) {
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
}
|
||||
return nextProviderStatus;
|
||||
|
|
@ -882,13 +995,19 @@ export class CliInstallerService {
|
|||
binaryPath,
|
||||
versionProbe.version
|
||||
) ?? (await this.multimodelBridgeService.getProviderStatus(binaryPath, providerId));
|
||||
if (generation !== this.statusGatherGeneration) {
|
||||
return providerStatus;
|
||||
}
|
||||
|
||||
const nextProviderStatus = this.applyProviderModelAvailabilityToProvider(
|
||||
binaryPath,
|
||||
versionProbe.version,
|
||||
providerStatus
|
||||
);
|
||||
this.updateLatestProviderStatus(nextProviderStatus);
|
||||
if (this.latestStatusSnapshot) {
|
||||
if (
|
||||
this.updateLatestProviderStatusIfCurrent(nextProviderStatus, generation) &&
|
||||
this.latestStatusSnapshot
|
||||
) {
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
}
|
||||
return nextProviderStatus;
|
||||
|
|
@ -903,32 +1022,43 @@ export class CliInstallerService {
|
|||
*/
|
||||
private async gatherStatus(
|
||||
ref: { current: CliInstallationStatus },
|
||||
diag: CliInstallerStatusRunDiag
|
||||
diag: CliInstallerStatusRunDiag,
|
||||
generation: number
|
||||
): Promise<void> {
|
||||
resetGatherDiag(diag);
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
const shellEnvStartedAt = Date.now();
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
diag.shellEnvMs = Date.now() - shellEnvStartedAt;
|
||||
|
||||
const r = ref.current;
|
||||
const binaryResolveStartedAt = Date.now();
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
diag.binaryResolveMs = Date.now() - binaryResolveStartedAt;
|
||||
if (binaryPath) {
|
||||
r.binaryPath = binaryPath;
|
||||
const versionProbeStartedAt = Date.now();
|
||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||
diag.versionProbeMs = Date.now() - versionProbeStartedAt;
|
||||
if (versionProbe.ok) {
|
||||
r.installed = true;
|
||||
r.installedVersion = versionProbe.version;
|
||||
r.launchError = null;
|
||||
r.authStatusChecking = true;
|
||||
this.rememberHealthyStatus(r);
|
||||
this.publishStatusSnapshot(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
|
||||
// Auth and GCS version check are independent — run in parallel.
|
||||
// Both mutate `r` directly so partial results survive the outer timeout.
|
||||
await Promise.all([
|
||||
this.checkAuthStatus(binaryPath, r, diag),
|
||||
this.checkAuthStatus(binaryPath, r, diag, generation),
|
||||
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
|
||||
]);
|
||||
this.rememberHealthyStatus(r);
|
||||
this.publishStatusSnapshot(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
} else {
|
||||
const recoveredHealthyStatus = this.getRecoverableHealthyStatus(binaryPath);
|
||||
if (recoveredHealthyStatus) {
|
||||
|
|
@ -938,7 +1068,7 @@ export class CliInstallerService {
|
|||
Object.assign(r, recoveredHealthyStatus, {
|
||||
launchError: null,
|
||||
});
|
||||
this.publishStatusSnapshot(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -957,7 +1087,7 @@ export class CliInstallerService {
|
|||
if (r.supportsSelfUpdate) {
|
||||
await this.fetchLatestVersion(r);
|
||||
}
|
||||
this.publishStatusSnapshot(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
}
|
||||
} else {
|
||||
// No binary — still check latest version for "install" prompt
|
||||
|
|
@ -967,7 +1097,7 @@ export class CliInstallerService {
|
|||
if (r.supportsSelfUpdate) {
|
||||
await this.fetchLatestVersion(r);
|
||||
}
|
||||
this.publishStatusSnapshot(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1065,33 +1195,70 @@ export class CliInstallerService {
|
|||
private async checkAuthStatus(
|
||||
binaryPath: string,
|
||||
result: CliInstallationStatus,
|
||||
diag: CliInstallerStatusRunDiag
|
||||
diag: CliInstallerStatusRunDiag,
|
||||
generation: number
|
||||
): Promise<void> {
|
||||
if (result.flavor === 'agent_teams_orchestrator') {
|
||||
result.authStatusChecking = true;
|
||||
try {
|
||||
const providers = await this.multimodelBridgeService.getProviderStatuses(
|
||||
binaryPath,
|
||||
(providersSnapshot) => {
|
||||
const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot);
|
||||
result.providers = frontendProviders;
|
||||
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||
result.authMethod =
|
||||
getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||
this.publishStatusSnapshot(result);
|
||||
let statusTarget = result;
|
||||
const applyProviders = (providersSnapshot: CliProviderStatus[], final: boolean): void => {
|
||||
if (generation !== this.statusGatherGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = statusTarget;
|
||||
const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot);
|
||||
target.providers = frontendProviders;
|
||||
target.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||
target.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||
if (final) {
|
||||
target.authStatusChecking = false;
|
||||
this.rememberHealthyStatus(target);
|
||||
}
|
||||
this.publishStatusSnapshot(target);
|
||||
};
|
||||
|
||||
const completion = this.multimodelBridgeService
|
||||
.getProviderStatuses(binaryPath, (providersSnapshot) => {
|
||||
applyProviders(providersSnapshot, false);
|
||||
})
|
||||
.then((providers) => {
|
||||
applyProviders(providers, true);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== this.statusGatherGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = getErrorMessage(error);
|
||||
diag.authLastError = msg;
|
||||
result.authStatusChecking = false;
|
||||
logger.warn(`Provider status check failed for claude-multimodel: ${msg}`);
|
||||
this.publishStatusSnapshot(result);
|
||||
});
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<'timeout'>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
statusTarget = cloneCliInstallationStatus(result);
|
||||
resolve('timeout');
|
||||
}, MULTIMODEL_PROVIDER_STATUS_INITIAL_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
});
|
||||
|
||||
const providerInitialWaitStartedAt = Date.now();
|
||||
const outcome = await Promise.race([completion.then(() => 'completed' as const), timeout]);
|
||||
diag.providerInitialWaitMs = Date.now() - providerInitialWaitStartedAt;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (outcome === 'timeout') {
|
||||
diag.authTimedOut = true;
|
||||
logger.warn(
|
||||
`Provider status check still running after ${MULTIMODEL_PROVIDER_STATUS_INITIAL_TIMEOUT_MS}ms; returning partial CLI status`
|
||||
);
|
||||
const frontendProviders = filterFrontendMultimodelProviders(providers);
|
||||
result.providers = frontendProviders;
|
||||
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||
result.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||
result.authStatusChecking = false;
|
||||
this.publishStatusSnapshot(result);
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
diag.authLastError = msg;
|
||||
result.authStatusChecking = false;
|
||||
logger.warn(`Provider status check failed for claude-multimodel: ${msg}`);
|
||||
this.publishStatusSnapshotIfCurrent(result, generation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -275,10 +275,16 @@ export interface RuntimeConfig {
|
|||
|
||||
export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key';
|
||||
|
||||
export interface AnthropicCompatibleEndpointConfig {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface ProviderConnectionsConfig {
|
||||
anthropic: {
|
||||
authMode: ProviderConnectionAuthMode;
|
||||
fastModeDefault: boolean;
|
||||
compatibleEndpoint: AnthropicCompatibleEndpointConfig;
|
||||
};
|
||||
codex: {
|
||||
preferredAuthMode: CodexAccountAuthMode;
|
||||
|
|
@ -376,6 +382,10 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
anthropic: {
|
||||
authMode: 'auto',
|
||||
fastModeDefault: false,
|
||||
compatibleEndpoint: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
|
|
@ -457,6 +467,22 @@ function normalizeCodexPreferredAuthMode(
|
|||
return DEFAULT_CONFIG.providerConnections.codex.preferredAuthMode;
|
||||
}
|
||||
|
||||
function normalizeAnthropicCompatibleEndpointConfig(
|
||||
value: unknown,
|
||||
fallback: AnthropicCompatibleEndpointConfig = DEFAULT_CONFIG.providerConnections.anthropic
|
||||
.compatibleEndpoint
|
||||
): AnthropicCompatibleEndpointConfig {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { ...fallback };
|
||||
}
|
||||
|
||||
const raw = value as Partial<AnthropicCompatibleEndpointConfig>;
|
||||
return {
|
||||
enabled: typeof raw.enabled === 'boolean' ? raw.enabled : fallback.enabled,
|
||||
baseUrl: typeof raw.baseUrl === 'string' ? raw.baseUrl.trim() : fallback.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldPersistNormalizedConfig(loaded: Partial<AppConfig>, normalized: AppConfig): boolean {
|
||||
return JSON.stringify(loaded) !== JSON.stringify(normalized);
|
||||
}
|
||||
|
|
@ -634,6 +660,9 @@ export class ConfigManager {
|
|||
anthropic: {
|
||||
...DEFAULT_CONFIG.providerConnections.anthropic,
|
||||
...(loaded.providerConnections?.anthropic ?? {}),
|
||||
compatibleEndpoint: normalizeAnthropicCompatibleEndpointConfig(
|
||||
loaded.providerConnections?.anthropic?.compatibleEndpoint
|
||||
),
|
||||
},
|
||||
codex: {
|
||||
preferredAuthMode: normalizeCodexPreferredAuthMode(
|
||||
|
|
@ -750,6 +779,10 @@ export class ConfigManager {
|
|||
anthropic: {
|
||||
...this.config.providerConnections.anthropic,
|
||||
...(connectionUpdate.anthropic ?? {}),
|
||||
compatibleEndpoint: normalizeAnthropicCompatibleEndpointConfig(
|
||||
connectionUpdate.anthropic?.compatibleEndpoint,
|
||||
this.config.providerConnections.anthropic.compatibleEndpoint
|
||||
),
|
||||
},
|
||||
codex: {
|
||||
...this.config.providerConnections.codex,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { ConfigManager } from './ConfigManager';
|
|||
import { type DataCache } from './DataCache';
|
||||
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
|
||||
import { type NotificationManager } from './NotificationManager';
|
||||
import { type TeamTaskWatchKind, TeamTaskWatchRegistry } from './TeamTaskWatchRegistry';
|
||||
|
||||
import type { FileSystemProvider, FsDirent } from './FileSystemProvider';
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
|
@ -42,6 +43,10 @@ const logger = createLogger('Service:FileWatcher');
|
|||
const DEBOUNCE_MS = 100;
|
||||
/** Retry delay when watched directories are unavailable or watcher errors occur */
|
||||
const WATCHER_RETRY_MS = 2000;
|
||||
/** Poll interval for team metadata and inboxes when the teams watcher hits OS watcher limits */
|
||||
const TEAMS_POLL_INTERVAL_MS = 1000;
|
||||
/** Poll interval for task files, which can be much larger than team metadata/inboxes */
|
||||
const TASKS_POLL_INTERVAL_MS = 3000;
|
||||
/** Interval for periodic catch-up scan to detect missed fs.watch events */
|
||||
const CATCH_UP_INTERVAL_MS = 30_000;
|
||||
/** Only catch-up scan files modified within this window */
|
||||
|
|
@ -68,11 +73,25 @@ interface ActiveSessionFile {
|
|||
lastObservedAt: number;
|
||||
}
|
||||
|
||||
type RecursiveWatcherType = 'projects' | 'todos' | 'teams' | 'tasks';
|
||||
type PollingWatcherType = 'teams' | 'tasks';
|
||||
interface CloseableWatcher {
|
||||
close: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export class FileWatcher extends EventEmitter {
|
||||
private projectsWatcher: fs.FSWatcher | null = null;
|
||||
private todosWatcher: fs.FSWatcher | null = null;
|
||||
private teamsWatcher: fs.FSWatcher | null = null;
|
||||
private tasksWatcher: fs.FSWatcher | null = null;
|
||||
private teamsWatcher: TeamTaskWatchRegistry | null = null;
|
||||
private tasksWatcher: TeamTaskWatchRegistry | null = null;
|
||||
private teamsPollingTimer: NodeJS.Timeout | null = null;
|
||||
private tasksPollingTimer: NodeJS.Timeout | null = null;
|
||||
private teamsPollingInProgress = false;
|
||||
private tasksPollingInProgress = false;
|
||||
private teamsPollingPrimed = false;
|
||||
private tasksPollingPrimed = false;
|
||||
private polledTeamFiles = new Map<string, string>();
|
||||
private polledTaskFiles = new Map<string, string>();
|
||||
private retryTimer: NodeJS.Timeout | null = null;
|
||||
private projectsPath: string;
|
||||
private todosPath: string;
|
||||
|
|
@ -202,15 +221,31 @@ export class FileWatcher extends EventEmitter {
|
|||
}
|
||||
|
||||
if (this.teamsWatcher) {
|
||||
this.teamsWatcher.close();
|
||||
void this.teamsWatcher.close();
|
||||
this.teamsWatcher = null;
|
||||
}
|
||||
|
||||
if (this.tasksWatcher) {
|
||||
this.tasksWatcher.close();
|
||||
void this.tasksWatcher.close();
|
||||
this.tasksWatcher = null;
|
||||
}
|
||||
|
||||
if (this.teamsPollingTimer) {
|
||||
clearInterval(this.teamsPollingTimer);
|
||||
this.teamsPollingTimer = null;
|
||||
}
|
||||
|
||||
if (this.tasksPollingTimer) {
|
||||
clearInterval(this.tasksPollingTimer);
|
||||
this.tasksPollingTimer = null;
|
||||
}
|
||||
this.teamsPollingInProgress = false;
|
||||
this.tasksPollingInProgress = false;
|
||||
this.teamsPollingPrimed = false;
|
||||
this.tasksPollingPrimed = false;
|
||||
this.polledTeamFiles.clear();
|
||||
this.polledTaskFiles.clear();
|
||||
|
||||
// Clear any pending debounce timers
|
||||
for (const timer of this.debounceTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
|
|
@ -284,6 +319,14 @@ export class FileWatcher extends EventEmitter {
|
|||
clearInterval(this.pollingTimer);
|
||||
this.pollingTimer = null;
|
||||
}
|
||||
if (this.teamsPollingTimer) {
|
||||
clearInterval(this.teamsPollingTimer);
|
||||
this.teamsPollingTimer = null;
|
||||
}
|
||||
if (this.tasksPollingTimer) {
|
||||
clearInterval(this.tasksPollingTimer);
|
||||
this.tasksPollingTimer = null;
|
||||
}
|
||||
|
||||
// 6. Clear all tracking maps (stop() already handles most of these)
|
||||
this.lastProcessedLineCount.clear();
|
||||
|
|
@ -291,6 +334,8 @@ export class FileWatcher extends EventEmitter {
|
|||
this.activeSessionFiles.clear();
|
||||
this.catchUpStatFailures.clear();
|
||||
this.polledFileSizes.clear();
|
||||
this.polledTeamFiles.clear();
|
||||
this.polledTaskFiles.clear();
|
||||
this.processingInProgress.clear();
|
||||
this.pendingReprocess.clear();
|
||||
|
||||
|
|
@ -377,7 +422,7 @@ export class FileWatcher extends EventEmitter {
|
|||
* Starts the teams directory watcher.
|
||||
*/
|
||||
private async startTeamsWatcher(): Promise<void> {
|
||||
if (this.teamsWatcher) {
|
||||
if (this.teamsWatcher || this.teamsPollingTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -390,15 +435,28 @@ export class FileWatcher extends EventEmitter {
|
|||
// Guard: stop() may have been called while awaiting pathExists
|
||||
if (!this.isWatching) return;
|
||||
|
||||
this.teamsWatcher = fs.watch(this.teamsPath, { recursive: true }, (eventType, filename) => {
|
||||
if (filename) {
|
||||
this.handleTeamsChange(eventType, filename);
|
||||
}
|
||||
const registry = new TeamTaskWatchRegistry({
|
||||
kind: 'teams',
|
||||
rootPath: this.teamsPath,
|
||||
onChange: (eventType, filename) => this.handleTeamsChange(eventType, filename),
|
||||
onError: (error) => this.handleTeamTaskWatcherError('teams', error),
|
||||
});
|
||||
this.attachWatcherRecovery(this.teamsWatcher, 'teams');
|
||||
this.teamsWatcher = registry;
|
||||
await registry.start();
|
||||
if (!this.isWatching || this.teamsWatcher !== registry) {
|
||||
void registry.close();
|
||||
return;
|
||||
}
|
||||
logger.info(`FileWatcher: Started watching teams at ${this.teamsPath}`);
|
||||
} catch (error) {
|
||||
const registry = this.teamsWatcher;
|
||||
if (this.startPollingFallbackForRecursiveWatchLimit('teams', error, registry ?? undefined)) {
|
||||
return;
|
||||
}
|
||||
logger.error('Error starting teams watcher:', error);
|
||||
if (this.teamsWatcher) {
|
||||
void this.teamsWatcher.close();
|
||||
}
|
||||
this.teamsWatcher = null;
|
||||
this.scheduleWatcherRetry();
|
||||
}
|
||||
|
|
@ -408,7 +466,7 @@ export class FileWatcher extends EventEmitter {
|
|||
* Starts the tasks directory watcher.
|
||||
*/
|
||||
private async startTasksWatcher(): Promise<void> {
|
||||
if (this.tasksWatcher) {
|
||||
if (this.tasksWatcher || this.tasksPollingTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -421,15 +479,28 @@ export class FileWatcher extends EventEmitter {
|
|||
// Guard: stop() may have been called while awaiting pathExists
|
||||
if (!this.isWatching) return;
|
||||
|
||||
this.tasksWatcher = fs.watch(this.tasksPath, { recursive: true }, (eventType, filename) => {
|
||||
if (filename) {
|
||||
this.handleTasksChange(eventType, filename);
|
||||
}
|
||||
const registry = new TeamTaskWatchRegistry({
|
||||
kind: 'tasks',
|
||||
rootPath: this.tasksPath,
|
||||
onChange: (eventType, filename) => this.handleTasksChange(eventType, filename),
|
||||
onError: (error) => this.handleTeamTaskWatcherError('tasks', error),
|
||||
});
|
||||
this.attachWatcherRecovery(this.tasksWatcher, 'tasks');
|
||||
this.tasksWatcher = registry;
|
||||
await registry.start();
|
||||
if (!this.isWatching || this.tasksWatcher !== registry) {
|
||||
void registry.close();
|
||||
return;
|
||||
}
|
||||
logger.info(`FileWatcher: Started watching tasks at ${this.tasksPath}`);
|
||||
} catch (error) {
|
||||
const registry = this.tasksWatcher;
|
||||
if (this.startPollingFallbackForRecursiveWatchLimit('tasks', error, registry ?? undefined)) {
|
||||
return;
|
||||
}
|
||||
logger.error('Error starting tasks watcher:', error);
|
||||
if (this.tasksWatcher) {
|
||||
void this.tasksWatcher.close();
|
||||
}
|
||||
this.tasksWatcher = null;
|
||||
this.scheduleWatcherRetry();
|
||||
}
|
||||
|
|
@ -472,7 +543,12 @@ export class FileWatcher extends EventEmitter {
|
|||
]);
|
||||
}
|
||||
|
||||
if (!this.projectsWatcher || !this.todosWatcher || !this.teamsWatcher || !this.tasksWatcher) {
|
||||
if (
|
||||
!this.projectsWatcher ||
|
||||
!this.todosWatcher ||
|
||||
!this.isWatcherOrPollingActive('teams') ||
|
||||
!this.isWatcherOrPollingActive('tasks')
|
||||
) {
|
||||
this.scheduleWatcherRetry();
|
||||
}
|
||||
}
|
||||
|
|
@ -488,18 +564,18 @@ export class FileWatcher extends EventEmitter {
|
|||
}, WATCHER_RETRY_MS);
|
||||
}
|
||||
|
||||
private attachWatcherRecovery(
|
||||
watcher: fs.FSWatcher,
|
||||
watcherType: 'projects' | 'todos' | 'teams' | 'tasks'
|
||||
): void {
|
||||
private attachWatcherRecovery(watcher: fs.FSWatcher, watcherType: RecursiveWatcherType): void {
|
||||
watcher.on('error', (error: NodeJS.ErrnoException) => {
|
||||
// Ephemeral .lock files cause harmless ENOENT when the recursive watcher
|
||||
// tries to scandir a path that was already deleted. Log as debug and skip
|
||||
// the teardown/retry — the watcher is still healthy.
|
||||
// the teardown/retry - the watcher is still healthy.
|
||||
if (error.code === 'ENOENT' && error.path?.endsWith('.lock')) {
|
||||
logger.debug(`FileWatcher: ${watcherType} ignoring transient ENOENT on lock file`);
|
||||
return;
|
||||
}
|
||||
if (this.startPollingFallbackForRecursiveWatchLimit(watcherType, error, watcher)) {
|
||||
return;
|
||||
}
|
||||
logger.error(`FileWatcher: ${watcherType} watcher error:`, error);
|
||||
if (watcherType === 'projects') {
|
||||
this.projectsWatcher = null;
|
||||
|
|
@ -510,7 +586,9 @@ export class FileWatcher extends EventEmitter {
|
|||
} else {
|
||||
this.tasksWatcher = null;
|
||||
}
|
||||
this.scheduleWatcherRetry();
|
||||
if (!this.isWatcherOrPollingActive(watcherType)) {
|
||||
this.scheduleWatcherRetry();
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on('close', () => {
|
||||
|
|
@ -526,10 +604,295 @@ export class FileWatcher extends EventEmitter {
|
|||
} else {
|
||||
this.tasksWatcher = null;
|
||||
}
|
||||
this.scheduleWatcherRetry();
|
||||
if (!this.isWatcherOrPollingActive(watcherType)) {
|
||||
this.scheduleWatcherRetry();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleTeamTaskWatcherError(watcherType: TeamTaskWatchKind, error: unknown): void {
|
||||
const watcher = watcherType === 'teams' ? this.teamsWatcher : this.tasksWatcher;
|
||||
if (this.startPollingFallbackForRecursiveWatchLimit(watcherType, error, watcher ?? undefined)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error(`FileWatcher: ${watcherType} watcher error:`, error);
|
||||
if (watcherType === 'teams') {
|
||||
this.teamsWatcher = null;
|
||||
} else {
|
||||
this.tasksWatcher = null;
|
||||
}
|
||||
void watcher?.close();
|
||||
|
||||
if (!this.isWatcherOrPollingActive(watcherType)) {
|
||||
this.scheduleWatcherRetry();
|
||||
}
|
||||
}
|
||||
|
||||
private isWatcherOrPollingActive(watcherType: RecursiveWatcherType): boolean {
|
||||
if (watcherType === 'projects') {
|
||||
return this.projectsWatcher !== null;
|
||||
}
|
||||
if (watcherType === 'todos') {
|
||||
return this.todosWatcher !== null;
|
||||
}
|
||||
if (watcherType === 'teams') {
|
||||
return this.teamsWatcher !== null || this.teamsPollingTimer !== null;
|
||||
}
|
||||
return this.tasksWatcher !== null || this.tasksPollingTimer !== null;
|
||||
}
|
||||
|
||||
private startPollingFallbackForRecursiveWatchLimit(
|
||||
watcherType: RecursiveWatcherType,
|
||||
error: unknown,
|
||||
watcher?: CloseableWatcher
|
||||
): boolean {
|
||||
if ((watcherType !== 'teams' && watcherType !== 'tasks') || !this.isWatchLimitError(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
logger.warn(
|
||||
`FileWatcher: ${watcherType} watcher hit ${err.code ?? 'a platform limit'}; falling back to polling`
|
||||
);
|
||||
|
||||
if (watcherType === 'teams') {
|
||||
this.teamsWatcher = null;
|
||||
} else {
|
||||
this.tasksWatcher = null;
|
||||
}
|
||||
|
||||
this.startTeamTaskPolling(watcherType);
|
||||
|
||||
try {
|
||||
void watcher?.close();
|
||||
} catch (closeError) {
|
||||
logger.debug(`FileWatcher: ${watcherType} watcher close after fallback failed`, closeError);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private isWatchLimitError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return (
|
||||
code === 'EMFILE' ||
|
||||
code === 'ENOSPC' ||
|
||||
code === 'ERR_FS_WATCHER_LIMIT' ||
|
||||
code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM'
|
||||
);
|
||||
}
|
||||
|
||||
private startTeamTaskPolling(watcherType: PollingWatcherType): void {
|
||||
const existingTimer = watcherType === 'teams' ? this.teamsPollingTimer : this.tasksPollingTimer;
|
||||
if (existingTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runPoll = (): void => {
|
||||
if (!this.isWatching) {
|
||||
return;
|
||||
}
|
||||
if (watcherType === 'teams') {
|
||||
if (this.teamsPollingInProgress) {
|
||||
return;
|
||||
}
|
||||
this.teamsPollingInProgress = true;
|
||||
this.pollTeamsForChanges()
|
||||
.catch((err) => {
|
||||
logger.error('FileWatcher: Error during teams polling:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
this.teamsPollingInProgress = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tasksPollingInProgress) {
|
||||
return;
|
||||
}
|
||||
this.tasksPollingInProgress = true;
|
||||
this.pollTasksForChanges()
|
||||
.catch((err) => {
|
||||
logger.error('FileWatcher: Error during tasks polling:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
this.tasksPollingInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
runPoll();
|
||||
const timer = setInterval(runPoll, this.getTeamTaskPollIntervalMs(watcherType));
|
||||
timer.unref();
|
||||
|
||||
if (watcherType === 'teams') {
|
||||
this.teamsPollingTimer = timer;
|
||||
} else {
|
||||
this.tasksPollingTimer = timer;
|
||||
}
|
||||
}
|
||||
|
||||
private getTeamTaskPollIntervalMs(watcherType: PollingWatcherType): number {
|
||||
return watcherType === 'teams' ? TEAMS_POLL_INTERVAL_MS : TASKS_POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
private async pollTeamsForChanges(): Promise<void> {
|
||||
const nextSnapshot = await this.collectTeamsPollSnapshot();
|
||||
if (!this.isWatching) {
|
||||
return;
|
||||
}
|
||||
this.emitPolledChanges(
|
||||
'teams',
|
||||
this.polledTeamFiles,
|
||||
nextSnapshot,
|
||||
this.teamsPollingPrimed,
|
||||
(eventType, relativePath) => this.handleTeamsChange(eventType, relativePath)
|
||||
);
|
||||
this.polledTeamFiles = nextSnapshot;
|
||||
this.teamsPollingPrimed = true;
|
||||
}
|
||||
|
||||
private async pollTasksForChanges(): Promise<void> {
|
||||
const nextSnapshot = await this.collectTasksPollSnapshot();
|
||||
if (!this.isWatching) {
|
||||
return;
|
||||
}
|
||||
this.emitPolledChanges(
|
||||
'tasks',
|
||||
this.polledTaskFiles,
|
||||
nextSnapshot,
|
||||
this.tasksPollingPrimed,
|
||||
(eventType, relativePath) => this.handleTasksChange(eventType, relativePath)
|
||||
);
|
||||
this.polledTaskFiles = nextSnapshot;
|
||||
this.tasksPollingPrimed = true;
|
||||
}
|
||||
|
||||
private emitPolledChanges(
|
||||
watcherType: PollingWatcherType,
|
||||
previousSnapshot: Map<string, string>,
|
||||
nextSnapshot: Map<string, string>,
|
||||
isPrimed: boolean,
|
||||
emitChange: (eventType: string, relativePath: string) => void
|
||||
): void {
|
||||
if (!isPrimed) {
|
||||
logger.info(`FileWatcher: ${watcherType} polling baseline captured`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [relativePath, fingerprint] of nextSnapshot) {
|
||||
const previous = previousSnapshot.get(relativePath);
|
||||
if (previous === undefined) {
|
||||
emitChange('rename', relativePath);
|
||||
} else if (previous !== fingerprint) {
|
||||
emitChange('change', relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const relativePath of previousSnapshot.keys()) {
|
||||
if (!nextSnapshot.has(relativePath)) {
|
||||
emitChange('rename', relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async collectTeamsPollSnapshot(): Promise<Map<string, string>> {
|
||||
const snapshot = new Map<string, string>();
|
||||
const teamEntries = await this.safeReadDir(this.teamsPath);
|
||||
|
||||
for (const teamEntry of teamEntries) {
|
||||
if (!teamEntry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const teamName = teamEntry.name;
|
||||
const teamPath = path.join(this.teamsPath, teamName);
|
||||
await this.collectPolledDirectoryFiles(snapshot, teamPath, teamName, (fileName) =>
|
||||
fileName.endsWith('.json')
|
||||
);
|
||||
|
||||
await this.collectPolledDirectoryFiles(
|
||||
snapshot,
|
||||
path.join(teamPath, 'inboxes'),
|
||||
`${teamName}/inboxes`,
|
||||
(fileName) => fileName.endsWith('.json')
|
||||
);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async collectTasksPollSnapshot(): Promise<Map<string, string>> {
|
||||
const snapshot = new Map<string, string>();
|
||||
const teamEntries = await this.safeReadDir(this.tasksPath);
|
||||
|
||||
for (const teamEntry of teamEntries) {
|
||||
if (!teamEntry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const teamName = teamEntry.name;
|
||||
await this.collectPolledDirectoryFiles(
|
||||
snapshot,
|
||||
path.join(this.tasksPath, teamName),
|
||||
teamName,
|
||||
(fileName) => !fileName.startsWith('.') && fileName.endsWith('.json')
|
||||
);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async collectPolledDirectoryFiles(
|
||||
snapshot: Map<string, string>,
|
||||
dirPath: string,
|
||||
relativeRoot: string,
|
||||
shouldInclude: (fileName: string) => boolean
|
||||
): Promise<void> {
|
||||
const entries = await this.safeReadDir(dirPath);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !shouldInclude(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await this.addPolledFile(
|
||||
snapshot,
|
||||
path.join(dirPath, entry.name),
|
||||
`${relativeRoot}/${entry.name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async addPolledFile(
|
||||
snapshot: Map<string, string>,
|
||||
absolutePath: string,
|
||||
relativePath: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const stats = await fsp.stat(absolutePath);
|
||||
if (!stats.isFile()) {
|
||||
return;
|
||||
}
|
||||
snapshot.set(
|
||||
relativePath,
|
||||
`${stats.dev}:${stats.ino}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.size}`
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.debug(`FileWatcher: Unable to stat polled file ${absolutePath}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async safeReadDir(dirPath: string): Promise<fs.Dirent[]> {
|
||||
try {
|
||||
return await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.debug(`FileWatcher: Unable to read polled directory ${dirPath}`, error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// SSH Polling Mode
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ const STAT_TIMEOUT_MS = 2000;
|
|||
// let callers stat only the files they actually need.
|
||||
const STAT_PREFETCH_LIMIT = 1500;
|
||||
|
||||
export interface LocalFileSystemProviderReaddirOptions {
|
||||
prefetchEntryStats?: boolean;
|
||||
}
|
||||
|
||||
async function statWithTimeout(filePath: string, timeoutMs: number): Promise<fs.Stats> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
|
|
@ -81,9 +85,12 @@ export class LocalFileSystemProvider implements FileSystemProvider {
|
|||
};
|
||||
}
|
||||
|
||||
async readdir(dirPath: string): Promise<FsDirent[]> {
|
||||
async readdir(
|
||||
dirPath: string,
|
||||
options: LocalFileSystemProviderReaddirOptions = {}
|
||||
): Promise<FsDirent[]> {
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
if (entries.length > STAT_PREFETCH_LIMIT) {
|
||||
if (options.prefetchEntryStats === false || entries.length > STAT_PREFETCH_LIMIT) {
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isFile: () => entry.isFile(),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
||||
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||
import {
|
||||
collectRuntimePathBinaryCandidates,
|
||||
isAbsoluteExistingFile,
|
||||
RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
||||
} from '@main/utils/runtimePathBinaryResolver';
|
||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||
import { getShellPreferredHome, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { existsSync, promises as fsp, readFileSync, statSync } from 'fs';
|
||||
import { promises as fsp, readdirSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { gunzipSync } from 'zlib';
|
||||
|
||||
|
|
@ -23,7 +27,6 @@ const MAX_TARBALL_BYTES = 250 * 1024 * 1024;
|
|||
const MAX_BINARY_BYTES = 350 * 1024 * 1024;
|
||||
const FETCH_TIMEOUT_MS = 60_000;
|
||||
const VERSION_TIMEOUT_MS = 10_000;
|
||||
const PATH_SHELL_ENV_TIMEOUT_MS = 1_500;
|
||||
|
||||
interface NpmPackageMetadata {
|
||||
name?: string;
|
||||
|
|
@ -57,17 +60,6 @@ function getCurrentManifestPath(): string {
|
|||
return path.join(getRuntimeRootPath(), 'current.json');
|
||||
}
|
||||
|
||||
function isAbsoluteExistingFile(filePath: string | null | undefined): filePath is string {
|
||||
if (!filePath || !path.isAbsolute(filePath) || !existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseManifest(value: unknown): OpenCodeRuntimeManifest | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
|
|
@ -126,47 +118,61 @@ function getPathExecutableNames(): string[] {
|
|||
: ['opencode'];
|
||||
}
|
||||
|
||||
function splitPathEnv(pathValue: string | undefined): string[] {
|
||||
if (!pathValue) {
|
||||
return [];
|
||||
}
|
||||
return pathValue
|
||||
.split(path.delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
function collectPathOpenCodeBinaryCandidates(
|
||||
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = [],
|
||||
options: { includeFallbackPathEntries?: boolean } = {}
|
||||
): string[] {
|
||||
return collectRuntimePathBinaryCandidates({
|
||||
executableNames: getPathExecutableNames(),
|
||||
additionalEnvSources,
|
||||
includeFallbackPathEntries: options.includeFallbackPathEntries,
|
||||
extraCandidates: collectNvmOpenCodeBinaryCandidates(),
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePathOpenCodeBinary(
|
||||
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
|
||||
): string | null {
|
||||
const shellEnv = getCachedShellEnv() ?? {};
|
||||
const pathEntries = [
|
||||
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
|
||||
...splitPathEnv(shellEnv.PATH),
|
||||
...splitPathEnv(buildMergedCliPath()),
|
||||
...splitPathEnv(process.env.PATH),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of pathEntries) {
|
||||
const normalizedEntry = path.resolve(entry);
|
||||
if (seen.has(normalizedEntry)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalizedEntry);
|
||||
for (const executableName of getPathExecutableNames()) {
|
||||
const candidate = path.join(normalizedEntry, executableName);
|
||||
if (isAbsoluteExistingFile(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
function collectNvmOpenCodeBinaryCandidates(): string[] {
|
||||
if (process.platform === 'win32') {
|
||||
const appdata = process.env.APPDATA;
|
||||
if (!appdata) {
|
||||
return [];
|
||||
}
|
||||
return collectVersionedOpenCodeBinaryCandidates(path.join(appdata, 'nvm'));
|
||||
}
|
||||
return null;
|
||||
|
||||
return collectVersionedOpenCodeBinaryCandidates(
|
||||
path.join(getShellPreferredHome(), '.nvm', 'versions', 'node'),
|
||||
'bin'
|
||||
);
|
||||
}
|
||||
|
||||
function collectVersionedOpenCodeBinaryCandidates(rootPath: string, binSegment = ''): string[] {
|
||||
let versions: string[];
|
||||
try {
|
||||
versions = readdirSync(rootPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return versions
|
||||
.toSorted((left, right) => right.localeCompare(left, undefined, { numeric: true }))
|
||||
.flatMap((version) => {
|
||||
const versionPath = binSegment
|
||||
? path.join(rootPath, version, binSegment)
|
||||
: path.join(rootPath, version);
|
||||
return getPathExecutableNames().map((executableName) =>
|
||||
path.join(versionPath, executableName)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
type OpenCodeBinaryVersionProbe =
|
||||
| { ok: true; version: string | null }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type VerifiedOpenCodeBinaryProbe =
|
||||
| { ok: true; binaryPath: string; version: string | null }
|
||||
| { ok: false; firstFailure: { binaryPath: string; error: string } | null };
|
||||
|
||||
async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeBinaryVersionProbe> {
|
||||
try {
|
||||
const { stdout } = await execCli(binaryPath, ['--version'], {
|
||||
|
|
@ -179,30 +185,79 @@ async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeB
|
|||
}
|
||||
}
|
||||
|
||||
async function resolvePathOpenCodeBinaryWithBestEffortEnv(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
): Promise<string | null> {
|
||||
const cachedCandidate = resolvePathOpenCodeBinary();
|
||||
if (cachedCandidate) {
|
||||
return cachedCandidate;
|
||||
function normalizeBinaryCandidateForCompare(binaryPath: string): string {
|
||||
const normalized = path.resolve(binaryPath);
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
async function probeFirstWorkingOpenCodeBinaryCandidate(
|
||||
candidates: string[],
|
||||
seen: Set<string>,
|
||||
firstFailure: { binaryPath: string; error: string } | null
|
||||
): Promise<VerifiedOpenCodeBinaryProbe> {
|
||||
let nextFirstFailure = firstFailure;
|
||||
for (const binaryPath of candidates) {
|
||||
const normalized = normalizeBinaryCandidateForCompare(binaryPath);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
const version = await probeOpenCodeBinaryVersion(binaryPath);
|
||||
if (version.ok) {
|
||||
return { ok: true, binaryPath, version: version.version };
|
||||
}
|
||||
nextFirstFailure ??= { binaryPath, error: version.error };
|
||||
}
|
||||
|
||||
return { ok: false, firstFailure: nextFirstFailure };
|
||||
}
|
||||
|
||||
async function probeFirstWorkingPathOpenCodeBinary(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
): Promise<VerifiedOpenCodeBinaryProbe> {
|
||||
const seenCandidates = new Set<string>();
|
||||
let firstFailure: { binaryPath: string; error: string } | null = null;
|
||||
|
||||
const cachedProbe = await probeFirstWorkingOpenCodeBinaryCandidate(
|
||||
collectPathOpenCodeBinaryCandidates([], {
|
||||
includeFallbackPathEntries: false,
|
||||
}),
|
||||
seenCandidates,
|
||||
firstFailure
|
||||
);
|
||||
if (cachedProbe.ok) {
|
||||
return cachedProbe;
|
||||
}
|
||||
firstFailure = cachedProbe.firstFailure;
|
||||
|
||||
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
|
||||
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
||||
fallbackEnv: process.env,
|
||||
});
|
||||
return resolvePathOpenCodeBinary([shellEnv]);
|
||||
const shellProbe = await probeFirstWorkingOpenCodeBinaryCandidate(
|
||||
collectPathOpenCodeBinaryCandidates([shellEnv], {
|
||||
includeFallbackPathEntries: false,
|
||||
}),
|
||||
seenCandidates,
|
||||
firstFailure
|
||||
);
|
||||
if (shellProbe.ok) {
|
||||
return shellProbe;
|
||||
}
|
||||
firstFailure = shellProbe.firstFailure;
|
||||
|
||||
return probeFirstWorkingOpenCodeBinaryCandidate(
|
||||
collectPathOpenCodeBinaryCandidates([shellEnv]),
|
||||
seenCandidates,
|
||||
firstFailure
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveVerifiedPathOpenCodeBinaryPath(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
): Promise<string | null> {
|
||||
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv(options);
|
||||
if (!binaryPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await probeOpenCodeBinaryVersion(binaryPath)).ok ? binaryPath : null;
|
||||
const result = await probeFirstWorkingPathOpenCodeBinary(options);
|
||||
return result.ok ? result.binaryPath : null;
|
||||
}
|
||||
|
||||
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
||||
|
|
@ -526,26 +581,25 @@ export class OpenCodeRuntimeInstallerService {
|
|||
}
|
||||
|
||||
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
|
||||
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv();
|
||||
if (!binaryPath) {
|
||||
return { installed: false, source: 'missing', state: 'idle' };
|
||||
}
|
||||
const version = await probeOpenCodeBinaryVersion(binaryPath);
|
||||
if (!version.ok) {
|
||||
const result = await probeFirstWorkingPathOpenCodeBinary();
|
||||
if (result.ok) {
|
||||
return {
|
||||
installed: false,
|
||||
binaryPath,
|
||||
installed: true,
|
||||
binaryPath: result.binaryPath,
|
||||
version: result.version ?? undefined,
|
||||
source: 'path',
|
||||
state: 'failed',
|
||||
error: version.error,
|
||||
state: 'ready',
|
||||
};
|
||||
}
|
||||
if (!result.firstFailure) {
|
||||
return { installed: false, source: 'missing', state: 'idle' };
|
||||
}
|
||||
return {
|
||||
installed: true,
|
||||
binaryPath,
|
||||
version: version.version ?? undefined,
|
||||
installed: false,
|
||||
binaryPath: result.firstFailure.binaryPath,
|
||||
source: 'path',
|
||||
state: 'ready',
|
||||
state: 'failed',
|
||||
error: result.firstFailure.error,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
322
src/main/services/infrastructure/TeamTaskWatchRegistry.ts
Normal file
322
src/main/services/infrastructure/TeamTaskWatchRegistry.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import { OPENCODE_TASK_LOG_ATTRIBUTION_FILE } from '@shared/constants/opencodeTaskLogAttribution';
|
||||
import { watch } from 'chokidar';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
import type { Dirent } from 'fs';
|
||||
|
||||
export type TeamTaskWatchKind = 'teams' | 'tasks';
|
||||
export type TeamTaskWatchEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir';
|
||||
|
||||
export interface TeamTaskWatchRegistryOptions {
|
||||
kind: TeamTaskWatchKind;
|
||||
rootPath: string;
|
||||
onChange: (eventType: TeamTaskWatchEventType, relativePath: string) => void;
|
||||
onError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
const RECONCILE_INTERVAL_MS = 30_000;
|
||||
|
||||
// Keep this list aligned with FileWatcher.processTeamsChange().
|
||||
// If a new team artifact should produce TeamChangeEvent, add it here too.
|
||||
const TEAM_ROOT_FILES = new Set([
|
||||
'config.json',
|
||||
'processes.json',
|
||||
'sentMessages.json',
|
||||
'team.meta.json',
|
||||
'members.meta.json',
|
||||
OPENCODE_TASK_LOG_ATTRIBUTION_FILE,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Shallow watcher registry for team and task artifacts.
|
||||
*
|
||||
* Why this exists:
|
||||
* - Node recursive fs.watch on Linux expands into many inotify subscriptions.
|
||||
* Large ~/.claude/teams trees can hit EMFILE/ENOSPC and freeze startup work.
|
||||
* - FileWatcher only consumes a small set of team/task JSON artifacts, so a
|
||||
* broad recursive watcher mostly watches runtime/log/member noise.
|
||||
*
|
||||
* Contract:
|
||||
* - Watch only teams/, teams/<team>/, teams/<team>/inboxes/, tasks/, tasks/<team>/.
|
||||
* - Do not enable Chokidar polling here. Polling is owned by FileWatcher fallback.
|
||||
* - Initial app startup baseline must stay silent to avoid replaying old files.
|
||||
* - Newly discovered targets are scanned once so files created before rebuild
|
||||
* are not lost.
|
||||
*/
|
||||
export class TeamTaskWatchRegistry {
|
||||
private watcher: FSWatcher | null = null;
|
||||
private reconcileTimer: NodeJS.Timeout | null = null;
|
||||
private targets = new Set<string>();
|
||||
private targetKey = '';
|
||||
private initialTargetsCaptured = false;
|
||||
private closed = false;
|
||||
private generation = 0;
|
||||
private reconcileInProgress = false;
|
||||
private reconcileAgain = false;
|
||||
|
||||
constructor(private readonly options: TeamTaskWatchRegistryOptions) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
await this.reconcileTargets();
|
||||
if (this.closed || this.reconcileTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This is target reconciliation, not content polling. It only rebuilds the
|
||||
// shallow watch set when team/task/inbox directories appear or disappear.
|
||||
this.reconcileTimer = setInterval(() => {
|
||||
void this.reconcileTargets();
|
||||
}, RECONCILE_INTERVAL_MS);
|
||||
this.reconcileTimer.unref();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.generation += 1;
|
||||
|
||||
if (this.reconcileTimer) {
|
||||
clearInterval(this.reconcileTimer);
|
||||
this.reconcileTimer = null;
|
||||
}
|
||||
|
||||
const watcher = this.watcher;
|
||||
this.watcher = null;
|
||||
this.targets.clear();
|
||||
this.targetKey = '';
|
||||
if (watcher) {
|
||||
await watcher.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcileTargets(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (this.reconcileInProgress) {
|
||||
this.reconcileAgain = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconcileInProgress = true;
|
||||
try {
|
||||
const targets = await this.collectTargets();
|
||||
const nextKey = targets.join('\n');
|
||||
if (nextKey !== this.targetKey) {
|
||||
const addedTargets = targets.filter((target) => !this.targets.has(target));
|
||||
await this.rebuildWatcher(targets, nextKey, addedTargets);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.closed) {
|
||||
this.options.onError(error);
|
||||
}
|
||||
} finally {
|
||||
this.reconcileInProgress = false;
|
||||
}
|
||||
|
||||
if (this.reconcileAgain && !this.closed) {
|
||||
this.reconcileAgain = false;
|
||||
await this.reconcileTargets();
|
||||
}
|
||||
}
|
||||
|
||||
private async rebuildWatcher(
|
||||
targets: string[],
|
||||
nextKey: string,
|
||||
addedTargets: string[]
|
||||
): Promise<void> {
|
||||
const generation = this.generation + 1;
|
||||
this.generation = generation;
|
||||
|
||||
const previousWatcher = this.watcher;
|
||||
this.watcher = null;
|
||||
if (previousWatcher) {
|
||||
await previousWatcher.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (this.closed || generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextWatcher = watch(targets, {
|
||||
ignoreInitial: true,
|
||||
ignorePermissionErrors: true,
|
||||
followSymlinks: false,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
this.watcher = nextWatcher;
|
||||
this.targets = new Set(targets);
|
||||
this.targetKey = nextKey;
|
||||
// First registry build is app startup baseline and must not emit old files.
|
||||
// Later rebuilds can emit existing files only for newly added targets.
|
||||
const shouldEmitExistingFiles = this.initialTargetsCaptured;
|
||||
this.initialTargetsCaptured = true;
|
||||
|
||||
const handleEvent = (eventType: TeamTaskWatchEventType, changedPath?: string): void => {
|
||||
if (this.closed || generation !== this.generation || !changedPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const relativePath = this.toRelativePath(changedPath);
|
||||
if (!relativePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// addDir/unlinkDir can make the watch target set stale immediately.
|
||||
// Periodic reconciliation is the backup path if the directory event is missed.
|
||||
if (this.shouldReconcile(eventType, relativePath)) {
|
||||
void this.reconcileTargets();
|
||||
}
|
||||
|
||||
if (!this.shouldEmit(eventType, relativePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.options.onChange(eventType, relativePath);
|
||||
};
|
||||
|
||||
nextWatcher.on('add', (changedPath) => handleEvent('add', changedPath));
|
||||
nextWatcher.on('change', (changedPath) => handleEvent('change', changedPath));
|
||||
nextWatcher.on('unlink', (changedPath) => handleEvent('unlink', changedPath));
|
||||
nextWatcher.on('addDir', (changedPath) => handleEvent('addDir', changedPath));
|
||||
nextWatcher.on('unlinkDir', (changedPath) => handleEvent('unlinkDir', changedPath));
|
||||
nextWatcher.on('error', (error) => {
|
||||
if (!this.closed && generation === this.generation) {
|
||||
this.options.onError(error);
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldEmitExistingFiles) {
|
||||
await this.emitExistingFilesForNewTargets(addedTargets, generation);
|
||||
}
|
||||
}
|
||||
|
||||
private async emitExistingFilesForNewTargets(
|
||||
targets: string[],
|
||||
generation: number
|
||||
): Promise<void> {
|
||||
const normalizedRoot = path.normalize(this.options.rootPath);
|
||||
for (const targetPath of targets) {
|
||||
if (this.closed || generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
if (path.normalize(targetPath) === normalizedRoot) {
|
||||
continue;
|
||||
}
|
||||
// Covers the race where a new team/task/inbox dir is created with JSON
|
||||
// files before Chokidar has rebuilt its target list. Only immediate files
|
||||
// are scanned, matching depth: 0.
|
||||
const entries = await this.readDirectory(targetPath);
|
||||
for (const entry of entries) {
|
||||
if (this.closed || generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = this.toRelativePath(path.join(targetPath, entry.name));
|
||||
if (relativePath && this.shouldEmit('add', relativePath)) {
|
||||
this.options.onChange('add', relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async collectTargets(): Promise<string[]> {
|
||||
// Keep this intentionally shallow. Do not add members/, runtime/,
|
||||
// .opencode-runtime/, logs, or other deep trees unless FileWatcher starts
|
||||
// emitting user-visible events for those artifacts.
|
||||
const targets = new Set<string>([path.normalize(this.options.rootPath)]);
|
||||
const rootEntries = await this.readDirectory(this.options.rootPath);
|
||||
|
||||
for (const entry of rootEntries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const teamPath = path.join(this.options.rootPath, entry.name);
|
||||
targets.add(path.normalize(teamPath));
|
||||
|
||||
if (this.options.kind === 'teams') {
|
||||
const inboxPath = path.join(teamPath, 'inboxes');
|
||||
if (await this.isDirectory(inboxPath)) {
|
||||
targets.add(path.normalize(inboxPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...targets].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
private async readDirectory(dirPath: string): Promise<Dirent[]> {
|
||||
try {
|
||||
return await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async isDirectory(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fsp.stat(dirPath)).isDirectory();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private toRelativePath(changedPath: string): string | null {
|
||||
const absolutePath = path.isAbsolute(changedPath)
|
||||
? changedPath
|
||||
: path.join(this.options.rootPath, changedPath);
|
||||
const relativePath = path.relative(this.options.rootPath, absolutePath);
|
||||
|
||||
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return relativePath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
private shouldReconcile(eventType: TeamTaskWatchEventType, relativePath: string): boolean {
|
||||
if (eventType !== 'addDir' && eventType !== 'unlinkDir') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = relativePath.split('/').filter(Boolean);
|
||||
if (parts.length === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.options.kind === 'teams' && parts.length === 2 && parts[1] === 'inboxes';
|
||||
}
|
||||
|
||||
private shouldEmit(eventType: TeamTaskWatchEventType, relativePath: string): boolean {
|
||||
if (eventType === 'addDir' || eventType === 'unlinkDir') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// This is the event gate. Expanding it changes the FileWatcher public event
|
||||
// surface, so update tests and TeamChangeEvent consumers together.
|
||||
const parts = relativePath.split('/').filter(Boolean);
|
||||
if (this.options.kind === 'tasks') {
|
||||
return parts.length === 2 && !parts[1].startsWith('.') && parts[1].endsWith('.json');
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
return TEAM_ROOT_FILES.has(parts[1]);
|
||||
}
|
||||
|
||||
return parts.length === 3 && parts[1] === 'inboxes' && parts[2].endsWith('.json');
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,10 @@
|
|||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import {
|
||||
formatUpdaterReleaseNotes,
|
||||
getUpdaterReleaseNoteForVersion,
|
||||
} from '@shared/utils/releaseNotes';
|
||||
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
||||
import { app, net } from 'electron';
|
||||
import electronUpdater from 'electron-updater';
|
||||
|
|
@ -97,6 +101,7 @@ export class UpdaterService {
|
|||
constructor() {
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
autoUpdater.fullChangelog = true;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
|
@ -241,11 +246,14 @@ export class UpdaterService {
|
|||
return;
|
||||
}
|
||||
|
||||
const latestReleaseNote = getUpdaterReleaseNoteForVersion(info.releaseNotes, info.version);
|
||||
const releaseNotes = formatUpdaterReleaseNotes(info.releaseNotes);
|
||||
|
||||
if (
|
||||
shouldSkipReleaseForUpdater({
|
||||
tag_name: `v${info.version}`,
|
||||
name: typeof info.releaseName === 'string' ? info.releaseName : undefined,
|
||||
body: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
|
||||
body: latestReleaseNote,
|
||||
})
|
||||
) {
|
||||
logger.warn(`Suppressing updater notification for locally marked release ${info.version}`);
|
||||
|
|
@ -278,7 +286,7 @@ export class UpdaterService {
|
|||
this.sendStatus({
|
||||
type: 'available',
|
||||
version: info.version,
|
||||
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
|
||||
releaseNotes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ function isPathLikeCandidate(candidate: string): boolean {
|
|||
}
|
||||
|
||||
function getPathEntries(): string[] {
|
||||
// TODO: Consider sharing runtimePathBinaryResolver here after preserving this resolver's
|
||||
// path-like candidate support and Windows PATHEXT normalization exactly.
|
||||
const delimiter = process.platform === 'win32' ? ';' : path.delimiter;
|
||||
const shellEnv = getCachedShellEnv() ?? {};
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -232,9 +234,11 @@ export class CodexBinaryResolver {
|
|||
|
||||
private static async runResolve(): Promise<string | null> {
|
||||
const override = process.env.CODEX_CLI_PATH?.trim();
|
||||
const shellOverride = getCachedShellEnv()?.CODEX_CLI_PATH?.trim();
|
||||
const appManagedBinaryPath = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
||||
const candidates = [
|
||||
...(override ? [override] : []),
|
||||
...(shellOverride && shellOverride !== override ? [shellOverride] : []),
|
||||
...(appManagedBinaryPath ? [appManagedBinaryPath] : []),
|
||||
'codex',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -135,6 +135,31 @@ describe('CodexBinaryResolver', () => {
|
|||
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim);
|
||||
});
|
||||
|
||||
it('uses CODEX_CLI_PATH from cached shell env before app-managed and PATH lookup', async () => {
|
||||
setPlatform('darwin');
|
||||
delete process.env.CODEX_CLI_PATH;
|
||||
process.env.PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
|
||||
const shellBinary = '/Users/tester/.local/bin/codex';
|
||||
const appManagedBinary = '/Users/tester/.agent-teams-ai/data/runtimes/codex/current/codex';
|
||||
getCachedShellEnvMock.mockReturnValue({
|
||||
CODEX_CLI_PATH: shellBinary,
|
||||
PATH: '/opt/homebrew/bin:/usr/bin:/bin',
|
||||
});
|
||||
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(appManagedBinary);
|
||||
|
||||
accessMock.mockImplementation((filePath) => {
|
||||
if (filePath === shellBinary || filePath === appManagedBinary) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
});
|
||||
|
||||
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
|
||||
CodexBinaryResolver.clearCache();
|
||||
|
||||
await expect(CodexBinaryResolver.resolve()).resolves.toBe(shellBinary);
|
||||
});
|
||||
|
||||
it('prefers a verified app-managed Codex binary before PATH lookup', async () => {
|
||||
const appManagedBinary = 'C:\\Users\\tester\\AppData\\Roaming\\AgentTeams\\codex.exe';
|
||||
const pathBinary = 'C:\\Program Files\\nodejs\\codex.cmd';
|
||||
|
|
|
|||
|
|
@ -108,6 +108,77 @@ describe('ClaudeMultimodelBridgeService runtime status mapping', () => {
|
|||
expect(provider.subscriptionRateLimits).toBeNull();
|
||||
});
|
||||
|
||||
test('preserves OpenCode route metadata in runtime model catalog mapping', () => {
|
||||
const provider = mapRuntimeProviderStatus('opencode', {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_configured_local',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: false,
|
||||
models: ['llama.cpp/qwen-test:0.5b'],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-21T00:00:00.000Z',
|
||||
staleAt: '2026-05-21T00:10:00.000Z',
|
||||
defaultModelId: 'llama.cpp/qwen-test:0.5b',
|
||||
defaultLaunchModel: 'llama.cpp/qwen-test:0.5b',
|
||||
models: [
|
||||
{
|
||||
id: 'llama.cpp/qwen-test:0.5b',
|
||||
launchModel: 'llama.cpp/qwen-test:0.5b',
|
||||
displayName: 'qwen-test:0.5b',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
metadata: {
|
||||
cost: null,
|
||||
context: 32768,
|
||||
limits: null,
|
||||
free: false,
|
||||
opencode: {
|
||||
providerId: 'llama.cpp',
|
||||
modelId: 'qwen-test:0.5b',
|
||||
sourceLabel: 'llama.cpp',
|
||||
accessKind: 'configured_authless',
|
||||
routeKind: 'configured_local',
|
||||
proofState: 'needs_probe',
|
||||
requiresExecutionProof: true,
|
||||
reason: 'Execution proof required',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.modelCatalog?.models[0]?.metadata?.opencode).toEqual({
|
||||
providerId: 'llama.cpp',
|
||||
modelId: 'qwen-test:0.5b',
|
||||
sourceLabel: 'llama.cpp',
|
||||
accessKind: 'configured_authless',
|
||||
routeKind: 'configured_local',
|
||||
proofState: 'needs_probe',
|
||||
requiresExecutionProof: true,
|
||||
reason: 'Execution proof required',
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores Anthropic subscription rate limits for API key auth', () => {
|
||||
const provider = mapRuntimeProviderStatus('anthropic', {
|
||||
supported: true,
|
||||
|
|
|
|||
|
|
@ -18,11 +18,13 @@ import type {
|
|||
CliProviderReasoningEffort,
|
||||
CliProviderStatus,
|
||||
CliProviderSubscriptionRateLimitSnapshot,
|
||||
OpenCodeModelRouteMetadata,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||
|
||||
const PROVIDER_STATUS_TIMEOUT_MS = 25_000;
|
||||
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 15_000;
|
||||
const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
|
||||
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||
const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||
|
|
@ -43,7 +45,12 @@ interface RuntimeExtensionCapabilitiesResponse {
|
|||
interface RuntimeProviderCapabilitiesResponse {
|
||||
modelCatalog?: {
|
||||
dynamic?: boolean;
|
||||
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback' | 'runtime';
|
||||
source?:
|
||||
| 'anthropic-models-api'
|
||||
| 'anthropic-compatible-api'
|
||||
| 'app-server'
|
||||
| 'static-fallback'
|
||||
| 'runtime';
|
||||
};
|
||||
reasoningEffort?: {
|
||||
supported?: boolean;
|
||||
|
|
@ -81,7 +88,7 @@ interface RuntimeProviderModelCatalogItemResponse {
|
|||
supportsPersonality?: boolean;
|
||||
isDefault?: boolean;
|
||||
upgrade?: boolean;
|
||||
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback';
|
||||
source?: 'anthropic-models-api' | 'anthropic-compatible-api' | 'app-server' | 'static-fallback';
|
||||
badgeLabel?: string | null;
|
||||
statusMessage?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
|
|
@ -90,7 +97,7 @@ interface RuntimeProviderModelCatalogItemResponse {
|
|||
interface RuntimeProviderModelCatalogResponse {
|
||||
schemaVersion?: number;
|
||||
providerId?: CliProviderId;
|
||||
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback';
|
||||
source?: 'anthropic-models-api' | 'anthropic-compatible-api' | 'app-server' | 'static-fallback';
|
||||
status?: 'ready' | 'stale' | 'degraded' | 'unavailable';
|
||||
fetchedAt?: string;
|
||||
staleAt?: string;
|
||||
|
|
@ -483,6 +490,61 @@ function collectRuntimeReasoningEfforts(values?: string[]): CliProviderReasoning
|
|||
);
|
||||
}
|
||||
|
||||
const OPENCODE_ACCESS_KINDS = new Set([
|
||||
'no_model',
|
||||
'unknown_model',
|
||||
'credentialed',
|
||||
'builtin_free',
|
||||
'configured_authless',
|
||||
'verified',
|
||||
'not_authenticated',
|
||||
'execution_failed',
|
||||
]);
|
||||
|
||||
const OPENCODE_ROUTE_KINDS = new Set([
|
||||
'connected_provider',
|
||||
'builtin_free',
|
||||
'configured_local',
|
||||
'catalog_provider',
|
||||
]);
|
||||
|
||||
const OPENCODE_PROOF_STATES = new Set(['not_required', 'needs_probe', 'verified', 'failed']);
|
||||
|
||||
function asStringOrNull(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function mapOpenCodeModelRouteMetadata(value: unknown): OpenCodeModelRouteMetadata | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const accessKind = record.accessKind;
|
||||
const routeKind = record.routeKind;
|
||||
const proofState = record.proofState;
|
||||
if (
|
||||
typeof accessKind !== 'string' ||
|
||||
typeof routeKind !== 'string' ||
|
||||
typeof proofState !== 'string' ||
|
||||
!OPENCODE_ACCESS_KINDS.has(accessKind) ||
|
||||
!OPENCODE_ROUTE_KINDS.has(routeKind) ||
|
||||
!OPENCODE_PROOF_STATES.has(proofState)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: asStringOrNull(record.providerId),
|
||||
modelId: asStringOrNull(record.modelId),
|
||||
sourceLabel: asStringOrNull(record.sourceLabel),
|
||||
accessKind: accessKind as OpenCodeModelRouteMetadata['accessKind'],
|
||||
routeKind: routeKind as OpenCodeModelRouteMetadata['routeKind'],
|
||||
proofState: proofState as OpenCodeModelRouteMetadata['proofState'],
|
||||
requiresExecutionProof: record.requiresExecutionProof === true,
|
||||
reason: asStringOrNull(record.reason),
|
||||
};
|
||||
}
|
||||
|
||||
function mapRuntimeProviderModelMetadata(
|
||||
metadata?: Record<string, unknown> | null
|
||||
): NonNullable<CliProviderStatus['modelCatalog']>['models'][number]['metadata'] {
|
||||
|
|
@ -490,11 +552,13 @@ function mapRuntimeProviderModelMetadata(
|
|||
return null;
|
||||
}
|
||||
const context = metadata.context;
|
||||
const opencode = mapOpenCodeModelRouteMetadata(metadata.opencode);
|
||||
return {
|
||||
cost: metadata.cost ?? null,
|
||||
context: typeof context === 'number' && Number.isFinite(context) ? context : null,
|
||||
limits: metadata.limits ?? null,
|
||||
free: metadata.free === true,
|
||||
...(opencode ? { opencode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -515,6 +579,7 @@ function mapRuntimeProviderModelCatalog(
|
|||
!fetchedAt ||
|
||||
!staleAt ||
|
||||
(source !== 'anthropic-models-api' &&
|
||||
source !== 'anthropic-compatible-api' &&
|
||||
source !== 'app-server' &&
|
||||
source !== 'static-fallback') ||
|
||||
(status !== 'ready' && status !== 'stale' && status !== 'degraded' && status !== 'unavailable')
|
||||
|
|
@ -539,6 +604,7 @@ function mapRuntimeProviderModelCatalog(
|
|||
);
|
||||
const itemSource =
|
||||
model.source === 'anthropic-models-api' ||
|
||||
model.source === 'anthropic-compatible-api' ||
|
||||
model.source === 'app-server' ||
|
||||
model.source === 'static-fallback'
|
||||
? model.source
|
||||
|
|
@ -731,7 +797,11 @@ export class ClaudeMultimodelBridgeService {
|
|||
private async buildCliEnv(
|
||||
binaryPath: string
|
||||
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
|
||||
return buildProviderAwareCliEnv({ binaryPath, allowStoredApiKeyDecryption: false });
|
||||
return buildProviderAwareCliEnv({
|
||||
binaryPath,
|
||||
allowStoredApiKeyDecryption: false,
|
||||
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
|
||||
});
|
||||
}
|
||||
|
||||
private async buildProviderCliEnv(
|
||||
|
|
@ -742,6 +812,8 @@ export class ClaudeMultimodelBridgeService {
|
|||
binaryPath,
|
||||
providerId,
|
||||
allowStoredApiKeyDecryption: false,
|
||||
allowedStoredApiKeyEnvVarNames:
|
||||
providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -762,6 +834,12 @@ export class ClaudeMultimodelBridgeService {
|
|||
return this.isRuntimeStatusCompatibilityError(error) || lower.includes('runtime status');
|
||||
}
|
||||
|
||||
private isRuntimeStatusTimeoutError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lower = message.toLowerCase();
|
||||
return lower.includes('timed out') || lower.includes('timeout');
|
||||
}
|
||||
|
||||
private mapRuntimeProviderStatus(
|
||||
providerId: CliProviderId,
|
||||
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
|
||||
|
|
@ -901,14 +979,14 @@ export class ClaudeMultimodelBridgeService {
|
|||
providerId: CliProviderId,
|
||||
env: NodeJS.ProcessEnv,
|
||||
connectionIssues: Partial<Record<CliProviderId, string>>,
|
||||
options: { summary?: boolean } = {}
|
||||
options: { summary?: boolean; timeoutMs?: number } = {}
|
||||
): Promise<CliProviderStatus> {
|
||||
const args = ['runtime', 'status', '--json', '--provider', providerId];
|
||||
if (options.summary) {
|
||||
args.push('--summary');
|
||||
}
|
||||
const { stdout } = await execCli(binaryPath, args, {
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
timeout: options.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS,
|
||||
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
||||
env,
|
||||
});
|
||||
|
|
@ -925,7 +1003,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
private async getProviderStatusFromScopedRuntimeStatus(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId,
|
||||
options: { summary?: boolean } = {}
|
||||
options: { summary?: boolean; timeoutMs?: number } = {}
|
||||
): Promise<CliProviderStatus> {
|
||||
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
|
||||
return this.getProviderStatusFromRuntimeStatusCommand(
|
||||
|
|
@ -940,7 +1018,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
private async getProviderStatusesFromScopedRuntimeStatus(
|
||||
binaryPath: string,
|
||||
onUpdate?: (providers: CliProviderStatus[]) => void,
|
||||
options: { summary?: boolean; providerIds?: readonly CliProviderId[] } = {}
|
||||
options: { summary?: boolean; timeoutMs?: number; providerIds?: readonly CliProviderId[] } = {}
|
||||
): Promise<CliProviderStatus[] | null> {
|
||||
const providerIds = options.providerIds ?? ORDERED_PROVIDER_IDS;
|
||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||
|
|
@ -967,6 +1045,19 @@ export class ClaudeMultimodelBridgeService {
|
|||
}
|
||||
|
||||
if (failures.length === providerIds.length) {
|
||||
if (failures.every(({ error }) => this.isRuntimeStatusTimeoutError(error))) {
|
||||
logger.warn(
|
||||
`Provider-scoped runtime status timed out for ${failures
|
||||
.map(({ providerId }) => providerId)
|
||||
.join(', ')}; using error provider statuses without slower fallback probes`
|
||||
);
|
||||
for (const { providerId, error } of failures) {
|
||||
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
|
||||
}
|
||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -1137,7 +1228,11 @@ export class ClaudeMultimodelBridgeService {
|
|||
providerId: CliProviderId,
|
||||
onCatalogUpdate?: (provider: CliProviderStatus) => void
|
||||
): Promise<CliProviderStatus> {
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const generation = this.beginProviderStatusHydration([providerId]);
|
||||
|
|
@ -1353,14 +1448,22 @@ export class ClaudeMultimodelBridgeService {
|
|||
binaryPath: string,
|
||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||
): Promise<CliProviderStatus[]> {
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const generation = this.beginProviderStatusHydration(DEFAULT_PROVIDER_STATUS_IDS);
|
||||
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(
|
||||
binaryPath,
|
||||
onUpdate,
|
||||
{ summary: true, providerIds: DEFAULT_PROVIDER_STATUS_IDS }
|
||||
{
|
||||
summary: true,
|
||||
timeoutMs: PROVIDER_STATUS_SUMMARY_TIMEOUT_MS,
|
||||
providerIds: DEFAULT_PROVIDER_STATUS_IDS,
|
||||
}
|
||||
);
|
||||
if (providers) {
|
||||
this.hydrateProviderCatalogs(binaryPath, providers, generation, onUpdate);
|
||||
|
|
|
|||
|
|
@ -194,6 +194,8 @@ export class CliProviderModelAvailabilityService {
|
|||
binaryPath: context.binaryPath,
|
||||
providerId: context.provider.providerId,
|
||||
allowStoredApiKeyDecryption: false,
|
||||
allowedStoredApiKeyEnvVarNames:
|
||||
context.provider.providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined,
|
||||
}).then((result) => ({
|
||||
env: result.env,
|
||||
providerArgs: result.providerArgs ?? [],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { ApiKeyService } from '../extensions/apikeys/ApiKeyService';
|
||||
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import type { AnthropicCompatibleEndpointConfig } from '../infrastructure/ConfigManager';
|
||||
import type {
|
||||
CodexAccountAuthMode,
|
||||
CodexAccountSnapshotDto,
|
||||
|
|
@ -37,6 +38,7 @@ type ExternalCredential = {
|
|||
|
||||
interface StoredApiKeyAccessOptions {
|
||||
allowStoredApiKeyDecryption?: boolean;
|
||||
allowedStoredApiKeyEnvVarNames?: readonly string[];
|
||||
}
|
||||
|
||||
const PROVIDER_CAPABILITIES: Record<
|
||||
|
|
@ -71,6 +73,8 @@ const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
|
|||
gemini: 'GEMINI_API_KEY',
|
||||
};
|
||||
|
||||
const ANTHROPIC_BASE_URL_ENV_VAR = 'ANTHROPIC_BASE_URL';
|
||||
const ANTHROPIC_AUTH_TOKEN_ENV_VAR = 'ANTHROPIC_AUTH_TOKEN';
|
||||
const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
|
||||
const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH';
|
||||
const CODEX_HOME_ENV_VAR = 'CODEX_HOME';
|
||||
|
|
@ -80,6 +84,7 @@ const CODEX_LOGIN_STATUS_TIMEOUT_MS = 5_000;
|
|||
const ANTHROPIC_API_KEY_VERIFY_TIMEOUT_MS = 10_000;
|
||||
const ANTHROPIC_API_KEY_VERIFY_CACHE_TTL_MS = 60_000;
|
||||
const ANTHROPIC_DEFAULT_API_BASE_URL = 'https://api.anthropic.com';
|
||||
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
|
||||
|
||||
type CodexCliLoginStatus = 'logged_in' | 'not_logged_in' | 'unknown';
|
||||
|
||||
|
|
@ -107,6 +112,10 @@ type AnthropicApiKeyVerifier = (
|
|||
baseUrl?: string | null
|
||||
) => Promise<AnthropicApiKeyVerificationResult>;
|
||||
|
||||
type CodexAccountSnapshotReader = Pick<CodexAccountFeatureFacade, 'getSnapshot'> & {
|
||||
refreshSnapshot?: CodexAccountFeatureFacade['refreshSnapshot'];
|
||||
};
|
||||
|
||||
interface ProviderStatusEnrichmentOptions {
|
||||
hydrateModelCatalog?: boolean;
|
||||
}
|
||||
|
|
@ -150,6 +159,51 @@ function buildAnthropicModelsUrl(baseUrl?: string | null): string {
|
|||
return url.toString();
|
||||
}
|
||||
|
||||
function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
!FIRST_PARTY_ANTHROPIC_HOSTS.has(url.hostname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnthropicCompatibleAuthEnv(env: NodeJS.ProcessEnv): boolean {
|
||||
if (!isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(env.ANTHROPIC_AUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim());
|
||||
}
|
||||
|
||||
function isUsableAnthropicCompatibleEndpoint(
|
||||
endpoint: AnthropicCompatibleEndpointConfig | undefined
|
||||
): endpoint is AnthropicCompatibleEndpointConfig {
|
||||
if (endpoint?.enabled !== true || !endpoint.baseUrl.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(endpoint.baseUrl.trim());
|
||||
return (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
isAnthropicCompatibleBaseUrl(endpoint.baseUrl)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAnthropicApiKeyWithApi(
|
||||
apiKey: string,
|
||||
baseUrl?: string | null
|
||||
|
|
@ -307,7 +361,7 @@ async function checkCodexCliLoginStatus({
|
|||
|
||||
export class ProviderConnectionService {
|
||||
private static instance: ProviderConnectionService | null = null;
|
||||
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
|
||||
private codexAccountFeature: CodexAccountSnapshotReader | null = null;
|
||||
private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null =
|
||||
null;
|
||||
private readonly anthropicApiKeyVerificationCache = new Map<
|
||||
|
|
@ -327,7 +381,7 @@ export class ProviderConnectionService {
|
|||
return ProviderConnectionService.instance;
|
||||
}
|
||||
|
||||
setCodexAccountFeature(feature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null): void {
|
||||
setCodexAccountFeature(feature: CodexAccountSnapshotReader | null): void {
|
||||
this.codexAccountFeature = feature;
|
||||
}
|
||||
|
||||
|
|
@ -367,11 +421,125 @@ export class ProviderConnectionService {
|
|||
return null;
|
||||
}
|
||||
|
||||
private getConfiguredAnthropicCompatibleEndpoint(): AnthropicCompatibleEndpointConfig | null {
|
||||
const endpoint =
|
||||
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
|
||||
return isUsableAnthropicCompatibleEndpoint(endpoint)
|
||||
? { enabled: true, baseUrl: endpoint.baseUrl.trim() }
|
||||
: null;
|
||||
}
|
||||
|
||||
private getConfiguredAnthropicCompatibleEndpointIssue(): string | null {
|
||||
const endpoint =
|
||||
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
|
||||
if (endpoint?.enabled !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = endpoint.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
return 'Anthropic-compatible endpoint is enabled, but no base URL is configured.';
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return 'Anthropic-compatible endpoint base URL must use http:// or https://.';
|
||||
}
|
||||
|
||||
if (url.username || url.password) {
|
||||
return 'Anthropic-compatible endpoint base URL must not include credentials.';
|
||||
}
|
||||
|
||||
if (!isAnthropicCompatibleBaseUrl(baseUrl)) {
|
||||
return 'Anthropic-compatible endpoint cannot use the first-party Anthropic API host.';
|
||||
}
|
||||
} catch {
|
||||
return 'Anthropic-compatible endpoint base URL is invalid.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getConfiguredAnthropicCompatibleToken(
|
||||
options?: StoredApiKeyAccessOptions
|
||||
): Promise<ExternalCredential> {
|
||||
const storedToken = await this.lookupStoredApiKeyValue(ANTHROPIC_AUTH_TOKEN_ENV_VAR, options);
|
||||
if (storedToken?.value.trim()) {
|
||||
return {
|
||||
label: 'Stored in app',
|
||||
value: storedToken.value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const envToken = this.getExternalEnvValue(ANTHROPIC_AUTH_TOKEN_ENV_VAR);
|
||||
return envToken
|
||||
? {
|
||||
label: `Detected from ${ANTHROPIC_AUTH_TOKEN_ENV_VAR}`,
|
||||
value: envToken,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
private async applyConfiguredAnthropicCompatibleEndpointEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
options?: StoredApiKeyAccessOptions
|
||||
): Promise<boolean> {
|
||||
const endpoint = this.getConfiguredAnthropicCompatibleEndpoint();
|
||||
if (!endpoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
env[ANTHROPIC_BASE_URL_ENV_VAR] = endpoint.baseUrl;
|
||||
const token = await this.getConfiguredAnthropicCompatibleToken(options);
|
||||
if (token?.value.trim()) {
|
||||
env[ANTHROPIC_AUTH_TOKEN_ENV_VAR] = token.value.trim();
|
||||
}
|
||||
|
||||
if (typeof env.ANTHROPIC_API_KEY !== 'string' || !env.ANTHROPIC_API_KEY.trim()) {
|
||||
env.ANTHROPIC_API_KEY = '';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getAnthropicCompatibleEndpointConnectionInfo(): Promise<
|
||||
NonNullable<CliProviderConnectionInfo['compatibleEndpoint']>
|
||||
> {
|
||||
const endpoint =
|
||||
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
|
||||
const hasStoredToken = await this.hasStoredApiKey(ANTHROPIC_AUTH_TOKEN_ENV_VAR);
|
||||
const envToken = this.getExternalEnvValue(ANTHROPIC_AUTH_TOKEN_ENV_VAR);
|
||||
const tokenSource = hasStoredToken ? 'stored' : envToken ? 'environment' : null;
|
||||
|
||||
return {
|
||||
enabled: endpoint.enabled,
|
||||
baseUrl: endpoint.baseUrl,
|
||||
tokenConfigured: Boolean(tokenSource),
|
||||
tokenSource,
|
||||
tokenSourceLabel:
|
||||
tokenSource === 'stored'
|
||||
? 'Stored in app'
|
||||
: tokenSource === 'environment'
|
||||
? `Detected from ${ANTHROPIC_AUTH_TOKEN_ENV_VAR}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getConfiguredAnthropicApiKeyForTeamRuntime(env: NodeJS.ProcessEnv): Promise<string | null> {
|
||||
if (this.getConfiguredAuthMode('anthropic') !== 'api_key') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configuredEndpoint =
|
||||
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
|
||||
if (
|
||||
configuredEndpoint?.enabled === true ||
|
||||
isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY');
|
||||
if (storedKey?.value.trim()) {
|
||||
return storedKey.value.trim();
|
||||
|
|
@ -388,6 +556,14 @@ export class ProviderConnectionService {
|
|||
options?: StoredApiKeyAccessOptions
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
if (providerId === 'anthropic') {
|
||||
if (await this.applyConfiguredAnthropicCompatibleEndpointEnv(env, options)) {
|
||||
return env;
|
||||
}
|
||||
|
||||
if (hasAnthropicCompatibleAuthEnv(env)) {
|
||||
return env;
|
||||
}
|
||||
|
||||
const authMode = this.getConfiguredAuthMode(providerId);
|
||||
if (authMode === 'oauth') {
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
|
|
@ -427,7 +603,9 @@ export class ProviderConnectionService {
|
|||
return env;
|
||||
}
|
||||
|
||||
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
||||
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||
refreshRuntimeMissing: true,
|
||||
});
|
||||
applyCodexRuntimeContextEnv(env, snapshot);
|
||||
const readiness = evaluateCodexLaunchReadiness({
|
||||
preferredAuthMode: snapshot.preferredAuthMode,
|
||||
|
|
@ -480,6 +658,10 @@ export class ProviderConnectionService {
|
|||
options?: StoredApiKeyAccessOptions
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
if (providerId === 'anthropic') {
|
||||
if (await this.applyConfiguredAnthropicCompatibleEndpointEnv(env, options)) {
|
||||
return env;
|
||||
}
|
||||
|
||||
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
||||
return env;
|
||||
}
|
||||
|
|
@ -503,7 +685,9 @@ export class ProviderConnectionService {
|
|||
return env;
|
||||
}
|
||||
|
||||
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
||||
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||
refreshRuntimeMissing: true,
|
||||
});
|
||||
applyCodexRuntimeContextEnv(env, snapshot);
|
||||
const readiness = evaluateCodexLaunchReadiness({
|
||||
preferredAuthMode: snapshot.preferredAuthMode,
|
||||
|
|
@ -550,10 +734,23 @@ export class ProviderConnectionService {
|
|||
runtimeBackendOverride?: string | null
|
||||
): Promise<string | null> {
|
||||
if (providerId === 'anthropic') {
|
||||
const compatibleEndpointIssue = this.getConfiguredAnthropicCompatibleEndpointIssue();
|
||||
if (compatibleEndpointIssue) {
|
||||
return compatibleEndpointIssue;
|
||||
}
|
||||
|
||||
if (this.getConfiguredAnthropicCompatibleEndpoint()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasAnthropicCompatibleAuthEnv(env)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -572,7 +769,9 @@ export class ProviderConnectionService {
|
|||
return null;
|
||||
}
|
||||
|
||||
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
||||
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||
refreshRuntimeMissing: true,
|
||||
});
|
||||
const runtimeEnv = { ...env };
|
||||
applyCodexRuntimeContextEnv(runtimeEnv, snapshot);
|
||||
const readiness = evaluateCodexLaunchReadiness({
|
||||
|
|
@ -681,7 +880,9 @@ export class ProviderConnectionService {
|
|||
return [];
|
||||
}
|
||||
|
||||
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
||||
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||
refreshRuntimeMissing: true,
|
||||
});
|
||||
const readiness = evaluateCodexLaunchReadiness({
|
||||
preferredAuthMode: snapshot.preferredAuthMode,
|
||||
managedAccount: snapshot.managedAccount,
|
||||
|
|
@ -783,6 +984,18 @@ export class ProviderConnectionService {
|
|||
provider: CliProviderStatus
|
||||
): Promise<CliProviderStatus> {
|
||||
const connection = provider.connection;
|
||||
if (connection?.compatibleEndpoint?.enabled === true) {
|
||||
return {
|
||||
...provider,
|
||||
subscriptionRateLimits: null,
|
||||
statusMessage:
|
||||
provider.statusMessage ??
|
||||
(connection.compatibleEndpoint.tokenConfigured
|
||||
? 'Anthropic-compatible endpoint configured'
|
||||
: 'Anthropic-compatible endpoint configured. Auth token is not set.'),
|
||||
};
|
||||
}
|
||||
|
||||
if (connection?.configuredAuthMode !== 'api_key') {
|
||||
return provider;
|
||||
}
|
||||
|
|
@ -916,6 +1129,8 @@ export class ProviderConnectionService {
|
|||
: hasStoredApiKey
|
||||
? 'Stored in app'
|
||||
: (externalCredential?.label ?? null);
|
||||
const compatibleEndpoint =
|
||||
providerId === 'anthropic' ? await this.getAnthropicCompatibleEndpointConnectionInfo() : null;
|
||||
|
||||
return {
|
||||
...capabilities,
|
||||
|
|
@ -924,6 +1139,7 @@ export class ProviderConnectionService {
|
|||
apiKeyConfigured,
|
||||
apiKeySource,
|
||||
apiKeySourceLabel,
|
||||
compatibleEndpoint,
|
||||
codex:
|
||||
providerId === 'codex' && codexSnapshot
|
||||
? {
|
||||
|
|
@ -971,7 +1187,9 @@ export class ProviderConnectionService {
|
|||
envVarName: string,
|
||||
options?: StoredApiKeyAccessOptions
|
||||
): Promise<{ envVarName: string; value: string } | null> {
|
||||
if (options?.allowStoredApiKeyDecryption === false) {
|
||||
const allowedWhenMetadataOnly =
|
||||
options?.allowedStoredApiKeyEnvVarNames?.includes(envVarName) === true;
|
||||
if (options?.allowStoredApiKeyDecryption === false && !allowedWhenMetadataOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -985,8 +1203,13 @@ export class ProviderConnectionService {
|
|||
return CODEX_NATIVE_BACKEND_ID;
|
||||
}
|
||||
|
||||
private async getCodexAccountSnapshot(): Promise<CodexAccountSnapshotDto> {
|
||||
private async getCodexAccountSnapshot(options?: {
|
||||
forceRefresh?: boolean;
|
||||
}): Promise<CodexAccountSnapshotDto> {
|
||||
if (this.codexAccountFeature) {
|
||||
if (options?.forceRefresh && this.codexAccountFeature.refreshSnapshot) {
|
||||
return this.codexAccountFeature.refreshSnapshot({ forceRefreshToken: true });
|
||||
}
|
||||
return this.codexAccountFeature.getSnapshot();
|
||||
}
|
||||
|
||||
|
|
@ -1042,6 +1265,27 @@ export class ProviderConnectionService {
|
|||
};
|
||||
}
|
||||
|
||||
private async getCodexLaunchSnapshot(
|
||||
env: NodeJS.ProcessEnv,
|
||||
options?: { refreshRuntimeMissing?: boolean }
|
||||
): Promise<CodexAccountSnapshotDto> {
|
||||
let snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
||||
if (!options?.refreshRuntimeMissing || snapshot.appServerState !== 'runtime-missing') {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
try {
|
||||
snapshot = this.mergeCodexApiKeyAvailability(
|
||||
await this.getCodexAccountSnapshot({ forceRefresh: true }),
|
||||
env
|
||||
);
|
||||
} catch {
|
||||
// Keep the original runtime-missing snapshot so callers still report the concrete issue.
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async resolveCodexApiKeyValue(
|
||||
env: NodeJS.ProcessEnv,
|
||||
runtimeBackendOverride?: string | null,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue