From de1b9e2378ac6ea6589c7922e1e23814d0245bbc Mon Sep 17 00:00:00 2001 From: Arach Tchoupani Date: Sat, 30 May 2026 16:13:40 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20Scope=20GitHub=20Actio?= =?UTF-8?q?ns=20to=20release=20lanes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 20 ++- .github/workflows/deploy.yml | 7 +- .github/workflows/release-app-ios.yml | 63 +++++++ .github/workflows/release-app-macos.yml | 137 ++++++++++++++ .../{heavy-ci.yml => release-candidate.yml} | 170 ++---------------- ...pm-publish.yml => release-package-npm.yml} | 38 +++- docs/eng/releasing.md | 29 ++- docs/releases.md | 18 +- scripts/ship-release.mjs | 4 +- 9 files changed, 310 insertions(+), 176 deletions(-) create mode 100644 .github/workflows/release-app-ios.yml create mode 100644 .github/workflows/release-app-macos.yml rename .github/workflows/{heavy-ci.yml => release-candidate.yml} (56%) rename .github/workflows/{npm-publish.yml => release-package-npm.yml} (72%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3617f479..2a7b23e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,23 @@ name: CI on: pull_request: - push: - branches: - - main + paths: + - ".github/workflows/*.yml" + - "apps/cloud/**" + - "apps/desktop/**" + - "apps/mesh-front-door/**" + - "packages/agent-sessions/**" + - "packages/cli/**" + - "packages/protocol/**" + - "packages/runtime/**" + - "packages/session-trace/**" + - "packages/session-trace-react/**" + - "packages/web/**" + - "scripts/**" + - "package.json" + - "bun.lock" + - "tsconfig*.json" + workflow_call: permissions: contents: read diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cfad882a..6978fee5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,8 +1,13 @@ -name: Deploy to GitHub Pages +name: Deploy Site on: push: branches: [main] + paths: + - ".github/workflows/deploy.yml" + - "landing/**" + - "package.json" + - "bun.lock" permissions: contents: read diff --git a/.github/workflows/release-app-ios.yml b/.github/workflows/release-app-ios.yml new file mode 100644 index 00000000..cfb3ab1f --- /dev/null +++ b/.github/workflows/release-app-ios.yml @@ -0,0 +1,63 @@ +name: Release App iOS + +on: + push: + tags: + - app-ios-v* + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-app-ios-${{ github.ref }} + cancel-in-progress: false + +jobs: + app-store: + name: Upload to App Store Connect + runs-on: macos-latest + timeout-minutes: 60 + environment: Production + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "24.x" + package-manager-cache: false + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Validate release target + shell: bash + run: | + set -euo pipefail + + package_version="$(node -p "require('./package.json').version")" + + if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then + expected="${GITHUB_REF_NAME#app-ios-v}" + if [[ "$expected" != "$package_version" ]]; then + echo "::error::Tag version $expected does not match package.json version $package_version." + exit 1 + fi + fi + + - name: Verify asc CLI + env: + OPENSCOUT_ASC_BIN: ${{ vars.OPENSCOUT_ASC_BIN || 'asc' }} + run: | + set -euo pipefail + + if ! command -v "$OPENSCOUT_ASC_BIN" >/dev/null 2>&1; then + echo "::error::Missing App Store Connect CLI: $OPENSCOUT_ASC_BIN" + exit 1 + fi + + - name: Release to App Store Connect + env: + OPENSCOUT_ASC_APP_ID: ${{ vars.OPENSCOUT_ASC_APP_ID || '6761672978' }} + OPENSCOUT_ASC_BIN: ${{ vars.OPENSCOUT_ASC_BIN || 'asc' }} + run: bash apps/ios/scripts/release.sh diff --git a/.github/workflows/release-app-macos.yml b/.github/workflows/release-app-macos.yml new file mode 100644 index 00000000..1a80828a --- /dev/null +++ b/.github/workflows/release-app-macos.yml @@ -0,0 +1,137 @@ +name: Release App macOS + +on: + push: + tags: + - app-macos-v* + workflow_dispatch: + inputs: + version: + description: "Version to release. Defaults to package.json." + required: false + upload_release: + description: "Create or update the GitHub release with the DMG." + type: boolean + required: false + default: true + +permissions: + contents: write + +concurrency: + group: release-app-macos-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name || github.run_id }} + cancel-in-progress: false + +jobs: + dmg: + name: Build signed and notarized DMG + runs-on: macos-latest + timeout-minutes: 45 + environment: Production + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.13" + + - name: Resolve release target + id: release + shell: bash + env: + INPUT_VERSION: ${{ inputs.version || '' }} + run: | + set -euo pipefail + + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + version="${INPUT_VERSION:-$(node -p "require('./package.json').version")}" + tag="app-macos-v${version}" + else + tag="$GITHUB_REF_NAME" + version="${tag#app-macos-v}" + fi + + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "::error::Release version must look like X.Y.Z, got: $version" + exit 1 + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Import Developer ID certificate + shell: bash + env: + P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64 }} + P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_APPLICATION_P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.MACOS_RELEASE_KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + + if [[ -z "$P12_BASE64" || -z "$P12_PASSWORD" || -z "$KEYCHAIN_PASSWORD" ]]; then + echo "::error::Missing macOS signing secrets." + exit 1 + fi + + keychain="$RUNNER_TEMP/openscout-release.keychain-db" + cert="$RUNNER_TEMP/developer-id-application.p12" + + printf '%s' "$P12_BASE64" | base64 -d > "$cert" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" + security import "$cert" -k "$keychain" -P "$P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + security list-keychains -d user -s "$keychain" "$HOME/Library/Keychains/login.keychain-db" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$keychain" + + - name: Configure notarization profile + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + set -euo pipefail + + if [[ -z "$APPLE_ID" || -z "$APPLE_TEAM_ID" || -z "$APPLE_APP_SPECIFIC_PASSWORD" ]]; then + echo "::error::Missing Apple notarization secrets." + exit 1 + fi + + xcrun notarytool store-credentials openscout-ci-notarytool \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" + + - name: Build DMG + env: + VERSION: ${{ steps.release.outputs.version }} + OPENSCOUT_NOTARY_PROFILE: openscout-ci-notarytool + OPENSCOUT_SIGN_IDENTITY: ${{ vars.OPENSCOUT_SIGN_IDENTITY || '' }} + run: bash apps/macos/scripts/build-dmg.sh + + - name: Upload DMG artifact + uses: actions/upload-artifact@v5 + with: + name: OpenScoutMenu-${{ steps.release.outputs.version }} + path: apps/macos/dist/OpenScoutMenu-${{ steps.release.outputs.version }}.dmg + if-no-files-found: error + + - name: Create or update GitHub release + if: ${{ github.event_name == 'push' || inputs.upload_release }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + + dmg="apps/macos/dist/OpenScoutMenu-${RELEASE_VERSION}.dmg" + + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" "$dmg" --clobber + else + gh release create "$RELEASE_TAG" "$dmg" \ + --title "OpenScout macOS ${RELEASE_VERSION}" \ + --notes "Signed and notarized macOS app release." + fi diff --git a/.github/workflows/heavy-ci.yml b/.github/workflows/release-candidate.yml similarity index 56% rename from .github/workflows/heavy-ci.yml rename to .github/workflows/release-candidate.yml index 1bbc2d48..8f343b4c 100644 --- a/.github/workflows/heavy-ci.yml +++ b/.github/workflows/release-candidate.yml @@ -1,167 +1,31 @@ -name: Heavy CI +name: Release Candidate on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review - - labeled - - unlabeled + push: + tags: + - candidate-v* workflow_dispatch: inputs: - target: - description: Heavy lane to run. - type: choice - required: true - default: full - options: - - native - - web-build - - e2e - - full live_harness: - description: Run the live agent harness when the e2e lane is selected. + description: Run the live agent harness after the normal candidate lanes. type: boolean required: false default: false permissions: contents: read - pull-requests: read concurrency: - group: heavy-ci-${{ github.event.pull_request.number || github.ref }} + group: release-candidate-${{ github.ref }} cancel-in-progress: true jobs: - plan: - name: Plan heavy lanes - runs-on: ubuntu-latest - outputs: - native: ${{ steps.plan.outputs.native }} - web_build: ${{ steps.plan.outputs.web_build }} - e2e: ${{ steps.plan.outputs.e2e }} - live_harness: ${{ steps.plan.outputs.live_harness }} - steps: - - name: Resolve labels, dispatch inputs, and touched paths - id: plan - env: - GH_TOKEN: ${{ github.token }} - DISPATCH_TARGET: ${{ github.event.inputs.target || '' }} - DISPATCH_LIVE_HARNESS: ${{ github.event.inputs.live_harness || 'false' }} - run: | - set -euo pipefail - - native=false - web_build=false - e2e=false - live_harness=false - reasons=() - - labels_file="$(mktemp)" - jq -r '.pull_request.labels[]?.name // empty' "$GITHUB_EVENT_PATH" > "$labels_file" - - has_label() { - grep -Fxq "$1" "$labels_file" - } - - if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then - target="${DISPATCH_TARGET:-full}" - reasons+=("workflow_dispatch:${target}") - - case "$target" in - native) - native=true - ;; - web-build) - web_build=true - ;; - e2e) - e2e=true - ;; - full) - native=true - web_build=true - e2e=true - ;; - *) - echo "::error::Unsupported heavy CI target: $target" - exit 1 - ;; - esac - - if [[ "$DISPATCH_LIVE_HARNESS" == "true" ]]; then - live_harness=true - e2e=true - reasons+=("workflow_dispatch:live_harness") - fi - else - if has_label "ci:full"; then - native=true - web_build=true - e2e=true - reasons+=("label:ci:full") - fi - - if has_label "ci:native"; then - native=true - reasons+=("label:ci:native") - fi - - if has_label "ci:web-build"; then - web_build=true - reasons+=("label:ci:web-build") - fi - - if has_label "ci:e2e"; then - e2e=true - reasons+=("label:ci:e2e") - fi - - pr_number="$(jq -r '.pull_request.number // empty' "$GITHUB_EVENT_PATH")" - if [[ -n "$pr_number" ]]; then - files_file="$(mktemp)" - gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \ - --jq '.[].filename' > "$files_file" - - if grep -Eq '^(apps/ios|apps/macos|packages/scout-native-core)/' "$files_file"; then - native=true - reasons+=("paths:native") - fi - fi - fi - - if [[ "$e2e" != "true" ]]; then - live_harness=false - fi - - if ((${#reasons[@]} == 0)); then - reasons+=("no heavy lanes selected") - fi - - { - echo "native=$native" - echo "web_build=$web_build" - echo "e2e=$e2e" - echo "live_harness=$live_harness" - } >> "$GITHUB_OUTPUT" - - { - echo "### Heavy CI plan" - echo - echo "- native: \`$native\`" - echo "- web/CLI build: \`$web_build\`" - echo "- e2e: \`$e2e\`" - echo "- live harness: \`$live_harness\`" - echo "- reasons: \`${reasons[*]}\`" - } >> "$GITHUB_STEP_SUMMARY" + linux-checks: + name: Linux typecheck and unit tests + uses: ./.github/workflows/ci.yml native-macos: - name: Native macOS build/test - needs: plan - if: needs.plan.outputs.native == 'true' + name: macOS app build/test runs-on: macos-latest timeout-minutes: 30 steps: @@ -194,9 +58,7 @@ jobs: run: bun ./apps/macos/bin/openscout-menu.ts build native-ios: - name: Native iOS build/test - needs: plan - if: needs.plan.outputs.native == 'true' + name: iOS app build/test runs-on: macos-latest timeout-minutes: 60 steps: @@ -266,9 +128,7 @@ jobs: test web-cli-package-build: - name: Web and CLI packaged build - needs: plan - if: needs.plan.outputs.web_build == 'true' + name: Web and npm package build runs-on: ubuntu-latest timeout-minutes: 35 steps: @@ -298,8 +158,6 @@ jobs: e2e-scenarios: name: Runtime e2e scenarios - needs: plan - if: needs.plan.outputs.e2e == 'true' runs-on: ubuntu-latest timeout-minutes: 35 steps: @@ -320,8 +178,8 @@ jobs: live-harness: name: Live local harness pass - needs: plan - if: needs.plan.outputs.live_harness == 'true' + needs: e2e-scenarios + if: ${{ github.event_name == 'workflow_dispatch' && inputs.live_harness }} runs-on: ubuntu-latest timeout-minutes: 60 steps: diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/release-package-npm.yml similarity index 72% rename from .github/workflows/npm-publish.yml rename to .github/workflows/release-package-npm.yml index eafc236c..6edeccfe 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/release-package-npm.yml @@ -1,10 +1,13 @@ -name: Publish npm +name: Release Package npm on: + push: + tags: + - npm-v* workflow_dispatch: inputs: tag: - description: "Release tag to publish, for example v0.2.68" + description: "Release tag to publish, for example npm-v0.2.68 or v0.2.68" required: true npm_tag: description: "npm dist-tag" @@ -16,7 +19,7 @@ permissions: id-token: write concurrency: - group: npm-publish-${{ inputs.tag }} + group: release-package-npm-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} cancel-in-progress: false jobs: @@ -27,17 +30,36 @@ jobs: - name: Resolve release target id: release shell: bash + env: + INPUT_TAG: ${{ inputs.tag || '' }} + INPUT_NPM_TAG: ${{ inputs.npm_tag || '' }} run: | - tag="${{ inputs.tag }}" - npm_tag="${{ inputs.npm_tag }}" + set -euo pipefail + + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + tag="$INPUT_TAG" + npm_tag="${INPUT_NPM_TAG:-latest}" + else + tag="$GITHUB_REF_NAME" + npm_tag="latest" + fi - if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - echo "Release tag must look like vX.Y.Z, got: $tag" >&2 + case "$tag" in + npm-v*) version="${tag#npm-v}" ;; + v*) version="${tag#v}" ;; + *) + echo "Release tag must look like npm-vX.Y.Z or vX.Y.Z, got: $tag" >&2 + exit 1 + ;; + esac + + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "Release version must look like X.Y.Z, got: $version" >&2 exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" echo "npm_tag=${npm_tag:-latest}" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v6 diff --git a/docs/eng/releasing.md b/docs/eng/releasing.md index ac605f37..45294688 100644 --- a/docs/eng/releasing.md +++ b/docs/eng/releasing.md @@ -22,7 +22,7 @@ Before your first release, make sure the following are set up locally. - Preferred: configure npm trusted publishing for `@openscout/scout`: - owner/repo: `arach/openscout` - - workflow filename: `npm-publish.yml` + - workflow filename: `release-package-npm.yml` - allowed action: `npm publish` - Fallback: add a GitHub repository secret named `NPM_TOKEN` with publish access. @@ -105,8 +105,9 @@ npm run ship -- 0.2.68 --execute --yes --github-npm ``` This verifies the internal builds and packed public manifest locally, pushes the -tag, creates the GitHub release, then dispatches `.github/workflows/npm-publish.yml` -to publish `@openscout/scout` from the release tag. +tag, creates the GitHub release, then dispatches +`.github/workflows/release-package-npm.yml` to publish `@openscout/scout` from +the release tag. For manual recovery or an emergency local publish, the lower-level helper still builds the internal packages, verifies the packed public manifest, and publishes @@ -170,6 +171,28 @@ bun apps/macos/bin/openscout-menu.ts dmg The shell script stays the source of truth for release builds because it fails fast when signing or notarization is misconfigured. +### GitHub Actions app release + +Tag `app-macos-v` to build and upload the signed, notarized DMG from +CI. The workflow expects these repository secrets: + +- `MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64` +- `MACOS_DEVELOPER_ID_APPLICATION_P12_PASSWORD` +- `MACOS_RELEASE_KEYCHAIN_PASSWORD` +- `APPLE_ID` +- `APPLE_TEAM_ID` +- `APPLE_APP_SPECIFIC_PASSWORD` + +Optional repository variables: + +- `OPENSCOUT_SIGN_IDENTITY` + +Tag `app-ios-v` to run the iOS App Store Connect upload. The tag +version must match the root `package.json` version because +`apps/ios/scripts/release.sh` uses that manifest as the iOS marketing version. +The runner must have the `asc` CLI available, or `OPENSCOUT_ASC_BIN` must point +to it. `OPENSCOUT_ASC_APP_ID` can override the default App Store app id. + ## After publishing - **Push the bump commit**: `git push origin main`. diff --git a/docs/releases.md b/docs/releases.md index 56d7c28e..199d18be 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -39,12 +39,12 @@ npm run ship -- 0.2.64 --execute --yes --github-npm Use `--github-npm` for the normal public release path. It still runs the npm dry-run build locally before tagging, but the real publish happens when the -release helper dispatches `.github/workflows/npm-publish.yml` after creating the -GitHub release. +release helper dispatches `.github/workflows/release-package-npm.yml` after +creating the GitHub release. Configure npm trusted publishing for package `@openscout/scout` with: - owner/repo: `arach/openscout` -- workflow filename: `npm-publish.yml` +- workflow filename: `release-package-npm.yml` - allowed action: `npm publish` If trusted publishing is not configured yet, add a GitHub repository secret @@ -53,3 +53,15 @@ named `NPM_TOKEN` with publish access; the workflow supports that as a fallback. The script refuses to execute from a dirty worktree unless `--allow-dirty` is passed. Prefer a clean release branch so the `Release v` commit and tag represent exactly what was shipped. + +## GitHub Actions release lanes + +Routine pull requests run the Linux typecheck/unit workflow only. Merges to +`main` do not run CI unless they deploy the landing site. + +- `candidate-v*` runs `Release Candidate`: Linux checks, native macOS/iOS + simulator checks, package dry-runs, and runtime scenarios. +- `npm-v*` runs `Release Package npm` and publishes `@openscout/scout`. +- `app-macos-v*` runs `Release App macOS` and builds/uploads the signed, + notarized DMG. +- `app-ios-v*` runs `Release App iOS` and uploads through App Store Connect. diff --git a/scripts/ship-release.mjs b/scripts/ship-release.mjs index 44725e0e..c8e614c0 100644 --- a/scripts/ship-release.mjs +++ b/scripts/ship-release.mjs @@ -276,7 +276,7 @@ function printPlan(version, options) { run("gh", [ "workflow", "run", - "npm-publish.yml", + "release-package-npm.yml", "--ref", "main", "--field", @@ -394,7 +394,7 @@ function main() { run("gh", [ "workflow", "run", - "npm-publish.yml", + "release-package-npm.yml", "--ref", "main", "--field", From 3fca71c1f60acbc06a5a99425383e68cb7a74cc8 Mon Sep 17 00:00:00 2001 From: Arach Tchoupani Date: Sun, 31 May 2026 00:16:14 -0400 Subject: [PATCH 2/5] Add native Comms channel primitive --- apps/desktop/src/cli/commands/ask.test.ts | 57 +- apps/desktop/src/cli/commands/ask.ts | 90 +- apps/macos/Sources/AppDelegate.swift | 19 + .../Sources/OpenScoutAppController.swift | 30 +- .../macos/Sources/Services/CommsService.swift | 345 ++++++ .../Sources/Services/HotkeyManager.swift | 1 + apps/macos/Sources/Views/CommsWindow.swift | 993 ++++++++++++++++++ apps/macos/Sources/Views/MainView.swift | 10 + ...nnel-primitive-and-adapter-destinations.md | 91 ++ packages/protocol/src/channel-identity.ts | 47 + packages/protocol/src/index.ts | 1 + packages/runtime/src/broker-daemon.test.ts | 17 + packages/runtime/src/broker-daemon.ts | 93 +- .../runtime/src/conversations/api.test.ts | 70 +- packages/runtime/src/conversations/api.ts | 58 +- packages/runtime/src/scout-broker.ts | 104 +- packages/runtime/src/sqlite-store.ts | 14 +- packages/web/client/lib/types.ts | 3 + packages/web/client/scout/hooks.ts | 11 +- .../scout/inspector/ConversationInspector.tsx | 8 +- .../web/client/scout/slots/BackToPicker.tsx | 4 +- .../client/scout/slots/MessagesLeftPanel.tsx | 22 +- .../web/client/screens/AgentInfoScreen.tsx | 2 +- .../web/client/screens/ChannelsScreen.tsx | 13 +- .../web/client/screens/ConversationScreen.tsx | 402 +++---- .../web/client/screens/MessagesScreen.tsx | 73 +- .../client/screens/conversation-screen.css | 96 +- .../web/server/core/broker/service.test.ts | 147 ++- packages/web/server/core/broker/service.ts | 100 +- .../web/server/core/conversations/service.ts | 55 + .../create-openscout-web-server.test.ts | 125 ++- .../web/server/create-openscout-web-server.ts | 89 +- packages/web/server/db-queries.test.ts | 47 + packages/web/server/db/activity.ts | 33 +- packages/web/server/db/fleet.ts | 20 +- packages/web/server/db/sessions.ts | 115 +- packages/web/server/db/types/mobile.ts | 3 + 37 files changed, 3009 insertions(+), 399 deletions(-) create mode 100644 apps/macos/Sources/Services/CommsService.swift create mode 100644 apps/macos/Sources/Views/CommsWindow.swift create mode 100644 docs/eng/sco-060-comms-channel-primitive-and-adapter-destinations.md create mode 100644 packages/protocol/src/channel-identity.ts diff --git a/apps/desktop/src/cli/commands/ask.test.ts b/apps/desktop/src/cli/commands/ask.test.ts index 60f8619c..3bbf5024 100644 --- a/apps/desktop/src/cli/commands/ask.test.ts +++ b/apps/desktop/src/cli/commands/ask.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { formatScoutAskRoutingError, renderAskCommandHelp } from "./ask.ts"; +import { formatScoutAskRoutingError, renderAskCommandHelp, renderScoutAskReceipt } from "./ask.ts"; describe("renderAskCommandHelp", () => { test("documents owned-work semantics and DM default routing", () => { @@ -92,3 +92,58 @@ describe("formatScoutAskRoutingError", () => { expect(message).toContain("Re-run with the fully qualified form"); }); }); + +describe("renderScoutAskReceipt", () => { + test("makes offline queued delivery explicit in notify mode", () => { + expect(renderScoutAskReceipt({ + replyMode: "notify", + receipt: { + ok: true, + state: "queued", + ids: { + targetAgentId: "talkie-shell-claude", + invocationId: "inv-1", + flightId: "flt-1", + conversationId: "dm.operator.talkie-shell-claude", + }, + }, + flight: { + id: "flt-1", + invocationId: "inv-1", + requesterId: "operator", + targetAgentId: "talkie-shell-claude", + state: "queued", + summary: "Message stored for Talkie Shell Claude. Will deliver when online.", + metadata: { + dispatchOutcome: { + status: "queued_until_online", + reason: "no_runnable_endpoint", + }, + }, + }, + })).toContain("Queued until target is online: Message stored for Talkie Shell Claude. Will deliver when online."); + }); + + test("calls out acknowledged dispatch separately from final completion", () => { + expect(renderScoutAskReceipt({ + replyMode: "notify", + receipt: { + ok: true, + state: "queued", + ids: { + targetAgentId: "openscout-card", + invocationId: "inv-2", + flightId: "flt-2", + }, + }, + flight: { + id: "flt-2", + invocationId: "inv-2", + requesterId: "operator", + targetAgentId: "openscout-card", + state: "running", + summary: "Openscout Card acknowledged via spawn.", + }, + })).toContain("Dispatch acknowledged: Openscout Card acknowledged via spawn."); + }); +}); diff --git a/apps/desktop/src/cli/commands/ask.ts b/apps/desktop/src/cli/commands/ask.ts index 31520173..e97c3152 100644 --- a/apps/desktop/src/cli/commands/ask.ts +++ b/apps/desktop/src/cli/commands/ask.ts @@ -22,6 +22,7 @@ import { const HELP_FLAGS = new Set(["--help", "-h"]); const DEFAULT_ASK_ACK_TIMEOUT_SECONDS = 30; +const DEFAULT_ASK_DISPATCH_SETTLE_MS = 4_000; export function renderAskCommandHelp(): string { return [ @@ -64,9 +65,10 @@ export function renderAskCommandHelp(): string { ].join("\n"); } -function renderScoutAskReceipt(value: { +export function renderScoutAskReceipt(value: { receipt: ScoutAskReceipt; replyMode: NonNullable; + flight?: ScoutFlightRecord | null; }): string { const { ids } = value.receipt; const pieces = [ @@ -75,14 +77,46 @@ function renderScoutAskReceipt(value: { ids.conversationId ? renderConversationRoute(ids.conversationId) : null, renderBindingRef(ids.bindingRef), ].filter((piece): piece is string => Boolean(piece)); + const delivery = renderScoutAskDeliveryStatus(value.flight); const suffix = value.replyMode === "notify" - ? "Scout will surface the completion when it arrives." - : ids.invocationId - ? `Next: scout wait ${ids.invocationId} --timeout 600` - : "Follow the ask receipt to continue."; + ? delivery + ? `${delivery} Scout will surface the completion when it arrives.` + : "Scout will surface the completion when it arrives." + : delivery + ? `${delivery} ${ids.invocationId ? `Next: scout wait ${ids.invocationId} --timeout 600` : "Follow the ask receipt to continue."}` + : ids.invocationId + ? `Next: scout wait ${ids.invocationId} --timeout 600` + : "Follow the ask receipt to continue."; return `${pieces.join(" · ")}. ${suffix}`; } +function renderScoutAskDeliveryStatus( + flight?: ScoutFlightRecord | null, +): string | null { + if (!flight) { + return null; + } + + const detail = flight.error ?? flight.summary ?? flight.output; + const renderedDetail = detail ? ` ${detail}` : ""; + if (flight.state === "running" || flight.state === "waiting") { + return `Dispatch acknowledged:${renderedDetail}`; + } + if (flight.state === "completed") { + return `Completed:${renderedDetail}`; + } + if (flight.state === "failed" || flight.state === "cancelled") { + return `Dispatch ${flight.state}:${renderedDetail}`; + } + if (flight.state === "queued" && isStoredUntilOnlineFlight(flight)) { + return `Queued until target is online:${renderedDetail}`; + } + if (flight.state === "queued" || flight.state === "waking") { + return `Dispatch ${flight.state}:${renderedDetail}`; + } + return `Dispatch state ${flight.state}:${renderedDetail}`; +} + function renderScoutTargetLabel(targetLabel: string): string { const trimmed = targetLabel.trim(); return trimmed.startsWith("@") ? trimmed : `@${trimmed}`; @@ -111,6 +145,47 @@ function waitReferenceForFlight(flight: ScoutFlightRecord): string { return flight.invocationId || flight.id; } +function isStoredUntilOnlineFlight(flight: ScoutFlightRecord): boolean { + const dispatchOutcome = flight.metadata?.dispatchOutcome; + if ( + dispatchOutcome + && typeof dispatchOutcome === "object" + && !Array.isArray(dispatchOutcome) + && (dispatchOutcome as { status?: unknown }).status === "queued_until_online" + ) { + return true; + } + return /will deliver when online|message stored/i.test(flight.summary ?? ""); +} + +function isSettledInitialDispatchFlight(flight: ScoutFlightRecord): boolean { + if (flight.state === "running" + || flight.state === "waiting" + || flight.state === "completed" + || flight.state === "failed" + || flight.state === "cancelled") { + return true; + } + return flight.state === "queued"; +} + +async function loadInitialScoutAskFlight( + brokerUrl: string, + flightId: string, + timeoutMs = DEFAULT_ASK_DISPATCH_SETTLE_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + let latest: ScoutFlightRecord | null = null; + while (Date.now() < deadline) { + latest = await loadScoutFlight(brokerUrl, flightId); + if (!latest || isSettledInitialDispatchFlight(latest)) { + return latest; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return latest ?? await loadScoutFlight(brokerUrl, flightId); +} + function renderBindingRef(bindingRef?: string | null): string | null { if (!bindingRef) { return null; @@ -248,6 +323,10 @@ export async function runAskWithOptions( const replyMode = options.replyMode ?? "inline"; if (replyMode !== "inline") { + const flight = receipt.ids.flightId + ? await loadInitialScoutAskFlight(resolveScoutBrokerUrl(), receipt.ids.flightId) + .catch(() => null) + : null; context.output.writeValue( { senderId, @@ -256,6 +335,7 @@ export async function runAskWithOptions( bindingRef: renderBindingRef(receipt.ids.bindingRef), receipt, replyMode, + flight, }, renderScoutAskReceipt, ); diff --git a/apps/macos/Sources/AppDelegate.swift b/apps/macos/Sources/AppDelegate.swift index 7e8486e8..6d0bab40 100644 --- a/apps/macos/Sources/AppDelegate.swift +++ b/apps/macos/Sources/AppDelegate.swift @@ -45,6 +45,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { HUDController.shared.toggle() } } + HotkeyManager.shared.register( + id: 2, + keyCode: CarbonKeyCode.c, + modifiers: CarbonModifier.hyper + ) { + Task { @MainActor in + OpenScoutAppController.shared.openComms() + } + } controller.$menuBarSymbolName .combineLatest(controller.$menuBarTooltip) @@ -114,6 +123,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private func buildContextMenu() -> NSMenu { let menu = NSMenu() + let commsItem = NSMenuItem(title: "Open Comms", action: #selector(openComms), keyEquivalent: "c") + commsItem.target = self + commsItem.keyEquivalentModifierMask = [.command, .control, .option, .shift] + menu.addItem(commsItem) + let openItem = NSMenuItem(title: "Open OpenScout", action: #selector(openWebApp), keyEquivalent: "") openItem.target = self menu.addItem(openItem) @@ -162,6 +176,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { controller.openWebApp() } + @objc + private func openComms() { + controller.openComms() + } + @objc private func openTailscale() { controller.openTailscale() diff --git a/apps/macos/Sources/OpenScoutAppController.swift b/apps/macos/Sources/OpenScoutAppController.swift index 144a7785..ae67fccf 100644 --- a/apps/macos/Sources/OpenScoutAppController.swift +++ b/apps/macos/Sources/OpenScoutAppController.swift @@ -189,6 +189,12 @@ final class OpenScoutAppController: ObservableObject { openWebPath("/") } + func openComms(cId: String? = nil) { + Task { + await openCommsNow(cId: cId) + } + } + func openWebPath(_ path: String) { Task { await openWebSurfaceNow(path: path) @@ -486,7 +492,29 @@ final class OpenScoutAppController: ObservableObject { requestRefresh(reason: .manual) } -private func ensureWebServerRunning() async throws { + private func openCommsNow(cId: String?) async { + guard !webActionPending else { + return + } + + webActionPending = true + defer { + webActionPending = false + } + + lastError = nil + + do { + try await ensureWebServerRunning() + CommsWindowController.shared.show(cId: cId) + } catch { + lastError = error.localizedDescription + } + + requestRefresh(reason: .manual) + } + + private func ensureWebServerRunning() async throws { if await isWebSurfaceReachable() { return } diff --git a/apps/macos/Sources/Services/CommsService.swift b/apps/macos/Sources/Services/CommsService.swift new file mode 100644 index 00000000..de07ba16 --- /dev/null +++ b/apps/macos/Sources/Services/CommsService.swift @@ -0,0 +1,345 @@ +import Combine +import Foundation + +enum CommsFilter: String, CaseIterable, Identifiable { + case all + case `private` + case shared + + var id: String { rawValue } + + var label: String { + switch self { + case .all: return "All" + case .private: return "Private" + case .shared: return "Shared" + } + } +} + +enum CommsScope { + case `private` + case shared +} + +struct CommsItem: Identifiable, Decodable, Sendable { + let cId: String + let kind: String + let title: String + let alias: String? + let participantIds: [String] + let agentName: String? + let preview: String? + let messageCount: Int + let lastMessageAt: TimeInterval? + let workspaceRoot: String? + let currentBranch: String? + + var id: String { cId } + + var displayTitle: String { + if let alias, !alias.isEmpty { return alias } + if let agentName, !agentName.isEmpty { return agentName } + return title + } + + var scope: CommsScope { + if kind == "direct", participantIds.count <= 2 { + return .private + } + return .shared + } + + var scopeLabel: String { + switch scope { + case .private: return "Private" + case .shared: return "Shared" + } + } + + var cIdShort: String { + if cId.hasPrefix("c.") { + let rest = String(cId.dropFirst("c.".count)) + return "cId \(String(rest.prefix(8)))" + } + if cId.hasPrefix("dm.") { + return "cId legacy-dm" + } + if cId.hasPrefix("channel.") { + return "cId #\(String(cId.dropFirst("channel.".count)))" + } + return cId.count > 16 ? "cId \(String(cId.prefix(12)))" : "cId \(cId)" + } + + enum CodingKeys: String, CodingKey { + case cId + case fallbackId = "id" + case kind + case title + case alias + case participantIds + case agentName + case preview + case messageCount + case lastMessageAt + case workspaceRoot + case currentBranch + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + cId = try c.decodeIfPresent(String.self, forKey: .cId) + ?? c.decode(String.self, forKey: .fallbackId) + kind = try c.decode(String.self, forKey: .kind) + title = try c.decode(String.self, forKey: .title) + alias = try c.decodeIfPresent(String.self, forKey: .alias) + participantIds = try c.decodeIfPresent([String].self, forKey: .participantIds) ?? [] + agentName = try c.decodeIfPresent(String.self, forKey: .agentName) + preview = try c.decodeIfPresent(String.self, forKey: .preview) + messageCount = try c.decodeIfPresent(Int.self, forKey: .messageCount) ?? 0 + lastMessageAt = try c.decodeIfPresent(TimeInterval.self, forKey: .lastMessageAt) + workspaceRoot = try c.decodeIfPresent(String.self, forKey: .workspaceRoot) + currentBranch = try c.decodeIfPresent(String.self, forKey: .currentBranch) + } +} + +struct CommsMessage: Identifiable, Decodable, Sendable { + let id: String + let cId: String + let actorId: String? + let actorName: String + let body: String + let createdAt: TimeInterval + let messageClass: String + + var isOperator: Bool { + actorId == "operator" || messageClass == "operator" || actorName.lowercased() == "operator" + } + + enum CodingKeys: String, CodingKey { + case id + case cId + case fallbackCId = "conversationId" + case actorId + case actorName + case body + case createdAt + case messageClass = "class" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + cId = try c.decodeIfPresent(String.self, forKey: .cId) + ?? c.decode(String.self, forKey: .fallbackCId) + actorId = try c.decodeIfPresent(String.self, forKey: .actorId) + actorName = try c.decodeIfPresent(String.self, forKey: .actorName) + ?? actorId + ?? "unknown" + body = try c.decode(String.self, forKey: .body) + createdAt = try c.decode(TimeInterval.self, forKey: .createdAt) + messageClass = try c.decodeIfPresent(String.self, forKey: .messageClass) ?? "message" + } +} + +@MainActor +final class CommsService: ObservableObject { + static let shared = CommsService() + + @Published private(set) var items: [CommsItem] = [] + @Published private(set) var messages: [CommsMessage] = [] + @Published private(set) var selectedCId: String? + @Published var filter: CommsFilter = .all + @Published private(set) var isLoading = false + @Published private(set) var isSending = false + @Published private(set) var lastError: String? + + private let decoder = JSONDecoder() + private var pollTask: Task? + private var itemsTask: Task? + private var messagesTask: Task? + + private init() {} + + var selectedItem: CommsItem? { + guard let selectedCId else { return nil } + return items.first { $0.cId == selectedCId } + } + + var filteredItems: [CommsItem] { + items.filter { item in + switch filter { + case .all: return true + case .private: return item.scope == .private + case .shared: return item.scope == .shared + } + } + } + + func start(preferredCId: String? = nil) { + if let preferredCId { + selectedCId = preferredCId + } + guard pollTask == nil else { + refresh(force: true) + return + } + refresh(force: true) + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 2_500_000_000) + self?.refresh() + } + } + } + + func stop() { + pollTask?.cancel() + pollTask = nil + itemsTask?.cancel() + messagesTask?.cancel() + itemsTask = nil + messagesTask = nil + } + + func refresh(force: Bool = false) { + if itemsTask != nil { return } + if !force, pollTask == nil { return } + isLoading = items.isEmpty + itemsTask = Task { [weak self] in + await self?.loadItems() + } + } + + func select(_ cId: String) { + guard selectedCId != cId else { return } + selectedCId = cId + messages = [] + loadMessages() + } + + func loadMessages() { + guard let selectedCId else { return } + messagesTask?.cancel() + messagesTask = Task { [weak self] in + await self?.loadMessages(cId: selectedCId) + } + } + + func send(_ body: String) async { + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let selectedCId, !isSending else { return } + isSending = true + defer { isSending = false } + do { + let url = HudFleetService.webBaseURL().appending(path: "api/send") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "body": trimmed, + "cId": selectedCId, + "conversationId": selectedCId, + ]) + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw CommsServiceError.sendFailed + } + lastError = nil + refresh(force: true) + loadMessages() + } catch { + lastError = Self.userFacingError(error) + } + } + + private func loadItems() async { + defer { + isLoading = false + itemsTask = nil + } + do { + let base = HudFleetService.webBaseURL() + let commsURL = base + .appending(path: "api/comms") + .appending(queryItems: [URLQueryItem(name: "limit", value: "120")]) + let fallbackURL = base + .appending(path: "api/conversations") + .appending(queryItems: [URLQueryItem(name: "limit", value: "120")]) + let next = try await fetchWithFallback([CommsItem].self, primary: commsURL, fallback: fallbackURL) + items = next + if selectedCId == nil || !next.contains(where: { $0.cId == selectedCId }) { + selectedCId = next.first?.cId + } + lastError = nil + loadMessages() + } catch { + lastError = Self.userFacingError(error) + } + } + + private func loadMessages(cId: String) async { + defer { + messagesTask = nil + } + do { + let url = HudFleetService.webBaseURL() + .appending(path: "api/messages") + .appending(queryItems: [ + URLQueryItem(name: "cId", value: cId), + URLQueryItem(name: "conversationId", value: cId), + URLQueryItem(name: "limit", value: "220"), + ]) + let next = try await fetch([CommsMessage].self, from: url) + guard selectedCId == cId else { return } + messages = next.sorted { $0.createdAt < $1.createdAt } + lastError = nil + } catch { + guard selectedCId == cId else { return } + lastError = Self.userFacingError(error) + } + } + + private func fetch(_ type: T.Type, from url: URL) async throws -> T { + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse else { + throw CommsServiceError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + throw CommsServiceError.httpStatus(http.statusCode) + } + return try decoder.decode(type, from: data) + } + + private func fetchWithFallback(_ type: T.Type, primary: URL, fallback: URL) async throws -> T { + do { + return try await fetch(type, from: primary) + } catch { + return try await fetch(type, from: fallback) + } + } + + private static func userFacingError(_ error: Error) -> String { + if let commsError = error as? CommsServiceError { + return commsError.localizedDescription + } + return error.localizedDescription + } +} + +enum CommsServiceError: LocalizedError { + case invalidResponse + case httpStatus(Int) + case sendFailed + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Comms returned an invalid response." + case .httpStatus(let status): + return "Comms returned HTTP \(status)." + case .sendFailed: + return "Comms send failed." + } + } +} diff --git a/apps/macos/Sources/Services/HotkeyManager.swift b/apps/macos/Sources/Services/HotkeyManager.swift index d4557346..b4254970 100644 --- a/apps/macos/Sources/Services/HotkeyManager.swift +++ b/apps/macos/Sources/Services/HotkeyManager.swift @@ -118,5 +118,6 @@ enum CarbonModifier { // Common keyCodes we use here. Source: HIToolbox/Events.h. enum CarbonKeyCode { + static let c: UInt32 = 8 static let h: UInt32 = 4 } diff --git a/apps/macos/Sources/Views/CommsWindow.swift b/apps/macos/Sources/Views/CommsWindow.swift new file mode 100644 index 00000000..98e38016 --- /dev/null +++ b/apps/macos/Sources/Views/CommsWindow.swift @@ -0,0 +1,993 @@ +import AppKit +import SwiftUI + +@MainActor +final class CommsWindowController { + static let shared = CommsWindowController() + + private let service = CommsService.shared + private var panel: OverlayPanel? + + private init() {} + + func show(cId: String? = nil) { + service.start(preferredCId: cId) + if panel == nil { + panel = OverlayPanelShell.makePanel( + config: OverlayPanelShell.Config( + size: NSSize(width: 1020, height: 720), + title: "OpenScout Comms", + isMovableByWindowBackground: true, + activatesOnMouseDown: true, + resizable: true, + minContentSize: NSSize(width: 780, height: 540) + ), + rootView: CommsRootView(service: service) + ) + } + guard let panel else { return } + OverlayPanelShell.position(panel, placement: .mouseScreenCentered(yOffsetRatio: 0.04)) + OverlayPanelShell.present(panel, activate: true) + } + + func toggle() { + if panel?.isVisible == true { + dismiss() + } else { + show() + } + } + + func dismiss() { + panel?.orderOut(nil) + service.stop() + } +} + +struct CommsRootView: View { + @ObservedObject var service: CommsService + + enum Field: Hashable { case search, composer, command } + + // Drafts are kept per-channel so switching cId never loses in-progress + // text — a core "don't surprise me" affordance for a comms tool. + @State private var drafts: [String: String] = [:] + @State private var channelQuery = "" + @State private var showCommands = false + @State private var commandQuery = "" + @State private var commandIndex = 0 + @FocusState private var focus: Field? + + var body: some View { + ZStack { + VisualEffectBackground(material: .hudWindow, cornerRadius: 8) + HUDChrome.canvas + HUDPaperGrain(opacity: 0.03) + + VStack(spacing: 0) { + header + HUDHairline() + main + } + .background(keyboardCommands) + } + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(HUDChrome.borderRim, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .preferredColorScheme(.dark) + .onAppear { focusComposerSoon() } + .onChange(of: service.selectedCId) { _, _ in focusComposerSoon() } + } + + private var header: some View { + HStack(spacing: 12) { + Text("C") + .font(HUDType.mono(15, weight: .bold)) + .foregroundStyle(HUDChrome.canvas) + .frame(width: 28, height: 28) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(HUDChrome.accent) + ) + + VStack(alignment: .leading, spacing: 2) { + Text("COMMS") + .font(HUDType.mono(11, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.ink) + Text(service.selectedItem?.cIdShort ?? "cId") + .font(HUDType.mono(10)) + .foregroundStyle(HUDChrome.inkFaint) + .lineLimit(1) + } + + Spacer(minLength: 10) + + HStack(spacing: 4) { + ForEach(CommsFilter.allCases) { filter in + CommsFilterButton( + label: filter.label, + isSelected: service.filter == filter + ) { + service.filter = filter + } + } + } + + Button { + service.refresh(force: true) + service.loadMessages() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .semibold)) + } + .buttonStyle(CommsIconButtonStyle()) + .help("Refresh") + + Button { + CommsWindowController.shared.dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + } + .buttonStyle(CommsIconButtonStyle()) + .help("Close") + } + .padding(.horizontal, 18) + .frame(height: 54) + .background(HUDChrome.canvasAlt) + } + + private var main: some View { + HStack(spacing: 0) { + rail + .frame(width: 304) + HUDHairline(axis: .vertical) + detail + } + } + + private var rail: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Text("\(visibleItems.count)") + .font(HUDType.mono(18, weight: .bold)) + .foregroundStyle(HUDChrome.ink) + Text("CHANNELS") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + Spacer() + if service.isLoading { + ProgressView() + .controlSize(.small) + .scaleEffect(0.7) + } + } + .padding(.horizontal, 14) + .frame(height: 44) + .background(HUDChrome.canvas) + + railSearch + + HUDHairline() + + if visibleItems.isEmpty { + railPlaceholder + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(visibleItems) { item in + CommsRailRow( + item: item, + isSelected: service.selectedCId == item.cId + ) { + service.select(item.cId) + } + } + } + } + .scrollIndicators(.hidden) + } + } + .background(HUDChrome.canvas) + } + + private var railSearch: some View { + HStack(spacing: 7) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(HUDChrome.inkFaint) + TextField("Jump to channel", text: $channelQuery) + .textFieldStyle(.plain) + .font(HUDType.body(12)) + .foregroundStyle(HUDChrome.ink) + .focused($focus, equals: .search) + .onSubmit { + if let first = visibleItems.first { + service.select(first.cId) + channelQuery = "" + focus = .composer + } + } + if channelQuery.isEmpty { + Text("⌘K") + .font(HUDType.mono(9, weight: .bold)) + .foregroundStyle(HUDChrome.inkDeep) + } else { + Button { + channelQuery = "" + focus = .search + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(HUDChrome.inkFaint) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 14) + .frame(height: 34) + .background(HUDChrome.canvas) + } + + @ViewBuilder + private var railPlaceholder: some View { + VStack(spacing: 10) { + if service.isLoading && service.items.isEmpty { + ProgressView() + .controlSize(.small) + Text("LOADING CHANNELS") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + } else if let error = service.lastError, !error.isEmpty, service.items.isEmpty { + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(HUDChrome.inkDeep) + Text("COMMS UNREACHABLE") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + Text(error) + .font(HUDType.body(11)) + .foregroundStyle(HUDChrome.inkFaint) + .multilineTextAlignment(.center) + .lineLimit(3) + } else if !channelQuery.isEmpty { + Image(systemName: "magnifyingglass") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(HUDChrome.inkDeep) + Text("NO MATCHES") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + } else { + Image(systemName: "tray") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(HUDChrome.inkDeep) + Text(service.filter == .all ? "NO CHANNELS" : "NO \(service.filter.label.uppercased()) CHANNELS") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 20) + .background(HUDChrome.canvas) + } + + @ViewBuilder + private var detail: some View { + if let item = service.selectedItem { + VStack(spacing: 0) { + detailHeader(item) + HUDHairline() + messages + HUDHairline() + composer + } + .background(HUDChrome.canvas) + } else { + VStack(spacing: 14) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 34, weight: .regular)) + .foregroundStyle(HUDChrome.inkDeep) + Text("NO CHANNEL SELECTED") + .font(HUDType.mono(11, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + if let error = service.lastError, !error.isEmpty { + Text(error) + .font(HUDType.body(12)) + .foregroundStyle(ShellPalette.error) + .multilineTextAlignment(.center) + .lineLimit(3) + .frame(maxWidth: 360) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(HUDChrome.canvas) + } + } + + private func detailHeader(_ item: CommsItem) -> some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 5) { + Text(item.displayTitle) + .font(HUDType.body(20, weight: .semibold)) + .foregroundStyle(HUDChrome.ink) + .lineLimit(1) + .truncationMode(.tail) + HStack(spacing: 8) { + CommsChip(text: item.scopeLabel.uppercased()) + CommsChip(text: item.cIdShort) + CommsChip(text: "\(item.participantIds.count) member\(item.participantIds.count == 1 ? "" : "s")") + } + } + Spacer(minLength: 12) + if let branch = item.currentBranch, !branch.isEmpty { + Text(branch) + .font(HUDType.mono(10, weight: .medium)) + .foregroundStyle(HUDChrome.inkMuted) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 180, alignment: .trailing) + } + } + .padding(.horizontal, 20) + .frame(height: 72) + .background(HUDChrome.canvasAlt) + } + + private var messages: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 2) { + ForEach(Array(service.messages.enumerated()), id: \.element.id) { index, message in + CommsMessageRow( + message: message, + showsHeader: Self.showsHeader(at: index, in: service.messages) + ) + .id(message.id) + .padding(.top, Self.showsHeader(at: index, in: service.messages) ? 8 : 0) + } + Color.clear + .frame(height: 1) + .id("bottom") + } + .padding(.horizontal, 20) + .padding(.vertical, 18) + } + .scrollIndicators(.visible) + .background(HUDChrome.canvas) + .overlay { + if service.messages.isEmpty { + VStack(spacing: 8) { + Image(systemName: "text.bubble") + .font(.system(size: 26, weight: .regular)) + .foregroundStyle(HUDChrome.inkDeep) + Text("NO MESSAGES YET") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + } + .allowsHitTesting(false) + } + } + .overlay(alignment: .bottom) { + if showCommands { + commandPalette + } + } + .onChange(of: service.messages.count) { _, _ in + withAnimation(.easeOut(duration: 0.16)) { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + .onAppear { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + } + + private var composer: some View { + VStack(spacing: 8) { + if let error = service.lastError, !error.isEmpty { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11, weight: .semibold)) + Text(error) + .font(HUDType.body(12)) + .lineLimit(2) + Spacer() + } + .foregroundStyle(ShellPalette.error) + } + + HStack(alignment: .bottom, spacing: 10) { + Button { + startDictation() + } label: { + Image(systemName: "mic.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(HUDChrome.inkMuted) + .frame(width: 38, height: 38) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(HUDChrome.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .help("Dictate — or press Fn twice") + + Button { + showCommands ? closeCommands() : openCommands() + } label: { + Image(systemName: "command") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(showCommands ? HUDChrome.accent : HUDChrome.inkMuted) + .frame(width: 38, height: 38) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(showCommands ? HUDChrome.accentDim : HUDChrome.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .help("Commands — ⌘P or type /") + + TextField(composerPlaceholder, text: draftBinding, axis: .vertical) + .textFieldStyle(.plain) + .font(HUDType.body(14)) + .foregroundStyle(HUDChrome.ink) + .lineLimit(1...5) + .focused($focus, equals: .composer) + .onKeyPress(phases: .down) { press in + // Return sends; Shift+Return inserts a newline + // (Messages-style). ⌘↩ still works via the send button. + guard press.key == .return else { return .ignored } + if press.modifiers.contains(.shift) { return .ignored } + sendDraft() + return .handled + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(focus == .composer ? HUDChrome.accentDim : HUDChrome.border, lineWidth: 1) + ) + + Button { + sendDraft() + } label: { + Image(systemName: service.isSending ? "hourglass" : "paperplane.fill") + .font(.system(size: 14, weight: .semibold)) + .frame(width: 38, height: 38) + } + .buttonStyle(CommsSendButtonStyle()) + .disabled(currentDraftEmpty || service.isSending) + .keyboardShortcut(.return, modifiers: [.command]) + .help("Send") + } + + if !currentDraftEmpty { + HStack(spacing: 0) { + Spacer() + Text("⏎ to send · ⇧⏎ newline") + .font(HUDType.mono(9)) + .foregroundStyle(HUDChrome.inkFaint) + } + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(HUDChrome.canvasAlt) + } + + private func sendDraft() { + guard let cId = service.selectedCId else { return } + let body = drafts[cId] ?? "" + guard !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + drafts[cId] = "" + focus = .composer + Task { + await service.send(body) + } + } + + // Per-channel draft binding — text follows the selected cId so switching + // channels parks the draft instead of discarding it. + private var draftBinding: Binding { + let key = service.selectedCId ?? "" + return Binding( + get: { drafts[key] ?? "" }, + set: { newValue in + // Typing "/" into an empty composer opens the command area + // instead of entering the slash as message text — "/" stays + // a control key, never a literal sent to an agent. + if newValue == "/" && (drafts[key] ?? "").isEmpty { + openCommands() + return + } + drafts[key] = newValue + } + ) + } + + private var currentDraftEmpty: Bool { + (drafts[service.selectedCId ?? ""] ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + } + + // Scope filter (All/Private/Shared) plus the live quick-filter query. + private var visibleItems: [CommsItem] { + let scoped = service.filteredItems + let query = channelQuery.trimmingCharacters(in: .whitespaces).lowercased() + guard !query.isEmpty else { return scoped } + return scoped.filter { + $0.displayTitle.lowercased().contains(query) || $0.cId.lowercased().contains(query) + } + } + + private func moveSelection(_ delta: Int) { + let items = visibleItems + guard !items.isEmpty else { return } + let current = items.firstIndex { $0.cId == service.selectedCId } ?? -1 + let next = max(0, min(items.count - 1, current + delta)) + guard items.indices.contains(next) else { return } + service.select(items[next].cId) + } + + private func focusComposerSoon() { + DispatchQueue.main.async { focus = .composer } + } + + // Routes to the focused field's editor via the standard responder action, + // the same one Edit ▸ Start Dictation uses. No-op if nothing accepts it. + private func startDictation() { + focus = .composer + NSApp.sendAction(Selector(("startDictation:")), to: nil, from: nil) + } + + private func copyCId() { + guard let cId = service.selectedCId else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(cId, forType: .string) + } + + // ── Command area ──────────────────────────────────────────────────── + // A local command surface anchored over the chat area. Every entry acts + // on the Comms UI (navigate, scope, refresh, dictate, copy) — none of it + // transmits text to agents, which is why "/" can safely open it. + private func openCommands() { + commandQuery = "" + commandIndex = 0 + showCommands = true + focus = .command + } + + private func closeCommands() { + showCommands = false + focus = .composer + } + + private func runCommand(_ command: CommsCommand) { + closeCommands() + command.run() + } + + private var commands: [CommsCommand] { + [ + CommsCommand(id: "jump", title: "Jump to channel", hint: "⌘K", systemImage: "magnifyingglass", keywords: ["search", "find", "goto"]) { focus = .search }, + CommsCommand(id: "next", title: "Next channel", hint: "⌘↓", systemImage: "chevron.down", keywords: ["down"]) { moveSelection(1) }, + CommsCommand(id: "prev", title: "Previous channel", hint: "⌘↑", systemImage: "chevron.up", keywords: ["up"]) { moveSelection(-1) }, + CommsCommand(id: "all", title: "Show all channels", hint: "⌘1", systemImage: "tray.full", keywords: ["filter"]) { service.filter = .all }, + CommsCommand(id: "private", title: "Show private", hint: "⌘2", systemImage: "lock", keywords: ["filter", "dm", "direct"]) { service.filter = .private }, + CommsCommand(id: "shared", title: "Show shared", hint: "⌘3", systemImage: "person.2", keywords: ["filter", "group"]) { service.filter = .shared }, + CommsCommand(id: "refresh", title: "Refresh", hint: "⌘R", systemImage: "arrow.clockwise", keywords: ["reload", "sync"]) { + service.refresh(force: true) + service.loadMessages() + }, + CommsCommand(id: "dictate", title: "Dictate message", hint: "fn fn", systemImage: "mic", keywords: ["voice", "speak"]) { startDictation() }, + CommsCommand(id: "copy", title: "Copy cId", hint: "", systemImage: "doc.on.doc", keywords: ["clipboard", "id"]) { copyCId() }, + ] + } + + private var filteredCommands: [CommsCommand] { + let query = commandQuery.trimmingCharacters(in: .whitespaces).lowercased() + guard !query.isEmpty else { return commands } + return commands.filter { + $0.title.lowercased().contains(query) || $0.keywords.contains { $0.contains(query) } + } + } + + private var commandPalette: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Text("›") + .font(HUDType.mono(14, weight: .bold)) + .foregroundStyle(HUDChrome.accent) + TextField("Run a command", text: $commandQuery) + .textFieldStyle(.plain) + .font(HUDType.body(13)) + .foregroundStyle(HUDChrome.ink) + .focused($focus, equals: .command) + .onSubmit { + if let command = filteredCommands[safe: commandIndex] { + runCommand(command) + } + } + Text("ESC") + .font(HUDType.mono(9, weight: .bold)) + .foregroundStyle(HUDChrome.inkDeep) + } + .padding(.horizontal, 12) + .frame(height: 40) + + HUDHairline() + + if filteredCommands.isEmpty { + Text("NO COMMANDS") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + .frame(maxWidth: .infinity) + .frame(height: 56) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(filteredCommands.enumerated()), id: \.element.id) { index, command in + Button { + runCommand(command) + } label: { + HStack(spacing: 10) { + Image(systemName: command.systemImage) + .font(.system(size: 12, weight: .semibold)) + .frame(width: 16) + .foregroundStyle(index == commandIndex ? HUDChrome.accent : HUDChrome.inkMuted) + Text(command.title) + .font(HUDType.body(13)) + .foregroundStyle(HUDChrome.ink) + Spacer() + if !command.hint.isEmpty { + Text(command.hint) + .font(HUDType.mono(9, weight: .bold)) + .foregroundStyle(HUDChrome.inkFaint) + } + } + .padding(.horizontal, 12) + .frame(height: 34) + .background(index == commandIndex ? HUDChrome.canvasLift : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + if hovering { commandIndex = index } + } + } + } + } + .frame(maxHeight: 240) + .scrollIndicators(.hidden) + } + } + .frame(width: 360) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(HUDChrome.canvasAlt) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(HUDChrome.borderRim, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .shadow(color: Color.black.opacity(0.45), radius: 24, y: 12) + .padding(.bottom, 14) + .onChange(of: commandQuery) { _, _ in commandIndex = 0 } + .onKeyPress(.upArrow) { + commandIndex = max(0, commandIndex - 1) + return .handled + } + .onKeyPress(.downArrow) { + commandIndex = min(filteredCommands.count - 1, commandIndex + 1) + return .handled + } + .onKeyPress(.escape) { + closeCommands() + return .handled + } + } + + // Invisible buttons that register window-level shortcuts. Kept active + // (opacity 0, not .hidden/.disabled) so the chords stay live. + private var keyboardCommands: some View { + Group { + Button("") { service.filter = .all }.keyboardShortcut("1", modifiers: .command) + Button("") { service.filter = .private }.keyboardShortcut("2", modifiers: .command) + Button("") { service.filter = .shared }.keyboardShortcut("3", modifiers: .command) + Button("") { moveSelection(1) }.keyboardShortcut(.downArrow, modifiers: .command) + Button("") { moveSelection(-1) }.keyboardShortcut(.upArrow, modifiers: .command) + Button("") { + service.refresh(force: true) + service.loadMessages() + }.keyboardShortcut("r", modifiers: .command) + Button("") { focus = .composer }.keyboardShortcut("l", modifiers: .command) + Button("") { focus = .search }.keyboardShortcut("k", modifiers: .command) + Button("") { showCommands ? closeCommands() : openCommands() }.keyboardShortcut("p", modifiers: .command) + } + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + + private var composerPlaceholder: String { + if let title = service.selectedItem?.displayTitle, !title.isEmpty { + return "Message \(title)" + } + return "Message" + } + + private static func showsHeader(at index: Int, in messages: [CommsMessage]) -> Bool { + guard index > 0 else { return true } + let prev = messages[index - 1] + let current = messages[index] + if prev.actorId != current.actorId || prev.isOperator != current.isOperator { return true } + return normalizedSeconds(current.createdAt) - normalizedSeconds(prev.createdAt) > 300 + } + + private static func normalizedSeconds(_ timestamp: TimeInterval) -> TimeInterval { + timestamp > 10_000_000_000 ? timestamp / 1000 : timestamp + } +} + +private struct CommsFilterButton: View { + let label: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(label.uppercased()) + .font(HUDType.mono(10, weight: .bold)) + .tracking(1.0) + .foregroundStyle(isSelected ? HUDChrome.canvas : HUDChrome.inkMuted) + .frame(minWidth: 70, minHeight: 28) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isSelected ? HUDChrome.accent : HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(isSelected ? HUDChrome.accentDim : HUDChrome.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} + +private struct CommsRailRow: View { + let item: CommsItem + let isSelected: Bool + let action: () -> Void + + @State private var hovering = false + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(item.scopeLabel.uppercased()) + .font(HUDType.mono(8, weight: .bold)) + .tracking(1.2) + .foregroundStyle(isSelected ? HUDChrome.accent : HUDChrome.inkFaint) + Spacer(minLength: 8) + Text(item.lastMessageAt.map(formatShortTime) ?? "NEW") + .font(HUDType.mono(9)) + .foregroundStyle(HUDChrome.inkFaint) + } + + Text(item.displayTitle) + .font(HUDType.body(14, weight: .semibold)) + .foregroundStyle(HUDChrome.ink) + .lineLimit(1) + .truncationMode(.tail) + + HStack(spacing: 6) { + Text(item.preview?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? item.cIdShort) + .font(HUDType.body(11)) + .foregroundStyle(HUDChrome.inkMuted) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8) + if item.messageCount > 0 { + Text("\(item.messageCount)") + .font(HUDType.mono(9, weight: .bold)) + .foregroundStyle(HUDChrome.inkFaint) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(maxWidth: .infinity, minHeight: 82, alignment: .leading) + .background(rowFill) + .overlay(alignment: .leading) { + Rectangle() + .fill(HUDChrome.accent) + .frame(width: 2.5) + .opacity(isSelected ? 1 : 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { next in + hovering = next + } + + HUDHairline() + } + + private var rowFill: Color { + if isSelected { return HUDChrome.canvasLift } + if hovering { return HUDChrome.canvasAlt } + return HUDChrome.canvas + } +} + +private struct CommsMessageRow: View { + let message: CommsMessage + var showsHeader: Bool = true + + var body: some View { + HStack(alignment: .bottom, spacing: 10) { + if message.isOperator { + Spacer(minLength: 54) + } + + VStack(alignment: message.isOperator ? .trailing : .leading, spacing: 5) { + if showsHeader { + HStack(spacing: 8) { + Text(message.actorName.uppercased()) + .font(HUDType.mono(9, weight: .bold)) + .tracking(1.0) + .foregroundStyle(message.isOperator ? HUDChrome.accent : HUDChrome.inkMuted) + Text(formatShortTime(message.createdAt)) + .font(HUDType.mono(9)) + .foregroundStyle(HUDChrome.inkFaint) + } + } + + Text(message.body) + .font(HUDType.body(13)) + .foregroundStyle(HUDChrome.ink) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(message.isOperator ? HUDChrome.accentWhisper : HUDChrome.canvasAlt) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(message.isOperator ? HUDChrome.accentSoft : HUDChrome.borderSoft, lineWidth: 1) + ) + } + .frame(maxWidth: 560, alignment: message.isOperator ? .trailing : .leading) + + if !message.isOperator { + Spacer(minLength: 54) + } + } + .frame(maxWidth: .infinity, alignment: message.isOperator ? .trailing : .leading) + } +} + +private struct CommsChip: View { + let text: String + + var body: some View { + Text(text) + .font(HUDType.mono(9, weight: .bold)) + .tracking(0.8) + .foregroundStyle(HUDChrome.inkMuted) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 8) + .frame(height: 22) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(HUDChrome.border, lineWidth: 1) + ) + } +} + +private struct CommsIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(HUDChrome.inkMuted) + .frame(width: 28, height: 28) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(configuration.isPressed ? HUDChrome.canvasLift : HUDChrome.canvas) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(HUDChrome.border, lineWidth: 1) + ) + } +} + +private struct CommsSendButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(HUDChrome.canvas) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(configuration.isPressed ? HUDChrome.accentDim : HUDChrome.accent) + ) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + } +} + +private struct CommsCommand: Identifiable { + let id: String + let title: String + let hint: String + let systemImage: String + let keywords: [String] + let run: () -> Void +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + +private func formatShortTime(_ timestamp: TimeInterval) -> String { + let seconds = timestamp > 10_000_000_000 ? timestamp / 1000 : timestamp + let date = Date(timeIntervalSince1970: seconds) + if Calendar.current.isDateInToday(date) { + return SelfTimeFormatter.time.string(from: date) + } + return SelfTimeFormatter.date.string(from: date) +} + +private enum SelfTimeFormatter { + static let time: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + return formatter + }() + + static let date: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + return formatter + }() +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/apps/macos/Sources/Views/MainView.swift b/apps/macos/Sources/Views/MainView.swift index 86ed7e08..c68de27f 100644 --- a/apps/macos/Sources/Views/MainView.swift +++ b/apps/macos/Sources/Views/MainView.swift @@ -118,6 +118,16 @@ struct MainView: View { .buttonStyle(HeaderIconButtonStyle()) .help(showQR ? "Hide pairing QR" : "Show pairing QR") + Button { + controller.openComms() + } label: { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 11, weight: .semibold)) + } + .buttonStyle(HeaderIconButtonStyle()) + .disabled(controller.webActionPending) + .help("Open Comms") + Button { controller.openWebApp() } label: { diff --git a/docs/eng/sco-060-comms-channel-primitive-and-adapter-destinations.md b/docs/eng/sco-060-comms-channel-primitive-and-adapter-destinations.md new file mode 100644 index 00000000..84cb0336 --- /dev/null +++ b/docs/eng/sco-060-comms-channel-primitive-and-adapter-destinations.md @@ -0,0 +1,91 @@ +# SCO-060: Comms Channel Primitive and Adapter Destinations + +## 1. Status + +- **Status:** Draft +- **Owner:** OpenScout +- **Scope:** Comms identity, native Comms shell, adapter-ready routing +- **Intent:** Put DMs and named shared lanes on one durable top-level channel primitive, with `cId` as the stable identifier. + +## 2. Summary + +The product direction is a standalone native **Comms** surface: close to the HUD in navigational pull, but cleaner and focused on communication. It should not become another HUD slot, a settings home, or a web wrapper. + +The model direction is also simple: a DM is a channel with a small member set and private defaults. A named shared lane is a channel with an alias, broader visibility, and different notification policy. Adding members can change the channel's treatment without changing its `cId`. + +`cId` means **channel id**. It is intentionally compact enough to stay usable in UI, logs, and API payloads. + +## 3. External Shape + +| System | Top-Level Unit | DM Model | Nested Discussion | Useful Takeaway | +| --- | --- | --- | --- | --- | +| Slack | Unified messaging container | 1:1 and group DMs use the same top-level API family as shared lanes | Message replies under a parent timestamp | Unified row model is proven. | +| Microsoft Teams | Chat or team channel | Chat covers 1:1 and group DMs | Reply chains inside channel posts | Membership and policy vary more than identity. | +| Matrix | Room | DMs are rooms with direct-chat metadata | Relations and replies | Room/channel identity is stable while semantics shift. | +| Zulip | Stream plus topic | Direct messages are separate | Topic is the main sub-lane | Topics may be useful later, but not as the root unit. | +| Vercel Chat SDK | Channel + thread + adapter | DM open calls return an active unit | Thread is the active bot lane | Adapter thinking is useful; nouns do not need to match. | + +## 4. Nouns + +| Term | Meaning | +| --- | --- | +| **Comms** | The native communication surface. | +| **Channel** | Durable top-level communication unit. DMs, group DMs, named shared lanes, and system lanes all fit here. | +| **cId** | Stable channel id. Prefer opaque `c.` ids for new rows. | +| **Alias** | Optional display handle such as `#talkie-next`. | +| **Member** | Actor allowed to participate or receive events under policy. | +| **Thread** | Nested discussion rooted at a message inside a channel. | +| **Adapter** | Bridge to an external destination such as Slack, Teams, Matrix, email, or a future A2A surface. | +| **Binding** | Mapping between one Scout channel and one adapter destination. | +| **Policy** | Visibility, notification, retention, and member mutation rules. | + +## 5. Shape + +```ts +type Channel = { + cId: `c.${string}`; + kind: "direct" | "group" | "shared" | "system"; + alias?: string; + title: string; + memberIds: string[]; + policy: ChannelPolicy; + metadata?: Record; +}; + +type ChannelPolicy = { + visibility: "private" | "workspace" | "public" | "system"; + shareMode: "local" | "shared"; + discoverability: "hidden" | "listed"; + notifications: "direct" | "mentions" | "all" | "muted"; + memberMutation: "locked" | "owner" | "members"; +}; + +type AdapterBinding = { + id: string; + cId: Channel["cId"]; + adapter: "slack" | "teams" | "matrix" | "email" | "a2a"; + destinationId: string; + policy?: Partial; +}; +``` + +## 6. Product Rules + +1. The native app uses **Comms**, **channel**, and **cId**. +2. `cId` is stable across member, alias, and policy changes. +3. A private 1:1 can become group-like by adding members. +4. A shared lane becomes channel-like by alias and policy, not by a different root type. +5. Threads are nested under messages and do not own the top-level identity. +6. Adapter destination ids live in bindings, never inside `cId`. + +## 7. Native First Slice + +- Add a standalone native Comms panel, invoked from the menu bar and Hyper+C. +- Use a unified rail for private and shared channels. +- Show `cId` as the identity chip. +- Keep the visual treatment clean, immersive, and communication-centered. +- Avoid add-member controls in this slice; the app shape matters first. + +## 8. Compatibility Boundary + +Existing runtime and web routes may still expose older field names while the repo moves forward. New Comms-facing surfaces should use `cId`, channel vocabulary, and `/api/comms`-style aliases. Treat older names as compatibility baggage, not product ontology. diff --git a/packages/protocol/src/channel-identity.ts b/packages/protocol/src/channel-identity.ts new file mode 100644 index 00000000..fe73283a --- /dev/null +++ b/packages/protocol/src/channel-identity.ts @@ -0,0 +1,47 @@ +import type { MetadataMap, ScoutId } from "./common.js"; + +export const CHANNEL_ID_PREFIX = "c."; +export const CHANNEL_NATURAL_KEY_METADATA = "naturalKey"; +export const CHANNEL_LEGACY_ID_METADATA = "legacyId"; + +export function mintChannelId(randomUuid: () => string): ScoutId { + return `${CHANNEL_ID_PREFIX}${randomUuid().toLowerCase()}`; +} + +export function directChannelNaturalKey(participantIds: ScoutId[]): string { + return `direct:${stableIdentityParts(participantIds).join(",")}`; +} + +export function namedChannelNaturalKey(channel: string): string { + return `channel:${encodeIdentityPart(channel.trim().toLowerCase() || "shared")}`; +} + +export function systemChannelNaturalKey(name: string): string { + return `system:${encodeIdentityPart(name.trim().toLowerCase() || "system")}`; +} + +export function channelNaturalKeyFromMetadata( + metadata: MetadataMap | undefined, +): string | null { + const value = metadata?.[CHANNEL_NATURAL_KEY_METADATA]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function channelLegacyIdFromMetadata( + metadata: MetadataMap | undefined, +): string | null { + const value = metadata?.[CHANNEL_LEGACY_ID_METADATA]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function stableIdentityParts(values: ScoutId[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) + .sort() + .map(encodeIdentityPart); +} + +function encodeIdentityPart(value: string): string { + return encodeURIComponent(value).replace(/[!'()*]/g, (char) => + `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 8a21af79..609cdc28 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -8,6 +8,7 @@ export * from "./scout-agent-card.js"; export * from "./relay-agent-card.js"; export * from "./mesh.js"; export * from "./conversations.js"; +export * from "./channel-identity.js"; export * from "./collaboration.js"; export * from "./unblock-requests.js"; export * from "./messages.js"; diff --git a/packages/runtime/src/broker-daemon.test.ts b/packages/runtime/src/broker-daemon.test.ts index 402f9e56..36749f64 100644 --- a/packages/runtime/src/broker-daemon.test.ts +++ b/packages/runtime/src/broker-daemon.test.ts @@ -1406,6 +1406,23 @@ describe("broker daemon comms layer", () => { expect( events.some((event) => event.kind === "flight.updated" && event.payload.flight?.targetAgentId === "ghost" && event.payload.flight?.state === "waking"), ).toBe(true); + + const snapshot = await waitFor( + () => getJson<{ + flights: Record; + }>; + }>(harness.baseUrl, "/v1/snapshot"), + (value) => value.flights[response.flightId]?.state === "queued", + ); + const flight = snapshot.flights[response.flightId]; + expect(flight?.summary).toBe("Message stored for Ghost. Will deliver when online."); + expect(flight?.metadata?.dispatchOutcome).toEqual(expect.objectContaining({ + status: "queued_until_online", + reason: "no_runnable_endpoint", + })); }, 15_000); test("keeps replayed invocations available to daemon routes after restart", async () => { diff --git a/packages/runtime/src/broker-daemon.ts b/packages/runtime/src/broker-daemon.ts index 151a5011..3c9c7c2c 100644 --- a/packages/runtime/src/broker-daemon.ts +++ b/packages/runtime/src/broker-daemon.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { existsSync, mkdirSync, openSync } from "node:fs"; import { lstat, mkdir, stat, unlink } from "node:fs/promises"; @@ -18,6 +19,8 @@ import { assertValidUnblockRequestEvent, assertValidUnblockRequestRecord, buildScoutReturnAddress, + namedChannelNaturalKey, + channelNaturalKeyFromMetadata, type ActorIdentity, type AgentDefinition, type AgentEndpoint, @@ -52,6 +55,8 @@ import { type ScoutDeliverResponse, type ScoutDeliverRouteKind, type ScoutProjectAgentSpec, + directChannelNaturalKey, + mintChannelId, type ThreadWatchCloseRequest, type ThreadWatchOpenRequest, type ThreadWatchRenewRequest, @@ -60,6 +65,7 @@ import { OPENSCOUT_IROH_MESH_ALPN, OPENSCOUT_MESH_PROTOCOL_VERSION, SCOUT_DISPATCHER_AGENT_ID, + systemChannelNaturalKey, normalizeAgentSelectorSegment, parseAgentIdentity, type AgentHarness, @@ -2141,6 +2147,9 @@ function brokerConversationChannel(snapshot: ReturnType return null; } + if (typeof conversation.metadata?.channel === "string") { + return conversation.metadata.channel; + } return conversation.id.startsWith("channel.") ? conversation.id.replace(/^channel\./, "") : null; @@ -2196,11 +2205,14 @@ function brokerTargetLabel(agent: AgentDefinition): string { return `@${handle && handle.length > 0 ? handle : agent.id}`; } -function brokerRouteKind(conversation: Pick): ScoutDeliverRouteKind { +function brokerRouteKind(conversation: Pick): ScoutDeliverRouteKind { if (conversation.kind === "direct") { return "dm"; } - return conversation.id === BROKER_SHARED_CHANNEL_ID ? "broadcast" : "channel"; + return conversation.id === BROKER_SHARED_CHANNEL_ID + || conversation.metadata?.channel === "shared" + ? "broadcast" + : "channel"; } function normalizeBrokerProductTarget(value: string): string { @@ -2295,6 +2307,20 @@ function directConversationIdForActors(sourceId: string, targetId: string): stri return `dm.${[sourceId, targetId].sort().join(".")}`; } +function findConversationByIdentity( + snapshot: ReturnType, + naturalKey: string, + legacyId?: string, +): ConversationDefinition | undefined { + if (legacyId && snapshot.conversations[legacyId]) { + return snapshot.conversations[legacyId]; + } + return Object.values(snapshot.conversations).find( + (conversation) => + channelNaturalKeyFromMetadata(conversation.metadata) === naturalKey, + ); +} + async function ensureBrokerActorForDelivery(actorId: string): Promise { const snapshot = runtime.snapshot(); if (snapshot.actors[actorId] || snapshot.agents[actorId]) { @@ -2320,12 +2346,16 @@ async function ensureBrokerDeliveryConversation(input: { const targetAgentId = input.targetAgentId?.trim(); if (!normalizedChannel && targetAgentId) { - const conversationId = targetAgentId === SCOUT_DISPATCHER_AGENT_ID && input.requesterId === operatorActorId + const legacyConversationId = targetAgentId === SCOUT_DISPATCHER_AGENT_ID && input.requesterId === operatorActorId ? BROKER_SHARED_CHANNEL_ID : directConversationIdForActors(input.requesterId, targetAgentId); const participantIds = [...new Set([input.requesterId, targetAgentId])].sort(); const shareMode = resolveConversationShareMode(snapshot, participantIds, "local"); - const existing = snapshot.conversations[conversationId]; + const naturalKey = directChannelNaturalKey(participantIds); + const existing = targetAgentId === SCOUT_DISPATCHER_AGENT_ID && input.requesterId === operatorActorId + ? snapshot.conversations[legacyConversationId] + : findConversationByIdentity(snapshot, naturalKey, legacyConversationId); + const conversationId = existing?.id ?? mintChannelId(randomUUID); const alreadyMatches = existing && existing.kind === "direct" && existing.visibility === "private" @@ -2349,6 +2379,8 @@ async function ensureBrokerDeliveryConversation(input: { participantIds, metadata: { surface: "broker", + naturalKey, + legacyId: legacyConversationId, ...(targetAgentId === SCOUT_DISPATCHER_AGENT_ID && input.requesterId === operatorActorId ? { role: "partner" } : {}), }, }; @@ -2366,48 +2398,77 @@ async function ensureBrokerDeliveryConversation(input: { let definition: ConversationDefinition; if (channel === "voice") { + const naturalKey = namedChannelNaturalKey("voice"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_VOICE_CHANNEL_ID); definition = { - id: BROKER_VOICE_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: "voice", visibility: "workspace", shareMode: resolveConversationShareMode(snapshot, scopedParticipants, "local"), authorityNodeId: nodeId, participantIds: scopedParticipants, - metadata: { surface: "broker", channel: "voice" }, + metadata: { + surface: "broker", + channel: "voice", + naturalKey, + legacyId: BROKER_VOICE_CHANNEL_ID, + }, }; } else if (channel === "system") { + const naturalKey = systemChannelNaturalKey("system"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_SYSTEM_CHANNEL_ID); definition = { - id: BROKER_SYSTEM_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "system", title: "system", visibility: "system", shareMode: "local", authorityNodeId: nodeId, participantIds: [operatorActorId, input.requesterId].sort(), - metadata: { surface: "broker", channel: "system" }, + metadata: { + surface: "broker", + channel: "system", + naturalKey, + legacyId: BROKER_SYSTEM_CHANNEL_ID, + }, }; } else if (channel === "shared") { + const naturalKey = namedChannelNaturalKey("shared"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_SHARED_CHANNEL_ID); definition = { - id: BROKER_SHARED_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: "shared-channel", visibility: "workspace", shareMode: "shared", authorityNodeId: nodeId, participantIds: sharedParticipants, - metadata: { surface: "broker", channel: "shared" }, + metadata: { + surface: "broker", + channel: "shared", + naturalKey, + legacyId: BROKER_SHARED_CHANNEL_ID, + }, }; } else { + const legacyChannelId = `channel.${sanitizeConversationSegment(channel)}`; + const naturalKey = namedChannelNaturalKey(channel); + const existing = findConversationByIdentity(snapshot, naturalKey, legacyChannelId); definition = { - id: `channel.${sanitizeConversationSegment(channel)}`, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: channel, visibility: "workspace", shareMode: resolveConversationShareMode(snapshot, scopedParticipants, "local"), authorityNodeId: nodeId, participantIds: scopedParticipants, - metadata: { surface: "broker", channel }, + metadata: { + surface: "broker", + channel, + naturalKey, + legacyId: legacyChannelId, + }, }; } @@ -4424,6 +4485,14 @@ async function executeLocalInvocation( ...initialFlight, state: "queued" as const, summary: `Message stored for ${agent?.displayName ?? invocation.targetAgentId}. Will deliver when online.`, + metadata: { + ...(initialFlight.metadata ?? {}), + dispatchOutcome: { + status: "queued_until_online", + reason: "no_runnable_endpoint", + checkedAt: Date.now(), + }, + }, }; await persistFlight(queuedFlight); return; diff --git a/packages/runtime/src/conversations/api.test.ts b/packages/runtime/src/conversations/api.test.ts index c58e82eb..9a1deec5 100644 --- a/packages/runtime/src/conversations/api.test.ts +++ b/packages/runtime/src/conversations/api.test.ts @@ -3,7 +3,10 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { ConversationDefinition } from "@openscout/protocol"; +import { + directChannelNaturalKey, + type ConversationDefinition, +} from "@openscout/protocol"; import { SQLiteControlPlaneStore } from "../sqlite-store.ts"; import { conversationIdForAgent } from "./legacy-ids.ts"; @@ -106,7 +109,7 @@ describe("Conversations", () => { } }); - test("findByNaturalKey returns null in SCO-031 (column lands in SCO-030)", () => { + test("findByNaturalKey returns null when no metadata natural key exists", () => { const store = createStore(); try { seedActorsAndNode(store, ["operator", "agent-1"]); @@ -226,12 +229,13 @@ describe("Conversations", () => { } }); - test("ensureByNaturalKey uses the naturalKey as the row id (SCO-031 transitional)", () => { + test("ensureByNaturalKey mints an opaque id and stores the natural key", () => { const store = createStore(); try { seedActorsAndNode(store, ["operator", "agent-1"]); + const naturalKey = directChannelNaturalKey(["operator", "agent-1"]); const created = store.conversations.ensureByNaturalKey({ - naturalKey: "dm.operator.agent-1", + naturalKey, kind: "direct", title: "Direct", visibility: "private", @@ -239,11 +243,44 @@ describe("Conversations", () => { authorityNodeId: "node-1", participantIds: ["operator", "agent-1"], }); - expect(created.id).toBe("dm.operator.agent-1"); + expect(created.id).toMatch(/^c\.[0-9a-f-]{36}$/); + expect(created.metadata?.naturalKey).toBe(naturalKey); - const reloaded = store.conversations.findById("dm.operator.agent-1"); + const reloaded = store.conversations.findById(created.id); expect(reloaded?.kind).toBe("direct"); expect(reloaded?.participantIds.sort()).toEqual(["agent-1", "operator"]); + expect(store.conversations.findByNaturalKey(naturalKey)?.id).toBe(created.id); + + const duplicate = store.conversations.ensureByNaturalKey({ + naturalKey, + kind: "direct", + title: "Direct again", + visibility: "private", + shareMode: "local", + authorityNodeId: "node-1", + participantIds: ["operator", "agent-1"], + }); + expect(duplicate.id).toBe(created.id); + } finally { + store.close(); + } + }); + + test("findByAgent resolves opaque direct conversations by natural key", () => { + const store = createStore(); + try { + seedActorsAndNode(store, ["operator", "agent-1"]); + const created = store.conversations.ensureByNaturalKey({ + naturalKey: directChannelNaturalKey(["operator", "agent-1"]), + kind: "direct", + title: "Direct", + visibility: "private", + shareMode: "local", + authorityNodeId: "node-1", + participantIds: ["operator", "agent-1"], + }); + + expect(store.conversations.findByAgent("agent-1")?.id).toBe(created.id); } finally { store.close(); } @@ -263,6 +300,27 @@ describe("Conversations", () => { } }); + test("resolveLegacyId resolves structural direct ids to opaque natural-key rows", () => { + const store = createStore(); + try { + seedActorsAndNode(store, ["operator", "agent-1"]); + const created = store.conversations.ensureByNaturalKey({ + naturalKey: directChannelNaturalKey(["operator", "agent-1"]), + kind: "direct", + title: "Direct", + visibility: "private", + shareMode: "local", + authorityNodeId: "node-1", + participantIds: ["operator", "agent-1"], + }); + + const resolved = store.conversations.resolveLegacyId(conversationIdForAgent("agent-1")); + expect(resolved?.id).toBe(created.id); + } finally { + store.close(); + } + }); + test("resolveLegacyId returns null when no candidate row exists", () => { const store = createStore(); try { diff --git a/packages/runtime/src/conversations/api.ts b/packages/runtime/src/conversations/api.ts index 2e764b1f..0408238d 100644 --- a/packages/runtime/src/conversations/api.ts +++ b/packages/runtime/src/conversations/api.ts @@ -17,6 +17,8 @@ * `./legacy-ids.ts` for the structural form definitions. */ +import { randomUUID } from "node:crypto"; + import type { Database } from "bun:sqlite"; import type { @@ -27,6 +29,11 @@ import type { ShareMode, VisibilityScope, } from "@openscout/protocol"; +import { + channelNaturalKeyFromMetadata, + directChannelNaturalKey, + mintChannelId, +} from "@openscout/protocol"; import type { SQLiteControlPlaneStore } from "../sqlite-store.js"; @@ -81,12 +88,23 @@ export class Conversations implements ConversationsApi { return this.store.getConversation(id); } - /** - * SCO-030 (Opaque Conversation IDs) introduces the `natural_key` column and - * fills this body. In SCO-031 the method exists so call sites can be - * migrated without waiting on the schema change — it always returns `null`. - */ - findByNaturalKey(_key: string): ConversationDefinition | null { + findByNaturalKey(key: string): ConversationDefinition | null { + const normalizedKey = key.trim(); + if (!normalizedKey) { + return null; + } + const rows = this.readDb.query( + "SELECT id FROM conversations ORDER BY created_at ASC", + ).all() as ConversationRow[]; + for (const row of rows) { + const conversation = this.findById(row.id); + if ( + conversation && + channelNaturalKeyFromMetadata(conversation.metadata) === normalizedKey + ) { + return conversation; + } + } return null; } @@ -96,7 +114,8 @@ export class Conversations implements ConversationsApi { * pattern that used to live in `db-queries.ts`. */ findByAgent(agentId: ScoutId): ConversationDefinition | null { - return this.findById(conversationIdForAgent(agentId)); + return this.findById(conversationIdForAgent(agentId)) + ?? this.findByNaturalKey(directChannelNaturalKey(["operator", agentId])); } findByParent(parentId: ScoutId): ConversationDefinition[] { @@ -159,12 +178,6 @@ export class Conversations implements ConversationsApi { return winner ? this.findById(winner.id) : null; } - /** - * SCO-030 transitional bridge (per SCO-031 §13 Q5): in SCO-031 we use the - * `naturalKey` as the row's literal `id`. SCO-030 swaps this for an opaque - * id + `natural_key` lookup; the method shape stays the same so callers - * don't move twice. - */ ensureByNaturalKey(input: EnsureConversationInput): ConversationDefinition { const existing = this.findByNaturalKey(input.naturalKey); if (existing) { @@ -172,7 +185,7 @@ export class Conversations implements ConversationsApi { } const conversation: ConversationDefinition = { - id: input.naturalKey, + id: mintChannelId(randomUUID), kind: input.kind, title: input.title, visibility: input.visibility, @@ -181,7 +194,10 @@ export class Conversations implements ConversationsApi { participantIds: input.participantIds, parentConversationId: input.parentConversationId, topic: input.topic, - metadata: input.metadata, + metadata: { + ...(input.metadata ?? {}), + naturalKey: input.naturalKey, + }, }; this.upsert(conversation); return conversation; @@ -214,6 +230,12 @@ export class Conversations implements ConversationsApi { const parsedDirect = parseDirectConversationId(rawId); if (parsedDirect) { + const byNaturalKey = this.findByNaturalKey( + directChannelNaturalKey([parsedDirect.operatorId, parsedDirect.agentId]), + ); + if (byNaturalKey) { + return byNaturalKey; + } for (const candidate of directConversationIdCandidates(parsedDirect.agentId)) { const hit = this.findById(candidate); if (hit) { @@ -224,6 +246,12 @@ export class Conversations implements ConversationsApi { const legacyScoutAgentId = parseLegacyScoutSessionConversationId(rawId); if (legacyScoutAgentId) { + const byNaturalKey = this.findByNaturalKey( + directChannelNaturalKey(["operator", legacyScoutAgentId]), + ); + if (byNaturalKey) { + return byNaturalKey; + } for (const candidate of directConversationIdCandidates(legacyScoutAgentId)) { const hit = this.findById(candidate); if (hit) { diff --git a/packages/runtime/src/scout-broker.ts b/packages/runtime/src/scout-broker.ts index e4bdcbbe..0b29bc39 100644 --- a/packages/runtime/src/scout-broker.ts +++ b/packages/runtime/src/scout-broker.ts @@ -1,11 +1,17 @@ +import { randomUUID } from "node:crypto"; import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { BUILT_IN_AGENT_DEFINITION_IDS, + namedChannelNaturalKey, + channelNaturalKeyFromMetadata, diagnoseAgentIdentity, + directChannelNaturalKey, extractAgentSelectors, formatMinimalAgentIdentity, + mintChannelId, + systemChannelNaturalKey, normalizeAgentSelectorSegment, withAgentReferenceAliases, type AgentHarness, @@ -535,13 +541,28 @@ export function scoutConversationIdForChannel(channel?: string): string { return `channel.${sanitizeConversationSegment(normalizedChannel)}`; } +function resolveConversationIdForChannel( + snapshot: ScoutBrokerSnapshot, + channel?: string, +): string { + const normalizedChannel = channel?.trim() || "shared"; + const legacyId = scoutConversationIdForChannel(normalizedChannel); + const naturalKey = normalizedChannel === "system" + ? systemChannelNaturalKey("system") + : namedChannelNaturalKey(normalizedChannel); + return findConversationByIdentity(snapshot, naturalKey, legacyId)?.id ?? legacyId; +} + function relayRouteKind( - conversation: { id: string; kind: string }, + conversation: { id: string; kind: string; metadata?: Record }, ): "dm" | "channel" | "broadcast" { if (conversation.kind === "direct") { return "dm"; } - return conversation.id === BROKER_SHARED_CHANNEL_ID ? "broadcast" : "channel"; + return conversation.id === BROKER_SHARED_CHANNEL_ID + || conversation.metadata?.channel === "shared" + ? "broadcast" + : "channel"; } function buildMentionCandidate( @@ -1138,53 +1159,82 @@ function conversationDefinition( ]).sort(); if (normalizedChannel === "voice") { + const naturalKey = namedChannelNaturalKey("voice"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_VOICE_CHANNEL_ID); return { - id: BROKER_VOICE_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: "voice", visibility: "workspace", shareMode: resolveConversationShareMode(snapshot, nodeId, scopedParticipants, "local"), authorityNodeId: nodeId, participantIds: scopedParticipants, - metadata: { surface: "scout-cli", channel: "voice" }, + metadata: { + surface: "scout-cli", + channel: "voice", + naturalKey, + legacyId: BROKER_VOICE_CHANNEL_ID, + }, }; } if (normalizedChannel === "system") { + const naturalKey = systemChannelNaturalKey("system"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_SYSTEM_CHANNEL_ID); return { - id: BROKER_SYSTEM_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "system", title: "system", visibility: "system", shareMode: "local", authorityNodeId: nodeId, participantIds: unique([OPERATOR_ID, senderId]).sort(), - metadata: { surface: "scout-cli", channel: "system" }, + metadata: { + surface: "scout-cli", + channel: "system", + naturalKey, + legacyId: BROKER_SYSTEM_CHANNEL_ID, + }, }; } if (normalizedChannel === "shared") { + const naturalKey = namedChannelNaturalKey("shared"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_SHARED_CHANNEL_ID); return { - id: BROKER_SHARED_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: "shared-channel", visibility: "workspace", shareMode: "shared", authorityNodeId: nodeId, participantIds: sharedParticipants, - metadata: { surface: "scout-cli", channel: "shared" }, + metadata: { + surface: "scout-cli", + channel: "shared", + naturalKey, + legacyId: BROKER_SHARED_CHANNEL_ID, + }, }; } + const legacyChannelId = `channel.${sanitizeConversationSegment(normalizedChannel)}`; + const naturalKey = namedChannelNaturalKey(normalizedChannel); + const existing = findConversationByIdentity(snapshot, naturalKey, legacyChannelId); return { - id: `channel.${sanitizeConversationSegment(normalizedChannel)}`, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: normalizedChannel, visibility: "workspace", shareMode: resolveConversationShareMode(snapshot, nodeId, scopedParticipants, "local"), authorityNodeId: nodeId, participantIds: scopedParticipants, - metadata: { surface: "scout-cli", channel: normalizedChannel }, + metadata: { + surface: "scout-cli", + channel: normalizedChannel, + naturalKey, + legacyId: legacyChannelId, + }, }; } @@ -1237,6 +1287,20 @@ function directConversationIdForActors(sourceId: string, targetId: string): stri return `dm.${[sourceId, targetId].sort().join(".")}`; } +function findConversationByIdentity( + snapshot: ScoutBrokerSnapshot, + naturalKey: string, + legacyId?: string, +): ScoutBrokerConversationRecord | undefined { + if (legacyId && snapshot.conversations[legacyId]) { + return snapshot.conversations[legacyId]; + } + return Object.values(snapshot.conversations).find( + (conversation) => + channelNaturalKeyFromMetadata(conversation.metadata) === naturalKey, + ); +} + async function ensureBrokerDirectConversationBetween( baseUrl: string, snapshot: ScoutBrokerSnapshot, @@ -1244,12 +1308,16 @@ async function ensureBrokerDirectConversationBetween( sourceId: string, targetId: string, ): Promise<{ agent: ScoutBrokerAgentRecord | undefined; conversation: ScoutBrokerConversationRecord; existed: boolean }> { - const conversationId = targetId === SCOUT_AGENT_ID && sourceId === OPERATOR_ID + const legacyConversationId = targetId === SCOUT_AGENT_ID && sourceId === OPERATOR_ID ? BROKER_SHARED_CHANNEL_ID : directConversationIdForActors(sourceId, targetId); const participantIds = [...new Set([sourceId, targetId])].sort(); + const naturalKey = directChannelNaturalKey(participantIds); + const existing = targetId === SCOUT_AGENT_ID && sourceId === OPERATOR_ID + ? snapshot.conversations[legacyConversationId] + : findConversationByIdentity(snapshot, naturalKey, legacyConversationId); + const conversationId = existing?.id ?? mintChannelId(randomUUID); const nextShareMode = resolveConversationShareMode(snapshot, nodeId, participantIds, "local"); - const existing = snapshot.conversations[conversationId]; const alreadyMatches = existing && existing.kind === "direct" && existing.shareMode === nextShareMode @@ -1280,6 +1348,8 @@ async function ensureBrokerDirectConversationBetween( participantIds, metadata: { surface: "scout", + naturalKey, + legacyId: legacyConversationId, ...(targetId === SCOUT_AGENT_ID && sourceId === OPERATOR_ID ? { role: "partner" } : {}), }, }; @@ -1611,8 +1681,12 @@ export async function loadScoutMessages(options: { limit?: number; baseUrl?: string; } = {}): Promise { + const baseUrl = options.baseUrl ?? resolveScoutBrokerUrl(); + const context = await loadScoutBrokerContext(baseUrl); const search = new URLSearchParams(); - const conversationId = scoutConversationIdForChannel(options.channel); + const conversationId = context + ? resolveConversationIdForChannel(context.snapshot, options.channel) + : scoutConversationIdForChannel(options.channel); if (conversationId) { search.set("conversationId", conversationId); } @@ -1623,12 +1697,12 @@ export async function loadScoutMessages(options: { search.set("limit", String(options.limit)); } const suffix = search.size > 0 ? `?${search.toString()}` : ""; - return brokerReadJson(options.baseUrl ?? resolveScoutBrokerUrl(), `/v1/messages${suffix}`); + return brokerReadJson(baseUrl, `/v1/messages${suffix}`); } export async function watchScoutMessages(options: ScoutWatchOptions): Promise { const broker = await requireScoutBrokerContext(); - const conversationId = scoutConversationIdForChannel(options.channel); + const conversationId = resolveConversationIdForChannel(broker.snapshot, options.channel); const controller = new AbortController(); const abort = () => controller.abort(); diff --git a/packages/runtime/src/sqlite-store.ts b/packages/runtime/src/sqlite-store.ts index a89aaf6e..d82092dc 100644 --- a/packages/runtime/src/sqlite-store.ts +++ b/packages/runtime/src/sqlite-store.ts @@ -2710,7 +2710,7 @@ export class SQLiteControlPlaneStore { return "ask_replied"; } - if (agentId && message.actorId !== agentId) { + if (agentId && message.actorId !== agentId && this.isDirectConversation(message.conversationId)) { return "ask_opened"; } @@ -2721,6 +2721,18 @@ export class SQLiteControlPlaneStore { return "message_posted"; } + private isDirectConversation(conversationId: string | undefined, db: Database = this.readDb): boolean { + if (!conversationId) { + return false; + } + const row = queryGet<{ kind: string }, [string]>( + db, + "SELECT kind FROM conversations WHERE id = ?1 LIMIT 1", + conversationId, + ); + return row?.kind === "direct"; + } + private resolveActivityAgentIdForMessage(message: MessageRecord): string | null { if (message.class === "status" && typeof message.metadata?.targetAgentId === "string" && this.isKnownAgentId(message.metadata.targetAgentId)) { return message.metadata.targetAgentId; diff --git a/packages/web/client/lib/types.ts b/packages/web/client/lib/types.ts index bd524460..72404024 100644 --- a/packages/web/client/lib/types.ts +++ b/packages/web/client/lib/types.ts @@ -592,6 +592,9 @@ export type SessionEntry = { id: string; kind: string; title: string; + alias?: string | null; + naturalKey?: string | null; + legacyId?: string | null; participantIds: string[]; authorityNodeId?: string | null; authorityNodeName?: string | null; diff --git a/packages/web/client/scout/hooks.ts b/packages/web/client/scout/hooks.ts index df31e2ac..c4437813 100644 --- a/packages/web/client/scout/hooks.ts +++ b/packages/web/client/scout/hooks.ts @@ -66,18 +66,18 @@ export function useScoutCommands(): CommandOption[] { }, { id: "nav:messages", - label: "Go to Messages", + label: "Go to Conversations", action: () => navigate({ view: "messages" }), shortcut: "Cmd+4", }, { id: "nav:messages-dms", - label: "Go to Messages — DMs", + label: "Go to Conversations — Private", action: () => navigate({ view: "messages", filter: "dm" }), }, { id: "nav:messages-channels", - label: "Go to Messages — Channels", + label: "Go to Conversations — Shared", action: () => navigate({ view: "messages", filter: "channel" }), shortcut: "Cmd+5", }, @@ -274,8 +274,9 @@ const VIEW_LABELS: Record = { agents: "Agents", fleet: "Fleet", conversations: "Conversations", + messages: "Conversations", sessions: "Sessions", - channels: "Channels", + channels: "Conversations", activity: "Activity", mesh: "Mesh", broker: "Broker", @@ -290,7 +291,7 @@ export function useScoutNavCenter(): ReactNode | null { const tabItems: { label: string; view: Route["view"] }[] = [ { label: "Fleet", view: "inbox" }, { label: "Agents", view: "agents" }, - { label: "Messages", view: "messages" }, + { label: "Conversations", view: "messages" }, { label: "Sessions", view: "sessions" }, { label: "Mesh", view: "mesh" }, { label: "Broker", view: "broker" }, diff --git a/packages/web/client/scout/inspector/ConversationInspector.tsx b/packages/web/client/scout/inspector/ConversationInspector.tsx index 028929c7..0f3b6b7f 100644 --- a/packages/web/client/scout/inspector/ConversationInspector.tsx +++ b/packages/web/client/scout/inspector/ConversationInspector.tsx @@ -16,9 +16,9 @@ import { isActiveConversationFlight } from "../../lib/conversations.ts"; import type { Flight, Message, SessionEntry } from "../../lib/types.ts"; const KIND_LABELS: Record = { - direct: "Direct message", - channel: "Channel", - group_direct: "Group", + direct: "Conversation", + channel: "Conversation", + group_direct: "Conversation", thread: "Thread", }; @@ -230,7 +230,7 @@ export function ConversationInspector() { )} {typeof participantCount === "number" && ( - + )} {lastAt && ( = { all: "All", - dm: "DMs", - channel: "Channels", + dm: "Private", + channel: "Shared", }; const SORTS: MessagesSort[] = ["recent", "name", "unread"]; @@ -271,14 +272,18 @@ export function ScoutMessagesLeftPanel() { const title = conversationDisplayTitle(s); const channel = isGroupConversation(s); const agent = s.agentId ? agentById.get(s.agentId) : undefined; + const identifier = threadIdentifier(s, agent); + const sub = identifier.toLowerCase() === title.toLowerCase() + ? undefined + : identifier; return ( 0 ? parts.join("\n") : undefined; } +function threadIdentifier(s: SessionEntry, agent: Agent | undefined): string { + if (isGroupConversation(s)) { + return conversationShortLabel(s); + } + const handle = agent?.handle?.trim().replace(/^@+/, ""); + if (handle) return handle; + if (s.agentId) return s.agentId.split(".")[0] ?? s.agentId; + return conversationDisplayTitle(s); +} + function trimPreview(preview: string | null): string | null { if (!preview) return null; const collapsed = preview.replace(/\s+/g, " ").trim(); diff --git a/packages/web/client/screens/AgentInfoScreen.tsx b/packages/web/client/screens/AgentInfoScreen.tsx index a239dc0f..e4e5c81e 100644 --- a/packages/web/client/screens/AgentInfoScreen.tsx +++ b/packages/web/client/screens/AgentInfoScreen.tsx @@ -192,7 +192,7 @@ export function AgentInfoScreen({ ...(agent.capabilities.length > 0 ? [{ label: "Capabilities", value: }] : []), ]; const conversationItems: ProfileField[] = [ - { label: "Thread ID", value: conversationId }, + { label: "Conversation UID", value: conversationId }, ...(session?.workspaceRoot ? [{ label: "Workspace", value: session.workspaceRoot }] : []), ...(session?.currentBranch ? [{ label: "Session branch", value: session.currentBranch }] : []), ...(session?.messageCount != null ? [{ label: "Messages", value: String(session.messageCount) }] : []), diff --git a/packages/web/client/screens/ChannelsScreen.tsx b/packages/web/client/screens/ChannelsScreen.tsx index 5b0af05b..f7be3498 100644 --- a/packages/web/client/screens/ChannelsScreen.tsx +++ b/packages/web/client/screens/ChannelsScreen.tsx @@ -24,6 +24,7 @@ import { DictationMic } from "../components/DictationMic.tsx"; import { MessageEmbeds } from "../components/MessageEmbeds.tsx"; import { useAgentHovercard } from "../components/AgentHoverCard.tsx"; import type { Agent, Message, Route, SessionEntry } from "../lib/types.ts"; +import { ConversationScreen } from "./ConversationScreen.tsx"; import "./conversation-screen.css"; import "./channel-screen.css"; @@ -668,7 +669,7 @@ function NoChannelSelected({ count }: { count: number }) { export function ChannelsScreen({ channelId, - navigate: _navigate, + navigate, }: { channelId?: string; navigate: (r: Route) => void; @@ -719,6 +720,16 @@ export function ChannelsScreen({ } }); + if (channelId) { + return ( + + ); + } + return (
{selectedChannel ? ( diff --git a/packages/web/client/screens/ConversationScreen.tsx b/packages/web/client/screens/ConversationScreen.tsx index 282420c7..5ada35d3 100644 --- a/packages/web/client/screens/ConversationScreen.tsx +++ b/packages/web/client/screens/ConversationScreen.tsx @@ -6,8 +6,6 @@ import type { import { api } from "../lib/api.ts"; import { filterAgentsByMachineScope, - filterSessionsByMachineScope, - machineScopedAgentIds, } from "../lib/machine-scope.ts"; import { compactAgentId, @@ -29,7 +27,6 @@ import { TERMINAL_CONVERSATION_FLIGHT_STATES, conversationShortLabel, isActiveConversationFlight, - isGroupConversation, isRequesterWaitTimeoutConversationFlight, isStaleConversationWorkingTurn, isStaleConversationWorkingTurnAnswered, @@ -72,9 +69,9 @@ import "./conversation-screen.css"; import "./ops-screen.css"; const KIND_LABELS: Record = { - direct: "Direct message", - channel: "Channel", - group_direct: "Group", + direct: "Conversation", + channel: "Conversation", + group_direct: "Conversation", thread: "Thread", }; @@ -191,13 +188,6 @@ type SendResult = { flight?: EventFlightRecord | null; }; -type ChannelSendResult = { - unresolvedTargets?: string[]; - targetDiagnostic?: { - detail?: string; - }; -}; - type ComposeMode = "tell" | "ask"; type ComposeAction = "tell" | "ask" | "steer"; @@ -224,10 +214,6 @@ function pathLeaf(path: string | null | undefined): string | null { return parts.at(-1) ?? normalized; } -function sanitizeChannelSlug(value: string): string { - return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-") || "shared"; -} - function deriveDisplayTitle(session: SessionEntry): string { if (session.kind === "direct" && session.agentName) return session.agentName; if (session.kind === "direct" && session.agentId) { @@ -482,6 +468,16 @@ function defaultFlightDetail(agentName: string, state: string): string { } } +function displayNameForActor( + actorId: string | null | undefined, + agents: Agent[], + operatorName: string, +): string { + if (!actorId || actorId === "operator") return operatorName; + const agent = agents.find((candidate) => candidate.id === actorId); + return agent?.name ?? compactAgentId(actorId) ?? actorId; +} + function describePresence(input: { agentName: string; agentState: string | null; @@ -671,6 +667,23 @@ function participantListLabel(session: SessionEntry | null): string | null { .join(", "); } +function shortConversationIdentity(id: string): string { + if (id.startsWith("conv.")) { + return `conv.${id.slice("conv.".length, "conv.".length + 8)}`; + } + if (id.startsWith("channel.")) { + return `#${id.slice("channel.".length)}`; + } + if (id.startsWith("dm.")) { + return "legacy DM"; + } + return id.length > 22 ? `${id.slice(0, 10)}...${id.slice(-7)}` : id; +} + +function conversationIdentityLabel(id: string): string { + return id.startsWith("conv.") ? "UID" : "ID"; +} + type RailWorkspaceGroup = { workspace: string; sessions: SessionEntry[]; @@ -888,7 +901,7 @@ function PresenceSidebar({ return (